JVM 虚拟机创建对象的过程分析(一)

gejigeji Web安全 2019年10月16日发布
Favorite收藏

导语:本文我们将详细介绍JVM如何创建新对象,并分析在编写新的Object()时会发生什么?

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

 1.png

本文我们将详细介绍JVM如何创建新对象,并分析在编写新的Object()时会发生什么?

在讲解过程中,我们会随时向你分析合适对象会分配使用线程本地分配缓冲区(TLAB),TLAB专门分配给每个线程的内存区域,由于缺少同步,因此创建对象的速度非常快。

但是如何正确选择TLAB的大小?如果你需要分配TLAB大小的10%,但只有9%是免费的,该怎么办?是否可以在TLAB之外分配对象?何时将分配的内存设置为零?

正是为了解决以上问题,我才决定写这么一篇文章,来和大家一起分享。

在阅读之前,最好记住一些垃圾收集器的工作方式,这对你理解下文很有用。Java虚拟机主要分为五大模块:类装载器子系统、运行时数据区、执行引擎、本地方法接口和垃圾收集模块。其中垃圾收集模块在Java虚拟机规范中并没有要求Java虚拟机垃圾收集,但是在没有发明无限的内存之前,大多数JVM实现都是有垃圾收集的。

创建新对象需要哪些步骤?

首先,有必要找到一个正确大小的未被占用的内存区域,然后需要初始化该对象:零内存,初始化一些内部结构(调用getClass()和与之同步时使用的对象信息),最后你需要调用构造函数。

本文的结构是这样的:首先,我们将尝试介绍一些理论上将要发生的事情;然后我们将以某种方式进入JVM的内部,并观察其实际情况;最后,我们将编写一些基准测试来验证我们的预测。

注意:有些部分的内容是经过刻意简化的,不过这只是为了讲解方便,并没有失去实质性内容。本文所说的垃圾收集,指的是任何压缩收集器。

TLAB 101

第一部分是为我们的对象分配空闲内存,在一般情况下,有效分配内存是一项艰巨的任务,充满痛苦和煎熬。例如,为大小为2的倍数的链表创建链表,它们进行搜索,如果需要,还会删除内存区域,并将其从一个列表移动到另一个列表(又称为buddy分配器)。buddy内存分配算法技术是一种内存分配算法,将内存划分分区,试图以适当地满足内存请求。buddy内存分配算法是比较容易实行。它支持有限,高效的分裂和内存块的合并。目的是为了解决内存的外碎片。幸运的是,Java设备中有一个垃圾收集器,它本身就可以进行复杂的工作。

由于JVM中的内存发布了GC,因此分配器只需要知道在哪里可以找到此空闲内存,这样,就可以控制对指向该最多空闲内存的一个指针的访问。也就是说,分配必须非常简单,并且由小马和彩虹(pony and rainbow)组成:你需要将指针添加到对象的自由伊甸园大小以及我们的内存(此技术称为“Bump-the-pointer”)。Bump-the-pointer(撞点)技术跟踪在伊甸园空间创建的最后一个对象,这个对象会被放在伊甸园空间的顶部。如果之后需要创建对象,只需检查伊甸园空间是否有足够的剩余空间。如果有足够的空间,对象就会被创建在伊甸园空间,并且被放置在顶部(此时会更换标记位)。这样一来,每次创建新的对象时,只需要检查最后被创建的对象。这将极大地加快内存分配速度。但是,如果我们在多线程的情况下,事情将截然不同。如果想要以线程安全的方式以多线程在伊甸园空间存储对象,不可避免的需要加锁,而这将极大地的影响性能。

译者注:VM区域总体分两类,heap区和非heap区。其中伊甸园就属于heap区,Eden Space字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾收集后,不能被收集的对象被放入到空的survivor区域。

内存可以同时分配多个线程,因此需要某种形式的同步。如果使之最简单(阻止堆区域或指针的原子增量),则内存分配很容易成为瓶颈,因此JVM开发人员使用“bump-the-pointer”开发了先前的想法,即每个线程都分配了一个线程,且它仅属于大块内存。只要有可能,这种缓冲区内的分配都将发生指针的所有相同增量,但只在本机,不进行同步,并且每次当前指针结束时都请求一个新区域。该区域称为线程本地分配缓冲区。它实际上是一种分层的缓冲指针,其中第一层是堆区域,第二层是当前线程的TLAB。通过将缓冲区放置在缓冲区中,某些组件可以停止并进一步扩展。

