Java Collections Best Practices

4 minute read

Choosing the right collection and using it correctly is one of the highest-impact decisions in Java development. This chapter distils the key principles.


1. Choose the Right Collection

Decision Flowchart

Do you need key-value pairs?
├── YES → Need sorted keys?
│         ├── YES → TreeMap
│         └── NO  → Need insertion order?
│                   ├── YES → LinkedHashMap
│                   └── NO  → HashMap
│
└── NO → Do you need uniqueness (no duplicates)?
         ├── YES → Need sorted?
         │         ├── YES → TreeSet
         │         └── NO  → Need insertion order?
         │                   ├── YES → LinkedHashSet
         │                   └── NO  → HashSet
         │
         └── NO → Need FIFO/LIFO?
                  ├── YES → ArrayDeque (stack or queue)
                  └── NO  → Need priority ordering?
                            ├── YES → PriorityQueue
                            └── NO  → ArrayList (default)

Quick Reference

Need Use Why
General-purpose list ArrayList O(1) access, cache-friendly
Queue / Stack ArrayDeque Faster than LinkedList
No duplicates HashSet O(1) add/contains
No duplicates + order LinkedHashSet O(1) + insertion order
Sorted unique elements TreeSet O(log n), range queries
Key-value lookup HashMap O(1) get/put
Sorted key-value TreeMap O(log n), sorted iteration
Priority processing PriorityQueue Min/max heap
Thread-safe map ConcurrentHashMap Lock-striping, high throughput

2. Program to Interfaces

// ✅ GOOD — declare as interface
List<String> names = new ArrayList<>();
Map<String, Integer> scores = new HashMap<>();
Set<String> tags = new HashSet<>();
Queue<String> tasks = new ArrayDeque<>();

// ❌ BAD — declare as implementation
ArrayList<String> names = new ArrayList<>();
HashMap<String, Integer> scores = new HashMap<>();

Why? You can swap implementations without changing all downstream code.


3. Use Immutable Collections

// ✅ Return immutable collections from APIs
public List<String> getNames() {
    return List.copyOf(internalList);
}

// ✅ Use List.of() for constants
private static final List<String> VALID_STATUSES = List.of("ACTIVE", "INACTIVE", "PENDING");

// ✅ Use Map.of() for small lookup tables
private static final Map<String, Integer> HTTP_CODES = Map.of(
    "OK", 200,
    "NOT_FOUND", 404,
    "ERROR", 500
);

Benefits:

  • Thread-safe by design.
  • Prevent accidental modification.
  • Clear intent — readers know it won’t change.

4. Pre-size Collections

// ❌ BAD — multiple resizes for large data
List<String> list = new ArrayList<>();
Map<String, Integer> map = new HashMap<>();

// ✅ GOOD — single allocation
List<String> list = new ArrayList<>(10_000);
Map<String, Integer> map = new HashMap<>(10_000);  // rounds to next power of 2

Pre-sizing avoids the cost of repeated resize + copy operations.


5. Never Use Raw Types

// ❌ BAD — raw type, no type safety
List names = new ArrayList();
Map scores = new HashMap();

// ✅ GOOD — parameterised
List<String> names = new ArrayList<>();
Map<String, Integer> scores = new HashMap<>();

// ✅ When type is unknown, use wildcard
List<?> unknown = getList();

6. Use removeIf() Instead of Iterator Remove

// ❌ Verbose
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().isBlank()) it.remove();
}

// ✅ Clean
list.removeIf(String::isBlank);

7. Use Map.computeIfAbsent() for Multi-Maps

// ❌ Verbose null-check pattern
Map<String, List<String>> groups = new HashMap<>();
if (!groups.containsKey("fruits")) {
    groups.put("fruits", new ArrayList<>());
}
groups.get("fruits").add("apple");

// ✅ One-liner
groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("apple");

8. Use merge() for Counting and Accumulation

// ❌ Verbose
Integer count = map.get(key);
map.put(key, count == null ? 1 : count + 1);

// ✅ Clean
map.merge(key, 1, Integer::sum);

9. Return Empty Collections, Not Null

// ❌ BAD — caller must null-check
public List<String> findNames() {
    if (noResults) return null;
}

// ✅ GOOD — return empty collection
public List<String> findNames() {
    if (noResults) return List.of();
}

10. Thread Safety Options

Approach Use When
Collections.synchronizedMap() Quick wrapper, low contention
ConcurrentHashMap High-throughput concurrent access
CopyOnWriteArrayList Read-heavy, rare writes
Immutable collections (List.of()) Read-only data shared across threads
External synchronization (synchronized) Full control needed
// ❌ HashMap in multithreaded code — data corruption
Map<String, Integer> unsafe = new HashMap<>();

// ✅ Thread-safe alternative
Map<String, Integer> safe = new ConcurrentHashMap<>();

11. Prefer Streams for Transformations

// ✅ Filter + transform + collect
List<String> activeNames = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .sorted()
    .toList();

// ✅ Group by
Map<String, List<User>> byDepartment = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment));

12. equals() and hashCode() for Collection Elements

If you put custom objects in Set, Map (as keys), or use contains(), indexOf() — you must override both:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof MyClass that)) return false;
    return Objects.equals(id, that.id);
}

@Override
public int hashCode() {
    return Objects.hash(id);
}

Anti-Pattern Summary

Anti-Pattern Fix
Raw types (List, Map) Always use generics (List<String>)
Returning null for empty results Return List.of() or Collections.emptyList()
containsKey() + get() Use getOrDefault() or computeIfAbsent()
HashMap in concurrent code Use ConcurrentHashMap
Declaring ArrayList instead of List Program to interfaces
Not pre-sizing large collections Pass expected size to constructor
Mutable keys in HashMap/HashSet Use immutable keys
Sorting before binary search (forgetting) Always sort first, or use TreeSet/TreeMap

Key Takeaways

  1. Choose the right collection — use the decision flowchart.
  2. Program to interfaces — List, not ArrayList.
  3. Prefer immutable — List.of(), Map.of(), List.copyOf().
  4. Pre-size collections when the count is known.
  5. Never use raw types — always parameterise with <T>.
  6. Return empty collections, not null.
  7. Use modern API methods: computeIfAbsent(), merge(), removeIf().
  8. Override equals() + hashCode() for custom collection elements.

Module Complete!

You’ve completed the Collections & Generics module. You now understand Lists, Sets, Maps, Queues, Iterators, Comparable/Comparator, Generics, Wildcards, and best practices for choosing and using the right collection.

Updated: