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


70

我注意到在实施时有些奇怪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可以将null用作空映射,因此需要在局部变量中进​​行额外的检查和缓存。但是为什么Arrays.fill()要用for循环代替?

似乎此提交中引入了更改。不幸的是,我没有找到为什么普通的for循环比更好的解释Arrays.fill()。它更快吗?或更安全?


3
当然不是更安全,但是当呼叫未内联且很短时,它可能会更快。在HotSpot上,循环和显式调用都将导致快速的编译器内部函数(在欢乐时光中)。filltabfill
Marko Topolnik 2015年

14
我猜想这是为了避免类java.util.Arrays加载此方法的副作用。对于应用程序代码,通常不必担心。
Holger 2015年

5
有趣。我的直觉是这是一个错误。此变更集的审阅线程在这里,它引用的是先前的线程,并在此处继续。该较早线程中的初始消息指向Doug Lea的CVS存储库中的HashMap.java原型。我不知道这是哪里来的。它似乎与OpenJDK历史记录中的任何内容都不匹配。
Stuart Marks

7
...无论如何,它可能是一些旧快照;for循环在clear()方法中使用了很多年。这个changeset引入了Arrays.fill()调用,因此它只在树中呆了几个月。还要注意,此变更集引入的基于Integer.highestOneBit()的二次幂计算也同时消失了,尽管已注意到但在本次审查中被忽略了。嗯
Stuart Marks 2015年

6
@EJP,我不同意。我不是在寻求观点,只是在寻求事实,问题不在于代码样式。注释中有三个不错的版本,所有版本都可以验证。Marko的版本可以通过在解释/ C1 / C2模式下执行的良好基准进行验证。可以通过调查JVM启动时的类加载顺序来验证Holger的版本。可以通过研究提交树和邮件列表讨论来验证Stuart的版本(可能是正确的)。毕竟,任何人都可以直接在core-libs-dev中提出这个问题。数千个问题中,正确答案是“这是一个错误”。
Tagir Valeev

Answers:


29

我将尝试总结评论中提出的三个更合理的版本。

@霍尔格

我猜想这是为了避免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.fillclear()方法。该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

当然不是更安全,但是当呼叫未内联且很短时,它可能会更快。在HotSpot上,循环和显式调用都将导致快速的编译器内部函数(在欢乐时光中)。filltabfill

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

还应注意,即使C1“客户端”编译器(1-3层)也能够内联Arrays.fill调用,例如in WeakHashMap.clear(),如内联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)

当然,它还可以通过功能强大的智能C2“服务器”编译器轻松内联。因此,我在这里看不到任何问题。似乎@Marco版本也不正确。

最后,我们有@StuartMarks(他是JDK开发人员,因此是官方声音)的一些评论

有趣。我的直觉是这是一个错误。此变更集的审阅线程在这里,它引用的是先前的线程,并在此处继续。该较早线程中的初始消息指向Doug Lea的CVS存储库中的HashMap.java原型。我不知道这是哪里来的。它似乎与OpenJDK历史记录中的任何内容都不匹配。

...无论如何,它可能是一些旧快照;for循环在clear()方法中使用了很多年。这个changeset引入了Arrays.fill()调用,因此它只在树中呆了几个月。还要注意,此变更集引入的基于Integer.highestOneBit()的二次幂计算也同时消失了,尽管已注意到但在本次审查中被忽略了。嗯

事实上,HashMap.clear()包含循环多年被替换Arrays.fill在2013年4月10日,住少一个半个,直到今年9月4日,当讨论提交了介绍。讨论的提交实际上是对HashMap内部结构的重大重写,以解决JDK-8023463问题。长期以来,人们一直担心HashMap散列码重复的键会毒害密钥,从而将HashMap搜索速度降低为线性,使其容易受到DoS攻击。解决该问题的尝试是在JDK-7中进行的,包括对String hashCode的一些随机化。如此看来HashMap 实现是从较早的提交派生的,是独立开发的,然后合并到master分支中,以覆盖介于两者之间的一些更改。

我们可能会支持这种假设进行比较。就拿版本,其中Arrays.fill移除(2013年9月4日),并将其与比较以前的版本(2013年7月30日)。该diff -U0输出具有4341线。现在,让我们与添加时(2013-04-01)之前的版本进行比较Arrays.fill。现在diff -U0仅包含2680行。因此,新版本实际上比旧版本更像直接父版本。

