Java ArrayList: Complete Guide with Practical Examples

Learn how to use ArrayList in Java for dynamic arrays with practical examples, performance considerations, and best practices.

Introduction to ArrayList

ArrayList is one of the most frequently used classes in the Java Collections Framework. It provides a resizable array implementation of the List interface, which means it can grow and shrink dynamically as you add or remove elements. Unlike a standard Java array, whose size is fixed at the time of creation, an ArrayList handles resizing automatically behind the scenes.

The class belongs to the java.util package and has been part of Java since version 1.2. In real-world applications, you will encounter ArrayList almost everywhere — from storing query results to holding lists of domain objects in a Spring Boot controller. Understanding how it works internally and knowing its strengths and limitations will help you write more efficient and maintainable Java code.

When to Use ArrayList

Choose ArrayList when you need a collection that grows dynamically, when you frequently access elements by their index, and when you mostly add elements at the end of the list. If your application performs many insertions or deletions in the middle of a large list, consider LinkedList instead. For unique elements with fast lookups, look into HashSet .

Prerequisites

Before working through this tutorial, you should be comfortable with the following Java fundamentals:

  • Basic Java syntax, including variable declarations and data types
  • How to create and use standard arrays
  • Classes and objects , including constructors and method calls
  • Basic understanding of generics (the angle-bracket notation for type parameters)
  • The concept of interfaces (ArrayList implements the List interface)

If any of these topics feel unfamiliar, we recommend completing the linked tutorials first. Having a solid foundation will make the ArrayList concepts much easier to grasp.

What You Will Learn

By the end of this tutorial, you will be able to:

  • Create and initialize ArrayLists using different approaches
  • Add, remove, access, and update elements in an ArrayList
  • Iterate over an ArrayList using multiple techniques
  • Sort and search elements within an ArrayList
  • Explain the internal mechanics of how ArrayList grows
  • Identify and avoid common ArrayList pitfalls
  • Apply best practices for performance and readability
  • Decide when ArrayList is the right choice versus other collection types

How ArrayList Works Internally

At its core, an ArrayList is backed by a regular Java array. When you create an ArrayList with the default constructor, it allocates an empty array with an initial capacity of 10. As you add elements, they are placed into this backing array one by one.

The critical difference from a plain array is what happens when the backing array becomes full. Instead of throwing an error, the ArrayList automatically performs a resize operation :

  1. A new, larger array is allocated (typically 1.5 times the previous capacity)
  2. All existing elements are copied from the old array into the new one
  3. The internal reference is updated to point to the new array
  4. The old array becomes eligible for garbage collection

This resizing is transparent to you as the developer, but it has a real performance cost — copying n elements takes O(n) time. If you know in advance roughly how many elements the list will hold, you can specify an initial capacity to avoid unnecessary resizes.

ArrayList preserves the order in which elements were added (insertion order), allows duplicate elements, and permits null values. It does not provide any built-in synchronization, meaning it is not safe for concurrent modification by multiple threads without external synchronization.

Key Characteristics at a Glance

  • Implements the List interface (and therefore Collection and Iterable )
  • Allows duplicate and null elements
  • Maintains insertion order
  • Provides O(1) random access by index
  • Amortized O(1) append at the end
  • O(n) insertion or deletion in the middle (due to element shifting)
  • Not thread-safe

Creating an ArrayList — Syntax

There are several ways to create an ArrayList. The most common forms are shown below:

Java
import java.util.ArrayList;
import java.util.List;

// 1. Empty ArrayList with default initial capacity (10)
List<String> names = new ArrayList<>();

// 2. Empty ArrayList with a specified initial capacity
List<Integer> numbers = new ArrayList<>(500);

// 3. ArrayList initialized from another collection
List<String> original = List.of("A", "B", "C");
List<String> copy = new ArrayList<>(original);

// 4. Using List.of() for an immutable list (Java 9+)
List<String> immutable = List.of("X", "Y", "Z");

Declare as List, Not ArrayList

