Collections in Java — practice with a word counter, custom keys and loan queues (and the HashMap that printed in an order I couldn’t understand)
My first word counter worked on the first try… or so I thought. I fed in the text, went through the words with a HashMap, printed the result and stared at the screen: the words came out jumbled. Not alphabetical, not in order of appearance, not by frequency. I figured I had mixed up the data by accident and spent half an hour checking the loop. The loop was fine. What I expected from a HashMap (an order) is exactly what a HashMap does not promise.
That’s the beauty of collections: each one promises different things, and most mistakes come from asking one for what another one promised. So today we practise with three programs where you’ll compare, break and fix.
If you’ve already read the collections theory, this article is for applying it: counting words with a Map and its three flavors, seeing what happens when your own objects are keys in a HashSet or a HashMap, and building loan queues and stacks with Deque and PriorityQueue. They are medium-difficulty programs. Each one comes with commented code, the output and the doubts that usually arise.
Table of Contents
Before you start
It helps to be clear on:
- That
Listallows duplicates and keeps order;Setdoesn’t allow duplicates;Mapassociates keys with values. - That
HashSetandHashMapguarantee no order at all;LinkedHash...keeps insertion order andTree...sorts. - That
HashSetandHashMapusehashCode()to choose the bucket andequals()to decide whether it’s already there. - That
merge(key, 1, Integer::sum)is used for counting.
If any of these sounds shaky, review the theory before going on.
The three programs are independent. Create a folder for each one and compile them separately.
Program 1: word counter with Map
Counting how many times each word of a text appears is the “hello world” of Maps. We’ll do it with all three flavors at once to see what changes.
The idea
A word is a key and its number of appearances is the value. For each word in the text: if it was already in the map, we add 1; if not, we add it with 1. This can be written two ways: with merge (short) or with getOrDefault (explicit), and we’ll check that they give the same result. We’ll also store everything at once in a HashMap, a TreeMap and a LinkedHashMap to compare how each one prints.
The text is a line from Hamlet in English (no accents or special letters, and in the public domain). You can swap it for any other, but keep the words separated by spaces and in lowercase.
Before running, think about the order each map will print in. Alphabetical? In order of appearance? Something else? Then we check.
The code
Words.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class Words {
public static void main(String[] args) {
String text = "to be or not to be that is the question whether it is nobler in the mind to suffer";
String[] words = text.split(" ");
Map<String, Integer> hash = new HashMap<>();
Map<String, Integer> tree = new TreeMap<>();
Map<String, Integer> linked = new LinkedHashMap<>();
Map<String, Integer> manual = new HashMap<>();
for (String p : words) {
hash.merge(p, 1, Integer::sum);
tree.merge(p, 1, Integer::sum);
linked.merge(p, 1, Integer::sum);
manual.put(p, manual.getOrDefault(p, 0) + 1); // the same count, without merge
}
System.out.println("Words in total: " + words.length);
System.out.println("Distinct words: " + hash.size());
System.out.println("HashMap: " + hash);
System.out.println("TreeMap: " + tree);
System.out.println("LinkedHashMap: " + linked);
System.out.println("Does getOrDefault give the same as merge? " + manual.equals(hash));
String mostRepeated = null;
int max = 0;
for (Map.Entry<String, Integer> e : tree.entrySet()) {
if (e.getValue() > max) {
max = e.getValue();
mostRepeated = e.getKey();
}
}
System.out.println("Most repeated: " + mostRepeated + " (" + max + " times)");
List<String> onlyOnce = new ArrayList<>();
for (Map.Entry<String, Integer> e : tree.entrySet()) {
if (e.getValue() == 1) {
onlyOnce.add(e.getKey());
}
}
System.out.println("Appear only once: " + onlyOnce);
}
}
Output
Words in total: 19
Distinct words: 14
HashMap: {mind=1, or=1, be=2, question=1, in=1, nobler=1, is=2, it=1, the=2, that=1, suffer=1, not=1, whether=1, to=3}
TreeMap: {be=2, in=1, is=2, it=1, mind=1, nobler=1, not=1, or=1, question=1, suffer=1, that=1, the=2, to=3, whether=1}
LinkedHashMap: {to=3, be=2, or=1, not=1, that=1, is=2, the=2, question=1, whether=1, it=1, nobler=1, in=1, mind=1, suffer=1}
Does getOrDefault give the same as merge? true
Most repeated: to (3 times)
Appear only once: [in, it, mind, nobler, not, or, question, suffer, that, whether]
Check it by hand:
- Total and distinct: the text has 19 words, but only 14 distinct ones. The map has 14 entries because each key appears only once.
- **
TreeMap:** alphabetical (be, in, is, it, mind…). It compares keys using their natural order. - **
LinkedHashMap: in order of first appearance** (to, be, or, not…). The repetition oftodoesn’t change its position. - **
HashMap: yet another order, neither alphabetical nor by appearance. It’s the one that results from the hash codes, and it’s the same every time you run this program with these** words, but Java doesn’t promise it. Don’t rely on it. - **
toappears 3 times**,be,isandthetwice, and the rest once. - **
manual.equals(hash):** two maps are equal if they have the same pairs, regardless of order or implementation. That’s whygetOrDefaultandmergegive the same result.
Integer::sum is a method reference: “the function that adds two integers”. merge uses it only if the key already existed, to combine the old value with the new 1; if it didn’t exist, it stores the 1 as is.
Questions you might ask
**Why if (e.getValue() > max) and not >=?** Because we traverse the TreeMap alphabetically: with > we keep the first word that reaches the maximum and, if there’s a tie, the alphabetically smallest wins. With >= the last one of the tie would win.
**Why do I traverse with entrySet() and not with keySet() and then get?** Because entrySet() gives you the key and the value at once, in a single pass. With keySet() you’d have to look up each value with get, which is repeated work.
Which of the three do I use? HashMap if you only want to count and look up; TreeMap if you’re going to show the words sorted afterwards; LinkedHashMap if you want to respect the order in which they appeared.
And if the text has capital letters or punctuation? Then The and the would be different keys. The usual approach is to convert the text to lowercase with toLowerCase() and split with a regular expression, for example split("\\W+"), which cuts at anything that isn’t a letter.
Doing map.put(w, map.get(w) + 1) without first checking whether the key exists. The first time get(w) returns null, and when you add 1 to it the program crashes with NullPointerException. That’s why getOrDefault and merge exist.
Try it yourself
- Swap the text for one of your own and check that the
TreeMapstill comes out alphabetical and theLinkedHashMapin order of first appearance. - Add a final line to the program that prints the words that appear more than once, in alphabetical order.
Program 2: your objects as keys
The program that explains why the set at the start of the theory claimed to have the same book twice. Here we put it through all its nuances.
The idea
We’re going to create five versions of “a book identified by its ISBN”. With the first four we’ll put two books with the same ISBN into a HashSet:
BareBook: withoutequals()orhashCode().EqualsBook: onlyequals().FullBook:equals()andhashCode().RecordBook: arecord, which generates them on its own.MutableBook: it honors the contract, but itstitlefield can be changed after it’s been stored.
For each one, a test method prints whether they’re equals, whether they have the same hashCode and how many elements the set ends up with. Then we use FullBook as a HashMap key to count copies, and finally we see what happens when the key changes after being stored.
Before running, write down what size you think each set will have. The second row (only equals) is the one that surprises the most.
The code
Keys.java
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class Keys {
static class BareBook {
final String isbn;
BareBook(String isbn) { this.isbn = isbn; }
}
static class EqualsBook {
final String isbn;
EqualsBook(String isbn) { this.isbn = isbn; }
@Override
public boolean equals(Object o) {
return o instanceof EqualsBook other && isbn.equals(other.isbn);
}
}
static class FullBook {
final String isbn;
FullBook(String isbn) { this.isbn = isbn; }
@Override
public boolean equals(Object o) {
return o instanceof FullBook other && isbn.equals(other.isbn);
}
@Override
public int hashCode() {
return Objects.hash(isbn);
}
@Override
public String toString() {
return "Book " + isbn;
}
}
record RecordBook(String isbn) { }
static class MutableBook {
private String title;
MutableBook(String title) { this.title = title; }
void setTitle(String title) { this.title = title; }
@Override
public boolean equals(Object o) {
return o instanceof MutableBook other && title.equals(other.title);
}
@Override
public int hashCode() {
return title.hashCode();
}
@Override
public String toString() {
return title;
}
}
static void test(String name, Object a, Object b) {
Set<Object> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(name + ": equals=" + a.equals(b)
+ ", same hashCode=" + (a.hashCode() == b.hashCode())
+ ", set size=" + set.size());
}
public static void main(String[] args) {
System.out.println("== Two objects with the same ISBN ==");
test("Nothing ", new BareBook("L-101"), new BareBook("L-101"));
test("Only equals ", new EqualsBook("L-101"), new EqualsBook("L-101"));
test("equals+hash ", new FullBook("L-101"), new FullBook("L-101"));
test("record ", new RecordBook("L-101"), new RecordBook("L-101"));
System.out.println("== As a HashMap key ==");
Map<FullBook, Integer> copies = new HashMap<>();
copies.merge(new FullBook("L-101"), 1, Integer::sum);
copies.merge(new FullBook("L-102"), 1, Integer::sum);
copies.merge(new FullBook("L-101"), 1, Integer::sum);
System.out.println("Copies: " + copies);
System.out.println("== A key that changes after being stored ==");
MutableBook book = new MutableBook("Dune");
Set<MutableBook> shelf = new HashSet<>();
shelf.add(book);
System.out.println("Before changing the title: contains=" + shelf.contains(book));
book.setTitle("Dune (2nd edition)");
System.out.println("After changing it: contains=" + shelf.contains(book)
+ ", size=" + shelf.size());
System.out.println("But traversing it, it's still there: " + shelf);
}
}
Output
== Two objects with the same ISBN ==
Nothing : equals=false, same hashCode=false, set size=2
Only equals : equals=true, same hashCode=false, set size=2
equals+hash : equals=true, same hashCode=true, set size=1
record : equals=true, same hashCode=true, set size=1
== As a HashMap key ==
Copies: {Book L-101=2, Book L-102=1}
== A key that changes after being stored ==
Before changing the title: contains=true
After changing it: contains=false, size=1
But traversing it, it's still there: [Dune (2nd edition)]
Check it by hand:
- Nothing: no
equalsand not the same hash. Two different objects as far as Java is concerned: size 2. - **Only
equals:**equalsgivestrue, but the hash is still different. TheHashSetlooks at the hash first to choose the bucket, so it never even finds out they’re equal: size 2. This is the most treacherous bug, because the code looks correct. - **
equals+hashCode:** the full contract. Size 1. - **
record:** the same as the previous one, without writing anything. It compares all the fields (here there’s only one). - **
HashMapwithFullBookkeys:** the twoL-101s are recognized as the same key andmergeadds up:L-101=2,L-102=1. - Key that changes: the book is still inside the set (
size=1, and it shows up when traversing), butcontainsgivesfalse. It’s a silent loss.
The hashCode of MutableBook depends on title. When it was stored, the set placed it in the bucket corresponding to the title "Dune". After changing the title, contains computes a new hash, looks in another bucket and finds nothing, even though the object is there.
Questions you might ask
**Why isn’t equals() enough?** Because HashSet and HashMap first use hashCode() to decide which bucket to look in and only compare with equals() the objects that are in that bucket. If two equal objects have different hashes, they are never compared.
**And if I always make hashCode() return 1?** It honors the contract, but every element would land in the same bucket and the HashSet would become slow, almost as slow as walking a list. A good hash spreads things out.
**Which fields do I use for equals() and hashCode()? The ones that identify** the object. For a book, the ISBN; for a person, maybe the ID number. And the same ones in both methods. Objects.hash(...) and Objects.equals(...) help you write them without worrying about nulls.
How do I avoid the changing-key problem? Use immutable keys: final fields and no setters. A record, a String or an Integer are good candidates precisely for that reason.
Overriding equals() and forgetting hashCode(). It compiles without warnings, passes the tests with a single object and fails when there are two “equal” ones in a HashSet or a HashMap.
Try it yourself
- Add to
FullBooka secondtitlefield that does not go intoequals()orhashCode(). Check that two books with the same ISBN and different titles are still the same. - Change
RecordBookso it has two fields (isbnandtitle) and check that now two books with the same ISBN and different titles are not equal. Why?
Program 3: loan queues and stacks
The library also has queues: of people waiting, of returned books and of reservations with priority. Three structures, three behaviors.
The idea
- A queue (FIFO, first in, first out): the first to arrive is the first to leave. It’s the line of members waiting to be served.
- A stack (LIFO, last in, first out): the last one in is the first one out. It’s the cart of returned books: the one you left on top is the one you pick up first.
- A priority queue: it doesn’t matter who arrived first, the most urgent one leaves first. To know what “most urgent” means,
ReservationimplementsComparable(you saw it in the Comparable theory).
We build both the queue and the stack with the same type, ArrayDeque, which is a double-ended queue. It uses one set of methods or another depending on whether you want it as a queue or as a stack.
push and pop act on the front of the structure; offer adds at the end and poll takes from the front. That’s why push+pop behave like a stack and offer+poll like a queue.
The code
QueueDemo.java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.NoSuchElementException;
import java.util.PriorityQueue;
public class QueueDemo {
record Reservation(String member, int priority) implements Comparable<Reservation> {
@Override
public int compareTo(Reservation other) {
return Integer.compare(priority, other.priority); // lower number = more urgent
}
}
public static void main(String[] args) {
// 1. Queue (FIFO): the first to arrive is the first to leave
Deque<String> waiting = new ArrayDeque<>();
waiting.offer("Ana");
waiting.offer("Luis");
waiting.offer("Marta");
System.out.println("Queue: " + waiting);
System.out.println("Who is first (peek): " + waiting.peek());
System.out.println("I serve (poll): " + waiting.poll());
System.out.println("Queue now: " + waiting);
// 2. Stack (LIFO): the last to arrive is the first to leave
Deque<String> returned = new ArrayDeque<>();
returned.push("Dune");
returned.push("Emma");
returned.push("Hamlet");
System.out.println("Stack: " + returned);
System.out.println("Top (peek): " + returned.peek());
System.out.println("I reshelve (pop): " + returned.pop());
System.out.println("Stack now: " + returned);
// 3. Priority queue: the "smallest" according to compareTo leaves first
PriorityQueue<Reservation> reservations = new PriorityQueue<>();
reservations.add(new Reservation("Pablo", 3));
reservations.add(new Reservation("Lucía", 1));
reservations.add(new Reservation("Irene", 2));
reservations.add(new Reservation("Mario", 5));
System.out.println("Printing it doesn't sort it: " + reservations);
System.out.println("Service order:");
while (!reservations.isEmpty()) {
System.out.println(" " + reservations.poll());
}
// 4. Empty queues and stacks
Deque<String> empty = new ArrayDeque<>();
System.out.println("poll() on an empty queue: " + empty.poll());
try {
empty.pop();
} catch (NoSuchElementException e) {
System.out.println("pop() on an empty stack: " + e.getClass().getSimpleName());
}
}
}
Output
Queue: [Ana, Luis, Marta] Who is first (peek): Ana I serve (poll): Ana Queue now: [Luis, Marta] Stack: [Hamlet, Emma, Dune] Top (peek): Hamlet I reshelve (pop): Hamlet Stack now: [Emma, Dune] Printing it doesn't sort it: [Reservation[member=Lucía, priority=1], Reservation[member=Pablo, priority=3], Reservation[member=Irene, priority=2], Reservation[member=Mario, priority=5]] Service order: Reservation[member=Lucía, priority=1] Reservation[member=Irene, priority=2] Reservation[member=Pablo, priority=3] Reservation[member=Mario, priority=5] poll() on an empty queue: null pop() on an empty stack: NoSuchElementException
Check it by hand:
- Queue:
Ana, Luis, Martago in in that order;peek()looks at the front without removing,poll()removes it and the queue is left as[Luis, Marta]. - Stack:
Dune,Emma,Hamletgo in in that order, but the stack prints withHamletin front (the top).pop()returnsHamlet, the last one in. - Priority queue: when printed, it does not come out sorted (
Pablowith priority 3 appears beforeIrenewith 2). Only the first one is guaranteed to be the smallest. But when taking out withpoll()everything comes out in order: 1, 2, 3, 5. - Empty ones:
poll()on an empty queue givesnull;pop()on an empty stack throwsNoSuchElementException.
There’s an old class, Stack, that also works as a stack, but the current recommendation is to use ArrayDeque for stacks and queues. Stack carries designs from the first version of Java.
Questions you might ask
**Why do peek() and poll() return null instead of an exception?** Because they are designed for the normal case “maybe there’s nobody”. Their versions that throw an exception (element() and remove()) exist for when an empty queue is an error. pop(), on the other hand, does throw an exception.
What happens if two reservations have the same priority? The order between them is not guaranteed. If the tie matters, add a second criterion to compareTo (for example, arrival time).
**Why isn’t a PriorityQueue printed sorted?** Inside, it’s a heap: a structure that only guarantees the smallest is at the front, not that everything is sorted. If you want to go through it in order, you have to take elements out with poll().
**Can I traverse a queue with a for-each?** Yes, but in the structure’s internal order (the one it prints). For ArrayDeque that’s the expected one; for PriorityQueue, it isn’t.
Mixing up add/offer with push. If you insert with add and take out with pop, you mix the end with the front and the order comes out the opposite of what you expected.
Try it yourself
- Change
Reservationso that, on equal priority, the member whose name comes first alphabetically is served first. Hint:compareTowith two criteria. - Make the waiting queue a priority queue using a
PriorityQueue<String>and check in what order it servesAna,LuisandMarta. Why that order?
Typical mistakes in this practice
- **Expecting an order from a
HashMaporHashSet**: none is guaranteed. UseLinkedHash...orTree.... - **Doing
map.put(w, map.get(w) + 1)** without checking whether it exists:NullPointerExceptionthe first time. UsemergeorgetOrDefault. - **Overriding only
equals()** (or onlyhashCode()):HashSetandHashMapmisbehave without warning. - Changing a field that is part of the hash of an object that is already inside a
HashSetor aHashMap: it gets lost without an error. - Mixing queue and stack methods (
addwithpop): the order comes out reversed. - **Expecting a
PriorityQueueto print sorted**: onlypoll()hands things over in order.
Watch it step by step in Python Tutor
Python Tutor runs the code line by line and draws variables, arrays and references. Choose Java. Here’s how an ArrayDeque works inside: a circular array with a head index, where taking out the first one doesn’t force anyone to move.
public class Tutor {
public static void main(String[] args) {
String[] queue = new String[4];
int head = 0;
int count = 0;
String[] arrive = {"Ana", "Luis", "Marta"};
for (String s : arrive) { // offer() of three people
queue[(head + count) % queue.length] = s;
count++;
}
String served = queue[head]; // poll(): the first one leaves
queue[head] = null;
head = (head + 1) % queue.length;
count--;
queue[(head + count) % queue.length] = "Pablo"; // offer(): takes the last slot
count++;
queue[(head + count) % queue.length] = "Irene"; // offer(): wraps back to the start
count++;
System.out.println(served + " has been served");
for (int i = 0; i < count; i++) {
System.out.println(queue[(head + i) % queue.length]);
}
}
}
Expected output:
Ana has been served Luis Marta Pablo Irene
What to watch, step by step:
- After the first
for,queueholdsAna, Luis, Martaand a gap (null) at the end.headis 0 andcount, 3. - In the “poll”,
servedreceives"Ana", her slot becomesnullandheadmoves to 1. Nobody moved:LuisandMartaare still in exactly the same positions of the array. Pablois stored at(1 + 2) % 4 = 3, the last slot of the array.Ireneis stored at(1 + 3) % 4 = 0: the index wraps around and reuses the slotAnaleft behind. The array ends up as[Irene, Luis, Marta, Pablo], but the real order of the queue, which reads starting fromhead, isLuis, Marta, Pablo, Irene.
Summary
- **
Map** associates keys with values;merge(key, 1, Integer::sum)is the short way to count, andgetOrDefaultthe explicit one. - **
HashMappromises no order;LinkedHashMaprespects insertion andTreeMap** sorts by key. - **
HashSetandHashMaprequire consistentequals()andhashCode()** on the keys: the hash chooses the bucket andequalsdecides. With only one of the two, duplicates appear. - Keys should be immutable: if you change what goes into the hash, the element “disappears” from lookups.
- **
ArrayDeque** works as a queue (offer/poll) and as a stack (push/pop); **PriorityQueue** always takes out the smallest according toComparable(or aComparator).
Checklist: have you practised it?
- [ ] I’ve compiled and run the three programs.
- [ ] I’ve checked at least one output of each program by hand.
- [ ] I’ve seen that
HashMap,TreeMapandLinkedHashMapprint in different orders. - [ ] I’ve reproduced the “only
equals” case and I understand why the set gives 2. - [ ] I’ve seen that a key that changes can no longer be found.
- [ ] I’ve checked that a printed
PriorityQueuedoesn’t come out sorted, butpoll()does. - [ ] I’ve looked at the Python Tutor program and seen how the index wraps around the array.
And now, on to the exercises: collections exercises, with three levels, three fragments to debug and a cheat sheet at the end. See you there!
Next step: exercisesCollections in Java — exercises with authors, catalogs and a merge of two libraries (and the “b” that was in the map but never showed up)Did this article help you? Here is how you can support SergioLearns →
