Java Collections, Generics & Ordering: List, Set, Map, Comparable and Comparator
1. Start with the contract
The Collections Framework is easier to use when you choose by behavior first, then by implementation.
| Need | Interface | Common implementation |
|---|---|---|
| Ordered values; duplicates allowed | List<E> |
ArrayList<E> |
| Unique values | Set<E> |
HashSet<E> |
| Key-value lookup | Map<K, V> |
HashMap<K, V> |
| Sorted unique values | SortedSet<E> |
TreeSet<E> |
| Sorted key-value lookup | SortedMap<K, V> |
TreeMap<K, V> |
| Insertion-order iteration | Set<E> or Map<K, V> |
LinkedHashSet<E> or LinkedHashMap<K, V> |
Prefer interfaces in fields, parameters, and return types. The implementation remains a local decision:
List<String> activeUserIds = new ArrayList<>();
Set<String> processedEventIds = new HashSet<>();
Map<String, Invoice> invoicesByNumber = new HashMap<>();
ArrayList is the normal default for a list: fast indexed reads and good append performance. Reach for LinkedList only when its linked-node behavior is genuinely needed; it is rarely the best general-purpose list.
2. List, Set, and Map have different rules
List keeps encounter order and allows duplicates. Set represents uniqueness, using equals() and hashCode() for hash-based implementations. Map gives each key one value; putting the same key again replaces its value.
List<String> queue = new ArrayList<>(List.of("PAYMENT", "PAYMENT", "EMAIL"));
Set<String> channels = new HashSet<>(queue);
System.out.println(channels.size());
Map<String, Integer> quantities = new HashMap<>();
quantities.put("SKU-001", 2);
quantities.merge("SKU-001", 3, Integer::sum);
System.out.println(quantities.get("SKU-001"));
When traversing a map, iterate entries rather than keys followed by get:
for (Map.Entry<String, Integer> entry : quantities.entrySet()) {
System.out.printf("%s: %d%n", entry.getKey(), entry.getValue());
}
For immutable constant data, use the factory methods:
List<String> roles = List.of("ADMIN", "OPERATOR");
Set<String> scopes = Set.of("read", "write");
Map<String, String> headers = Map.of("Accept", "application/json");
They are unmodifiable, reject null, and Set.of rejects duplicate elements. Use new ArrayList<>(roles) when you need a mutable copy.
3. Hashing and ordering are part of the model
Objects placed in a HashSet or used as HashMap keys need stable equals() and hashCode() values. Do not mutate fields used in those methods while an object is already in a hash-based collection.
public record ProductCode(String value) {
public ProductCode {
Objects.requireNonNull(value);
if (value.isBlank()) {
throw new IllegalArgumentException("value must not be blank");
}
}
}
Set<ProductCode> codes = new HashSet<>();
codes.add(new ProductCode("SKU-001"));
System.out.println(codes.contains(new ProductCode("SKU-001")));
Records are particularly useful as collection keys because their generated equals() and hashCode() are component-based. For a mutable class, make the identity fields final whenever possible.
Use LinkedHashMap or LinkedHashSet when a deterministic insertion order is part of the output. Use TreeMap or TreeSet when the collection itself must stay sorted. A tree-based collection uses its ordering to determine equality, so an ordering that returns 0 treats two values as the same element or key.
4. Generics make invalid states harder to express
Generics place the element type in the collection contract. Avoid raw types; they defer a type error until runtime.
List<Invoice> invoices = new ArrayList<>();
invoices.add(new Invoice("INV-001", BigDecimal.TEN));
Invoice invoice = invoices.getFirst();
Type parameters work for your own reusable code too:
public record Page<T>(List<T> items, int page, int totalPages) {
public Page {
items = List.copyOf(items);
}
}
Page<Invoice> result = new Page<>(invoices, 1, 1);
The most useful wildcard rule is PECS: Producer Extends, Consumer Super.
public static double totalOf(List<? extends Number> values) {
return values.stream().mapToDouble(Number::doubleValue).sum();
}
public static void addDefaults(List<? super Integer> target) {
target.add(10);
target.add(20);
}
? extends Number is safe to read as Number, but you cannot add a specific subtype to it. ? super Integer is safe to receive Integer values, but reads only have the guaranteed type Object.
Use a bounded type parameter when the method itself needs the bound:
public static <T extends Comparable<? super T>> T max(List<? extends T> values) {
return values.stream()
.max(Comparator.naturalOrder())
.orElseThrow();
}
5. Natural ordering with Comparable
Implement Comparable<T> when a type has one obvious, domain-wide default order. Its compareTo result is negative, zero, or positive; only the sign matters.
public record Priority(int level, String label) implements Comparable<Priority> {
@Override
public int compareTo(Priority other) {
return Integer.compare(level, other.level);
}
}
List<Priority> priorities = new ArrayList<>(List.of(
new Priority(3, "Low"),
new Priority(1, "High"),
new Priority(2, "Normal")
));
priorities.sort(Comparator.naturalOrder());
Do not write return this.level - other.level; subtraction can overflow. Use Integer.compare, Long.compare, or Comparator.comparing instead.
The natural ordering should normally be consistent with equals(). If two distinct values compare as zero, a TreeSet will retain only one of them and a TreeMap will treat one as the other’s key.
6. Use Comparator for contextual ordering
Most domain types have more than one valid order: by creation time, total amount, customer name, or status. Keep those choices outside the entity with Comparator.
public record Invoice(
String number,
String customerName,
BigDecimal total,
LocalDate issuedAt
) { }
Comparator<Invoice> byNewestThenNumber = Comparator
.comparing(Invoice::issuedAt, Comparator.reverseOrder())
.thenComparing(Invoice::number);
Comparator<Invoice> byHighestTotal = Comparator
.comparing(Invoice::total)
.reversed();
invoices.sort(byNewestThenNumber);
For nullable sort keys, decide the business order explicitly:
Comparator<Invoice> byCustomerName = Comparator.comparing(
Invoice::customerName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
);
List.sort changes the existing list. To preserve the original list, create a sorted result instead:
List<Invoice> newestFirst = invoices.stream()
.sorted(byNewestThenNumber)
.toList();
7. Practical defaults
- Start with
ArrayList,HashSet, andHashMap. - Use
LinkedHashMaporLinkedHashSetwhen iteration order is visible or tested. - Use
TreeMaporTreeSetonly when sorted storage is required, and verify the ordering is consistent with the intended uniqueness rule. - Expose
List,Set, orMapin APIs; avoid leaking a concrete collection type. - Prefer
ComparatoroverComparablewhen an object can be sorted in more than one useful way. - Use
List.copyOf,Set.copyOf, andMap.copyOfat boundaries where a collection should not be mutated after construction.