深入理解Java虚拟机 ch3 垃圾回收器和内存分配策略 读书笔记

part2 自动内存管理机制

  本章接着上一章的内容继续讲。本章的内容分两块:垃圾收集器内存分配和回收策略,也就是内存的分配和回收。

章三 垃圾收集器和内存分配策略

  首先谈内存分配。上一章节提到,JVM运行时内存分为5个区域,其中程序计数器,虚拟机栈和本地方法栈这三个区域是线程私有的,随线程创建而分配,线程死亡而收回,无需特别管理。而方法区和Java堆中的内存分配和回收则较为复杂,尤其是Java堆,是本章研究的重点。

  下面谈垃圾收集器。顾名思义,垃圾收集器负责垃圾回收。那么,什么是垃圾,什么时候回收垃圾,怎样回收垃圾?这三个问题就是有关垃圾回收器设计的最核心问题。随后,还会讲述一些重要的垃圾回收器。

一 垃圾回收

1.1 什么是垃圾

  前面说到,垃圾回收器是针对Java堆和方法区这两块内存区域进行回收,那么垃圾也就是这两块区域的垃圾。需要回收的垃圾主要分三种:Java堆中“已死”的对象实例,方法区中的废弃常量无用的类

  下面的重点是:怎么判断一个对象实例“已死”,常量已被废弃,类无用了?

  考虑到判断对象实例的生死是这里的重点,也是垃圾回收器回收最频繁高效的区域,且废弃常量的判定与其基本一致。这里先讲对无用的类的判定。对类无用的判定比较复杂,需要同时满足下面三个条件:

  • 该类的所有实例已经被回收(Java堆中不存在该类实例)
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过放射访问该类的方法

  对于满足上述3个条件的无用类,JVM可以进行回收,根据设置而定。需要注意的是,对于大量使用反射、动态代理和CGLib等ByteCode框架、动态生成JSP及OSCi这类频繁自定义ClassLoader的场景需要设置类卸装功能,防止方法区(HotSpot中的永生代)溢出。一般情况下,不用担心溢出问题。

  下面谈这里的重点,Java堆中的对象生死判定。这里涉及到两个算法:引用计数算法可达性分析算法

  引用计数算法很容易理解,具体实施如下:对每个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;每当有一个地方不在引用它时,计数器值减1;计数器值为0的对象则被判定为垃圾,可以被回收。该算法简单有效,Python的默认判定算法就是使用引用计数法的。引用计数法的优点很明显:简单高效,算法设计简单,对于计数器为0的对象可以立即清除。但该算法有个问题:循环计数。下面给出循环引用的示例代码:

class Reference {
    public Reference ref = null;
}
class MainTest{

    public static void main(String[] args) {
        Reference ref1 = new Reference();
        Reference ref2 = new Reference(); //两个对象的计数器分别加1

        ref1.ref = ref2;
        ref2.ref = ref1;    //两个对象的计数器再分别加1,循环引用

        ref1 = null;
        ref2 = null;      //这里,ref1和ref2应该被回收,但是两个对象的计数器值为1,
    }
}

  下面谈可达性分析算法,JVM中就是通过该算法来判定对象是否存活的。以GC Roots对象为起点,从这些节点想下搜索,得到整个引用链,当一个对象到GC Roots没有任何引用链相连时,即GC Roots到这个对象不可达时,认为该对象不可用,可以被回收。可以作为GC Roots的对象包括以下:

  • 虚拟机栈(栈帧中本地变量表)中引用的对象
  • 本地方法栈中Native方法引用的对象
  • 方法区中类静态属性和常量引用的对象

  最后需要说明的时,对于由可达性分析算法中分析为不可达的对象,在死亡前,会经历至少两次标记。且可以在finalize()函数中实现自救,但强烈不建议这样去做。这里也说明了,可达性分析算法的时效性不如引用计数法,该法引起的GC停顿也是后面要分析的重点。

1.2 怎么回收:垃圾收集算法

  考虑到垃圾收集的具体操作十分繁琐,这里仅阐述主流算法的思想。涉及的算法包括标记-清除算法,复制算法和标记-整理算法。

  标记-清除算法思路很简单:首先标记处所有需要会受到额对象,在标记完成后统一回收所有被标记的对象。本算法是最基础的收集算法,其他算法都是在其不足之处进行改进。不足之处:一是效率问题,标记和清除两国过程的效率都不高:二是空间问题,标记清除后会产生大量的空间碎片。

  复制算法是针对标记-清除算法产生大量空间碎片这个问题来进行设计的。基本设计思路如下:将容量分为两块,每次只使用其中一块,当其满了以后,对其进行回收,将活着的对象复制到另一块,顺序存放。这样解决了空间碎片问题,但会浪费掉一块内存空间。

  现在的商业虚拟机都采用复制算法来回收新生代(Java堆分为新生代和老年代)。通过把新生代分为Eden区和两个Survivor区,默认比例为8:1:1.这样,每次使用时,使用Eden区和一个Survivor区,在内存回收后将所有存活对象复制到另一个Survivor区,这样空间利用率可达90%。这里包含了一个假设:即新生代的对象大部分都活不过一个内存回收,这样,新生代就可以良好的运行。若是内存回收后,有超过10%的对象存活,则通过分配担保机制,将部分对象移至老年代

  针对老年代的特点:对象存活时间较长,较为稳定。提出了标记——整理算法:在完成标记后,让所有存活的对象都像一侧移动,然后清理掉端边界以外的内存。

  分代收集算法:将内存区域进行分块,对不同的区域采取不同的算法。目前主流操作时把Java堆分为新生代和老年代。对新生代采取复制算法,对老年代采取标记-整理和标记-清除算法。

