@@ -37,30 +37,122 @@ struct SBTable {
3737 SBRow rows[ROWS ];
3838};
3939
40+ // ─── StringArena ──────────────────────────────────────────────────────────
41+ //
42+ // Auto-growing bump allocator for key strings inside StringDictionaryBuffer.
43+ //
44+ // Memory is organised as a linked list of 512 KB chunks. alloc() is a single
45+ // atomic fetch_add on the current chunk — fully contention-free as long as
46+ // the chunk is not full. When a chunk fills up, grow() serialises creation of
47+ // the next chunk via a CAS spinlock; contention here is extremely rare.
48+ //
49+ // Threads that lose a CAS race in insert_with_id leave their arena allocation
50+ // as waste; space is recovered on the next reset().
51+ //
52+ // reset() frees all chunks after the first and resets the first chunk's
53+ // position counter. The first chunk is kept to avoid a malloc on the next
54+ // use. reset() must only be called when no concurrent alloc() calls are in
55+ // flight.
56+ class StringArena {
57+ static constexpr size_t CHUNK_SIZE = 512 * 1024 ;
58+
59+ // Plain struct allocated via calloc (zero-initialised); pos accessed via
60+ // __atomic builtins, consistent with the rest of the file.
61+ struct Chunk {
62+ Chunk* next; // singly-linked for traversal in reset() / ~StringArena()
63+ size_t pos; // bump pointer within data[]
64+ char data[CHUNK_SIZE ];
65+ };
66+
67+ Chunk* _first; // head of chain; kept across resets
68+ std::atomic<Chunk*> _active; // current allocation target
69+ std::atomic<bool > _growing{false }; // serialises new-chunk creation
70+
71+ static Chunk* make_chunk () {
72+ return static_cast <Chunk*>(calloc (1 , sizeof (Chunk)));
73+ }
74+
75+ void grow (Chunk* full) {
76+ // One thread at a time creates the next chunk. Others spin briefly
77+ // then re-check _active; if it has already advanced they return.
78+ bool expected = false ;
79+ while (!_growing.compare_exchange_weak (expected, true ,
80+ std::memory_order_acquire, std::memory_order_relaxed)) {
81+ if (_active.load (std::memory_order_relaxed) != full) return ;
82+ expected = false ;
83+ spinPause ();
84+ }
85+ if (_active.load (std::memory_order_relaxed) != full) {
86+ _growing.store (false , std::memory_order_release);
87+ return ;
88+ }
89+ Chunk* fresh = make_chunk ();
90+ // On OOM store nullptr so alloc() returns nullptr instead of spinning.
91+ _active.store (fresh, std::memory_order_release);
92+ if (fresh) full->next = fresh; // link into chain for reset() traversal
93+ _growing.store (false , std::memory_order_release);
94+ }
95+
96+ public:
97+ StringArena () : _first(make_chunk()), _active(_first) {}
98+
99+ ~StringArena () {
100+ Chunk* c = _first;
101+ while (c) { Chunk* n = c->next ; free (c); c = n; }
102+ }
103+
104+ StringArena (const StringArena&) = delete ;
105+ StringArena& operator =(const StringArena&) = delete ;
106+
107+ char * alloc (size_t n) {
108+ n = (n + alignof (void *) - 1 ) & ~(alignof (void *) - 1 );
109+ for (;;) {
110+ Chunk* c = _active.load (std::memory_order_acquire);
111+ if (!c) return nullptr ; // OOM
112+ size_t off = __atomic_fetch_add (&c->pos , n, __ATOMIC_RELAXED);
113+ if (off + n <= CHUNK_SIZE ) return c->data + off;
114+ grow (c);
115+ }
116+ }
117+
118+ // Free all chunks after the first; reset the first.
119+ // O(extra_chunks). Also clears the OOM state: if alloc() had returned
120+ // nullptr after a failed make_chunk(), the next alloc() after reset()
121+ // will succeed again (up to one chunk's worth).
122+ void reset () {
123+ Chunk* c = _first ? _first->next : nullptr ;
124+ while (c) { Chunk* n = c->next ; free (c); c = n; }
125+ if (_first) {
126+ _first->next = nullptr ;
127+ __atomic_store_n (&_first->pos , (size_t )0 , __ATOMIC_RELAXED);
128+ }
129+ _active.store (_first, std::memory_order_release);
130+ }
131+ };
132+
40133// ─── StringDictionaryBuffer ────────────────────────────────────────────────
41134//
42135// Open-addressing concurrent hash table mapping string keys to u32 IDs.
43136//
137+ // Key strings are owned by the per-buffer StringArena. Overflow SBTable
138+ // nodes are heap-allocated (calloc) and freed by freeOverflowNodes() on
139+ // clear() and destruction. This makes clear() O(number-of-overflow-nodes)
140+ // rather than O(number-of-entries), and eliminates per-key malloc/free.
141+ //
44142// Concurrency model:
45- // - Inserts (insert_with_id, copyFrom): use __sync_bool_compare_and_swap on
46- // keys[c] (full fence on GCC) to claim a slot. id and keylen are stored
47- // AFTER the CAS wins (release); a reader that observes keys[c] != null via
48- // an acquire load must also check ids[c] != 0 before using it. On x86 the
49- // CAS already implies a full memory barrier; on ARM the acquire/release
50- // pairing provides the required ordering. id remains 0 until published.
51- // - Reads (lookup): acquire-load keys[c]; if non-null and key matches, return
52- // the id — only if ids[c] != 0 (not yet published = miss). Null means
53- // "slot unclaimed" — treated as miss. There is a narrow window where a
54- // inserter has started but not yet committed the CAS; this is safe (returns
55- // miss = 0), identical to the existing Dictionary behaviour.
143+ // - Inserts (insert_with_id, copyFrom): CAS on keys[c] to claim a slot.
144+ // id stored AFTER winning CAS (release); readers check ids[c] != 0.
145+ // Losers of the CAS leave their arena allocation as recoverable waste.
146+ // - Reads (lookup): acquire-load keys[c]; miss on null or unpublished id.
56147// - clear(): called only when no concurrent readers/writers are active.
57148//
58- // Not signal-safe for insert_with_id / copyFrom (call malloc ).
149+ // Not signal-safe for insert_with_id / copyFrom (arena alloc + calloc ).
59150// Signal-safe for lookup (read-only, no allocation).
60151class StringDictionaryBuffer {
61152private:
62153 SBTable* _table;
63154 std::atomic<int > _size{0 };
155+ StringArena _arena;
64156
65157 static unsigned int hash (const char * key, size_t length) {
66158 unsigned int h = 2166136261U ;
@@ -72,21 +164,9 @@ class StringDictionaryBuffer {
72164 return strncmp (candidate, key, length) == 0 && candidate[length] == ' \0 ' ;
73165 }
74166
75- // Iterative DFS walk of the overflow tree. Each frame tracks one table and
76- // the next row to visit. Because we descend into row->next immediately and
77- // only pop the frame after every row has been processed, the stack holds at
78- // most one frame per overflow-chain level. The hash rotation has period
79- // 32 / gcd(32, ROW_BITS) = 32: after 32 levels the row index cycles back to
80- // the original. In practice, reaching level N requires N*CELLS keys with
81- // identical 32-bit FNV hashes (gcd(7,32)=1 means any two keys that share
82- // the same row index across all 32 rotation steps must hash identically).
83- // A chain longer than 33 levels therefore requires 99+ exact FNV collisions
84- // — statistically negligible for real-world string inputs. stk[34] covers
85- // this practical bound; the top < 34 guard is safety-overflow protection.
86- // If it ever fires, entries beyond level 33 are silently dropped rather than
87- // crashing — acceptable given how the bound is reached only by adversarial
88- // or degenerate inputs.
89- static void freeTable (SBTable* table) {
167+ // Free only overflow SBTable chain nodes (not key strings — arena-owned).
168+ // Same DFS traversal as the old freeTable but without the per-key free.
169+ static void freeOverflowNodes (SBTable* table) {
90170 struct Frame { SBTable* t; int row; };
91171 Frame stk[34 ];
92172 int top = 0 ;
@@ -99,9 +179,6 @@ class StringDictionaryBuffer {
99179 continue ;
100180 }
101181 SBRow* row = &f.t ->rows [f.row ++];
102- for (int j = 0 ; j < CELLS ; j++) {
103- if (row->keys [j]) free (row->keys [j]);
104- }
105182 if (row->next && top < 34 ) stk[top++] = {row->next , 0 };
106183 }
107184 }
@@ -130,15 +207,22 @@ class StringDictionaryBuffer {
130207
131208public:
132209 StringDictionaryBuffer () {
133- _table = ( SBTable*) calloc (1 , sizeof (SBTable));
210+ _table = static_cast < SBTable*>( calloc (1 , sizeof (SBTable) ));
134211 }
135212
136213 ~StringDictionaryBuffer () {
137- if (_table != nullptr ) { freeTable (_table); }
138- free (_table);
139- _table = nullptr ;
214+ if (_table != nullptr ) {
215+ freeOverflowNodes (_table);
216+ free (_table);
217+ _table = nullptr ;
218+ }
140219 }
141220
221+ StringDictionaryBuffer (const StringDictionaryBuffer&) = delete ;
222+ StringDictionaryBuffer& operator =(const StringDictionaryBuffer&) = delete ;
223+ StringDictionaryBuffer (StringDictionaryBuffer&&) = delete ;
224+ StringDictionaryBuffer& operator =(StringDictionaryBuffer&&) = delete ;
225+
142226 // Signal-safe read-only probe. Returns 0 on miss.
143227 u32 lookup (const char * key, size_t len) const {
144228 const SBTable* table = _table;
@@ -159,9 +243,8 @@ class StringDictionaryBuffer {
159243 return 0 ;
160244 }
161245
162- // Insert with the given id. Returns the id stored for this key
163- // (either the given id on a new slot, or the existing id on a duplicate).
164- // NOT signal-safe (calls malloc).
246+ // Insert with the given id. Returns the id stored for this key.
247+ // NOT signal-safe (arena alloc; calloc for overflow nodes).
165248 u32 insert_with_id (const char * key, size_t len, u32 id) {
166249 SBTable* table = _table;
167250 unsigned int h = hash (key, len);
@@ -170,7 +253,7 @@ class StringDictionaryBuffer {
170253 for (int c = 0 ; c < CELLS ; c++) {
171254 char * existing = __atomic_load_n (&row->keys [c], __ATOMIC_ACQUIRE);
172255 if (!existing) {
173- char * new_key = ( char *) malloc (len + 1 );
256+ char * new_key = _arena. alloc (len + 1 );
174257 if (!new_key) return 0 ;
175258 memcpy (new_key, key, len);
176259 new_key[len] = ' \0 ' ;
@@ -179,7 +262,7 @@ class StringDictionaryBuffer {
179262 _size.fetch_add (1 , std::memory_order_relaxed);
180263 return id;
181264 }
182- free ( new_key);
265+ // CAS lost — new_key is arena waste, recovered on clear().
183266 existing = __atomic_load_n (&row->keys [c], __ATOMIC_ACQUIRE);
184267 }
185268 if (existing && keyEquals (existing, key, len)) {
@@ -189,7 +272,7 @@ class StringDictionaryBuffer {
189272 }
190273 }
191274 if (!row->next ) {
192- SBTable* nt = ( SBTable*) calloc (1 , sizeof (SBTable));
275+ SBTable* nt = static_cast < SBTable*>( calloc (1 , sizeof (SBTable) ));
193276 if (nt == nullptr ) return 0 ;
194277 if (!__sync_bool_compare_and_swap (&row->next , nullptr , nt)) free (nt);
195278 }
@@ -213,11 +296,13 @@ class StringDictionaryBuffer {
213296 collectTable (_table, out);
214297 }
215298
216- // Free all keys and reset to empty. Call only with no concurrent accessors.
299+ // Free overflow nodes, zero the root table, reset the arena.
300+ // Call only with no concurrent accessors.
217301 void clear () {
218302 if (_table == nullptr ) { _size.store (0 , std::memory_order_relaxed); return ; }
219- freeTable (_table);
303+ freeOverflowNodes (_table);
220304 memset (_table, 0 , sizeof (SBTable));
305+ _arena.reset ();
221306 _size.store (0 , std::memory_order_relaxed);
222307 }
223308
@@ -238,14 +323,20 @@ class StringDictionaryBuffer {
238323// concurrent inserts in the rotation window:
239324// phase 1: copy active → clearTarget (before rotate)
240325// phase 2: copy old_active → new_active (after drain, catch late inserts)
241- // lookupDuringDump(key): probes dump then active; inserts into both if new,
242- // using the existing global ID (or a new fetch-add if truly new everywhere).
326+ // lookupDuringDump(key): probes dump then active; inserts into both if new.
243327//
244- // Signal safety :
328+ // Concurrency :
245329// bounded_lookup acquires RefCountGuard on active before reading.
246330// lookup also acquires RefCountGuard before inserting (not signal-safe due to
247- // malloc , but the guard protects the buffer pointer lifetime).
331+ // arena alloc , but the guard protects the buffer pointer lifetime).
248332// lookupDuringDump is NOT signal-safe; call from dump thread only.
333+ //
334+ // _accepting gates new RefCountGuard creation during clearAll(). The
335+ // key-string arena makes the TOCTOU window between the _accepting acquire-
336+ // load and the RefCountGuard count++ benign: even if clearAll()'s drain
337+ // misses a racing caller, that caller reads from the zeroed root table and
338+ // returns 0. The seq_cst recheck previously needed to close this window
339+ // is therefore unnecessary and has been removed from the hot path.
249340class StringDictionary {
250341 std::atomic<u32 > _next_id{1 }; // starts at 1; id=0 reserved as "no entry"
251342 std::atomic<bool > _accepting{true }; // false while clearAll() is resetting buffers
@@ -268,8 +359,6 @@ class StringDictionary {
268359
269360 // Insert into active buffer; returns globally stable id. NOT signal-safe.
270361 u32 lookup (const char * key, size_t len) {
271- // Bail immediately if clearAll() is resetting the buffers; creating a
272- // RefCountGuard here could race with freeTable() inside clear().
273362 if (!_accepting.load (std::memory_order_acquire)) return 0 ;
274363 while (true ) {
275364 StringDictionaryBuffer* active = _rot.active ();
@@ -278,8 +367,6 @@ class StringDictionary {
278367 if (_rot.active () != active) continue ;
279368 u32 id = active->lookup (key, len);
280369 if (id != 0 ) return id;
281- // nextId() may be consumed without assignment if a concurrent insert wins
282- // the CAS for the same key; IDs are unique but not guaranteed to be dense.
283370 u32 new_id = nextId ();
284371 u32 result = active->insert_with_id (key, len, new_id);
285372 if (result == new_id) countInsert (len);
@@ -290,8 +377,6 @@ class StringDictionary {
290377 // Insert into active buffer if size < size_limit; returns 0 when at cap.
291378 // NOT signal-safe.
292379 u32 bounded_lookup (const char * key, size_t len, int size_limit) {
293- // Bail immediately if clearAll() is resetting the buffers; creating a
294- // RefCountGuard here could race with freeTable() inside clear().
295380 if (!_accepting.load (std::memory_order_acquire)) return 0 ;
296381 while (true ) {
297382 StringDictionaryBuffer* active = _rot.active ();
@@ -326,32 +411,31 @@ class StringDictionary {
326411 }
327412
328413 // Two-phase ID-preserving rotate.
329- // Invariant: all insert paths are blocked for the duration of rotate():
330- // - Signal-path lookup() callers are blocked by SignalBlocker at the
331- // rotateDictsAndRun call site, which masks SIGPROF and SIGVTALRM
332- // (the profiling signals) on the calling thread before rotate() is
333- // invoked.
334- // - JNI recording methods (e.g. recordTraceRoot) call
335- // _locks[lock_index].tryLock() at the Profiler level before reaching
336- // bounded_lookup(); lockAll() holds all _locks[], so those tryLock()
337- // calls fail and the recording method returns without ever calling
338- // bounded_lookup().
339- // - lookupDuringDump() is only called on the dump thread, which is the
340- // same thread executing rotateDictsAndRun() — no concurrency possible.
341- // clearTarget() returns the scratch buffer that becomes the new active
342- // after rotate(). It is always empty on entry because clearStandby()
343- // is called after each cycle in rotateDictsAndRun() and JFR operations
344- // are serialized by _state_lock, so cycle N+1 always starts after
345- // clearStandby() of cycle N completes.
414+ // StringDictionary makes no assumption about which callers are blocked.
415+ // In the Profiler context, three caller-side invariants reduce the
416+ // concurrency that phase 2 must handle:
417+ // - Signal paths: the caller (rotateDictsAndRun) holds a SignalBlocker
418+ // that masks SIGPROF/SIGVTALRM on the calling thread during rotate(),
419+ // so no profiler signal fires on this thread between Phase 1 and 2.
420+ // - JNI callers (e.g. recordTrace0): they bypass lockAll() and CAN
421+ // still insert into old_active after Phase 1. Phase 2's
422+ // waitForRefCountToClear(old_active) drains those in-flight inserts
423+ // before copying — that is the reason phase 2 exists.
424+ // - lookupDuringDump(): same thread as the rotate() caller — no
425+ // concurrency.
426+ // clearTarget() is the buffer that becomes the new active after rotate().
427+ // The caller is responsible for ensuring it is empty on entry (Profiler
428+ // achieves this by calling clearStandby() after every cycle and
429+ // serialising JFR operations with _state_lock).
346430 void rotate () {
347431 StringDictionaryBuffer* old_active = _rot.active ();
348432 // Phase 1: pre-populate clearTarget from active (before rotate).
349433 _rot.clearTarget ()->copyFrom (*old_active);
350434 _rot.rotate ();
351435 // Drain all in-flight accessors on old_active (now the dump buffer).
352436 RefCountGuard::waitForRefCountToClear (old_active);
353- // Phase 2: all insert paths are blocked, so old_active cannot have
354- // gained new entries between phase 1 and phase 2 .
437+ // Phase 2: catch any entries inserted into old_active between Phase 1
438+ // and the drain completing .
355439 _rot.active ()->copyFrom (*old_active);
356440 }
357441
@@ -395,16 +479,12 @@ class StringDictionary {
395479 }
396480
397481 // Reset all three buffers and restart the ID counter.
398- // Sets _accepting=false so that bounded_lookup callers that bypass lockAll()
399- // (JNI paths) do not create new RefCountGuards after the caller's initial
400- // waitForAllRefCountsToClear() drains. Drains again internally to catch any
401- // guard that started between the caller's drain and this flag flip, then
402- // clears, then re-enables lookups.
482+ // _accepting=false gates new RefCountGuard creation; the subsequent drain
483+ // ensures no concurrent accessor is mid-read when clear() zeroes the root
484+ // table. clear() is O(overflow_nodes + extra_arena_chunks); both are
485+ // typically zero for small-to-medium dictionaries.
403486 void clearAll () {
404487 _accepting.store (false , std::memory_order_seq_cst);
405- // Drain guards that were already past the _accepting check when the flag
406- // was set. After this returns, no new guards will be created (they all
407- // see _accepting=false) so the clear below is safe.
408488 RefCountGuard::waitForAllRefCountsToClear ();
409489 _a.clear (); _b.clear (); _c.clear ();
410490 _rot.reset ();
0 commit comments