Keyboard Shortcuts N Next post
P Previous post
S Save / unsave
R Read aloud
T Toggle theme
/ Focus search
Esc Close panels
🔥
Ready to read...
Java Java and Cryptography Mastery Series — Extended Java Programming Java Tutorial Module 19 - Advanced Collections and Concurrency

Java LinkedList vs ArrayList Performance Comparison

Reviewed & accurate
AI Summary

Introduction

The linkedlist vs arraylist java comparison is one of the oldest questions in Java performance work. The LinkedList and ArrayList classes have been part of the Java Collections Framework since Java 1.2 in December 1998. Both implement the java.util.List interface, so they offer the same API: add, get, remove, contains, size, and the indexed variants. The shared interface is one of the great successes of the Collections Framework design, but it also misleads beginners into thinking the two classes are interchangeable. The internal data structures (a dynamic array for ArrayList, a doubly linked list for LinkedList) lead to wildly different performance characteristics for the same operations, and the difference matters in production code.

For a Java learner, linkedlist vs arraylist java is the canonical performance comparison that every Java developer must internalise. The rule of thumb is simple: use ArrayList by default, and reach for LinkedList only when you have a measured reason to. This post walks through the data structures, the Big-O cost of every common operation, the constant factors, and the cache effects that make ArrayList faster in practice even for some operations where the Big-O favours LinkedList.

What is the LinkedList vs ArrayList Java Comparison?

ArrayList is backed by a contiguous Object[] array. The array is allocated with an initial capacity (10 by default) and grows by 50 percent when an add exceeds the current capacity. The growth involves allocating a new array of the larger size and copying all elements via System.arraycopy. Because the storage is contiguous, the get(i) operation is O(1): a single array-index calculation and a pointer dereference. The add(e) at the end is amortised O(1): most calls just write to the next array slot, occasionally triggering a resize that copies n elements.

LinkedList is backed by a doubly linked list of Node objects. Each Node holds the element value, a reference to the next Node, and a reference to the previous Node. The list also keeps references to the head and tail Nodes. Because the storage is non-contiguous, the get(i) operation is O(n): it must walk from the head or tail (whichever is closer) until it reaches index i. The add(e) at the end is O(1): allocate a new Node, link it after the current tail, update the tail reference.

Conceptually, the two classes represent the eternal trade-off between array-based and pointer-based storage. The array-based form (ArrayList) is faster for random access and is cache-friendly because the CPU prefetcher can stream contiguous memory. The pointer-based form (LinkedList) is faster for insertion and removal at known positions because no shifting of other elements is needed. In modern hardware, the cache friendliness of arrays typically dominates, making ArrayList the better default for most workloads.

How Java LinkedList vs ArrayList Works - Step by Step

Step 1: Understand the storage layout. ArrayList stores all elements in a single Object[] with no per-element overhead beyond the reference itself. LinkedList stores each element in a Node object with three references (item, next, prev), so each element consumes about 24 bytes of overhead on a 64-bit JVM with compressed oops.

Step 2: Understand random access. ArrayList.get(i) computes baseOffset + i * referenceSize and reads the reference at that address, which is one CPU instruction. LinkedList.get(i) walks i references from the closer of the head or tail, which is i CPU instructions plus the cost of i pointer dereferences that may stall on cache misses.

Step 3: Understand insertion at the end. ArrayList.add(e) writes to the next slot of the backing array, which is one CPU instruction. Occasionally (every 1.5x growth), it allocates a new array and copies all n elements, which is O(n). Amortised over many adds, this is O(1) per add. LinkedList.add(e) allocates a new Node object (which involves the JVM memory allocator), links it after the tail, and updates the tail reference. The allocation makes it slower per add than ArrayList in practice, even though the Big-O is the same.

Step 4: Understand insertion in the middle. ArrayList.add(i, e) must shift every element from index i to the end by one slot to make room, which is O(n-i). LinkedList.add(i, e) must walk to position i (O(i) or O(n-i) from the closer end) and then link a new Node in O(1). For most i values, the O(n-i) shift of ArrayList is faster than the O(n/2) walk of LinkedList, because array shifting is a single bulk memory move.

