G1垃圾回收器学习笔记

一、基础概念

垃圾回收器G1(也就是Garbage-First)是从JDK7 Update4 开始正式提供的,在多CPU和大内存服务器上对垃圾回收提供软实施目标和高吞吐量。它在并行和串行以及CMS GC针对堆空间的管理方式上都是连续的。

为了控制垃圾收集时间过长不可控,将连续的内存堆空间拆分为一系列的分区Region,将对大块内存进行垃圾收集操作转变为了对于一部分分区进行垃圾收集操作,而不再是对整个空间,整个堆或者整个老年代进行垃圾收集。使得对于垃圾收集的时长有了更精确的把控。并且在G1中,都由一系列的内存分区来构成新生代或者是老年代等等,而不再要求是一个连续的内存块。因此这样也就不需要在JVM运行的时候考虑哪些分区是新生代老年代。

通常的运行状态是:

映射G1分区的虚拟内存随着时间的推移在不同的代之间切换。回收过后的分区Region会划入到未使用分区中,可以作为新生代分区使用也可以作为作为老年代使用。

G1新生代的收集方式是并行收集使用的是标记复制算法,所以一旦新生代发生回收,那么整个新生代都会被回收。其老年代的收集则不会回收整个老年代而只会回收其中的一部分Region,并且这部分分区会在下一次增量回收时与所有新生代分区一起收集。这就是混合回收。回收对象的时候优先选择最老的数据(通常也是垃圾)进行收集。

其中G1中的大对象(带分配的对象大小超过一定的阈值),为了减少这种对象在垃圾回收过程的复制时间,直接把对象分配到老年代中。

1.分区

分区(堆分区,Heap Region),是G1堆和操作系统交互的最小管理单位。分区类型大致为:

  • 自由分区
  • 新生代分区
  • 老年代分区
  • 大对象分区

新生代可以分为Eden和Survivor,大对象分区可以分为大对象头分区和大对象连续分区。在G1中每一个Region大小都是相同的。那么我们应该如何设置Region的大小呢?如果设置的大小过大,那么分配效率可以提高,并且一个Region可以存放多个对象,但是回收的时候花费是时间很长不利于确定和控制回收时间。如果设置的大小过小,那么分配的效率就会减小。所以需要我们来对大小进行权衡。目前上限大小是32MB,下限为1MB。现在的版本中只能设置为1、2、4、8、16、32MB大小。默认情况下整个堆空间分为2048个Region,这个值也可以自动根据最小的堆分区大小计算得出。
堆空间中 R e g i o n 个数 = 堆空间大小 / 最小的堆 R e g i o n 大小 堆空间中Region个数 = 堆空间大小/最小的堆Region大小 堆空间中Region个数=堆空间大小/最小的堆Region大小
Region的大小可以通过以下两种方式确定:

  • 根据参数G1HeapRegionSize进行确定,默认值为0
  • G1在不指定HR大小的时候,会进行启发式推断出Region的大小

G1启发式推断:

根据堆空间的最大值和最小值以及Region的个数进行推断。 -Xms设置初始Java堆大小,而-Xmx设置最大Java堆大小。设置初始化堆大小等价于设置Xms(默认0Mb),设置最大堆大小等价于设置Xmx(默认96Mb)。具体操作为:

判断是否设置过堆分区的大小,如果有则使用,如果没有就根据初始内存和最大分配内存从这两个值求得平均值,并根据Region的个数算出Region的大小。并将得到的值和分区的下限进行比较取得两者最大值。之后把值对齐至2的幂次方大小,判断此时的大小如果超过32Mb就设置为32Mb,如果小于1Mb就设置为1Mb。之后还可以根据计算好的Region大小去计算其他需要用到的大小,比如卡表的大小等。

G1最大可以管理64Gb的内存(2048 * 32MB = 64Gb)

G1中的大对象可以不使用新生代空间直接进入老年代,标准为对象大小超过Region大小的一半。

新生代

