1 Star 0 Fork 0

cxylk / Java-Notes

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
jvm垃圾回收和调优.md 31.50 KB
一键复制 编辑 原始数据 按行查看 历史
cxylk 提交于 2023-02-16 11:25 . jvm gc

垃圾判断算法

引用计数法

在对象中添加⼀个属性⽤于标记对象被引⽤的次数,每多⼀个其他对象引⽤,计数+1,当引⽤失效时,计 数-1,如果计数=0,表示没有其他对象引⽤,就可以被回收。

好处:简单、效率高

坏处:无法解析循环引用的问题

可达性分析法

通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

内存池

所有垃圾回收算法都是针对内存池的。

为了避免频繁调用操作系统API去向操作系统申请分配内存、释放内存,引入了内存池的概念。

Memory Pool

即内存池,JVM中的内存模型管理器

Memory Chunk

内存块,比如堆、元空间等

Memory Cell

具体的对象占用的空间,一个cell占8字节(8字节对齐),一个对象可以有多个cell

垃圾回收算法

标记清除

最基础的收集算法,后续收集算法的基础

分两个阶段:

  • 标记:标记出所有需要被回收的对象(或标记存活的对象)
  • 清除:回收所有被标记的对象(或所有未被标记的对象)

存在问题:

  • 效率不稳定:当Java堆中包含大量对象,执行效率会随着对象数量的增长而降低
  • 内存碎片化问题:

标记复制

为了解决标记清除的效率问题。将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收

存在问题:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制开销。

优点:对于多数对象都是可回收的情况,需要复制的就是占少数的存活对象,实现简单、运行高效。所以很多虚拟机都采用了这种算法来回收新生代

缺点:将可用内存缩小为了原来的一半,空间浪费太多

标记整理

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

  • 做了什么?合并内存碎片,让内存回归到连续状态
  • 优点:可以合并内存碎片
  • 缺点:合并过程是一个CPU密集型的操作,所以会耗费CPU

它和“标记-清除”算法的不同之处就是它是移动式的。如果要移动存活对象,那么就会发生Stop The World:对象移动操作必须全程暂停用户应用程序才能进行

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。它建立在2个分代假说之上:

1、弱分代假说:绝大多数对象都是朝生夕灭的

2、强分代假说:熬过多次垃圾收集过程的对象越难以消亡

为了解决对象存在跨代引用而为内存回收带来很大的性能负担,出现了第三条经验法则:

3、跨代引用假说:跨代引用相对于同代引用来说仅占极少数

HotSpot的算法细节实现

根节点枚举

迄今为止,所有收集器在根节点枚举这一步骤时都必须暂停用户线程,也就是会面临“Stop The Word”的困扰。

为什么需要暂停用户线程?保证分析结果的准确性,防止分析过程中,根节点集合的对象引用关系不断发生变化。而且根节点是垃圾回收的起点,它是所有存活对象的直接或间接引用的集合,如果在根节点枚举期间,用户线程继续执行并且创建了新的对象,那么这些新对象可能被误判为垃圾对象,从而被错误的回收。

当用户线程停下来后,并不需要一个不漏地检查完所有执行上下文(例如栈帧中的本地变量表)和全局引用(例如常量池或类静态属性)的引用位置,虚拟机应当是有办法直接得到哪些地方存在这对象引用的。Hotspot的解决方案就是使用一组称为OopMap的数据结构达到这个目的。OopMap可以简单理解为是记录这个线程一路跑下来经历过的所有Java对象的集合,是一种空间换时间的策略。

为什么需要OopMap?说简单点就是查找根对象的时候不用去扫描整个栈。

至于何时更新OopMap(不更新的话,没有这个数据,GC时就需要扫描所有线程的所有栈的所有栈帧来查找根对象),在什么地方更新OopMap,这就需要理解安全点。

关于OopMap的结构,这里引用知乎上面的一张图

