Back to Blog
javaperformancecollectionsdata-structuresoptimizationjava-21

Java Collections Performance: Choosing the Right Data Structure (2026)

Choosing ArrayList when you need a LinkedList (or vice versa) causes O(n²) performance. Learn the time complexity of Java collections operations and how to pick the right one every time.

J

JOptimize Team

May 28, 2026· 8 min read

Using the wrong Java collection doesn't fail fast — it degrades gradually. LinkedList used as a stack is fine for 100 elements and catastrophic for 100,000. ArrayList with remove(0) in a loop is O(n²). HashMap with a poorly implemented hashCode() degrades to O(n) per lookup. The fix in each case is one line — but you have to know which line.


The Big-O Reference

OperationArrayListLinkedListHashMapTreeMapHashSet
get(index)O(1)O(n)
add(end)O(1) amort.O(1)O(1) amort.O(log n)O(1)
add(front)O(n)O(1)
remove(index)O(n)O(n)O(1)O(log n)O(1)
containsO(n)O(n)O(1)O(log n)O(1)
Ordered?IndexInsertionNoSorted keyNo
Null keys1 null keyNo1 null

ArrayList vs LinkedList: The Common Mistake

// SLOW — remove(0) shifts all elements: O(n) per call → O(n²) total LinkedList<Task> queue = new LinkedList<>(); while (!queue.isEmpty()) { process(queue.remove(0)); } // FAST for queue operations — use ArrayDeque ArrayDeque<Task> queue = new ArrayDeque<>(); queue.offer(task); // Enqueue task = queue.poll(); // Dequeue — O(1), no shifting

Rule: for FIFO queues, use ArrayDeque, not LinkedList or ArrayList. ArrayDeque is faster than LinkedList (better cache locality) and faster than ArrayList.remove(0) (no shifting).

// For stack (LIFO), also use ArrayDeque ArrayDeque<String> stack = new ArrayDeque<>(); stack.push("a"); // O(1) stack.pop(); // O(1) // Do NOT use Stack<T> — it's synchronized (legacy) and slow

HashMap: The hashCode() Trap

// BAD hashCode — all objects hash to same bucket → O(n) lookups public class BadKey { private final String value; @Override public int hashCode() { return 1; } // Everything in one bucket! @Override public boolean equals(Object o) { ... } } Map<BadKey, String> map = new HashMap<>(); // With 10,000 entries: every get() scans all 10,000 — O(n) not O(1)
// GOOD — use records or proper hashCode public record OrderKey(Long orderId, String region) {} // Records auto-generate correct hashCode and equals // Or use Objects.hash() @Override public int hashCode() { return Objects.hash(orderId, region, customerId); }

For critical hot paths, you can verify distribution: map.entrySet().stream().collect(Collectors.groupingBy(e -> System.identityHashCode(e.getKey()) % 16, Collectors.counting())) — buckets should be roughly equal.


When to Use TreeMap vs HashMap

// HashMap — O(1) operations, unordered Map<String, Product> productById = new HashMap<>(); productById.get("prod-123"); // Fast lookup // TreeMap — O(log n), sorted by key TreeMap<LocalDate, SalesReport> reportsByDate = new TreeMap<>(); reportsByDate.get(date); // Slower lookup reportsByDate.subMap( // ONLY possible with TreeMap LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31) ).values(); // Reports for Q1

Use TreeMap when: you need range queries, floor/ceiling operations, or sorted iteration. For everything else, HashMap is faster.


Concurrent Collections

// BAD — synchronized on every read AND write Map<String, CacheEntry> cache = Collections.synchronizedMap(new HashMap<>()); // synchronized(map) wraps every get/put — contention under load // GOOD — ConcurrentHashMap: reads don't lock, writes lock segment only Map<String, CacheEntry> cache = new ConcurrentHashMap<>(); cache.computeIfAbsent(key, k -> loadFromDb(k)); // Atomic, efficient // For read-heavy, rarely-modified lists List<Listener> listeners = new CopyOnWriteArrayList<>(); listeners.add(listener); // Creates new array copy — slow write listeners.forEach(l -> l.notify(event)); // No lock needed — reads are snapshot

Use CopyOnWriteArrayList for:

  • Listener/observer lists
  • Config values read frequently, updated rarely
  • Thread-safe iteration without ConcurrentModificationException

Java 21: SequencedCollection

// New in Java 21 — consistent API for collections with defined order SequencedCollection<String> list = new ArrayList<>(List.of("a", "b", "c")); list.getFirst(); // "a" — previously: list.get(0) list.getLast(); // "c" — previously: list.get(list.size()-1) list.addFirst("z"); // Add to front — previously: list.add(0, "z") list.reversed(); // Reversed view — previously: Collections.reverse(copy) // Works on List, Deque, LinkedHashSet, TreeSet SequencedSet<String> set = new LinkedHashSet<>(Set.of("a", "b", "c")); set.getFirst(); // "a" — first element by insertion order

Immutable Collections for Thread Safety

// Pre-Java 9 — verbose and still mutable if cast List<String> list = Collections.unmodifiableList(new ArrayList<>(Arrays.asList("a", "b"))); // Java 9+ — truly immutable, no copy needed, memory-efficient List<String> immutable = List.of("a", "b", "c"); // Compact, immutable Map<String, Integer> map = Map.of("a", 1, "b", 2); Set<String> set = Set.of("x", "y", "z"); // For large collections List<String> copy = List.copyOf(mutableList); // Defensive copy, immutable

Immutable collections from List.of() have a smaller memory footprint than ArrayList and are inherently thread-safe — no synchronization needed.


Common Mistakes to Avoid

  • LinkedList as a general-purpose listArrayList beats LinkedList for almost everything due to better cache locality; only use LinkedList when you need O(1) head/tail additions with Deque interface
  • Vector or Hashtable — legacy, fully synchronized on every operation; use ArrayList + ConcurrentHashMap instead
  • new ArrayList<>(collection) when you only need read access — use List.copyOf(collection) for an immutable snapshot, or pass the collection directly if mutation isn't needed
  • Not initializing HashMap capacitynew HashMap<>(expectedSize * 4 / 3 + 1) avoids rehashing for large maps; the default initial capacity of 16 with load factor 0.75 causes rehashing at 12 entries

Summary

Choose Java collections based on access patterns: ArrayList for indexed access, ArrayDeque for queues and stacks, HashMap for key lookup, TreeMap for sorted ranges, ConcurrentHashMap for concurrent maps, CopyOnWriteArrayList for read-heavy concurrent lists, and List.of() / Map.of() for immutable data. The wrong choice rarely breaks anything immediately — but it makes your application slower in proportion to its success.


Detect Collection Performance Issues

JOptimize detects LinkedList used as a general list, Vector/Hashtable usage, missing initial capacity on large HashMaps, and synchronization bottlenecks in concurrent code.

Pick the right collection once, save performance tuning forever.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.