带你读《JVM G1源码分析和调优》之二:G1的基本概念

简介: 本书尝试从G1的原理出发,系统地介绍新生代回收、混合回收、Full GC、并发标记、Refine线程等内容;同时依托于jdk8u的源代码介绍Hotspot如何实现G1,通过对源代码的分析来了解G1提供了哪些参数、这些参数的具体意义;最后本书还设计了一些示例代码,给出了G1在运行这些示例代码时的日志,通过日志分析来尝试调整参数并达到性能优化,还分析了参数调整可能带来的负面影响。

点击查看第一章
点击查看第三章

第2章

G1的基本概念
通常我们所说的GC是指垃圾回收,但是在JVM的实现中GC更为准确的意思是指内存管理器,它有两个职能,第一是内存的分配管理,第二是垃圾回收。这两者是一个事物的两个方面,每一种垃圾回收策略都和内存的分配策略息息相关,脱离内存的分配去谈垃圾回收是没有任何意义的。
本书第3章会介绍G1如何分配对象,第4章到第10章都是介绍G1是如何进行垃圾回收的。为了更好地理解后续章节,本章主要介绍G1的一些基本概念,主要有:G1实现中所用的一些基础数据堆分区、G1的停顿预测模型、垃圾回收中使用到的对象头、并发标记中涉及的卡表和位图,以及垃圾回收过程中涉及的线程、栈帧和句柄等。

2.1 分区

分区(Heap Region,HR)或称堆分区,是G1堆和操作系统交互的最小管理单位。G1的分区类型(HeapRegionType)大致可以分为四类:
□自由分区(Free Heap Region,FHR)
□新生代分区(Young Heap Region,YHR)
□大对象分区(Humongous Heap Region,HHR)
□老生代分区(Old Heap Region,OHR)
其中新生代分区又可以分为Eden和Survivor;大对象分区又可以分为:大对象头分区和大对象连续分区。
每一个分区都对应一个分区类型,在代码中常见的is_young、is_old、is_houmongous
等判断分区类型的函数都是基于上述的分区类型实现,关于分区类型代码如下所示:

image.png

在G1中每个分区的大小都是相同的。该如何设置HR的大小?设置HR的大小有哪些考虑?
HR的大小直接影响分配和垃圾回收效率。如果过大,一个HR可以存放多个对象,分配效率高,但是回收的时候花费时间过长;如果太小则导致分配效率低下。为了达到分配效率和清理效率的平衡,HR有一个上限值和下限值,目前上限是32MB,下限是1MB(为了适应更小的内存分配,下限可能会被修改,在目前的版本中HR的大小只能为1MB、2MB、4MB、8MB、16MB和32MB),默认情况下,整个堆空间分为2048个HR(该值可以自动根据最小的堆分区大小计算得出)。HR大小可由以下方式确定:
□可以通过参数G1HeapRegionSize来指定大小,这个参数的默认值为0。
□启发式推断,即在不指定HR大小的时候,由G1启发式地推断HR大小。
HR启发式推断根据堆空间的最大值和最小值以及HR个数进行推断,设置Initial
HeapSize(默认为0)等价于设置Xms,设置MaxHeapSize(默认为96MB)等价于设置
Xmx。堆分区默认大小的计算方式在HeapRegion.cpp中的setup_heap_region_size(),代
码如下所示:

image.png

按照默认值计算,G1可以管理的最大内存为2048×32MB = 64GB。假设设置
xms = 32G,xmx = 128G,则每个堆分区的大小为32M,分区个数动态变化范围从1024
到4096个。
G1中大对象不使用新生代空间,直接进入老生代,那么多大的对象能称为大对象?简单来说是region_size的一半。
新生代大小
新生代大小指的是新生代内存空间的大小,前面提到G1中新生代大小按分区组织,即首先计算整个新生代的大小,然后根据上一节中的计算方法计算得到分区大小,两者相除得到需要多少个分区。G1中与新生代大小相关的参数设置和其他GC算法类似,G1中还增加了两个参数G1MaxNewSizePercent和G1NewSizePercent用于控制新生代的大小,整体逻辑如下:
□如果设置新生代最大值(MaxNewSize)和最小值(NewSize),可以根据这些值计算新生代包含的最大的分区和最小的分区;注意Xmn等价于设置了MaxNewSize
和NewSize,且NewSize = MaxNewSize。
□如果既设置了最大值或者最小值,又设置了NewRatio,则忽略NewRatio。
□如果没有设置新生代最大值和最小值,但是设置了NewRatio,则新生代的最大值和最小值是相同的,都是整个堆空间/(NewRatio + 1)。
□如果没有设置新生代最大值和最小值,或者只设置了最大值和最小值中的一个,那么G1将根据参数G1MaxNewSizePercent(默认值为60)和G1NewSizePercent
(默认值为5)占整个堆空间的比例来计算最大值和最小值。
值得注意的是,如果G1推断出最大值和最小值相等,则说明新生代不会动态变化。不会动态变化意味着G1在后续对新生代垃圾回收的时候可能不能满足期望停顿的时间,具体内容将在后文继续介绍。新生代大小相关的代码如下所示:

image.png
image.png
image.png

如果G1是启发式推断新生代的大小,那么当新生代变化时该如何实现?简单地说,使用一个分区列表,扩张时如果有空闲的分区列表则可以直接把空闲分区加入到新生代分区列表中,如果没有的话则分配新的分区然后把它加入到新生代分区列表中。G1有一个线程专门抽样处理预测新生代列表的长度应该多大,并动态调整。
另外还有一个问题,就是分配新的分区时,何时扩展?一次扩展多少内存?
G1是自适应扩展内存空间的。参数-XX:GCTimeRatio表示GC与应用的耗费时间比,G1中默认为9,计算方式为_gc_overhead_perc = 100.0×(1.0 / (1.0 + GCTimeRatio)),
即G1 GC时间与应用时间占比不超过10%时不需要动态扩展,当GC时间超过这个阈值的10%,可以动态扩展。扩展时有一个参数G1ExpandByPercentOfAvailable(默认值是20)来控制一次扩展的比例,即每次都至少从未提交的内存中申请20%,有下限要求(一次申请的内存不能少于1M,最多是当前已分配的一倍),代码如下所示:

image.png

GC中内存的扩展时机在第5章介绍。

2.2 G1停顿预测模型

G1是一个响应时间优先的GC算法,用户可以设定整个GC过程的期望停顿时间,由参数MaxGCPauseMillis控制,默认值200ms。不过它不是硬性条件,只是期望值,G1会努力在这个目标停顿时间内完成垃圾回收的工作,但是它不能保证,即也可能完不成(比如我们设置了太小的停顿时间,新生代太大等)。
那么G1怎么满足用户的期望呢?就需要停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的堆分区数量(即选择收集哪些内存空间),从而尽量满足用户设定的目标停顿时间。如使用过去10次垃圾回收的时间和回收空间的关系,根据目前垃圾回收的目标停顿时间来预测可以收集多少的内存空间。比如最简单的办法是使用算术平均值建立一个线性关系来预测。如过去10次一共收集了10GB的内存,花费了1s,那么在200ms的停顿时间要求下,最多可以收集2GB的内存空间。G1的预测逻辑是基于衰减平均值和衰减标准差。
衰减平均(Decaying Average)是一种简单的数学方法,用来计算一个数列的平均值,核心是给近期的数据更高的权重,即强调近期数据对结果的影响。衰减平均计算公式如下所示:

image.png

式中α为历史数据权值,1-α为最近一次数据权值。即α越小,最新的数据对结果影响越大,最近一次的数据对结果影响最大。不难看出,其实传统的平均就是α取值为(n-1) /n的情况。
同理,衰减方差的定义如下:

image.png

停顿预测模型是以衰减标准差为理论基础实现的,代码如下所示:

image.png

在这个预测计算公式中:
□davg表示衰减均值。
□sigma()返回一个系数,来自G1ConfidencePercent(默认值为50,sigma为0.5)的配置,表示信赖度。
□dsd表示衰减标准偏差。
□confidence_factor表示可信度相关系数,confidence_factor当样本数据不足时(小于5个)取一个大于1的值,并且样本数据越少该值越大。当样本数据大于5时confidence_factor取值为1。这是为了弥补样本数据不足,起到补偿作用。
□方法的参数TruncateSeq,顾名思义,是一个截断的序列,它只跟踪序列中最新的n个元素。在G1 GC过程中,每个可测量的步骤花费的时间都会记录到TruncateSeq
(继承了AbsSeq)中,用来计算衰减均值、衰减变量、衰减标准偏差等,代码如下所示:

image.png

这个add方法就是上面两个衰减公式的实现代码。其中_davg为衰减均值,
_dvariance为衰减方差,_alpha默认值为0.7。G1的软实时停顿就是通过这样的预测模型来实现的。

2.3 卡表和位图

