Jvm相关知识点

一. JVM的内存布局

从Object角度来看:

1. 堆(Heap)

        Heap堆区是Java发生OOM(Out Of Memory)故障的地方,堆中存储着我们平时创建的实例对象,最终这些不再使用的对象会被垃圾收集器回收掉,而且堆是线程共享的。一般情况下,堆所占用的内存空间是JVM内存区域中最大的,我们在平时编码中,创建对象如果不加以克制,内存空间也会被耗尽。堆的内存空间是可以自定义大小的,同时也支持在运行时动态修改,通过 -Xms 、-Xmx 这两参数去改变堆的初始值和最大值。-X指的是JVM运行参数,ms 是memory start的简称,代表的是最小堆容量,mx是memory max的简称,代表的是最大堆容量;如 -Xms256M代表堆的初始值是256M,-Xmx1024M代表堆的最大值是1024M。由于堆的内存空间是可以动态调整的,所以在服务器运行的时候,请求流量的不确定性可能会导致我们堆的内存空间不断调整,会增加服务器的压力,所以我们一般都会将JVM的Xms和Xmx的值设置成一样,同样也为了避免在GC(垃圾回收)之后调整堆大小时带来的额外压力。

        堆区分为两大区:Young区和Old区,又称新生代和老年代。对象刚创建的时候,会被创建在新生代,到一定阶段之后会移送至老年代,如果创建了一个新生代无法容纳的新对象,那么这个新对象也可以创建到老年代。如上图所示。新生代分为1个Eden区和2个S区,S代表Survivor。大部分的对象会在Eden区中生成,当Eden区没有足够的空间容纳新对象时,会触发Young Garbage Collection,即YGC。在Eden区进行垃圾清除时,它的策略是会把没有引用的对象直接给回收掉,还有引用的对象会被移送到Survivor区。Survivor区有S0和S1两个内存空间,每次进行YGC的时候,会将存活的对象复制到未使用的那块内存空间,然后将当前正在使用的空间完全清除掉,再交换两个空间的使用状况。如果YGC要移送的对象Survivor区无法容纳,那么就会将该对象直接移交给老年代。上面说了,到一定阶段的对象会移送到老年区,这是什么意思呢?每一个对象都有一个计数器,当每次进行YGC的时候,都会 +1。通过-XX:MAXTenuringThrehold参数可以配置当计数器的值到达某个阈值时,对象就会从新生代移送至老年代。该参数的默认值为15,也就是说对象在Survivor区中的S0和S1内存空间交换的次数累加到15次之后,就会移送至老年代。如果参数配置为1,那么创建的对象就会直接移送至老年代。具体的对象分配即回收流程可观看下图所示。那么为什么这个默认值是15呢?因为对象的YGC次数是存在对象头的,这个存储空间是4个字节,只能存到15。如果Survivor区无法放下,或者创建了一个超大新对象,Eden和Old区都无法存放,就会触发Full Garbage Collection,即FGG,便再尝试放在Old区,如果还是容纳不了,就会抛出OOM异常。

        如上图所示,Java会根据对象存活时间的不同,把Java堆分为年轻代、老年代两个区域,年轻代还被进一步分为Eden区、From Survivor 0区、To Survivor 1区。

        当有对象需要分配时,一个对象永远优先被分配在年轻代的Eden区,等到Eden区域内存不够时,JVM找机会启动垃圾回收。此时Eden区中没有被引用的对象的内存会被回收,而一些存活时间较长的对象就会进入到老年代。在JVM中有一个名为-XX:MaxTenuringThreshold的参数专门来设置晋升到老年代所需要经历的GC次数,如果一个对象经历设置的GC次数,下一次GC就会将对象进入老年代。

        为什么Java堆要用这种区域划分的方案呢?根据我们实际经验,虚拟机中的对象必定会有存活时间长的对象,也有存活时间短的对象,而时间短的对象占大部分,这个规律是符合正态分布的,假设我们将他们混合在一起存储,那么存活短的对象有很多,那么势必会导致频繁的垃圾回收。而垃圾回收时,不得不对所有内存进行扫描,但是其中有些对象,他存活时间很长,对他们扫描简直就是浪费时间。因此为了提高垃圾回收效率,分区存储、分代管理还是有必要。

        还有一个值得我们思考的问题,为是什么默认JVM配置,Eden: from : to = 8:1:1呢?其实这也是经验之谈,IBM公司根据大量的项目统计得出的结果,根据IBM公司对对象存活时间的统计,发现80%的对象存活的时间很短。于是他们将Eden区设置为年轻代的80%,这样可以减少空间的浪费,提高内存空间的利用率。

        那么为什么需要设置两个Survivor区呢?设置两个Survivor区+使用复制算法,最大的好处就是解决了碎片化。为什么一个Survivor区不行?刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区,而Survivor区中的对象由于每次都会接收Eden的对象,同时还会Survivor区对象还会被会复制到Old区,这样继续循环下去,Survivor区就极易导致内存碎片化。碎片化带来的风险是极大的,堆中没有足够大的连续内存空间,浪费survivor空间。

        那么建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复,从而解决了内存碎片化的问题。

