DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Java Collections Framework Explained Simply

When I first saw Collection and Collections in the same Java file, I thought one of them was a typo. Then I found Arrays, Objects and Collectors and was sure the Java team had a thing for confusing names.

On top of that, I had a bigger problem. Arrays were fine until I needed to add an item to the middle, remove one, avoid duplicates or look something up by name. I kept writing the same helper code over and over, and every version had its own bugs.

The Collections Framework is Java's answer to all of this. It's a ready-made toolbox of data structures and algorithms. In this post, we'll learn the big idea, the Collection interface, the Collections class (and why they're different), the built-in algorithms, and the most popular collections you'll use every day. I've drawn the key pictures in text so you can see how things connect.

All the snippets assume import java.util.*; at the top, and they go inside a main method unless I show a full class.

The concept: what is the Collections Framework?

A collection is an object that holds a group of other objects. The Collections Framework is the set of classes and interfaces in java.util that gives you the common ways to group objects: lists, sets, queues and maps.

Why use it instead of plain arrays?

  • Arrays have a fixed size. A collection grows and shrinks as needed.
  • Ready-made operations. Adding, removing, searching, sorting and checking for duplicates are already written, tested and fast.
  • Different shapes for different needs. Need order? A list. No duplicates? A set. Look up by key? A map.
  • Swappable parts. Code written against an interface works with any implementation.

The framework has three parts. This is the most useful way to remember it:

   1. INTERFACES        the contracts: what a list, set, queue or map CAN DO
                        (List, Set, Queue, Map ...)

   2. IMPLEMENTATIONS   the real classes that do the work
                        (ArrayList, HashSet, HashMap ...)

   3. ALGORITHMS        ready-made methods that work on any collection
                        (sort, search, shuffle ... in the Collections class)
Enter fullscreen mode Exit fullscreen mode

The family tree

Here's how the main interfaces and classes are related. Spend a minute on this picture, because everything else hangs on it:

                      Iterable
                         |
                     Collection                         Map   (separate tree!)
            /------------+-------------\                 |
          List          Set           Queue          +---+----------+
           |             |              |            |   |          |
      ArrayList      HashSet        PriorityQueue  HashMap LinkedHashMap TreeMap
      LinkedList     LinkedHashSet  ArrayDeque
                     TreeSet        (also LinkedList)
Enter fullscreen mode Exit fullscreen mode

Two details in this picture trip people up:

  • Map is not a Collection. A map stores key-value pairs, not single items, so it has its own tree. It's still part of the framework.
  • Iterable sits at the very top. Anything that is Iterable can be used in a for-each loop.

The Collection interface

Collection<E> is the root contract for groups of single items. Lists, sets and queues all extend it, so they all share the same basic methods:

  • add(item) and addAll(otherCollection)
  • remove(item) and removeAll(otherCollection)
  • contains(item) and containsAll(otherCollection)
  • size(), isEmpty() and clear()
  • iterator() to walk through the items
  • removeIf(condition), stream() and toArray()

Because they share this contract, you can write code against Collection and not care which kind you get:

Collection<String> names = new ArrayList<>();
names.add("Asha");
names.add("Ravi");
names.add("Asha");

System.out.println(names.size());
System.out.println(names.contains("Ravi"));
names.remove("Asha");
System.out.println(names);
Enter fullscreen mode Exit fullscreen mode

Output:

3
true
[Ravi, Asha]
Enter fullscreen mode Exit fullscreen mode

remove("Asha") removed only the first match. Now change one word, the implementation on the first line:

Collection<String> names = new HashSet<>();
// ...the same add calls as above...
System.out.println(names.size());
Enter fullscreen mode Exit fullscreen mode

Output:

2
Enter fullscreen mode Exit fullscreen mode

Everything else stayed the same, but a set ignores the second "Asha", so the size is 2. That's the power of programming to an interface: the rest of your code doesn't change when the structure does.

Looping and removing safely

Because every Collection is Iterable, you can loop with for-each. Behind the scenes, the compiler turns it into an iterator loop, using iterator(), hasNext() and next().

Here's a trap that almost everyone falls into: changing the collection while looping over it.

List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));

for (Integer n : nums) {
    if (n == 2) {
        nums.remove(n);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Exception in thread "main" java.util.ConcurrentModificationException
Enter fullscreen mode Exit fullscreen mode

Most collections are fail-fast. They count how many times they've been changed. The iterator remembers that count, and if it sees a different number, it throws this exception, instead of continuing with confusing data.

There are two safe ways:

List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));

Iterator<Integer> it = nums.iterator();
while (it.hasNext()) {
    if (it.next() % 2 == 0) {
        it.remove();          // removes through the iterator: allowed
    }
}
System.out.println(nums);
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 3]
Enter fullscreen mode Exit fullscreen mode

