Помнишь, как в детстве собирал коллекцию марок или вкладышей от жвачек? И как бесило, когда попадалась одна и та же картинка в десятый раз? Вот HashSet — это такая коллекция в Java, которая автоматически отсеивает все повторы. Добавляешь элемент второй раз — он просто проигнорируется. Удобно же! Я когда только начинал изучать коллекции, думал: "Ну ладно, есть ArrayList, зачем мне ещё что-то?" А потом столкнулся с задачей — нужно было собрать список уникальных email'ов из базы данных. И тут-то я понял всю красоту HashSet. Вместо того чтобы городить проверки "а есть ли уже такой элемент", просто кидаешь всё в HashSet и получаешь готовый список уникальных значений.

Что такое HashSet и почему он крутой

HashSet — это класс из Java Collections Framework, который реализует интерфейс Set. А Set, если по-простому, это математическое множество. Помнишь из школы круги Эйлера? Вот это оно и есть. Главные фишки HashSet: Только уникальные элементы. Попробуй добавить один и тот же элемент дважды — второй раз он просто не добавится. Причём HashSet сам следит за уникальностью, тебе ничего проверять не нужно. Скорость — огонь. Добавить, удалить или проверить наличие элемента можно за константное время O(1). Это значит, что неважно, сколько у тебя элементов — 10 или 10 миллионов, операция выполнится примерно за одно и то же время. Круто, да? Можно хранить null. В отличие от некоторых других коллекций, HashSet спокойно принимает null-значения. Правда, только одно — ведь это тоже элемент, а значит должен быть уникальным. Порядок не гарантируется. И вот тут многие новички спотыкаются. Добавил элементы в одном порядке, а при переборе получил в другом. Это нормально! HashSet вообще не заморачивается с порядком, он думает только об эффективности.HashSet и интерфейс Set в Java - 2

Как это работает под капотом

Вот это самое интересное. Оказывается, 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), потому что внутри древовидная структура.

Частые вопросы (FAQ)

В: Если я удалю элемент из середины HashSet, останется ли "дырка"? О: Нет, в коллекциях дырок не бывает. Это проблема массивов. В HashSet (как и в других коллекциях) элементы автоматически "схлопываются", и дыр не образуется. В: Зачем нужен коэффициент загрузки, если можно просто увеличивать размер, когда места не хватает? О: Всё дело в коллизиях! Если подождать, пока заполнятся все 100% ячеек, коллизий будет очень много. Это сильно замедлит работу. Расширяясь на 75% заполненности, мы сохраняем баланс между скоростью и памятью. В: Почему порядок элементов в HashSet такой странный? О: Потому что элементы распределяются по ячейкам на основе хэш-кода. В: Почему порядок элементов в HashSet такой странный? О: Потому что он вообще не думает о порядке! Элементы раскидываются по ячейкам на основе хэш-кода, который вычисляется из самого элемента. Ты не сможешь предсказать, куда попадёт элемент. Нужен порядок? Тогда смотри в сторону LinkedHashSet (он запоминает, в каком порядке ты добавлял элементы) или TreeSet (он их автоматически сортирует). В: Что будет, если я создам свой класс, засуну объекты в HashSet, но забуду переопределить equals() и hashCode()? О: Всё сломается! HashSet определяет уникальность элементов именно через hashCode() и equals(). Если не переопределишь эти методы, даже объекты с одинаковыми данными будут считаться разными. Это классическая ошибка новичков. В: А можно использовать HashSet для хранения массивов? О: Технически можно, но это плохая идея. У массивов equals() и hashCode() работают не так, как ты ожидаешь — они сравнивают ссылки, а не содержимое. Лучше используй List или преобразуй массивы в списки через Arrays.asList().

Когда использовать HashSet

HashSet — твой выбор, если нужна коллекция уникальных элементов и важна скорость. Особенно если часто проверяешь, есть ли элемент в коллекции — тут HashSet рвёт всех. А порядок не важен? Вообще идеально! А вот если порядок критичен, тогда бери LinkedHashSet (он сохранит порядок добавления) или TreeSet (отсортирует автоматически). Работаешь с несколькими потоками одновременно? Забудь про обычный HashSet, юзай ConcurrentHashMap.newKeySet(). Ну а если тебе вообще нужны дубликаты, то это уже не Set, а List.

Практика — наше всё

Знаешь, можно прочитать про коллекции десять статей, но настоящее понимание придёт только когда сам напишешь код. Попробуй вот что: напиши программу, которая берёт текст (любой, хоть "Преступление и наказание") и находит все уникальные слова. Используй HashSet для хранения. А потом — чисто ради интереса — попробуй сделать то же самое, но с ArrayList, где ты руками проверяешь через contains(), есть ли слово или нет. Запусти на большом тексте и сравни время работы. Охренеешь от разницы, честно! Коллекции в Java — это фундамент. Без них никуда. Так что время, потраченное на их изучение, точно не пропадёт зря!