Serguey Asael Shinder: A concatenated String key collides: 'ab' + 'c' equals 'a' + 'bc'
A cache keyed on two values is often written the quick way:
Map<String, Result> cache = new HashMap<>();
Result lookup(String tenant, String resource) {
return cache.computeIfAbsent(tenant + resource, k -> load(tenant, resource));
}
It works in every test that uses realistic names, and it is wrong. "ab" + "c" and "a" + "bc" are the same string, "abc", so tenant ab asking for resource c gets the entry cached for tenant a and resource bc.
Why it happens. String.equals compares the character sequence and nothing else (https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html#equals(java.lang.Object)). Once two fields are concatenated, the information about where one ended and the next began is gone. The map is doing exactly what it should; the key no longer describes the input.
A separator is not a full fix. tenant + ":" + resource helps until a field can contain :, and then "a:" + "b" and "a" + ":b" collide again. It is safe only if the separator is guaranteed never to appear in any field — a guarantee that usually lives in nobody's code.

What to use instead.
- A record as the key. A record's generated
equalscompares each component, and itshashCodeis derived from all of them (https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Record.html#equals(java.lang.Object)):
``java record CacheKey(String tenant, String resource) {} Map<CacheKey, Result> cache = new HashMap<>(); cache.computeIfAbsent(new CacheKey(tenant, resource), k -> load(k.tenant(), k.resource())); ``
List.of(tenant, resource). AListis equal to another list with the same elements in the same order (https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html#equals(java.lang.Object)), so the boundary between elements is part of the comparison.List.ofrejectsnullelements, which is usually what you want in a key.
- If the key must be a string or bytes — a Redis key, a file name, input to a hash — encode each field's length before it, or use an escaping scheme that cannot be ambiguous.
3:abc|1:ccannot be confused with2:ab|2:bc.
The same mistake in security code. When the "key" is the input to a hash or a signature, the collision is not a stale cache entry but two different messages with the same digest. Hashing user + action lets someone choose a different split of the same bytes. Length-prefix every field, or use a structured encoding, before hashing.
Where to look. Search for + inside computeIfAbsent, get, put and containsKey calls, and for String.join or StringBuilder feeding a digest or a cache.
No JDK runs on the machine this note was written on; the behaviour described is the one specified in the linked javadoc.