← Back to the section

An array in Java has a fixed size: create it for 10 elements and you can't fit more. In real code the size is usually unknown in advance: there might be three users, or there might be three thousand. That's what collections are for — a set of ready-made data structures that grow, search and store elements on their own. Let's go through the three main kinds and figure out when to use each.

the same values go into three collections add “Moscow” add “Kazan” add “Moscow” a second time List Moscow Kazan Moscow size() → 3order and duplicates Set Moscow Kazan repeat dropped size() → 2unique values only Map Moscow→1 Kazan→1 Moscow→2 value replaced size() → 2one entry per key

Three collections receive the same three additions. The List stores everything in a row and remembers the order; the Set spots the repeat and silently drops it; the Map replaces the value under the same key. That is why the final sizes differ: 3, 2 and 2.

Why collections are needed

Almost any program accumulates something: a list of orders, a set of unique tags, a mapping "login → user". Writing such structures by hand is slow and error-prone. The Java standard library already has them, battle-tested over the years and fast.

All collections live in the java.util package. At their core are three interfaces: List, Set and Map. An interface describes what a collection can do, while concrete classes (ArrayList, HashMap, etc.) describe how it's done internally.

Short formula: an ordered sequence with possible duplicates — List; a set of unique values — Set; "key → value" pairs — Map.

List — an ordered list

List stores elements in the order you added them and allows duplicates. Any element can be accessed by index (as in an array), starting from zero.

live example

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

