Skip to main content

ArrayList vs LinkedList in 2026: JMH Benchmarks and Why LinkedList Rarely Wins

Real JMH numbers for add, get, iterate and remove on ArrayList and LinkedList, why the gap is bigger than Big-O suggests, the one case LinkedList actually wins, and why ArrayDeque is usually the right answer for stack and queue code instead of either.

You reach for a List in Java roughly once per file you write, and most of the time the choice between ArrayList and LinkedList is made on autopilot — often from advice that is a decade out of date. The short version this post defends with real numbers: default to ArrayList, and reach for LinkedList only in the one narrow case where it genuinely wins. If what you actually need is a stack or a queue, neither is the right tool — ArrayDeque is, and the benchmarks below show why.

Every number in this post comes from a JMH run committed to the companion repository, and every code block links to the file it came from. You can clone it and get the same shape of result on your own machine.

Versions this was tested against. JDK 25.0.4.1+1 (Temurin, LTS — GA August 2025), JMH 1.37, JUnit Jupiter 5.11.0, Maven 3.9.11. ArrayList, LinkedList and ArrayDeque are not version-gated — everything here applies identically back to Java 8. Benchmarks ran on a shared, multi-tenant container, not an isolated box; see the honesty note in the companion repo’s README before treating any single number as precise.

The problem: two lists that look interchangeable and are not

ArrayList and LinkedList both implement List<E>. You can write code against the interface and swap one for the other without the compiler complaining. That is exactly what makes the wrong choice invisible until the collection is large enough to hurt — the code compiles, the unit tests pass with ten elements, and the slowdown only shows up in production with ten million.

ArrayList: one contiguous array A B C D E spare get(2) is base_address + 2*elementSize — one pointer calculation, one cache line. LinkedList: one Node object per element, scattered on the heap A B C D get(2) must walk A→B→C, following a pointer to wherever the JVM happened to allocate each node.

That picture is the entire explanation for every number below. ArrayList is a resizable array: get(index) is arithmetic on a single contiguous block, which is also why the CPU’s cache loves it — reading element 5 pulls elements 6–20 into cache for free. LinkedList is a doubly-linked chain of individually-allocated Node objects: get(index) means walking that chain from one end, and each hop can land on a cache miss because the JVM’s garbage collector is free to place each node anywhere on the heap.

Going deeper on this section:

  • The full method-by-method API reference lives in Oracle’s LinkedList Javadoc and ArrayList Javadoc.
  • Ankur’s existing LinkedList deep dive covers the full method surface and the Deque/Queue/Stack adapter methods in detail — this post assumes you’ve skimmed that or already know the API, and focuses on the one question it doesn’t answer with numbers: how much does the difference actually cost.

The smallest thing that proves it: five operations, two implementations, one JMH run

The companion repository’s ListOpsBenchmark runs the same five operations — append, insert-at-front, insert-at-middle, random-access read, full iteration — against both implementations at 1,000 and 100,000 elements. Every mutating benchmark inserts an element and immediately removes it again, so the list’s size (and therefore its cost profile) never drifts over the course of a measurement window.

@Benchmark
public void addFront(Blackhole bh) {
    list.add(0, -1);
    bh.consume(list.remove(0));
}

@Benchmark
public int getRandom() {
    return list.get(rnd.nextInt(size));
}

Full source: ListOpsBenchmark.java.

Benchmark                        (impl)  (size)   Mode  Cnt       Score        Error   Units
ListOpsBenchmark.addFront     ArrayList    1000  thrpt    3    6585.099 ±  11228.597  ops/ms
ListOpsBenchmark.addFront     ArrayList  100000  thrpt    3      53.499 ±      6.659  ops/ms
ListOpsBenchmark.addFront    LinkedList    1000  thrpt    3   42897.540 ±  19001.947  ops/ms
ListOpsBenchmark.addFront    LinkedList  100000  thrpt    3   43314.310 ±  43475.313  ops/ms
ListOpsBenchmark.getRandom    ArrayList  100000  thrpt    3   40457.598 ±  41624.817  ops/ms
ListOpsBenchmark.getRandom   LinkedList  100000  thrpt    3      19.165 ±     14.662  ops/ms

Trimmed from the full captured run: output/01-list-ops-sweep.txt.

