A list answers one question: what is at index 3. A map answers a different one: what is stored under this key. HashMap answers it in roughly constant time whether it holds ten entries or ten million, which is why it turns up in almost every non-trivial Java program.
That speed is bought with a contract between hashCode() and equals() that the compiler will not enforce for you. Break it and nothing throws — the entry is still in the map, still counted by size(), still printed by toString(), and simply cannot be found again. This article covers the API first, then the contract, then exactly what breaking it looks like.
![]()
Every output line, error message and count below was produced by compiling and running the code on OpenJDK 21.0.6. Cost is expressed as counts of hashCode() and equals() calls from instrumented key classes, never as elapsed time — a counter gives the same number on every machine and a stopwatch does not.
Why a Map is not a Collection
Map is part of the Java collections framework, but it is not a Collection. It is a separate interface with its own hierarchy, and the compiler is blunt about it:
import java.util.*;
public class NotCollection {
public static void main(String[] args) {
Map<String, Integer> stock = new HashMap<>();
Collection<String> c = stock;
for (String s : stock) {
System.out.println(s);
}
}
}
NotCollection.java:6: error: incompatible types: Map<String,Integer> cannot be converted to Collection<String>
Collection<String> c = stock;
^
NotCollection.java:7: error: for-each not applicable to expression type
for (String s : stock) {
^
required: array or java.lang.Iterable
found: Map<String,Integer>
2 errors
The reason is structural. A Collection<E> stores single elements of one type E. A map stores pairs, and there is no single E that describes a pair without inventing one. Map also does not extend Iterable, which is why the enhanced for loop refuses it outright.
Collection<E> | Map<K, V> | |
|---|---|---|
| Unit stored | one element | a key and a value together |
Extends Iterable | yes | no |
Works with a direct for loop | yes | no — you iterate one of its views |
| Uniqueness enforced on | the element, for Set | the key, always |
| Size counts | elements | key/value pairs |
What a map gives you instead of being iterable is three views — keySet(), values() and entrySet() — each of which is a Collection. Those are covered later in this article.
Creating a HashMap and the operations you will use most
Declare the interface, instantiate the implementation. Both type arguments must be reference types, so an int value becomes Integer:
import java.util.HashMap;
import java.util.Map;
public class Basics {
public static void main(String[] args) {
Map<String, Integer> stock = new HashMap<>();
stock.put("bolt", 120);
stock.put("nut", 340);
stock.put("washer", 85);
System.out.println("size = " + stock.size());
System.out.println("get bolt = " + stock.get("bolt"));
System.out.println("get screw = " + stock.get("screw"));
System.out.println("containsKey = " + stock.containsKey("screw"));
System.out.println("containsValue = " + stock.containsValue(340));
Integer prev = stock.put("bolt", 200);
System.out.println("put returns = " + prev);
System.out.println("bolt now = " + stock.get("bolt"));
Integer gone = stock.remove("washer");
System.out.println("remove returns= " + gone);
System.out.println("remove missing= " + stock.remove("washer"));
System.out.println("size = " + stock.size());
System.out.println("map = " + stock);
}
}
size = 3
get bolt = 120
get screw = null
containsKey = false
containsValue = true
put returns = 120
bolt now = 200
remove returns= 85
remove missing= null
size = 2
map = {bolt=200, nut=340}
Three things in that output are worth pinning down.
A key is unique; a value is not. put on a key that already exists replaces the value and returns the old one. That return value is free information most code throws away — it tells you whether you overwrote something.
containsValue is not containsKey. containsKey is a hash lookup and effectively constant time. containsValue has no index to work with and scans every entry, so it is O(n). Reaching for it in a loop is a common way to turn a fast program into a slow one.
remove returns the old value, or null if there was nothing there. Like put, it hands back what it displaced.
| Call | Returns | Cost |
|---|---|---|
put(k, v) | the previous value, or null | O(1) average |
get(k) | the value, or null | O(1) average |
containsKey(k) | boolean | O(1) average |
remove(k) | the removed value, or null | O(1) average |
size() | number of pairs | O(1) |
isEmpty() | boolean | O(1) |
containsValue(v) | boolean | O(n) — full scan |
clear() | void | O(n) |
Why get returning null is ambiguous
HashMap permits null values, and it permits one null key. That makes null from get mean two entirely different things:
import java.util.HashMap;
import java.util.Map;
public class NullAmbig {
public static void main(String[] args) {
Map<String, String> config = new HashMap<>();
config.put("host", "localhost");
config.put("proxy", null);
System.out.println("get(\"proxy\") = " + config.get("proxy"));
System.out.println("get(\"region\") = " + config.get("region"));
System.out.println("containsKey proxy = " + config.containsKey("proxy"));
System.out.println("containsKey region = " + config.containsKey("region"));
System.out.println("size = " + config.size());
System.out.println("getOrDefault region = " + config.getOrDefault("region", "us-east"));
System.out.println("getOrDefault proxy = " + config.getOrDefault("proxy", "us-east"));
}
}
get("proxy") = null
get("region") = null
containsKey proxy = true
containsKey region = false
size = 2
getOrDefault region = us-east
getOrDefault proxy = null
The two get calls return the same thing and mean opposite things: "proxy" is present with a null value, "region" is absent. Only containsKey distinguishes them.
getOrDefault does not resolve the ambiguity either — look at the last line. It substitutes the default only when the key is absent, and a key mapped to null is present, so it returns null. That is the documented behaviour and it is the right one, but it means getOrDefault is a convenience for maps that never store null, not a null-safety mechanism.
⚠️ The simplest fix is to never put
nullin a map. Thennullfromgethas exactly one meaning,getOrDefaultbehaves the way you expect, and you never needcontainsKeyfollowed byget— which is two hash lookups where one would do.
putIfAbsent, merge and computeIfAbsent
The read-modify-write cycle — fetch the current value, adjust it, put it back — is the most common thing anyone does with a map, and Map has three methods that collapse it into one call.
putIfAbsent(k, v) stores v only when the key has no value, and returns whatever was already there:
Map<String, Integer> m = new HashMap<>();
System.out.println(m.putIfAbsent("a", 1)); // null - nothing was there, 1 is stored
System.out.println(m.putIfAbsent("a", 99)); // 1 - already present, 99 discarded
System.out.println(m.get("a")); // 1
null
1
1
Note the exact wording: no value, not no key. A key explicitly mapped to null counts as absent here, so putIfAbsent overwrites it. That is the null ambiguity from the previous section showing up in a second place.
Counting words with merge
Counting is the textbook read-modify-write. Written by hand it needs a null check on the first sighting of every word:
import java.util.HashMap;
import java.util.Map;
public class WordCount {
public static void main(String[] args) {
String text = "the quick brown fox jumps over the lazy dog the fox";
Map<String, Integer> counts = new HashMap<>();
for (String word : text.split(" ")) {
counts.put(word, counts.getOrDefault(word, 0) + 1);
}
System.out.println(counts);
System.out.println("distinct words = " + counts.size());
System.out.println("the = " + counts.get("the"));
System.out.println("cat = " + counts.get("cat"));
}
}
{the=3, over=1, quick=1, lazy=1, jumps=1, brown=1, dog=1, fox=2}
distinct words = 8
the = 3
cat = null
merge(key, value, remappingFunction) says the same thing in one call: store value if the key is absent, otherwise replace the current value with f(current, value).
for (String word : text.split(" ")) {
counts.merge(word, 1, Integer::sum);
}
{over=1, the=3, quick=1, lazy=1, jumps=1, brown=1, dog=1, fox=2}
Same eight keys, same eight counts — and a different printed order. counts.equals(other) is true for the two maps while counts.toString().equals(other.toString()) is false. Two calls that mean the same thing to you can leave the internals in a different state, and the order you see is a consequence of that state. Do not read anything into it; the ordering section below returns to this.
One more property of merge: if the remapping function returns null, the entry is removed. That makes decrement-to-zero a one-liner:
Map<String, Integer> stock = new HashMap<>();
stock.put("bolt", 3);
stock.merge("bolt", -3, (old, delta) -> old + delta == 0 ? null : old + delta);
System.out.println(stock + " containsKey=" + stock.containsKey("bolt"));
{} containsKey=false
Grouping with computeIfAbsent
Grouping needs a container per key, and the naive version creates that container before knowing whether it is needed. computeIfAbsent(key, f) calls f only when the key has no value, stores the result, and returns the value either way — so the result is always something you can immediately call a method on:
import java.util.*;
public class Grouping {
public static void main(String[] args) {
String[] names = {"Anh", "Binh", "Alice", "Chi", "Bob", "An"};
Map<Character, List<String>> byLetter = new HashMap<>();
for (String name : names) {
byLetter.computeIfAbsent(name.charAt(0), k -> new ArrayList<>()).add(name);
}
System.out.println(byLetter);
}
}
{A=[Anh, Alice, An], B=[Binh, Bob], C=[Chi]}
The lambda runs once per distinct letter, not once per name — if the key is already there, computeIfAbsent returns the existing list without evaluating the lambda at all. Verify that by throwing from inside it:
Map<String, List<String>> g = new HashMap<>();
g.put("a", new ArrayList<>(List.of("kept")));
g.computeIfAbsent("a", k -> { throw new IllegalStateException("not called"); }).add("added");
System.out.println(g);
{a=[kept, added]}
How a HashMap decides which bucket a key goes in
Internally a HashMap is an array — the table — whose slots are called buckets. Placing a key takes three steps: call hashCode() on it, mix the high bits of that value down into the low bits, then mask off enough low bits to get an index into the table.
static int bucketOf(String key, int tableLength) {
int h = key.hashCode();
return (tableLength - 1) & (h ^ (h >>> 16));
}
The mask works because the table length is always a power of two, so length - 1 is a run of 1 bits and the & is a cheap modulo. The h ^ (h >>> 16) step exists because that mask throws away every high bit: without the mix, two keys differing only above bit 16 would collide every time. Run it on the eight words from the counting example:
| Key | hashCode() | h ^ (h >>> 16) | Bucket in a table of 16 |
|---|---|---|---|
"the" | 114801 | 114800 | 0 |
"over" | 3423444 | 3423456 | 0 |
"quick" | 107947501 | 107946882 | 2 |
"lazy" | 3314548 | 3314502 | 6 |
"jumps" | 101487109 | 101487625 | 9 |
"brown" | 94011702 | 94012588 | 12 |
"dog" | 99644 | 99645 | 13 |
"fox" | 101583 | 101582 | 14 |

Eight keys, sixteen buckets, and they land in seven of them. Nine buckets stay empty and one — bucket 0 — holds a chain of two entries. That is normal and it is what "average constant time" means in practice: get("dog") computes one index and reads one slot, while get("over") computes one index and then walks a chain of two, comparing keys with equals() until one matches.
Two more facts follow from the table being an array:
The table grows. A HashMap starts with 16 buckets and a load factor of 0.75, so it resizes when the thirteenth entry arrives — doubling the table and recomputing every index. Adding one entry can therefore reorder the whole map:
Map<Integer, Integer> m = new HashMap<>();
for (int i = 1; i <= 12; i++) m.put(i * 7, i);
System.out.println("12 entries: " + m.keySet());
m.put(91, 13);
System.out.println("13 entries: " + m.keySet());
12 entries: [49, 35, 84, 21, 70, 7, 56, 42, 28, 77, 14, 63]
13 entries: [35, 70, 7, 42, 77, 14, 49, 84, 21, 56, 91, 28, 63]
You can pre-size it. new HashMap<>(64) allocates a bigger table up front. That is worth doing when you already know roughly how many entries are coming and want to avoid the rehashing that each resize costs; it is not worth doing speculatively.
The hashCode and equals contract
Everything above rests on one rule, stated in the documentation of Object.hashCode():
If two objects are equal according to
equals(Object), then callinghashCode()on each of them must produce the same integer result.
The implication runs in one direction only. Equal objects must share a hash code; unequal objects may share one, and with only 2³² possible hash codes they sometimes have to.

The reason the rule exists is visible in the diagram. A lookup uses hashCode() to pick a bucket and equals() only to compare against what is inside that bucket. If two equal keys produce different hash codes, the lookup never reaches the bucket where the entry actually lives, and equals() is never given the chance to say yes.
equals without hashCode: the entry that vanishes
BadPoint overrides equals correctly and leaves hashCode at the version inherited from Object, which is based on object identity:
import java.util.*;
class BadPoint {
final int x, y;
BadPoint(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof BadPoint)) return false;
BadPoint p = (BadPoint) o;
return x == p.x && y == p.y;
}
@Override
public String toString() { return "(" + x + "," + y + ")"; }
}
public class Contract {
public static void main(String[] args) {
BadPoint a = new BadPoint(1, 2);
BadPoint b = new BadPoint(1, 2);
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("a.hashCode() = " + a.hashCode());
System.out.println("b.hashCode() = " + b.hashCode());
Map<BadPoint, String> map = new HashMap<>();
map.put(a, "origin-ish");
System.out.println("map.get(b) = " + map.get(b));
System.out.println("map.containsKey(b) = " + map.containsKey(b));
map.put(b, "again");
System.out.println("map.size() = " + map.size());
System.out.println("map = " + map);
}
}
a.equals(b) = true
a.hashCode() = 692404036
b.hashCode() = 1554874502
map.get(b) = null
map.containsKey(b) = false
map.size() = 2
map = {(1,2)=origin-ish, (1,2)=again}
The two hash-code numbers depend on the JVM run; the fact that they differ does not. Read the last two lines carefully. The map contains two entries whose keys report themselves as equal and print identically, and no get call with a freshly built (1,2) will ever return either of them. Nothing threw. Nothing warned. The data is simply unreachable.
Adding a hashCode derived from the same fields equals uses fixes it:
@Override
public int hashCode() {
return Objects.hash(x, y);
}
a.equals(b) = true
a.hashCode() = 994
b.hashCode() = 994
map.get(b) = origin-ish
map.size() = 1
map = {(1,2)=again}
The rule is mechanical: hashCode must be computed from exactly the fields equals compares — not fewer, and not more. Fewer means two objects that differ can never be told apart into different buckets, which is legal but slow. More means two equal objects can get different hash codes, which is the bug above.
A key mutated after it is stored
The contract is not a one-off check at put time. The map computed a bucket index from the key's hash code and stored the entry there; if the key's hash code changes afterwards, the entry stays where it was and the key no longer points at it.
import java.util.*;
class Tag {
String name;
Tag(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
return o instanceof Tag && name.equals(((Tag) o).name);
}
@Override
public int hashCode() { return name.hashCode(); }
@Override
public String toString() { return "Tag(" + name + ")"; }
}
public class MutableKey {
public static void main(String[] args) {
Tag t = new Tag("draft");
Map<Tag, String> m = new HashMap<>();
m.put(t, "v1");
System.out.println("before: get(t) = " + m.get(t));
t.name = "final";
System.out.println("after: get(t) = " + m.get(t));
System.out.println("after: get(new Tag(\"final\")) = " + m.get(new Tag("final")));
System.out.println("after: get(new Tag(\"draft\")) = " + m.get(new Tag("draft")));
System.out.println("after: remove(t) = " + m.remove(t));
System.out.println("after: size = " + m.size());
System.out.println("after: map = " + m);
}
}
before: get(t) = v1
after: get(t) = null
after: get(new Tag("final")) = null
after: get(new Tag("draft")) = null
after: remove(t) = null
after: size = 1
after: map = {Tag(final)=v1}
The entry is unreachable by any key. Tag("final") hashes to the new bucket, where nothing is stored. Tag("draft") hashes to the bucket where the entry does live, but the entry's key object now reports "final" and equals says no. remove fails for the same reason, so the entry cannot even be deleted — it can only be reached by iterating the map, which is why toString still prints it. In a long-lived map this is a memory leak.
The defence is a rule with no exceptions: map keys must be immutable, or at least never mutated in the fields that feed hashCode and equals. String, the boxed primitives, LocalDate and records with immutable components are all safe. A mutable class you wrote, or an ArrayList, is not.
What a constant hashCode costs
hashCode returning a constant is a legal implementation. Every object is then in one bucket, and every lookup degenerates into scanning that bucket with equals(). To measure it without a stopwatch, count the equals calls with a static counter on the key class:
import java.util.*;
class Sku {
static int equalsCalls = 0;
final String code;
Sku(String code) { this.code = code; }
@Override
public boolean equals(Object o) {
equalsCalls++;
return o instanceof Sku && code.equals(((Sku) o).code);
}
@Override
public int hashCode() { return code.hashCode(); } // or: return 42;
}
Ten thousand entries, then one get per entry:
hashCode() | Lookups | equals() calls |
|---|---|---|
code.hashCode() | 10,000 | 10,000 |
return 42; | 10,000 | 50,014,999 |
One equals call per lookup against roughly five thousand. Every key landed in the same bucket, so each lookup scans half that bucket on average — the map has become a linear search wearing a HashMap costume. Note that this is a correct map: every result it returns is right. Only the performance collapsed, which is why nothing in the type system or the test suite catches it.
Iterating a HashMap
A map is not iterable, so you iterate one of its three views. All three are live windows onto the same map, not copies.