2. 元空间(Metaspace)

在JDK8版本中,元空间的前身Pern区已经被淘汰。在JDK7及之前的版本中,Hotspot还有Pern区,翻译为永久代,在启动时就已经确定了大小,难以进行调优,并且只有FGC时会移动类元信息。不同于之前版本的Pern(永久代),JDK8的元空间已经在本地内存中进行分配,并且,Pern区中的所有内容中字符串常量移至堆内存,其他内容也包括了类元信息、字段、静态属性、方法、常量等都移至元空间内。

3. 虚拟机栈(JVM Stacks)

Java 虚拟机栈是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。虚拟机栈是 JVM 运行时数据区域的一个核心,除了一些native方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。虚拟机栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。在活动线程中, 只有位于栈顶的帧才是有效的, 称为当前栈帧。正在执行的方法称为当前方法。在执行引擎运行时, 所有指令都只能针对当前栈帧进行操作。而StackOverflowError 表示请求的栈溢出, 导致内存耗尽, 通常出现在递归方法中。

4. 本地方法栈(Native Method Stacks)

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

5. 程序计数寄存器(Program Counter Register)

在程序计数寄存器(Program Counter Register,PC)中,Register的命名源于CPU的寄存器,CPU只有把数据装载到寄存器才能够运行。寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一个指令。这样必然会导致经常中断或恢复,如何才能保证分毫无差呢?每个线程在创建之后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常。简单来说:因为代码是在线程中运行的,线程有可能被挂起。即CPU一会执行线程A,线程A还没有执行完被挂起了,接着执行线程B,最后又来执行线程A了,CPU得知道执行线程A的哪一部分指令,线程计数器会告诉CPU。

从线程角度看:

二. GC的优化指标

        GC 是 garbage collection 的缩写,意思是垃圾回收——把内存(特别是堆内存)中不再使用的空间释放掉;清理不再使用的对象。

        堆内存是各个线程共享的空间,不能无节制的使用。服务器运行的时间通常都很长。累积的对象也会非常多。这些对象如果不做任何清理,任由它们数量不断累加,内存很快就会耗尽。所以GC就是要把不使用的对象都清理掉,把内存空间空出来,让项目可以持续运行下去。

STW:Stop the world,是在垃圾回收算法执行过程中将jvm内存冻结、停顿的一种状态在STW状态下,所有的线程都是停止运行的(垃圾回收线程除外),当STW发生时除了GC所需要的线程,其他的线程都将停止工作,中断了的线程知道GC线程结束才会继续任务,STW是不可避免的,垃圾回收算法的执行一定会出现STW,而我们最好的解决办法就是减少停顿的时间。

吞吐量: 吞吐量指的是运行用户代码占总时间的比例,它有一个计算公式为:吞吐量 = 应用程序运行的时间/ (应用程序运行的时间 + GC回收的时间);举个例子,假设程序运行时间为100s,GC垃圾回收时间为1秒,则吞吐量为100/(1+100) = 99%;如果这个值越小代表着垃圾回收占用的时间越多,GC垃圾回收占用时间多的原因就是堆内存不足导致垃圾回收的频率太多。gc的吞吐量越大,那么垃圾回收能力也就越强,jvm调优的标准就是在最大吞吐量优先的情况下,降低用户线程停顿时间。

Latency:一次STW的时间。使用多线程进行垃圾回收可以在一定程度上减少Latency耗时。

FootPrint:最终应用对内存的需求,某应用内存使用速度100M/S,回收速度是80M/S,最终会导致out of memory,需要STW回收,如果每10秒做一次GC(SWT全部加收),此时峰值是200M内存(FootPrint),所以说内存的使用是动态变化的。

三. 引用计数算法 和 根可达路径法

