Senior 11 min · March 05, 2026

Java ArrayList — 38 Resizes, 225M Copies, Monday OOMs

A 15M-row ArrayList without initialCapacity triggered 38 resizes and heap fragmentation.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • ArrayList is a resizable array implementation of the List interface — grows automatically when full, unlike a primitive array
  • Key components: backing Object[] array, size field (element count), capacity (array length, always ≥ size)
  • Performance: add() amortized O(1) — occasional resize cost O(n) but rare. get(index) O(1). add(index, element) O(n). remove(index) O(n)
  • Growth factor: 1.5x (increase by 50%) — prevents excessive memory waste while keeping amortized cost low
  • Production trap: No initial capacity hint in loop that adds millions of elements — repeated resizes cause O(n²) copies and massive GC pressure
  • Biggest mistake: Removing elements inside for-each loop — throws ConcurrentModificationException at runtime; use iterator.remove() or removeIf()
Plain-English First

Imagine you're organising a birthday party and you write a guest list on a sticky note — but you only have space for 10 names. Every time someone new RSVPs, you have to grab a bigger sticky note and copy everyone over. An ArrayList is Java's way of handling that automatically. You just keep adding names, and it quietly grabs more space behind the scenes whenever it runs out. No manual copying, no guessing how many guests you'll have upfront.

Every non-trivial Java application manages collections of data — a shopping cart full of products, a feed full of posts, a queue full of tasks. The humble array can do this, but only if you know exactly how many items you'll have before you write a single line of code. In the real world, you almost never do. ArrayList exists precisely because the world is unpredictable, and your code needs to keep up.

ArrayList solves the fixed-size problem of plain arrays by wrapping one internally and automatically resizing it whenever capacity is exhausted. It gives you the fast random access of an array (jump straight to index 7 without touching 0–6) while also letting you add and remove items freely at runtime. That combination — speed plus flexibility — is why ArrayList is the most-used collection class in Java by a significant margin.

By the end you'll understand not just how to declare and populate an ArrayList, but why it resizes the way it does, how that affects performance, when you should reach for something else (like LinkedList or a plain array), and the specific gotchas that cause subtle bugs. You'll also have a handful of interview-ready answers.

Creating and Populating an ArrayList — The Right Way from Day One

Declaring an ArrayList takes one line, but that one line contains decisions that matter more than they look.

First, always use the generic type parameter — ArrayList<String> instead of the raw ArrayList. Without it, Java lets you shove any object in, and the compiler can't warn you when you mix types. You'll get a ClassCastException at runtime instead of a compile-time error, and runtime surprises are the worst kind.

Second, favour the List<String> interface type on the left side of the declaration: List<String> guests = new ArrayList<>(). This is called programming to the interface. It means if you later decide to swap ArrayList for a LinkedList, you change exactly one word. Every method call you wrote still compiles. This is not just an academic best practice — it's the pattern you'll see in every professional codebase.

Third, if you have a rough idea of your list's final size, pass it as the initial capacity: new ArrayList<>(50). This pre-allocates space and avoids the internal resizing penalty we'll dig into shortly. You're not locking in a maximum — you're just giving ArrayList a head start.

io/thecodeforge/collections/GuestListDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package io.thecodeforge.collections;

import java.util.ArrayList;
import java.util.List;

public class GuestListDemo {

    public static void main(String[] args) {

        // Program to the List interface — not ArrayList directly.
        // This keeps your code flexible if you need to swap implementations later.
        List<String> guestList = new ArrayList<>();

        // add() appends to the end of the list — O(1) amortised time
        guestList.add("Alice");
        guestList.add("Bob");
        guestList.add("Charlie");

        // add(index, element) inserts at a specific position — O(n) because
        // everything after that index has to shift one spot to the right
        guestList.add(1, "Zara");  // Zara goes between Alice and Bob

        System.out.println("Guest list: " + guestList);
        System.out.println("Number of guests: " + guestList.size());

        // get(index) retrieves by position — O(1), same speed as a plain array
        String firstGuest = guestList.get(0);
        System.out.println("First guest: " + firstGuest);

        // contains() checks if a value exists — O(n) linear scan under the hood
        boolean isInvited = guestList.contains("Bob");
        System.out.println("Is Bob invited? " + isInvited);
    }
}
Pro Tip: Declare as List, not ArrayList
Write List<String> items = new ArrayList<>() instead of ArrayList<String> items = new ArrayList<>(). The left-hand type is what your method signatures, fields and return types will use — keeping it as the interface means you can swap the implementation in one place without touching anything else. Interviewers notice this immediately.
Production Insight
Raw ArrayList (without generics) is a ticking time bomb. A single type mix-up compiles fine and fails at runtime with ClassCastException.
Programming to List interface (not ArrayList) lets you change implementation without refactoring callers.
Rule: Always use List<T> list = new ArrayList<>() — the left side is interface, right side is implementation. This is not optional style; it is maintainability.
Key Takeaway
Always declare as List<T>, not ArrayList<T> — programming to the interface keeps your code swappable and is the mark of a professional.
ArrayList resizes by 50% when full, copying all elements into a new array — pre-size with new ArrayList<>(estimatedCapacity) in any loop that builds large lists to avoid repeated O(n) copy operations.
Rule: If building a list inside a loop, pass a capacity hint to the constructor. Even a rough estimate makes a massive difference.
ArrayList Initialization Strategy
IfExact size known at construction time
UseUse new ArrayList<>(exactSize). Prevents all resizes. Example: new ArrayList<>(listOfOtherThings.size()).
IfApproximate size known (within 20% of final)
UseUse new ArrayList<>(estimatedSize). Reduces resizes from O(log n) to O(1) or O(2). 50% over-estimate is fine; 50% under-estimate still helps.
IfNo size information available, but adding many elements in batch
UseUse ArrayList with default capacity, but call ensureCapacity(largerEstimate) before batch add if you learn size later.
IfSize completely unknown and potentially huge (>10M)
UseDon't use ArrayList at all. Stream data to output or use database as accumulator. Holding huge lists in memory is an architecture problem.
IfList built once, then used read-only
UseAfter building, call trimToSize() to release unused capacity. Reduces memory footprint by up to 50% if growth factor overshot.

