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

Какая деталь реализации делает этот код настолько неудачным?

Этот вопрос связан не с известным и документированным фактом, что HashMap не является потокобезопасным, а касается конкретных режимов отказа в коде HotSpot и JDK. Я удивлен тем, насколько с легкостью этот код выходит из строя с NPE:

public static void main(String[] args) {
    Map<Integer, Integer> m = new HashMap<>(0, 0.75f);
    IntStream.range(0, 5).parallel().peek(i -> m.put(i, i)).map(m::get).count();
}

Нет никакой тайны относительно того, откуда приходит NPE: на шаге .map(m::get) при попытке распаковать a null. Он не работает примерно в 4 из 5 запусков.

На моей машине Runtime#availableProcessors() сообщает 8, поэтому, предположительно, диапазон длины 5 разбит на 5 подзадач, каждый из которых имеет только один член. Я также предполагаю, что мой код работает в интерпретируемом режиме. Это может быть вызов JIT-скомпилированных методов HashMap или Stream, но верхний уровень интерпретируется, поэтому исключает любые изменения, в которых состояние HashMap загружается в локальную память потока (регистры/стек), тем самым задерживая наблюдение обновлений другого потока. Если некоторые из пяти операций put не выполняются буквально в одно и то же время на разных ядрах, я не ожидаю, что он разрушит внутреннюю структуру HashMap. Сроки отдельных задач должны быть чрезвычайно точными, учитывая небольшой объем работы.

Точно ли точное время (commonPool потоки должны быть неактивными), или есть другой маршрут, который может привести к сбою в Oracle/OpenJDK HotSpot? Моя текущая версия

java version "1.8.0_72"
Java(TM) SE Runtime Environment (build 1.8.0_72-b15)
Java HotSpot(TM) 64-Bit Server VM (build 25.72-b15, mixed mode)

ОБНОВЛЕНИЕ: я считаю, что даже создание только двух вставк имеет такую ​​же высокую частоту отказа:

IntStream.range(0, 2).parallel().peek(i -> m.put(i, i)).map(m::get).count();
4b9b3361

Ответ 1

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

Но действительно, неудача с NullPointerException происходит довольно часто. Я создал следующий код отладки, чтобы проиллюстрировать работу HashMap s:

static <K,V> void debugPut(HashMap<K,V> m, K k, V v) {
    if(m.isEmpty()) debug(m);
    m.put(k, v);
    debug(m);
}
private static <K, V> void debug(HashMap<K, V> m) {
    for(Field f: FIELDS) try {
        System.out.println(f.getName()+": "+f.get(m));
    } catch(ReflectiveOperationException ex) {
        throw new AssertionError(ex);
    }
    System.out.println();
}
static final Field[] FIELDS;
static {
    String[] name={ "table", "size", "threshold" };
    Field[] f=new Field[name.length];
    for (int ix = 0; ix < name.length; ix++) try {
        f[ix]=HashMap.class.getDeclaredField(name[ix]);
    }
    catch (NoSuchFieldException ex) {
        throw new ExceptionInInitializerError(ex);
    }
    AccessibleObject.setAccessible(f, true);
    FIELDS=f;
}

Используя это с помощью простого последовательного for(int i=0; i<5; i++) debugPut(m, i, i);, напечатанного:

table: null
size: 0
threshold: 1

table: [Ljava.util.HashMap$Node;@70dea4e
size: 1
threshold: 1

table: [Ljava.util.HashMap$Node;@5c647e05
size: 2
threshold: 3

table: [Ljava.util.HashMap$Node;@5c647e05
size: 3
threshold: 3

table: [Ljava.util.HashMap$Node;@33909752
size: 4
threshold: 6

table: [Ljava.util.HashMap$Node;@33909752
size: 5
threshold: 6

Как видите, из-за начальной емкости 0 существует три разных массива поддержки, созданных даже во время последовательной операции. Каждый раз, когда емкость увеличивается, существует более высокая вероятность того, что краткий параллельный put пропускает обновление массива и создает свой собственный массив.

Это особенно актуально для исходного состояния пустой карты и нескольких потоков, пытающихся поместить свой первый ключ, поскольку все потоки могут столкнуться с начальным состоянием таблицы null и создавать свои собственные. Кроме того, даже когда вы читаете состояние завершенного первого put, для второго put создается новый массив.

Но пошаговая отладка выявила еще больше шансов на нарушение:

Внутри метода putVal мы видим в конце:

++modCount;
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

Другими словами, после успешной установки нового ключа таблица будет изменена, если новый размер превышает threshold. Таким образом, при первом put, resize() вызывается в начале, потому что таблица null, и поскольку указанная начальная емкость 0, т.е. слишком низкая, чтобы сохранить одно сопоставление, новая емкость будет 1 а новый threshold будет 1 * loadFactor == 1 * 0.75f == 0.75f, округлен до 0. Итак, в конце первого put, новый threshold превышен, а другая операция resize() запущена. Таким образом, с первой емкостью 0 первая put уже создает и заполняет два массива, что дает гораздо более высокие шансы сломаться, если несколько потоков выполняют это действие одновременно, все сталкиваются с начальным состоянием.

И есть еще один момент. Если посмотреть в операцию resize(), мы видим строки:

 @SuppressWarnings({"rawtypes","unchecked"})
 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
 table = newTab;
 if (oldTab != null) {
     … (transfer old contents to new array)

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

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