🔄 Java List Interface and Implementations

📚 Introduction to Java List Interface

The List interface is one of the most fundamental and widely used interfaces in the Java Collections Framework. It represents an ordered collection of elements where duplicates are allowed. Unlike sets, lists maintain insertion order and allow positional access and manipulation of elements.

As part of the Java Collections Framework, the List interface extends the Collection interface and adds methods specifically designed for ordered collections. This makes lists perfect for scenarios where you need to:

  • Maintain elements in a specific order
  • Access elements by their position (index)
  • Allow duplicate elements
  • Insert or remove elements at specific positions

List Interface implenetation Diagram

The Java Collections Framework provides several implementations of the List interface, each with its own characteristics, advantages, and trade-offs:

  1. ArrayList: A resizable array implementation that offers fast random access but slower insertions and deletions.
  2. LinkedList: A doubly-linked list implementation that provides fast insertions and deletions but slower random access.
  3. Vector: A synchronized (thread-safe) version of ArrayList.
  4. Stack: A subclass of Vector that implements the Last-In-First-Out (LIFO) stack data structure.

In this tutorial, we'll explore the List interface in depth, examine its various implementations, and learn how to use them effectively in your Java applications.

🧩 The List Interface in Java: Core Methods and Functionality

The List interface defines several methods beyond those inherited from the Collection interface. These methods take advantage of the ordered nature of lists and provide powerful functionality for working with indexed collections.

Core Methods of the List Interface in Java

public interface List<E> extends Collection<E> {
    // Positional access
    E get(int index);
    E set(int index, E element);
    void add(int index, E element);
    E remove(int index);
    
    // Search
    int indexOf(Object o);
    int lastIndexOf(Object o);
    
    // List iteration
    ListIterator<E> listIterator();
    ListIterator<E> listIterator(int index);
    
    // View
    List<E> subList(int fromIndex, int toIndex);
}

Let's explore these methods with examples:

Basic List Operations

import java.util.*;

public class BasicListOperationsDemo {
    public static void main(String[] args) {
        // Create a list
        List<String> fruits = new ArrayList<>();
        
        // Add elements
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        System.out.println("Initial list: " + fruits);
        
        // Access by index
        String second = fruits.get(1);
        System.out.println("Second fruit: " + second);
        
        // Modify an element
        fruits.set(1, "Blueberry");
        System.out.println("After replacing Banana with Blueberry: " + fruits);
        
        // Insert at specific position
        fruits.add(1, "Banana");
        System.out.println("After inserting Banana at index 1: " + fruits);
        
        // Remove by index
        String removed = fruits.remove(2);
        System.out.println("Removed: " + removed);
        System.out.println("After removing element at index 2: " + fruits);
        
        // Remove by object
        boolean wasRemoved = fruits.remove("Apple");
        System.out.println("Was Apple removed? " + wasRemoved);
        System.out.println("After removing Apple: " + fruits);
        
        // Find position of an element
        fruits.add("Cherry");
        fruits.add("Banana");
        System.out.println("Updated list: " + fruits);
        
        int firstBanana = fruits.indexOf("Banana");
        int lastBanana = fruits.lastIndexOf("Banana");
        System.out.println("First occurrence of Banana: index " + firstBanana);
        System.out.println("Last occurrence of Banana: index " + lastBanana);
        
        // Check if list contains an element
        boolean containsCherry = fruits.contains("Cherry");
        System.out.println("Contains Cherry? " + containsCherry);
        
        // Get size of list
        int size = fruits.size();
        System.out.println("List size: " + size);
        
        // Clear the list
        fruits.clear();
        System.out.println("After clearing: " + fruits);
        System.out.println("Is list empty? " + fruits.isEmpty());
    }
}

Output:

Initial list: [Apple, Banana, Cherry]
Second fruit: Banana
After replacing Banana with Blueberry: [Apple, Blueberry, Cherry]
After inserting Banana at index 1: [Apple, Banana, Blueberry, Cherry]
Removed: Blueberry
After removing element at index 2: [Apple, Banana, Cherry]
Was Apple removed? true
After removing Apple: [Banana, Cherry]
Updated list: [Banana, Cherry, Banana]
First occurrence of Banana: index 0
Last occurrence of Banana: index 2
Contains Cherry? true
List size: 3
After clearing: []
Is list empty? true

Java List Iteration

Lists provide multiple ways to iterate through their elements:

import java.util.*;

public class ListIterationDemo {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        
        // 1. Using enhanced for loop
        System.out.println("Using enhanced for loop:");
        for (String fruit : fruits) {
            System.out.println(fruit);
        }
        
        // 2. Using traditional for loop with index
        System.out.println("\nUsing traditional for loop:");
        for (int i = 0; i < fruits.size(); i++) {
            System.out.println(i + ": " + fruits.get(i));
        }
        
        // 3. Using Iterator
        System.out.println("\nUsing Iterator:");
        Iterator<String> iterator = fruits.iterator();
        while (iterator.hasNext()) {
            String fruit = iterator.next();
            System.out.println(fruit);
        }
        
        // 4. Using ListIterator (can move both forward and backward)
        System.out.println("\nUsing ListIterator (forward):");
        ListIterator<String> listIterator = fruits.listIterator();
        while (listIterator.hasNext()) {
            int index = listIterator.nextIndex();
            String fruit = listIterator.next();
            System.out.println(index + ": " + fruit);
        }
        
        System.out.println("\nUsing ListIterator (backward):");
        while (listIterator.hasPrevious()) {
            int index = listIterator.previousIndex();
            String fruit = listIterator.previous();
            System.out.println(index + ": " + fruit);
        }
        
        // 5. Using forEach method (Java 8+)
        System.out.println("\nUsing forEach method:");
        fruits.forEach(fruit -> System.out.println(fruit));
        
        // 6. Using Stream API (Java 8+)
        System.out.println("\nUsing Stream API:");
        fruits.stream()
              .forEach(System.out::println);
    }
}

Output:

Using enhanced for loop:
Apple
Banana
Cherry

Using traditional for loop:
0: Apple
1: Banana
2: Cherry

Using Iterator:
Apple
Banana
Cherry

Using ListIterator (forward):
0: Apple
1: Banana
2: Cherry

Using ListIterator (backward):
2: Cherry
1: Banana
0: Apple

Using forEach method:
Apple
Banana
Cherry

Using Stream API:
Apple
Banana
Cherry

Java List Views with subList()

The subList() method returns a view of a portion of the list:

import java.util.*;