引用计数算法:在引用计数法中,每个对象都有一个引用计数器,记录着指向该对象的引用数量。当引用计数器为零时,表示没有任何引用指向该对象,该对象可以被释放,回收其占用的内存。当一个对象被引用时,引用计数器加一;当一个引用被释放时,引用计数器减一,通过不断地增减引用计数器,系统可以动态地追踪对象的引用情况,并在适当的时候回收不再被引用的对象。

引用计数法的优点包括:

  • 实时性好:当没有引用指向一个对象时,该对象可以立即被回收,释放内存资源。
  • 没有必要沿指针查找:引用计数法和 GC 标记 - 清除算法不一样,没必要由根沿指针查找。
  • 简单高效:引用计数法是一种相对简单的内存管理技术,实现起来较为高效。

引用计数法的缺点包括:

  • 循环引用问题:当存在循环引用的情况下,对象之间的引用计数可能永远不会为零,导致内存泄漏的发生。
  • 实现烦琐复杂:引用计数的算法本身很简单,但事实上实现起来却不容易。
  • 不支持并发:引用计数法在多线程环境下需要进行额外的同步操作,以确保引用计数的准确性,这可能导致一定的性能损失。
  • 额外开销:每个对象都需要维护一个引用计数器,这会带来一定的额外开销。

根可达路径法:基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

