乐趣区

关于java:从源码里的一个注释我追溯到了12年前有点意思

你好呀,我是歪歪。

那天我正在用键盘疯狂的输入:

忽然微信弹出一个音讯,是一个读者发给我的。

我点开一看:

啊,这相熟的滋味,一看就是 HashMap,八股文梦开始的中央啊。

然而他问出的问题,仿佛又不是一个属于 HashMap 的八股文:

为什么这里要把 table 变量赋值给 tab 呢?

table 大家都晓得,是 HashMap 的一个成员变量,往 map 外面放的数据就存储在这个 table 外面的:

在 putVal 办法外面,先把 table 赋值给了 tab 这个局部变量,后续在办法外面都是操作的这个局部变量了。

其实,不只是 putVal 办法,在 HashMap 的源码外面,“tab= table”这样的写发多达 14 个,比方 getNode 外面也是这样的用法:

咱们先思考一下,如果不必 tab 这个局部变量,间接操作 table,会不会有问题?

从代码逻辑和性能上来看,是不会有任何故障的。

如果是其他人这样写,我会感觉可能是他的编程习惯,没啥深意,反正又不是不能用。

然而这玩意可是 Doug Lea 写的,隐约间感觉必然是有深意在外面的。

所以为什么要这样写呢?

巧了,我感觉我刚好晓得答案是什么。

因为我在其余中央也看到过这种把成员变量赋值给局部变量的写法,而且在正文外面,备注了本人为什么这么写。

而这个中央,就是 Java 的 String 类:

比方 String 类的 trim 办法,在这个办法外面就把 String 的 value 赋给了 val 这个局部变量。

而后旁边给了一个十分简短的正文:

avoid getfield opcode

本文的故事,就从一行正文开始,一路追溯到 2010 年,我终于抽丝剥茧找到了问题的答案。

一行正文,就是说要防止应用 getfield 字节码。

尽管我不懂是啥意思,然而至多我拿到了几个关键词,算是找到了一个“线头”,接下来的事件就很简略了,顺着这个线头往下缕就完事了。

而且直觉上通知我这又是一个属于字节码层面的极其的优化,缕到最初肯定是一个骚操作。

那么我就先给你说论断了:这个代码的确是 Doug Lea 写的,在当年的确是一种优化伎俩,然而时代变了,放到当初,的确没有卵用。

答案藏在字节码

既然这里提到了字节码的操作,那么接下来的思路就是比照一下这两种不同写法别离的字节码是长啥样的不就分明了吗?

比方我先来一段这样的测试代码:

public class MainTest {private final char[] CHARS = new char[5];

    public void test() {System.out.println(CHARS[0]);
        System.out.println(CHARS[1]);
        System.out.println(CHARS[2]);
    }

    public static void main(String[] args) {MainTest mainTest = new MainTest();
        mainTest.test();}
}

下面代码中的 test 办法,编译成字节码之后,是这样的:

能够看到,三次输入,对应着三次这样的字节码:

在网上轻易找个 JVM 字节码指令表,就能够晓得这几个字节码别离在干啥事儿:

  • getstatic:获取指定类的动态域, 并将其压入栈顶
  • aload_0:将第一个援用类型本地变量推送至栈顶
  • getfield:获取指定类的实例域, 并将其值压入栈顶
  • iconst_0:将 int 型 0 推送至栈顶
  • caload:将 char 型数组指定索引的值推送至栈顶
  • invokevirtual:调用实例办法

如果,我把测试程序依照后面提到的写法批改一下,并从新生成字节码文件,就是这样的:

能够看到,getfield 这个字节码只呈现了一次。

从三次到一次,这就是正文中写的“avoid getfield opcode”的具体意思。

的确是缩小了生成的字节码,实践上这就是一种极其的字节码层面的优化。

具体到 getfield 这个命令来说,它干的事儿就是获取指定对象的成员变量,而后把这个成员变量的值、或者援用放入操作数栈顶。

更具体的说,getfield 这个命令就是在拜访咱们 MainTest 类中的 CHARS 变量。

往底层一点的说就是如果没有局部变量来承接一下,每次通过 getfield 办法都要拜访堆外面的数据。

而让一个局部变量来承接一下,只须要第一次获取一次,之后都把这个堆上的数据,“缓存”到局部变量表外面,也就是搞到栈外面去。之后每次只须要调用 aload_<n> 字节码,把这个局部变量加载到操作栈下来就完事。

aload_<n> 的操作,比起 getfield 来说,是一个更加轻量级的操作。

这一点,从 JVM 文档中对于这两个指令的形容的长度也能看进去:

https://docs.oracle.com/javas…

就不细说了,看到这里你应该明确:把成员变量赋值到局部变量之后再进行操作,的确是一种优化伎俩,能够达到“avoid getfield opcode”的目标。

