Java Tutorial
🔍

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

Java
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

Java
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

Java
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.

Java
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}
Java
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

OperationComplexityInternal Mechanism
add(element)O(1) amortisedelementData[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() creationO(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

Java
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

Java
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

Java
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

Java
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

TopicLink
How LinkedList uses doubly-linked nodes as an alternative to ArrayList's contiguous arrayJava LinkedList →
How HashMap's bucket array compares to ArrayList's Object[] in terms of indexingJava HashMap →
How the Collections Framework and its algorithms operate on the ArrayList backing structureJava Collections Framework →
How Java Streams process ArrayList elements lazily without copying the backing arrayJava Streams API →
How Generics ensure ArrayList's Object[] array is type-safe at compile timeJava Generics →
Java ArrayList Internal Working | DevStackFlow