Back to blog

Deep Dive: Java Collections Framework

javacollectionsdata-structuresperformancebackend
Deep Dive: Java Collections Framework

The Java Collections Framework is one of the most fundamental and powerful parts of the Java Standard Library. Understanding how to choose and use the right collection can dramatically impact your application's performance, memory usage, and code maintainability.

This guide provides a comprehensive deep-dive into the Collections Framework, with performance analysis, practical examples, and best practices for experienced developers.

Collections Framework Overview

The Collections Framework consists of interfaces, implementations, and algorithms that represent and manipulate collections of objects.

Core Hierarchy

Collection (interface)
├── List (interface)
│   ├── ArrayList
│   ├── LinkedList
│   └── Vector (legacy, synchronized)

├── Set (interface)
│   ├── HashSet
│   ├── LinkedHashSet
│   └── TreeSet (SortedSet)

└── Queue (interface)
    ├── ArrayDeque
    ├── PriorityQueue
    └── LinkedList
 
Map (interface, separate from Collection)
├── HashMap
├── LinkedHashMap
├── TreeMap (SortedMap)
├── Hashtable (legacy, synchronized)
└── ConcurrentHashMap (thread-safe)

Key Design Principles:

  • Interface-based: Program to interfaces, not implementations
  • Fail-fast iterators: Most collections throw ConcurrentModificationException when structurally modified during iteration
  • Generic types: Type-safe collections using Java Generics
  • Consistent API: Common methods across all collection types

List Interface: Ordered Collections

Lists maintain insertion order and allow duplicate elements. Choose between ArrayList and LinkedList based on your access patterns.

ArrayList: Dynamic Array

Best for: Random access, iteration, and when you know approximate size.

List<String> names = new ArrayList<>(100); // Initial capacity
 
// O(1) - Add to end (amortized)
names.add("Alice");
names.add("Bob");
 
// O(1) - Random access by index
String first = names.get(0);
 
// O(n) - Insert in middle (requires shifting)
names.add(1, "Charlie");
 
// O(n) - Search by value
boolean hasAlice = names.contains("Alice");
 
// O(n) - Remove by index (requires shifting)
names.remove(0);

Performance Characteristics:

  • Access by index: O(1)
  • Add to end: O(1) amortized (O(n) when resizing)
  • Add/remove at index: O(n) - requires shifting elements
  • Search: O(n)
  • Memory overhead: Low - just array storage

Internal Mechanism:

// Simplified ArrayList internals
public class ArrayList<E> {
    private Object[] elementData;
    private int size;
    
    public boolean add(E e) {
        ensureCapacity(size + 1);
        elementData[size++] = e;
        return true;
    }
    
    private void ensureCapacity(int minCapacity) {
        if (minCapacity > elementData.length) {
            // Grow by ~50% (old capacity + old capacity >> 1)
            int newCapacity = elementData.length + (elementData.length >> 1);
            elementData = Arrays.copyOf(elementData, newCapacity);
        }
    }
}

Best Practices:

// ✅ DO: Specify initial capacity if known
List<Item> items = new ArrayList<>(expectedSize);
 
// ✅ DO: Use for random access scenarios
for (int i = 0; i < items.size(); i++) {
    process(items.get(i)); // O(1) access
}
 
// ❌ DON'T: Frequent insertions in middle
for (int i = 0; i < 1000; i++) {
    items.add(0, newItem); // O(n) each time - slow!
}

LinkedList: Doubly-Linked List

Best for: Frequent insertions/deletions at both ends, or when you need a Deque.

LinkedList<String> queue = new LinkedList<>();
 
// O(1) - Add to either end
queue.addFirst("First");
queue.addLast("Last");
 
// O(1) - Remove from either end
String first = queue.removeFirst();
String last = queue.removeLast();
 
// O(n) - Random access by index
String item = queue.get(5); // Traverses from head or tail
 
// O(n) - Search
boolean found = queue.contains("item");

Performance Characteristics:

  • Access by index: O(n)
  • Add/remove at head/tail: O(1)
  • Add/remove at arbitrary position: O(n) to find + O(1) to modify
  • Search: O(n)
  • Memory overhead: High - each node has two pointers (next, prev)