卡表(CardTable)在CMS中是最常见的概念之一,G1中不仅保留了这个概念,还引入了RSet。卡表到底是一个什么东西?
GC最早引入卡表的目的是为了对内存的引用关系做标记,从而根据引用关系快速遍历活跃对象。举个简单的例子,有两个分区,假设分区大小都为1MB,分别为A和B。如果A中有一个对象objA,B中有一个对象objB,且objA.field = objB,那么这两个分区就有引用关系了,但是如果我们想找到分区A,要如何引用分区B?做法有两种:
□遍历整个分区A,一个字一个字的移动(为什么以字为单位?原因是JVM中对象会对齐,所以不需要按字节移动),然后查看内存里面的值到底是不是指向B,这种方法效率太低,可以优化为一个对象一个对象地移动(这里涉及JVM如何识别对象,以及如何区分指针和立即数),但效率还是太低。
□借助额外的数据结构描述这种引用关系,例如使用类似位图(bitmap)的方法,记录A和B的内存块之间的引用关系,用一个位来描述一个字,假设在32位机器上(一个字为32位),需要32KB(32KB×32 = 1M)的空间来描述一个分区。那么我们就可以在这个对象ObjA所在分区A里面添加一个额外的指针,这个指针指向另外一个分区B的位图,如果我们可以把对象ObjA和指针关系进行映射,那么当访问ObjA的时候,顺便访问这个额外的指针,从这个指针指向的位图就能找到被ObjA引用的分区B对应的内存块。通常我们只需要判定位图里面对应的位是否有1,有的话则认为发生了引用。
以位为粒度的位图能准确描述每一个字的引用关系,但是一个位通常包含的信息太少,只能描述2个状态:引用还是未引用。实际应用中JVM在垃圾回收的时候需要更多的状态,如果增加至一个字节来描述状态,则位图需要256KB的空间,这个数字太大,开销占了25%。所以一个可能的做法位图不再描述一个字,而是一个区域,JVM选择512字节为单位,即用一个字节描述512字节的引用关系。选择一个区域除了空间利用率的问题之外,实际上还有现实的意义。我们知道Java对象实际上不是一个字能描述的(有一个参数可以控制对象最小对齐的大小,默认是8字节,实际上Java在JVM中还有一些附加信息,所以对齐后最小的Java对象是16字节),很多Java对象可能是几十个字节或者几百个字节,所以用一个字节描述一个区域是有意义的。但是我没有找到512的来源,为什么512效果最好?没有相应的数据来支持这个数字,而且这个值不可以配置,不能修改,但是有理由相信512字节的区域是为了节约内存额外开销。按照这个值,1MB的内存只需要2KB的额外空间就能描述引用关系。这又带来另一个问题,就是512字节里面的内存可能被引用多次,所以这是一个粗略的关系描述,那么在使用的时候需要遍历这512字节。
再举一个例子,假设有两个对象B、C都在这512字节的区域内。为了方便处理,记录对象引用关系的时候,都使用对象的起始位置,然后用这个地址和512对齐,因此B和C对象的卡表指针都指向这一个卡表的位置。那么对于引用处理也有可有两种处理方法:
□处理的时候会以堆分区为处理单位,遍历整个堆分区,在遍历的时候,每次都会以对象大小为步长,结合卡表,如果该卡表中对应的位置被设置,则说明对象和其他分区的对象发生了引用。具体内容在后文中介绍Refine的时候还会详细介绍。
□处理的时候借助于额外的数据结构,找到真正对象的位置,而不需要从头开始遍历。在后文的并发标记处理时就使用了这种方法,用于找到第一个对象的起始位置。
在G1除了512字节粒度的卡表之外,还有bitMap,例如使用bitMap可以描述一个分区对另外一个分区的引用情况。在JVM中bitMap使用非常多,例如还可以描述内存的分配情况。
G1在混合收集算法中用到了并发标记。在并发标记的时候使用了bitMap来描述对象的分配情况。例如1MB的分区可以用16KB(16KB×ObjectAlignmentInBytes×8 =
1MB)来描述,即16KB额外的空间。其中ObjectAlignmentInBytes是8字节,指的是对象对齐,第二个8是指一个字节有8位。即每一个位可以描述64位。例如一个对象长度对齐之后为24字节,理论上它占用3个位来描述这个24字节已被使用了,实际上并不需要,在标记的时候只需要标记这3个位中的第一个位,再结合堆分区对象的大小信息就能准确找出。其最主要的目的是为了效率,标记一个位和标记3个位相比能节约不少时间,如果对象很大,则更划算。这些都是源码的实现细节,大家在阅读源码时需要细细斟酌。

2.4 对象头

我们都知道Java语言是多态,那么如何实现多态?C++语言本身支持多态调用,众所周知,C++完成多态依赖于一个指针:虚指针(virtual pointer),这个指针指向一个虚表(virtual table),这个虚表里面存储的是虚函数的地址,而这些函数的地址是在C++代码编译时确定的,通常虚表位于程序的数据段(Data Segment)中。
因为Java代码首先被翻译成字节码(bytecode),在JVM执行时才能确定要执行函数的地址,如何实现Java的多态调用,最直观的想法是把Java对象映射成C++对象或者封装成C++对象,比如增加一个额外的对象头,里面指向一个对象,而这个对象存储了Java代码的地址。所以JVM设计了对象的数据结构来描述Java对象,这个结构分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。而我们刚才提到的类似虚指针的东西就可以放在对象头中,而JVM设计者还利用对象头来描述更多信息,对象的锁信息、GC标记信息等。我们这里只讨论和G1相关的信息,更多信息大家可以参考其他书籍或者文章。
JVM中对象头分为两部分:标记信息、元数据信息,代码如下所示:

image.png

1.标记信息
第一部分标记信息位于MarkOop。
根据JVM源码的注释,针对标记信息在32位JVM用32位来描述,我们可以总结出这32位的组合情况,如表2-1所示。

image.png

