为啥 Arrays.fill() 不再在 HashMap.clear() 中使用?

Posted

技术标签:

【中文标题】为啥 Arrays.fill() 不再在 HashMap.clear() 中使用?【英文标题】:Why is Arrays.fill() not used in HashMap.clear() anymore?为什么 Arrays.fill() 不再在 HashMap.clear() 中使用? 【发布时间】:2015-12-18 01:31:38 【问题描述】:

我注意到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 中引入了更改。不幸的是,我没有找到解释为什么普通的 for 循环可能比 Arrays.fill() 更好。它更快吗?还是更安全?

【问题讨论】:

当然不会更安全,但是当fill 调用未内联且tab 很短时,它可能会更快。在 HotSpot 上,循环和显式 fill 调用都将导致快速编译器内在(在快乐的一天场景中)。 我想这是为了避免 java.util.Arrays 类作为这种方法的副作用而被加载。对于应用程序代码,这通常不是问题。 有趣。我的直觉是这是一个错误。此变更集的审核线程是here,它引用了earlier thread,即continued here。早期线程中的初始消息指向 Doug Lea 的 CVS 存储库中的 HashMap.java 原型。我不知道这是从哪里来的。它似乎与 OpenJDK 历史中的任何内容都不匹配。 ... 无论如何,它可能是一些旧快照; for 循环在 clear() 方法中使用了很多年。 Arrays.fill() 调用是由this changeset 引入的,所以它在树中只存在了几个月。另请注意,this changeset 引入的基于 Integer.highestOneBit() 的二次幂计算也同时消失了,尽管在审查过程中注意到但忽略了这一点。嗯。 @EJP,我不同意。我不是在寻找意见,只是为了事实,问题不在于代码风格。 cmets中有3个不错的版本,都可以验证。 Marko 的版本可以通过以解释/C1/C2 模式执行的良好基准来验证。 Holger 的版本可以通过调查 JVM 启动时的类加载顺序来验证。 Stuart 的版本(可能是正确的)可以通过调查提交树和邮件列表讨论来验证。毕竟任何人都可以直接在 core-libs-dev 中问这个问题。有成千上万个问题的正确答案是“这是一个错误”。 【参考方案1】:

我将尝试总结 cmets 中提出的三个不太合理的版本。

@Holgersays:

我猜这是为了避免类 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 仅在 HashMap 之后的 14 个类加载。 HashMap 加载由 sun.reflect.Reflection 初始化触发,因为它具有 HashMap 静态字段。 Arrays 负载很可能由WeakHashMap 负载触发,而clear() 方法中实际上有Arrays.fillWeakHashMap 负载由扩展 WeakHashMapjava.lang.ClassValue$ClassValueMap 触发。 ClassValueMap 存在于每个 java.lang.Class 实例中。所以在我看来,如果没有Arrays 类,JDK 根本无法初始化。 Arrays 静态初始化器也很短,它只初始化断言机制。此机制用于许多其他类(包括,例如,很早就加载的java.lang.Throwable)。 java.util.Arrays 中没有执行其他静态初始化步骤。因此@Holger 版本对我来说似乎不正确。

在这里我们还发现了非常有趣的事情。 WeakHashMap.clear() 仍然使用 Arrays.fill。当它出现在那里时很有趣,但不幸的是它转到了prehistoric times(它已经存在于第一个公共 OpenJDK 存储库中)。

接下来,@MarcoTopolnik says:

当然不会更安全,但是当fill 调用未内联且tab 很短时,它可能会更快。在 HotSpot 上,循环和显式 fill 调用都将导致快速编译器内在(在快乐的一天场景中)。

令我惊讶的是,Arrays.fill 并没有直接内在化(参见@apangin 生成的intrinsic list)。似乎这种循环可以被 JVM 识别和矢量化,而无需显式的内部处理。因此,在非常特殊的情况下(例如,如果达到MaxInlineLevel 限制),确实不能内联额外调用。另一方面,这是非常罕见的情况,它只是一次调用,它不是循环内的调用,它是静态的,不是虚拟/接口调用,因此性能提升可能只是微不足道的,并且仅在某些特定情况下。不是 JVM 开发人员通常关心的事情。

