LinkedList is the JDK's second implementation of List and probably the most misunderstood class in the whole Framework. The sentence repeated in tutorials, in job interviews and in code comments is always the same: "use ArrayList to access by index and LinkedList to insert and delete, which is O(1)". That sentence, exactly as it sounds, is false, and believing it leads to writing considerably slower code while thinking you are optimising.
In this lesson you are going to understand exactly what a doubly linked list is, which operations are really O(1) and under what precise condition, and why in practice —even in the cases that look like its home ground— ArrayList usually wins. You will also see where LinkedList does make sense: not so much as a List, but as a Deque, that is, as a double-ended queue. And you will finish with the one BiblioTech case where its semantics really fit: the queue of pending reservations, consumed at one end and grown at the other.
This lesson is not just about a class. It is about learning to reason honestly about data structures, telling apart what the theory says from what the processor does.
Contents
- The structure: doubly linked nodes
- What it implies for memory and for the cache
- The nuance almost everybody explains badly
ArrayListversusLinkedList, operation by operation- The honest, up-to-date conclusion
LinkedListas aDequeListIterator: two-way traversal and insertion- Measuring performance honestly
- When
LinkedListis the right choice: the reservation queue - Common Mistakes and Tips
- Exercises
- The structure: doubly linked nodes
An ArrayList keeps its elements in a contiguous block of memory. A LinkedList keeps nothing contiguous: it keeps a chain of nodes, each in its own corner of the heap, joined by references.
This is the JDK's actual node, and it fits in five lines:
private static class Node<E> {
E item; // the element it carries
Node<E> next; // reference to the NEXT node (null if it is the last)
Node<E> prev; // reference to the PREVIOUS node (null if it is the first)
}And the list itself keeps only three fields:
flowchart LR
LL["LinkedList<br/>size = 4<br/>first, last"]
N1["null ← prev<br/><b>Effective Java</b><br/>next →"]
N2["← prev<br/><b>Design Patterns</b><br/>next →"]
N3["← prev<br/><b>Refactoring</b><br/>next →"]
N4["← prev<br/><b>Java Magazine</b><br/>next → null"]
LL -.->|first| N1
LL -.->|last| N4
N1 --> N2
N2 --> N3
N3 --> N4
N4 -.-> N3
N3 -.-> N2
N2 -.-> N1
The solid arrows are the next links; the dotted ones, the prev links. Having links in both directions is what makes it doubly linked, and it has two important consequences: it can be traversed backwards, and from a node you can remove that node without knowing the previous one.
Its two fundamental characteristics follow immediately from this structure:
There are no indices. The nodes are not numbered nor placed at computable positions. To reach element 500 you have to start at first and jump 500 times. There is no way of doing it faster, and there lies the structural weakness of LinkedList.
There is no capacity. Each node is created when it is needed and discarded when it is removed. There are no resizes, no copies, no internal array to fill. Adding the millionth element costs exactly the same as adding the second.
Seeing how a node is inserted in the middle explains the rest:
flowchart LR
subgraph before["BEFORE"]
direction LR
A1["Node A"] --> A2["Node B"]
end
subgraph after["AFTER inserting X between A and B"]
direction LR
B1["Node A"] --> BX["Node X"]
BX --> B2["Node B"]
end
The operation consists of creating node X and changing four references: A.next = X, X.prev = A, X.next = B, B.prev = X. Four assignments. No element moves, neither the first nor the millionth. That is what "O(1) insertion" means. And now comes the nuance.
- What it implies for memory and for the cache
Before the nuance, two costs that complexity tables do not show and that in practice weigh more than the O() itself.
Memory cost
In an ArrayList, each element takes up one reference in the internal array: 4 bytes with compressed pointers, 8 without.
In a LinkedList, each element needs a complete Node object:
| Node component | Approximate bytes (64-bit JVM with compressed pointers) |
|---|---|
| Object header | 12 |
item (reference to the element) |
4 |
next (reference) |
4 |
prev (reference) |
4 |
| Alignment padding | 4 |
| Total per element | ~28 bytes |
Against the ~4 bytes per element of a well-sized ArrayList, that is about seven times more memory in infrastructure alone, without counting the objects pointed at. For a million elements: about 4 MB versus about 28 MB. And every node is an object the garbage collector has to trace (module 10-07).
Cache locality cost
This is the decisive factor and the one most people ignore. As 05-01 explained, the processor does not read memory byte by byte: it brings in 64-byte blocks to its cache. In an ArrayList, reading element 0 also brings in the next 15 for free; walking the list is practically a sequential read, the pattern the hardware likes best.
In a LinkedList, each node can be anywhere on the heap. If you created them in order they may be close together, but after a while of additions and removals, and after several collector passes, they end up scattered. Traversing then means one unpredictable memory jump per element, and every jump that misses the cache costs on the order of a hundred times more than a cached access.
flowchart TB
subgraph AL["ArrayList: sequential traversal"]
direction LR
X0["e0"] --- X1["e1"] --- X2["e2"] --- X3["e3"] --- X4["e4"]
end
subgraph LL["LinkedList: jumps all over the heap"]
direction LR
Y0["node0"] -.-> Y3["node1"]
Y3 -.-> Y1["node2"]
Y1 -.-> Y4["node3"]
Y4 -.-> Y2["node4"]
end
The result, measured again and again: walking a LinkedList is usually between 2 and 10 times slower than walking an ArrayList of the same size, even though both operations are O(n). O() notation ignores constants, and here the constant matters enormously.
- The nuance almost everybody explains badly
Let us repeat the popular claim: "inserting and deleting in a LinkedList is O(1)".
The correct statement is: inserting and deleting is O(1) IF YOU ALREADY HAVE THE POSITION, that is, if you already hold a reference to the node (or you are at one end). Getting to that position costs O(n).
Look at it in the JDK's real code:
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size) { linkLast(element); }
else { linkBefore(element, node(index)); } // node(index) is the problem
}
Node<E> node(int index) {
if (index < (size >> 1)) { // if it is in the first half
Node<E> x = first;
for (int i = 0; i < index; i++) { x = x.next; } // O(n) jumps
return x;
} else { // if it is in the second half
Node<E> x = last;
for (int i = size - 1; i > index; i--) { x = x.prev; }
return x;
}
}linkBefore is O(1): four assignments. But node(index) walks up to half the list. The complete method is O(n). The optimisation of starting from the nearest end only halves it: it is still O(n).
Direct consequence: this loop, which looks reasonable, is a disaster.
List<Integer> list = new LinkedList<>();
for (int i = 0; i < 100_000; i++) { list.add(i); }
// "I insert in the middle, which in a LinkedList is O(1)"... NO
for (int i = 0; i < 1000; i++) {
list.add(list.size() / 2, i); // every call walks 50,000 nodes: O(n)
}A thousand calls × 50,000 jumps = 50 million pointer jumps. The same loop with an ArrayList would do a thousand System.arraycopy calls of 50,000 references, which is the same O() but executed by a block-copy instruction optimised by the hardware: in practice, several times faster.
When it really is O(1)
Only in three situations:
1. At the ends. addFirst, addLast, removeFirst, removeLast reach first and last directly. Genuinely O(1), with no traversal.
LinkedList<String> queue = new LinkedList<>();
queue.addLast("Marta Ruiz"); // really O(1)
queue.removeFirst(); // really O(1)2. With an iterator already at the position.
ListIterator<Material> it = list.listIterator();
while (it.hasNext()) {
Material m = it.next();
if (m.getTitle().startsWith("Java")) {
it.add(new Note("Review")); // REALLY O(1): the iterator is already there
}
}The full traversal is O(n), but each individual insertion is O(1), and no elements are shifted. Here LinkedList really is theoretically superior to ArrayList, where every add(i, e) would shift the rest.
3. Iterator.remove() during a traversal. Same reasoning: the iterator already has the node.
The rule in summary, worth memorising:
In a
LinkedListthe operation is cheap; getting to the spot is expensive. In anArrayListgetting to the spot is free; the operation is expensive.
And since "getting to the spot" is what any indexed method does, the net result almost always favours ArrayList.
ArrayList versus LinkedList, operation by operation
ArrayList versus LinkedList, operation by operation| Operation | ArrayList |
LinkedList |
Who wins in practice |
|---|---|---|---|
get(i) / set(i, e) |
O(1) | O(n) | ArrayList, no argument |
add(e) at the end |
amortised O(1) | O(1) | A tie; ArrayList is usually faster thanks to the cache |
add(0, e) at the start |
O(n) | O(1) | LinkedList in theory; ArrayDeque beats both |
add(i, e) in the middle |
O(n) shifting | O(n) traversal + O(1) | ArrayList: the arraycopy is faster than jumping nodes |
remove(size()-1) |
O(1) | O(1) | A tie |
remove(0) |
O(n) | O(1) | LinkedList; ArrayDeque beats both |
remove(i) |
O(n) | O(n) | ArrayList |
remove(Object) |
O(n) | O(n) | ArrayList |
contains / indexOf |
O(n) | O(n) | ArrayList (much better cache) |
Traversing with for-each |
O(n) fast | O(n) slow | ArrayList, 2-10× |
Traversing with an indexed for |
O(n) | O(n²) | ArrayList; on a LinkedList it is a serious mistake |
Inserting with a ListIterator |
O(n) per insertion | O(1) per insertion | LinkedList |
addFirst / pollFirst |
Do not exist on List |
O(1) | LinkedList (or ArrayDeque) |
| Memory per element | ~4-8 bytes | ~28 bytes | ArrayList |
sort |
O(n log n) directly | O(n log n) + dump to an array and rebuild | ArrayList |
Pay special attention to the indexed for row, because it is a performance mistake that slips in easily:
// On a LinkedList of 100,000 elements: each get(i) walks up to i nodes.
// Total: 1 + 2 + 3 + ... + 100,000 = about 5,000 million jumps.
for (int i = 0; i < list.size(); i++) {
process(list.get(i)); // DISGUISED O(n^2)
}
// Correct on both implementations:
for (Material m : list) { // O(n): the iterator moves from node to node
process(m);
}If your method's parameter is a List and you do not know which implementation will arrive, always traverse with for-each. That is one of the reasons the RandomAccess marker interface exists (05-03): it lets a generic algorithm ask whether indexed access is cheap.
if (list instanceof RandomAccess) {
for (int i = 0; i < list.size(); i++) { process(list.get(i)); }
} else {
for (Material m : list) { process(m); }
}You will hardly ever write this in your own code —use for-each and be done with it— but it is useful to know why it exists.
- The honest, up-to-date conclusion
This is the part of the lesson that departs most from the usual discourse, so it comes with names and arguments.
In practice, ArrayList wins almost always. Even in scenarios that appear to favour LinkedList, repeated measurements on modern JVMs give victory to the array, because:
- Cache locality dominates. The difference between an L1 cache access and a miss that goes to main memory is two orders of magnitude. No algorithmic advantage survives that when the O() is the same.
System.arraycopyis a native instruction that copies blocks of memory at hardware speed. Shifting 10,000 contiguous references is surprisingly cheap; following 10,000 scattered pointers is not.- Nodes put pressure on the collector. A million elements are a million
Nodeobjects to trace, versus a single array. - Insertions "in the middle" are almost never really in the middle. In real code you insert at the end, or remove by criterion with
removeIf, or sort. All of those operations favourArrayList.
This is also the public opinion of Joshua Bloch, author of Effective Java and co-author of the Collections Framework itself, who has gone as far as to say that these days LinkedList does not add enough value to justify its general use.
So is LinkedList useless? It is useful, but not as a List: as a Deque. When what you want is a queue or a stack —add at one end, take from the other— LinkedList delivers real O(1) with no capacity to manage.
And even so, on that ground it has a better competitor: ArrayDeque, which you will see in 05-07, and which is faster and uses less memory because it uses a circular array. The JDK's official recommendation for queues and stacks is ArrayDeque, not LinkedList.
The practical summary, without beating about the bush:
| I need... | Use |
|---|---|
| A list | ArrayList |
| A queue or a stack | ArrayDeque (05-07, 05-08) |
Lots of insertion in the middle while traversing with a ListIterator |
LinkedList (a rare but legitimate case) |
A List that is also a Deque in the same variable |
LinkedList |
A queue that accepts null |
LinkedList (ArrayDeque rejects them) |
There are three solid reasons for you to keep learning LinkedList: you will meet it in existing code, it is the canonical example of a linked list —a structure that turns up in a thousand places— and understanding why it is not the answer teaches you to reason about performance better than any memorised rule.
LinkedList as a Deque
LinkedList as a DequeLinkedList implements two interfaces at once, and there lies its distinctive trait:
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.SerializableIt is the only class in the JDK that is a List and a Deque simultaneously. That gives it access to the whole family of end operations, all genuinely O(1):
| Method | What it does | If it is empty |
|---|---|---|
addFirst(e) / offerFirst(e) |
Inserts at the start | — |
addLast(e) / offerLast(e) |
Inserts at the end | — |
getFirst() / getLast() |
Reads without removing | NoSuchElementException |
peekFirst() / peekLast() |
Reads without removing | Returns null |
removeFirst() / removeLast() |
Removes and returns | NoSuchElementException |
pollFirst() / pollLast() |
Removes and returns | Returns null |
peek() / poll() |
Aliases of peekFirst/pollFirst (queue semantics) |
null |
push(e) / pop() |
Aliases of addFirst/removeFirst (stack semantics) |
pop: NoSuchElementException |
The distinction between the two families —throw an exception or return null— is an important design decision of Queue and Deque, and it is explained thoroughly in 05-07. For now, take away the rule: use peek/poll/offer when an empty collection is a normal situation; use getFirst/removeFirst/addFirst when empty means something is wrong.
An example with both semantics on the same class:
LinkedList<String> queue = new LinkedList<>();
// As a QUEUE (FIFO): enters at the back, leaves at the front
queue.addLast("Marta Ruiz");
queue.addLast("Diego Alonso");
queue.addLast("Nuria Vidal");
System.out.println(queue.pollFirst()); // Marta Ruiz (the first to arrive)
System.out.println(queue); // [Diego Alonso, Nuria Vidal]
// As a STACK (LIFO): enters and leaves at the same end
LinkedList<String> stack = new LinkedList<>();
stack.push("registration");
stack.push("withdrawal");
stack.push("amendment");
System.out.println(stack.pop()); // amendment (the last one in)
System.out.println(stack); // [withdrawal, registration]Notice the variable's type: here it is declared as a LinkedList because we need methods that List does not have. If you are only going to use it as a queue, the right thing according to the golden rule of 05-02 is to declare it by the interface that reflects its use:
Queue and stack usage in detail are lessons 05-07 and 05-08.
ListIterator: two-way traversal and insertion
ListIterator: two-way traversal and insertionListIterator is an extension of Iterator exclusive to lists, and it is the correct way of inserting or replacing elements while you traverse. Its API:
| Method | What it does |
|---|---|
hasNext() / next() |
Move forward, as in Iterator |
hasPrevious() / previous() |
Move back |
nextIndex() / previousIndex() |
Position of the next / previous one |
add(E e) |
Inserts at the current position, before the one next() would return |
set(E e) |
Replaces the last one returned by next() or previous() |
remove() |
Removes the last one returned |
The conceptual key: a ListIterator does not point at an element, it points at a gap between elements (a cursor). next() steps over the element to its right and returns its value; previous() steps over the one to its left.
flowchart LR
P0["^0"] --- A["A"] --- P1["^1"] --- B["B"] --- P2["^2"] --- C["C"] --- P3["^3"]
The ^ marks are the possible cursor positions. With the cursor at ^1, next() returns B and leaves the cursor at ^2; previous() would return A and leave it at ^0.
Inserting while you traverse
This is the use that justifies its existence:
List<String> operations = new LinkedList<>(
List.of("add:978-0000000001", "drop:978-0000000002", "add:978-0000000003"));
ListIterator<String> it = operations.listIterator();
while (it.hasNext()) {
String op = it.next();
if (op.startsWith("drop:")) {
it.add("notice:review-" + op.substring(5)); // inserted AFTER the current one
}
}
System.out.println(operations);Three details to understand:
it.add(x)inserts at the cursor's position, which afternext()is right behind the element returned. That is why the notice appears after the drop.- The inserted element is not visited again.
addadvances the cursor past what was inserted, so there is no infinite loop. Check it: ifadddid not do that, the new element would be examined and could generate another, indefinitely. - There is no
ConcurrentModificationException. The iterator is the one doing the modifying, so it updates its ownexpectedModCount(05-02).
Trying the same with a for-each and list.add(...) would give the exception immediately. And doing it with indices over an ArrayList would force you to recompute the position after every insertion, a classic generator of bounds bugs.
Replacing while you traverse
ListIterator<String> it = names.listIterator();
while (it.hasNext()) {
String n = it.next();
if (n.isBlank()) {
it.set("(no name)"); // replaces the last one returned by next()
}
}Equivalent to replaceAll when the condition is simple, but it allows arbitrary logic and deciding element by element.
Traversing backwards
// listIterator(size) places the cursor at the END
ListIterator<Material> it = catalog.listIterator(catalog.size());
while (it.hasPrevious()) {
Material m = it.previous();
System.out.println(m.getTitle());
}On a LinkedList this is efficient thanks to the prev links; on an ArrayList it is too, because indexed access is O(1). In both cases it is clearer than a decreasing for when you also need to insert or remove.
Important warning: mixing a ListIterator with direct modifications of the list breaks everything. While an iterator is alive, all changes must go through it.
- Measuring performance honestly
Let us compare the two implementations with a simple experiment. First, a warning to be taken seriously.
Microbenchmarks in Java are treacherous. The JVM compiles the code as it runs (JIT), so the first iterations are much slower than the later ones; the garbage collector may kick in halfway through the measurement; and the compiler may remove entirely a loop whose result is not used, giving you times of zero. The right tool is JMH (Java Microbenchmark Harness), OpenJDK's official library, which handles warm-up, iterations and consuming the results. What follows is an illustrative approximation, useful for seeing orders of magnitude, not for publishing figures.
package com.nexussoftware.bibliotech.presentation;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class ListComparison {
private static final int N = 100_000;
public static void main(String[] args) {
// Warm-up: we let the JIT compile before measuring
for (int i = 0; i < 3; i++) { measureAll(false); }
System.out.println("=== Measurement (N = " + N + ") ===");
measureAll(true);
}
private static void measureAll(boolean print) {
List<Integer> array = new ArrayList<>();
List<Integer> linked = new LinkedList<>();
long t1 = measure(() -> { for (int i = 0; i < N; i++) { array.add(i); } });
long t2 = measure(() -> { for (int i = 0; i < N; i++) { linked.add(i); } });
long t3 = measure(() -> { long s = 0; for (int i = 0; i < N; i++) { s += array.get(i); } consume(s); });
long t4 = measure(() -> { long s = 0; for (Integer v : array) { s += v; } consume(s); });
long t5 = measure(() -> { long s = 0; for (Integer v : linked) { s += v; } consume(s); });
List<Integer> a2 = new ArrayList<>(array);
List<Integer> l2 = new LinkedList<>(array);
long t6 = measure(() -> { for (int i = 0; i < 20_000; i++) { a2.add(0, i); } });
long t7 = measure(() -> { for (int i = 0; i < 20_000; i++) { l2.add(0, i); } });
if (print) {
System.out.printf("add at the end ArrayList %6d ms | LinkedList %6d ms%n", t1, t2);
System.out.printf("get(i) indexed ArrayList %6d ms | LinkedList (NOT measured: O(n^2))%n", t3);
System.out.printf("traverse foreach ArrayList %6d ms | LinkedList %6d ms%n", t4, t5);
System.out.printf("add(0, e) x20000 ArrayList %6d ms | LinkedList %6d ms%n", t6, t7);
}
}
private static long measure(Runnable task) {
long start = System.nanoTime();
task.run();
return (System.nanoTime() - start) / 1_000_000; // to milliseconds
}
/** Stops the JIT removing the loop because it considers its result useless. */
private static void consume(long value) {
if (value == Long.MIN_VALUE) { System.out.print(""); }
}
}Typical results on a desktop machine, with the order of magnitude as the only reliable information:
| Operation (N = 100,000) | ArrayList |
LinkedList |
Comment |
|---|---|---|---|
add at the end |
~3 ms | ~5 ms | A practical tie; the array wins on cache |
get(i) in an indexed loop |
~1 ms | minutes | O(n²): not even measured |
Traversing with for-each |
~1 ms | ~6 ms | 6× slower, same O() |
add(0, e) × 20,000 |
~120 ms | ~2 ms | Here LinkedList does win... |
addFirst × 20,000 on an ArrayDeque |
~1 ms | — | ...but ArrayDeque beats them both |
The last row sums up the lesson: the only clear case in favour of LinkedList is inserting at the start, and for that there is a better structure.
And an equally valuable methodological conclusion: measure before optimising. If somebody swaps an ArrayList for a LinkedList "because it inserts faster" without having measured, there is a good chance they have made the program worse.
- When
LinkedList is the right choice: the reservation queue
LinkedList is the right choice: the reservation queueHere comes BiblioTech's real case. When a material is on loan, an employee can reserve it. Reservations form a queue: they are served in order of arrival, added at the back and consumed at the front. It is the exact pattern where a linked list is appropriate: zero access by index, everything at the ends.
First, the Reservation class. Since it is a piece of data with its own identity and state (fulfilled), we model it as a class, not as a record (04-07):
package com.nexussoftware.bibliotech.domain;
/** A request to reserve a material that is already on loan. */
public class Reservation {
public static final int NORMAL_PRIORITY = 5;
private final Employee employee;
private final Material material;
private final int requestDay;
private final int priority; // 1 = maximum urgency, 10 = minimum
private boolean fulfilled;
public Reservation(Employee employee, Material material, int requestDay, int priority) {
this.employee = employee;
this.material = material;
this.requestDay = Math.max(requestDay, 0);
this.priority = (priority < 1 || priority > 10) ? NORMAL_PRIORITY : priority;
this.fulfilled = false;
}
public Reservation(Employee employee, Material material, int requestDay) {
this(employee, material, requestDay, NORMAL_PRIORITY);
}
public Employee getEmployee() { return employee; }
public Material getMaterial() { return material; }
public int getRequestDay() { return requestDay; }
public int getPriority() { return priority; }
public boolean isFulfilled() { return fulfilled; }
public void markFulfilled() { this.fulfilled = true; }
/** Days this reservation has been waiting. */
public int daysWaiting(int currentDay) {
return Math.max(0, currentDay - requestDay);
}
@Override
public String toString() {
return String.format("Reservation[%s -> %s, day %d, priority %d%s]",
employee.getName(), material.getTitle(), requestDay, priority,
fulfilled ? ", FULFILLED" : "");
}
}And the reservation queue of a material:
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import com.nexussoftware.bibliotech.domain.Employee;
import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.domain.Reservation;
/**
* FIFO queue of reservations for ONE material.
*
* LinkedList is a justified choice here: we only operate at the ends
* (addLast when reserving, pollFirst when serving) and never by index. Even so,
* an ArrayDeque (05-07) would be faster; LinkedList is used because we also need
* to traverse and remove by criterion, and because it illustrates the case.
*/
public class ReservationQueue {
private final Material material;
private final LinkedList<Reservation> pending = new LinkedList<>();
public ReservationQueue(Material material) {
this.material = material;
}
/** Enqueue at the back. Really O(1): the 'last' node is touched. */
public void reserve(Employee employee, int day) {
pending.addLast(new Reservation(employee, material, day));
}
/** Check who is next WITHOUT taking them out. Returns null if there is nobody. */
public Reservation next() {
return pending.peekFirst();
}
/**
* Serves the first reservation in the queue. Really O(1): the 'first' node is touched.
* Returns null if there were none pending.
*/
public Reservation serveNext(int day) {
Reservation r = pending.pollFirst(); // poll: null if empty, no exception
if (r != null) {
r.markFulfilled();
material.lend();
}
return r;
}
/** Cancels an employee's most recent reservation. Traverses from the back. */
public boolean cancelLatestOf(Employee employee) {
Iterator<Reservation> it = pending.descendingIterator(); // from last to first
while (it.hasNext()) {
if (it.next().getEmployee().equals(employee)) {
it.remove(); // REALLY O(1): the iterator already has the node
return true;
}
}
return false;
}
/** Expires the reservations that have been waiting too many days. */
public int expire(int currentDay, int maxDays) {
int before = pending.size();
pending.removeIf(r -> r.daysWaiting(currentDay) > maxDays);
return before - pending.size();
}
/** Moves an urgent reservation to the front of the queue. Really O(1). */
public void moveToFront(Reservation urgent) {
pending.remove(urgent); // O(n): it has to be located
pending.addFirst(urgent); // O(1): at the front
}
public List<Reservation> list() { return new ArrayList<>(pending); }
public int waiting() { return pending.size(); }
public boolean hasPending() { return !pending.isEmpty(); }
/** Position in the queue (1 = next up). 0 if the employee has no reservation. */
public int positionOf(Employee employee) {
int position = 1;
for (Reservation r : pending) { // for-each, NEVER get(i) on a LinkedList
if (r.getEmployee().equals(employee)) { return position; }
position++;
}
return 0;
}
}Full usage:
Employee marta = new Employee("Marta Ruiz", "EMP-001");
Employee diego = new Employee("Diego Alonso", "EMP-002");
Employee nuria = new Employee("Nuria Vidal", "EMP-003");
Material effectiveJava = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
effectiveJava.lend(); // it is already on loan to somebody else
ReservationQueue queue = new ReservationQueue(effectiveJava);
queue.reserve(marta, 100);
queue.reserve(diego, 101);
queue.reserve(nuria, 103);
System.out.println("Waiting: " + queue.waiting());
System.out.println("Next: " + queue.next());
System.out.println("Position of Nuria: " + queue.positionOf(nuria));
effectiveJava.returnItem();
Reservation served = queue.serveNext(110);
System.out.println("Served: " + served);
System.out.println("Now it is the turn of: " + queue.next().getEmployee().getName());
int expired = queue.expire(130, 20); // more than 20 days waiting
System.out.println("Expired reservations: " + expired);
System.out.println("Still waiting: " + queue.waiting());Waiting: 3 Next: Reservation[Marta Ruiz -> Effective Java, day 100, priority 5] Position of Nuria: 3 Served: Reservation[Marta Ruiz -> Effective Java, day 110, priority 5, FULFILLED] Now it is the turn of: Diego Alonso Expired reservations: 1 Still waiting: 1
Notice three deliberate decisions in this code:
positionOftraverses withfor-each, never withget(i). On aLinkedList, the indexed loop would be O(n²).cancelLatestOfusesdescendingIteratorandit.remove(): the iterator already has the node, so the removal is really O(1). It is the legitimate case from section 3.moveToFrontis honest about its cost: theremove(Object)is O(n) because the reservation has to be located; only theaddFirstis O(1). This method would be the first candidate for review if the queue grew a lot, and aPriorityQueue(05-07) would solve the urgency problem better.
And a final design note: although LinkedList works well here, the canonical declaration of a queue is Deque<Reservation> pending = new ArrayDeque<>(). LinkedList has been used because we also need removeIf and traversals, and because this was its natural case; in 05-07 you will see the ArrayDeque version and why it is usually better.
Common Mistakes and Tips
Traversing a LinkedList with an indexed for. for (int i = 0; i < list.size(); i++) list.get(i) is O(n²): every get walks the chain from one end. With 100,000 elements it goes from milliseconds to minutes. Always use for-each or an iterator.
Believing add(i, e) is O(1) on a LinkedList. The insertion is; getting to index i is not. The complete method is O(n). Only the ends and insertions via an iterator are genuinely O(1).
Choosing LinkedList "because it inserts faster" without measuring. In practice it loses almost always on cache locality and memory consumption. Start with ArrayList and change only with data in hand.
Using LinkedList as a queue when ArrayDeque exists. ArrayDeque is faster and takes less space. The only reason to prefer LinkedList is needing List and Deque in the same variable, or accepting null elements.
Confusing get/remove with peek/poll. On an empty list, getFirst() and removeFirst() throw NoSuchElementException; peekFirst() and pollFirst() return null. Choose according to whether an empty queue is normal or is an error.
Modifying the list while a ListIterator is alive. Any direct list.add, list.remove or list.clear invalidates the iterator and causes ConcurrentModificationException on the next operation. While the iterator exists, everything goes through it.
Forgetting that it.add(x) does not revisit what was inserted. That is what avoids the infinite loop, and it is the right behaviour; but if you expected the new element to be evaluated, it will not be.
Sorting a LinkedList often. sort dumps to an array, sorts and rebuilds the whole node chain. If you are going to sort frequently, use ArrayList; if you need permanent ordering, TreeSet or PriorityQueue (05-06, 05-07).
Tip: declare by the interface that reflects the use. If it is a list, List<X> l = new ArrayList<>(). If it is a queue, Deque<X> d = new ArrayDeque<>(). Declaring LinkedList<X> is only justified when you need both faces at once.
Tip: LinkedList is a magnificent mental exercise. Implementing it by hand —with nodes, prev and next— teaches more about pointers and data structures than many books. But in production, ArrayList.
Exercises
Exercise 1: an operation history with a maximum size
Write OperationHistory to hold the last N catalogue operations (strings such as "add:978-0000000001"), using a LinkedList as the internal structure:
void register(String operation): adds at the end and, if the maximum is exceeded, removes the oldest (the first).String latest()andString oldest(): without removing them, returningnullif it is empty.List<String> inReverseOrder(): from the most recent to the oldest, withdescendingIterator.int removeByPrefix(String prefix): removes all those starting with that prefix and returns how many it took out.
Justify in comments which operations are really O(1) and which are not.
Exercise 2: inserting separators with a ListIterator
Write a static method void insertSeparators(List<Material> catalog) that walks a catalogue already sorted by type and inserts, before the first material of each type, a special separator Material (use a Book with the title "--- BOOKS ---" or create a small class Separator extends Material).
It must work with a ListIterator and without causing ConcurrentModificationException or infinite loops. Explain why the insertion is not visited again.
Then add a version insertSeparatorsWrong that tries the same with a for-each and document exactly what fails.
Exercise 3: an honest comparison
Write Benchmark to compare ArrayList and LinkedList in four scenarios, with prior warm-up and with protection against the JIT removing the code:
- Adding N elements at the end.
- Traversing with
for-eachand summing. - Inserting 10,000 elements at the start.
- Removing by criterion with
removeIf.
Print a table with the times and write, in a final comment, your reasoned conclusion and the warning about JMH.
Solutions
Solution 1
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
/**
* A bounded history of catalogue operations.
*
* LinkedList fits here: all the usual operations are at the ends.
*/
public class OperationHistory {
private final LinkedList<String> operations = new LinkedList<>();
private final int max;
public OperationHistory(int max) {
this.max = Math.max(max, 1);
}
public void register(String operation) {
if (operation == null || operation.isBlank()) { return; }
operations.addLast(operation); // REALLY O(1): touches the 'last' node
if (operations.size() > max) {
operations.removeFirst(); // REALLY O(1): touches the 'first' node
}
// On an ArrayList, that removeFirst would be remove(0): O(n) because of the shift.
// This is exactly the scenario where the linked list makes sense.
}
/** peekLast returns null if empty; getLast would throw NoSuchElementException. */
public String latest() { return operations.peekLast(); }
public String oldest() { return operations.peekFirst(); }
/**
* descendingIterator walks from 'last' to 'first' using the prev links.
* It is O(n) in total, with a single pointer jump per element.
*/
public List<String> inReverseOrder() {
List<String> result = new ArrayList<>(operations.size());
Iterator<String> it = operations.descendingIterator();
while (it.hasNext()) {
result.add(it.next());
}
return result;
}
/**
* removeIf makes ONE O(n) pass and each individual removal is O(1),
* because the internal iterator is already positioned on the node.
* A loop with remove(Object) would be O(n^2) and would also fail with
* ConcurrentModificationException if it were done inside a for-each.
*/
public int removeByPrefix(String prefix) {
if (prefix == null) { return 0; }
int before = operations.size();
operations.removeIf(op -> op.startsWith(prefix));
return before - operations.size();
}
public int size() { return operations.size(); }
public boolean isEmpty() { return operations.isEmpty(); }
}A test:
OperationHistory h = new OperationHistory(3);
h.register("add:978-0000000001");
h.register("add:978-0000000002");
h.register("drop:978-0000000001");
h.register("add:978-0000000003"); // exceeds the maximum: the oldest falls out
System.out.println("Oldest: " + h.oldest()); // add:978-0000000002
System.out.println("Latest: " + h.latest()); // add:978-0000000003
System.out.println("Reverse: " + h.inReverseOrder());
System.out.println("Drops removed: " + h.removeByPrefix("drop:"));
System.out.println("Remaining: " + h.size());This is LinkedList's ideal use case: a sliding window. Each entry adds at one end and removes at the other, both genuinely O(1). With an ArrayList, every remove(0) would shift the whole list. That said, the professional answer for a sliding window is ArrayDeque (05-07), which does the same with a circular array, without nodes and without pressure on the collector.
Solution 2
package com.nexussoftware.bibliotech.presentation;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import com.nexussoftware.bibliotech.domain.Material;
public final class CatalogSeparators {
private CatalogSeparators() { }
/** A dummy material that only serves as a section heading. */
private static Material separator(String type) {
return new Book("--- " + type.toUpperCase() + " ---", "", "SEP-" + type, 2000);
}
/**
* Inserts a separator before the first material of each type.
* It requires the catalogue to be SORTED by type.
*/
public static void insertSeparators(List<Material> catalog) {
if (catalog == null || catalog.isEmpty()) { return; }
ListIterator<Material> it = catalog.listIterator();
String previousType = null;
while (it.hasNext()) {
Material m = it.next(); // the cursor is left AFTER m
String type = m.getType();
if (!type.equals(previousType)) {
// We step back to insert BEFORE m
it.previous(); // the cursor is BEFORE m again
it.add(separator(type)); // inserts and advances the cursor
it.next(); // we step over m again
previousType = type;
}
}
}
/**
* The INCORRECT version, to document the failure.
*/
public static void insertSeparatorsWrong(List<Material> catalog) {
String previousType = null;
for (Material m : catalog) {
if (!m.getType().equals(previousType)) {
catalog.add(separator(m.getType()));
// FAILURE: the for-each uses an internal Iterator that saved the modCount
// when it started. This add increments it. On the NEXT call to next()
// the iterator detects the discrepancy and throws
// ConcurrentModificationException.
//
// Besides, even if it did not fail, the add would ALWAYS append AT THE END,
// not at the right position: the result would be wrong anyway.
previousType = m.getType();
}
}
}
public static void main(String[] args) {
List<Material> catalog = new LinkedList<>(List.of(
new Dvd("Refactoring Live", "DVD-0007", 95),
new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018),
new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994),
new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly")
));
insertSeparators(catalog);
catalog.forEach(m -> System.out.println(m.getTitle()));
}
}--- DVD --- Refactoring Live --- BOOK --- Effective Java Design Patterns --- MAGAZINE --- Java Magazine
The sequence previous() → add(...) → next() deserves a careful explanation. After it.next() the cursor is behind the current material, but the separator has to go in front. previous() steps the cursor back to the previous position (and returns the same material, which we discard). add inserts there and leaves the cursor behind what was inserted, that is, still in front of the material. next() steps over it again to carry on.
That automatic cursor advance after add is exactly what prevents the infinite loop: the inserted element is never returned by next(). If add did not do it, the next next() would return the separator, whose type would not match previousType either, and another separator would be inserted, indefinitely.
And about the incorrect version, there are two stacked failures: the ConcurrentModificationException and, more fundamentally, the fact that catalog.add(x) inserts at the end, not where you are traversing. It is a good reminder that the for-each does not know its own position.
Solution 3
package com.nexussoftware.bibliotech.presentation;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Supplier;
/**
* An illustrative comparison between ArrayList and LinkedList.
*
* WARNING: this is NOT a rigorous benchmark. The JVM compiles the code
* on the fly (JIT), the garbage collector can step in halfway through
* the measurement and the compiler can remove loops whose result is not
* used. To measure seriously you have to use JMH (Java Microbenchmark
* Harness), OpenJDK's official tool. These figures are good for seeing
* ORDERS OF MAGNITUDE, nothing more.
*/
public class Benchmark {
private static final int N = 100_000;
private static final int INSERTIONS = 10_000;
private static long shield = 0; // stops the JIT removing the loops
public static void main(String[] args) {
System.out.println("Warming up the JVM (3 passes without measuring)...");
for (int i = 0; i < 3; i++) { execute(false); }
System.out.println("\n=== Results (N = " + N + ") ===");
System.out.printf("%-28s %12s %12s%n", "Scenario", "ArrayList", "LinkedList");
System.out.println("-".repeat(54));
execute(true);
System.out.println("\n(shield = " + shield + ", ignore it: it only stops the JIT deleting the loops)");
}
private static void execute(boolean print) {
row("1. add at the end", print,
() -> addAtEnd(new ArrayList<>()),
() -> addAtEnd(new LinkedList<>()));
List<Integer> a1 = fill(new ArrayList<>());
List<Integer> l1 = fill(new LinkedList<>());
row("2. traverse with for-each", print,
() -> traverse(a1), () -> traverse(l1));
row("3. insert at the start", print,
() -> insertAtStart(fill(new ArrayList<>())),
() -> insertAtStart(fill(new LinkedList<>())));
row("4. removeIf even numbers", print,
() -> purge(fill(new ArrayList<>())),
() -> purge(fill(new LinkedList<>())));
}
private static void row(String label, boolean print,
Supplier<Long> withArray, Supplier<Long> withLinked) {
long ta = withArray.get();
long tl = withLinked.get();
if (print) {
System.out.printf("%-28s %9d ms %9d ms%n", label, ta, tl);
}
}
private static List<Integer> fill(List<Integer> list) {
for (int i = 0; i < N; i++) { list.add(i); }
return list;
}
private static long addAtEnd(List<Integer> list) {
long t = System.nanoTime();
for (int i = 0; i < N; i++) { list.add(i); }
return ms(t);
}
private static long traverse(List<Integer> list) {
long t = System.nanoTime();
long sum = 0;
for (Integer v : list) { sum += v; }
shield += sum; // we consume the result
return ms(t);
}
private static long insertAtStart(List<Integer> list) {
long t = System.nanoTime();
for (int i = 0; i < INSERTIONS; i++) { list.add(0, i); }
return ms(t);
}
private static long purge(List<Integer> list) {
long t = System.nanoTime();
list.removeIf(v -> v % 2 == 0);
shield += list.size();
return ms(t);
}
private static long ms(long startNano) {
return (System.nanoTime() - startNano) / 1_000_000;
}
}Typical output (the absolute values vary a lot depending on the machine; what matters is the ratio):
=== Results (N = 100000) === Scenario ArrayList LinkedList ------------------------------------------------------ 1. add at the end 3 ms 6 ms 2. traverse with for-each 1 ms 7 ms 3. insert at the start 118 ms 2 ms 4. removeIf even numbers 2 ms 11 ms
Reasoned conclusion. LinkedList wins in a single scenario, number 3, and it wins by a lot: inserting at the start is its natural ground. In the other three it loses, and in traversal —the most frequent operation in any real program— it is about seven times slower with the same O(n) complexity: the difference is purely cache locality.
Scenario 4 is especially revealing. removeIf is O(n) on both and removes in O(1) on both (via the iterator), yet LinkedList still loses, because walking the scattered nodes dominates the total time.
And the decisive observation: if you add an ArrayDeque with addFirst to the test bench, scenario 3 drops to roughly 1 ms, beating them both. That is, even in the one case where LinkedList beats ArrayList, it is not the best tool available. That is why the practical recommendation is: ArrayList for lists, ArrayDeque for queues and stacks, LinkedList hardly ever.
Repeat the warning with every number you see: for real performance decisions, measure with JMH on your specific workload.
Conclusion
You now know what a doubly linked list is: a chain of nodes with prev, item and next, with no array, no capacity and no indices. You understand what that really implies: ~28 bytes per element against the ~4 of an ArrayList, and nodes scattered around the heap that destroy cache locality and make traversing it several times slower even though the complexity is the same O(n). You have seen in the JDK's own code that node(index) walks the chain, and with that you have dismantled the myth: inserting and deleting is O(1) only if you already have the position, which happens only at the ends and through an iterator; getting there by index costs O(n).
You have the operation-by-operation comparison table and the honest conclusion that follows from it: ArrayList wins almost always, because System.arraycopy is a native block instruction, because the cache dominates, because nodes put pressure on the collector and because insertions "in the middle" almost never are. You know that the Framework's own co-author considers it dispensable today, and that its real niche is not being a List but being a Deque, ground on which ArrayDeque beats it. You also know how to spot the performance mistake that slips in most often: an indexed for over a LinkedList is disguised O(n²).
You know its dual List + Deque nature and the whole family of end operations, with the distinction between the variant that throws an exception (getFirst, removeFirst) and the one that returns null (peekFirst, pollFirst) —which 05-07 will develop—. And you have mastered the ListIterator: the cursor that lives between elements, the two-way traversal, add and set during the traversal, why what is inserted is not visited again and why it is the only correct way of inserting while you traverse.
You know how to measure honestly: warm up the JVM, consume the results so that the JIT does not remove the loops, distrust absolute numbers and turn to JMH when the decision really matters. And above all, you have adopted the professional criterion: measure before optimising, because swapping ArrayList for LinkedList "because it inserts faster" is, almost always, making the program worse.
BiblioTech has gained the Reservation class and a ReservationQueue that enqueues at the back, serves at the front, cancels through a descending iterator in real O(1) and expires old reservations with removeIf. It is the only point in the project where a linked list was justified, and even so you have seen why in 05-07 you will rewrite it with ArrayDeque.
And one weakness remains that is becoming unbearable. Catalog.remove(reference) walks the whole list. LoanManager.returnItem(reference, day) walks the whole list. ReservationQueue.positionOf(employee) walks the whole list. Every time BiblioTech has to find something by its identifier, it looks at them one by one. With five materials it is free; with fifty thousand, every search is fifty thousand comparisons, and grouping loans by employee with nested loops is O(n²).
In the next lesson, HashMap, that ends. You will see the structure that finds an element by its key in constant time no matter how many there are: how the hash function works, what buckets are, what happens when two keys collide, why the load factor is 0.75 and what happens during a rehash. And there the promise made in module 3 will finally be kept: you will see with a practical demonstration why equals and hashCode always had to go together, and exactly what happens to an object that enters a map with one of the two badly implemented.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
