JVM初探(二):垃圾回收机制

概述

我们知道自动的垃圾回收机制是Java语言一个特点,它让我们在写程序的时候不再需要考虑内存管理问题。内存管理实际上就是分配内存回收内存这两个问题,在上一篇文章我大概介绍了jvm是如何划分内存空间以合理的分配内存的,而这篇文章就介绍一下jvm是如何回收内存的。

对于线程私有的程序计数器,虚拟机栈和本地方法栈三块数据区域而言,生命周期是和线程绑定的,线程结束时自然就回收内存了;而对于栈,每一个方法代表每一个栈帧,方法结束的时候就出栈,这时内存也跟着回收了。这些区域的内存回收都是具有确定性的,而堆就不同。

我们知道,堆主要用与存放对象实例,而只有运行时才知道要创建那些对象,而只有对象完全不被使用时才能回收其占用的内存空间。对于这块内容,我们需要明确三个问题:

  • 哪些对象可以回收?(引用计数法、可达性算法)
  • 这些内存什么时候回收?(新生代、老年代、永久代,MinorGC和FullGC)
  • 这些内存怎么回收?(三种垃圾收集算法和分代收集算法,七种垃圾收集器)

一、判断对象是否可回收

我们要判断对象是否可以回收,最有效的方式就是判断这个对象是否正在被别的对象引用。针对这个问题,有两种算法:引用计数算法可达性分析算法

1.引用计数算法

引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收

简单的来说,就是为每一个对象实例配置一个计数器:

  • 当一个实例被创建并分配一个对象引用的时候,计数器为1;
  • 每当该对象被分配给一个对象实例的时候计数器就加一;
  • 当对象实例的某个引用超过了生命周期或者被设置为别的实例时,计数器就减一
  • 当计数器为0时实例就会被回收

引用计数器实现简单而且效率高,但是无法解决循环引用问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class A {
public B b;
}

public class B {
public A a;
}

public void test(){
A a = new A();
B b = new B();
a.b = b;
b.a = a;
}

当A实例中引用了B实例,而B实例中又引用了A实例,他们的极速器就永远不能为0,也就无法回收。

2.可达性算法

可达性算法通过判断对象的引用链是否可达来判断对象是否可以被回收。

通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

在java中,可以作为GC Roots的对象包括:

  • 虚拟机栈汇总引用的对象
  • 方法区汇总静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中方法引用的对象
可达性分析算法

3.强引用、软引用、弱引用、虚引用

我们知道以上两种算法都需要判断对象是否被引用,实际上,如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用,因此对象往往只有两种状态:被引用或者未被引用。

如果我们希望有这样一类对象:当内存空间足够时,能保留在内存中;如果内存空间在进行垃圾回收后还是非常紧张,就抛弃这些对象。比如一些系统缓存。

因此为了做出区分,JDK1.2之后Java的引用被分为强引用、软引用、弱引用、虚引用4种。这4种引用强度依次逐渐减弱。

  • 强引用指类似Object obj=new Object()这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
  • 软引用用来描述一些还有用但并非必须的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次回收。用SoftReference类实现。
  • 弱引用也描述非必需对象,只能存活到下一次垃圾回收之前。用WeakReference类实现。
  • 虚引用也被称为幽灵引用,为一个对象设置虚引用的唯一目的就是能在这个对象被垃圾回收时收到一个系统通知。用PhantomReference类实现。

这块内容具体可以参考:Java 的强引用、弱引用、软引用、虚引用

二、垃圾收集算法

要理解垃圾回收时机,我们需要理解分代算法,在这之前我们需要对四种垃圾收集算法有大概的印象:

1.标记清除算法

首先标记出所有需要回收的对象,在标记完成之后统一回收所有比标记的对象。

标记清除算法有两个问题:

  • 效率问题:标记和清除两个过程的效率都不高;
  • 空间问题:标记清除之后会产生大量不连续的内存碎片

2.复制算法

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

复制算法常用于回收新生代

如我们之前在介绍堆的内存结构的时候,jvm会将堆分外新生代和老年代。

而将新生代内存又分为一块较大的eden空间和两块较小的survivor空间,每次使用eden和其中一块survivor。当回收时,将edensurvivor中还存活着的对象一次地复制到另外一块survivor空间上,最后清理掉eden和刚才用过的survivor空间。

3.标记-整理算法

与标记清除类似,但是不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法常用于老年代。

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

4.分代收集算法

根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率:

  • 在新生代每次垃圾收集时都有大批对象死去,只有少量存活,所以使用复制算法;
  • 在老生代中代中存活率高,使用标记清理或者标记整理算法来回收。

三、分代收集算法的内存回收策略

java堆

正如之前所说,由于java对象实例存储于堆中,所以堆就是GC的主要场所。

根据分代收集算法,堆会分为新生代和老年代。

1.新生代和老年代

新生代的目标就是尽可能快速的收集掉那些生命周期短的对象,一般情况下,所有新生成的对象首先都是放在新生代的。新生代发生的GC叫MinorGC

老年代存放的都是一些生命周期较长的对象,就在新生代中经历了N次垃圾回收后仍然存活的对象会被放到老年代中。老年代发生的GC叫FullGC

新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0,survivor1)区,大部分对象在eden区中生成。

在进行垃圾回收时:

  • 一般先将eden区存活对象复制到survivor0区,然后清空eden区;
  • survivor0区也满了时,则将eden区和survivor0区存活对象复制到survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后交换survivor0区和survivor1区的角色。下次垃圾回收时扫描eden区和survivor1区,然后再交换survivor0区和survivor1,如此反复;
  • survivor1区也不足以存放eden区和survivor0区的存活对象时,就将存活对象直接存放到老年代。

2.分代收集算法的内存分配策略

这里再提一下内存分配策略:

  • 对象优先分配给eden区域。当eden区域没有足够空间时,发起一次MinorGC。
  • 需要大量连续内存空间的大对象直接进入老年代。比如巨长的数组或者字符串,还有非常高的树之类的。
  • 长期存活的对象会进入老年代。对象在新生代活过一定次数GC(一般是15次)后会移入老年代。
  • 动态对象年龄判定。如果在survivor空间中相同年龄所有对象大小的总和大小大于survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代

四、垃圾收集器

1.Serial收集器

新生代收集器,单线程。

2.ParNew收集器

Serial收集器的多线程版本.

3. Serial Old收集器

Serial收集器用于老年代的多线程版本。

4.Parallel Scavenge收集器

新生代收集器,多线程。它的关注点与其他收集器不同,其他的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标则是达到一个高吞吐量。

5.Parallel Old收集器

Parallel Scavenge收集器的老年代版本。

6.CMS收集器

老年代并行,以获取最短回收停顿时间为特点的收集器。只有他用标记-清除算法顶不住了就用Serial Old帮忙。

7.G1收集器

G1收集器是JDK7提供的一个新收集器。

G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代

0%