public class SubListDemo {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        fruits.add("Dragonfruit");
        fruits.add("Elderberry");
        
        System.out.println("Original list: " + fruits);
        
        // Get a sublist view (fromIndex inclusive, toIndex exclusive)
        List<String> subList = fruits.subList(1, 4);
        System.out.println("Sublist (1-4): " + subList);
        
        // Modifying the sublist affects the original list
        subList.set(0, "Blackberry");  // Replace Banana with Blackberry
        System.out.println("After modifying sublist: " + fruits);
        
        // Adding to the sublist affects the original list
        subList.add("Cantaloupe");  // Add at the end of sublist
        System.out.println("After adding to sublist: " + fruits);
        
        // Removing from the sublist affects the original list
        subList.remove("Cherry");
        System.out.println("After removing from sublist: " + fruits);
        
        // Clearing the sublist affects only that portion of the original list
        subList.clear();
        System.out.println("After clearing sublist: " + fruits);
        
        // WARNING: Modifying the original list directly while working with a
        // sublist can cause ConcurrentModificationException
        List<String> anotherSubList = fruits.subList(0, 2);
        System.out.println("Another sublist: " + anotherSubList);
        
        try {
            fruits.add("Fig");  // Modify original list
            System.out.println(anotherSubList);  // Try to access sublist
        } catch (ConcurrentModificationException e) {
            System.out.println("ConcurrentModificationException caught!");
            System.out.println("Cannot access sublist after modifying original list");
        }
    }
}

Output:

Original list: [Apple, Banana, Cherry, Dragonfruit, Elderberry]
Sublist (1-4): [Banana, Cherry, Dragonfruit]
After modifying sublist: [Apple, Blackberry, Cherry, Dragonfruit, Elderberry]
After adding to sublist: [Apple, Blackberry, Cherry, Dragonfruit, Cantaloupe, Elderberry]
After removing from sublist: [Apple, Blackberry, Dragonfruit, Cantaloupe, Elderberry]
After clearing sublist: [Apple, Elderberry]
Another sublist: [Apple, Elderberry]
ConcurrentModificationException caught!
Cannot access sublist after modifying original list

Bulk Operations in Java List

Lists inherit bulk operations from the Collection interface:

import java.util.*;

public class ListBulkOperationsDemo {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        
        List<String> moreFruits = new ArrayList<>();
        moreFruits.add("Banana");
        moreFruits.add("Dragonfruit");
        
        System.out.println("List 1: " + fruits);
        System.out.println("List 2: " + moreFruits);
        
        // Add all elements from another collection
        List<String> combined = new ArrayList<>(fruits);
        combined.addAll(moreFruits);
        System.out.println("After addAll: " + combined);
        
        // Remove all elements that exist in another collection
        List<String> difference = new ArrayList<>(combined);
        difference.removeAll(moreFruits);
        System.out.println("After removeAll: " + difference);
        
        // Retain only elements that exist in another collection
        List<String> intersection = new ArrayList<>(combined);
        intersection.retainAll(moreFruits);
        System.out.println("After retainAll: " + intersection);
        
        // Check if list contains all elements from another collection
        boolean containsAll = fruits.containsAll(moreFruits);
        System.out.println("fruits contains all elements from moreFruits? " + containsAll);
        
        // Remove elements that match a condition (Java 8+)
        List<String> filtered = new ArrayList<>(combined);
        filtered.removeIf(s -> s.startsWith("B"));
        System.out.println("After removing elements starting with 'B': " + filtered);
    }
}

Output:

List 1: [Apple, Banana, Cherry]
List 2: [Banana, Dragonfruit]
After addAll: [Apple, Banana, Cherry, Banana, Dragonfruit]
After removeAll: [Apple, Cherry]
After retainAll: [Banana, Banana, Dragonfruit]
fruits contains all elements from moreFruits? false
After removing elements starting with 'B': [Apple, Cherry, Dragonfruit]

🔍 Java List Implementations in Detail

Now let's explore the main implementations of the List interface in detail.

ArrayList in Java

ArrayList is the most commonly used List implementation. It's backed by a resizable array, providing fast random access but slower insertions and deletions, especially in the middle of the list.

import java.util.*;

public class ArrayListDemo {
    public static void main(String[] args) {
        // Creating an ArrayList
        ArrayList<String> fruits = new ArrayList<>();
        
        // Adding elements
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        System.out.println("ArrayList: " + fruits);
        
        // Creating with initial capacity
        ArrayList<Integer> numbers = new ArrayList<>(20);
        for (int i = 0; i < 10; i++) {
            numbers.add(i);
        }
        System.out.println("Numbers: " + numbers);
        
        // Creating from another collection
        ArrayList<String> fruitsCopy = new ArrayList<>(fruits);
        System.out.println("Copy of fruits: " + fruitsCopy);
        
        // Ensuring capacity
        fruits.ensureCapacity(100);  // Ensures the list can hold at least 100 elements
        
        // Trimming to size (reduces capacity to current size)
        fruitsCopy.trimToSize();
        
        // Performance characteristics
        long startTime = System.nanoTime();
        
        // Fast random access
        for (int i = 0; i < 10000; i++) {
            if (i < numbers.size()) {
                numbers.get(i % numbers.size());
            }
        }
        
        long accessTime = System.nanoTime() - startTime;
        startTime = System.nanoTime();
        
        // Slower insertions at beginning
        for (int i = 0; i < 1000; i++) {
            numbers.add(0, i);
        }
        
        long insertTime = System.nanoTime() - startTime;
        
        System.out.println("Time for 10000 random accesses: " + accessTime + " ns");
        System.out.println("Time for 1000 insertions at beginning: " + insertTime + " ns");
        
        // ArrayList is not synchronized
        System.out.println("Is ArrayList synchronized? No");
        
        // Creating a synchronized ArrayList
        List<String> syncFruits = Collections.synchronizedList(fruits);
        System.out.println("Synchronized list: " + syncFruits.getClass().getName());
    }
}

Output (times will vary):

ArrayList: [Apple, Banana, Cherry]
Numbers: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Copy of fruits: [Apple, Banana, Cherry]
Time for 10000 random accesses: 1234567 ns
Time for 1000 insertions at beginning: 7654321 ns
Is ArrayList synchronized? No
Synchronized list: java.util.Collections$SynchronizedList

ArrayList Internal Working in Java

Understanding how ArrayList works internally can help you use it more effectively:

import java.util.*;