Notice that the variable type above is List<String> , not ArrayList<String> . This follows the principle of programming to an interface . If you later decide to switch to a LinkedList , you only need to change the right-hand side of the assignment — the rest of your code continues to work because it depends on the List interface, not a specific implementation.

Simple Example — Adding and Accessing Elements

Here is a straightforward example that creates an ArrayList of strings, adds a few elements, and then accesses them:

Java
import java.util.ArrayList;
import java.util.List;

public class ArrayListBasicExample {
    public static void main(String[] args) {
        // Create an ArrayList to hold fruit names
        List<String> fruits = new ArrayList<>();

        // Add elements — they are appended to the end
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Orange");
        fruits.add("Mango");

        // Print the entire list
        System.out.println("Fruits: " + fruits);

        // Access an element by index (zero-based)
        String second = fruits.get(1);
        System.out.println("Second fruit: " + second);

        // Get the number of elements
        System.out.println("Total fruits: " + fruits.size());

        // Check whether the list contains a specific value
        boolean hasMango = fruits.contains("Mango");
        System.out.println("Contains Mango? " + hasMango);
    }
}
Output
Fruits: [Apple, Banana, Orange, Mango] Second fruit: Banana Total fruits: 4 Contains Mango? true

How the Example Works

Let's walk through the code line by line to understand exactly what happens:

  1. import java.util.List; — We import the List interface because we are declaring our variable as List<String> . The ArrayList import brings in the concrete class.
  2. List<String> fruits = new ArrayList<>(); — This creates an empty ArrayList backed by an array of capacity 10. The diamond operator <> tells the compiler to infer the generic type ( String ) from the left-hand side.
  3. fruits.add("Apple"); — The add() method appends the string to the end of the backing array and increments the internal size counter.
  4. System.out.println("Fruits: " + fruits); — When an ArrayList is concatenated with a string, its toString() method is called automatically. The default implementation returns the elements enclosed in square brackets, separated by commas.
  5. fruits.get(1); — This directly accesses position 1 in the backing array, which is the second element ("Banana") because indexing starts at 0. This operation runs in constant time O(1).
  6. fruits.size(); — Returns the current number of elements (4), which is not the same as the backing array's capacity (10).
  7. fruits.contains("Mango"); — Iterates through the list and checks each element using equals() . Returns true as soon as a match is found. This runs in O(n) time in the worst case.

Common ArrayList Operations

ArrayList provides a rich set of methods. Below is a practical example that covers the operations you will use most often:

Java
import java.util.ArrayList;
import java.util.List;

public class ArrayListOperations {
    public static void main(String[] args) {
        List<String> colors = new ArrayList<>();

        // --- Adding elements ---
        colors.add("Red");       // appends to the end
        colors.add("Green");
        colors.add("Blue");
        colors.add(1, "Yellow"); // inserts at index 1, shifts later elements right
        System.out.println("After adds: " + colors);

        // --- Updating an element ---
        colors.set(0, "Crimson"); // replaces element at index 0
        System.out.println("After set:  " + colors);

        // --- Removing elements ---
        colors.remove(2);          // removes by index
        colors.remove("Yellow");  // removes by value (first occurrence)
        System.out.println("After removes: " + colors);

        // --- Checking contents ---
        System.out.println("Is empty? " + colors.isEmpty());
        System.out.println("Index of Crimson: " + colors.indexOf("Crimson"));

        // --- Converting to an array ---
        String[] array = colors.toArray(new String[0]);
        System.out.println("Array length: " + array.length);

        // --- Clearing all elements ---
        colors.clear();
        System.out.println("After clear: " + colors);
    }
}
Output
After adds: [Red, Yellow, Green, Blue] After set: [Crimson, Yellow, Green, Blue] After removes: [Crimson, Blue] Is empty? false Index of Crimson: 0 Array length: 2 After clear: []

