JVM 二

   上一章写了一下jvm的分区,和对象的初始化操作,这篇主要说一下GC算法。

    说到GC那么首先想到的就是对象(堆),一个死亡的对象,那么GC是如何确定一个对象已经死了的。

1.引用计数算法

    通过对象的引用计数器来判断对象是否可回收,每当有一个地方引用的时候,计数器就会+1,当引用失效时,计数器就会-1,如果计数器为0则代表这个对象可以回收,但是在大多的JVM都没有选用这个算法,因为这个算法无法解决相互引用的问题。

A=null; B=null; A=B;B=A; 这样GC没办法回收。

2.可达性分析算法

通过一系列称为"GC Roots"的对象作为起点,当一个对象到GC Roots没有任何的引用链相连时,则说明这个对象不可用。5,6,7这三个对象都是不可用的。

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

    1.虚拟机栈(栈帧中的本地变量表)中引用的对象。

    2.方法区中类静态属性引用的对象。

    3.方法区中常量引用的对象

    4.本地方法栈中Native方法引用的对象。

3.引用计数(对引用新的定义):引用强度依次减弱,GC回收的从弱到强

强引用: 就是最常用A a=new A() 只有当引用不存在时,才会被回收

软引用: 在JVM将要发生内存溢出的时候回收,如果回收了还是没有足够的内存就会抛出内存溢出的异常

弱引用:只能生存到下一次GC回收之前

虚引用:虚引用对对象的生存时间没有影响,也不能通过虚引用来获取对象,设置虚引用的唯一目的就是在这个对象被GC回收的时候获取一个通知。

4.对象的生存还是死亡

    就算是被可达性算法标记不可达的对象,也不是马上就死亡(是可以抢救的,但是不推荐抢救,如果对抢救没有需求最好禁用)。要真正宣告一个对象死亡,至少要经过两次标记。在对象被判断为不可达时,会被第一次标记并且进行一次晒选,筛选的条件是此对象是否有必要执行finalize()方法,判断对象是否覆盖了finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没必要执行”。

    如果对象被判断为需要执行,则会将对象放入F-Queue队列中,然后GC会对F-Queue队列中的对象进行二次标记,如果对象在finalize()中将自己和引用链连接起来,那么第二次标记的时候就会将对象从F-Queue队列中移出。如果对象在finalize()方法中没有与引用链连接,那么这个对象就会被回收。

5.对方法区的回收

方法区也可以称为永久代,在方法区中垃圾收集主要是废弃的常量和无用的类。废弃的常量就是在任何地方都没有对这个常量的引用,就可以被回收。回收类的话条件就会多许多:

1.该类的所有实例都已经被回收,也就是java堆中不存在该类的任何实例。

2.加载该类的ClassLoader已经被回收

3.该类对象的java.lang.Class对象没有在任何地方引用,无法在任何地方通过反射访问该类。

    该类就可以被回收,但是不一定会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用-verbose:class已经-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。在大量使用反射、动态代理、CGLib等框架或者需要频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,来保证永久代不会溢出。

二 垃圾收集算法

1.最基础的收集算法:标记-清除算法  

算法分为两个阶段,标记和清除,算法的标记阶段就是之前对对象是否死亡的判断。然后就清除可以确认死亡的对象。

算法主要是有两个缺点:

1.效率,标记和清除的效率都不高

2.空间问题,标记清除后会产生大量的不连续的内存碎片,当需要大量的内存空间的时候,如果无法找到足够的连续的内存,就会触发GC的垃圾回收动作。

2.复制算法

    算法将内存分了两块,每次只使用其中的一块。当这一块用完的时候,就将活着的对象复制到另外一块区域,然后将这块区域都进行回收。这样在内存分配的时候就不需要考虑内存碎片等情况,只需要移动堆顶的指针就可以了。但是这种算法实现的代价太高了。

    现在的商业虚拟机都采用这种算法来回收新生代,一般都是讲内存划分为一块较大的Eden空间和两个较小的Survivor空间,每次使用Eden和一个Survivor空间,然后将Eden和一个Survivor中还存活的对象复制到另一个Survivor中。在HotSpot虚拟机中默认Eden和Survivor的比例是8:1也就是说每次可以回收90%的空间。当然也可能存活的对象超过了10%那么就需要使用内存的担保分配。内存的分配担保一般都是借用老年代的内存空间,将现有存活的对象都放置到老年代中。