G1中增加了两个参数G1MaxNewSizePercent(默认值:60 表示新生代占整个堆空间的最大比例) 和G1NewSizePercent(默认值:5 表示新生代占整个堆空间的最小比例)来控制新生代的大小:

  • 如果设置了两个值(最大新生代大小和最小新生代大小)就可以根据这两个值计算出新生代包含的最大分区和最小分区
  • 设置-Xmn等同于设置了最大新生代大小和最小新生代大小,并且这两个值都等同于设定的值(Xmn设置新生代大小)
  • 如果都设置了最大新生代大小和最小新生代大小,同时也设置了NewRatio,就会忽略NewRatio(-XX:NewRatio 老年代是新生代的几倍大小)
  • 如果没有设置最大新生代大小和最小新生代大小,但是设置了NewRatio,那么新生代的最大值和最小值是相同的都为:堆空间大小/(NewRatio+1)
  • 如果没有设置最大新生代大小和最小新生代大小,或者只是设置了其中一个值,那就会根据上述提到的新增的两个参数配合整个堆空间大小来计算出最大新生代大小和最小新生代大小

如果G1推断出最大新生代大小和最小新生代大小大小相同,那么就不会变化新生代的大小。也就意味着G1在后续对新生代垃圾回收的时候可能不能满足期望停顿的时间。

如果新生代大小需要发生变化,应该如何实现?

G1中使用了一个分区列表,扩张的时候如果有空闲的分区列表就可以直接把空闲的分区加入到新生代分区列表中。如果没有的话,就需要分配新的分区然后把他加入到新生代分区列表中。G1中有一个专门的线程来抽样处理预测新生代列表的长度应该多大,并且可以动态进行调整。

什么时候进行扩展?

G1是自适应扩展内存空间的。参数-XX:GCTimeRatio表示了GC和应用的耗时比,默认为9。也就是说如果GC时间和应用时间占比不超过10%的话就不需要动态扩展。

一次性扩展多少内存?

有一个参数G1ExpandByPercentOfAvailable(默认值为20)为一次扩展的比例。每次都至少从未提交的内存中申请20%大小的空间。当然也存在下限要求最少不能少于1MB,最大也不能超过当前已分配内存的一倍。

2.G1停顿预测模型

G1是一个响应时间优先的GC算法,用户可以设定整个GC过程的期望停顿时间,可以通过参数MaxGCPauseMillis控制,默认值为200ms。但是这个值只是期望值,表示G1会尽可能的在这个时间范围内完成垃圾回收的工作,但是并不能保证一定可以完成垃圾回收工作。

为了满足用户的期望,就需要使用停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要的堆分区数量,也就是选择收集哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去10次一共收集了10GB的内存,花费了1s。那么在200ms的时间下,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。

衰减平均: 用来计算一个数列的平均值,核心是给近期的数据更高的权重,强调近期数据对结果的印象。

(可自行寻找公式)

3.卡表和位图

卡表是CMS中最常见的概念之一。G1中保留了这个概念并引入了Rset。

什么是卡表?

GC最早引入卡表目的是对内存的引用关系作标记,从而根据关系快速遍历活跃对象。假如有两个分区,假设分区的大小都为1MB,分别为A和B。如果A中有一个对象objA,B中有一个对象objB,且objA.field = objB,那么这两个分区就有引用关系了。那么我们应该如何描述这种关系?

我们可以借助额外的数据结构,使用例如位图的方法,用一个位来描述一个字。我们只需要判定位图里面的位是否有1,有的话则认为发生了引用。以位为粒度的位图能准确描述每一个字的引用关系,但是包含信息太少,只能描述两个状态:引用和未被引用。但是如果增加一个字节来描述状态,则位图需要256kb的空间,这个数字太大,开销占了25%。所以一个可能的做法是位图不再描述一个字,而是一个区域,JVM使用512字节作为单位,用一个字节描述512字节的引用关系。
G1中还使用了bitmap,用bitmap可以描述一个分区对另外一个分区的引用情况,也可以描述内存分配的情况。并发标记时也使用了bitmap来描述对象的分配情况。