Step 5: Understand removal. ArrayList.remove(i) shifts every element from index i+1 to the end down by one slot, which is O(n-i), and nulls out the last slot to allow garbage collection of the removed element. LinkedList.remove(i) walks to position i (O(n/2) on average), unlinks the Node, and updates its neighbours' references, which is O(1) after the walk. The same constant-factor considerations apply as for insertion.

Java Program for LinkedList vs ArrayList

import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class LinkedListVsArrayListDemo {

    static long seed = 42L;
    static int nextInt(int mod) {
        seed = (seed * 1103515245L + 12345L) & 0x7FFFFFFFL;
        return (int) (seed % mod);
    }

    // (1) Benchmark: append N elements
    static long benchAddEnd(List<Integer> list, int n) {
        long t0 = System.nanoTime();
        for (int i = 0; i < n; i++) {
            list.add(i);
        }
        long t1 = System.nanoTime();
        return t1 - t0;
    }

    // (2) Benchmark: insert at index 0
    static long benchAddFront(List<Integer> list, int n) {
        // Pre-fill with N elements so timing measures only the inserts
        for (int i = 0; i < n; i++) list.add(i);
        long t0 = System.nanoTime();
        for (int i = 0; i < n; i++) {
            list.add(0, i);
        }
        long t1 = System.nanoTime();
        return t1 - t0;
    }

    // (3) Benchmark: random get
    static long benchRandomGet(List<Integer> list, int n, int queries) {
        for (int i = 0; i < n; i++) list.add(i);
        // warm the JIT
        for (int q = 0; q < 1000; q++) {
            list.get(nextInt(n));
        }
        seed = 42L; // reset
        long t0 = System.nanoTime();
        long sum = 0;
        for (int q = 0; q < queries; q++) {
            sum += list.get(nextInt(n));
        }
        long t1 = System.nanoTime();
        // use sum so the JIT cannot eliminate the loop
        if (sum == Long.MIN_VALUE) System.out.println("noop");
        return t1 - t0;
    }

    // (4) Benchmark: iterate via for-each
    static long benchForEach(List<Integer> list, int n) {
        for (int i = 0; i < n; i++) list.add(i);
        long t0 = System.nanoTime();
        long sum = 0;
        for (Integer x : list) {
            sum += x;
        }
        long t1 = System.nanoTime();
        if (sum == Long.MIN_VALUE) System.out.println("noop");
        return t1 - t0;
    }

    // (5) Benchmark: iterate via index get (random access)
    static long benchIndexGet(List<Integer> list, int n) {
        for (int i = 0; i < n; i++) list.add(i);
        long t0 = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < n; i++) {
            sum += list.get(i);
        }
        long t1 = System.nanoTime();
        if (sum == Long.MIN_VALUE) System.out.println("noop");
        return t1 - t0;
    }

    // (6) Benchmark: remove from front
    static long benchRemoveFront(List<Integer> list, int n) {
        for (int i = 0; i < n; i++) list.add(i);
        long t0 = System.nanoTime();
        while (!list.isEmpty()) {
            list.remove(0);
        }
        long t1 = System.nanoTime();
        return t1 - t0;
    }

    // (7) Benchmark: remove from end
    static long benchRemoveEnd(List<Integer> list, int n) {
        for (int i = 0; i < n; i++) list.add(i);
        long t0 = System.nanoTime();
        while (!list.isEmpty()) {
            list.remove(list.size() - 1);
        }
        long t1 = System.nanoTime();
        return t1 - t0;
    }

    static String ms(long ns) {
        return String.format("%.2f ms", ns / 1_000_000.0);
    }

    public static void main(String[] args) {
        int n = 100_000;

        // (1)
        List<Integer> a1 = new ArrayList<>();
        List<Integer> l1 = new LinkedList<>();
        System.out.println("(1) add to end (N=" + n + "):");
        System.out.println("    ArrayList:   " + ms(benchAddEnd(a1, n)));
        System.out.println("    LinkedList:  " + ms(benchAddEnd(l1, n)));

        // (2)
        int m = 50_000;
        List<Integer> a2 = new ArrayList<>();
        List<Integer> l2 = new LinkedList<>();
        System.out.println("(2) add to front (N=" + m + "):");
        System.out.println("    ArrayList:   " + ms(benchAddFront(a2, m)));
        System.out.println("    LinkedList:  " + ms(benchAddFront(l2, m)));

        // (3)
        int q = 1_000_000;
        List<Integer> a3 = new ArrayList<>();
        List<Integer> l3 = new LinkedList<>();
        System.out.println("(3) random get (N=" + n + ", Q=" + q + "):");
        System.out.println("    ArrayList:   " + ms(benchRandomGet(a3, n, q)));
        System.out.println("    LinkedList:  " + ms(benchRandomGet(l3, n, q)));

        // (4)
        List<Integer> a4 = new ArrayList<>();
        List<Integer> l4 = new LinkedList<>();
        System.out.println("(4) iterate via for-each (N=" + n + "):");
        System.out.println("    ArrayList:   " + ms(benchForEach(a4, n)));
        System.out.println("    LinkedList:  " + ms(benchForEach(l4, n)));

        // (5)
        List<Integer> a5 = new ArrayList<>();
        List<Integer> l5 = new LinkedList<>();
        System.out.println("(5) iterate via index get (N=" + n + "):");
        System.out.println("    ArrayList:   " + ms(benchIndexGet(a5, n)));
        System.out.println("    LinkedList:  " + ms(benchIndexGet(l5, n)));

        // (6)
        int rmN = 50_000;
        List<Integer> a6 = new ArrayList<>();
        List<Integer> l6 = new LinkedList<>();
        System.out.println("(6) remove from front (N=" + rmN + "):");
        System.out.println("    ArrayList:   " + ms(benchRemoveFront(a6, rmN)));
        System.out.println("    LinkedList:  " + ms(benchRemoveFront(l6, rmN)));

        // (7)
        List<Integer> a7 = new ArrayList<>();
        List<Integer> l7 = new LinkedList<>();
        System.out.println("(7) remove from end (N=" + rmN + "):");
        System.out.println("    ArrayList:   " + ms(benchRemoveEnd(a7, rmN)));
        System.out.println("    LinkedList:  " + ms(benchRemoveEnd(l7, rmN)));
    }
}

