AlgoMaster Logo

ArrayList

High Priority16 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

ArrayList is the default List implementation in Java, and the most common choice. Internally it's a regular Java array that knows how to grow when it runs out of room, which is what gives it fast indexed access and amortized-cheap appends. This lesson covers how the backing array works, what every common operation actually costs, how capacity differs from size, and the one iteration pitfall that bites almost every Java beginner: ConcurrentModificationException.

The Dynamic Array Behind ArrayList

An ArrayList<E> holds its elements in an ordinary Java array of type Object[]. The array is called the backing array. When you create an ArrayList, Java sets aside a slab of contiguous memory for that array, and every get, set, and add ultimately reads or writes one of its slots.

After three products have been added to a fresh list, the layout looks like this:

Two numbers describe an ArrayList at any moment: its size, the count of elements you've actually added, and its capacity, the length of the backing array. In the picture, size is 3 and capacity is 10. The slots from index 3 through 9 are empty placeholders. ArrayList keeps them around so the next few add calls don't have to allocate fresh memory. The size() method only counts the first three filled slots; the seven empty ones are invisible to anyone using the list.

Reading slot 0 is a direct memory access, so get(0) is constant time. Inserting at slot 1 has to shift everything to its right by one position, so add(1, x) is linear time. Everything follows from how arrays work, and ArrayList is honest about that.

A program that builds the list in the picture and prints its size:

The list reports a size of 3, not 10. Capacity is an internal implementation detail, and ArrayList doesn't expose a public method for reading it. The library's job is to make sure you never have to think about it.

Creating ArrayLists

There are three useful constructors. Each one is a different bet about how many elements you're about to add.

The no-arg constructor creates an empty list with a small default capacity. In OpenJDK, the array is actually a shared empty array on construction, and the first add allocates a backing array of length 10. From a user's perspective, the default capacity is 10, which suits the case where the eventual size is unknown.

The capacity-hint constructor takes an integer and allocates the backing array at that length right away. Use it when you have a rough idea of the final size, because it skips the growth steps you'd otherwise pay for as the list filled up.

Sizing the backing array up front skips intermediate reallocations. For a 500-element load, the default constructor would resize the array several times as it grew; the hinted constructor allocates once.

The copy constructor takes any existing Collection and builds a new ArrayList from its elements, in iteration order. The new list's capacity is set to fit the source, so it doesn't reserve extra room.

List.of(...) returns an immutable list, so calling add directly on it would throw. The copy constructor gives you a fresh, mutable ArrayList containing the same elements. This is the most common way to convert "give me a mutable list out of these starting values" into a single line.

Core Operations and Their Costs

The five operations you'll use 90% of the time are add(e), add(index, e), get(index), remove(index), and contains(e). Each one has a predictable cost that maps directly to what the backing array has to do.

add(e) appends to the end. If the backing array has room, it writes into the next free slot and bumps the size, which is a constant-time step. If the array is full, it has to grow first, which costs O(n) for that one call. Averaged across many appends, the cost is amortized O(1).

add(index, e) inserts at a specific position. Every element from index to the end has to slide one slot to the right to make room. That's O(n) in the worst case (inserting at index 0 of a long list is the most expensive case).

get(index) reads the slot directly. The array supports random access, so this is O(1) regardless of where in the list you read.

remove(index) deletes the element at index and slides every element to its right one slot to the left to close the gap. That's O(n) in the worst case, just like add(index, e).

contains(e) walks the list from index 0 until it finds a match (using equals) or runs off the end. That's O(n), because the elements aren't sorted or indexed by value.

A program that exercises each of these:

Step through it. After the three appends, the cart is [Notebook, Pen, Eraser]. add(0, "Highlighter") slides those three elements right by one and writes "Highlighter" into slot 0, leaving [Highlighter, Notebook, Pen, Eraser]. get(0) returns the new first element. contains("Pen") walks the array, finds "Pen" at slot 2, and returns true. remove(1) deletes "Notebook" and slides "Pen" and "Eraser" left, leaving [Highlighter, Pen, Eraser].

Repeated add(0, ...) in a long list is expensive. Each call shifts every existing element. For lots of front-inserts, use ArrayDeque or LinkedList instead.

A common confusion lives in remove. There are two overloads on ArrayList<E>:

  • remove(int index) deletes the element at that index.
  • remove(Object o) deletes the first occurrence of an element equal to o.

For an ArrayList<Integer>, the call cart.remove(1) matches the first overload, because 1 is an int literal. To remove the element whose value is 1, write cart.remove(Integer.valueOf(1)).

The first call deletes the element at index 1, which is 20. The second call deletes the element equal to 30. They look almost identical at the call site but do completely different things. The Integer.valueOf wrapper is the standard way to force the Object overload.

Capacity Versus Size, and How Resizing Works

