自动内存管理(2):垃圾收集器与内存分配策略

63 阅读18分钟

什么是垃圾回收?

垃圾回收实际上就是一种自动内存管理机制,它的核心工作是回收程序中不再使用的对象占用的内存,防止内存泄漏,减轻程序员手动管理内存的负担

垃圾回收机制需要考虑三个问题:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

回顾JVM内存的几个区域:

  1. 线程私有:

    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  2. 线程共有:

    • 方法区

对于线程私有的区域,它们随着线程而生随着线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈操作,而每一个栈帧分配的大小类的结构上就确定下来,因此这些区域不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

对于堆和方法区,就有着很明显的不确定性,这部分内存的分配和回收是动态的,因此垃圾收集器所关注的正是这部分内存该如何管理

死亡的对象

堆的回收

在堆中存放着几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些对象“死”了

引用计数法

一种教科书方法是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加一,当引用失效时,计数器值就减一,任何时刻计数器为零的对象就是不可能再被使用的

但是在主流的JVM中,并没有使用引用计数法来管理内存,因为它有两个致命的缺陷:

  • 循环引用无法回收:两个对象互相引用,即使外界已不可达,计数器仍不为 0。
  • 高开销与并发瓶颈:每次引用变更都要更新计数器,多线程下还要加锁或使用原子操作。

可达性分析算法

主流的商用语言,都是通过可达性分析算法来判定对象是否存活的。

可达性分析把堆看成一个有向图,对象是节点,引用是边。垃圾收集器从一组被称为 GC Roots 的固定起点出发,沿着引用链一路向下搜索,走过的对象被标记为“存活”,未走过的被判定为可回收。

image.png

一般,GC Roots包含:

  • 虚拟机栈帧中局部变量表所引用的对象(这些是正在使用的对象)
  • 方法区中类静态属性引用的对象
  • 方法区中常量(如字符串常量池)引用的对象
  • 本地方法栈中 JNI 引用的对象
  • 虚拟机内部的引用:基本类型对应的Class对象、常驻异常对象、系统类加载器
  • 所有被同步锁(synchronized)持有的对象
  • 反映虚拟机内部情况的JMXBeanJVMTI回调等

再谈引用

JDK 1.2之前,Java中的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存某个对象的引用。

在这种定义之下,对象只有引用被引用两种状态,对于一些中间状态没法描述:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。

因此之后java对引用的概念进行了扩充:

  1. 强引用:传统的引用定义,只要强引用还存在,垃圾收集器永远不会回收它

任何通过 new 直接赋值、未包装进 Reference 子类的引用都是强引用。

Object obj = new Object();   // obj 是强引用

只要强引用链还存在,该对象就不可能被 GC 回收,哪怕发生OOM。这意味着:

  • 它是导致内存泄漏的常见根源:例如静态集合中存放了生命周期已结束的对象,因为集合持有强引用,对象永远无法释放。
  • 想回收必须显式切断:obj = null;

GC实现看,根遍历时遇到强引用会直接标记对象存活并继续递归,不会进入任何特殊处理逻辑,是 GC开销最低的引用类型。

  1. 软引用:描述一些目前还有用,但是非必须的对象。在系统即将发送内存溢出前,会将这些对象回收。
SoftReference<byte[]> ref = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = ref.get();  // 可能为 null

GC 行为JVM会尽力持有软引用对象,直到判定内存“确实紧张”时才回收。具体HotSpot实现的规则:

  1. 当发生GC时,如果堆中空闲内存低于阈值,且对象的 (当前时钟 - 最后一次访问时间) > 空闲堆大小 × SoftRefLRUPolicyMSPerMB,则回收该软引用对象。
  2. 默认 -XX:SoftRefLRUPolicyMSPerMB=1000,表示每1MB空闲堆内存可以换取1秒的额外存活时间。比如空闲100MB,对象只要在100秒内被访问过,就不会被回收。
  3. 如果堆真正要溢出(OOM前),所有软引用会被强行全部回收。

