Реалізація HashMap Java 8


92

Відповідно до наступного документа з посиланнями: Впровадження Java HashMap

Мене плутає реалізація HashMap(вірніше, вдосконалення в HashMap). Мої запити:

По-перше

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

Чому і як використовуються ці константи? Я хочу кілька наочних прикладів для цього. Як вони цим досягають підвищення продуктивності?

По-друге

Якщо ви бачите вихідний код HashMapв JDK, ви знайдете такий статичний внутрішній клас:

static final class TreeNode<K, V> extends java.util.LinkedHashMap.Entry<K, V> {
    HashMap.TreeNode<K, V> parent;
    HashMap.TreeNode<K, V> left;
    HashMap.TreeNode<K, V> right;
    HashMap.TreeNode<K, V> prev;
    boolean red;

    TreeNode(int arg0, K arg1, V arg2, HashMap.Node<K, V> arg3) {
        super(arg0, arg1, arg2, arg3);
    }

    final HashMap.TreeNode<K, V> root() {
        HashMap.TreeNode arg0 = this;

        while (true) {
            HashMap.TreeNode arg1 = arg0.parent;
            if (arg0.parent == null) {
                return arg0;
            }

            arg0 = arg1;
        }
    }
    //...
}

Як він використовується? Я просто хочу пояснити алгоритм .

Відповіді:


225

HashMapмістить певну кількість відер. Він використовує hashCodeдля визначення, у яке відро їх покласти. Для простоти уявіть це як модуль.

Якщо наш хеш-код - 123456, і у нас є 4 сегменти, 123456 % 4 = 0то елемент надходить у перший сегмент, сегмент 1.

HashMap

Якщо наша функція хеш-коду хороша, вона повинна забезпечувати рівномірний розподіл, тому всі сегменти будуть використовуватися дещо однаково. У цьому випадку сегмент використовує зв’язаний список для зберігання значень.

Пов’язані відра

Але ви не можете розраховувати на те, що люди реалізують хороші хеш-функції. Люди часто пишуть погані хеш-функції, що призведе до нерівномірного розподілу. Можливо також, що нам просто не може пощастити з нашими вкладами.

Погана хеш-карта

Чим менше рівномірний цей розподіл, тим далі ми рухаємось від операцій O (1) і тим ближче ми рухаємось до операцій O (n).

Реалізація Hashmap намагається пом'якшити це шляхом організації деяких сегментів у дерева, а не пов'язаних списків, якщо сегменти стають занадто великими. Це те, що TREEIFY_THRESHOLD = 8для. Якщо відро містить більше восьми предметів, воно повинно стати деревом.

Відро дерева

Це дерево - червоно-чорне дерево. Спочатку він сортується за хеш-кодом. Якщо хеш-коди однакові, він використовує compareToметод, Comparableякщо об'єкти реалізують цей інтерфейс, інакше ідентифікаційний хеш-код.

Якщо записи буде видалено з карти, кількість записів у сегменті може зменшитися таким чином, що ця деревоподібна структура більше не потрібна. Ось для чого UNTREEIFY_THRESHOLD = 6це. Якщо кількість елементів у сегменті опуститься нижче шести, ми могли б також повернутися до використання пов'язаного списку.

Нарешті, є MIN_TREEIFY_CAPACITY = 64.

Коли хеш-карта збільшується в розмірі, вона автоматично змінює розмір, щоб мати більше сегментів. Якщо у нас є невелика хеш-карта, ймовірність того, що ми отримаємо дуже повні відра, досить велика, тому що у нас не так багато різних відрів, в які можна покласти речі. Набагато краще мати більшу хеш-карту з більшою кількістю сегментів, які є менш заповненими. Ця константа в основному говорить про те, щоб не починати робити відра в дерева, якщо наша хеш-карта дуже мала - вона повинна замінюватися, щоб спочатку бути більшою.


Щоб відповісти на ваше запитання про приріст продуктивності, ці оптимізації були додані для покращення найгіршого випадку. Я лише припускаю, але ви, мабуть, побачите помітне покращення продуктивності завдяки цим оптимізаціям, якби ваша hashCodeфункція була не дуже хорошою.


3
Нерівний розподіл не завжди є ознакою поганих хеш-функцій. Деякі типи даних, наприклад String, мають набагато більший простір значень, ніж intхеш-код, отже, зіткнень не уникнути. Тепер це залежить від фактичних значень, таких як фактичні Strings, які ви вводите на карту, чи отримаєте ви рівномірний розподіл чи ні. Поганий розподіл може бути наслідком просто нещастя.
Холгер