Internal Structure:

// Simplified LinkedList node
private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;
}

ArrayList vs LinkedList: When to Use

OperationArrayListLinkedList
get(index)O(1) ✅O(n) ❌
add(element) (end)O(1)* ✅O(1) ✅
add(0, element)O(n) ❌O(1) ✅
remove(index)O(n) ❌O(n) ⚠️
Iterator.remove()O(n) ❌O(1) ✅
Memory overheadLow ✅High ❌

*Amortized - Occasionally O(n) when resizing

Decision Matrix:

// Use ArrayList when:
// - Random access is frequent
// - Adding to end is primary operation
// - Memory efficiency matters
// - You iterate with index-based loops
List<String> names = new ArrayList<>();
 
// Use LinkedList when:
// - Implementing a queue/deque
// - Frequent add/remove at head
// - Using iterator to remove during traversal
Deque<Task> taskQueue = new LinkedList<>();

Set Interface: Unique Elements

Sets guarantee uniqueness (no duplicates) but differ in ordering and performance.

HashSet: Fast, Unordered

Best for: Fast membership testing, no ordering required.

Set<String> uniqueUsers = new HashSet<>();
 
// O(1) - Add element
uniqueUsers.add("alice");
uniqueUsers.add("bob");
uniqueUsers.add("alice"); // Ignored - already exists
 
// O(1) - Check membership
boolean hasAlice = uniqueUsers.contains("alice");
 
// O(1) - Remove
uniqueUsers.remove("bob");
 
// Unordered iteration
for (String user : uniqueUsers) {
    System.out.println(user); // Order not guaranteed
}

Performance Characteristics:

  • Add: O(1)
  • Contains: O(1)
  • Remove: O(1)
  • Iteration: O(n)
  • Ordering: None (unpredictable)

Internal Implementation: Uses HashMap internally where elements are keys (values are a dummy PRESENT object).

// Simplified HashSet internals
public class HashSet<E> {
    private transient HashMap<E, Object> map;
    private static final Object PRESENT = new Object();
    
    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }
}

Important Considerations:

// ✅ DO: Use immutable or effectively immutable objects
Set<String> names = new HashSet<>();
names.add("Alice");
 
// ❌ DON'T: Modify objects after adding
class Person {
    String name;
    
    @Override
    public int hashCode() {
        return name.hashCode();
    }
}
 
Person p = new Person("Alice");
Set<Person> people = new HashSet<>();
people.add(p);
p.name = "Bob"; // ⚠️ BREAKS SET - can't find 'p' anymore!

LinkedHashSet: Ordered by Insertion

Best for: When you need predictable iteration order with Set semantics.

Set<String> orderedUsers = new LinkedHashSet<>();
orderedUsers.add("charlie");
orderedUsers.add("alice");
orderedUsers.add("bob");
 
// Iteration in insertion order
for (String user : orderedUsers) {
    System.out.println(user); // charlie, alice, bob
}

Performance Characteristics:

  • Same as HashSet (O(1) for add/contains/remove)
  • Additional memory: Maintains doubly-linked list for ordering
  • Iteration: Predictable insertion order

TreeSet: Sorted Order

Best for: When you need elements in sorted order.

Set<Integer> scores = new TreeSet<>();
scores.add(85);
scores.add(92);
scores.add(78);
 
// Iteration in sorted order
for (int score : scores) {
    System.out.println(score); // 78, 85, 92
}
 
// Range operations
SortedSet<Integer> highScores = ((TreeSet<Integer>) scores).tailSet(80);
System.out.println(highScores); // [85, 92]

Performance Characteristics:

  • Add: O(log n)
  • Contains: O(log n)
  • Remove: O(log n)
  • Iteration: O(n), in sorted order
  • Implementation: Red-Black Tree

Requirements:

// Elements must be Comparable OR provide Comparator
Set<Person> people = new TreeSet<>(
    Comparator.comparing(Person::getLastName)
        .thenComparing(Person::getFirstName)
);

Set Comparison Table

