Home Java Java ArrayList Explained — How It Works, When to Use It, and What to Watch Out For

Java ArrayList Explained — How It Works, When to Use It, and What to Watch Out For

In Plain English 🔥
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.
⚡ Quick Answer
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 of this article 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 in production code. You'll also have a handful of interview-ready answers ready to go.

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 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 interface type on the left side of the declaration: List 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.

GuestListDemo.java · JAVA
1234567891011121314151617181920212223242526272829303132
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);
    }
}
▶ Output
Guest list: [Alice, Zara, Bob, Charlie]
Number of guests: 4
First guest: Alice
Is Bob invited? true
⚠️
Pro Tip: Declare as List, not ArrayListWrite `List items = new ArrayList<>()` instead of `ArrayList 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.

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.

ResizingDemo.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940
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());
    }
}
▶ Output
Without capacity hint: 12 ms
With capacity hint: 4 ms
Memory trimmed. List size: 100000
🔥
Interview Gold: The Amortised O(1) ExplanationIf 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.

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.

OrderProcessing.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
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()));
    }
}
▶ Output
All orders:
ORD-001 — PENDING
ORD-002 — SHIPPED
ORD-003 — PENDING
ORD-004 — CANCELLED
ORD-005 — SHIPPED

After removing cancelled orders: 4 remaining

Orders by value (highest first):
ORD-003 $299.50
ORD-005 $179.99
ORD-001 $149.99
ORD-002 $59.00

Pending orders only:
ORD-003 $299.50
ORD-001 $149.99
⚠️
Watch Out: Never Remove Inside a For-Each LoopCalling `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.

ArrayList vs Array vs LinkedList — Choosing the Right Tool Without Guessing

Knowing how to use ArrayList is only half the skill. The other half is knowing when not to use it.

A plain array (String[]) beats ArrayList when your size is fixed and known at compile time, and you care about memory or raw speed. Arrays avoid the object overhead of ArrayList and let you use primitive types directly (int[] instead of List). If you're writing a fixed lookup table or working in a performance-critical path, arrays are your friend.

LinkedList is the other common alternative. It's a doubly-linked list, meaning each element holds a reference to the next and previous elements. This makes insertions and deletions at both ends O(1) — perfect for a queue or stack. But get(index) on a LinkedList is O(n) because it has to walk the chain from the beginning. In practice, LinkedList also has higher memory usage per element due to those extra references. Most developers reach for it too quickly — unless you're doing heavy insertion/deletion at both ends, ArrayList is usually faster in practice due to better cache locality.

Vector is the old thread-safe ArrayList from Java 1.0. Don't use it in new code. If you need thread safety, use Collections.synchronizedList() or CopyOnWriteArrayList from java.util.concurrent depending on your read/write ratio.

CollectionComparison.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class CollectionComparison {

    public static void main(String[] args) {

        int iterations = 50_000;

        // --- ArrayList: fast random access, slower mid-list insert ---
        List<String> arrayList = new ArrayList<>();
        long start = System.nanoTime();
        for (int i = 0; i < iterations; i++) {
            // Inserting at index 0 forces everything else to shift right — O(n) each time
            arrayList.add(0, "Task-" + i);
        }
        long arrayListInsertTime = System.nanoTime() - start;

        // --- LinkedList: fast front/back insert, slow random access ---
        List<String> linkedList = new LinkedList<>();
        start = System.nanoTime();
        for (int i = 0; i < iterations; i++) {
            // addFirst() is O(1) — just rewire the head pointer, no shifting
            ((LinkedList<String>) linkedList).addFirst("Task-" + i);
        }
        long linkedListInsertTime = System.nanoTime() - start;

        System.out.println("Inserting " + iterations + " items at the front:");
        System.out.println("  ArrayList:   " + arrayListInsertTime / 1_000_000 + " ms  ← lots of shifting");
        System.out.println("  LinkedList:  " + linkedListInsertTime / 1_000_000 + " ms  ← just pointer rewiring");

        // Now show the flip side: random access
        start = System.nanoTime();
        String ignored1 = arrayList.get(iterations / 2);  // O(1) — direct index jump
        long arrayListGetTime = System.nanoTime() - start;

        start = System.nanoTime();
        String ignored2 = linkedList.get(iterations / 2);  // O(n) — walks the chain
        long linkedListGetTime = System.nanoTime() - start;

        System.out.println("\nRandom access (middle element):");
        System.out.println("  ArrayList:   " + arrayListGetTime + " ns  ← direct jump");
        System.out.println("  LinkedList:  " + linkedListGetTime + " ns  ← walked the chain");
    }
}
▶ Output
Inserting 50000 items at the front:
ArrayList: 312 ms ← lots of shifting
LinkedList: 3 ms ← just pointer rewiring

