list-benchmarks: ArrayList vs LinkedList JMH benchmarks (add/get/iterate/remove) and when ArrayDeque fits instead of either

This commit is contained in:
Claude
2026-10-01 04:30:17 +00:00
parent 1ac2f6077a
commit 9059df4ad2
14 changed files with 1450 additions and 0 deletions
@@ -0,0 +1,84 @@
package com.ankurm.listbenchmarks;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Stack/queue-shaped workload: push a value in, pop it back out, repeatedly, on a deque that
* already holds {@code size} elements. This is the workload the article's "when ArrayDeque fits
* instead of either" section is about - it is neither "random access" (ArrayList's strength) nor
* "insert given a list reference" (the only thing LinkedList is actually good at), it is the far
* more common "push/pop/offer/poll from one or both ends" shape.
*
* <p>{@code ArrayDeque} is not a {@link List} - it deliberately does not implement
* {@code get(index)} - which is itself part of the article's point: reach for it when you need a
* stack or a queue, not a list, and the API stops you from doing index math you did not mean to
* do.
*
* <p>See {@code output/02-deque-ops-sweep.txt} for the captured run.
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
public class DequeBenchmark {
@Param({"ArrayDeque", "LinkedList", "ArrayList"})
public String impl;
@Param({"1000", "100000"})
public int size;
private Deque<Integer> deque; // used for ArrayDeque and LinkedList
private List<Integer> arrayListAsStack; // used only for the ArrayList "anti-pattern" arm
@Setup(Level.Trial)
public void setup() {
switch (impl) {
case "ArrayDeque" -> deque = new ArrayDeque<>(size);
case "LinkedList" -> deque = new LinkedList<>();
case "ArrayList" -> arrayListAsStack = new ArrayList<>(size);
default -> throw new IllegalStateException(impl);
}
if ("ArrayList".equals(impl)) {
for (int i = 0; i < size; i++) arrayListAsStack.add(i);
} else {
for (int i = 0; i < size; i++) deque.addLast(i);
}
}
/** push then pop at the head - the natural operation for a real Deque. */
@Benchmark
public void pushPopHead(Blackhole bh) {
if (arrayListAsStack != null) {
// The only way to get "push/pop at the front" semantics out of a List is
// add(0, x) / remove(0) - an O(n) shift on every single call. That cost IS the point.
arrayListAsStack.add(0, -1);
bh.consume(arrayListAsStack.remove(0));
} else {
deque.addFirst(-1);
bh.consume(deque.pollFirst());
}
}
/** offer then poll at the tail - the natural operation for a real Queue. */
@Benchmark
public void offerPollTail(Blackhole bh) {
if (arrayListAsStack != null) {
arrayListAsStack.add(-1);
bh.consume(arrayListAsStack.remove(arrayListAsStack.size() - 1));
} else {
deque.addLast(-1);
bh.consume(deque.pollLast());
}
}
}
@@ -0,0 +1,73 @@
package com.ankurm.listbenchmarks;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
/**
* The one shape of removal where {@code LinkedList} can actually win: removing through an
* {@link Iterator} cursor you are already standing on, instead of by index.
*
* <p>{@code ArrayList.remove(index)} is always an array shift, even when the index came from an
* iterator that was already sitting on the element - the iterator does not save it from the
* shift. {@code LinkedList}'s {@code Iterator.remove()} unlinks the current node directly, no
* traversal needed, because the cursor already holds the node reference.
*
* <p>This is a plain wall-clock illustration with {@code System.nanoTime()}, not a JMH
* benchmark - JIT warmup and GC are not controlled for the way {@link ListOpsBenchmark} controls
* for them. Read it as "yes, this case is real", not as a throughput number to quote. The JMH
* numbers for the general case are in {@code output/01-list-ops-sweep.txt} and
* {@code output/02-deque-ops-sweep.txt}.
*/
public final class IteratorRemovalDemo {
private IteratorRemovalDemo() {}
public static void main(String[] args) {
int size = 200_000;
System.out.println("Removing every 3rd element of a " + size + "-element list via Iterator.remove()");
System.out.println();
long arrayListNanos = removeEveryThird(buildArrayList(size));
long linkedListNanos = removeEveryThird(buildLinkedList(size));
System.out.printf("ArrayList (iterator.remove, every 3rd): %,d ns (%.2f ms)%n",
arrayListNanos, arrayListNanos / 1_000_000.0);
System.out.printf("LinkedList (iterator.remove, every 3rd): %,d ns (%.2f ms)%n",
linkedListNanos, linkedListNanos / 1_000_000.0);
System.out.printf("Ratio (ArrayList time / LinkedList time): %.2fx%n",
(double) arrayListNanos / linkedListNanos);
System.out.println();
System.out.println("ArrayList.remove(index) still shifts every element after the removed one,");
System.out.println("even though the iterator was already positioned on it. LinkedList's");
System.out.println("iterator.remove() unlinks the node the cursor holds - no shift, no search.");
System.out.println("This is the one removal pattern where LinkedList is the right call.");
}
private static long removeEveryThird(List<Integer> list) {
long start = System.nanoTime();
int i = 0;
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
it.next();
if (i % 3 == 0) {
it.remove();
}
i++;
}
return System.nanoTime() - start;
}
private static List<Integer> buildArrayList(int size) {
List<Integer> l = new ArrayList<>(size);
for (int i = 0; i < size; i++) l.add(i);
return l;
}
private static List<Integer> buildLinkedList(int size) {
List<Integer> l = new LinkedList<>();
for (int i = 0; i < size; i++) l.add(i);
return l;
}
}
@@ -0,0 +1,87 @@
package com.ankurm.listbenchmarks;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* Throughput comparison of {@link ArrayList} and {@link LinkedList} on the five operations
* that actually show up in real code: append, random-index read, full iteration, and
* insert/remove at the front and in the middle.
*
* <p>Every mutating benchmark does an insert immediately followed by the matching removal so
* the list's size (and therefore the cost profile) stays constant across the whole measurement
* window instead of drifting as the iteration runs. That is what keeps {@code addFront} and
* {@code addFront} comparable at iteration 1 and iteration 1,000,000 of the same run.
*
* <p>Referenced from the "ArrayList vs LinkedList in 2026" post - see
* {@code output/01-list-ops-sweep.txt} for the captured run this repository ships.
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
public class ListOpsBenchmark {
@Param({"ArrayList", "LinkedList"})
public String impl;
@Param({"1000", "100000"})
public int size;
private List<Integer> list;
private final Random rnd = new Random(42);
@Setup(Level.Trial)
public void setup() {
list = "ArrayList".equals(impl) ? new ArrayList<>(size) : new LinkedList<>();
for (int i = 0; i < size; i++) {
list.add(i);
}
}
/** Append one element at the end, then remove it. Net size unchanged. */
@Benchmark
public void addEnd(Blackhole bh) {
list.add(size, -1);
bh.consume(list.remove(size));
}
/** Insert at index 0, then remove index 0. The operation ArrayList is worst at. */
@Benchmark
public void addFront(Blackhole bh) {
list.add(0, -1);
bh.consume(list.remove(0));
}
/** Insert at the midpoint, then remove it again. */
@Benchmark
public void addMiddle(Blackhole bh) {
int mid = size / 2;
list.add(mid, -1);
bh.consume(list.remove(mid));
}
/** Random-access read by index - no mutation, size never changes. */
@Benchmark
public int getRandom() {
return list.get(rnd.nextInt(size));
}
/** Full forward iteration summing every element, via the iterator (the idiomatic way). */
@Benchmark
public long iterateSum() {
long sum = 0;
for (int v : list) {
sum += v;
}
return sum;
}
}
@@ -0,0 +1,69 @@
package com.ankurm.listbenchmarks;
import org.junit.jupiter.api.Test;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Sanity checks only - these do not prove throughput, they prove the two data structures stay
* behaviourally interchangeable through {@link List} so the benchmark's "same workload, two
* implementations" framing is actually true, and pin the one behavioural difference the article
* leans on: {@code ArrayDeque} is not a {@link List}.
*/
class CorrectnessTest {
@Test
void arrayListAndLinkedListAgreeOnContents() {
List<Integer> a = new ArrayList<>();
List<Integer> l = new LinkedList<>();
for (int i = 0; i < 1000; i++) {
a.add(i);
l.add(i);
}
assertEquals(a, l, "List.equals is content-based, not implementation-based");
assertEquals(a.get(500), l.get(500));
}
@Test
void addFrontShiftsArrayListButNotLinkedList() {
List<Integer> a = new ArrayList<>(List.of(1, 2, 3));
List<Integer> l = new LinkedList<>(List.of(1, 2, 3));
a.add(0, 0);
l.add(0, 0);
assertEquals(List.of(0, 1, 2, 3), a);
assertEquals(List.of(0, 1, 2, 3), l);
// Both produce the same *result* - the benchmark exists because the *cost* differs.
}
@Test
void arrayDequeIsNotAList() {
Deque<Integer> d = new ArrayDeque<>();
assertThrows(ClassCastException.class, () -> {
@SuppressWarnings({"unchecked", "rawtypes"})
List<Integer> asList = (List) d; // compiles only via raw type + unchecked cast
asList.get(0);
}, "ArrayDeque deliberately does not implement List - there is no get(index)");
}
@Test
void arrayDequeAndLinkedListAgreeOnStackOrder() {
Deque<Integer> ad = new ArrayDeque<>();
Deque<Integer> ll = new LinkedList<>();
for (int v : new int[] {1, 2, 3}) {
ad.push(v);
ll.push(v);
}
// Both are LIFO via push/pop - same observable order, different internal layout.
assertEquals(3, ad.pop());
assertEquals(3, ll.pop());
assertEquals(2, ad.pop());
assertEquals(2, ll.pop());
}
}