Key observations from this example:

  • add(index, element) shifts every element from that index onward one position to the right. For a list with n elements, this is an O(n) operation.
  • set(index, element) directly replaces the element at the given position — it does not shift anything and runs in O(1).
  • remove(int) removes by index and shifts the remaining elements left. remove(Object) removes the first occurrence of the specified value. Both are O(n).
  • toArray(new String[0]) is the recommended way to convert a List to an array. Passing a zero-length array lets the JVM allocate a correctly sized array internally.

Iterating Over an ArrayList

There are several ways to loop through the elements of an ArrayList. Each approach has its own use case:

Java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ArrayListIteration {
    public static void main(String[] args) {
        List<String> languages = new ArrayList<>();
        languages.add("Java");
        languages.add("Python");
        languages.add("C++");
        languages.add("Go");

        // 1. Classic for loop with index
        System.out.println("Classic for loop:");
        for (int i = 0; i < languages.size(); i++) {
            System.out.println((i) + ": " + languages.get(i));
        }

        // 2. Enhanced for loop (for-each)
        System.out.println("\nEnhanced for loop:");
        for (String lang : languages) {
            System.out.println(lang);
        }

        // 3. Iterator (safe for removal during iteration)
        System.out.println("\nIterator:");
        Iterator<String> it = languages.iterator();
        while (it.hasNext()) {
            String lang = it.next();
            System.out.println(lang);
        }

        // 4. forEach with lambda (Java 8+)
        System.out.println("\nforEach with lambda:");
        languages.forEach(lang -> System.out.println(lang));
    }
}
Output
Classic for loop: 0: Java 1: Python 2: C++ 3: Go Enhanced for loop: Java Python C++ Go Iterator: Java Python C++ Go forEach with lambda: Java Python C++ Go

Which Iteration Style Should You Use?

For most read-only cases, the enhanced for loop or forEach with a lambda is the cleanest option. Use the classic for loop only when you need the index. Use an Iterator when you need to remove elements during iteration — the for-each loop will throw a ConcurrentModificationException if you try to call remove() on the list itself while iterating.

Sorting an ArrayList

Java provides convenient methods for sorting ArrayList elements. The approach depends on whether you are sorting primitive wrappers, strings, or custom objects:

Java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class ArrayListSorting {
    public static void main(String[] args) {
        // Sorting strings (natural order — alphabetical)
        List<String> names = new ArrayList<>();
        names.add("Charlie");
        names.add("Alice");
        names.add("Bob");

        names.sort(null); // null means natural ordering for Comparable types
        System.out.println("Alphabetical: " + names);

        // Sorting in reverse order
        names.sort(Comparator.reverseOrder());
        System.out.println("Reverse:      " + names);

        // Sorting integers
        List<Integer> numbers = new ArrayList<>();
        numbers.add(42);
        numbers.add(7);
        numbers.add(15);
        numbers.add(3);

        numbers.sort(Comparator.naturalOrder());
        System.out.println("Ascending:    " + numbers);

        numbers.sort(Comparator.reverseOrder());
        System.out.println("Descending:   " + numbers);
    }
}
Output
Alphabetical: [Alice, Bob, Charlie] Reverse: [Charlie, Bob, Alice] Ascending: [3, 7, 15, 42] Descending: [42, 15, 7, 3]

For sorting custom objects, your class must implement Comparable and override compareTo() , or you can provide a standalone Comparator . Here is a quick example using a Comparator with a custom class:

Java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

class Product {
    private String name;
    private double price;

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    public String getName() { return name; }
    public double getPrice() { return price; }

    @Override
    public String toString() {
        return name + " ($" + price + ")";
    }
}

public class CustomObjectSorting {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("Laptop", 999.99));
        products.add(new Product("Mouse", 25.50));
        products.add(new Product("Keyboard", 79.99));

        // Sort by price ascending
        products.sort(Comparator.comparingDouble(Product::getPrice));
        System.out.println("By price (low to high): " + products);

        // Sort by name alphabetically
        products.sort(Comparator.comparing(Product::getName));
        System.out.println("By name:                " + products);
    }
}
Output
By price (low to high): [Mouse ($25.5), Keyboard ($79.99), Laptop ($999.99)] By name: [Keyboard ($79.99), Laptop ($999.99), Mouse ($25.5)]

