今天我们来聊 GC ( Garbage Collection ), Java 与 C++ 之间有一堵由内存动态分配垃圾收集技术所围城的 “高墙”

外面的人想进去,里面的人想出来

GC 是什么?

Java垃圾回收机制

目前主流的 JVM(HotSpot)采用的是分代收集算法。与 C++ 不同的是,Java 采用的是类似于树形结构的可达性分析法来判断对象是否还存在引用。

即:从 GC Root 开始,把所有可以搜索得到的对象标记为存活对象。

JVM 内存结构

GC主要是针对运行的数据区的

做应用程序开发,只需要大方向关注 5 块区域

  1. 方法区(Method Area);
  2. Java栈(Java stack);
  3. 本地方法栈(Native Method Stack);
  4. 堆(Heap);
  5. 程序计数器(Program Counter Register)
方法区 (Method Area)

    与Heap一样,也是各个线程共享的内存域,这块区域主要是用来存储类加载器加载的类信息,常量,静态变量,通俗的讲就是编译后的class文件信息。

Jvm栈

    与程序计数器一样,它是每个线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

本地方法栈(Native Method Stacks)

    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

堆 (Heap)

    是Jvm管理的内存中最大的一块。程序的主要数据也都是存放在堆内存中的,这一块区域被所有的线程所共享,通常出现线程安全问题的一般都是这个区域的数据出现的问题。

程序计数器 (Program Counter Register)

    个人感觉的他就是为多线程准备的,程序计数器是每个线程独有的,所以是线程安全的。它主要用于记录每个线程的执行情况。

GC实现机制-我们为什么要去了解GC和内存分配?

A:

  • 内存溢出
  • 内存泄漏
  • 并发处理
  • 当垃圾收集成为系统达到更高并发量的瓶颈时,我们需要监控和调节

GC 主要是针对 Java Heap 这块区域,其次是方法区

JVM的内存空间,从大的层面上来分析包含:新生代空间( Young )和老年代空间( Old )。新生代空间(Young)又被分为2个部分(Eden区域、Survivous区域)和3个板块(1个Eden区域和2个Survivous区域)

Eden(伊甸园)区域:用来存放使用new或者newInstance等方式创建的对象,默认这些对象都是存放在Eden区,除非这个对象太大,或者超出了设定的阈值-XX:PretenureSizeThresold,这样的对象会被直接分配到Old区域。

2个Survivous(幸存)区域:一般称为S0,S1,理论上他们一样大。

Q:
S0和S1一般多大,靠什么参数来控制,有什么变化?

A:

一般来说很小,我们大概知道它与Young差不多相差一倍的比例,设置的参数主要有两个:

-XX:SurvivorRatio=8
-XX:InitialSurvivorRatio=8

第一个参数 :是 Eden 和 Survivous 区域比重(注意 Survivous 一般包含两个区域 $S_{0}$ 和 $S_{1}$ ,这里是一个Survivous的大小)。如果将 -XX:SurvivorRatio=8 设置为8,则说明Eden区域是一个Survivous区的8倍,换句话说 $S_{0}$ 或 $S_{1}$ 空间是整个Young空间的1/10,剩余的8/10由Eden区域来使用。

第二个参数 :是 $Young/S0$ 的比值,当其设置为 8 时,表示 $S_{0}$ 或 $S_{1}$ 占整个 Young 空间的1/8(或12.5%)

GC的实现

  1. Minor GC
  2. Full GC

通过 Minor GC 后进入旧生代的平均大小大于旧生代的可用内存。如果发现统计数据说之前 Minor GC 的平均晋升大小比目前旧生代剩余的空间大,则不会触发 Minor GC 而是转为触发 Full GC。

Minor GC

First GC

在不断创建对象的过程中,当Eden区域被占满,此时会开始做Young GC也叫Minor GC

  1. 第一次GC时Survivous中S0区和S1区都为空,将其中一个作为To Survivous(用来存储Eden区域执行GC后不能被回收的对象)。比如:将S0作为To Survivous,则S1为From Survivous。

  2. 将Eden区域经过GC不能被回收的对象存储到To Survivous(S0)区域(此时Eden区域的内存会在垃圾回收的过程中全部释放),但如果To Survivous(S0)被占满了,Eden中剩下不能被回收对象只能存放到Old区域。

  3. 将Eden区域空间清空,此时From Survivous区域(S1)也是空的。

  4. S0与S1互相切换标签,S0为From Survivous,S1为To Survivous。

Second GC

当第二次Eden区域被占满时,此时开始做GC

  1. 将Eden和From Survivous(S0)中经过GC未被回收的对象迁移到To Survivous(S1),如果To Survious(S1)区放不下,将剩下的不能回收对象放入Old区域;

  2. 将Eden区域空间和From Survivous(S0)区域空间清空;

  3. S0与S1互相切换标签,S0为To Survivous,S1为From Survivous。

