Java 虚拟机的垃圾回收

时间:2024-05-13 21:37:14

背景

垃圾收集(Garbage Collection,GC),GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。

对于Java来说,运行时区域:程序计数器虚拟机栈本地方法栈。这三个区域分配多少内存在类结构确定下来时就已知了(编译期可知)。

方法区这两个区域则不一样,只有程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。垃圾收集所关注的就是这部分内存。

判断对象是否存活算法

1、引用计数算法

在JDK1.2之前,使用的是引用计数算法。

当一个类被加载到内存以后,就会在方法区、堆栈、程序计数器等产生一系列信息,当创建对象的时候,为这个对象在堆栈中分配内存,同时会产生一个引用计数器,同时引用计数器+1,当有新的引用的时候,引用计数器继续+1,而当其中一个引用销毁的时候,引用计数器-1,当引用计数器被减为0的时候,标志着这个对象已经没有引用了,可以回收了!这种算法在JDK1.2之前的版本被广泛使用,但是随着业务的发展,很快出现了一个问题,当我们的代码出现下面的情形时,该算法将无法适应:

ObjA.obj = ObjB

ObjB.obj = ObjA

这样的代码会产生如下引用情形 objA 指向 objB,而 objB 又指向 objA,这样当其他所有的引用都消失了之后,objA 和 objB 还有一个相互的引用,也就是说这两个对象的引用计数器各为1,而实际上这两个对象都已经没有额外的引用,已经是垃圾了。

2、可达性分析算法

在JDK1.2之后都使用可达性分析算法。

这个算法的基本思路是通过一系列被称为 GC Roots 的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径被称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,证明此对象是不可用的,也就是可回收的。

Java 中可作为 GC Roots 的对象有:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

对象回收前的两次标记

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这是它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

Java 虚拟机的垃圾回收

对象自我拯救的演示代码:

package jvm;
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 Throwable {
//创建对象
SAVE_HOOK = new FinalizeEscapeGC(); //对象第一次自救成功,系统调用了对象的finalize()方法
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5s以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
}else {
System.out.println("不,我已经死了");
} //对象第二次自救失败,因为一个对象的finalize()方法最多只会被系统自动调用一次
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5s以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
}else {
System.out.println("不,我已经死了");
}
} }

运行结果:

Java 虚拟机的垃圾回收

注意:finalize()方法并不适合拯救对象,它的运行成本高昂,不确定性大,无法保证各个对象的调用顺序,finalize()能做的工作,比如关闭外部资源之类,使用try-finally或者其他方式都可以做得更好、更及时。

引用的定义

在JDK1.2之前,Java中的引用定义很传统:如果引用类型的数据中存储的数值代表的是另外一块内存的起始地址,就称在这块内存代表着一个引用。这种定义很纯粹,但是太过于狭隘。

在JDK1.2之后,Java对引用的概念进行扩充,将引用分为强引用、软引用、弱引用、虚引用四种,引用强度依次减弱。

强引用:

这种引用代码中普遍存在。是指创建一个对象并把这个对象赋给一个引用变量(类似 Object obj = new Object() 这类应用)。只要强引用还存在,垃圾收集器永远不会回收被引用的对象。即使内存不足的时候。

软引用:

用来描述一些还有用但并非必需的对象。软引用关联的对象在系统将要发生内存溢出异常之前,会把这些对象列入回收范围之中进行第二次回收。

通过 SoftReference 类来实现,当系统内存充足时,系统不会进行软引用的内存回收,软引用的对象和强引用没有太多区别,但内存不足时会回收软引用的对象。

弱引用:

也是用来描述非必需对象的。但是它的强度比软引用更弱一些,被弱应用关联的对象只能生存到下一次垃圾收集发生之前。

通过 WeakReference 类来实现,具有很强的不确定性。每次垃圾回收都会回收只被弱引用关联的对象。

虚引用:

虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是为了能在这个对象被垃圾收集器回收时收到一个系统通知。通过 PhantomReference 类来实现。

注意

软引用和弱引用可以单独使用,虚引用不能单独使用,必须关联引用队列。虚引用的作用是就跟踪对象被垃圾回收的状态,程序可以通过检测与虚引用关联的虚引用队列是否已经包含了指定的虚引用,从而了解虚引用的对象是否即将被回收。虚引用通过 PhantomRefence 类实现,它本身对对象没有影响,类似于没有引用,对象甚至感觉不到虚引用的存在,如果一个对象只有一个虚引用存在,那么它就类似没有引用存在。PlantomReference 比较特殊,它的 get 方法总是返回 null,所以你得不到它引用的对象。它保存在 ReferenceQueue 中的轨迹,允许你知道对象何时从内存中移除。