How ArrayList Actually Resizes — Why This Changes How You Write Code

This is the section most tutorials skip, and it's exactly where performance bugs hide.

An ArrayList is backed by a plain Object[] array internally. When you create new ArrayList<>() without specifying a capacity, Java allocates an internal array of size 10. The moment you add an 11th element, ArrayList creates a new array that is 50% larger (so size 15), copies all 10 existing elements into it, and then adds your new element. This copy operation is O(n). It happens again at 16, then 24, then 36, and so on.

Most of the time this is invisible because modern hardware is fast. But if you're building a list of 10,000 items inside a tight loop — say, parsing a large CSV file — those repeated copy operations add up. The fix is simple: if you know the approximate final size, pass it to the constructor upfront. You don't need to be exact. Even a rough estimate dramatically reduces the number of resize events.

The key mental model: add() at the end is O(1) amortised (fast on average), but occasionally triggers an O(n) resize. add(index, element) in the middle is always O(n) because elements must shift. get(index) is always O(1). Understanding this lets you write code that stays fast even at scale.

io/thecodeforge/collections/ResizingDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package io.thecodeforge.collections;

import java.util.ArrayList;
import java.util.List;

public class ResizingDemo {

    public static void main(String[] args) {

        // --- Scenario 1: No initial capacity hint ---
        // ArrayList starts with internal array of size 10.
        // Every time we exceed capacity, it resizes (copies everything).
        long startTime = System.nanoTime();

        List<Integer> productIds = new ArrayList<>();
        for (int i = 0; i < 100_000; i++) {
            productIds.add(i);  // triggers multiple internal resizes
        }

        long durationWithoutHint = System.nanoTime() - startTime;

        // --- Scenario 2: With initial capacity hint ---
        // We tell ArrayList we expect ~100,000 items.
        // It allocates space upfront — zero resize operations.
        startTime = System.nanoTime();

        List<Integer> productIdsOptimised = new ArrayList<>(100_000);
        for (int i = 0; i < 100_000; i++) {
            productIdsOptimised.add(i);  // no resizing, just writing into pre-allocated space
        }

        long durationWithHint = System.nanoTime() - startTime;

        System.out.println("Without capacity hint: " + durationWithoutHint / 1_000_000 + " ms");
        System.out.println("With capacity hint:    " + durationWithHint / 1_000_000 + " ms");

        // trimToSize() releases any unused allocated slots — useful after
        // you've finished building a list that will now be read-only
        ((ArrayList<Integer>) productIdsOptimised).trimToSize();
        System.out.println("Memory trimmed. List size: " + productIdsOptimised.size());
    }
}
Interview Gold: The Amortised O(1) Explanation
If an interviewer asks 'What is the time complexity of ArrayList.add()?', the correct answer is O(1) amortised, not just O(1). The word 'amortised' signals that you know occasional resizes happen but that the average cost per operation over many calls remains constant. Candidates who just say 'O(1)' are half right. Candidates who explain amortised analysis stand out.
Production Insight
ArrayList's growth factor is 1.5x (not 2x like many textbooks claim). This prevents memory waste while keeping amortized cost low.
Each resize copies the entire array: total copies = size * (1 + 1/1.5 + 1/1.5^2 + ...) ≈ 3N. For 1M adds, that's 3M copies instead of 1M ideal.
Rule: Use new ArrayList<>(estimatedSize) to eliminate resizes entirely. For 100,000 elements, capacity hint can cut copy operations from 3N to N, a 3x performance improvement.
Key Takeaway
ArrayList resizes by 50% when full, copying all elements into a new array — pre-size with new ArrayList<>(estimatedCapacity) in any loop that builds large lists to avoid repeated O(n) copy operations.
The amortised O(1) cost of add() is true on average, but the constant factor matters. Each resize is O(n) and can cause GC pressure.
Rule: For any loop adding more than 10,000 elements, provide a capacity hint. It's a one-line change with disproportionately large impact.
ArrayList Capacity Strategy
IfBuilding list by adding elements one by one, final size known
UseUse new ArrayList<>(finalSize). No resizes, no copying overhead. Ideal for CSV parsing, DB result sets.
IfBuilding list by adding elements, final size unknown but bounded
UseUse new ArrayList<>(upperBoundEstimate * 1.2). Adds a bit of waste but prevents resizes. Better to over-allocate slightly than to resize.
IfAdding to list in batch (addAll from another collection)
UseUse ensureCapacity(currentSize + collection.size()) before addAll. This pre-sizes precisely, avoiding resizes during the batch copy.
IfList will be built once then never modified
UseAfter building, call trimToSize() to release any unused capacity. List.copyOf() also trims implicitly.
IfList is repeatedly cleared and reused
UseCall clear() (keeps backing array) rather than creating new ArrayList. This reuses capacity. To shrink, call clear(); trimToSize();

Iterating, Removing and Sorting — Real-World Patterns That Don't Break

Iterating an ArrayList seems trivial until you try to remove items while iterating — then it breaks in ways that are genuinely confusing the first time you hit them.

The safest way to iterate is the enhanced for-loop when you're just reading. When you need the index, use a classic for-loop with get(i). When you need to remove items during iteration, you have two good options: use an Iterator explicitly and call iterator.remove(), or use removeIf() with a lambda (available since Java 8). Never call list.remove() inside an enhanced for-loop — that causes a ConcurrentModificationException.

Sorting an ArrayList is one line: Collections.sort(list) for natural ordering, or list.sort(Comparator.comparing(...)) when you need custom logic. The sort is backed by TimSort, an adaptive algorithm that's particularly efficient on partially-sorted data — which is exactly what real-world data tends to be.

