Java HashSet and TreeSet
A Set is a collection that stores unique elements — no duplicates allowed. Java’s two most common set implementations, HashSet and TreeSet, both enforce this uniqueness but organize their elements very differently under the hood: HashSet uses a hash table for fast, unordered storage, while TreeSet uses a self-balancing binary search tree to keep elements sorted at all times. Picking between them is really a tradeoff between raw speed and guaranteed ordering.
Overview / How Sets Work
The Set interface extends Collection and adds exactly one rule: no two elements may be “equal” to each other. What counts as “equal” depends on the implementation. HashSet is backed internally by a HashMap — every element you add is stored as a key in that map (with a dummy constant value), so uniqueness is decided by hashCode() and equals(). TreeSet is backed internally by a TreeMap, which is a red-black tree, so uniqueness and ordering are both decided by compareTo() (or a Comparator you supply) rather than equals().
This leads to very different performance characteristics. A HashSet offers average O(1) time for add, remove, and contains, but gives you no ordering guarantee at all — the iteration order can change between runs, between JDK versions, or even after a resize. A TreeSet offers O(log n) time for the same operations, but always iterates in sorted order and supports powerful range queries like “give me everything between X and Y.”
Another subtle but important difference: HashSet permits a single null element, because HashMap reserves a special bucket for a null key. A TreeSet using natural ordering does not permit null — trying to insert one throws a NullPointerException, because the tree has to call null.compareTo(...) to figure out where it belongs.
Syntax
Set<Type> setName = new HashSet<>();
Set<Type> setName = new TreeSet<>();
TreeSet<Type> setName = new TreeSet<>(comparator);
| Method | Available on | Description |
|---|---|---|
add(e) |
both | Inserts e; returns false if it was already present |
remove(e) |
both | Removes e if present |
contains(e) |
both | Checks membership |
size() / isEmpty() |
both | Count of elements / whether it’s empty |
first() / last() |
TreeSet | Smallest / largest element |
higher(e) / lower(e) |
TreeSet | Strictly greater / lesser neighbor of e |
ceiling(e) / floor(e) |
TreeSet | Smallest element >= e / largest element <= e |
headSet(e) / tailSet(e) |
TreeSet | View of elements before / from e |
Examples
1. A basic HashSet
import java.util.HashSet;
import java.util.Set;
public class Main {
public static void main(String[] args) {
Set<Integer> numbers = new HashSet<>();
numbers.add(5);
numbers.add(3);
numbers.add(9);
numbers.add(3);
numbers.add(1);
System.out.println("Set contents: " + numbers);
System.out.println("Size: " + numbers.size());
System.out.println("Contains 9? " + numbers.contains(9));
}
}
Output:
Set contents: [1, 3, 5, 9]
Size: 4
Contains 9? true
Notice the duplicate 3 was silently ignored — add() returns false for it instead of throwing. For small, non-negative Integer values like these, HashSet happens to iterate in ascending order because an Integer‘s hash code is its own value, which maps directly to a bucket index in the default 16-bucket table. This is an implementation detail, not a guarantee — don’t rely on it for anything but strings and larger/negative numbers, where the order looks essentially random.
2. TreeSet keeps things sorted
import java.util.TreeSet;
public class Main {
public static void main(String[] args) {
TreeSet<Integer> scores = new TreeSet<>();
scores.add(72);
scores.add(95);
scores.add(48);
scores.add(88);
scores.add(60);
System.out.println("Sorted scores: " + scores);
System.out.println("Lowest: " + scores.first());
System.out.println("Highest: " + scores.last());
System.out.println("Smallest score >= 60: " + scores.ceiling(60));
System.out.println("Largest score < 88: " + scores.lower(88));
}
}
Output:
Sorted scores: [48, 60, 72, 88, 95]
Lowest: 48
Highest: 95
Smallest score >= 60: 60
Largest score < 88: 72
Unlike HashSet, this ordering is guaranteed by the contract of TreeSet — every insertion walks the red-black tree to find the correct sorted position. Methods like ceiling and lower take advantage of that structure to answer “nearest value” questions in O(log n) time instead of scanning the whole set.
3. A realistic example: sorting custom objects
import java.util.TreeSet;
class Employee implements Comparable<Employee> {
String name;
double salary;
Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}
@Override
public int compareTo(Employee other) {
return Double.compare(this.salary, other.salary);
}
@Override
public String toString() {
return name + " ($" + salary + ")";
}
}
public class Main {
public static void main(String[] args) {
TreeSet<Employee> employees = new TreeSet<>();
employees.add(new Employee("Dana", 72000));
employees.add(new Employee("Sam", 58000));
employees.add(new Employee("Priya", 91000));
System.out.println("Employees sorted by salary:");
for (Employee e : employees) {
System.out.println(e);
}
System.out.println("Highest paid: " + employees.last());
}
}
Output:
Employees sorted by salary:
Sam ($58000.0)
Dana ($72000.0)
Priya ($91000.0)
Highest paid: Priya ($91000.0)
Because Employee implements Comparable<Employee>, the TreeSet knows exactly how to order every insertion — no manual sorting step needed. If you wanted a different order (say, alphabetical by name) without changing the class, you’d pass a Comparator<Employee> to the TreeSet constructor instead of relying on compareTo.
Under the Hood
HashSet: internally it’s a HashMap<E, Object> where your elements are keys and every value points to a shared dummy constant. When you call add(e), Java computes e.hashCode(), “spreads” the bits to reduce collisions, and uses hash & (capacity - 1) to pick a bucket index (capacity is always a power of two, default 16). If that bucket already holds elements, Java compares your new element to each one with equals() to check for a real duplicate; if none match, it’s appended to that bucket’s list (or a small tree, once a bucket gets crowded with 8+ entries). Once the number of elements exceeds capacity * loadFactor (0.75 by default), the whole table doubles in size and everything is rehashed — which is why iteration order can shift as a set grows.
TreeSet: internally it’s a TreeMap<E, Object>, a self-balancing red-black tree. Every add(e) walks down from the root, comparing e against existing nodes with compareTo() (or your Comparator) until it finds the right spot, then rebalances the tree if needed to keep operations at O(log n). Critically, TreeSet decides duplicates using compareTo, not equals. If compareTo returns 0 for two objects that are otherwise different, the second one is silently rejected as a “duplicate” — even though equals() would say they’re not the same object at all. This is a frequent source of subtle bugs when a Comparator only looks at one field.
Common Mistakes
Mistake 1: Forgetting to override equals() and hashCode()
Wrong — two objects that “look” identical are treated as distinct because the default Object.equals() compares memory references:
import java.util.HashSet;
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
public class Main {
public static void main(String[] args) {
HashSet<Point> points = new HashSet<>();
points.add(new Point(1, 2));
points.add(new Point(1, 2));
System.out.println("Size: " + points.size());
}
}
Output:
Size: 2
Both points have the same coordinates, but the set still stores two entries because Point never told Java what “equal” means for it. Fixed version:
import java.util.HashSet;
import java.util.Objects;
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Point)) return false;
Point other = (Point) obj;
return x == other.x && y == other.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
public class Main {
public static void main(String[] args) {
HashSet<Point> points = new HashSet<>();
points.add(new Point(1, 2));
points.add(new Point(1, 2));
System.out.println("Size: " + points.size());
}
}
Output:
Size: 1
Always override equals() and hashCode() together — if two objects are equal, their hash codes must match too, or a HashSet may never even look in the right bucket to find the duplicate.
Mistake 2: Removing from a set while looping over it with for-each
Wrong — modifying a set during iteration corrupts the iterator’s internal bookkeeping:
import java.util.HashSet;
import java.util.Set;
public class Main {
public static void main(String[] args) {
Set<Integer> numbers = new HashSet<>();
numbers.add(2);
numbers.add(4);
numbers.add(6);
numbers.add(8);
for (int n : numbers) {
if (n == 2) {
numbers.remove(n);
}
}
System.out.println(numbers);
}
}
Output:
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java)
at java.base/java.util.HashMap$KeyIterator.next(HashMap.java)
at Main.main(Main.java:12)
A for-each loop uses an Iterator behind the scenes, and every set method that structurally changes the collection (like remove called directly on the set) invalidates it, tripping a safety check the next time next() is called. Fixed version — remove through the iterator itself:
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public class Main {
public static void main(String[] args) {
Set<Integer> numbers = new HashSet<>();
numbers.add(2);
numbers.add(4);
numbers.add(6);
numbers.add(8);
Iterator<Integer> it = numbers.iterator();
while (it.hasNext()) {
int n = it.next();
if (n == 2) {
it.remove();
}
}
System.out.println(numbers);
}
}
Output:
[4, 6, 8]
Best Practices
- Always override
equals()andhashCode()together for any class you plan to store in aHashSet. - For
TreeSet, make sure yourcompareTo/Comparatoris consistent withequals— otherwise two “different” objects can silently vanish as duplicates. - Use
TreeSetwhen you need sorted iteration or range queries (headSet,tailSet,ceiling,floor). - Use
HashSetwhen you only need fast uniqueness checks and don’t care about order — it’s faster and uses less memory per operation. - Never mutate a set directly while iterating with for-each; use
Iterator.remove()instead. - If you want insertion-order iteration with HashSet-like speed, use
LinkedHashSetinstead of either of these. - Don’t hard-code assumptions about
HashSetiteration order into your program logic — it’s an implementation detail that can change. - If you know roughly how many elements you’ll store, construct the
HashSetwith an initial capacity to avoid repeated resizing.
Practice Exercises
Exercise 1: Write a program that reads a list of words from an array and prints only the unique ones (in any order), using a HashSet.
Exercise 2: Given an array of int values with duplicates, use a TreeSet to print them in sorted order with duplicates removed, then print the second-highest value using TreeSet methods (no manual loops for the second-highest part).
Exercise 3: Define a Book class with title and isbn fields. Override equals() and hashCode() based only on isbn, then add several Book objects (including two with the same isbn but different titles) to a HashSet and print the resulting size to confirm the duplicate was rejected.
Summary
HashSetstores unique elements using a hash table; it offers averageO(1)operations but no ordering guarantee.TreeSetstores unique elements in a sorted red-black tree; it offersO(log n)operations plus guaranteed sorted order and range queries.HashSetuniqueness is decided byequals()/hashCode();TreeSetuniqueness is decided bycompareTo()or a suppliedComparator.- Objects placed in a
HashSetmust overrideequals()andhashCode()together to behave correctly. TreeSetwith natural ordering rejectsnull;HashSetallows exactly onenull.- Never remove from a set directly inside a for-each loop — use
Iterator.remove()to avoidConcurrentModificationException. - Reach for
LinkedHashSetwhen you want predictable insertion-order iteration.
