JVM内存结构、GC算法以及各种垃圾收集器介绍

    之前有“(updated)Allocation Failure引发的一些GC知识梳理”这篇文章中简单介绍过GC相关的一些知识点,但是总结的并不是很全面,本片文章希望能尽量详细分析下JVM内存结构、GC算法以及各种垃圾收集器介绍。

 

JVM内存结构如下:

JVM内存结构、GC算法以及各种垃圾收集器介绍_第1张图片

主要包括了下面几个部分:

  1. 程序计数器(线程私有)

存放着当前线程所执行的字节码的行号指示器

  1. 栈(线程私有)

每个方法被执行的时候都会创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法被调用直到执行完的过程就对应了一个栈帧在JVM中从入栈到出栈的过程。

  1. 本地方法栈(线程私有)

与上面的栈相似,不同的是它是为执行native方法用的

  1. 方法区(永久代,对应JDK1.8中元空间,线程共享)

存放JVM加载的类信息、常量,静态变量,即编译器编译后的代码等数据

  1. 堆(线程共享)

JVM中内存最大的一块区域,在JVM启动的时候创建,用于存放几乎是所有对象的实例

 

JVM堆大小相关的一些配置:

堆 = 年轻代 + 老年代,Sun官方建议年轻代大小为整个堆的3/8左右

    -Xmx: 最大堆内存

    -Xms: 初始堆内存大小,一般设置成和-Xmx一样,防止堆空间收缩引起额外的性能损耗

    -Xss:每个线程的栈大小

 

GC算法针对的是堆:

    JVM中的垃圾收集器回收的对象主要就是堆中的部分,因为每一个栈帧分配多少内存在类结构确定的情况下就是已知的,所以内存分配和回收比较容易。而方法区中的部分回收的性价比比较低,因为这一区域存放的都是一些常量以及类信息,经常会被用到,所以在JDK8中把这部分迁移到了本地内存来做。

    而堆就不同,堆存放的是一些对象实例,很多都是一些朝生夕死的对象,一次回收就能回收大部分的空间,所以GC回收算法针对的主要是堆区域。

 

JVM调优的一些见解:

    1.GC调优的目的只有一个,尽量让对象在年轻代就回收掉,不要让它进入老年代

    2.大的年轻代会增加每次minor GC的时间,但是会延长GC的周期,而且大的年轻代说明老年代相对较小,会导致更频繁的Full GC

    3.通过参数设置GC调优一般是最后的手段,多数GC的问题一般都是代码的问题,应该首先考虑优化自己的代码

 

再次提一下,怎样判断哪些对象需要回收:

    一般使用的是可达性分析法(Reachability Analysis),从GC Roots开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象没有任何引用链和它连接的时候,证明对象是不可用的,那这些对象就可以回收了。

    GC Roots主要是栈和方法区中的对象(堆外指向堆内的引用),包括:

  1. JVM栈中引用的对象
  2. 本地方法栈中引用的对象
  3. 方法区中静态属性引用的对象
  4. 方法区中常量引用的对象

 

 

 

垃圾收集算法简介:

    主要有如下几种:标记-清除、复制、标记-整理、分代收集算法

    响应能力和吞吐量是不一样的,响应能力指的是一个程序对系统的请求能够在多少时间内返回,而吞吐量关注的是一个指定时间内,一个系统能处理多少请求。GC停顿的次数和时间肯定是越少越好,但是如果要求GC停顿的次数少,那么每次都会到内存撑爆的时候进行GC,这样GC停顿的时间就长了,所以这两个是个悖论,要根据自己系统的需求进行折衷选取。

    对于关注吞吐量的系统,卡顿是可以接受的,因为这个系统并不会关注单次响应要在多长时间内返回。

 

标记-清除:

    分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,完成标记之后然后统一回收掉。

有两个缺点:一是这两个阶段的效率都不高,二是会产生大量不连续的内存碎片,导致无法为大对象分配足够连续的内存从而触发Full GC。

JVM内存结构、GC算法以及各种垃圾收集器介绍_第2张图片

 