Real-World Example — Task Manager

Let's build a small task management program that demonstrates how ArrayList works with a custom class. This kind of structure is common in real applications where you need to track a collection of domain objects:

Java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

class Task {
    private int id;
    private String description;
    private boolean completed;

    public Task(int id, String description) {
        this.id = id;
        this.description = description;
        this.completed = false;
    }

    public int getId() { return id; }

    public String getDescription() { return description; }

    public boolean isCompleted() { return completed; }

    public void markComplete() { this.completed = true; }

    @Override
    public String toString() {
        return "[" + id + "] " + description
                + (completed ? " (DONE)" : " (PENDING)");
    }
}

public class TaskManager {
    private List<Task> tasks;
    private int nextId;

    public TaskManager() {
        tasks = new ArrayList<>();
        nextId = 1;
    }

    public void addTask(String description) {
        tasks.add(new Task(nextId++, description));
        System.out.println("Added task: " + description);
    }

    public void completeTask(int id) {
        for (Task task : tasks) {
            if (task.getId() == id) {
                task.markComplete();
                System.out.println("Completed: " + task);
                return;
            }
        }
        System.out.println("Task not found with ID: " + id);
    }

    public void removeCompletedTasks() {
        Iterator<Task> iterator = tasks.iterator();
        int removed = 0;
        while (iterator.hasNext()) {
            if (iterator.next().isCompleted()) {
                iterator.remove();
                removed++;
            }
        }
        System.out.println("Removed " + removed + " completed task(s).");
    }

    public void displayPendingTasks() {
        System.out.println("\n--- Pending Tasks ---");
        boolean found = false;
        for (Task task : tasks) {
            if (!task.isCompleted()) {
                System.out.println(task);
                found = true;
            }
        }
        if (!found) {
            System.out.println("No pending tasks.");
        }
    }

    public static void main(String[] args) {
        TaskManager manager = new TaskManager();

        manager.addTask("Write unit tests for UserServlet");
        manager.addTask("Fix NullPointerException in OrderService");
        manager.addTask("Update README with API documentation");
        manager.addTask("Review pull request #42");

        // Complete a couple of tasks
        manager.completeTask(2);
        manager.completeTask(4);

        // Show only pending tasks
        manager.displayPendingTasks();

        // Clean up completed tasks
        manager.removeCompletedTasks();

        // Show remaining tasks
        manager.displayPendingTasks();
    }
}
Output
Added task: Write unit tests for UserServlet Added task: Fix NullPointerException in OrderService Added task: Update README with API documentation Added task: Review pull request #42 Completed: [2] Fix NullPointerException in OrderService (DONE) Completed: [4] Review pull request #42 (DONE) --- Pending Tasks --- [1] Write unit tests for UserServlet (PENDING) [3] Update README with API documentation (PENDING) Removed 2 completed task(s). --- Pending Tasks --- [1] Write unit tests for UserServlet (PENDING) [3] Update README with API documentation (PENDING)

This example highlights several important patterns:

  • Encapsulating the ArrayList inside a class (the TaskManager ) rather than exposing it directly — this is a good practice because it lets you control how the list is modified.
  • Using an Iterator to safely remove elements while iterating — calling tasks.remove() inside a for-each loop would throw a ConcurrentModificationException .
  • Auto-incrementing IDs using a simple counter — this avoids duplicate identifiers without needing a database.

Common Mistakes to Avoid

After reviewing hundreds of Java programs, these are the most frequent ArrayList mistakes we see developers make:

Mistake 1: Using Raw Types Instead of Generics

Java — Incorrect
// BAD: No type parameter — compiler cannot help you
List rawList = new ArrayList();
rawList.add("Hello");
rawList.add(42); // No compile error, but mixed types!

