Java List Interface: Contract and Implementations
java list interface: Understand the Java List interface, its core methods, and how to choose between ArrayList and LinkedList for your use case.
The Java List interface is one of the most frequently used types in the Collections Framework. It defines an ordered collection that allows duplicate elements and provides positional access to elements. Understanding the contract of the java list interface is essential for writing maintainable Java code, because the interface—not the concrete class—should drive most of your code.
The List Contract: Ordering, Indexing, and Duplicates
List extends the Collection interface and adds several guarantees. Unlike a Set, a List maintains insertion order, so elements can be accessed by their integer index. It also permits duplicate elements, meaning the same object can appear multiple times. The interface defines methods for positional access, such as get(int index), set(int index, E element), add(int index, E element), and remove(int index). These methods form the core of the List contract, and any implementation must honor their semantics.
A practical consequence is that you can iterate over a List in a deterministic order, and you can rely on indexOf and lastIndexOf to locate elements. This behavior is what distinguishes a List from a Set, which has no defined iteration order and forbids duplicates.
Core Methods Every List Implementation Must Provide
The List interface declares more than 25 methods, but most developers work with a small subset. The most commonly used methods include:
boolean add(E e)– appends an element to the end.void add(int index, E element)– inserts at a specific position.E get(int index)– returns the element at the given index.E set(int index, E element)– replaces the element at the given index.E remove(int index)– removes and returns the element at the index.boolean remove(Object o)– removes the first occurrence of the specified element.int size()– returns the number of elements.int indexOf(Object o)– returns the index of the first occurrence, or -1.List<E> subList(int fromIndex, int toIndex)– returns a view of a portion of the list.
Here is a simple example that uses the List interface as the variable type, which allows you to swap implementations without changing the rest of the code:
List<String> tasks = new ArrayList<>(); tasks.add("Write report"); tasks.add("Review code"); tasks.add("Deploy"); String firstTask = tasks.get(0); tasks.set(1, "Review pull request"); tasks.remove(2); for (String task : tasks) { System.out.println(task); }
Notice that the variable is declared as List<String>, not ArrayList<String>. This is a good practice because it decouples your code from a specific implementation, making it easier to change later if performance requirements shift.
Choosing Between ArrayList and LinkedList
The two most common implementations of the List interface are ArrayList and LinkedList. ArrayList is backed by a resizable array, while LinkedList uses a doubly linked list of nodes. The choice between them depends on the operations you perform most often.
ArrayList excels at random access. Calling get(index) runs in constant time because it is just an array lookup. Adding an element to the end is amortized constant time, but inserting or removing an element in the middle requires shifting all subsequent elements, which is O(n). LinkedList offers constant-time insertion or removal at the beginning or end, but accessing an element by index requires traversing the list from the head or tail, making get(index) O(n).
In most real-world applications, ArrayList is the better default. It has better cache locality because elements are stored contiguously, and it uses less memory per element because it does not store node pointers. LinkedList only becomes attractive when you frequently add or remove elements at the beginning of a large list, or when you are implementing a queue and do not need random access.
Here is how you create both:
List<String> arrayList = new ArrayList<>(); List<String> linkedList = new LinkedList<>();
If you are unsure which to use, start with ArrayList. Profile your application before switching to a different implementation.
Performance Characteristics Without Benchmarks
Performance is often the deciding factor when choosing a List implementation. The time complexity of common operations is a useful guide, but actual behavior also depends on memory allocation, garbage collection, and the size of the list.
| Operation | ArrayList | LinkedList |
|---|---|---|
get(int index) | O(1) | O(n) |
add(E e) (at end) | O(1) amortized | O(1) |
add(int index, E e) | O(n) | O(n) |
remove(int index) | O(n) | O(n) |
remove(Object o) | O(n) | O(n) |
iterator().next() | O(1) | O(1) |
These numbers reflect the algorithm, not the constant factors. For example, ArrayList's O(n) insertion at the front requires shifting every element, which is a fast array copy operation. LinkedList's O(n) insertion at a specific index requires traversing the list to find the node, which involves pointer chasing and is often slower in practice.
Memory usage also differs. An ArrayList stores a single contiguous array, with some spare capacity. A LinkedList stores a separate node object for each element, and each node holds two pointers in addition to the data. For large lists, this overhead can be significant.
Concurrency Considerations for List Implementations
Neither ArrayList nor LinkedList is thread-safe. If multiple threads access a List concurrently, and at least one thread modifies it, you must synchronize externally. The simplest approach is to wrap the list with Collections.synchronizedList:
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
However, this wrapper synchronizes each individual method, so compound operations like if (!list.isEmpty()) { list.get(0); } are not atomic. You still need to synchronize on the list object for such sequences.
For read-heavy scenarios with infrequent writes, CopyOnWriteArrayList is a better choice. It creates a fresh copy of the underlying array on every modification, so reads are lock-free and can safely iterate while other threads modify the list. The trade-off is that writes are expensive, making it unsuitable for write-heavy workloads.
List<String> copyOnWriteList = new CopyOnWriteArrayList<>();
Choose a concurrent implementation only when you have verified that the default implementations cause thread-safety issues. Premature concurrency protection can add unnecessary overhead.
Using List with Streams and Modern Java
Java 8 introduced the Stream API, which integrates naturally with List. You can convert a List to a stream, apply transformations, and collect the results back into a new List. This is a common pattern for filtering, mapping, and sorting.
List<String> names = List.of("Alice", "Bob", "Charlie"); List<String> filtered = names.stream() .filter(name -> name.startsWith("A")) .map(String::toUpperCase) .collect(Collectors.toList());
Java 9 added List.of, which creates an immutable list with a fixed set of elements. This is useful when you need a constant list that will not change. Similarly, List.copyOf creates an immutable copy of an existing collection.
List<Integer> immutableList = List.of(1, 2, 3); List<String> copy = List.copyOf(existingList);
Immutable lists do not allow add, remove, or set operations, and they reject null elements. This makes them a safe choice for constants and for APIs that should not be modified by callers.
Common Pitfalls When Working with List
One frequent mistake is modifying a List while iterating over it with an index-based loop. This can cause elements to be skipped or cause a ConcurrentModificationException when using an iterator. The safe way to remove elements during iteration is to use an Iterator and call iterator.remove():
List<String> list = new ArrayList<>(List.of("a", "b", "c")); Iterator<String> it = list.iterator(); while (it.hasNext()) { String value = it.next(); if (value.equals("b")) { it.remove(); } }
Another pitfall is the subList method. The returned list is a view backed by the original list, so changes to the sublist affect the original, and structural modifications to the original list invalidate the sublist. If you need a standalone copy, use new ArrayList<>(list.subList(from, to)).
Null handling is also worth attention. Most List implementations allow null elements, but List.of and List.copyOf do not. If you are writing generic code, do not assume a List is null-free unless the implementation guarantees it.
When to Use a List Instead of Other Collections
Choosing between List, Set, Queue, and Map depends on the semantics you need. Use a List when you require a defined order, when duplicates are allowed, or when you need index-based access. If you only need to check membership and do not care about order, a Set is more appropriate because it eliminates duplicates and offers faster contains operations on average. If you need to process elements in FIFO or LIFO order, a Queue or Deque is a better fit. A Map is for key-value associations, not for storing individual elements.
A common pattern is to use a List as an intermediate structure when building a collection, then convert it to a Set or Map for lookup-heavy operations. For example, you might read lines from a file into a List, then use a Set to remove duplicates. This leverages the List's ordering and the Set's uniqueness efficiently.
When you do choose a List, prefer the interface type in method signatures and variable declarations. This gives you the freedom to change the implementation later without affecting callers. It also makes your code more readable because the intent is clear: you are working with an ordered, indexable collection.