skip to content
everything.deepDives.arraylistVsLinkedlist

ArrayList vs LinkedList, measured

big-O says one thing, the CPU cache says another.

chapter
chapter 17.11
reading time
9 min read

big-O counts steps. the CPU charges for memory: how many bytes you touch, whether they're next to each other, and whether it can see the next address coming. an ArrayList is one array of references. a LinkedList is a 24-byte node per element, and walking it is pointer chasing.

measured on jdk 25: iteration is 4.7x slower on a fresh LinkedList and 26x slower on a shuffled one, get(i) is 61,000x slower, and even "insert in the middle", the textbook win, loses 27 to 1 unless you're already standing there with an iterator.

the picture

you know the table every data structures course hands out? LinkedList: O(1) insert, O(n) access. ArrayList: O(1) access, O(n) insert. so for anything insert-heavy, linked list, right? that table counts how many steps each operation takes. it says nothing about how long a step takes, and on a modern CPU the answer ranges from a fraction of a nanosecond (data already in cache, or on its way) to around a hundred (a trip to main memory).

an array is a row of houses on one street: walk along and the next door is always right there, and you can see the next ten doors coming. a linked list is a scavenger hunt: each house has a note with the address of the next one, which could be across town, and you can't even start walking until you've read the note.

the basics (what each list holds, the node layout, ArrayDeque) are in ArrayList and LinkedList and ArrayDeque. this chapter measures.

big-O vs the stopwatch

four operations, measured on the same machine, each in a fresh JVM. pick one and compare what big-O predicts with what happened.

per get, random index, 100,000 elements · log scale
ArrayListbig-O: O(1)
0.66 ns
LinkedListbig-O: O(n)
40.6 µs

the one big-O gets right, and then some: 61,000 times slower. LinkedList.get walks from the nearer end, about 25,000 nodes per call here, at 1.6 ns a hop.

pick an operation. bars are log scale: each step to the right is about 10x slower. green is the winner, red the loser, amber is LinkedList's one real trick. jdk 25, apple M4 Pro, median of three runs.

two of the four go the way big-O says (get(i) and adding at the front). the other two are where the CPU disagrees with the textbook. iteration is the big one, because it's what most code does most of the time, and its big-O is identical for both lists.

why iteration isn't equal

here are the addresses a for-each loop actually reads, in order, for 32 elements.

03206409601280 Bread number →each band = one 64-byte cache line
cache lines, 32 elements
11
next address known early?
yes: slot i + 1
measured, per element
0.34 ns
slot or node the Integer it points at the next read

two streams, both marching forward: the slots of the array, and the Integers, which were allocated in the same order. the CPU sees the pattern and prefetches ahead, and since the next slot's address is just i + 1, it can start several loads at once.

switch layouts. each dot is one memory read, in order. the blue line is the path from one read to the next. same 32 numbers in all three, very different paths through memory.

three things add up:

  1. bytes per element. an ArrayList reads a 4-byte slot and the 16-byte Integer. a LinkedList reads a 24-byte node and the Integer. twice the memory traffic for the same data.
  2. dependent loads. in an array, the address of slot i + 1 is known before you finish with slot i, so the CPU starts several loads at once. in a linked list, the address of the next node is inside the current node, so each load waits for the one before it. no overlap.
  3. order. a fresh LinkedList was allocated in order, so its nodes sit next to each other and the hardware prefetcher keeps up. a list that's been built by inserting at random positions (or has lived through enough add and remove in the middle) points all over the heap, and every hop is a guess the prefetcher gets wrong.

the collections chapter measured the fresh case and noted the gap would grow as nodes scatter. here's how much: 1.6 ns per element fresh, 8.9 shuffled, against 0.34 for the ArrayList. a garbage collector that copies objects can sometimes put a list's nodes back in order, which is why the shuffled number is a bad day, not a constant.

memory per element

measured with jcmd GC.class_histogram on a million Integers (values from 1,000 up, so none come from the integer cache):

ArrayList20.9 B
LinkedList40 B
ArrayList, no compressed oops25.7 B
LinkedList, no compressed oops56 B
bytes per element, including the Integer (16 bytes) each element points at. ArrayList's array had grown to 1,215,487 slots for 1,000,000 elements, so its slot cost includes 22% slack. compact object headers (-XX:+UseCompactObjectHeaders) changed nothing here: an 8-byte header plus a node's 12 bytes of fields still rounds up to 24.

so a LinkedList roughly doubles the memory per element, and turning off compressed references (above a 32 GB heap they turn off by themselves, see object layout) makes the gap wider, since a node has three references and an array slot has one.

where LinkedList actually wins

removing elements while you iterate. LinkedList's iterator unlinks a node in O(1); ArrayList's shifts everything after it, every time.

quick check
remove every even value from a 100,000-element list (built fresh, values 1,000 and up). which way is about 700 times slower than the other three?

so LinkedList is genuinely good at exactly one thing: changing a list at a spot you're already standing on, through a ListIterator. 9.7 ns for an insert and remove in the middle, against 6 µs for ArrayList. if your algorithm does a lot of that, on a big list, it's a real win. in practice that's rare enough that the person who wrote LinkedList, Joshua Bloch, has said he never uses it.

how these were measured

no JMH, so the harness had to be careful on its own. the core of it:

Bench.java
// runs op() in rounds of `reps` calls; the first 5 rounds are warmup; prints the median round
static void time(String name, int reps, long opsPerCall, LongSupplier op) {
    int rounds = 21;
    double[] ns = new double[rounds - 5];
    for (int r = 0; r < rounds; r++) {
        long t0 = System.nanoTime();
        long acc = 0;
        for (int i = 0; i < reps; i++) acc += op.getAsLong();   // results feed a sum...
        long t = System.nanoTime() - t0;
        sink += acc;                                            // ...that escapes, so the JIT can't delete the work
        if (r >= 5) ns[r - 5] = (double) t / reps / opsPerCall;
    }
    Arrays.sort(ns);
    System.out.printf("%-28s %10.2f ns/op%n", name, ns[ns.length / 2]);
}
terminal
java -Xms4g -Xmx4g -Xmn2g -cp . Bench iter-ll-scattered 100000
  • one case per JVM. if one method calls get on both lists, the call site becomes polymorphic and the JIT optimizes it worse for both. a fresh JVM per case keeps every measurement monomorphic.
  • warmup rounds thrown away, so the code is compiled by C2 before anything counts.
  • a sink. every result is added into a static volatile field. without that, C2 is allowed to notice nobody uses the sum and delete the loop.
  • a huge young gen (-Xmn2g), so no GC runs in the middle of a measurement and moves the shuffled nodes back into order.
  • median, three runs, busy machine. the laptop was running other work (load average 10 to 19), so single runs wobbled, one case by almost 2x between runs. the ratios barely moved.

for anything you'd publish or base a decision on, use JMH. it does all of this and more (forking, blackholes, statistics), and it catches the mistakes you didn't think of.

keylines
  • big-O counts steps. the CPU charges for bytes touched and for loads it can't predict.
  • ArrayList iterates 4.7x faster than a fresh LinkedList, 26x faster than a shuffled one.
  • LinkedList.get(i) walks. 40 µs per call at 100,000 elements.
  • insert in the middle by index: ArrayList wins 27 to 1. with an iterator already there, LinkedList wins.
  • a LinkedList costs about 40 bytes per element, an ArrayList about 21.
  • measure with JMH. if you can't, one case per JVM, warmup, a sink, and a median.