另外在源代码中我们还看到一个Promoted的状态,Promoted指的是对象从新生代晋升到老生代时,正常的情况需要对这个对象头进行保存,主要的原因是如果发生晋升失败,需要重新恢复对象头。如果晋升成功这个保存的对象头就没有意义。所以为了提高晋升失败时对象头的恢复效率,设计了promo_bits,这个其实是重用了加锁位(包括偏向锁),实际上只需要在以下三种情况时才需要保存对象头:
□使用了偏向锁,并且偏向锁被设置了。
□对象被加锁了。
□对象设置了hash_code。
这里和GC直接相关的就是标记位11,前面的30位指针是非常有用的。在GC垃圾回收时,当对象被设置为marked(11)时,ptr指向什么位置?简单来说这个ptr是为了配合对象晋升时发生的对象复制(copy)。在对象复制时,先分配空间,再把原来对象的所有数据都复制过去,再修改对象引用的指针,就完成了。但是我们要思考这样一个问题,当有多个引用对象的字段指向同一个被引用对象时,我们完成一个被引用对象的复制之后,其他引用对象还没有被遍历(即还指向被引用对象老的地址),如何处理这种情况?这个时候简单设置状态为marked,表示被引用对象已经被标记且被复制了,ptr就是指向新的复制的地址。当遍历其他引用对象的时候,发现被引用对象已经完成标记,则不再需要复制对象,直接完成对象引用更新就可以了。我们在讲述垃圾回收的时候会通过示意图再帮助大家巩固理解这个字段的意义。
2.元数据信息
第二部分元数据信息字段指向的是Klass对象(Klass对象是元数据对象,如Instance
Klass描述Java对象的类结构),这个字段也和垃圾回收有关系。
这里大家先思考一个问题,就是在垃圾回收的时候如何区别一个立即数和指针地址?比如从Java的根集合中发现有一个值(如:0X12345678),那么这个数到底是一个整数还是一个Java对象的地址?实际上垃圾回收器不能区别,但是为了准确地回收垃圾,必须区别出来。一个简单的办法就是,把0X12345678先看成一个地址,即强制转换成OOP结构,再判定这个OOP是否是含有Klass指针,如果有的话即认为是一个指针,如果是NULL的话则认为是一个立即数。那么这里会有一个误判,即把一个立即数识别成一个OOP,当这个立即数刚好和一个OOP的地址相同的时候。所以JVM维护了一个全局的OOpMap,用于标记栈里面的数是立即数还是值。每一个InstanceKlass都维护了一个Map(OopMapBlock)用于标记Java类里面的字段到底是OOP还是int这样的立即数类型。这里面的字段Klass很多时候用于再次确认。
由此可见,可以从根集合出发开始标记,通过外部的数据结构来标识是否为OOP对象。但是我们在JVM源码中还是看到了很多地方会根据对象头里面的Klass指针是否为NULL来判断是不是OOP对象,这似乎是多此一举。理论上根据额外的数据结构已经不需要再次判断,但是在垃圾回收的时候,通常是对整个区域的一块内存进行完全遍历,在对象分配时都是连续分配,当堆的尾部有尚未分配对象的时候,比如在新生代一个字通常初始化为0x20202020,需要对这些空白地址进行转换以判断是否为OOP,是否需要垃圾回收。在这里即使误判影响也不大,因为会根据RSet来判定是否为活跃对象(live object),如果是的话继续,即使误判之后也没关系,这相当于是浮动垃圾,在下一次回收的时候仍然可能被回收。

2.5 内存分配和管理

C/C++程序员和Java程序员最大的区别之一就是对内存管理的工作,Java程序员不需要管理内存,因为有JVM帮助管理。所以JVM的所谓开发必然涉及内存的分配和管理。我们这里尽可能地简化描述内存分配和管理,只描述和GC算法相关的部分。本质上来说,了解这一部分内容越多,特别是了解JVM如何与操作系统交互的部分,越容易对JVM调优。
JVM作为内存分配的管理器,一定涉及如何与内存交互。那么JVM是如何管理内存的?实际上内存管理的算法很多,简单来说JVM从操作系统申请一块内存,然后根据不同的GC算法进行管理。下面以Linux为例看一下JVM是如何做的。
首先JVM先通过操作系统的系统调用(system call)进行内存的申请,典型的就是mmap。在这里提一个问题,众所周知glibc提供了我们常用的内存管理函数如malloc/free/realloc/memcopy/memset等。为什么JVM不直接使用这些函数?glibc里面的malloc也是通过mmap等系统调用来完成内存的分配,之后glibc再对已经分配到的内存进行管理。GC算法实现了一套自己的管理方式,所以再基于malloc/free实现效率肯定不高。mmap必须以PAGE_SIZE为单位进行映射,而内存也只能以页为单位进行映射,若要映射非PAGE_SIZE整数倍的地址范围,要先进行内存对齐,强行以PAGE_SIZE的倍数大小进行映射。还要注意一点,操作系统对内存的分配管理典型地分为两个阶段:保留(reserve)和提交(commit)。保留阶段告知系统从某一地址开始到后面的dwSize大小的连续虚拟内存需要供程序使用,进程其他分配内存的操作不得使用这段内存;提交阶段将虚拟地址映射到对应的真实物理内存中,这样这块内存就可以正常使用。
对于保留和提交,Windows在使用VirtualAlloc分配内存时传递不同的参数MEM_RESERVE/MEM_COMMIT,Linux在mmap保留内存时使用MAP_PRIVATE | MAP_NORESERVE | MAP_ANONYMOUS,提交内存时使用MAP_PRIVATE | MAP_FIXED | MAP_ANONYMOUS。其中MAP_NORESERVE指不要为这个映射保留交换空间,MAP_FIXED使用指定的映射起始地址。
在JVM中我们还看到了使用类库函数malloc/free的地方。这和JVM内存管理策略有关,JVM内部也有很多数据需要在堆中分配,而这和Java堆空间没有关系,所以直接使用类库函数。另外需要提一下JVM推荐使用jemalloc替代glibc,原因是其效率更高。
JVM中常见的对象类型有以下6种:
□ResourceObj:线程有一个资源空间(Resource Area),一般ResourceObj都位于这里。定义资源空间的目的是对JVM其他功能的支持,如CFG、在C1/C2优化时可能需要访问运行时信息(这些信息可以保存在线程的资源区)。
□StackObj:栈对象,声明的对象使用栈管理。其实栈对象并不提供任何功能,且禁止New/Delete操作。对象分配在线程栈中,或者使用自定义的栈容器进行管理。
□ValueObj:值对象,该对象在堆对象需要进行嵌套时使用,简单地说就是对象分配的位置和宿主对象(即拥有这个ValueObj对象的对象)是一样的。
□AllStatic:静态对象,全局对象,只有一个。值得一提的是C++中静态对象的初始化并没有通过规范保证,可能会有一个问题,就是两个静态对象相互依赖,那么在初始化的时候可能出错。JVM中的很多静态对象的初始化,都是显式调用静态初始化函数。
□MetaspaceObj:元对象,比如InstanceKlass这样的元数据就是元对象。
□CHeapObj:这是堆空间的对象,由new/delete/free/malloc管理。其包含的内容很多,比如Java对象、InstanceOop(后面提到的G1对象分配出来的对象)。除了Java对象,还有其他的对象也在堆中。
JVM中为了准确描述这些堆中的对象,以方便对JVM进行优化,所以又定义了更具体的子类型,代码如下所示:

image.png
image.png

这些信息描述了JVM使用内存的情况,这一部分信息能够帮助定位JVM本身运行时出现的问题,我们将在最后的附录B中通过本地内存跟踪(Native Memory Tracking)来进一步解读这些信息。

2.6 线程

线程是程序执行的基本单元,在JVM中也定义封装了线程。图2-1是JVM的线程类图。

image.png

这里只介绍G1中涉及的几类线程:
□JavaThread:就是要执行Java代码的线程,比如Java代码的启动会创建一个JavaThread运行;对于Java代码的启动,可以通过JNI_CreateJavaVM来创建一个JavaThread,而对于一般的Java线程,都是调用java.lang.thread中的start方法,这个方法通过JNI调用创建JavaThread对象,完成真正的线程创建。
□CompilerThread:执行JIT的线程。
□WatcherThread:执行周期性任务,JVM里面有很多周期性任务,例如内存管理中对小对象使用了ChunkPool,而这种管理需要周期性的清理动作Cleaner;JVM中内存抽样任务MemProf?ilerTask等都是周期性任务。
□NameThread:是JVM内部使用的线程,分类如图2-1所示。
□VMThread:JVM执行GC的同步线程,这个是JVM最关键的线程之一,主要是用于处理垃圾回收。简单地说,所有的垃圾回收操作都是从VMThread触发的,如果是多线程回收,则启动多个线程,如果是单线程回收,则使用VMThread进行。VMThread提供了一个队列,任何要执行GC的操作都实现了VM_GC_Operation,在JavaThread中执行VMThread::execute(VM_GC_Operation)把GC操作放入到队列中,然后再用VMThread的run方法轮询这个队列就可以了。当这个队列有内容的时候它就开始尝试进入安全点,然后执行相应的GC任务,完成GC任务后会退出安全点。
□ConcurrentGCThread:并发执行GC任务的线程,比如G1中的ConcurrentMark
Thread和ConcurrentG1Ref?ineThread,分别处理并发标记和并发Ref?ine,这两个线程将在混合垃圾收集和新生代垃圾回收中介绍。
□WorkerThread:工作线程,在G1中使用了FlexibleWorkGang,这个线程是并行执行的(个数一般和CPU个数相关),所以可以认为这是一个线程池。线程池里面的线程是为了执行任务(在G1中是G1ParTask),也就是做GC工作的地方。VMThread会触发这些任务的调度执行(其实是把G1ParTask放入到这些工作线程中,然后由工作线程进行调度)。
从线程的实现角度来看,JVM中的每一个线程都对应一个操作系统(OS)线程。JVM为了提供统一的处理,设计了JVM线程状态,代码如下所示:

image.png
image.png


JVM可以运行在不同的操作系统之上,所以它也统一定义了操作系统线程的状态,代码如下所示:

image.png


这里定义不同的线程状态有两个目的:第一、统一管理,第二、根据状态可以做一些同步处理,相关内容在VMThread进入安全点时会有涉及。关于安全点的内容并不影响G1的阅读,后文将会详细介绍。
当线程创建时,它的状态为NEW,当执行时转变为RUNNABLE。线程在Windows
和Linux上的实现稍有区别。在Linux上创建线程后,虽然设置成NEW,但是Linux的线程创建完之后就可以执行,所以为了让线程只有在执行Java代码的start之后才能执行,当线程初始化之后,通过等待一个信号将线程暂停,代码如下所示:

