Production incident

The queue that got slower as it drained

45 minintermediate38 yrs

A real incident: symptom first, cause hidden, tradeoff at the end.

The incident

A batch worker pulls jobs off an in-memory backlog and processes them FIFO. The backlog is an ArrayList, and the worker takes the next job with `backlog.remove(0)`. For two years the backlog held a few hundred jobs and nobody noticed anything. Then a upstream retry storm pushed 30,000 jobs into it in one window. What operations saw: 1. Throughput collapsed — the drain took over a minute for work that normally takes under a second. 2. CPU sat at 100% on one core the entire time. No lock contention, no GC pressure, no I/O wait. 3. A thread dump taken mid-incident showed the worker inside System.arraycopy, called from ArrayList.remove. 4. It recovered on its own once the backlog drained, so the first two incidents were closed as "transient load". Find the root cause. Fix it. Then answer the design question: what is the right data structure here, and what makes it right?

What this teaches

  • remove(0) on an ArrayList shifts every remaining element, so it is O(n)
  • Draining n items that way is O(n^2) — invisible until n grows
  • Big-O on a single call hides the cost of the loop around it
  • ArrayDeque removes from the head with no shifting and no node allocation
  • A thread dump pointing at arraycopy is the whole diagnosis if you read it

Starter

Starter.java
import java.util.*;

/**
 * Incident reproduction: the batch worker's backlog drain.
 *
 * The measurement here counts ELEMENTS MOVED rather than milliseconds, so the
 * result is the same on a laptop and on a build agent. A linear drain moves
 * about n elements in total. This one moves about n^2 / 2.
 */
public class Starter {

    static final int JOBS = 30_000;

    /**
     * An ArrayList that reports how much copying its removals cause.
     *
     * ArrayList.remove(index) arraycopies (size - index - 1) elements down one
     * slot. Counting that in the override is exact, not an estimate.
     */
    static final class InstrumentedList<E> extends ArrayList<E> {
        long elementsMoved;

        @Override
        public E remove(int index) {
            elementsMoved += size() - index - 1;
            return super.remove(index);
        }
    }

    public static void main(String[] args) {
        InstrumentedList<String> backlog = new InstrumentedList<>();
        for (int i = 0; i < JOBS; i++) backlog.add("job-" + i);

        System.out.println("jobs queued      = " + JOBS);

        // The worker loop. One line, and it is the whole incident.
        int processed = 0;
        String first = null;
        String last = null;
        long start = System.nanoTime();
        while (!backlog.isEmpty()) {
            String job = backlog.remove(0);
            if (processed == 0) first = job;
            last = job;
            processed++;
        }
        long millis = (System.nanoTime() - start) / 1_000_000;

        System.out.println("jobs processed   = " + processed);
        System.out.println("first job out    = " + first + "   (FIFO expects job-0)");
        System.out.println("last job out     = " + last);
        System.out.println("elements moved   = " + backlog.elementsMoved);
        System.out.println("wall clock       = " + millis + "ms");

        // A linear drain moves roughly one element per job. Allow generous
        // slack; we are separating O(n) from O(n^2), not measuring precisely.
        long linearBudget = 4L * JOBS;
        System.out.println("linear budget    = " + linearBudget);
        System.out.println("over budget by   = " + (backlog.elementsMoved / Math.max(1, linearBudget)) + "x");

        boolean fifo = "job-0".equals(first);
        boolean linear = backlog.elementsMoved <= linearBudget;
        System.out.println(fifo && linear ? "PASS" : "FAIL");
    }
}

Run it locally:

cd exercises/java/collections/arraylist-vs-linkedlist/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    ArrayList stores elements contiguously. Removing index 0 has to move everything else down one slot. How many elements move per poll, and how many across the whole drain?

  2. Hint 2

    The thread dump is not a red herring. Ask what calls arraycopy inside ArrayList.remove.

  3. Hint 3

    LinkedList would fix the head-removal cost. Before you reach for it, ask what else you would be paying for on every element.

  4. Hint 4

    Count the moves rather than timing them — the shape of the number is the diagnosis, and it does not depend on how fast the machine is.