可作为GC Roots的六种对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。我们在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 本地方法栈中的JNI(native方法)引用的对象。
  • 方法区中的静态属性引用的对象和常量引用的对象。在类中定义了全局的静态的对象,也就是使用了static关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为GC Roots是必须的。还有常量引用,就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots。

        虚拟机标记对象是根不可达的之后,也不是直接回收对象,还会查看对象是否有finalize方法,如果有重写且在方法内完成自救[比如再建立引用],还是可以抢救一下,注意这边一个类的finalize只执行一次,这就会出现一样的代码第一次自救成功第二次失败的情况。[如果类重写finalize且还没调用过,会将这个对象放到一个叫做F-Queue的序列里,这边finalize不承诺一定会执行,这么做是因为如果里面死循环的话可能会时F-Queue队列处于等待,严重会导致内存崩溃,这是我们不希望看到的。

四. 三色标记清除算法

        三色标记法是Jvm中用来标记对象是否为垃圾的一种方法,主要是针对CMS、G1等垃圾收集器使用的,这类收集器都有一个垃圾回收线程与用户线程同时执行的并发过程,这是一般标记清除算法不能支持的。

三色标记法中的三色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过,也就是整个引用链还未全部扫完。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

三色标记法过程

  1. 首先从GC Roots开始标记,他们所有直接引用对象变成灰色,自己本身变为黑色(GC Roots对象本身不是垃圾),这里我们用队列去存储灰色对象,把这些灰色对象放到队列中。
  2. 然后从队列中取出灰色对象继续进行分析:将这个对象所有直接引用变成灰色,放入队列中,然后这个对象变为黑色;如果没有直接引用就直接变为黑色。
  3. 继续从队列中取出一个灰色对象,重复第二步,一直到灰色队列为空。
  4. 分析完后,仍然是白色的对象,就是不可大对象,可以作为垃圾被回收。
  5. 最后重置标记状态。

举个栗子:

刚开始所有对象都是白色:

三色标记初始阶段,遍历一次Root Set集合(不是递归遍历所有,只是遍历一遍),发现GC Roots直接引用(A、B、E)变成灰色,放入队列中,GC Roots变成了黑色:

然后从灰色队列中取出一个灰色对象进行标记,比如A、将他直接引用C、D变成灰色,放入队列,A因为已扫描完它的直接引用对象,所以变成黑色:

继续取出灰色对对象,比如取出B对象,将它的直接引用F标记为灰色,放入队列,B对象此时标记为黑色:

继续从队列中取出灰色对象E,但是E没有直接引用其他对象,将E标记为黑色:

根据上述步骤,取出C 、D 、F 对象进行分析,他们都没有直接引用其他对象,那么就变为黑色:

最终分析标记结束后,还有一个G对象是白色,说明此G 对象是一个垃圾对象,不可访问,可以被清理掉。

三色标记法就是为了使垃圾扫描阶段能够与用户线程并发执行而产生的,因为传统的标记清除算法,必须要暂停所有用户线程。但是并发标记一共会有两个问题:垃圾回收线程标记不是垃圾,同时用户用户线程将对象属性赋予null,取消引用,但是根据标记认为它不是垃圾,就是浮动垃圾问题;第二个是本来垃圾回收线程把它标记为垃圾,但是用户线程又有新的对象属性指向它,这个时候就会导致比较严重的漏标记问题

对于浮动垃圾来说,问题不大,大不了下次GC就能清除了,但是对于第二个问题就很严重,已经标记为垃圾了,结果又产生了引用,然后最后被回收了,肯定就会NPE了。

出现第二个问题必须满足两个条件:

  • 并发标记过程中黑色对象(A)引用了白色对象(F)
  • 灰色对象(B)断开了同一个白色对象(F)引用

--→

只要避免这两个条件的任何一个,就可以避免这种情况的发生,为了应对这两个问题,就出现两种解决方案:增量更新(CMS采用的方案)和原始快照(SATB,G1采用的方案)

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

如上图所示就是在赋值操作前添加一个写屏障,在写屏障中记录新增的引用,用户线程执行:A.f = F,那么在写屏障中将新增的这个引用关系记录下来。其实就是,当黑色对象新增一个白色对象的引用时,通过写屏障把这个关系记录下来。然后在重新标记阶段,再以此引用关系的黑色对象为根,再扫描一次,以保证不会漏标。要实现也很简单,在重新标记阶段直接把A对象变为灰色,放入灰色队列中,再来一次标记分析过程,但是如果此过程用户线程还在继续运行,那么也会有漏标的情况,所以重新标记需要STW,但是这个时间耗时不会太长,因为在并发标记的时候,已经把大部分对象都正确标记了。如果时间还是太长,可以设置在重新标记前,执行一次Minor GC,这个在CMS垃圾回收器中是可以设置参数-XX:+CMSScavengeBeforeRemark 。

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

如上图所示简单来说,就是赋值操作(B.f = null),那么在写屏障中,首先会把B.f 记录下来,在进行置空操作(B.f = null),记录下来这个对象(B.f指向的F对象)就可以称之为原始快照。记录下来之后呢?很简单,之后直接将他(F对象)变为黑色,意思就是默认认为它不是垃圾,不需要清理,当然这里的F有可能是垃圾,也有可能不是,如果是垃圾,就当作浮动垃圾,在下次回收的时候处理掉。但是总归不会出现漏标问题导致错误回收。

五. CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种基于标记清除算法,追求最短停顿时间的真正意义上的第一款并发垃圾收集器。CMS是老年代垃圾回收器,在回收过程中可以与用户线程同时进行,它可以与Serial回收器和Parallel New回收器搭配使用,Java9之后默认年轻代使用Parallel New回收器,并且不可更改,同时JDK9已经不推荐使用CMS,默认使用G1,并且JDK14已被删除了。CMS牺牲了吞吐量来追求回收速度,低延迟,低停顿。

如上图CMS主要分为:

  1. 初始标记只是标记一下GC Roots能直接关联到的对象,速度很快,这个过程需要STW。
  2. 并发标记就是从GC Roots开始遍历整个对象引用的过程,这个阶段时间较长,但是适合用户线程并发执行的不需要停顿用户线程,这个过程不需要STW。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。所以需要下一步
  3. 重新标记阶段是为了修正并发标记期间,因为用户线程执行而导致标记产生变动(错标、漏标)哪一部分对象(三色标记清除算法),这个阶段停顿时间通常比初始标记时间长,但也远比并发标记时间短,这个过程也需要STW。
  4. 并发清除阶段是清理删除标记阶段已被判处死亡的对象,由于基于清除算法,不需要移动存活对象,这个阶段可以和用户线程并发执行不需要STW。

使用CMS需要注意的几点

  • 占用CPU资源:与CPU核数挂钩。CMS默认启动的回收线程数为(处理器(CPU)核心数+3)/4 ,也就是说如果CPU的核心数在四个或者四个以上,并发回收的垃圾收集线程占用不少于1/4的CPU资源。假设我的服务器是2核的,那么(2+3)/4=1,需要一半的核心数来处理垃圾收集
  • 内存碎片问题:由于CMS使用的是标记清除算法,这种会产生内存碎片,导致后续大对象无法分配,就会触发Full GC。可以使用参数-XX:+UseCMSCompactAtFullCollection(默认开启,JDK9废弃),在进行Full GC 之前进行一次内存整理。虽然空间碎片解决了,但是停顿时间也增长了,CMS还提供一个参数-XX:CMSFullGCBeforeCompaction=n(默认为0,表示每次进入Full GC时都进行碎片整理),参数作用是当CMS执行n次不整理内存碎片后,下一次进入Full GC前先进行碎片整理。
  • 无法处理浮动垃圾:在并发回收阶段时,当用户线程并发创建了一个对象年轻代放不下,直接晋升到老年代或者年轻代对象超过存活次数晋升到老年代,由于存在这种现象,因此CMS垃圾回收器就必须预留一部分空间给用户线程,不能等老年代满了才去回收。CMS有一个触发垃圾回收阀值的参数:-XX:CMSInitiatingOccupancyFraction,默认值为92%,表示老年代内存达到92%开始进行垃圾回收,但是该参数需要配合-XX:+UseCMSInitiatingOccupancyOnly一起使用,单独设置无效。

六. G1收集器

G1(Garbage-First) 是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。在JDK1.7版本正式启用,是JDK9以后的默认垃圾收集器,取代了CMS收集器。

G1垃圾收集器也是基于分代收集理论设计的,但是它的堆内存的布局与其他垃圾收集器的布局有很明显的区别,G1收集器不再按照固定大小以及固定数量的分代区域划分,而是把JAVA堆划分为2048个大小相等的独立的Region,每个Region大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1-32MB,且必须为2的N次幂。每一个Region都可以根据需要充当新生代的Eden区、S0和S1区或者老年代。在一般的垃圾收集中对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。 G1的大多数行为都把H区作为老年代的一部分来看待。如果对象超过1.5个region,就放到H。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,而是一系列区域(不需要连续,逻辑连续即可)的动态集合。由于G1这种基于Region回收的方式,可以预测停顿时间。G1会根据每个Region里面垃圾“价值”的大小,在后台维护一个优先级列表,每次根据用户设定的允许收集停顿的时间(-XX:MaxGCPauseMillis,默认为200毫秒)优先处理价值收益最大的Region。

G1在逻辑上仍然采用了分代的思想,从整体来看是基于「标记-整理」算法实现的收集器,但从局部(两个Region之间)上看又是基于「标记-复制」算法实现,所以G1不会产生垃圾碎片。

G1收集器的大体流程:

  1. 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,让下一阶段用户线程并发运行时,能够在Region上正确的分配对象。这个阶段需要STW,耗时很短,而且是借用MinorGC(上一轮垃圾回收时触发GC)时候同步完成的。
  2. 并发标记:从GC Roots 开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象,这个过程耗时较长,但是是与用户线程并发执行的。对象扫描完之后还需要重新处理STAB记录下的在并发时有引用变动的对象。
  3. 最终标记:这个阶段也需要STW,用于处理并发阶段结束后仍然遗留下来的最后少量的原始快照记录。
  4. 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户期望的停顿时间来执行回收计划,然后把决定回收的Region里的存活对象复制到空的Region,然后清空旧Region的空间。由于涉及到对象的移动,所以这个阶段也是需要STW的。

从上述可以看出,除了并发标记,其他阶段都是需要STW的,G1收集器不单单是追求低延迟的收集器,也衡量了吞吐量,所以在延迟和吞吐量之间做了一个权衡。

G1的可预测的停顿时间模型:

G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

G1收集器会去跟踪各个Region里面的垃圾堆积的「价值」大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表。每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是「Garbage First」名字的由来。

这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。所以说G1实现可预测的停顿时间模型的关键就是Region布局和优先级队列。

G1如何处理对象引用关系改变

在三色标记清除算法中已经了解过,G1使用原始快照的方式处理对象引用关系改变。垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建。

G1为每一个Region设计了两个名为「TAMS(Top at Mark Start)」的指针。把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间Stop The World。G1可以通过-XX:MaxGCPauseMillis参数设置垃圾收集的最大停顿时间的JVM参数,单位为毫秒。

在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。G1收集器会根据这个设定值进行自我调整以尽量达到这个暂停时间目标。例如,如果设定了-XX:MaxGCPauseMillis=200,那么JVM会尽力保证大部分(但并非全部)的GC暂停时间不会超过200毫秒。

G1如何处理跨Region引用对象:

G1将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?G1使用记忆集来解决这个问题,G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。可以简单理解为,Region中有一个哈希表,映射了跨Region引用的地址信息,可以方便的找到跨Region的对象。使用记忆集固然没啥毛病,但是麻烦的是,G1的堆内存是以Region为基本回收单位的,所以它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

由于Region数量较多,每个Region都维护有自己的记忆集,光是存储记忆集这块就要占用相当一部分内存,G1比其他圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。这可以说是G1的缺陷之一。

G1 的适用场景

  • 超过50%的Java堆被活动数据占用
  • 对象分配频率或年代提升频率变化很大
  • GC停顿时间过长(长于0.5至1秒)
  • 8G 以上的堆内存 (建议值)
  • 停顿时间 500ms 以内

七. 其他的收集器

Jdk8默认使用的是 Parallel Scavenge + Parallel Old

  1. Serial 收集器
    Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。新生代采用标记-复制算法老年代采用标记-整理算法
            但是 Serial 收集器简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
  2. ParNew 收集器
    ParNew(Parallel New)垃圾收集器是Java虚拟机中的一种垃圾收集器,它是Serial收集器的多线程版本。ParNew收集器主要用于多核处理器的服务器环境,它能够利用多线程来加速垃圾收集过程,以提高垃圾收集的吞吐量。新生代采用标记-复制算法,老年代采用标记-整理算法。 ParNew 收集器通常搭配老年代收集器 CMS 使用,用于收集新生代的垃圾对象,当年轻代的内存空间不足时,ParNew收集器会触发一次新生代的垃圾收集,将存活的对象拷贝到Survivor区或老年代,并清理掉无用的对象。而CMS收集器负责老年代的垃圾收集,不会触发整个堆的垃圾收集,采用并发的方以减少停顿时间。
  3. Parallel Scavenge 收集器
    Parallel Scavenge 收集器是一种面向新生代的垃圾收集器,也是使用标记-复制算法的多线程收集器新生代采用标记-复制算法,老年代采用标记-整理算法。 Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。由于 Parallel Scavenge 收集器的设计目标是优化吞吐量,因此适用于那些对于暂停时间要求不是特别高的后台应用。
  4. Serial Old收集器
    Serial 收集器的老年代版本,是一种基于标记-整理算法的老年代垃圾收集器,它同样是一个单线程收集器,Serial Old 收集器通常搭配新生代的 Serial 收集器一起使用,这样整个堆内存的垃圾收集都由单个线程完成,适用于那些对资源要求不高,对响应时间要求相对较低的应用场景。在 JDK 8 及更新版本中,Serial Old 收集器已经不存在。
  5. Parallel Old收集器
    Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。Parallel Old 收集器使用标记-整理(Mark-Compact)算法来回收老年代中的内存空间。在标记阶段,会标记所有存活的对象;在整理阶段,会将存活的对象向一端移动,然后清理掉边界以外的内存,使得整个老年代空间变得更加连续。 Parallel Old 收集器的设计目标是在保证较高吞吐量的前提下,尽量减少应用程序的停顿时间。因此,适合对吞吐量要求较高的应用场景。Parallel Old 收集器通常与 ParNew 收集器或 Parallel Scavenge 收集器配合使用,形成完整的并行垃圾收集方案。新生代的垃圾收集由 ParNew 或 Parallel Scavenge 负责,老年代的垃圾收集由 Parallel Old 负责。
  6. ZGC垃圾收集器
    ZGC(Z Garbage Collector)是一款在jdk11中加入的具有实验性质的低延迟的垃圾收集器,在jdk15中去掉实验的标识,成为具有商用的垃圾收集器。垃圾收集停顿时间控制在10毫秒以内(在jdk16之后停顿时间已经控制到1毫秒以内)的一款低停顿的垃圾收集器。如果非要给ZGC下一个定义的话,ZGC收集器是一款基于Region内存布局的,不设分代(不分老年代、新生代)的,使用了读屏障、染色指针和内存多重映射等技术来实现的基于标记-整理算法实现的,以低延迟为首要目标的一款并发的垃圾收集器。使用–XX:+UseZGC 参数可以启用 ZGC。
    ZGC与G1收集器在内存布局上类似,也是使用基于Region(有的资料叫做Page/ZPage)的堆内存布局。但与G1不同的是,ZGC的Region是具有动态性的(动态创建和销毁以及动态的区域容量大小),根据容量大小分为 小、中、大三类容量。
    小型Region(Small Region):容量固定为2MB,用于放置大小小于256KB的小对象。
    中型Region(Medium Region):容量固定为32MB,用于放置大小大于等于256KB但小于4MB的对象。
    大型Region (Large Region):容量不固定,可以动态变化,但必须为2MB的整倍数,用于放置大于等于4MB的对象。每个大型Region中只会存放一个大对象,所以大Region实际容量并不一定比中型Region的容量大。
  • 15
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值