image.png

在调用start方法时,发送通知事件,让线程真正运行起来。

2.6.1 栈帧

栈帧(frame)在线程执行时和运行过程中用于保存线程的上下文数据,JVM设计了Java栈帧,这是垃圾回收中最重要的根,栈帧的结构在不同的CPU中并不相同,在x86中代码如下所示:

image.png


在实际应用中主要使用vframe,它包含了栈帧的字段和线程对象。在JaveThread中定义了JavaFrameAnchor,这个结构保存的是最后一个栈帧的sp、fp。每一个JavaThread都有一个JavaFrameAnchor,即最后一次调用栈的sp、fp。而通过这两个值可以构造栈帧结构,并且根据栈帧的内容,能够遍历整个JavaThread运行时的所有调用链。获取的方法就是根据JavaFrameAnchor里面的sp、fp构造栈帧,再根据栈帧构造vframe结构,代码如下所示:

image.png

在遍历的时候主要通过sender获得下一个栈,其中sender位于栈帧中,其具体的位置依赖于栈的布局,比如汇编解释器在执行时栈帧的代码如下:

image.png

栈帧也是和GC密切相关的,在GC过程中,通常第一步就是遍历根,Java线程栈帧就是根元素之一,遍历整个栈帧的方式是通过StackFrameStream,其中封装了一个next指针,其原理和上述的代码一样,通过sender获得调用者的栈帧。
值得一提的是,我们将Java的栈帧作为根来遍历堆,对对象进行标记并收集垃圾。

2.6.2 句柄

实际上线程既可以支持Java代码的执行也可以执行本地代码,如果本地代码(这里的本地代码指的是JVM里面的本地代码,而不是用户自定义的本地代码)引用了堆里面的对象该如何处理?是不是也是通过栈?理论上是可行的,实际上JVM并没有区分Java栈和本地方法栈,如果通过栈进行处理则必须要区分这两种情况。JVM设计了另一个概念,handleArea,这是一块线程的资源区,在这个区域分配句柄(handle),并且管理所有的句柄,如果函数还在调用中,那么句柄有效,句柄关联的对象也就是活跃对象。为了管理句柄的生命周期,引入了HandleMark,通常HandleMark分配在栈上,在创建HandleMark的时候标记handleArea对象有效,在HandleMark对象析构的时候,从HandleArea中删除对象的引用。由于所有句柄都形成了一个链表,那么访问这个句柄链表就可以获得本地代码执行中对堆对象的引用。
句柄和OOP对象关联,在HandleArea中有一个slot用于指向OOP对象。
本节源码都在下面两个文件中,为了便于阅读和减少篇幅,我们对其中的类代码进行了重组,代码如下所示:

image.png
image.png


在HandleMark中标记Chunk的地址,这个就是找到当前本地方法代码中活跃的句柄,因此也就可以找到对应的活跃的OOP对象。下面是HandleMark的构造函数和析构函数,它们的主要工作就是构建句柄链表,代码如下所示:

image.png
image.png

在这里我们提到了Chunk,Chunk的回收是通过前面我们提到的周期性线程Watcher
Thread完成的。
还需要提到一点,就是JVM中的本地代码指的是JVM内部的代码,除了JVM内部的本地代码,还有JNI代码也是本地代码。对于本地代码,并不归JVM直接管理,在执行JNI代码的时候,也有可能访问堆中的OOP对象。所以也需要一个机制进行管理,JVM引入了类似的句柄机制,称为JNIHandle。JNIHandle分为两种,全局和局部对象引用,大部分对象的引用属于局部对象引用,最终还是调用了JNIHandleBlock来管理,因为JNIHandle没有设计一个JNIHandleMark的机制,所以在创建时需要明确调用make_local,在回收时也需要明确调用destory_local。对于全局对象,比如在编译任务compilerTask中会访问Method对象,这时候就需要把这些对象设置为全局的(否则在GC时可能会被回收的)。这两部分在垃圾回收时的处理是不同的,局部JNIhandle是通过线程,全局JNIhandle则是通过全局变量开始。

2.6.3 JVM本地方法栈中的对象

上节介绍本地方法栈是如何管理和链接对象的。每一个Java线程都私有一个句柄区_handle_area来存储其运行过程中创建的临时对象,这个句柄区是随着Java线程的栈帧变化的,我们看一下HandleMark是如何管理的。HandleArea的作用上一节已经介绍过了,这里我们先看一下它们的结构图(如图2-2所示),然后再通过代码演示如何管理句柄。

image.png

Java线程每调用一个Java方法就会创建一个对应HandleMark来保存已分配的对象句柄,然后等调用返回后即行恢复,代码如下所示:

image.png

所以当Java线程运行一段时间之后,通过HandleMark构建的对象识别链如图2-3所示:

image.png