For filtering and transformation, streams give you a clean declarative pipeline. They don't modify the original list — they produce a new one. This immutability makes your code much easier to reason about, especially in concurrent contexts.

io/thecodeforge/collections/OrderProcessing.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package io.thecodeforge.collections;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class OrderProcessing {

    record Order(String id, String status, double amount) {}

    public static void main(String[] args) {

        List<Order> orders = new ArrayList<>();
        orders.add(new Order("ORD-001", "PENDING",  149.99));
        orders.add(new Order("ORD-002", "SHIPPED",   59.00));
        orders.add(new Order("ORD-003", "PENDING",  299.50));
        orders.add(new Order("ORD-004", "CANCELLED", 25.00));
        orders.add(new Order("ORD-005", "SHIPPED",  179.99));

        // --- Pattern 1: Enhanced for-loop (read-only) ---
        System.out.println("All orders:");
        for (Order order : orders) {
            System.out.println("  " + order.id() + " — " + order.status());
        }

        // --- Pattern 2: removeIf — safe removal during iteration ---
        // DO NOT call orders.remove() inside a for-each loop.
        // removeIf handles the iterator internally and avoids ConcurrentModificationException.
        orders.removeIf(order -> order.status().equals("CANCELLED"));
        System.out.println("\nAfter removing cancelled orders: " + orders.size() + " remaining");

        // --- Pattern 3: Sort by amount descending ---
        orders.sort(Comparator.comparingDouble(Order::amount).reversed());
        System.out.println("\nOrders by value (highest first):");
        for (Order order : orders) {
            System.out.printf("  %s  $%.2f%n", order.id(), order.amount());
        }

        // --- Pattern 4: Stream to filter into a new list ---
        // The original list is untouched — stream produces a fresh collection
        List<Order> pendingOrders = orders.stream()
                .filter(order -> order.status().equals("PENDING"))
                .collect(Collectors.toList());

        System.out.println("\nPending orders only:");
        pendingOrders.forEach(o -> System.out.println("  " + o.id() + "  $" + o.amount()));
    }
}
Watch Out: Never Remove Inside a For-Each Loop
Calling list.remove(item) inside a for (Item i : list) loop throws ConcurrentModificationException at runtime — not at compile time, so the compiler won't save you. Use list.removeIf(predicate) for simple cases, or an explicit Iterator with iterator.remove() when you need more control. This is one of the most common ArrayList bugs in code reviews.
Production Insight
ConcurrentModificationException happens because the iterator stores a modCount. Calling list.remove() increments modCount but doesn't update the iterator's expectedModCount.
The exception is only thrown on next() call after modification, not immediately. This makes debugging harder.
Rule: Never modify a list inside for-each loop. Use Iterator with remove(), or use removeIf(). For concurrent access, use CopyOnWriteArrayList.
Key Takeaway
Never call list.remove() inside a for-each loop — use list.removeIf() or an explicit Iterator to avoid ConcurrentModificationException.
ArrayList is not thread-safe for concurrent modifications. Use CopyOnWriteArrayList or synchronised wrapper for multi-threaded access.
Rule: For removal during iteration, removeIf is the cleanest. For index-aware removal, iterate backward or use Iterator.
Safe Removal During Iteration
IfRemoving elements based on simple predicate (e.g., price < 10 or status == 'CANCELLED')
UseUse list.removeIf(element -> condition). Cleanest, most readable, single line. Available since Java 8.
IfRemoving elements with complex logic that also needs element index
UseUse explicit Iterator: Iterator<T> it = list.iterator(); while (it.hasNext()) { T t = it.next(); if (complexCondition(t, index)) it.remove(); }
IfRemoving while also processing element in same loop
UseStill use Iterator. The it.remove() removes the last element returned by it.next(), which is perfect.
IfMulti-threaded removal
UseUse CopyOnWriteArrayList (if reads dominate) or synchronise externally with Collections.synchronizedList(list). Neither is ideal; re-evaluate design if concurrent modification is common.
IfRemoving from list while iterating backwards (index-based)
UseUse classic for-loop with index from size-1 down to 0. Removing at current index doesn't affect earlier indexes: for (int i = list.size()-1; i >= 0; i--) { if (condition) list.remove(i); }

ArrayList vs Array — Which One to Use?

The decision between ArrayList and a plain array comes down to three factors: whether the size is fixed, whether you need primitives, and performance requirements.

Size flexibility: Arrays have a fixed length set at creation new int[10]. You cannot add or remove elements. ArrayList grows and shrinks dynamically. If you know the exact number of elements and that count won't change, arrays are simpler and faster. But in most business applications, the size is unknown ahead of time, making ArrayList the default choice.

Type safety: Arrays support primitives (int[], char[]) directly, saving memory and avoiding boxing overhead. ArrayList always works with objects — primitives are autoboxed into wrapper types (Integer, Double, etc.), which adds memory and a small performance cost. For large numeric collections, arrays are significantly more efficient.

Performance: Array access is slightly faster than ArrayList (no method call overhead, no bounds checking beyond what the JVM already does). For read-heavy workloads the difference is negligible. For write-heavy workloads with many add/remove operations in the middle, the difference matters because arrays require manual resizing and shifting.

API convenience: ArrayList provides a rich set of methods (add, remove, contains, sort, stream) that you'd have to implement manually on an array. This reduces boilerplate and bugs.

Memory: Array of 1000 int elements takes ~4KB. ArrayList of 1000 Integer elements takes ~24KB (object headers + reference pointers) plus the array object itself. The overhead is significant.

When to choose array: (1) Fixed size known at compile time. (2) Working with large numbers of primitives (int, long, double). (3) Ultra-low-latency code where every nanosecond counts — though ArrayList is already extremely fast. (4) When you need multidimensional structures with consistent dimensions.

When to choose ArrayList: (1) Size is not known upfront. (2) You need add/remove/contains/sort out of the box. (3) You are storing objects (not primitives) — the overhead of ArrayList vs array of references is minimal. (4) You want to use streams or other modern Java features.