String value = (String) rawList.get(1); // ClassCastException at runtime
Java — Correct
// GOOD: Type parameter catches errors at compile time
List<String> safeList = new ArrayList<>();
safeList.add("Hello");
// safeList.add(42); // Compile error — cannot add Integer

String value = safeList.get(0); // No cast needed

Mistake 2: Removing Elements During For-Each Iteration

Java — Incorrect
List<String> items = new ArrayList<>();
items.add("A");
items.add("B");
items.add("C");

// BAD: Throws ConcurrentModificationException
for (String item : items) {
    if (item.equals("B")) {
        items.remove(item);
    }
}
Java — Correct
// GOOD Option 1: Use Iterator.remove()
Iterator<String> it = items.iterator();
while (it.hasNext()) {
    if (it.next().equals("B")) {
        it.remove();
    }
}

// GOOD Option 2: Use removeIf() (Java 8+)
items.removeIf(item -> item.equals("B"));

Mistake 3: Accessing Indices That Do Not Exist

Java — Incorrect
List<String> list = new ArrayList<>();
list.add("Only");

// BAD: Index 5 does not exist — throws IndexOutOfBoundsException
String s = list.get(5);
Java — Correct
// GOOD: Validate the index before accessing
int index = 5;
if (index >= 0 && index < list.size()) {
    String s = list.get(index);
} else {
    System.out.println("Index out of range.");
}

Mistake 4: Confusing size() with Capacity

Java — Common Misunderstanding
List<String> list = new ArrayList<>(); // backing array capacity = 10
list.add("A");

// size() returns 1 (actual number of elements)
// It does NOT return 10 (the internal array capacity)
System.out.println(list.size()); // prints 1, not 10

// If you need the capacity, you must cast to ArrayList
int capacity = ((ArrayList<?>) list).trimToSize();
// Note: there is no public getCapacity() method.
// trimToSize() shrinks the backing array to match size().

Best Practices

Following these guidelines will help you use ArrayList effectively and write cleaner code:

  1. Always specify the generic type. Never use raw types. Generics catch type errors at compile time rather than at runtime, making your programs safer and more readable.
  2. Set an initial capacity when the size is predictable. If you know a list will hold roughly 10,000 elements, create it as new ArrayList<>(10_000) . This avoids multiple resize operations during population.
  3. Program to the List interface, not the ArrayList class. Declaring variables as List<T> makes it easy to swap implementations later without changing the rest of your code.
  4. Use isEmpty() instead of size() == 0 . Both work, but isEmpty() communicates intent more clearly and may be marginally faster on some implementations.
  5. Prefer removeIf() for conditional removal (Java 8+). It is more readable than a manual iterator loop and less error-prone than the for-each approach.
  6. Use Arrays.asList() or List.of() for fixed lists. If you do not need the list to be resizable, these factory methods are more concise and produce immutable lists (in the case of List.of() ).
  7. Call trimToSize() after bulk removals. If you remove a large number of elements and know the list will not grow again, trimming frees the unused memory in the backing array.
  8. Do not use ArrayList in multi-threaded code without synchronization. Use Collections.synchronizedList() , CopyOnWriteArrayList , or explicit synchronization if multiple threads will modify the list concurrently.

Performance Considerations

Understanding the time complexity of ArrayList operations helps you make informed decisions and avoid subtle performance problems:

Time Complexity Summary

Operation Time Complexity Notes
get(index) O(1) Direct array access
set(index, element) O(1) Direct array access
add(element) Amortized O(1) O(n) only when resizing occurs
add(index, element) O(n) Elements must be shifted right
remove(index) O(n) Elements must be shifted left
remove(object) O(n) Search + shift
contains(object) O(n) Linear search
indexOf(object) O(n) Linear search
clear() O(n) Nulls out all references

Memory Overhead

