Layers of Logic

10.4

List: ArrayList, LinkedList, Vector, Stack

Four classes implement List. You should use two of them. Java keeps the other two because deleting them would break code written in 1997.

Core19 min read4 exercises
01

Previously on

In Section 10.3 you met Collection, the interface that every collection in Java implements. It deliberately says nothing about position. A Collection can tell you how many things it holds, and whether some particular thing is inside it. It can’t tell you what’s third.

For plenty of work that’s fine. For plenty of other work it isn’t. When you keep a roster of units, the order matters, and so does being able to ask for unit number three.

List is where position comes back. It’s also where we finally settle the question left open in Section 4.2: contiguous memory against scattered nodes. This time with measurements.

02

The problem

List makes three promises that Collection can’t make.

PromiseWhat it gives you
Insertion orderthings stay where you put themwalk it twice, same order
Positionselements are numberedget(3), set(3, e), add(3, e), indexOf(e)
Duplicates allowedno uniqueness ruleadd the same value twice, get it twice

Four classes in the standard library make those three promises: ArrayList, LinkedList, Vector and Stack. You can store any of them in a List variable, and any of them will pass the same tests.

Here’s the difficulty. Because they all keep the same promises, your code can’t tell them apart. Every method does what List says it should do. The only thing that changes is how long each method takes.

That difference can be a thousand times over, and nothing in the compiler or in your tests will warn you about it. So the choice is yours, and you have to make it from what your program actually does.

03

The idea

ArrayList: an array that regrows

Start with what an ArrayList has to do. It has to hold things in order, let you reach any position quickly, and keep working when you add more things than it currently has room for.

It does all of that with a single array. When the array fills up, the ArrayList makes a bigger one and copies everything across. You built this by hand in the Section 4.2 exercises, so you already know what’s happening inside.

Stack

main

ArrayList list@1a2b

Heap

ArrayList@1a2b

02
13
24
35
4
5
One backing array with spare logical capacity. Position 3 is reached directly through indexed array access.

ArrayList.get(3) delegates to indexed access in its backing array. It does not walk positions 0, 1, and 2 first, so the operation is constant time whether the list holds ten items or ten million. Mainstream JVMs also lay out array elements compactly, which often gives an ArrayList good cache locality. That physical layout is an implementation observation, not a raw-address guarantee in Java source.

That same packing costs you at the front. Inserting at position 0 means every other element has to shift one place to the right to make room.

LinkedList: nodes joined by references

A LinkedList solves the same problem in a different way, and it starts from a different worry. What if you don’t want to shift anything when you insert?

So it doesn’t keep the elements together at all. Each element sits in its own small object, called a node. Each node holds the value and a reference to the next node. Java’s version is doubly linked, which means each node points backwards as well as forwards.

Stack

main

LinkedList list@1a2b

Heap

Node@1a2b

item2
next@283c

Node@283c

item3
next@364d

Node@364d

item4
nextnull
Three separate objects, anywhere on the heap. Each one only knows the next. There are no positions to calculate.

Now inserting between two nodes costs three reference changes and no shifting at all. Nothing moves.

You pay for that somewhere else. There are no positions to calculate any more, so reaching position 5000 means starting at the front and following 5000 references, one at a time.

04

Under the hood

Going deeper

The measurements

You’ll hear a rule of thumb: ArrayList for reading, LinkedList for writing. Let’s check whether it holds.

OperationArrayList vs LinkedList
get(i) in the middle, 20,000 timesArrayList 0 msLinkedList 1681 ms
add at the FRONT, 40,000 timesArrayList 69 msLinkedList 2 ms
add at the END, 1,000,000 timesArrayList 30 msLinkedList 53 ms
Two of these match the rule of thumb. The third one doesn't.

The first two rows behave the way you’d expect. The third row is worth slowing down on.

ArrayList is faster at adding to the end, even though it sometimes has to copy its whole array.

That sounds wrong the first time you read it, so here’s why it happens.

Why ArrayList wins at adding to the end

  1. It copies rarelyEach new array is about double the last one, so a million additions cause around twenty copies rather than a million.
  2. Copying a block is fastMoving a contiguous run of memory is one of the things processors are best at. It is not a loop of individual assignments.
  3. LinkedList allocates every timeA million elements means a million Node objects, each with a 12 byte header and two references, from Section 6.3.
  4. And they scatterThose nodes land anywhere on the heap, so walking them misses the cache constantly. That is the Section 4.2 measurement showing up again.

