Почему Arrays.fill() больше не используется в HashMap.clear()?

Я заметил что-то странное в реализации HashMap.clear(). Вот как он выглядел в OpenJDK 7u40:

public void clear() {
    modCount++;
    Arrays.fill(table, null);
    size = 0;
}

И вот как выглядит OpenJDK 8u40:

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

Я понимаю, что теперь table может быть пустым для пустой карты, поэтому требуется дополнительная проверка и кэширование в локальной переменной. Но почему заменить Arrays.fill() на цикл for?

Кажется, что изменение было введено в this commit. К сожалению, я не нашел объяснений, почему простой цикл может быть лучше, чем Arrays.fill(). Это быстрее? Или безопаснее?

Ответ 1

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

@Holger говорит:

Я предполагаю, что это необходимо для исключения класса java.util.Arrays, получающего загрузку в качестве побочного эффекта этого метода. Для кода приложения это обычно не вызывает беспокойства.

Это самая простая вещь для тестирования. Пусть скомпилирует такую ​​программу:

public class HashMapTest {
    public static void main(String[] args) {
        new java.util.HashMap();
    }
}

Запустите его с помощью java -verbose:class HashMapTest. Это будет печатать события загрузки класса по мере их возникновения. С JDK 1.8.0_60 я вижу более 400 загруженных классов:

... 155 lines skipped ...
[Loaded java.util.Set from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.AbstractSet from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.Collections$EmptySet from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.Collections$EmptyList from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.Collections$EmptyMap from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.Collections$UnmodifiableCollection from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.Collections$UnmodifiableList from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.Collections$UnmodifiableRandomAccessList from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded sun.reflect.Reflection from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
**[Loaded java.util.HashMap from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.HashMap$Node from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.Class$3 from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.Class$ReflectionData from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.Class$Atomic from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded sun.reflect.generics.repository.AbstractRepository from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded sun.reflect.generics.repository.GenericDeclRepository from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded sun.reflect.generics.repository.ClassRepository from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.Class$AnnotationData from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded sun.reflect.annotation.AnnotationType from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.util.WeakHashMap from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.ClassValue$ClassValueMap from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.reflect.Modifier from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded sun.reflect.LangReflectAccess from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
[Loaded java.lang.reflect.ReflectAccess from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
**[Loaded java.util.Arrays from C:\Program Files\Java\jre1.8.0_60\lib\rt.jar]
...

Как вы можете видеть, HashMap загружается задолго до того, как код приложения и Arrays загружается только 14 классов после HashMap. Загрузка HashMap инициируется инициализацией sun.reflect.Reflection, поскольку она имеет статические поля HashMap. Нагрузка Arrays, скорее всего, будет вызвана нагрузкой WeakHashMap, которая на самом деле имеет Arrays.fill в методе clear(). Нагрузка WeakHashMap запускается java.lang.ClassValue$ClassValueMap, которая расширяет WeakHashMap. ClassValueMap присутствует в каждом экземпляре java.lang.Class. Поэтому мне кажется, что без класса Arrays JDK не может быть инициализирован вообще. Также статический инициализатор Arrays очень короткий, он инициализирует только механизм утверждения. Этот механизм используется во многих других классах (включая, например, java.lang.Throwable, который загружается очень рано). В java.util.Arrays других статических шагов инициализации не выполняются. Таким образом, версия @Holger кажется мне неправильной.

Здесь мы также нашли очень интересную вещь. WeakHashMap.clear() все еще использует Arrays.fill. Это интересно, когда он появился там, но, к сожалению, это относится к доисторическим временам (он уже был в первом публичном репозитории OpenJDK).

Далее @MarcoTopolnik говорит:

Безопаснее, но это может быть быстрее, если вызов fill не встроен, а tab - короткий. В HotSpot как цикл, так и явный вызов fill приведут к быстрой компиляции компилятора (в сценарии "счастливый день" ).

Для меня было действительно удивительно, что Arrays.fill не является непосредственно встроенным (см. собственный список, сгенерированный @apangin). Кажется, что такой цикл может быть распознан и векторизован JVM без явной внутренней обработки. Таким образом, верно, что дополнительный вызов может быть не встроен в очень конкретные случаи (например, если достигнут предел MaxInlineLevel). С другой стороны, это очень редкая ситуация, и это всего лишь один вызов, это не вызов внутри цикла, а статический, а не виртуальный/интерфейсный вызов, поэтому улучшение производительности может быть только незначительным и только в некоторых конкретных сценариях. Не то, на что обычно заботятся разработчики JVM.

Также следует отметить, что даже компилятор C1 'client' (уровень 1-3) способен встраивать Arrays.fill, называемый, например, в WeakHashMap.clear(), так как inlining log (-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining) говорит:

36       3  java.util.WeakHashMap::clear (50 bytes)
     !m        @ 4   java.lang.ref.ReferenceQueue::poll (28 bytes)
                 @ 17   java.lang.ref.ReferenceQueue::reallyPoll (66 bytes)   callee is too large
               @ 28   java.util.Arrays::fill (21 bytes)
     !m        @ 40   java.lang.ref.ReferenceQueue::poll (28 bytes)
                 @ 17   java.lang.ref.ReferenceQueue::reallyPoll (66 bytes)   callee is too large
               @ 1   java.util.AbstractMap::<init> (5 bytes)   inline (hot)
                 @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
               @ 9   java.lang.ref.ReferenceQueue::<init> (27 bytes)   inline (hot)
                 @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
                 @ 10   java.lang.ref.ReferenceQueue$Lock::<init> (5 bytes)   unloaded signature classes
               @ 62   java.lang.Float::isNaN (12 bytes)   inline (hot)
               @ 112   java.util.WeakHashMap::newTable (8 bytes)   inline (hot)

Конечно, он также легко встроен компилятором Smart и мощным C2-сервером. Таким образом, я не вижу здесь проблем. Похоже, что версия @Marco неверна.

Наконец, у нас есть пара комментариев от @StuartMarks (кто является разработчиком JDK, таким образом, официальный голос):

Интересно. Я подозреваю, что это ошибка. Тема обзора для этого набора изменений здесь и ссылается на более ранний поток, то есть продолжение здесь. Исходное сообщение в этом более раннем потоке указывает на прототип HashMap.java в репозитории CVS Doug Lea. Я не знаю, откуда это взялось. Кажется, что это не похоже на что-либо в истории OpenJDK.

... В любом случае это мог быть какой-то старый снимок; цикл for был в методе clear() в течение многих лет. Вызов Arrays.fill() был введен этот набор изменений, поэтому он был в дереве всего несколько месяцев. Заметим также, что в то же время исчезло вычисление мощности двух на основе Integer.highestOneBit(), введенное этот набор изменений, хотя это был отмечен, но отклонен во время обзора. Хммм.

Действительно, HashMap.clear() содержал цикл много лет, был заменен Arrays.fill 10 апреля 2013 года и остался менее половины -a-год до 4 сентября, когда обсуждаемый commitбыл представлен. Рассматриваемая фиксация была фактически переписью внутренних элементов HashMap для исправления JDK-8023463. Это была длинная история о возможности отравить HashMap ключами, которые дублируют хэш-коды, что уменьшает скорость поиска HashMap до линейного, что делает его уязвимым для DoS-атак. Попытки решить эту проблему были выполнены в JDK-7, включая некоторую рандомизацию String hashCode. Похоже, что реализация HashMap была разветвлена ​​из более ранней фиксации, разработанной независимо, затем объединена с основной ветвью, перезаписывающей несколько изменений, введенных между ними.

Мы можем поддержать эту гипотезу, выполняющую diff. Возьмите версию, где Arrays.fill был удален (2013-09-04) и сравните его с предыдущая версия (2013-07-30). Выход diff -U0 имеет 4341 строку. Теперь давайте разграничим версию до того, как был добавлен Arrays.fill (2013-04-01). Теперь diff -U0 содержит только 2680 строк. Таким образом, более новая версия фактически больше похожа на более старую, чем на непосредственную родительскую.

Заключение

Итак, в заключение я бы согласился со Стюартом Марком. Не было никакой конкретной причины удалить Arrays.fill, это просто потому, что промежуточное изменение было переписано по ошибке. Использование Arrays.fill отлично подходит как в коде JDK, так и в пользовательских приложениях и используется, например, в WeakHashMap. Класс Arrays загружается в любом случае довольно рано во время инициализации JDK, имеет очень простой статический инициализатор и метод Arrays.fill может быть легко встроен даже клиентским компилятором, поэтому не следует отмечать недостаток производительности.

Ответ 2

Потому что это намного быстрее!

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

void jdk7clear() {
    Arrays.fill(table, null);
}

void jdk8clear() {
    Object[] tab;
    if ((tab = table) != null) {
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

работает на массивах различных размеров, содержащих случайные значения. Вот типичные результаты:

Map size |  JDK 7 (sd)|  JDK 8 (sd)| JDK 8 vs 7
       16|   2267 (36)|   1521 (22)| 67%
       64|   3781 (63)|   1434 ( 8)| 38%
      256|   3092 (72)|   1620 (24)| 52%
     1024|   4009 (38)|   2182 (19)| 54%
     4096|   8622 (11)|   4732 (26)| 55%
    16384|  27478 ( 7)|  12186 ( 8)| 44%
    65536| 104587 ( 9)|  46158 ( 6)| 44%
   262144| 445302 ( 7)| 183970 ( 8)| 41%

И вот результаты при работе над массивом, заполненным нулями (поэтому проблемы с сборкой мусора устраняются):

Map size |  JDK 7 (sd)|  JDK 8 (sd)| JDK 8 vs 7
       16|     75 (15)|     65 (10)|  87%
       64|    116 (34)|     90 (15)|  78%
      256|    246 (36)|    191 (20)|  78%
     1024|    751 (40)|    562 (20)|  75%
     4096|   2857 (44)|   2105 (21)|  74%
    16384|  13086 (51)|   8837 (19)|  68%
    65536|  52940 (53)|  36080 (16)|  68%
   262144| 225727 (48)| 155981 (12)|  69%

Числа находятся в наносекундах, (sd) - 1 стандартное отклонение, выраженное в процентах от результата (fyi, "нормально распределенная" популяция имеет SD 68), vs - это время JDK 8 относительно JDK 7.

Интересно, что это не только значительно быстрее, но и отклонение немного уже, что означает, что реализация JDK 8 дает немного более стабильную производительность.

Тесты выполнялись на jdk 1.8.0_45 по большому (миллионному) числу раз на массивах, заполненных случайными объектами Integer. Чтобы удалить лишние цифры, на каждом наборе результатов были сброшены самые быстрые и медленные 3% таймингов. Была запрошена сборка мусора, и нить уступила и спала непосредственно перед запуском каждого вызова метода. Разминка JVM проводилась на первых 20% работы, и эти результаты были отброшены.

Ответ 3

Для меня причина - это вероятное повышение производительности при незначительной стоимости с точки зрения ясности кода.

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

Потенциальные преимущества производительности не настолько незначительны, если вы рассматриваете все, что связано:

  • В JVM не будет необходимости разрешать класс Arrays, а также загружать и инициализировать его, если это необходимо. Это нетривиальный процесс, когда JVM выполняет несколько шагов. Во-первых, он проверяет загрузчик классов, чтобы узнать, загружен ли класс, и это происходит каждый раз, когда вызывается метод; здесь, конечно, есть оптимизации, но это все равно требует определенных усилий. Если класс не загружен, JVM должен будет пройти дорогостоящий процесс его загрузки, проверить байт-код, разрешить другие необходимые зависимости и, наконец, выполнить статическую инициализацию класса (который может быть сколь угодно дорого). Учитывая, что HashMap является таким основным классом и что Arrays - такой огромный класс (3600+ строк), избегание этих затрат может привести к заметной экономии.

  • Поскольку вызов метода Arrays.fill(...) отсутствует, JVM не должен будет решить, будет ли/когда встроить метод в тело вызывающего. Поскольку HashMap#clear() имеет тенденцию называться много, JVM в конечном итоге выполнит вложение, что требует перекомпиляции JIT метода clear. Без вызовов метода clear всегда будет работать с максимальной скоростью (один раз изначально JITed).

Еще одно преимущество, которое больше не вызывает методы в Arrays, заключается в том, что он упрощает график зависимостей внутри пакета java.util, поскольку одна зависимость удалена.

Ответ 4

Я собираюсь снимать в темноте здесь...

Я предполагаю, что он, возможно, был изменен, чтобы подготовить почву для специализация (так называемые дженерики над примитивными типами). Возможно (и я настаиваю, возможно), это изменение предназначено для перехода на Java 10 проще, если специализация является частью JDK.

Если вы посмотрите Состояние документа специализации, Ограничения языка, он говорит следующее:

Поскольку любые переменные типа могут принимать значения, а также ссылочные типы, правила проверки типов, включающие такие переменные типа (отныне "авары" ). Например, для avar T:

  • Невозможно преобразовать значение null в переменную типа T
  • Невозможно сравнить T с нулевым
  • Невозможно преобразовать T в объект
  • Невозможно преобразовать T [] в Object []
  • ...

(Акцент мой).

И впереди в разделе Редактирование спецификаций он говорит:

Специализируясь на универсальном классе, специализатор будет выполнять ряд преобразований, большинство локализованных, но некоторые требуют глобального представления класса или метода, в том числе:

  • ...
  • Подстановка переменных типов и переименование имени выполняются на подписи всех методов
  • ...

Позже, ближе к концу документа, в разделе Дальнейшее исследование говорится:

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


Теперь, касаясь изменения...

Если метод Arrays.fill(Object[] array, Object value) будет специализированным, то его подпись должна измениться на Arrays.fill(T[] array, T value). Однако этот случай специально указан в разделе (уже упомянутых) ограничений языка (это будет нарушать подчеркнутые элементы). Поэтому, возможно, кто-то решил, что лучше не использовать его из метода HashMap.clear(), особенно если value есть null.

Ответ 5

Нет никакой фактической разницы в функциональности между циклами версии 2. Arrays.fill делает то же самое.

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

Для каждого подхода есть две отдельные проблемы:

  • с помощью Arrays.fill делает код менее подробным и читаемым.
  • Прямой цикл в коде HashMap (например, версия 8) - это лучший вариант. Хотя накладные расходы, которые вставляют класс Arrays, незначительны, могут стать менее значительными, когда речь заходит о чем-то распространенном как HashMap, где каждый бит повышения производительности имеет большой эффект (представьте себе, что минимальное уменьшение размера HashMap в полномасштабном webapp). Примите во внимание тот факт, что класс Arrays использовался только для этого одного цикла. Это изменение достаточно мало, что делает этот метод менее понятным.

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

Мое мнение, что это можно считать усовершенствованием, даже если только случайно.