3.标记-整理算法

    复制算法在每次GC回收的时候,存活对象率较高的时候就不在适合。所以在老年代中一般不在选用复制算法(老年代中对象的回收率不高)。所以根据老年代的特点提出了标记-整理算法。同样是先标记,但是标记完成后,先让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

    这样就会留出大量规整的内存空间。

4.分代收集算法

    根据对象的存活周期的不同将内存划分为几块。一般分为新生代和老年代。在新生代中一般选用复制回收算法,老年代中一般是标记-整理或者标记-清除。

三:HotSpot的算法实现
          从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,如果要逐个检查这里面的引用,那么必然会消耗很多时 间。而且,可达性的分析对执行时间有要求,因为这项分析工作必须在一 个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看 起来就像被冻结在某个时间点上,这点是导致GC进行时必须停顿所有 Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因。即使是在号称 (几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
        由于目前的主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有 执行上下文和全局的引用位置。HotSpot中使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。OOPMap文章参考。HotSpot没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有 这些功能的指令才会产生Safepoint。还有一个特殊的JNI,JNI不是java的解释器生成的也不是JIT编译器生成,在OOpMap中没有JIT方法的信息。所以在HotSpot中所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来。JNI需要调用Java API的时候也必须自己用句柄包装指针。此时只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象。 这也意味着在调用JNI方法会有句柄的转换,调用速度相对较慢。
         有了Safepoint,需要考虑的问题是如何在GC时让所有的线程(这里不包括执行 JNI调用的线程)都到达Safepoint。这里有两种方式:
1.抢先式中断   抢先式中断不需 要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程 中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。 很少使用
2.主动式中断   当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
         有了Safepoint不一定就能解决所有的问题,如果此时线程处于sleep或者Blocked状态,无法响应JVM的的中断请求。JVM也显然不太可能等待线程重新被分配CPU时间。所以有了安全区域的概念。
         安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
         在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完 成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为 止。

四:垃圾回收器
  Serial:单线程的复制算法   新生代回收器      尽量缩短垃圾收集时用户线程的停顿时间  适用于交互
 Serial Old:单线程的标记-整理法 老年代回收器  老年代的回收率不高

  ParNew:多线程的复制算法   新生代回收器
  Serial Old:单线程的标记-整理法 老年代回收器  老年代的回收率不高


  Parallel Scavenge:多线程的复制算法  新生代回收器   吞吐量优先的算法  吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间) 主要适合在后台运算而不需要太多交互的任务  图和上面的一样
 Parallel  Old: 多线程的标记-整理法   老年代回收器

在说CMS和G1之前,先说明在以上几种( Serial Old,Parallel  Old)老年代的回收之前都会触发新生代的回收。
主要原因是在回收老年代时可能新生代的对象关联了老年代的对象而且此新生代的对象是可达的,所以此时老年代的对象也是可达的。
而反过来再回收新生代时也会有同样的问题,老年代中有对象引用了新生代的对象,此时单纯看新生代对象是不可达的但是老年代对象是可达的。所以回收新生代时也需要考虑老年代的垃圾,但是如果新生代的GC也需要扫描所有堆的话,效率会降低很多。所以在老年代有一个write barrier(写屏障)来管理的card table(卡表),card table存放了所有老年代对象对新生代对象的引用。此时只需要扫描card table表就可以知道哪些老年代对象引用了新生代对象。card table文章推荐
card table简单点说就是每个老年代对象都会在上面有一个flag,标志是否引用新生代。
使用card table虽然好,但是在高并发情况下,频繁的写屏障很容易发生虚共享(false sharing),从而带来性能开销。多个线程同时修改(此对象的关联关系发生改变或刚升入老年代)一个地址的flag。一个简单的解决方案,就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表项未被标记过才将其标记为dirty。JVM参数-XX:+UseCondCardMark

CMS收集器比较复杂先看图  老年代的老年代回收器

在说CMS先说GC的并发和并行
1.并发  并发是指用户线程和GC线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
2.并行  并行是指用户线程暂停 多条GC线程并行工作
        从图中可以看出,CMS会有一个初始化标记的过程,此时也需要“Stop The World”。1.初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快(这里的直接关联是新生代和老年代都需要扫描  因为可能新生代对象引用了老年代对象 )。2.这里是并发标记(GC线程和用户线程同时执行),就是将GC Roots第一步关联的对象往下跟踪。3.重新标记时也需要“Stop The World”,重新标记的目的是为了修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录。如:
        1).新生代的对象晋升到老年代;
        2).直接在老年代分配对象;
        3)老年代对象的引用关系发生变更;
