Nizam Miftahul
← All articles

Java Collections, Generics & Ordering: List, Set, Map, Comparable and Comparator

JavaBackendCollectionsGenerics

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, and HashMap.
  • Use LinkedHashMap or LinkedHashSet when iteration order is visible or tested.
  • Use TreeMap or TreeSet only when sorted storage is required, and verify the ordering is consistent with the intended uniqueness rule.
  • Expose List, Set, or Map in APIs; avoid leaking a concrete collection type.
  • Prefer Comparator over Comparable when an object can be sorted in more than one useful way.
  • Use List.copyOf, Set.copyOf, and Map.copyOf at boundaries where a collection should not be mutated after construction.