Lists: ArrayList vs LinkedList

Why ArrayList wins almost every time, what amortised O(1) means, and the one workload where LinkedList is not a mistake.

11 min read🗂️ Collections and Generics

"ArrayList or LinkedList?" is asked in every interview and answered wrongly in most textbooks. The textbook says LinkedList is O(1) for insertion and ArrayList is O(n), so use LinkedList when you insert a lot. On a real CPU, ArrayList wins almost every benchmark, including most of the insertion-heavy ones. This lesson is about why, in bytes and cache lines, and about the one case where the textbook is right.

ArrayList

An ArrayList is a resizable array. It holds an Object[] elementData and an int size. get(i) is a bounds check and an array index: O(1), and a single cache-friendly memory read. add(e) writes at size and increments it: O(1) — unless the array is full, in which case it allocates a new array and copies. That copy is O(n), but it happens rarely enough that the amortised cost per add is O(1).

add(i, e) and remove(i) in the middle shift every subsequent element with System.arraycopy: O(n). contains and indexOf scan: O(n).

Under the hood: growth, memory, and the cache

Growth is by half. A new ArrayList holds a shared empty array and allocates its first real array of 10 on the first add. When full, grow() computes oldCapacity + (oldCapacity >> 1): 10, 15, 22, 33, 49, 73, … The total copying over n appends is bounded by about 3n element moves, which is what "amortised O(1)" means in this class, and why new ArrayList<>(expected) matters only for lists in the hundreds of thousands, where it saves twenty or so resizes and the garbage they make.

Memory per element. An ArrayList slot is one reference: 4 bytes with compressed oops (heaps under 32 GB), 8 without. A LinkedList.Node is an object: 12-byte header + item + next + prev references = 24 bytes, plus the same 4-byte reference from wherever the node is pointed at. Per element, the linked list costs six times the array's slot before counting the element itself.

The cache is the whole story. A CPU reads memory in 64-byte lines. One line of an ArrayList's array holds 16 references, so iterating touches one line per 16 elements, and the hardware prefetcher, seeing sequential addresses, has the next line ready before it is asked. A LinkedList node is wherever the allocator put it; iterating is a pointer chase, each next a potential cache miss of 100 or more cycles, and the prefetcher cannot guess where the next node is. Big-O counts operations; the memory hierarchy charges per line.

ArrayList: one cache line = 16 references → prefetched sequential addresses; the next line is loading before you ask LinkedList: one cache line per node, scattered item · next24 B item · next24 B item · next24 B item · next24 B
Iteration touches one line per sixteen elements in the array and one line per element in the chain. That ratio, not the big-O, decides most benchmarks.

LinkedList

A LinkedList is a doubly linked chain of nodes, each holding an element and two references. addFirst, addLast, removeFirst, removeLast are O(1) — no shifting, no resizing. get(i) walks from the nearer end: O(n). add(i, e) is O(1) once you are at position i, but getting there is O(n), so through the List interface it is O(n) too.

The benchmark reality

OperationArrayListLinkedListWinner in practice
get(i)O(1)O(n)ArrayList, by a lot
add(e) at endO(1) amortisedO(1)ArrayList (no allocation per element)
add(0, e) at frontO(n)O(1)LinkedList — this is the case
add(i, e) middleO(n) shiftO(n) walkArrayList until n is very large
Iteratesequentialpointer chaseArrayList
containsO(n)O(n)ArrayList (faster scan)
Memory per element~4–8 bytes~28 bytesArrayList

The middle-insert row surprises people. System.arraycopy of a few thousand references is a tight memmove that runs at memory bandwidth, tens of gigabytes per second; walking a few thousand nodes is a few thousand cache misses. The crossover where LinkedList wins for random middle insertion is at sizes where you should be using a different structure anyway.

Walkthrough: the middle-insert benchmark, with numbers

A JMH benchmark inserts at a random index into a list of 10,000 Integers, ten thousand times, on a laptop. Representative results, and where each microsecond goes:

  1. ArrayList, random add(i, e): about 1.2 µs per insert. The average shift is 5,000 references, 20 KB, moved by arraycopy at roughly 20 GB/s: one microsecond. The bounds check, the write and the occasional grow are noise.
  2. LinkedList, random add(i, e): about 9 µs per insert. The walk to index i averages 2,500 hops from the nearer end; a hop whose node is not in cache is 50 to 100 ns; even with a warm cache holding a fraction of the nodes, the walk dominates. The insertion itself, four pointer writes and a 24-byte allocation, is under 50 ns and is the part the textbook counted.
  3. At 100,000 elements the shift is 200 KB (10 µs) and the walk is 25,000 hops (most of them misses: over a millisecond). The array is still winning by a hundred to one, because the copy scales with bandwidth and the walk scales with latency.
  4. At the front, add(0, e): the array shifts everything, 40 µs at 10,000 elements; the linked list writes four pointers, 30 ns. This is the row where the textbook is right, and it is the row ArrayDeque answers in 10 ns with a contiguous ring buffer.