Two things jump out immediately. addFront is the one row where LinkedList wins outright and keeps winning as the list grows — flat at roughly 43,000 ops/ms whether the list holds 1,000 or 100,000 elements, because inserting at a known end is a single pointer write. ArrayList.addFront collapses from ~6,600 ops/ms to ~53 ops/ms over the same size range, a two-orders-of-magnitude fall, because every call shifts the entire array one slot to the right. getRandom flips the story completely: at 100,000 elements ArrayList beats LinkedList by roughly 2,000x, because LinkedList.get(index) has no choice but to walk from one end.

Read ratios, not raw numbers. This machine is shared with five other concurrent workloads while these benchmarks ran. JMH itself ran one benchmark class at a time, never overlapping with another JMH run — but it cannot see what else the host is scheduling. Several rows carry an error margin wider than the mean (DequeBenchmark.offerPollTail ArrayDeque at size 1,000 is 203,926 ± 985,600 ops/ms in the raw output) — that is scheduler noise, not the JVM doing something exotic. The relative gaps between rows measured in the same run are far more trustworthy than any single absolute figure.

One to two paragraphs of intermediate depth: the reason addFront is flat for LinkedList but addMiddle is not (see output/01, addMiddle rows) is that the middle still requires an O(n) walk from the nearer end — LinkedList only gets its O(1) guarantee when the position is already known, which in practice means “the very first or very last element,” or a position your code is already standing on via an iterator. The full sweep, including addMiddle and iterateSum at both sizes, is in the output file linked above.

Going deeper on this section:

Where the gap actually comes from

iterateSum is the benchmark that trips people up: both implementations iterate in O(n), yet ArrayList is still roughly 4–5x faster at 100,000 elements. If both are “linear,” why the gap? Because Big-O counts operations, not cache misses, and the two have wildly different memory layouts.

One CPU cache-line fetch (64 bytes) ArrayList: 16 consecutive ints in ONE fetch fetch 1 fetch 2 fetch 3 fetch 4 LinkedList: one node per fetch, scattered — 16x more memory fetches for the same 16 elements. That gap, not Big-O, is most of the ~4-5x iterateSum difference measured at 100,000 elements.

There is a second, smaller cost stacked on top: object overhead. Each LinkedList node is its own object — a 16-byte header plus two 4-byte compressed-oops references (prev, next) plus the boxed Integer payload itself, versus ArrayList‘s backing Object[] holding references directly in one block. The accordion below has the JOL-level byte accounting if you want it; it is a real cost but a smaller one than the cache-miss pattern above, so this post does not spend the main flow on it.

Reference depth: per-node byte accounting for LinkedList vs ArrayList

With compressed oops (the default under 32 GB heaps), a 64-bit HotSpot LinkedList.Node costs approximately:

  • 12-byte object header (mark word + compressed class pointer)
  • 4 bytes for the item reference
  • 4 bytes for next
  • 4 bytes for prev
  • padded to 24 bytes (8-byte alignment)

Storing Integer elements adds a second allocation per element for the boxed Integer itself (16 bytes: 12-byte header + 4-byte int value, no padding needed) unless it falls in the Integer cache range (−128 to 127, cached at class-load time and reused). So a LinkedList<Integer> of 100,000 elements outside the cache range costs roughly 100,000 × (24 + 16) = 4,000,000 bytes of node+box overhead alone, on top of the 400,000 bytes an int[]-equivalent would need — before counting the LinkedList object’s own three fields (first, last, size).

ArrayList‘s backing store is one Object[]: 16-byte array header + 4 bytes per reference, grown by 50% (oldCapacity + (oldCapacity >> 1)) when it fills, which is why occasional addEnd calls show the high-variance rows in output/01 — a resize triggers a full array copy, and whether a given 1-second measurement window catches a resize is partly luck.

This is arithmetic from the JDK source and the compact object headers work (JEP 450) roadmap, not a measured number in this repository — if you want the measured version, JOL (Java Object Layout) will print the real instance size on whatever JDK build you run it on, since header size has changed across JDK releases and will change again under JEP 450.

Going deeper on this section:

  • The object-header size assumed above is current for a 64-bit HotSpot JVM today; JEP 450 (compact object headers) is on track to shrink it, which would narrow — not close — this part of the gap.
  • Prime Baeldung-type folklore says “LinkedList is slower because of memory,” full stop; the cache-line picture above is the more complete explanation, and it is the one you can actually verify by profiling.

