Array vs ArrayList vs LinkedList (And What `List` Actually Is)
The interviewer asked twice about LinkedList's advantage, because the standard answer is incomplete. Here's the full one, including why ArrayList wins even when Big-O says it shouldn't.
Aryansh Kurmi
Software Developer
In an interview I was asked the difference between ArrayList and LinkedList. I gave the textbook answer. She asked again. I repeated myself with more words. She moved on, unsatisfied.
The reason: the textbook answer is incomplete, and the incomplete part is the whole point.
Part 0: List is an interface, not a thing
Let's clear this up first, because it's a real source of confusion.
List<String> names = new ArrayList<>();
Listis an interface — a contract. It says "whatever I am, I supportadd,get,remove,size, and I keep elements in order and allow duplicates." It contains no code that does anything.ArrayListis a class — an actual implementation, using an array underneath.LinkedListis a different class implementing the same contract, using linked nodes.
The relationship:
Iterable (interface)
│
Collection (interface)
│
List (interface) ← "ordered, indexed, duplicates allowed"
╱ ╲
ArrayList LinkedList ← classes: actual implementations
Why declare the variable as List rather than ArrayList?
List<String> names = new ArrayList<>(); // ✓ preferred
ArrayList<String> names2 = new ArrayList<>(); // ✗ over-specified
Because it lets you swap the implementation without touching any other code. Your method signature says "give me any List" and it doesn't care which one arrives:
public double average(List<Integer> numbers) { ... } // accepts either
This is programming to an interface, and it's the Dependency Inversion principle in practice.
Sibling interfaces worth knowing
| Interface | Contract |
|---|---|
List |
Ordered, indexed, duplicates allowed |
Set |
No duplicates, usually unordered |
Queue |
FIFO — add at one end, remove from the other |
Deque |
Double-ended — add/remove at both ends |
Map |
Key → value pairs (not a Collection) |
Part 1: The plain array
The most primitive option:
int[] numbers = new int[5];
numbers[0] = 10;
System.out.println(numbers[2]);
System.out.println(numbers.length); // a field, not a method
What it is in memory
A single contiguous block. Five ints, 4 bytes each, side by side:
address: 1000 1004 1008 1012 1016
┌─────┬─────┬─────┬─────┬─────┐
│ 10 │ 0 │ 0 │ 0 │ 0 │
└─────┴─────┴─────┴─────┴─────┘
Why get(i) is instant
Because it's arithmetic, not searching:
address of element i = base_address + (i × element_size)
element 3 = 1000 + (3 × 4) = 1012
The CPU computes an address and reads it. One operation, regardless of array size. An array of ten and an array of ten million take exactly the same time.
That's what O(1) means here — not "fast," but "the time doesn't depend on n."
Array limitations
int[] a = new int[5];
a[5] = 1; // ✗ ArrayIndexOutOfBoundsException — fixed size, forever
- Size is fixed at creation. Want a sixth element? Allocate a new array and copy.
- No helper methods. No
add,remove,contains. Just indexing. - Can hold primitives (
int[],double[]) — which is a genuine advantage, covered below.
Part 2: ArrayList
An ArrayList is an array plus automatic resizing plus convenience methods.
Inside, it really is just an array:
public class ArrayList<E> {
transient Object[] elementData; // the actual storage
private int size; // how many slots are USED
}
Note there are two different numbers: capacity (how big the array is) and size (how many elements you've added). list.size() returns the second.
How growth works
List<String> list = new ArrayList<>(); // capacity 10 on first add
When you add an 11th element:
1. allocate a new array of capacity 15 (old + old/2 — 1.5x growth)
2. System.arraycopy(old, 0, new, 0, 10) (a fast native memory copy)
3. elementData = new
4. add the element
The old array becomes garbage.
Amortised O(1) — the phrase to use
Adding to the end is usually O(1) — write to the next free slot, increment size. But occasionally it's O(n) because of the copy.
Average it out: adding n elements involves copies at 10, 15, 22, 33, 49... a geometric series that sums to less than 2n total copies. So the average per-add cost is constant.
That's amortised O(1). Saying "amortised" in an interview signals you actually understand it.
Practical tip: if you know the size, say so:
List<String> list = new ArrayList<>(10_000); // one allocation, zero copies
Inserting in the middle is the expensive part
list.add(0, "new"); // insert at the front
Every existing element must shift one slot right:
before: [A][B][C][D][ ]
↓ shift everything right
after: [X][A][B][C][D]
O(n). Adding 100,000 items to the front of an ArrayList is roughly 5 billion element moves.
The API
List<String> list = new ArrayList<>();
list.add("a"); // append O(1) amortised
list.add(0, "z"); // insert at index O(n)
list.get(1); // read O(1)
list.set(1, "b"); // overwrite O(1)
list.remove(0); // remove by index O(n)
list.remove("a"); // remove by value O(n) — must search first
list.contains("a"); // search O(n)
list.indexOf("a"); // search O(n)
list.size(); // O(1)
Part 3: LinkedList
A completely different shape. Instead of one contiguous block, each element is its own object holding pointers to its neighbours:
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
Java's LinkedList is doubly linked — each node knows both neighbours.
head tail
│ │
▼ ▼
┌──────┐ ⇄ ┌──────┐ ⇄ ┌──────┐ ⇄ ┌──────┐
│ A │ │ B │ │ C │ │ D │
└──────┘ └──────┘ └──────┘ └──────┘
Each box is a separate object, scattered anywhere in the heap.
Why get(i) is slow
There's no arithmetic to do. To reach element 3, you must walk:
start at head → next → next → next
O(n). (Java optimises slightly: if the index is past the halfway point it walks backwards from the tail. Still O(n).)
Why insertion at the ends is fast
Adding to the front is just pointer surgery:
before: head → [A] ⇄ [B] ⇄ [C]
new node: [X]
after: head → [X] ⇄ [A] ⇄ [B] ⇄ [C]
Set two pointers. O(1). No shifting, no copying, no matter how many elements exist.
Part 4: The answer she was actually looking for
Here's the standard answer everyone gives:
"ArrayList has O(1) access but O(n) insertion. LinkedList has O(n) access but O(1) insertion."
The second half is misleading, and that's why she asked again.
The correction: LinkedList insertion in the middle is ALSO O(n)
linkedList.add(5000, "x");
To insert at index 5000, you must first get to index 5000. That's a 5,000-step walk. Only then is the pointer update O(1).
find the position: O(n) ← the expensive part
rewire pointers: O(1) ← the part everyone quotes
─────────────────────────
total: O(n)
So for arbitrary insertion, both are O(n). ArrayList pays for shifting; LinkedList pays for walking. The Big-O is identical.
So what IS LinkedList's real advantage?
Two specific cases:
1. Insert or remove at the ends.
linkedList.addFirst("x"); // O(1) — no walking needed, head is right there
linkedList.removeFirst(); // O(1)
linkedList.addLast("y"); // O(1)
linkedList.removeLast(); // O(1)
2. Insert or remove at a position you're already holding — via an iterator:
Iterator<String> it = linkedList.iterator();
while (it.hasNext()) {
if (it.next().startsWith("temp")) {
it.remove(); // O(1) — the iterator already has the node
}
}
Removing 10,000 items this way from a LinkedList is 10,000 pointer updates. From an ArrayList it's 10,000 array shifts.
The sentence to say
"LinkedList's only genuine advantage is O(1) insert and remove at the ends, or at a position an iterator already holds. That makes it a Deque, not really a List. And
ArrayDequebeats it at being a Deque too. In practiceArrayListis the default andLinkedListis almost never the right choice."
Part 5: Why ArrayList wins even when Big-O says it shouldn't
This is the part that separates a memorised answer from an understood one.
Cache locality
Your CPU doesn't read memory one byte at a time. It reads cache lines — 64-byte chunks — into a small, extremely fast cache.
Accessing RAM is roughly 100× slower than accessing L1 cache. So performance in practice is often decided by cache hit rate, not by operation count.
ArrayList: elements sit contiguously. Reading element 0 pulls elements 1 through 15 into cache for free. Iterating is nearly all cache hits.
one cache line = 64 bytes = 16 ints
[0][1][2][3][4][5][6][7][8][9][10][11][12][13][14][15] ← one memory fetch
LinkedList: each node is a separate heap object, potentially anywhere. Following next is a pointer chase to an unpredictable address — likely a cache miss each time. The CPU's prefetcher can't help, because it can't guess where the next node is until it reads the current one.
[Node A] ... 4KB away ... [Node B] ... 12KB away ... [Node C]
miss miss miss
Memory overhead
Storing one Integer in a list of 1,000,000:
| Per element | Total | |
|---|---|---|
int[] |
4 bytes | ~4 MB |
ArrayList<Integer> |
4 (ref) + ~16 (Integer object) = 20 | ~20 MB |
LinkedList<Integer> |
~16 (Integer) + ~24 (Node: item, next, prev + header) = 40 | ~40 MB |
LinkedList uses roughly 10× the memory of a primitive array and 2× an ArrayList. More memory means more cache misses and more GC pressure — a million extra objects for the collector to trace.
The practical result
Benchmark almost anything and ArrayList wins — including cases where LinkedList "should" be faster by Big-O. Even iterate-and-remove-from-middle often favours ArrayList, because System.arraycopy is a highly optimised bulk memory move while pointer chasing is a series of cache misses.
Big-O counts operations. It doesn't know that some operations are 100× more expensive than others.
That sentence is what a strong candidate says.
Part 6: The decision table
| Operation | ArrayList |
LinkedList |
Winner in practice |
|---|---|---|---|
get(i) |
O(1) | O(n) | ArrayList, overwhelmingly |
add at end |
O(1) amortised | O(1) | ArrayList (locality) |
add/remove at front |
O(n) | O(1) | LinkedList |
add/remove in middle |
O(n) shift | O(n) walk | ArrayList (arraycopy is fast) |
remove via iterator |
O(n) | O(1) | LinkedList |
contains |
O(n) | O(n) | ArrayList (locality) |
| Iterate all | O(n) | O(n) | ArrayList, by a lot |
| Memory per element | Low | ~2× higher | ArrayList |
When to actually use each
Use ArrayList — the default. Random access, iteration, appending, anything general. If you're unsure, this is the answer.
Use LinkedList — almost never. If you genuinely need a queue or a deque, use ArrayDeque, which is array-backed (so it has cache locality) and still O(1) at both ends.
Use a plain array — when you need primitives without boxing (int[] not List<Integer>), when the size is fixed and known, or in tight numeric loops where every byte and cycle matters.
Deque<String> queue = new ArrayDeque<>(); // ✓ prefer this
Deque<String> queue2 = new LinkedList<>(); // ✗ works, but slower
Part 7: Traps worth knowing
Arrays.asList returns a fixed-size view
List<String> list = Arrays.asList("a", "b", "c");
list.set(0, "z"); // ✓ allowed — writes through to the backing array
list.add("d"); // ✗ UnsupportedOperationException
It's a view over the array, not a copy. Fixed size. To get a real list:
List<String> real = new ArrayList<>(Arrays.asList("a", "b", "c"));
List.of is fully immutable (Java 9+)
List<String> list = List.of("a", "b", "c");
list.set(0, "z"); // ✗ UnsupportedOperationException
list.add("d"); // ✗ UnsupportedOperationException
Also rejects nulls. Prefer it for constants.
ConcurrentModificationException
for (String s : list) {
if (s.startsWith("temp")) {
list.remove(s); // ✗ ConcurrentModificationException
}
}
The iterator keeps a modCount; changing the list behind its back invalidates it. This is fail-fast behaviour — deliberately throwing rather than silently skipping elements.
Three correct approaches:
list.removeIf(s -> s.startsWith("temp")); // ✓ cleanest
Iterator<String> it = list.iterator(); // ✓ explicit
while (it.hasNext()) {
if (it.next().startsWith("temp")) it.remove();
}
List<String> kept = list.stream() // ✓ new list
.filter(s -> !s.startsWith("temp"))
.toList();
Removing by index vs by value
List<Integer> nums = new ArrayList<>(List.of(10, 20, 30));
nums.remove(1); // removes INDEX 1 → the value 20
nums.remove(Integer.valueOf(10)); // removes the VALUE 10
remove(int) and remove(Object) are different overloads. With List<Integer> this is a genuine footgun.
Autoboxing costs more than you think
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
list.add(i); // each int becomes an Integer object
}
A million heap allocations, roughly 20 MB, and GC pressure. int[] would be 4 MB and zero allocations. (Integers from −128 to 127 are cached, which is also why Integer a = 128, b = 128; a == b is false while 127 == 127 is true.)
The 30-second interview answer
"
Listis an interface — the contract for an ordered, indexed collection allowing duplicates.ArrayListandLinkedListare two implementations.
ArrayListis a resizable array.get(i)is O(1) because the address is arithmetic. Appending is amortised O(1) — it grows 1.5× and copies, which averages to constant. Inserting in the middle is O(n) because of shifting.
LinkedListis doubly-linked nodes.get(i)is O(n) because you have to walk. Insertion at the ends is O(1).But the important nuance:
LinkedListinsertion in the middle is also O(n), because finding the position costs the traversal. Its only real advantage is O(1) at the ends or at a held iterator position — which makes it a Deque, andArrayDequedoes that better.In practice
ArrayListalmost always wins, even where Big-O suggests otherwise, because contiguous memory gives cache locality andLinkedListpointer-chasing causes cache misses. Big-O counts operations; it doesn't know some operations are 100× more expensive than others."
That last paragraph is the one that would have stopped the question being asked twice.