Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Choosing a Collection: Complexity and Memory Footprint

A complete Big-O table for every common collection, what each one actually costs per element in bytes, why boxing dominates numeric collections, and a decision procedure that fits on one page.

Intermediate7 min readUpdated
On this page

Two tables and a decision procedure. This page is the reference you should be able to reconstruct from memory, plus the reasoning that makes it more than memorisation.

Key Takeaways

  • ArrayList and HashMap are the right answer far more often than the alternatives.
  • Memory overhead per element ranges from ~4 bytes (ArrayList) to ~48 (HashMap entry) to ~64 (TreeMap node).
  • Boxing costs roughly 16 extra bytes and one cache miss per numeric element.
  • Presizing removes every resize copy and is free.
  • Below a few thousand elements, cache locality beats Big-O.

Time complexity

Collectionget / containsadd / putremoveIterationOrdered
ArrayListO(1) by index, O(n) by valueO(1) amortisedO(n)O(n) fastInsertion
LinkedListO(n)O(1) at endsO(1) via iteratorO(n) slowInsertion
ArrayDequeO(n)O(1) both endsO(1) both endsO(n) fastInsertion
CopyOnWriteArrayListO(n)O(n)O(n)O(n), snapshotInsertion
HashSet / HashMapO(1)O(1)O(1)O(n + capacity)None
LinkedHashSet / LinkedHashMapO(1)O(1)O(1)O(n)Insertion or access
TreeSet / TreeMapO(log n)O(log n)O(log n)O(n)Sorted
EnumSet / EnumMapO(1), one instructionO(1)O(1)O(n)Declaration
PriorityQueueO(n) for containsO(log n)O(log n) pollO(n), heap orderHead only
ConcurrentHashMapO(1)O(1)O(1)O(n), weakly consistentNone

Two rows repay a second look. HashMap iteration is O(n + capacity), not O(n): the iterator walks every table slot including the empty ones. A map that once held a million entries and now holds ten still costs a million slot reads per iteration, because the table never shrinks.

And CopyOnWriteArrayList has an O(n) add. It is not "a thread-safe ArrayList" — it is a read-optimised structure whose writes copy everything. Using it for a collection that is appended to in a loop turns an O(n) build into O(n²).

Memory, per element

Assuming a 64-bit JVM with compressed oops (the default below a 32GB heap): object header 12 bytes, reference 4 bytes, everything padded to an 8-byte boundary.

StructurePer-element overheadNotes
int[]4 bytesThe floor
Object[] / ArrayList4 bytes + ~33% slackSlack from 1.5× growth
LinkedList node~40 bytes24-byte node, padded, plus the reference
HashMap entry~48 bytes32-byte Node + amortised table slot
LinkedHashMap entry~56 bytesTwo extra references
TreeMap entry~64 bytesLeft, right, parent, colour
Integer object+16 bytesOn top of whatever holds the reference
the same million numbers, four ways
int[] a                = new int[1_000_000];        //  ~4 MB
List<Integer> b        = new ArrayList<>(1_000_000);// ~20 MB (4 ref + 16 Integer)
Set<Integer> c         = new HashSet<>(...);        // ~64 MB
Map<Integer,Integer> d = new HashMap<>(...);        // ~80 MB

A factor of twenty between the first and the last, for the same information. On a container with a 512MB heap limit that difference decides whether the service runs.

Boxing

what autoboxing actually does
List<Integer> list = new ArrayList<>();
list.add(42);                     // Integer.valueOf(42) — cached, no allocation
list.add(1_000_000);              // new Integer(1000000) — a heap allocation
int x = list.get(0);              // intValue() — a dereference, possibly a cache miss
 
// The classic puzzle:
Integer a = 127, b = 127;   a == b;   // true  — both from the Integer cache
Integer c = 128, d = 128;   c == d;   // false — two distinct objects

The Integer cache covers -128 to 127 by default (the upper bound is settable with -XX:AutoBoxCacheMax). Real data escapes it immediately, so every element becomes a separate 16-byte object that must be dereferenced to read a 4-byte value — the allocation is bad, and the cache miss on each read is usually worse.

