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.
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.
The problem
List makes three promises that Collection can’t make.
| Promise | What it gives you | |
|---|---|---|
| Insertion order | things stay where you put them | walk it twice, same order |
| Positions | elements are numbered | get(3), set(3, e), add(3, e), indexOf(e) |
| Duplicates allowed | no uniqueness rule | add 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.
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
Heap
ArrayList@1a2b
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
Heap
Node@1a2b
Node@283c
Node@364d
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.
Under the hood
Going deeperThe measurements
You’ll hear a rule of thumb: ArrayList for reading, LinkedList for writing. Let’s check whether it holds.
| Operation | ArrayList vs LinkedList | |
|---|---|---|
| get(i) in the middle, 20,000 times | ArrayList 0 ms | LinkedList 1681 ms |
| add at the FRONT, 40,000 times | ArrayList 69 ms | LinkedList 2 ms |
| add at the END, 1,000,000 times | ArrayList 30 ms | LinkedList 53 ms |
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
- It copies rarelyEach new array is about double the last one, so a million additions cause around twenty copies rather than a million.
- 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.
- LinkedList allocates every timeA million elements means a million Node objects, each with a 12 byte header and two references, from Section 6.3.
- 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.
| ArrayList | Vector | |
|---|---|---|
| Thread safe | no | yes, every method locks |
| Speed | faster | slower, it pays for locking you rarely need |
| Growth | about 50 percent bigger | doubles |
| Use it | yes | no |
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 topWe 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 pointThere’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 itset 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.
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.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
"LinkedList is better when you add a lot." Measure adding a million items to the end of each. What actually happens?
Show the answer
ArrayListwins. Measured on a normal machine, a million additions at the end took about 30 ms forArrayListand about 53 ms forLinkedList.That surprises people, because an
ArrayListsometimes 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
LinkedListmakes 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.LinkedListdoes 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.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
StackextendsVector, it inherits everyListmethod, including the ones that reach into the middle. Sostack.add(0, 99)inserts at the bottom, andstack.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
ArrayDequeinstead. It haspushandpopand noget(i)at all.When is a `LinkedList` genuinely the right choice?
Show the answer
Rarely, and the honest answer is worth saying plainly. Most of the time
ArrayListis the right choice, and people reach forLinkedListout 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,
ArrayDequeis 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
LinkedListmeans finding the middle first, and that walk is the expensive part. You gain nothing.
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
Test the Folklore
ex-10-4-aTest 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
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
Hint 2
Hint 3almost the answer
Break a Stack From the Bottom
ex-10-4-bReach 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
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
Stack and you get 1, 2, 3. Iterate an ArrayDeque you pushed onto and you get 3, 2, 1.Hint 3almost the answer
subList Is a Window
ex-10-4-csubList 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
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
Hint 3almost the answer
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.Choose For a Real Workload
ex-10-4-dFour 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
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
Hint 2
Hint 3almost the answer
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
- ListCollections.sort works on a List, and asks your class for an order.10.8 - Comparable, Comparator, and Sorting
List will return in 10.5 - Set and Map: Inside HashMap