What breaks: the O(n²) you don’t notice until the list is big

The failure mode that costs people real time is not a crash, it’s a silent slowdown that only shows up at scale. The classic version: iterating a LinkedList by index.

// O(n^2) on a LinkedList — each get(i) restarts the walk from an end
for (int i = 0; i < list.size(); i++) {
    process(list.get(i));
}

On an ArrayList this loop is O(n) — each get(i) is O(1). On a LinkedList, each get(i) is itself O(n), so the whole loop is O(n²). At 1,000 elements that is survivable. At 100,000 it is 10,000x more work than the equivalent for-each loop, and nothing about the code’s visual shape warns you — it reads exactly like the loop that’s fine on ArrayList.

The fingerprint of this bug. A method that is fast in every test (small fixtures) and then degrades non-linearly in production as a collection grows, where the code review shows a for (int i...) loop calling .get(i) on something typed as List<T> rather than a concrete type — nothing in the call site tells you whether get is O(1) or O(n). list.get(i) inside a loop is always suspicious when the concrete type isn’t visible at the call site.

The fix is almost always free: switch to a for-each loop or an explicit iterator, which both implementations provide in O(n) total regardless of which one you have:

// O(n) on both implementations
for (T item : list) {
    process(item);
}

The companion repo’s IteratorRemovalDemo measures a related but opposite case — where going through the iterator, rather than by index, is what makes LinkedList win:

Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
    it.next();
    if (i % 3 == 0) {
        it.remove();   // unlinks the current node directly on LinkedList
    }
    i++;
}

Source: IteratorRemovalDemo.java.

Removing every 3rd element of a 200000-element list via Iterator.remove()

ArrayList  (iterator.remove, every 3rd): 1,314,187,777 ns (1314.19 ms)
LinkedList (iterator.remove, every 3rd): 5,019,906 ns (5.02 ms)
Ratio (ArrayList time / LinkedList time): 261.80x

Captured run: output/03-iterator-removal-demo.txt. This one is a plain nanoTime() wall-clock measurement, not a JIT-warmed JMH run — read the 262x as “this is real and large,” not as a number to quote to two decimal places.

This is, honestly, close to the only realistic case in 2026 code where choosing LinkedList over ArrayList is the right call: you are already positioned on an element via an iterator and need to remove or insert right there, repeatedly, without restarting a search each time. ArrayList.listIterator().remove() still triggers an array shift even though the iterator was already sitting on the element — the iterator saves you the search, not the shift.

Going deeper on this section:

  • The ConcurrentModificationException both implementations throw when you mutate a list mid-for-each is a fail-fast check on a modification counter, not a correctness guarantee — it does not detect every possible race, only structural changes it happens to notice. That is a java.util collections topic, not specific to this comparison.
  • Josh Bloch’s original Effective Java Item on “prefer for-each” predates this exact pitfall and still names it as the first reason to prefer that loop form.

The default most people miss: ArrayDeque

If what you actually need is a stack or a queue — push/pop, offer/poll, add/remove only at the ends — neither ArrayList nor LinkedList is the right answer. ArrayDeque is a resizable circular array purpose-built for exactly this shape of work, and unlike ArrayList it supports O(1) removal at both ends, not just the tail.

ArrayDeque: circular array, head and tail pointers move, elements never shift C D free free A B head→A tail→D

Because it’s backed by one array instead of per-element node objects, ArrayDeque gets the same cache-locality advantage ArrayList has, while still supporting O(1) operations at both ends the way LinkedList does. The companion repo’s DequeBenchmark compares push/pop at the head across all three:

Benchmark                         (impl)  (size)   Mode  Cnt       Score        Error   Units
DequeBenchmark.pushPopHead    ArrayDeque    1000  thrpt    3  298909.914 ±   7056.600  ops/ms
DequeBenchmark.pushPopHead    ArrayDeque  100000  thrpt    3  103405.573 ±  46955.844  ops/ms
DequeBenchmark.pushPopHead    LinkedList    1000  thrpt    3   83721.678 ±  70288.226  ops/ms
DequeBenchmark.pushPopHead    LinkedList  100000  thrpt    3   84775.820 ±  29939.400  ops/ms
DequeBenchmark.pushPopHead     ArrayList    1000  thrpt    3   11128.421 ±   2892.341  ops/ms
DequeBenchmark.pushPopHead     ArrayList  100000  thrpt    3      55.833 ±     28.174  ops/ms

