深入了解Java虚拟机(2)垃圾收集器与内存分配策略

时间:2021-12-12 03:41:09

垃圾收集器与内存分配策略

  由于JVM中对象的频繁操作是在堆中,所以主要回收的是堆内存,方法区中的回收也有,但是比较谨慎

一、对象死亡判断方法

  1.引用计数法

    就是如果对象被引用一次,就给计数器+1,否则-1

    实现简单,但是无法解决对象相互引用的问题;实际上JVM也不是使用的此种方式,因此已下的程序我们会看到内存被回收了

/**
*testGC()方法执行后,objA和objB会不会被GC呢?
*@author zzm
*/
class ReferenceCountingGC{
public Object instance=null;
private static final int _1MB = 1024*1024;
/**
*这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
*/
private byte[]bigSize=new byte[2*_1MB];
public static void testGC(){
ReferenceCountingGC objA=new ReferenceCountingGC();
ReferenceCountingGC objB=new ReferenceCountingGC();
objA.instance=objB;
objB.instance=objA;
objA=null;
objB=null;
//假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}

  2.可达性分析

    定义一些GCroot,如果从GCroot到对象是不可达的,那么对象就可以被回收

    可能的gcroot:栈中的存放的对象的引用、方法区中静态属性和常量引用的对象、本地方法栈引用的对象(native)

    主流的jvm都是使用的此种方式

    深入了解Java虚拟机(2)垃圾收集器与内存分配策略

  3.引用

    无论通过什么方式,都是通过“引用”来判断!

    在JVM中引用分为四种:强、软、弱、虚(具体参考这篇文章:http://www.cnblogs.com/zhangxinly/p/6978355.html

  4.finalize方法

    如果对象不可达,对象将被标记,

    类覆写了此方法,且对象的此方法从未被JVM执行过,则对象被放入一个队列,等待一个线程来执行此对象的方法(注意只会执行一次)

    可以使用这种特性在对象不可达,被发现为可回收的状态下,重新回收对象;就是在finalize方法中重新建立强引用

    不建议使用,了解即可 

/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*
* @author zzm
*/
class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() {
System.out.println("yes,i am still alive:)");
} protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize mehtod executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
} public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead:(");
}
//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead:(");
}
}
}

  5.方法区回收

    方法区中的常量回收:在程序中没有任何地方使用到如:str=“abc”,则回收

    类回收:类所有实例全被回收、类加载器被回收、类的Class对象未被引用无法在任何地方通过反射调用,则该类可以被回收卸载

    在如今框架动态代理大行其道的今天,JVM必须有卸载类的方法,不然出现泄漏

二、回收算法

  1.标记-清除算法

    标记不可达对象,然后jvm进行统一回收

    缺点:

      效率不高,两个过程效率都不高

      回收后内存不连续,因为是从中移除掉不可达的,会导致大量碎片,如果JVM要分配一个连续的大内存,将会产生问题

  2.复制算法

    1)将内存分为两份A和B,如果A不够用了,就将A中存活的对象复制到B中(复制过去的肯定小于等于原来的)

    2)然后将A清空,等待B满了之后再次执行相反的动作;循环往复

    问题:内存只能使用一半

    优点:迅速,复制之后内存空间连续

    使用:在新生代中,对象的创建和死亡是十分快的,这就保证了每次从A复制到B中的会很少(大量的被回收),所以A和B不需要一样大,甚至B可以很小

    在主流虚拟机中使用的是这种方法,分为A/B/C三快,比例为8:1:1,将A和B复制到C

  3.标记整理

    也是将需要回收的标记

    然后不统一回收,而是将存活的统一移动到一端

    最后将端外的全部回收

  4.分代算法

    分代:根据对象的存活周期将内存分代如:新生代(对象创建死亡活跃)和老年代(比较稳定)

    根据以上的介绍:在新生代中就适合用复制算法,在老年代中就适合用标记整理/清理算法

    就是复制+标记整理两种算法结合

三、HotSpot的算法实现

  1.枚举根节点

    根节点很多,如果要逐个检查这里面的引用会浪费时间

    GC停顿,为了在gc时引用状态不改变,需要停顿所有执行线程,直至gc完成

    所以:JVM有方法直接知道哪些地方存放这引用;通过oopMap这样的数据结构来实现

    oop:     

      在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

      这样,GC在扫描时就可以直接得知这些信息了

  2.安全点  

    在特定的位置记录信息,进行GC;此时需要让线程都跑到安全点挂起

    这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)

    其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。

    而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志

    发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的

  3.安全区域

    如果程序没有执行:没有CUP时间片(sleep、blocked等),线程是无法响应中断的,也就没法去安全点进行挂起

    解决:使用安全区域,在safe region中任意地方开始GC都是安全的,就不需要线程跑到安全点了

    流程:

      当线程进入safe region时标识自己进入safe region;

      当GC时不管safe region状态的线程;

      当线程要出来时,要判断系统是否已经枚举GCroot完成,否则要等待其完成才能出safe region