ArrayList consumes more memory than a plain array of the same logical size because:

  • The object header and internal fields ( int size , int modCount , and the array reference) add overhead.
  • The backing array may have unused slots (capacity > size), wasting memory.
  • Each element stored in an ArrayList is a reference (4 or 8 bytes depending on the JVM), so the actual objects live elsewhere on the heap.

In memory-constrained environments, or when storing millions of primitive values, consider using specialized libraries like Trove or Eclipse Collections that offer primitive-aware lists, or stick with plain arrays.

ArrayList vs. LinkedList — A Practical Comparison

Developers sometimes assume LinkedList is always better for insertions and deletions. In practice, ArrayList outperforms LinkedList in most real-world scenarios due to better CPU cache locality — the backing array stores elements contiguously in memory, which modern CPUs handle efficiently. LinkedList nodes are scattered across the heap, causing more cache misses.

Use LinkedList only when you have a proven performance bottleneck involving frequent insertions or deletions at known positions within a very large list. Always measure with a profiler before switching.

Exercises

Apply what you have learned by completing these hands-on exercises. Try to solve each one before looking at the solution.

Exercise 1: Filter and Sum

Create an ArrayList<Integer> containing the numbers 1 through 20. Then:

  1. Remove all even numbers from the list.
  2. Calculate and print the sum of the remaining numbers.
  3. Print the final list.

Exercise 2: Merge Two Lists Without Duplicates

Given two ArrayList<String> objects, write a method that returns a new ArrayList containing all elements from both lists, but without any duplicate values. Preserve the order of first appearance.

Exercise 3: Employee Search

Create an Employee class with fields for name, department, and salary. Create an ArrayList of at least 8 employees across different departments. Then:

  1. Find and print all employees in a given department.
  2. Find the employee with the highest salary.
  3. Calculate the average salary per department.

Exercise 4: ArrayList as a Stack

Implement a simple stack (last-in, first-out) using only ArrayList . Your stack should support push() , pop() , peek() , and isEmpty() operations. Test it by pushing the values 10, 20, 30 and then popping them off one by one, printing each popped value.

Solutions

Solution to Exercise 1: Filter and Sum

Java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class FilterAndSum {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        for (int i = 1; i <= 20; i++) {
            numbers.add(i);
        }

        // Remove even numbers using an Iterator
        Iterator<Integer> iterator = numbers.iterator();
        while (iterator.hasNext()) {
            if (iterator.next() % 2 == 0) {
                iterator.remove();
            }
        }

        // Calculate the sum
        int sum = 0;
        for (int n : numbers) {
            sum += n;
        }

        System.out.println("Remaining numbers: " + numbers);
        System.out.println("Sum: " + sum);
    }
}
Output
Remaining numbers: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19] Sum: 100

The sum of all odd numbers from 1 to 19 is 100. The Iterator approach safely removes elements without triggering a ConcurrentModificationException . Alternatively, you could use numbers.removeIf(n -> n % 2 == 0); for a more concise solution.

Solution to Exercise 2: Merge Without Duplicates

Java
import java.util.ArrayList;
import java.util.List;

public class MergeLists {
    public static List<String> mergeWithoutDuplicates(
            List<String> list1, List<String> list2) {
        List<String> merged = new ArrayList<>();

        for (String item : list1) {
            if (!merged.contains(item)) {
                merged.add(item);
            }
        }
        for (String item : list2) {
            if (!merged.contains(item)) {
                merged.add(item);
            }
        }
        return merged;
    }

    public static void main(String[] args) {
        List<String> a = new ArrayList<>();
        a.add("Java");
        a.add("Python");
        a.add("Go");

        List<String> b = new ArrayList<>();
        b.add("Python");
        b.add("Rust");
        b.add("Go");

        List<String> result = mergeWithoutDuplicates(a, b);
        System.out.println("Merged: " + result);
    }
}
Output
Merged: [Java, Python, Go, Rust]

This approach preserves the order of first appearance. Note that contains() is O(n), so the overall method is O(n × m) where n and m are the sizes of the two lists. For very large lists, a LinkedHashSet would be more efficient because it provides O(1) contains checks while preserving insertion order.

