漫谈 JVM 内存分代、垃圾回收

638 阅读9分钟
原文链接: gudong.name

关于 JVM 内存模型以及垃圾回收的文章网上很多,自己以前也看过很多,但是却从来也没有系统的去了解学习过,这次正巧看到一本讲解 JVM 的好书 - 周志明老师的《深入理解 Java 虚拟机》,然后就花了点时间,认真系统的学习了一遍。

这篇主要简单分享一下关于 JVM 内存模型、内存溢出、内存分代、以及垃圾回收算法的相关知识。当然在原书中,这几部分作者都花了不少篇幅去讲解。如果这篇文章让你对相关知识产生了兴趣而意犹未尽,推荐去阅读原书。

JVM 内存区域

都知道 JVM 的内存区域分为5个部分,如果有疑惑,可以参看之前的一篇文章 - JVM 内存区域介绍

这里也简单罗列一下 JVM 的五部分

  • 程序计数器

    这是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器,线程私有。

  • Java 虚拟机栈

    它是 Java方法执行的内存模型,每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,线程私有。

  • 本地方法栈

    跟虚拟机栈类似,不过本地方法栈用于执行本地方法,线程私有。

  • Java 堆

    该区域存在的唯一目的就是存放对象,几乎应用中所有的对象实例都在这里分配内存,所有线程共享。

  • 方法区

    它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,所有线程共享。

有关 OOM

都知道,任何一个应用在启动后,操作系统分配给它的内存一定是有限的,所以如何合理有效的管理内存,就变得尤为重要。

而从上节可知,我们一般讨论的对象内存分配均发生在 Java 堆上。所以这里说的内存管理大部分情况下即指对 Java 堆内存。而程序计数器、虚拟机栈他们随着线程生而生,亡而亡,所以他们内存相对比较好管理,出现的问题也比较少。

一个应用启动后,不停运行,不停的执行命令,创建对象,而这些对象,大都存放在堆内存区域。这部分区域的大小是有限的,而需要生成的对象是无限的,当某一次创建对象时发现堆内存实在没有空间可用来创建对象的时候,JVM 就会爆出 OutOfMemoryError 异常(后文统称 OOM),程序就会挂掉。

上面只是说明了一下表象。其实 OOM 远不是上面说的那么简单。如果要理解 OOM,这里还有一些其他知识需要说明:

  • 为了更好的管理内存,堆内存进行了分代。
  • OOM 发生前其实 JVM 会进行内存的垃圾回收(GC)。
  • 垃圾回收有多种不同的实现算法。
  • 堆内存的新生代和老年代的垃圾回收算法不一致。

内存分代

一个应用启动,操作系统会给他分配一个初始的内存大小,由上可知,这部分内存大部分应该属于堆内存,JVM 为了更好地利用管理这部分内存,对该区域做了划分。一部分成为新生代,另一部分称为老年代。

一开始对象的创建都发生在新生代,随着对象的不断创建,如果新生代没有空间创建新对象,将会发生 GC ,这时发生在新生代的 GC 称之为 Minor GC,位于新生代的对象每经过一次 Minor GC 后,如果这个对象没有被回收,则该对象自己的标记数加1,这个标记数用于标识这个对象经历了多少次的 Minor GC,对于 Sun 的 Hotspot 虚拟机,如果这个次数超过 15 ,该对象就会被移动到老年代。

随着时间的推移,如果老年代也没有足够的空间容纳对象,老年代也会试着发起 GC,这时的 GC 被称为 Full GC。

相比 Minor GC,Full GC 发生的次数比较少,但是每发生一次 Full GC,整个堆内存区域都需要执行一次垃圾回收算法,这对程序性能造成的影响比 Minor GC 大很多。所以我们应该尽量避免或者减少 Full GC 的发生。

同时,在堆内存区域,发生最多的 GC 情形就是新生代的 Minor GC 了,因为所有的对象会优先去新生代开辟空间,所以这块的内存变化会很快,只有内存不够用,就会发生 GC,但是一般的 Minor GC 执行比 Full GC 快很多。为什么呢?因为新生代和老年代的垃圾回收算法不一样。

垃圾回收算法

标记-清除算法(Mark-Sweep)

这是最基础的收集算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:

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

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

它的主要缺点有两个:

1、效率问题,标记和清除过程的效率都不高;

2、空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法(Copying)

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点。

但是这种算法的效率相当高,所以,现在的商业虚拟机都采用这种收集算法来回收新生代。为什么新生代可以使用复制算法呢?

IBM 有专门研究表明,新生代中的对象 98% 都是朝生夕死,所以就不需要按照1:1的比例来划分内存空间。这里鉴于此,新生代采用了如下的划分策略。

现在把新生代再划分为三部分,一块较大的 Eden(伊甸园) 和两块较小的 Survivor(幸存者) 区域。

当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot 虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。

这样清理完成后,原来的 Survivor 就空了,并一直保持为空,直到下次 Minor GC 时,它再作为存活对象的盛放地。两个 Survivor 就这样轮流当做 GC 过程中新生代存活对象的中转站。

但是,如果使用复制算法的内存区域有大量的存活对象时,复制算法就会变得捉襟见肘,这时需要更大的 Survivor 区用于盛放那些存活对象,甚至可能需要 1:1的比例。所以针对堆内存区域的老年代,就有了下面的算法。

标记-整理算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这种方法避免了碎片的产生,同时也不需要一块额外的内存空间,对于老年代会比较合适。

但是相比复制算法,虽然该算法占用的内存空间少,但是耗费的垃圾回收时间会比复制算法久,所以上面也说了

我们应该尽量避免或者减少 Full GC 的发生。

   这两种算法用精炼的语言描述就是

  • 复制算法:用空间换时间

  • 标记-整理算法:用时间换空间

一句话 鱼与熊掌不可兼得,但是针对新生代和老年代,他们都是最佳的选择。

总结

简单梳理一下文中讲到的一些知识点

  • 为了更好的管理堆内存,该区域分为新生代和老年代。
  • 新生代发生垃圾回收要比老年代频繁。
  • 新生代发生的垃圾回收成为 Minor GC;老年代发生的 GC 成为 Full GC。
  • 新生代使用复制算法进行垃圾回收;老年代使用标记-整理算法
  • 为了更高效管理新生代的内存,按照复制算法,结合 IBM 的研究论证,新生代分为三块,一块比较大的 Eden 区和两块比较小的 Survivor 区,比例为 8:1:1
  • 尽可能的避免或者减少垃圾回收

参考

《深入理解 Java 虚拟机》- 周志明老师

Android GC 原理探究