FeatureHashSetLinkedHashSetTreeSet
OrderingNoneInsertionSorted
Add/Contains/RemoveO(1)O(1)O(log n)
MemoryLowMediumMedium
Use caseFast lookupPredictable orderSorted data

Map Interface: Key-Value Pairs

Maps store key-value associations with unique keys.

HashMap: Fast, Unordered

Best for: Fast key-based lookup, no ordering required.

Map<String, Integer> ages = new HashMap<>();
 
// O(1) - Put key-value
ages.put("Alice", 30);
ages.put("Bob", 25);
ages.put("Alice", 31); // Overwrites previous value
 
// O(1) - Get by key
Integer aliceAge = ages.get("Alice");
 
// O(1) - Check key existence
boolean hasAlice = ages.containsKey("Alice");
 
// O(n) - Check value existence (inefficient!)
boolean hasAge30 = ages.containsValue(30);
 
// O(1) - Remove by key
ages.remove("Bob");
 
// Iterate over entries
for (Map.Entry<String, Integer> entry : ages.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

Performance Characteristics:

  • Put/Get/Remove: O(1) average, O(n) worst case (hash collisions)
  • ContainsKey: O(1)
  • ContainsValue: O(n)
  • Iteration: O(n)

Internal Mechanism:

// Simplified HashMap internals
public class HashMap<K, V> {
    transient Node<K, V>[] table; // Array of buckets
    
    static class Node<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K, V> next; // Chain for collisions
    }
    
    public V put(K key, V value) {
        int hash = hash(key);
        int bucket = hash & (table.length - 1);
        // Handle collision with chaining or tree
        // ...
    }
}

Hash Collision Handling:

  • Java 8+: Buckets convert from linked list to red-black tree when chain length exceeds 8
  • Load factor: Default 0.75 - resizes when 75% full
  • Initial capacity: Default 16, doubles on resize

Best Practices:

// ✅ DO: Specify initial capacity for large maps
Map<String, User> users = new HashMap<>(10000);
 
// ✅ DO: Use compute methods for atomic updates
map.compute("key", (k, v) -> v == null ? 1 : v + 1);
 
// ✅ DO: Use getOrDefault to avoid null checks
int count = map.getOrDefault("key", 0);
 
// ❌ DON'T: Use containsKey + get (two lookups)
if (map.containsKey("key")) {
    return map.get("key"); // Second lookup!
}
 
// ✅ DO: Single lookup
String value = map.get("key");
if (value != null) {
    return value;
}

Modern Java 8+ Methods:

Map<String, Integer> scores = new HashMap<>();
 
// putIfAbsent - only add if key doesn't exist
scores.putIfAbsent("Alice", 100);
 
// computeIfAbsent - lazy initialization
Map<String, List<String>> groups = new HashMap<>();
groups.computeIfAbsent("admin", k -> new ArrayList<>()).add("Alice");
 
// merge - combine values
scores.merge("Bob", 10, Integer::sum); // Adds 10 to existing value

LinkedHashMap: Insertion-Ordered Map

Best for: When you need predictable iteration order (insertion or access order).

Map<String, Integer> cache = new LinkedHashMap<>();
cache.put("first", 1);
cache.put("second", 2);
cache.put("third", 3);
 
// Iteration in insertion order
cache.forEach((k, v) -> System.out.println(k + ": " + v));
// Output: first: 1, second: 2, third: 3

LRU Cache Implementation:

// Access-ordered LinkedHashMap (LRU cache)
Map<String, String> lruCache = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > MAX_ENTRIES;
    }
};
 
lruCache.put("A", "1");
lruCache.put("B", "2");
lruCache.put("C", "3");
lruCache.get("A"); // Moves A to end (most recently used)
lruCache.put("D", "4"); // Evicts B (least recently used)

TreeMap: Sorted Map

Best for: When you need keys in sorted order or range queries.

TreeMap<Integer, String> sortedMap = new TreeMap<>();
sortedMap.put(3, "three");
sortedMap.put(1, "one");
sortedMap.put(2, "two");
 
// Iteration in key order
sortedMap.forEach((k, v) -> System.out.println(k + ": " + v));
// Output: 1: one, 2: two, 3: three
 