2.png

事实证明,在大多数情况下,分配必须非常快,只需执行几条指令即可执行,并且看起来像如下这样:

start = currentThread.tlabTop;<br data-filtered="filtered">
end = start + sizeof(Object.class);<br data-filtered="filtered">
<br data-filtered="filtered">
if (end > currentThread.tlabEnd) {<br data-filtered="filtered">
  goto slow_path;<br data-filtered="filtered">
}<br data-filtered="filtered">
<br data-filtered="filtered">
currentThread.setTlabTop(end);<br data-filtered="filtered">
callConstructor(start, end);

它看起来好得令人难以置信,所以让我们使用PrintAssembly,看看创建java.lang.Object的方法的编译方式。注意,HotSpot虚拟机会跳过<br data-filtered="filtered">。

; Hotspot machinery skipped<br data-filtered="filtered">
mov    0x60(%r15),%rax    ; start = tlabTop<br data-filtered="filtered">
lea    0x10(%rax),%rdi    ; end = start + sizeof(Object)<br data-filtered="filtered">
cmp    0x70(%r15),%rdi    ; if (end > tlabEnd)<br data-filtered="filtered">
ja     0x00000001032b22b5 ; goto slow_path<br data-filtered="filtered">
mov    %rdi,0x60(%r15)    ; tlabTop = end<br data-filtered="filtered">
; Object initialization skipped

可以看出对象初始化了,而这正是我们期望看到的代码。同时,我们注意到JIT编译器将zanilaynil直接分配到调用方法中。

这样,JVM几乎是无负荷运行的,不涉及垃圾收集,为十几条指令创建新对象,将清理内存和整理碎片的任务转移到GC。一个不错的好处是分配的连续数据的局部性,这不能保证经典的分配器。关于这种局限性对典型应用程序性能的影响,有一个完整的研究。

TLAB大小对事件的影响

TLAB的大小应该是多少?可以合理地假设缓冲区大小越小,内存分配通过慢速分支的频率就越高,因此,TLAB需要做的更多。现在,我们使用相对慢的公共堆存储内存,并更快地创建新对象。但是还有另一个问题,就是内部碎片的问题。

假设出现以下情况:TLAB大小为2兆字节,伊甸园区域(从中分配TLAB)为500兆字节,并且应用程序具有50个线程。一旦新TLAB在堆中的位置结束,将结束其TLAB的第一个线程将引发垃圾收集。假设TLAB被±均匀地填满(在实际应用中可能不是这样),剩余的平均TLAB将被填满大约一半。也就是说,如果仍然有0.5 * 50 * 2 == 50 mb的空闲内存(最多10%),垃圾收集就开始了。这样很大一部分内存仍然是空闲的,GC仍然被调用。

5.png

如果你继续增加TLAB的大小或线程数,则内存损失将线性增长,并且事实证明TLAB会加速分配,但会降低整个应用程序的速度,从而再次使垃圾收集器不堪重负。

如果TLAB中的位置仍然存在,但是新对象太大了,该怎么办?如果撤掉旧缓冲区并分配新缓冲区,碎片只会增加,如果在这种情况下总是直接在伊甸园中创建一个对象,那么应用程序将开始运行得比它可能的速度慢。

一般来说,该做什么不是很清楚。你可以提供神秘的常量(就像对内联启发式所做的那样),可以给出开发人员购买的规模并针对每个应用程序进行调整(非常方便),可以以某种方式教会JVM猜测正确的答案。

解决方案

不指定大小,而是指出碎片的百分比,这是堆中我们准备为快速分配而牺牲的一部分,JVM将以某种方式解决它,它负责这个参数TLABWasteTargetPercent,默认值为1%。使用相同的线程内存分配均匀性假设,我们得到一个简单的方程:

tlab_size * threads_count * 1/2 = eden_size * waste_percent

如果我们准备捐赠10%的伊甸园,我们有50个线程,而伊甸园需要500兆字节,那么在垃圾收集开始时,半空TLAB可以释放50兆字节,也就是说,在我们的示例中,TLAB的大小将是2兆字节。

