深入理解JVM之垃圾回收机制

概述

GC需要完成的三件事:

哪些对象需要回收?何时进行回收?怎么回收?

程序计数器,虚拟机栈,本地方法栈不需要考虑垃圾回收的机制,因为栈帧随着方法的开始和结束而进行入栈和出栈的操作。所以需要进行垃圾回收机制的也就是堆和方法区。

哪些对象需要回收?

如何判断对象已死?

可达性分析算法和finalize()方法。

可达性分析

把一系列称为"GC Roots"的对象作为起点,向下进行搜索,当GC Roots到某个对象不可达时,这个对象就是可回收的。

GC Roots对象包括:

虚拟机栈中引用的对象。方法区中类静态属性引用的对象。方法区中常量引用的对象。本地方法栈中引用的对象。

为什么不使用引用计数法呢?

引用计数法就是每当加了一个引用,引用计数器加一,一个引用失效,引用计数器减一,引用计数器为零时该对象死亡。

但是引用计数无法解决的是循环引用的问题。

循环引用的问题:

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
          
        object1.object = object2;
        object2.object = object1;
          
        object1 = null;
        object2 = null;
        System.gc();
	}

若是使用引用计数法:

最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃收集器就永远不会回收它们

finalize()

即使在可达性分析算法中不可达的对象,也并不是非死不可的,宣告一个对象死亡,要经过两次标记过程:第一个是GC Roots不可达,第二步是此对象是否有必要执行finalize()方法。

如果该对象重写了finalize()方法且finalize()方法还没有被虚拟机所调用,则其对象需要执行该方法。

那么,该对象会放入一个队列之中,并由一个Finalizer线程去执行finalize()方法。finalize方法是对象拯救自己的最后一次方法,只需要与任何一个GC Roots建立关联即可。这样他就还是存活的。

public class FinalizeEscape {
	public static FinalizeEscape SAVE=null;
	@Override
	protected void finalize() throws Throwable {
		// TODO Auto-generated method stub
		super.finalize();
		System.out.println("finalize excute....");
		FinalizeEscape.SAVE=this;
	}
	
	public void isAlive() {
		System.out.println(" i am still alive .....");
	}
	public static void main(String[] args) throws Exception{
		// TODO Auto-generated method stub
		SAVE=new FinalizeEscape();
		SAVE=null;
		System.gc();
		Thread.sleep(500);
		if(SAVE==null) {
			System.out.println(" i am dead .....");
		}else {
			SAVE.isAlive();
		}
		
		SAVE=null;
		System.gc();
		Thread.sleep(500);
		if(SAVE==null) {
			System.out.println(" i am dead .....");
		}else {
			SAVE.isAlive();
		}
	}