// Range queries
SortedMap<Integer, String> subMap = sortedMap.subMap(1, 3); // [1, 3)
Map.Entry<Integer, String> firstEntry = sortedMap.firstEntry();
Map.Entry<Integer, String> lastEntry = sortedMap.lastEntry();
 
// Ceiling/Floor operations
Integer ceiling = sortedMap.ceilingKey(2); // Smallest key >= 2
Integer floor = sortedMap.floorKey(2);     // Largest key <= 2

Performance Characteristics:

  • Put/Get/Remove: O(log n)
  • Iteration: O(n), in sorted order
  • Range operations: O(log n + m) where m is result size

ConcurrentHashMap: Thread-Safe Map

Best for: High-concurrency scenarios with many threads.

Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
 
// Thread-safe operations without external synchronization
concurrentMap.put("key", 1);
concurrentMap.compute("key", (k, v) -> v == null ? 1 : v + 1);
 
// Bulk operations
concurrentMap.forEach(10, (k, v) -> {
    // Parallel forEach with parallelism threshold
    System.out.println(k + ": " + v);
});
 
// Atomic updates
concurrentMap.computeIfAbsent("counter", k -> 0);
concurrentMap.computeIfPresent("counter", (k, v) -> v + 1);

Key Differences from HashMap:

  • Lock-free reads: Most get operations don't require locks
  • Fine-grained locking: Writes lock only specific segments
  • Weak consistency: Iterators reflect state at some point, may not see concurrent updates
  • No null keys/values: Unlike HashMap, nulls are not allowed

Performance:

// ConcurrentHashMap scales better with threads
// Single thread: HashMap ~= ConcurrentHashMap
// Multiple threads: ConcurrentHashMap >> Collections.synchronizedMap(HashMap)

Map Comparison Table

FeatureHashMapLinkedHashMapTreeMapConcurrentHashMap
OrderingNoneInsertion/AccessSortedNone
Get/PutO(1)O(1)O(log n)O(1)
Thread-safe
Null keys✅ (one)✅ (one)
Use caseGeneralLRU cacheSorted keysConcurrent

Queue Interface: FIFO and Priority

Queues process elements in specific order (FIFO, priority, etc.).

ArrayDeque: Double-Ended Queue

Best for: Queue or stack operations (faster than LinkedList and Stack).

Deque<String> deque = new ArrayDeque<>();
 
// Use as Queue (FIFO)
deque.offer("first");
deque.offer("second");
String first = deque.poll(); // "first"
 
// Use as Stack (LIFO)
deque.push("A");
deque.push("B");
String top = deque.pop(); // "B"
 
// Double-ended operations
deque.addFirst("head");
deque.addLast("tail");
String head = deque.removeFirst();
String tail = deque.removeLast();

Performance Characteristics:

  • Add/Remove at either end: O(1) amortized
  • Random access: Not supported (not intended use)
  • Memory: More efficient than LinkedList (no node overhead)

Why ArrayDeque over LinkedList?

// ArrayDeque advantages:
// 1. Lower memory overhead (no node pointers)
// 2. Better cache locality (contiguous array)
// 3. Faster insertions/removals at ends
 
Deque<Integer> deque = new ArrayDeque<>(); // ✅ Preferred
LinkedList<Integer> list = new LinkedList<>(); // ❌ Less efficient as deque

PriorityQueue: Heap-Based Priority Queue

Best for: Processing elements by priority (not insertion order).

// Min-heap by default (smallest element first)
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(3);
 
System.out.println(minHeap.poll()); // 1
System.out.println(minHeap.poll()); // 3
System.out.println(minHeap.poll()); // 5
 
// Max-heap with custom comparator
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(
    Comparator.reverseOrder()
);
 
// Custom objects with priority
class Task implements Comparable<Task> {
    String name;
    int priority;
    
    @Override
    public int compareTo(Task other) {
        return Integer.compare(this.priority, other.priority);
    }
}
 
PriorityQueue<Task> taskQueue = new PriorityQueue<>();
taskQueue.offer(new Task("Low", 3));
taskQueue.offer(new Task("High", 1));
taskQueue.offer(new Task("Medium", 2));
 