Random access (middle element):
ArrayList: 180 ns ← direct jump
LinkedList: 892340 ns ← walked the chain
🔥
The 90% RuleIn practice, ArrayList is the right default for 90% of use cases. LinkedList's theoretical advantages rarely materialise in production because CPU cache performance favours contiguous memory (ArrayList's backing array) over scattered pointer-linked nodes (LinkedList). Always benchmark before switching — intuition about performance is wrong more often than you'd expect.
Feature / AspectArrayListLinkedListPlain Array
Backed byObject[] internallyDoubly-linked nodesNative memory block
get(index) speedO(1) — direct accessO(n) — walks the chainO(1) — direct access
add() at endO(1) amortisedO(1) alwaysN/A — fixed size
add() in middleO(n) — elements shiftO(1) if you have the nodeN/A — fixed size
remove() in middleO(n) — elements shiftO(1) if you have the nodeN/A — fixed size
Memory overheadLow — just the array + metadataHigh — two pointers per nodeLowest — no wrapper
Supports primitivesNo — boxes int to IntegerNo — boxes int to IntegerYes — int[], double[], etc.
Thread safe?No — use synchronizedList()No — use synchronizedList()No
Best forGeneral purpose, read-heavyQueue/deque, frequent front insertsFixed size, primitives, performance

🎯 Key Takeaways

  • Always declare your variable as List, not ArrayList — 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.
  • Never call list.remove() inside a for-each loop — use list.removeIf() or an explicit Iterator to avoid ConcurrentModificationException.
  • ArrayList beats LinkedList in most real-world scenarios because contiguous memory gives better CPU cache performance — only reach for LinkedList when you genuinely need O(1) insertion at both ends with no random access.

⚠ Common Mistakes to Avoid

  • Mistake 1: Removing elements inside a for-each loop — This throws java.util.ConcurrentModificationException at runtime because the ArrayList's internal modCount changes while the iterator is active, which the iterator detects and treats as a contract violation. Fix it by using 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.
  • Mistake 2: Using == to compare ArrayList contents instead of .equals() — 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 ==. Use listA.equals(listB) instead, which compares element-by-element using each element's own .equals() method.
  • Mistake 3: Storing Integer indexes from indexOf() without checking for -1 — 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 an IndexOutOfBoundsException or silently wrong results. Always check: int idx = list.indexOf(target); if (idx != -1) { ... } before using the result.

Interview Questions on This Topic

  • QWhat is the difference between ArrayList and LinkedList, and when would you actually choose LinkedList over ArrayList in a production application?
  • QWhat does 'amortised O(1)' mean in the context of ArrayList.add(), and what triggers the worst-case O(n) behaviour?
  • QIf ArrayList is not thread-safe, what are your options for using a list in a multi-threaded environment, and what are the trade-offs between Collections.synchronizedList() and CopyOnWriteArrayList?

Frequently Asked Questions

What is the difference between ArrayList and a regular array in Java?

A regular array has a fixed size you must declare upfront and cannot change. ArrayList is a resizable wrapper around an array that grows automatically when you exceed its capacity. You also get built-in methods like add(), remove(), contains(), and sort() with ArrayList, none of which exist on a plain array. The trade-off is that ArrayList can't hold primitives directly — it boxes them into wrapper types like Integer, which adds a small memory and performance cost.

Is ArrayList thread-safe in Java?

No, ArrayList is not thread-safe. If multiple threads read and write to the same ArrayList concurrently without synchronisation, you'll get unpredictable results and ConcurrentModificationException errors. Use Collections.synchronizedList(new ArrayList<>()) for a simple synchronised wrapper, or CopyOnWriteArrayList from java.util.concurrent if reads vastly outnumber writes — it creates a fresh copy of the backing array on every write, making reads completely lock-free.

Why does ArrayList use Object[] internally instead of a typed array like String[]?

Java generics are erased at compile time through a mechanism called type erasure — the JVM doesn't actually know about the generic type parameter at runtime. So ArrayList and ArrayList both use the same Object[] under the hood, with the generic type only enforced at the compiler level. This is why you'll occasionally see unchecked cast warnings when working with reflection or raw types around ArrayList.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousCollections Framework OverviewNext →LinkedList in Java
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged