1. Overview
In this lesson, we’ll explore how to work with multiple elements at once in a list. Instead of handling items one by one, we’ll learn how to perform operations that affect groups of elements directly and concisely. These bulk operations let us express additions, removals, and transformations without writing the corresponding loops.
The relevant module we need to import when we’re starting with this lesson is bulk-operations-on-lists-start.
If we want to have a look at the fully implemented lesson as a reference, feel free to import: bulk-operations-on-lists-end.
2. Why Use Bulk Operations?
Suppose we want to add ten tasks to a to-do list, or remove several completed items from the main list of tasks. Doing this task by task (e.g., calling add() ten times) is repetitive and can clutter our code. Bulk operations allow us to perform these actions on an entire collection of elements in a single, expressive method call.
2.1. Performance and Convenience
When working with multiple elements, methods such as addAll() can avoid some of the repeated work of calling add() in a loop. The benefit depends on the list implementation and the operation.
For example, ArrayList.addAll() grows its internal array once if more capacity is needed, then copies the new elements together. This can avoid repeated growth, but it does not guarantee a faster result for every workload.
A single bulk call states our intent directly. We can express adding a group of tasks without writing the corresponding for loop.
2.2. Simplifying Common Tasks
Bulk operations simplify common data manipulation scenarios:
- Merging lists: Combining two lists into one
- List difference: Removing from one list all elements that exist in another
- Data initialization: Populating a list with a predefined set of items
- Cleanup: Removing all items that meet certain criteria
With bulk operations, we write less boilerplate code, reduce the chance of off-by-one errors in loops, and clarify our intent.
The start module includes the tests in JavaCollectionsTests. Each uses a manual loop that we’ll replace with a bulk operation, making the intent easier to read.
3. Adding Multiple Elements with addAll()
A common task is adding multiple items to a list. The addAll() method lets us do this easily, either by appending elements at the end or by inserting them at a specific position.
3.1. Adding at the End
If we need to add several new items to the end of an existing list, one way is to iterate through the collection of new items and add them one by one:
@Test
public void whenAddingAllElementsAtEnd_thenListContainsThem() {
List<String> tasks = new ArrayList<>();
tasks.add("Task A");
List<String> newTasks = List.of("Task B", "Task C", "Task D");
for (int i = 0; i < newTasks.size(); i++) {
tasks.add(newTasks.get(i));
}
// assertions
}
This approach works, but it requires writing explicit loop logic for a common operation.
The addAll() method provides a more direct way to append all elements from another collection to the end of the current list. All we need to do is replace our for loop with a simple line:
List<String> newTasks = List.of("Task B", "Task C", "Task D");
tasks.addAll(newTasks);
3.2. Adding at a Specific Index
Sometimes, we must insert a group of elements at a particular position within the list, not just at the end. Manually inserting multiple elements at a specific index requires index management.
We’d need to call add() with the index and element repeatedly. We must increase the insertion index for each element to preserve the order of the inserted block:
@Test
public void whenAddingAllElementsAtIndex_thenListContainsThem() {
List<String> tasks = new ArrayList<>(List.of("Task A", "Task E"));
List<String> itemsToInsert = List.of("Task B", "Task C", "Task D");
int insertionIndex = 1;
for (int i = 0; i < itemsToInsert.size(); i++) {
tasks.add(insertionIndex + i, itemsToInsert.get(i));
}
// assertions
}
This requires careful index management. The List interface offers an overloaded version of addAll() that accepts an index. Let’s use this method to insert items into the middle of a list:
List<String> tasks = new ArrayList<>(List.of("Task A", "Task E"));
List<String> itemsToInsert = List.of("Task B", "Task C", "Task D");
tasks.addAll(1, itemsToInsert);
We inserted the new elements in the middle of our list. Existing items at or after that position get shifted to make room.
4. Removing Groups of Elements
Removing multiple items at once is as simple as adding them. We can either remove specific groups of items or clear the entire list.
4.1. Removing Multiple Elements
Let’s imagine we want to exhaustively remove all the elements of one list from another. One approach would be to use a nested loop:
@Test
public void whenExhaustivelyRemoving_thenListNotContainingAny() {
List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C", "Task B"));
List<String> toRemove = List.of("Task B", "Task D");
for (int i = 0; i < toRemove.size(); i++) {
String elementToRemove = toRemove.get(i);
while (tasks.contains(elementToRemove)) {
tasks.remove(elementToRemove);
}
}
// assertions
}
Alternatively, the removeAll() method is a much simpler and more expressive choice. All we need to do is replace our for loop with a simple method call:
List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C", "Task B"));
List<String> toRemove = List.of("Task B", "Task D");
tasks.removeAll(toRemove);
4.2. Removing Elements Conditionally
Sometimes, we need to remove elements based on a certain condition rather than by matching elements in another collection.
For this, we would need to loop through the whole list and remove the matching elements. However, removing an item in a forward loop shifts the remaining elements left. If we increment the index after a removal, we can skip the item that moved into that position.
Iterating in reverse order avoids this problem because a removal does not shift the items we still need to check:
@Test
public void whenRemovingElementsConditionally_thenListNotContainingAny() {
List<String> tasks = new ArrayList<>(List.of("Task Important", "Task Minor", "Task Urgent", "Task Normal"));
for (int i = tasks.size() - 1; i >= 0; i--) {
String task = tasks.get(i);
if (task.contains("Minor") || task.contains("Normal")) {
tasks.remove(i);
}
}
// assertions
}
To improve this, we can use removeIf(), passing in a lambda expression as a Predicate argument to perform the filtering:
List<String> tasks = new ArrayList<>(List.of("Task Important", "Task Minor", "Task Urgent", "Task Normal"));
tasks.removeIf(task -> task.contains("Minor") || task.contains("Normal"));
4.3. Clearing the Entire List
Reassigning tasks = new ArrayList<>() makes the variable refer to a new, empty list. It does not empty the original list, which other references can still access.
If the variable is declared final, we cannot reassign it. When we need existing references to observe an empty list, we must modify the original list in place.
One manual approach is to remove elements one by one:
@Test
public void whenClearingAList_thenListIsEmpty() {
List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C"));
while (!tasks.isEmpty()) {
tasks.remove(0);
}
// assertions
}
The isEmpty() method is part of the Collection interface and returns true if the list contains no elements. It’s a convenient and readable way to check whether a list (or any collection) is empty, compared to checking tasks.size() == 0.
The clear() method removes all elements without an explicit loop. It modifies the list in place:
List<String> tasks = new ArrayList<>(List.of("Task A", "Task B"));
tasks.clear();
5. Replacing Elements With replaceAll()
Sometimes, we need to transform every element in the list, rather than adding or removing elements. Without replaceAll(), we’d typically iterate through the list using an index, such as in a traditional for loop. We can call set() with the index and new element to replace each item:
@Test
public void whenReplacingAllElements_thenAllElementsTransformed() {
List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C"));
for (int i = 0; i < tasks.size(); i++) {
String originalTask = tasks.get(i);
tasks.set(i, originalTask + " - Done");
}
// assertions
}
This works, but the List interface provides the replaceAll() method to perform this in a less verbose manner. By passing a lambda expression as a UnaryOperator instance to replaceAll(), we can adjust every element in place without creating a new list or managing indices manually:
List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C"));
tasks.replaceAll(task -> task + " - Done");
Let’s run the entire test class to verify that all tests still pass and produce the expected results:

6. Conclusion
Bulk operations let us express changes to groups of list elements directly, rather than managing each step of a loop. Choosing an operation that matches our intent makes additions, removals, and transformations easier to understand, while preserving the list and its existing references when we modify it in place.