So the true version of the rule is narrower than the folklore.

Vector: the one you shouldn’t use

Vector is an ArrayList where every method is synchronized. It came from Java 1.0, before the collections framework existed, back when Java made things thread safe by default and let you pay for that whether you needed it or not.

ArrayListVector
Thread safenoyes, every method locks
Speedfasterslower, it pays for locking you rarely need
Growthabout 50 percent biggerdoubles
Use ityesno

You’ve seen this story before. StringBuilder and StringBuffer in Section 9.2 have the same shape: an early design that locked everything, a later design that didn’t, and the old class kept so that old code still compiles.

There’s a second problem with Vector, and it’s the more interesting one. It isn’t as thread safe as it looks.

if (!vector.contains(x)) {     // lock, check, unlock
    vector.add(x);             // lock, add, unlock
}

Each method locks on its own. The pair of them doesn’t. Another thread can slip in between those two lines and add x first, and now you have it twice. You’ve paid for the locking and you didn’t get the safety you wanted.

Real concurrent code uses Collections.synchronizedList with an explicit block around the whole sequence, or it uses CopyOnWriteArrayList. Both of those are Phase XIV.

Stack: a design mistake Java can’t delete

Before we look at the code, let’s be clear about what a stack is supposed to do.

A stack does exactly one job. The last item you put in is the first item you take out. You add to the top, and you remove from the top. You never reach into the middle. This is called last in, first out.

Now let’s look at how Java declares its Stack class:

public class Stack<E> extends Vector<E>

Notice that Stack extends Vector. A Vector is a List, so Stack inherits every method a list has. That includes the methods that reach into the middle, and that’s a problem. Look:

Stack<Integer> st = new Stack<>();
st.push(1); st.push(2); st.push(3);

st.add(0, 99);            // inserts at the BOTTOM
System.out.println(st);   // [99, 1, 2, 3]
st.get(0);                // reads past the top

We asked for a stack, and Java gave us something we can insert into the bottom of. Both of those calls defeat the point of having a stack at all.

This is the substitution test from Section 7.1 failing. Whoever wrote Stack used inheritance to borrow Vector’s code, not because a stack really is a kind of list. It’s the same mistake as Square extends Rectangle, except this one shipped in the standard library and can’t be taken back.

When you want a stack, use ArrayDeque:

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
stack.pop();       // 2
// stack.get(0);   doesn't exist, and that's the point

There’s no get(i) and no add(index, e), so this class can’t be misused the way Stack can. It’s faster than Stack as well.

The methods that only a List has

list.get(2);              // read by position
list.set(2, 99);          // replace at position
list.add(2, 99);          // insert at position, shifting the rest right
list.remove(2);           // remove by position
list.indexOf(99);         // first position of a value, or -1
list.subList(1, 3);       // a view of part of it

set and add are the pair worth keeping straight. set(2, 99) replaces whatever is at position 2, and the size doesn’t change. add(2, 99) inserts at position 2, and the size grows by one.

05

What it costs

Choosing wrong is silent, and that’s the part to remember. Both classes pass every test you write. Nothing warns you. Only the clock tells you, and often only once there’s real load on the program.

ArrayList wastes some memory on purpose. It keeps spare capacity so it doesn’t have to grow on every addition, so a list holding 100 things might have room for 150.

LinkedList wastes a lot more. Every element costs a Node object, which is a 12 byte header plus two references plus the value, for something that takes 4 bytes inside an array. That works out at roughly six times the memory.

Two of the four classes in this section exist only so that old code keeps compiling. You still need to recognise them, because they turn up in tutorials and in code you’ll be asked to maintain.

There’s one more thing List can’t do for you. It promises nothing about speed. A method that takes a List has no way of knowing whether get(i) is instant or a walk from the front. RandomAccess from Section 8.5 exists because the interface couldn’t say it.

