HashMap vs Hashtable в Java: кто кого? Разбираемся под капотом
Если вы когда-нибудь задавались вопросом: «А в чём, собственно, разница между HashMap и Hashtable?» — вы не одиноки. Это один из самых популярных вопросов на Stack Overflow, и не зря. Оба класса хранят данные в формате «ключ — значение», оба используют хеширование, но при этом живут в разных мирах. Один — шустрый и современный, другой — старый, надёжный, но слегка неповоротливый. Давайте заглянем им «под капот» и разберёмся, что к чему.
В этой статье мы не просто перечислим отличия. Мы поймём, как работает внутренняя механика, почему один класс позволяет null, а другой — нет, и когда стоит использовать ConcurrentHashMap вместо обоих.
Краткий экскурс в историю
Hashtable появился в Java ещё в версии 1.0. Это был один из первых классов-коллекций, и он с самого начала был синхронизированным. В те времена многопоточность была редкостью, но разработчики Java решили перестраховаться. Позже, в Java 2 (1.2), вышли новые коллекции из фреймворка Collections Framework, и среди них — HashMap. Он был несинхронизированным, но гораздо быстрее. С тех пор Hashtable считается устаревшим (legacy), хотя его никто не удалял из языка.
Главное отличие: синхронизация и потоки
Первое и самое важное различие — это синхронизация. Методы Hashtable (например, put(), get(), remove()) помечены ключевым словом synchronized. Это значит, что два потока не могут одновременно выполнять один и тот же метод на одном объекте Hashtable. Звучит как преимущество, но на практике это превращается в узкое горлышко.
// Hashtable — все методы синхронизированы
Hashtable<String, String> table = new Hashtable<>();
table.put("key1", "value1"); // потокобезопасно, но медленно при высокой конкуренции
// HashMap — не синхронизирован
HashMap<String, String> map = new HashMap<>();
map.put("key1", "value1"); // быстро, но не потокобезопасно
Если вы работаете в однопоточном приложении (а таких большинство), HashMap будет работать быстрее, потому что ему не нужно тратить время на захват и освобождение блокировок. Если же вам нужна потокобезопасность, лучше не использовать Hashtable, а взять ConcurrentHashMap (появился в Java 5). Он использует более тонкую блокировку (сегменты или блокировки на уровне бакетов) и работает значительно быстрее в многопоточной среде.
Null, null и ещё раз null
Второе важное отличие — работа с null. HashMap позволяет использовать null и в качестве ключа (ровно один раз), и в качестве значений (сколько угодно). Hashtable же выбросит NullPointerException, если вы попытаетесь передать null.
HashMap<String, String> map = new HashMap<>();
map.put(null, "nullKey"); // OK
map.put("key", null); // OK
System.out.println(map.get(null)); // "nullKey"
Hashtable<String, String> table = new Hashtable<>();
// table.put(null, "value"); // NullPointerException!
// table.put("key", null); // NullPointerException!
Почему так? Всё дело в том, как работает хеширование. В HashMap для ключа null используется специальный хеш-код 0, и он помещается в нулевой бакет (bucket). Hashtable же использует старый механизм, где вызов hashCode() на null приводит к ошибке. К тому же, Hashtable не рассчитывает на null-значения, так как это нарушало бы логику синхронизированных операций.
Внутреннее устройство: как работает хеш-таблица под капотом
Чтобы понять разницу глубже, давайте заглянем внутрь. И HashMap, и Hashtable основаны на хеш-таблице. Это массив «бакетов», где каждый бакет — это связный список (или дерево, начиная с Java 8). Когда вы добавляете пару «ключ-значение», происходит следующее:
- Вычисляется хеш-код ключа с помощью метода
hashCode(). - Хеш-код преобразуется в индекс массива (обычно через операцию
index = hash & (n-1), где n — размер массива). - Пара помещается в соответствующий бакет. Если в бакете уже есть элементы, происходит проверка на равенство через
equals().
Разница в деталях: HashMap использует более хитрый алгоритм для вычисления хеша (он дополнительно перемешивает биты), чтобы уменьшить коллизии. Hashtable использует прямой хеш-код. Также HashMap динамически расширяется (rehash), когда загрузка превышает 75% (коэффициент загрузки 0.75). Hashtable делает то же самое, но с немного другими параметрами.
Начиная с Java 8, если в одном бакете HashMap скапливается больше 8 элементов, связный список превращается в сбалансированное дерево (красно-черное). Это ускоряет поиск с O(n) до O(log n). В Hashtable такого нет — там всегда список.
Итерация и Enumeration
Ещё одно отличие — способ обхода элементов. HashMap использует Iterator (fail-fast: если во время итерации структура изменяется, выбрасывается ConcurrentModificationException). Hashtable поддерживает как Iterator, так и старый Enumeration (который не кидает исключение при изменении). Enumeration — это пережиток прошлого, его лучше не использовать в новом коде.
// Итерация по HashMap (fail-fast)
HashMap<String, String> map = new HashMap<>();
map.put("a", "1");
for (String key : map.keySet()) {
// map.put("b", "2"); // ConcurrentModificationException!
}
// Итерация по Hashtable через Enumeration (не fail-fast)
Hashtable<String, String> table = new Hashtable<>();
table.put("a", "1");
Enumeration<String> keys = table.keys();
while (keys.hasMoreElements()) {
table.put("b", "2"); // Нет исключения, но результат непредсказуем
}
Производительность: кто быстрее?
В однопоточном сценарии HashMap быстрее Hashtable примерно на 10-30% (зависит от размера данных и количества коллизий). Причина — отсутствие синхронизации. В многопоточном сценарии Hashtable может стать ещё медленнее из-за блокировок. ConcurrentHashMap в таком случае — лучший выбор: он использует сегментную блокировку (до Java 8) или CAS-операции (с Java 8), что даёт высокую производительность при конкурентном доступе.