看到这里你的心开始有点蠢蠢欲动了,感觉这个代码很棒啊,我是不是也能够搞一波呢?

不要焦急,还有更棒的,我还没给你讲完呢。

stackoverflow

在 Java 外面,咱们其实能够看到很多中央都有这样的写法,比方咱们后面提到的 HashMap 和 String,你认真看 J.U.C 包外面的源码,很多都是这样写的。

然而,也有很多代码并没有这样写。

比方在 stackoverflow 就有这样的一个发问:

发问的哥们说为什么 BigInteger 没有采纳 String 的 trim 办法“avoid getfield opcode”这样的写法呢?

上面的答复是这样说的:

在 JVM 中,String 是一个十分重要的类,这种渺小的优化可能会进步一点启动速度。另一方面,BigInteger 对于 JVM 的启动并不重要。

所以,如果你看了这篇文章,本人也想在代码外面用这样的“棒”写法,三思。

醒醒吧,你才几个流量呀,值得你优化到这个水平?

而且,我就通知你,后面字节码层面是有优化不假,咱们都眼见为实了。

然而这个老哥揭示了我:

他提到了 JIT,是这样说的:这些渺小的优化通常是不必要的,这只是缩小了办法的字节码大小,一旦代码变得足够热而被 JIT 优化,它并不真正影响最终生成的汇编。

于是,我在 stackoverflow 上一顿乱翻,终于在万千线索中,找出了我感觉最有价值的一个。

这个问题,就和文章结尾的读者问我的能够说截然不同了:

https://stackoverflow.com/que…

这个哥们说:在 jdk 源码中,更具体地说,是在汇合框架中,有一个编码的小嗜好,就是在表达式中读取变量之前,先将其赋值到一个局部变量中。这只是一个简略的小嗜好吗,还是外面藏着一下我没有留神到的更重要的货色?

随后,还有人帮他补充了几句:

这代码是 Doug Lea 写的,小 Lea 子这人吧,常常搞一些出人意料的代码和优化。他也因为这些“莫名其妙”的代码闻名,习惯就好了。

而后这个问题上面有个答复是这样说的:

Doug Lea 是汇合框架和并发包的次要作者之一,他编码的时候偏向于进行一些优化。然而这些优化这可能会违反直觉,让普通人感到困惑。

毕竟人家是在大气层。

接着他给出了一段代码,外面有三个办法,来验证了不同的写法生成的不同的字节码:

三个办法别离如下:

对应的字节码我就不贴了,间接说论断:

The testSeparate method uses 41 instructions
The testInlined method indeed is a tad smaller, with 39 instructions
Finally, the testRepeated method uses a whopping 63 instructions

同样的性能,然而最初一种间接应用成员变量的写法生成的字节码是最多的。

所以他给出了和我后面一样的论断:

这种写法的确能够节俭几个字节的字节码,这可能就是应用这种形式的起因。

然而 …

次要啊,他要开始 but 了:

然而,在不论是哪个办法,在被 JIT 优化之后,产生的机器代码将与原始字节码“无关”。

能够十分确定的是:三个版本的代码最终都会编译成雷同的机器码(汇编)。

因而,他的倡议是:不要应用这种格调,只需编写易于浏览和保护的“愚昧”代码。你会晓得什么时候轮到你应用这些“优化”。

能够看到他在“write dumb code”上附了一个超链接,我挺倡议你去读一读的:

https://www.oracle.com/techni…

在这外面,你能够看到《Java Concurrency in Practice》的作者 Brian Goetz:

他对于“dumb code”这个货色的解读:

他说:通常,在 Java 应用程序中编写疾速代码的办法是编写“dumb code”——简略、洁净,并遵循最显著的面向对象准则的代码。

很显著,tab = table 这种写法,并不是“dumb code”。

好了,说回这个问题。这个老哥接着做了进一步的测试,测试后果是这样的:

他比照了 testSeparate 和 TestInLine 办法通过 JIT 优化之后的汇编,这两个办法的汇编是雷同的。

然而,你要搞清楚的是这个小哥在这里说的是 testSeparate 和 testInLine 办法,这两个办法都是采纳了局部变量的形式:

只是 testSeparate 的可读性比 testInLine 高了很多。

而 testInLine 的写法,就是 HashMap 的写法。

所以,他才说:咱们程序员能够只专一于编写可读性更强的代码,而不是搞这些“骚”操作。JIT 会帮咱们做好这些货色。

从 testInLine 的办法命名上来看,也能够猜到,这就是个内联优化。

它提供了一种(十分无限,但有时很不便)“线程平安”的模式:它确保数组的长度(如 HashMap 的 getNode 办法中的 tab 数组)在办法执行时不会扭转。

他为什么没有提到咱们更关怀的 testRepeated 办法呢?

