JAVA GC 与 内存分配策略

发表信息: by

GC与内存分配策略

1 概述

说起垃圾回收机制,大部分人都把这项技术当做java语言的伴生产物。事实上,gc的历史远比java久远,在1960年就诞生了第一门真正使用内存动态分配和垃圾回收的语言Lisp

GC需要完成的是三件事:

  • 哪些内存需要被回收
  • 什么时候后进行回收
  • 怎么进行内存的回收

JVM内存区域一篇文章中,我们介绍了java运行时候的各个内存区域,其中程序计数器,虚拟机栈,本地方法栈3个区域都随着线程而生,随线程而灭。栈中的栈帧有条不紊的进行着入栈出栈操作。因此这几个区域的内存是具有确定性的,不用过多的考虑回收的问题,方法结束或者线程结束之后,内存自然就回收了。而java的堆和方法区则不一样,就比如if语句,只有程序运行的时候才知道走那条分支,分配多少内存。

2 如何确定对象已死

首先需要解决GC要完成的三件事之一,回收什么对象。当垃圾收集器回收对象之前就是要确定哪些对象还“或者“, 哪些对象已经“死去”。下面我们介绍几个算法来确定“对象已死”

2.1 引用计数算法

很多教科书判断对象是否还活着的算法是这样的:给对象添加一个引用计数器,没当一个地方引用它的时候,计数器加1,当引用失效时,计数器-1, 如果计数器等于0的时候,则判断对象已死,回收对象。

客观的说,引用计数器算法,效率高,实现简单,在大部分的情况下,它都是一个不错的算法。但是JAVA虚拟机却没有使用该算法,为什么呢?我们看下面代码:

A a = new A();
B b = new B();

a.b = b;
b.a = a;

a = null;
b = null;

代码中,虽然a,b已经是无用的内存了,但是a,b的引用计数器却不为0,所以不能被回收(这就是无法解决循环引用的问题)。

2.2 可达性分析算法