还应该注意的是,即使是 C1“客户端”编译器(第 1-3 层)也能够内联 Arrays.fill,例如在 WeakHashMap.clear() 中调用,因为内联日志 (-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)

当然,它也很容易被智能且强大的 C2“服务器”编译器内联。因此,我认为这里没有问题。似乎@Marco 版本也不正确。

最后我们有几个来自@StuartMarks 的comments(他是JDK 开发人员,因此有一些官方声音):

有趣。我的直觉是这是一个错误。此变更集的审核线程是here,它引用了earlier thread,即continued here。早期线程中的初始消息指向 Doug Lea 的 CVS 存储库中的 HashMap.java 原型。我不知道这是从哪里来的。它似乎与 OpenJDK 历史中的任何内容都不匹配。

...无论如何,它可能是一些旧快照; for 循环在 clear() 方法中使用了很多年。 Arrays.fill() 调用是由this changeset 引入的,所以它在树中只存在了几个月。另请注意,this changeset 引入的基于 Integer.highestOneBit() 的二次幂计算也同时消失了,尽管在审查过程中注意到但忽略了这一点。嗯。

确实,HashMap.clear() 包含多年的循环,在 2013 年 4 月 10 日是 replaced 和 Arrays.fill,直到 9 月 4 日讨论的 commit 被引入时才停留不到半年。讨论的提交实际上是对 HashMap 内部的重大重写,以修复 JDK-8023463 问题。关于使用具有重复哈希码的键来毒化HashMap 的可能性很长,这会将HashMap 的搜索速度降低到线性,使其容易受到 DoS 攻击。解决这个问题的尝试是在 JDK-7 中执行的,包括一些 String hashCode 的随机化。因此,HashMap 实现似乎是从早期的提交中派生出来的,独立开发,然后合并到主分支中,覆盖了中间引入的几个更改。

我们可能会支持这个假设执行差异。取 version 删除 Arrays.fill (2013-09-04) 并将其与 previous version (2013-07-30) 进行比较。 diff -U0 输出有 4341 行。现在让我们在添加Arrays.fill (2013-04-01) 之前与version 进行比较。现在diff -U0 只包含 2680 行。因此,较新的版本实际上更类似于旧版本而不是直接父版本。

结论

因此,我同意 Stuart Marks 的结论。没有具体的理由删除Arrays.fill,只是因为中间的更改被错误地覆盖了。在 JDK 代码和用户应用程序中使用 Arrays.fill 非常好,例如,在 WeakHashMap 中使用。 Arrays 类在 JDK 初始化的早期就被加载了,具有非常简单的静态初始化器,并且 Arrays.fill 方法可以很容易地被客户端编译器内联,所以应该没有性能缺陷。

【讨论】:

我的评论只是猜测一个意图,并没有声称考虑到当前的实现它实际上是有用的。一个更简单的测试包括在HashMap.clear() 中设置一个断点来捕获第一个(JVM 内部)调用,是的,到那时,java.util.Arrays 已经被加载了。对我来说,Stuart Marks 两天前已经给出了答案。实际上有一种相反的趋势,有充分的理由用Arrays.fill 替换手动循环,在这里,我们只是冲突更新。顺便提一句。 Arrays.fill 可能会变成内在的,就像 Arrays.copyOf 已经是…… 你说 - 数组加载很可能是由 WeakHashMap 加载触发的,它实际上在 clear() 方法中有 Arrays.fill。。只有在加载当前类时需要该类时才加载该类。因此,Arrays 只会在加载 WeakHashMap 时加载,如果 WeakHashMap 具有 Arrays 的静态初始化,仅在 clear 方法中使用 Arrays.fill() 不会触发 Arrays 的加载。我认为其他一些类正在触发Arrays 的负载。 我不同意这个结论;分支合并似乎不太可能是错误。对我来说,开发人员只是认为直接编写循环而不是调用Arrays.fill可能更快(由于其他地方提到的与类加载和方法内联相关的原因) ,并且由于循环非常简单且很小,因此继续进行。 @hagrawal:你说得对,静态引用不会自动触发类加载/初始化。但是,通过将初始化程序添加到记录调用堆栈跟踪的Arrays 类中,找到实际触发器并不难。在我测试的 jdk 中,构造函数String(char[]) 调用了Arrays.copy。它是由sun.nio.cs.FastCharsetProvider 调用的,但我猜,如果调用者不是第一个调用者,还有很多其他地方初始化它…… @Holger 好的。在我看来(我认为你也是这个意思Arrays 没有加载是因为sun.nio.cs.FastCharsetProvider 因为Arrays 在它之前加载很多。据我所知,一个类将在引用它的类之后很快加载,所以按照这个逻辑,Arrays 应该已经加载,因为 sun.reflect.misc.ReflectUtiljava.util.concurrent.atomic.AtomicReferenceFieldUpdater 因为这些类是在 @987654410 之前加载的@【参考方案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%

以下是对填充了 null 的数组进行操作时的结果(因此消除了垃圾收集问题):

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 个标准差,表示为结果的百分比(仅供参考,“正态分布”总体的 SD 为 68),vs 是 JDK 8 相对于 JDK 的时间7.

有趣的是,它不仅速度明显更快,而且偏差也略窄,这意味着 JDK 8 实现提供了稍微一致的性能。

测试在 jdk 1.8.0_45 上运行了大量(数百万)次,并在随机 Integer 对象填充的数组上运行。为了去除离群的数字,在每组结果中,最快和最慢的 3% 的时间都被丢弃了。请求垃圾收集并且线程在运行该方法的每次调用之前产生并休眠。 JVM 预热在前 20% 的工作中完成,这些结果被丢弃。

【讨论】:

能否请您分享您正在使用的基准测试框架的详细信息。您何时进行显式垃圾收集?您是否在单独的 JVM 中运行单独的测试?输出看起来不像 JMH。此外,如果您有 16% 的 sd(在这种情况下相当高的 sd,可能是测试方法问题?),您不能说 3% 更快的结果在统计上是显着的。可以是随机波动。 @TagirValeev 我写了自己的测试。允许热身。你可以说 3% 在统计上是显着的,因为测试的数量(数百万),而且即使运行也非常一致。我不允许 GC(这可以解释大 SD)。我将再次运行它来控制 GC。请注意,SD 那么大 - 标准正态分布的 SD = 68%,但我同意 - 我希望相同的代码具有非常小的方差。 能否分享整个基准测试代码?当发现像neutrinos travelling faster than light 这样的现象时,通常是测量错误,而不是物理学革命。 昨晚我做了自己的基准测试,但我只能看到两个 clear() 方法版本(在 JDK 1.8.0_45 32 位上运行)之间的性能差异很小。因此,此答案需要显示基准测试的完整源代码,否则将无法信任。 如果可以在 SO 上关闭答案,我会投票关闭这个答案。【参考方案3】:

我要在这里在黑暗中拍摄......

我的猜测是它可能已被更改,以便为Specialization(也就是原始类型的泛型)奠定基础。 也许(我坚持也许),如果专业化成为 JDK 的一部分,此更改旨在简化向 Java 10 的过渡。

如果您查看State of the Specialization document,语言限制部分,它会显示以下内容:

因为任何类型变量都可以取值以及引用类型,所以涉及此类类型变量的类型检查规则(以下称为“avars”)。例如,对于 avar T:

无法将 null 转换为类型为 T 的变量 无法将 T 与 null 进行比较 无法将 T 转换为 Object 无法将 T[] 转换为 Object[] ...

(重点是我的)。

Specializer 转换部分的前面,它说:

当专门化一个任意泛型类时,专门化器将执行许多转换,大部分是本地化的,但有些需要类或方法的全局视图,包括:

... 对所有方法的签名执行类型变量替换和名称修改 ...

稍后,在文档末尾附近的进一步调查部分,它说:

虽然我们的实验证明以这种方式进行专业化是可行的,但还需要进行更多的调查。具体来说,我们需要针对任意核心 JDK 库(特别是 Collections 和 Streams)进行一些有针对性的实验。


现在,关于更改...

如果Arrays.fill(Object[] array, Object value) 方法要专门化,那么它的签名应该更改为Arrays.fill(T[] array, T value)。然而,这种情况在(已经提到的)语言限制部分中特别列出(它会违反强调的项目)。所以也许有人决定最好不要从HashMap.clear()方法中使用它,特别是如果valuenull

【讨论】:

【参考方案4】:

对我而言,原因可能是性能改进,而代码清晰度方面的成本可以忽略不计。

请注意,fill 方法的实现很简单,一个简单的 for 循环将每个数组元素设置为 null。因此,用实际实现替换对它的调用不会导致调用方方法的清晰度/简洁性有任何显着下降。

如果您考虑所涉及的所有方面,潜在的性能优势并不是那么微不足道:

    JVM 不需要解析Arrays 类,如果需要,还需要加载和初始化它。这是一个重要的过程,JVM 执行几个步骤。首先,它检查类加载器以查看该类是否已经加载,并且每次调用方法时都会发生这种情况;当然,这里涉及到优化,但仍然需要一些努力。如果类没有被加载,JVM 将需要经历加载它、验证字节码、解决其他必要的依赖关系、最后执行类的静态初始化(这可能是任意昂贵的)的昂贵过程。鉴于HashMap 是一个如此核心的类,而Arrays 是一个如此庞大的类(3600 多行),避免这些成本可能会带来显着的节省。

    由于没有Arrays.fill(...) 方法调用,JVM 不必决定是否/何时将方法内联到调用者的主体中。由于HashMap#clear() 经常被调用,JVM 最终会执行内联,这需要对clear 方法进行JIT 重新编译。在没有方法调用的情况下,clear 将始终以最高速度运行(最初是 JITed)。

不再调用Arrays 中的方法的另一个好处是它简化了java.util 包中的依赖关系图,因为删除了一个依赖关系。

【讨论】:

【参考方案5】:

两个版本的循环在功能上没有实际区别。 Arrays.fill 做同样的事情。

所以选择使用或不使用它不一定被认为是一个错误。当涉及到这种微观管理时,由开发人员决定。

每种方法都有 2 个独立的关注点:

使用Arrays.fill 使代码更简洁,更易读。 直接在HashMap 代码中循环(如版本8)实际上是一个更好的选择。虽然插入 Arrays 类的开销可以忽略不计,但它可能会变得更小,所以当涉及到像 HashMap 这样普遍的东西时,每一点性能增强都会产生很大的影响(想象一下在成熟的 webapp 中最小的 HashMap 占用空间) )。考虑到 Arrays 类仅用于这一循环这一事实。更改足够小,不会降低 clear 方法的可读性。

如果不询问实际执行此操作的开发人员,就无法找到确切的原因,但是我怀疑这要么是一个错误,要么是一个小的改进。 更好的选择。

我的观点是它可以被认为是一种增强,即使只是偶然的。

【讨论】:

您的基准测试很有趣,但可能是使用较新 JVM 的结果。尝试在 java 8 JVM 中运行源 sn-ps 的副本。 您的基准测试看起来不正确(在哪里预热?您做了多少次运行等) - 在某些系统上,11 或 18 毫秒接近 currentTimeMillis 的分辨率。所以我不会非常相信这些结果...... 确实,它有点粗略,可能不一定能准确指出那个变化。将使用确切的代码 sn-ps 进行更新。至于运行,大约是 20 次,变化为 +-1 毫秒。

以上是关于为啥 Arrays.fill() 不再在 HashMap.clear() 中使用?的主要内容,如果未能解决你的问题,请参考以下文章

C# 等效于 Java 的 Arrays.fill() 方法[重复]

java.util.Arrays.fill方法

Arrays

Java基础_Arrays

Java:Arrays类

JAVA源码走读二分查找与Arrays类(未完)