Source: DequeBenchmark.java. Full run: output/02-deque-ops-sweep.txt.

ArrayDeque beats LinkedList at push/pop by roughly 3.6x at 1,000 elements, and never falls into ArrayList‘s O(n) head-shift trap — at 100,000 elements, ArrayList used as a stack via add(0, x) collapses to 56 ops/ms, an 1,800x gap from ArrayDeque‘s 103,406. There is a smaller, noisier effect in the opposite direction (tail-only operations, in output/02‘s offerPollTail rows) where ArrayList is competitive precisely because tail-only append/removeLast never needs to shift anything — but that only holds if you truly never touch the head, which is a narrower contract than most “queue” usage actually has.

One deliberate API consequence worth knowing: ArrayDeque does not implement List at all — there is no get(index). That isn’t an oversight; it’s the type system refusing to let you write index-based code against something that was never meant to support it. The companion repo pins this directly:

Deque<Integer> d = new ArrayDeque<>();
assertThrows(ClassCastException.class, () -> {
    List<Integer> asList = (List) d; // compiles only via raw type + unchecked cast
    asList.get(0);
});

Source: CorrectnessTest.java.

One more default worth knowing: ArrayDeque permits no null elements — addFirst(null) throws NullPointerException immediately, on purpose, because poll() uses null as the “empty” sentinel and an actual null element would be indistinguishable from an empty deque. LinkedList allows null freely, which is one of the few remaining reasons to pick it over ArrayDeque for queue-shaped code that genuinely needs to queue a null.

Going deeper on this section:

  • The ArrayDeque Javadoc documents the null-rejection behaviour explicitly in its class-level description.
  • ArrayDeque is also commonly faster than Stack and Vector for LIFO/FIFO use, for the same reason it beats LinkedList — both Stack and Vector carry legacy synchronized methods on every call, which this post doesn’t benchmark because that cost is a locking story, not a data-structure one.
Intermediate depth: how ArrayDeque resizes, and why its capacity is always a power of two

ArrayDeque‘s backing array capacity is always rounded up to the next power of two. That is not cosmetic — the head and tail indices wrap around the array using a bitmask (index & (capacity - 1)) instead of a modulo operation, which is measurably cheaper than % on the hot path of every push/pop/offer/poll call, and only works cleanly when capacity is a power of two. When the array fills, it doubles, copying elements into a new, larger array in head-to-tail logical order (not raw array-index order, since the circular buffer may currently be wrapped around the end of the array).

This is why an ArrayDeque constructed with an initial-capacity hint that isn’t a power of two silently gets a larger one — new ArrayDeque<>(10) actually allocates for 16. It is read from the JDK’s ArrayDeque source (the capacity rounds up via Integer.highestOneBit doubled when the requested size isn’t already a power of two), not independently measured in this repository.

Should you even think about this choice day to day?

Honest answer: mostly no. For the overwhelming majority of lists in real programs — under a few thousand elements, read more than they’re mutated in the middle — ArrayList is correct by default and the performance difference versus getting it “wrong” is invisible. Reach for this post’s numbers when a profiler, not intuition, points at list operations as a hot path, or when you already know the access pattern is stack/queue-shaped (then it’s ArrayDeque, decided up front, not after measuring). Do not pre-optimize a list choice you haven’t measured.
You need…Reach forWhy
General-purpose list, read-heavy or unsureArrayListBest cache locality, O(1) random access, right default ≥95% of the time.
Stack or queue (push/pop/offer/poll at the ends only)ArrayDequeO(1) at both ends, better cache locality than LinkedList, explicitly not a List so you can’t misuse it as one.
Repeated insert/remove through an iterator cursor, not by indexLinkedListThe one case it’s actually faster — see the 262x iterator-removal number above.
Thread-safe mutation from multiple threadsNeither, as-isNone of the three are thread-safe; see Collections.synchronizedList or, more often, CopyOnWriteArrayList for read-heavy concurrent lists — covered in a companion post.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.