Помнишь, как в детстве собирал коллекцию марок или вкладышей от жвачек? И как бесило, когда попадалась одна и та же картинка в десятый раз? Вот HashSet — это такая коллекция в Java, которая автоматически отсеивает все повторы. Добавляешь элемент второй раз — он просто проигнорируется. Удобно же!
Я когда только начинал изучать коллекции, думал: "Ну ладно, есть ArrayList, зачем мне ещё что-то?" А потом столкнулся с задачей — нужно было собрать список уникальных email'ов из базы данных. И тут-то я понял всю красоту HashSet. Вместо того чтобы городить проверки "а есть ли уже такой элемент", просто кидаешь всё в HashSet и получаешь готовый список уникальных значений.![HashSet и интерфейс Set в Java - 2]()
Что такое HashSet и почему он крутой
HashSet — это класс из Java Collections Framework, который реализует интерфейс Set. А Set, если по-простому, это математическое множество. Помнишь из школы круги Эйлера? Вот это оно и есть. Главные фишки HashSet: Только уникальные элементы. Попробуй добавить один и тот же элемент дважды — второй раз он просто не добавится. Причём HashSet сам следит за уникальностью, тебе ничего проверять не нужно. Скорость — огонь. Добавить, удалить или проверить наличие элемента можно за константное время O(1). Это значит, что неважно, сколько у тебя элементов — 10 или 10 миллионов, операция выполнится примерно за одно и то же время. Круто, да? Можно хранить null. В отличие от некоторых других коллекций, HashSet спокойно принимает null-значения. Правда, только одно — ведь это тоже элемент, а значит должен быть уникальным. Порядок не гарантируется. И вот тут многие новички спотыкаются. Добавил элементы в одном порядке, а при переборе получил в другом. Это нормально! HashSet вообще не заморачивается с порядком, он думает только об эффективности.
Как это работает под капотом
Вот это самое интересное. Оказывается, HashSet — это такой хитрый обёртка над HashMap. Да-да, внутри у него настоящая HashMap! Когда ты добавляешь элемент в HashSet, на самом деле происходит вот что: - Твой элемент становится ключом в HashMap - А в качестве значения подставляется одна и та же константа PRESENT Представь себе гардероб с номерками. Ты сдаёшь куртку (твой элемент) и получаешь номерок (это будет ключ). А все куртки висят на одинаковых плечиках (константа PRESENT). Гардеробщику важны только номерки — они все разные. А плечики одинаковые, но это никого не волнует. Вот как это выглядит в исходниках Java:private transient HashMap<E,Object> map;
// Эта константа используется как "заглушка" для всех значений
private static final Object PRESENT = new Object();
// Когда добавляешь элемент в HashSet
public boolean add(E e) {
// На самом деле добавляешь его как ключ в HashMap
return map.put(e, PRESENT) == null;
}
Видишь? Метод add() просто вызывает put() у внутренней HashMap, подставляя твой элемент как ключ и константу PRESENT как значение.
Кстати, именно поэтому в документации к HashSet часто упоминается хэш-таблица. Потому что он использует HashMap, а HashMap построена на хэш-таблице.История про коэффициент загрузки (она важная!)
Когда я впервые увидел конструктор HashSet с параметрами initialCapacity и loadFactor, я подумал: "Зачем мне это? Я просто хочу хранить элементы!" Но потом на одном проекте наш HashSet начал тормозить, и вот тогда я разобрался. Начальная ёмкость (initial capacity) — это сколько "ячеек" изначально создаётся в твоей хэш-таблице. По умолчанию их 16. Представь себе комод с 16 ящиками. Коэффициент загрузки (load factor) — это такой порог, при достижении которого комод "расширяется". По умолчанию он равен 0.75 (или 75%). Вот как это работает на примере. У тебя есть комод на 16 ящиков. Коэффициент загрузки 0.75. То есть когда 12 из 16 ящиков заполнятся (16 умножить на 0.75 будет как раз 12), комод вдруг БАЦ! — и расширяется до 32 ящиков. А всё содержимое перераспределяется заново. Это называется красивым словом "ре-хэширование". А зачем расширяться заранее, спросишь ты? Ну подумай сам: если ждать, пока все ящики забьются до отказа, начнутся проблемы. Элементы начнут "сталкиваться" друг с другом — это называется коллизиями. И вместо супербыстрой работы за O(1) получишь медленный O(n). А это уже совсем не весело. Формула простая:Коэффициент загрузки = Количество элементов / Размер хэш-таблицы
Подставляем наши числа: 12 элементов делим на 16 ячеек = 0.75. Всё сходится!
И тут всегда возникает вопрос: а почему именно 0.75, а не 0.5 или 0.9? Честно говоря, это эмпирически подобранное значение — такой компромисс между скоростью и памятью. Если поставить 0.5, коллизий будет меньше, но половина памяти просто пропадёт зря. Если накрутить до 0.9, память использоваться будет эффективнее, зато коллизий — хоть отбавляй. Вот и выбрали золотую середину.Конструкторы HashSet
Когда создаёшь HashSet, можешь выбрать один из нескольких вариантов. Самый простой:HashSet<String> set1 = new HashSet<>();
Тут всё по умолчанию — ёмкость 16, коэффициент 0.75. Для большинства задач этого хватает с головой.
Но бывает, что ты заранее знаешь: элементов будет много. Тогда можешь сразу задать бóльшую ёмкость:
HashSet<String> set2 = new HashSet<>(100);
Коэффициент останется стандартным 0.75, а вот начальный размер будет 100. Это позволит избежать лишних расширений на старте.
Хочешь контролировать и ёмкость, и коэффициент? Пожалуйста:
HashSet<String> set3 = new HashSet<>(100, 0.9f);
Правда, я обычно коэффициент не трогаю — ребята из Oracle не дураки, 0.75 выбрали неспроста.
А вот что реально удобно — это создать HashSet из готовой коллекции:
List<String> list = Arrays.asList("Java", "Python", "Java", "C++");
HashSet<String> set4 = new HashSet<>(list);
Дубликаты отфильтруются сами собой! Я этим трюком постоянно пользуюсь, когда надо быстренько почистить список от повторов.Практический пример
Ладно, хватит теории. Давай посмотрим живой код:import java.util.*;
public class HashSetDemo {
public static void main(String[] args) {
HashSet<String> countries = new HashSet<>();
// Добавляем страны
countries.add("Польша");
countries.add("США");
countries.add("Германия");
countries.add("Польша"); // Попытка добавить дубликат
// Смотрим, что получилось
System.out.println("Страны: " + countries);
System.out.println("Количество: " + countries.size());
// Проверяем наличие элемента
if (countries.contains("Германия")) {
System.out.println("Германия в списке!");
}
// Удаляем элемент
countries.remove("США");
System.out.println("После удаления: " + countries);
// Перебираем элементы
System.out.println("\nПеребор всех элементов:");
for (String country : countries) {
System.out.println("- " + country);
}
}
}
Вывод:
Страны: [Германия, Польша, США]
Количество: 3
Германия в списке!
После удаления: [Германия, Польша]
Перебор всех элементов:
- Германия
- Польша
Видишь? "Польша" попала в множество только один раз, хотя мы пытались запихнуть её туда дважды. А порядок вывода вообще не такой, как порядок добавления — но для HashSet это абсолютно нормально.Основные методы HashSet
Метод add(E e) добавляет элемент, но только если такого ещё нет. Возвращает true, когда элемент реально добавился, и false, если он уже был. Это удобно для проверок. remove(Object o) удаляет элемент. Тоже возвращает true или false в зависимости от того, был ли элемент в множестве. С помощью contains(Object o) можно проверить, есть ли элемент. Работает моментально — O(1). Это одна из главных фишек HashSet! Метод size() просто говорит, сколько элементов внутри. Есть ещё isEmpty() — проверяет, пусто ли множество. А clear() вычищает всё подчистую. Через iterator() получаешь итератор, чтобы пройтись по всем элементам. Хотя обычно проще использовать for-each цикл. И наконец clone() — делает копию множества. Но учти, это поверхностная копия: сам HashSet новый, а вот элементы внутри остаются те же самые объекты.HashSet и многопоточность (важная тема!)
Вот тут многие попадаются. HashSet НЕ является потокобезопасным! Если несколько потоков одновременно пытаются изменить HashSet, может случиться что угодно — от потерянных данных до падения программы. В старых статьях часто рекомендуют использовать Collections.synchronizedSet():Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
Но есть проблема: этот метод блокирует всю коллекцию целиком для любой операции. Это медленно и неэффективно. Один поток читает — все остальные ждут. Другой поток добавляет — опять все ждут.
Современный подход (Java 8+):
// Создаём потокобезопасный Set на основе ConcurrentHashMap
Set<String> concurrentSet = ConcurrentHashMap.newKeySet();
Или так:
Set<String> concurrentSet = Collections.newSetFromMap(
new ConcurrentHashMap<>()
);
ConcurrentHashMap использует более умную систему блокировок — она блокирует не всю коллекцию, а только отдельные сегменты. Это позволяет нескольким потокам работать одновременно без конфликтов. Производительность — совсем другой уровень!
Если тебе нужен именно отсортированный потокобезопасный Set, тогда используй ConcurrentSkipListSet. Но учти, что операции там будут O(log n) вместо O(1), потому что внутри древовидная структура.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