Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
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;
Expand All @@ -15,42 +17,49 @@
*
*
* <ul>
* Benchmark to illustrate the trade-offs around case-insensitive Map look-ups - using either...
* <li>(RECOMMENDED) TreeMap with Comparator of String::compareToIgnoreCase
* <li>HashMap with look-ups using String::to<X>Case
* Benchmark for the trade-offs around case-insensitive Map look-ups, comparing:
* <li>TreeMap with a {@code String::compareToIgnoreCase} comparator — allocation-free, O(log n)
* <li>HashMap keyed on {@code toLowerCase()} — O(1) but allocates a folded String per look-up
* <li>FlatHashtable with a {@link FlatHashtable.CaseInsensitiveStringKeyStrategy} — O(1) probe,
* allocation-free (case folded inside hash/matches), value stored unboxed
* </ul>
*
* <p>For case-insensitive lookups, TreeMap map creation is consistently faster because it avoids
* String::to<X>Case calls.
* <p><b>Takeaways.</b> FlatHashtable is ~2x the (previously recommended) TreeMap at the same zero
* allocation, and matches HashMap's look-up throughput <i>without</i> HashMap's per-look-up folded
* String (which drives the multi-threaded GC pressure). The case-insensitive hash is the
* consistent-for-all-inputs two-way fold ({@link
* datadog.trace.util.Strings#caseInsensitiveHashCode} — see its note); a cheaper ASCII-only fold
* would recover a few percent for header-name-only hot paths, deliberately not the default. {@code
* LOW_LOAD_FACTOR} makes no difference here (the fold, not the probe count, dominates), so the
* default 0.5 is used.
*
* <p>Despite calls to String::to<X>Case, HashMap lookups are faster in single threaded
* microbenchmark by 50% but are worse when frequently called in a multi-threaded system.
* <p>Numbers below: MacBook M1, Zulu 21, per-thread lookup index, @Fork(5). <code>
* 1 thread
*
* <p>With many threads, the extra allocation from calling String::to<X>Case leads to frequent GCs
* which has adverse impacts on the whole system. <code>
* MacBook M1 with 1 thread (Java 21)
* Benchmark Mode Cnt Score Error Units
* create_flatHashtable thrpt 15 3723141.4 ± 63717.9 ops/s
* create_hashMap thrpt 15 905452.5 ± 16561.3 ops/s
* create_treeMap thrpt 15 1208339.4 ± 84364.2 ops/s
*
* Benchmark Mode Cnt Score Error Units
* CaseInsensitiveMapBenchmark.create_hashMap thrpt 6 994213.041 ± 15718.903 ops/s
* CaseInsensitiveMapBenchmark.create_treeMap thrpt 6 1522900.015 ± 21646.688 ops/s
*
* CaseInsensitiveMapBenchmark.get_hashMap thrpt 6 69149862.293 ± 9168648.566 ops/s
* CaseInsensitiveMapBenchmark.get_treeMap thrpt 6 42796699.230 ± 9029447.805 ops/s
* lookup_flatHashtable thrpt 15 75874505.0 ± 3722582.3 ops/s
* lookup_flatHashtable_lowLoad thrpt 15 75686682.1 ± 1879579.4 ops/s
* lookup_hashMap thrpt 15 80319813.9 ± 7410634.9 ops/s
* lookup_treeMap thrpt 15 45926358.7 ± 1917349.2 ops/s
* </code> <code>
* MacBook M1 with 8 threads (Java 21)
*
* Benchmark Mode Cnt Score Error Units
* CaseInsensitiveMapBenchmark.create_hashMap thrpt 6 6641003.483 ± 543210.409 ops/s
* CaseInsensitiveMapBenchmark.create_treeMap thrpt 6 10030191.764 ± 1308865.113 ops/s
* 8 threads (with -prof gc; alloc = gc.alloc.rate.norm)
*
* CaseInsensitiveMapBenchmark.get_hashMap thrpt 6 38748031.837 ± 9012072.804 ops/s
* CaseInsensitiveMapBenchmark.get_treeMap thrpt 6 173495470.789 ± 27824904.999 ops/s
* Benchmark Mode Cnt Score Error Units alloc
* lookup_flatHashtable thrpt 15 558144937.2 ± 22797680.7 ops/s ~0 B/op
* lookup_flatHashtable_lowLoad thrpt 15 564984154.1 ± 25899687.5 ops/s ~0 B/op
* lookup_hashMap thrpt 15 529773720.8 ± 82928000.6 ops/s 24.0 B/op (151 GCs)
* lookup_treeMap thrpt 15 262110611.8 ± 30486484.7 ops/s ~0 B/op
* </code>
*/
@Fork(2)
@Warmup(iterations = 2)
@Measurement(iterations = 3)
@Threads(8)
@State(Scope.Thread)
public class CaseInsensitiveMapBenchmark {
static final String[] PREFIXES = {"foo", "bar", "baz", "quux"};

Expand Down Expand Up @@ -87,12 +96,17 @@ static <T> T init(Supplier<T> supplier) {
return keys;
});

static int sharedLookupIndex = 0;
// Per-thread (@State(Scope.Thread)) so cycling the lookup key doesn't contend a shared counter.
// The maps stay static/shared (read-only after class-init); only the index is per-thread. A
// shared
// counter's cache-line ping-pong would floor the fastest lookups (the flat probe) at @Threads(8),
// masking exactly the differences this benchmark compares.
int lookupIndex = 0;

static String nextLookupKey() {
int localIndex = ++sharedLookupIndex;
String nextLookupKey() {
int localIndex = ++lookupIndex;
if (localIndex >= LOOKUP_KEYS.length) {
sharedLookupIndex = localIndex = 0;
lookupIndex = localIndex = 0;
}
return LOOKUP_KEYS[localIndex];
}
Expand Down Expand Up @@ -178,5 +192,78 @@ public Integer lookup_treeMap() {
return TREE_MAP.get(nextLookupKey());
}

// FlatHashtable with a case-insensitive KeyStrategy: the strategy folds case inside hash/matches,
// so lookups are O(1) (single probe) AND allocation-free (no String::to<X>Case) — TreeMap's zero-
// alloc property without TreeMap's O(log n) comparison walk. Value is stored unboxed. Read-only
// after build, so reads are lock-free (see FlatHashtable / ThreadSafeMapBenchmark).
static final class CIEntry extends FlatHashtable.Entry {
final String key; // original case preserved
final int value;

CIEntry(String key, long hash, int value) {
super(hash); // cache the (char-by-char) case-insensitive hash
this.key = key;
this.value = value;
}
}

// Dogfoods the shared toolbox pieces: the CI hash is Strings.caseInsensitiveHashCode (sealed by
// CaseInsensitiveStringKeyStrategy), the table owns the spread. Only matches/hashOf are bespoke.
static final class CaseInsensitiveKeyStrategy
extends FlatHashtable.CaseInsensitiveStringKeyStrategy<CIEntry> {
static final CaseInsensitiveKeyStrategy INSTANCE = new CaseInsensitiveKeyStrategy();

private CaseInsensitiveKeyStrategy() {}

@Override
public boolean matches(String key, CIEntry entry) {
return key.equalsIgnoreCase(entry.key); // case-folded, allocation-free
}

@Override
public long hashOf(CIEntry entry) {
return entry.hash; // CIEntry caches its (raw, case-insensitive) hash
}
}

static CIEntry[] _create_flat(float loadFactor) {
// 16 distinct case-insensitive keys (foo-0..quux-3).
CIEntry[] table =
FlatHashtable.create(CIEntry.class, PREFIXES.length * NUM_SUFFIXES, loadFactor);
for (int suffix = 0; suffix < NUM_SUFFIXES; ++suffix) {
for (String prefix : PREFIXES) {
String key = prefix + "-" + suffix;
long hash = CaseInsensitiveKeyStrategy.INSTANCE.hash(key);
FlatHashtable.insert(
table, new CIEntry(key, hash, suffix), CaseInsensitiveKeyStrategy.INSTANCE);
}
}
// The HashMap/TreeMap builds' second loop (UPPER_PREFIXES, suffix 0 & 2) only OVERWRITES values
// case-insensitively — it adds no new keys, and values don't affect lookup throughput — so the
// read set is these same 16 keys.
return table;
}

@Benchmark
public CIEntry[] create_flatHashtable() {
return _create_flat(FlatHashtable.DEFAULT_LOAD_FACTOR);
}

static final CIEntry[] FLAT_TABLE = _create_flat(FlatHashtable.DEFAULT_LOAD_FACTOR);
static final CIEntry[] FLAT_TABLE_LOW = _create_flat(FlatHashtable.LOW_LOAD_FACTOR);

@Benchmark
public CIEntry lookup_flatHashtable() {
// Lock-free, allocation-free, single-probe case-insensitive lookup.
return FlatHashtable.get(FLAT_TABLE, nextLookupKey(), CaseInsensitiveKeyStrategy.INSTANCE);
}

@Benchmark
public CIEntry lookup_flatHashtable_lowLoad() {
// Same, but at LOW_LOAD_FACTOR (4x): does the sparser table shave probes for the (mostly
// hash-fold-dominated) CI lookup, or is it a wash? — see the delta to lookup_flatHashtable.
return FlatHashtable.get(FLAT_TABLE_LOW, nextLookupKey(), CaseInsensitiveKeyStrategy.INSTANCE);
}

// TODO: Add ConcurrentSkipListMap & synchronized HashMap & TreeMap
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
* <li>TreeMap — when a custom Comparator is needed (see CaseInsensitiveMapBenchmark)
* <li>LinkedHashMap — only when insertion-order iteration is required; cost is paid at
* construction and in per-entry memory
* <li>FlatHashtable — a find-or-create table over self-contained entries (not a general Map: no
* arbitrary put/remove). Compared here on the ops it does support — build, get, iterate —
* where its self-contained entry stores the value <i>unboxed</i> (one object per key, no
* {@code Integer}). Fixed-capacity, so it must be sized to the working set up front.
* </ul>
*
* <p><b>Uncontended synchronization tax.</b> A {@link Collections#synchronizedMap} case is included
Expand Down Expand Up @@ -81,12 +85,107 @@ static TagMap fillTagMap(TagMap map) {
return map;
}

// FlatHashtable is a find-or-create table over self-contained entries — no arbitrary put/remove,
// so only the comparable ops appear here: build (via the comparison-free insert of distinct
// keys),
// get, and iterate. Its entry carries the value UNBOXED (no Integer), one object per key.
static final class IntEntry {
final String key;
final int value;

IntEntry(String key, int value) {
this.key = key;
this.value = value;
}
}

static final class IntEntryKeyStrategy extends FlatHashtable.StringKeyStrategy<IntEntry> {
// Canonical exact-typed singleton: one instance, private ctor => the static-poly discipline is
// enforced by the class, not left to each caller to declare correctly.
static final IntEntryKeyStrategy INSTANCE = new IntEntryKeyStrategy();

private IntEntryKeyStrategy() {}

@Override
public boolean matches(String key, IntEntry entry) {
return key.equals(entry.key);
}

@Override
public long hashOf(IntEntry entry) {
return hash(entry.key);
}
}

// --- CHA-defeat decoys ---------------------------------------------------------------------
// Never used to build a table; loaded (in setUp) only so KeyStrategy.hash and KeyStrategy.matches
// each have >=2 concrete implementors. That denies C2 the single-implementor CHA devirtualization
// of keyStrat.hash/matches inside get(). If the strategy calls still inline afterward, the win is
// structural (the constant INSTANCE's exact type propagated through the inlined get), not a CHA
// bet that would deopt when a second subclass loads.

// Second StringKeyStrategy impl -> KeyStrategy.matches is now polymorphic.
static final class DecoyStringKeyStrategy extends FlatHashtable.StringKeyStrategy<IntEntry> {
static final DecoyStringKeyStrategy INSTANCE = new DecoyStringKeyStrategy();

private DecoyStringKeyStrategy() {}

@Override
public boolean matches(String key, IntEntry entry) {
return key == entry.key; // deliberately different body from IntEntryKeyStrategy
}

@Override
public long hashOf(IntEntry entry) {
return hash(entry.key);
}
}

// Direct KeyStrategy impl with its own hash -> KeyStrategy.hash is now polymorphic too.
static final class DecoyKeyStrategy extends FlatHashtable.KeyStrategy<String, IntEntry> {
static final DecoyKeyStrategy INSTANCE = new DecoyKeyStrategy();

private DecoyKeyStrategy() {}

@Override
public long hash(String key) {
return key.length();
}

@Override
public boolean matches(String key, IntEntry entry) {
return key.equals(entry.key);
}

@Override
public long hashOf(IntEntry entry) {
return entry.key.length();
}
}

// Referenced only so these three concrete KeyStrategy implementors load at benchmark class-init,
// before the hot method compiles — see the CHA-defeat note above.
@SuppressWarnings("unused")
static final Object[] CHA_DEFEAT = {
IntEntryKeyStrategy.INSTANCE, DecoyStringKeyStrategy.INSTANCE, DecoyKeyStrategy.INSTANCE
};

static IntEntry[] newFilledFlat() {
// Sized to the key count (FlatHashtable is fixed-capacity, no resize): load factor <= 0.5.
IntEntry[] table = FlatHashtable.create(IntEntry.class, INSERTION_KEYS.length);
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
FlatHashtable.insert(table, new IntEntry(INSERTION_KEYS[i], i), IntEntryKeyStrategy.INSTANCE);
}
return table;
}

// Per-thread prebuilt maps for the read + clone benchmarks (built once per trial, per thread).
HashMap<String, Integer> hashMap;
Map<String, Integer> synchronizedHashMap;
TreeMap<String, Integer> treeMap;
LinkedHashMap<String, Integer> linkedHashMap;
TagMap tagMap;
IntEntry[] flatTable;
int index = 0;

@Setup(Level.Trial)
Expand All @@ -99,6 +198,7 @@ public void setUp() {
linkedHashMap = new LinkedHashMap<>();
fill(linkedHashMap);
tagMap = fillTagMap(TagMap.create());
flatTable = newFilledFlat();
}

String nextLookupKey() {
Expand Down Expand Up @@ -159,6 +259,11 @@ public TagMap create_tagMap_via_ledger() {
return ledger.build();
}

@Benchmark
public IntEntry[] create_flatHashtable() {
return newFilledFlat();
}

// ---- copy ----

@Benchmark
Expand Down Expand Up @@ -200,6 +305,11 @@ public Integer get_synchronizedHashMap() {
return synchronizedHashMap.get(nextLookupKey());
}

@Benchmark
public IntEntry get_flatHashtable() {
return FlatHashtable.get(flatTable, nextLookupKey(), IntEntryKeyStrategy.INSTANCE);
}

@Benchmark
public void iterate_hashMap(Blackhole blackhole) {
for (Map.Entry<String, Integer> entry : hashMap.entrySet()) {
Expand All @@ -219,4 +329,16 @@ public void iterate_synchronizedHashMap(Blackhole blackhole) {
}
}
}

@Benchmark
public void iterate_flatHashtable(Blackhole blackhole) {
// Context-passing forEach: blackhole rides through as context, so the lambda doesn't capture.
FlatHashtable.forEach(
flatTable,
blackhole,
(bh, e) -> {
bh.consume(e.key);
bh.consume(e.value);
});
}
}
Loading