Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f932ad8
Unify Hashtable static API with ConcurrentHashtable; deprecate Support
dougqh Jul 29, 2026
5367290
Migrate HashtableTest to blessed Hashtable static API
dougqh Jul 29, 2026
f0a72ab
Hashtable: annotate nullability (@Nonnull/@Nullable)
dougqh Jul 29, 2026
5a7d245
Rename Hashtable.insertHeadEntry overloads to insertHeadEntryAt/For
dougqh Aug 20, 2026
54a0760
Add a strict entry-count cap to Hashtable.D1/D2
dougqh Aug 26, 2026
c4c230d
Handle Hashtable.D1's new strict cap in CardinalityLimitReporter
dougqh Aug 26, 2026
0d2491d
Add Hashtable.SizeTracker, EvictionCursor, and Table building blocks
dougqh Aug 26, 2026
c04c3de
Back Hashtable.D1/D2's entry-count cap with SizeTracker
dougqh Aug 26, 2026
96dd249
Port drain from ConcurrentHashtable to Hashtable
dougqh Aug 26, 2026
e64b384
Expose isFull on D1/D2
dougqh Aug 26, 2026
5543d30
Mark Hashtable D1/D2 getOrCreate as @Nullable
dougqh Aug 26, 2026
b3e59f3
Unify the Hashtable factory API on a capped/uncapped vocabulary
dougqh Aug 26, 2026
c2b9acf
Avoid a capturing predicate in Hashtable D1/D2 remove
dougqh Aug 26, 2026
3f4c479
Lead the size-tracked Hashtable statics with the SizeTracker
dougqh Aug 26, 2026
c630240
Drop references to the deprecated Support facade from Hashtable javadoc
dougqh Aug 26, 2026
69ae56f
Lead getOrCreate's javadoc with the fact that it can refuse
dougqh Aug 26, 2026
8829af1
Rename getOrCreate to tryGetOrCreate on Hashtable and FlatHashtable
dougqh Aug 26, 2026
5a9c328
Replace Hashtable insertOrReplace with a refusing tryInsertOrReplace
dougqh Aug 26, 2026
09c356f
Clean up Hashtable comments: drop outward references, order by use
dougqh Aug 26, 2026
5851b46
Fold SizeTracker and EvictionCursor into one SizeManager
dougqh Aug 26, 2026
1c370d9
Rename Hashtable.Table to State and make it something you hold
dougqh Aug 26, 2026
c893117
Take State in the size-tracked statics; keep eviction static too
dougqh Aug 26, 2026
050c304
Round out the State-taking statics: size, isEmpty, bucketFor, forEach
dougqh Aug 26, 2026
33e9f4f
Add size-tracked drain; fix two review nits
dougqh Aug 26, 2026
4c4509d
Step the eviction cursor on a failed scan; name the count honestly
dougqh Aug 27, 2026
3909184
Fix two eviction/drain defects found by Codex review
dougqh Aug 27, 2026
2d6bdb9
Add a selection guide to Hashtable and FlatHashtable
dougqh Aug 27, 2026
2dab029
Add Hashtable.D1/D2 tryGetOrUpdate to keep the cap refusal off the ca…
dougqh Aug 27, 2026
60b3b11
Add a primitive-long context overload of Hashtable.D1.tryGetOrUpdate
dougqh Aug 27, 2026
d813ca0
Record the capped-table rerun of HashtableD1Benchmark
dougqh Aug 27, 2026
b756710
Assert against double-inserting the same Entry instance
dougqh Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ final class CardinalityLimitReporter {

// Distinct blocked tag names in a window: 9 property fields + the configured peer tags + up to
// AdditionalTagsSchema.MAX_ADDITIONAL_TAG_KEYS + base.service, with headroom for the brief
// overlap
// of old and new peer names across a schema rebuild. Fixed capacity; the table chains on overflow
// rather than dropping, so an underestimate only adds chain depth on this cold path.
// overlap of old and new peer names across a schema rebuild. Fixed, strict-cap capacity: if this
// is ever underestimated, excess distinct tags are silently dropped from the summary rather than
// recorded (see the null-check in record()) -- this is a cold, best-effort logging path, not a
// correctness-sensitive one.
private static final int TAG_CAPACITY = 64;

// Rough width of one "<tag>=<count>, " entry, used to pre-size the summary builder. Cold path, so
Expand All @@ -43,7 +44,8 @@ final class CardinalityLimitReporter {

private final RatelimitedLogger rlLog;
// Tag name -> blocked count accumulated since the last emitted summary.
private final Hashtable.D1<String, TagBlockEntry> blockedByTag = new Hashtable.D1<>(TAG_CAPACITY);
private final Hashtable.D1<String, TagBlockEntry> blockedByTag =
Hashtable.D1.createCapped(TagBlockEntry.class, TAG_CAPACITY);

CardinalityLimitReporter() {
this(new RatelimitedLogger(log, 5, MINUTES));
Expand All @@ -56,7 +58,10 @@ final class CardinalityLimitReporter {
/** Records {@code count} values blocked for {@code tag} in the current reporting cycle. */
void record(String tag, long count) {
if (count > 0) {
blockedByTag.getOrCreate(tag, TagBlockEntry::new).count += count;
TagBlockEntry entry = blockedByTag.tryGetOrCreate(tag, TagBlockEntry::new);
if (entry != null) {
entry.count += count;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,17 +274,19 @@ static CIEntry[] _create_flat(float loadFactor) {
}
}
// Mirror the HashMap/TreeMap builds' second loop (UPPER_PREFIXES, suffix 0 & 2): 8 case-
// insensitive collisions. getOrCreate finds the already-present lower-case entry (a hit -> the
// create never fires, nothing allocates) and then the value is overwritten explicitly -- getOr-
// Create itself never updates an existing entry, so without this the FlatHashtable arm would do
// insensitive collisions. tryGetOrCreate finds the already-present lower-case entry (a hit, so
// the create never fires and nothing allocates) and then the value is overwritten explicitly --
// tryGetOrCreate itself never updates an existing entry, so without this the FlatHashtable arm
// would do
// less work (and end up with different final values) than the maps' overwriting put(), a false
// performance advantage. With the overwrite, all three create arms perform the same 24
// operations and end up with the same final values.
for (int suffix = 0; suffix < NUM_SUFFIXES; suffix += 2) {
for (String prefix : UPPER_PREFIXES) {
String key = prefix + "-" + suffix;
CIEntry entry =
FlatHashtable.getOrCreate(table, key, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE);
FlatHashtable.tryGetOrCreate(
table, key, CaseInsensitiveKeyStrategy.INSTANCE, CI_CREATE);
entry.value = suffix + 1;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,34 @@
* substitute for {@code HashMap} particularly for simple counter/tally use cases with a primitive
* value, where avoiding the per-update boxing allocation pays off even on a JVM with much better
* allocation handling than JDK 8 had.
*
* <p>Rerun on the capped/{@code State}-backed table (5 forks, 15 datapoints/method, Zulu 17.0.7
* AArch64, 8 threads). <b>Not comparable to the table above:</b> JMH auto-detected the {@code full
* + dont-inline} Blackhole here rather than the cheap {@code compiler} one, on the same JVM build
* and JMH 1.37 -- the mode is auto-detected per run and is not stable across runs, so every
* absolute number in this file is conditional on a mode that JMH does not record beside it. Compare
* within a table, never across. M ops/us:
*
* <pre>{@code
* add_hashMap 1204.8 add_hashtable 974.4
* update_hashMap 577.2 update_hashtable 1862.6
* iterate_hashMap 15.9 iterate_hashtable 21.5
* }</pre>
*
* <p>Within this run: {@code update_hashtable} wins by ~3.2x and {@code iterate_hashtable} by
* ~1.35x, while {@code add_hashtable} now <em>loses</em> by ~19% -- no longer the "roughly
* comparable" of the JDK 8 table, and a wider gap than the slight edge HashMap held in the previous
* Java 17 run. {@code add} is where the capped table's bookkeeping is least amortized: both sides
* allocate one entry per insert, so there is no boxing win to offset it, and the loop does nothing
* else. The counter/tally path -- the case {@code Hashtable} exists for -- is unaffected.
*
* <p>That is the right side of the trade for this family. {@code Hashtable} and {@link
* ConcurrentHashtable} are designed for workloads where <b>updates dominate</b>: the table is
* populated once and then hit repeatedly, so per-insert cost amortizes away and in-place mutation
* of a primitive field is the operation that runs hot. Paying on {@code add} to make {@code update}
* faster is the trade those workloads want. {@code FlatHashtable} and {@code TagMap} sit at the
* other end -- built up and read, not updated in a loop -- so this result does not transfer to
* them, and neither does the reasoning that justifies it.
*/
@Fork(2)
@Warmup(iterations = 2)
Expand Down Expand Up @@ -143,11 +171,14 @@ public static class D1State {
int cursor;
final BhD1Consumer consumer = new BhD1Consumer();

// Level.Iteration, not Trial: this rebuilds the table and the HashMap, so each iteration must
// start from a fresh, identically-sized state rather than inheriting mutated counters. The
// pollution call rides along -- it is idempotent and untimed, so repeating it costs nothing.
@Setup(Level.Iteration)
public void setUp() {
BenchmarkUtils.polluteHashDispatch();

table = new Hashtable.D1<>(CAPACITY);
table = Hashtable.D1.createCapped(D1Counter.class, CAPACITY);
hashMap = new HashMap<>(CAPACITY);
keys = SOURCE_KEYS;
for (int i = 0; i < N_KEYS; ++i) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,14 @@ public static class D2State {
int cursor;
final BhD2Consumer consumer = new BhD2Consumer();

// Level.Iteration, not Trial: this rebuilds the table and the HashMap, so each iteration must
// start from a fresh, identically-sized state rather than inheriting mutated counters. The
// pollution call rides along -- it is idempotent and untimed, so repeating it costs nothing.
@Setup(Level.Iteration)
public void setUp() {
BenchmarkUtils.polluteHashDispatch();

table = new Hashtable.D2<>(CAPACITY);
table = Hashtable.D2.createCapped(D2Counter.class, CAPACITY);
hashMap = new HashMap<>(CAPACITY);
k1s = SOURCE_K1;
k2s = SOURCE_K2;
Expand Down
81 changes: 54 additions & 27 deletions internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
* <p><b>Concurrent use is racy by design, not lock-free-safe in general.</b> The single-reference
* guarantee above covers only the slot reference and an entry's {@code final} fields; a non-final
* payload field written after construction is <b>not</b> safely published by a racing {@link
* #getOrCreate}, and a freshly built entry that loses the slot race is discarded without ever being
* retained by the table. That is fine for build-then-publish usage (populate on one thread, e.g. a
* static-final table, then read from many) and for a payload where a stale/default read or a
* #tryGetOrCreate}, and a freshly built entry that loses the slot race is discarded without ever
* being retained by the table. That is fine for build-then-publish usage (populate on one thread,
* e.g. a static-final table, then read from many) and for a payload where a stale/default read or a
* discarded race-loser is <b>benign</b> (miss → recreate; clobber → one wins). For concurrent
* <i>creation</i> of entries with meaningful post-construction state, keep entry state fully {@code
* final} — do not rely on this class for safe publication of mutable entry fields.
Expand All @@ -35,21 +35,41 @@
* the question whose unasked version becomes an unbounded-growth leak in a long-lived agent living
* in someone else's process. A regular {@code Map}'s auto-resize lets you forget that (fine when
* you own the heap; the wrong default when you are a guest in one). This table never grows on its
* own: {@link #get} / {@link #getOrCreate} / {@link #insert} <i>cap</i> rather than churn — a full
* table degrades to recompute-on-miss with bounded memory and no reallocation — and growth is an
* explicit, deliberate {@link #resize} / {@link #resizingInsert}. So it defaults to the
* own: {@link #get} / {@link #tryGetOrCreate} / {@link #insert} <i>cap</i> rather than churn — a
* full table degrades to recompute-on-miss with bounded memory and no reallocation — and growth is
* an explicit, deliberate {@link #resize} / {@link #resizingInsert}. So it defaults to the
* bounded-footprint posture the agent needs, with unbounded growth an opt-in you have to reach for
* (and one that, over externally-controlled keys, is the leak this structure otherwise prevents —
* see {@link #resizingInsert}). The trade only pays when a miss is benign (a cache / interner), not
* for a must-hold-everything map.
*
* <h2>Choosing between the three tables</h2>
*
* <ol>
* <li><b>Concurrent access?</b> Use {@code ConcurrentHashtable} -- the only thread-safe one of
* the three. This class is racy by design (see above), and {@code Hashtable} is not
* thread-safe at all.
* <li><b>Otherwise: does the population reset wholesale, or evolve?</b> A table cleared as a unit
* -- once per cycle, per request, or built and then discarded -- wants this class, whose open
* addressing has no tombstones and so offers no removal beyond clearing. A table whose
* entries come and go independently wants the chained {@code Hashtable}, which removes and
* evicts in place.
* </ol>
*
* <p>Lifetime is the usual shorthand for that second question and mostly works, because a
* short-lived table never needs to remove -- it just dies. The case it mis-sorts is a long-lived
* table that resets on a cycle: that is a sequence of short lives, and belongs with the short-lived
* ones. Compare a table that evicts stale entries one at a time while the busy ones survive the
* cycle (evolving -- {@code Hashtable}) against one that clears every entry each time it reports
* (resets -- this class).
*
* <p><b>Strategy roles, split by concern.</b> The per-use policy is a small set of {@link Strategy
* strategy} objects rather than one, so a caller supplies only what an operation needs:
*
* <ul>
* <li>a {@link MatchingStrategy} — the <i>key side</i>: {@link MatchingStrategy#hashKey hash a
* lookup key} (defaults to {@code hashCode}) and {@link MatchingStrategy#matches match} it
* against a stored entry. Used by {@link #get} / {@link #getOrCreate}.
* against a stored entry. Used by {@link #get} / {@link #tryGetOrCreate}.
* <li>a {@link HashStrategy} — the <i>entry side</i>: {@link HashStrategy#hashOf hash a stored
* entry}. Used by {@link #insert} / {@link #iterator} / {@link #resize} (which have an entry,
* not a key). For {@link Entry}-based tables this is just the cached {@link Entry#hash}, so
Expand All @@ -63,7 +83,7 @@
* <pre>{@code
* private static final MyStrategy S = new MyStrategy(); // concrete type => exact type pinned
* ...
* E e = FlatHashtable.getOrCreate(table, key, S, MyEntry::new); // non-capturing create
* E e = FlatHashtable.tryGetOrCreate(table, key, S, MyEntry::new); // non-capturing create
* }</pre>
*
* <p><b>Contract:</b> {@code table.length} must be a power of two ({@link #capacityFor}). Both
Expand All @@ -73,7 +93,7 @@
* where the entry was placed (trivially true when both default to {@code hashCode}). Cardinality
* cap / overflow / a live-size counter are <b>caller policy</b> (this class is pure mechanism): a
* capped caller does {@link #get} first, and only on a miss checks its budget before {@link
* #getOrCreate} (so hits stay a single probe and the create path is warmup-rare).
* #tryGetOrCreate} (so hits stay a single probe and the create path is warmup-rare).
*/
public final class FlatHashtable {
private FlatHashtable() {}
Expand All @@ -96,20 +116,21 @@ protected Entry(long hash) {

/**
* Single-key, {@code HashMap}-style convenience over the {@linkplain FlatHashtable static core}:
* {@link #get} / {@link #getOrCreate} / {@link #insert} / {@link #forEach} without writing a
* {@link #get} / {@link #tryGetOrCreate} / {@link #insert} / {@link #forEach} without writing a
* {@link MatchingStrategy}. Reach for it when you want something quick that beats {@code
* HashMap<K, V>} — the entry carries its own value fields, so updating an existing value is
* allocation-free (look up once, then write the returned entry).
*
* <p><b>Fixed or growable, chosen at construction.</b> {@link #createFixed} keeps the raw core's
* bounded posture — the table holds up to {@code maxCapacity} entries, then {@link #getOrCreate}
* caps and returns {@code null} (the caller supplies the overflow default). {@link
* #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code initialCapacity}
* is a sizing hint, not a cap, the table doubles when it fills past its load factor, and {@code
* getOrCreate} never returns {@code null}. The distinct factory names make the choice explicit at
* the call site (there's no ambiguous {@code (Class, int)} constructor); {@code Capacity} always
* counts <i>entries</i> — contrast the chained {@code Hashtable.D1}, whose factory counts
* <i>buckets</i>.
* bounded posture — the table holds up to {@code maxCapacity} entries, then {@link
* #tryGetOrCreate} caps and returns {@code null} (the caller supplies the overflow default).
* {@link #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code
* initialCapacity} is a sizing hint, not a cap, the table doubles when it fills past its load
* factor, and {@code tryGetOrCreate} never returns {@code null}. The distinct factory names make
* the choice explicit at the call site (there's no ambiguous {@code (Class, int)} constructor);
* {@code Capacity} always counts <i>entries</i>, matching the chained {@code
* Hashtable.D1.createCapped}. Across the family a table factory's number is always entries — only
* the low-level array allocators take a bucket count.
*
* <p><b>Entry-centric, not strategy-based.</b> Supply a {@link D1.Entry} subclass carrying the
* key and value fields; key equality is {@link Object#equals} by default (override {@link
Expand Down Expand Up @@ -176,7 +197,7 @@ private D1(TEntry[] table, float loadFactor, boolean growable, int limit) {

/**
* A bounded {@link D1} holding up to {@code maxCapacity} entries at the {@link
* #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}).
* #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreate} returns {@code null}).
*/
@Nonnull
public static <K, E extends D1.Entry<K>> D1<K, E> createFixed(
Expand Down Expand Up @@ -239,9 +260,15 @@ public TEntry get(@Nullable K key) {
* one. A growable table never returns {@code null}; a fixed one returns {@code null} when full
* and {@code key} is absent (the caller supplies the overflow default). A hit is always
* returned even at capacity — the cap blocks only creation, not lookup.
*
* <p>The {@code try} prefix marks "this may refuse" — a growable table simply never exercises
* it. The name has to serve both postures, since the posture is chosen per instance at the
* factory while the method name is per class, and the two mistakes are not symmetric:
* under-promising refusal costs an NPE at the cap, over-promising it costs a redundant null
* check. So it errs toward {@code try}.
*/
@Nullable
public TEntry getOrCreate(@Nullable K key, @Nonnull CreateStrategy<TEntry, K> createStrat) {
public TEntry tryGetOrCreate(@Nullable K key, @Nonnull CreateStrategy<TEntry, K> createStrat) {
final TEntry existing = get(key);
if (existing != null) {
return existing;
Expand Down Expand Up @@ -300,7 +327,7 @@ public <C> void forEach(C context, @Nonnull BiConsumer<? super C, ? super TEntry

/**
* Two-key (composite-key) analogue of {@link D1}. Both key parts pass directly through {@link
* #get} / {@link #getOrCreate}, so a lookup allocates no {@code Pair} — the win over {@code
* #get} / {@link #tryGetOrCreate}, so a lookup allocates no {@code Pair} — the win over {@code
* HashMap<Pair, V>}. Same fixed-or-growable ({@link #createFixed} / {@link #createGrowable}),
* entry-centric, no-{@code remove}, not-thread-safe contract as {@link D1}.
*
Expand Down Expand Up @@ -366,7 +393,7 @@ private D2(TEntry[] table, float loadFactor, boolean growable, int limit) {

/**
* A bounded {@link D2} holding up to {@code maxCapacity} entries at the {@link
* #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}).
* #DEFAULT_LOAD_FACTOR}, then capping ({@link #tryGetOrCreate} returns {@code null}).
*/
@Nonnull
public static <K1, K2, E extends D2.Entry<K1, K2>> D2<K1, K2, E> createFixed(
Expand Down Expand Up @@ -425,11 +452,11 @@ public TEntry get(@Nullable K1 key1, @Nullable K2 key2) {
}

/**
* Two-key analogue of {@link D1#getOrCreate}: growable never returns {@code null}; fixed
* Two-key analogue of {@link D1#tryGetOrCreate}: growable never returns {@code null}; fixed
* returns {@code null} when full and {@code (key1, key2)} is absent.
*/
@Nullable
public TEntry getOrCreate(
public TEntry tryGetOrCreate(
@Nullable K1 key1,
@Nullable K2 key2,
@Nonnull CreateStrategy2<TEntry, K1, K2> createStrat) {
Expand Down Expand Up @@ -486,7 +513,7 @@ public <C> void forEach(C context, @Nonnull BiConsumer<? super C, ? super TEntry
}

/**
* Two-key creation strategy for {@link D2#getOrCreate}: mint a new entry for {@code (key1,
* Two-key creation strategy for {@link D2#tryGetOrCreate}: mint a new entry for {@code (key1,
* key2)}. Like {@link CreateStrategy}, supply a {@code static final} constant or a
* <i>non-capturing</i> lambda (e.g. {@code MyEntry::new}) so it stays a single monomorphic,
* allocation-free instance.
Expand Down Expand Up @@ -523,7 +550,7 @@ public interface HashStrategy<E> {
* #matches}), and how to hash that key ({@link #hashKey}). {@code hashKey} <b>defaults to {@code
* key.hashCode()}</b> — override it only when the key's identity needs different hashing (e.g.
* case-insensitive), and then keep it consistent with the table's {@link HashStrategy#hashOf}.
* Used by {@link #get} / {@link #getOrCreate}.
* Used by {@link #get} / {@link #tryGetOrCreate}.
*
* <p>A {@link FunctionalInterface} ({@code matches} is the sole abstract method), so the common
* case can be a non-capturing lambda; a strategy that also customizes hashing is a named class
Expand Down Expand Up @@ -704,7 +731,7 @@ public static <E, K> E get(
*/
@StrategyConsumer
@Nullable
public static <E, K> E getOrCreate(
public static <E, K> E tryGetOrCreate(
@Nonnull E[] table,
K key,
@Nonnull MatchingStrategy<E, K> matchStrat,
Expand Down
Loading
Loading