public class ArrayListInternalsDemo {
    public static void main(String[] args) {
        // ArrayList is backed by an array
        // When created, it has a default capacity (usually 10)
        ArrayList<Integer> list = new ArrayList<>();
        System.out.println("Initial capacity: 10 (default)");
        
        // As elements are added, the array fills up
        for (int i = 0; i < 10; i++) {
            list.add(i);
        }
        System.out.println("List after adding 10 elements: " + list);
        System.out.println("Size: " + list.size());
        System.out.println("Capacity: 10");
        
        // When capacity is reached and a new element is added,
        // ArrayList creates a new, larger array (typically 1.5x size)
        // and copies all elements to it
        list.add(10);
        System.out.println("List after adding 11th element: " + list);
        System.out.println("Size: " + list.size());
        System.out.println("New capacity: 15 (1.5x growth)");
        
        // When elements are removed, the array size doesn't shrink
        for (int i = 0; i < 5; i++) {
            list.remove(list.size() - 1);
        }
        System.out.println("List after removing 5 elements: " + list);
        System.out.println("Size: " + list.size());
        System.out.println("Capacity still: 15 (doesn't shrink automatically)");
        
        // trimToSize() can be used to shrink the capacity to match the size
        ((ArrayList<Integer>)list).trimToSize();
        System.out.println("After trimToSize()");
        System.out.println("Size: " + list.size());
        System.out.println("Capacity now: 6 (matches size)");
        
        // Inserting in the middle requires shifting elements
        list.add(3, 99);
        System.out.println("After inserting 99 at index 3: " + list);
        System.out.println("Elements at indexes 3+ were shifted right");
        
        // Removing from the middle also requires shifting elements
        list.remove(2);
        System.out.println("After removing element at index 2: " + list);
        System.out.println("Elements at indexes 3+ were shifted left");
    }
}

Output:

Initial capacity: 10 (default)
List after adding 10 elements: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Size: 10
Capacity: 10
List after adding 11th element: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Size: 11
New capacity: 15 (1.5x growth)
List after removing 5 elements: [0, 1, 2, 3, 4, 5]
Size: 6
Capacity still: 15 (doesn't shrink automatically)
After trimToSize()
Size: 6
Capacity now: 6 (matches size)
After inserting 99 at index 3: [0, 1, 2, 99, 3, 4, 5]
Elements at indexes 3+ were shifted right
After removing element at index 2: [0, 1, 99, 3, 4, 5]
Elements at indexes 3+ were shifted left

LinkedList in Java

LinkedList is implemented as a doubly-linked list, providing fast insertions and deletions but slower random access. It also implements the Deque interface, allowing it to be used as a queue or stack.

import java.util.*;

public class LinkedListDemo {
    public static void main(String[] args) {
        // Creating a LinkedList
        LinkedList<String> fruits = new LinkedList<>();
        
        // Adding elements
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
        System.out.println("LinkedList: " + fruits);
        
        // LinkedList specific methods (from Deque interface)
        
        // Add to the beginning
        fruits.addFirst("Apricot");
        System.out.println("After addFirst: " + fruits);
        
        // Add to the end
        fruits.addLast("Dragonfruit");
        System.out.println("After addLast: " + fruits);
        
        // Get first element
        String first = fruits.getFirst();
        System.out.println("First element: " + first);
        
        // Get last element
        String last = fruits.getLast();
        System.out.println("Last element: " + last);
        
        // Remove first element
        String removedFirst = fruits.removeFirst();
        System.out.println("Removed first: " + removedFirst);
        System.out.println("After removeFirst: " + fruits);
        
        // Remove last element
        String removedLast = fruits.removeLast();
        System.out.println("Removed last: " + removedLast);
        System.out.println("After removeLast: " + fruits);
        
        // Using as a queue (FIFO)
        LinkedList<String> queue = new LinkedList<>();
        queue.offer("First");  // Add to the end
        queue.offer("Second");
        queue.offer("Third");
        System.out.println("\nQueue: " + queue);
        
        String polled = queue.poll();  // Remove from the beginning
        System.out.println("Polled from queue: " + polled);
        System.out.println("Queue after poll: " + queue);
        
        // Using as a stack (LIFO)
        LinkedList<String> stack = new LinkedList<>();
        stack.push("Bottom");  // Add to the beginning
        stack.push("Middle");
        stack.push("Top");
        System.out.println("\nStack: " + stack);
        
        String popped = stack.pop();  // Remove from the beginning
        System.out.println("Popped from stack: " + popped);
        System.out.println("Stack after pop: " + stack);
        
        // Performance characteristics
        LinkedList<Integer> numbers = new LinkedList<>();
        for (int i = 0; i < 10; i++) {
            numbers.add(i);
        }
        
        long startTime = System.nanoTime();
        
        // Slow random access (has to traverse the list)
        for (int i = 0; i < 1000; i++) {
            if (i < numbers.size()) {
                numbers.get(i % numbers.size());
            }
        }
        
        long accessTime = System.nanoTime() - startTime;
        startTime = System.nanoTime();
        
        // Fast insertions at beginning
        for (int i = 0; i < 1000; i++) {
            numbers.addFirst(i);
        }
        
        long insertTime = System.nanoTime() - startTime;
        
        System.out.println("\nTime for 1000 random accesses: " + accessTime + " ns");
        System.out.println("Time for 1000 insertions at beginning: " + insertTime + " ns");
    }
}

Output (times will vary):

LinkedList: [Apple, Banana, Cherry]
After addFirst: [Apricot, Apple, Banana, Cherry]
After addLast: [Apricot, Apple, Banana, Cherry, Dragonfruit]
First element: Apricot
Last element: Dragonfruit
Removed first: Apricot
After removeFirst: [Apple, Banana, Cherry, Dragonfruit]
Removed last: Dragonfruit
After removeLast: [Apple, Banana, Cherry]

Queue: [First, Second, Third]
Polled from queue: First
Queue after poll: [Second, Third]

Stack: [Top, Middle, Bottom]
Popped from stack: Top
Stack after pop: [Middle, Bottom]

Time for 1000 random accesses: 2345678 ns
Time for 1000 insertions at beginning: 1234567 ns

LinkedList Internal Working

Understanding how LinkedList works internally:

import java.util.*;

public class LinkedListInternalsDemo {
    public static void main(String[] args) {
        // LinkedList is implemented as a doubly-linked list
        // Each element (node) has references to the previous and next nodes
        System.out.println("LinkedList internal structure:");
        System.out.println("null <- [Node1] <-> [Node2] <-> [Node3] -> null");
        System.out.println("       (head)               (tail)");
        
        LinkedList<String> list = new LinkedList<>();
        
        // Adding elements creates new nodes and updates references
        list.add("A");
        System.out.println("\nAfter adding 'A':");
        System.out.println("null <- [A] -> null");
        System.out.println("       (head/tail)");
        
        list.add("B");
        System.out.println("\nAfter adding 'B':");
        System.out.println("null <- [A] <-> [B] -> null");
        System.out.println("       (head)    (tail)");
        
        list.add("C");
        System.out.println("\nAfter adding 'C':");
        System.out.println("null <- [A] <-> [B] <-> [C] -> null");
        System.out.println("       (head)          (tail)");
        
        // Adding at the beginning is fast - just update references
        list.addFirst("Z");
        System.out.println("\nAfter adding 'Z' at the beginning:");
        System.out.println("null <- [Z] <-> [A] <-> [B] <-> [C] -> null");
        System.out.println("       (head)                   (tail)");
        
        // Adding in the middle requires traversing to that position
        list.add(2, "X");
        System.out.println("\nAfter adding 'X' at index 2:");
        System.out.println("null <- [Z] <-> [A] <-> [X] <-> [B] <-> [C] -> null");
        System.out.println("       (head)                         (tail)");
        
        // Removing an element updates the surrounding references
        list.remove("X");
        System.out.println("\nAfter removing 'X':");
        System.out.println("null <- [Z] <-> [A] <-> [B] <-> [C] -> null");
        System.out.println("       (head)                   (tail)");
        
        // Accessing by index requires traversing from head or tail
        // (whichever is closer)
        System.out.println("\nAccessing element at index 2 ('B'):");
        System.out.println("Start at head, traverse: Z -> A -> B (found)");
        
        System.out.println("\nAccessing element at index 3 ('C'):");
        System.out.println("Start at tail, traverse: C (found)");
        
        // Memory usage
        System.out.println("\nMemory usage comparison:");
        System.out.println("ArrayList: One array + small overhead");
        System.out.println("LinkedList: Two references per element + overhead");
    }
}

Output:

LinkedList internal structure:
null <- [Node1] <-> [Node2] <-> [Node3] -> null
       (head)               (tail)

After adding 'A':
null <- [A] -> null
       (head/tail)

After adding 'B':
null <- [A] <-> [B] -> null
       (head)    (tail)

After adding 'C':
null <- [A] <-> [B] <-> [C] -> null
       (head)          (tail)

After adding 'Z' at the beginning:
null <- [Z] <-> [A] <-> [B] <-> [C] -> null
       (head)                   (tail)

After adding 'X' at index 2:
null <- [Z] <-> [A] <-> [X] <-> [B] <-> [C] -> null
       (head)                         (tail)

After removing 'X':
null <- [Z] <-> [A] <-> [B] <-> [C] -> null
       (head)                   (tail)

Accessing element at index 2 ('B'):
Start at head, traverse: Z -> A -> B (found)

Accessing element at index 3 ('C'):
Start at tail, traverse: C (found)

Memory usage comparison:
ArrayList: One array + small overhead
LinkedList: Two references per element + overhead

Vector in Java: Thread-Safe List Implementation

Vector is similar to ArrayList but is synchronized, making it thread-safe. It's considered legacy code, but it's still useful in multi-threaded environments where synchronization is needed.

import java.util.*;

public class VectorDemo {
    public static void main(String[] args) {
        // Creating a Vector
        Vector<String> vector = new Vector<>();
        
        // Adding elements
        vector.add("Apple");
        vector.add("Banana");
        vector.add("Cherry");
        System.out.println("Vector: " + vector);
        
        // Vector-specific methods
        
        // Add element at index
        vector.add(1, "Blueberry");
        System.out.println("After adding at index 1: " + vector);
        
        // Get element at index
        String element = vector.elementAt(2);
        System.out.println("Element at index 2: " + element);
        
        // Get first element
        String firstElement = vector.firstElement();
        System.out.println("First element: " + firstElement);
        
        // Get last element
        String lastElement = vector.lastElement();
        System.out.println("Last element: " + lastElement);
        
        // Remove element at index
        vector.removeElementAt(1);
        System.out.println("After removing element at index 1: " + vector);
        
        // Set element at index
        vector.setElementAt("Cranberry", 1);
        System.out.println("After setting element at index 1: " + vector);
        
        // Vector capacity management
        Vector<Integer> numbers = new Vector<>(10, 5);  // Initial capacity 10, increment 5
        System.out.println("Initial capacity: " + numbers.capacity());
        
        for (int i = 0; i < 12; i++) {
            numbers.add(i);
        }
        
        System.out.println("After adding 12 elements, capacity: " + numbers.capacity());
        
        // Thread safety
        System.out.println("\nVector is synchronized (thread-safe)");
        
        // Performance comparison with ArrayList
        List<Integer> vectorList = new Vector<>();
        List<Integer> arrayList = new ArrayList<>();
        
        long startTime = System.nanoTime();
        for (int i = 0; i < 100000; i++) {
            vectorList.add(i);
        }
        long vectorTime = System.nanoTime() - startTime;
        
        startTime = System.nanoTime();
        for (int i = 0; i < 100000; i++) {
            arrayList.add(i);
        }
        long arrayListTime = System.nanoTime() - startTime;
        
        System.out.println("Time to add 100,000 elements to Vector: " + vectorTime + " ns");
        System.out.println("Time to add 100,000 elements to ArrayList: " + arrayListTime + " ns");
        System.out.println("Vector is slower due to synchronization overhead");
    }
}

Output (times will vary):

Vector: [Apple, Banana, Cherry]
After adding at index 1: [Apple, Blueberry, Banana, Cherry]
Element at index 2: Banana
First element: Apple
Last element: Cherry
After removing element at index 1: [Apple, Banana, Cherry]
After setting element at index 1: [Apple, Cranberry, Cherry]
Initial capacity: 10
After adding 12 elements, capacity: 15

Vector is synchronized (thread-safe)
Time to add 100,000 elements to Vector: 12345678 ns
Time to add 100,000 elements to ArrayList: 7654321 ns

Vector is slower due to synchronization overhead

Stack in Java: LIFO Data Structure

Stack is a subclass of Vector that implements the Last-In-First-Out (LIFO) stack data structure. It's also synchronized but has been largely replaced by more modern alternatives.

import java.util.*;

public class StackDemo {
    public static void main(String[] args) {
        // Creating a Stack
        Stack<String> stack = new Stack<>();
        
        // Pushing elements onto the stack
        stack.push("First");
        stack.push("Second");
        stack.push("Third");
        System.out.println("Stack: " + stack);
        
        // Peek at the top element without removing it
        String top = stack.peek();
        System.out.println("Top element (peek): " + top);
        System.out.println("Stack after peek: " + stack);
        
        // Pop (remove and return) the top element
        String popped = stack.pop();
        System.out.println("Popped element: " + popped);
        System.out.println("Stack after pop: " + stack);
        
        // Check if stack is empty
        boolean isEmpty = stack.empty();
        System.out.println("Is stack empty? " + isEmpty);
        
        // Search for an element (returns 1-based position from the top)
        int position = stack.search("First");
        System.out.println("Position of 'First' from top: " + position);
        
        // Note: Stack extends Vector, so all Vector methods are available
        stack.add("Fourth");  // Same as push
        System.out.println("Stack after adding 'Fourth': " + stack);
        
        // Modern alternatives to Stack
        System.out.println("\nModern alternatives to Stack:");
        
        // 1. ArrayDeque as a stack
        Deque<String> arrayDequeStack = new ArrayDeque<>();
        arrayDequeStack.push("First");
        arrayDequeStack.push("Second");
        System.out.println("ArrayDeque as stack: " + arrayDequeStack);
        System.out.println("Popped from ArrayDeque: " + arrayDequeStack.pop());
        
        // 2. LinkedList as a stack
        Deque<String> linkedListStack = new LinkedList<>();
        linkedListStack.push("First");
        linkedListStack.push("Second");
        System.out.println("LinkedList as stack: " + linkedListStack);
        System.out.println("Popped from LinkedList: " + linkedListStack.pop());
    }
}

Output:

Stack: [First, Second, Third]
Top element (peek): Third
Stack after peek: [First, Second, Third]
Popped element: Third
Stack after pop: [First, Second]
Is stack empty? false
Position of 'First' from top: 2
Stack after adding 'Fourth': [First, Second, Fourth]

Modern alternatives to Stack:
ArrayDeque as stack: [Second, First]
Popped from ArrayDeque: Second
LinkedList as stack: [Second, First]
Popped from LinkedList: Second

🔄 Choosing the Right List Implementation

Each List implementation has its own strengths and weaknesses. Here's a guide to help you choose the right one for your needs:

ArrayList

Use when:

  • You need frequent random access to elements by index
  • You mostly add/remove elements at the end of the list
  • You need to iterate through the list frequently
  • Memory efficiency is important

Avoid when:

  • You frequently insert or remove elements at the beginning or middle of the list
  • You need thread safety (unless you explicitly synchronize it)

LinkedList

Use when:

  • You frequently add/remove elements at the beginning or middle of the list
  • You need to implement a queue or deque
  • You don't need random access by index often

Avoid when:

  • You need frequent random access to elements by index
  • Memory efficiency is critical (each element has additional overhead for the node references)

Vector

Use when:

  • You need thread safety without implementing it yourself
  • You're working with legacy code that uses Vector

Avoid when:

  • Performance is critical (synchronization adds overhead)
  • You don't need thread safety
  • You're writing new code (ArrayList with explicit synchronization is usually better)

Stack

Use when:

  • You're working with legacy code that uses Stack
  • You need a simple LIFO data structure with thread safety

Avoid when:

  • You're writing new code (Deque implementations like ArrayDeque are better alternatives)
  • Performance is critical

Comparing Java List Implementations: Performance Analysis

Operation ArrayList LinkedList Vector Stack
get(index) O(1) O(n) O(1) O(1)
add(E) (at end) O(1)* O(1) O(1)* O(1)*
add(index, E) O(n) O(n) O(n) O(n)
remove(index) O(n) O(n) O(n) O(n)
Iterator.remove() O(n) O(1) O(n) O(n)
ListIterator.add(E) O(n) O(1) O(n) O(n)

*Amortized constant time (occasional resizing of the backing array takes O(n) time)

🚫 Common Pitfalls and Gotchas

When working with Lists in Java, be aware of these common issues:

1. ConcurrentModificationException

This exception occurs when you modify a list while iterating through it using an iterator or enhanced for loop:

List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");

// WRONG: Will throw ConcurrentModificationException
for (String item : list) {
    if (item.equals("B")) {
        list.remove(item);  // Modifying while iterating!
    }
}

// CORRECT: Use Iterator's remove method
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if (item.equals("B")) {
        iterator.remove();  // Safe way to remove during iteration
    }
}

