首页
社区
课程
招聘
[翻译][注意]JVM 虚拟机创建对象的过程分析(下)
发表于: 2019-11-24 19:48 6076

[翻译][注意]JVM 虚拟机创建对象的过程分析(下)

2019-11-24 19:48
6076

上篇见这

从TLAB的角度来思考这个问题。为了不跑偏,我们深入挖掘源码,看看TLAB在快、慢和非常慢三种场景下的内存分配。你需要看看operator new是怎么编译的,为了少死点脑细胞,我们来看看客户端的编译器代码(C1):它比服务端的更简单更容易理解,麻雀虽小,五脏俱全,因为new在java中非常常见,所以它里面有非常多的优化。

我们对两个方法感兴趣:

C1_MacroAssembler :: allocate_object。包含了TLAB中对象分配和初始化相关的代码

Runtime1 :: generate_code_for。当分配操作不能在短时间内完成时调用。

JVM如何判断对象是否总是能在短时间内被创建值得我们关注,通过寻找方法的调用处(find usages),我们找到了instanceKlass.hpp)中的这个注释:

显而易见,大对象(超过128kB)和具有finalize方法的类在JVM中分配会非常耗时(猜一猜 - 抽象类的分配代码在哪?)。让我们记住这一点,然后返回去看看内存分配进程。

tlab_allocate会尝试快速分配对象,它的代码我们刚好在PrintAssembly中看到过了。当它结束之后,我们的内存分配也就结束了,之后转入对象的初始化操作。

tlab_refill 会尝试分配一个新的TLAB。我们进行了一个有趣的测试,这一方法会确定是分配一个新的TLAB(抛弃旧的)还是保留旧的TLAB,直接在eden区分配一个对象

tlab_refill_waste_limit 负责控制TLABs的大小,以免我们在为对象分配内存时造成过多的浪费。默认情况下,它的值是当前TLAB大小的1.5%(当然,也可以通过设置TLABRefillWasteFraction的值来确定这个百分比,TLABRefillWasteFraction的默认值是64,TLAB的大小除以这一参数值即为浪费的百分比)。在每次进行慢分配的时候,这一限制都会生效以避免分配失败。它的值在每次GC循环之后会被重置。

eden_allocate- 会尝试在eden区分配内存(对象或是TLAB)。这一部分与TLAB中的分配非常相似:我们会检查是否还有空闲空间,如果有,使用原子操作(cmpxchg lock clause)获取内存,如果没有,则进行慢分配。eden区内的分配不是wait-free:如果两个线程同时在eden区进行内存分配,有可能其中一个的分配并不起效,不得不重新进行分配。

如果你不能在eden区分配内存,可以尝试使用InstanceKlass :: allocate_instance方法。在调用这一方法之前,需要做大量的辅助工作。为GC设置特殊的结构体,创建必要的栈桢以适应调用约定),因此这一操作并不快。它里面有大量的代码,如果没有一份简单的描述的话,看起来会非常吃力,所以我不会详细讲解里面的代码,只会给出一个大概的框架:

首先,JVM会尝试通过指定的接口为当前的垃圾回收分配内存。在调用发生的时候也是一样的,正如上面所说的:首先尝试在TLAB中进行分配,然后尝试在堆中分配TLAB并创建对象。如果失败了,就会调用垃圾回收。还有些其他地方涉及到GC overhead limt exceed这一error,包括各种与GC有关的通知,日志和与内存分配无关的检查。

如果垃圾回收之后空间还是不够,就会尝试在老年代进行分配(这取决于选择的GC算法),在失败的时候,再进行一次垃圾回收,并尝试创建对象,如果不起作用,就会抛出OutOfMemoryError

当对象被成功创建后,会检查该对象是否有finzlize方法,如果是的话,就会注册它的Finilizer方法(这下你就明白了为什么这个类在标准库中,但从来没有显式调用过)。这个方法本身在很久以前就写好了:Finilizer对象创建为一个全局变量,并添加到链表中(这意味着它后面会被析构并回收)。这刚好证明了JVM的无条件调用以及这条忠告:“不要使用finalize方法,尽管你非常想要用”

最后,我们几乎已经知道了关于分配的所有事情:对象被快速分配,TLAB快速填充,对象有时候马上被分配到eden区,有时候他们会触发JVM中的一些耗时的调用。

我把内存分配的时候相关的所有数据记录下来(慢分配,refilles的平均数,分配线程的数量,内部碎片造成的损失),那么用这些信息我们可以干什么呢?
图片描述

上述是一些性能相关的数据,最终存储到 hsperfdata文件中,你可以使用jcmd或sun.jvmstat.monitor API编程来查看它。
没有其他方法能获取到这些信息,但如果你用的是Oracle JDK,使用JFR,你就可以在栈追踪区域看到它。(这一private API在OpenJDK中不可用)。

这些信息重要吗?在大多数情况下,它不重要,但在某些情况下它很重要。比如,Twitter JVM团队写了一份报告,在慢分配中,调整必要的参数后,能够降低几个百分点的时间开销。

当我们查看代码的时候,经常能看到一些预载入的额外检查,这些我通常都手动忽略了。

预载入是一种提高性能的技术,它把一些我们马上要(但不是现在)读取的数据加载进处理器缓存中。在硬件层面,当处理器认为下一部分数据是顺序排列的,就会载入它。在软件层面,(编译器,虚拟机的)程序员生成一些特殊的指令给处理器一些提示,告诉它把某个地址的内存放进缓存中效果更好。