Where numeric collections are large or hot, the options are: IntStream and the primitive streams; plain arrays; or a primitive-collection library — Eclipse Collections, fastutil, HPPC or Agrona. A fastutil IntArrayList of a million values costs 4MB against roughly 20MB for ArrayList<Integer>, and iterating it never dereferences anything.

Sizing

presizing, done right
new ArrayList<>(expectedSize);                    // exact — no resize at all
new HashMap<>((int) (expectedSize / 0.75f) + 1);  // account for the load factor
HashMap.newHashMap(expectedSize);                 // Java 19+ does the arithmetic
new StringBuilder(expectedLength);
new ArrayBlockingQueue<>(capacity);               // bounded on purpose

The HashMap line is the one people get wrong: new HashMap<>(1000) sets the capacity to 1024, and with a 0.75 load factor it resizes at 768 entries. Sizing for the expected count requires dividing by the load factor first.

Presizing matters most in two places: building a large collection in a loop, where it eliminates a logarithmic number of full copies; and any collection allocated per request, where the resize garbage multiplies by your request rate.

Where Big-O stops predicting

An O(1) HashMap lookup involves computing a hash, masking, reading a table slot, following a reference to a Node (likely a cache miss), comparing the hash, and calling equals. An O(n) scan of a 20-element ArrayList is twenty sequential reads from one or two cache lines, with the branch predictor getting every iteration right.

Below roughly 50–100 elements the linear scan often wins. That is not a licence to use lists as maps, but it does explain why micro-optimising a small collection by "upgrading" it to a HashMap usually achieves nothing, and why the JDK itself uses linear search inside small EnumMaps and inside HashMap buckets before treeification.

The same reasoning explains ArrayList beating LinkedList at middle insertion, and ArrayDeque beating LinkedList everywhere — covered in ArrayList vs LinkedList.

The decision procedure

Four questions cover almost every real choice. The two green boxes are the answer most of the time.

Then apply three modifiers:

  1. Shared between threads? ConcurrentHashMap, ConcurrentHashMap.newKeySet(), a bounded BlockingQueue, or CopyOnWriteArrayList if reads overwhelmingly dominate.
  2. Keys are enums? EnumMap or EnumSet, always.
  3. Millions of primitives? An array, a primitive stream, or a primitive-collection library.

What gets asked

Interviewers usually ask this as a scenario: "you need to store X and do Y with it — what do you use?" Answer with the collection and the reason, and state the cost you are accepting. "TreeMap, because I need floorEntry for effective-dated lookups, and I am accepting O(log n) and about 64 bytes per entry to get it" is a complete answer.

The follow-up worth preparing is the memory one. Most candidates can recite Big-O; being able to say "roughly 48 bytes per HashMap entry, so a million entries is about 48MB before the keys and values" is much rarer, and it is exactly the reasoning that matters when a service is running in a container with a fixed memory limit.

Frequently Asked Questions

How much memory does a HashMap entry actually use?
About 48 bytes per entry on a 64-bit JVM with compressed oops, before the key and value objects themselves. That is a 32-byte Node — header, cached hash, key reference, value reference, next reference — plus roughly 5.3 bytes of amortised table slot at a 0.75 load factor, plus alignment. A million-entry map costs around 48MB of overhead on top of whatever the keys and values weigh.
Why is a List of Integer so much more expensive than an int array?
Each Integer is a separate heap object with a 16-byte header holding a 4-byte value, and the list stores a 4-byte reference to it. So each element costs about 20 bytes against 4 for an int array — a five-fold difference, plus the cache misses from chasing a reference to reach each value. Integers from -128 to 127 come from a shared cache, which is why small test data hides the cost.
Does Big-O actually decide which collection is faster?
Only once the sizes are large. Below a few thousand elements, constant factors and cache behaviour dominate completely — an O(n) scan of a contiguous array routinely beats an O(1) hash lookup that dereferences a scattered node. Use Big-O to rule out the catastrophically wrong choice, then measure. This is the honest answer, and interviewers tend to appreciate it.

Related tutorials