这里Chunk的管理是动态变化的,第一个Chunk可能为256或者1024个字节,每一个Chunk都有一个额外空间,主要是调用malloc时会有一段额外的信息,比如地址的长度等,在32位机器上一般为20个字节,所以每一个Chunk都会比最大值少5个OOP对象。另外,一般的Chunk块通常为32KB。最后还需要提一点的就是,Handle
Mark通常都是分配在线程栈中,也意味着无需额外的管理,只需要找到HandleMark就能找到哪些对象是存活的。我们来看一个简单的例子,看看如何遍历堆空间。
下面这个代码片段是为了输出堆空间里面的对象,例如我们执行jmap命令来获取堆空间对象的时候最终会调用到VM_HeapDumper::do_thread()来遍历所有的对象。通过下面的代码我们能非常清楚地看到,如果JavaThread执行的是Java代码,则直接通过StackValueCollection访问局部变量,如果执行的是本地代码,线程则通过active_handles()访问句柄而访问对象。

image.png
image.png

2.6.4 Java本地方法栈中的对象

Java线程使用一个对象句柄存储块JNIHandleBlock来为其在本地方法中申请的临时对象创建对应的句柄,每个JNIHandleBlock里面有一个oop数组,长度为32,如果超过数组长度则申请新的Block并通过next指针形成链表。另外JNIHandleBlock中还有一个_pop_frame_link属性,用来保存Java线程切换方法时分配本地对象句柄的上下文环境,从而形成调用handle的链表。

2.7 日志解读

如果启动JVM的时候我们没有指定参数,则可以通过设置Java -XX:+Print
CommandLineFlags这个参数让JVM打印出那些已经被用户或者JVM设置过的详细的XX参数的名称和值。例如我们可以得到JVM使用的默认垃圾收集器,如下所示:
-XX:InitialHeapSize=266930688 -XX:MaxHeapSize=4270891008
-XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers
-XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation
-XX:+UseParallelGC
如果指定G1作为垃圾回收,但是没有指定堆空间的参数,当发生GC的时候,我们可以看到:
-Xmx256M -XX:+UseG1GC -XX:+UnlockExperimentalVMOptions
-XX:G1LogLevel=f?inest -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
-XX:+UseAdaptiveSizePolicy
garbage-f?irst heap total 131072K, used 37569K [0x00000000f8000000,
0x00000000f8100400, 0x0000000100000000)
region size 1024K, 24 young (24576K), 0 survivors (0K)
Eden开始之前是24MB,主要来自于预测值,且24个分区,即每个分区都是1MB。
第一次GC后的堆空间信息如下所示:
[Eden: 24.0M(24.0M)->0.0B(13.0M) Survivors: 0.0B->3072.0K Heap:
24.0M(128.0M)->12.9M(128.0M)]
GC之后Eden设置为13M,来自于256M×5% = 12.8MB,取整后就是13MB,并且满足预测时间。其中,256M是堆的大小,5%是G1 NewSizePercent指定的默认值。

2.8 参数介绍和调优

上文已经详细介绍了G1中堆大小和新生代大小的计算、分区设置、G1的停顿预测模型以及停顿预测模型中的几个参数。这里给出使用中的一些注意事项:
□参数G1HeapRegionSize指定堆分区大小。分区大小可以指定,也可以不指定;不指定时,由内存管理器启发式推断分区大小。
□参数xms/xmx指定堆空间的最小值/最大值。一定要正确设置xms/xmx,否则将使用默认配置,将影响分区大小推断。
□在以前的内存管理器中(非G1),为了防止新生代因为内存不断地重新分配导致性能变低,通常设置Xmn或者NewRatio。但是G1中不要设置MaxNewSize、
NewSize、Xmn和NewRatio。原因有两个,第一G1对内存的管理不是连续的,所以即使重新分配一个堆分区代价也不高,第二也是最重要的,G1的目标满足垃圾收集停顿,这需要G1根据停顿时间动态调整收集的分区,如果设置了固定的分区数,即G1不能调整新生代的大小,那么G1可能不能满足停顿时间的要求。具体情况本书后续还会继续讨论。
□参数GCTimeRatio指的是GC与应用程序之间的时间占比,默认值为9,表示GC与应用程序时间占比为10%。增大该值将减少GC占用的时间,带来的后果就是动态扩展内存更容易发生;在很多情况下10%已经很大,例如可以将该值设置为19,则表示GC时间不超过5%。
□根据业务请求变化的情况,设置合适的扩展G1ExpandByPercentOfAvailable速率,保持效率。
□JVM在对新生代内存分配管理时,还有一个参数就是保留内存G1ReservePercent(默认值是10),即在初始化,或者内存扩展/收缩的时候会计算更新有多少个分区是保留的,在新生代分区初始化的时候,在空闲列表中保留一定比例的分区不使用,那么在对象晋升的时候就可以使用了,所以能有效地减小晋升失败的概率。这个值最大不超过50,即最多保留50%的空间,但是保留过多会导致新生代可用空间少,过少可能会增加新生代晋升失败,那将会导致更为复杂的串行回收。
□G1NewSizePercent是一个实验参数,需要使用-XX:+UnlockExperimentalVMOptions
才能改变选项。有实验表明G1在回收Eden分区的时候,大概每GB需要100ms,所以可以根据停顿时间,相应地调整。这个值在内存比较大的时候需要减少,例如32G可以设置-XX:G1NewSizePercent = 3,这样Eden至少保留大约1GB的空间,从而保证收集效率。
□参数MaxGCPauseMillis指期望停顿时间,可根据系统配置和业务动态调整。因为G1在垃圾收集的时候一定会收集新生代,所以需要配合新生代大小的设置来确定,如果该值太小,连新生代都不能收集完成,则没有任何意义,每次除了新生代之外只能多收集一个额外老生代分区。
□参数GCPauseIntervalMillisGC指GC间隔时间,默认值为0,GC启发式推断为MaxGCPauseMillis + 1,设置该值必须要大于MaxGCPauseMillis。
□参数G1ConfidencePercent指GC预测置信度,该值越小说明基于过去历史数据的预测越准确,例如设置为0则表示收集的分区基本和过去的衰减均值相关,无波动,所以可以根据过去的衰减均值直接预测下一次预测的时间。反之该值越大,说明波动越大,越不准确,需要加上衰减方差来补偿。
□JVM中提供了一个对象对齐的值ObjectAlignmentInBytes,默认值为8,需要明白该值对内存使用的影响,这个影响不仅仅是在JVM对对象的分配上面,正如上面看到的它也会影响对象在分配时的标记情况。注意这个值最少要和操作系统支持的位数一致才能提高对象分配的效率。所以32位系统最少是4,64位最少是8。一般不用修改该值。

