本篇为学习JAVA虚拟机的第八篇文章,我们知道,JVM为我们管理垃圾对象实现自动回收,让我们不需要太关心内存释放问题,一定程度上减少了内存溢出的错误。这一切的背后是如何实现的呢?

一、垃圾标记算法

1.1 引用计数法

算法思想

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效时,计数器☞减一;任何时候计数器为0的对象是不可能再被使用的。

主要缺陷

无法解决对象间相互循环引用的问题。

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Test {

public Object instance = null;

private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];

public static void testGC()
{
Test objA = new Test();//count=1
Test objB = new Test();//count=1

objA.instance = objB;//count=2
objB.instance = objA;//count=2

objA = null;//count=1
objB = null;//count=1

System.gc();
}

public static void main(String[] args) {
testGC();
}
}

输入参数

-verbose:gc -XX:+PrintGCDetails

结果

1
2
3
4
5
6
7
8
9
10
11
[GC (System.gc()) [PSYoungGen: 6063K->600K(37888K)] 6063K->608K(123904K), 0.0037131 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 600K->0K(37888K)] [ParOldGen: 8K->529K(86016K)] 608K->529K(123904K), [Metaspace: 2595K->2595K(1056768K)], 0.0062705 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 37888K, used 328K [0x00000000d6100000, 0x00000000d8b00000, 0x0000000100000000)
eden space 32768K, 1% used [0x00000000d6100000,0x00000000d6152030,0x00000000d8100000)
from space 5120K, 0% used [0x00000000d8100000,0x00000000d8100000,0x00000000d8600000)
to space 5120K, 0% used [0x00000000d8600000,0x00000000d8600000,0x00000000d8b00000)
ParOldGen total 86016K, used 529K [0x0000000082200000, 0x0000000087600000, 0x00000000d6100000)
object space 86016K, 0% used [0x0000000082200000,0x0000000082284778,0x0000000087600000)
Metaspace used 2601K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 288K, capacity 386K, committed 512K, reserved 1048576K

分析

日志中6063K->600K(37888K),从原来的6M内存变成了600k,表明对象已被回收,从而表明JVM没有使用引用计数算法。Java中使用了可达性分析算法来来判定对象是否存活。

1.2 可达性分析算法

这个算法的基本思路就是通过一系列的称谓GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所有走过的路径为引用链,当一个对象到GC Roots没有任何引用链时,则证明此对象时不可用的,下面看一下例子:

image

上面的这张图,对象object5object6object7虽然互相没有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象

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

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

二、Java中的引用类型

从JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用,这四种引用的强度一次逐渐减弱

  1. 强引用就是指在程序代码之中普遍存在的,类似 Object obj = new Object() 这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。

  2. 软引用是用来描述一些还有用但并非需要的对象,对于软引用关联着的对象,在系统将要发生内存异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存异常

  3. 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存释放足够,都会回收掉只被弱引用关联的对象

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

三、两次标记

《深入理解java虚拟机》原文:

在java根搜索算法中判断对象的可达性,对于不可达的对象,也并不一定是必须清理。这个时候有一个缓刑期,真正的判断一个对象死亡,至少要经过俩次标记过程:

如果对象在进行根搜索后发现没有与GC roots相关联的引用链,那他将会第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这俩种情况都视为“没有必要执行”。

即当一个对象重写了finalize()方法的时候,这个对象被判定为有必要执行finalize()方法,那么这个对象被放置在F-Queue队列之中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的执行是指虚拟机会出发这个方法,但不承诺会等待它运行结束。这样做的原因:如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(极端的情况下),将可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何建立关联即可,那么在第二次标记时它将会被移出“即将回收”的集合;如果对象这时候没有逃脱,就会被回收。

3.1 finalize的工作原理

一旦垃圾收集器准备好释放对象占用的存储空间,它首先调用finalize(),而且只有在下一次垃圾收集过程中,才会真正回收对象的内存.所以如果使用finalize(),就可以在垃圾收集期间进行一些重要的清除或清扫工作.