3
+1, я хотів би додати, що конкретним сценарієм, який цей підхід пом’якшує, є атака DOS зіткнення хешу . java.lang.Stringмає детермінований, некриптографічний hashCode, тому зловмисники можуть тривіально створювати окремі рядки зі збіжними хеш-кодами. До цієї оптимізації це могло призвести до погіршення операцій HashMap до O (n)-часу, тепер воно просто знижує їх до O (log (n)).
MikeFHay

1
+1, if the objects implement that interface, else the identity hash code.я шукав цю іншу частину.
Номер 945

1
@NateGlenn хеш-код за замовчуванням, якщо ви його не
Майкл

Я не зрозумів: "Ця константа в основному говорить про те, щоб не починати робити відра в дерева, якщо наша хеш-карта дуже мала - вона повинна замінюватися, щоб спочатку бути більшою." для MIN_TREEIFY_CAPACITY. Чи означає це "Як тільки ми вставляємо ключ, який потрібно хешувати, у відро, яке вже містить 8 ( TREEIFY_THRESHOLD) ключів, і якщо вже є 64 ( MIN_TREEIFY_CAPACITY) ключі HashMap, зв’язаний список цього сегмента перетворюється на збалансоване дерево".
anir

16

Якщо сказати простіше (наскільки я міг би спростити) + ще кілька деталей.

Ці властивості залежать від багатьох внутрішніх речей, які було б дуже здорово зрозуміти - перед тим, як переходити до них безпосередньо.

TREEIFY_THRESHOLD -> коли один сегмент досягає цього (а загальна кількість перевищує MIN_TREEIFY_CAPACITY), він перетворюється на ідеально збалансований вузол червоного / чорного дерева . Чому? Через швидкість пошуку. Подумайте про це по-іншому:

знадобиться не більше 32 кроків для пошуку запису у відрі / кошику із записами Integer.MAX_VALUE .

Вступ до наступної теми. Чому кількість бункерів / відрів завжди дорівнює двом ? Принаймні дві причини: швидша, ніж робота за модулем, і модуль для від'ємних чисел буде негативним. І ви не можете помістити запис у "негативне" відро:

 int arrayIndex = hashCode % buckets; // will be negative

 buckets[arrayIndex] = Entry; // obviously will fail

Натомість є гарний трюк, який використовується замість modulo:

 (n - 1) & hash // n is the number of bins, hash - is the hash function of the key

Це семантично те саме, що і модульна операція. Він збереже нижчі біти. Це має цікавий наслідок, коли ви робите:

Map<String, String> map = new HashMap<>();

У наведеному вище випадку рішення про те, куди надходить запис, приймається на основі останніх 4 бітів лише вашого хеш-коду.

Тут починає діяти множення відер. За певних умов (це займе багато часу для детального пояснення ), відра збільшуються вдвічі. Чому? Коли відра збільшуються вдвічі, у гру вступає ще один біт .

Отже, у вас є 16 сегментів - останні 4 біти хеш-коду вирішують, куди надходить запис. Ви подвоюєте відра: 32 відра - 5 останніх бітів вирішують, куди входити.

Як такий цей процес називається повторним хешуванням. Це може ставати повільним. Тобто (для людей, яким не байдуже), оскільки HashMap "жартують" як: швидко, швидко, швидко, слауоу . Є й інші реалізації - пошук паушної хеш-карти ...

Тепер UNTREEIFY_THRESHOLD вступає в дію після повторного хешування. На той момент деякі записи можуть переходити з цих бункерів в інші (вони додають ще один біт до (n-1)&hashобчислення - і як такі можуть переходити в інші сегменти), і це може досягти цього UNTREEIFY_THRESHOLD. На даний момент не виграє зберігати смітник як red-black tree node, а LinkedListзамість цього, як

 entry.next.next....

MIN_TREEIFY_CAPACITY - це мінімальна кількість сегментів до того, як певний сегмент перетвориться на дерево.


10

TreeNode- це альтернативний спосіб зберігання записів, які належать до одного смітника HashMap. У старих реалізаціях записи кошика зберігалися у зв'язаному списку. У Java 8, якщо кількість записів у сміттєвому ящику перевищує порогове значення ( TREEIFY_THRESHOLD), вони зберігаються у деревній структурі замість вихідного пов'язаного списку. Це оптимізація.

З реалізації:

/*
 * Implementation notes.
 *
 * This map usually acts as a binned (bucketed) hash table, but
 * when bins get too large, they are transformed into bins of
 * TreeNodes, each structured similarly to those in
 * java.util.TreeMap. Most methods try to use normal bins, but
 * relay to TreeNode methods when applicable (simply by checking
 * instanceof a node).  Bins of TreeNodes may be traversed and
 * used like any others, but additionally support faster lookup
 * when overpopulated. However, since the vast majority of bins in
 * normal use are not overpopulated, checking for existence of
 * tree bins may be delayed in the course of table methods.

не зовсім правда. Якщо вони проходять TREEIFY_THRESHOLD І загальна кількість бункерів становить не менше MIN_TREEIFY_CAPACITY. Я спробував це висвітлити у своїй відповіді ...
Євген

3

Вам потрібно було б це візуалізувати: скажімо, є ключ класу з заміненою лише функцією hashCode (), щоб завжди повертати одне і те ж значення

public class Key implements Comparable<Key>{

  private String name;

  public Key (String name){
    this.name = name;
  }

  @Override
  public int hashCode(){
    return 1;
  }

  public String keyName(){
    return this.name;
  }

  public int compareTo(Key key){
    //returns a +ve or -ve integer 
  }

}

а потім десь ще, я вставляю 9 записів у HashMap, усі ключі є екземплярами цього класу. напр

Map<Key, String> map = new HashMap<>();

    Key key1 = new Key("key1");
    map.put(key1, "one");

    Key key2 = new Key("key2");
    map.put(key2, "two");
    Key key3 = new Key("key3");
    map.put(key3, "three");
    Key key4 = new Key("key4");
    map.put(key4, "four");
    Key key5 = new Key("key5");
    map.put(key5, "five");
    Key key6 = new Key("key6");
    map.put(key6, "six");
    Key key7 = new Key("key7");
    map.put(key7, "seven");
    Key key8 = new Key("key8");
    map.put(key8, "eight");

//Since hascode is same, all entries will land into same bucket, lets call it bucket 1. upto here all entries in bucket 1 will be arranged in LinkedList structure e.g. key1 -> key2-> key3 -> ...so on. but when I insert one more entry 

    Key key9 = new Key("key9");
    map.put(key9, "nine");

  threshold value of 8 will be reached and it will rearrange bucket1 entires into Tree (red-black) structure, replacing old linked list. e.g.

                  key1
                 /    \
               key2   key3
              /   \   /  \

Обхід дерева швидший {O (log n)}, ніж LinkedList {O (n)}, і коли n зростає, різниця стає більш значною.


Він не може побудувати ефективне дерево, оскільки у нього немає можливості порівняти ключі, крім їхніх хеш-кодів, які є однаковими, і методу їх рівних, який не допомагає з упорядкуванням.
user253751

@immibis Їх хеш-коди не обов'язково однакові. Вони цілком імовірно різні. Якщо класи реалізують це, він додатково використовуватиме compareToз Comparable. identityHashCodeце ще один механізм, який він використовує.
Майкл

@Michael У цьому прикладі всі хеш-коди обов'язково однакові, і клас не реалізує Comparable. identityHashCode буде марним у пошуку правильного вузла.
user253751

@immibis Ах, так, я це лише прокрутив, але ти маєш рацію. Отже, як Keyне реалізується Comparable, identityHashCodeбуде використано :)
Майкл

@EmonMishra, на жаль, просто візуального буде недостатньо, я намагався це висвітлити у своїй відповіді.
Євген

2

Зміна реалізації HashMap була додана разом із JEP-180 . Метою було:

Покращити продуктивність java.util.HashMap в умовах високого зіткнення хешу, використовуючи збалансовані дерева, а не зв’язані списки для зберігання записів на карті. Впровадьте те саме вдосконалення в класі LinkedHashMap

Однак чиста продуктивність - не єдиний виграш. Це також запобіжить атаці HashDoS , якщо хеш-карта використовується для зберігання вводу користувача, оскільки червоно-чорне дерево, яке використовується для зберігання даних у сегменті, має найгіршу складність вставки в O (журнал n). Дерево використовується після дотримання певних критеріїв - див . Відповідь Євгена .


-1

Щоб зрозуміти внутрішню реалізацію хеш-карти, потрібно зрозуміти хешування. Хешування в найпростішій формі - це спосіб присвоєння унікального коду будь-якій змінній / об’єкту після застосування будь-якої формули / алгоритму до його властивостей.

Справжня хеш-функція повинна дотримуватися цього правила -

“Хеш-функція повинна повертати один і той же хеш-код кожного разу, коли функція застосовується до однакових або рівних об’єктів. Іншими словами, два рівні об’єкти повинні послідовно створювати один і той же хеш-код ».


Це не відповідає на питання.
Стівен С
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.