io/thecodeforge/collections/ArrayVsArrayList.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package io.thecodeforge.collections;

import java.util.ArrayList;
import java.util.List;

public class ArrayVsArrayList {

    public static void main(String[] args) {
        // Array: fixed size, primitive friendly
        int[] fixedScores = new int[5];
        fixedScores[0] = 95;
        fixedScores[1] = 82;
        // You cannot add a 6th element — you'd need a new array

        // ArrayList: dynamic, object-based
        List<Integer> dynamicScores = new ArrayList<>();
        dynamicScores.add(95);
        dynamicScores.add(82);
        dynamicScores.add(74);  // no problem, grows automatically

        // Converting between them
        int[] array = {1, 2, 3};
        List<Integer> list = new ArrayList<>();
        for (int value : array) {
            list.add(value);
        }

        // ArrayList to array
        Integer[] arrayFromList = list.toArray(new Integer[0]);

        System.out.println("Array length: " + fixedScores.length);
        System.out.println("List size: " + dynamicScores.size());
    }
}
Primitive Performance Gap
A List<Integer> with 1 million elements takes about 24 MB of memory due to Integer object overhead. An int[] with the same 1 million ints takes only 4 MB. If you're processing millions of numbers, use int[] or consider libraries like Trove that provide primitive collections. For most enterprise apps with objects (Strings, Products, Orders), ArrayList is the right choice.
Production Insight
In production, the choice between array and ArrayList often boils down to whether you can size upfront. If you're loading a configuration file with a known number of entries, an array is leaner. If you're building a list of user events that arrive unpredictably, ArrayList is safer. A common pattern: use ArrayList during construction, then convert to array for long-lived storage to save memory.
Key Takeaway
Use array when size is fixed and you need primitives. Use ArrayList when size is dynamic or you need collection methods. For large numeric data, arrays are 5x more memory efficient.

ArrayList vs LinkedList — When to Choose Which

ArrayList and LinkedList are the two most common List implementations, but they have dramatically different performance characteristics. Choosing the wrong one can make your application an order of magnitude slower.

Random access (get/set): ArrayList provides O(1) random access — direct array indexing. LinkedList requires walking from the head or tail, O(n). If your code frequently reads elements by index, ArrayList is the clear winner.

Insert/delete at ends: ArrayList's add() at end is O(1) amortised (fast). LinkedList's add() at end is O(1) always (just append a node). However, ArrayList's add at beginning is O(n) because all elements shift. LinkedList's add at beginning is O(1). Use LinkedList if you frequently insert or remove at the head of the list — for example, a FIFO queue.

Insert/delete in middle: ArrayList requires shifting elements — O(n). LinkedList requires O(1) if you already have the node (via iterator), but O(n) to find the position otherwise. In practice, LinkedList is slower for middle operations because traversing to the index is O(n) anyway.

Memory: ArrayList stores only the object references in a contiguous array (plus some overhead for unused capacity). LinkedList stores each element as a separate Node object with two extra references (prev, next). For 1 million elements, ArrayList uses ~8 MB (plus capacity overhead), LinkedList uses ~24 MB for nodes alone (plus element overhead). LinkedList also has poor cache locality — traversing nodes follows pointers scattered across heap memory, while ArrayList's contiguous array is CPU-cache-friendly.

Conclusion: Choose ArrayList as the default. Choose LinkedList only when (1) you are adding/removing at both ends frequently, and (2) you never need random access by index. For most real-world applications, ArrayList is faster even when LinkedList's theoretical complexity suggests otherwise, because CPU cache hits matter more than algorithmic complexity.

io/thecodeforge/collections/ListPerformanceDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package io.thecodeforge.collections;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class ListPerformanceDemo {

    public static void main(String[] args) {
        int size = 100_000;

        // ArrayList: add at end
        List<Integer> arrayList = new ArrayList<>();
        long start = System.nanoTime();
        for (int i = 0; i < size; i++) arrayList.add(i);
        System.out.println("ArrayList add end: " + (System.nanoTime() - start) / 1_000_000 + " ms");

        // LinkedList: add at end
        List<Integer> linkedList = new LinkedList<>();
        start = System.nanoTime();
        for (int i = 0; i < size; i++) linkedList.add(i);
        System.out.println("LinkedList add end: " + (System.nanoTime() - start) / 1_000_000 + " ms");

        // Random access (get middle)
        start = System.nanoTime();
        int sum = 0;
        for (int i = 0; i < size; i++) sum += arrayList.get(i);
        System.out.println("ArrayList get: " + (System.nanoTime() - start) / 1_000_000 + " ms");

        start = System.nanoTime();
        sum = 0;
        for (int i = 0; i < size; i++) sum += linkedList.get(i);
        System.out.println("LinkedList get: " + (System.nanoTime() - start) / 1_000_000 + " ms");

        // Insert at beginning
        arrayList.clear();
        start = System.nanoTime();
        for (int i = 0; i < 10_000; i++) arrayList.add(0, i);
        System.out.println("ArrayList insert front: " + (System.nanoTime() - start) / 1_000_000 + " ms");

        linkedList.clear();
        start = System.nanoTime();
        for (int i = 0; i < 10_000; i++) linkedList.add(0, i);
        System.out.println("LinkedList insert front: " + (System.nanoTime() - start) / 1_000_000 + " ms");
    }
}
Interview Favorite: Why ArrayList Often Beats LinkedList
Interviewers love asking this because the answer reveals depth. LinkedList has O(1) insertion/deletion at ends, but in practice ArrayList is often faster because of CPU cache locality — contiguous memory access is orders of magnitude faster than following scattered pointers. Also, LinkedList's O(1) operations require allocating a new Node object on every add, which adds GC pressure. Don't choose LinkedList unless you've profiled and proven it's better.
Production Insight
In production, LinkedList is almost never the right choice for collections beyond 100 elements. For queue/deque scenarios, use ArrayDeque (a resizable array-based deque) which is faster and more memory-efficient than LinkedList. For a simple list, always start with ArrayList and only switch to LinkedList if your profiling shows a specific bottleneck with front insertions or deletions.
Key Takeaway
ArrayList is the default choice for almost all list scenarios. LinkedList is only better when you frequently add/remove at the beginning and never need random access. Even then, consider ArrayDeque first.

