关于 Java 捡垃圾那些事

文章目录

        • 概述
      • 对象
        • 创建过程
        • 对象内存布局
          • 对象头
        • 对象实例数据
          • 对齐填充
        • 对象的访问定位
      • 判断一个对象是否可被回收
        • 引用计数算法
        • 可达性分析算法
        • finalize()
        • 方法区的回收
          • 常量池判断
          • 类判断
      • 引用类型
        • 强引用
        • 软引用
        • 弱引用
        • 虚引用
      • 垃圾回收算法
        • 分代收集理论
          • 分代假说
          • 堆内存划分
          • 回收类型
        • 标记-清除
          • 工作过程
          • 缺陷
          • 停顿
        • 标记-复制(复制)
          • 优点
          • 缺点
          • 使用场景
          • 分配担保
        • 标记-整理
          • 与标记-清除的对比
        • 其它
        • 拓展阅读
        • 参考资料

概述

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收

垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。

每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。

对象

创建过程

虚拟机中对象创建的过程:

1、遇到new指令,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。 如果没有,那必须先执行相应的类加载过程。

2、在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定。

为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。有两种方式:

指针碰撞: 假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。

空闲列表: 如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案也有两种:

一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采CAS配上失败重试的方式保证更新操作的原子性。

另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存称为 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

3、**内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为 零值。**如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

4、**接下来,Java虚拟机还要对对象进行必要的设置,**例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5、在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始—— **构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。**一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

关于 Java 捡垃圾那些事_第1张图片

对象内存布局

一个Java对象在内存中包括对象头、实例数据和补齐填充3个部分:

关于 Java 捡垃圾那些事_第2张图片

对象头
  • Mark Word:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在32位系统占4字节,在64位系统中占8字节;

关于 Java 捡垃圾那些事_第3张图片

  • Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
  • Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;

对象实例数据

对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)。

对于reference类型来说,在32位系统上占用4bytes, 在64位系统上占用8bytes。

对齐填充

Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。

对象的访问定位

程序通过栈上的 reference 数据来操作堆上的具体对象。

主流的访问方式有句柄和直接指针两种,HotSpot使用句柄

  • 句柄:堆中划分一块内存专门用作句柄池,reference 存储对象的句柄地址,句柄包含对象实例数据以及类型数据的具体地址信息。
  • 直接指针访问中,reference 存储的是对象地址,如果单纯只访问对象,因为节省了一次指针定位的时间开销,所以会更快。

关于 Java 捡垃圾那些事_第4张图片

判断一个对象是否可被回收

引用计数算法

为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。

在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。

public class Test {

    public Object instance = null;

    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
        doSomething();
    }
}

在上述代码中,a 与 b 引用的对象实例互相持有了对象的引用,因此当我们把对 a 对象与 b 对象的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。

可达性分析算法

通过一系列 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到 GC Root 间没有任何引用链相连,则证明此对象不可能再被使用。

关于 Java 捡垃圾那些事_第5张图片

Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

  • 虚拟机栈中局部变量表中引用的对象,如线程被调用的方法堆栈中使用到的参数、局部变量、临时变量。
  • 本地方法栈中 JNI 中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中的常量引用的对象,如字符串常量池 String Table 里的引用。
  • JVM 内部引用,如基本数据类型对应的 Class 对象,异常对象等。
  • 被 synchronized 持有的对象。

某个区域的对象可能被位于堆中其它区域的对象所引用,需要将这些其它对象叶加入到 GC Root 集合。

finalize()

当一个对象被判断为不可达之后,需要判断该对象是否有必要执行 finalize() 方法,如果对象没有覆盖该方法,或者该方法已经被虚拟机调用过,那么此对象将不会被回收。

如果虚拟机认为该对象需要执行 finalize() 方法,则会将该对象放到一个队列当中,虚拟机启动一个线程区执行该队列的 finalize() 方法。

如果在该方法中让对象重新被引用,那么该对象就会被移出队列,从而不会被回收。

任何一个对象的 finalize() 方法只会被执行一次。

类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

主要回收常量池中废弃的常量和不再使用的类。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

常量池判断

在常量池中的 “java” 没有被任何字符串对象引用,虚拟机也没有其它地方引用这个字面量,则当发生内存回收时,这个 “java” 常量就会被清理出常量池。常量池中其它类(接口)、方法、字段的符号引用也与此类似。

类判断

类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

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

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

强引用

传统所说的“引用”。

被强引用关联的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

软引用

该类对象是描述一些还有用,但非必须的对象。

被软引用关联的对象只有在内存不够的情况下才会被回收。

使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

垃圾回收算法

分代收集理论

分代假说
  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
堆内存划分