在这种方法中,存在严重的遗漏:假定所有流的分配都是相同的,但这几乎是不可能的。不希望将数量调整为最密集的流程的分配速度,也不要太慢。此外,在典型的应用程序中,有数百个线程,例如,在你最喜欢的应用程序服务器中,并且只有很少的几个线程可以创建新对象而不会造成严重的负载,这也需要以某种方式加以考虑。而且,如果你想起一个问题“如果你需要分配TLAB大小的10%,而自由空间只有9%,该怎么办?”,然后它变得完全不可见。

我使用的是master jdk9,这是CMakeLists.txt,如果你想重复此过程,CLion将开始使用它。

避开陷阱

我们感兴趣的文件来自第一个sling,称为threadLocalAllocBuffer.cpp,它描述了缓冲区的结构。尽管该类描述了一个缓冲区,但是它只为每个线程创建一个缓冲区,并在分配新的TLAB时重新使用,以及TLAB的各种使用情况统计信息。

要了解JIT编译器,你需要像JIT编译器一样思考。因此,请立即跳过初始化,为新线程创建一个缓冲区,并计算默认值,然后查看resize,它在每个程序集的末尾针对所有线程进行调用。当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 

void ThreadLocalAllocBuffer::resize() {<br data-filtered="filtered">
  // ...<br data-filtered="filtered">
  size_t alloc =_allocation_fraction.average() * <br data-filtered="filtered">
                     (Universe::heap()->tlab_capacity(myThread()) / HeapWordSize);<br data-filtered="filtered">
  size_t new_size = alloc / _target_refills;<br data-filtered="filtered">
  // ...<br data-filtered="filtered">
}

对于每个线程,跟踪其分配的强度,并根据它和constant_target_refills(已仔细签名为“希望该线程在两个程序集之间请求的TLAB数量”)进行计算,以计算出新的大小。

_ target_refills被初始化后的结果:

// Assuming each thread's active tlab is, on average,  1/2 full at a GC<br data-filtered="filtered">
_target_refills = 100 / (2 * TLABWasteTargetPercent);

这正是我们在上面假设的假设,但是计算的不是TLAB的大小,而是计算针对该流程的新TLAB请求的数量。为了确保在组装时所有线程的可用内存不超过%,每个流的TLAB大小必须是其通常在组装之间分配的总内存的2x%。通过将1除以2x,可以获得所需的请求数。

线程分配的份额需要在某个时间进行更新,在每个垃圾收集开始时,所有线程的统计信息都会更新,具体方法请点此。我们检查线程是否至少更新了其TLAB,不需要重新计算不执行任何操作(或至少不进行分配)的线程的大小。

我们检查是否使用一半的伊甸园来避免完整GC的影响,例如,对System.gc()的显式调用对计算的影响。最后,考虑伊甸园花了多少流量,并更新了其分配份额。我们将更新有关线程如何使用其TLAB,分配了多少以及浪费了多少内存的统计信息。

为了避免由于程序集的频率以及与垃圾收集器的不稳定性和流的需求相关联的不同分配模式而导致的各种不稳定影响,分配的份额不仅是数字,而且是以指数加权的移动平均值进行的,从而保持了最后N个程序集的平均值。在JVM中,一切都可以进行控制,这个地方也不例外,TLABAllocationWeight标志控制平均值平均多快忘掉旧值。

通过以上了解,我们基本上能够解释以下问题了:

1.JVM知道它可以在碎片上花费多少内存,此值计算线程在垃圾收集之间应请求的TLAB数量。

2.JVM跟踪每个线程使用多少内存,并平均这些值。

3.每个线程根据它使用的内存的比例获得TLAB的大小,这解决了线程之间分配不均的问题,平均而言,分配很快,并且内存浪费很少。

8.png

如果应用程序有100个线程,其中3个服务于用户请求,2个正在执行计时器上的辅助活动,而其他所有线程都处于空闲状态,则第一组线程将接收大型的TLAB,第二组线程将很小,其他所有线程都是默认值。最令人高兴的是,所有线程的“慢速”分配(TLAB请求)数量将相同。

本文翻译自:https://umumble.com/blogs/java/how-does-jvm-allocate-objects%3F/如若转载,请注明原文地址: https://www.4hou.com/web/20875.html
点赞 0
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论