Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
846dcc3
Guard MutatingBucketIterator.replace against relinking an already-lin…
dougqh Aug 29, 2026
553d81c
Carry Maybe<T> forward pending #12328 merge
dougqh Aug 28, 2026
a129e7b
Add Maybe-returning tryGetOrCreateAsMaybe to Hashtable and FlatHashtable
dougqh Aug 28, 2026
4e5dabd
Promote tryGetOrCreateAsMaybe to tryGetOrCreate, demote nullable form…
dougqh Aug 28, 2026
fe71bfe
Tighten Maybe.update's primitive-overload javadoc
dougqh Aug 29, 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.tryGetOrCreateOrNull(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
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package datadog.trace.util;

import java.util.function.BiConsumer;
import java.util.function.ObjLongConsumer;
import javax.annotation.Nullable;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* A do/don't guide for using {@link Maybe}, not a research instrument like {@code
* datadog.trace.util.escape.EscapeShapeBenchmark} (which this class's arms are built on top of).
* Read {@code gc.alloc.rate.norm} — the "good" arm in each pair is expected to read 0 B/op on every
* JDK the way {@code EscapeShapeBenchmark}'s {@code singleSite}/{@code passedToInlinedStrategy}
* arms do; the paired "bad" arm exists to make the regression visible rather than theoretical. Run
* as
*
* <pre>
* ./gradlew :internal-api:jmh -Pjmh.includes=MaybeUsagePatternsBenchmark -Pjmh.profilers=gc -PtestJvm=17
* </pre>
*
* This is the intended backing example for a perf-review check like "EA-dependent elision on a hot
* path where a structural alternative exists at parity → prefer the deterministic form": both pairs
* below have a same-cost deterministic form available, so reviewing a real diff against these arms
* is a matter of asking "which arm does this call site look like," not re-deriving the
* escape-analysis argument each time.
*
* <p><b>The boxed-context pair is the sharper illustration of that phrase than it first looks
* like.</b> {@code badBoxedContextUpdateInlined} was expected to allocate the boxed {@code Long}
* and, measured here, does not -- with the whole {@code update} call inlined, C2 scalar-replaces
* the box the same as it would any other short-lived object. That is exactly the "EA-dependent"
* half of that phrase: {@link Maybe#update(long, ObjLongConsumer)} has no <em>box</em> to eliminate
* in the first place, so it reads 0 B/op regardless of whether the mutator lambda's own inlining
* holds; the generic-context form's 0 B/op is contingent on that specific inlining, which {@code
* badBoxedContextUpdateUninlined} demonstrates by taking it away via the same {@code
* -XX:CompileCommand=dontinline} technique {@code EscapeShapeBenchmark} uses for its {@code
* UninlinedStrategy} arm. This is narrower than immunity to every inlining failure: if the
* producing method or the {@code update} call itself fails to inline -- a different boundary,
* exercised by {@code EscapeShapeBenchmark}'s {@code passedToUninlinedStrategy} arm (24 B/op) --
* the {@code Maybe} wrapper itself becomes a real allocation for either overload.
*/
@Fork(
value = 2,
jvmArgsAppend = {
"-XX:CompileCommand=dontinline,datadog.trace.util.MaybeUsagePatternsBenchmark$UninlinedBoxedAdder::accept"
})
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Threads(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(java.util.concurrent.TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class MaybeUsagePatternsBenchmark {

static final class Widget {
long count;
}

/** A non-capturing updater, as {@link Maybe#update(long, ObjLongConsumer)} expects. */
static final ObjLongConsumer<Widget> ADD_PRIMITIVE = (w, delta) -> w.count += delta;

/**
* The same update expressed through the generic-context overload instead. {@code Long} is not
* assignable from {@code long} without boxing, so calling {@link Maybe#update(Object,
* BiConsumer)} with a {@code long} argument boxes it every time — the exact per-call allocation
* {@link Maybe#update(long, ObjLongConsumer)} exists to avoid. Kept as a {@code
* BiConsumer<Widget, Long>} rather than inlined at the call site so the two arms below differ
* only in which overload is selected, not in lambda shape.
*/
static final BiConsumer<Widget, Long> ADD_BOXED_INLINED = (w, delta) -> w.count += delta;

/**
* Same logic as {@link #ADD_BOXED_INLINED}, but as a named class rather than a lambda so {@code
* -XX:CompileCommand=dontinline} (see this class's {@link Fork} annotation) has a concrete method
* to target -- kept out of line the same way {@code EscapeShapeBenchmark}'s {@code
* UninlinedStrategy} is, by the {@code CompileCommand} rather than {@code CompilerControl}, since
* JMH's processor only reads that annotation from {@code @Benchmark} methods.
*/
static final class UninlinedBoxedAdder implements BiConsumer<Widget, Long> {
@Override
public void accept(Widget w, Long delta) {
w.count += delta;
}
}

static final BiConsumer<Widget, Long> ADD_BOXED_UNINLINED = new UninlinedBoxedAdder();

/**
* Deliberately outside {@code Long}'s [-128, 127] cache range -- a cached delta like {@code 1L}
* would make {@link #badBoxedContextUpdateUninlined} read 0 B/op too, for a reason with nothing
* to do with which overload got picked.
*/
static final long DELTA = 1_000L;

private final Widget[] table = new Widget[8];
private int counter;

public MaybeUsagePatternsBenchmark() {
for (int i = 0; i < table.length; i++) {
// Half the slots stay null so every arm below actually exercises the refused/empty path,
// not just the present one -- see EscapeShapeBenchmark's `alternate()` javadoc for why an
// always-taken branch would quietly turn these into single-site arms and lie.
if ((i & 1) == 0) {
table[i] = new Widget();
}
}
}

private int nextKey() {
return (counter++) & (table.length - 1);
}

@Nullable
private Widget lookup(int key) {
return table[key];
}

/**
* GOOD: exactly one {@code Maybe.of(...)} call site, fed by delegating to the existing nullable
* method. See {@link Maybe}'s class javadoc for why this is the recommended shape.
*/
private Maybe<Widget> tryLookupDelegating(int key) {
return Maybe.of(lookup(key));
}

/**
* BAD: a {@code Maybe.of(...)} call site per branch. Both branches return the same wrapper type,
* so this looks equivalent to {@link #tryLookupDelegating} at every call site that uses it — the
* difference only shows up here, in the allocation profile of the method that builds the {@code
* Maybe}, which is exactly why it is easy to introduce by accident.
*/
private Maybe<Widget> tryLookupMultiSite(int key) {
Widget w = lookup(key);
if (w != null) {
return Maybe.of(w);
} else {
return Maybe.<Widget>of(null);
}
}

@Benchmark
public void goodSingleConstructionSite(Blackhole bh) {
Maybe<Widget> t = tryLookupDelegating(nextKey());
bh.consume(t.isPresent());
}

@Benchmark
public void badMultiConstructionSite(Blackhole bh) {
Maybe<Widget> t = tryLookupMultiSite(nextKey());
bh.consume(t.isPresent());
}

@Benchmark
public void goodPrimitiveContextUpdate(Blackhole bh) {
Maybe<Widget> t = tryLookupDelegating(nextKey());
t.update(DELTA, ADD_PRIMITIVE);
bh.consume(t.isPresent());
}

/**
* Reads 0 B/op here despite boxing {@link #DELTA} on every call -- this call site stays inlined,
* so C2 scalar-replaces the {@code Long} the same as any other non-escaping object. See {@link
* #badBoxedContextUpdateUninlined} for what that 0 is actually contingent on.
*/
@Benchmark
public void badBoxedContextUpdateInlined(Blackhole bh) {
Maybe<Widget> t = tryLookupDelegating(nextKey());
t.update(DELTA, ADD_BOXED_INLINED);
bh.consume(t.isPresent());
}

/**
* The same boxing, with only the inlining taken away (via {@link UninlinedBoxedAdder} and this
* class's {@code CompileCommand}). Whatever this costs above {@link #goodPrimitiveContextUpdate}
* is the box {@link #badBoxedContextUpdateInlined} was quietly relying on EA to remove.
*/
@Benchmark
public void badBoxedContextUpdateUninlined(Blackhole bh) {
Maybe<Widget> t = tryLookupDelegating(nextKey());
t.update(DELTA, ADD_BOXED_UNINLINED);
bh.consume(t.isPresent());
}
}
Loading