1.3 什么时候进行垃圾回收

  在上文中提到,JVM中采用可达性分析算法通过列举GC Roots查看引用链来判定对象生死的。对于Java堆和方法区很大的情况下,如何快速完成GC Roots的枚举和对象的标记极大的影响JVM性能和GC停顿时间,影响使用者体验。现在主流的JVM使用准确式GC,通过维护OopMap数据结构来得知对象引用地址,从而完成上述工作。

  那么,在何处生成OopMap至关重要:HotSpot中仅在安全点记录了这个信息。当所有线程都位于安全点时,进行垃圾回收。安全点的选定以是否能让程序长时间执行为标准:如方法调用、循环跳转、异常跳转等。

  关于安全点的另一个问题是如何让所有线程都位于安全点。这里有两种方法:抢先式中断主动式中断。目前JVM都采用主动式中断:当GC需要中断线程时,设置一个标志,各个线程轮询去访问该标志,发现中断标志为真则自己中断挂起。

  此外,当线程处于Sleep状态Blocked状态时,线程无法响应中断请求,这样就无法进行GC。针对这种情况,我们把其列为安全区域,视为对安全点的扩展,在安全区域的任何阶段进行GC都是安全的。

二 垃圾收集器

  上文讲述了垃圾回收的基本原理,本部分讲述几款常见的垃圾收集器。对于本部分的内容,不做详细讲解,主要搭配讲解。

Serial收集器Serial Old收集器ParNew收集器

  Serial收集器和ParNew收集器这两个收集器都是新生代收集器。其中,Serial收集器是JVM运行在Client模式下的默认新生代收集器,是单线程收集器;ParNew收集器是其多线程版本。

  Serial Old收集器是Serial收集器的老年代版本,也是单线程收集器,采用标记-整理算法。此外,在JDK1.5之前,其为Parallel Scavenge收集器的老年代版本,也是CMS收集器的后备方案。

Parallel Scavenge收集器和Parallel Old收集器

  这套收集器组合和别的收集器不一样的是其目标是实现最大吞吐量,而其他收集器的目标是实现最小GC停顿时间。也就是说,其适合在后台运算不需要进行很多交互的任务。另外,采用Parallel Scavenge收集器GC自适应的调节策略:根据VM监控情况动态调整参数实现最合适的停顿时间或最大吞吐量。

  需要说明的是,在Parallel Old出来之前,Parallel Scavenge收集器只能和Serial Old搭配使用,发挥不了其优势。

CMS收集器

  CMS收集器(Concurrent Mark Sweep)以获取最小GC停顿时间为目标,适合应用在网站和B/S服务端这种重视响应速度的场合。由名字可知,CMS收集器是基于并发的标记-清除收集器。其收集分为四个过程:

  • 初始标记:标记GC Roots能关联到的对象,速度很快,会Stop the World
  • 并发标识:进行GC Roots Tracing,耗时最长,但由于是并发进行,不会Stop the World
  • 重新标识:修正并发标记过程中的变动,时间比初始标识稍长,会Stop the World
  • 并发清除

  由上述过程可发现,在采用CMS收集器时,GC停顿只发生在初始标记和重新标识这两个耗时较短的阶段,所以GC停顿时间很低。

  下面谈谈CMS收集器的三大缺点:

  • 对CPU资源敏感:也就是说,在并发标记期间,CMS收集器本身是占用系统资源的,这是没法避免的,只能通过设计减小其占用比例
  • CMS无法处理浮动垃圾:即在并发清理阶段产生的垃圾。这一点也是无法避免的。
  • CMS基于标记-清理,会产生大量内存碎片。这点可以通过设置整理来解决大部分问题。
G1收集器

  G1收集器面向服务端,其回收过程与CMS收集器类似。使用G1收集器时,新生代和老年代无需物理隔离。具体的还是上网查资料吧,这里不再详细叙述。

三 内存分配和回收策略

  下面讲一下几条最普遍的内存分配规则,读者可以自行验证。验证时记得查看或设置自己的垃圾回收器。

  • 大多数情况下,对象在新生代的Eden区分配。当Eden区没有足够空间时,进行一次Minor GC
  • 大对象(长字符串,数组等)直接进入老年代,具体多大算大可以自己设置。
  • 长期存活的对象将进入老年代:默认是经过15次Minor GC算长期,该值也可以自己重新设置
  • 动态对象年龄判定:若相同年龄的所有对象大小总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到上述年龄阈值。
  • 空间分配担保:老年代要保证能有足够的空间进行安排可能从新生代移至老年代的对象,这就是空间分配担保。这里的问题在于:要是确保使老年代可用空间最大,则需进行Full GC,但这样会引起GC停顿。所有需要提供策略实现其中的一个平衡。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章