 运行结果:

由此可见,SAVE对象的finalize()方法确实执行了,并在收集前成功逃脱了。

代码中有两段完全一样的方法,第一次成功逃脱,第二次因为已经执行过了finalize()方法,所以也就不在执行了,因此第二段代码逃脱失败。

然而,并不鼓励使用finalize()方法。

四种引用类型

强引用:

强引用是使用最普遍的引用。Object obj =new Object(); 如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

软引用:

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。使用SoftReferrnce类实现软引用。

弱引用:

被弱引用引用的对象只能生存到下一次垃圾回收之前。当GC开始工作时,无论内存是否充足,都会回收弱引用引用的对象。使用WeakReference来实现弱引用类。

虚引用:

顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

回收方法区

在堆中,尤其是在新生代,进行一次GC可回收70%-90%,而在方法区中效率就远低于此。

方法区主要进行废弃常量和无用的类的收集。

判断一个类是否是无用的类:该类所有的对象实例都已经被回收,加载该类的ClassLoader已经被回收,该类对应的java.lang.Class对应不被引用。

垃圾收集算法

标记-清除

标记出所有需要回收的对象,然后将做了标记的都给清除。缺点是导致内存碎片化。 

复制

 

将内存一般分为A区域,一半分为B区域。图中我们将前两行分为A,后两行分为B。刚开始的时候,我们只使用A区域的内存,而不使用B区域的内存。

第一次GC,经过一次可达性分析后,我们将A中存活对象直接复制到B区域,然后直接将整块A区域清除。A区域变成未使用的。第二次GC,同理,将B的存活对象复制到A,将B清除,B变为空。

这样A和B区域交互使用。

这个算法可以解决内存碎片化的问题,但是会导致内存浪费,一次只能使用一半的内存。

新生代主要使用的是复制算法。一般来说,Eden:Survior1:Survior2=8:1:1,因为每次GC新生代垃圾都会有75%-90%,这样,直接将Eden幸存的对象复制到Survior1区域中,然后将Eden区域清除,第二次清除时,将Eden区域和S1区域幸村对象复制到S2区域,将Eden和S1区域清除,就这样,S1,S2两个区域交替使用,新生代内存利用空间可以达到90%,而且解决了内存碎片化的问题。注意,当Survior内存区域不够时(多于10%对象存活),可以向老年代进行分配担保。

标记-整理算法

复制算法在对象存活率较高时就会产生一个问题,因为要进行过多的复制操作,效率会降低,而且浪费空间会比较多。对于老年代,存活对象率比较高,而且对象比较大,占用内存大,所以不宜使用复制算法,采用标记整理算法。

将存活的对象移到回收对象留下的空间里,以形成连续的内存。

总结

新生代中,每次GC都有大量对象死去,少量存活,选用复制算法。

老年代中,对象存活率高,没有额外空间进行内存担保,使用标记-整理。

HotSpot算法实现

可达性分析必须要GC停顿,即GC时必须要使所有的线程停下来,来保持一致性的问题。

枚举根节点

在寻找GC Roots,是通过一个OopMap来实现的。

安全点

在OopMap的帮助下,可以很容易的寻找GC Roots,但是,每一个指令都可能导致OopMap的变化,如果为每一条指令都生成一个对应的OopMap,那么,将会需要大量的空间。于是,HotSpot只是在特定的点记录了这些信息,这些点叫做安全点,程序旨在安全点才停下来执行GC。

如何让让所有的线程跑到安全点中断呢?

抢先式中断和主动式中断。

抢先式中断是把所有的线程都中断,然后把不在安全点上的线程恢复,直到他到达安全点上。

主动式中断:设置一个中断标志,各个线程主动区轮询这个标志,发现中断标志为真时,自己主动挂起。

安全区域

安全区域是指在一段代码中,引用关系不会发生变化。

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

Serial Collector

XX + UseSerialGC 序列化垃圾收集器,一个单线程的收集器,实际中使用的并不多。

Parallel Collector:

多线程收集,并发量大,但是在每次垃圾收集的时候,会冻结别的线程的执行,回导致JVM停顿。

ParNew:

多线程的Serial。在单CPU不一定会比Serial好,但在多CPU下,肯定优于。,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。

Parallel Scavenge:新生代收集。该收集器的主要目标是达到一个可控制的吞吐量。

GC停顿时间是每次垃圾收集停顿的时间,吞吐量是运行代码时间/(运行代码时间+GC时间)。

对于与用户交互的时间,停顿时间越短越好,对于后台计算,不需要太多用户交互,高吞吐量更重要。

Parallel old:

Parallel Scavenge的老年代版本。在注重吞吐量的情况下,使用Parallel Scavenge+Parallel old

CMS:

注重于获取最短停顿时间。并发收集,分区处理。停顿时间短,在垃圾收集的时候,JVM还可以运行。

分为以下四个流程:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

G1

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

 

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

 

根据需求来决定垃圾收集器。

内存分配

Minor GC 和 Full GC

  • Minor GC:回收新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。

  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

栈上分配

当new出一个小的对象来的时候,并且开了JVM栈上分配优化的话,那么会优先分配到栈(线程栈)上面去。JavaServer模式默认会开启栈优化。

优点:方法结束后,栈帧销毁,栈上的对象也就直接销毁了,无需垃圾回收,效率特别高。

无逃逸:如果方法外面有一个引用指向这个对象,称做逃逸。

标量替换:

线程本地分配(voliatile

每一个线程都有自己的Thread local Allocation Buffer。

占用eden。默认1%。

如果每一个线程都要放入eden的同一块区域那么这个区域就要进行加锁,但是每个线程的数据都有自己的一块独立的区域那么就不需要加锁了,不加锁就提高了访问效率。

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的,就是说即使Minor GC一个对象都没回收,老年代也有足够的空间存放存活的对象

如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值