Подтвердить что ты не робот

Явный контур Java HashMap.get(Object)

Несколько ответов на SO упоминают, что метод get в HashMap может попасть в бесконечный цикл (например, этот или этот), если не синхронизирован должным образом (и, как правило, в нижней строке "не используйте HashMap в многопоточной среде, используйте ConcurrentHashMap" ).

Хотя я могу легко понять, почему одновременные вызовы метода HashMap.put(Object) могут вызвать бесконечный цикл, я не могу понять, почему метод get (Object) может застрять, когда он пытается прочитать HashMap, что в этот момент изменяется. Я рассмотрел реализацию в openjdk и содержит цикл, но условие выхода e != null должно быть выполнено рано или поздно. Как он может зависеть навсегда? Кусок кода, который явно упоминается, чтобы быть уязвимым для этой проблемы:

public class MyCache {
    private Map<String,Object> map = new HashMap<String,Object>();

    public synchronized void put(String key, Object value){
        map.put(key,value);
    }

    public Object get(String key){
        // can cause in an infinite loop in some JDKs!!
        return map.get(key);
    }
}

Может кто-нибудь объяснить, как поток, помещающий объект в HashMap, и другое чтение из него может чередоваться таким образом, что генерируется бесконечный цикл? Это связано с некоторой проблемой когерентности кеша или переупорядочением команд процессора (поэтому проблема может возникнуть только на многопроцессорной машине)?

4b9b3361

Ответ 1

Вы ссылаетесь на HashMap в Java 6. Он был переписан на Java 8. До этого переписать бесконечный цикл на get(Object) был возможен, если бы было два потока записи. Я не знаю, как может произойти бесконечный цикл на get с одним автором.

В частности, бесконечный цикл возникает, когда есть два одновременных вызова на resize(int), который вызывает transfer:

 void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
         while(null != e) {
             Entry<K,V> next = e.next;
             if (rehash) {
                 e.hash = null == e.key ? 0 : hash(e.key);
             }
             int i = indexFor(e.hash, newCapacity);
             e.next = newTable[i];
             newTable[i] = e;
             e = next;
         }
     }
 }

Эта логика меняет порядок упорядочения узлов в хэш-ведре. Два одновременных обращения могут сделать цикл.

Посмотрите:

             e.next = newTable[i];
             newTable[i] = e;

Если два потока обрабатывают один и тот же node e, тогда первый поток выполняется нормально, а второй поток устанавливает e.next = e, потому что newTable[i] уже был установлен на e первым потоком. node e теперь указывает на себя, и когда get(Object) называется, он вводит бесконечный цикл.

В Java 8 размер изменяет порядок node, поэтому цикл не может произойти таким образом. Однако вы можете потерять данные.

Итераторы для класса LinkedHashMap могут застревать в бесконечном цикле, когда есть несколько читателей и нет писателей, когда поддерживается упорядочение доступа. С несколькими считывателями и порядком доступа каждое чтение удаляет, а затем вставляет доступный node из двойного связанного списка узлов. Несколько считывателей могут привести к тому, что один и тот же node будет повторно вставлен в список более одного раза, вызывая цикл. Снова класс был переписан для Java 8, и я не знаю, существует ли эта проблема или нет.

Ответ 2

Ситуация:

По умолчанию емкость HashMap равна 16, а коэффициент загрузки - 0,75, что означает, что HashMap удвоит свою емкость, когда 12-я пара ключей-значений входит в карту (16 * 0,75 = 12).

Когда 2 потока пытается получить доступ к HashMap одновременно, вы можете столкнуться с бесконечным циклом. Thread 1 и Thread 2 пытается поставить 12-ю пару ключей.

Thread 1 получил шанс выполнения:

  • В потоке 1 ставится 12-я пара ключ-значение,
  • В Thread 1 установлено, что предел порога достигнут, и он создает новые ковши повышенной емкости. Таким образом, емкость карты увеличивается с 16 до 32.
  • Теперь поток 1 переносит все существующие пары ключ-значение в новые ковши.
  • Тема 1 указывает на первую пару ключа и пару (пару) ключ-значение для начала процесса передачи.

Тема 1 после указания пар ключ-значение и перед запуском процесса передачи потеряет контроль, а Thread 2 получил шанс на выполнение.