Done when

  • Elements moved during a full drain is O(n), not O(n^2)
  • FIFO order is preserved — the fix must not turn it into a stack
  • A comment justifies the chosen structure against both ArrayList and LinkedList

Solution

Show the solution — try it yourself first
Solution.java
import java.util.*;

/**
 * Root cause: `backlog.remove(0)` on an ArrayList is O(n), not O(1).
 *
 * ArrayList keeps its elements contiguously. Removing index 0 has to shift
 * every remaining element down one slot, which it does with System.arraycopy —
 * exactly what the thread dump showed. One poll of a 30,000-element backlog
 * moves 29,999 elements.
 *
 * Draining the whole backlog that way moves 29,999 + 29,998 + ... + 0, which is
 * n(n-1)/2 ≈ 450 million element moves for 30,000 jobs. That is the collapse.
 * It is pure CPU inside arraycopy: no locks, no GC, no I/O — which is precisely
 * why every other hypothesis was ruled out and the incidents got closed as
 * "transient load". It recovered on its own because the cost falls as the
 * backlog shrinks.
 *
 * Why it hid for two years: at a few hundred jobs, n^2/2 is a few tens of
 * thousands of moves — genuinely free. The bug did not appear under load; it
 * was always there and only became visible when n grew. Nothing about the code
 * changed.
 *
 * Why not LinkedList: it would make head removal genuinely O(1) and would fix
 * this. But you would then pay a Node allocation per job (~24 bytes of overhead
 * each, so ~700 KB of pure overhead for this backlog) plus GC churn, and any
 * indexed access anywhere else in the class becomes O(n).
 *
 * ArrayDeque is the right answer. It is a circular array: removing from the head
 * moves a head index instead of moving elements, so nothing is shifted and
 * nothing is allocated per element. Its own Javadoc says it is "likely to be
 * faster than LinkedList when used as a queue". It has been available since
 * Java 6.
 *
 * The one thing to be careful about: ArrayDeque is not a List, so if the
 * surrounding code indexes into the backlog you have to change that too. Here
 * it only polls, so the swap is direct.
 */
public class Solution {

    static final int JOBS = 30_000;

    /** Same instrumentation, so the two runs are comparable. */
    static final class InstrumentedList<E> extends ArrayList<E> {
        long elementsMoved;

        @Override
        public E remove(int index) {
            elementsMoved += size() - index - 1;
            return super.remove(index);
        }
    }

    public static void main(String[] args) {
        // THE FIX: a queue, used as a queue.
        Deque<String> backlog = new ArrayDeque<>();
        for (int i = 0; i < JOBS; i++) backlog.addLast("job-" + i);

        System.out.println("jobs queued      = " + JOBS);

        int processed = 0;
        String first = null;
        String last = null;
        long start = System.nanoTime();
        while (!backlog.isEmpty()) {
            String job = backlog.pollFirst();   // O(1), shifts nothing
            if (processed == 0) first = job;
            last = job;
            processed++;
        }
        long millis = (System.nanoTime() - start) / 1_000_000;

        System.out.println("jobs processed   = " + processed);
        System.out.println("first job out    = " + first + "   (FIFO expects job-0)");
        System.out.println("last job out     = " + last);
        System.out.println("elements moved   = 0   (head index advances instead)");
        System.out.println("wall clock       = " + millis + "ms");

        // Proof of the diagnosis: the original shape, measured.
        InstrumentedList<String> old = new InstrumentedList<>();
        for (int i = 0; i < JOBS; i++) old.add("job-" + i);
        while (!old.isEmpty()) old.remove(0);
        System.out.println("the old way moved = " + old.elementsMoved + " elements");
        System.out.println("expected n(n-1)/2 = " + ((long) JOBS * (JOBS - 1) / 2));

        boolean fifo = "job-0".equals(first);
        boolean allProcessed = processed == JOBS;
        System.out.println(fifo && allProcessed ? "PASS" : "FAIL");
    }
}

Stretch

The real fix in a service is usually not a different in-memory list at all — it is a bounded queue that applies backpressure so 30,000 jobs never pile up in one process. Swap in an ArrayBlockingQueue with a capacity and describe what the producer now has to handle that it did not before.

← Back to When would you use LinkedList instead of ArrayList?