Java的引用对象类在包 java.lang.ref 下。其中包含了三种显式的引用类型(也即是Reference类的三个子类):SoftReference、 WeakReference、PhantomReference。

三种类型的引用定义了三种不同层次的可达性级别,由强到弱排列如下:SoftReference > WeakReference > PhantomReference,越弱表示对垃圾回收器的限制越少,对象越容易被回收。

垃圾收集算法

1、标记-清除算法(用于老年代)

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程就是上面所说的对象回收的二次标记

之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

Java 虚拟机的垃圾回收

2、复制算法(用于年轻代)

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

Java 虚拟机的垃圾回收

一般采用这种收集算法来回收年轻代,年轻代中的对象98%是朝生夕死的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉 Eden 和刚才用过的 Survivor 的空间。 HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次年轻代中可用内存空间为整个年轻代容量的90%(80%+10%), 只有10%的内存是会被“浪费”的。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。如果另外一块Survivor空间没有足够的空间存放上一次年轻代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

3、标记-整理算法(用于老年代)

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

Java 虚拟机的垃圾回收

分代收集

Java 虚拟机的垃圾回收

Java 虚拟机的垃圾回收

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

年轻代(Young generation):绝大多数最新被创建的对象会被分配到这里,由于大部分对象在创建后会很快变得不可达,所以很多对象被创建在新生代,然后消失。对象从这个区域消失的过程我们称之为”Minor GC“

一共有三个空间,其中包含一个伊甸园空间(Eden ),两个幸存者空间(Survivor )。各个空间的执行顺序如下:

  1. 绝大多数刚刚被创建的对象会存放在伊甸园空间。

  2. 在伊甸园空间执行了第一次GC之后,存活的对象被移动到其中一个幸存者空间。

  3. 此后,在伊甸园空间执行GC之后,存活的对象会被堆积在同一个幸存者空间。

  4. 当一个幸存者空间饱和,还在存活的对象会被移动到另一个幸存者空间。之后会清空已经饱和的那个幸存者空间。

  5. 在以上的步骤中重复几次依然存活的对象,就会被移动到老年代。

仔细观察这些步骤就会发现,其中一个幸存者空间必须保持是空的。如果两个幸存者空间都有数据,或者两个空间都是空的,那一定标志着你的系统出现了某种错误。

老年代(Old generation): 对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这里。其所占用的空间要比新生代多。也正由于其相对较大的空间,发生在老年代上的GC要比新生代少得多。对象从老年代中消失的过程,我们称之为”Major GC“(或者”Full GC“)。

永久代( Permanent generation):也被称为方法区(method area)。它用来保存类常量以及字符串常量。因此,这个区域不是用来永久的存储那些从老年代存活下来的对象。这个区域也可能发生GC。并且发生在这个区域上的GC事件也会被算为“Major GC”

GC停顿

可达性分析必须在一个能确保一致性的快照中进行,分析期间整个执行系统看起来就像被冻结在某个时间点上,如果分析过程中对象的引用关系还在不断变化,就不能保证分析结果的准确性。所以GC进行时必须停顿所有的Java执行线程。

1、线程执行时

程序执行是并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停,安全点的选定标准是以程序“是否具有让程序长时间执行的特征”,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。

GC发生时让所有线程(不包括执行JNI调用的线程)都“跑”到最近的安全点再停顿下来(即GC中断线程)的方式有两种:
  (1)抢先式中断
  在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。几乎没有虚拟机使用这种方式。

  (2)主动式中断
  当GC需要中断线程时,设置一个标志,这个标志的地方和安全点是重合的,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

2、线程不执行时

线程不执行的时候,例如线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求。如果这时候需要进行GC,就需要使用安全区域来解决。安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域的任何地方开始GC都是安全的。可以把安全区域看做是扩展了的安全点。

在线程执行到安全区域中的代码时,首先标识自己进入了安全区域,这时如果JVM要发起GC,就不会管标识为安全区域的线程。当线程要离开安全区域时,需要检查系统是否完成了根节点枚举(或者整个GC过程),如果没有完成,就必须等待直到收到可以安全离开的信号为止。