1. Introduction to ArrayList
ArrayList<E> is a widely used implementation of Java’s List<E> interface. It stores elements in an ordered sequence, supports index-based access, and permits duplicate values. Unlike a fixed-size array, an ArrayList can grow as elements are added and can shrink logically as elements are removed.
For example, a shopping cart may contain Notebook, Pen, and Eraser. If another Pen is added, the list can preserve that duplicate and its position. Each element can be accessed using an index beginning at 0.
ArrayList is often the default choice when a program needs a general-purpose list because it provides:
- Fast access to elements by index.
- Efficient appending in typical use.
- Convenient methods for insertion, removal, searching, and iteration.
- Automatic management of its internal storage.
Its main trade-off is that inserting or removing an element near the beginning or middle can require shifting many later elements.
2. The Dynamic Array Behind ArrayList
An ArrayList uses an ordinary array internally, commonly called its backing array. The backing array stores references to the list’s elements in consecutive slots. The list manages this array so callers can add items without manually allocating a larger array.
Suppose a list contains three products:
| Backing-array index | Stored value |
|---|---|
| 0 | Notebook |
| 1 | Pen |
| 2 | Eraser |
| 3–9 | Unused capacity |
In this illustration, the backing array has capacity 10, but only three elements belong to the list.
Two properties explain this difference:
- Size: the number of elements currently in the list. It is returned by
size(). - Capacity: the number of element slots currently available in the backing array before another growth operation is needed.
Size is part of the public List API. Capacity is an implementation detail; ArrayList does not provide a public capacity() method.
2.1 How the array structure affects performance
An array can access a slot directly when its index is known. This is why get(index) is constant time, \(O(1)\), for an ArrayList.
When an element is inserted into the middle, the list must make room by moving elements to higher indices. Removing an element from the middle leaves a gap, so later elements must move left. These operations can take linear time, \(O(n)\).
2.2 Visualizing ArrayList storage
The diagram is conceptual. The exact capacity chosen by an implementation can vary by Java version and construction path.
3. Creating ArrayLists
The three commonly used constructors cover an unknown number of elements, a known approximate count, and copying an existing collection.
3.1 No-argument constructor
This is appropriate when the expected number of elements is unknown or small. The list begins empty and allocates storage as needed. In OpenJDK implementations, the no-argument constructor may initially use a shared empty array and allocate storage on the first insertion. The exact internal strategy is an implementation detail, not a promise of the List interface.
3.2 Constructor with an initial capacity
The integer specifies an initial capacity, not the list’s size. The new list is still empty, so size() returns 0. The capacity hint is useful when a program expects to add many elements and wants to reduce intermediate resizing.
The requested capacity must not be negative; a negative value causes IllegalArgumentException.
3.3 Copy constructor
The copy constructor creates a new, mutable ArrayList containing the source collection’s elements in the source’s iteration order. This is a useful way to obtain a mutable list from an immutable list such as one created by List.of(...).
The copy is shallow: the list structure is new, but objects stored inside it are not cloned. If the elements are mutable objects, the source and copy still refer to the same element objects.
4. Core ArrayList Operations
The most common operations are appending, indexed insertion, indexed retrieval, indexed removal, and membership testing.
| Operation | Purpose | Typical time complexity |
|---|---|---|
add(element) | Append an element | Amortized \(O(1)\); occasional \(O(n)\) growth |
add(index, element) | Insert at a position | \(O(n)\) |
get(index) | Read an element by index | \(O(1)\) |
set(index, element) | Replace an element | \(O(1)\) |
remove(index) | Remove by position | \(O(n)\) in general |
remove(object) | Remove the first equal element | \(O(n)\) |
contains(object) | Test whether an equal element exists | \(O(n)\) |
indexOf(object) | Find the first matching index | \(O(n)\) |
lastIndexOf(object) | Find the last matching index | \(O(n)\) |
| Iterate through all elements | Visit the list | \(O(n)\) |
These are typical costs for ArrayList. The cost of equality comparisons can also depend on the element type and its equals() implementation.
4.1 Appending with add(element)
Appending places an element after the current last element. When the backing array has room, the operation writes into the next available slot and increases the size. If the array is full, the implementation must allocate a larger array and copy existing references.
Most appends are inexpensive, which is why the average cost over a long sequence of appends is amortized \(O(1)\). This does not mean every individual append is constant time.
4.2 Inserting with add(index, element)
Indexed insertion places the new element at the specified position. Existing elements from that position onward shift one place to the right. Inserting at index 0 in a long list may therefore move nearly every element.
A valid insertion index ranges from 0 through size(), inclusive. The index size() appends to the end; an index outside that range causes IndexOutOfBoundsException.
4.3 Reading with get(index) and replacing with set(index, element)
get(index) returns the element at a valid position. set(index, element) replaces the element already at that position and returns the old value. set does not change the list’s size because it replaces rather than inserts.
For both methods, a valid existing-element index ranges from 0 through size() - 1. An empty list has no valid index for get or set.
4.4 Removing by index or value
remove(index) removes the element at a position and returns it. The elements after that position shift left.
remove(object) searches for the first element equal to the supplied object and removes that occurrence. If no matching element exists, the list remains unchanged.
The distinction between these overloads is particularly important for numeric wrapper types.
5. The remove(int) Versus remove(Object) Trap
A list of integers has two remove overloads with different meanings:
remove(int index)removes the element at the specified position.remove(Object value)removes the first element equal to the supplied value.
The compiler chooses the overload based on the argument’s type and the available method signatures. In the first call, the integer literal selects the index-based overload. Integer.valueOf(30) supplies an Integer object, selecting the value-based overload.
This is not unique to ArrayList; the same overload distinction applies to List<Integer> generally. Be explicit whenever the intent is to remove a numeric value rather than an index.
6. Size Versus Capacity
Size and capacity describe different things:
| Property | Size | Capacity |
|---|---|---|
| Meaning | Number of elements currently stored | Number of slots in the backing array |
| Publicly accessible? | Yes, through size() | No public capacity() method |
| Changes when appending into available room? | Increases | Usually unchanged |
| Changes when the backing array fills? | Increases for the added element | Grows to make room |
Determines valid get indices? | Yes | No |
If the size is 3 and capacity is 10, only indices 0, 1, and 2 are valid for reading list elements. Unused capacity does not create additional list elements and cannot be accessed through get(3).
7. How ArrayList Resizes
When an insertion requires more space than the backing array provides, ArrayList grows its internal storage. Conceptually, the process is:
- Allocate a larger backing array.
- Copy existing element references into the new array.
- Use the new array and store the incoming element.
The exact growth policy is an implementation detail. OpenJDK has commonly grown capacity by roughly one half of the old capacity, but code should not depend on a specific growth factor.
A resize can make one particular append \(O(n)\), because existing references must be copied. However, the backing array grows geometrically rather than by a fixed one-slot increment. The expensive copies are spread over many insertions, giving appends an amortized \(O(1)\) cost.
7.1 ensureCapacity(int)
ensureCapacity(minCapacity) asks the ArrayList to ensure that its backing storage can accommodate at least the requested capacity. It can be useful before a large batch of additions. It is a method of the concrete ArrayList class, not part of the List interface.
7.2 trimToSize()
trimToSize() asks the list to reduce its backing-array capacity to match its current size. It does not remove list elements or change size(). Trimming can reduce unused storage, but it may cause another allocation and copy if the list grows again later.
Use these methods only when there is a practical capacity or memory reason; ordinary application code often does not need to manage capacity directly.
8. Understanding ArrayList Time Complexity
The complexity of an operation follows from whether the backing array can access an index directly or must move/search through elements.
- Constant time, \(O(1)\): indexed access and replacement, because the slot is directly addressable.
- Linear time, \(O(n)\): searching with
contains, inserting or removing near the beginning, and operations that must shift many elements. - Amortized constant time, \(O(1)\): appending over a sequence of additions, even though an occasional resize is linear.
8.1 Why repeated front insertion can be expensive
If a program repeatedly inserts at index 0, each insertion shifts the elements already present. As the list grows, the amount of shifting per operation increases. Across many front insertions, total work can become quadratic.
If a workload frequently adds and removes at the ends, consider whether an ArrayDeque is a better fit. If indexed access is important, ArrayList is usually more suitable. A LinkedList has different trade-offs, but it does not make arbitrary indexed access fast.
9. Iterating Over an ArrayList
Three common iteration styles are an indexed for loop, an enhanced for-each loop, and an iterator-based loop.
9.1 Indexed loop
Use this form when the index is needed—for example, when displaying positions or comparing neighboring elements. It works efficiently with ArrayList because get(i) is \(O(1)\).
9.2 Enhanced for-each loop
Use this when the element values are needed but the index is not. It is concise and works through an iterator internally.
Do not structurally modify the same list directly inside a for-each loop. Doing so can trigger ConcurrentModificationException.
9.3 Iterator-based traversal
An Iterator supports sequential traversal and provides its own remove() operation. When elements need to be removed while iterating, use the iterator’s removal method or use removeIf, rather than calling products.remove(...) directly from a for-each loop.
10. ConcurrentModificationException and Fail-Fast Iterators
One of the most common beginner mistakes is removing an element directly from an ArrayList while an enhanced for-each loop is traversing it.
An ArrayList iterator is fail-fast. It tracks structural modifications—changes such as adding or removing elements—and checks for unexpected changes during traversal. If the list is structurally modified outside the iterator while that iterator is active, the iterator may throw ConcurrentModificationException.
Despite its name, this exception does not necessarily indicate multiple threads. A single thread can cause it by changing the list directly during iteration.
Fail-fast behavior is a bug-detection mechanism, not a synchronization guarantee. Programs should not rely on the exception to make concurrent access safe.
10.1 Safer option: Iterator.remove()
When using an iterator, remove through that iterator:
The iterator can keep its traversal state consistent when its own remove() method performs the removal.
10.2 Safer option: removeIf
For a condition-based removal, removeIf is often the clearest approach:
It expresses the intent directly: remove every element matching the predicate. This avoids manually changing the list from inside an enhanced for-each loop.
10.3 Indexed removal loop
An indexed loop can also be written to account for shifting, but it requires care. After removing at index i, the next element shifts into that same index. If the loop immediately increments i, it can skip the shifted element. Reverse traversal or explicit index adjustment can avoid that issue, but removeIf is usually easier to read for simple filtering.
10.4 Iteration decision flow
11. ArrayList Compared with Other Collection Types
ArrayList is a list implementation, not the best data structure for every task.
| Requirement | Often suitable | Reason |
|---|---|---|
| Preserve order and allow duplicates | ArrayList | Maintains indexed sequence semantics |
| Frequent indexed reads | ArrayList | Direct access by index |
| Frequent membership checks with unique elements | HashSet | Designed for membership lookup |
| Lookup a value by a key | HashMap | Associates keys with values |
| Frequent queue/deque operations at the ends | ArrayDeque | Designed for end operations |
| Need synchronized legacy list behavior | Vector may appear in older code | Legacy synchronized implementation; modern designs often use other approaches |
Choose based on the operations the program performs most often, rather than selecting a collection based only on its name.
12. Common Mistakes to Avoid
- Confusing size with capacity. A capacity of
10does not mean the list contains ten elements. - Assuming every append is \(O(1)\) in the worst case. A resize can make an individual append \(O(n)\); the amortized cost is \(O(1)\).
- Using repeated front insertion on large lists. Each insertion may shift many elements.
- Mixing up the
removeoverloads.remove(1)onList<Integer>removes index1, not necessarily the value1. - Modifying a list directly during for-each traversal. Use
Iterator.remove()orremoveIf. - Assuming a copy constructor deep-copies objects. It copies element references into a new list structure.
- Depending on OpenJDK’s exact capacity growth factor. The implementation strategy can change.
- Expecting
setto add an element.setreplaces an existing element and requires a valid current index.
13. Best Practices
- Prefer the interface type for variables and parameters when implementation-specific methods are not needed:
List<String> products = new ArrayList<>();. - Use
ArrayListas a sensible default for ordered collections with frequent indexed access and iteration. - Provide an initial capacity when the expected element count is known and large enough to make it worthwhile.
- Use
removeIffor straightforward condition-based filtering. - Use
Iterator.remove()when removal is part of explicit iterator traversal. - Use
HashSetorHashMapwhen membership or key-based lookup is the main requirement. - Treat capacity management methods as targeted optimizations rather than routine necessities.
- Keep the overload distinction between
remove(int)andremove(Object)clear in code involving numeric values.
14. Quick Revision
ArrayList<E>is a resizable-array implementation ofList<E>.- It preserves sequence order, supports duplicates, and uses zero-based indices.
size()counts actual elements; capacity describes internal storage.get(index)andset(index, element)are typically \(O(1)\).- Indexed insertion and removal can be \(O(n)\) because elements shift.
add(element)is amortized \(O(1)\), with occasional linear-time resizing.containsand index-search methods are \(O(n)\) in general.remove(int)removes by index;remove(Object)removes by value.ensureCapacityandtrimToSizebelong toArrayList, not theListinterface.- Avoid direct structural modification during for-each traversal; use
Iterator.remove()orremoveIf.
15. Conclusion
ArrayList is a practical default for most Java list requirements because it combines ordered storage, duplicate support, fast indexed access, and convenient collection operations. Its behavior becomes predictable once the backing array is understood: direct slot access is fast, shifting costs time, and occasional resizing supports continued growth.
The most important habits are to distinguish size from capacity, understand amortized append performance, select the correct remove overload, and use safe techniques when modifying a list during traversal. These concepts help developers choose ArrayList confidently and recognize when another collection type better matches the workload.