import java.util.*;
public class Iterate {
public static void main(String[] args) {
Map<String, Integer> stock = new HashMap<>();
stock.put("bolt", 120);
stock.put("nut", 340);
stock.put("washer", 85);
for (Map.Entry<String, Integer> e : stock.entrySet()) {
System.out.println(e.getKey() + " -> " + e.getValue());
}
stock.forEach((key, value) -> System.out.println(key + " = " + value));
}
}
washer -> 85
bolt -> 120
nut -> 340
washer = 85
bolt = 120
nut = 340
forEach takes a BiConsumer and is the shortest form when you only need to read. entrySet is the one to reach for when you need an index-free loop with break, continue or a return in it, which a lambda cannot do.
Why entrySet is the right view
The tempting loop is over keySet(), calling get(key) inside it. That works, and it re-hashes every key to find a value the iterator was already standing on. Counting the hashCode() calls with an instrumented key class on a map of 1,000 entries:
| Loop | hashCode() calls |
|---|---|
for (K k : map.keySet()) sum += map.get(k); | 1,000 |
for (Entry<K, V> e : map.entrySet()) sum += e.getValue(); | 0 |
for (V v : map.values()) sum += v; | 0 |
The entries are already there — the iterator walks the table and hands you each node, key and value together. Going back through get throws that away and pays for a second lookup per element.
Pick the view by what you need: values() when the keys are irrelevant (and note it is a Collection, not a Set, because values may repeat), keySet() when the values are irrelevant, entrySet() whenever you need both. The entry itself is writable through setValue, which updates the map in place without a second lookup:
for (Map.Entry<String, Integer> e : stock.entrySet()) {
if (e.getValue() < 100) {
e.setValue(e.getValue() * 10);
}
}
System.out.println(stock);
{washer=850, bolt=120, nut=340}
Removing entries while iterating
Structurally modifying a map during a for-each over any of its views fails fast:
for (String key : stock.keySet()) {
if (stock.get(key) < 100) {
stock.remove(key);
}
}
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1605)
at java.base/java.util.HashMap$KeyIterator.next(HashMap.java:1628)
at RemoveWhileIterating.main(RemoveWhileIterating.java:10)
The iterator records the map's modification count when it is created and rechecks it on every next(). Note the exception name: nothing is concurrent here, one thread did all of it. Two ways to do it correctly:
Iterator<Map.Entry<String, Integer>> it = stock.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue() < 100) {
it.remove();
}
}
stock.entrySet().removeIf(e -> e.getValue() < 100);
Both go through the iterator, which updates the modification count as it removes. removeIf is the one to write; the explicit iterator is for cases where the condition needs more than one expression.
Because the views are live, removing from one removes from the map. stock.keySet().remove("bolt") deletes the whole entry. Adding, on the other hand, is impossible — keySet().add("x") throws UnsupportedOperationException, since there would be no value to store.
HashMap guarantees no ordering
HashMap makes no promise whatsoever about iteration order, and the order it does produce is neither insertion order nor sorted order. It falls out of which bucket each key hashed to and how large the table currently is.
It is also, on a given JDK with a given set of keys, completely deterministic — which is exactly what makes it dangerous. The word-count map prints the same eight pairs in the same order every run on OpenJDK 21, so a test asserting on toString() passes today and breaks the day a key is added, the map is built with merge instead of put, or the JDK changes. Both of those first two happened earlier in this article:
built with put/getOrDefault: {the=3, over=1, quick=1, lazy=1, jumps=1, brown=1, dog=1, fox=2}
built with merge: {over=1, the=3, quick=1, lazy=1, jumps=1, brown=1, dog=1, fox=2}
Two maps that are equals() to each other, printing in different orders, because merge links a brand-new entry into its bucket at a different position than put does. That is an implementation detail which is free to change in any release. Never assert on it, never rely on it, and never let a user-visible list come out of a raw HashMap.
When order matters, say so in the type. LinkedHashMap keeps the entries in insertion order by maintaining a linked list alongside the table. TreeMap keeps them sorted by key, using compareTo or a Comparator instead of hashing. Both are drop-in for Map, so switching costs one word at the declaration.
Common mistakes with HashMap
Overriding equals and not hashCode. The single most expensive mistake in this article: the entry is stored, counted and printed, and can never be found. Generate both together from the IDE, or use a record, which writes both for you from the components.
Using a mutable object as a key and then mutating it. The entry becomes unreachable by every key, including the one you still hold a reference to, and cannot even be removed.
Treating get returning null as "not present". It also means "present, mapped to null". Use containsKey when the difference matters, or keep null out of the map entirely.
containsKey followed by get. Two hash lookups where one suffices. getOrDefault, merge or computeIfAbsent usually replaces the pair.
Calling containsValue in a loop. It is a full scan. If you need lookups by value, you need a second map.
Relying on the printed order. It is stable enough to write a passing test against and unstable enough to break that test later.
Sharing one HashMap across threads. It is not thread-safe, and a concurrent resize can corrupt it in ways that do not throw. Use ConcurrentHashMap when more than one thread writes.
Boxing in a hot loop. Map<String, Integer> boxes every count. It is usually irrelevant, and it is worth knowing about when the map holds millions of numeric values.
FAQ
What is the difference between a Map and a Collection in Java?
A Collection stores single elements and extends Iterable, so you can loop over it directly. A Map stores key/value pairs, does not extend Collection or Iterable, and cannot go into a for-each loop — for (String s : myMap) is a compile error. Instead it exposes three views that are collections: keySet(), values() and entrySet(). Uniqueness is enforced on the key only; values may repeat freely.
Why does my HashMap get return null even though I just put that key in?
Almost always because the key class overrides equals without overriding hashCode. The two key objects are equal but report different hash codes, so get looks in a bucket the entry was never stored in. map.size() will show the extra entry and System.out.println(map) will show both keys printing identically. The other cause is a mutable key that was changed after it was stored. Both are fixed by making keys immutable and deriving hashCode from the same fields equals uses.
What is the difference between put and putIfAbsent?
put always stores the value and returns the previous one. putIfAbsent stores the value only when the key currently has no value, and returns whatever was already there — null if it stored yours. Watch the wording: a key explicitly mapped to null counts as absent, so putIfAbsent will overwrite it.
When should I use merge instead of computeIfAbsent?
Use merge when you are combining a new value with an existing one — counting, summing, appending a string. Use computeIfAbsent when you need a container created on first use and then mutated, which is the grouping idiom map.computeIfAbsent(k, x -> new ArrayList<>()).add(item). A rule of thumb: merge replaces the value, computeIfAbsent returns something you then call a method on. merge also removes the entry if its function returns null.
Why should I iterate a HashMap with entrySet instead of keySet?
Because keySet() gives you the key alone, so you have to call get(key) to reach the value, and that is a second hash lookup per element. On a map of 1,000 entries with an instrumented key class, the keySet() plus get() loop made 1,000 hashCode() calls and the entrySet() loop made zero. The entry the iterator hands you already carries both, and Entry.setValue even lets you update the map in place.
Can a HashMap have null keys or null values?
Yes to both — one null key and any number of null values. TreeMap rejects a null key because it has to call compareTo on it, and the immutable maps from Map.of reject null keys and values with a NullPointerException. Storing null in a HashMap is legal but it makes get ambiguous, so it is usually a design smell rather than a feature.
Is HashMap ordered?
No. HashMap documents no ordering guarantee at all, and the order you observe comes from bucket indices and the current table size. It is deterministic for a given key set on a given JDK, which tempts people into depending on it, but adding one entry can trigger a resize that reorders everything. Use LinkedHashMap for insertion order or TreeMap for key order.
Is HashMap thread-safe?
No. Concurrent writes can corrupt the internal table without throwing anything, and a read can then see a broken structure. Collections.synchronizedMap wraps every method in a lock, which is correct but serialises all access; ConcurrentHashMap is the right choice for a map that several threads write to.
Conclusion
The HashMap API is small, and most of it is one idea applied consistently: put, get, containsKey and remove all cost one hash and one short walk, and getOrDefault, putIfAbsent, merge and computeIfAbsent exist so the read-modify-write cycle is one lookup instead of two. Iterate with entrySet because the iterator already holds what get would go and fetch again — zero extra hashCode() calls against 1,000 on a 1,000-entry map.
The part that actually bites is the contract underneath. hashCode picks the bucket, equals searches inside it, and the two must be computed from the same fields or a stored entry becomes unreachable: size() says 2, toString() prints both keys identically, and get returns null forever. The same failure arrives by a different route when a key is mutated after it is stored. Keys should be immutable, and hashCode and equals should always be written — or generated — together. A bad-but-legal hashCode does not corrupt anything; it just turned 10,000 equals calls into 50,014,999.
Next in this series: reading and writing text files — opening a file for reading and writing, what happens when the path does not exist, and why the resource has to be closed.