Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 13 · Collections and Generics
Ordering, Queues and Choosing a Collection
How to sort objects with Comparable and Comparator, how queues, deques and priority queues work, why ArrayDeque replaces the legacy Stack, and a decision guide with complexity and memory trade-offs for choosing the right collection.
For
Learners who know lists, sets and maps and need to sort, process items in order and make informed collection choices.
Level
Before you start
Lists and Sets (article 11) and Maps and How HashMap Works (article 12).
On this page
Key takeaways
- Comparable defines a class's natural order through compareTo. Comparator defines other orders from outside the class, and Comparator.comparing with thenComparing builds multi-level orders readably.
- Never implement a comparison by subtracting two ints, because overflow can reverse the result. Use Integer.compare or Comparator.comparingInt.
- A TreeSet or TreeMap treats two elements as duplicates when the comparison returns zero, whatever equals says, so the comparator must be consistent with the intended equality.
- Use ArrayDeque for queues and stacks, and avoid the legacy Stack class. A PriorityQueue returns the smallest element first but does not iterate in sorted order.
- Choose collections by the operations you need most. Compare costs by operation, remember that boxing and per-entry overhead use memory, and measure before optimising.
Collections hold things. Real programs also need to put them in order (the candidates with the highest scores first, the files sorted by name) and to process them in a particular order (the next task to run, the next request to handle). Both need an idea of what "comes before what", and both have a standard Java answer.
What you will learn
#- How
ComparableandComparatordefine order, and how to chain comparators. - Why a subtraction-based comparison is a bug.
- How sorted collections decide what a duplicate is.
- How
Queue,Deque,ArrayDequeandPriorityQueuebehave. - How to choose a collection by its costs.
Natural order: Comparable
#Numbers and strings know how to sort themselves. For your own classes, you teach Java what "natural order" means by implementing Comparable<T> and its single method compareTo. It returns a negative number if this object comes before the other, zero if they are equivalent in ordering, and a positive number if it comes after.
// File: Main.javaimport java.util.ArrayList;import java.util.Collections;import java.util.Comparator;import java.util.List;public class Main { record Candidate(String name, int score, int yearsExperience) implements Comparable<Candidate> { @Override public int compareTo(Candidate other) { return Integer.compare(other.score, this.score); // natural order: highest score first } } // The tempting but broken way to compare ints static int brokenCompare(int a, int b) { return a - b; } public static void main(String[] args) { List<Candidate> list = new ArrayList<>(List.of( new Candidate("Meena", 88, 2), new Candidate("Asha", 92, 5), new Candidate("Ravi", 88, 7), new Candidate("Dev", 75, 1))); Collections.sort(list); // uses compareTo System.out.println(list.stream().map(Candidate::name).toList()); // Comparator: orders defined outside the class, built from small pieces list.sort(Comparator.comparingInt(Candidate::score).reversed() .thenComparing(Candidate::yearsExperience, Comparator.reverseOrder()) .thenComparing(Candidate::name)); System.out.println(list.stream().map(Candidate::name).toList()); list.sort(Comparator.comparing(Candidate::name, String.CASE_INSENSITIVE_ORDER)); System.out.println(list.stream().map(Candidate::name).toList()); System.out.println(brokenCompare(Integer.MIN_VALUE, 1) > 0); // wrong answer: overflow System.out.println(Integer.compare(Integer.MIN_VALUE, 1) > 0); // correct }}[Asha, Meena, Ravi, Dev][Asha, Ravi, Meena, Dev][Asha, Dev, Meena, Ravi]truefalseCollections.sort(list) and list.sort(null) use the natural order. The Candidate record implements Comparable so that the natural order is "highest score first". When two candidates have the same score (Meena and Ravi, both 88), compareTo returns 0, and the sort is stable, meaning equal elements keep their original relative order, so Meena stays before Ravi.
A note on the subtraction bug
#The last two lines of output expose a bug that appears in a surprising amount of real code: a - b as a comparison. It looks like it works, but Integer.MIN_VALUE - 1 overflows and wraps around to a large positive number, so the sign is wrong and the sort silently misorders elements for extreme values. Always use Integer.compare(a, b) (or Comparator.comparingInt), which handles every case. This is overflow from article 2 striking again.
The compareTo contract
#Like equals, compareTo has a contract: it must be antisymmetric (a.compareTo(b) and b.compareTo(a) have opposite signs), transitive, and consistent. It is strongly recommended to be consistent with equals: a.compareTo(b) == 0 should hold exactly when a.equals(b). Sorted collections use compareTo and not equals, so a mismatch produces surprising results, as we will see in a moment.
Other orders: Comparator
#A class can have only one natural order, but you often need others: by name, by date, by score then name. A Comparator<T> is an object that compares two values, and it lives outside the class. The Comparator interface offers static factory methods that make comparators readable:
Method | Purpose |
|---|---|
| Order by a key extracted from each element |
| The same, avoiding boxing for primitive keys |
| Break ties with a second criterion |
| Flip the order |
| Natural order, or its reverse |
| Handle |
The chain in the example reads almost like English: "by score descending, then by years of experience descending, then by name". Beware one subtlety: comparingInt(Candidate::score).reversed() applies reversed() to that one comparator, and the thenComparing calls that follow are not reversed. Each step can be given its own direction, as with Comparator.reverseOrder() for the years.
The older style that you will meet in code from before Java 8 is an anonymous class:
Collections.sort(list, new Comparator<Candidate>() { @Override public int compare(Candidate a, Candidate b) { return a.name().compareTo(b.name()); }});It does the same job in five lines instead of one. Lambdas and method references (article 15) replaced this style.
Sorted collections: what counts as a duplicate?
#TreeSet and TreeMap keep their elements sorted, using either the natural order or a comparator you give them. Crucially, they identify duplicates with the comparison, not with equals.
// File: Main.javaimport java.util.TreeMap;import java.util.TreeSet;public class Main { public static void main(String[] args) { TreeSet<String> names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); names.add("Asha"); names.add("asha"); // compares as equal to "Asha": treated as a duplicate names.add("Ravi"); System.out.println(names + " size=" + names.size()); System.out.println(names.contains("RAVI")); TreeMap<String, Integer> counts = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); counts.merge("Java", 1, Integer::sum); counts.merge("JAVA", 1, Integer::sum); counts.merge("java", 1, Integer::sum); System.out.println(counts); }}[Asha, Ravi] size=2true{Java=3}With the case-insensitive comparator, "Asha" and "asha" compare as equal, so the set keeps only the first one, even though "Asha".equals("asha") is false. Sometimes that is exactly what you want (a case-insensitive map of words, as in the last line). When it is not, it is a source of vanishing data. The rule: the comparator defines equality for sorted collections, so make sure that it matches your intent, and include enough tie-breakers that elements that are different never compare as zero.
Queues: processing in order
#A queue holds elements for processing. The classic rule is first in, first out (FIFO): like a line at a counter, whoever has waited longest is served first. A Queue has two ways to do each operation, one that throws an exception on failure and one that returns a special value:
Operation | Throws on failure | Returns special value |
|---|---|---|
Insert at the tail |
|
|
Remove the head |
|
|
Look at the head |
|
|
Prefer the offer, poll, peek family when an empty queue is a normal situation.
A Deque ("double-ended queue", pronounced "deck") allows insertion and removal at both ends, and so can act as a queue, a stack, or both. The recommended implementation is ArrayDeque: a resizable circular array. Its documentation says it is likely to be faster than Stack when used as a stack and faster than LinkedList when used as a queue. It does not allow null elements.
A stack is last in, first out (LIFO), like a pile of plates, with push and pop. Java has a legacy class java.util.Stack (it extends Vector, is synchronised and has all the List methods, which a stack should not have). The advice in the Java documentation itself is to use a Deque instead.
A PriorityQueue is different: it does not serve in arrival order, but always gives you the element that is smallest according to the natural order or a comparator. It is implemented as a binary heap, a tree stored in an array where each parent is no larger than its children. offer and poll cost logarithmic time, and peek is constant. Because only the smallest is guaranteed to be at the front, iterating over a PriorityQueue, or printing it, does not give sorted order. The documentation says explicitly that the iterator provides no guarantee about ordering.
// File: Main.javaimport java.util.ArrayDeque;import java.util.Comparator;import java.util.Deque;import java.util.List;import java.util.PriorityQueue;import java.util.Queue;public class Main { record Task(String name, int priority) {} static boolean balanced(String text) { // a classic stack use Deque<Character> stack = new ArrayDeque<>(); for (char c : text.toCharArray()) { if (c == '(' || c == '[') { stack.push(c); } else if (c == ')' || c == ']') { if (stack.isEmpty()) { return false; } char open = stack.pop(); if ((c == ')' && open != '(') || (c == ']' && open != '[')) { return false; } } } return stack.isEmpty(); } public static void main(String[] args) { Queue<String> line = new ArrayDeque<>(); line.offer("first"); line.offer("second"); line.offer("third"); System.out.println(line.poll() + " " + line.peek() + " " + line.size()); Deque<Integer> deque = new ArrayDeque<>(); deque.addFirst(2); deque.addFirst(1); deque.addLast(3); System.out.println(deque + " " + deque.pollFirst() + " " + deque.pollLast()); System.out.println(balanced("(a[b])") + " " + balanced("(a[b)]") + " " + balanced("((")); PriorityQueue<Task> tasks = new PriorityQueue<>(Comparator.comparingInt(Task::priority)); tasks.offer(new Task("write report", 3)); tasks.offer(new Task("fix outage", 1)); tasks.offer(new Task("reply to email", 5)); tasks.offer(new Task("review code", 2)); System.out.println("peek: " + tasks.peek().name()); StringBuilder order = new StringBuilder(); while (!tasks.isEmpty()) { order.append(tasks.poll().name()).append("; "); } System.out.println(order); // Top 3 largest values using a min-heap of size 3 PriorityQueue<Integer> top = new PriorityQueue<>(); for (int value : List.of(41, 7, 93, 18, 66, 25, 80)) { top.offer(value); if (top.size() > 3) { top.poll(); // discard the smallest of the four } } System.out.println(top.stream().sorted().toList()); }}first second 2[1, 2, 3] 1 3true false falsepeek: fix outagefix outage; review code; write report; reply to email;[66, 80, 93]Notice how the pieces fit. The bracket checker uses an ArrayDeque as a stack: it pushes each opening bracket and pops it when the matching closing bracket arrives. The priority queue handed out the tasks by priority, regardless of insertion order. And the top-three example shows a small but powerful idea: to find the k largest values from a stream of any size, keep a min-heap of size k, and whenever it grows past k, throw away the smallest. It uses memory proportional to k, not to the size of the input.
Comparing the costs
#Complexity lets you compare collections by operation. These are typical costs. Real performance also depends on constants, memory layout and data, so use them to choose a sensible default and measure when it matters.
Collection | Add | Lookup by value | Index access | Remove | Order |
|---|---|---|---|---|---|
| O(1) amortised at end; O(n) elsewhere | O(n) | O(1) | O(n) | Insertion |
| O(1) at ends | O(n) | O(n) | O(1) at a known node | Insertion |
| O(1) average | O(1) average | n/a | O(1) average | None |
| O(1) average | O(1) average | n/a | O(1) average | Insertion or access |
| O(log n) | O(log n) | n/a | O(log n) | Sorted |
| O(1) at both ends | O(n) | n/a | O(1) at ends | FIFO / LIFO |
| O(log n) | O(n) | n/a | O(log n) for the head | Head is smallest |
In this table, "O(1)" means the cost does not grow with the collection's size, "O(log n)" grows slowly, and "O(n)" grows in proportion.
Memory matters too
#- Boxing. A
List<Integer>stores references toIntegerobjects, not raw numbers. For millions of numbers, anint[]uses far less memory. - Per-entry overhead. A
HashMapentry is an object holding a key, a value, a hash and a link, on top of the key and value objects. ALinkedListnode is an object with two links. AnArrayListhas just a reference per element, plus spare capacity. - Capacity. An
ArrayListcan have unused space.new ArrayList<>(expectedSize)avoids repeated growing when you know the size in advance, andtrimToSize()releases the spare.
How to choose
#Ask three questions, in order:
- Do I need key-value lookup? If yes, a
Map. If not, aCollection. - What order do I need? None (hash-based), insertion (
ArrayList,LinkedHash*), sorted (Tree*), priority (PriorityQueue), first-in-first-out or stack order (ArrayDeque). - Which operation will dominate? Index access favours
ArrayList. Membership checks favour a hash set. Range and nearest-neighbour queries favour a tree.
If you are unsure, ArrayList, HashMap and HashSet are the right defaults. The rest are for specific needs, and the interface type you declare keeps your options open. Shared mutable collections between threads need special handling, covered in article 20.
Common mistakes
#- Comparing with subtraction.
- Forgetting
thenComparing, so ties come out in arbitrary order. - A comparator inconsistent with the intended equality in a
TreeSetorTreeMap, causing items to disappear. - Using
StackorVectorin new code. - Calling
remove()orelement()on a queue that may be empty, and getting an exception. - Assuming a
PriorityQueueiterates in order. - Putting
nullinto anArrayDeque(it throws). - Choosing a collection by habit instead of by the operations needed.
Exercises
#Exercise 1
Multi-level sort
#Sort a list of Employee(name, department, salary) by department alphabetically, then by salary descending, then by name. Write the comparator.
Exercise 2: Find the bug
#A comparator is written as (a, b) -> a.price() - b.price() where price() returns int in paise. Prices include 2_000_000_000 and -2_000_000_000. What can go wrong, and what is the fix?
Exercise 3: Predict
#What does new TreeSet<>(Comparator.comparingInt(String::length)) do when you add "cat", "dog" and "bird"? Why?
Exercise 4: Queue or stack?
#For each, choose queue, stack or priority queue: (a) undo history in a text editor, (b) print jobs processed in arrival order, (c) an emergency room serving the most urgent patient first.
Exercise 5: Top k
#Write a method List<Integer> topK(Iterable<Integer> values, int k) that returns the k largest values without sorting all of them, and state its time complexity.
Show solutionHide solution
Comparator<Employee> order = Comparator.comparing(Employee::department) .thenComparing(Employee::salary, Comparator.reverseOrder()) .thenComparing(Employee::name);employees.sort(order);Each thenComparing breaks the ties left by the one before it. Giving salary its own reversed comparator reverses just that level, so the departments stay in ascending order.
Exercise 2
Show solutionHide solution
The difference 2_000_000_000 - (-2_000_000_000) is 4,000,000,000, which does not fit in an int and overflows to a negative number, so the comparator says the larger price is smaller. The sort order is then wrong, and no exception warns you. The fix is Integer.compare(a.price(), b.price()) or Comparator.comparingInt(Item::price).
Exercise 3
Show solutionHide solution
The set contains [cat, bird]. The comparator orders by length only, so "cat" and "dog" compare as equal (both have length 3) and the second one is treated as a duplicate and dropped. A comparator used by a sorted set defines equality, so if you want to keep both, add a tie-breaker: Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()).
Exercise 4
Show solutionHide solution
(a) Stack (last in, first out): the most recent change is undone first. (b) Queue (first in, first out): jobs are served in arrival order. (c) Priority queue: the most urgent patient is served first, whatever the arrival time. In Java you would use ArrayDeque for the first two (push/pop and offer/poll) and PriorityQueue for the third.
Exercise 5
Show solutionHide solution
static List<Integer> topK(Iterable<Integer> values, int k) { PriorityQueue<Integer> heap = new PriorityQueue<>(); for (int value : values) { heap.offer(value); if (heap.size() > k) { heap.poll(); } } List<Integer> result = new ArrayList<>(heap); result.sort(Comparator.reverseOrder()); return result;}Each of the n values costs O(log k) for the heap operations, so the total is O(n log k), better than sorting everything (O(n log n)) when k is small, and it needs only O(k) memory.
Where to go next
#- Next: Generics: Type Safety, Wildcards and Erasure explains the angle brackets used throughout this module.
- For comparators written as lambdas: Lambdas, Functional Interfaces and Method References.
- For sorting with streams: Streams in Practice (and When Not to Use Them).
Sources & further reading
- Technology
- Software Engineering
- #data structures
- #interview preparation
- #Java
- #Java backend
- #Java collections
- #Java interview
- #JPA
- #learn Java
- #Spring Boot
- #SQL
Connected lessons
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

