ArrayList in Java
Learn ArrayList: dynamic resizing, internal array management, when to choose ArrayList over plain arrays, and performance trade-offs.
Learn ArrayList: dynamic resizing, internal array management, when to choose ArrayList over plain arrays, and performance trade-offs.
ArrayList in Java
ArrayList is Java’s most widely used dynamic list implementation. It wraps a resizable array internally while exposing the List interface, giving you the convenience of a dynamic collection with random access performance.
Introduction
ArrayList is the workhorse of Java collections. It implements the List interface using a resizable array internally, delivering O(1) random access by index while automatically growing its backing array when capacity is exhausted. For most use cases requiring an ordered, dynamically-sized collection, ArrayList is the correct default choice — and the fact that it is the default choice is exactly why understanding its behavior matters. When you reach for ArrayList, you are betting your access patterns on O(1) indexed reads and amortized O(1) appends to the end, with the tradeoff being O(n) insertions and deletions in the middle.
The critical distinction that trips up many developers is the difference between size() and capacity(). size() is the number of elements currently stored; capacity() is the length of the internal backing array. A new ArrayList<>() starts with capacity 10 and grows by approximately 50% each resize. If you know the expected size upfront, new ArrayList<>(expectedSize) avoids the multiple resize-copy cycles that degrade performance for large, unbounded lists. The growth formula (oldCapacity * 3) / 2 + 1 produces a new capacity of 16, then 25, then 38, and so on — each resize copies all existing elements to a new array.
ArrayList is not thread-safe. Concurrent modification from multiple threads causes ConcurrentModificationException in single-threaded iteration and data corruption in multi-threaded writes. For thread-safe scenarios, CopyOnWriteArrayList offers safe iteration at the cost of O(n) writes on every mutation. This post covers internal structure and the resize mechanism, the fail-fast iterator and when ConcurrentModificationException fires, capacity estimation for large lists, the contains() and indexOf() O(n) performance characteristic that surprises many, and the complete failure scenario matrix from ArrayIndexOutOfBoundsException to OutOfMemoryError on capacity exhaustion.
When to Use ArrayList
Use ArrayList when:
- You need a resizable ordered collection with O(1) random access
- You primarily add/get elements by index and seldom insert/delete in the middle
- You want to store object references with generics for compile-time type safety
- You need to pass collections between APIs that expect
List
Do not use ArrayList when:
- You frequently insert or remove elements at arbitrary positions (use
LinkedList) - You need O(1) insertion at the head (arrays are poor at prepending)
- You need primitive-specific collections without boxing (use primitive collections libraries)
- You need thread-safe operations without external synchronization
Internal Structure
ArrayList maintains an internal Object[] array and tracks size separately. When capacity is exceeded, it grows by approximately 50% ( (oldCapacity * 3) / 2 + 1 ), allocating a new backing array and copying elements.
// Internal structure (simplified)
public class ArrayList<E> {
Object[] elementData;
int size;
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = (oldCapacity * 3) / 2 + 1;
elementData = Arrays.copyOf(elementData, newCapacity);
}
}
Mermaid Diagram: ArrayList Resize Operation
sequenceDiagram
participant Client
participant AL as ArrayList
participant Array as Internal Array
Client->>AL: add(element) at full capacity
AL->>AL: allocate new array (1.5x size)
AL->>Array: System.arraycopy(oldArray, newArray)
Array-->>AL: elements copied
AL->>AL: add new element
AL-->>Client: element added
Failure Scenarios
| Scenario | Cause | Result |
|---|---|---|
ArrayIndexOutOfBoundsException | Index < 0 or >= size | Runtime crash |
NullPointerException | Adding null to a list that rejects nulls | Runtime crash / silent failure |
ConcurrentModificationException | Modifying list while iterating | Runtime crash |
ClassCastException | Retrieving element and casting incorrectly | Runtime crash |
| Capacity exhaustion | Adding beyond Integer.MAX_VALUE | OutOfMemoryError |
Trade-Off Table
| Aspect | Array | ArrayList |
|---|---|---|
| Resizing | Manual only | Automatic on add() |
| Generic type | No (raw Object[]) | Yes (ArrayList<T>) |
| Autoboxing | Yes (primitives stored as Object) | Yes |
| Random access | O(1) | O(1) |
| Insertion at middle | O(n) | O(n) |
| Memory overhead | Minimal | Slightly higher (size tracking, capacity) |
| Thread safety | None | None (use Collections.synchronizedList) |
Code Snippets
Basic Operations
ArrayList gives you four primary operations that matter: add(), set(), get(), and remove(). Each one has a distinct performance profile, and mixing them up is how you accidentally turn a fast loop into something that scales poorly. add(element) appends to the end in amortized O(1). That is your default path. add(index, element) inserts at a specific position, which means every element after it shifts right by one slot. That is O(n) and it adds up fast in tight loops. set(index, element) overwrites without shifting anything — O(1). remove(index) removes and returns the element at a position, then shifts everything after it leftward — also O(n).
The get(index) and size() calls are both O(1) regardless of list size. The contains(element) call is O(n) because it scans the array linearly. This catches a lot of developers off guard — they assume it has the same O(1) access as get(). If you are calling contains() repeatedly on large lists, consider keeping a HashSet alongside the ArrayList for O(1) membership checks. The memory overhead is worth it if the list is large and lookups are frequent.
List<String> tasks = new ArrayList<>();
tasks.add("Review PR"); // Append — amortized O(1)
tasks.add(0, "Standup"); // Insert at head — O(n), shifts all elements right
tasks.set(1, "Code Review"); // Replace at index — O(1), no shifting
System.out.println(tasks.get(2)); // Get by index — O(1)
System.out.println(tasks.size()); // 3 — O(1)
System.out.println(tasks.contains("Standup")); // true — O(n) scan
Iterating Safely
ArrayList’s iterator is fail-fast: it tracks a modCount field that increments every time the list changes structurally. When you call add(), remove(), or clear(), modCount goes up. The iterator snapshots the expected modCount when it is created, then checks it before every next() call. If the counts do not match, the iterator throws ConcurrentModificationException. That is the detector firing. It is not a guarantee — a sufficiently determined data race can still cause inconsistent reads — but it catches the common accidental cases.
What this means in practice: you cannot call list.remove() inside a for-each loop. The enhanced for loop uses an iterator under the hood, and mutating the list through the list reference rather than the iterator corrupts the iterator’s internal state. The fix is to call it.remove() on the iterator itself, which updates the expected modCount as it deletes. That is the idiomatic approach for single-threaded pruning.
When you need to remove elements matching a condition, the iterator pattern is the only correct in-place approach. If you prefer a functional style, removeIf() handles all the iterator logic internally and reads cleanly: tasks.removeIf(task -> task.isEmpty());
// Safe: use iterator's remove
for (Iterator<String> it = tasks.iterator(); it.hasNext();) {
String task = it.next();
if (task.startsWith("Review")) {
it.remove(); // updates expected modCount — no ConcurrentModificationException
}
}
// Safe: copy to array first if modifying during for-each
// The copy has its own independent iterator, so remove on original is safe
for (String task : new ArrayList<>(tasks)) {
tasks.remove(task);
}
// Cleanest: removeIf with a predicate
tasks.removeIf(task -> task.isEmpty() || task.startsWith("Draft"));
Trim and Capacity Hints
trimToSize() and ensureCapacity() let you control the internal backing array directly. trimToSize() shrinks the backing array down to match the current element count, discarding unused capacity. This is most useful before serialization or when passing a nearly-finalized list to an external API — an ArrayList with capacity 10,000 but only 500 elements wastes roughly 95% of its array. Calling trimToSize() copies those 500 elements into a smaller array, making the oversized buffer available for garbage collection.
ensureCapacity(int minCapacity) pre-allocates the internal array to a given size without adding elements. This is useful when you know the final size in advance, such as when loading a known dataset or building a list from a pre-counted query. Without it, a large ArrayList grows through a series of intermediate capacities (10, 16, 25, 38, 58…), each resize copying all existing elements. For a list that will hold 500,000 elements, skipping the initial capacity means roughly 20 resize operations before the array stabilizes. One call to ensureCapacity(500_000) avoids all of them.
Neither method changes the logical size of the list. size() still returns the element count; capacity is just an implementation detail. However, avoid calling trimToSize() repeatedly on a growing list — that defeats the purpose.
ArrayList<String> large = new ArrayList<>(1000);
large.addAll(hugeDataset);
large.trimToSize(); // Reduce backing array to exact size — useful before serialization
Observability Checklist
- Monitor
ArrayListresize frequency via profiling — excessive resizing indicates poor initial capacity estimation - Log list size distributions to identify unbounded growth patterns
- Track
ConcurrentModificationExceptionoccurrences in production logs - Use JFR (Java Flight Recorder) to detect excessive GC pressure from frequent resizing
- Review
.contains()calls in hot paths — O(n) scan is often missed
Security Notes
ArrayListis not thread-safe; concurrent modification from multiple threads can cause corruption- When storing sensitive data, be aware that resizing creates a new array — the old array’s contents may linger in memory until GC reclaims it
- Consider using
Collections.unmodifiableList()to prevent accidental mutation of returned lists - Deserialization of untrusted
ArrayListpayloads can trigger DoS via excessive capacity allocation
Common Pitfalls / Anti-Patterns
- Initial capacity:
new ArrayList<>()starts at capacity 10; large lists benefit fromnew ArrayList<>(expectedSize) - Subtracting from size during iteration:
tasks.remove(tasks.size() - 1)before iterating backwards is safe; forward iteration with removal requires an iterator - Confusing
size()andcapacity():size()is element count;capacity()is backing array size (internal) nullelements:ArrayListallowsnullunless created withCollections.checkedListor similar- Autoboxing overhead: Storing primitives in
ArrayList<String>orArrayList<Integer>triggers boxing — considerIntArrayListfrom third-party libraries for performance-critical primitive collections
Quick Recap
ArrayListwraps a resizableObject[]array and implementsList- Random access is O(1); insertion at the end is amortized O(1); insertion in the middle is O(n)
- Growth strategy: 50% capacity increase on resize
- Not thread-safe; use
CopyOnWriteArrayListor external synchronization for concurrent access - Initial capacity matters for large, known-size lists
Interview Questions
Further Reading
- Official ArrayList Documentation — Oracle’s API documentation for ArrayList
- Baeldung: ArrayList vs LinkedList — Performance comparison and when to choose each
- Understanding ArrayList Capacity and Size — Deep dive into how ArrayList manages its internal array
- Effective Java: Item 26 — Favor generic lists over arrays (from Joshua Bloch’s classic)
- Array Basics — fixed-size array fundamentals before moving to resizable lists
- LinkedList — doubly-linked list implementation for frequent insert/delete
- HashMap — hash-based key-value storage, often used alongside ArrayList
Conclusion
ArrayList is the practical choice for most dynamic list needs in Java. It delivers O(1) random access and amortized O(1) appends while exposing the familiar List interface. The tradeoffs are insertion costs in the middle — O(n) due to element shifting — and no built-in thread safety.
The most impactful optimization is setting an initial capacity when you know the expected size. This avoids the multiple resize operations that come with large, unbounded lists. ArrayList is the right default before reaching for more specialized collections.
ArrayList builds directly on Array Basics, extending fixed-size arrays with automatic growth. For scenarios requiring frequent insertions at arbitrary positions, LinkedList is worth considering, though it sacrifices random access speed.
Category
Related Posts
Abstract Classes in Java
Learn about partially implemented classes that define contracts for subclasses using abstract methods and concrete implementations.
Arithmetic Operators in Java
Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.
Array Basics in Java
Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.