// CORRECT: Use removeIf (Java 8+)
list.removeIf(item -> item.equals("B"));

// CORRECT: Use a copy of the list
for (String item : new ArrayList<>(list)) {
    if (item.equals("B")) {
        list.remove(item);  // Safe because we're iterating over a copy
    }
}

2. IndexOutOfBoundsException

This occurs when you try to access an index that doesn't exist:

List<String> list = new ArrayList<>();
list.add("A");

// WRONG: Will throw IndexOutOfBoundsException
String item = list.get(1);  // Index 1 doesn't exist (only index 0 exists)

// CORRECT: Check bounds first
if (1 < list.size()) {
    String item = list.get(1);
}

// CORRECT: Use a try-catch block
try {
    String item = list.get(1);
} catch (IndexOutOfBoundsException e) {
    System.out.println("Index out of bounds");
}

3. Unexpected Behavior with subList()

The subList() method returns a view of the original list, not a copy:

List<String> original = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
List<String> sub = original.subList(1, 3);  // Contains "B", "C"

// Changes to the sublist affect the original list
sub.set(0, "X");
System.out.println(original);  // Prints [A, X, C, D]

// Changes to the original list can invalidate the sublist
original.add(0, "Z");
try {
    System.out.println(sub);  // Throws ConcurrentModificationException
} catch (ConcurrentModificationException e) {
    System.out.println("Sublist invalidated");
}

// To avoid this, create a new list from the sublist
List<String> safeSub = new ArrayList<>(original.subList(1, 3));

4. Performance Issues with Wrong List Type

Using the wrong list type for your use case can lead to performance problems:

// WRONG: Using LinkedList for frequent random access
List<Integer> numbers = new LinkedList<>();
for (int i = 0; i < 100000; i++) {
    numbers.add(i);
}

// Slow operation (O(n) for LinkedList)
for (int i = 0; i < 1000; i++) {
    numbers.get(i * 50);
}