Or, shorter, with removeIf: nums.removeIf(n -> n % 2 == 0);

The Collections class (with an "s")

Now the name that causes the most confusion. Let's separate them clearly:

  • Collection (no s) is an interface. It's the contract for a group of items.
  • Collections (with an s) is a utility class. It holds only static helper methods for working on collections. You never create an object of it.

A simple memory trick: the plural-named classes in Java are usually toolboxes of static methods. Collections works on collections, Arrays works on arrays, and Objects works on objects. You call their methods like Collections.sort(list).

   Collection<E>       interface   "what a group of items can do"      (you implement/use it)
   Collections         class       "helpers that work on collections"  (you just call its methods)
Enter fullscreen mode Exit fullscreen mode

Algorithms

In the framework, an algorithm is a ready-made, reusable method that works on collections. They're all static methods in the Collections class, and the nice part is that they work on any List, whether it's an ArrayList or a LinkedList.

Here are the most useful ones:

List<Integer> nums = new ArrayList<>(List.of(5, 2, 9, 1, 7));

Collections.sort(nums);
System.out.println(nums);

System.out.println(Collections.binarySearch(nums, 7));

Collections.reverse(nums);
System.out.println(nums);

System.out.println(Collections.max(nums) + " " + Collections.min(nums));

Collections.swap(nums, 0, 4);
System.out.println(nums);

System.out.println(Collections.frequency(nums, 5));
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 2, 5, 7, 9]
3
[9, 7, 5, 2, 1]
9 1
[1, 7, 5, 2, 9]
1
Enter fullscreen mode Exit fullscreen mode

What each one did:

  • sort put the list in ascending order.
  • binarySearch found 7 at index 3. The list must be sorted first, or the result is meaningless.
  • reverse flipped the order.
  • max and min found the biggest and smallest.
  • swap exchanged the items at positions 0 and 4.
  • frequency counted how many times 5 appears.

Others worth knowing: shuffle (random order), rotate, fill, nCopies, emptyList and addAll.

Sorting your own way

By default, sort uses the items' natural order (numbers ascending, strings alphabetical). For anything else, pass a Comparator, which is a rule for comparing two items:

List<String> names = new ArrayList<>(List.of("Meera", "Jo", "Ravi", "Asha"));

Collections.sort(names);
System.out.println(names);

Collections.sort(names, Comparator.comparing(String::length));
System.out.println(names);

Collections.sort(names, Collections.reverseOrder());
System.out.println(names);
Enter fullscreen mode Exit fullscreen mode

Output:

[Asha, Jo, Meera, Ravi]
[Jo, Asha, Ravi, Meera]
[Ravi, Meera, Jo, Asha]
Enter fullscreen mode Exit fullscreen mode

Look at the second line. Asha and Ravi both have 4 letters, and they kept their earlier order. That's because the sort is stable: equal items don't swap places.

Behind the scenes

  • Collections.sort(list) simply calls list.sort(...). For objects, Java uses TimSort, a fast and stable algorithm that takes about n log n steps. (For arrays of primitives, Arrays.sort uses a different algorithm, called dual-pivot quicksort.)
  • binarySearch repeatedly cuts the search range in half, so it takes about log n steps. It's very fast on an ArrayList, but slower on a LinkedList, which can't jump to the middle.

Read-only views and thread-safe wrappers

Collections can also wrap a collection to change its behavior:

List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> readOnly = Collections.unmodifiableList(original);

original.add("c");
System.out.println(readOnly);

try {
    readOnly.add("d");
} catch (UnsupportedOperationException e) {
    System.out.println("Read-only!");
}
Enter fullscreen mode Exit fullscreen mode

Output:

[a, b, c]
Read-only!
Enter fullscreen mode Exit fullscreen mode

An unmodifiable list is a view, not a copy. You can't change it through readOnly, but when someone changes original, the view shows the change. If you want a real, independent, unchangeable copy, use List.copyOf(original). There's also Collections.synchronizedList(...) to make a list safe for several threads (although for new code, the classes in java.util.concurrent are usually a better choice).

Well-known collections

Now the real workers. Let's visit the four families.

List: ordered, with duplicates, accessed by position

A List keeps items in the order you add them, allows duplicates and lets you use an index.

ArrayList is the everyday choice. Behind the scenes, it keeps an array. When the array fills up, it creates a bigger one (about 1.5 times larger) and copies everything over.

List<String> fruits = new ArrayList<>();
fruits.add("Apple");
fruits.add("Banana");
fruits.add("Apple");
fruits.add(1, "Mango");

System.out.println(fruits);
System.out.println(fruits.get(2));
System.out.println(fruits.indexOf("Apple"));
Enter fullscreen mode Exit fullscreen mode

Output:

[Apple, Mango, Banana, Apple]
Banana
0
Enter fullscreen mode Exit fullscreen mode
   ArrayList is an array inside:

   [ Apple ][ Mango ][ Banana ][ Apple ][  ][  ]     size = 4, capacity = 6
      0        1        2        3
   get(2) jumps straight to slot 2. Adding in the middle shifts the items after it.
Enter fullscreen mode Exit fullscreen mode

LinkedList stores each item in a small node that points to the previous and next ones:

   head --> [ prev | Apple  | next ] <--> [ prev | Mango | next ] <--> [ prev | Banana | next ] <-- tail
Enter fullscreen mode Exit fullscreen mode

Adding or removing at the ends is very quick. But get(i) must walk node by node, so it's slow for big lists.

Set: no duplicates

A Set never holds the same item twice. add returns false if the item was already there:

Set<Integer> ids = new HashSet<>();

System.out.println(ids.add(10));
System.out.println(ids.add(10));
System.out.println(ids.size());
Enter fullscreen mode Exit fullscreen mode

Output:

true
false
1
Enter fullscreen mode Exit fullscreen mode

There are three common sets, and the only real difference is the order:

List<String> input = List.of("pear", "apple", "fig", "apple");

System.out.println(new LinkedHashSet<>(input));
System.out.println(new TreeSet<>(input));
Enter fullscreen mode Exit fullscreen mode

Output:

[pear, apple, fig]
[apple, fig, pear]
Enter fullscreen mode Exit fullscreen mode
  • HashSet: fastest, but the order is not guaranteed.
  • LinkedHashSet: keeps the order you inserted (pear, apple, fig).
  • TreeSet: keeps items sorted (apple, fig, pear).

Map: key to value

A Map stores pairs. Each key is unique, and it points to a value.

Map<String, Integer> marks = new HashMap<>();
marks.put("Asha", 88);
marks.put("Ravi", 72);
marks.put("Asha", 91);

System.out.println(marks.get("Asha"));
System.out.println(marks.size());
System.out.println(marks.getOrDefault("Meera", 0));
Enter fullscreen mode Exit fullscreen mode

Output:

91
2
0
Enter fullscreen mode Exit fullscreen mode

Putting "Asha" a second time replaced her old value. And getOrDefault gave 0 for a missing key, instead of null.

A very common job is counting. Here's a word counter that uses merge:

String text = "to be or not to be";
Map<String, Integer> counts = new TreeMap<>();

for (String word : text.split(" ")) {
    counts.merge(word, 1, Integer::sum);
}

System.out.println(counts);
Enter fullscreen mode Exit fullscreen mode

Output:

{be=2, not=1, or=1, to=2}
Enter fullscreen mode Exit fullscreen mode

Maps come in the same three flavors as sets: HashMap (fastest, no order), LinkedHashMap (insertion order) and TreeMap (sorted by key).

Behind the scenes: how HashMap finds things

A HashMap keeps an array of buckets. When you put a key, Java calls the key's hashCode(), turns it into a bucket number and stores the entry there. To find it again, it repeats the same steps, then uses equals() to pick the right entry if a bucket holds several.

   put("Asha", 91)
        |
   "Asha".hashCode()  -->  a number  -->  bucket index 3

   buckets:   [0] [1] [2] [3: Asha=91 -> Ravi=72] [4] [5] ...
                               ^
                   two keys landed in the same bucket (a "collision"),
                   so equals() decides which one you meant
Enter fullscreen mode Exit fullscreen mode

This is why lookup is so quick (about one step on average, not a search through everything). A few details:

  • It starts with 16 buckets, and doubles when it's 75% full.
  • Since Java 8, a bucket that gets too crowded switches from a chain to a balanced tree, so even bad cases stay fast.
  • If you use your own class as a key, you must override both equals() and hashCode(), and keep them consistent. Otherwise, lookups will fail even for identical-looking objects.
  • A HashSet is secretly a HashMap (the items are the keys). In the same way, TreeSet is backed by a TreeMap, and LinkedHashSet by a LinkedHashMap.

Queue and Deque: waiting lines and stacks

A Queue processes items in order: first in, first out (FIFO). A Deque (double-ended queue) can add and remove at both ends, so it works as a stack too (last in, first out, LIFO).

Queue<String> line = new ArrayDeque<>();
line.offer("A");
line.offer("B");
line.offer("C");

System.out.println(line.poll());
System.out.println(line.peek());

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
stack.push(3);

System.out.println(stack.pop());
Enter fullscreen mode Exit fullscreen mode

Output:

A
B
3
Enter fullscreen mode Exit fullscreen mode
  • offer adds, poll removes and returns the front, and peek looks at the front without removing.
  • For a stack, prefer ArrayDeque over the old Stack class.