结论

因此,总而言之,我同意Stuart Marks的观点。没有删除的具体原因Arrays.fill,只是因为中间的更改被错误覆盖。Arrays.fill在JDK代码和用户应用程序中使用都非常好,例如,在中使用过WeakHashMapArrays无论如何,该类都是在JDK初始化期间很早就加载的,它具有非常简单的静态初始化器,并且Arrays.fill即使由客户端编译器也可以轻松地内联方法,因此,不应该注意到性能上的缺点。


我的评论只是猜测一个意图,而不是说对于当前的实现它实际上是有用的。一个更简单的测试包括仅设置一个断点HashMap.clear()以捕获第一个(JVM内部)调用,并且是的,到那时,java.util.Arrays该加载已被加载。对我来说,Stuart Marks两天前已经给出了答案。实际上,存在相反的趋势,有Arrays.fill充分的理由替换为手动循环,在这里,我们只是在进行更新。顺便说一句。Arrays.fill可能Arrays.copyOf已经成为内在的……
Holger 2015年

您说过-Arrays加载很可能是由WeakHashMap加载触发的,该加载实际上在clear()方法中具有Arrays.fill。。仅当在加载当前类时需要该类时才加载该类。因此,Arrays在装载只会加载WeakHashMap如果WeakHashMap有一个静态intialization Arrays,只是有Arrays.fill()clear方法不会触发的加载Arrays。我认为其他一些类正在触发的负载Arrays
hagrawal

我不同意结论。分支合并似乎不太可能是错误的。对我来说,开发人员只是认为直接编写循环而不是调用循环Arrays.fill 可能会更快(由于与类加载和方法内联有关的原因,在其他地方已提到),并且由于循环非常简单且很小,因此可以继续进行。
罗杰里奥

@hagrawal:您说对了,因为静态引用不会自动触发类加载/初始化。但是,通过将一个初始化程序添加到Arrays记录调用堆栈跟踪的类中来查找实际触发器并不难。在jdk中,我测试了,是String(char[])调用的构造函数Arrays.copy。它是由调用的sun.nio.cs.FastCharsetProvider,但我想还有很多其他地方可以对其进行初始化,如果该调用者不是第一个调用者的话
Holger 2015年

@霍尔格 在我看来,它没有加载(我认为您是说它的意思),因为在加载之前就已经加载了很多。据我所知,一个类将在引用该类的类之后立即加载,因此按照该逻辑,应该加载这些类是因为或因为这些类是在之前加载的Arrayssun.nio.cs.FastCharsetProviderArraysArrayssun.reflect.misc.ReflectUtiljava.util.concurrent.atomic.AtomicReferenceFieldUpdaterArrays
hagrawal

3

因为它是快!

我对这两种方法的简化版本进行了一些彻底的基准测试:

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个标准偏差(fyi,“正态分布”总体的SD为68),vs是相对于JDK 7的JDK 8计时。

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

该测试在jdk 1.8.0_45上对填充有随机Integer对象的阵列进行了大(百万)次运行。为了删除偏远的数字,在每组结果中,最快和最慢的3%的计时被丢弃。请求垃圾回收,并且在每次调用该方法之前就放弃并休眠线程。在前20%的工作中就完成了JVM预热,并且这些结果被丢弃了。


2
您能否分享您所使用的基准测试框架的详细信息。您何时进行显式垃圾收集?您是否在单独的JVM中运行了单独的测试?输出看起来不像JMH。同样,如果您的标清值为16%(这种情况下的标清值很高,可能是测试方法学上的问题吗?),则不能说结果提高3%具有统计学意义。可能是随机波动。
塔吉尔·瓦列夫2015年

@TagirValeev我编写了自己的测试。允许热身。您可以说3%在统计上是有意义的,因为测试量(百万次)非常均匀,甚至可以运行。我不允许使用GC(可以解释大SD)。我将再次控制GC来运行它。请注意,SD是如此之大-标准正态分布的SD = 68%,但我与您同在-我希望同一代码的方差很小。
波西米亚风格

4
您能否分享整个基准代码?当发现诸如中微子的传播快于光的现象,这通常是测量误差,而不是物理学的革命。
Tagir Valeev 2015年

3
昨晚我做了自己的基准测试,但是在两种clear()方法版本(在32位JDK 1.8.0_45上运行)之间,我只能看到性能上的微小差异。因此,此答案将需要显示基准测试的完整源代码,否则将无法被信任。
罗杰里奥2015年

