大家好,我是伟伟。那天,我正在用键盘疯狂输出:突然微信弹出一条消息,是一位读者发给我的。我点开:啊,这熟悉的味道,一看就是HashMap,千篇一律的文学梦开始的地方。不过他问的问题好像不是属于HashMap的八卦:这里为什么要把表变量赋值给tab?众所周知,table是HashMap的一个成员变量,map中放入的数据就存放在这张表中:在putVal方法中,首先将table赋值给局部变量tab,后面的操作都在方法中进行这个局部变量。事实上,它不仅仅是putVal方法。在HashMap的源码中,有多达14个“tab=table”的写入和发送。比如getNode中的用法也是一样的:我们先想一想。如果不用局部变量tab,直接操作table,会不会有问题?从代码逻辑和功能来看,不会有问题。如果是别人这么写,我觉得可能是他的编程习惯,没什么深意,反正也不是不能用。不过这东西是DougLea写的,隐约觉得里面肯定有深意。那为什么要这样写呢?巧合的是,我想我刚好知道答案是什么。因为在其他地方看到过这种将成员变量赋值给局部变量的方式,并且在注释中注明了为什么要这样写。而这个地方就是Java的String类:比如String类的trim方法,其中将String的值赋值给了局部变量val。然后在旁边给出了一个非常简短的注释:避免getfieldopcode这篇文章的故事从一行注释开始,一路回到2010年,我终于找到了问题的答案。一行注释说要避免getfield字节码。虽然不知道是什么意思,但至少我得到了几个关键词,也就是说我找到了一个“线程”,接下来的事情就很简单了,顺着线程往下走就完事了。而我的直觉告诉我,这又是一个字节码层面的极端优化,最后一定是show操作。那我先告诉你结论:这段代码确实是DougLea写的,那时候确实是一种优化方法,但是时代变了,现在真的没用了。答案隐藏在字节码中。既然这里说了字节码的操作,接下来的思路就是比较一下这两种不同写法的字节码长什么样子,是不是很清楚了?例如,让我从这样的测试代码开始:publicclassMainTest{privatefinalchar[]CHARS=newchar[5];publicvoidtest(){System.out.println(CHARS[0]);System.out.println(CHARS[1]);System.out.println(CHARS[2]);}publicstaticvoidmain(String[]args){MainTestmainTest=newMainTest();mainTest.test();}}上面代码中的测试方法编译成字节码后是这样的:可以看到三个输出时间对应三个字节码是这样的:可以通过在互联网。段代码是做什么的:getstatic:获取指定类的静态字段,并压栈顶aload_0:将引用类型的第一个局部变量压栈顶getfield:获取类的实例字段将指定的类,压入栈顶将其值压入栈顶iconst_0:将int类型0压入栈顶caload:将char类型数组指定索引的值压入栈顶invokevirtual:调用实例方法如果,我按照上面写的方法修改测试程序,重新生成字节码文件,就这样:可以看到,getfield的字节码只出现了一次。从三次变成一次,这就是注释中写的“avoidgetfieldopcode”的具体含义。实际上,生成的字节码减少了。理论上,这是字节码层面的极致优化。具体对于getfield命令来说,它所做的就是获取指定对象的成员变量,然后将该成员变量的值或者引用放到操作数栈的顶部。更具体地说,getfield命令正在访问MainTest类中的CHARS变量。在底层,如果没有局部变量接管,每次使用getfield方法,都必须访问堆中的数据。而让一个局部变量接管,第一次只需要获取一次,然后把堆上的数据“缓存”到局部变量表中,也就是获取到栈中。之后每次只需要调用aload_字节码,将这个局部变量加载到操作栈上即可。aload_的操作是比getfield更轻量级的操作。这一点从JVM文档中对这两条指令的描述长度也能看出来:https://docs.oracle.com/javas...这里就不细说了,大家应该明白这里:putmembers变量赋值给局部变量后进行操作,确实是一种优化方法,可以达到“避免getfieldopcode”的目的。看到这里,你的心开始有点动了。我觉得这段代码很棒。我也可以来一波吗?别着急,还有更好的,我还没告诉你呢。在Java中的stackoverflow中,我们其实可以在很多地方看到这样的写法,比如我们前面提到的HashMap和String。仔细看J.U.C包里的源码,很多都是这样写的。但是,也有很多代码不是这样写的。比如stackoverflow上有这样一个问题:问问题的小伙伴问为什么BigInteger不用String的trim方法“avoidgetfieldopcode”?下面的回答是这样说的:在JVM中,String是一个非常重要的类,这个微小的优化可能会提高一点启动速度。另一方面,BigInteger对于JVM启动并不重要。所以,如果你看了这篇文章,想在代码中使用这种“粘”的写法,请三思。醒醒,你只有几个流量,值得优化到这种程度吗?而且,我告诉你,前面确实有字节码层面的优化,我们都看到了就相信了。但是这个家伙提醒我:他提到了JIT,他是这样说的:这些微小的优化通常是不必要的,它只是减少了方法的字节码大小,一旦代码变得足够热可以被JIT优化,它并不会真正影响由此产生的装配。于是,我翻遍了stackoverflow,终于在几千条线索中找到了最有价值的一条。这个问题和文章开头那个读者问我的一模一样:https://stackoverflow.com/que...这位哥们说:在jdk源码中,更具体地说,在collectionframework中,有一个编码怪癖是在用表达式读取局部变量之前先给局部变量赋值。这只是一个简单的怪癖,还是有更重要的东西潜伏在我没有注意到的里面?后来有人帮他加了几句:这段代码是DougLea写的,小Lea,经常做一些意想不到的代码和优化。他也以这些“莫名其妙”的代码出名,习惯就好。然后这个问题下面有一个回答是这样说的:DougLea是collectionframework和concurrentpackage的主要作者之一,他倾向于在编码时做一些优化。但是这些优化对于普通人来说可能是违反直觉和混淆的。毕竟,人在大气中。然后他给出了一段代码,其中有三种方法可以验证不同写法产生的不同字节码:三种方法如下:对应的字节码我就不贴了,直接说结论:testSeparate方法使用41条指令testInlined方法确实小了一点,有39条指令最后,testRepeated方法使用了多达63条指令,功能相同,但最后一种直接使用成员变量的方式生成的字节码最多。所以他给出了和我之前一样的结论:这种写法确实可以节省几个字节的字节码,这可能是使用这种方式的原因。但是……主要是,他即将开始但是:但是,在任何一种方法中,在经过JIT优化之后,生成的机器代码将与原始字节码“不可知”。有一件事是非常肯定的:所有三个版本的代码最终都会编译成相同的机器代码(程序集)。因此他的建议是:不要使用这种风格,只需编写易于阅读和维护的“愚蠢”代码即可。您会知道何时轮到您使用这些“优化”。可以看到他附上了“writedumbcode”的超链接,推荐大家阅读:https://www.oracle.com/techni...里面可以看到《Java Concurrency in Practice》的作者BrianGoetz:他对“笨代码”的解读:他说:一般来说,在Java应用程序中编写快速代码的方法是编写“笨代码”——简单、干净,并遵循最明显的基于面向对象原则的代码。显然,tab=table不是“哑码”。好,回到这个问题。老哥接着做了进一步的测试,测试结果如下:他对比了testSeparate和TestInLine方法JIT优化后的汇编,两个方法的汇编是一样的。但是,你要弄清楚的是,这位小哥在这里说的是testSeparate和testInLine方法,这两个方法都是使用局部变量:只是testSeparate的可读性比testInLine高很多。而testInLine的写法就是HashMap的写法。这就是为什么他说:我们程序员只能专注于编写更具可读性的代码,而不是从事这些“表演”操作。JIT会为我们做这些事情。从testInLine的方法命名也可以猜到这是一个内联优化。它提供了一种(非常有限,但有时很方便)形式的“线程安全”:它确保数组的长度(就像HashMap的getNode方法中的tab数组)在方法执行时不会改变。为什么他没有提到我们更关心的testRepeated方法呢?他在回答中也提到了这一点:他对之前的陈述做了一个小的更正/澄清。你是什??么意思,直接翻译是一个小的更正或澄清。话说回来,我刚才说的有点满,现在打脸了,听我狡辩。他之前说了什么?他说:你不用看,这三种方法生成的最终汇编一定是一模一样的。但是现在他说的是:不能产生相同的机器码不能产生相同的程序集最后,这位老哥还补充了这种写法除了字节码级优化之外的另一个好处:一旦对n赋值后,n不会更改getNode方法。如果直接使用数组的长度,假设其他方法也同时操作了HashMap,那么在getNode方法中就有可能感知到这种变化。这个小知识点相信大家都知道,很直观,就不多说了。然而,看到这里,我们似乎还是没有找到问题的答案。然后继续往下挖。继续挖掘和向下挖掘的线索其实之前已经出现过:通过这个链接,我们可以来到这个地方:https://stackoverflow.com/que...看看我陷害的代码,你就会了发现这里提出的问题其实和之前一样。我为什么要提起它并再次谈论它?由于它只是一个跳板,我想在下面引出一个答案:这个答案说有两件事引起了我的注意。首先是答案本身,他说:这是该课程的作者DougLea喜欢使用的极端优化。这是一个您可以访问的超链接,它将很好地回答您的问题。这里提到的超链接有很多故事:http://mail.openjdk.java.net/...不过在讲这个故事之前,我想先说说这个答案下面的评论,也就是我盒装的部分。这条评论观点鲜明:“极端”需要强调!这不是每个人都应该效仿的通用的好写作方式。靠着自己在stackoverflow这么多年的自觉,这里是藏龙卧虎。一般来说,说话这么自信的都是大老板。于是点开他的名字,看了一眼,原来是个老大:这哥们来自Google,参与过很多项目,包括我们很熟悉的Guava,也不是普通的开发者,而是首席开发人员。还为Google的Java风格指南做出了贡献。所以他说的话还是很有份量的,你得听听。然后,我们转到那个故事超链接。在这个超链接里有一个叫UlfZibis的小伙伴提的问题:问题中提到Ulf的同学:在String类中,我经常看到成员变量被复制到局部变量。我在想,我为什么要做这样的缓存,我是不是对JVM这么不信任,谁能帮我解答下?Ulf的问题和我们文章中的问题是一样的,他的问题是2010年提出的,应该是我能找到最早的关于这个问题的地方。所以你必须记住,下面电子邮件中的对话是12年前的。在对话中,对于这个问题,有一个比较官方的回答:回答他问题的人是MartinBuchholz,JDK的开发者之一,DougLea的同事,在书中也出现了《Java并发编程实战》:.png)SUN的JDK并发高手,怕不怕就问。他说:这是一种由DougLea开创的编码风格。这是一个极端的优化,可能是不必要的。您可以期望JIT提供相同的优化。不过,对于这种非常底层的代码,能写出更接近机器码的代码,也是一件很不错的事情。关于这个问题,这些人来回讨论了好几轮:在邮件的底部,有这样一个链接,可以点击查看他们讨论的内容:主要看这封名为Osvaldovs.Martin的邮件:https://mail.openjdk.java.net...Osvaldo兄写了这么多内容,主要是想喷Martin的话:这是一个极端的优化,可能没有必要。您可以期望JIT提供相同的优化。他说他做过实验得出的结论是,这个优化对于运行在Server模式下的Hotspot没有什么区别,但是对于运行在Client模式下的Hotspot来说非常重要。在他的测试用例中,这种方法使性能提高了6%。然后他说,他现在写的代码,包括以后几年写的代码,都应该跑在HotspotrunninginClientmode里。所以请不要乱用Doug故意写的这段优化代码,谢谢大家。同时,他还提到了JavaME、JavaFXMobile&TV,所以我不得不再次提醒你:这段对话发生在12年前,他提到的技术在我眼里已经逝去。经过。哦,我也不是没见过,毕竟我初中的时候玩过JavaME写的游戏。即使在Osvaldo大哥的话更加激烈的时候,Martin还是做出了积极的回应:Martin说谢谢你的测试,我也把这种编码风格融入到我的代码中,但我一直在纠结的事情是要不要推动人们去做那个也。因为我觉得我们可以在JIT层面优化这个东西。接下来是最后一封电子邮件,来自一位名叫DavidHolmes的弟兄。巧的是,这个老人的名字也可以在《Java并发编程实战》一书中找到。他是作者,我介绍他是为了表达他的话也很重要:因为他的邮件是这个问题的最终答案。以我自己的理解,我用自己的话为大家翻译全文。他是这样说的:我已经将这个问题转发给hotspot-compiler-dev,请他们跟进。我知道当时Doug之所以这样写是因为当时的编译器没有做相应的优化,所以他这样写是为了帮助编译器优化一波。不过我觉得这个问题早就解决了,至少在C2阶段是这样。如果C1没有解决这个问题,我觉得还是要解决的。最后,对于这种写法,我的建议是:在Java层面,代码不要这样写。不需要在Java级别以这种方式编码。至此,问题就很清楚了。首先得出结论,不推荐这样写。其次,Doug当年写的确实是一个优化,但是随着编译器的发展,这个优化下沉到了编译器层面,它帮我们做了。最后,如果你对上面说的C1和C2不理解,我换个说法。C1其实就是ClientCompiler,即客户端编译器,其特点是编译时间较短但输出代码优化程度较低。C2其实就是ServerCompiler,即服务端编译器,特点是编译时间长但输出代码优化质量更高。前面的Osvaldo说他主要使用客户端编译器,也就是C1。这也是后来DavidHolmes口口声声说C2优化了这个问题的原因。如果C1没有,可以跟进。呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜呜没关系,这个东西面试一般不会考的。经常提到JVM帮我们做了很多“激进”的优化来提升性能,比如内联、快慢路径分析、窥视孔优化,这些都是C2做的。另外,在JDK10的时候,推出了Graal编译器来替代C2。至于为什么要更换C2,嗯,原因之一可以看这个链接...http://icyfenix.cn/tricks/202...C2的历史已经很悠久了,可以追溯到到CliffClick攻读博士学位时。这个用C++编写的编译器的工作,虽然仍然有效,但已经变得如此复杂,以至于连CliffClick自己都不愿意继续维护它。可以看到前面提到的C1和C1的特性正好是互补的。所以为了在程序启动、响应速度和程序运行效率之间找到一个平衡点,在JDK6之后,JVM支持了一种叫做分层编译的模式。这也是为什么大家说:“Java代码会跑得越来越快,Java代码需要预热”的根本原因和理论支撑。在这里,我引用《深入理解Java虚拟机HotSpot》书中7.2.1节【分层编译】的内容,让大家简单了解一下这是什么东西。首先,我们可以使用-XX:+TieredCompilation启用分层编译,它引入了四个额外的编译层。0级:解释执行。级别1:打开所有优化的C1编译(没有分析)。分析就是分析。级别2:C1编译,带有调用计数和边沿计数的分析信息(受限分析)。级别3:具有所有分析信息的C1编译(完整分析)。级别4:C2编译。普通层次编译级别转换路径如下图所示:0→3→4:普通级别转换。使用C1进行完全编译,如果后续方法执行得足够频繁,则转到第4级。0→2→3→4:C2编译器忙。先在level2快速编译,收集到足够的profiling信息后切换到level3,最后在C2不忙的时候切换到level4。0→3→1/0→2→1:Level2/3转换为Level1,因为编译后方法不那么重要了。如果C2编译失败,也会进入级别1。0→(3→2)→4:C1编译器忙,编译任务要么等待C1,要么快速转移到level2,再从level2转移到level4。如果之前不知道分层编译,没关系,现在有这么一个概念就可以了。再说一遍,你不会通过面试的,别担心。好吧,恭喜你来了。回顾全文,你学到了什么?是的,除了一个没用的知识点,什么都没学到。