在hotspot中,预载入是一个C2专有的优化,所以在C1的代码中我们没看到它。优化的具体过程是这样的:当在TLAB中分配内存时,生成一条指令,用于要分配的内存位置后面的数据载入缓存。平均而言,Java应用会进行很多很多次分配,所以,把后续的数据预告载入缓存是个不错的主意:下一次创建对象的时候,不需要再等待,因为它已经被加载进缓存中了。
图片描述

根据AllocatePrefetchStyle选项的不同,预载入有几种不同的模式:你可以在每次内存分配之后进行一次预载入,或是进行多次预载入。此外,可以通过AllocatePrefetchStyle改变预载入时的指令:你可以只把数据加载进L1缓存中(例如,当你加载完之后马上就不再使用的数据),只加载进L3中,或是一次性加载加入:这些可选项取决于处理器的架构,对应的值和指令可以在这个.ad文件中找到。

在生产中,我们不建议你去碰这些值,除非你是JVM工程师,想要在SPECjbb-benchmark中拿个比较好的名次,或是写一些性能非常高的Java代码,你进行的所有修改都经过了再三地确认(那你可能不需要看这篇文章了,因为你几乎对这个已经有深入的了解了)

在内存分配的时候,会清除所有东西,只会留下一些,在调用对象构造函数之前,需要初始化的数据。在C1-编译器中也可以看到这部分内容,但是在ARM中,代码更简洁,也有一些更有趣的东西。

请求的方法是:
C1_MacroAssembler :: initialize_object
这个方法并不复杂

首先,设置对象的header。header由两部分组成 - mark word,其中包括关于锁,身份hash码(或偏向锁),垃圾回收以及指向对象类的类指针,它的同名native类表示它在metaspace中,使用它可以获取到java.lang.class。

图片描述

这一指向类的指针通常是32位的,并不是64位。它的最小可能大小12字节(强制对齐会使它增长在16字节)

如果ZeroTLAB选项没有启用,所有的内存会被清空。默认情况下它是关闭的。将一大片内存区域清0也会导致缓存清空,更有效的释放一片内存的方法是,使用一小段马上会被覆写的内存来进行释放。此外,聪明的C2编译器只会做必要的工作,也不会清空内存,而是在后面紧跟一个设计好的参数。这是另一种做法。

最后,设置StoreStore barrier(关于barrier的更多细节,可以看这篇文章))。barrier的作用是阻止处理器执行后续搜集,直到当前指令执行完。

对于一些设置为public的对象,这是必要的:如果代码中有错误,而这一对象被设置为public,你会想要看到它内部的fields(语言特性允许你这样做)的默认值或是开发者预设好的值,而不是一个随机的值,虚拟机希望获取到一个正确的header。x86架构的内存模型更加强大,它不需要这一指令,所以我们看到的是ARM的实现。

注意上述代码可能有bug,我只是用这些代码来证明文章的正确性,并没有实际运行过

到目前为止,所有的一切看起来都棒极了:我们阅读了源代码,找到了一些有意思的东西,但是对编译器实际做的情况我们并不清楚,也许我们的理解都是错误的。再回头看看PrintAssembly中new Long(1023)的相关代码:

棒极了,看起来确实和我们预期的一样。

总而言之,创建一个新的对象的过程如下所示:
首先尝试在TLAB中分配对象
如果TLAB中空间不够,要么从eden区中再分配一块TLAB,要么直接在eden区创建该对象,整个过程是一个原子操作。
如果eden区空间也不够,就会进行垃圾回收。
如果垃圾回收之后,空间还是不够,则会尝试在老年代进行分配。
如果还是不够,则会抛出OOM(out of memory)异常
对象创建成功后,设置对象的header,并调用它的构造方法。
至此,理论部分就结束了,接下来开始实战:是否上述操作能够提高性能,是否需要预载入,是否TLAB的大小可以通过一些值进行控制。

现在我们已经知道了有多少个对象会被创建,哪一个参数能够控制这一过程,是时候在实战在校验这些理论的正确性了。首先让我们写一个简单的benchmark,它仅仅在几个线程中创建java.lang.Object,以及修改一些JVM参数。
这一实验运行在 Java 1.8.0_121,Debian 3.16,Intel Xeon X5675的实验环境中。横坐标是线程的数量,纵坐标是每微秒进行的空间分配的次数。

图片描述

结果正如预期的一样:

默认情况下,分配次数基本上和线程数成正比,这和我们预期的一样。随着线程数的增长,斜率稍微降低了一点,这很正常:如果在两次分配之间做一些操作(比如,使用Blakehole #consume CPU),那么分配的碎片会减少,比率会重新回到线性。
关闭预载入使得所有的分配操作都有一些变慢。在这次benchmark中,我们仅仅使用分配来增加JVM的工作负荷,在实际中程序的实现是多种多样的,所以我们不能使用实际程序来总结出一个特定的结论。最后,虽说没有特定的方法,但你仍然可以关掉预载入以验证这一操作对你的程序的影响。
在TLAB分配停止之后,情况变得很糟糕:每次调用JIT->JVM的开销基本上都达到了原来的两倍半,随着线程数的增长,对单一指针的竞争也会增长,结果也不具有普适性。

最后,我们来看看对析构的影响,比较eden区对象的析构的性能:


[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

最后于 2020-1-28 22:15 被kanxue编辑 ,原因:
收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//