四、垃圾收集器

    先来一张图,了解HotSpot中的垃圾收集器;

    深入了解Java虚拟机(2)垃圾收集器与内存分配策略

  1.Serial收集器(单线程,新生代)

    特点:

      这是一个单线程收集器

      收集时,停止所有工作线程,直到它工作结束

      会导致程序停顿

    场景:

      在单cpu环境中,简单高效,没有线程交互开销,专心做垃圾收集

      在桌面客户端client应用中,交给JVM的内存管理不会太多,使用也不会造成长时间停顿

  2.ParNew收集器(多线程,老年代)

    可以认为是Serial的多线程版本;

    特点:

      多线程并行收集;但是用户线程还是全部暂停

      在多cup中有优势,在单核系统中不一定比serial好,因为存在线程切换开销

    场景:

      由于HotSpot推出了划时代的CMS收集器作为老年代的收集器,却只有ParNew能与之共同工作来收集新生代

  3.Paraller Scavenge 收集器(吞吐量收集器,gc自适应调节)

    新生代收集器,也是并行采用复制算法,但是可以手动或自动调节cpu的吞吐量,  

      所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

      GC停顿时间短适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

    -XX:MaxGCPauseMillis参数:控制最大垃圾收集停顿时间的

      MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为如果把这个参数的值设置得稍小一点就能使

      得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾

      收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每

      次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

    -XX:GCTimeRatio参数:直接设置吞吐量大小的

      GCTimeRatio参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。

      如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1/(1+19)),默认值为99,就是允许最大1%(即1/(1+99))的垃圾收集时间。

    +UseAdaptiveSizePolicy:这是一个开关参数

      当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数

      虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)[1]。

      只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标

      自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别

  4.Serial Old

    顾名思义,Serial的老年代版本

    作为CMS收集器的后备预案,在并发收集Concurrent Mode Failure时使用

    深入了解Java虚拟机(2)垃圾收集器与内存分配策略

  5.Parallel old收集器

    顾名思义,Parallel的老年代版本

    这样就组成了:完整的新生代和老年代吞吐量收集器

    深入了解Java虚拟机(2)垃圾收集器与内存分配策略

  6.CMS收集器(可并发)

    阶段:  

      初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,速度很快;

      并发标记(CMS concurrent mark):gc可达性GC RootsTracing的过程

      重新标记(CMS remark):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录

      并发清除(CMS concurrent sweep):清除标记的内存

    详解:  

      初始标记、重新标记这两个步骤仍然需要“Stop The World”。

      而重新标记阶段这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

      由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

    缺点:

      对CPU资源敏感:启动的线程=(cpu+3)/4,也就是cpu多则占用整个系统的资源少,反之则相反;在cpu少的情况下会使系统突然变慢

      浮动垃圾,由于在清除时,用户线程还在运行产生垃圾,这些垃圾只能等下次GC

      基于标记-清除,产生大量碎片;

    深入了解Java虚拟机(2)垃圾收集器与内存分配策略

  7.G1收集器(最新)

    可预测的停顿;标记整理+复制,无CMS的碎片问题

    分析:

      在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代;

      G1收集器它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

      G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

      G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region

      这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

      G1把内存“化整为零”的:

      把Java堆分为多个Region后,垃圾收集是否就真的能以Region为单位进行了?

      Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。

      那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保证准确性?

      这个问题其实并非在G1中才有,只是在G1中更加突出而已。

      在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象时也面临相同的问题,如果回收新生代时也不得不同时扫描老年代的话,那么Minor GC的效率可能下降不少。

      在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。

      G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。

      当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

    过程:与CMS很类似

      初始标记(Initial Marking)

      并发标记(Concurrent Marking)

      最终标记(Final Marking)

      筛选回收(Live Data Counting and Evacuation)

    深入了解Java虚拟机(2)垃圾收集器与内存分配策略

五、内存分配与回收策略  

  1.对象优先在Eden分配

  2.大对象直接分配在老年代上:其阀值控制:-XX:PretenureSizeThreshould=字节

  3.长期存货的对象进入老年代:其阀值(每经过一次复制,值+1):-XX:MaxTenuringThreshold=15

  4.动态对象年龄判断:

    不一定一定要达到阀值才放入老年代:当Survivor中相同年龄的对象>=Survivor的一半时,这些对象直接进入老年代

  5.空间分配担保

    当Eden存活对象复制入Survivor中,如果空间不够,复制进入老年代中,在复制进老年代时,也要判断空间大小(值为历次进入老年代对象的平均值)

附录:垃圾收集相关常数

  深入了解Java虚拟机(2)垃圾收集器与内存分配策略

  深入了解Java虚拟机(2)垃圾收集器与内存分配策略