JavaCollectionsintermediate
Updated:

ArrayList in Java: Internal Working and Operations

5 min read

ArrayList is Java's workhorse collection. See how the backing array grows, what each operation costs in Big-O terms, and the traps that show up in interviews.

TL;DR – Quick Answer

ArrayList in Java is a resizable-array implementation of the List interface. Internally it stores elements in an Object[] array; when the array fills up, ArrayList allocates a new array roughly 1.5 times larger and copies the elements over. This gives O(1) random access, amortized O(1) appends, and O(n) inserts or removals in the middle.

On This Page

Nine out of ten lists you create in real Java projects will be ArrayLists. That is exactly why interviewers probe it: "How does ArrayList grow?", "Why is add amortized O(1)?", "What happens if you remove while iterating?" If you can answer those three questions with mechanics rather than memorized lines, you stand out. Let's build that understanding.

ArrayList is one implementation of the List interface, so everything Lists guarantee — insertion order, duplicates, index access — applies here.

The internal structure: one array and a size counter

Strip away the API and ArrayList is just two fields:

transient Object[] elementData;  // the backing array
private int size;                // how many slots are actually used

Every behavior follows from this. get(i) returns elementData[i] after a bounds check — that is why random access is O(1). add(x) writes to elementData[size] and increments size. The interesting part is what happens when the array is full.

Two related numbers matter and are easy to confuse:

  • Size — elements stored. What size() returns.
  • Capacity — length of elementData. Invisible through the public API.

new ArrayList<>() does not even allocate the array immediately. It points to a shared empty array, and only on the first add does it allocate the default capacity of 10. That lazy allocation keeps thousands of empty lists cheap.

How growth works: the 1.5x rule

When add finds the array full, ArrayList grows it:

  1. Compute new capacity: oldCapacity + (oldCapacity >> 1) — that is, 1.5x the old.
  2. Allocate the new array and copy all elements with Arrays.copyOf.
  3. Point elementData at the new array and complete the add.

So a default list grows 10 → 15 → 22 → 33 → 49 → 73 and so on. Each resize is O(n), but because capacity grows geometrically, resizes become exponentially rarer. Spread the copy cost over all the cheap adds between resizes and the average cost per add is constant — that is precisely what amortized O(1) means.

Interview note: Vector doubles its capacity (2x) on resize while ArrayList grows by 1.5x. Mentioning this contrast when asked about ArrayList growth signals that you have actually looked at how the classes behave, not just read a definition.

You can watch the resize behavior indirectly by timing bulk adds with and without a pre-sized list:

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

public class GrowthDemo {
    static long fill(List<Integer> list, int n) {
        long start = System.nanoTime();
        for (int i = 0; i < n; i++) {
            list.add(i);
        }
        return (System.nanoTime() - start) / 1_000_000; // ms
    }

    public static void main(String[] args) {
        int n = 5_000_000;
        long grown    = fill(new ArrayList<>(), n);      // ~23 resizes on the way
        long presized = fill(new ArrayList<>(n), n);     // zero resizes

        System.out.println("Default capacity : " + grown + " ms");
        System.out.println("Pre-sized        : " + presized + " ms");
    }
}

Run it a few times; the pre-sized version is consistently faster because it skips every intermediate allocation and copy. The exact numbers vary by machine — what matters is the pattern.

Pro tip: When you know the final size in advance — loading query results, converting another collection — pass it to the constructor: new ArrayList<>(expectedSize). It is a one-line change that eliminates all resize copies.

What every operation costs

Operation Complexity Why
get(i) / set(i, x) O(1) Direct array indexing
add(x) at end Amortized O(1) Occasional resize copy, rare by design
add(i, x) O(n) System.arraycopy shifts elements right
remove(i) O(n) Shifts elements left to close the gap
remove(Object) O(n) Linear search, then shift
contains(x) / indexOf(x) O(n) Linear scan calling equals
Iteration O(n) Sequential array walk, very cache-friendly

The O(n) rows share one cause: arrays are contiguous, so inserting or deleting anywhere except the tail means physically moving everything after that position. Removing index 0 of a million-element list moves 999,999 references. If your workload does that in a loop, reconsider the data structure — the trade-offs are laid out in the ArrayList vs LinkedList comparison linked at the end of this page.

Core operations in practice

A runnable tour of the operations you will actually use, with the gotchas flagged:

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

public class ArrayListOps {
    public static void main(String[] args) {
        List<Integer> marks = new ArrayList<>(List.of(85, 42, 91, 42, 77));

        marks.add(60);                 // append -> [85, 42, 91, 42, 77, 60]
        marks.add(0, 99);              // insert at head, shifts all right
        marks.set(1, 80);              // replace index 1 (was 85)

        // remove by INDEX vs remove by VALUE — the classic Integer trap
        marks.remove(2);                        // removes element AT index 2
        marks.remove(Integer.valueOf(42));      // removes first VALUE 42

        System.out.println(marks);              // [99, 80, 42, 77, 60]
        System.out.println(marks.indexOf(42));  // 2
        System.out.println(marks.contains(91)); // false

        marks.removeIf(m -> m < 70);   // bulk conditional removal
        System.out.println(marks);     // [99, 80, 77]
    }
}

