list-benchmarks: ArrayList vs LinkedList JMH benchmarks (add/get/iterate/remove) and when ArrayDeque fits instead of either
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user