Collections & Generics

Java · Lesson 22 of 32 6 min read Updated

Arrays have a fixed size, and real data rarely does: items go into a shopping cart and come out again, new students join a class, a shop sells more items every hour. The Java Collections Framework in java.util gives you ready-made, growable data structures. The three you will use every day are List, Set and Map.

Collections are built on generics: the <String> in List<String> tells Java what type of element the collection holds, so the compiler can catch mistakes. In this lesson you will use the main collections, then write generic classes and methods of your own.

List: an ordered, growable sequence

A list keeps elements in order, allows duplicates and gives each element an index, like an array that can grow. ArrayList is the standard implementation. Declare the variable with the interface type List and create an ArrayList; the empty <> (called the diamond) lets Java infer the element type.

Java
import java.util.ArrayList;
import java.util.List;

public class Lists {
    public static void main(String[] args) {
        List<String> cart = new ArrayList<>();
        cart.add("Rice 5kg");
        cart.add("Toor dal");
        cart.add("Ghee");
        cart.add(1, "Atta 10kg");                // insert at index 1

        System.out.println(cart);
        System.out.println("Items: " + cart.size());
        System.out.println("Second item: " + cart.get(1));
        System.out.println("Has ghee? " + cart.contains("Ghee"));

        cart.set(0, "Basmati rice 5kg");         // replace
        cart.remove("Toor dal");                  // remove by value
        System.out.println(cart);

        System.out.println("First: " + cart.getFirst() + ", last: " + cart.getLast());
        System.out.println("Reversed: " + cart.reversed());

        for (String item : cart) {
            System.out.println("- " + item);
        }

        List<Integer> marks = new ArrayList<>(List.of(67, 92, 45, 88));
        marks.removeIf(m -> m < 50);
        marks.sort(null);                         // natural order
        System.out.println(marks);

        List<String> fixed = List.of("Mon", "Tue");
        try {
            fixed.add("Wed");
        } catch (UnsupportedOperationException e) {
            System.out.println("List.of gives an unmodifiable list");
        }
    }
}
Output
[Rice 5kg, Atta 10kg, Toor dal, Ghee]
Items: 4
Second item: Atta 10kg
Has ghee? true
[Basmati rice 5kg, Atta 10kg, Ghee]
First: Basmati rice 5kg, last: Ghee
Reversed: [Ghee, Atta 10kg, Basmati rice 5kg]
- Basmati rice 5kg
- Atta 10kg
- Ghee
[67, 88, 92]
List.of gives an unmodifiable list

Unlike arrays, collections print nicely by themselves. A few highlights:

  • getFirst(), getLast() and reversed() come from sequenced collections, added in Java 21. Older code writes cart.get(cart.size() - 1) for the last element.
  • removeIf(m -> m < 50) removes every element matching a condition. The m -> m < 50 part is a lambda, a short inline function; you will learn lambdas properly in Lambdas & Functional Interfaces.
  • List.of(...) creates an unmodifiable list. It is perfect for fixed data, but adding to it throws UnsupportedOperationException. Wrap it in new ArrayList<>(...) when you need to change it.

Collections can only hold objects, so a list of numbers is List<Integer>, not List<int> (that gives error: unexpected type ... required: reference, found: int). Java converts between int and Integer automatically, which is called autoboxing.

Set: unique elements

A set never holds duplicates; adding an element that is already present does nothing and returns false. Choose the implementation by the order you need: HashSet (fastest, no predictable order), LinkedHashSet (keeps insertion order) or TreeSet (keeps elements sorted).

Java
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;