What you get in exchange is the ability to change your mind by editing one word, and a default that’s right most of the time.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. "LinkedList is better when you add a lot." Measure adding a million items to the end of each. What actually happens?

    Show the answer

    ArrayList wins. Measured on a normal machine, a million additions at the end took about 30 ms for ArrayList and about 53 ms for LinkedList.

    That surprises people, because an ArrayList sometimes has to copy its whole array. But it only does that a handful of times. Each new array is roughly double the last one, so a million additions cause around twenty copies. Copying a block of memory is something processors are very good at.

    Meanwhile a LinkedList makes a new node object for every single element. A million elements means a million objects, each one with a header and two references, scattered around the heap.

    LinkedList does win at adding to the front, where it's about 30 times faster. That's a much narrower claim than the folklore, and it's the true one.

  2. Why is `Stack extends Vector` considered a design mistake?

    Show the answer

    Because it makes a stack a List, and a stack shouldn't be one.

    A stack does exactly one job. The last item you put in is the first item you take out. You add to the top and you remove from the top, and you never reach into the middle.

    Because Stack extends Vector, it inherits every List method, including the ones that reach into the middle. So stack.add(0, 99) inserts at the bottom, and stack.get(0) reads past the top. Both of those defeat the point of having a stack.

    This is the substitution test from Section 7.1 failing. Inheritance was used to borrow code, not because a stack really is a kind of list. Use ArrayDeque instead. It has push and pop and no get(i) at all.

  3. When is a `LinkedList` genuinely the right choice?

    Show the answer

    Rarely, and the honest answer is worth saying plainly. Most of the time ArrayList is the right choice, and people reach for LinkedList out of habit rather than from measuring.

    It wins when you add and remove at the ends often and almost never look things up by position. A queue is the classic case.

    Even then, ArrayDeque is usually faster, because it uses one array instead of a million small node objects.

    The trap is thinking "I insert in the middle a lot, so I'll use a LinkedList". Inserting in the middle of a LinkedList means finding the middle first, and that walk is the expensive part. You gain nothing.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises100 pointsabout 105 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Test the Folklore

Real work·30 min·25 points

checkedex-10-4-a

Test what everybody says about ArrayList and LinkedList.

Predict all three before running. Two of your predictions will be right.

The third is adding at the end, and it is worth sitting with. ArrayList sometimes has to copy its entire array, and it still wins, comfortably.

Explain that result properly. The answer involves how often the copy happens, how fast a block copy is, and how much a million small objects cost. All three are things you measured in earlier phases.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Folklore {
    static long time(Runnable r)
    static long timeRandomAccess(List<Integer> l, int rounds)
    static long timeAddFront(List<Integer> l, int rounds)
    static long timeAddEnd(List<Integer> l, int rounds)
}

Each timing method does the work on the list it is given and returns milliseconds. timeAddFront must use add(0, i) and timeAddEnd must use add(i), because which end you touch is the entire experiment.

What your program must do

  • Predict all three comparisons before running
  • Implement the four methods and record the six numbers
  • Identify which result contradicts the usual advice
  • Explain that result using object allocation and how memory is laid out
Folklore.java
import java.util.*;

public class Folklore {

    static long time(Runnable r) {
        return 0; // TODO
    }

    // Read the middle element, rounds times.
    static long timeRandomAccess(List<Integer> l, int rounds) {
        return 0; // TODO
    }

    // add(0, i), rounds times.
    static long timeAddFront(List<Integer> l, int rounds) {
        return 0; // TODO
    }

    // add(i), rounds times.
    static long timeAddEnd(List<Integer> l, int rounds) {
        return 0; // TODO
    }

    public static void main(String[] args) {
        // Predict all three comparisons BEFORE running.
        // TODO: get(n/2) 20_000 times on a 100_000 element ArrayList and LinkedList
        // TODO: add(0, i) 40_000 times on fresh lists
        // TODO: add(i) 1_000_000 times on fresh lists
    }
}
Hint 1
The two you will get right: reading the middle favours ArrayList, and adding at the front favours LinkedList. Both by a lot.
Hint 2
The third one is the surprise. Adding at the end is supposed to favour LinkedList, because ArrayList has to resize. It does not.
Hint 3almost the answer
LinkedList allocates a Node object for every single element, and those objects land wherever the heap has room. ArrayList writes into one block of memory, and the processor reads a block far faster than it chases addresses. Resizing costs less than that scattering does.
What this is really testing

Whether "LinkedList is better for adding" survives measurement. One of the three rows disagrees with what almost everyone believes.

B