3.2 finalize()在什么时候被调用?
  1. 所有对象被Garbage Collection时自动调用,比如运行System.gc()的时候.
  2. 程序退出时为每个对象调用一次finalize方法。
  3. 显式的调用finalize方法

这个方法的用途就是:在该对象被回收之前,该对象的finalize()方法会被调用。这里的回收之前指的就是被标记之后,问题就出在这里,有没有一种情况就是原本一个对象开始不再上一章所讲的“关系网”(引用链)中,但是当开发者重写了finalize()后,并且将该对象重新加入到了“关系网”中,也就是说该对象对我们还有用,不应该被回收,但是已经被标记啦,怎么办呢?

针对这个问题,虚拟机的做法是进行两次标记,即第一次标记不在“关系网”中的对象,并且要判断该对象有没有实现finalize()方法了,如果没有实现就直接判断该对象可回收。如果实现了就会先放在一个队列中,并由虚拟机建立的一个低优先级的线程去执行它。

随后就会进行第二次的小规模标记,如果对象还没有逃脱,在这次被标记的对象就会真正的被回收了。

四、垃圾收集算法

4.1 标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经基本介绍过了。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

标记-清除算法的执行过程如图:

image

4.2 复制算法

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

复制算法的执行过程如图:

image

4.3 标记-整理算法

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

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

image

4.4 分代收集算法

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

image

五、新生代和老年代

5.1 新生代

新生代分为三个区域,一个Eden区和两个Survivor区,它们之间的比例为(8:1:1),这个比例也是可以修改的。通常情况下,对象主要分配在新生代的Eden区上,少数情况下也可能会直接分配在老年代中。

Java虚拟机每次使用新生代中的Eden和其中一块SurvivorFrom),在经过一次MinorGC后,将EdenSurvivor中还存活的对象一次性地复制到另一块Survivor空间上(这里使用的复制算法进行GC),最后清理掉Eden和刚才用过的SurvivorFrom)空间。将此时在Survivor空间存活下来的对象的年龄设置为1,以后这些对象每在Survivor区熬过一次GC,它们的年龄就加1,当对象年龄达到某个年龄(默认值为15)时,就会把它们移到老年代中。

在新生代中进行GC时,有可能遇到另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

总结:

1、Minor GC是发生在新生代中的垃圾收集,采用的复制算法;

2、新生代中每次使用的空间不超过90%,主要用来存放新生的对象;

3、Minor GC每次收集后Eden区和一块Survivor区都被清空;

5.1 老年代

老年代里面存放都是生命周期长的对象,对于一些较大的对象(即需要分配一块较大的连续内存空间),是直接存入老年代的,还有很多从新生代的Survivor区域中熬过来的对象。

老年代中使用的是Full GCFull GC所采用的是标记-清除或者标记-整理算法。老年代中的Full GC不像Minor GC操作那么频繁,并且进行一次Full GC所需要的时间要比Minor GC的时间长。

5.2 触发Full GC的条件
  • 老年代空间不足
  • JDK8以前的永久代空间不足,现在永久代已经被元数据区代替
  • CMS GC时出现promotion failedconcurrent mode failure(下面文章讲到CMS垃圾收集器的时候会说明)
  • minor GC晋升到老年代的平均大小大于老年代的剩余空间
  • 调用System.gc()提醒JVM回收一下,只是提醒
5.3 对象如何晋升到老年代

一般有如下情况会晋升:

  • 经历一定minor次数依然存活的对象
  • survivor区中存放不下的对象
  • 新生成的大对象
5.4 常用的调优参数

image

5.5 内存申请过程

A. JVM会试图为相关Java对象在Eden中初始化一块内存区域

B. 当Eden空间足够时,内存申请结束。否则到下一步

C. JVM试图释放在Eden中所有不活跃的对象(Minor GC), 释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor

D. 当Survivor区空间不够时或者某些对象熬的时间比较长,则Survivor区这些对象会被移到Old

E. 当Old区空间不够时,JVM会在Old区进行完全的垃圾收集(Full GC

F. 完全垃圾收集后,若SurvivorOld区仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现out of memory错误.