《深入理解Java虚拟机学习笔记之垃圾收集器与内存分配策略》

来源:互联网 发布:iframe 端口不同 跨域 编辑:程序博客网 时间:2024/05/18 03:29

  Java运行时内存区域分为5个部分,其中程序计数器、虚拟机栈、本地方法栈都是随线程而生,随线程而灭,所以这个3个区域不需要过多考虑回收内存的问题。而堆内存和方法区则不一样,一个接口中的多个实现类 需要的内存可能不一样一个方法中的多个分支需要的内存也不可能一样,只要在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的也就是这部分对象。

  Java的垃圾收集器帮助程序员自动的对内存进行回收,省去了很多操作,垃圾回收期到底是如何判断一个对象是否需要被垃圾回收呢,或者说如何判断对象是否已经死亡?


  引用计数算法

  引用计数算法判断对象是否存活很简单,给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;任何时刻计数器的值为0的对象就是“死亡的”。这种算法非常简单,也的确被广泛使用,但是Java虚拟机却没有选择这种算法,其中最主要的原因是它很难解决对象之间相互循环引用的问题。通过下面的例子看看循环引用时,Java虚拟机会不会回收。

public class GcTest {    public Object instance = null;    private static final int _1MB = 1024*1024;    private byte[] bigSize = new byte[2 * _1MB];    public static void testGc(){        GcTest objA = new GcTest();        GcTest objB = new GcTest();        objA.instance = objB;        objB.instance = objA;        objA = null;        objB = null;        System.gc();    }}
  GC日志:

  [GC (System.gc()) [PSYoungGen: 8127K->954K(38400K)] 8127K->962K(125952K), 0.0050651 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 

 我们发现虽然对象之间存在循环引用,但是虚拟机依然回收了这个量对象

  可达性分析算法

   在主流的商用程序语言(Java、C#等)都是主要通过可达性分析来判断对象是否存活。这个算法的节本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

  如图object5、 objec6、object7 虽然互有关联,但是他们到GC Roots是不可达的,所以它们将会被判定是可回收的对象。



  在Java语言中,可作为GC Roots 的对象包括下面几种:

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

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

  方法取中常量引用的对象

  本地方法中JNI(即一般说的Native方法)引用的对象


  Java中的引用

  无论是采用哪种算法判断对象的引用数量,都离不开“引用”。在Java1.2后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用 4种。

   强引用:在程序代码之中普遍存在的,类似Object obj = new Object() 这类的引用,只要强引用还在,垃圾收集器永远不会回收引用的对象

        软引用:描述一些还有用但非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收                          如果这次回收没有足够的内存,会抛出内存溢出异常。使用SoftReference类来实现软引用

弱引用:也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾收集器进行回收时,                          无论当前内存是否足够,都会回收掉只被弱引用关联的对象。使用WeakReference类实现弱引用

        虚引用:最弱的一种引用关系,无法通过虚引用获得一个对象的实例。它的存在唯一的目的就是能在这个对象被回收时收到系统的一个通知。使用                                       PhantomReference类来实现虚引用。


   生存还是死亡

   即使在可达性分析算法中不可达的对象,也并非是“非死不可的”。这时候他们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经过两次标记过程:如果对象在及逆行可达性分析后发现没有与GC ROOTS 相连接的引用链,那么它将会被第一次标记且进行一次筛选,筛选的条件是此对象是否有必要执行finnlize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

   如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”,只是触发该方法,并不保证会等待它运行结束。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次标记,如果对象在finaliz()中重新被引用,那么在第二次标记中它将被移除“即将回收”的集合;如果第二次标记后对象依然没有被引用,那么基本上它将被真正的回收了。通过下面的例子演示finalize()如何自救:

public class FinalizeEscapeGc {    public static FinalizeEscapeGc SAVE_HOOK = null;    public void isAlive(){        System.out.println("我还活着");    }    @Override    protected void finalize() throws Throwable {        super.finalize(); //必须要调用父类的实现        System.out.println("finalize方法被执行");        FinalizeEscapeGc.SAVE_HOOK = this;    }    public static void main(String[] args) throws InterruptedException {        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("我已去也");        }        //下面代码和上面完全相同,但是这次自救失败,对象被回收        SAVE_HOOK = null;System.gc();Thread.sleep(500); if (SAVE_HOOK !=null){            SAVE_HOOK.isAlive(); }        else{             System.out.println("我已去也"); } 
运行结果:

finalize方法被执行
我还活着
我已去也
  

   从上面的例子可以看出,第一次SAVE_HOOK对象的finaliz()方法确实被GC触发,并且成功自救,但是第二次却失败了。这是因为一个对象的finalize()方法都只会被系统自动调用一次。


 垃圾收集算法

  标记-清除算对象

  最基础的收集算法是“标记-清除”算法,如同的它名字一样,算法分为“标记”和“清除”两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。之所以称它时最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的主要不足有两个:一个是效率问题,标记和清除这两个过程的效率都不高;另一个是空间问题,标记清除出之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中需要分配较大对象时,无法找到足够大的连续内存而不得不提前出发一次垃圾回收。该算法的执行过程如下图:



  复制算法

  为了解决效率问题,复制算法将可用内存按容量划分为大小相等的两块,每次只用其中的一块。当这一块的内存用完了,就会将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎念等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只不过这种算法的代价是内存缩小为了原来的一般,执行过程如下图


  现在的商业虚拟机都采用这种手机算法回收新生代,因为新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Surivior。当回收时,将Eden和Surivior中还存活着的对象一次性地复制到另外一个Surivior空间上,最后清理掉Eden和刚才用过的Surivior空间。HostSpot虚拟机默认Eden和Surivior的大小比例是8:1,当Surivior空间不够用时,需要依赖其他内存(老年代)进行分配担保。

  标记-整理算法

  复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的时,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不直接选用这种算法,而是采用标记-整理算法。该算法和标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界意外的内存,“标记-整理”算法的执行过程如下图:


  

  分代收集算法

  当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法不是什么新的是想,只是根据对象存活周期的不同将内存划分为几块。一般是吧Java堆分为新生代和老年代。在新生代中,每次收集都有大量的对象死去,只有少量存活,那就选择复制算法,只需要付出少量存辉对象的复制成本就可以完成收集。而老年代中因为对象存活效率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”算法或者“标记-整理”算法来进行回收。


  HotSpot的算法实现

  枚举根节点

   从可达性分析中从GC ROOTS 节点找引用链这个操作为例,可作为GC ROOTS的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗大量时间。

  另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行,这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称不会发生停顿的CMS收集器中,枚举根节点时也必须要停顿的。

  在HotSpot虚拟机中,在类加载完成的时候,就把对象内什么偏移量上是什么类型的数据计算算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用,这些都会保存在一组称为OopMap的数据结构中。在OopMap的协助下,HotSpot可以快速且准备地完成GC Roots枚举。

  安全点

  OopMap有一个问题就是会产生大量的额外空间,这样的GC的空间成本将会变得很高。HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非所有的地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等带时间太长,也不能过于频繁以致于过分增大运时的负荷。所以安全点的选定是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,例如方法调用、循环跳转、异常跳转等。

  对于安全点,另一个需要考虑的问题时如何在GC发生时让所有线程都“跑”到最近的安全点上再停顿下来,这里有两种方案:抢先式中断和主动式中断,其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎么有虚拟机使用这种方式。

  而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单低设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志位真时就自己中断挂起。发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点时重合的。

  安全区域

  使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,这时候线程无法响应JVM的中断请求,走到安全点挂起。对于这种情况就需要使用安全区域来解决。

  安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。当线程执行到安全区域中的代码时,首先表示自己已经进入了安全区域,那样,当在这段时间JVM要发起GC时,就不用管标识自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举,如果完成了,那线程就继续执行,否则它就必须等待直到收到可以离开安全区域的信号为止。

  垃圾收集器

 前面介绍了内存回收的算法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,而且新生代和老年代所有用的收集器不同,因此有各种组合,下面显示HotSpot中的垃圾收集器:


  Serial收集器

  Serial收集器是最基本、发展历史最悠久的收集器。如其名,这个收集器是以一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的时在它进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束。主要用在Client模式下的虚拟机。

  ParNew收集器

  ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等与Serial收集器完全一样。在多CPU环境下比Serial收集器效率高。

  这里对并行并发在垃圾收集器里面的含义做个解释:

  并行(Parallel):指多条垃圾收集器线程并行工作,但此时用户线程任然处于等待状态。

  并发(Concurrent):指用户线程与垃圾收集器线程同时执行(并不一定是并行),用户程序在继续运行,而垃圾收集器程序运行于另一个CPU上。

  Parallel Scavenge收集器

  Parallel Scavenge收集器也是一个并行的多线程收集器,它的特点是它的关注点与其他收集器不同,目标是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),比如:虚拟机总共运行了100分钟,其中垃圾收集器花掉1分钟,那么吞吐量就是99%。

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

 Serial Old收集器

  Serial Old是Serail收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。主要运用在Client模式下的虚拟机中,如果运用在Server模式,它主要有两种用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后预案。

 Parallel Old收集器

  Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。它是一个并行的多线程的收集器。

 CMS收集器

 CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很打一部分的Java应用几种在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这样的需求。

 CMS收集器是基于“标记-清除”算法实现的,它分为4个过程:

  初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要STW

  并发标记:进行GC Roots Tracing(追踪)

  重新标记:修正并发标记期间用户程序继续运作导致标记产生变动的哪一部分对象的标记记录。需要STW,且停顿时间会比初始标记长。

  并发清除

  由于整个过程中消耗时间最长的并发标记和并发清除过程收集器线程都可以和用户线程一起工作,所以总体来说,CMS收集器的内存回收过程是与用户一起并发执行的。体现了它并发手机、低停顿的优点。

  CMS收集器的三个明显缺点:

1.为了达到用户线程并发运行的效果,必须占用一部分CPU,降低了虚拟机的吞吐量。

   2.无法处理浮动垃圾(用户线程并发执行过程中产生的垃圾),只能在下次GC中回收

3.CMS是基于“标记-清除”算法的,所以势必会产生空间碎片,造成内存空间不连续,从而在分配大对象时,由于找不到连续的内存空间造成FullGC,并进行内存的整理。

  G1收集器

  G1收集器是当今收集器技术发展的最前沿成果之一,HotSpot开发团队希望在以后的可以替换掉CMS收集器,它具备以下的优点:

   1.并行与并发

   2.分代收集

   3.空间整合

   4.可预测的停顿

  

  对象的分配

  对象的内存分配,可以说就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。

   对象在Eden上的分配,如果当前Eden内存不够,就会触发MinorGc,如果发现Surivior空间不足以保存存活下的对象,这时虚拟机会通过分配担保机制提前转义到老年代中。

   大对象可以直接被分配到老年代中,通过-XX:PretenutrSizeThresold参数设置大小。长期存活的对象将根据对象的“年龄”将年龄增加到一定程度(每一次MinorGC后存活的对象+1,默认15岁会被转移到老年代)。可以通过-XX:MaxTenuringThreshold设置年龄大小。为了更好地适应不同程序的内存情况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果Surivior空间中相同年龄所有对象大小的总和大于Surivior空间的一般,年龄大于等于该年里的对象就可以直接进入老年代。

 

阅读全文
0 0
原创粉丝点击