public class Sets {
    public static void main(String[] args) {
        String[] visitors = {"Pune", "Kochi", "Jaipur", "Pune", "Agra", "Kochi"};

        Set<String> hash = new HashSet<>();
        Set<String> linked = new LinkedHashSet<>();
        Set<String> tree = new TreeSet<>();
        for (String city : visitors) {
            hash.add(city);
            linked.add(city);
            tree.add(city);
        }
        System.out.println("Unique cities: " + linked.size());
        System.out.println("LinkedHashSet: " + linked);
        System.out.println("TreeSet:       " + tree);
        System.out.println("add Pune again? " + linked.add("Pune"));

        Set<String> team = new TreeSet<>(Set.of("Aarav", "Isha", "Rohan"));
        Set<String> chess = new TreeSet<>(Set.of("Isha", "Meera", "Rohan"));
        Set<String> both = new TreeSet<>(team);
        both.retainAll(chess);
        System.out.println("In both clubs: " + both);
    }
}
Output
Unique cities: 4
LinkedHashSet: [Pune, Kochi, Jaipur, Agra]
TreeSet:       [Agra, Jaipur, Kochi, Pune]
add Pune again? false
In both clubs: [Isha, Rohan]

Sets are ideal for "have I seen this before?" questions, and contains on a HashSet is very fast even with millions of elements. retainAll keeps only elements that are also in another collection (an intersection); addAll gives a union and removeAll a difference. Hash-based collections rely on equals and hashCode, which is why you must override both together in your own classes (or use records).

Map: keys and values

A map stores key-value pairs: a student's roll number and name, an item and its stock. Each key appears once; putting a value for an existing key replaces the old value. HashMap is the everyday choice, LinkedHashMap keeps insertion order and TreeMap keeps keys sorted.

Java
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;

public class Maps {
    public static void main(String[] args) {
        Map<String, Integer> stock = new HashMap<>();
        stock.put("Samosa", 40);
        stock.put("Kachori", 25);
        stock.put("Jalebi", 60);
        stock.put("Samosa", 35);                     // same key: value replaced

        System.out.println("Samosas left: " + stock.get("Samosa"));
        System.out.println("Dhokla: " + stock.get("Dhokla"));
        System.out.println("Dhokla or 0: " + stock.getOrDefault("Dhokla", 0));
        System.out.println("Has jalebi? " + stock.containsKey("Jalebi"));

        String[] sales = {"Samosa", "Jalebi", "Samosa", "Kachori", "Samosa"};
        Map<String, Integer> sold = new TreeMap<>();
        for (String item : sales) {
            sold.merge(item, 1, Integer::sum);        // add 1, starting from 1
        }
        System.out.println(sold);

        for (Map.Entry<String, Integer> e : sold.entrySet()) {
            System.out.println(e.getKey() + " sold " + e.getValue());
        }
    }
}
Output
Samosas left: 35
Dhokla: null
Dhokla or 0: 0
Has jalebi? true
{Jalebi=1, Kachori=1, Samosa=3}
Jalebi sold 1
Kachori sold 1
Samosa sold 3

get returns null for a missing key, so prefer getOrDefault when a sensible default exists. merge(item, 1, Integer::sum) is the standard counting trick: if the key is new, store 1; otherwise add 1 to the old value (Integer::sum is a method reference, also explained in the lambdas lesson). To loop over a map, iterate its entrySet(), keySet() or values().

Generics: type-safe code for any type

Generics let you write a class or method once and use it with many types, while the compiler still checks every use. The letter in angle brackets, such as T (type) or E (element), is a type parameter: a placeholder filled in when the class is used.

Java
import java.util.List;

public class Generic {
    record Pair<A, B>(A first, B second) {}

    static class Box<T> {
        private T item;

        void put(T item) { this.item = item; }
        T get() { return item; }
        boolean isEmpty() { return item == null; }
    }

