Of all the classes in the Collections Framework, ArrayList is the one you will write most. In any real Java project, between 70% and 90% of the collections are ArrayList, and rightly so: it combines the instant access by position of an array with the automatic growth of a collection, and its traversal is the fastest of all the List implementations. It is the default answer when you need "a list of things", and you should only change it when you have a concrete, measured reason.
Precisely because you will use it so much, it is worth understanding it from the inside. In this lesson you will see that an ArrayList is literally the ArrayCatalog you wrote in 05-01 —an internal array plus a counter—, but written by the JDK's engineers and tuned for twenty-five years. Understanding that internal array explains almost everything at a stroke: why get(i) is instant, why remove(0) is expensive, why there is a constructor that asks for the capacity, why add is "amortised" O(1) even though it sometimes copies a million elements, and why subList can surprise you. By the end, BiblioTech's catalogue will have stopped being an array for good.
Contents
- What an
ArrayListis inside - Capacity versus size
- Resizing and amortised cost
- The constructor with an initial capacity
- Creating and filling a list
- The complete API, method by method
remove(int)versusremove(Object): the classic trapsubListis a view- Complexity table for each operation
- Safe traversal and removal
ArrayListversus array- Array ↔ list conversion
- Lists of lists
equalsandhashCodeof a list- Refactoring BiblioTech
- Common Mistakes and Tips
- Exercises
- What an
ArrayList is inside
ArrayList is insideIf you open the source of java.util.ArrayList in the JDK, the first thing you find is this (simplified):
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess {
private static final int DEFAULT_CAPACITY = 10;
transient Object[] elementData; // the internal array where the elements live
private int size; // how many elements there REALLY are
}Two fields. An array and an integer. It is exactly the ArrayCatalog from 05-01: a Material[] materials and an int n. The difference is not in the idea, but in the fact that here all the capacity management, growth and shifting is written, tested and optimised by other people.
flowchart TB
subgraph AL["ArrayList object"]
S["size = 4"]
E["elementData →"]
end
subgraph ARR["Object[] elementData (capacity 10)"]
direction LR
C0["[0]"]
C1["[1]"]
C2["[2]"]
C3["[3]"]
C4["[4] null"]
C5["[5] null"]
C6["[6] null"]
C7["[7] null"]
C8["[8] null"]
C9["[9] null"]
end
E --> ARR
C0 --> L1["Book<br/>Effective Java"]
C1 --> L2["Book<br/>Design Patterns"]
C2 --> L3["Book<br/>Refactoring"]
C3 --> L4["Magazine<br/>Java Magazine"]
Everything else follows from that structure:
get(i)is O(1): it is a directelementData[i]access, just as in an array.add(e)at the end is O(1): write intoelementData[size]and incrementsize... except when the array fills up.add(0, e)andremove(0)are O(n): all the following elements have to be shifted.contains(e)andindexOf(e)are O(n): you have to walk comparing withequals.- Traversing is extremely fast: contiguous memory, the best cache locality possible (05-01).
Notice also implements RandomAccess. It is a marker interface: it declares no methods, it only says "this list allows access by index in constant time". Some JDK algorithms, such as Collections.binarySearch, consult that mark to choose a strategy. LinkedList does not carry it.
- Capacity versus size
This distinction is the origin of half the misunderstandings about ArrayList, and you already know it from ArrayCatalog:
| Concept | What it is | How you consult it |
|---|---|---|
Size (size) |
How many elements there really are | list.size() |
| Capacity | How many fit before it has to grow | It cannot be consulted from the public API |
In the diagram above, the size is 4 and the capacity is 10: there are six free cells, invisible from outside. And that is the big improvement over arrays: capacity is an internal detail that does not concern you. With Material[] catalog = new Material[10], catalog.length was 10 even if you had only filled 4, and keeping count was your job. With ArrayList, size() always tells the truth.
List<String> names = new ArrayList<>();
System.out.println(names.size()); // 0 (internal capacity: 10 as soon as you add the first)
names.add("Marta Ruiz");
names.add("Diego Alonso");
System.out.println(names.size()); // 2An efficiency detail introduced by Java 7: a new ArrayList<>() reserves nothing when constructed; it points at a shared empty array and only reserves the 10 positions when you add the first element. That way, creating thousands of lists that may never be used costs no memory.
There is a method for returning the spare capacity to the system:
ArrayList<Material> list = new ArrayList<>(1000);
// ... only 30 get filled ...
list.trimToSize(); // reduces the internal capacity to 30Careful: trimToSize() only exists on ArrayList, not on the List interface, so to call it you would have to declare the variable as an ArrayList. It is one of the very few legitimate exceptions to the golden rule of 05-02, and it only makes sense on enormous lists that are not going to grow any further.
- Resizing and amortised cost
What happens when you add element number 11 to a list of capacity 10? Exactly what your ArrayCatalog did:
// inside ArrayList, simplified:
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // capacity * 1.5
return elementData = Arrays.copyOf(elementData, newCapacity);
}oldCapacity >> 1 is a bit shift to the right, that is, a division by two. So the new capacity is 1.5 times the previous one. And Arrays.copyOf creates a new array and copies every element: an O(n) operation.
flowchart LR
A["Array of capacity 10<br/>size = 10, FULL"] --> B["add(element 11)"]
B --> C["new Object[15]"]
C --> D["copy the 10 elements: O(n)"]
D --> E["elementData points at the new array"]
E --> F["the old array is left to the collector"]
F --> G["write element 11<br/>size = 11"]
The sequence of capacities starting from an empty list is:
| When adding element no. | Capacity before | Capacity after | Was there a copy? | Elements copied |
|---|---|---|---|---|
| 1 | 0 | 10 | Initial reservation | 0 |
| 11 | 10 | 15 | Yes | 10 |
| 16 | 15 | 22 | Yes | 15 |
| 23 | 22 | 33 | Yes | 22 |
| 34 | 33 | 49 | Yes | 33 |
| 50 | 49 | 73 | Yes | 49 |
| 74 | 73 | 109 | Yes | 73 |
Why add is still O(1)
If some calls to add cost O(n), how can the table in 05-02 say that add is O(1)? Because it is amortised O(1), and the idea is worth understanding because it turns up in many data structures.
Amortised means average cost per operation over a long sequence, not the cost of one isolated operation. Do the sums: to reach 1000 elements, the total number of elements copied across all the resizes is roughly 2000 (the sum of a geometric series with ratio 1.5 converges to about twice the final size). That is, about 2 copies per element added, on average, no matter whether you add a thousand or a million. Two operations per element is a constant, and a constant is O(1).
The key is that the array grows multiplicatively (×1.5), not additively. If it grew by 10 each time, reaching 1000 elements would take 100 resizes copying 10+20+30+...+990 ≈ 50,000 elements: 50 per element added, and rising. That really would be a problem, and it would be amortised O(n).
The useful analogy: moving house is extremely expensive, but if you only move when you double your belongings, the average cost per item you accumulate is constant.
- The constructor with an initial capacity
There are three constructors, and knowing when to use the second is a professional touch:
List<Material> a = new ArrayList<>(); // default capacity (10 once filled)
List<Material> b = new ArrayList<>(500); // initial capacity 500
List<Material> c = new ArrayList<>(otherCollection); // copy at exactly the right sizeWhen does the initial capacity matter? When you know in advance, even approximately, how many elements you are going to put in and there are a lot of them.
// Loading the whole catalogue: we know it is about 5000 materials
List<Material> catalog = new ArrayList<>(5000);
for (String line : fileLines) {
catalog.add(convert(line));
}Without the initial capacity, reaching 5000 elements would have caused about 20 resizes and about 10,000 reference copies. With it, none. For 5000 elements the difference is milliseconds; for several million, it is noticeable.
When does it NOT matter? Almost always. For lists of tens or hundreds of elements, new ArrayList<>() is perfect and adding a magic number only clutters the code. Do not fall into micro-optimisation: use the capacity constructor when the size is large and predictable, and the empty one the rest of the time.
The third constructor is the one you will use for defensive copies (03-07) and for converting between collections:
List<Material> independentCopy = new ArrayList<>(catalog); // modifiable shallow copy
List<String> fromASet = new ArrayList<>(isbnSet);
List<String> fromAFixedList = new ArrayList<>(List.of("a", "b", "c")); // now it IS modifiable
- Creating and filling a list
import java.util.ArrayList;
import java.util.List;
List<Material> catalog = new ArrayList<>();
catalog.add(new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018));
catalog.add(new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994));
catalog.add(new Book("Refactoring", "Martin Fowler", "978-0000000003", 1999));
catalog.add(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"));
catalog.add(new Dvd("Refactoring Live", "DVD-0007", 95));And the shorthand forms when you already have the elements:
// MODIFIABLE list from loose elements
List<String> employees = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal"));
// IMMUTABLE list (careful: add will throw UnsupportedOperationException)
List<String> fixed = List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal");
// Adding several at once to an existing list
catalog.addAll(otherMaterials);
Collections.addAll(catalog, material1, material2, material3);The idiom new ArrayList<>(List.of(...)) is the most practical way to initialise a modifiable list with content: it says what it does and it fits on one line.
- The complete API, method by method
Adding
List<String> l = new ArrayList<>(List.of("A", "B", "C"));
l.add("D"); // [A, B, C, D] at the end, amortised O(1)
l.add(1, "X"); // [A, X, B, C, D] at position 1, O(n): shifts the rest
l.addAll(List.of("E","F")); // [A, X, B, C, D, E, F]
l.addAll(0, List.of("Z")); // [Z, A, X, B, C, D, E, F]add(int, E) accepts indices from 0 to size() (both included: size() means "at the end"). Outside that range, IndexOutOfBoundsException — how to handle it is module 6.
Reading and writing
String first = l.get(0); // O(1)
String last = l.get(l.size()-1); // the idiom for the last element
String previous = l.set(1, "Y"); // REPLACES and returns what was there. O(1)Confusing add(1, "Y") with set(1, "Y") is a frequent mistake: the first inserts (the list grows), the second replaces (the list keeps its size).
Removing
l.remove(0); // by INDEX, returns the removed element
l.remove("B"); // by OBJECT, returns a boolean. Uses equals
l.removeIf(s -> s.isEmpty()); // by CRITERION, returns a boolean
l.clear(); // empties the listThe first three are O(n) in the general case: removing at position i forces the size - i - 1 following elements to be shifted with an internal System.arraycopy, exactly as your ArrayCatalog did. Only remove(size()-1) is O(1).
Searching
boolean there = l.contains("C"); // O(n), uses equals
int pos = l.indexOf("C"); // first occurrence, or -1 if absent. O(n)
int lastPos = l.lastIndexOf("C"); // last occurrencecontains and indexOf depend entirely on equals. This connects directly with 03-09: if your class does not override it, it inherits Object's, which compares references, and an "equal but distinct" object will never be found:
class MaterialWithoutEquals { /* does not override equals */ }
List<Card> cards = new ArrayList<>();
cards.add(new Card("Effective Java", "Joshua Bloch", 2018));
// Card is a record: generated equals, compares components
System.out.println(cards.contains(new Card("Effective Java", "Joshua Bloch", 2018))); // true
// If Card were a normal class without equals:
// System.out.println(...) -> false, even though the data is identicalPractical rule: if a class is going to live in a collection and be searched for, it needs equals (and hashCode). In 05-05 you will see why both, always together.
Transforming and sorting
l.replaceAll(String::toUpperCase); // applies a UnaryOperator to each element
l.sort(Comparator.naturalOrder()); // sorts in place
l.sort(Comparator.comparing(Material::getTitle));
l.forEach(System.out::println); // traverses applying a ConsumerAll four arrived in Java 8 and use the functional interfaces of 04-06. replaceAll is especially useful and little known: it modifies each element in place, without creating another list.
List<Material> catalog = ...;
catalog.sort(Comparator.comparing(Material::getType)
.thenComparing(Material::getTitle));Prefer list.sort(comparator) over Collections.sort(list, comparator): it is the interface's own method and needs no utility class.
Querying
remove(int) versus remove(Object): the classic trap
remove(int) versus remove(Object): the classic trapList has two overloaded remove methods:
With most types there is no ambiguity, but with List<Integer> there is, and it produces the Framework's most famous bug:
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30, 40));
numbers.remove(1); // what does it remove?
System.out.println(numbers); // [10, 30, 40] <- it removed POSITION 1, not the value 1Overload resolution (03-03) always prefers the exact match with no conversions: 1 is an int, so it calls remove(int index) without autoboxing. If your intention was to remove the value 20, the result is right by accident; if you wanted to remove the value 1 from a list that starts at 10, you have silently deleted the wrong element.
Worse still:
List<Integer> ids = new ArrayList<>(List.of(100, 200, 300));
ids.remove(100); // IndexOutOfBoundsException: Index 100 out of bounds for length 3You were after the value 100 and you asked for position 100.
The solutions
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30, 40));
numbers.remove(Integer.valueOf(20)); // 1. explicit: by VALUE
numbers.remove((Integer) 30); // 2. cast: by VALUE
numbers.remove(1); // 3. by POSITION (it is clear that is what you want)Integer.valueOf(20) is the form preferred for readability. And a very useful general rule: be suspicious of overloads when you use collections of Integer. The same problem does not occur with List<String> or List<Material>, because remove("B") cannot be confused with an index.
Call on a List<Integer> |
Resolves as | Effect |
|---|---|---|
list.remove(2) |
remove(int) |
Removes the element at position 2 |
list.remove(Integer.valueOf(2)) |
remove(Object) |
Removes the value 2 |
list.remove((Integer) 2) |
remove(Object) |
Removes the value 2 |
list.indexOf(2) |
Only one version exists | Looks for the value 2 (autoboxing) |
list.contains(2) |
Only one version exists | Looks for the value 2 (autoboxing) |
Notice the last two rows: contains and indexOf only accept Object, so there autoboxing does happen and everything works as you expect. The ambiguity belongs exclusively to remove.
subList is a view
subList is a viewsubList(from, to) returns the portion of the list between those indices (from included, to excluded). But it is not a copy: it is a view over the original list, just as Arrays.asList was a view over the array.
List<String> l = new ArrayList<>(List.of("A", "B", "C", "D", "E"));
List<String> middle = l.subList(1, 4); // [B, C, D]
middle.set(0, "X");
System.out.println(l); // [A, X, C, D, E] <- the ORIGINAL has changed
middle.clear();
System.out.println(l); // [A, E] <- they were deleted from the ORIGINALThat behaviour is intentional and very powerful —clear() on a sublist is the standard idiom for deleting a range— but you have to know about it, because the surprises come in two kinds:
1. Changes propagate in both directions. Modifying the view modifies the original and vice versa.
2. Modifying the original structurally invalidates the view. If you add or remove elements on the original list directly, any later use of the sublist throws ConcurrentModificationException, by the same modCount mechanism you saw in 05-02:
List<String> l = new ArrayList<>(List.of("A", "B", "C", "D", "E"));
List<String> middle = l.subList(1, 4);
l.add("F"); // structural modification of the original
System.out.println(middle); // ConcurrentModificationExceptionIf what you want is an independent copy, wrap it:
Legitimate uses of subList as it is:
// Paging results without copying the whole list
int from = page * perPage;
int to = Math.min(from + perPage, results.size());
List<Material> currentPage = results.subList(from, to);
// Deleting a whole range
catalog.subList(0, 10).clear(); // removes the first 10
// Sorting only a stretch
catalog.subList(0, 5).sort(Comparator.comparing(Material::getTitle));
- Complexity table for each operation
This table is what will let you decide with judgement in 05-04:
| Operation | Complexity | Why |
|---|---|---|
get(i) |
O(1) | Direct elementData[i] access |
set(i, e) |
O(1) | Direct write |
add(e) (at the end) |
amortised O(1) | Write and add 1; occasionally copy everything |
add(0, e) (at the start) |
O(n) | Shift every element to the right |
add(i, e) (in the middle) |
O(n) | Shift the n - i following ones |
remove(size()-1) (last) |
O(1) | Set to null and subtract 1 |
remove(0) (first) |
O(n) | Shift all the following ones to the left |
remove(i) |
O(n) | Shift the n - i - 1 following ones |
remove(Object) |
O(n) | Search (O(n)) + shift (O(n)) |
contains(e) / indexOf(e) |
O(n) | Walk comparing with equals |
size() / isEmpty() |
O(1) | It is a field, nothing is counted |
clear() |
O(n) | Sets every cell to null so as not to leak memory |
Traversing with for-each |
O(n), very fast | Contiguous memory: optimal cache locality |
sort(cmp) |
O(n log n) | TimSort (05-09) |
contains after sort + binarySearch |
O(log n) | Only if you keep it sorted (05-09) |
The message to take away: ArrayList is excellent for indices and for the end of the list, and mediocre for the start and the middle. If your program does list.add(0, x) or list.remove(0) inside a loop over thousands of elements, you are doing O(n²) without realising it, and it is time to look at an ArrayDeque (05-07).
- Safe traversal and removal
The three ways of traversing, with their criteria:
// 1. for-each: by default, when you only read
for (Material m : catalog) {
System.out.println(m.describe());
}
// 2. classic for with an index: when you need the position or write with set
for (int i = 0; i < catalog.size(); i++) {
System.out.printf("%2d. %s%n", i + 1, catalog.get(i).getTitle());
}
// 3. forEach with a Consumer: when you already have the action as a method reference
catalog.forEach(System.out::println);A warning about option 2: catalog.get(i) is O(1) on an ArrayList, but it will be O(n) on a LinkedList. An indexed loop over a LinkedList is O(n²) and is one of the most common performance mistakes. The for-each is correct on both.
And removal during traversal, picking up 05-02 with the concrete idioms:
// CORRECT AND PREFERRED: one line
catalog.removeIf(m -> !m.isAvailable());
// CORRECT: explicit iterator, when the logic is complex
Iterator<Material> it = catalog.iterator();
while (it.hasNext()) {
Material m = it.next();
if (!m.isAvailable()) {
recordWithdrawal(m); // side effect before deleting
it.remove();
}
}
// CORRECT: classic for BACKWARDS, when you need the index
for (int i = catalog.size() - 1; i >= 0; i--) {
if (!catalog.get(i).isAvailable()) {
System.out.println("Withdrawal at position " + i);
catalog.remove(i);
}
}
// INCORRECT: ConcurrentModificationException
for (Material m : catalog) {
if (!m.isAvailable()) { catalog.remove(m); }
}A little-known efficiency detail: removeIf on an ArrayList is implemented as a single pass that marks the elements to remove and then compacts the array in one go, so it is O(n). A loop calling remove(Object) n times would be O(n²). Another reason to prefer removeIf.
ArrayList versus array
ArrayList versus array| Aspect | Material[] |
List<Material> |
|---|---|---|
| Size | Fixed at creation | Grows and shrinks by itself |
| Adding / removing | By hand with copyOf / arraycopy |
add / remove |
| Real size | length is the capacity; you have to keep a counter |
size() is the truth |
| Access by index | a[i] — the fastest possible |
get(i) — O(1), with a method call |
| Primitives | int[] with no wrappers |
List<Integer> with autoboxing |
Memory (1 M ints) |
~4 MB | ~20 MB |
| Traversal | The fastest | Very fast (the same array inside) |
| Searching by criterion | A hand-written loop | contains, indexOf, removeIf |
| Sorting | Arrays.sort |
list.sort |
| Multidimensional | int[][] natural |
List<List<Integer>>, more verbose |
| Type safety | Covariant: ArrayStoreException at run time |
Generics: error at compile time |
| Available API | The Arrays class |
The whole List interface and Collections |
When the array is still better:
- Primitives in quantity.
int[],double[],byte[]. An I/O byte buffer (module 7) is always abyte[]. - Size fixed by nature. A 3×12 matrix, a chessboard, the 256 values of a conversion table.
- Maximum performance in numerical computation, where cache locality and the absence of wrappers make the difference.
- Implementing data structures, as
ArrayList,HashMapandArrayDequedo inside. - APIs that demand it:
main(String[] args),String.split,toArray.
For everything else, ArrayList.
- Array ↔ list conversion
It is an everyday operation and each direction has its trap.
From list to array
List<Material> list = new ArrayList<>(...);
Material[] array = list.toArray(new Material[0]); // THE CORRECT IDIOM
Object[] bad = list.toArray(); // loses the type: almost never usefulThe new Material[0] is not wasted: it tells the method the type of array it must create. Since the array passed is too small, toArray internally creates one of the right size and returns that. Surprisingly, new Material[0] is usually faster than new Material[list.size()] on modern JVMs, because it avoids zero-filling an array that is going to be overwritten entirely. Always use new T[0].
The resulting array is independent: adding to the list afterwards does not change it. But it is a shallow copy: the objects are the same ones.
From array to list
Three forms, with very different behaviours:
Material[] array = { book1, book2, book3 };
// 1. Fixed-size VIEW backed by the array (05-01)
List<Material> view = Arrays.asList(array);
view.set(0, other); // OK, and it modifies array[0]
// view.add(other); // UnsupportedOperationException
// 2. MODIFIABLE and INDEPENDENT list <- what you want almost always
List<Material> modifiable = new ArrayList<>(Arrays.asList(array));
modifiable.add(other); // OK, and it does not touch 'array'
// 3. IMMUTABLE and independent list (Java 9+)
List<Material> immutable = List.of(array);
// immutable.set(0, other); // UnsupportedOperationException| Form | Modifiable | Does it reflect changes to the array? | When to use it |
|---|---|---|---|
Arrays.asList(a) |
Only set |
Yes, both ways | Wrapping an array to pass it to an API that asks for a List |
new ArrayList<>(Arrays.asList(a)) |
Fully | No | The default option |
List.of(a) |
No | No | Constants; rejects null |
And the two traps you already know from 05-01, which bite again here:
int[] primitives = { 1, 2, 3 };
List<int[]> wrong = Arrays.asList(primitives); // ONE list of ONE element (the whole array)
System.out.println(wrong.size()); // 1
Integer[] boxed = { 1, 2, 3 };
List<Integer> right = Arrays.asList(boxed); // 3 elements, as you expected
System.out.println(right.size()); // 3Generics only work with reference types (10-01), so an int[] is taken as a single object. To go from int[] to List<Integer> without Streams, an explicit loop is the way:
List<Integer> list = new ArrayList<>(primitives.length);
for (int n : primitives) { list.add(n); } // autoboxing on every addIn module 10 you will see that Arrays.stream(primitives).boxed().toList() does the same in one expression.
- Lists of lists
Since a List can contain any type, it can contain other lists. It is the flexible equivalent of the two-dimensional array from 05-01:
List<List<Material>> byType = new ArrayList<>();
List<Material> books = new ArrayList<>();
books.add(new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018));
books.add(new Book("Refactoring", "Martin Fowler", "978-0000000003", 1999));
List<Material> magazines = new ArrayList<>();
magazines.add(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"));
byType.add(books);
byType.add(magazines);
byType.add(new ArrayList<>()); // the DVDs, still none of them
// Access: two indices, just as in a two-dimensional array
System.out.println(byType.get(0).get(1).getTitle()); // Refactoring
// Nested traversal
for (List<Material> group : byType) {
System.out.println("Group of " + group.size() + " materials:");
for (Material m : group) {
System.out.println(" " + m.getTitle());
}
}The advantage over Material[][] is that each sublist grows on its own, with nothing to dimension in advance.
But notice the weakness: index 0 means "books" by convention, and that convention is written down nowhere. It is exactly the problem solved by a Map<String, List<Material>> in 05-05, where the key states explicitly what each group contains.
And a warning about initialisation:
List<List<String>> matrix = new ArrayList<>();
for (int i = 0; i < 3; i++) {
matrix.add(new ArrayList<>()); // each row needs ITS OWN list
}
matrix.get(0).add("data"); // now it worksIf you forget to create each sublist, matrix.get(0) returns null and the add throws NullPointerException. And if you add the same list three times (List<String> row = new ArrayList<>(); matrix.add(row); matrix.add(row);), the three rows will be the same one through aliasing (03-02), and writing into one changes them all.
equals and hashCode of a list
equals and hashCode of a listAbstractList does override equals and hashCode, unlike arrays. Two lists are equal if they have the same elements in the same order:
List<String> a = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
List<String> b = new LinkedList<>(List.of("Marta Ruiz", "Diego Alonso"));
List<String> c = new ArrayList<>(List.of("Diego Alonso", "Marta Ruiz"));
System.out.println(a.equals(b)); // true <- the implementation does NOT matter
System.out.println(a.equals(c)); // false <- the ORDER does matter
System.out.println(a.hashCode() == b.hashCode()); // trueThat a.equals(b) is true with b being a LinkedList is deliberate: the List.equals contract compares content, not class. It is an important difference from arrays, where equals compares references and Arrays.equals was needed.
The hashCode is computed like this, and it explains why two equal lists share it:
Practical consequence: a list's hashCode depends on its elements, so it changes if the list changes. Using a mutable list as a HashMap key or as a HashSet element is a recipe for disaster; you will see it demonstrated in 05-05. If you need a list as a key, use an immutable one (List.of(...) or List.copyOf(...)).
And beware: for the list's equals to work, the elements must have a correct equals. A List<Card> compares properly because Card is a record; a list of objects without equals would compare by reference element by element.
- Refactoring BiblioTech
It is time to convert the catalogue for good. Compare the findByType method in its two versions.
Before: with arrays
/** Module 4's version, with Material[] and a manual counter. */
public Material[] findByType(String type) {
Material[] result = new Material[n]; // you have to reserve the MAXIMUM possible
int found = 0;
for (int i = 0; i < n; i++) { // up to n, not up to materials.length
if (materials[i] != null && materials[i].getType().equals(type)) {
result[found] = materials[i];
found++;
}
}
return Arrays.copyOf(result, found); // and trim at the end
}Four problems in eleven lines: over-reserving, keeping a separate counter, walking only up to n without forgetting it, and trimming on return.
After: with List
/** Module 5's version. */
public List<Material> findByType(String type) {
List<Material> result = new ArrayList<>();
for (Material m : materials) {
if (m.getType().equals(type)) { result.add(m); }
}
return result;
}And with the Predicate from 04-06, a single method serves any criterion:
public List<Material> find(Predicate<Material> criteria) {
List<Material> result = new ArrayList<>();
for (Material m : materials) {
if (criteria.test(m)) { result.add(m); }
}
return result;
}The complete Catalog
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import com.nexussoftware.bibliotech.domain.Material;
/** BiblioTech's catalogue on top of ArrayList. */
public class Catalog {
private final List<Material> materials;
private final Set<String> references = new HashSet<>(); // O(1) uniqueness
public Catalog() {
this.materials = new ArrayList<>();
}
/** Initial capacity: useful only when we load a large catalogue in one go. */
public Catalog(int expectedMaterials) {
this.materials = new ArrayList<>(Math.max(expectedMaterials, 10));
}
public boolean add(Material m) {
if (m == null) { return false; }
if (!references.add(m.getReference())) {
return false; // duplicate reference
}
return materials.add(m); // always true on a List
}
/** Inserts at a specific position. O(n): it shifts the rest. */
public boolean insert(int position, Material m) {
if (m == null || position < 0 || position > materials.size()) { return false; }
if (!references.add(m.getReference())) { return false; }
materials.add(position, m);
return true;
}
public boolean remove(String reference) {
if (!references.remove(reference)) { return false; }
return materials.removeIf(m -> m.getReference().equals(reference));
}
/** Replaces the material at a position, keeping the size. */
public Material replace(int position, Material item) {
Material previous = materials.set(position, item); // returns what was there
references.remove(previous.getReference());
references.add(item.getReference());
return previous;
}
public Material get(int position) { return materials.get(position); } // O(1)
public boolean contains(Material m) { return materials.contains(m); } // O(n), uses equals
public int indexOf(Material m) { return materials.indexOf(m); } // O(n)
public List<Material> find(Predicate<Material> criteria) {
List<Material> result = new ArrayList<>();
for (Material m : materials) {
if (criteria.test(m)) { result.add(m); }
}
return result;
}
public int count(Predicate<Material> criteria) {
int n = 0;
for (Material m : materials) {
if (criteria.test(m)) { n++; }
}
return n;
}
public void sort(Comparator<Material> criteria) { materials.sort(criteria); }
/** A page of results using subList, with the bounds properly controlled. */
public List<Material> page(int pageNumber, int perPage) {
int from = pageNumber * perPage;
if (from >= materials.size() || from < 0) { return List.of(); }
int to = Math.min(from + perPage, materials.size());
return new ArrayList<>(materials.subList(from, to)); // a COPY, not a view
}
/** Immutable copy: nobody outside can alter the catalogue. */
public List<Material> list() { return List.copyOf(materials); }
/** For when an old API asks for an array. */
public Material[] asArray() { return materials.toArray(new Material[0]); }
public int size() { return materials.size(); }
public boolean isEmpty() { return materials.isEmpty(); }
public void clear() { materials.clear(); references.clear(); }
}Full usage:
Catalog catalog = new Catalog();
catalog.add(new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018));
catalog.add(new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994));
catalog.add(new Book("Refactoring", "Martin Fowler", "978-0000000003", 1999));
catalog.add(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"));
catalog.add(new Dvd("Refactoring Live", "DVD-0007", 95));
catalog.sort(Comparator.comparing(Material::getType)
.thenComparing(Material::getTitle));
System.out.println("--- Catalogue (" + catalog.size() + " materials) ---");
catalog.list().forEach(m -> System.out.println(" " + m.describe()));
List<Material> cheap = catalog.find(m -> m.getDailyRate() < 0.30);
System.out.println("Rate below 0.30 EUR/day: " + cheap.size());
System.out.println("Page 0 (2 per page):");
catalog.page(0, 2).forEach(m -> System.out.println(" " + m.getTitle()));--- Catalogue (5 materials) --- [Book] Design Patterns - Erich Gamma (978-0000000002) [Book] Effective Java - Joshua Bloch (978-0000000001) [Book] Refactoring - Martin Fowler (978-0000000003) [DVD] Refactoring Live (DVD-0007) [Magazine] Java Magazine no. 42 (REV-2024-03) Rate below 0.30 EUR/day: 4 Page 0 (2 per page): Design Patterns Effective Java
Compared with the ArrayCatalog of 05-01, all the memory management has gone and operations that were previously unthinkable have appeared: paging, positional insertion, replacement, guaranteed uniqueness. And a deliberate weakness remains: remove and any search by reference walk the whole list. With 5 materials it makes no difference; with 50,000, it does. That is the job of 05-05.
Common Mistakes and Tips
list.length instead of list.size(). length belongs to arrays, length() to String, size() to collections.
Confusing add(i, e) with set(i, e). The first inserts and the list grows; the second replaces and the size does not change. If your list grows when it should not, look here.
remove(int) on a List<Integer>. list.remove(2) removes position 2, not the value 2. Use remove(Integer.valueOf(2)) to remove by value.
Modifying the list inside a for-each. ConcurrentModificationException. Use removeIf, Iterator.remove() or traverse backwards with indices.
An indexed loop over a list that might not be an ArrayList. for (int i = 0; i < list.size(); i++) list.get(i) is O(n) on an ArrayList and O(n²) on a LinkedList. If the parameter is a List, traverse with for-each.
list.add(0, x) or list.remove(0) inside a loop. Every call shifts the whole list: O(n²) in total. If you need to work at the start, use an ArrayDeque (05-07).
Believing subList is a copy. It is a view: modifying it modifies the original, and modifying the original structurally invalidates it with ConcurrentModificationException. If you want a copy, new ArrayList<>(list.subList(a, b)).
Expecting Arrays.asList to return a modifiable list. It is fixed-size: set yes, add/remove throw UnsupportedOperationException. Use new ArrayList<>(Arrays.asList(...)).
Arrays.asList on an int[]. It returns a list of one element. You need Integer[] or an explicit loop.
Searching with contains on a class without equals. It will never find anything, even if the data matches. Revisit 03-09.
Returning the internal list from a getter. The caller will be able to empty it. Return List.copyOf(materials) or, at the very least, Collections.unmodifiableList(...) knowing that it is a view (05-02).
Tip: isEmpty() instead of size() == 0. It reads better and on some implementations it is faster.
Tip: initial capacity only when it matters. With thousands or millions of elements expected, new ArrayList<>(n) saves resizes. With twenty, it is noise.
Exercises
Exercise 1: a loan manager with ArrayList
Write LoanManager with an internal List<Loan> and these methods:
void register(Loan l).boolean returnItem(String reference, int day): finds the loan by its reference (LN-0001), invokesregisterReturn(day)and returnstrueif it found it.List<Loan> overdue(int currentDay): those that are overdue and not returned.int purgeReturned(): removes the ones already returned usingremoveIfand returns how many it took out.List<Loan> latest(int howMany): the lasthowManyregistered, usingsubListwith the bounds properly controlled and returning a copy.Loan oldest(): the one with the lowestloanDay, ornullif there are none.
Exercise 2: the remove trap documented
Write a class RemoveDemo with a main method that:
- Creates a
List<Integer>with the values 10, 20, 30, 40, 50. - Shows what happens with
remove(1),remove(Integer.valueOf(20))andremove((Integer) 30), printing the list after each operation. - Shows why
remove(100)would throwIndexOutOfBoundsException(without running it: comment it out and explain). - Creates an equivalent
List<String>and explains why the ambiguity does not exist there. - Writes a safe, reusable method
boolean removeValue(List<Integer> list, int value).
Exercise 3: conversions and their traps
Write CollectionConverter with static methods that demonstrate, each with its own console output:
void demoAsList(): thatArrays.asListis a fixed-size view backed by the array (modify from both sides and show which operations fail).void demoIndependentCopy(): thatnew ArrayList<>(Arrays.asList(a))is independent.void demoToArray(): the conversion back withtoArray(new String[0]).void demoPrimitives(): the trap ofArrays.asList(int[])versusArrays.asList(Integer[]).List<Integer> toList(int[] primitives): the correct conversion with a loop.
Solutions
Solution 1
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.List;
import com.nexussoftware.bibliotech.domain.Loan;
public class LoanManager {
private final List<Loan> loans = new ArrayList<>();
public void register(Loan l) {
if (l != null) { loans.add(l); } // add at the end: amortised O(1)
}
/**
* O(n) linear search by reference. With many loans you would have to
* index by reference in a Map<String, Loan>: that is 05-05.
*/
public boolean returnItem(String reference, int day) {
if (reference == null) { return false; }
for (Loan l : loans) { // for-each: we only read
if (reference.equals(l.getReference())) {
l.registerReturn(day); // we modify the OBJECT, not the list: legal
return true;
}
}
return false;
}
public List<Loan> overdue(int currentDay) {
List<Loan> result = new ArrayList<>();
for (Loan l : loans) {
if (!l.isReturned() && l.isOverdue(currentDay)) {
result.add(l);
}
}
return result; // an empty list if there are none, NEVER null
}
/**
* removeIf makes a single O(n) pass. A loop with remove(Object)
* would be O(n^2) and would also give ConcurrentModificationException.
*/
public int purgeReturned() {
int before = loans.size();
loans.removeIf(Loan::isReturned); // method reference (04-06)
return before - loans.size();
}
public List<Loan> latest(int howMany) {
if (howMany <= 0 || loans.isEmpty()) { return List.of(); }
int from = Math.max(0, loans.size() - howMany); // lower-bound protection
// subList returns a VIEW: we copy it so that it is independent
return new ArrayList<>(loans.subList(from, loans.size()));
}
public Loan oldest() {
Loan best = null;
for (Loan l : loans) {
if (best == null || l.getLoanDay() < best.getLoanDay()) {
best = l;
}
}
return best; // null if the list is empty; document it in the javadoc
}
public int size() { return loans.size(); }
}Design points to remember. returnItem modifies the object inside a for-each, which is perfectly legal: modCount only counts structural changes to the list. purgeReturned uses removeIf instead of a loop with remove, going from O(n²) to O(n) and avoiding the exception. latest protects the lower bound with Math.max and copies the subList view. And every method that returns a collection returns an empty list instead of null.
In 05-09 you will see that oldest() fits on one line with Collections.min(loans, Comparator.comparingInt(Loan::getLoanDay)).
Solution 2
package com.nexussoftware.bibliotech.presentation;
import java.util.ArrayList;
import java.util.List;
public class RemoveDemo {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30, 40, 50));
System.out.println("Initial: " + numbers);
// 1) remove(int): the literal 1 is an int -> remove(int index) is chosen
// without autoboxing, because overloading prefers the exact match.
numbers.remove(1);
System.out.println("After remove(1): " + numbers + " <- removed POSITION 1");
// 2) remove(Object): Integer.valueOf(30) is an Integer -> remove(Object)
numbers.remove(Integer.valueOf(30));
System.out.println("After remove(valueOf(30)): " + numbers + " <- removed VALUE 30");
// 3) The cast has the same effect as valueOf, but it reads worse
numbers.remove((Integer) 40);
System.out.println("After remove((Integer) 40): " + numbers + " <- removed VALUE 40");
// 4) The dangerous call, commented out on purpose:
// numbers.remove(100);
// -> IndexOutOfBoundsException: Index 100 out of bounds for length 2
// The programmer wanted to delete the VALUE 100 and asked for POSITION 100.
// The error shows up at run time, not at compile time.
// 5) With String there is no ambiguity: "Marta Ruiz" is not an int,
// so it can only resolve as remove(Object).
List<String> employees = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
employees.remove("Marta Ruiz");
System.out.println("Employees: " + employees);
// 6) A safe, reusable method
List<Integer> ids = new ArrayList<>(List.of(101, 205, 307));
System.out.println("removeValue(205): " + removeValue(ids, 205) + " -> " + ids);
System.out.println("removeValue(999): " + removeValue(ids, 999) + " -> " + ids);
}
/**
* Removes by VALUE with no possible ambiguity. The parameter is an int for the
* caller's convenience, and the explicit wrapper guarantees that
* remove(Object) is invoked and not remove(int).
*/
public static boolean removeValue(List<Integer> list, int value) {
return list.remove(Integer.valueOf(value));
}
}Initial: [10, 20, 30, 40, 50] After remove(1): [10, 30, 40, 50] <- removed POSITION 1 After remove(valueOf(30)): [10, 40, 50] <- removed VALUE 30 After remove((Integer) 40): [10, 50] <- removed VALUE 40 Employees: [Diego Alonso] removeValue(205): true -> [101, 307] removeValue(999): false -> [101, 307]
The underlying lesson goes beyond remove: when two overloads differ only in primitive versus wrapper, the primitive one wins, because overload resolution prefers not to apply boxing. It is the same rule from 03-03, now with visible consequences.
Solution 3
package com.nexussoftware.bibliotech.presentation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class CollectionConverter {
private CollectionConverter() { }
public static void demoAsList() {
System.out.println("--- Arrays.asList is a fixed-size VIEW ---");
String[] array = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
List<String> view = Arrays.asList(array);
view.set(0, "Marta R."); // allowed
System.out.println("array[0] after view.set: " + array[0]); // Marta R.
array[1] = "Diego A."; // from the other side
System.out.println("view after array[1]=: " + view); // Diego A.
// view.add("New"); -> UnsupportedOperationException: FIXED size
// view.remove(0); -> UnsupportedOperationException
System.out.println("add and remove would throw UnsupportedOperationException");
}
public static void demoIndependentCopy() {
System.out.println("--- new ArrayList<>(Arrays.asList(a)) is INDEPENDENT ---");
String[] array = { "Marta Ruiz", "Diego Alonso" };
List<String> copy = new ArrayList<>(Arrays.asList(array));
copy.add("Nuria Vidal"); // now it is allowed
copy.set(0, "Marta R.");
System.out.println("copy: " + copy); // [Marta R., Diego Alonso, Nuria Vidal]
System.out.println("array: " + Arrays.toString(array)); // [Marta Ruiz, Diego Alonso]
System.out.println("The original array has NOT been affected");
}
public static void demoToArray() {
System.out.println("--- From list to array ---");
List<String> list = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
// new String[0] states the TYPE. toArray internally creates one of the right size.
String[] array = list.toArray(new String[0]);
System.out.println("array: " + Arrays.toString(array) + " (length " + array.length + ")");
list.add("Nuria Vidal"); // the array already generated does not change
System.out.println("after adding to the list, array still has " + array.length);
Object[] untyped = list.toArray(); // loses the type: almost never useful
System.out.println("toArray() with no argument returns Object[]: " + untyped.length);
}
public static void demoPrimitives() {
System.out.println("--- The primitives trap ---");
int[] primitives = { 1, 2, 3 };
List<int[]> wrong = Arrays.asList(primitives);
System.out.println("Arrays.asList(int[]).size() = " + wrong.size()
+ " <- ONE list of ONE element: the whole array");
Integer[] boxed = { 1, 2, 3 };
List<Integer> right = Arrays.asList(boxed);
System.out.println("Arrays.asList(Integer[]).size() = " + right.size()
+ " <- three elements, as you expected");
System.out.println("Cause: generics only accept reference types (10-01)");
}
/** The correct conversion from int[] to List<Integer>. */
public static List<Integer> toList(int[] primitives) {
if (primitives == null) { return List.of(); }
// exact initial capacity: we know how many are going in
List<Integer> list = new ArrayList<>(primitives.length);
for (int n : primitives) {
list.add(n); // autoboxing: int -> Integer on every add
}
return list;
}
public static void main(String[] args) {
demoAsList();
demoIndependentCopy();
demoToArray();
demoPrimitives();
System.out.println("toList(new int[]{7,8,9}) = " + toList(new int[] { 7, 8, 9 }));
}
}The operational conclusion of the exercise fits into one rule: Arrays.asList only to wrap a read-only array that has to be passed to an API asking for a List; new ArrayList<>(Arrays.asList(...)) for everything else; toArray(new T[0]) for the way back. And with primitives, always a loop or —from module 10 onwards— Arrays.stream(...).boxed().
Conclusion
You now know from the inside the collection you will use most. You know that an ArrayList is an internal array plus an int size: exactly the ArrayCatalog you wrote yourself, but tuned by the JDK. Its whole performance profile follows from that structure: get and set in O(1), add at the end in amortised O(1), and O(n) to insert or remove anywhere else because the rest has to be shifted with an internal System.arraycopy.
You distinguish capacity from size, and you know that capacity is an internal detail you cannot consult and almost never have to manage. You understand resizing: the array grows by ×1.5 with an O(n) copy, and precisely because it grows multiplicatively the average cost per element stays constant — that is amortised cost, an idea that will reappear with HashMap and ArrayDeque. And you know when the constructor with an initial capacity is worth it: with thousands or millions of elements expected, not with twenty.
You have mastered the complete API: add in its two forms, get, set versus add(i, e), remove in its three variants, indexOf, contains and its total dependence on equals —the thread that comes from 03-09—, sort, replaceAll, removeIf and forEach with the functional interfaces of 04-06. You know the trap of remove(int) versus remove(Object) on a List<Integer> and the three ways of defusing it, and you know that subList is a view: modifying it changes the original, modifying the original invalidates it, and for a copy you have to wrap it in a new ArrayList<>(...).
You know how to traverse and remove safely —removeIf by default, Iterator.remove() for complex logic, a backwards for if you need the index—, you have the table comparing ArrayList with the array and the five cases where the array still wins, and you handle conversions in both directions with the traps of Arrays.asList and of primitives. You know about lists of lists and their weakness —the index that means something by unwritten convention— and you know that two lists are equal if they have the same elements in the same order, whatever their implementation, with the caveat that their hashCode changes when their content changes.
BiblioTech's Catalog is now a professional class: registration with duplicate control, positional insertion, replacement, removal, searching by any Predicate, sorting by any Comparator, paging with subList and an immutable view towards the outside. Zero lines of memory management.
In the next lesson, LinkedList, you will see the other implementation of List: a chain of nodes linked in both directions, with no array and no capacity. You will understand what that implies for memory and for the processor cache, and above all you will dismantle the myth almost everybody repeats wrongly: that "LinkedList inserts in O(1)". It is true only if you already have the position, and getting there costs O(n). You will see the honest comparison operation by operation, why in practice ArrayList wins almost always, why LinkedList survives mostly as a Deque, how to insert correctly while traversing with a ListIterator, and the one BiblioTech case where LinkedList really is the right choice: the reservation queue that is consumed at the front and grows at the back.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