public class ListDemo {
    public static void main(String[] args) {
        List<String> cities = new ArrayList<>();
        cities.add("Moscow");
        cities.add("Kazan");
        cities.add("Moscow");                // duplicates are allowed
        System.out.println(cities.get(0));   // Moscow
        System.out.println(cities.size());   // 3
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Here List<String> is "a list of strings". The angle brackets are a generic type; they fix that the elements inside are exactly String — more on that in a separate article about generics.

The two main implementations:

  • ArrayList — a regular array inside that expands on its own. Use it by default: fast access by index and fast appending to the end. In 95% of cases it's ArrayList.
  • LinkedList — a linked list: each element knows its neighbours. Inserting at the front is cheap, but the familiar "at least it inserts into the middle faster" is a myth. To insert into the middle you first have to walk there along the chain of references, and that is slow. ArrayList shifts the tail of its array with a single machine copy operation and in practice wins almost always. Reach for LinkedList only when you need a queue open at both ends — and even then ArrayDeque is usually the better choice.

Short formula: when in doubt, use ArrayList.

Set — a set of unique values

Set stores only unique elements: adding the same value again changes nothing. This is handy when you need to remove duplicates or check "is this one already there?".

live example

import java.util.HashSet;
import java.util.Set;

public class SetDemo {
    public static void main(String[] args) {
        Set<String> tags = new HashSet<>();
        tags.add("java");
        tags.add("backend");
        tags.add("java");                          // the repeat is ignored
        System.out.println(tags.size());           // 2
        System.out.println(tags.contains("java")); // true
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Implementations:

  • HashSet — the fastest, but the order of elements is not guaranteed. Use it by default.
  • TreeSet — keeps elements sorted (strings alphabetically, numbers in ascending order). Needed when the iteration order matters.
  • LinkedHashSet — preserves insertion order. An in-between option.

Map — a "key → value" dictionary

Map stores pairs: a value is fetched instantly by its key. It's like a phone book: you find a number by a name. Keys are unique, values may repeat.

live example

import java.util.HashMap;
import java.util.Map;

public class MapDemo {
    public static void main(String[] args) {
        Map<String, Integer> ages = new HashMap<>();
        ages.put("Anna", 30);
        ages.put("Ivan", 25);
        ages.put("Anna", 31);                             // same key — the value is overwritten
        System.out.println(ages.get("Anna"));             // 31
        System.out.println(ages.getOrDefault("Peter", 0)); // 0 — no such key
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Implementations:

  • HashMap — fast access by key, order not guaranteed. The default choice.
  • TreeMap — keys are kept sorted.
  • LinkedHashMap — preserves the insertion order of keys.

getOrDefault is handy to avoid getting null when a key is absent — there are separate articles about handling missing values and Optional.

How to iterate over collections

The most common way is the for-each loop: it reads simply and needs no indices.

live example

import java.util.List;

public class ForEachDemo {
    public static void main(String[] args) {
        List<String> cities = List.of("Moscow", "Kazan", "Sochi");
        for (String city : cities) {       // "for each city in cities"
            System.out.println(city);
        }
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

For a Map, you iterate over the pairs via entrySet():

live example

import java.util.Map;

public class EntriesDemo {
    public static void main(String[] args) {
        Map<String, Integer> ages = Map.of("Anna", 30, "Ivan", 25);
        for (Map.Entry<String, Integer> entry : ages.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Under the hood, for-each uses an iterator (Iterator) — an object that "walks" through the collection element by element. You need it directly in one important case: when you have to remove elements right during iteration. Removing via cities.remove(...) inside a for-each is not allowed — it will throw a ConcurrentModificationException. The correct way is:

live example

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class IteratorDemo {
    public static void main(String[] args) {
        List<String> cities = new ArrayList<>(List.of("Moscow", "Kazan", "Sochi"));
        Iterator<String> it = cities.iterator();
        while (it.hasNext()) {
            String city = it.next();
            if (city.startsWith("K")) {
                it.remove();            // safe removal through the iterator
            }
        }
        System.out.println(cities);     // [Moscow, Sochi]
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Why equals and hashCode matter

HashSet and HashMap determine the "sameness" of elements and keys not by their reference in memory, but through two methods: equals (whether two objects are equal in meaning) and hashCode (a "fingerprint" number by which the collection quickly finds the right slot). For String and numbers they're already implemented correctly, which is why the examples above work.

But your own class without these methods will behave unexpectedly in a Set/Map:

live example

import java.util.HashSet;
import java.util.Set;

public class PointDemo {
    record Point(int x, int y) {}      // a record generates equals and hashCode itself

    public static void main(String[] args) {
        Set<Point> points = new HashSet<>();
        points.add(new Point(1, 2));
        System.out.println(points.contains(new Point(1, 2)));  // true
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

If Point were a regular class without overridden equals/hashCode, the result would be false: two different objects with the same coordinates would be considered different. Rule: if an object is put into a Set or becomes a key of a Map, it must have consistent equals and hashCode. The easiest way to get them for free is to make the type a record; more on that in the articles about OOP and modern Java features.

EnumMap and EnumSet: collections for enum keys

When the key is an enum, a regular HashMap is overkill: there is nothing to hash, and the set of keys is known in advance. EnumMap keeps values in a plain array indexed by the constant's ordinal — faster than HashMap, smaller in memory, and iteration always follows the declaration order of the constants. EnumSet is even more frugal: a set of enum values is packed into a bit mask. The rule is simple: if the key or the element is an enum, take EnumMap/EnumSet, not HashMap/HashSet.

Immutable collections

Sometimes a collection needs to be protected from changes — for example, returned from a method so that the calling code can't spoil it. For this there are the factory methods List.of, Set.of, Map.of (since Java 9):

live example

import java.util.List;

public class ImmutableDemo {
    public static void main(String[] args) {
        List<String> roles = List.of("admin", "user");   // an immutable list
        try {
            roles.add("guest");
        } catch (UnsupportedOperationException e) {
            System.out.println("UnsupportedOperationException: cannot be changed");
        }
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Such collections are immutable: an attempt to add or remove anything will throw an exception. This is safer and more expressive — it's immediately clear that the data isn't meant to be changed. If you later need a mutable copy, wrap it: new ArrayList<>(List.of(...)).

In short

  • Three base interfaces: List (order + duplicates), Set (unique values), Map ("key → value" pairs).
  • By default use ArrayList, HashSet, HashMap — they're fast and cover most tasks.
  • LinkedList is almost never needed: ArrayList is faster even where it should lose in theory. TreeSet/TreeMap — when you need sorting.
  • Iterate via for-each; remove during iteration only through Iterator.remove().
  • Elements of a Set and keys of a Map need consistent equals and hashCode; the simplest way is a record.
  • List.of, Set.of, Map.of create immutable collections — handy for protecting data.