补充:在JVM中,OopMap是记录方法栈帧中对象指针位置信息的一种数据结构。OopMap可以帮助JVM在进行垃圾回收时准确的识别哪些对象是存活的,从而避免对已经被回收的对象进行额外的扫描,从而提高垃圾回收的效率。在方法的字节码中,对象引用的位置可能会被优化,例如寄存器重分配、栈顶重排等,这会导致JVM在进行垃圾回收时无法准确地判断哪些位置存储的是对象引用,哪些位置存储的是其他数据。OopMap记录了方法栈帧中哪些位置存储的是对象引用,以及对应的对象类型信息,从而解决了这个问题。

安全点

有了OopMap,Hotspot可以快速准确的完成GC Roots枚举。但这会带来另外一个问题:可能导致引用关系变化。

Hotspot不会为每条指令都生成OopMap(这样就会导致OopMap过于庞大,影响性能),只是在特定的位置记录下栈里和寄存器里哪些位置是引用,而这个特定的位置就是安全点

有了安全点,用户线程就不能在任意位置都能停顿下来开始执行垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

如何在发生垃圾收集时让所有线程跑到最近的安全点,然后停顿下来?

  • 抢先式中断
  • 主动式中断:现在都采用的是这种方式

安全点同时解决了STW及更新OopMap的问题

我们来看下核心源码(safepoint.cpp)

