RizTech Academy logo
RizTech Academy
CollectionsLesson 2 of 725 min

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

  1. Work through the method table. Create a list of five names and call add(i, e), set, remove(i), indexOf, subList and removeIf on it, printing after each. Predict each result before running.

  2. 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.

  3. Fix it three ways. removeIf, an explicit iterator, and a backwards index loop. Confirm all three agree.

  4. Trip over remove(int). With a List<Integer> of [10, 20, 30], call remove(1) and then remove(Integer.valueOf(10)) on a fresh copy. Explain the difference to yourself before reading back.

  5. Measure the front insert. Time 100,000 add(0, x) calls against 100,000 add(x) calls. Then time contains on a 200,000-element ArrayList against the same on a HashSet.

  6. 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 with ArrayDeque, and time both with 100,000 operations. Then explain which operation made the difference — and note that "cancel by name" is a contains-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