Java ArrayList: Usage, Performance, and Tradeoffs
java arraylist: Understand how Java ArrayList works internally, its performance tradeoffs, thread-safety limitations, and when to choose it over arrays or LinkedList.
Java ArrayList is the default choice for most developers when they need a resizable sequence of objects. It combines the familiar indexing behavior of an array with automatic growth, but that convenience comes with specific performance characteristics and limitations that matter in production code.
How ArrayList Stores Data Internally
ArrayList is backed by a plain Object array. When you create an ArrayList with new ArrayList<>(), it starts with an empty internal array. The first time you add an element, it allocates an array with a default capacity of ten elements. As elements are added beyond the current capacity, the list grows by creating a new array roughly 1.5 times the previous size and copying all existing elements into it.
This growth strategy means that adding elements is amortized O(1) — most additions write directly into the backing array, and only occasional additions trigger a resize and copy. The copy operation is O(n), but because it happens infrequently, the average cost per addition stays constant.
Creating and Populating an ArrayList
The simplest way to create and populate an ArrayList is:
List<String> names = new ArrayList<>(); names.add("Ada"); names.add("Grace"); names.add("Margaret");
You can also initialize from an existing collection:
List<String> names = new ArrayList<>(existingSet);
And when you know the approximate size in advance, pass it to the constructor:
List<String> names = new ArrayList<>(1000);
The capacity constructor avoids repeated resizing when you expect to add many elements. If you know you will store roughly 1,000 entries, allocating capacity for 1,000 up front prevents the intermediate growth steps.
Common Operations and Their Costs
ArrayList provides indexed access, insertion, removal, and iteration. Each operation has a distinct cost profile:
| Operation | Time Complexity | Notes |
|---|---|---|
get(index) | O(1) | Direct array access |
add(element) | Amortized O(1) | May trigger resize |
add(index, element) | O(n) | Shifts subsequent elements |
remove(index) | O(n) | Shifts subsequent elements |
remove(element) | O(n) | Linear search then shift |
contains(element) | O(n) | Linear search |
indexOf(element) | O(n) | Linear search |
The O(n) operations matter when you work with large lists. Removing elements from the front of a large ArrayList repeatedly produces quadratic behavior. If your code frequently removes from the front, a LinkedList or an ArrayDeque may be a better fit.
Iteration and Modification During Iteration
The standard way to iterate an ArrayList is the enhanced for loop:
for (String name : names) { System.out.println(name); }
This compiles to an iterator-based loop. If you modify the list while iterating — for example, calling remove inside the loop — the iterator throws ConcurrentModificationException. The iterator tracks a modification count and fails fast when it detects concurrent changes.
To remove elements during iteration, use the iterator's own remove method:
Iterator<String> it = names.iterator(); while (it.hasNext()) { if (it.next().length() < 3) { it.remove(); } }
Or use removeIf on Java 8 and later:
names.removeIf(name -> name.length() < 3);
Both approaches avoid the exception because they go through the list's internal modification tracking correctly.
ArrayList vs Array vs LinkedList
The choice between array, ArrayList, and LinkedList depends on the access pattern:
| Criterion | Array | ArrayList | LinkedList |
|---|---|---|---|
| Fixed size | Yes | No | No |
| Random access | O(1) | O(1) | O(n) |
| Insert at end | N/A | Amortized O(1) | O(1) |
| Insert at middle | N/A | O(n) | O(n) with traversal |
| Memory overhead | Minimal | Moderate | Higher per node |
| Primitive support | Yes | No (boxing) | No (boxing) |
Use a plain array when the size is fixed and you need primitive types without boxing overhead. Use ArrayList when you need dynamic growth and random access is the dominant pattern. LinkedList rarely wins in practice — its node-based structure adds memory overhead and cache-unfriendly traversal, while offering no real advantage for most workloads.
Thread Safety and Concurrency
ArrayList is not thread-safe. Concurrent reads from multiple threads are safe as long as no thread modifies the list. Any structural modification — adding or removing elements — while another thread reads can produce inconsistent state or ConcurrentModificationException.
For concurrent access, options include:
Collections.synchronizedList(new ArrayList<>())— synchronizes every method, but compound operations like check-then-act still need external synchronization.CopyOnWriteArrayList— safe for read-heavy workloads where writes are rare, because each write copies the entire backing array.ConcurrentLinkedQueueorArrayDeque— when the workload is queue-like rather than indexed.
The right choice depends on the read-write ratio. A synchronized wrapper is simple but serializes all access. CopyOnWriteArrayList is better when reads vastly outnumber writes.
Capacity Management and Memory
ArrayList's internal array is often larger than the number of elements it holds. After many removals, the backing array may be significantly larger than needed. Calling trimToSize() shrinks the backing array to the current element count, which can reduce memory usage for long-lived lists that have shrunk.
Conversely, repeatedly adding elements without capacity hints causes repeated resizing. Each resize allocates a new array and copies all elements, which is both CPU and memory intensive for large lists. The ensureCapacity(int) method lets you pre-allocate when you know the expected size.
For primitive-heavy workloads, the boxing overhead of storing Integer, Double, or Long objects can be significant. An int[] array uses 4 bytes per element, while an ArrayList<Integer> uses 16 bytes or more per element depending on JVM settings and object layout. If you store millions of primitives, consider a primitive collection library or a plain array.
Common Mistakes and Edge Cases
One frequent mistake is using remove(int) when intending to remove a value. remove(3) removes the element at index 3, not the element whose value is 3. To remove by value, use remove(Integer.valueOf(3)) — but note this only removes the first occurrence.
Another edge case: subList returns a view backed by the original list. Modifying the sublist modifies the original list, and modifying the original list structurally after creating a sublist makes the sublist's behavior undefined.
List<String> sub = names.subList(0, 2); names.add("New"); // structural change sub.size(); // undefined behavior
The subList view is useful for range operations, but it is not a snapshot. Treat it as a live window into the original list.
When ArrayList Is the Wrong Choice
ArrayList is not the right structure for every sequence problem. If your workload is dominated by insertion and removal at both ends, ArrayDeque provides O(1) operations at both ends with better cache locality than LinkedList. If you need a sorted structure with O(log n) lookup, TreeSet or a sorted list with binary search may be more appropriate. If you frequently query by key rather than by index, a HashMap is the correct structure.
The decision should be driven by the dominant access pattern, not by habit. ArrayList is an excellent default for indexed sequences with occasional modification, but recognizing when the access pattern differs saves you from quadratic behavior and avoidable memory overhead.