Тема 2 получила шанс выполнения:

  • В Thread 2 делается попытка поставить 12-ю пару ключ-значение,
  • В Thread 2 установлено, что предельный порог достигнут, и он создает новые ковши повышенной емкости. Таким образом, емкость карты увеличивается с 16 до 32.
  • В потоке 2 теперь передаются все существующие пары ключ-значение в новые ковши.
  • Поток 2 указывает на первую пару ключевых значений и следующую (вторую) пару ключ-значение для начала процесса передачи.
  • При передаче пар ключ-значение из старых ковшей в новые ведра пары ключ-значение будут отменены в новых ковшиках, потому что hashmap добавит пары ключ-значение в начале, а не в конец. Hashmap добавляет новые пары ключ-значение в начале, чтобы избежать перетаскивания связанного списка каждый раз и поддерживать постоянную производительность.
  • В потоке 2 будут переданы все пары ключ-значение из старых ковшей в новые ведра, а Thread 1 получит шанс на выполнение.

Thread 1 получил шанс выполнения:

  • Тема 1 перед тем, как оставить управление, указывала на первый элемент и следующий элемент старого ведра.
  • Теперь, когда Thread 1 начал класть пары ключ-значение из старого ведра в новое ведро. Он успешно помещает (90, val) и (1, val) в новый Bucket.
  • Когда он пытается добавить следующий элемент (1, val), который равен (90, val) в новый Bucket, он закончится бесконечным циклом.

Решение:

Чтобы решить эту проблему, используйте либо Collections.synchronizedMap, либо ConcurrentHashMap.

ConcurrentHashMap является потокобезопасным, к коду может обращаться один поток за раз.

HashMap можно синхронизировать с помощью метода Collections.synchronizedMap(hashMap). Используя этот метод, мы получаем объект HashMap, который эквивалентен объекту HashTable. Поэтому каждая модификация выполняется на карте заблокирована на объекте Map.

Ответ 3

Учитывая, что единственная возможность, которую я вижу для бесконечного цикла, будет e.next = e в методе get:

for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next)

И это может произойти только в методе transfer во время изменения размера:

 do {
     Entry<K,V> next = e.next;
     int i = indexFor(e.hash, newCapacity);
     e.next = newTable[i]; //here e.next could point on e if the table is modified by another thread
     newTable[i] = e;
     e = next;
 } while (e != null);

Если только один поток изменяет карту, я считаю, что совершенно невозможно иметь бесконечный цикл только с одним потоком. Это было более очевидно при старой реализации get перед jdk 6 (или 5):

public Object get(Object key) {
        Object k = maskNull(key);
        int hash = hash(k);
        int i = indexFor(hash, table.length);
        Entry e = table[i]; 
        while (true) {
            if (e == null)
                return e;
            if (e.hash == hash && eq(k, e.key)) 
                return e.value;
            e = e.next;
        }
    }

Даже тогда случай все еще кажется невероятным, за исключением случаев, когда есть много столкновений.

P.S: Мне бы хотелось, чтобы это было неправильно, хотя!

Ответ 4

Хотя я никогда лично не использовал хэш-карту и заканчивал бесконечным циклом (когда-либо), я скажу, если мы говорим о потоках, ответ - это блокировки.

Тупики - это когда более одного потока пытаются получить доступ к одному и тому же ресурсу одновременно, поэтому все участвующие потоки ждут завершения всех остальных потоков, поэтому они все голодают.

В Java ключевое слово synchronized гарантирует, что указанный метод синхронизируется по всем потокам, поэтому ни один из двух потоков не пытается получить доступ к одной и той же информации сразу.

Вернуться к ресурсу... Если я правильно помню... В Java весь хэш файл считается ресурсом, поэтому один метод "проверяет его", как только он начнется. Однако, если два метода пытаются получить хэш-карту в одно и то же время: тупик.

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

Дальнейшее чтение: Существует очень вдохновляющий человек по имени Эдсгар У. Дейкстра из Нидерландов, который, я считаю, очень интенсивно работал над предотвращением тупиковой ситуации и многопоточными системами. Одной из его самых известных визуализаций и головоломок о тупиках была проблема столовых философов. Действительно фантастический человек.