// Roll all threads forward to a safepoint and suspend them all
void SafepointSynchronize::begin() {
    ...
  // Make interpreter safepoint aware
  //通知解释器做好准备工作,迎接GC到来
  Interpreter::notice_safepoints();
    ...
    //将polling_page对应的物理页设置成不可读
    os::make_polling_page_unreadable();
  // wait until all threads are stopped
  while (_waiting_to_block > 0) {
      ...

上面3行代码做了如下三件事:

1、告诉JVM马上要开始GC了,需要做一些准备工作,比如通知解释器做好准备工作,迎接GC到来

2、将polling_page对应的物理页设置成不可读,这步非常重要

3、不停检测,确定是否所有的线程都已进入安全点,只有都已进入安全点,才能执行GC逻辑

STW的实现

STW在前面说过,即GC线程与用户线程无法并发运行,GC期间需要暂停用户线程。如果不暂停的话,那么GC线程一边收集垃圾,用户线程一边制造垃圾,那么垃圾就没完没了了。

继续看上面这段代码,在开启安全点为什么要将物理页的属性改为不可读呢?

因为JVM在生成执行流代码的时候,都会在适合作为安全点的地方插入一段代码

test %eax, os::_polling_page

这段代码就是安全点的本质,也是触发STW的本质。如果os::_polling_page对应的物理页属性是可读的,这段代码并没有什么特殊意义,但是如果是不可读的,都的时候就会触发段异常,对应的操作系统信号:SIGSEGV

JVM捕获了这个异常,并进行了处理,所有的线程都是这个地方STW的。源码:os_linux_zero.cpp

JVM_handle_linux_signal(int sig,
                        siginfo_t* info,
                        void* ucVoid,
                        int abort_if_unrecognized) {    
    ...
	// Check to see if we caught the safepoint code in the process
    // of write protecting the memory serialization page.  It write
    // enables the page immediately after protecting it so we can
    // just return to retry the write.
    //捕捉到SIGSEGV信号
    if (sig == SIGSEGV &&
        os::is_memory_serialize_page(thread, (address) info->si_addr)) {
      // Block current thread until permission is restored.
      //在这里阻塞
      os::block_on_serialize_page_trap();
      return true;
    }
  }

当GC结束后唤醒所有阻塞的线程

// Wake up all threads, so they are ready to resume execution after the safepoint
// operation has been carried out
void SafepointSynchronize::end() {
    ...
  	if (PageArmed) {
    	// Make polling safepoint aware
    	//将物理页设置为可读
    	os::make_polling_page_readable();
  	  	PageArmed = 0 ;
  	}
  	...
    // Release threads lock, so threads can be created/destroyed again. It will also starts all threads
    // blocked in signal_thread_blocked
    Threads_lock->unlock();
}

当线程到达SafePoint时,会在OopMap中标记所有对象引用的位置。在JVM的垃圾回收阶段,根据OopMap中的标记信息进行垃圾回收。这样,即使每条指令都不生成OopMap,HotSpot虚拟机仍然可以准确地追踪所有对象引用,并且避免对已经被回收的对象进行额外的扫描,提高垃圾回收的效率

安全区域

安全点机制保证了程序执行时,在不太长的时间就会遇到可进入垃圾收集过程的安全点。但是,如果程序不执行,比如用户线程处于sleepblocked状态,这时候线程就不能走到安全的地方去中断挂起自己,虚拟机也不会等到线程重新激活在分配处理器时间,这时候就必须引入安全区域来解决。

安全区域可以确保在某一段代码段之内,引用关系不会再发生变化,所以,在这个区域中任意地方开始垃圾收集都是安全的。安全区间可以看作被扩展拉伸了的安全点

记忆集与卡表

在说这个之前,先来说下对象的跨代引用:

1、新生代->新生代,这种引用是不会有问题的

2、新生代->老年代,会造成多标、浮动垃圾,但对程序运行来说没有问题

3、老年代->老年代,也没问题

4、老年代->新生代,这就有问题了,比如新生代中的对象被GC了,那么就会产生空指针问题。

如何解决呢?就是通过记忆集

记忆集

在分代理论收集中,为了解决对象跨代引用所带来的问题,垃圾收集器会在新生代中建立名为记忆集的数据结构,用来缩减GC Roots扫描范围的问题(避免把整个老年代加进GC Roots扫描范围)

记忆集是一种用于记录从非收集区域指向收集区域指针集合抽象数据结构

记忆集存在的意义:因为对象都是“朝生夕灭”的,等不到进入老年代,所以没有必要给每个对象记录跨代引用记录,只需要记录哪些老年代的对象有指向年轻代的引用即可。

卡表

具体的实现就是卡表

HotSpot中默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

卡表的每一个元素都对应这其标识的内存区域中一块特定大小的内存块,叫做卡页(Card Page)。卡页大小都是2的N次幂字节数,如上面代码所示是2的9次幂,即512字节。

脏页

一个卡页通常包含不止一个对象,只要卡页有一个(或多个)对象的字段存在跨代指针,就将对应的卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。那么在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块包含跨代指针,把他们加入GC Roots中一并扫描。

关于这部分内容,在G1收集器的时候还会详细讲解。

存在2个问题
  • 写屏障开销,不过这个开销于发生Minor GC时扫描整个老年代代价相比低很多,见下文
  • 伪共享
    • 解决方法一:先检查卡表标记,只有当卡表没有被标记时才将其标记为变脏
    • 解决方法二:JDK7新增参数:-XX:+UseCondCardMark

注意:在某些情况下,卡表元素也是可以手动变脏的,比如,当使用JNI来操作堆内存时,Java程序可能无法实时地跟踪对象引用的变化。在这种情况下,程序可以手动标记卡表项变脏,以确保垃圾回收器能够正确地处理对象引用的变化。

写屏障

怎么解决卡表元素维护的问题?比如卡表何时变脏、谁来把它们变脏等。

何时变脏

当其它区域中的对象引用了本区域对象时,对应的卡表元素就变脏、变脏时间点发生在引用类型字段赋值的那一刻。

如何变脏

通过写屏障维护卡表状态,为所有赋值操作生成相应的指令,在赋值前的部分的写屏障叫做写前屏障,在复制后的则叫做写后屏障。类似于Spring的AOP。

1 写前屏障()
2 写操作
3 写后屏障()

并发的可达性分析

所有的垃圾回收算法都要经历标记阶段。如果GC线程在标记的时候暂停所有⽤户线程(STW),那就没三色标记什么事了。但是这样会有⼀个问题,⽤户线程需要等到GC线程标记完才能运⾏,给⽤户的感觉就是 很卡,⽤户体验很差。 现在主流的垃圾收集器都⽀持并发标记。什么是并发标记呢?就是标记的时候不暂停或少暂停⽤户线程, ⼀起运⾏。这势必会带来三个问题:多标、少标、漏标。垃圾收集器是如何解决这个问题的呢:三⾊标记 +读写屏障。

三色标记

把遍历对象过程中遇到的对象,按照“是否访问过”这个条件标记成三种颜⾊:

  • 白色:尚未访问过,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。
  • 黑色:本对象已访问过,⽽且本对象 引⽤到 的其他对象也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引⽤到 的其他对象尚未全部访问完。全部访问后,会转换为黑色。

三个问题

多标

GC线程已经标记了B,但是此时用户线程中的代码中断开了A对B的引用,而因为此时B已经被标记为了灰色,本轮GC不会被回收,这就是所谓的多标。多标会造成浮动垃圾,躲过本轮GC。对程序逻辑是没有影响的,下次收集清理掉就好。

少标

并发标记开始后创建的对象,都视为黑色,本轮GC不清除。少标也会造成浮动垃圾

漏标

当GC把B标记完,准备标记B引用的对象,此时用户线程中将B对D的引用断开了,改为A对D的引用。但是A已经被标记为黑色,不会再对它扫描,而D还是白色,那么D就会被回收,程序就会出错,比如发生空指针异常

如何解决漏标

多标和少标是可以容忍的,无非就是产生了一些逃过本轮GC的浮动垃圾而已,下次清除就好了。但是漏标会产生程序错误,所以必须要解决。

我们分析下上面这张图是如何产生漏标的:

条件一:黑色对象重新引用了白色对象,即黑色对象的成员变量增加了新的引用。

条件二、灰色对象断开了白色对象的引用,即灰色对象的成员变量的引用发生了变化。

解决方法就是破环这两个条件的任意一个即可

写屏障+增量更新(IU)

增量更新破坏的是第一个条件:当黑色对象A插入了新的指向白色对象D的引用时,将就这个新插入的引用记录下来(OopMap),等并发标记结束后,再将这些记录过的引用关系中的黑色对象A为根,重新扫描一次。可以理解为黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了

写屏障+原始快照(SATB)

原始快照破化的是第二个条件:当灰色对象B删除了指向B色对象D的引用关系时,就将这个要删除的引用记录下来(OopMap),在并发扫描结束后,再将这些记录过的引用关系中的灰色对象B为根,重新扫描一次。可以理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

实际应用

CMS:写屏障(准确说是写后屏障)+增量更新

G1:写屏障+原始快照

思考

关于三色标记的2个问题

1、新创建的对象为什么只能标记成黑色

首先要明白三色标记的出现是因为并发垃圾收集(用户线程和GC线程同时存在)期间数据会出现变动,变动有2种情况

  • 新创建的对象
    • 现有的解放方案是标记成黑色,本轮GC不管,造成少标也只会产生逃过本轮GC的一点浮动垃圾,对程序逻辑没影响
    • 如果标记成白色,那么:1、GC永远结束不了;2、因为白色需要去扫描,比如在CMS等并发垃圾收集器上,在重新标记阶段又要回到初始标记阶段,那么标记阶段就结束不了
  • 已有的引用间的关系变动,上面说过,这种情况会导致多标和漏标问题产生
    • 漏标,使用增量更新或原始快照解决即可
    • 多标,那么会造成一些逃过本轮GC的浮动垃圾,不会影响程序运行

2、经过一轮三色标记后,对象的颜色是如何还原的

三色标记后,对象只可能是白色或者黑色,白色的对象会被清理掉,而黑色对象**在移动的时候会设置成无色 **

垃圾收集器

前置知识

串行

指一个GC线程运行。GC时,会暂停所有工作线程,直到GC结束

并行

多个GC线程同时运行。会先暂停mutator(可以理解为用户线程)的运行,然后开启多个线程并行地执行GC

并行GC的目标是尽量缩短用户线程的暂停时间

并发

多个GC线程与用户线程同时运行

并发GC的目标是消除用户线程的暂停时间

CMS

CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的垃圾收集器。采用标记-清除回收算法实现。

四个步骤

1、初始标记

只标记GC Roots能直接关联到的对象,速度很快,会STW(就是前面说过的根节点枚举)

2、并发标记

从GC Roots的直接关联对象开始遍历整个对象图(OopMap)的过程,可想而知需要的时间较长,但是不会STW,能感知到CPU飙升,但是不会出现卡顿现象。

3、重新标记

CMS通过写屏障(写后屏障)+增量更新记录了并发标记阶段新建立的引用关系,重新标记就是去遍历整个记录。这个过程会STW

4、并发清除

清除未被标记的对象,即死亡对象。因为不需要移动存活对象(采用的是标记-清除算法),所以GC线程可以和用户线程并发运行。

优点

1、并发收集

2、停顿时间短

耗时最长的并发标记和并发清除阶段都不会STW,GC线程可以和用户线程一起运行。

缺点

1、对CPU资源敏感,运行期间会和用户线程抢夺CPU资源。这是所有并发垃圾收集器的缺点

默认启动的回收线程数=(处理器核心数量+3)/4。所以当处理器核心数量不足4个时,就会对用户产生较大的影响。

2、无法处理浮动垃圾(标记结束后创建的对象)

因为并发标记和并发清除阶段用户线程还在运行,就会伴随这新的垃圾对象产生,而这些对象出现在标记阶段以后,本轮GC无法处理。

3、因为使用的是“标记-清除”算法,所以收集结束后会产生大量的空间碎片。

带来的问题就是当分配大对象时无法找到足够大的连续空间而提前触发一次Full GC。

G1

介绍

G1开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

如上图所示,G1将堆分成了一个一个Region,这些Region在用的时候会被赋予不同的角色:Eden、Survivor、Old、Humongous,一个Region只能是一个角色。这里需要注意Humongous区域,它专门用来存储大对象,即大小超过一个Region容量一半的对象。如果对象大小超过了整个Region的大小,那么会将它存放在N个连续的Humongous Region中。Region的大小通过参数-XX:G1HeapRegionSize设定。Region的取值范围是1M-32M,默认是2048个

// Minimum region size; we won't go lower than that.
// We might want to decrease this in the future, to deal with small
// heaps a bit more efficiently.
#define MIN_REGION_SIZE  (      1024 * 1024 ) //最小Region

// Maximum region size; we don't go higher than that. There's a good
// reason for having an upper bound. We don't want regions to get too
// large, otherwise cleanup's effectiveness would decrease as there
// will be fewer opportunities to find totally empty regions after
// marking.
#define MAX_REGION_SIZE  ( 32 * 1024 * 1024 ) //最大Region

// The automatic region size calculation will try to have around this
// many regions in the heap (based on the min heap size).
#define TARGET_REGION_NUMBER          2048 //默认的Region数量

G1名字的由来

回收某个Region的价值大小=回收获得的空间大小+回收所需时间

G1收集器会维护一个优先级列表,每个region按价值大小排序存放在这个优先级列表中,收集时优先收集价值更大的region,这就是G1名字的由来。

四个步骤

1、初始标记

做了两件事

  • 标记GC Roots能直接关联到的对象
  • 修改TAMS指针的值,TAMS以上的值为新创建的对象,这些对象被隐式标记过,默认它们是存活的。

这个过程会STW,但耗时很短

2、并发标记

从GC Roots能直接关联到的对象开始遍历整个对象图。不会STW

3、最终标记

遍历写屏障+STAB记录下的旧的引用对象图,需要STW

4、筛选回收

更新region的统计数据,对各个region的回收价值进⾏计算并排序,然后根据⽤户设置的期望暂停时间的 期望值⽣成回收集。 然后开始执行清除操作。将旧的region中的存活对象移动到新的Region中,清理这个旧的region。这个 段需要STW。

再谈卡表

前面已经说过了记忆集和具体的实现卡表,但是G1的记忆集更复杂。因为它的每个region都维护有自己的记忆集。G1的记忆集在存储结构的本质上是一种哈希表,key是别的region的起始地址,value是一个集合,里面存储的是卡表的索引号(指向卡页),也就是说这种卡表结构是双向的,不仅记录了“我指向谁”,还记录了“谁指向我”。

假设一个region占2M,我们看下region和卡页及卡表之间的关系:

每个卡表元素对应512字节的卡页,每个region是2M,那么有2M/512B=4KB/1B,即卡页中1B管理了Region中的4KB内存。

G1和CMS相比的优劣势

优势

1、可以指定最大停顿时间

2、基于内存的region布局

3、按收益动态确定回收集

4、空间整理

CMS是基于“标记-清除”,会产生内存空间碎片问题。但G1从整体来看是基于“标记-整理”算法实现,从局部(两个region)来看是基于“标记-复制”算法实现。这两种算法都不会产生内存碎片问题,垃圾收集完后都能提供规整的可用内存,这样的话就不会因为分配大对象没有足够的规整内存而触发下一次收集。

劣势

1、内存占用大

G1的记忆集实现复杂,每个region无论扮演什么角色都会维护一个卡表,所以G1的记忆集可能会占用整个堆容量的20%乃至更多内存。

2、执行负载大

CMS使用写后屏障来维护卡表,而G1因为实现的卡表复杂,还需要写前屏障,这在用户程序过程中会产生由跟踪引用变化带来的额外负担。

GC和内存模型关系

内存模型是根据所选择的垃圾收集器决定的,而不是说垃圾收集器是根据内存模型来决定。

我们看下源码就知道了(universe.cpp)

//初始化堆
jint Universe::initialize_heap() {
  //使用ParallelGC
  if (UseParallelGC) {
#if INCLUDE_ALL_GCS
    Universe::_collectedHeap = new ParallelScavengeHeap();
#else  // INCLUDE_ALL_GCS
    fatal("UseParallelGC not supported in this VM.");
#endif // INCLUDE_ALL_GCS

  } else if (UseG1GC) {//使用G1GC
#if INCLUDE_ALL_GCS
    G1CollectorPolicy* g1p = new G1CollectorPolicy();
    g1p->initialize_all();
    G1CollectedHeap* g1h = new G1CollectedHeap(g1p);
    Universe::_collectedHeap = g1h;
#else  // INCLUDE_ALL_GCS
    fatal("UseG1GC not supported in java kernel vm.");
#endif // INCLUDE_ALL_GCS

  } else {
    GenCollectorPolicy *gc_policy;
    //使用SerialGC
    if (UseSerialGC) {
      gc_policy = new MarkSweepPolicy();
    } else if (UseConcMarkSweepGC) {
#if INCLUDE_ALL_GCS
      if (UseAdaptiveSizePolicy) {
        gc_policy = new ASConcurrentMarkSweepPolicy();
      } else {
        gc_policy = new ConcurrentMarkSweepPolicy();
      }
#else  // INCLUDE_ALL_GCS
    fatal("UseConcMarkSweepGC not supported in this VM.");
#endif // INCLUDE_ALL_GCS
    } else { // default old generation
      gc_policy = new MarkSweepPolicy();
    }
    gc_policy->initialize_all();

    Universe::_collectedHeap = new GenCollectedHeap(gc_policy);
  }

  jint status = Universe::heap()->initialize();
  if (status != JNI_OK) {
    return status;
  }
  ...

内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代的Eden区分配。当Eden没有足够的空间时,将触发一次Minor GC(Young Gc)

大对象直接进入老年代

大对象:需要大量连续内存空间的java对象,比如字符串以及数组。

why?避免大对象分配内存时由于分配担保机制带来的复制而降低效率。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1.对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

动态对象年龄判定

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

动态年龄计算的代码如下:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
 //survivor_capacity是survivor空间的大小
 size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
 size_t total = 0;
 uint age = 1;
 while (age < table_size) {
     //sizes数组是每个年龄段对象大小
     total += sizes[age];
     if (total > desired_survivor_size) {
         break;
     }
     age++;
 }
 uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
 ...
}

空间担保机制

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

亿级流量调优

这里以亿级流量秒杀电商系统为例:

1、如果每个用户平均访问20个商品详情页,那访客数约等于500w(一亿/20)

2、如果按转化率10%来算,那日均订单约等于50w(500w*10%)

3、如果30%的订单是在秒杀前两分钟完成的,那么每秒产生1200笔订单(50w*30%/120s)

4、订单⽀付⼜涉及到发起⽀付流程、物流、优惠券、推荐、积分等环节,导致产⽣⼤量对象,这⾥我们假 设整个⽀付流程⽣成的对象约等于20K,那每秒在Eden区⽣成的对象约等于20M(1200笔 * 20K)

5、在⽣产环境中,订单模块还涉及到百万商家查询订单、改价、包邮、发货等其他操作,⼜会产⽣⼤量对 象,我们放⼤10倍,即每秒在Eden区⽣成的对象约等于200M(其实这⾥就是在⼤并发时刻可以考虑服务 降级的地⽅,架构其实就是取舍) 这⾥的假设数据都是⼤部分电商系统的通⽤概率,是有⼀定代表性的。

假设分配给堆的初始大小为8G(生产环境肯定比这个大),那么堆中各区域的内存布局如下:

依照上面的分析,每秒在Eden区生成的对象约等于200M,那么2.2G/200M/s=11s,也就是说11s内Eden区就会被占满。如果一个请求占3s,那么这时来了一个请求,就会产生3*200M=600M对象,这600M对象都还在使用,也就是说还能通过引用找到他们,那么他们就无法被回收,而这600MSurvivor区是放不下的,所以这时候会触发空间担保机制,提前将Survivor放不下的对象也就是这600M提前转移到老年代去。而老年代大小是5.4G,那么经历5.4G/600M=9次ygc后,就会触发一次fgc。而一次ygc触发时间是11s,那么9次ygc就是99s,也就是说99s触发一次fgc

经过上面的分析,我们知道在堆内存8G的情况下,11s触发一次ygc,99s触发一次fgc,而正常的频率应该是

5分钟一次ygc,一天一次fgc。所以需要对当前系统调优。

调优怎么调呢?当前系统不要触发空间担保即可,言外之意就是Survivor的大小要大于600M,假设就以600M来算,根据Eden:Survivor=8:1,那么Eden就需要4800M,那么新生代大概需要4800+600*2=6G,而老年代是新生代的2倍,那么老年代就需要12G,整个堆就需要6+12=18G的大小。由于我们估算的比价大,所以单机有16G内存就足够了。当然,实际情况可能秒杀的流量更大,产生的对象也不一定和分析一致,所以还是要以系统实际情况为主。

调优的目的:避免OOM<--避免full gc<--避免young gc

单次gc时间在100ms以内(0.1s)

1
https://gitee.com/cxylk/Java-Notes.git
git@gitee.com:cxylk/Java-Notes.git
cxylk
Java-Notes
Java-Notes
main

搜索帮助

53164aa7 5694891 3bd8fe86 5694891