JVM-03-垃圾回收

JVM-03-垃圾回收

前序

如果对C++这门语言熟悉的人,再来看Java,就会发现这两者对垃圾(内存)回收的策略有很大的不同。

  • C++:垃圾回收很重要,我们必须要自己来回收!!!
  • Java:垃圾回收很重要,我们必须交给系统来帮我们完成!!!

我想这也能看出这两门语言设计者的心态吧,

总之,Java和C++之间有一堵由内存动态分布和垃圾回收技术所围成的高墙,墙外面的人想进去,墙里面的人想出来。

本篇我们就来详细介绍Java的垃圾回收策略。

1. 为什么要进行垃圾回收?

  • Java是一门面向对象的语言,在一个系统运行中,会伴随着很多对象的创建,而这些对象一旦创建了就占据了一定的内存
  • 在上一篇我们已经介绍过,创建的对象是保存在堆中的,当对象使用完毕之后,如果不进行清理,那么会一直占据内存空间,很明显内存空间是有限的,如果不回收这些无法的对象咋局的内存,那么新创建的对象也申请不到内存空间,系统就会抛出异常。
  • 所以必须要经常进行内存的回收,也就是垃圾收集。

2. 为什么要了解垃圾回收?

  • 首先最主要的一点:面试必问哈哈哈!
  • 文章开头,我们就说Java的垃圾回收是系统自动进行的,不需要我们程序员手动处理,那么我们为什么还要了解垃圾回收呢,?
    • 其实这也是一个程序猿进阶的过程,生产项目在运行过程中,很可能会存在内存溢出,内存泄漏等问题,出现了这些问题,我们应该怎么排除?
    • 还有就是在生产服务器有限的资源上如果做更好的Java运行时内存区域,提高系统的运行效率等,我们必须知其然知其所以然。

PS:本篇博客只是介绍Java垃圾回收机制,关于排查内存泄漏、溢出,运行时内存区域参数调优等会在后面进行介绍(也就是下一篇博客)。

3. 回收的哪部分的区域?

  • Java运行时的内存结构,其中程序计数器、虚拟机栈、本地方法栈这三个区域是线程私有的,随线程而生,随线程而灭
  • 栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈和出栈操作

这几个区域的内存分配和回收都具备确定性,在方法结束或线程结束时,内存也就跟着回收了,所以不需要我们考虑。

  • 那么现在就剩下方法区了,这两块区域在编译期间我们并不能确定完全创建多少个对象,有些是在运行时期创建的对象,所以Java内存回收机制主要是作用在这两块区域。

4. 怎么判断对象是垃圾对象?

通过上面介绍了,我们了解了为什么要进行垃圾回收以及回收哪部分的垃圾,那么接下来我们怎么去区分哪些对象为垃圾呢?

换句话来说,我们如何判断哪些对象还“活着”,哪些对象已经“死了”,那些“死了”的对象占据的内存就是我们要进行回收的。

4.1 引用计数法

这个算法是这样的:

  • 给每个创建的对象增加一个引用计数器,每当有一个地方引用它时,这个计数器就加1。
  • 而当引用失效的时候,这个计数器就减1。
  • 当这个计数器为0的时候,也就是说没有这个对象的引用了,那么这就是一个无效的对象,便可以进行垃圾回收了。

这个算法实现简单,而且效率也高,但是Java没有采用该算法来进行垃圾回收

原因:这种算法无法解决对象之间循环引用的问题。

下面我们来看一个循环引用的例子:

  • 首先有一个Person类,这个类有两个自引用属性,分别表示其父亲,儿子
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
26
27
28
29
30
31
32
33
34
public class Person {

private Byte[] _1MB = null;

public Person(){
/**
* 这个成员属性的作用纯粹就是占据一定内存,
* 以便在日志中查看是否被回收
*/

_1MB = new Byte[1024 * 1024];
}


private Person father;
private Person son;


public Person getFather() {
return father;
}

public void setFather(Person father) {
this.father = father;
}

public Person getSon() {
return son;
}

public void setSon(Person son) {
this.son = son;
}
}
  • 接着,我们通过Person类构造两个对象,分别是父亲,儿子,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) {
Person father = new Person();
Person son = new Person();

father.setSon(son);
son.setFather(father);


father = null;
son = null;

/**
* 调用此方法表示希望进行一次垃圾回收。但是它不能保证垃圾回收一定会进行,
* 而且具体什么时候进行是取决于具体的虚拟机的,不同的虚拟机有不同的对策。
*/

System.gc();

}
  • 最后我们来分析一波
  1. 首先,从第3-6行代码,其运行时内存结构图如下:

mark

father对象和son对象

  • 其引用计数的 第一个是栈内存指向
  • 第二个就是其属性互相引用对方
  • 所有引用计数器都是2。
  1. 接着我们来看代码第8,9行,分别将这两个对象置为null,也就是去掉了栈内存的指向

mark

  1. 结论

到这个时候其实这两个对象只是自己引用罢了,没有别的地方在引用它们,引用计数器为1,那么这两个对象按照引用计数算法实现的虚拟机就不会回收,可想而知,这是我们不能接受的。

所以Java虚拟机都没有使用该算法来判断对象是否存活,我们可以通过增加打印虚拟机参数来验证。

我们将测试类加上一个JVM虚拟机参数,用来打印GC的信息

1
-verbose:gc

在IDEA中如下设置参数:

mark

运行结果如下:

mark

我们可以看到GC从12129K->696K,表示垃圾会收钱有12129K,回收后剩下696K。

堆的总量是(251392K) 回收的内存大小是12129-696=11433K.