将回收对象依据经过垃圾收集的次数分配到不同的区域中存储。

  • 新生代:将朝生夕灭的对象保存在一块,从而不需要再标记哪些对象需要回收。只需要关注如何保留少数需要存活的对象。

  • 老年代:将难以消亡的对象集中在一块,那么虚拟机可以使用较低的频率来回收这个区域。

分配区域的难点在于对象不是孤立的,对象会存在跨代引用。第三条假说证明互相引用的两个对象,倾向于同生共灭。

针对跨代引用,只需在新生代上建立一个全局的数据结构,这个结构将老年代划分成若干小块,标记哪一块内存存在跨代引用。这块内存的对象在 Minor GC 时会被加入 GC Root 中。

回收类型

根据划分的区域可以有不同的回收类型

  1. 部分收集(Partial GC),目标不是完整收集整个 Java 堆的垃圾收集
    • Minor GC / Young GC:新生代
    • Major GC / Old GC:老年代,现仅 CMS 支持。
    • Mixed GC:混合收集,整个新生代以及部分老年代,现仅 G1 支持。
  2. 整堆收集(Full GC),收集整个 Java 堆和方法区的垃圾收集。

根据划分的区域也可以有与区域立马存储对象存亡特征相匹配的垃圾收集算法

  • 标记-复制算法
  • 标记-清除算法
  • 标记-整理算法
  • 新生代使用:复制算法
  • 老年代使用:标记 - 清除 或者 标记 - 整理 算法

标记-清除

此算法是其它算法的基础。

关于 Java 捡垃圾那些事_第6张图片

工作过程

如果标记的对象是可回收对象,那么回收时就回收被标记的对象。如果标记的对象是活动对象,那么回收时就回收未被标记的对象。

以下按“被标记对象为活动对象叙述”。

在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。

在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表之后进行分配时只需要遍历这个空闲链表,就可以找到分块。

在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。

缺陷
  • 标记和清除过程效率都不高

    如果堆中包含大量对象,而且其中大部分是需要被回收的。这时必须要进行大量标记和清除动作,导致两个过程的效率都会随对象数量增长而降低。

  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存,此时不得不启动另一次垃圾收集动作。。

停顿

需要停顿用户线程来标记、清理可回收对象,只是停顿时间相对较短。

标记-复制(复制)

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

优点
  • 如果内存中大多数对象是可回收的,那么需要复制的对象就很少。
  • 只需要对整个半区进行复制。
  • 实现简单,运行高效。
缺点
  • 如果内存中大多数对象都是可存活的,那么开销就很大。
  • 可用内存为原来的一半,代价过大。
使用场景

现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后**清理 Eden 和使用过的那一块 Survivor。**Serial、ParNew 等新生代收集器均采用这种内存布局。

默认内存比例

  • Eden : Survivor = 8 :1.
分配担保

当尚未使用的 Survivor 不足以容纳一次 Minor GC 存活后的对象时,就需要依赖老年代进行分配担保,使得放不下的那些对象直接进入老年代。

关于 Java 捡垃圾那些事_第7张图片

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

因为可能需要进行空间分配担保,所以在老年代中一般不能直接选用。

标记-整理

关于 Java 捡垃圾那些事_第8张图片

针对老年代对象的存亡特征设计的算法。

让所有存活的对象都向一端移动,再更新所有引用这些对象的地方,然后直接清理掉端边界以外的内存。

这个过程中必须全程暂停用户应用程序,也叫 “Stop The World”。

与标记-清除的对比

过程:

两者的标记过程相同,而整理算法需要移动对象。

内存

移动则内存回收时会更复杂,不移动则内存分配时会更复杂。

停顿时间:

不移动对象停顿时间会更短,甚至可以不停顿。

移动则整个过程都会停顿。

吞吐量:

吞吐量 = (赋值器 + 收集器)的效率。

  • 不移动对象会使得收集器的效率提升,但内存分配和访问相比垃圾收集频率高得多,所以总吞吐量下降。

    如:关注延迟的CMS收集器。

  • 移动对象则相反。

    如:关注吞吐量的Parallel Scavenge收集器

两者融合

让虚拟机平时使用“标记-清除”算法,但内存碎片化程度已经大到影响内存分配时,使用一次“标记-整理”算法,如 CMS 收集器。

其它

ZGC、Shenandoah:读屏障(Read Barrier)实现整理过程与用户线程的并发执行,即整理过程不必再 Stop the World。

拓展阅读

Java 捡垃圾黑科技
Java 捡垃圾利器


⭐️ 如果对你有帮助,请点个赞

参考资料

《深入理解Java虚拟机-第三版》
cyc 2018
JavaGuide

你可能感兴趣的:(JVM)