Skip to content
CalliCoder

Java ArrayList Tutorial with Examples

Java 13 min read

Creation, access, removal, iteration and sorting — plus the four traps: remove(Integer), Arrays.asList's fixed size, subList as a live view, and mutating during a for-each.

ArrayList is a resizable array behind the List interface. Indexed access is constant time, appending is amortised constant time, and inserting or removing anywhere else shifts every element after it.

That one sentence explains all of its performance behaviour. Most of what goes wrong with it is not performance, though. It is four specific API surprises, and they are the reason this article is longer than the class deserves.

Written against Java 17.

Creating one

List<String> tags = new ArrayList<>();                  // empty, default capacity
List<String> sized = new ArrayList<>(10_000);           // pre-sized, no reallocation
List<String> copy = new ArrayList<>(existingCollection); // independent copy

Declare the variable as List, not ArrayList. The implementation is an internal decision; the interface is the contract.

The capacity argument is not a size, the list is still empty. It sets the size of the backing array so that growth does not reallocate. Growth is otherwise roughly 1.5× each time, which means copying the array; if you know you are about to add a hundred thousand elements, say so up front.

The three ways to get a small list, and how they differ

This is worth getting right once, because the three look interchangeable and behave differently:

List<String> a = new ArrayList<>(List.of("x", "y"));    // mutable, independent
List<String> b = List.of("x", "y");                     // immutable, rejects null
List<String> c = Arrays.asList("x", "y");               // fixed-size view over an array

List.of is immutable. add, remove and set all throw UnsupportedOperationException. It also rejects null elements, and List.of(null) throws NullPointerException, occasionally surprising when building one from a map lookup.

Arrays.asList is fixed-size, not immutable. set works; add and remove throw. It is a view over the array, so changing the array changes the list and vice versa:

String[] array = {"x", "y"};
List<String> view = Arrays.asList(array);
view.set(0, "z");
System.out.println(array[0]);   // z

When you want a real mutable list, wrap: new ArrayList<>(Arrays.asList(...)).

Adding and accessing

tags.add("java");                  // append
tags.add(0, "spring");             // insert at index — shifts everything right
tags.addAll(List.of("jpa", "sql"));
tags.set(1, "spring-boot");        // replace, returns the old value

String first = tags.get(0);
int size = tags.size();            // size(), not length
boolean empty = tags.isEmpty();

get on an out-of-range index throws IndexOutOfBoundsException, including on an empty list — get(0) is not null-safe, there is nothing there to be null.

Removing, and the overload that catches everyone

List has two remove methods: remove(int index) and remove(Object o). With a List<Integer>, the compiler picks the first for an int literal:

List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30, 40));

numbers.remove(2);                        // removes INDEX 2 -> the value 30
numbers.remove(Integer.valueOf(20));      // removes the VALUE 20

remove(2) calls the index overload because 2 is an int and no boxing is needed. If you mean the value, box it explicitly or use remove(Integer.valueOf(x)). This is the single most common ArrayList bug, and it produces a wrong result rather than an error, unless the index happens to be out of range, in which case you get a confusing IndexOutOfBoundsException from code that never mentioned an index.

For bulk conditional removal, removeIf rather than a loop:

tags.removeIf(t -> t.startsWith("draft-"));

Iterating, and ConcurrentModificationException

for (String tag : tags) {
    System.out.println(tag);
}

tags.forEach(System.out::println);

for (int i = 0; i < tags.size(); i++) {
    System.out.println(i + ": " + tags.get(i));
}

Modifying the list inside a for-each throws:

for (String tag : tags) {
    if (tag.isBlank()) {
        tags.remove(tag);      // ConcurrentModificationException
    }
}

“Concurrent” is misleading. One thread does this perfectly well. The iterator holds a modification count, notices the list changed underneath it, and fails fast rather than silently skipping elements.

There is a case that is worse than the exception: removing the second-to-last element usually exits the loop without throwing, because the iterator’s hasNext() compares its cursor to the new, smaller size and reports false. The loop ends early and nothing tells you.

Three correct approaches:

tags.removeIf(String::isBlank);                          // best

Iterator<String> it = tags.iterator();
while (it.hasNext()) {
    if (it.next().isBlank()) {
        it.remove();                                      // the iterator's own remove
    }
}

List<String> kept = tags.stream()
        .filter(t -> !t.isBlank())
        .collect(Collectors.toCollection(ArrayList::new)); // new list

Searching depends on equals

tags.contains("java");        // true/false
tags.indexOf("java");         // first index, or -1
tags.lastIndexOf("java");     // last index, or -1

All three walk the list calling equals. For your own types. That means contains does nothing useful until equals is implemented:

public final class Note {
    private final String title;

    // without this, contains() compares references and finds nothing
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Note other)) return false;
        return Objects.equals(title, other.title);
    }

    @Override
    public int hashCode() {
        return Objects.hash(title);
    }
}

Note the cost: contains on an ArrayList is O(n). If membership testing is the main operation, a HashSet is the right structure, O(1), at the price of losing order.