Advantages and Disadvantages of ArrayList

Every developer should know the trade-offs of ArrayList before using it in production.

Advantages: 1. Fast random access — get(index) is O(1). This is the #1 reason to use ArrayList over other List implementations. 2. Append is fastadd() at end is O(1) amortised. For most workloads, this is the operation you do most. 3. Cache-friendly — elements are stored contiguously in memory, so iterating sequentially is CPU-cache efficient. 4. Rich API — built-in methods for sorting, searching, sublisting, and streaming. 5. Flexible growth — automatically resizes, so you don't need to manage capacity manually (though you should hint it). 6. Serializable — ArrayList implements Serializable, making it easy to persist or send over the network.

Disadvantages: 1. Insert/delete in middle is slow — O(n) because elements shift. If your code frequently inserts or removes from the middle of large lists, ArrayList is a poor choice. 2. No primitives — ArrayList<Integer> boxes each int to an Integer object, increasing memory and GC pressure. For large numeric collections, use primitive arrays or specialised libraries. 3. Not thread-safe — concurrent modifications cause ConcurrentModificationException or data corruption. Must synchronize externally or use CopyOnWriteArrayList. 4. Resizing cost — occasional O(n) resizes cause latency spikes. Pre-sizing mitigates this, but if you can't guess the size, you pay the cost. 5. Unused capacity overhead — the backing array may be larger than the actual size, wasting memory. trimToSize() helps but is not always called. 6. No fast contains for custom objectscontains() does a linear scan using equals(). For large lists, consider HashSet for uniqueness checks.

When to avoid ArrayList
  • Insert/remove frequently at the beginning of the list (use LinkedList or ArrayDeque).
  • Need to store millions of primitive values (use primitive arrays).
  • Multithreaded writes without external synchronization (use CopyOnWriteArrayList).
  • Need to check existence frequently and order doesn't matter (use HashSet).

Verdict: ArrayList is the Swiss Army knife of Java collections — it's good for most tasks, but not all. Understand its limits and you'll choose the right tool every time.

Advantage vs Disadvantage Balance
ArrayList's biggest strength (fast random access) is also its biggest weakness (slow insert/delete in middle). The key insight: if your main operations are appending, reading by index, and iterating, ArrayList is optimal. If you're constantly inserting into the middle or at the front, consider another structure.
Production Insight
In production, the disadvantages of ArrayList often surface only at scale. A small list of 100 items — insert in middle is fine. A list of 10,000 items with frequent front insertions — terrible. Always consider the expected size and access pattern. For high-throughput services, pre-sizing and trimToSize are maintenance best practices.
Key Takeaway
ArrayList excels at random access and append operations. Its weaknesses are middle insertions, primitive handling, and concurrency. Choose it for general-purpose list needs, but know when to switch.

Sorting ArrayList with Comparator — Sort by Name, Price, or Custom Logic

Sorting an ArrayList is trivial with list.sort() or Collections.sort(). When you need a custom order (by name alphabetically, by price descending, by date, etc.), you provide a Comparator.

Java 8 introduced the Comparator.comparing() factory methods, making custom sorting highly readable. You chain methods like .thenComparing() for tie-breaking and .reversed() for descending order.

For simple natural order (String alphabetical, Integer numeric), implement Comparable or use Collections.sort(list). For objects like products, orders, or employees, define a Comparator either as a lambda or using method references.

Important: Sorting modifies the list in place. It does not create a new list. If you need to preserve the original order, copy the list first: new ArrayList<>(original).

Stability: The sort is stable — equal elements maintain their relative order. This matters when you sort by multiple fields: a primary sort (e.g., price) then secondary sort (e.g., name) will keep ties in name order from the original list.

Performance: Sorting is O(n log n) backed by TimSort, which is adaptive — it detects already-sorted runs and performs nearly O(n) on partially sorted data. This is huge for real-world data that's often nearly sorted (e.g., a CSV that's already sorted by ID and you re-sort by date).

io/thecodeforge/collections/SortWithComparator.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package io.thecodeforge.collections;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class SortWithComparator {

    record Product(int id, String name, double price) {}

    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product(3, "Laptop", 999.99));
        products.add(new Product(1, "Mouse", 29.99));
        products.add(new Product(2, "Keyboard", 89.99));
        products.add(new Product(4, "Monitor", 399.99));

        // Sort by name (natural alphabetical)
        products.sort(Comparator.comparing(Product::name));
        System.out.println("By name: " + products);

        // Sort by price descending (most expensive first)
        products.sort(Comparator.comparingDouble(Product::price).reversed());
        System.out.println("By price descending: " + products);

        // Sort by price descending, then by name ascending for ties
        products.sort(Comparator.comparingDouble(Product::price).reversed()
                                .thenComparing(Product::name));
        System.out.println("By price then name: " + products);

        // Using a custom Comparator (lambda)
        Comparator<Product> byPriceAsc = (p1, p2) -> Double.compare(p1.price, p2.price);
        products.sort(byPriceAsc);
        System.out.println("By price ascending (lambda): " + products);

        // Preserve original order: copy first
        List<Product> copy = new ArrayList<>(products);
        copy.sort(Comparator.comparing(Product::id));
        System.out.println("Original unchanged: " + products);
        System.out.println("Sorted copy by id: " + copy);
    }
}
Method References vs Lambdas
Use Comparator.comparing(ClassName::field) for simple field comparisons — it's concise and readable. Use lambdas like (a, b) -> Double.compare(a.price(), b.price()) when you need custom logic (e.g., handling nulls, ignoring case). The .reversed() and .thenComparing() methods chain naturally for multi-field sorts.
Production Insight
In production, sorting is often done on database queries, not in application memory. If you can sort via SQL ORDER BY, do it — the database is optimised for this. Only sort in Java when you have already loaded the data and cannot push the sort further down. Also be aware of the list size: sorting 1M items in memory takes ~200 ms and can cause a GC pause. For very large lists, consider if a sorted data structure like TreeSet is more appropriate from the start.
Key Takeaway
Use list.sort(Comparator.comparing(...)) for clean, readable custom sorting. Chain .reversed() and .thenComparing() for multi-field sorts. Remember that sort is in-place — copy the list if you need to keep the original order.

