A Java HashMap finds keys with hashCode and equals, so a broken contract or a key changed after put makes entries vanish. Learn the rules, why TreeSet uses compareTo instead, and which collection to pick.
A HashMap or HashSet trusts two methods on every key: hashCode to decide where to look, and equals to decide what counts as a match. Get either one wrong, or change a key after you’ve stored it, and the collection quietly stops finding things that are still inside it. No exception, no warning, just null.
This post covers the equals and hashCode contracts, how a HashMap finds a key, why a changed key gets lost, why TreeSet uses compareTo instead, and how to choose a list, deque, map or immutable collection. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
The equals contract, and a symmetry bug
An equals method has to behave like real equality, or collections give answers that depend on which side asked. The part on values and references covers == versus equals, and the part on records shows the equals that records write for you. This section is about writing one by hand.
Here’s a wrapper that tries to be helpful. It compares case-insensitively with other wrappers, and with plain strings too:
final class CaseInsensitive {
private final String key;
CaseInsensitive(String text) {
this.key = text.toLowerCase(Locale.ROOT);
}
@Override
public boolean equals(Object other) {
if (other instanceof CaseInsensitive c) {
return key.equals(c.key);
}
if (other instanceof String s) {
return key.equals(s.toLowerCase(Locale.ROOT));
}
return false;
}
@Override
public int hashCode() {
return key.hashCode();
}
}
void main() {
var name = new CaseInsensitive("Ana");
IO.println("name.equals(\"ana\"): " + name.equals("ana"));
IO.println("\"ana\".equals(name): " + "ana".equals(name));
var wrappers = new ArrayList<Object>();
wrappers.add(name);
IO.println("wrappers contains \"ana\": " + wrappers.contains("ana"));
var strings = new ArrayList<Object>();
strings.add("ana");
IO.println("strings contains name: " + strings.contains(name));
}
It prints:
name.equals("ana"): true
"ana".equals(name): false
wrappers contains "ana": false
strings contains name: true
CaseInsensitive knows about String, but String has never heard of CaseInsensitive. So a.equals(b) and b.equals(a) disagree. ArrayList.contains(x) calls x.equals(element), which means the answer flips depending on which object is in the list and which one you’re searching for.
That’s a broken symmetry rule. The equals contract in Object‘s documentation has five rules, for any non-null x, y and z:
- Reflexive:
x.equals(x)is true. - Symmetric:
x.equals(y)is true exactly wheny.equals(x)is true. - Transitive: if
x.equals(y)andy.equals(z), thenx.equals(z). - Consistent: calling it again gives the same answer, as long as neither object changed.
- Null:
x.equals(null)is false, not an exception.
The fix is to compare only with your own type. A final class matters here: if a subclass could add fields and its own equals, the same symmetry problem comes back between parent and child.
final class CaseInsensitive {
private final String key;
CaseInsensitive(String text) {
this.key = text.toLowerCase(Locale.ROOT);
}
@Override
public boolean equals(Object other) {
return other instanceof CaseInsensitive c && key.equals(c.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
}
void main() {
var a = new CaseInsensitive("Ana");
var b = new CaseInsensitive("ANA");
var c = new CaseInsensitive("ana");
IO.println("reflexive: " + a.equals(a));
IO.println("symmetric: " + (a.equals(b) == b.equals(a)));
IO.println("transitive: " + (a.equals(b) && b.equals(c) && a.equals(c)));
IO.println("null: " + a.equals(null));
IO.println("vs String: " + a.equals("ana") + " " + "ana".equals(a));
}
It prints:
reflexive: true
symmetric: true
transitive: true
null: false
vs String: false false
A wrapper and a string are never equal now, from either side. That’s less clever, and it’s correct. instanceof also handles null for free, because null instanceof CaseInsensitive is false.
Equal objects must have equal hash codes
The hashCode contract has one rule that matters most: if a.equals(b) is true, a.hashCode() must equal b.hashCode(). Unequal objects may share a hash code. Equal ones may not differ.
The classic bug is overriding equals and forgetting hashCode. The build this series uses, javac -Xlint:all -Werror, catches it:
class Email {
private final String address;
Email(String address) {
this.address = address;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && address.equals(e.address);
}
}
void main() {
var subscribers = new HashSet<Email>();
subscribers.add(new Email("ana@example.com"));
IO.println(subscribers.contains(new Email("ana@example.com")));
}
The build fails with:
Main.java:1: warning: [overrides] Class Main.Email overrides equals, but neither it nor any superclass overrides hashCode method
error: warnings found and -Werror specified
This surprised us: the check exists, but it’s off by default. Plain javac Main.java and java Main.java both accept this file without a word. The overrides lint only runs when you ask for it with -Xlint. The message says Main.Email because a compact source file wraps its classes in a hidden Main class.
So here’s what happens when nobody asks. @SuppressWarnings("overrides") switches the check off, so you can see the bug run:
@SuppressWarnings("overrides")
class Email {
private final String address;
Email(String address) {
this.address = address;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && address.equals(e.address);
}
}
void main() {
var a = new Email("ana@example.com");
var b = new Email("ana@example.com");
IO.println("a.equals(b): " + a.equals(b));
IO.println("same hashCode: " + (a.hashCode() == b.hashCode()));
var subscribers = new HashSet<Email>();
subscribers.add(a);
IO.println("contains b: " + subscribers.contains(b));
subscribers.add(b);
IO.println("size: " + subscribers.size());
var list = new ArrayList<Email>();
list.add(a);
IO.println("list has b: " + list.contains(b));
}
It prints:
a.equals(b): true
same hashCode: false
contains b: false
size: 2
list has b: true
a and b are equal, but each still has Object‘s hash code, which is based on the object’s identity. The HashSet looks for b in the wrong place and says it isn’t there. Then it happily adds a “duplicate”, so a set holds two equal elements. The ArrayList never uses hash codes, so it finds b. That’s why this bug hides: code that uses lists works, and the same class breaks the moment it goes into a set or becomes a map key.
The fix is a hashCode built from the same fields equals compares. Objects.hash does that in one line:
class Email {
private final String user;
private final String domain;
Email(String user, String domain) {
this.user = user;
this.domain = domain;
}
@Override
public boolean equals(Object other) {
return other instanceof Email e && user.equals(e.user) && domain.equals(e.domain);
}
@Override
public int hashCode() {
return Objects.hash(user, domain);
}
}
void main() {
var a = new Email("ana", "example.com");
var b = new Email("ana", "example.com");
IO.println("same hashCode: " + (a.hashCode() == b.hashCode()));
var subscribers = new HashSet<Email>();
subscribers.add(a);
subscribers.add(b);
IO.println("contains b: " + subscribers.contains(b));
IO.println("size: " + subscribers.size());
}
It prints:
same hashCode: true
contains b: true
size: 1
Use every field that equals uses, and no field it ignores. A record does all of this for you from its components, which the part on records shows. If Email were record Email(String user, String domain) {}, there’d be nothing to forget.
How a HashMap finds a key
A HashMap keeps its entries in an array of buckets, and each key’s hash code decides which bucket it lives in. A lookup doesn’t search the whole map. It computes the key’s hash code, goes straight to one bucket, and checks only the entries in that bucket with equals.
Two keys can land in the same bucket, and they can even have the same hash code. equals sorts them out:
void main() {
IO.println("\"Aa\".hashCode() = " + "Aa".hashCode());
IO.println("\"BB\".hashCode() = " + "BB".hashCode());
var map = new HashMap<String, Integer>();
map.put("Aa", 1);
map.put("BB", 2);
IO.println("get Aa: " + map.get("Aa"));
IO.println("get BB: " + map.get("BB"));
IO.println("size: " + map.size());
}
It prints:
"Aa".hashCode() = 2112
"BB".hashCode() = 2112
get Aa: 1
get BB: 2
size: 2
"Aa" and "BB" collide exactly, so they share a bucket. The map still keeps them apart, because "Aa".equals("BB") is false. A collision costs a little time. It never costs correctness.
Explain it like I’m ten
A HashMap is a coat check. When you hand in your coat, the attendant reads the tag on it and picks a rail from the tag. That’s hashCode. The attendant hangs your coat on that rail, next to a few other coats.
When you come back, you show the same tag. The attendant walks to that one rail and checks the tickets on the coats hanging there, one by one, until one matches. That’s equals. Nobody searches the whole room.
Now suppose you sneak back and change the tag on your coat after handing it in. When you ask for it, the new tag sends the attendant to a different rail. Your coat isn’t there. It’s still in the room, on the old rail, but nobody will look for it there.
The precise version
A HashMap holds an array of buckets whose length is a power of two, 16 by default once the first entry goes in. To pick a bucket, it takes the key’s hashCode(), mixes the high bits into the low ones with h ^ (h >>> 16), and keeps the low bits with (n - 1) & hash, where n is the number of buckets. Each entry stores the hash it was put with.
get(key) recomputes the hash, goes to that bucket, and for each entry checks that the stored hash is equal and that the keys are == or equals. The first entry that passes is the answer. When the map passes 75% full, the array doubles and entries are spread across the new buckets. If one bucket collects too many entries (the JDK’s threshold constant is 8) and the table has at least 64 buckets, that bucket becomes a small tree, so even a bad hash function doesn’t make lookups walk a long chain.
Where the analogy breaks: a real attendant would notice the new tag and search. HashMap never re-reads a key after put. It trusts the hash it stored, so a changed key isn’t moved, flagged or rehashed. Also, the rail isn’t picked from the whole tag. Only the low bits of the mixed hash choose the bucket, which is why two very different hash codes can share one.
Changing a key after put loses the entry
A key that changes after you store it is the most common way a HashMap “loses” data. The entry is still in the map. You just can’t reach it any more.
class Coat {
String tag;
Coat(String tag) {
this.tag = tag;
}
@Override
public boolean equals(Object other) {
return other instanceof Coat c && tag.equals(c.tag);
}
@Override
public int hashCode() {
return tag.hashCode();
}
}
void main() {
var owners = new HashMap<Coat, String>();
var coat = new Coat("blue-17");
owners.put(coat, "Ana");
IO.println("before: " + owners.get(coat));
coat.tag = "red-42";
IO.println("after, same object: " + owners.get(coat));
IO.println("after, old tag: " + owners.get(new Coat("blue-17")));
IO.println("containsKey: " + owners.containsKey(coat));
IO.println("remove: " + owners.remove(coat));
IO.println("size: " + owners.size());
IO.println("values: " + owners.values());
coat.tag = "blue-17";
IO.println("tag put back: " + owners.get(coat));
}
It prints:
before: Ana
after, same object: null
after, old tag: null
containsKey: false
remove: null
size: 1
values: [Ana]
tag put back: Ana
Coat has a correct equals and hashCode. The bug is that both depend on tag, and tag changed while the coat was a key. Read the results in pairs:
- The same object, after the change: its new hash sends
getto a different bucket, so it finds nothing.containsKeyandremovefail the same way. - A fresh
Coat("blue-17"): its hash matches the stored one, sogetreaches the right bucket. Thenequalscompares it with the stored key, whose tag now saysred-42, and they don’t match. sizeandvalues: Ana is still in there. Iterating the map finds her. Only lookups can’t.
Putting the old tag back makes the entry reachable again, which proves nothing was deleted. In real code nobody remembers the old value, so the entry stays stuck, and a long-lived map leaks memory this way.
The fix is to use keys that can’t change: records with immutable components, String, Integer, or classes with final fields. If a key really must change, remove it first, change it, then put it back.
Watching a lookup, then a lost key
The animation is a simplified picture with made-up numbers: 8 buckets, and invented hash codes. It shows owners.get(coat) finding Ana, then the same call after the tag changes:
A simplified HashMap with 8 buckets and made-up hash codes. The key’s hash picks bucket 3, equals rejects the first entry and accepts the second, and get returns Ana. After the tag changes, the new hash picks empty bucket 6, so get returns null while the entry still sits in bucket 3.
Here are those steps in words, in case the animation doesn’t play for you:
owners.get(coat)starts with a coat whose tag isblue-17.getcallscoat.hashCode(). In this picture that gives 1283, and 1283 picks bucket 3 of 8.- Bucket 3 holds two entries.
getcallsequalson the first one’s key,pear-05, and it’s false, so it moves on. equalson the second entry’s key,blue-17, is true. That’s the entry.getreturns the value stored with it,"Ana".- Now the coat’s tag changes to
red-42. The entry doesn’t move: it stays in bucket 3 with its stored hash of 1283. The nextget(coat)computes a new hash, 5078, which picks bucket 6. Bucket 6 is empty, sogetreturnsnull.
TreeSet and TreeMap use compareTo, not equals
A TreeSet or TreeMap keeps its keys sorted, and it decides whether two keys are the same by comparing them, not by calling equals. If compare returns 0, the tree treats the two as one key. A comparator that returns 0 for things that aren’t equal makes the set drop them:
void main() {
var byLength = new TreeSet<String>(Comparator.comparingInt(String::length));
for (var fruit : List.of("fig", "kiwi", "pear", "plum", "apple")) {
IO.println("add " + fruit + ": " + byLength.add(fruit));
}
IO.println(byLength);
IO.println("contains pear: " + byLength.contains("pear"));
IO.println("contains lime: " + byLength.contains("lime"));
var prices = List.of(new BigDecimal("1.0"), new BigDecimal("1.00"));
IO.println("equals: " + prices.get(0).equals(prices.get(1)));
IO.println("compareTo: " + prices.get(0).compareTo(prices.get(1)));
IO.println("HashSet size: " + new HashSet<>(prices).size());
IO.println("TreeSet size: " + new TreeSet<>(prices).size());
}
It prints:
add fig: true
add kiwi: true
add pear: false
add plum: false
add apple: true
[fig, kiwi, apple]
contains pear: true
contains lime: true
equals: false
compareTo: 0
HashSet size: 2
TreeSet size: 1
pear and plum have four letters, like kiwi, so the set refused them. Worse, contains("lime") says true for a word that was never added. Four letters is enough.
BigDecimal shows the same thing in the JDK itself. 1.0 and 1.00 have different scales, so equals says they differ and a HashSet keeps both. Their compareTo is 0, so a TreeSet keeps one. When a class’s natural order agrees with equals, the documentation calls it consistent with equals, and that’s what you want for keys.
A class gets a natural order by implementing Comparable. A Comparator gives an order from outside the class. Comparator.comparing(...).thenComparing(...) builds one field by field, and the tie-breakers are what keep it consistent with equals:
record Version(int major, int minor) implements Comparable<Version> {
private static final Comparator<Version> ORDER =
Comparator.comparingInt(Version::major).thenComparingInt(Version::minor);
@Override
public int compareTo(Version other) {
return ORDER.compare(this, other);
}
}
record Person(String last, String first, int age) {}
void main() {
var versions = new TreeSet<>(
List.of(new Version(21, 0), new Version(8, 2), new Version(17, 1)));
IO.println(versions);
var people = new ArrayList<>(List.of(
new Person("Silva", "Rui", 41),
new Person("Okafor", "Ada", 29),
new Person("Silva", "Ana", 35),
new Person("Okafor", "Ada", 52)));
people.sort(Comparator.comparing(Person::last)
.thenComparing(Person::first)
.thenComparing(Comparator.comparingInt(Person::age).reversed()));
people.forEach(IO::println);
}
It prints:
[Version[major=8, minor=2], Version[major=17, minor=1], Version[major=21, minor=0]]
Person[last=Okafor, first=Ada, age=52]
Person[last=Okafor, first=Ada, age=29]
Person[last=Silva, first=Ana, age=35]
Person[last=Silva, first=Rui, age=41]
Version 8.2 sorts before 17.1 because the comparison is numeric. As strings, "17" would come first. people is sorted by last name, then first name, then oldest first. The two Ada Okafors only differ by age, and the last thenComparing puts them in order. Without it, a TreeSet using this comparator would keep only one of them.
Choosing a list, a stack or a queue
ArrayList is the right list almost every time, and the reason is how the two lists store their elements. An ArrayList keeps references in one array. get(i) goes straight to slot i. Adding at the end writes the next free slot, and now and then copies everything into a bigger array.
A LinkedList wraps every element in its own node object, with links to its neighbours. get(i) has to walk from one end, node by node, and every element costs an extra object. Inserting in the middle is cheap only once an iterator has already walked there, which rarely makes up for the rest.
For a stack or a queue, use ArrayDeque. The old Stack class extends Vector, locks on every call, and its own documentation recommends a Deque instead.
void main() {
var stack = new ArrayDeque<String>();
stack.push("open file");
stack.push("read line");
stack.push("parse number");
IO.println("stack pop: " + stack.pop());
IO.println("stack peek: " + stack.peek());
var queue = new ArrayDeque<String>();
queue.offer("Ana");
queue.offer("Ben");
queue.offer("Cy");
IO.println("queue poll: " + queue.poll());
IO.println("queue now: " + queue);
IO.println("empty poll: " + new ArrayDeque<String>().poll());
}
It prints:
stack pop: parse number
stack peek: read line
queue poll: Ana
queue now: [Ben, Cy]
empty poll: null
push and pop work at the front, so the last thing pushed comes out first. offer adds at the back and poll takes from the front, so the queue is first in, first out. poll on an empty deque returns null instead of throwing. ArrayDeque doesn’t accept null elements, so a null from poll always means the deque was empty.
Choosing a map
The three general-purpose maps differ in one thing you can see: the order you get back when you iterate. Pick by what you need:
| You need | Use | Iteration order |
|---|---|---|
| Fast lookup, and order doesn’t matter | HashMap |
None you should rely on |
| Fast lookup, in the order keys were added | LinkedHashMap |
Insertion order |
| Sorted keys, or ranges such as “all keys before Lagos” | TreeMap |
Sorted by compareTo or a comparator |
HashMap and LinkedHashMap look keys up in constant time on average, assuming decent hash codes. TreeMap guarantees log(n) time. The same three choices exist for sets: HashSet, LinkedHashSet and TreeSet.
void main() {
var cities = List.of("Porto", "Lagos", "Delhi", "Accra");
var inserted = new LinkedHashMap<String, Integer>();
var sorted = new TreeMap<String, Integer>();
for (var i = 0; i < cities.size(); i++) {
inserted.put(cities.get(i), i + 1);
sorted.put(cities.get(i), i + 1);
}
IO.println("LinkedHashMap: " + inserted);
IO.println("TreeMap: " + sorted);
IO.println("first key: " + sorted.firstKey());
IO.println("before Lagos: " + sorted.headMap("Lagos"));
}
It prints:
LinkedHashMap: {Porto=1, Lagos=2, Delhi=3, Accra=4}
TreeMap: {Accra=4, Delhi=3, Lagos=2, Porto=1}
first key: Accra
before Lagos: {Accra=4, Delhi=3}
No HashMap is printed here on purpose. Its order comes from the buckets, so it can change when the map grows or when you run a different JDK. If output order matters, choose one of the other two. When the keys are an enum, EnumMap is the best choice: it stores values in an array indexed by the enum’s ordinal, and it iterates in declaration order.
Immutable collections and unmodifiable views
List.of, Set.of and Map.of create collections that can’t change at all, and List.copyOf makes an unchangeable copy of an existing one. Collections.unmodifiableList is different. It’s a read-only window onto a list that can still change underneath:
void main() {
var names = new ArrayList<String>(List.of("Ana", "Ben"));
List<String> view = Collections.unmodifiableList(names);
List<String> copy = List.copyOf(names);
names.add("Cy");
IO.println("names: " + names);
IO.println("view: " + view);
IO.println("copy: " + copy);
try {
view.add("Dee");
} catch (UnsupportedOperationException e) {
IO.println("view.add threw " + e.getClass().getSimpleName());
}
try {
Map.of("Ana", 31, "Ben", null);
} catch (NullPointerException e) {
IO.println("Map.of with a null value threw " + e.getClass().getSimpleName());
}
}
It prints:
names: [Ana, Ben, Cy]
view: [Ana, Ben, Cy]
copy: [Ana, Ben]
view.add threw UnsupportedOperationException
Map.of with a null value threw NullPointerException
Cy was added to names, and the view shows it, because the view has no elements of its own. The copy was taken before, so it doesn’t. You can’t change a list through the view, but anyone holding names can. Hand out List.copyOf when you want a caller to see a fixed list.
The of and copyOf methods reject null anywhere. Map.of also rejects the same key twice, which catches a copy-paste mistake at the moment the map is built:
void main() {
var ok = Map.of("Ana", 31, "Ben", 27);
IO.println("size: " + ok.size());
IO.println("Ana: " + ok.get("Ana"));
var typo = Map.of("Ana", 31, "Ben", 27, "Ana", 40);
IO.println(typo.size());
}
It prints, then stops:
size: 2
Ana: 31
Exception in thread "main" java.lang.IllegalArgumentException: duplicate key: Ana
A HashMap would have kept the second value without complaint. Set.of throws the same way for a duplicate element.
Removing elements while you iterate
A for-each loop over an ArrayList uses an iterator, and that iterator fails if the list changes behind its back. Remove an element inside the loop and the next step throws:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Dee"));
for (var name : names) {
IO.println("checking " + name);
if (name.startsWith("B")) {
names.remove(name);
}
}
IO.println(names);
}
It prints, then stops:
checking Ana
checking Ben
Exception in thread "main" java.util.ConcurrentModificationException
The name is misleading: there’s only one thread. “Concurrent” means the list was changed while an iterator was in the middle of walking it.
The exception isn’t guaranteed, and that’s the worse case. Here’s the same loop on a three-element list:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy"));
for (var name : names) {
IO.println("checking " + name);
if (name.startsWith("B")) {
names.remove(name);
}
}
IO.println(names);
}
It prints:
checking Ana
checking Ben
[Ana, Cy]
No exception, and Cy was never checked. Removing Ben shrank the list to two, the iterator had already handed out two elements, so it decided it was finished before checking for changes. If Cy had started with “B”, it would still be in the list. The documentation calls this check “best-effort”, and this is what that means.
Two correct ways exist. removeIf does the whole job in one call. An explicit Iterator lets you remove the element you’re on with it.remove(), which the iterator knows about:
void main() {
var names = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Bea", "Dee"));
names.removeIf(name -> name.startsWith("B"));
IO.println("removeIf: " + names);
var guests = new ArrayList<>(List.of("Ana", "Ben", "Cy", "Bea", "Dee"));
var it = guests.iterator();
while (it.hasNext()) {
var name = it.next();
if (name.startsWith("B")) {
it.remove();
IO.println("removed " + name);
}
}
IO.println("iterator: " + guests);
}
It prints:
removeIf: [Ana, Cy, Dee]
removed Ben
removed Bea
iterator: [Ana, Cy, Dee]
Use removeIf unless you need to do something with each removed element, as the iterator loop does here.
getOrDefault, merge and computeIfAbsent
Three Map methods replace most of the “check, then put” code people used to write. getOrDefault returns a fallback for a missing key. merge combines a new value with an existing one. computeIfAbsent creates a value the first time a key appears.
void main() {
var text = "the quick fox saw the lazy dog and the dog saw the fox";
var counts = new TreeMap<String, Integer>();
for (var word : text.split(" ")) {
counts.merge(word, 1, Integer::sum);
}
IO.println(counts);
IO.println("the: " + counts.getOrDefault("the", 0));
IO.println("cat: " + counts.getOrDefault("cat", 0));
var byLength = new TreeMap<Integer, List<String>>();
for (var word : counts.keySet()) {
byLength.computeIfAbsent(word.length(), k -> new ArrayList<>()).add(word);
}
IO.println(byLength);
}
It prints:
{and=1, dog=2, fox=2, lazy=1, quick=1, saw=2, the=4}
the: 4
cat: 0
{3=[and, dog, fox, saw, the], 4=[lazy], 5=[quick]}
counts.merge(word, 1, Integer::sum) puts 1 for a new word, and adds 1 to a word already there. computeIfAbsent creates an empty list the first time it sees a length, returns the list in the map either way, and add puts the word into it. Both maps are TreeMaps, so the printed order is sorted and the same on every run.
What to remember
equalsmust be reflexive, symmetric, transitive, consistent, and false fornull. Compare only with your own type, or the answer depends on which side asks.- Equal objects must have equal hash codes. Override
hashCodewithequals, using the same fields, for example withObjects.hash.-Xlintwarns when you forget, but only if you turn it on. Records do both for you. - A
HashMapuseshashCodeto pick a bucket, thenequalsinside it. Never change a field thathashCodeuses while the object is a key, or the entry becomes unreachable. TreeSetandTreeMaptreatcompare(a, b) == 0as the same key. AddthenComparingtie-breakers so the order agrees withequals.- Use
ArrayListfor lists,ArrayDequefor stacks and queues, and chooseHashMap,LinkedHashMaporTreeMapby the iteration order you need. List.ofandList.copyOfcan’t change.Collections.unmodifiableListis a view that shows changes to the list behind it.- Don’t remove from a list inside a for-each loop. Use
removeIforIterator.remove.
A hash-based collection only works if a key’s
equalsandhashCodeagree and don’t change while it’s inside.