A PriorityQueue doesn't use arrival order. It always gives you the smallest item first (or whatever your comparator says is first):

PriorityQueue<Integer> pq = new PriorityQueue<>(List.of(5, 1, 3));

System.out.println(pq.poll());
System.out.println(pq.poll());
Enter fullscreen mode Exit fullscreen mode

Output:

1
3
Enter fullscreen mode Exit fullscreen mode

Don't print the whole queue to check the order. Inside, it's a heap, and only the front is guaranteed to be the smallest.

How fast is each one?

Here is a rough guide to the speed of the common operations (n is the number of items):

  • ArrayList: get(i) is instant (O(1)). Adding at the end is fast (O(1) on average). Inserting or removing in the middle, or searching with contains, takes O(n).
  • LinkedList: adding or removing at the ends is O(1). get(i) and contains are O(n).
  • HashSet / HashMap: add, remove and lookup are O(1) on average.
  • TreeSet / TreeMap: add, remove and lookup are O(log n), and the items stay sorted.
  • ArrayDeque: adding and removing at either end is O(1).
  • PriorityQueue: add and poll are O(log n), and peek is O(1).

Which one should I use?

Start from what you need, and the choice is usually clear:

   Need to look things up by a KEY?          -> Map
        default: HashMap     sorted keys: TreeMap     insertion order: LinkedHashMap

   Need NO DUPLICATES?                       -> Set
        default: HashSet     sorted: TreeSet          insertion order: LinkedHashSet

   Need first-in-first-out, a stack,
   or "smallest first"?                      -> Queue / Deque
        ArrayDeque  or  PriorityQueue

   Otherwise, an ordered group?              -> List
        default: ArrayList
Enter fullscreen mode Exit fullscreen mode

Some extra advice:

  • Default to ArrayList and HashMap. They're the best general-purpose choices. In practice, ArrayList beats LinkedList in most real cases, even for inserts, because arrays are friendly to the CPU.
  • Declare variables with the interface: List<String> names = new ArrayList<>();. Then you can swap the implementation by editing one word.
  • Collections hold objects, not primitives. Write List<Integer>, not List<int>. (This is where wrapper classes and autoboxing come in.)
  • Sorted data you keep changing? Use a TreeSet or TreeMap. Sorted once? Keep an ArrayList and call Collections.sort once.

Common mistakes

  1. Mixing up Collection, Collections and Arrays. Collection is the interface. Collections and Arrays are classes of static helper methods.
  2. Changing a collection while looping over it. It throws ConcurrentModificationException. Use an Iterator with remove(), or removeIf.
  3. Using your own class as a key without equals() and hashCode(). Lookups fail, and duplicates sneak into sets. Also, don't change a key's fields after you've put it into a map or set.
  4. Relying on the order of a HashSet or HashMap. It looks stable in small tests, then changes. If you need an order, choose LinkedHash... or Tree....
  5. Calling get(i) in a loop over a LinkedList. Each call walks the list, so the loop becomes slow (O(n²)). Use for-each, or an ArrayList.
  6. Forgetting that some lists can't be changed. List.of(...), List.copyOf(...) and Collections.unmodifiableList(...) throw UnsupportedOperationException on changes. Arrays.asList(1, 2, 3) has a fixed size, so add also fails. Wrap it in new ArrayList<>(...) when you need a normal list.

Also remember Collections.binarySearch only works on a list that's already sorted in the same order.

Quick recap

  • The Collections Framework is Java's ready-made toolbox in java.util: interfaces, implementations and algorithms.
  • Collection is the root interface for groups of items (List, Set, Queue). Map is separate, because it stores pairs.
  • Every Collection is Iterable, so for-each works, but don't change a collection while looping over it.
  • Collections (with an s) is a utility class of static methods: sort, binarySearch, reverse, shuffle, max, min, frequency, swap and wrappers like unmodifiableList.
  • List: ArrayList (array inside, the default) and LinkedList (nodes). Set: HashSet, LinkedHashSet, TreeSet. Map: HashMap, LinkedHashMap, TreeMap. Queue/Deque: ArrayDeque, PriorityQueue.
  • HashMap uses hashCode() to find a bucket and equals() to pick the entry, so your key classes need both.
  • Choose by need: key lookup, no duplicates, queue behavior or an ordered list. When unsure, start with ArrayList and HashMap.

Try it yourself

Here's a small practice idea: build a tiny contact book. Store contacts in a Map<String, String> (name to phone number) and print them sorted by name using a TreeMap. Then add every name to a List, sort it with Collections.sort by name length, and use Collections.binarySearch to find one name.

For a bonus experiment, create a Student class without equals() and hashCode(), and add two students with the same data to a HashSet. Notice that both get in. Then add the two methods and watch the duplicate disappear.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

Top comments (0)