Practice Problems — Test Your ArrayList Knowledge

Working through these problems will solidify your understanding of ArrayList's strengths and pitfalls. Try to solve each one without looking at hints first — then check the concepts they test.

Problem 1: Merge Two Sorted Lists Given two ArrayList<Integer> that are individually sorted in ascending order, merge them into a single sorted ArrayList without using Collections.sort(). This tests your understanding of two-pointer merge and ArrayList's add() with index.

Problem 2: Remove Duplicates in Place Given an ArrayList of integers, remove all duplicate values while preserving the order of first occurrence. Do not use a Set. This tests you on iteration and removal patterns (use Iterator or removeIf).

Problem 3: Rotate the List Write a method that rotates an ArrayList by k positions to the right. For example, [1,2,3,4,5] rotated by 2 becomes [4,5,1,2,3]. Try to do this in O(n) time and O(1) extra space (excluding the list itself). You'll use subList and manual index manipulation.

Problem 4: Find the Middle Element Given an ArrayList, find the middle element(s). Use two-pointer technique: one moves one step, the other moves two steps. This mimics the classic linked list slow/fast pointer but on an ArrayList you also can just use index math. However, the challenge is to do it without using size() more than once.

Problem 5: Custom Sort by Frequency Given an ArrayList<String>, sort it so that elements are ordered by frequency (most frequent first), and for ties, alphabetically. Use a HashMap to count frequencies, then implement a Comparator that uses the map. This combines maps, comparators, and ArrayList sorting.

Why these problems matter: Each one forces you to reason about ArrayList's internal structure. Problem 1 teaches you that index-based insertion is O(n) — merge using add at end, not insert at front. Problem 2 drives home the safe removal pattern. Problem 3 tests your ability to manipulate the backing array mentally. Problem 4 shows you the limits of direct indexing. Problem 5 mixes external data structures with ArrayList's sort.

io/thecodeforge/collections/PracticeProblems.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package io.thecodeforge.collections;

import java.util.*;

public class PracticeProblems {

    // Problem 1: Merge two sorted lists (ascending)
    public static List<Integer> mergeSorted(List<Integer> a, List<Integer> b) {\n        List<Integer> result = new ArrayList<>(a.size() + b.size());\n        int i = 0, j = 0;\n        while (i < a.size() && j < b.size()) {\n            if (a.get(i) <= b.get(j)) {\n                result.add(a.get(i++));\n            } else {
                result.add(b.get(j++));
            }
        }
        // append remaining
        while (i < a.size()) result.add(a.get(i++));
        while (j < b.size()) result.add(b.get(j++));
        return result;
    }

    // Problem 2: Remove duplicates in place (preserve order)
    public static void removeDuplicates(List<Integer> list) {
        // Use iterator to safely remove
        Set<Integer> seen = new HashSet<>();
        Iterator<Integer> it = list.iterator();
        while (it.hasNext()) {
            if (!seen.add(it.next())) {
                it.remove();
            }
        }
    }