4.最后并发的清理这些对象。
        虽然说的是CMS有这些优点但是对应的也有缺点
1.在并发阶段CSM需要使用CPU资源   CMS默认启动的回收线程数是(CPU数量 +3)/4  如果CPU数量过少就会对用户的线程造成糟糕的影响  影响交互效果
2.CMS无法处理浮动垃圾 
当初始化标记后,用户线程也在进行就会产生新的垃圾,所以可能会导致老年代GC刚结束立马又需要一次老年代GC。而且此时用户线程产生的老年代垃圾需要有地方存放,所有在使用CMS收集时老年代不能装满默认阈值为92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一 次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
3.因为CMS使用的是标记-清除 所以会产生大量的零散空间  如果此时需要分配一个数组对象 明明老年代空间是足够的但是因为都是零散空间而导致无法放置,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开 关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并 整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。 虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于 设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。
4.其实这也是所有老年代收集器的问题,因为存在老年代对象引用了新生代对象,所以在老年代对象GC时,只能全堆扫描(新生代+老年代)才能完成标记。如果在CMS标记时新生代对象过多则会延迟标记时间。所以CMS也有7步的一个说法:
1.Initial Mark(初始化标记)
2.Concurrent Mark(并发标记)  为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty
3.Concurrent Preclean(并发预清理)将会重新扫描前一个阶段标记的Dirty对象,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识。
4.Concurrent Abortable Preclean(可中止的并发预清理)  这里主要是希望新生代能完成一次GC减少重新标记的对象量 。CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。
5.Final Remark(重新标记)  主要目的是重新扫描之前并发处理阶段的所有残留更新对象。
6.Concurrent Sweep(并发清理)  清理所有未被标记的死亡对象,回收被占用的空间。
7.Concurrent Reset(并发重置)  清理并恢复在CMS GC过程中的各种状态,重新初始化CMS相关数据结构

G1收集器

推荐文章 
G1是将内存分为多个小的region,取值范围从1M到32M,且是2的指数。Region逻辑上连续,物理内存地址不连续。同时每个Region被标记成E、S、O、H,分别表示Eden、Survivor、Old、Humongous。其中E、S属于年轻代,O与H属于老年代。H表示Humongous。从字面上就可以理解表示大的对象(下面简称H对象)。当分配的对象大于等于Region大小的一半的时候就会被认为是巨型对象。
跨代引用的问题,为了解决Young GC的时候,扫描整个老年代,G1引入了Card Table Remember Set的概念,基本思想就是用空间换时间。这两个数据结构是专门用来处理Old区到Young区的引用。Young区到Old区的引用则不需要单独处理,因为Young区中的对象本身变化比较大,没必要浪费空间去记录下来。
RSet:全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。
Card Table上面有介绍。
举个例子:Region A=Region B1 ,Region C=Region B2     B1和B2属于同一个region。此时regionB中B1和B2对应的Card Table 标记将会变为dirty_card
Remembered Sets 参考1  参考2
Remembered Sets的维护,当对象的引用发生改变时不可能每改变一个对象就直接修改对于的Remembered Sets。所以需要
1.Post-write barriers 2.Concurrent refinement threads  屏障代码在写操作之后(因此名称是“post-write barrier”)。简单的说就是对象引用改变时将card写入到缓冲日志(参考2中是队列)中,然后Concurrent refinement threads会通过缓冲日志中的card计算出card所在的Region,如果Region不存在,或者Region是Young区,或者该Region在回收集合中,则不进行处理记录。如果不满足以上条件则需要改变对象的Remembered Sets的标记,如果写入速度大于更改速度就会导致应用线程也需要帮忙更新。
除去Remembered Sets的维护 G1主要Mixed GC是以下几步:
1.初始标记(Initial Marking)
2.并发标记(Concurrent Marking)
3.最终标记(Final Marking)
4.筛选回收(Live Data Counting and Evacuation)
看起来和CMS差不多,其实不一样。
1.是一样的。
2.第二步在并发标记会将这一步所改变的对象都记录到satb_mark_queue队列,
3.第三步时 CMS需要从GC Roots开始的原因是因为有新对象的产生,对象的引用发生的改变。G1中解决的办法是
     1).G1采用的是pre-write barrier解决对象的引用发生的改变问题。简单说就是在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录并保存在一个队列里,在JVM源码中这个队列叫satb_mark_queue。在remark阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象,snapshot(在GC前存活对象的一个快照)的完整性也就得到了保证。
     2).新增对象的问题:每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象就是新分配的,因而被视为隐式marked。prevTAMS是上一次的concurrent marking所得的对象存活状态,已完成的。nextTAMS是本次concurrent marking所得的对象存活状态,未完成的。