Break a Stack From the Bottom

Real work·25 min·25 points

checkedex-10-4-b

Reach into the middle of a stack, which should be impossible.

Insert at the bottom. Read an element without popping it. Both compile, both run, and both defeat what a stack is for.

Then do the same operations on an ArrayDeque and find that the methods do not exist. That absence is the feature.

Finish by connecting it back to Section 7.1. This is the same failure as Square extends Rectangle, and it is sitting in the standard library because Java cannot delete it without breaking code written in 1997.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class BrokenStack {
    static List<Integer> stackBottomInsert()
    static int stackReadBottom()
    static List<Integer> stackIterationOrder()
    static List<Integer> dequeIterationOrder()
}

Every one of these pushes 1, then 2, then 3, and then does one thing to the result. The first three use java.util.Stack. The last uses ArrayDeque with push, so you can compare the orders.

What your program must do

  • Insert at the bottom of a Stack and show the result
  • Read the bottom element without popping
  • Compare the iteration order of Stack and ArrayDeque
  • State the substitution test and say exactly how Stack fails it
BrokenStack.java
import java.util.*;

public class BrokenStack {

    // Push 1, 2, 3. Then insert 99 at the BOTTOM. Return the whole thing as a list.
    static List<Integer> stackBottomInsert() {
        return List.of(); // TODO
    }

    // Push 1, 2, 3. Then read the bottom element WITHOUT popping.
    static int stackReadBottom() {
        return 0; // TODO
    }

    // Push 1, 2, 3. Return the order a for-each loop gives you.
    static List<Integer> stackIterationOrder() {
        return List.of(); // TODO
    }

    // Same three pushes on an ArrayDeque. Return the order it gives you.
    static List<Integer> dequeIterationOrder() {
        return List.of(); // TODO
    }

    public static void main(String[] args) {
        // TODO: print all four and compare the last two carefully
        // TODO: state the substitution test and say exactly how Stack fails it
    }
}
Hint 1
Stack extends Vector, so it inherited every List method: add(index, value), get(index), set, all of them.
Hint 2
A stack should hand back what you pushed last, first. Iterate a Stack and you get 1, 2, 3. Iterate an ArrayDeque you pushed onto and you get 3, 2, 1.
Hint 3almost the answer
The substitution test says a subclass must work anywhere its parent works, without surprising anyone. Stack passes that test as a Vector and fails as a stack, because it allows operations a stack does not have and reports its contents backwards.
What this is really testing

Whether you can see a substitution failure in the standard library. Stack extends Vector is the Square extends Rectangle mistake, shipped.

C

subList Is a Window

Real work·20 min·20 points

checkedex-10-4-c

subList looks like it gives you part of a list. It gives you a window onto the same list.

Change something through the window and watch the original change. Then clear the window and watch three elements vanish from the original.

Then do it the other way round: change the original’s size and touch the window afterwards. You get an exception, and the reason is the same modification counter from Section 10.2.

This is the aliasing problem from Section 6.3, in a method whose name sounds like it makes a copy.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Views {
    static List<String> setThroughView(List<String> units)
    static List<String> clearThroughView(List<String> units)
    static String touchAfterResize(List<String> units)
    static List<String> realCopy(List<String> units)
}

All four take subList(1, 4). The first three return what happened to the original. touchAfterResize returns "ok" or "ConcurrentModificationException". realCopy returns a copy it changed, and must leave the original alone.

What your program must do

  • Show that changing the sublist changes the original
  • Show that clearing the sublist removes those elements from the original
  • Show what happens to a sublist after the original changes size
  • Make a real copy and prove it is independent
Views.java
import java.util.*;

public class Views {

    // subList(1, 4), then set(0, "CHANGED"). Return units.
    static List<String> setThroughView(List<String> units) {
        return units; // TODO
    }

    // subList(1, 4), then clear(). Return units.
    static List<String> clearThroughView(List<String> units) {
        return units; // TODO
    }

    // subList(1, 4), then add something to units, then touch the sublist.
    // Return "ok" or "ConcurrentModificationException".
    static String touchAfterResize(List<String> units) {
        return "ok"; // TODO
    }

    // A real copy of subList(1, 4). Change the copy. units must not move.
    static List<String> realCopy(List<String> units) {
        return List.of(); // TODO
    }