复制:

    将内存划分为两块相同的区域,每次只使用其中一块。当这一块用完时,将这一块上海存活的对象复制到另外一块上,在把当前这块内存的内存空间一次性清理掉。

    优点是不用考虑内存碎片的情况了,而且分配的时候寻找空闲的内存只要移动下堆顶指针即可,内存分配简单。

    缺点是只使用一般导致利用率不高,并且如有对象是长期存活的话,复制这种对象会导致效率变低。

JVM内存结构、GC算法以及各种垃圾收集器介绍_第3张图片

 

标记-整理:

    过程和“标记-清除”差不多,标记挖之后,算法会让所有仍然存活的对象向一段移动,然后直接清理掉端边界以外的内存。

JVM内存结构、GC算法以及各种垃圾收集器介绍_第4张图片

 

分代收集算法:

    把Java堆分成了新生代和老年代,新生代主要都是一些朝生夕死的对象,而老年代都是一些存活时间很久的对象。所以新生代中一般选用复制算法,而老年代因为对象存活率高,没有额外的空间对它进行分配担保,必须使用“标记-清理”或者“标记-整理”算法。

 

JMV的Client与Server模式:

    JVM在运行的时候有两种模式,一种是Client模式,另外一种是Server模式。两种的区别在于前者启动快,但是在进入稳定运行之后,后者的速度比前者要快很多,原因是当虚拟机运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器, 而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器。C2比C1编译器编译的相对彻底,服务起来之后,性能更高。

    JVM虚拟机启动的时候,会根据机器的CPU核数以及内存大小自动选择使用哪种模式,当然也可以自己制定,Linux上对应的路径为:

jdk1.8.0_162/jre/lib/amd64/jvm.cfg

JVM内存结构、GC算法以及各种垃圾收集器介绍_第5张图片

可以使用java –version查看当前是运行在什么模式下:

 

垃圾收集器的具体实现:

JVM内存结构、GC算法以及各种垃圾收集器介绍_第6张图片

    如上图所示,垃圾回收算法一共有7个,3个属于年轻代、3个属于年老代,还有一个G1和其他6个有所不同,它抛弃了新生代和老年代的概念,具体后续细说。

JVM会从年轻代和年老代各选出一个算法进行组合,连线表示哪些算法可以组合使用,逐一简单介绍下这些垃圾收集器。

 

Serial:

    该收集器是一款单线程的新生代收集器,采用的是复制算法,是JVM在Client模式下默认的垃圾收集器,回收时会导致STW。单线程是指它只用单个线程去完成垃圾回收的动作,图示如下:

JVM内存结构、GC算法以及各种垃圾收集器介绍_第7张图片

ParNew:

    和上面的Serial差不多,也是在新生代,也是复制算法,回收的时候也会STW。不同的是它是一款多线程的垃圾收集器,可以理解为它是Serial的多线程版本。它是JVM在Server模式下默认的垃圾收集器,图示如下:

JVM内存结构、GC算法以及各种垃圾收集器介绍_第8张图片

Paralle Scavenge:

    它的GC机制和上面的ParNew是一样的,不同的是这款垃圾收集器可以控制吞吐量,它不能和CMS组合使用。吞吐量=运行代码时间/(运行代码时间+垃圾收集时间)。较高的吞吐量可以最好的利用CPU的效率。

    可以通过参数进行设置吞吐量以及在多少时间范围内完成垃圾回收。通过-XX:UseAdaptiveSizePolicy:参数开关,启动后系统动态自适应调节各参数,如-Xmn、-XX:SurvivorRatio等参数,这是和ParNew收集器重要的区别

Serial Old:

    在老年代中使用,仍然使用单线程进行垃圾回收,使用的是“标记-整理”算法,会导致STW。图示如下:

JVM内存结构、GC算法以及各种垃圾收集器介绍_第9张图片

它主要是在Client模式下使用。

在Server模式有两大用途:JDK1.5之前与Parallel Scavenge收集器搭配使用。它作为CMS收集器的后备预案,如果CMS失败的话会使用它进行垃圾回收

Parallel Old:

Parallel Old是Parallel Scavenge收集器的老年代版本,也采用“标记-整理”算法,但只能配合Parallel Scavenge使用,适用在注重吞吐量以及CPU资源敏感的场合采用。

JVM内存结构、GC算法以及各种垃圾收集器介绍_第10张图片