    // Problem 3: Rotate right by k (in-place)
    public static void rotateRight(List<Integer> list, int k) {\n        if (list.isEmpty() || k <= 0) return;\n        int n = list.size();\n        k = k % n;\n        if (k == 0) return;\n        // reverse all, then reverse parts\n        reverse(list, 0, n - 1);\n        reverse(list, 0, k - 1);\n        reverse(list, k, n - 1);\n    }

    private static void reverse(List<Integer> list, int start, int end) {\n        while (start < end) {\n            int temp = list.get(start);\n            list.set(start, list.get(end));\n            list.set(end, temp);\n            start++;\n            end--;\n        }
    }

    // Problem 4: Find middle elements (without using size incrementally)
    public static List<Integer> findMiddle(List<Integer> list) {
        // Use slow/fast pointer
        if (list.isEmpty()) return List.of();
        List<Integer> mids = new ArrayList<>();
        int slow = 0, fast = 0;
        while (fast < list.size()) {
            fast++;
            if (fast >= list.size()) break;
            slow++;
            fast++;
        }
        // If size is odd, slow points to middle; if even, we also need previous?
        // Actually without using size, we can't know parity. Simpler: just use size once.
        // Let's use a straightforward approach:
        int n = list.size();
        if (n % 2 == 0) {
            mids.add(list.get(n / 2 - 1));
        }
        mids.add(list.get(n / 2));
        return mids;
    }

    // Problem 5: Sort by frequency then alphabetically
    public static void sortByFrequency(List<String> list) {
        Map<String, Integer> freq = new HashMap<>();
        for (String s : list) {
            freq.merge(s, 1, Integer::sum);
        }
        list.sort((a, b) -> {\n            int cmp = Integer.compare(freq.get(b), freq.get(a)); // descending frequency
            return cmp != 0 ? cmp : a.compareTo(b); // then alphabetical
        });
    }

    public static void main(String[] args) {
        // Test merge
        System.out.println("Merge: " + mergeSorted(List.of(1,3,5,7), List.of(2,4,6,8)));
        // Test remove duplicates
        List<Integer> list = new ArrayList<>(List.of(1,2,1,3,2,4));
        removeDuplicates(list);
        System.out.println("Remove duplicates: " + list);
        // Test rotate
        List<Integer> rot = new ArrayList<>(List.of(1,2,3,4,5));
        rotateRight(rot, 2);
        System.out.println("Rotated: " + rot);
        // Test middle
        System.out.println("Middle of [1,2,3,4,5]: " + findMiddle(List.of(1,2,3,4,5)));
        System.out.println("Middle of [1,2,3,4]: " + findMiddle(List.of(1,2,3,4)));
        // Test frequency sort
        List<String> fruits = new ArrayList<>(List.of("apple", "banana", "apple", "cherry", "banana", "banana"));
        sortByFrequency(fruits);
        System.out.println("Frequency sort: " + fruits);
    }
}
Practice Makes Prepared
These problems cover the most common ArrayList patterns you'll encounter in interviews and real code: merging, safe removal, in-place manipulation, and custom sorting. Master these and you'll confidently handle any list operation.
Production Insight
In production, you might not write algorithms like rotation from scratch — but understanding them helps when you need to debug a collection operation or optimise a bottleneck. For example, reverse-fixing a bad sort order is a common real-world operation. Always test edge cases: empty list, single element, null elements.
Key Takeaway
Practice problems reinforce ArrayList mechanics: O(1) access, O(n) shifts, safe iteration, and custom sorting. Work through these to internalise the data structure.
● Production incidentPOST-MORTEMseverity: high

The 47 OutOfMemoryError Emails on Monday Morning

Symptom
The service processed daily reports successfully all week. Every Monday at 9:05 AM, it crashed with java.lang.OutOfMemoryError: Java heap space. Weekends had larger transaction volume. GC logs showed allocation failure, but no leak. The heap dump showed 15M-element ArrayList of strings, but the total heap was 4GB — should have fit. The real issue was fragmentation from repeated resizing.
Assumption
The team assumed ArrayList resizing was a rare, cheap operation. They didn't know that growing from 1M to 15M elements with default 1.5x growth factor triggers 20+ resizes. Each resize copies the entire array, causing massive allocation bursts. They also didn't know that many small resizes fragment the heap, making large contiguous allocations impossible.
Root cause
The CSV parser built an ArrayList<String> without capacity hint: List<String> values = new ArrayList<>();. For a 15M-row file, ArrayList started at capacity 10, then resized at 10, 15, 22, 33, 49, 73, 109, 163, 244, 366, 549, 823, 1234, 1851, 2776, 4164, 6246, 9369, 14053, 21079, 31618, 47427, 71140, 106710, 160065, 240097, 360145, 540217, 810325, 1,215,487, 1,823,230, 2,734,845, 4,102,267, 6,153,400, 9,230,100, 13,845,150, 20,767,725. That's 38 resize events. Final allocation at 15M required a 15M-element array, but the heap had been fragmented by the 37 previous temporary arrays that were GC'd but left gaps. The JVM couldn't find a contiguous 15M-slot block. The Friday file with 1M rows had only 20 resizes and succeeded.
Fix
1. Added capacity hint: List<String> values = new ArrayList<>(expectedRowCount + 1000); The expected row count was known from file metadata — use it. 2. For unknown sizes, used ArrayList with initial capacity of 1M for large imports, plus ensureCapacity() when new max discovered. 3. Switched to LinkedList for intermediate processing when final size wasn't needed as array, but that wasn't the fix. 4. The real fix: processed the CSV in streaming fashion — parse, transform, write to DB, discard — never held all rows in memory. ArrayList wasn't needed at all for final storage. 5. Added a metrics alert when ArrayList growth factor causes capacity to double more than 5 times within a single operation.
Key lesson
  • Always provide an initial capacity hint when you know expected size, especially for large lists. Even a rough estimate (like 'size will be > 1000') dramatically reduces resizes.
  • ArrayList's 1.5x growth factor triggers O(n log n) total copies — for 15M elements, that's ~225M element moves, not 15M. Each move is a full array copy.
  • Large ArrayLists are not just memory-heavy — they cause heap fragmentation. Each resize allocates a new array, leaving the old array for GC, fragmenting heap and making large contiguous allocations impossible.
  • If you don't need random access by index, consider processing data in streaming fashion instead of loading all into memory. Often, ArrayList is the wrong abstraction for batch jobs.
Production debug guideSymptom → Action mapping for common ArrayList failures in production Java applications.5 entries
Symptom · 01
OutOfMemoryError: Java heap space with ArrayList in heap dump
Fix
ArrayList grew unexpectedly large. Check if capacity hint missing. In heap dump, look at element count vs capacity. If capacity is much larger than size, call trimToSize() after building list. If size is huge, you may need streaming instead of in-memory collection.
Symptom · 02
ConcurrentModificationException when iterating
Fix
You're modifying list while iterating with for-each. Use Iterator with iterator.remove() or list.removeIf(predicate). For multi-threaded access, use CopyOnWriteArrayList or synchronised wrapper.
Symptom · 03
Performance slow when adding many elements — profiling shows arraycopy hotspot
Fix
ArrayList is resizing frequently. Add capacity hint in constructor: new ArrayList<>(expectedSize). Use ensureCapacity() before bulk add.
Symptom · 04
IndexOutOfBoundsException: Index -1 or out of range
Fix
list.indexOf(element) returned -1 (not found), and you used result as index. Always check int idx = list.indexOf(x); if (idx >= 0) { list.get(idx); }
Symptom · 05
Memory usage higher than expected for element count
Fix
ArrayList's backing array may have unused capacity. Call trimToSize() after finishing modifications to release unused slots. For repeated patterns, re-use list and call clear() (doesn't release backing array) or assign new instance.
★ ArrayList Debug Cheat SheetFast diagnostics for ArrayList issues in production Java applications.
OutOfMemoryError with ArrayList growth
Immediate action
Check initial capacity and growth pattern
Commands
jmap -histo:live <pid> | grep ArrayList
jcmd <pid> GC.heap_info
Fix now
Add capacity hint: new ArrayList<>(estimatedSize). For unknown size, process in chunks or use streaming.
ConcurrentModificationException during iteration+
Immediate action
Find where list is modified during iteration
Commands
grep -n 'list\.remove\|list\.add' src/ | grep -v 'for'
grep -n 'for.*:.*list' src/
Fix now
Replace for-each with explicit Iterator: Iterator<T> it = list.iterator(); while (it.hasNext()) { T t = it.next(); if (condition) it.remove(); }
Slow ArrayList growth in performance-critical path+
Immediate action
Check if capacity hint was provided and growth factor
Commands
grep -n 'new ArrayList<.*>()' src/ | grep -v 'new ArrayList<.*>('
jstat -gc <pid> 1000 10 | grep -E 'YGCT|FGCT'
Fix now
Add capacity hint: new ArrayList<>(10000). For batch builds, use ensureCapacity(size) before loop.
Using indexOf result as array index — ArrayIndexOutOfBoundsException+
Immediate action
Check if result of indexOf is used without validation
Commands
grep -n 'indexOf' src/ | grep -v '== -1' | grep -v '>= 0'
grep -n '\.get\[' src/ | grep 'indexOf'
Fix now
Always validate: int idx = list.indexOf(target); if (idx != -1) { use idx } else { handle not found }
Memory high — ArrayList capacity much larger than size+
Immediate action
Check capacity vs size in heap dump or via reflection
Commands
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
echo 'get field capacity via reflection'
Fix now
Call ((ArrayList<?>) list).trimToSize(); after list is fully populated. For long-lived lists, consider clear() vs new instance.
ArrayList vs Array vs LinkedList vs Vector
Feature / AspectArrayListLinkedListPlain ArrayVector (legacy)
Backed byObject[] internallyDoubly-linked nodesNative memory blockObject[] with synchronisation
get(index) speedO(1) — direct accessO(n) — walks the chainO(1) — direct accessO(1) — direct access
add() at endO(1) amortisedO(1) alwaysN/A — fixed sizeO(1) amortised (synchronised)
add() in middleO(n) — elements shiftO(1) if you have the nodeN/A — fixed sizeO(n) — shift + synchronised
remove() in middleO(n) — elements shiftO(1) if you have the nodeN/A — fixed sizeO(n) — shift + synchronised
Memory overhead per element~8 bytes (reference)~16 bytes (prev + next refs)0 extra (values inline)~8 bytes + synchronisation overhead
Supports primitives?No — boxes int to IntegerNo — boxes int to IntegerYes — int[], double[], etc.No — boxes like ArrayList
Thread safe?No — use synchronizedList()No — use synchronizedList()NoYes — methods are synchronised
Recommended in new code?Yes — default listRarely — only for queue/dequeYes — for fixed sizes, primitivesNo — use ArrayList with sync wrapper
Best forGeneral purpose, read-heavyQueue/deque, frequent front insertsFixed size, primitives, performanceLegacy code only

