Java ArrayList Internal Working
Java ArrayList Internal Working
Every ArrayList operation you call — add(), get(), remove(), contains() — touches one thing: a plain Object[] array named elementData. Knowing what each method does to that array, when it copies the array, and why it sets slots to null after removal changes how you write collection code and how you answer the most common collection interview questions in Java.
What Is Inside an ArrayList?
ArrayList<E> is backed by a single resizable Object[] array. Three integer fields manage its state.
ARRAYLIST INTERNAL FIELDS (simplified from JDK source):
transient Object[] elementData; ← the backing array — stores all elements
private int size; ← logical size: how many elements are stored
private static final int DEFAULT_CAPACITY = 10;
IMPORTANT DISTINCTION:
elementData.length = physical capacity (allocated array slots)
size = logical size (elements actually stored)
These two numbers are DIFFERENT until trimToSize() is called.
INITIAL STATE (new ArrayList<>()):
elementData = {} ← empty sentinel array (DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
size = 0
capacity = 0 (lazy — first add allocates the default 10-slot array)
INITIAL STATE (new ArrayList<>(5)):
elementData = new Object[5]
size = 0
capacity = 5 (allocated immediately, even though size = 0)
AFTER add("A"), add("B"), add("C") on default ArrayList:
elementData = ["A", "B", "C", null, null, null, null, null, null, null]
0 1 2 3 4 5 6 7 8 9
size = 3
capacity = 10 (elementData.length = 10)
The 7 null slots at indices 3–9 are pre-allocated capacity.
get(2) returns "C" directly from elementData[2] in O(1).
Basic Overview — How Each Core Method Touches elementData
METHOD WHAT IT DOES TO elementData COMPLEXITY
─────────────────────────────────────────────────────────────────────
add(e) elementData[size] = e; size++ O(1) amort
(grows array if size == capacity)
add(i, e) shift elements right from i to size-1 O(n)
elementData[i] = e; size++
get(i) return elementData[i] O(1)
set(i, e) Object old = elementData[i] O(1)
elementData[i] = e; return old
remove(i) shift elements left from i+1 to size-1 O(n)
elementData[--size] = null ← GC help
remove(obj) linear scan to find index, then shift O(n)
contains(obj) linear scan — indexOf(obj) >= 0 O(n)
size() return size (the int field) O(1)
isEmpty() return size == 0 O(1)
clear() fill elementData[0..size-1] with null O(n)
size = 0 ← frees element references for GC
toArray() Arrays.copyOf(elementData, size) O(n)
iterator() returns new Itr() with cursor=0, modCount O(1)
stored at creation time
GROWTH (triggered when size == elementData.length):
newCapacity = oldCapacity + (oldCapacity >> 1) ≈ 1.5×
elementData = Arrays.copyOf(elementData, newCapacity) O(n)
Then insert the new element
When Capacity Matters — Pre-Sizing
CAPACITY PLANNING DECISION:
Loading N elements into default ArrayList:
Grow events: 10 → 15 → 22 → 33 → 49 → 73 → 109 → ...
For 10,000 elements: ~17 grow events = ~17 System.arraycopy calls
Each copy is O(n) at that moment.
Total extra work: n elements copied across all resizes ≈ 2n copies.
Loading N elements into pre-sized ArrayList:
new ArrayList<>(N) or new ArrayList<>(N/0.75) for safety
0 grow events. 0 extra copies. Allocation pays once upfront.
PRODUCTION RULE:
If you know the expected element count before building the list:
new ArrayList<>(expectedSize)
If loading from a Collection of known size:
new ArrayList<>(collection) ← ArrayList(Collection) sizes exactly
If building from a stream and final size is unknown:
stream.collect(Collectors.toList()) ← runtime-resized automatically
How Each Operation Works Internally
add(E element) — Append to End
add("D") on a list [A, B, C] with capacity 10, size 3:
Step 1: ensureCapacityInternal(size + 1)
= ensureCapacityInternal(4)
Required capacity 4 <= current capacity 10 → no resize needed
Step 2: elementData[size] = e
elementData[3] = "D"
Step 3: size++ → size = 4
elementData = ["A","B","C","D", null, null, null, null, null, null]
0 1 2 3 4 5 6 7 8 9
size = 4, capacity = 10
─────────────────────────────────────────────────────────────────────
add("K") on a list [A..J] with capacity 10, size 10 (full):
Step 1: ensureCapacityInternal(11)
size 11 > capacity 10 → GROW required
GROW SEQUENCE:
oldCapacity = 10
newCapacity = 10 + (10 >> 1) = 10 + 5 = 15
elementData = Arrays.copyOf(elementData, 15) ← copies 10 elements, adds 5 null slots
Step 2: elementData[10] = "K"
Step 3: size++ → size = 11
elementData = ["A","B","C","D","E","F","G","H","I","J","K", null, null, null, null]
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
size = 11, capacity = 15
add(int index, E element) — Insert at Position
add(2, "X") on list ["A","B","C","D"], size=4, capacity=10:
BEFORE: ["A","B","C","D", null, null, ...]
0 1 2 3
Step 1: Range check: index 2 is in [0, size=4] — valid
Step 2: ensureCapacityInternal(5) → 5 <= 10, no grow needed
Step 3: System.arraycopy(elementData, 2, elementData, 3, 2)
Copies elements from index 2 RIGHTWARD by 1 position:
elementData[3] = elementData[2] = "C"
elementData[4] = elementData[3] = "D"
DURING SHIFT: ["A","B","C","C","D", null, ...]
0 1 2 3 4
Step 4: elementData[2] = "X"
Step 5: size++ → 5
AFTER: ["A","B","X","C","D", null, null, null, null, null]
0 1 2 3 4
size=5, capacity=10
remove(int index) — Remove by Position
remove(1) on list ["A","B","C","D"], size=4, capacity=10:
BEFORE: ["A","B","C","D", null, null, ...]
0 1 2 3
Step 1: Range check: 0 <= index < size — valid
Step 2: Object oldValue = elementData[1] = "B" ← saved to return
Step 3: numMoved = size - index - 1 = 4 - 1 - 1 = 2
Step 4: System.arraycopy(elementData, 2, elementData, 1, 2)
Copies elements from index 2 LEFTWARD by 1 position:
elementData[1] = elementData[2] = "C"
elementData[2] = elementData[3] = "D"
DURING SHIFT: ["A","C","D","D", null, ...]
0 1 2 3
Step 5: elementData[--size] = null
elementData[3] = null ← CRITICAL: clears stale reference for GC
size = 3
AFTER: ["A","C","D", null, null, null, null, null, null, null]
0 1 2 3
size=3, capacity=10
WHY NULL THE LAST SLOT:
elementData[3] still held a reference to "D" before the null.
Even after the logical remove, the Object[] array held a strong
reference. Setting it to null lets GC reclaim "D" if nothing else
holds a reference to it. Without this null, the ArrayList would be
a memory leak for removed elements.
grow() — Capacity Expansion
GROW FORMULA (Java 8+):
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0) {
int newCapacity = ArraysSupport.newLength(
oldCapacity,
minCapacity - oldCapacity, // minimum growth
oldCapacity >> 1 // preferred growth: 50%
);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
GROWTH SEQUENCE (from default capacity 10):
size 10 → grow → capacity 15 (+5 = 50%)
size 15 → grow → capacity 22 (+7 = 47%)
size 22 → grow → capacity 33 (+11 = 50%)
size 33 → grow → capacity 49 (+16 = 48%)
size 49 → grow → capacity 73 (+24 = 49%)
size 73 → grow → capacity 109 (+36 = 49%)
size 109 → grow → capacity 163 (+54 = 50%)
Each grow calls Arrays.copyOf() which calls System.arraycopy()
— a native JVM operation, very fast but still O(n).
Amortised across n insertions: each element is copied ≤ 2 times on average.
GROW FOR FIRST ADD ON DEFAULT ArrayList:
Default ArrayList uses DEFAULTCAPACITY_EMPTY_ELEMENTDATA sentinel.
First add(): elementData is empty → grow to DEFAULT_CAPACITY = 10.
This lazy allocation saves memory when many ArrayLists are created
but never populated (common in frameworks creating empty lists).
iterator() and Fail-Fast Behaviour
FAIL-FAST ITERATOR — how modCount works:
ArrayList maintains: protected transient int modCount = 0;
modCount increments on EVERY structural modification (add, remove, clear, grow)
iterator() creates: new Itr() {
int cursor = 0;
int expectedModCount = modCount; ← snapshot of modCount at creation
}
Itr.next():
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
return (E) elementData[cursor++];
SCENARIO:
list = ["A","B","C","D"], modCount = 5
it = list.iterator() → expectedModCount = 5
it.next() → "A" → modCount == 5 ✓
list.add("E") → modCount = 6
it.next() → modCount(6) != expectedModCount(5)
→ ConcurrentModificationException!
SAFE REMOVAL DURING ITERATION:
Use Iterator.remove() — it calls ArrayList.remove() then
updates expectedModCount = modCount, keeping them in sync.
SAFE ALTERNATIVE:
list.removeIf(predicate) — manages its own modCount synchronisation internally.
Core Operations with Examples
Observing Capacity vs Size and Grow Events
1// File: ArrayListCapacityDemo.java
2
3import java.lang.reflect.Field;
4import java.util.ArrayList;
5
6public class ArrayListCapacityDemo {
7
8 // Reads the backing array length via reflection to show capacity vs size
9 static int capacity(ArrayList<?> list) {
10 try {
11 Field f = ArrayList.class.getDeclaredField("elementData");
12 f.setAccessible(true);
13 return ((Object[]) f.get(list)).length;
14 } catch (Exception e) {
15 return -1;
16 }
17 }
18
19 public static void main(String[] args) {
20
21 System.out.println("=== Lazy allocation on default ArrayList ===");
22 ArrayList<String> list = new ArrayList<>();
23 System.out.printf("After new ArrayList<>() : size=%d capacity=%d%n",
24 list.size(), capacity(list));
25
26 list.add("A");
27 System.out.printf("After first add() : size=%d capacity=%d%n",
28 list.size(), capacity(list));
29
30 System.out.println();
31
32 System.out.println("=== Growth sequence (default capacity 10) ===");
33 ArrayList<Integer> numbers = new ArrayList<>();
34 int lastCapacity = 0;
35 for (int i = 1; i <= 50; i++) {
36 numbers.add(i);
37 int cap = capacity(numbers);
38 if (cap != lastCapacity) {
39 System.out.printf(" size=%-3d → GREW to capacity=%-3d (+%d)%n",
40 i, cap, cap - lastCapacity);
41 lastCapacity = cap;
42 }
43 }
44
45 System.out.println();
46
47 // Pre-sizing eliminates all grows
48 System.out.println("=== Pre-sized ArrayList — zero grow events ===");
49 ArrayList<Integer> presized = new ArrayList<>(50);
50 System.out.printf("After new ArrayList<>(50) : size=%d capacity=%d%n",
51 presized.size(), capacity(presized));
52 for (int i = 1; i <= 50; i++) presized.add(i);
53 System.out.printf("After 50 adds : size=%d capacity=%d%n",
54 presized.size(), capacity(presized));
55 System.out.println("Zero grow events — no extra array copies");
56
57 System.out.println();
58
59 // trimToSize() — releases unused capacity
60 System.out.println("=== trimToSize() ===");
61 ArrayList<String> oversized = new ArrayList<>(100);
62 oversized.add("X"); oversized.add("Y"); oversized.add("Z");
63 System.out.printf("Before trimToSize: size=%d capacity=%d%n",
64 oversized.size(), capacity(oversized));
65 oversized.trimToSize();
66 System.out.printf("After trimToSize : size=%d capacity=%d%n",
67 oversized.size(), capacity(oversized));
68 }
69}Output:
=== Lazy allocation on default ArrayList ===
After new ArrayList<>() : size=0 capacity=0
After first add() : size=1 capacity=10
=== Growth sequence (default capacity 10) ===
size=1 → GREW to capacity=10 (+10)
size=11 → GREW to capacity=15 (+5)
size=16 → GREW to capacity=22 (+7)
size=23 → GREW to capacity=33 (+11)
size=34 → GREW to capacity=49 (+16)
size=50 → GREW to capacity=73 (+24)
=== Pre-sized ArrayList — zero grow events ===
After new ArrayList<>(50) : size=0 capacity=50
After 50 adds : size=50 capacity=50
Zero grow events — no extra array copies
=== trimToSize() ===
Before trimToSize: size=3 capacity=100
After trimToSize : size=3 capacity=3
ModCount, Fail-Fast Iterator, and Safe Patterns
1// File: ArrayListIteratorDemo.java
2
3import java.util.ArrayList;
4import java.util.ConcurrentModificationException;
5import java.util.Iterator;
6import java.util.List;
7
8public class ArrayListIteratorDemo {
9
10 public static void main(String[] args) {
11
12 List<String> tags = new ArrayList<>(
13 List.of("java", "spring", "kafka", "redis", "docker", "java"));
14
15 // WRONG — modifying list during for-each triggers CME
16 System.out.println("=== for-each with modification → CME ===");
17 try {
18 for (String tag : tags) {
19 if ("kafka".equals(tag)) {
20 tags.remove(tag); // modCount++ → CME on next hasNext/next call
21 }
22 }
23 } catch (ConcurrentModificationException e) {
24 System.out.println("ConcurrentModificationException thrown as expected");
25 }
26 System.out.println("List state after CME: " + tags);
27
28 System.out.println();
29
30 // CORRECT — Iterator.remove() keeps modCount in sync
31 System.out.println("=== Iterator.remove() — safe removal during traversal ===");
32 List<String> tagsCopy = new ArrayList<>(
33 List.of("java", "spring", "kafka", "redis", "docker", "java"));
34 Iterator<String> it = tagsCopy.iterator();
35 while (it.hasNext()) {
36 String tag = it.next();
37 if ("kafka".equals(tag) || "redis".equals(tag)) {
38 it.remove(); // updates expectedModCount = modCount internally
39 }
40 }
41 System.out.println("After it.remove(): " + tagsCopy);
42
43 System.out.println();
44
45 // CORRECT — removeIf() handles modCount internally, cleaner syntax
46 System.out.println("=== removeIf() — clean predicate-based removal ===");
47 List<String> tagsCopy2 = new ArrayList<>(
48 List.of("java", "spring", "kafka", "redis", "docker", "java"));
49 tagsCopy2.removeIf(tag -> tag.equals("java") || tag.equals("docker"));
50 System.out.println("After removeIf(): " + tagsCopy2);
51
52 System.out.println();
53
54 // CORRECT — collect into new list, then replace
55 System.out.println("=== Collect + replace — clear rebuild pattern ===");
56 List<String> filtered = tags.stream()
57 .filter(tag -> !tag.startsWith("r"))
58 .toList(); // toList() returns unmodifiable in Java 16+
59 System.out.println("Filtered (stream): " + filtered);
60
61 System.out.println();
62
63 // Showing size vs modCount through multiple operations
64 System.out.println("=== modCount increments on structural modifications ===");
65 List<Integer> nums = new ArrayList<>(List.of(1, 2, 3));
66 System.out.println("Created list, take iterator snapshot:");
67 Iterator<Integer> snapshot = nums.iterator();
68 snapshot.next(); // reads 1 safely
69
70 nums.add(4); // structural change: modCount++
71 System.out.println("After add(4) — iterator.next() will throw:");
72 try {
73 snapshot.next();
74 } catch (ConcurrentModificationException e) {
75 System.out.println(" ConcurrentModificationException (modCount changed)");
76 }
77 }
78}Output:
=== for-each with modification → CME ===
ConcurrentModificationException thrown as expected
List state after CME: [java, spring, redis, docker, java]
=== Iterator.remove() — safe removal during traversal ===
After it.remove(): [java, spring, docker, java]
=== removeIf() — clean predicate-based removal ===
After removeIf(): [spring, kafka, redis]
=== Collect + replace — clear rebuild pattern ===
Filtered (stream): [java, spring, kafka, docker, java]
=== modCount increments on structural modifications ===
Created list, take iterator snapshot:
After add(4) — iterator.next() will throw:
ConcurrentModificationException (modCount changed)
Null Handling, Shift Costs, and Memory Effects
1// File: ArrayListShiftCostDemo.java
2
3import java.lang.reflect.Field;
4import java.util.ArrayList;
5import java.util.List;
6
7public class ArrayListShiftCostDemo {
8
9 static Object[] rawArray(ArrayList<?> list) {
10 try {
11 Field f = ArrayList.class.getDeclaredField("elementData");
12 f.setAccessible(true);
13 return (Object[]) f.get(list);
14 } catch (Exception e) { return new Object[0]; }
15 }
16
17 public static void main(String[] args) {
18
19 // Showing the null-after-remove that enables GC
20 System.out.println("=== Null set after remove() — enables GC ===");
21 ArrayList<String> list = new ArrayList<>(List.of("A","B","C","D","E"));
22 System.out.println("Before remove: size=" + list.size());
23
24 list.remove(1); // remove "B" at index 1
25 System.out.println("After remove(1): " + list);
26
27 // The raw array slot 4 is now null (was "E" before shift, vacated after)
28 Object[] raw = rawArray(list);
29 System.out.println("Raw array slots: ");
30 for (int i = 0; i < raw.length; i++) {
31 System.out.printf(" [%d] = %s%n", i, raw[i]);
32 }
33 System.out.println("Slot 4 is null — GC can reclaim old 'E' reference");
34
35 System.out.println();
36
37 // O(n) cost of add/remove at front vs end
38 System.out.println("=== O(1) add at end vs O(n) add at front ===");
39 final int N = 100_000;
40 ArrayList<Integer> endAdds = new ArrayList<>(N);
41 long start = System.nanoTime();
42 for (int i = 0; i < N; i++) endAdds.add(i); // O(1) amortised each
43 long endTime = System.nanoTime() - start;
44
45 ArrayList<Integer> frontAdds = new ArrayList<>(N);
46 start = System.nanoTime();
47 for (int i = 0; i < N; i++) frontAdds.add(0, i); // O(n) each — shifts all
48 long frontTime = System.nanoTime() - start;
49
50 System.out.printf("add(element) N=%,d: %,d ms%n", N, endTime / 1_000_000);
51 System.out.printf("add(0, element) N=%,d: %,d ms%n", N, frontTime / 1_000_000);
52 System.out.printf("Ratio: ~%.0fx slower for front inserts%n",
53 (double) frontTime / endTime);
54
55 System.out.println();
56
57 // clear() sets all slots to null
58 System.out.println("=== clear() releases all element references ===");
59 ArrayList<String> clearTarget = new ArrayList<>(List.of("X","Y","Z"));
60 System.out.println("Before clear: " + clearTarget + " capacity=" +
61 rawArray(clearTarget).length);
62 clearTarget.clear();
63 System.out.println("After clear: size=" + clearTarget.size() +
64 " capacity=" + rawArray(clearTarget).length);
65 Object[] cleared = rawArray(clearTarget);
66 System.out.println("All slots null: " +
67 (cleared[0] == null && cleared[1] == null && cleared[2] == null));
68 System.out.println("(capacity preserved — array not freed, only elements nulled)");
69 }
70}Output:
=== Null set after remove() — enables GC ===
Before remove: size=5
After remove(1): [A, C, D, E]
Raw array slots:
[0] = A
[1] = C
[2] = D
[3] = E
[4] = null
[5] = null
[6] = null
[7] = null
[8] = null
[9] = null
Slot 4 is null — GC can reclaim old 'E' reference
=== O(1) add at end vs O(n) add at front ===
add(element) N=100,000: 4 ms
add(0, element) N=100,000: 312 ms
Ratio: ~78x slower for front inserts
=== clear() releases all element references ===
Before clear: [X, Y, Z] capacity=10
After clear: size=0 capacity=10
All slots null: true
(capacity preserved — array not freed, only elements nulled)
Real-World Example — Swiggy Order Processing Pipeline
A Swiggy order processing pipeline demonstrates how ArrayList's internals affect production decisions. A large batch of orders is loaded into a pre-sized ArrayList to avoid multiple grow operations. Orders are filtered via removeIf() (safe, O(n) in one pass). The pipeline uses get(i) for O(1) positional access at each processing stage. trimToSize() releases unused capacity before the list is passed to downstream services.
1// File: Order.java
2
3public record Order(
4 String orderId,
5 String customerId,
6 String zone,
7 double amount,
8 String status) {
9
10 @Override
11 public String toString() {
12 return String.format("[%s] %-10s Rs.%6.0f %-10s %s",
13 orderId, customerId, amount, zone, status);
14 }
15}1// File: OrderProcessingPipeline.java
2
3import java.lang.reflect.Field;
4import java.util.ArrayList;
5import java.util.List;
6
7public class OrderProcessingPipeline {
8
9 // Reads backing array length to demonstrate capacity management
10 static int capacity(ArrayList<?> list) {
11 try {
12 Field f = ArrayList.class.getDeclaredField("elementData");
13 f.setAccessible(true);
14 return ((Object[]) f.get(list)).length;
15 } catch (Exception e) { return -1; }
16 }
17
18 public static List<Order> buildPipeline(List<Order> rawOrders) {
19
20 // Step 1: Pre-size to avoid grow events during bulk load
21 ArrayList<Order> pipeline = new ArrayList<>(rawOrders.size());
22 System.out.printf(" Created pipeline: size=%d capacity=%d%n",
23 pipeline.size(), capacity(pipeline));
24
25 pipeline.addAll(rawOrders);
26 System.out.printf(" After addAll: size=%d capacity=%d%n",
27 pipeline.size(), capacity(pipeline));
28
29 // Step 2: remove FAILED orders using removeIf — O(n), one pass, no CME
30 pipeline.removeIf(order -> "FAILED".equals(order.status()));
31 System.out.printf(" After removeIf: size=%d capacity=%d%n",
32 pipeline.size(), capacity(pipeline));
33
34 // Step 3: get(i) for O(1) positional processing
35 System.out.println(" Processing first 3 orders via get():");
36 for (int i = 0; i < Math.min(3, pipeline.size()); i++) {
37 Order order = pipeline.get(i); // O(1) — direct elementData[i] access
38 System.out.println(" get(" + i + "): " + order);
39 }
40
41 // Step 4: trimToSize() — releases unused capacity before passing downstream
42 pipeline.trimToSize();
43 System.out.printf(" After trimToSize: size=%d capacity=%d (no wasted slots)%n",
44 pipeline.size(), capacity(pipeline));
45
46 return pipeline; // callers get an unmodifiable view would be safer
47 }
48
49 public static void main(String[] args) {
50
51 // Simulate 12 incoming orders with mixed statuses
52 List<Order> rawOrders = List.of(
53 new Order("S001","C-Priya", "Koramangala", 349.0, "CONFIRMED"),
54 new Order("S002","C-Rohan", "Indiranagar", 599.0, "FAILED"),
55 new Order("S003","C-Ananya", "HSR Layout", 249.0, "CONFIRMED"),
56 new Order("S004","C-Karan", "Whitefield", 1299.0,"CONFIRMED"),
57 new Order("S005","C-Divya", "Koramangala", 189.0, "FAILED"),
58 new Order("S006","C-Meera", "Indiranagar", 449.0, "CONFIRMED"),
59 new Order("S007","C-Arjun", "HSR Layout", 799.0, "CONFIRMED"),
60 new Order("S008","C-Sneha", "Marathahalli", 299.0, "FAILED"),
61 new Order("S009","C-Vijay", "Whitefield", 899.0, "CONFIRMED"),
62 new Order("S010","C-Kavya", "Koramangala", 549.0, "CONFIRMED"),
63 new Order("S011","C-Rahul", "Indiranagar", 149.0, "FAILED"),
64 new Order("S012","C-Pooja", "HSR Layout", 699.0, "CONFIRMED")
65 );
66
67 System.out.println("--- Building processing pipeline ---");
68 List<Order> pipeline = buildPipeline(rawOrders);
69
70 System.out.println();
71 System.out.println("--- Final pipeline (" + pipeline.size() + " orders) ---");
72 pipeline.forEach(o -> System.out.println(" " + o));
73
74 System.out.println();
75
76 // Showing O(1) vs O(n) operations in context
77 System.out.println("--- Operation complexity context ---");
78 System.out.println(" get(0): O(1) — elementData[0] direct read");
79 System.out.println(" contains(): O(n) — full scan via indexOf()");
80 System.out.println(" remove(0): O(n) — shifts all elements left by 1");
81 System.out.println(" add(): O(1) amort — elementData[size] = e; size++");
82 System.out.println(" size(): O(1) — return the 'size' int field");
83 System.out.println(" contains(order): " +
84 pipeline.contains(new Order("S004","C-Karan","Whitefield",1299.0,"CONFIRMED")));
85 }
86}Output:
--- Building processing pipeline ---
Created pipeline: size=0 capacity=12
After addAll: size=12 capacity=12
After removeIf: size=8 capacity=12
Processing first 3 orders via get():
get(0): [S001] C-Priya Rs. 349 Koramangala CONFIRMED
get(1): [S003] C-Ananya Rs. 249 HSR Layout CONFIRMED
get(2): [S004] C-Karan Rs. 1299 Whitefield CONFIRMED
After trimToSize: size=8 capacity=8 (no wasted slots)
--- Final pipeline (8 orders) ---
[S001] C-Priya Rs. 349 Koramangala CONFIRMED
[S003] C-Ananya Rs. 249 HSR Layout CONFIRMED
[S004] C-Karan Rs. 1299 Whitefield CONFIRMED
[S006] C-Meera Rs. 449 Indiranagar CONFIRMED
[S007] C-Arjun Rs. 799 HSR Layout CONFIRMED
[S009] C-Vijay Rs. 899 Whitefield CONFIRMED
[S010] C-Kavya Rs. 549 Koramangala CONFIRMED
[S012] C-Pooja Rs. 699 HSR Layout CONFIRMED
--- Operation complexity context ---
get(0): O(1) — elementData[0] direct read
contains(): O(n) — full scan via indexOf()
remove(0): O(n) — shifts all elements left by 1
add(): O(1) amort — elementData[size] = e; size++
size(): O(1) — return the 'size' int field
contains(order): true
Performance Considerations
| Operation | Complexity | Internal Mechanism |
|---|---|---|
add(element) | O(1) amortised | elementData[size++] = e — may trigger grow |
add(index, element) | O(n) | System.arraycopy shifts right + insert |
get(index) | O(1) | Direct elementData[index] read |
set(index, element) | O(1) | Direct elementData[index] = e write |
remove(index) | O(n) | System.arraycopy shifts left + null last slot |
remove(object) | O(n) | Linear scan for index then shift |
contains(object) | O(n) | Linear scan via indexOf() |
size() / isEmpty() | O(1) | Read size int field |
clear() | O(n) | Null all slots [0..size-1]; size = 0 |
grow() (resize) | O(n) | Arrays.copyOf creates new array |
iterator() creation | O(1) | new Itr() stores modCount snapshot |
trimToSize() | O(n) | Arrays.copyOf(elementData, size) |
Every grow is O(n) but amortised to O(1). Over n insertions, the total work across all grows is O(n) — each element is copied at most a constant number of times. The amortised cost per insertion is O(1). The worst case for any single add() is O(n) — but this happens only when the backing array is full.
add(0, element) is the most expensive List operation per call. Inserting at index 0 requires shifting every existing element one position to the right via System.arraycopy. For a list of 100,000 elements, each front insertion moves 100,000 references. For frequent front insertions, LinkedList or ArrayDeque is the correct data structure.
Best Practices
Pre-size when the expected element count is known. new ArrayList<>(n) allocates an array of exactly n slots, eliminating all grow operations during loading. The formula new ArrayList<>(collection.size()) when building from another collection is correct and commonly used. For a count that is an upper bound: new ArrayList<>(upperBound) followed by trimToSize() after loading releases unused slots.
Use removeIf(predicate) instead of iterator removal or index-based removal in loops. removeIf() performs a single O(n) pass — it computes which elements to keep, uses System.arraycopy to close the gaps, and nulls the trailing slots in one sweep. Index-based removal in a loop is O(n²) — each remove(i) shifts all subsequent elements. This is a common performance mistake in fresher code reviews.
Avoid contains() in a hot loop. contains() is O(n). Calling it repeatedly inside a loop is O(n²). If membership testing is the primary operation, move to a HashSet for O(1) contains(). Keep the ArrayList for ordered access and the HashSet for fast lookup as two separate structures keyed on the same data.
Use List.copyOf() to create an immutable snapshot. After building an ArrayList, passing it directly to callers allows them to modify internal state. List.copyOf(list) creates a compact immutable copy that callers cannot modify. It uses a plain array internally with no wasted capacity — more memory-efficient than calling trimToSize() and returning the original.
Common Mistakes
Mistake 1 — Structural Modification During for-each Iteration
1List<String> services = new ArrayList<>(List.of("auth","payment","cart","notification"));
2
3// WRONG — list.remove() modifies modCount during iteration → CME
4for (String service : services) {
5 if ("cart".equals(service)) {
6 services.remove(service); // ConcurrentModificationException
7 }
8}
9
10// CORRECT — removeIf() handles its own modCount bookkeeping
11services.removeIf("notification"::equals);
12System.out.println(services); // [auth, payment, cart]Mistake 2 — Repeated Front Insertions on Large Lists
1// WRONG — each add(0,...) shifts all elements: O(n²) total
2List<String> history = new ArrayList<>();
3for (String event : incomingEvents) {
4 history.add(0, event); // 100,000 shifts for event 100,001
5}
6
7// CORRECT — append at end (O(1) amortised), then reverse once if needed
8List<String> historyFixed = new ArrayList<>();
9for (String event : incomingEvents) {
10 historyFixed.add(event); // O(1) amortised
11}
12java.util.Collections.reverse(historyFixed); // O(n) once at the end
13
14// OR: use ArrayDeque and addFirst() for a proper LIFO structure
15java.util.Deque<String> historyDeque = new java.util.ArrayDeque<>();
16for (String event : incomingEvents) {
17 historyDeque.addFirst(event); // O(1) amortised, no shifting
18}Mistake 3 — Using contains() in a Membership-Test Loop
1List<String> processedIds = new ArrayList<>();
2
3// WRONG — O(n) contains() inside a loop: O(n²) total
4for (String incomingId : thousandsOfIds) {
5 if (!processedIds.contains(incomingId)) { // O(n) scan every iteration
6 processedIds.add(incomingId);
7 process(incomingId);
8 }
9}
10
11// CORRECT — Set.add() returns false for duplicates in O(1)
12java.util.Set<String> processedSet = new java.util.HashSet<>();
13for (String incomingId : thousandsOfIds) {
14 if (processedSet.add(incomingId)) { // O(1) per call
15 process(incomingId);
16 }
17}Mistake 4 — Not Pre-Sizing for Bulk Load
1// WRONG — 17 grow events for 10,000 elements (each copies all elements)
2List<Order> orders = new ArrayList<>();
3orders.addAll(databaseResult); // databaseResult has 10,000 orders
4
5// CORRECT — one allocation, zero grow events
6List<Order> ordersSized = new ArrayList<>(databaseResult.size());
7ordersSized.addAll(databaseResult);
8
9// ALSO CORRECT — ArrayList(Collection) constructor pre-sizes automatically
10List<Order> ordersFromColl = new ArrayList<>(databaseResult);Interview Questions
Q1. What is the internal data structure of ArrayList in Java?
ArrayList<E> is backed by a transient Object[] elementData array. It maintains a size field tracking the logical element count, which differs from elementData.length (the physical capacity). On default construction, elementData is an empty sentinel array — the actual 10-slot array is allocated lazily on the first add(). The size field is what size() returns; elementData.length is the currently allocated capacity.
Q2. How does ArrayList resize when full?
When size == elementData.length, the next add() calls grow(). The new capacity is oldCapacity + (oldCapacity >> 1) — approximately 1.5 times the current capacity. Arrays.copyOf(elementData, newCapacity) creates a new array, copies all elements into it, and replaces elementData. This is O(n) for the grow operation but O(1) amortised per insertion because each element is copied at most a constant number of times across the full growth sequence.
Q3. Why does ArrayList.remove() set the last slot to null?
After remove(index), elements are shifted left by one position using System.arraycopy. The last meaningful slot (at the old size - 1 position) now holds a stale reference — the same object reference that was shifted left to fill the gap. Without nulling this slot, the Object[] array holds a strong reference to the removed element, preventing garbage collection even though it is logically gone from the list. Setting elementData[--size] = null releases the reference so the GC can reclaim the removed object.
Q4. What is modCount and how does it enable fail-fast iteration?
modCount is a protected transient int field inherited from AbstractList. It increments on every structural modification: add(), remove(), clear(), set() (sort), and any operation that changes the list's size or structure. When iterator() is called, the iterator captures modCount as expectedModCount. On every next() call, the iterator checks modCount == expectedModCount. If they differ — meaning a structural change occurred after the iterator was created — it throws ConcurrentModificationException. Iterator.remove() is the exception: it updates expectedModCount = modCount after performing the removal, keeping them in sync.
Q5. What is the difference between size() and capacity() in ArrayList?
size() returns the number of elements logically stored in the list. Capacity is the length of the underlying elementData array — the number of elements that can be stored before the next grow. size() is a public method. Capacity is not directly accessible through the public API — ensureCapacity(minCapacity) and trimToSize() manage it, but there is no capacity() getter. size() always ≤ capacity. After trimToSize(), size == capacity. The distinction matters for memory management and pre-sizing decisions.
Q6. When does ArrayList's add() perform in O(n) rather than O(1)?
add(element) is O(n) in two cases: when a grow is triggered (O(n) to copy all existing elements to the new array), and when using add(int index, E element) with any index other than size (O(n) to shift elements right). For add(element) at the end, grows are rare and amortised out — the average cost per insertion is O(1). For add(0, element) (front insertion), every call is O(n) — the entire list shifts right. Pre-sizing eliminates grows; using a different data structure (ArrayDeque, LinkedList) eliminates front-insertion cost.
FAQs
What is the default initial capacity of ArrayList?
The default initial capacity is 10 — but the array is not allocated until the first add(). A new ArrayList<>() stores a reference to a shared empty sentinel array (DEFAULTCAPACITY_EMPTY_ELEMENTDATA) to avoid the allocation cost for empty lists. The first add() triggers the creation of the 10-slot array. new ArrayList<>(0) uses a different empty sentinel (EMPTY_ELEMENTDATA) and grows to 1 on first add, not 10.
Does ArrayList allow null elements?
Yes. null can be added to any position, including multiple nulls. list.contains(null) returns true if any null element is present (uses e == null comparison). list.remove(null) removes the first null occurrence. list.indexOf(null) returns the index of the first null.
What is the difference between ArrayList.clear() and creating a new ArrayList?
clear() sets all elements to null and resets size to 0 but keeps the backing array allocated at its current capacity. A new ArrayList<>() starts with the empty sentinel array. clear() is appropriate when the list will be refilled soon — no reallocation needed. Creating a new instance releases the current backing array to GC. For lists with large allocated-but-unused capacity, a new instance uses less memory immediately; clear() retains the capacity for fast reuse.
Is ArrayList thread-safe?
No. Concurrent structural modifications from multiple threads corrupt the backing array — lost updates, stale reads, or ArrayIndexOutOfBoundsException during simultaneous resizes. Use Collections.synchronizedList(new ArrayList<>()) for a globally locked wrapper, or CopyOnWriteArrayList for read-heavy concurrent lists. Neither is equivalent in performance to a single-threaded ArrayList.
Why is ArrayList preferred over LinkedList for most use cases?
ArrayList's O(1) get(index) and cache-friendly contiguous memory make it faster for random access and sequential iteration — the two most common list operations. LinkedList's get(index) is O(n) because it must traverse the chain from head or tail. ArrayList's add(element) is O(1) amortised, competitive with LinkedList's O(1) addLast(). LinkedList adds overhead of two pointers per node (~24 bytes/node) versus ArrayList's 4-byte reference. LinkedList only wins when frequent O(1) insertions and removals at both ends are required alongside the List interface — ArrayDeque handles that better.
What happens to the backing array when ArrayList.clear() is called?
clear() iterates from index 0 to size-1, sets each slot to null, then sets size = 0. The backing elementData array itself is NOT freed — its length (capacity) is preserved. This means a cleared ArrayList still occupies the same heap memory as before. The advantage: immediately adding elements to a cleared list uses the existing array without triggering a grow. If memory should be reclaimed, call trimToSize() after clear() or assign a new ArrayList<>().
Summary
Every ArrayList operation is ultimately an operation on a transient Object[] elementData array. add(element) sets elementData[size++] and may trigger a 1.5× grow via Arrays.copyOf. get(index) reads elementData[index] in O(1). remove(index) shifts elements left with System.arraycopy and nulls the last slot for GC. contains() scans linearly. clear() nulls all element slots without releasing the backing array.
Three internal implementation details answer most ArrayList interview questions: the size vs capacity distinction (lazy allocation, pre-sizing benefit), the 1.5× growth formula (amortised O(1) for append), and modCount (fail-fast iterator contract). Understanding why remove(index) nulls the last slot — preventing memory leaks through stale object references — separates candidates who have read source code from those who have only read tutorials.
What to Read Next
| Topic | Link |
|---|---|
| How LinkedList uses doubly-linked nodes as an alternative to ArrayList's contiguous array | Java LinkedList → |
| How HashMap's bucket array compares to ArrayList's Object[] in terms of indexing | Java HashMap → |
| How the Collections Framework and its algorithms operate on the ArrayList backing structure | Java Collections Framework → |
| How Java Streams process ArrayList elements lazily without copying the backing array | Java Streams API → |
| How Generics ensure ArrayList's Object[] array is type-safe at compile time | Java Generics → |