Solution to Exercise 3: Employee Search

Java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class Employee {
    private String name;
    private String department;
    private double salary;

    public Employee(String name, String department, double salary) {
        this.name = name;
        this.department = department;
        this.salary = salary;
    }

    public String getName() { return name; }
    public String getDepartment() { return department; }
    public double getSalary() { return salary; }

    @Override
    public String toString() {
        return name + " | " + department + " | $" + salary;
    }
}

public class EmployeeSearch {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("Alice", "Engineering", 95000));
        employees.add(new Employee("Bob", "Marketing", 62000));
        employees.add(new Employee("Carol", "Engineering", 110000));
        employees.add(new Employee("Dave", "HR", 58000));
        employees.add(new Employee("Eve", "Engineering", 88000));
        employees.add(new Employee("Frank", "Marketing", 71000));
        employees.add(new Employee("Grace", "HR", 64000));
        employees.add(new Employee("Hank", "Engineering", 102000));

        // 1. Find all employees in Engineering
        System.out.println("--- Engineering Employees ---");
        for (Employee emp : employees) {
            if (emp.getDepartment().equals("Engineering")) {
                System.out.println(emp);
            }
        }

        // 2. Find the highest-paid employee
        Employee highest = employees.get(0);
        for (Employee emp : employees) {
            if (emp.getSalary() > highest.getSalary()) {
                highest = emp;
            }
        }
        System.out.println("\nHighest paid: " + highest);

        // 3. Average salary per department
        Map<String, List<Double>> byDept = new HashMap<>();
        for (Employee emp : employees) {
            byDept.computeIfAbsent(
                emp.getDepartment(), k -> new ArrayList<>())
                .add(emp.getSalary());
        }

        System.out.println("\n--- Average Salary by Department ---");
        for (Map.Entry<String, List<Double>> entry : byDept.entrySet()) {
            double avg = entry.getValue().stream()
                    .mapToDouble(Double::doubleValue)
                    .average()
                    .orElse(0);
            System.out.printf("%s: $%.2f%n", entry.getKey(), avg);
        }
    }
}
Output
--- Engineering Employees --- Alice | Engineering | $95000.0 Carol | Engineering | $110000.0 Eve | Engineering | $88000.0 Hank | Engineering | $102000.0 Highest paid: Carol | Engineering | $110000.0 --- Average Salary by Department --- Engineering: $98750.00 HR: $61000.00 Marketing: $66500.00

Solution to Exercise 4: ArrayList as a Stack

Java
import java.util.ArrayList;

public class ArrayListStack {
    private ArrayList<Integer> stack = new ArrayList<>();

    public void push(int value) {
        stack.add(value);
    }

    public int pop() {
        if (stack.isEmpty()) {
            throw new RuntimeException("Stack is empty");
        }
        return stack.remove(stack.size() - 1);
    }

    public int peek() {
        if (stack.isEmpty()) {
            throw new RuntimeException("Stack is empty");
        }
        return stack.get(stack.size() - 1);
    }

    public boolean isEmpty() {
        return stack.isEmpty();
    }

    public static void main(String[] args) {
        ArrayListStack stack = new ArrayListStack();
        stack.push(10);
        stack.push(20);
        stack.push(30);

        System.out.println("Top of stack: " + stack.peek());

        System.out.println("Popped: " + stack.pop());
        System.out.println("Popped: " + stack.pop());
        System.out.println("Popped: " + stack.pop());

        System.out.println("Is empty? " + stack.isEmpty());
    }
}
Output
Top of stack: 30 Popped: 30 Popped: 20 Popped: 10 Is empty? true

This implementation uses the end of the ArrayList as the top of the stack. push() appends to the end (amortized O(1)), and pop() removes from the end (O(1) because no element shifting is needed). In production code, you would typically use ArrayDeque as a stack instead of Stack (which is legacy) or a custom ArrayList wrapper, but this exercise demonstrates how ArrayList can serve as the underlying data structure.