在主流的商用程序设计语言(java,c#,甚至是古老的Lisp)都用可达性分析算法。这个算法的思路是找到一个合理的对象称为“GC Root“ 从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC root没有人格引用链,则表示该对象不可用。

在java语言中,可以作为GC Root的对象有如下:

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

2.3 再谈引用

无论是通过”引用计数器“算法还是通过”可达性分析算法“,判断对象的”生存“和”死亡“都和对象的引用有关,这里我们再详细的谈一下引用。

在JDK1.2以前,java中的引用定义很传统:如果reference类型的数据中存储的数值代表着另一块内存的地址,则这块内存代表着一个引用。但是这样定义却只能被分为:有引用和无引用两部分。对于描述一些”食之无味,弃之可惜”的对象来说就显得很乏力。譬如:如果我有个对象可能还有用,但是我希望内存不够用的时候就回收这些可能还有用的对象,如果内存够用,就留着这些对象。如果仅仅分为“有引用”和“无引用”的话,就不能处理这些“有引用却可能无用”的对象。

根据这些情况,在jdk1.2 之后,java对引用的概念进行的补充,分成了:强引用,软引用,弱引用,虚引用

Strong Reference, Soft Reference, Weak Reference, Phanton Reference

下面我们一一介绍:

  • **Strong Reference : ** 就是指的程序代码中普遍存在的引用。Object object = new Object() 这种创建的就是强引用。当强引用的对象还存活的时候,垃圾收集器是永远不会回收这些活着的强引用对象的。
  • Soft Reference : 描述一些还有用,但是并非必须的对象,对于软引用关联的对象来说,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果对象回收之后还是没有内存,则报内存溢出的错误。
  • Weak Reference : 他的强度与软引用更弱了一些。被弱引用关联的对象只能生存至下一次垃圾回收之前。当进行GC时候,不管内存溢不溢出,都会被回收。
  • Phanton Reference : 这些就是所谓的幽灵引用或者幻影引用,它是一种最弱的引用关系。无法通过引用来获取对象,他的存在对内存不造成任何影响。设置着一个引用的目的是为了能在这个对象被收集器回收的时候收到一个系统通知。

2.4 生存还是死亡

即使在可达性分析算法中不可达的对象,也并非“必须得死”,这时候是在处于“缓刑”状态。要真正的宣判一个对象的死亡,至少要经历两次标记过程:如果对象在进行可达性分析的时候没有雨GC Root相关联,则它将会被第一次标记,并且进行一次缓刑筛选,筛选的条件就是此对象有没有必要执行的“finalize()”方法。当对象没有覆盖“finalize()”方法或者“finalize()“方法已经被执行(finalize方法只能被执行一次)的话,则此对象被清除。

如果当前对象被确定,应该执行”finalize“方法,则会将这个对象放在F-Queue队列里,并且在稍后由一个虚拟机自动创建的,低优先级的Finalize线程去执行它,这里的执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束:因为如果finalize方法里执行了一些比较慢的,或者死循环,就会造成F-Queue线程阻塞(快死的对象还敢bb)。如果在finalize方法里,成功的被再次引用了,则该对象被”救活“。如果没有被救活,则被第二次标记,清除该对象。

但是要注意,就算对象在finalize里被救活了,则下次要被清除的话,就直接被清除(因为finalize方法只能被执行一次)

我们看看是怎么被救活的

class A{
    private static A object = null;
    ....
    ...
    ..
    .
    protect void finalize(){
        super.finalize();
        //这里对象将会被救活一次
        A.object = this;
    }
}

main(){
    A a = new A();
    a = null;
    System.gc();
}

需要特别说明的是,上面的finalize方法带有悲情色彩,我们不推荐重写finalize方法, 有些人认为finalize方法可以放些大型对象或者大型连接的释放,资源的释放。但是这些都是对C++/C程序员的妥协,final代码块已经能够完美的解决上面的问题了。finalize方法运行代价高昂,不稳定,无法保证各个对象的调用顺序,说以说,在用finalize的时候,请用final代码块来代替!

2.5 回收方法区

很多人认为方法区是没有垃圾回收机制的,的确,方法区中进行垃圾回收性价比很低。在堆中,尤其是在新生代中,进行一次垃圾回收70%~95%的空间,而永久代的垃圾收集效率远远低于此。

永久代的回收主要是回收两部分:废弃常量和无用的类。

  • 回收废弃常量 如果常量池中有"abc",但是当前系统没有任何String对象是叫做”abc“的,换句话说,没有其他地方引用到这个字面的量,这个”abc“常量就会被系统清理出常量的池。

  • 回收无用的类(判断条件)

    • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有在仍和地方被引用,无法再任何地方通过反射访问到该类的方法。

3 垃圾收集算法

3.1 标记-清除算法

最基础的算法就是”标记-清除算法“,分为两个部分:标记, 清除。

  • 标记出所有需要回收的对象
  • 清除所有标记的对象

图片来自网络

它的不足有两个:

  • 标记和清除的效率都不高
  • 标记清除后产生大量的内存碎片,如果碎片太多,就会导致如果需要分配大对象的时候,找不到一块完整的空间,而尽早的触发GC。

3.2 复制算法

为了解决效率问题,就出现了复制算法。它需要将内存分成两部分,每次只是用一半,当这块内存用完了,就将存活的对象复制到另一半后,清理这一半内存(因为是连续内存,清理起来比较快),在空内存中分配对象的效率也比较高。

图片来自网络

现在的商用虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%都是”朝生夕死“的。所以不需要按照1:1来划分内存空间的,而是将内存分为一部分较大的Eden区,两块较小的Survivor空间。每次使用Eden和其中的一块Survivor。当回收的时候,将Eden和Survivor中的还存活着的对象一次性的复制到另一块Survivor空间上,最后清理掉Eden和用过的Survivor空间。HopSot的默认分配方式是8:1。当Survivor不够用的时候,需要依赖老年代进行”分配担保“

3.3 标记-整理算法

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保。所以老年代一般不能直接用这种算法。 根据老年代的特点,有人提出了另外一种”标记-整理算法“。标记过程仍然与”标记-清除“算法一样,但是后续步骤不是直接将可回收的对象进行清除,而是将存活的对象向一段移动,然后直接清理掉端边界以外的内存。

图片来自网络

3.4 分代收集算法

分代收集算法其实就是讲村手周期不同,将内存换分为:新生代,老年代。因为新生代死亡率高,所以用复制算法,而在老年代中的对象死亡率低,则用”标记-清除“, ”标记-整理“算法。

4 内存分配与回收策略

java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:

  • 给对象非配内存
  • 回收分配给对象的内存 前面我们一直在讲回收分配给对象的 内存,现在我们讲一讲”给对象分配内存“

图片来自网络

对象的内存分配,往大了讲,是在堆上分配内存,对象主要是分配在新生代的Eden区,如果启动了本地线程缓冲(TLAB),则按线程优先在TLAB上分配。少数情况也可能直接分配在老年区。

4.1 对象优先在Eden区分配

4.1.2 对象内存的分配

大多数的情况下,对象在新生代Eden区中分配。当eden区没有足够的空间的时候,虚拟机发生一次Minor GC。Minor GC 之后,把生存的对象(复制算法)放入Survivor中。如果Survivor中放不下,根据分配担保机制 将对象放入老年区。虚拟机还会给每个对象定义一个年龄计数器,如果Eden出生并且经过一次MinorGC仍然存货并且能够被Survivor容纳的话,将被移动到Survivor中,并且将年龄置为1.对象在Survivor中每熬过一次MinorGC,年龄就会增加一,当年龄增加到一定程度(默认是15)的时候,就会将对象晋升到老年代。

为了能够更好的适应不同程序的内存状况,虚拟机并不是永远的要求对象年龄必须增加到15才能晋升到老年代,如果Survivor空间中年龄所有对象的和的平均值比某个对象的年龄小,则这个对象也会被晋升到老年代。

4.1.3 空间分配担保

在发生Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象空间的总和,如果这个条件成立,那么Minor GC可以确保是安全的,如果不成立,虚拟机会判断是否允许担保失败,如果允许,则会检查老年代最大可用连续内存是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试MinorGC,尽管这次MinorGC是有风险的;如果小于,或者虚拟机不允许担保失败,则会进行Full GC。

这里解释一下”冒险“是冒得什么险。新生代使用复制算法,但是为了内存利用率,只是用其中一个Surivior来作为;轮换备份,因此当出现了大量对象在MinorGC之后仍然存活,就需要老年代进行内存担保。老年代如果进行担保,就要确保老年代本身剩余的空间能够容纳这些对象。

(ps: 这就像银行的担保一样,如果A要借100万,银行就需要A找一个担保人,并且担保人的账户里至少有100万。这样就算A不还款,就需要在B里扣,银行还是不亏)

Minor GC 是指在新生代的垃圾回收动作,因为java对象大多数都具备”朝生夕死“的情况,所以Minor GC非常频繁。一般回收的速度也非常快。 Full GC 是指发生在老年代的GC,FullGC的速度一般比MinorGC的速度慢10倍以上。

本文借鉴《深入理解JAVA虚拟机》 作者所写的不足原著精彩的十分之一,有兴趣的读者请研读一下原著

博客迁移自 GC-CSDN