4.对象头

为了描述Java对象,JVM设计了对象的数据结构,这个结构分为三个区域:对象头,实例数据,对齐填充。对象头中分为两个部分:标记信息、元数据信息。

标记信息:

第一部分标记信息位于MarkOop。有三种情况需要保存对象头:

  1. 使用了偏向锁,并且偏向锁被设置了
  2. 对象被加锁了
  3. 对象设置了hash_code

元数据信息:

元数据信息字段指向Klass对象,是元数据的对象,和垃圾回收有关系。

垃圾回收的时候如何区别一个立即数和指针地址?

最简单的方法是把值看作是地址,强制转为OOP结构,再判断其中是否含有Klass指针。如果有就是一个指针地址,否则为立即数。但是可能会有误判,比如立即数刚好和一个指针地址相同。所以JVM维护了一个全局的OOpMap,用来标记栈中的数是立即数还是值。

每一个Klass都维护了一个Map,用于标记java类中的字段是地址还是int这样的立即数类型。

5.内存分配和管理

JVM会先通过操作系统的系统调用mmap等来进行内存的申请、分配。mmap必须要以pageSize的倍数大小进行映射,内存也只能以页为单位进行映射。如果必须要映射非页倍数大小的地址范围,则需要先进行内存对齐,强行按照页的倍数大小来进行映射。
操作系统中对内存的管理主要分为了两个阶段:

  • 保留(reserve):系统从某一地址开始到后面的dwSize大小的连续虚拟内存需要供程序使用,进程其他分配内存的操作不得使用这段内存
  • 提交(commit):将虚拟地址映射到对应的真是物理内存中,那么这块内存就可以正常使用

JVM中并不是完全都是使用系统调用来进行内存分配的,很多地方也使用了类库函数malloc/free等等,使用方式和JVM的内存管理策略相关,JVM内部也有很多数据需要在堆中进行分配,但是这和java堆空间没有关系,所以直接使用类库函数。Jvm中推荐使用jemalloc代替glibc,因为效率更高。

6.线程
  • JavaThread:执行java代码的线程
  • CompilerThread: 执行JIT的线程
  • WatcherThread: 执行周期性任务
  • NameThread: JVM内部使用线程
  • VMThread:JVM执行同步线程,是最关键的线程之一,主要用于处理垃圾回收。所有的垃圾回收操作都是从这个线程触发的,如果是多线程回收,就启动多个线程。如果是单个线程就使用VMThread进行。VMThread中提供了一个队列,任何执行GC的操作都实现了VM_GC_Operation,在javaThread中执行了VMThread::execute(VM_GC_Operation)就会将GC操作放入到队列中,然后再用VMThread的run方法轮询这个队列,当这个队列中有内容的时候就会开始尝试进入安全点,然后执行相应的GC任务,完成任务之后退出安全点
  • ConcurrentGCThread: 并发执行GC任务的线程,比如G1中的ConcurrentMark Thread 和 ConcurrentG1RefineThread,分别处理并发标记和并发Refine
  • WorkerThread:工作线程,在G1中使用了FlexibleWorkGang,这个线程是并行执行的(一般和CPU个数相关),所以可以认为这是一个线程池,其中的线程是为了执行任务(G1中是G1ParTASK),也就是GC工作的地方。VMThread会触发这些任务的调度执行。

当Linux线程创建后,最然状态为NEW,但线程创建完成之后就可以执行,所以为了让线程在执行Java代码的start之后才能执行,我们会让线程等待一个信号,将线程先暂停。

6.1 栈帧

栈帧(frame)在线程执行时和运行过程中用于保存线程的上下文数据,是垃圾回收中的最重要的根。GC时通常第一步就是遍历根,java线程栈帧就是根元素之一,遍历整个栈帧的方式就是用过StackFrameStream,其中封装了一个next指针,通过sender获得调用者的栈帧。我们将java的栈帧作为根来遍历堆,对其中的对象进行标记并收集垃圾。