Sorting

List<String> names = new ArrayList<>(List.of("Zoe", "adam", "Bea"));

names.sort(null);                                    // natural order: Bea, Zoe, adam
names.sort(String.CASE_INSENSITIVE_ORDER);           // adam, Bea, Zoe
names.sort(Comparator.reverseOrder());

Natural String order is by char value, so every uppercase letter sorts before every lowercase one. “Alphabetical” almost always means a Comparator, and for user-facing output a Collator for the right locale.

For objects, compose:

notes.sort(Comparator.comparing(Note::category)
        .thenComparing(Note::updatedAt, Comparator.reverseOrder())
        .thenComparing(Note::title, String.CASE_INSENSITIVE_ORDER));

Comparator.comparing with a key extractor that can return null throws; use Comparator.nullsLast(...) when the field is optional.

list.sort(...) sorts in place. stream().sorted(...) produces a new list and leaves the original alone, pick according to whether the original is shared.

subList is a view, not a copy

List<String> all = new ArrayList<>(List.of("a", "b", "c", "d", "e"));
List<String> middle = all.subList(1, 4);     // [b, c, d] — inclusive, exclusive

middle.set(0, "B");
System.out.println(all);      // [a, B, c, d, e]  — the original changed

middle.clear();
System.out.println(all);      // [a, e]           — five became two

subList returns a window onto the same storage. Writes go through in both directions, and structurally modifying the backing list invalidates the view, the next operation on it throws ConcurrentModificationException.

subList(...).clear() is the idiomatic way to delete a range. If you wanted a snapshot, copy it: new ArrayList<>(all.subList(1, 4)).

Converting to an array

String[] array = tags.toArray(new String[0]);
Object[] objects = tags.toArray();

new String[0] rather than new String[tags.size()]. The zero-length form is at least as fast on current JVMs and cannot race. Sizing the array separately from filling it means a concurrent modification can leave trailing nulls.

Thread safety

ArrayList is not synchronised. Two threads adding concurrently can corrupt it: lost elements, or an ArrayIndexOutOfBoundsException from inside add as the array is resized under the other thread.

List<String> sync = Collections.synchronizedList(new ArrayList<>());
List<String> cow  = new CopyOnWriteArrayList<>();

synchronizedList locks every method. Compound operations still need external synchronisation, and iteration must hold the lock for the whole loop:

synchronized (sync) {
    for (String s : sync) { ... }
}

CopyOnWriteArrayList copies the array on every write, so reads never lock and iterators never throw. That trade is right for a list read constantly and written rarely (listener registries, cached configuration) and wrong for anything write-heavy.

ArrayList or LinkedList

ArrayList, almost always. The textbook argument for LinkedList is O(1) insertion, and it holds only if you already have the position as an Iterator. Reaching index n means walking n nodes, so LinkedList.get(n) is O(n), and each element carries two pointers plus an object header, so it uses several times the memory and defeats CPU cache prefetching.

Even mid-list insertion, where LinkedList should win, frequently loses in practice: ArrayList shifts elements with System.arraycopy, an intrinsic operating on contiguous memory. Use ArrayDeque for a queue or stack; reach for LinkedList when you have measured a reason.

Frequently asked questions

Why did remove(2) delete the wrong element?

List has both remove(int) and remove(Object). With a List<Integer>, an int literal selects the index overload. Use remove(Integer.valueOf(2)) to remove by value.

What causes ConcurrentModificationException in a single-threaded loop?

Structurally modifying the list while a for-each iterator is walking it. Use removeIf, or Iterator.remove().

Why did my loop end early instead of throwing?

Removing the second-to-last element leaves the iterator’s cursor equal to the new size, so hasNext() returns false and the loop exits silently. Another reason to use removeIf.

What is the difference between List.of and Arrays.asList?

List.of is immutable and rejects nulls. Arrays.asList is fixed-size but mutable through set, and it is a live view over the array you passed.

Does capacity affect size?

No. Capacity is the backing array length; size is the element count. A list created with capacity 10,000 has size 0.

Why does contains() not find my object?

It compares with equals, which defaults to reference identity. Implement equals and hashCode on the element type.

Is subList a copy?

No. It is a view over the same storage. Writes propagate both ways, and structurally modifying the backing list invalidates it. Wrap in new ArrayList<>(...) for a snapshot.

Should I use toArray(new String[0]) or size the array?

The zero-length form. It is at least as fast on modern JVMs and avoids a race that can leave trailing nulls.

Is ArrayList thread-safe?

No. Use Collections.synchronizedList (and hold the lock while iterating) or CopyOnWriteArrayList for read-heavy, write-rare cases.

When is LinkedList actually better?

Rarely. Its O(1) insertion needs an iterator already at the position; get(n) is O(n) and its memory overhead defeats cache locality. Use ArrayDeque for queues.

Where should I go next?

Parsing dates covers another part of the standard library with more sharp edges than its size suggests, and the Java guides cover the rest.