Size is the number of elements you've added. It's what size() returns and what get(i) will let you read up to. Capacity is the length of the backing array. The two are independent: size grows every time you add, capacity grows only when the backing array runs out of room.

When add is called and size == capacity, the list has to grow. In OpenJDK's ArrayList, growth happens in three steps:

  1. Allocate a new, larger array. The new capacity is typically oldCapacity + (oldCapacity >> 1), which is 1.5x the old capacity.
  2. Copy every existing element from the old array into the new array.
  3. Drop the old array and use the new one as the backing array. Then write the new element.

The factor isn't fixed across all JDKs and versions, but 1.5x has been the OpenJDK choice for a long time. The exact number matters less than the fact that growth is geometric, not arithmetic: each resize allocates a chunk proportional to the current capacity rather than adding a fixed amount.

A diagram of one resize step:

The trip from 10 to 15 happened in one step because the array was full when add("K") came in. The 11th element fits in the new array, and the next four appends will be free of resize cost.

Geometric growth is why add(e) is amortized O(1) instead of O(n). A single append might trigger a resize that copies the whole list, but those expensive copies happen so infrequently as the list grows that, averaged across many appends, each one costs constant time.

add(e) is amortized O(1) but individual worst-case O(n). If you know the final size, pass it to the constructor (new ArrayList<>(1000)) so the list never has to resize while you fill it.

Two methods let you take control over capacity directly:

  • ensureCapacity(int minCapacity) grows the backing array to at least the given length if it isn't already that big. Useful when you're about to do a lot of appends and you'd rather pay for one large allocation now than several growth steps later.
  • trimToSize() shrinks the backing array down to exactly the current size. Useful when a list has finished growing and you don't want to hold on to the extra capacity.

The output of size() doesn't change between the two prints, because trimming the backing array doesn't drop any elements. What changes is the capacity, which is no longer visible to your code, but it does free up memory in the heap that was previously sitting unused. Most application code never needs either of these methods, but they exist for tight memory budgets or for finalizing a large read-only list.

ensureCapacity and trimToSize are declared on the concrete ArrayList class, not on the List interface. To call them, the variable needs to be typed as ArrayList, not List. That's a rare case where the implementation type matters more than the interface.

Iteration and ConcurrentModificationException

Walking an ArrayList is straightforward. There are three common shapes:

Three different shapes, same result. The index-based loop is the only one that exposes the index to your code; the other two hide it because the iterator handles position internally.

A bug shows up when you modify the list during an enhanced-for loop. ArrayList's iterator is fail-fast: it tracks the number of structural changes (called the modification count) and checks it on every next call. If a structural change happened that didn't go through the iterator itself, the next iterator step throws ConcurrentModificationException.

A "structural change" means anything that changes the list's size or rearranges its elements: add, remove, clear. Calling set(i, e) to replace an element in place doesn't count as a structural change, so it's safe during iteration.

This code throws:

The enhanced-for is sugar for an iterator. On the second iteration, the iterator calls next, sees that the list's modification count went up (because cart.remove("Pen") changed it), and throws to stop you before something worse happens. The exception name is misleading; it's nothing to do with threads, it's a single-thread bug where one piece of code is iterating while another (in this case, the same piece) is mutating.

The name "fail-fast" describes the policy. The iterator doesn't try to recover or guess what you meant. It throws immediately so the problem shows up at the call site instead of corrupting the iteration silently.

Two clean fixes:

Use the iterator's own `remove` method. The iterator knows about the modification, so it doesn't trip the check:

Use `removeIf`, which is built for this case:

removeIf walks the list once and removes every element matching the predicate, all in one operation that doesn't fight the fail-fast check. It's the most readable fix in modern Java.

A separate way to avoid the exception is the old-fashioned index-based loop, which doesn't use an iterator at all:

This works, but it's easy to get wrong. The i-- correction is needed because remove(i) shifts everything left and the loop's i++ would otherwise skip the new occupant of slot i. removeIf exists to make this kind of code unnecessary.

When to Use ArrayList

ArrayList is the default List implementation for a reason. Indexed reads are O(1), appends are amortized O(1), and the backing array is a single block of memory the CPU walks in order. For the bulk of use cases (a catalog, a search result, an order history, a feed of recent items), ArrayList is a fine default.

The cases where it isn't appropriate all involve heavy modification in the middle. Inserting or removing near the front of a long list is O(n), so if your workload is "shift things into and out of the front a lot", a LinkedList or ArrayDeque is usually a better fit. If you need fast lookup by value rather than by index, HashSet or HashMap will outperform ArrayList.contains by orders of magnitude.

Two practical habits will save you time later:

  • Pre-size when you know the count. new ArrayList<>(expectedSize) avoids most of the growth work.
  • Use `removeIf` instead of removing inside an enhanced-for. It's shorter, safer, and reads like what you mean.

Quiz

ArrayList Quiz

10 quizzes