    static <T> T firstOr(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    static <T extends Comparable<T>> T largest(List<T> items) {
        T best = items.get(0);
        for (T item : items) {
            if (item.compareTo(best) > 0) {
                best = item;
            }
        }
        return best;
    }

    static double total(List<? extends Number> numbers) {
        double sum = 0;
        for (Number n : numbers) {
            sum += n.doubleValue();
        }
        return sum;
    }

    public static void main(String[] args) {
        Box<String> lunch = new Box<>();
        lunch.put("Rajma chawal");
        String food = lunch.get();                 // no cast needed
        System.out.println(food + ", empty? " + lunch.isEmpty());

        Pair<String, Integer> score = new Pair<>("Ananya", 97);
        System.out.println(score.first() + " scored " + score.second());

        System.out.println(firstOr(List.of(), "nobody"));
        System.out.println(largest(List.of(45, 92, 78)));
        System.out.println(largest(List.of("Kochi", "Agra", "Pune")));
        System.out.println(total(List.of(10, 20.5, 3L)));
    }
}
Output
Rajma chawal, empty? false
Ananya scored 97
nobody
92
Pune
33.5
  • Box<T> and Pair<A, B> are generic types; Box<String> can only hold strings, and get() returns a String with no cast.
  • <T> T firstOr(...) is a generic method: the <T> before the return type declares its own type parameter.
  • <T extends Comparable<T>> is a bounded type parameter: T must be comparable, so compareTo is allowed. It works for both numbers and strings.
  • List<? extends Number> is a wildcard: a list of any kind of number (Integer, Double, Long), read-only in practice.

The payoff is safety: with List<String> names, writing names.add(42) fails at compile time with error: incompatible types: int cannot be converted to String, instead of crashing later.

Which collection should I use?

You needUse
An ordered list, duplicates allowedArrayList
Unique items, fast lookupHashSet (or LinkedHashSet to keep order)
Unique items, always sortedTreeSet
Look up values by a keyHashMap (or LinkedHashMap, TreeMap)
A fixed list or map that never changesList.of(...), Set.of(...), Map.of(...)

Common mistakes

  • List<int>. Use the wrapper type: List<Integer>.
  • The remove trap on integer lists. list.remove(2) removes the element at index 2; to remove the value 2, write list.remove(Integer.valueOf(2)).
  • Changing a list while looping over it with for-each. This usually throws ConcurrentModificationException. Use removeIf instead.
  • Adding to List.of(...). It is unmodifiable; copy it into an ArrayList first.
  • Forgetting that map.get can return null. Use getOrDefault or containsKey.

Key points

  • List (ArrayList) is ordered and allows duplicates; Set (HashSet, LinkedHashSet, TreeSet) keeps unique elements; Map (HashMap, TreeMap) stores key-value pairs.
  • Declare variables with interface types: List<String> cart = new ArrayList<>();.
  • Java 21 sequenced collections add getFirst, getLast and reversed.
  • List.of, Set.of and Map.of create unmodifiable collections.
  • Generics (<T>) give compile-time type safety; bounds (T extends Comparable<T>) and wildcards (? extends Number) control which types are allowed.

Exercise

A school canteen recorded orders as "Aarav:Samosa", "Isha:Dosa", "Aarav:Tea", "Meera:Dosa", "Isha:Tea", "Aarav:Samosa". Write a program that builds a TreeMap<String, List<String>> of each student's items, a TreeSet<String> of all different items sold, and a TreeMap<String, Integer> counting each item. Print all three.

Show solution
Java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;

public class Canteen {
    public static void main(String[] args) {
        String[] orders = {"Aarav:Samosa", "Isha:Dosa", "Aarav:Tea",
                           "Meera:Dosa", "Isha:Tea", "Aarav:Samosa"};

        Map<String, List<String>> byStudent = new TreeMap<>();
        Set<String> items = new TreeSet<>();
        Map<String, Integer> counts = new TreeMap<>();

        for (String order : orders) {
            String[] parts = order.split(":");
            String student = parts[0];
            String item = parts[1];

            byStudent.computeIfAbsent(student, k -> new ArrayList<>()).add(item);
            items.add(item);
            counts.merge(item, 1, Integer::sum);
        }
        System.out.println(byStudent);
        System.out.println(items);
        System.out.println(counts);
    }
}
Output
{Aarav=[Samosa, Tea, Samosa], Isha=[Dosa, Tea], Meera=[Dosa]}
[Dosa, Samosa, Tea]
{Dosa=2, Samosa=2, Tea=2}

computeIfAbsent creates an empty list the first time a student appears and returns the existing list after that, so .add(item) always works. A TreeMap and TreeSet keep the output sorted.

Quick Quiz

1. Which collection automatically removes duplicates?

2. What does map.put("Samosa", 35) do if "Samosa" is already a key?

3. Why does List<int> not compile?

4. What does <T extends Comparable<T>> mean in a method?

Learn Java with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call