6.2 句柄

一个线程可以执行Java代码也可以执行Java的本地代码,如果Java本地代码(JVM内部的本地代码,JNI代码也算)需要引用堆里面的对象应该如何处理?JVM并没区分本地方法栈和java栈,如果通过栈进行处理必须要区分这两种情况。因此JVM设计了另一个概念:HandleAera,一块线程资源区,在这个区域分配句柄,并且管理所有的句柄。如果函数还在调用中,那么句柄有效,句柄关联的对象也就是活跃对象。只针对本地代码。

为了管理句柄的生命周期,引入了HandleMark,通常标记是分配在线程栈上的,在创建HandleMark的时候标记HandleArea对象有效。当HandleMark对象析构的时候,从HandleArea中删除对象的引用。由于所有的句柄都形成了一个链表。因此通过整个链表可以获得本地代码执行中对堆对象的引用。

句柄和OOP对象关联,在HandleArea中有一个slot用于指向OOP对象。

对于本地代码,并不归JVM直接管理,在执行JNI代码的时候,也有可能访问堆中的OOP对象。所以也需要一个机制进行管理,JVM同样引入类似的句柄机制,JNIHandle。分为两种,全局和局部对象引用。局部对象引用最终调用了JNIHandleBlock来管理,因为JNIHandle没有设计一个JNIHandleMark的机制,所以创建时需要明确调用make_local,回收时也需要明确调用destory_local。对于全局对象,在编译任务compilerTask中会访问method对象,需要把这些对象设置为全局的,否在可能在GC的时候被回收。这两个部分的垃圾回收时的处理也是不同的,局部JNIhandle是通过线程,全局JNIhandle则是通过全局变量开始的

在JVM本地方法栈中,每一个java线程都私有一个句柄区,handle_area来存储其运行过程中创建的临时对象,这个句柄区是跟随着java线程的栈帧变化的。

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

7.参数介绍和调优
  • G1HeapRegionSize:指定堆分区大小。可以指定也可以不指定,由内存管理器启发式推断出分区的大小
  • xms/xmx: 指定堆空间的最小/最大值,一定要设置,否则将使用默认配置,将影响分区大小推断
  • GCTimeRatio:指GC与应用程序之间的时间占比,默认值为9,表示GC与应用程序时间占比为10%。增大该值将减少GC占用的时间,带来的后果就是动态扩展内存更容易发生,一般设置为19
  • G1ReservePercent:默认值是10,JVM对新生代内存分配管理时,在初始化或者内存的扩展或者收缩的时候会计算更新有多少个分区是保留的,在新生代分区初始化时,在空闲列表中保留一定的比例分区不使用,那么在对象晋升的时候就可以使用,能有效减少对象晋升失败的概率。最大不得超过50,否则会影响新生代的晋升,新生代可用空间过小可能会触发串行回收
  • MaxGCPauseMillisGC:指期望停顿时间,可根据系统配置和业务动态调整。要配合新生代大小的设置来确定,如果停顿时间设置过小,可能连新生代分区的收集都有问题。每次除了回收新生代之外最多回收一个老年代分区
  • GCPauseIntervalMillisGC:指GC间隔时间,默认为0,启发式推断为期望停顿时间+1,该值必须要大于MaxGCPauseMillis
  • G1ConfidencePercent:GC预测置信度,值越小说明基于过去历史数据的预测参考就越多

在以前的内存管理器中(非G1),为了防止新生代因为内存不断地重新分配导致性能变低,通常设置Xmn或者NewRatio。但是G1中不要设置MaxNewSize、NewSize、Xmn和NewRatio。原因有两个,第一G1对内存的管理不是连续的,所以即使重新分配一个堆分区代价也不高,第二也是最重要的,G1的目标满足垃圾收集停顿,这需要G1根据停顿时间动态调整收集的分区,如果设置了固定的分区数,即G1不能调整新生代的大小,那么G1可能不能满足停顿时间的要求。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值