1
如果可以关闭SO的答案,我将投票关闭该答案。
ZhekaKozlov '02

1

对我而言,原因是可能会提高性能,而在代码清晰度方面却可以忽略不计。

注意,该fill方法的实现很简单,一个简单的for循环将每个数组元素设置为null。因此,用实际的实现替换对它的调用不会在调用方方法的清晰度/简洁性上引起任何明显的降低。

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

  1. JVM将不需要解析Arrays该类,并且在需要时无需加载和初始化它。这是一个不平凡的过程,其中JVM执行几个步骤。首先,它检查类加载器以查看是否已经加载了该类,并且每次调用方法时都会发生这种情况。当然,这里涉及到优化,但是仍然需要一些努力。如果未加载该类,则JVM将需要经历昂贵的加载过程,包括验证字节码,解决其他必要的依赖关系以及最终对类进行静态初始化(这可能会非常昂贵)。鉴于这HashMap是一个核心类,又Arrays是一个庞大的类(3600多个行),因此避免这些成本可能会节省大量费用。

  2. 由于没有Arrays.fill(...)方法调用,因此JVM不必决定是否/何时将方法内联到调用者的主体中。由于HashMap#clear()经常被调用,因此JVM最终将执行内联,这需要重新编译该clear方法。没有任何方法调用,clear将始终以最高速度运行(一旦最初是JITed)。

不再调用方法的另一个好处Arraysjava.util,由于删除了一个依赖关系,因此简化了包内部的依赖关系图。


1

我要在黑暗中射击...

我的猜测是,可能已对其进行了更改,以便为专业化做准备(也就是原始类型的泛型)。也许(并且我坚持也许),如果专业化成为JDK的一部分,那么此更改旨在使向Java 10的过渡更加容易。

如果查看“专业化状态”文档的语言限制”部分,则表示以下内容:

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

  • 无法将null转换为类型为T的变量
  • 无法将T比较为null
  • 无法将T转换为对象
  • 无法将T []转换为Object []
  • ...

(强调是我的)。

在“专化程序”转换部分的前面,它表示:

当专门化任何通用类时,专门化器将执行许多转换,大多数是本地化的,但是某些转换需要类或方法的全局视图,包括:

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

稍后,在文档末尾的“进一步调查”部分中,它表示:

尽管我们的实验证明以这种方式进行专业化是可行的,但仍需要进行更多的研究。具体来说,我们需要针对任何流行的核心JDK库(尤其是Collections和Streams)执行许多有针对性的实验。


现在,关于更改...

如果该Arrays.fill(Object[] array, Object value)方法将是专门的,则其签名应更改为Arrays.fill(T[] array, T value)。但是,这种情况在(已经提到的)“语言限制”部分中特别列出(这会违反强调的内容)。因此,也许有人认为最好不要从HashMap.clear()方法中使用它,尤其是如果valueis null


0

两个版本的循环之间的功能没有实际差异。Arrays.fill做完全相同的事情。

因此,选择是否使用它不一定被认为是错误的。由开发人员来决定何时进行这种微管理。

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

  • 使用Arrays.fill可以使代码的详细程度降低,可读性更高。
  • HashMap实际上,直接在代码中循环(例如版本8)是更好的选择。尽管插入Arrays该类的开销可以忽略不计,但它可能变得更少,因此涉及到诸如HashMap性能增强的每一点都会产生巨大影响的广泛应用(想象一下,成熟的Webapp中HashMap的占用空间最小)。考虑到Arrays类仅用于这一循环这一事实。所做的更改很小,不会使clear方法的可读性降低。

没有询问开发人员实际是谁,就无法找到确切的原因,但是我怀疑这是一个错误还是一个小的改进。更好的选择。

我的看法是,即使只是偶然,也可以认为它是一种增强。


您的基准测试很有趣,但是可能是使用更新的JVM的结果。尝试在Java 8 JVM中运行源代码片段的副本。
波西米亚风格

7
您的基准测试看起来不正确(预热在哪里?您进行了多少次运行等)-并且11或18 ms在某些系统上接近currentTimeMillis的分辨率。所以我不太相信这些结果...
assylias

确实,这有点粗略,不一定一定要指出确切的变化。将使用确切的代码片段进行更新。至于运行,大约是20个,变化为+ -1毫秒。
Laurentiu L.
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.