// CORRECT: Use ArrayList for frequent random access
List<Integer> betterNumbers = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
    betterNumbers.add(i);
}

// Fast operation (O(1) for ArrayList)
for (int i = 0; i < 1000; i++) {
    betterNumbers.get(i * 50);
}

5. Forgetting That Lists Allow Null Elements

Unlike some other collections, Lists in Java allow null elements:

List<String> list = new ArrayList<>();
list.add(null);
list.add("A");
list.add(null);

System.out.println(list);  // Prints [null, A, null]
System.out.println(list.indexOf(null));  // Prints 0 (first occurrence)

// Be careful when working with nulls
for (String item : list) {
    // WRONG: Will throw NullPointerException
    if (item.equals("B")) {  // item might be null
        // ...
    }
    
    // CORRECT: Check for null first
    if ("B".equals(item)) {  // Safe even if item is null
        // ...
    }
}

🌟 Java List Interface: Best Practices

Follow these best practices when working with Lists in Java:

1. Program to the Interface, Not the Implementation

// GOOD: Programming to the interface
List<String> list = new ArrayList<>();

// AVOID: Programming to the implementation
ArrayList<String> list = new ArrayList<>();

Programming to the interface makes your code more flexible. You can change the implementation later without modifying the rest of your code.

2. Choose the Right List Implementation for Your Use Case

As discussed in the "Choosing the Right List Implementation" section, select the implementation that best fits your specific needs.

3. Use Generics for Type Safety

// GOOD: Using generics
List<String> strings = new ArrayList<>();
strings.add("Hello");
String s = strings.get(0);  // No casting needed

// AVOID: Raw types (pre-Java 5 style)
List rawList = new ArrayList();
rawList.add("Hello");
String s = (String) rawList.get(0);  // Casting required, potential ClassCastException

4. Consider Immutable Lists for Thread Safety

// Creating an immutable list (Java 9+)
List<String> immutableList = List.of("A", "B", "C");

// Pre-Java 9
List<String> unmodifiableList = Collections.unmodifiableList(Arrays.asList("A", "B", "C"));

// Attempting to modify these lists will throw UnsupportedOperationException

Immutable lists are inherently thread-safe and can be safely shared between threads.

5. Use the Right Method for Removing Elements During Iteration

// GOOD: Using Iterator's remove method
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if (shouldRemove(item)) {
        iterator.remove();
    }
}

// GOOD: Using removeIf (Java 8+)
list.removeIf(item -> shouldRemove(item));

6. Be Careful with List Views

// Create a copy instead of a view when needed
List<String> copy = new ArrayList<>(originalList.subList(1, 3));

7. Consider Memory Usage

// Trim ArrayList to size when you're done adding elements
ArrayList<String> list = new ArrayList<>();
// ... add elements ...
list.trimToSize();  // Reduces memory usage

8. Use Enhanced For Loop or Stream API for Cleaner Code

// GOOD: Enhanced for loop
for (String item : list) {
    System.out.println(item);
}

// GOOD: Stream API (Java 8+)
list.forEach(System.out::println);

9. Use Factory Methods for Small Lists (Java 9+)

// Creating small immutable lists
List<String> empty = List.of();
List<String> single = List.of("A");
List<String> multiple = List.of("A", "B", "C");

10. Consider Thread Safety Needs

// For thread safety with ArrayList
List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());

// When using a synchronized list with an iterator
synchronized (synchronizedList) {
    Iterator<String> iterator = synchronizedList.iterator();
    while (iterator.hasNext()) {
        // ...
    }
}

🔍 Why Lists Matter: Use Cases and Applications

Lists are one of the most versatile and commonly used data structures in Java. Here are some typical use cases:

1. Maintaining Ordered Collections

Lists are perfect when you need to maintain elements in a specific order:

  • Displaying items in a user interface (e.g., menu items, search results)
  • Processing steps in a workflow
  • Command history in an application

2. Dynamic Collections

When the size of your collection changes frequently:

  • Building a collection of results from a database query
  • Managing a list of active users in a system
  • Collecting user input over time

3. Random Access to Elements

When you need to access elements by their position:

  • Pagination of results (accessing page 3 of search results)
  • Accessing specific elements in a sequence
  • Implementing algorithms that require indexed access

4. Implementing Other Data Structures

Lists serve as the foundation for implementing other data structures:

  • Stacks (using ArrayList or LinkedList)
  • Queues (using LinkedList)
  • Deques (using LinkedList)
  • Graphs (adjacency lists)

5. Real-World Examples

// Example: Managing a to-do list application
public class TodoListManager {
    private List<Task> tasks = new ArrayList<>();
    
    public void addTask(Task task) {
        tasks.add(task);
    }
    
    public void completeTask(int taskId) {
        for (Task task : tasks) {
            if (task.getId() == taskId) {
                task.setCompleted(true);
                break;
            }
        }
    }
    
    public List<Task> getIncompleteTasks() {
        List<Task> incompleteTasks = new ArrayList<>();
        for (Task task : tasks) {
            if (!task.isCompleted()) {
                incompleteTasks.add(task);
            }
        }
        return incompleteTasks;
    }
    
    public void reorderTask(int taskId, int newPosition) {
        Task taskToMove = null;
        int currentPosition = -1;
        
        // Find the task and its current position
        for (int i = 0; i < tasks.size(); i++) {
            if (tasks.get(i).getId() == taskId) {
                taskToMove = tasks.get(i);
                currentPosition = i;
                break;
            }
        }
        
        if (taskToMove != null && currentPosition != -1) {
            // Remove from current position
            tasks.remove(currentPosition);
            
            // Insert at new position
            if (newPosition >= tasks.size()) {
                tasks.add(taskToMove);
            } else {
                tasks.add(newPosition, taskToMove);
            }
        }
    }
}

class Task {
    private int id;
    private String description;
    private boolean completed;
    
    // Constructor, getters, setters...
    
    public int getId() { return id; }
    public String getDescription() { return description; }
    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed = completed; }
}

📝 Java List Interface: Key Takeaways