Task urgent = taskQueue.poll(); // Returns "High" (priority 1)

Performance Characteristics:

  • Offer (add): O(log n)
  • Poll (remove): O(log n)
  • Peek: O(1)
  • Implementation: Binary heap (array-based)

Important Notes:

// ❌ PITFALL: Iteration order is NOT priority order!
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(5);
pq.offer(1);
pq.offer(3);
 
for (Integer i : pq) {
    System.out.println(i); // NOT guaranteed to be 1, 3, 5!
}
 
// ✅ CORRECT: Use poll() for priority order
while (!pq.isEmpty()) {
    System.out.println(pq.poll()); // 1, 3, 5 (guaranteed)
}

BlockingQueue: Thread-Safe Queues

Best for: Producer-consumer patterns with thread coordination.

import java.util.concurrent.*;
 
// Bounded queue - blocks when full
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
 
// Producer thread
new Thread(() -> {
    try {
        queue.put("item"); // Blocks if queue is full
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();
 
// Consumer thread
new Thread(() -> {
    try {
        String item = queue.take(); // Blocks if queue is empty
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

Common Implementations:

  • ArrayBlockingQueue: Bounded, array-based
  • LinkedBlockingQueue: Optionally bounded, node-based
  • PriorityBlockingQueue: Unbounded, priority-ordered
  • DelayQueue: Elements available after delay

Performance Comparison: Big O Notation

Time Complexity Summary

CollectionAddRemoveGetContainsIteration
ArrayListO(1)*O(n)O(1)O(n)O(n)
LinkedListO(1)O(1)**O(n)O(n)O(n)
HashSetO(1)O(1)-O(1)O(n)
TreeSetO(log n)O(log n)-O(log n)O(n)
HashMapO(1)O(1)O(1)O(1)†O(n)
TreeMapO(log n)O(log n)O(log n)O(log n)†O(n)
PriorityQueueO(log n)O(log n)O(1)‡O(n)O(n)
ArrayDequeO(1)O(1)--O(n)

*Amortized - Occasionally O(n) when resizing
**At known position - O(n) to find position
†containsKey for maps
‡peek only - not arbitrary get

Space Complexity

CollectionSpace ComplexityOverhead
ArrayListO(n)Low - just array
LinkedListO(n)High - 2 pointers per node
HashSet/HashMapO(n)Medium - hash table + chains
TreeSet/TreeMapO(n)Medium - tree nodes

Choosing the Right Collection

Decision Tree

Need key-value pairs?
├─ YES → Map
│  ├─ Need sorting? → TreeMap
│  ├─ Need insertion order? → LinkedHashMap
│  ├─ Thread-safe? → ConcurrentHashMap
│  └─ Default → HashMap

└─ NO → Collection
   ├─ Need uniqueness?
   │  ├─ YES → Set
   │  │  ├─ Need sorting? → TreeSet
   │  │  ├─ Need insertion order? → LinkedHashSet
   │  │  └─ Default → HashSet
   │  │
   │  └─ NO → List or Queue
   │     ├─ Need FIFO/LIFO? → Queue
   │     │  ├─ Priority-based? → PriorityQueue
   │     │  ├─ Thread-safe? → BlockingQueue
   │     │  └─ Default → ArrayDeque
   │     │
   │     └─ Ordered collection? → List
   │        ├─ Frequent random access? → ArrayList
   │        └─ Frequent head/tail ops? → LinkedList

Use Case Examples

// 1. User session cache (bounded, access-order)
Map<String, Session> sessions = new LinkedHashMap<>(1000, 0.75f, true) {
    protected boolean removeEldestEntry(Map.Entry<String, Session> eldest) {
        return size() > 1000;
    }
};
 
// 2. Unique visitor tracking (fast membership)
Set<String> uniqueVisitors = new HashSet<>();
 
// 3. Leaderboard (sorted scores)
TreeMap<Integer, String> leaderboard = new TreeMap<>(Comparator.reverseOrder());
 
// 4. Task queue (priority-based)
PriorityQueue<Task> taskQueue = new PriorityQueue<>(
    Comparator.comparing(Task::getPriority)
);
 
// 5. Event processing (FIFO)
Deque<Event> eventQueue = new ArrayDeque<>();
 
// 6. Thread-safe counter
Map<String, AtomicInteger> counters = new ConcurrentHashMap<>();
counters.computeIfAbsent("requests", k -> new AtomicInteger()).incrementAndGet();
 
// 7. Sorted time-series data
TreeMap<Instant, Double> timeSeries = new TreeMap<>();
SortedMap<Instant, Double> lastHour = timeSeries.tailMap(
    Instant.now().minus(1, ChronoUnit.HOURS)
);
 
// 8. Batch processing (ArrayList for iteration performance)
List<Record> batch = new ArrayList<>(1000);
for (Record record : batch) {
    process(record); // Fast sequential access
}

Best Practices

1. Initialize with Capacity

// ✅ GOOD: Avoid resizing overhead
List<String> items = new ArrayList<>(expectedSize);
Map<String, Integer> map = new HashMap<>(expectedSize);
 
// ❌ BAD: Multiple resizes as it grows
List<String> items = new ArrayList<>(); // Default 10
for (int i = 0; i < 10000; i++) {
    items.add(String.valueOf(i)); // Resizes ~14 times!
}

2. Use Interface Types

// ✅ GOOD: Flexible, allows implementation change
List<String> names = new ArrayList<>();
Set<Integer> ids = new HashSet<>();
Map<String, User> users = new HashMap<>();
 
// ❌ BAD: Tightly coupled to implementation
ArrayList<String> names = new ArrayList<>();
HashSet<Integer> ids = new HashSet<>();

3. Fail-Fast vs Fail-Safe Iteration

// ❌ PITFALL: ConcurrentModificationException
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
for (String item : list) {
    if (item.equals("B")) {
        list.remove(item); // ❌ Throws exception!
    }
}
 
// ✅ SOLUTION 1: Use Iterator.remove()
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String item = it.next();
    if (item.equals("B")) {
        it.remove(); // ✅ Safe
    }
}
 
// ✅ SOLUTION 2: Use removeIf (Java 8+)
list.removeIf(item -> item.equals("B"));
 
// ✅ SOLUTION 3: Collect and remove (for complex logic)
List<String> toRemove = new ArrayList<>();
for (String item : list) {
    if (shouldRemove(item)) {
        toRemove.add(item);
    }
}
list.removeAll(toRemove);

4. Immutable vs Mutable

// ✅ Immutable collections (Java 9+)
List<String> immutable = List.of("A", "B", "C");
Set<Integer> immutableSet = Set.of(1, 2, 3);
Map<String, Integer> immutableMap = Map.of("A", 1, "B", 2);
 
// Immutable collections:
// - Thread-safe by default
// - No risk of accidental modification
// - More efficient memory (no unused capacity)
// - throw UnsupportedOperationException on modification attempts
 
// ❌ PITFALL: Collections.unmodifiableList() is just a view
List<String> original = new ArrayList<>(Arrays.asList("A", "B"));
List<String> unmodifiable = Collections.unmodifiableList(original);
original.add("C"); // ⚠️ Also affects unmodifiable!

5. Equals and HashCode

// ✅ MUST implement both equals() and hashCode() for hash-based collections
class User {
    private String id;
    private String name;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
 
// ❌ PITFALL: Violating equals/hashCode contract
Set<User> users = new HashSet<>();
users.add(new User("1", "Alice"));
users.contains(new User("1", "Alice")); // May return false without proper equals/hashCode!

6. Null Handling

// Collections that allow nulls
List<String> list = new ArrayList<>();
list.add(null); // ✅ OK
 
Map<String, Integer> map = new HashMap<>();
map.put(null, 1); // ✅ One null key allowed
map.put("key", null); // ✅ Null values allowed
 
// Collections that DON'T allow nulls
TreeSet<String> treeSet = new TreeSet<>();
treeSet.add(null); // ❌ NullPointerException
 
TreeMap<String, Integer> treeMap = new TreeMap<>();
treeMap.put(null, 1); // ❌ NullPointerException
 
ConcurrentHashMap<String, Integer> concurrent = new ConcurrentHashMap<>();
concurrent.put(null, 1); // ❌ NullPointerException
concurrent.put("key", null); // ❌ NullPointerException

Thread-Safety Considerations

Option 1: Synchronized Wrappers (Legacy)

// ❌ Poor performance - coarse-grained locking
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
 
// Must manually synchronize iteration
synchronized (syncMap) {
    for (Map.Entry<String, Integer> entry : syncMap.entrySet()) {
        // Process entry
    }
}
// ✅ Better performance - fine-grained locking
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
 
// No external synchronization needed
concurrentMap.put("key", 1);
cowList.add("item");

Option 3: Immutable Collections

// ✅ Thread-safe by nature (cannot be modified)
List<String> immutable = List.of("A", "B", "C");
// Safe to share across threads without synchronization

When to Use Each

ScenarioSolution
Rare updates, many readsCopyOnWriteArrayList/Set
Frequent concurrent updatesConcurrentHashMap
Producer-consumerBlockingQueue
Read-only dataImmutable collections
Legacy codeCollections.synchronized*()

Common Pitfalls

1. Array Conversion

List<String> list = Arrays.asList("A", "B", "C");
list.add("D"); // ❌ UnsupportedOperationException - fixed-size!
 
// ✅ Create mutable copy
List<String> mutableList = new ArrayList<>(Arrays.asList("A", "B", "C"));
mutableList.add("D"); // ✅ OK

2. Sublist Modifications

List<String> original = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
List<String> sublist = original.subList(1, 3); // ["B", "C"] - a view!
 
sublist.clear(); // Clears sublist
System.out.println(original); // ["A", "D"] - ⚠️ Original modified!

3. Type Erasure with Generics

// ❌ Cannot create generic array
List<String>[] arrayOfLists = new ArrayList<String>[10]; // Compile error
 
// ✅ Use List of Lists instead
List<List<String>> listOfLists = new ArrayList<>();

4. Comparator Inconsistency

// ❌ PITFALL: Comparator inconsistent with equals
class Person {
    String name;
    int age;
    
    @Override
    public boolean equals(Object o) {
        // Compares both name and age
    }
}
 
// TreeSet uses comparator that only compares age
TreeSet<Person> people = new TreeSet<>(Comparator.comparing(Person::getAge));
people.add(new Person("Alice", 30));
people.add(new Person("Bob", 30)); // ⚠️ Not added - same age!
// TreeSet thinks they're equal, but equals() says they're different

Performance Benchmarks

Microbenchmark results (approximate, JVM-dependent):

ArrayList.get(index):          ~2 ns
LinkedList.get(index):         ~100 ns (50x slower!)
 
HashMap.get(key):              ~10 ns
TreeMap.get(key):              ~30 ns
ConcurrentHashMap.get(key):    ~15 ns
 
ArrayList.add(end):            ~5 ns
LinkedList.add(end):           ~20 ns
ArrayDeque.add(end):           ~5 ns
 
HashSet.contains():            ~10 ns
TreeSet.contains():            ~30 ns

Key Takeaways:

  • Array-based structures (ArrayList, ArrayDeque) have best cache locality
  • Hash-based structures (HashMap, HashSet) excel at lookups
  • Tree-based structures (TreeMap, TreeSet) provide ordering at O(log n) cost
  • Linked structures (LinkedList) have overhead from pointer chasing

Conclusion

The Java Collections Framework provides powerful, well-designed data structures for every use case:

  • Choose ArrayList for most list needs - fast, efficient, general-purpose
  • Choose HashMap for fast key-value lookups without ordering
  • Choose HashSet for uniqueness checks and fast membership testing
  • Choose TreeMap/TreeSet when you need sorted data or range queries
  • Choose ArrayDeque for queue/stack operations (not LinkedList!)
  • Choose ConcurrentHashMap for high-concurrency scenarios
  • Choose PriorityQueue for priority-based processing

Master these collections, understand their performance characteristics, and your Java applications will be faster, more maintainable, and more robust.


Part of the Java Learning Roadmap series

Back to: Phase 3: Core Java APIs

Related Deep Dives:

Next Step: Getting Started with Spring Boot

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.