换句话说,上面的例子Java虚拟机是有进行垃圾回收的,所以,这也间接佐证了Java虚拟机并不是采用的引用计数法来判断对象是否是垃圾。

PS:这些参数信息详解也会在后面博客进行详细介绍。

4.2 根搜索算法

我们这里直接给出结论:在主流的商用程序中(Java,C#),都是使用根搜索算法(GC Roots Tracing)来判定对象是否存活。

该算法的思路是:通过一系列名为“GC Roots” 的对象作为终点,当一个对象到GC Roots之间无法通过引用到达的时候,那么该对象别可以进行回收了。

mark

上图Object1,Object2,Object3到GC Roots是可达的,所以不会被作为垃圾回收。

mark

上图Object1,Object2,Object3这三个对象互相引用,但是到 GC Roots不可达,所以都会被垃圾回收掉。

那么有哪些对象可以作为GC Roots呢?

在Java语言中,以下四个对象可以作为GC Roots:

mark

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

5. 如何进行垃圾回收?

垃圾回收涉及到大量的程序细节,而且各个平台的虚拟机操作内存的方式也不一样,但是他们进行垃圾回收的算法是通用的,所以这里我们也只介绍几种通用算法。

5.1 标记-清除算法

算法实现:分为标记-清除两个阶段,首先根据上面的根搜索算法标记出所有需要回收的对象,在标记完成后,然后在统一回收掉所有被标记的对象。

缺点:

  1. 效率低:标记和清除这两个过程的效率都不高。
  2. 容易产生内存碎片,因为内存申请是不连续的,那么清除一些对象以后,就会产生大量,而碎片太多时,当有个大对象需要分配内存时,便会造成没有足够的连续内存分配而提前触发垃圾回收,甚至直接抛出OutOfMemoryExecption。

mark

5.2 复制算法

为了解决标记-清除算法的两个缺点,复制算法诞生了。

算法实现

  • 将 可用的内存按容量划分为两个大小相等的区域,每次只用掉其中的一块,当这一块内存用完了,就将还活着的对象复制到另一块区域上,然后再把已使用过的内存一次性清理掉。

优点:

  • 每次都是只对其中一块内存进行回收,不用考虑内存碎片的问题,而且分配内存的时候,只需要移动堆顶部的指针,按顺序进行分配,简单高效

缺点

将内存分为两块,但是每次只能使用一块,也就是说,机器的一半内存是闲置的,这资源浪费有点严重。并且如果对象存活率较高,每次都需要复制大量的对象,效率也会变得很低。

mark

5.3 标记-整理算法

上面我们说过复制算法会浪费一半的内存,并且对象存活率较高时,会有过多的复制操作,效率低下。

如果对象存活率很高,基本上不会进行垃圾回收的时候,标记-整理算法就诞生了。

算法实现

  • 首先标记出所有存活的对象,然后让所有存活对象向一端进行移动,最后直接清理到端边界以外的内存。

局限性:只有对象存活率很高的情况下,使用该算法才会效率较高。

mark

5.4 分代收集算法

当前商业虚拟机都是采用此算法,但是其实这不是什么新的算法,而是上面几种算法的合集。

算法实现:

  1. 对于存活周期较短,每次都有大批对象死亡,只有少量存活的区域,采用复制算法,因为只要需要付出少量存活对象复制的成本
  2. 对于存活周期较长,没有额外空间进行分配担保的区域,采用标记-整理算法,或者标记-清除算法。

比如,对于 HotSpot 虚拟机,它将堆空间分为如下两块区域:

mark

堆有新生代老年代两块区域组成,而新生代区域又分为三个部分,分别是 Eden,From Surivor,To Survivor ,比例是8:1:1。

  • 新生代采用复制算法,每次使用一块Eden区和一块Survivor区,当进行垃圾回收的时候,将Eden和一块Survivor区域的所有存活对象都复制到另外一块Survivor区域,然后清理掉刚刚存放对象的区域,以此循环。
  • 老年代采用标记-清除或者标记-整理算法,根据使用的垃圾回收器来进行判断。

至于为什么要这样,这是由于内存分配的机制导致的,新生代存的基本上都是朝生夕死的对象,而老年代存放的都是存活率很高的对象。关于内存分配下篇博客我们会详细进行介绍。

6. 何时进行垃圾回收?

理清了什么是垃圾,怎么回收垃圾,最后一点就是Java虚拟机何时进行垃圾回收呢?

  • 程序猿可以使用System.gc()方法,手动回收,但是它不能保证垃圾回收一定会进行,而且具体什么时候进行是取决于具体的虚拟机的,不同的虚拟机有不同的对策。
  • 其次虚拟机会自行根据当前内存的大小,判断合适进行垃圾回收,比如前面所说的,新生代满了,新生代产生的对象无法分配内存时,便会触发垃圾回收机制。

这里需要说明的是宣告一个对象的死亡,至少要经过两次标记。

  • 前面说过,如果对象与GC Roots 不可达,那么此对象会被第一次标记并进行一次筛选(筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖 finalize()方法,或者该方法已经执行了一次,那么虚拟机都将视为没有必要执行finalize()方法。)

  • 如果这个对象有必要执行 finalize()方法,那么该对象将会被放置在一个虚拟机自动创建的,低优先级,名为F-Queue 队列中是,GC会对F-Queue 进行二次标记,如果对象在finalize() 成功拯救了自己(比如重新建立了GC Roots链接),那么在第二次标记的时候,就会将该对象移除即将回收的集合,否则就会被回收掉。

打赏
  • 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!
  • © 2019-2022 Zhuuu
  • PV: UV:

请我喝杯咖啡吧~

支付宝
微信