In this comprehensive guide to Java's List interface and implementations, we've covered:

  1. The List Interface: An ordered collection that allows duplicates and provides indexed access to elements.

  2. Core List Implementations:

    • ArrayList: Backed by a resizable array, offering fast random access but slower insertions/deletions.
    • LinkedList: Implemented as a doubly-linked list, providing fast insertions/deletions but slower random access.
    • Vector: A synchronized version of ArrayList (thread-safe).
    • Stack: A LIFO data structure extending Vector.
  3. Key Operations:

    • Adding, removing, and accessing elements
    • Searching for elements
    • Iterating through lists
    • Working with sublists
    • Bulk operations
  4. Performance Characteristics:

    • Time complexity of common operations for different implementations
    • Memory usage considerations
  5. Common Pitfalls:

    • ConcurrentModificationException
    • IndexOutOfBoundsException
    • Unexpected behavior with subList()
    • Performance issues with the wrong list type
  6. Best Practices:

    • Programming to the interface
    • Choosing the right implementation
    • Using generics for type safety
    • Proper iteration techniques
    • Thread safety considerations
  7. Use Cases:

    • Maintaining ordered collections
    • Dynamic collections
    • Random access to elements
    • Implementing other data structures

By understanding the strengths and weaknesses of each List implementation, you can make informed decisions about which one to use in your Java applications, leading to more efficient and maintainable code.

🏋️ Exercises and Mini-Projects

Exercise 1: Custom ArrayList Implementation

Let's implement a simplified version of ArrayList to understand how it works internally:

public class SimpleArrayList<E> {
    private static final int DEFAULT_CAPACITY = 10;
    private Object[] elements;
    private int size;
    
    public SimpleArrayList() {
        elements = new Object[DEFAULT_CAPACITY];
        size = 0;
    }
    
    public void add(E element) {
        ensureCapacity();
        elements[size++] = element;
    }
    
    @SuppressWarnings("unchecked")
    public E get(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
        }
        return (E) elements[index];
    }
    
    public int size() {
        return size;
    }
    
    public boolean isEmpty() {
        return size == 0;
    }
    
    public void remove(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
        }
        
        // Shift elements to the left
        for (int i = index; i < size - 1; i++) {
            elements[i] = elements[i + 1];
        }
        
        // Clear the last element and decrement size
        elements[--size] = null;
    }
    
    private void ensureCapacity() {
        if (size == elements.length) {
            int newCapacity = elements.length * 2;
            Object[] newElements = new Object[newCapacity];
            System.arraycopy(elements, 0, newElements, 0, size);
            elements = newElements;
        }
    }
    
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("[");
        for (int i = 0; i < size; i++) {
            sb.append(elements[i]);
            if (i < size - 1) {
                sb.append(", ");
            }
        }
        sb.append("]");
        return sb.toString();
    }
    
    public static void main(String[] args) {
        SimpleArrayList<String> list = new SimpleArrayList<>();
        
        // Test adding elements
        list.add("Apple");
        list.add("Banana");
        list.add("Cherry");
        System.out.println("List after adding 3 elements: " + list);
        
        // Test getting elements
        System.out.println("Element at index 1: " + list.get(1));
        
        // Test removing elements
        list.remove(1);
        System.out.println("List after removing element at index 1: " + list);
        
        // Test size and isEmpty
        System.out.println("Size: " + list.size());
        System.out.println("Is empty? " + list.isEmpty());
        
        // Test capacity expansion
        for (int i = 0; i < 10; i++) {
            list.add("Item " + i);
        }
        System.out.println("List after adding 10 more elements: " + list);
    }
}

Practice Exercise: Extend the SimpleArrayList implementation to include:

  1. An add(int index, E element) method to insert at a specific position
  2. A set(int index, E element) method to replace an element
  3. An indexOf(Object o) method to find the position of an element
  4. A contains(Object o) method to check if an element exists

Exercise 2: Word Frequency Counter

Let's create a program that counts the frequency of words in a text file:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;

public class WordFrequencyCounter {
    public static void main(String[] args) {
        // The text file to analyze
        String filename = "sample.txt";
        
        // Map to store word frequencies
        Map<String, Integer> wordFrequency = new HashMap<>();
        
        try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
            String line;
            while ((line = reader.readLine()) != null) {
                // Split the line into words
                String[] words = line.toLowerCase()
                                    .replaceAll("[^a-zA-Z ]", "")
                                    .split("\\s+");
                
                // Count each word
                for (String word : words) {
                    if (!word.isEmpty()) {
                        wordFrequency.put(word, wordFrequency.getOrDefault(word, 0) + 1);
                    }
                }
            }
            
            // Convert to list for sorting
            List<Map.Entry<String, Integer>> entries = new ArrayList<>(wordFrequency.entrySet());
            
            // Sort by frequency (descending)
            entries.sort((e1, e2) -> e2.getValue().compareTo(e1.getValue()));
            
            // Display the top 10 most frequent words
            System.out.println("Top 10 most frequent words:");
            for (int i = 0; i < Math.min(10, entries.size()); i++) {
                Map.Entry<String, Integer> entry = entries.get(i);
                System.out.printf("%-15s %d%n", entry.getKey(), entry.getValue());
            }
            
        } catch (IOException e) {
            System.err.println("Error reading file: " + e.getMessage());
        }
    }
}

Sample output (will vary based on the input file):

Top 10 most frequent words:
the            154
and            93
to             81
of             78
a              77
in             54
that           41
is             40
it             39
for            36

Practice Exercise: Modify the program to:

  1. Read from an actual text file
  2. Ignore common words (like "the", "a", "an")
  3. Display the results as a simple bar chart

Mini-Project: Contact Management System

Let's create a simple contact management system that demonstrates the use of various List implementations:

import java.util.*;
import java.util.function.Predicate;

class Contact {
    private String name;
    private String email;
    private String phone;
    private List<String> tags;
    
    public Contact(String name, String email, String phone) {
        this.name = name;
        this.email = email;
        this.phone = phone;
        this.tags = new ArrayList<>();
    }
    
    // Getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    
    public String getPhone() { return phone; }
    public void setPhone(String phone) { this.phone = phone; }
    
    public List<String> getTags() { return new ArrayList<>(tags); }
    
    public void addTag(String tag) {
        if (!tags.contains(tag)) {
            tags.add(tag);
        }
    }
    
    public void removeTag(String tag) {
        tags.remove(tag);
    }
    
    public boolean hasTag(String tag) {
        return tags.contains(tag);
    }
    
    @Override
    public String toString() {
        return String.format("Name: %-20s Email: %-25s Phone: %-15s Tags: %s", 
                            name, email, phone, String.join(", ", tags));
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Contact contact = (Contact) o;
        return email.equals(contact.email);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(email);
    }
}

class ContactManager {
    private List<Contact> contacts;
    
    public ContactManager() {
        // Using ArrayList for efficient random access and iteration
        contacts = new ArrayList<>();
    }
    
