Java Collections Best Practices
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
- Choose the right collection — use the decision flowchart.
- Program to interfaces —
List, notArrayList. - Prefer immutable —
List.of(),Map.of(),List.copyOf(). - Pre-size collections when the count is known.
- Never use raw types — always parameterise with
<T>. - Return empty collections, not
null. - Use modern API methods:
computeIfAbsent(),merge(),removeIf(). - 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.