SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: A concatenated String key collides: 'ab' + 'c' equals 'a' + 'bc'

· by Serguey Asael Shinder / Serguey Shinder

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.

Serguey Asael Shinder: A concatenated String key collides: 'ab' + 'c' equals 'a' + 'bc'
A concatenated String key collides: 'ab' + 'c' equals 'a' + 'bc' — Serguey Asael Shinder

What to use instead.

  1. A record as the key. A record's generated equals compares each component, and its hashCode is 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())); ``

  1. List.of(tenant, resource). A List is 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.of rejects null elements, which is usually what you want in a key.
  1. 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:c cannot be confused with 2: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.