    public void addContact(Contact contact) {
        // Check if contact with same email already exists
        for (int i = 0; i < contacts.size(); i++) {
            if (contacts.get(i).getEmail().equals(contact.getEmail())) {
                contacts.set(i, contact);  // Replace existing contact
                return;
            }
        }
        contacts.add(contact);  // Add new contact
    }
    
    public void removeContact(String email) {
        contacts.removeIf(contact -> contact.getEmail().equals(email));
    }
    
    public Contact findByEmail(String email) {
        for (Contact contact : contacts) {
            if (contact.getEmail().equals(email)) {
                return contact;
            }
        }
        return null;
    }
    
    public List<Contact> findByName(String namePart) {
        List<Contact> result = new ArrayList<>();
        String lowerNamePart = namePart.toLowerCase();
        
        for (Contact contact : contacts) {
            if (contact.getName().toLowerCase().contains(lowerNamePart)) {
                result.add(contact);
            }
        }
        
        return result;
    }
    
    public List<Contact> findByTag(String tag) {
        List<Contact> result = new ArrayList<>();
        
        for (Contact contact : contacts) {
            if (contact.hasTag(tag)) {
                result.add(contact);
            }
        }
        
        return result;
    }
    
    public List<Contact> findByCustomCriteria(Predicate<Contact> criteria) {
        List<Contact> result = new ArrayList<>();
        
        for (Contact contact : contacts) {
            if (criteria.test(contact)) {
                result.add(contact);
            }
        }
        
        return result;
    }
    
    public List<Contact> getAllContacts() {
        return new ArrayList<>(contacts);
    }
    
    public void sortContacts(Comparator<Contact> comparator) {
        Collections.sort(contacts, comparator);
    }
}

public class ContactManagementSystem {
    public static void main(String[] args) {
        ContactManager manager = new ContactManager();
        
        // Add some contacts
        Contact john = new Contact("John Smith", "john@example.com", "555-1234");
        john.addTag("friend");
        john.addTag("work");
        
        Contact alice = new Contact("Alice Johnson", "alice@example.com", "555-5678");
        alice.addTag("family");
        
        Contact bob = new Contact("Bob Williams", "bob@example.com", "555-9012");
        bob.addTag("work");
        
        manager.addContact(john);
        manager.addContact(alice);
        manager.addContact(bob);
        
        // Display all contacts
        System.out.println("All contacts:");
        for (Contact contact : manager.getAllContacts()) {
            System.out.println(contact);
        }
        
        // Sort contacts by name
        System.out.println("\nContacts sorted by name:");
        manager.sortContacts(Comparator.comparing(Contact::getName));
        for (Contact contact : manager.getAllContacts()) {
            System.out.println(contact);
        }
        
        // Find contacts by tag
        System.out.println("\nWork contacts:");
        List<Contact> workContacts = manager.findByTag("work");
        for (Contact contact : workContacts) {
            System.out.println(contact);
        }
        
        // Find contact by email
        System.out.println("\nLooking up by email:");
        Contact foundContact = manager.findByEmail("alice@example.com");
        if (foundContact != null) {
            System.out.println(foundContact);
        }
        
        // Find by custom criteria (contacts with gmail addresses)
        System.out.println("\nContacts with gmail addresses:");
        List<Contact> gmailContacts = manager.findByCustomCriteria(
            contact -> contact.getEmail().endsWith("@gmail.com")
        );
        if (gmailContacts.isEmpty()) {
            System.out.println("No contacts with gmail addresses found");
        } else {
            for (Contact contact : gmailContacts) {
                System.out.println(contact);
            }
        }
        
        // Update a contact
        System.out.println("\nUpdating John's phone number:");
        Contact johnUpdated = manager.findByEmail("john@example.com");
        if (johnUpdated != null) {
            johnUpdated.setPhone("555-4321");
            manager.addContact(johnUpdated);  // This will replace the existing contact
            
            System.out.println("After update:");
            Contact johnAfterUpdate = manager.findByEmail("john@example.com");
            System.out.println(johnAfterUpdate);
        }
        
        // Remove a contact
        System.out.println("\nRemoving Bob:");
        manager.removeContact("bob@example.com");
        
        System.out.println("\nAll contacts after removal:");
        for (Contact contact : manager.getAllContacts()) {
            System.out.println(contact);
        }
    }
}

Output:

All contacts:
Name: John Smith          Email: john@example.com         Phone: 555-1234       Tags: friend, work
Name: Alice Johnson       Email: alice@example.com        Phone: 555-5678       Tags: family
Name: Bob Williams        Email: bob@example.com          Phone: 555-9012       Tags: work

Contacts sorted by name:
Name: Alice Johnson       Email: alice@example.com        Phone: 555-5678       Tags: family
Name: Bob Williams        Email: bob@example.com          Phone: 555-9012       Tags: work
Name: John Smith          Email: john@example.com         Phone: 555-1234       Tags: friend, work

Work contacts:
Name: John Smith          Email: john@example.com         Phone: 555-1234       Tags: friend, work
Name: Bob Williams        Email: bob@example.com          Phone: 555-9012       Tags: work

Looking up by email:
Name: Alice Johnson       Email: alice@example.com        Phone: 555-5678       Tags: family

Contacts with gmail addresses:
No contacts with gmail addresses found

Updating John's phone number:
After update:
Name: John Smith          Email: john@example.com         Phone: 555-4321       Tags: friend, work

Removing Bob:

All contacts after removal:
Name: Alice Johnson       Email: alice@example.com        Phone: 555-5678       Tags: family
Name: John Smith          Email: john@example.com         Phone: 555-4321       Tags: friend, work

Practice Exercise: Extend the Contact Management System to:

  1. Add persistence (save/load contacts to/from a file)
  2. Create a simple command-line interface for user interaction
  3. Add more search capabilities (e.g., by phone number, partial matches)
  4. Implement contact groups (a contact can belong to multiple groups)

🔄 Related Topics

  • Java Collections Framework: The broader framework that includes Lists, Sets, Maps, and more
  • Java Generics: For type-safe collections
  • Java Streams API: For functional-style operations on collections
  • Data Structures: Understanding the underlying implementations of Lists
  • Algorithms: Sorting, searching, and other operations on Lists
  • Design Patterns: Many patterns use Lists (Iterator, Composite, etc.)

By mastering Java Lists, you'll have a powerful tool in your programming toolkit for solving a wide range of problems efficiently and elegantly.