Collections

July 3, 20265 min readUpdated 8/20/2026

Collections are the containers you reach for in every program: a list of orders, a set of tags, a map from id to customer. Java gives you four shapes and several implementations of each. Picking one should take about five seconds, and this post is mostly about making that true.

The four shapes

InterfaceHoldsDuplicatesOrderedReach for it when
Lista sequenceyesyes, by positionorder matters, or you need an index
Setunique valuesnono (usually)you care whether something is present
Mapkey → value pairsunique keysno (usually)you look things up by a key
Queueitems awaiting processingyesyesyou add at one end and take from the other

Declare the variable as the interface and construct the implementation. It costs nothing and lets you swap the implementation later without touching any other line:

List<String> names = new ArrayList<>();      // not ArrayList<String> names
Set<String> tags = new HashSet<>();
Map<String, Integer> ages = new HashMap<>();

The <String> is a generic type parameter: it tells the compiler what the collection holds, so it can reject the wrong thing at compile time and you never need a cast on the way out. A collection without one is a raw type — you will meet them in pre-2004 code, and they are a source of runtime ClassCastException.

List

List<String> names = new ArrayList<>();
names.add("Ana");
names.add("Bo");
names.add(0, "Cy");                    // insert at a position

System.out.println(names);             // [Cy, Ana, Bo]
System.out.println(names.get(1));      // Ana
System.out.println(names.size());      // 3
System.out.println(names.contains("Bo"));   // true
System.out.println(names.indexOf("Bo"));    // 2

names.remove("Cy");
names.set(0, "Ana Maria");             // replace in place
System.out.println(names.getFirst());  // Ana Maria  (Java 21)

ArrayList versus LinkedList: use ArrayList. It is backed by an array, so reading by index is instant and iteration is fast because the elements sit together in memory. LinkedList is theoretically better at inserting in the middle, but in practice its pointer-chasing loses to ArrayList for almost all real workloads. Choose it only when you have measured.

Set

Set<String> tags = new HashSet<>();
System.out.println(tags.add("java"));      // true  — added
System.out.println(tags.add("java"));      // false — already there, no duplicate
tags.add("backend");

System.out.println(tags.contains("java")); // true — this is what a Set is for
System.out.println(tags.size());           // 2

Set<String> sorted = new TreeSet<>(tags);  // iterates in sorted order
System.out.println(sorted);                // [backend, java]

Set<String> insertionOrder = new LinkedHashSet<>();  // keeps the order you added

Membership testing on a HashSet is effectively constant time regardless of size, which is why converting a list to a set before repeatedly asking "does it contain X" is one of the easiest performance wins there is — a List.contains scans every element.

Map

Map<String, Integer> stock = new HashMap<>();
stock.put("apples", 10);
stock.put("pears", 4);
stock.put("apples", 12);                   // same key: replaces, does not duplicate

System.out.println(stock.get("apples"));           // 12
System.out.println(stock.get("plums"));            // null — no such key
System.out.println(stock.getOrDefault("plums", 0));// 0 — usually what you want
System.out.println(stock.containsKey("pears"));    // true

for (Map.Entry<String, Integer> entry : stock.entrySet()) {
    System.out.println(entry.getKey() + " = " + entry.getValue());
}

Three methods that replace a lot of clumsy code:

Map<String, Integer> counts = new HashMap<>();

counts.merge("apples", 1, Integer::sum);       // add 1, or start at 1 — counting in one line
counts.merge("apples", 1, Integer::sum);
System.out.println(counts.get("apples"));      // 2

Map<String, List<String>> byLetter = new HashMap<>();
byLetter.computeIfAbsent("a", k -> new ArrayList<>()).add("Ana");   // build a multimap safely
System.out.println(byLetter);                  // {a=[Ana]}

counts.putIfAbsent("pears", 0);                // only if the key is missing

HashMap has no order. LinkedHashMap preserves insertion order and TreeMap keeps keys sorted — reach for those when the order is part of what you are producing, such as a report.

The rule that makes hashing work

HashSet and HashMap find things by calling hashCode() to pick a bucket and then equals() to confirm. If your class does not override both, they fall back to object identity, and lookups fail in a way that looks impossible:

class BadPoint {
    final int x, y;
    BadPoint(int x, int y) { this.x = x; this.y = y; }
}

record GoodPoint(int x, int y) { }        // records generate equals and hashCode for you

class Demo {
    void run() {
        Set<BadPoint> bad = new HashSet<>();
        bad.add(new BadPoint(1, 2));
        System.out.println(bad.contains(new BadPoint(1, 2)));   // false!

        Set<GoodPoint> good = new HashSet<>();
        good.add(new GoodPoint(1, 2));
        System.out.println(good.contains(new GoodPoint(1, 2))); // true
    }
}

The rule: if instances of your class will go into a hash-based collection, override equals and hashCode together — or use a record, which writes both for you.

A related trap: never mutate a field used in hashCode after adding the object to a set. Its hash changes, it stays in the old bucket, and it is now unreachable.

Immutable collections

List<String> fixed = List.of("a", "b", "c");
Set<String> tags = Set.of("java", "backend");
Map<String, Integer> limits = Map.of("max", 10, "min", 1);

// fixed.add("d");                      // UnsupportedOperationException

List<String> editable = new ArrayList<>(fixed);   // copy when you need to change it
editable.add("d");
System.out.println(editable.size());    // 4

Use List.of and friends for anything that should not change — constants, method returns, test fixtures. They are compact, they cannot be modified by a caller, and they reject null outright, which surfaces a bug at the point it is made.

Defensive copying

An immutable collection is also how you stop a caller changing your object after handing you something. From the console bank app this site uses for examples:

public CsvTable(Path file, List<String> header) {
    this.file = file;
    // List.copyOf gives an unmodifiable copy, so a caller cannot change the header later by
    // holding on to the list they passed in. Defensive copying, in one call.
    this.header = List.copyOf(header);
}

Without the copy, the caller still holds a reference to that list and can add a column to it tomorrow — and this object would silently start reading a different file format. One method call closes that off.

Choosing, in five seconds

  • Need order or an index? ArrayList.
  • Need uniqueness or fast "is it in there"? HashSet.
  • Looking things up by a key? HashMap.
  • Need any of those sorted? TreeSet / TreeMap.
  • Need insertion order preserved? LinkedHashSet / LinkedHashMap.
  • Shared across threads? ConcurrentHashMap — never a plain HashMap.

Next

Exception handling is next — what to do when the lookup fails, the file is missing, or the network is down.