他也在答复外面提到这一点:

他对之前的一个说法进行了 a minor correction/clarification。

啥意思,间接翻译过去就是进行一个小的修改或者廓清。用我的话说就是,后面话说的有点满,当初打脸了,你听我诡辩一下。

后面他说的是什么?

他说:这都不必看,这三个办法最终生成的汇编必定是截然不同的。

然而当初他说的是:

it can not result in the same machine code
它不能产生雷同的汇编

最初,这个老哥还补充了这个写法除了字节码层面优化之外的另一个益处:

一旦在这里对 n 进行了赋值,在 getNode 这个办法中 n 是不会变的。如果间接应用数组的长度,假如其余办法也同时操作了 HashMap,在 getNode 办法中是有可能感知到这个变动的。

这个小知识点我置信大家都晓得,很直观,不多说了。

然而,看到这里,咱们如同还是没找到问题的答案。

那就接着往下挖吧。

持续挖

持续往下挖的线索,其实曾经在后面呈现过了:

通过这个链接,咱们能够来到这个中央:

https://stackoverflow.com/que…

瞟一眼我框起来的代码,你会发现这里抛出的问题其实又是和后面是一样。

我为什么又要把它拿出来说一次呢?

因为它只是一个跳板而已,我想引出这上面的一个答复:

这个答复说外面有两个吸引到我留神的中央。

第一个就是这个答复自身,他说:这是该类的作者 Doug Lea 喜爱应用的一种极其优化。这里有个超链接,你能够去看看,能很好地答复你的问题。

这外面提到的这个超链接,很有故事:

http://mail.openjdk.java.net/…

然而在说这个故事之前,我想先说说这个答复上面的评论,也就是我框起来的局部。

这个评论观点鲜明的说:须要着重强调“极其”!这不是每个人都应该效仿的、通用的、良好的写法。

凭借我在 stackoverflow 混了这么几年的盲目,这里藏龙卧虎,一般来说 谈话底气这么足的,都是大佬。

于是我点了他的名字,去看了一眼,果然是大佬:

这哥们是谷歌的,参加了很多我的项目,其中就有咱们十分相熟的 Guava,而且不是一般开发者,而是 lead developer。同时也参加了 Google 的 Java 格调指南编写。

所以他说的话还是很有重量的,得听。

而后,咱们去到那个很有故事的超链接。

这个超链接外面是一个叫做 Ulf Zibis 的哥们提出的问题:

Ulf 同学的发问外面提到说:在 String 类中,我常常看到成员变量被复制到局部变量。我在想,为什么要做这样的缓存呢,就这么不信赖 JVM 吗,有没有人能帮我解答一下?

Ulf 同学的问题和咱们文章中的问题也是一样的,而他这个问题提出的工夫是 2010 年,应该是我能找到的对于这个问题最早呈现的中央。

所以你要记住,上面的这些邮件中的对话,曾经是距今 12 年前的对话了。

在对话中,针对这个问题,有比拟官网的答复:

答复他问题这个人叫做 Martin Buchholz,也是 JDK 的开发者之一,Doug Lea 的共事,他在《Java 并发编程实战》一书外面也呈现过:

.png)

来自 SUN 公司的 JDK 并发巨匠,就问你怕不怕。

他说:这是一种由 Doug Lea 发动的编码格调。这是一种极其的优化,可能没有必要。你能够期待 JIT 做出同样的优化。然而,对于这类十分底层的代码来说,写出的代码更靠近于机器码也是一件很 nice 的事件。

对于这个问题,这几个人有来有回的探讨了几个回合:

在邮件的下方,有这样的链接能够点击,能够看到他们探讨的内容:

次要再看看这个叫做 Osvaldo 对线 Martin 的邮件:

https://mail.openjdk.java.net…

Osvaldo 老哥写了这么多内容,次要是想喷 Martin 的这句话:这是一种极其的优化,可能没有必要。你能够期待 JIT 做出同样的优化。

他说他做了试验,得出的论断是这个优化对以 Server 模式运行的 Hotspot 来说没有什么区别,但对于 Client 模式运行的 Hotspot 来说却十分重要。在他的测试案例中,这种写法带来了 6% 的性能晋升。

而后他说他当初包含将来几年写的代码应该都会运行在以 Client 模式运行的 Hotspot 中。所以请不要乱动 Doug 特意写的这种优化代码,我谢谢你全家。

同时他还提到了 JavaME、JavaFX Mobile&TV,让我不得不再次揭示你:这段对话产生在 12 年前,他提到的这些技术,在我的眼里曾经是过眼云烟了,只听过,没见过。

哦,也不能算没见过,毕竟当年读初中的时候还玩过 JavaME 写的游戏。

就在 Osvaldo 老哥言辞比拟强烈的状况下,Martin 还是做出了踊跃的回应:

Martin 说谢谢你的测试,我也曾经把这种编码格调交融到我的代码外面了,然而我始终在纠结的事件是是否也要推动大家这样去做。因为我感觉咱们能够在 JIT 层面优化这个事件。

接下来,最初一封邮件,来自一位叫做 David Holmes 的老哥。

巧了,这位老哥的名字在《Java 并发编程实战》一书外面,也能够找到。

人家就是作者,我介绍他的意思就是想表白他的话也是很有重量的:

因为他的这一封邮件,算是给这个问题做了一个最终的答复。

我带着本人的了解,用我话来给你全文翻译一下,他是这样说的:

我曾经把这个问题转给了 hotspot-compiler-dev,让他们来跟进一下。

我晓得过后 Doug 这样写的起因是因为过后的编译器并没有相应的优化,所以他这样写了一下,帮忙编译器进行优化了一波。然而,我认为这个问题至多在 C2 阶段早就曾经解决了。如果是 C1 没有解决这个问题的话,我感觉是须要解决一下的。

最初针对这种写法,我的倡议是:在 Java 层面上不应该依照这样的形式去敲代码。

There should not be a need to code this way at the Java-level.

至此,问题就梳理的很分明了。

首先论断是不倡议应用这样的写法。

其次,Doug 当年这样写的确是一种优化,然而随着编译器的倒退,这种优化下沉到编译器层面了,它帮咱们做了。

最初,如果你不明确后面提到的 C1,C2 的话,那我换个说法。

C1 其实就是 Client Compiler,即客户端编译器,特点是编译工夫较短但输入代码优化水平较低。

C2 其实就是 Server Compiler,即服务端编译器,特点是编译耗时长但输入代码优化品质也更高。

后面那个 Osvaldo 说他次要是用客户端编译器,也就是 C1。所以前面的 David Holmes 才始终在说 C2 是优化了这个问题的,C1 如果没有的话能够跟进一下,巴拉巴拉巴拉的 …

对于 C2 的话,简略提一下,记得住就记,记不住也没关系,这玩意个别面试也不考。

大家经常提到的 JVM 帮咱们做的很多“激进”的为了晋升性能的优化,比方内联、快慢速路径分析、窥孔优化,都是 C2 搞的事件。

另外在 JDK 10 的时候呢,又推出了 Graal 编译器,其目标是为了代替 C2。

至于为什么要替换 C2,额,起因之一你能够看这个链接 …

http://icyfenix.cn/tricks/202…

C2 的历史曾经十分长了,能够追溯到 Cliff Click 大神读博士期间的作品,这个由 C++ 写成的编译器只管目前仍然成果拔群,但曾经简单到连 Cliff Click 自己都不违心持续保护的水平。

你看后面我说的 C1、C1 的特点,刚好是互补的。

所以为了在程序启动、响应速度和程序运行效率之间找到一个平衡点,在 JDK 6 之后,JVM 又反对了一种叫做分层编译的模式。

也是为什么大家会说:“Java 代码运行起来会越来越快、Java 代码须要预热”的根本原因和实践撑持。

在这里,我援用《深刻了解 Java 虚拟机 HotSpot》一书中 7.2.1 大节 [分层编译] 的内容,让大家简略理解一下这是个啥玩意。

首先,咱们能够应用 -XX:+TieredCompilation 开启分层编译,它额定引入了四个编译层级。

  • 第 0 级:解释执行。
  • 第 1 级:C1 编译,开启所有优化(不带 Profiling)。Profiling 即分析。
  • 第 2 级:C1 编译,带调用计数和回边计数的 Profiling 信息(受限 Profiling).
  • 第 3 级:C1 编译,带所有 Profiling 信息(齐全 Profiling).
  • 第 4 级:C2 编译。

常见的分层编译层级转换门路如下图所示:

  • 0→3→4:常见层级转换。用 C1 齐全编译,如果后续办法执行足够频繁再转入 4 级。
  • 0→2→3→4:C2 编译器忙碌。先以 2 级疾速编译,等收集到足够的 Profiling 信息后再转为 3 级,最终当 C2 不再忙碌时再转到 4 级。
  • 0→3→1/0→2→1:2/ 3 级编译后因为办法不太重要转为 1 级。如果 C2 无奈编译也会转到 1 级。
  • 0→(3→2)→4:C1 编译器忙碌,编译工作既能够期待 C1 也能够疾速转到 2 级,而后由 2 级转向 4 级。

如果你之前不晓得分层编译这回事,没关系,当初有这样的一个概念就行了。

再说一次,面试不会考的,释怀。

好了,祝贺你看到这里了。回忆全文,你学到了什么货色呢?

是的,除了一个没啥卵用的知识点外,什么都没有学到。

本文首发于公众号 why 技术,转载请注明出处和链接。

退出移动版