Why this matters
- The textbook answer — "LinkedList for insertions, ArrayList for reads" — is misleading, and knowing why marks the difference between memorised and understood.
ArrayListgrowth is the mechanism behind a surprising amount of garbage in list-heavy code, and one constructor argument removes it.- These two are the most frequently compared classes in Java interviews.
How each one stores elements
get(i) is instant. Inserting in the middle shifts everything after it.
get(i) walks from an end. Insert is pointer surgery.
ArrayListwraps anObject[]. Element i sits at offset i, so indexed access is a single calculation. Inserting in the middle shifts everything after it.LinkedListis a doubly linked list of nodes. Each node holds the element plus a reference forward and backward. Reaching index i means walking i links.
Cost, honestly
| Aspect | Operation | ArrayList vs LinkedList |
|---|---|---|
| get(i) / set(i) | O(1) | O(n) — must walk to the position |
| add at end | Amortised O(1) | O(1) |
| add or remove at index 0 | O(n) — shifts everything | O(1) |
| add or remove in the middle | O(n) shift | O(n) to find, then O(1) to relink |
| contains / indexOf | O(n) | O(n) |
| Memory per element | One slot, plus spare capacity | A node object with two references |
| Cache behaviour | Excellent — contiguous | Poor — nodes scattered on the heap |
get(i) / set(i)
OperationO(1)ArrayList vs LinkedListO(n) — must walk to the positionadd at end
OperationAmortised O(1)ArrayList vs LinkedListO(1)add or remove at index 0
OperationO(n) — shifts everythingArrayList vs LinkedListO(1)add or remove in the middle
OperationO(n) shiftArrayList vs LinkedListO(n) to find, then O(1) to relinkcontains / indexOf
OperationO(n)ArrayList vs LinkedListO(n)Memory per element
OperationOne slot, plus spare capacityArrayList vs LinkedListA node object with two referencesCache behaviour
OperationExcellent — contiguousArrayList vs LinkedListPoor — nodes scattered on the heap
LinkedList's middle insertion is only O(1) once you are already there, which an index-based call never is.
How ArrayList grows
The internal array has a capacity that is usually larger than the size. When it fills, a new
larger array is allocated and the contents copied.
List<Integer> numbers = new ArrayList<>(); // default capacity 10, allocated lazily
for (int i = 0; i < 1_000_000; i++) {
numbers.add(i); // ~30 reallocations and copies along the way
}
// Pre-size it when the count is known or estimable
List<Integer> sized = new ArrayList<>(1_000_000); // one allocation, no copying
Growth is roughly 1.5× the current capacity. That makes the amortised cost of add constant, because
the occasional expensive copy is spread across many cheap appends. But each copy allocates a full array
and leaves the old one for the garbage collector.
Practical consequences
- Pre-size when you can.
new ArrayList<>(expected)eliminates every intermediate array. size()is not capacity. A list of 3 elements may hold an array of 10.removenever shrinks the array. CalltrimToSize()on the concreteArrayListif a large list has shrunk permanently.clear()nulls the slots but keeps the capacity, which is efficient for reuse.
Removing while iterating
The enhanced for loop cannot remove safely. There are three correct approaches.
List<String> names = new ArrayList<>(List.of("ana", "bob", "carl", "dee"));
// Wrong: ConcurrentModificationException
// for (String name : names) {
// if (name.startsWith("b")) names.remove(name);
// }
// 1. removeIf — clearest, and the right default
names.removeIf(name -> name.startsWith("b"));
// 2. An explicit iterator, when the condition needs more than a predicate
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
if (iterator.next().length() > 3) {
iterator.remove(); // the only safe removal during iteration
}
}
// 3. A backwards index loop, when you need the index itself
for (int i = names.size() - 1; i >= 0; i--) {
if (names.get(i).isEmpty()) names.remove(i);
}
remove has two overloads
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30));
numbers.remove(1); // remove(int index) → removes 20
numbers.remove(Integer.valueOf(10)); // remove(Object value) → removes the value 10
Both compile. The first takes an index, the second a value, and for a List<Integer> the two are
indistinguishable at a glance. Always write Integer.valueOf(...) or cast to Object when you mean
the value.
CopyOnWriteArrayList
The thread-safe list, suited to a specific shape of workload.
List<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(listener); // copies the whole array — expensive
for (Listener listener : listeners) {
listener.onEvent(event); // iterates a snapshot; no lock, no CME
}
Every mutation copies the entire backing array. Reads and iteration are lock-free and never throw
ConcurrentModificationException, because the iterator holds the snapshot it started with.
When it fits
- Many reads, very few writes — event listener registries are the canonical example.
- Iteration must never throw, and seeing a slightly stale view is acceptable.
- Not for write-heavy use. Copying the array per write is quadratic in a loop.
Other list operations worth knowing
List<String> names = new ArrayList<>(List.of("carl", "ana", "bob"));
Collections.sort(names); // natural order, in place
names.sort(Comparator.comparing(String::length)); // the modern equivalent
Collections.reverse(names);
Collections.shuffle(names);
List<String> middle = names.subList(1, 3); // a VIEW, not a copy
int position = Collections.binarySearch(names, "bob"); // sorted list required
String[] array = names.toArray(new String[0]); // the idiomatic conversion
List<String> fromArray = new ArrayList<>(Arrays.asList(array));
Common misreadings
- "
LinkedListis faster for insertion." Only when you are already at the position. Index-based insertion pays O(n) to get there. - "
size()tells you how much memory the list uses." Capacity is usually larger and only grows. - "
removeshrinks the backing array." It does not. UsetrimToSize()if that matters. - "
ArrayListis thread-safe if you only read." Safe only if no thread writes at all. Any concurrent write needsCopyOnWriteArrayListor external synchronisation. - "
subListreturns a copy." It is a live view with a two-way relationship to the original. - "
toArray()with no argument gives a typed array." It returnsObject[]. Passnew String[0]for a typed one.
Quick recall
Everything you need if you only revisit this box.
ArrayListis a resizable array: O(1) indexed access, O(n) insertion other than at the end, excellent cache behaviour.LinkedListis a doubly linked list: O(1) relinking but O(n) to reach a position, plus a node object per element.- Default to
ArrayList. UseArrayDequefor queue or stack behaviour, notLinkedList. - Growth is ~1.5× with a full array copy. Pre-size with
new ArrayList<>(expected)when the count is known. - Remove during iteration with
removeIf, an explicitIterator.remove(), or a backwards index loop. remove(int)removes by index andremove(Object)by value — a real hazard forList<Integer>.CopyOnWriteArrayListsuits read-heavy, write-rare use; every write copies the array.subListis a view, not a copy.
Test yourself
Answer these before moving on — recall is what makes it stick.