Common mistakes to avoid

5 patterns
×

Removing elements inside a for-each loop

Symptom
java.util.ConcurrentModificationException at runtime. The error stack trace points to the next iteration after the remove, not the remove line itself, making it confusing to debug.
Fix
Use list.removeIf(predicate) for simple filter-and-remove scenarios, or use an explicit Iterator with iterator.remove() when you need conditional logic during the loop. For multi-threaded access, use CopyOnWriteArrayList.
×

Using `==` to compare ArrayList contents instead of `.equals()`

Symptom
Writing if (listA == listB) checks whether both variables point to the exact same object in memory, not whether they contain the same elements. Two separate ArrayLists with identical contents will always return false with ==.
Fix
Use listA.equals(listB) instead, which compares element-by-element using each element's own .equals() method. For nested lists, use Objects.deepEquals(listA, listB).
×

Storing Integer indexes from `indexOf()` without checking for -1

Symptom
list.indexOf(element) returns -1 when the element isn't found. If you pass that -1 directly into list.get(-1) or use it in an off-by-one calculation, you'll get IndexOutOfBoundsException or silently wrong results.
Fix
Always check: int idx = list.indexOf(target); if (idx != -1) { ... } before using the result. For binary search, check idx >= 0.
×

Building large ArrayList without capacity hint

Symptom
Performance is significantly slower than expected. Profiling shows System.arraycopy consuming CPU in ArrayList.grow(). Memory usage spikes during resizes.
Fix
Provide capacity hint in constructor: new ArrayList<>(expectedSize). If size unknown, use ensureCapacity(growthEstimate) before batch additions. For really large lists (>10M elements), consider streaming instead of in-memory list.
×

Using raw ArrayList without generics

Symptom
Compiles without warning (if unchecked flags not enabled), but throws ClassCastException at runtime when retrieving elements. The error stack trace points to a cast line far from the actual mistake.
Fix
Always use generic type parameter: List<String> list = new ArrayList<>(); Never use raw ArrayList. Enable compiler warnings with -Xlint:unchecked to catch these at compile time.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the time complexity of ArrayList.add()? Why is it called amortis...
Q02SENIOR
What is the difference between ArrayList and LinkedList, and when would ...
Q01 of 02SENIOR

What is the time complexity of ArrayList.add()? Why is it called amortised O(1)?

ANSWER
ArrayList.add() is amortised O(1), not strictly O(1). Most add operations simply write to the next available slot in the internal array, which is O(1). However, when the internal array is full, ArrayList creates a new array 1.5x larger, copies all existing elements (O(n)), then adds the new element. This resize happens about O(log n) times over n adds. The total cost of all resizes over n adds is O(n), so the average cost per add is O(1). This is amortised analysis: occasional expensive operations are averaged over many cheap ones. The growth factor of 1.5 minimizes copies while avoiding the memory waste of doubling each time. For 1000 adds, total copies are about 1500 — average 1.5 copies per add.
🔥

That's Collections. Mark it forged?

11 min read · try the examples if you haven't

Previous
Collections Framework Overview
2 / 21 · Collections
Next
LinkedList in Java