Map and HashMap
Store and look up values by key with HashMap, use getOrDefault, computeIfAbsent and merge, and avoid the null and mutable-key traps.
Map<String, User> byId = new HashMap<>();
byId.put(user.id(), user);
User u = byId.get("u1"); // null when absent
int n = counts.getOrDefault("x", 0);
counts.merge("x", 1, Integer::sum);A Map associates keys with values. Each key appears at most once; putting an existing key replaces its value and returns the old one. In a service you use maps to index rows by id, cache results, count events and group records.
How HashMap finds a key
HashMap calls the key's hashCode() to pick a bucket, then equals() to find the exact entry. When the hash codes are well spread, get, put and containsKey take constant time on average, whatever the size. That only works if the key class implements equals and hashCode consistently. String, the boxed numbers, enums and records already do.
Order is not defined
A HashMap makes no promise about iteration order, and the order can change as the map grows. Do not let a JSON response or a test depend on it. Use LinkedHashMap for insertion order or TreeMap for sorted keys.
The methods that save code
getOrDefault(key, fallback)returns the fallback when the key is absent.putIfAbsent(key, value)only writes when there is no current value.computeIfAbsent(key, k -> new ArrayList<>())creates a value on first use and returns it, which is the standard way to group.merge(key, 1, Integer::sum)inserts or combines, which is the standard way to count.
Iterate with for (var e : map.entrySet()) when you need both key and value. Looping over keySet() and calling get for each key does a second lookup per entry.
Mistakes people make
- Treating null as missing.
HashMapallows null values, sogetreturnsnullboth for an absent key and a key mapped tonull.getOrDefaultreturns the storednull, not your default. Avoid storing nulls, or usecontainsKey. - Mutable keys. If a field used in
hashCodechanges after the key goes in, the entry is stranded: neither the changed object nor an equal copy of the old one finds it. - Sharing across threads. Concurrent writes can lose updates and corrupt a
HashMap. A cache read and written by request threads should be aConcurrentHashMap, which rejects null keys and values.
When you know roughly how many entries you will add, HashMap.newHashMap(expected) (Java 19) sizes the table so it does not need to resize while you fill it.
Example
When to use it
- Index users loaded from the database by id so each request lookup is a single get.
- Count requests per endpoint with merge before writing the totals to a metrics log.
- Group order lines by customer id with computeIfAbsent when building an invoice batch.
More examples
Grouping with computeIfAbsent
computeIfAbsent creates the list the first time a customer appears and returns the existing list after that, so there is no get-check-put dance. LinkedHashMap keeps the customers in first-seen order.
record Order(String customer, String id) {}
void main() {
List<Order> orders = List.of(
new Order("acme", "o-1"),
new Order("globex", "o-2"),
new Order("acme", "o-3"));
Map<String, List<String>> byCustomer = new LinkedHashMap<>();
for (Order order : orders) {
byCustomer.computeIfAbsent(order.customer(), k -> new ArrayList<>()).add(order.id());
}
IO.println(byCustomer);
for (var entry : byCustomer.entrySet()) {
IO.println(entry.getKey() + " has " + entry.getValue().size() + " order(s)");
}
}
// Output:
// {acme=[o-1, o-3], globex=[o-2]}
// acme has 2 order(s)
// globex has 1 order(s)Absent key or null value?
get cannot tell the two cases apart, and getOrDefault returns the stored null for theme. Not storing null values at all is the simplest fix.
void main() {
Map<String, String> settings = new HashMap<>();
settings.put("theme", null);
IO.println("get theme: " + settings.get("theme"));
IO.println("get locale: " + settings.get("locale"));
IO.println("has theme: " + settings.containsKey("theme"));
IO.println("has locale: " + settings.containsKey("locale"));
IO.println("locale or default: " + settings.getOrDefault("locale", "en-GB"));
IO.println("theme or default: " + settings.getOrDefault("theme", "light"));
}
// Output:
// get theme: null
// get locale: null
// has theme: true
// has locale: false
// locale or default: en-GB
// theme or default: nullA key that changes after insertion
The entry is still in the map but filed under the old hash, so no lookup can reach it. Use immutable keys such as String or records with immutable fields.
class TenantKey {
String name;
TenantKey(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
return o instanceof TenantKey other && other.name.equals(name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}
void main() {
Map<TenantKey, String> configs = new HashMap<>();
TenantKey key = new TenantKey("tenant-a");
configs.put(key, "config A");
key.name = "tenant-b"; // the hash code changes after insertion
IO.println("same object: " + configs.get(key));
IO.println("equal to the old key: " + configs.get(new TenantKey("tenant-a")));
IO.println("size: " + configs.size());
}
// Output:
// same object: null
// equal to the old key: null
// size: 1
Discussion