Summary

ArrayList is a versatile and widely-used collection class that provides a resizable array implementation of the List interface. Here are the key takeaways from this tutorial:

  • ArrayList is backed by a regular Java array that grows automatically (by roughly 1.5×) when it runs out of space.
  • It offers O(1) random access by index, making it excellent for read-heavy workloads.
  • Insertions and deletions in the middle of the list are O(n) because elements must be shifted.
  • Always use generics to ensure type safety and eliminate the need for casting.
  • Use an Iterator or removeIf() when you need to remove elements during iteration.
  • Set an initial capacity when the expected size is known to avoid unnecessary resizing.
  • ArrayList is not thread-safe — use synchronized wrappers or concurrent collections for multi-threaded scenarios.
  • In most real-world cases, ArrayList outperforms LinkedList due to better cache locality.

Frequently Asked Questions

A regular Java array has a fixed size that cannot change after creation. If you allocate an array of length 5, it will always hold exactly 5 elements (or nulls). ArrayList, on the other hand, is a resizable array implementation that automatically grows when you add elements beyond its current capacity and can shrink when elements are removed.

Arrays can hold both primitives (int, double, etc.) and objects directly. ArrayList can only hold objects — when you add a primitive like an int, Java automatically boxes it into its wrapper class (Integer). Arrays also use special syntax with square brackets ( String[] arr ), while ArrayList is a class with methods like add() , remove() , and get() .

No, ArrayList is not thread-safe. If multiple threads modify the same ArrayList concurrently without external synchronization, the results are undefined — you may see inconsistent state, missed elements, or a ConcurrentModificationException .

To use a list safely across threads, you have several options: wrap the ArrayList with Collections.synchronizedList() , use CopyOnWriteArrayList from the java.util.concurrent package (which creates a new copy of the array on every write), or manually synchronize access using synchronized blocks. Each approach has different performance trade-offs, so choose based on your read-to-write ratio.

Use ArrayList when you need fast random access to elements by index and when you mostly add or remove elements at the end of the list. ArrayList provides O(1) random access but O(n) insertion and deletion in the middle due to element shifting.

Use LinkedList when you frequently insert or delete elements at known positions within the list, as these operations are O(1) for LinkedList (once you have a reference to the position) versus O(n) for ArrayList. However, in practice, ArrayList is faster than LinkedList for most real-world workloads because its backing array has much better CPU cache locality — contiguous memory access is significantly faster than following pointers between scattered nodes. Always measure with a profiler before switching from ArrayList to LinkedList based on theoretical complexity alone.

Yes, ArrayList can contain null values. You can add null as an element using add(null) , and the list can hold multiple null values since it allows duplicates. However, you need to be careful when working with null elements — calling methods on a null element will throw a NullPointerException , and operations like sort(null) (natural ordering) will fail if the list contains nulls because comparing null with a non-null value is not defined for most Comparable types.

When you add an element and the backing array is full, the ArrayList automatically increases its capacity. It creates a new array that is typically 1.5 times the size of the current array (the exact formula is newCapacity = oldCapacity + (oldCapacity >> 1) ), copies all existing elements from the old array into the new one, and then inserts the new element. The old array becomes eligible for garbage collection.

This resize operation takes O(n) time where n is the number of existing elements, which is why it is called "amortized O(1)" for add — most adds are O(1), but occasionally one triggers a resize and costs O(n). If you know approximately how many elements the list will hold, setting an initial capacity with new ArrayList<>(estimatedSize) avoids these resizes entirely.

The recommended approach is to call toArray(T[] array) with a zero-length array of the desired type:

String[] array = list.toArray(new String[0]);

This works because the toArray method checks if the provided array is large enough. If it is not (which is the case with a zero-length array), the method allocates a new array of the correct size and returns it. The alternative — passing a pre-sized array like new String[list.size()] — also works but is slightly more verbose and creates an array that will be discarded if the JVM internally allocates a different one anyway.