Two things to notice: the remove overload trap with boxed Integers, and removeIf, which handles remove-while-filtering in a single safe call.

Fail-fast iteration and ConcurrentModificationException

ArrayList keeps a modCount field that increments on every structural change (add, remove, clear). Its iterator snapshots that count when created and compares on every next(). If the counts differ — someone modified the list outside the iterator — it throws ConcurrentModificationException immediately rather than returning corrupt data.

List<String> tools = new ArrayList<>(List.of("Git", "Maven", "Docker"));

for (String t : tools) {
    if (t.equals("Maven")) {
        tools.remove(t);   // throws ConcurrentModificationException
    }
}

The fix is one of: Iterator.remove(), removeIf(...), or collecting survivors into a new list. This exact scenario is a staple of Java collections interview questions because it separates people who have debugged real code from people who have only read syntax.

Common mistake: A common mistake beginners make is assuming ConcurrentModificationException only happens with multiple threads. The name misleads: a single thread modifying a list mid-iteration triggers it too, as the loop above shows.

Memory behavior worth knowing

  • ArrayList stores object references, not primitives. A List<Integer> holds pointers to boxed Integer objects, which costs more memory and indirection than an int[]. For large numeric datasets, prefer arrays or specialized libraries.
  • Capacity never shrinks on its own. After removing 990,000 of 1,000,000 elements, the backing array still has ~1,000,000 slots. Call trimToSize() if that memory matters.
  • Because elements are contiguous, iteration enjoys excellent CPU cache locality — a big reason ArrayList outperforms LinkedList even at jobs that look linked-list-shaped.

Thread safety: what to use instead

ArrayList performs zero synchronization. If two threads add concurrently, you can lose elements or see an ArrayIndexOutOfBoundsException from a torn resize. Your options:

  1. Collections.synchronizedList(new ArrayList<>()) — wraps every method in a lock; you must still synchronize manually while iterating.
  2. CopyOnWriteArrayList — copies the whole array on each write; excellent for read-heavy, write-rare workloads like listener lists, terrible for frequent writes.

Vector is the third, legacy option and is not recommended for new code.

Where to go next

You now know the mechanics that generate every ArrayList answer: a contiguous array, geometric growth, shift-on-insert, fail-fast iteration. Next, put it in context — compare it head-to-head in ArrayList vs LinkedList, see how hashing changes the game in HashSet, or step back to the full collections map. If you want this depth across all of core Java with mentor-led practice, that is what our Java Full Stack program is built for.

Frequently Asked Questions

How does ArrayList work internally in Java?
ArrayList keeps its elements in a private Object[] array called elementData, plus a size field tracking how many slots are used. add() places the element at index size; if the array is full, ArrayList grows it to about 1.5 times the old capacity using Arrays.copyOf and then inserts.
What is the default initial capacity of an ArrayList?
Ten. However, new ArrayList<>() starts with a shared empty array and only allocates the 10-slot array when you add the first element. If you pass a capacity, like new ArrayList<>(1000), it allocates that size up front and avoids early resizes.
Why is adding to an ArrayList called amortized O(1)?
Most add() calls just write into a free slot, which is O(1). Occasionally the array is full and a resize copies all n elements, which is O(n). Because capacity grows geometrically, those expensive copies are rare enough that the average cost per add works out to a constant.
Is ArrayList thread-safe?
No. Concurrent modification from multiple threads can corrupt its state or throw ConcurrentModificationException. Use Collections.synchronizedList(new ArrayList<>()) or CopyOnWriteArrayList when multiple threads write to the same list.
What is the difference between size and capacity in ArrayList?
Size is the number of elements actually stored; capacity is the length of the internal backing array. Capacity is always greater than or equal to size. You control initial capacity through the constructor and can trim excess with trimToSize().
When should I not use an ArrayList?
Avoid it when your workload constantly inserts or removes at the head or middle of large lists (each such operation shifts elements, costing O(n)), when you need constant-time removal during iteration, or when duplicates must be rejected — a Set fits that job better.

Want to Build Your Career in Java Full Stack with AI?

Join CodeBegun and train with working industry engineers — Explore the Program

Apply for Demo Class →
Siva Prasad Galaba
Founder, CodeBegun · Staff Engineer

Founder of CodeBegun. 15+ years building Java systems at companies like Crunchyroll. Teaches Java, Spring Boot and system design the way the industry actually works, and mentors students through projects, mock interviews and placement preparation.

Technically reviewed by CodeBegun Technical TeamLast reviewed 14 July 2026 LinkedIn
Chat with us