4.这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。 清除空Region。
对象的跨代引用和标记问题解决了。G1的GC模式:
1.Young GC  Young GC 回收的是所有年轻代的Region当E区不能再分配新的对象时就会触发。E区的对象会移动到S区,当S区空间不够的时候,E区的对象会直接晋升到O区,同时S区的数据移动到新的S区,如果S区的部分对象到达一定年龄,会晋升到O区。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
2.Mixed GC  Mixed GC的触发也是由一些参数控制。比如XX:InitiatingHeapOccupancyPercent表示老年代占整个堆大小的百分比,默认值是45%,达到该阈值就会触发一次Mixed GC。选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。
3.full GC  如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。
重点:
1.并发标记(Concurrent Marking)只是为Mixed GC服务,Young GC 不需要。
2.拷贝存活对象(Evacuation)  无论是Mixed G还是Young GC 都需要拷贝对象。
Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间。Evacuation阶段可以自由选择任意多个region来独立收集构成收集集合(collection set,简称CSet),CSet集合中Region的选定依赖于停顿预测模型(挑选部分Region,去尽量满足停顿时间),该阶段并不evacuate所有有活对象的region,只选择收益高的少量region来evacuate,这种暂停的开销就可以(在一定范围内)可控。

五:对象的分配

    对象的分配,大部分在堆中(前面已经说到,对象的逃逸会导致在栈上分配),主要是分配在堆中的新生代Eden区上,如果启用了TLAB(本地线程分配缓冲)那么会优先在TLAB上分配。也可能直接分配到老年代中,分配的规则不是固定的,分配的细节取决于当前使用的哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

    大多数的情况下,对象在新生区Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代GC,java对象很多都是朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快)。

    设置-Xms20M、-Xmx20M、-Xmn10M 这个三个参数限制了java堆的大小为20M,不能扩展。其中10M分给新生代,剩余10M分给老年代。-XX:SurvivorRatio=8决定了新生代Eden区与Survivor区的空间比例是8:1。然后此时Eden是8M,Survivor两个1M,老年代10M。

    先放入3个2M的对象,在放入一4M的对象,发现Eden中没有内存了,就会触发一次Minor GC,但是Minor GC时发现无法将存活的对象放入Survivor中,所以只能通过担保机制将3个2M的对象提前转移到老年代中。GC结束后4M的对象成功的放入Eden中,此时内存所占Eden中4M,老年代中6M。

    担保机制是有一定风险的,此时需要老年代担保6M的对象,如果老年代中最大可用的连续内存大于等于6M(6M中可能有在下次Minor GC中会被回收的对象,这里拿最大和是为了保证安全性),那么Minor GC是安全的。但是如果不到6M,虚拟机就会去查看HandlePromotionFailure的设置值是否允许担保失败。如果允许失败就会检查老年代最大可用的连续空间是否大于之前晋升到老年代对象的平均值(这个值是动态变化的)。如果大于则就尝试进行一次Minor GC,这次Minor GC是有风险的。如果小于或者HandlePromotionFailure设置不允许则会进行一次Full GC(老年代的GC),来扩大老年代的担保空间。

    但是一些需要大量连续内存空间的java对象,最典型的就是长字符串和数组,这样就直接放入老年代中,避免在Eden区和Survivor区之间发生大量的内存复制。

    长期存活的对象也会进入老年代中,如何分辨什么对象为长期存活的对象。虚拟机为每个对象定义了一个对象年龄。如果对象在Eden出生并经过一次Minor GC后仍然存活的对象,并且能被Survivor容纳的话,就会被移动到Survivor空间中,而且设置对象的年龄为1,在Survivor中每经历一次Minor GC,年龄+1,如果年龄到15就会被移动到老年区。可以通过-XX:MaxTenuringThreshold设置移动到老年代的年龄。也可以动态判断对象的年龄来移动到老年区,一般默认的动态设置的年龄是如果Survivor中相同年龄对象个数的总和大于Survivor对象总和的一半,那么大于或者等于该年龄的对象会直接进入到老年代。

这一章说了如何判断对象是否存活和对象的分配(新生代,老年代),以及回收的一些算法。

努力吧,皮卡丘

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值