List and ArrayList
ArrayList is the collection you will use most, and it is straightforward until
you remove something while looping over it. Then the same mistake produces three
different outcomes depending on which element you removed — one of which is an
exception, and two of which are silent.
That is the centrepiece of this lesson. Everything before it is the groundwork.
The methods
List<String> names = new ArrayList<>();
| Method | Does | Note |
|---|---|---|
add(e) |
Appends | Always true for a list |
add(i, e) |
Inserts at a position | Shifts everything after it |
get(i) |
Element at a position | IndexOutOfBoundsException if invalid |
set(i, e) |
Replaces, returns the old value | Does not insert |
remove(i) |
Removes by index | See the trap below |
remove(Object) |
Removes the first equal element | Returns boolean |
removeIf(p) |
Removes everything matching | The safe bulk removal |
indexOf(o) / lastIndexOf(o) |
Position or -1 |
Uses equals |
contains(o) |
Membership | Uses equals, scans the whole list |
size() / isEmpty() / clear() |
||
sort(comparator) |
Sorts in place | |
subList(from, to) |
A view, end exclusive | Writes through |
toArray(new T[0]) |
To an array | |
List.copyOf(list) |
An immutable copy |
Two of those are worth expanding.
subList is a view, not a copy
List<Integer> big = new ArrayList<>(List.of(1, 2, 3, 4, 5));
List<Integer> mid = big.subList(1, 4);
mid.set(0, 99);
System.out.println(big);
[1, 99, 3, 4, 5]
Writing through the sublist changed the original. That is occasionally what you
want — big.subList(0, 3).clear() removes the first three elements neatly — and
a surprise otherwise. For an independent copy: new ArrayList<>(big.subList(1, 4)).
A sublist also becomes invalid if the backing list is structurally modified, and
then throws ConcurrentModificationException on use.
remove(int) against remove(Object)
List<Integer> counts = new ArrayList<>(List.of(10, 20, 30));
counts.remove(1);
System.out.println(counts);
[10, 30]
It removed index 1, not the value 1. List has both remove(int index) and
remove(Object o), and an int literal matches the index version exactly, so no
boxing happens and the value version is never considered.
counts.remove(Integer.valueOf(10));
[20, 30]
This only bites on List<Integer>, and it bites hard, because the code looks
obviously correct. Use Integer.valueOf(x) or a cast to (Integer) when you
mean the value.
The centrepiece: removing while iterating
Here is the code everybody writes:
List<String> names = new ArrayList<>(List.of("Priya", "Arjun", "Kavita"));
for (String n : names) {
if (n.equals("Priya")) {
names.remove(n);
}
}
ConcurrentModificationException
Now change one character — remove "Arjun" instead, the second of three:
removing 2nd-to-last : no exception, list = [Priya, Kavita]
No exception. It worked. Same code, same list, different element.
And the indexed version, removing every name starting with A:
List<String> skip = new ArrayList<>(List.of("Arjun", "Amit", "Priya"));
for (int i = 0; i < skip.size(); i++) {
if (skip.get(i).startsWith("A")) {
skip.remove(i);
}
}
System.out.println(skip);
[Amit, Priya]
Amit survived. No exception at all, and a wrong answer.
Three behaviours from one mistake. Here is why each happens.
The enhanced for loop is an Iterator underneath. The iterator holds a
modCount from when it started and compares it on every next(). Removing an
element bumps the list's modCount, so the next next() throws. But
hasNext() only checks cursor != size — and after removing the second-to-last
element, the cursor happens to equal the new size, so the loop ends before
next() is ever called again. The check is skipped, and you never find out.
The indexed loop has no check at all. Removing index 0 shifts everything down,
i then becomes 1, and the element that moved into position 0 is never looked
at.
The silent versions are the dangerous ones. An exception is a bug you fix today. A skipped element is a customer who never got a tiffin.
The three correct ways
// 1. removeIf — say what to remove, not how
names.removeIf(n -> n.startsWith("A"));
// 2. An explicit iterator, using its own remove
Iterator<String> it = names.iterator();
while (it.hasNext()) {
if (it.next().startsWith("A")) {
it.remove();
}
}
// 3. An index loop running backwards
for (int i = names.size() - 1; i >= 0; i--) {
if (names.get(i).startsWith("A")) {
names.remove(i);
}
}
All three give [Priya, Kavita] from [Priya, Arjun, Kavita].
Use removeIf. It is one line, it cannot be got wrong, and it says what you
mean. Reach for the iterator when the decision needs more than a predicate, and
for the backwards loop when you need the index for something else.
The same rule covers adding: never add to a collection you are looping over.
Build a second list and combine afterwards.
How ArrayList actually works
It wraps an array. add writes to the next free slot; when the array is full it
allocates a bigger one — half again as large — and copies everything across.
That gives you the performance profile:
| Operation | Cost | Why |
|---|---|---|
get(i) / set(i, e) |
Instant | Direct array index |
add(e) at the end |
Instant on average | Occasionally copies, amortised away |
add(0, e) |
Proportional to size | Shifts every element |
remove(i) |
Proportional to elements after i |
Shifts them down |
contains(o) |
Proportional to size | Scans, calling equals |
indexOf(o) |
Proportional to size | Same |
Two practical consequences.
Adding at the front in a loop is quadratic. Ten thousand add(0, x) calls
shift about fifty million elements. Add at the end and reverse, or use an
ArrayDeque.
contains on a large list is a scan. Checking membership of a 200,000-element
list two thousand times took 192 ms on the machine these lessons were
written on; the same checks against a HashSet took under a millisecond. If
you are calling contains inside a loop, you want a Set.
If you know the final size, new ArrayList<>(10_000) presizes the array and
avoids the copying. That is a real but small optimisation — do it when you know
the number, not on principle.
LinkedList, briefly
Every textbook pairs it with ArrayList. In practice you will almost never want
it: get(i) has to walk the chain, and its pointer-per-element overhead makes it
slower even at inserting in the middle once you count the cost of finding the
position.
Where it earns its place is as a Deque — adding and removing at both ends — and
even there ArrayDeque is usually faster. The choosing lesson measures this.
Check your work
What does list.remove(1) do on a List<Integer>? Removes the element at
index 1. An int literal matches remove(int) exactly. Use
remove(Integer.valueOf(1)) for the value.
Why does removing during a for-each sometimes throw and sometimes not? The
iterator checks modCount inside next(), but hasNext() only compares the
cursor to the size. Removing the second-to-last element makes those equal, the
loop ends, and next() is never called again — so the check never runs.
Why is the silent case worse than the exception? An exception is found immediately. A loop that quietly stops early, or an index loop that skips the element after each removal, produces a wrong answer nobody notices.
Name the three safe ways to remove while iterating. removeIf with a
predicate; an explicit Iterator using it.remove(); an index loop running
backwards.
Is subList a copy? No, a view. Writes go through to the backing list, and
structural changes to the backing list invalidate it.
Why is add(0, e) in a loop slow? Every insertion at the front shifts every
existing element one place right, so the total work grows with the square of the
size.
When should a List be a Set instead? When you call contains on it in a
loop. A list scan is proportional to size; a hash set lookup is not.
Practice 2, the three outcomes. Removing "Priya" (the first of three) gives
ConcurrentModificationException. Removing "Arjun" (second of three) gives no
exception and the correct result. The forward index loop over
["Arjun", "Amit", "Priya"] leaves [Amit, Priya] — the second A name
survives because removing index 0 shifted it into a position the loop had
already passed.
Practice 5, the front-insert timing. On the machine these lessons were
written on, 100,000 add(0, x) calls take a noticeable pause while the same
number of add(x) calls are instant. Both produce a 100,000-element list; only
one of them moved five billion elements to get there.
Practice
-
Work through the method table. Create a list of five names and call
add(i, e),set,remove(i),indexOf,subListandremoveIfon it, printing after each. Predict each result before running. -
Reproduce all three removal outcomes. Remove the first of three in a for-each. Then the second of three. Then run the forward index loop over
["Arjun", "Amit", "Priya"]. Write down what happened in each case and why. -
Fix it three ways.
removeIf, an explicit iterator, and a backwards index loop. Confirm all three agree. -
Trip over
remove(int). With aList<Integer>of[10, 20, 30], callremove(1)and thenremove(Integer.valueOf(10))on a fresh copy. Explain the difference to yourself before reading back. -
Measure the front insert. Time 100,000
add(0, x)calls against 100,000add(x)calls. Then timecontainson a 200,000-elementArrayListagainst the same on aHashSet. -
Harder — a waiting list. Model a tiffin waiting list where customers join at the back, are served from the front, and can cancel from anywhere by name. Implement it with
ArrayList, then withArrayDeque, and time both with 100,000 operations. Then explain which operation made the difference — and note that "cancel by name" is acontains-shaped problem that neither structure solves well, which is the next lesson's territory.
Next: Map — the type that answers "what is the value for this key", and the
four ways to count things with it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship