Java Collections in Practice

The Java Collections Framework provides lists, sets, maps, and queues with well-defined iteration and concurrency behavior. Choosing the right type affects correctness, performance, and scalability in real applications. This article focuses on mental models, when to use which abstraction, and when to switch to concurrent collections.

Overview

  • List: Ordered, allows duplicates. ArrayList is the default for random access; LinkedList is rarely better and has worse cache behavior in most cases.
  • Set: No duplicates. HashSet for unordered, LinkedHashSet for insertion order, TreeSet for sorted order (and higher cost).
  • Map: Key-value. HashMap, LinkedHashMap, TreeMap mirror the set choices. Use TreeMap only when you need order or range operations.
  • Queue: For producer-consumer or "process in order" patterns. ArrayDeque is a good default for a single-threaded queue or stack.

In multi-threaded code, prefer ConcurrentHashMap over synchronizing a plain HashMap for shared state. Use concurrent queues such as ConcurrentLinkedQueue, LinkedBlockingQueue, or similar when multiple threads produce or consume.

Example

A shared cache used by many request-handling threads is a good fit for ConcurrentHashMap:

Java
// Shared across request-handling threads
private final ConcurrentHashMap<String, UserSession> cache = new ConcurrentHashMap<>();

public UserSession getOrLoad(String userId) {
    return cache.computeIfAbsent(userId, id -> loadSessionFromDb(id));
}
  • computeIfAbsent atomically checks and computes; only one thread runs the loader per key. No external locking is needed.
  • If you used a plain HashMap with synchronized on the whole map, contention on a single lock would limit throughput. With ConcurrentHashMap, different buckets can be updated in parallel, giving better scalability for read-heavy or mixed workloads.

For a single-threaded queue or stack, ArrayDeque is a good default:

Java
Deque<Task> stack = new ArrayDeque<>();
stack.push(task);
Task next = stack.pop();
  • Deque is the interface; push() and pop() are stack-style operations. For a FIFO queue you would use offer() and poll().

Core Mechanism / Behavior

  • ArrayList: Resizable array; add is amortized O(1), get/set by index is O(1). Good cache locality.
  • HashMap: Hashing and buckets; average get/put is O(1) with a good hash function. Iteration order is undefined unless you use LinkedHashMap.
  • TreeMap: Red-black tree; get/put is O(log n); keys are ordered. Use when you need order or range operations.
  • Concurrent collections: Lock striping or lock-free structures so different threads can make progress without blocking each other on every operation. ConcurrentHashMap is usually a better fit than Collections.synchronizedMap(new HashMap()) for concurrent access.
  • Queues: Use ArrayDeque for single-threaded queue/stack; use ConcurrentLinkedQueue, LinkedBlockingQueue, or similar when multiple threads produce or consume.

Key Rules

  • Assuming iteration is thread-safe: Iterating over a non-concurrent collection while another thread mutates it can throw ConcurrentModificationException or show undefined behavior. Use concurrent collections or explicit locking.
  • Using HashMap when order matters: If you need insertion or access order, use LinkedHashMap. If you need sort order, use TreeMap.
  • Overusing LinkedList: For most list use cases, ArrayList is faster and simpler. Reserve LinkedList for frequent insert/remove in the middle when you already have the node reference.
  • Choose by need: order, uniqueness, concurrency, or specific access patterns. Prefer ArrayList and HashMap (or LinkedHashMap) by default; switch to concurrent variants when multiple threads share the structure; use tree-based structures only when you need ordering or range operations.

What's Next

For deep dives on specific types, see HashMap deep dive and ArrayList vs LinkedList. For concurrency, see ConcurrentHashMap internals.