CMS:(Concurrent Mark Sweep)

    它是一种以最短STW为目的的一种垃圾收集器,CMS只会回收老年代和永久代(1.8开始为元数据区,不归JVM管了)(默认是老年代或者永久代达到92%会进行触发一次Full GC),它的GC会分成四个步骤进行:

  1. 初始标记 会STW,标记下GC Roots能直接关联到的对象,速度很快
  2. 并发标记 即GC Tracing的过程,在三个标记中最慢
  3. 重新标记 会STW,修正“2”执行期间标记产生变动的那一部分的对象记录,时间比“1”稍长,但是远比“2”短
  4. 并发清除 用户线程和清理垃圾的线程同时工作,所以用户不会感觉到停顿,用户体验确实是挺好的,但是这个时候新垃圾                  就无法回收了,所以说无法清理浮动垃圾

JVM内存结构、GC算法以及各种垃圾收集器介绍_第11张图片

优点:

并发,低延迟

 

缺点:

1、对CPU资源非常敏感,因为它在并发期间会占用一部分的CPU资源,(CPU数量+3)/4,即至少是25%

2、CMS收集器无法处理浮动垃圾,即在并发清除时,这个时候用户进程产生的垃圾无法清除,只能等到下次GC时回收

3、因为是使用“标记-清除”算法,所以会产生大量内存碎片,从而不可避免的会触发一次Full GC,这个时候使用的就是STW(Serial Old垃圾回收期)整理内存碎片,停顿时间较长。CMS也提供了参数-XX:CMSFullGCsBeForeCompaction(默认0,即每次都进行内存整理)来指定多少次CMS收集之后,进行一次压缩的Full GC。

 

G1:

    OpenJdk9中,G1取代了CMS成为了默认的垃圾收集器,它的目标用户是服务器(即适合在大内存高并发的程序中使用),它的目的是缩短STW时间(几乎不需要STW),即它是一个低延迟的垃圾收集器。一般对于用户体验来说,低延迟要优先于高吞吐。

    G1将堆划分成了若干个Region(各Region物理上可以不连续),它仍然属于分带收集器,不过只有一部分Region包含了新生代,新生代和老年代不再是要求物理上连续的了。G1中的堆结构划分如下:

JVM内存结构、GC算法以及各种垃圾收集器介绍_第12张图片

    每个方块称为一个Region,每个Region的大小限制在1M~32M之间(可以通过G1HeapRegionSize参数设置大小,但必须是2的整次幂,且固定了就不可变动了),之所以规定最大是32M,这样是为了GC时候的效率。Region的大小默认为堆大小的1/2048。

    每个Region在运行时都会被定义一种角色(角色在GC后可能会变且每种角色占堆内存的比例不再确定),这个角色和之前的分代思想一样,但它们只是一个逻辑上的表示。还有一点不同的是它多了一个humongous区域,当新建的对象大小超过一个Region容量的50%,会专门放到一个或者多个连续的H区域。

    新生代(Eden+Survivor)的比例最初占堆内存的比例为5%(-XX:G1NewSizePercent),最高为60%(-XX:G1MaxNewSizePercent)。

    G1相比较与CMS,它提供了一个“可预测暂停时间”的机制,它可以避免对整个Java堆的全区域扫描。G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表。每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来),这就保证了在有限的时间内可以获取尽可能高的收集效率。

    当然它只是进行动态调整,尽可能在你指定的时间内完成GC,真实情况下不一定保证能在规定的时候内完成GC回收。

 

Region的内部结构:

    每个Region都有一个关联的Remembered Set(简称为RS),RS的数据结构是Hash表,里面存放的是Card Table(堆中每512byte 映射在card table 1byte)。即RS存放的是Region中存货的对象的指针,当Region中的数据发生变化时,首先反应到Card Table中的一个或者多个Card上,RS通过扫描内部的Card Table得知Region中的内存使用情况和存活对象。如果Region慢了,分配内存的线程会重新选择一个新的Region。空闲的Region被放到一个LinkList链表里面,这样可以快速找到新的Region。

    如果一个Region存活对象少、垃圾占有率高容易被回收。

 

G1中的对象分配机制:

1.TLAB(Thread Local Allocation Buffer)

   默认使用TLAB加速内存分配,TLAB是线程本地分配缓冲区,每个线程均可以“认领”某个分区用于本地线程的内存分配,这样就可以减少同步时间,提升GC效率。

2.Eden区中分配

    如果TLAB不够用,则在Eden中分配内存生成对象。 

3.Humongous区分配

    如果对象需要的内存超过一个region的50%以上,会忽略前两个步骤直接在老年代的humongous中分配(连续的Region)。

 

老年代中有指向新生代的引用,如何确保新生代对象不被GC,难道要扫描整个老年代?:

CMS中是使用卡表(Card Table)+写屏障技术来解决老年代到新生代的引用问题。

卡表(Card Table)技术是将堆空间划分为一系列2次幂大小的卡页(Card Page),然后创建一个卡表(Card Table,其实就是一个数组),用于标记卡页的状态,每个卡表项对应一个卡页。HotSpot JVM中的卡页(Card Page)大小为512字节。

当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty,这样只要扫描dirty的卡也就好了,避免了扫描整个老年代。这种结构被称为point-out,老年代point-out到新生代。

而G1不一样,G1有很多的老年代,如果使用了上面的这种point-out机制,还是要扫描很多老年代,所以G1使用的是一种叫point-in的机制。

G1中每个区域内部有一个结构叫Remembered Sets(RSets,已记忆集合)并且每个分区只有一个RSet,存储着其他分区中的对象对本分区对象的引用。GC的时候,只要扫描RSet中的其他old区对象对于本young区的引用,不需要扫描所有old区。

(ps:GC的时候新生代对象都是要扫描的,所以只要规避掉上面的这个老年代有指向新生代引用这个问题就好了)

 

G1中的垃圾回收机制:

G1中提供了三种垃圾回收模式,Young GC、Mixed GC和 Full GC,在不同的条件下被触发。它们都是STW的

Young GC:

    主要是对Eden区域进行区域,在所有的Eden空间耗尽时被触发。

    和之前的Young GC差不多,执行完一次Young GC,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。

    此时会有计算出Eden和Survivor大小,用于下次Young GC。统计信息会被保存下来用于辅助计算size,然后调整各代区域大小,便于下次可以在规定时间内完成GC。

Mixed GC:

     既可能只收集年轻代,也可能在收集年轻代的同时,包含部分老年代的收集,它在老年代大小达到一定阈值时被触发,默认是80%,也可以用-XX:InitiatingHeapOccupancyPercent参数进行配置。

Mixed GC的执行过程和CMS的超级像,主要分为以下几个步骤:

initial mark: 初始标记过程,整个过程STW,标记了从GC Root可达的对象

concurrent marking: 并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息

remark: 最终标记过程,整个过程STW,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象

clean up: 垃圾清除以及拷贝过程,整个过程STW(这里和CMS不一样,CMS使用的是“标记-清除”,所以CMS无法处理浮动垃圾,而这里使用的是“复制”算法),如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中

Full GC:

    如果对象内存分配速度过快,Mixed GC来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc.

 

总结:

    所以G1的GC机制,从局部上看是“复制”算法,从整体上看可以说是“标记-整理”算法,从而没有内存碎片的问题。

 

    如果想最小化使用内存(10OM以下)和并行开销,使用Serial GC

    如果想最大化应用程序的吞吐量,使用Parallel GC

    如果想最小化GC停顿时间,使用CMS

    如果服务器硬件好,运行的是高并发大容量的程序,可以考虑使用G1。但是!如果当前使用的CMS或者ParallelOldGC没有造成应用程序的垃圾收集停顿时间太长,那么继续使用当前的设置说不定更好。

 

 

 

参考:

《深入理解JVM虚拟机》

https://www.cnblogs.com/ityouknow/p/5614961.html(GC算法示例图)

https://www.cnblogs.com/huzi007/p/6728328.html(JVM的client与server模式)

http://openjdk.java.net/jeps/248(JDK9中,G1变成默认的垃圾收集器了)

http://www.importnew.com/27793.html(G1相关的一些源码)

https://juejin.im/post/5c39920b6fb9a049e82bbf94(JVM卡表技术)

http://www.cnblogs.com/ityouknow/p/5610232.html(参数控制各区域的内存大小)

你可能感兴趣的:(JVM)