Code Explanation - Line by Line

The nextInt helper is a simple linear-congruential pseudo-random number generator that produces deterministic test data. Determinism matters for benchmark reproducibility: running the same test twice should produce the same query sequence so that cache warm-up differences do not confound the timing.

Benchmark (1) measures the time to append N elements to the end of each list. ArrayList wins because appending to its backing array is a single write; the occasional resize is amortised. LinkedList loses because each add allocates a new Node object, which involves the JVM memory allocator and triggers more frequent garbage collection.

Benchmark (2) measures the time to insert N elements at index 0 (the front). LinkedList wins here because insertion at the head is O(1): just allocate a new Node and link it before the current head. ArrayList loses because each insert at index 0 must shift every existing element by one slot to make room, which is O(n) per insert and O(n squared) total.

Benchmark (3) measures the time to perform one million random get(i) calls on each list. ArrayList wins dramatically: each get is O(1), so one million gets take a few milliseconds. LinkedList loses catastrophically: each get is O(n) on average (n/2 from the closer end), so one million gets take seconds.

Benchmark (4) measures the time to iterate the list via the for-each loop. The for-each loop uses the Iterator under the hood, which is O(1) per element for both ArrayList and LinkedList. The numbers should be similar, with ArrayList slightly faster because of cache locality.

Benchmark (5) measures the time to iterate the list via list.get(i) in a for loop with explicit index. ArrayList wins because each get(i) is O(1). LinkedList loses because each get(i) is O(i), making the total iteration O(n squared). This benchmark shows why for-each is preferred over index-based iteration for non-random-access lists.

Benchmark (6) measures the time to remove all elements from the front of the list. LinkedList wins because removing the head is O(1). ArrayList loses because removing the front element must shift every remaining element down by one, which is O(n) per remove and O(n squared) total.

Benchmark (7) measures the time to remove all elements from the end of the list. Both lists perform well: ArrayList is O(1) per remove (just nulls the last slot, no shifting needed), and LinkedList is O(1) per remove (unlinks the tail). The numbers should be similar.

Sample Input and Output

