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
| Interface | Holds | Duplicates | Ordered | Reach for it when |
|---|---|---|---|---|
List | a sequence | yes | yes, by position | order matters, or you need an index |
Set | unique values | no | no (usually) | you care whether something is present |
Map | key → value pairs | unique keys | no (usually) | you look things up by a key |
Queue | items awaiting processing | yes | yes | you 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 plainHashMap.
Next
Exception handling is next — what to do when the lookup fails, the file is missing, or the network is down.