相关文章
|
3月前
|
存储 算法 Oracle
极致八股文之JVM垃圾回收器G1&ZGC详解
本文作者分享了一些垃圾回收器的执行过程,希望给大家参考。
|
2月前
|
存储 监控 算法
jvm-性能调优(二)
jvm-性能调优(二)
|
4月前
|
Arthas 监控 Java
(十一)JVM成神路之性能调优篇:GC调优、Arthas工具详解及各场景下线上最佳配置推荐
“在当前的互联网开发模式下,系统访问量日涨、并发暴增、线上瓶颈等各种性能问题纷涌而至,性能优化成为了现时代开发过程中炙手可热的名词,无论是在开发、面试过程中,性能优化都是一个常谈常新的话题”。
408 3
|
4月前
|
监控 Java 测试技术
JVM 性能调优 及 为什么要减少 Full GC
JVM 性能调优 及 为什么要减少 Full GC
118 4
|
6天前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
4天前
|
监控 Java 编译器
Java虚拟机调优实战指南####
本文深入探讨了Java虚拟机(JVM)的调优策略,旨在帮助开发者和系统管理员通过具体、实用的技巧提升Java应用的性能与稳定性。不同于传统摘要的概括性描述,本文摘要将直接列出五大核心调优要点,为读者提供快速预览: 1. **初始堆内存设置**:合理配置-Xms和-Xmx参数,避免频繁的内存分配与回收。 2. **垃圾收集器选择**:根据应用特性选择合适的GC策略,如G1 GC、ZGC等。 3. **线程优化**:调整线程栈大小及并发线程数,平衡资源利用率与响应速度。 4. **JIT编译器优化**:利用-XX:CompileThreshold等参数优化即时编译性能。 5. **监控与诊断工
|
15天前
|
存储 监控 Java
JVM进阶调优系列(8)如何手把手,逐行教她看懂GC日志?| IT男的专属浪漫
本文介绍了如何通过JVM参数打印GC日志,并通过示例代码展示了频繁YGC和FGC的场景。文章首先讲解了常见的GC日志参数,如`-XX:+PrintGCDetails`、`-XX:+PrintGCDateStamps`等,然后通过具体的JVM参数和代码示例,模拟了不同内存分配情况下的GC行为。最后,详细解析了GC日志的内容,帮助读者理解GC的执行过程和GC处理机制。
|
23天前
|
Arthas 监控 数据可视化
JVM进阶调优系列(7)JVM调优监控必备命令、工具集合|实用干货
本文介绍了JVM调优监控命令及其应用,包括JDK自带工具如jps、jinfo、jstat、jstack、jmap、jhat等,以及第三方工具如Arthas、GCeasy、MAT、GCViewer等。通过这些工具,可以有效监控和优化JVM性能,解决内存泄漏、线程死锁等问题,提高系统稳定性。文章还提供了详细的命令示例和应用场景,帮助读者更好地理解和使用这些工具。
|
28天前
|
监控 架构师 Java
JVM进阶调优系列(6)一文详解JVM参数与大厂实战调优模板推荐
本文详述了JVM参数的分类及使用方法,包括标准参数、非标准参数和不稳定参数的定义及其应用场景。特别介绍了JVM调优中的关键参数,如堆内存、垃圾回收器和GC日志等配置,并提供了大厂生产环境中常用的调优模板,帮助开发者优化Java应用程序的性能。
|
1月前
|
Arthas 监控 Java
JVM知识体系学习七:了解JVM常用命令行参数、GC日志详解、调优三大方面(JVM规划和预调优、优化JVM环境、JVM运行出现的各种问题)、Arthas
这篇文章全面介绍了JVM的命令行参数、GC日志分析以及性能调优的各个方面,包括监控工具使用和实际案例分析。
43 3