ArrayList in Java
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
List<String> tasks = new ArrayList<>();
tasks.add("Review PR"); // Append
tasks.add(0, "Standup"); // Insert at head — O(n)
tasks.set(1, "Code Review"); // Replace at index
System.out.println(tasks.get(2)); // Get by index
System.out.println(tasks.size()); // 3
System.out.println(tasks.contains("Standup")); // true
Iterating Safely
// Safe: use iterator's remove
for (Iterator<String> it = tasks.iterator(); it.hasNext();) {
String task = it.next();
if (task.startsWith("Review")) {
it.remove();
}
}
// Safe: copy to array first if modifying during for-each
for (String task : new ArrayList<>(tasks)) {
tasks.remove(task);
}
Trim and Capacity Hints
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
Model Answer: "A plain array has a fixed size determined at creation. ArrayList is a resizable wrapper around an array — it creates a new, larger internal array when elements no longer fit, copying all elements over. ArrayList also provides the List interface with add/remove/contains operations and uses generics for type safety.
Model Answer: "Amortized O(1) — most adds are constant time, but occasional O(n) resizing occurs when the backing array is full. The resize is proportional to the current size, so adding n elements costs O(n) total, or O(1) per element amortized.
Model Answer: "ArrayList starts with a 10-element backing array and grows by 50% each resize. For large lists, this means multiple expensive resizing operations each copying n elements. Providing new ArrayList(expectedSize) pre-allocates the correct size, avoiding intermediate resizes and copies.
Model Answer: "It is thrown when a collection is modified while being iterated with a fail-fast iterator. ArrayList's iterator checks the modCount (mutation count) before each next() call and throws if it has changed since the iterator was created. This is a detection mechanism, not a guarantee.
Model Answer: "Choose LinkedList when you need frequent insertions and deletions at arbitrary positions (especially near the head), because LinkedList only requires O(1) pointer updates rather than O(n) element shifting. For random access reads/writes, ArrayList is faster due to cache locality.
Model Answer: "When capacity is exceeded, ArrayList allocates a new array at 50% larger size using the formula (oldCapacity * 3) / 2 + 1. It then copies all elements from the old array to the new one via System.arraycopy(). This resize is O(n), which is why amortized add is O(1) — most adds avoid the resize cost entirely.
Model Answer: "size() returns the number of elements currently stored (always less than or equal to capacity). capacity() returns the length of the internal backing array. You cannot set capacity directly on a standard ArrayList — only via the constructor or ensureCapacity(). trimToSize() reduces capacity to match size.
Model Answer: "Yes, by default ArrayList permits null elements, and you can store multiple nulls. However, some projects use Collections.checkedList() or List.of() to reject nulls at runtime. Always check your list implementation's null policy — relying on nulls can cause subtle NullPointerException bugs when passing lists to APIs that do not expect them.
Model Answer: "Use CopyOnWriteArrayList for frequent reads and infrequent writes — it creates a new copy on every mutation, making iteration safe without locking. For simple thread safety, wrap with Collections.synchronizedList(new ArrayList<>()) which synchronizes every operation. Note neither approach is lock-free; for high-concurrency scenarios consider ConcurrentHashMap as a list alternative or external libraries.
Model Answer: "ensureCapacity(int minCapacity) pre-allocates the internal array to at least the specified size, avoiding incremental resizes when you know the expected size upfront. Call it before adding a large batch of elements: list.ensureCapacity(10000); list.addAll(hugeDataset); This is more efficient than relying on repeated add() calls to trigger auto-growth.
Model Answer: "No — ArrayList
Model Answer: "Use an iterator with remove() for safe in-place removal: Iterator
Model Answer: "toArray() returns Object[] — a new array of raw objects, requiring a cast. toArray(T[] a) is the generified form: if the list fits, it copies elements into the provided array (avoiding allocation); if it does not fit, a new array of the same runtime type is allocated and returned. Always prefer toArray(new String[0]) or toArray(new String[size]) over the parameterless version.
Model Answer: "Both store elements in a contiguous Object[] backing store with O(1) random access by index. ArrayList adds: automatic resizing (no fixed capacity), generics for type safety, helper methods (contains, indexOf, remove), and List interface implementation for API interoperability. Plain arrays do not resize, do not support generics at runtime, and have no remove/contains methods built in.
Model Answer: "The default capacity is 10. When you call new ArrayList<>() or new ArrayList
Model Answer: "Several options exist with different semantics: List.of(...) creates an immutable list — any mutation throws UnsupportedOperationException; Collections.unmodifiableList(list) wraps an existing list and blocks modifications via the wrapper; Collections.checkedList(list, type) wraps with runtime type checking on writes. For defensive copies returned from methods, List.of() is the most common choice.
Model Answer: "ArrayList can simulate stack operations using add() as push and remove(size()-1) as pop, but this is not recommended — remove() on the last element is O(n) due to array shifting. For stack semantics, use ArrayDeque which provides O(1) push(), pop(), and peek() backed by a resizable array. LinkedList also works but has worse cache locality.
Model Answer: "Both are O(n) — a linear scan through the array. contains() calls indexOf() internally. For frequent membership tests on large lists, consider using a HashSet instead, which provides O(1) lookup, at the cost of no ordering and additional memory overhead. You can also maintain a HashSet alongside your ArrayList for hybrid access patterns.
Model Answer: "clear() sets every element slot in the backing array to null and sets size = 0. It does not resize the backing array down — capacity remains unchanged. To also release memory, call trimToSize() after clear(). Note that for lists containing large objects, setting elements to null allows the garbage collector to reclaim them if no other references exist.
Model Answer: "ArrayList uses a fail-fast iterator that detects concurrent modification by checking a modCount field each time next() is called. If the count changed since iterator creation, it throws ConcurrentModificationException. This is a best-effort detector, not a guarantee. By contrast, collections like ConcurrentHashMap and CopyOnWriteArrayList use weakly-consistent iterators that tolerate concurrent modification — they may or may not reflect changes but never throw CME, making them suitable for concurrent access patterns.
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)
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.