JVM内存结构以及GC相关

1.java内存结构,每个区域都分别存什么东西 1.6 1.7 1.8都有什么区别

jvm内存结构分为:方法区(method area),堆(heap),虚拟机栈(vm stack),本地方法栈(Native method stack),程序计数器(Program counter register)

方法区和堆属于线程共享的,虚拟机栈,本地方法栈,程序计数器属于每个线程私有的,同时这三个还属于运行时数据区域

1.1 堆(heap):存放程序运行时创建的对象,堆内存最大,被线程共享,java的垃圾回收主要管理的就是堆内存(但这不代表只会对堆内存进行垃圾回收),所以堆内存又被称之为GC堆

由于目前垃圾回收算法都是采用分代收集的算法,所以堆里面还可以细分为新生代和老年代,在精细一点有Eden空间(伊甸园,存放新创建的对象),from survivor空间(s0,幸存者,存放经历过垃圾回收并未被回收,且年龄不足以进入老年代的对象),to survivor空间(s1,幸存者,存放经历过垃圾回收并未被回收,且年龄不足以进入老年代的对象),老年代(存放一直存活并且年龄已经到指定年龄的对象)

堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。也就是说堆的内存是一块块拼凑起来的。要增加堆空间时,往上“拼凑”(可扩展性)即可,但当堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

扩展的实现通过-Xmx(JVM启动时申请的初始Heap值)和-Xms(JVM运行时可申请的最大Heap值)控制,参数具体详解后续有时间再说

1.2 虚拟机栈(vm stack):每个线程私有的,生命周期和线程相同,虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法。

       局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型(int、short、byte、char、double、float、long、boolean)、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。

       操作数栈(Operand Stack)也称作操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

       动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

       方法返回:无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

       如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈动态扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

1.3 本地方法栈(Native method stack):本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

1.4 程序计数器(Program counter register):是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型里(概念模型,各种虚拟机可能会通过一些更高效的方式实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、跳转、循环、异常处理、线程恢复等基础操作都会依赖这个计数器来完成。每个线程都有独立的程序计数器,用来在线程切换后能恢复到正确的执行位置,各条线程之间的计数器互不影响,独立存储。所以它是一个“线程私有”的内存区域,生命周期与线程的生命周期一致。此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。

       如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)

1.5  方法区(method area):只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。

        永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)

      Java7 中我们通过-XX:PermSize-xx:MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize-XX:MaxMetaspaceSize 用来设置元空间参数

      存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中

      如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出 OutOfMemoryError

      JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)

      所以对于方法区,Java8 之后的变化:

        移除了永久代(PermGen),替换为元空间(Metaspace);

        永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);

        永久代中的 interned Strings(字符串常量) 和 class static variables(静态变量) 转移到了 Java heap;

        永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)

        方法区在JDK 6,7,8的进化:

        只有 HotSpot 才有永久代的概念

jdk1.6及之前有永久代,静态变量存放在永久代上
jdk1.7有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中

2.为什么要移除永久代

对永久代的调优过程非常困难,永久代的大小很难确定,其中涉及到太多因素,如类的总数、常量池大小和方法数量等,而且永久代的数据可能会随着每一次Full GC而发生移动
而在JDK8中,类的元数据保存在本地内存中,元空间的最大可分配空间就是系统可用内存空间,可以避免永久代的内存溢出问题,不过需要监控内存的消耗情况,一旦发生内存泄漏,会占用大量的本地内存。
JDK7之前的HotSpot,字符串常量池的字符串被存储在永久代中,因此可能导致一系列的性能问题和内存溢出错误。在JDK8中,字符串常量池中只保存字符串的引用。

字符串存在永久代中,容易出现性能问题和内存溢出。

类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