。。。以此类推

第三次、第四次…

反反复复多次没有被淘汰的对象,将会被放入Old区域中

究竟会经过多少次?

设置次数的参数:由计数器记录【计数器会在对象的头部记录它的交换次数】

–XX:MaxTenuringThreshold=15

Full GC

我们来捋捋 Full GC 的触发条件:

  1. System.gc()方法的调用;
    此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC来禁止RMI(Java远程方法调用)调用System.gc。
  2. 旧生代空间不足;
    旧生代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space 。为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
  3. 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

GC实现机制-Java虚拟机如何实现垃圾回收机制

  1. 引用计数算法(Reference Counting)
  2. 可达性分析算法(Reachability Analysis)

引用计数算法(Reference Counting)

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,这就是引用计数算法的核心。

客观来讲,引用计数算法实现简单,判定效率也很高,在大部分情况下都是一个不错的算法。但是Java虚拟机并没有采用这个算法来判断何种对象为死亡对象,因为它很难解决对象之间相互循环引用的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ReferenceCountingGC {
public Object object = null;

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

public static void testCG(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();

objA.object = null;
objB.object = null;

System.gc();
}
}

在上述代码段中,objA与objB互相循环引用,没有结束循环的判断条件,运行结果显示Full GC,就说明当Java虚拟机并不是使用引用计数算法来判断对象是否存活的。

可达性分析算法(Reachability Analysis)

这是Java虚拟机采用的判定对象是否存活的算法。

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

可作为GC Roots的对象包括:

  1. 虚拟机栈中引用的对象;
  2. 方法区中类静态属性引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈JNI引用的对象

GC实现机制-何为死亡对象?

从根引用开始,对象的内部属性可能也是引用,只要能级联到的都被认为是活着的对象。

Java虚拟机在进行死亡对象判定时,会经历两个过程。

如果对象在进行可达性分析后没有与 GC Roots 相关联的引用链,则该对象会被 JVM 进行第一次标记并且进行一次筛选

筛选的条件是此对象是否有必要执行 finalize() 方法,

  • 如果当前对象没有覆盖该方法,或者 finalize() 方法已经被 JVM 调用过都会被虚拟机判定为“没有必要执行”。

  • 如果该对象被判定为没有必要执行,那么该对象将会被放置在一个叫做 F-Queue 的队列当中,并在稍后由一个虚拟机自动建立的、低优先级的 Finalizer 线程去执行它,在执行过程中JVM可能不会等待该线程执行完毕,因为如果一个对象在 finalize() 方法中执行缓慢,或者发生死循环,将很有可能导致 F-Queue 队列中其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

  • 如果在 finalize() 方法中该对象重新与引用链上的任何一个对象建立了关联,即该对象连上了任何一个对象的引用链,例如 this 关键字,那么该对象就会逃脱垃圾回收系统;

  • 如果该对象在 finalize() 方法中没有与任何一个对象进行关联操作,那么该对象会被虚拟机进行第二次标记,该对象就会被垃圾回收系统回收。值得注意的是 finaliza() 方法JVM系统只会自动调用一次.

  • 如果对象面临下一次回收,它的 finalize() 方法不会被再次执行。

GC实现机制-垃圾收集算法

  1. 标记-清除算法(Mark-Sweep)
  2. 复制算法(Copying)
  3. 分代收集(Generational Collection)算法
  4. 标记-整理(Mark-Compat)算法

新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;

老年代中,其存活率较高、没有额外空间对它进行分配担保,就应该使用“标记-整理”或“标记-清理”算法进行回收。

常用的垃圾回收器

  1. Serial收集器
  2. ParNew收集器
  3. Parallel Scavenge收集器
  4. CMS(Concurrent Mark Sweep) 收集器
  5. G1(Garbage First) 收集器

Q:
对象进入Old区域有什么坏处?

A:
     Old区域一般称为老年代,老年代与新生代不一样。新生代,我们可以认为存活下来的对象很少,而老年代则相反,存活下来的对象很多,所以JVM的堆内存,才是我们通常关注的主战场,因为这里面活着的对象非常多,所以发生一次FULL GC,来找出来所有存活的对象是非常耗时的,因此,我们应该避免FULL GC的发生。

Q:
为什么发生FULL GC会带来很大的危害?

A:
    在发生FULL GC的时候,意味着JVM会安全的暂停所有正在执行的线程(Stop The World),来回收内存空间,在这个时间内,所有除了回收垃圾的线程外,其他有关JAVA的程序,代码都会静止,反映到系统上,就会出现系统响应大幅度变慢,卡机等状态。