这个 clock - timestamp 机制依赖 SoftReference 内部维护的 timestamp 字段:每次 get() 调用如果成功返回对象,JVM 就会更新该时间戳(通过 Reference#accessed 标记,最终在 GC 时处理),使得“最近使用”的对象更难被回收,形成天然的LRU语义。

  1. 弱引用:描述一些非必须的对象,下一次垃圾收集会将其回收。
WeakReference<Object> ref = new WeakReference<>(obj);

GC 行为:只要GC发现某对象仅被弱引用可达,就会立刻将该对象回收,并把对应的 WeakReference 实例放入与之关联的 ReferenceQueue。无论堆内存是否充裕。

  1. 虚引用:一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(obj, queue);
Object never = ref.get();   // 永远返回 null

虚引用的语义完全不同于前两者:

  • get() 永远返回 null,你无法再触及原对象。
  • 虚引用本身不影响对象生命周期,它的唯一作用是:当对象已被 GC 确定回收后,将这个虚引用实例放入 ReferenceQueue,给予一个临终通知
  • 该通知发生在 finalize() 之后(如果对象覆写了),但在内存回收之前。

ReferenceQueue用于当被引用的对象被垃圾回收器回收后,能够将对应的引用对象(Reference 实例)放入这个队列,从而让程序可以异步地感知回收事件并进行后续清理。

JVM中,普通强引用不会触发任何通知。即使使用 SoftReferenceWeakReference 或 PhantomReference,当它们所指向的对象被GC回收后,引用对象本身仍然存活,但已经不再指向有效对象(get() 返回 null)。程序需要一种机制来知道“这个引用对象已经没用了”,以便做资源释放、缓存清理等操作。ReferenceQueue 就是这种通知机制的载体。

简单来说:

  • 没有 ReferenceQueue:你只能通过反复轮询 Reference.get() == null 来判断对象是否被回收,效率低且不实时。
  • 有了 ReferenceQueue:垃圾回收器会在回收目标对象后,自动把关联的引用对象(Reference 实例)入队,你可以通过轮询或阻塞方式从队列中取出这些引用,明确知道哪些对象已被回收。

生存和死亡

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

什么是finalize()方法

finalize()是定义在 java.lang.Object类中的一个 protected​ 方法。这意味着所有Java对象都继承了这个方法。 它的原始设计目的是:在垃圾回收器确定这个对象没有任何引用之后,但在实际回收其内存空间之前,给对象一个“最后的机会”来释放其占用的非Java资源(如文件句柄、Socket连接、数据库连接、本地方法分配的内存等)。

资深游戏老玩家应该知道这个就是“亡语”,现在已经不怎么用了,因为问题太大了。

假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定需要执行finalize()方法,那么这个对象就会被放置在一个名为F-Queue的队列之中,之后会有一个由虚拟机创建的、低调度优先级的Finalizer线程去执行它们的finalize()方法。但不一定会等待它允许完毕。

finalize()方法是对象逃脱死亡命运最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK=null;
    public void isAlive(){
        System.out.println("仍然存活");
    }

    /**
     * 在对象的finalize方法中,将自己赋给静态变量,使得收集器对F-Queue的对象进行第二次标记的时候逃脱
     * @throws Throwable
     */
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize方法执行");
        //将自身赋给SAVE_HOOK静态变量
        FinalizeEscapeGC.SAVE_HOOK=this;
    }
    public static void main(String[]args) throws InterruptedException {
        //先创建一个对象
        SAVE_HOOK=new FinalizeEscapeGC();
        //消除对这个对象的引用
        SAVE_HOOK=null;
        //触发gc,gc第一次标记这个对象,对象被放入F-Queue中,等待Finalizer线程启动它的finalize方法
        System.gc();
        TimeUnit.SECONDS.sleep(1);
        if(SAVE_HOOK!=null){
            //结局,对象仍然存在
            SAVE_HOOK.isAlive();
        }else {
            System.out.println("对象已经死亡");
        }
        //第二次消除对象的引用
        SAVE_HOOK=null;
        //再次触发gc,收集器第一次标记对象,判定该对象已经执行过一次finalize,再第二次标记对象的时候移除回收它
        System.gc();
        TimeUnit.SECONDS.sleep(1);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else {
            //这次对象没有再执行finalize方法来挽救自己
            System.out.println("对象已经死亡");
        }
    }

}

方法区的回收

在方法区进行垃圾回收的性价比很低:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此

方法区的垃圾回收主要回收两个部分:

  1. 废弃常量
  2. 不再使用的类型

回收常量和Java堆中的对象非常类似,比如说: 假如一个字符串java曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是java,换句话说,已经没有任何字符串对象引用常量池中的java常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个java常量就将会被系统清理出常量池。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。