3.GC算法有哪些?

   判断对象存活的方法:

   1.引用计数法:当对象被创建后,系统会初始化一个引用计数器,当对象被引用了,计数器+1,引用失效后,计数器-1,直到计数器为0,代表该对象不在被使用了,可以被回收了,缺点是不能判定循环引用,如果两个对象相互引用了,各自的计数器始终不会变为0,所以这个算法只出现在早期的jvm里面,现在不会被用了

   2.根搜索算法:从根对象(GC ROOT)出发,一步步遍历找到和这个根对象具有引用关系的对象,然后再从这些对象开始继续寻找,从而形成一个个的引用链,然后不在这些链上的对象就会被标识为引用不可达对象,就是可以回收的对象了,这个算法可以很好的解决引用计数法里面循环引用的问题

    根对象(GC ROOT):虚拟机栈中引用对象(栈帧里面的本地变量表)

                                          方法区中常量引用对象

                                          方法区中静态属性引用对象

                                          本地方法栈中JNI(Native方法)引用的对象

                                          java虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象(                                                                         如:NullPointException,OutOfMemoryError等)等,还有系统类加载器

                                          所有被同步锁(synchronized)持有的对象

                                          反应Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

    注:GC判断对象是否可达主要还是看强引用,进行根搜索时,是需要暂停所有线程的(即STW,STOP THE                        WORLD),主要还是防止上述方法的对象图在算法运行时发生变化从而影响算法的准确性

           线程暂停时间长短和堆内存大小没有必然的关系,主要看对象的多少,少的话暂时时间就短,多的话暂停时间就长

           宣告一个对象死亡不仅仅通过上述算法,还要经历两次标记

           两次标记:即使在可达性分析算法中不可达对象,也不一定就会死亡的,他们都处于暂时的缓刑阶段,要宣告一个对象真正死亡,至少需要两次标记:

                             发现对象没有与GC ROOT相连接的引用链时,这个对象将会被第一次进行标记进行筛选,筛选条件是此对象是否有必要执行finaliza方法,当对象没有覆盖finaliza方法,或者finaliza方法已经被虚拟机执行过一次,虚拟机都认为没必要执行

                             如果对象被判定有必要执行finaliza,那么这是对象将会放到一个F-Queue的队列中,稍后会有虚拟机自动建立的低优先级的Finalizer线程去执行它,这里的执行值得是只是会触发finaliza方法,并不承诺会等它运行结束,原因是:害怕finaliza一直执行不完,或者执行缓慢,导致F-Queue里面的其他对象一直处于等待从而导致内存回收系统崩溃

                             finaliza方法是对象逃脱被回收的最后一次机会,稍后GC会对F-Queue队列里面的对象进行第二次小规模标记,如果对象在finaliza方法里面成功的拯救了自己(比如将当前对象与引用链上任何一个对象建立连接,或者把自己赋值给某个类变量,或对象的成员变量),这样在第二次标记的时候就会被移出即将回收的集合,如果这时还没逃脱,那就真的被回收了

                            但是需要注意的是finaliza方法只会被执行一次,如果在执行一次并逃脱回收的命运后,再次被回收是就不会再执行该方法了,还有不提倡使用此方法来拯救对象,因为她的运行代价高,不确定性很大(上面说过Finalizer线程是不承诺等待对象finaliza方法执行完成的),无法保证各个对象的调用顺序,而且finaliza能做的事情,使用try-finally或者其他方法都更合适,及时,所以建议大家不要用此方法

                 强引用,软引用,弱引用,虚引用的GC策略:

                            强引用:只要有GC ROOT,就不可能会被回收

                            软引用:内存不够时会被回收

                            弱引用:下一个GC时会回收

                            虚引用:未知,也就是随时可能会被回收

                            至于这些引用具体概念和理解,后续有时间在写文章来解释吧

    回收算法:标记-清除,标记-整理,复制算法,系统自行判定使用的适应性算法

           标记-清除:由标记与清除两个步骤完成

                              标记的过程就是上面说的根搜索算法标记的不可达对象,标记完之后就开始统一清除

                              优点:当存活对象比较多的时候,性能比较高,因为该算法只用处理那些被标记的对象,而不需要处理存活对象

                              缺点:标记清除完之后会出现很多小的内存块,使得之前原本是连续的内存块变得不再连续了,这些小的内存块只能存放比较小的对象,而比较大的对象是无法直接存储的

           标记-整理:由标记和整理两个步骤完成

                              标记也是使用根搜索算法进行的,整理是将所有可用对象移动到内存的一端,然后将端边界以外的内存全部清理掉

                              优点:避免了内存碎片化的问题,并且在新分配对象内存时通过简单的指针碰撞就可以完成了(什么是指针碰撞?

                              缺点:个人认为由于比标记-清除算法多了一步,所以耗时会比标记-清除算法时间长

            标记-复制:将可用内存划分为两块区域,每次只使用其中一块,当这一块内存使用完了就将还活着的对象整体复制到另外一块上面,然后再把已经使用过的内存空间一次性全部清理掉

                               优点:不会产生内存碎片,每次回收只用对半区回收,而不用对整个内存进行回收,内存分配时也可以不用考虑内存碎片等情况,只需要移动指针按照顺序进行分配即可

                               缺点:可用内存减少了一半,存在内存浪费的情况

             垃圾分代理论:

                            根据对象存活周期将对象分为新生代,老年代,新生代和老年代分配比例是:1:2(这个比例可以通过jvm参数控制),堆大小=新生代+老年代,里面新生代在被分为一块较大的Eden空间和两块较小的Survivor空间,分别被命名为from和to。,这三块空间的比例是:8:1:1(这个比例也可以通过jvm参数进行控制

                            新生代一般存的对象都是朝生夕死,所以一般使用复制算法,而老年代因为对象存活率高,没有空间担保,所以必须使用标记-清理,标记-整理算法进行回收

                           HotSpot的算法实现:

                           当系统停顿下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机有办法知道那些地方存放着对象引用,在HotSpot的实现中,是使用一组OopMap数据结构达到这个目的的

                          在oopMap协助下GC可以快速且准确的完成GC ROOT,但是我们需要一个时间点来记录OopMap,这个地方被称为安全点(Safepoint),程序在执行的时候并不是随时都能停下进行GC的,只有在到达安全点的时候才能暂停

                           Safepoint的选定不能让GC等待时间太长,也不能多余频繁以至于过分增大运行时的负载,所以安全点的选择基本上是以是否具有让程序长时间执行的特征为标准进行的,因为每条指令执行时间非常短暂,程序不可能因为指令流长度太长这个原因而过长时间运行,长时间执行的最明显特征就是指令序列复用,例如方法调用,循环跳转,异常跳转等,所以具有这些功能的指令才会产生Safepoint

                          对于Safepoint,还需要考虑如何在GC发生时让所有线程都跑到最近安全点上在停顿下来:抢先式中断和主动式中断

                         抢占式中断:它不需要线程的执行代码去配合,在GC发生时,先把所有线程中断,如果有的线程不在安全点上中断,那就回复线程,让它跑到安全点上在中断

                        主动式中断:当GC需要中断时,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动轮训这个标志,发现中断标志为true就主动挂起,这个标志和安全点是重合的,另外再加上创建对象需要分配内存的地方(这句话是什么意思)

                      现在几乎没有虚拟机采用抢占式中断来暂停线程从而响应GC事件

                      在使用Safepoint好像已经完美解决了如何进入GC的问题,但是实际情况不一定,Safepoint保证了程序/线程执行时在不太长时间就会遇到可以进入GC的Safepoint,但是如果程序/线程在不执行的状态下怎么办呢?所谓不执行就是没有分配CPU时间,典型的例子就是处于sleep或者blocked状态,这时线程就无法响应jvm的中断请求了,jvm也不会等待线程重新分配CPU时间,那这种情况就需要安全区域(Safe Regin)了

                       在线程执行到Safe Regin中的代码时,先标识自己进入Safe Regin,这样在这段时间里JVM发起GC时,就不用管标识为Safe Regin的线程了,在线程要离开Safe Regin时,它需要检查GC是否完成了根节点枚举或者整个GC过程,如果完成了,线程继续执行,否则就必须等待,等收到可以安全离开Safe Regin的信号为止

                           HotSpot实现复制算法流程:

                           当Eden区满了,会触发一次Minor gc,把存活的对象复制到Survivor from区,当Eden区再次满了就会扫描Eden和Survivor from,将存活的对象复制到Survivor to区,然后将Eden和Survivor from区清空

                           当后续Eden区又发生了Minor gc时,会对Eden区和To区进行回收,存活对象复制到from区域,将Eden区和To区清空

                        部分对象会在from和to区来回复制,如此交换15次,如果最终还是存活,就存入到老年代

                        老年代如此经过几次折腾也扛不住了(没有内存了),就会触发Full GC,那就是全量回收

                           内存担保:

                          当发生minor gc时,jvm会首先检查老年代最大可用连续空间是否大于最大新生代所有对象总和,如果大于,那么这次YGC是安全的,如果不大于,那么JVM就要判断HandlerpromotionFailure是否运行空间分配担保,如果允许,继续检查老年代最大可用大小是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试一次Monitor GC,尽管这次GC有风险,如果小于或者HandlerPromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC了。(注:我怎么感觉这里描述有些问题呢,如果大于的话或者HandlerPromotionFailure设置不允许冒险去GC没问题,小于为什么还要进行Full GC呢?这样不是不管什么情况都要进行一个GC吗虽然GC的类型不同?小于的时候我的理解不是应该不用GC了吗?直接用不就行了吗?

                         新生代采用复制算法,s0和s1始终只用了其中一块内存,当出现YGC后大部分的对象仍然存活,就需要老年代进行担保,把S区无法容纳的对象直接晋升到老年代

                        这种空间分配担保前提是老年代还有容纳的空间,但是一共会有多少对象存活下来,在实际完成回收之前是未知的,所以只好取之前每次回收晋升到老年代对象容量的平均值为经验值,与老年代剩余空间比较,决定是否进行Major GC来让老年代腾出更多空间

                      如果担保失败是会再次发起full gc的,虽然担保失败会绕一个大圈子,但是大部分情况下还是把HandlerpromotionFailure打开,避免频繁的FULL GC

4.垃圾收集器有哪些? 

   垃圾收集器分为串行,并行,并发和G1(G1下面会说)

   串行包含:Serial,SerialOld GC

   并行:Parallel

   并发:CMS

   Serial收集器:单线程收集器,新生代采用复制算法,,老年代采用标记整理算法,这里的单线程指的并不是用一个CPU或者一个收集线程去完成垃圾收集工作,更重要的是它在工作的时候,必须暂停其他的线程,直到垃圾收集完毕,但是这项工作是由虚拟机发起和自动完成的,在用户不可知,不可控的情况下把用户的正常工作的线程都停掉,这对很多应用来说是不能接受的

                          优点:对于单CPU环境来说,没有线程间的交互,专心做垃圾回收自然可以获得更高的垃圾回收效率

 使用方式:-XX:+UseSerialGC。

ParNew:Serial的多线程版本,与Serial不同的是,它采用多个线程去处理垃圾回收工作,所以在多CPU的环境下比Serial的表现要好

使用方式:-XX:+UseParNewGC。

设置线程数: XX:ParllGCThreads。

Parallel Scavenge收集器:吞吐量优先收集器,新生代收集器,采用复制算法并行多线程收集器,目标是达到可控的吞吐量,自适应调节策略,自动指定年轻代,Eden,Suvisor区的比例

使用方式:-XX:+UseParallelGC

最大垃圾收集停顿时间:-XX:MaxGCPauseMillis

参数解析:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器尽力保证内存回收花费的时间不超过用户设定的时间,但是垃圾回收停顿时间是以牺牲吞吐量和新生代空间为代价换取的,系统把新生代调整小一些肯定比大一些回收快,但是这也导致回收变得更加的频繁了,可能会由之前10秒一次,一次100ms变成5秒一次,一次70ms,停顿时间下降了,但是收集频率上升了,导致因为垃圾收集而造成的stw变长,影响系统吞吐量

吞吐量大小:-XX:GCTimeRatio

参数解析:GCTimeRatio应该是一个大于0,小于100的整数,也就是垃圾回收占用总时间的比例,假设GCTimeRatio为N,那么系统将花费不超过1/(1+n)的时间用于垃圾收集,比如这个参数设置为19,那允许的最大垃圾回收时间占总时间的5%(1/(1+19)),默认99,即最大1%的垃圾回收时间

设置年轻代线程数:-XX:ParllGCThreads

参数解析:cpu核数小于等于8时,默认和cpu核数相同,cpu超过8的时候设置为3+(5*cpu_count)/8

-XX:+UseAdaptiveSizePolicy:有了这个参数之后,就不要手工指定年轻代、Eden、Suvisor区的比例,晋升老年代的对象年龄等,因为虚拟机会根据系统运行情况进行自适应调节

Serial Old:Serial收集器老年版本,单线程收集器,使用标记-整理算法,使用方式:-XX:+UseSerialGC

Parallel old收集器:老年代垃圾收集器,多线程并发收集,基于标记-整理实现,使用方式:-XX:+UseParallelOldGC

cms收集器:采用标记-清除算法实现的,目标是获取更短的垃圾收集停顿时间,尽可能缩短垃圾回收时用户线程停顿时间,停顿时间越短,越适合与用户交互的程序

分为四个步骤:1.初始标记,2.并发标记,3.重新标记,4.并发清除,初始和重新标记都需要stw

初始标记主要标记GC ROOT能关联到的对象,标记之后恢复之前被暂停的所有应用,由于直接关联对象比较小,所以这里操作速度非常快

并发标记:GC root直接关联对象开始遍历整个对象图,这个过程耗时长,但是会和用户线程一起运行

重新标记:由于并发标记阶段工作线程和垃圾回收线程同时运行或交叉运行,所以会有一部分标记记录产生变动,为了修正这个情况,需要重新标记,这个阶段停顿时间比初始标记长一些但是比并发标记时间短,这是只会检查未标记的是否重新需要标记,而已经标记过的不会再次检查

并发清除:清理已经标记死亡的对象,并释放内存空间,由于不需要移动存活对象,所以这个阶段可以和用户线程并发运行

三色标记:cms将遍历对象图过程遇到的对象按是否访问过这个条件标记为白,黑,灰三色,白色代表未访问过,黑色代表本对象已访问,且本对象引用到的其他对象也都访问完了,灰色代表本对象访问过,但是引用到的其他对象尚未完全访问完,全部访问完成后会变成黑色

初始时:所有对象都在白色集合里面,

将GCROOT引用到的对象挪到灰色集合中

从灰色集合获取对象,将本对象引用到的对象挪到灰色集合里面,将本对象挪到黑色集合里面

重复第三部,直到灰色集合为空且结束

结束后仍在白色集合的对象即为GC ROOT不可达的,可以进行回收了

浮动垃圾:

浮动垃圾指得是第一次已经标记为存活对象了,但是在第二步并发标记时与GC ROOT断开引用关系的,因为重新标记只会重新检查未标记的对象,不会检查已经被标记为存活的对象,其实这部分对象是要被回收的,但是现在就回收不了,只能等下一次GC了,但是并不会影响程序的正确性

漏标:

漏标产生在并发标记时间,加入GC遍历到E对象了,E对象变灰了,这是应用程序执行将E里面一个属性对象复制给另外一个临时变量G后将E里面这个属性置为空,这是有另外一个对象H(且H是黑色的)的属性变量引用了这个临时变量G,这是因为E里面的属性已经为空了,所以不会将G放到灰色集合里面,同时又因为H是黑色的,不会在重新遍历处理了,那这是G会一直处在白色里面最后导致被回收,这样会影响到程序的正确性,这是不可接受的

如何解决漏标问题呢:在并发阶段,黑色对象引用白色对象时,记录下来黑色对象,在重新标记阶段,再将B变为灰色的将整个引用链重新扫描,但是这样会很耗费时间,优点是不会产生浮动垃圾了

CMS的缺点:1.它对cpu资源十分敏感,在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致系统变慢,总吞吐量降低,CMS默认启动回收的线程是(处理器核心数量+3)/4,也就是说如果在四核以上,并发回收垃圾收集线程只占用不到25%的处理运算资源,并且会随着处理器核心数量增加而下降,当核心不足四个时,cms对程序影响可能会变得很大,如果本来处理器负载很高,还是分出一半运算能力去执行收集器线程,就可能会导致用户程序执行速度忽然大幅度降低

2.cms无法处理浮动垃圾,由于并发标记时用户线程和GC的线程是一起运行了,所以就需要预留足够的内存空间提供给用户线程使用,因此CMS不能像别的垃圾收集器一样,等到老年代几乎完全被填满了在进行收集,必须预留一部分空间供并发收集时的程序使用,可以通过参数-XX: CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比, 降低内存回收频率, 获取更好的性能,1.6的时候CMS默认的是92%,但是这时又面临一个风险:预留的内存不够用咋办?就会出现并发失败,这时虚拟机不得不启动预案:冻结用户线程的执行,临时启用Serial Old重新进行老年代垃圾收集,但这样停顿时间就更长了

3.空间碎片:因为CMS是基于标记-清除算法实现的收集器,所以空间碎片是无法避免的,当空间碎片过多时,会给分配大对象造成很大的麻烦,往往会出现老年代还有很大的空间,剩余,但是找不到足够大的连续空间来分配当前对象,从而不得不提前进行full gc,为了解决这个问题,CMS提供了一个-XX: +UseCMS-CompactAtFullCollection开关参数,默认是开启的,用于在CMS收集器不得不进行full GC时开启内存碎片合并整理过程,但是由于这个过程是要移动内存的,所以无法并发,这样空间碎片问题解决了,但是停顿时间变长了,因为又提供一个参数:-XX: CMSFullGCsBeforeCompaction,这个参数作用是要求CMS进行若干次FULL GC时不整理空间碎片,下一次进入FULL GC前会先进行碎片整理(默认是0,表示每次进入FULL GC时都进行碎片整理)

5.G1垃圾收集器原理

G1出现是针对配备多核CPU以及大容量内存的机器,降低GC停顿时间还兼具高吞吐量的性能特征

G1的特点:

G1把内存分为多个独立区域的Region

G1仍然保留了分代思想,还是分为新生代和老年代,但它们不再是物理隔离了,而是一部分Region的集合

G1能充分利用多CPU,多核优势,尽量减少STW

G1整体采用标记-整理算法,局部采用复制算法,不会产生内存碎片

G1停顿可预测,能够明确指定一个时间段内,消耗的垃圾回收时间不超过指定的时间

G1会跟踪各个region里面垃圾价值大小,维护一个优先级列表,每次根据允许的时间回收价值最大的区域,从而保证在有限时间内高效的垃圾收集

Region:

G1会把内存分成2048个独立的区域(这个区域个数是否可以设置),每个区域大小根据堆大小确定,为2的N次幂,即1MB,2MB,4MB,8MB等,这个区域就是Region,每个Region可以根据需要扮演不同的角色,Eden,Survivor,老年代,可能现在这个区域是Eden,但是这个区域被回收后,被老年代占领了就变成了老年代区域了

G1还增加了一个区域,Humongous,主要用于存储大对象,如果超过0.5个region大小,就直接放到Humongous区,一般都被看成是老年代,这块区域不会被移动整理,但是会被回收

G1垃圾回收:

步骤共分为4步:

1.初始标记:暂停所有线程(STW),记录下gc root直接引用的对象,速度很快

2.并发标记:从GC ROOT直接关联的对象开始遍历整个对象图,这个过程耗时长,但是可以和用户线程并发,所以不需要STW

3.重标记:需要STW,修正并发标记时因为用户程序继续运行导致的标记产生变动的那一部分对象的标记记录,这个阶段停顿时间比初始标记长,但是比并发标记短

4.筛选回收:需要STW,对各个region的回收价值和成本进行排序,根据用户期望的GC停顿时间来执行回收计划

G1提供两种回收模式:Young GC和Mixed GC,两种均是STW的

Young GC:选定所有年轻代的region,通过控制年轻代region个数,来控制young gc的开销,但是并不是现有的Eden区满了就进行回收,这时G1会计算现在回收Eden区大概需要多久,如果比-XX:MaxGCPauseMills设置的参数小太多的话,就会继续给Eden区分配region,存放对象,直到G1计算回收时间和-XX:MaxGCPauseMills设定的值相近才会触发young gc

Mixed GC:不是full gc,选定所有的年轻代GC,另外根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region,还有H区(大对象),根据老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,主要使用复制算法,把各个region存活的对象copy到别的region里面去,拷贝过程中如果发现没有足够的空的region时会触发一次Full GC

Full GC:停止系统,采用单线程进行标记,清理和压缩,好空闲出来一批region供下一次MixedGC使用,这个过程非常耗时

7.Card Table(卡表)

上面说了我们在做YOUNG GC时会先根据GC ROOT进行标记,这里标记的都是年轻代里面的对象,那么有一个问题,假如老年代里面也引用了年轻代里面的对象了那咋办呢?这是我想到的第一个办法就是遍历老年代呗,但是因为老年代很大,而且里面对象很多,遍历会很耗时,那咋办呢?JVM用card table来解决这个问题(卡表是Remembered Set的一种实现,points-out结构)

基于卡表的设计,通常将堆空间划分为一系列2次幂大小的卡页(card page),卡表用于标记卡页的状态,每个卡表对应一个卡页

HotSpot JVM的卡页大小为512字节(也有说4K的,具体我也不知道哈),卡表被实现为一个简单的字节数组,即卡表每个标记项为1个字节,当一个老年代引用了新生代里面的对象,那么此时对象所在卡页对应的卡表状态就会变为dirty(脏),那么在进行Young gc时,对于老年代就只需要扫描卡表为0对应的卡页就行了

上面说到更新卡表状态,那么这个更新是怎么更新的呢?

这里就是写屏障(Write Barrier),这个写屏障和解决并发乱序执行的内存屏障不同,写屏障可以看做是对引用类型字段赋值这个操作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的操作,赋值前的叫写前屏障,赋值后的叫写后屏障,HotSpot许多收集器中都有使用到写屏障,但是都是写后屏障,G1用到了写前屏障(G1用写前屏障干啥了?

应用写屏障后,一旦对象引用进行了更新,就会产生额外的开销,但是这些开销与扫描整个老年代的代价相比还是低很多的

除了性能开销还有一个伪共享的问题:

维基百科对于伪共享的解释:CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改不同变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。例如:线程1和线程2共享一个缓存行,线程1只读取缓存行中的变量1,线程2修改缓存行中的变量2,虽然线程1和线程2操作的是不同的变量,由于变量1和变量2同处于一个缓存行中,当变量2被修改后,缓存行失效,线程1要重新从主存中读取,因此导致缓存失效,从而产生性能问题。为了更深入一步理解伪共享,我们先看一下CPU缓存。 总体来说就是线程同时访问缓存行导致了,数据在缓存行里的缓存数据失效。

假设处理器缓存行大小是64字节,每个卡表一字节,64个字节共享一个缓存行,64卡表元素对应的卡页大小为32KB(64*512字节),如果不同线程更新的对象引用正好处于这32KB的内存区域中,就会导致更新卡表写入同一个缓存行而影响性能,为了避免伪共享,那就不是无条件的写屏障,而是先检查卡标记,当卡标记未被标记过才进行更新,在JDK7以后,HotSpot虚拟机新增了一个参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断,开启的话会更加一次额外的开销,但是能解决伪共享问题,具体情况得依据实际情况来决定

8.RSET(Remembered Set)

G1的每个region初始化的时候都会有一个Rset,这个集合用来记录并跟踪其他Region指向该region中对象的引用。G1的垃圾回收是有对STW的控制的,如果对于整个堆进行一次回收的实际STW时间可能远远超过这个值,所以G1可以不用扫描整个堆,只需要通过扫描RSET来分析垃圾比例最高Region区,放入CSET,进行回收(points-in结构)

存储方式:

1.hash表:一个其他region引用当前Region中card的集合,放在一个数组里面,key是region的地址,value是card地址数组

2.细粒度:当稀疏表指定的Region的card数量超过阈值,退化成细粒度,一个region地址链表,共同维护当前Region中所有card的一个bitmap集合,该card被引用了设置对应的bit为1,并且还维护了一个对应region对当前region中card索引数量

3.粗粒度:当细粒度的size超过阈值时,退化为分区位图,所有region形成一个bitmap,如果有region对当前region有指针指向,就设置对应的bit为1

一般加入流程就是是否在粗粒度map中,是否在细粒度Table中,加入稀疏entry是否成功,加入细粒度是否成功,加入粗粒度

我们从上面可以了解到如果Rset数据结构退化成粗粒度的时候,对region回收的特别慢了,这时需要扫描所有的region才能正确回收

其次为了追求效率,年轻代没有Rset,维护Rset需要消耗很多性能,而年轻代快速回收的特性带来了大量的浪费

实现过程:

如果每次对引用类型字段赋值都要更新Rset,那么开销就太大了,G1采用post-write-barrier和concurrent refinement threads实现了RSet的更新

在赋值动作前后JVM插入一个pre-write barrier(写前屏障)和post-write barrier(写后屏障),其中写后屏障会找到字段对应对象的所在位置card,并设置为dirty

post-write barrier在用户线程写入一个 reference 的时候被调用,例如x.f=y。整个更新过程如下:

1.判断y是不是null,如果是null,则没必要更新,之后判断y和x是不是属于同一个region,如果y不为null,且与x不在同一个region的话进行下一步

2.判断x所在的card在cardtable里面是否被标记为dirty,如果是则结束,不过不是,先将card标记为dirty,然后将x所在的card放入到本地线程的rset log里面(也叫dirty card queue),如果队列满了,就将其方法全局队列里面(filled RS buffers),并为该线程分配一个新的队列

以上就是写屏障做的事情

当filled RS buffers达到阈值之后,会有concurrent refinement threads 对里面的card进行处理:首先将该card的状态置为clean,然后检查该card中的所有对象是否有指向其他Region对象的指针,如果有,则更新对应Region的RSet。

G1限制了filled RS buffers的大小,超过这个值提交dirty card queue(remembered set log)的线程需要自己处理这些card,这会造成一定的性能影响。

9.CSET(Collection Set)

收集集合代表每次GC暂停时回收的一系列目标分区,在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象会转移到空闲分区中,无论是年轻代还是混合收集,工作机制都是一样的,

10: Incremental Update

11.SATB

遗留:.方法区的垃圾回收,对象进入老年代的情况

何避免频繁的full gc

Shenandoah是什么垃圾收集器?ZGC是什么垃圾收集器

GC日志

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值