Поведение entrySet(). RemoveIf в ConcurrentHashMap

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

Я использую map.entrySet().removeIf(lambda) в удаляемой теме. Мне интересно, какие предположения я могу сделать о его поведении. Я вижу, что метод removeIf использует итератор для прохождения элементов на карте, проверки данного условия и последующего удаления при необходимости с помощью iterator.remove().

Документация дает некоторую информацию о поведении итераторов ConcurrentHashMap:

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

Поскольку весь вызов removeIf происходит в одном потоке, я могу быть уверен, что итератор не используется более чем одним потоком в то время. Тем не менее мне интересно, возможен ли описанный ниже ход событий:

  • Карта содержит отображение: 'A'->0
  • Удаление темы начинается с map.entrySet().removeIf(entry->entry.getValue()==0)
  • Удаление вызовов потоков .iteratator() внутри removeIf вызывает и получает итератор, отражающий текущее состояние коллекции
  • Другой поток выполняет map.put('A', 1)
  • Удаление потока по-прежнему видит 'A'->0 mapping (итератор отражает старое состояние), а поскольку 0==0 - true, он решает удалить ключ A с карты.
  • Теперь карта содержит 'A'->1, но удаление потока увидело старое значение 0 и запись 'A' ->1 удалена, хотя ее не должно быть. Карта пуста.

Я могу представить, что поведение может быть предотвращено реализацией по-разному. Например: возможно, итераторы не отражают операции put/remove, но всегда отражают обновления значений или, возможно, метод удаления итератора проверяет, все ли отображение (как ключ, так и значение) все еще присутствует на карте, прежде чем вызывать удаление ключа. Я не мог найти информацию об этих вещах, и мне интересно, есть ли что-то, что делает этот сейф безопасным.

Ответы

Ответ 1

Мне также удалось воспроизвести такой случай на моей машине. Я думаю, проблема в том, что EntrySetView (который возвращается ConcurrentHashMap.entrySet()) наследует реализацию removeIf от Collection, и выглядит так:

    default boolean removeIf(Predicate<? super E> filter) {
        Objects.requireNonNull(filter);
        boolean removed = false;
        final Iterator<E> each = iterator();
        while (each.hasNext()) {
            // `test` returns `true` for some entry
            if (filter.test(each.next())) { 
               // entry has been just changed, `test` would return `false` now
               each.remove(); // ...but we still remove
               removed = true;
            }
        }
        return removed;
    }

По моему скромному мнению, это не может рассматриваться как правильная реализация для ConcurrentHashMap.

Ответ 2

После обсуждения с пользователем Zielu в комментариях ниже ответа Zielu я углубился в код ConcurrentHashMap и узнал, что:

  • ConcurrentHashMap реализует метод remove(key, value), который вызывает replaceNode(key, null, value)
  • replaceNode проверяет, остаются ли все ключи и значение на карте перед удалением, поэтому использование должно быть прекрасным. Документация гласит, что она

Заменяет значение node значением v, обусловленным совпадением cv if      * не null.

  • В случае, указанном в вопросе ConcurrentHashMap .entrySet(), вызывается, который возвращает класс EntrySetView. Затем метод removeIf вызывает .iterator(), который возвращает EntryIterator.
  • EntryIterator extends BaseIterator и наследует реализацию remove, которая вызывает map.replaceNode(p.key, null, null), которая отключает условное удаление и всегда удаляет ключ.

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

Я думаю, что создал тестовый пример, который показывает, что поведение, описанное в моем вопросе, действительно может произойти. Пожалуйста, исправьте меня, если я ошибаюсь в коде.

Код запускает два потока. Один из них (DELETING_THREAD) удаляет все записи, сопоставленные с логическим значением "false". Другой (ADDING_THREAD) случайным образом помещает значения (1, true) или (1,false) в карту. Если он помещает true в значение, он ожидает, что запись все равно будет там, когда будет отмечена, и выбрасывает исключение, если это не так. Он быстро генерирует исключение, когда я запускаю его локально.

package test;

import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

public class MainClass {

    private static final Random RANDOM = new Random();

    private static final ConcurrentHashMap<Integer, Boolean> MAP = new ConcurrentHashMap<Integer, Boolean>();

    private static final Integer KEY = 1;

    private static final Thread DELETING_THREAD = new Thread() {

        @Override
        public void run() {
            while (true) {
                MAP.entrySet().removeIf(entry -> entry.getValue() == false);
            }
        }

    };

    private static final Thread ADDING_THREAD = new Thread() {

        @Override
        public void run() {
            while (true) {
                boolean val = RANDOM.nextBoolean();

                MAP.put(KEY, val);
                if (val == true && !MAP.containsKey(KEY)) {
                    throw new RuntimeException("TRUE value was removed");
                }

            }
        }

    };

    public static void main(String[] args) throws InterruptedException {
        DELETING_THREAD.setDaemon(true);
        ADDING_THREAD.start();
        DELETING_THREAD.start();
        ADDING_THREAD.join();
    }
}