但是回收类型的条件就非常苛刻:

  1. 该类的所有实例都已经被回收,堆中不存在该类和其派生子类的实例。
  2. 加载该类的类加载器已经回收,除非用了其他替换的类加载器,否则很难达成。
  3. 该类的Class对象没有被引用

JVM允许对满足这三个条件的无用类进行回收。

垃圾回收算法

从如何判定对象消亡的角度,垃圾回收算法被划分为“引用计数式垃圾回收”和“追踪式垃圾回收”两大类。主流的JVM都是后者,因此下面讨论“追踪式垃圾回收”。

分代收集理论

目前的虚拟机都是遵循了“分代收集”的经验法则:

  1. 弱分代假说:绝大多数对象都是朝生夕灭
  2. 强分代假说:熬过越多次垃圾回收的对象越是难以消灭

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

如果一个区域中大多数对象都是朝生夕灭难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。

如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

JVM的堆一般都会被设计至少划分为“新生代”和“老年代”,但是有一个问题:对象不是孤立的,对象之间会存在跨代引用

假如要现在进行一次只局限于新生代区域内的收集,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的新生代GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  1. 跨代引用假说:跨代引用相对于同代引用来说占比极少

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

因此不应该为了少量的跨代引用去扫描整个老年代,只需要在新生代上建立一个全局的数据结构(记忆集)。这个结构把老年代划分为若干小块,标识出老年区的哪一块内存存在跨代引用。这样,当新时代进行回收的时候,只需要把包含跨代引用的老年区的小块对象加入GC roots中扫描就绪。这种方法需要在对象改变引用关系时维护记录数据的正确性,增加一些运行时开销。

回收分类

  1. 新生代回收(Minor GC / Young GC):目标只是新生代的垃圾收集。
  2. 老年代回收(Major GC / Old GC):目标只是老年代的垃圾收集。
  3. 混合回收(Mixed GC):目标是收集整个新生代以及部分老年代的垃圾收集
  4. 全堆回收(Full GC):整个Java堆和方法区的垃圾收集

标记-清除算法

最早最基础的算法是“标记-清除算法”,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

它有两个缺点:

  1. 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低
  2. 内存空间碎片化,标记清除之后会产生大量不连续的内存碎片。后续分配大对象的时候,可能没有足够的连续空间,导致另一次GC

image.png

标记-复制算法

标记-复制算法将内存划分为两个大小一样的内存,每次只使用其中的一块,当这块内存用完了,将存活的对象复制到另一半上,然后清理这一块内存。

因为大部分对象都是可回收的,因此复制的对象仅仅只是少部分存活对象,因此执行效率高,同时每次都是针对半区进行内存回收,也不用考虑空间碎片。

但是缺点是可用内存缩小为原本的一半,空间浪费太多。

image.png 由上述的特点可以知道,这种方法特别适合去回收新生代,而新生代中做过研究,98%的对象熬不过第一轮收集,因此没有必要按照1:1的比例来划分新生代的空间。

因此有另一种更优化的算法“Appel式回收”,Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor

在发生垃圾收集的时候,将Eden区和Survivor区仍然存活的对象一次性复制到另一块Survivor区,然后清理掉Eden和用过的那块Survivor区。

HotSpot虚拟机默认EdenSurvivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%Eden80%加上一个Survivor10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

但是没有任何人可以保证,一轮GC过后,存活的对象不会将Survivor区占满,甚至溢出,因此Appel式回收Survivor空间不足以容纳一次Minor GC之后存活的对象时,就会依赖其他区域(老年代)进行分配担保:如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

标记-整理算法

标记-复制在对象存活数量较多的成本很高,因此它只适合于新生代,不适合于老年代。

根据老年代的存亡特点,另外有一个算法,即标记-整理算法,其中标记过程和标记清除算法一样,然后让所有的存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

image.png 但是是否移动存活的对象是一项优缺点并存的风险决策:

  1. 如果移动存活对象,尤其是老年代这种存活对象十分多的区域,移动对象并更新所有的引用是一种十分繁重的任务,并且需要全程暂停用户应用程序才能进行。
  2. 如果不移动存活对象,那么就会导致空间碎片化,导致内存分配复杂。

但是从整个程序的吞吐量来看,移动对象会更加划算。HotSpot虚拟机里面关注吞吐量Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟CMS收集器则是基于标记-清除算法的。