为啥 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.fill
。 WeakHashMap
负载由扩展 WeakHashMap
的 java.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.ReflectUtil
或 java.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()
方法中使用它,特别是如果value
是null
。
【讨论】:
【参考方案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() 中使用?的主要内容,如果未能解决你的问题,请参考以下文章