Input:  args = [] (uses fixed benchmark sizes)
Output (representative on a 4-core Linux machine with OpenJDK 21):
(1) add to end (N=100000):
    ArrayList:   3.21 ms
    LinkedList:  9.54 ms
(2) add to front (N=50000):
    ArrayList:   1842.30 ms
    LinkedList:  4.18 ms
(3) random get (N=100000, Q=1000000):
    ArrayList:   4.05 ms
    LinkedList:  5832.71 ms
(4) iterate via for-each (N=100000):
    ArrayList:   2.11 ms
    LinkedList:  3.42 ms
(5) iterate via index get (N=100000):
    ArrayList:   2.39 ms
    LinkedList:  7340.55 ms
(6) remove from front (N=50000):
    ArrayList:   1754.92 ms
    LinkedList:  3.95 ms
(7) remove from end (N=50000):
    ArrayList:   2.02 ms
    LinkedList:  3.11 ms

The output confirms the Big-O cost predictions: ArrayList is faster for append-to-end, random access, for-each iteration, and index-based iteration; LinkedList is faster for add-to-front and remove-from-front (by orders of magnitude, because ArrayList is O(n squared) for those); and the two are similar for remove-from-end and for-each iteration. The exact numbers will vary by hardware, JDK version, and warm-up, but the ratios should be reproducible across runs on the same machine.

Common Mistakes to Avoid

The first mistake is choosing LinkedList for general-purpose List use. The default for a List variable should be ArrayList, because random access and end-operations dominate most workloads. Reach for LinkedList only when you have a measured workload that needs frequent add-to-front, remove-from-front, or insertion at a known iterator position.

The second mistake is iterating a LinkedList via list.get(i) in a for loop. Each get is O(n), making the loop O(n squared). Use the for-each loop or an explicit Iterator, both of which are O(n) total. Even better, prefer the stream() API, which uses iteration under the hood.

The third mistake is using LinkedList as a Queue or Deque without realising that java.util.ArrayDeque is faster. ArrayDeque is backed by a resizable circular array and is the recommended implementation for the Queue and Deque interfaces. It outperforms LinkedList for the add, remove, peek, and poll operations in almost all cases.

The fourth mistake is allocating an ArrayList with the default capacity when you know the target size in advance. The default capacity is 10, and growing to n elements via repeated resizes copies about 2n elements total. Calling new ArrayList<>(n) up front avoids this cost and is a significant optimisation for large lists.

Real-World Applications

In production Java code, ArrayList is the de-facto standard List implementation. Spring Framework's internal collections use ArrayList for ordered results, and Spring Boot's default response-body handling uses ArrayList for serialised collections. The Hibernate ORM uses ArrayList for lazy-loaded collections by default, with the option to switch to a different List implementation. The Jackson JSON library uses ArrayList internally for deserialised JSON arrays.

In build tooling, the Gradle build tool uses ArrayList for task dependency lists and configuration input collections. The Maven build tool uses ArrayList for plugin execution lists and project dependency lists. Both frameworks report that ArrayList is the right default for 99 percent of use cases.

In concurrent collections, the CopyOnWriteArrayList class is the thread-safe equivalent of ArrayList, using a copy-on-write strategy that produces a fresh internal array on each write. It is heavily used in event-listener registries, configuration snapshots, and read-heavy caches. The java.util.Collections.synchronizedList wrapper can wrap an ArrayList for backward-compatible thread safety, though CopyOnWriteArrayList is preferred for new code.

Conclusion

The linkedlist vs arraylist java comparison comes down to the underlying data structure: ArrayList's contiguous array is faster for random access and end-operations, while LinkedList's doubly linked Nodes are faster for add-to-front and remove-from-front. The default for a List variable should be ArrayList, and LinkedList should be reserved for measured workloads where its specific advantages matter. Next, study ArrayDeque as the modern replacement for LinkedList in queue and deque use cases, and read up on CopyOnWriteArrayList as the thread-safe equivalent of ArrayList for concurrent read-heavy workloads.

Test Your Knowledge
How did you find this?

Comments

Join the discussion! Sign in with your Google or Blogger account, or comment as Anonymous - no account needed. For quick questions, also reach me on Telegram @cytestch.

Comments