    public static void main(String[] args) {
        // TODO: run all four and watch what leaks into the original
    }
}
Hint 1
subList does not copy anything. It hands back a small object that remembers the list and the two positions, and every read and write goes to the list underneath.
Hint 2
The view also remembers the list's change count. Adding to the original bumps that count, and the next call on the view notices and throws.
Hint 3almost the answer
A real copy is new ArrayList<>(units.subList(1, 4)). The constructor walks the view once and copies the references into a list of its own, and after that the two have nothing to do with each other.
What this is really testing

Whether you spot a view pretending to be a copy. This is the Section 6.3 aliasing problem in a place nobody looks for it.

D

Choose For a Real Workload

Hard·30 min·30 points·The Registry

checkedex-10-4-d

Four workloads, and the right implementation is not the same one each time.

Predict every winner before you measure. Then measure, and be honest about which predictions were wrong.

Workload B is the one that matters most, because a job queue is a shape you will actually build. Include ArrayDeque there. It beats both list implementations, and knowing that saves you from reaching for LinkedList out of habit.

Write one sentence per workload explaining the winner in terms of memory layout. Those four sentences are worth more than the timings.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Workloads {
    static long time(Runnable r)
    static long timeRandomAccess(List<Integer> l, int rounds)
    static long timeQueue(Deque<Integer> q, int rounds)
    static long timeInsertFront(List<Integer> l, int rounds)
    static long timeWalk(Iterable<Integer> l, int rounds)
    static String bestFor(String workload)
}

timeQueue adds at the end each round and removes from the front once the queue passes 100, so it settles at 100. bestFor takes "A", "B", "C" or "D" and answers with a class name: ArrayList, LinkedList or ArrayDeque.

What your program must do

  • Predict a winner for each of the four workloads before measuring
  • Measure all four with at least two implementations each
  • Include ArrayDeque in workload B and record where it lands
  • Fill in bestFor only after you have the numbers, and write one sentence per workload
Workloads.java
import java.util.*;

public class Workloads {

    // A. Load 200,000 units once, then look up by position constantly.
    // B. A job queue: add at the end, remove from the front, forever.
    // C. Build a roster by inserting each new unit at the front.
    // D. Walk the whole roster repeatedly, never touching positions.

    static long time(Runnable r) {
        return 0; // TODO
    }

    static long timeRandomAccess(List<Integer> l, int rounds) {
        return 0; // TODO
    }

    // Add at the end every round. Once the queue passes 100, remove from the front.
    static long timeQueue(Deque<Integer> q, int rounds) {
        return 0; // TODO
    }

    static long timeInsertFront(List<Integer> l, int rounds) {
        return 0; // TODO
    }

    static long timeWalk(Iterable<Integer> l, int rounds) {
        return 0; // TODO
    }

    // "A", "B", "C" or "D" -> ArrayList, LinkedList or ArrayDeque.
    static String bestFor(String workload) {
        return "unknown"; // TODO: answer after you have measured
    }

    public static void main(String[] args) {
        // TODO: predict each winner, then measure all four
    }
}
Hint 1
A and C are the two you already measured in the previous exercise. They go opposite ways.
Hint 2
B is the interesting one, because the honest answer is neither List. ArrayList is slow removing the front, and LinkedList allocates a node per job.
Hint 3almost the answer
D looks like a tie, because both walk the same number of steps. It is not a tie. ArrayList's elements sit next to each other in memory, and the processor reads a block far faster than it chases scattered addresses.
What this is really testing

Whether you can pick an implementation from the shape of the work rather than from habit. Four workloads, and the right answer is not the same one every time.

08

After the credits

A List will happily hold the same thing twice. And when you ask a List whether it contains something, it has to walk every element to find out.

Section 10.5 is the section this whole course has been building towards. Set refuses duplicates, and Map finds a value by key, and both of them do it without searching.

They manage that by calling your hashCode() from Section 8.3 to work out where the answer would be, and then your equals() to confirm it.

You’ll open the source of HashMap and find only things you already know: an array of buckets (4.2), hash & (n - 1) to choose one (3.1), >>> to mix the high bits down (2.2), and static class Node holding each entry (7.4).

And the bug you made happen on purpose in the Section 8.3 exercises will finally be standing in its natural habitat.

Threads you opened in this section