The lesson is not "ArrayList always wins". It is that a comparison of operation counts that ignores what each operation costs in memory traffic predicts the wrong winner, and that the case where the linked list wins already has a better answer.

When LinkedList is not a mistake

You are holding a ListIterator positioned somewhere in a long list and doing many inserts and removes at that position without re-seeking. A text buffer being edited at a cursor, a scheduler splicing entries near a known node. It is rare, and even then ArrayDeque or a gap buffer often wins.

Sizing an ArrayList

new ArrayList<>() starts with capacity 10 (lazily allocated). If you know you will add 100,000 elements, new ArrayList<>(100_000) avoids about 30 resize-and-copy cycles. ensureCapacity does the same later. trimToSize releases slack if a list will live a long time at a fixed size. None of this matters for small lists; all of it matters when you build a list of millions inside a loop.

Arrays.asList and List.of

Arrays.asList(a, b, c) returns a fixed-size list backed by an array: set works, add throws. List.of(a, b, c) returns an immutable list that also rejects null. Neither is an ArrayList; new ArrayList<>(List.of(a, b, c)) is the way to get a mutable one with initial content. Both remove overloads bite here too: list.remove(1) on a List<Integer> removes index 1, list.remove(Integer.valueOf(1)) removes the value.

CopyOnWriteArrayList

For completeness: a thread-safe list that copies the whole array on every write. Reads are lock-free and consistent; writes are O(n). Right for a listener list that changes rarely and is iterated constantly; wrong for anything written frequently. Concurrency has its own lesson.

Try it yourself

Count the copies

An ArrayList receives 1,000,000 appends from empty. Roughly how many times does it grow, and how many element moves does the growth cost in total? What does new ArrayList<>(1_000_000) change?

Answer

Capacity goes 10 → 15 → 22 → … multiplying by 1.5 each time; reaching a million takes about 30 growths (1.5³⁰ ≈ 190,000 × 10 > 10⁶... more precisely around 30). The copies sum to a geometric series bounded by about 3 × 10⁶ element moves, 12 MB of arraycopy, plus 30 discarded arrays for the collector. Pre-sizing removes all of it: one allocation, zero copies. For a million elements that is a few milliseconds; the point is that it is also a few milliseconds per million in a loop that runs a thousand times.

Which one, and how much memory?

Store 10,000,000 Integer ids and iterate them once a second. Compare ArrayList<Integer>, LinkedList<Integer> and int[] by memory, and say which the CPU iterates fastest.

Answer

int[]: 40 MB, and the fastest by far, one line per 16 values, no pointers. ArrayList<Integer>: 40 MB of references plus 160 MB of Integer objects (16 bytes each, cache-served only for −128..127), about 200 MB, and iteration dereferences each. LinkedList<Integer>: 240 MB of nodes plus 160 MB of Integers, about 400 MB, and iteration is two pointer chases per element. If the ids must be objects, an ArrayList with IntStream where possible; if they are numbers, an int[] or a primitive-collection library.

Spot the bug

java
List<Integer> scores = new ArrayList<>(List.of(10, 20, 30));
scores.remove(1);
scores.remove(Integer.valueOf(30));
System.out.println(scores);
Answer

[10]. The first remove(1) matches remove(int index) (an int argument, no boxing needed) and removes 20. The second passes an Integer, matches remove(Object), and removes the value 30. Overload resolution picks the primitive overload whenever the argument is an int, which is the trap from the Methods lesson, and the reason to write remove(Integer.valueOf(x)) whenever you mean the value.

Misconceptions

  • "LinkedList is O(1) insert, so it is faster for inserts." O(1) once you are at the position, and O(n) cache misses to get there. The array's O(n) shift is one memmove at memory bandwidth.
  • "ArrayList doubles when it grows." It grows by half (+ oldCapacity >> 1). HashMap doubles; ArrayList does not.
  • "Big-O is what matters at scale." At scale the memory hierarchy matters more: an operation count that ignores cache lines predicts the wrong winner here by a factor of ten to a hundred.
  • "LinkedList is the Java stack or queue." ArrayDeque is, and it is faster at both ends with contiguous memory.
  • "Arrays.asList gives me an ArrayList." It gives a fixed-size list over the array. add throws; set writes through to the array.

Going deeper

  • ArrayList.grow and LinkedList.Node source: the growth arithmetic and the 24-byte node.
  • Ulrich Drepper, "What Every Programmer Should Know About Memory", sections 3 and 6, for cache lines and prefetching.
  • JMH's CollectionsBenchmark samples, and any JMH run you do yourself, since the numbers above are indicative and your CPU's are the ones that count.
  • jol on an ArrayList and a LinkedList of ten elements, for the exact bytes.
  • Joshua Bloch and Neal Gafter, Java Puzzlers, puzzle 22, for remove(int) versus remove(Object).
Progress is saved on this device and to your account when signed in.