JVM

31 阅读31分钟

JVM 的概念

JVM 和 java 无关,任何语言只要能编译成 class 文件,都能在 JVM 运行。

  • JVM 是一种规范
  • 是一台虚拟出来的机器
    • 有自己的字节码指令集(汇编语言)
    • 自己的内存管理:堆、栈、方法区等

常见的 JVM 实现

  • Hotspot:目前使用的最多的 Java 虚拟机。
  • Jrocket:原来属于BEA 公司,曾号称世界上最快的 JVM,后被 Oracle 公司收购,合并于 Hotspot。
  • J9: IBM 有自己的 java 虚拟机实现,它的名字叫做 J9. 主要是用在 IBM 产品(IBM WebSphere 和 IBM 的 AIX 平台上)。
  • TaobaoVM: 只有一定体量、一定规模的厂商才会开发自己的虚拟机,比如淘宝有自己的 VM,它实际上是 Hotspot 的定制版,专门为淘宝准备的,阿里、天 猫都是用的这款虚拟机。
  • LiquidVM: 它是一个针对硬件的虚拟机,它下面是没有操作系统的(不是 Linux 也不是 windows),下面直接就是硬件,运行效率比较高。
  • zing: 它属于 zual 这家公司,非常牛,是一个商业产品,很贵!它的垃圾回收速度非常快(1 毫秒之内),是业界标杆。它的一个垃圾回收的算法后来被 Hotspot 吸收才有了现在的 ZGC。

作者:三明治笔记
链接:www.jianshu.com/p/291326173…
来源:简书
著作权归作者所有。

类加载与初始化

加载过程

  1. Loading(加载):把一个 class 文件加载到内存。
  2. Linking(链接)
    1. Verification(校验):验证符不符合 class 文件的标准。
    2. Preparation(准备):为类的静态变量分配内存,并将其初始化为默认值
    3. Resolution(解析):把 class 文件用到的常量池中的符号引用转化为直接引用。
  3. Initializing(初始化):为类的静态变量赋正确的初始值;调用静态代码块。
    • Class.forName 得到的 class 是已经初始化完成的
    • Classloader.loaderClass 得到的 class 是还没有链接的

  JVM 的类是懒加载,严格来说应该叫懒初始化。JVM 没有规定什么时候加载,可以根据不同的 JVM 实现各种时候加载,但是规定了什么时候初始化。

类加载器

  类加载器就是一个普通的 class,通过 ClassLoader 完成加载过程。

双亲委派机制

为什么会有双亲委派机制?

  为了安全,如果别人替换系统级别的类,比如 String ,加载的时候从 BootstrapClassLoader 开始加载,然后取的时候,也会在 BootstrapClassLoader 找到 String.class,就在一定程度上防止危险代码的植入。

对象创建

  1. 当 Java 虚拟机遇到字节码 new 指令时,会先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、链接、初始化;
  2. 如果没有,进行类对应的加载过程,如果检查通过,为对象分配内存,对象所需的内存在类加载完成之后就可完全确定,而分配方式有以下两种;
  1. 指针碰撞:如果堆内存是绝对规整的(所有被使用的内存放在一边,没使用的放在另一边),那么分配内存仅仅是把指针向空闲空间方向移动一段与对象大小相等的距离。使用 Serial、ParNew 等压缩整理的收集器使用的就是指针碰撞,简单高效。
  2. 空闲列表:堆内存不是规整的,那么就需要一个列表记录哪些内存块是可用的,分配的时候从列表中找到一块足够大的空间分给对象实例,并更新列表的记录。使用 CMS 等基于清除算法的收集器,理论上只能采用空闲列表。
      强调“理论上”是因为在 CMS 的实现里面,为了能在多数情况下分配得更快,设计了一个叫作 Linear Allocation Buffer 的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式来分配。
  1. 除了划分空间,还需要考虑对象在并发情况下的不安全情况,有以下两种方案:
  1. 对分配内存空间的动作进行同步处理---采用 CAS 配上重试方式保证更新的原子性
  2. 把内存分配的动作按照线程划分在不同的空间之中进行,每个线程预先分配一小块内存,成为本地线程分配缓存(Thread Local Allocation,TLAB)哪个线程要分配内存,就在本地缓冲区分配,本地缓冲区用完了再同步锁定。虚拟机是否使用 TLAB 可使用 -XX:+/-UseTLAB 参数来设定。
  1. 内存分配完毕之后,虚拟机必须将分配到的内存空间(不包含对象头)初始化为零值,如果使用了 TLAB 的话,这一步可以提前至 TLAB 分配时顺便进行。保证了对象的实例字段再 Java 代码中可以不赋初始值也可以直接使用。
  2. 对对象头里的数据进行设置
  3. 执行构造函数

对象在内存中的存储布局

  • makeword:存储了对象的 hashCode、GC信息、锁信息三部分,给对象上锁就是改变了 makeword 里的锁信息,在 64 位系统里固定占 8 字节。
  • 类型指针:虚拟机使用它来确定当前对象属于哪个类(但并不是唯一方式),包含类的元数据信息,在启用指针压缩时(将内存占用由 8 字节压缩未占用 4 字节,JDK1.6 后默认开启,堆空间为 4G 以下不需要压缩,4G 以上会开启,最大内存支持 32G),64 位系统占 4 字节,可以通过对对象指针的压缩编码、解码方式进行优化,使得 jvm 只用32 位。Java 指针压缩原理
  • 实例数据:是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容,无论是父类还是自己 定义的,都会被记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle 参数)和字段在Java源码中定义顺序的影响。 HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的 +XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
  • 对齐:前面的部分占大小不能为 8 所整除,就补齐, JVM 读数据是一块一块的,8 字节是最方便的。

混合模式

java 不是单纯的解释型语言,也不是单纯的编译型语言,默认是混合模式。

  • 混合使用解释器 + 热点代码编译(JIT)
  • 起始阶段使用解释执行
  • 热点代码监测
    • 多次被调用的方法(方法计数器:监测方法执行频率)
    • 多次被调用的循环(循环计数器:监测循环执行频率)
    • 进行编译

-Xmixed 默认为混合模式,开始解释执行,启动速度较快,对热点代码实行检测和编译; -Xint 使用纯解释模式,启动很快,执行稍慢; -Xcomp 使用纯编译模式,启动很慢,执行很快。

happensBefore 原则

对象定位的方式

就虚拟机 HotSpot 而言,它主要使用直接指针进行对象访问。

句柄池

  如果使用句柄方式访问,java 堆会开辟一块内存来做句柄池,reference 中存储的就是句柄的地址,而句柄中包含了对象实例数据和对象类型数据的具体地址信息。
  优点: reference 存储的是稳定不变的句柄的位置,在实例数据发生改变或者被回收时,只需要移动句柄的实例的指针,而 reference 不需要任何改变。
  缺点: 访问对象的时候,需要根据 reference 指针定位一次,而对象实例和类型都需要各自定位一次,所以访问较慢。

直接指针

  reference 指向的是对象的地址,而在对象实例数据中存放着类型数据的指针。
  优点: 在进行对象访问的时候,节省了一次指针定位的开销,速度更快。
  缺点: 在堆上的对象实例数据发生改变或者被回收的时候,栈上 reference 指向的地址也会发生改变。相比 句柄池方式,在对象改变上,直接指针速度较慢。

JMM

运行时数据区中紫色为线程独占区,堆和方法区是线程共享区。

  • 类装载子系统:就是完成类加载过程的系统。
  • :每个线程都会在创建时创建一个属于自己的栈空间。内部保存着一个个栈帧。
    • 栈帧:线程每执行到一个方法,JVM 都会为该方法在栈上分配独有的栈帧空间,栈帧中保存着局部变量表、操作数栈、动态链接和方法出口,每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
      • 局部变量表:编译器可知的各种 Java 虚拟机基本数据类型、对象引用和 returnAddress类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译器完成分配,就是说在方法运行过程中不会改变局部变量表的大小。可能出现的异常情况:
        • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常
        • 如果 Java 虚拟机栈可以动态扩展,当栈扩展到无法申请足够的内存会抛出 OutOfMemoryError 异常(HotSpot虚拟机的栈容量是不可以动态扩展的,以前的Classic 虚拟机倒是可以)
      • 操作数栈:在操作过程中存放被操作数据的临时存储空间,同时保存计算的中间结果;
      • 动态链接:存放直接引用;
      • 方法出口:方法正常或异常退出的定义(执行完之后接着执行哪个指令)。

在 class 文件格式的常量池中存有大量符号引用(1.类的全限定名,2.字段名和属性,3.方法名和属性), 这些符号引用一部分会在链接的解析阶段转为直接引用(向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),这种转化称为静态解析;
还有一部分引用会在运行期间转化为直接引用,这部分称为动态链接。

  • 本地方法栈:调用本地方法,也是每个线程会创建一个,为 native 方法服务,别的基本上和栈一样。
  • 程序计数器(PC) :每个线程都有自己的程序计数器,并且在任何时间一个线程只有一个方法在执行,这就是所谓的当前方法。程序计数器会存储当前线程正在执行的方法的 JVM 执行地址,JVM 就是通过读取程序计数器的值来决定下一条需要执行的字节码指令。并且每个线程都有,也不会担心线程切换找不到下一步该执行什么命令的问题。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。
  • 方法区:Perm Space(永久代实现、<1.8)和 Meta Space(本地内存中的元空间实现、>=1.8)只是方法区不同版本的实现,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
    • 运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中
  • JDK 7 之前:类型信息、常量、静态变量和即时编译器编译后的代码缓存等数据都在方法区里
  • JDK 7 :原本放到永久代的字符串常量池与静态变量等被移出。运行时常量池在方法区,而字符串常量池在堆中。
  • DK 8 : 放弃永久代,实现元空间,把 JDK 7 永久代剩余的内容移到元空间。
  • Java堆: 它是Java内存管理的核心区域,用来存放 Java 对象实例,几乎所有创建的 Java 对象实例都被直接分配到堆上,堆被所有的线程共享,理所当然,堆也是垃圾收集器重点照顾的区域,所以对内空间还会被不同的垃圾回收器进行进一步的细分,最有名的就是新生代、老生代的划分,因为 Java 的回收算法是使用的分代收集算法。
  • 直接内存: 不是虚拟机运行时数据区的一部分,不会受到 Java 堆大小的限制,但是会受到本机总内存的限制。在 JDK1.4 中加入了 NIO,引入了一种基于通道与缓冲区(Buffer)的 IO 模式,可以通过 Native 函数分配堆外内存,然后通过堆内的 DirectByteBuffer 对象对这块内存的引用进行操作,这样可以在一些场景中提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

GC

如何判断哪些对象可以回收,哪些对象不可以

1、引用计数法

  给对象添加一个引用计数器,每当有一个引用指向对象时,计数器 + 1,当指向该对象的引用失效时,计数器 - 1,每当一个对象的计数器为 0 时,说明这个对象不再被使用。

  特点: 很简单的实现,并且判断的效率高,但是缺点是无法解决对象循环引用的问题。

2、可达性分析算法

  有一系列 GC Roots 的对象作为起点,从这些节点开始向下搜索,搜索走过的路径为引用链,当一个对象到 GC Roots 没有任何一个引用链时,说明该对象不被使用。不过当对象被判断不可达之后,也不是非死不可,可以重写 finalize() 方法进行自救。

www.liangzl.com/get-article…

  一般来说,如下情况的对象可以作为 GC Roots:

  1. 虚拟机栈的(栈帧中的局部变量表)中引用的对象,比如各个线程被调用的方法中使用到的参数局部变量、临时变量等
  2. 方法区中的类静态变量
  3. 方法区中的常量引用的对象
  4. 本地方法栈中JNI(Java Native Interface,Java 本地接口)中的变量
  5. Java 虚拟机内部的引用,如基本类型对应的 Class 对象,一些常驻的异常对象等,还有系统类加载器
  6. 所有被同步锁(synchronized 关键字)持有的对象,因为锁住的堆地址不会变,但是栈引用是可以变的,所以理论上被锁住的在堆中的对象,不应该被回收。

blog.csdn.net/dreambyday/…

3、并发的可达性分析

  在标记的时候的时候,需要 STW,但是如果存储的对象很多,那么停顿的时间会很长,所以引入了并发标记,即用户线程与标记线程一起工作。
  一起工作可能出现“对象消失”的问题,那么什么是“对象消失”的现象呢,那么引入“三色标记”来辅助推导。

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

  Wilson于1994年在理论上证明了当且仅当以下两个条件都满足的时候,会出现对象消失,即原本应该是黑色的对象被标记为白色。

  • 赋值器插入了一条或多条从黑色到白色对象的引用;
  • 赋值器删除了全部灰色对象到该白色对象的直接或间接引用。

  因此,只需要解决其中一个条件即可,那么有两种方式:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

  • 增量更新:新增 黑->白 时记下来
    并发标记阶段结束后,再以这些记录下来的黑为根, 扫描一次,可以理解为新增 黑->白 时,黑色就变为了灰色对象,这在 CMS 里面叫重新标记,短 STW ;
  • 原始快照:删除灰色对象到白色对象的引用关系时,把这个要删除的引用关系记下来,并发扫描阶段结束后,以这些灰色对象为根重新扫描一遍,也就是说都会按照开始并发标记的时候的对象图快照重新扫描一遍。这在 G1 里面叫最终标记,可能产生浮动垃圾。

CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

oopMap

准确分辨出哪些内存是引用类型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。

  • 保守式 GC:即不记录数据的类型,GC 时全栈扫描,但是就算是全栈扫描,怎么区分是oop还是数字呢?进行边界比对,比如堆的起始位置是 top,结束位置是 bottom,在这中间的就是 oop。很明显,这个算法实现起来很简单,但是太 low 了,效率也很低。
  • 半保守式 GC:根对象不记录类型,根对象派生出来的对象记录数据类型。比如根对象A的地址存储在栈中,没有标记类型,找出所有根对象需要全栈扫描,但是 A 对象的属性 B 对象,在类 A的元信息中有记录,可以顺藤摸瓜找到,减少程序憨憨遍历时间。
  • 准确式 GC:不管是根对象,还是派生对象,都标记类型。Hospot JVM 中引入外部数据结构OopMap+OopMapBlock 实现了该算法。

采用准确式 GC 思路,本质就是说,程序平时运行多花费一点点时间记录数据,帮助GC时减少延时,是值得的。

方法区回收

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

  • 废弃的常量:举个回收字面量的例子:假如一个字符串 "java" 曾经进入常量池,但是这个常量没有其他地方引用这个给字面量,并且这时发生内存回收,而且垃圾收集器判断有必要的话,这个常量就会被清理出常量池。其他类(接口)、方法、字段的符号引用页类似
  • 不再使用的类:同时满足以下三个条件,会被允许进行回收,不是必然被回收
    • 该类所有的实例都被回收,堆中不存在该类以及派生子类的实例
    • 加载该类的类加载器被回收,除非是设计的可替换类加载器的场景外,很难达到
    • 该类对应的 java.langClass 对象没有任何地方引用,无法在任何地方通过反射访问该类的方法

常见的 GC 垃圾收集算法

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

首先标记出所有需要回收的对象,接着对这些标记的对象进行回收

缺点:

  1. 效率不稳定:如果堆中有大量对象需要回收,就会进行很多次标记和清除动作;
  2. 可能会产生很多内存碎片

2、复制 / 拷贝算法(Copying)

  复制算法将内存分为两个部分,每次只使用其中的一个部分,当其中一部分内存使用完了,就回收该部分,同时把存活的对象移动到另外一部分去。
  HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1 ,即每次新生代中可用内存空间为整个新生代的 90% ,只有一个 Survivor 空间会被浪费。
  但是任何人都无法保证每次回收都只不多于 10% 的对象存活,因此在 Appel 式回收还有一个充当罕见情况的“逃生门”安全设计,当 Survivor 空间不足容纳 Minor GC 之后存货的对象时,就需要依赖其他内存区域(大部分都是老年代)进行分配担保。

好处:不会有碎片化的空间。
缺点:

  1. 在存活对象较多的时候,需要进行大量的复制操作;
  2. 每次只使用一半的内存,空间浪费。

3、标记-整理 / 压缩算法(Mark - Compact)

  标记过程过程和标记清除算法一样,后续步骤是让所有存活对象向内存的一端移动,然后直接清理边界以外的内存。

缺点:
  移动对象的时候,需要停止所有线程,因为有可能这部分本来是空内存,在移动的时候突然被占用了,被称为“Stop The World” 。

JVM 内存分代模型

分代模型是部分垃圾回收器使用的模型:新生代 + 老年代 + 永久代(1.7)/ 元空间(1.8)

除了 ZGC Epsilon Shenandoah 都是逻辑分代模型
但特殊的有 G1 逻辑分代,物理不分代
别的分代模型都是逻辑分代(概念上分代),物理也分代(内存上分代)

堆内存逻辑分区

各个区域占比:

默认新生代占堆内存的 1/3,老年代占 2/3 ,可以通过 -XX:NewRatio 设置;
Ednu 区和 survivor 区在新生代的比例是 8:1:1,可以通过 -XX:SurvivorRatio 设置。

收集算法使用情况:

新生代的对象收集被称为 minor GC / YGC ,老年代的对象收集被称为 Major GC ,但是只有 CMS 支持单独回收老年代,Full GC 是老年代和新生代一起执行。
新生代大量死去,采用复制算法,老年代存活率高,使用标记清除或者标记整理算法。

新生代进入老年代的情况:

  1. 经过 minor GC 不被回收的对象,会来回在两个 survivor 区来回移动,移动一次年龄 +1,到达默认年龄进入老年代,可以使用 -XX MaxTenuringThreshold 设置,但是因为在 markword 中,分代年龄最大为 4 个bit,所以最大值是 15
    1. Parallel Scavenge 默认 15
    2. CMS 默认 6
    3. G1 默认 15
  2. 动态年龄判断:进行minor GC 的时候,如果这一批对象的总大小超过了 survivor 的 50%,那么就会计算出一批年龄较大的对象进入老年代,这个 50% 可以通过 -XX:TargetSurvivorRatio 指定,比如年龄1的占用了33%,年龄2的占用了33%,年龄3的对象占用34%,那么年龄 2 和 3 的都需要进入老年代。
  3. 大对象直接进入老年代:
    1. 在 ParNew 和 Serial 两款新生代收集器里,如果大于 -XX:PretenureSizeThreshold 参数设置的值,直接在老年代分配。

常见的垃圾回收器

安全点(Safepoint)和安全区域(Safe Region):www.cnblogs.com/yanl55555/p/13364827.html

新生代收集器

1、Serial

  serial 是历史最悠久的收集器,JDK 1.3.1 之前是 Hotspot 新生代收集器的唯一选择。
  serial 在进行垃圾回收的时候,必须暂停所有用户线程(STW),直到它收集结束。

2、Parallel Scavenge

  基于复制算法,只是在 GC 的时候是多线程的。Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量。

如果虚拟机完成某个任务,用户代码加上垃圾回收以供需要 100 分钟,其中垃圾回收耗费了一分钟,那吞吐量就是 99%

  Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

  Parallel Scavenge 收集器还有一个参数 -XX:+UseAdaptiveSizePolicy 值得我们关注。这是一 个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数 了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时 间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。

3、ParNew

  就是 Serial 的多线程并行版,其余的参数配置和行为外和 Serial 一致。

  ParNew 和 Parallel Scavenge 的区别:

自适应调节策略是 Parallel Scavenge 收集器区别与 ParNew 收集器的一个重要特征; CMS 收集器出现后,ParNew 能与 CMS 收集器一起工作,但是 Parallel Scavenge 不能一起工作。

老年代收集器

1、Serial Old

  采用标记-整理算法,收集的时候会 STW 。

2、Parallel Old

  是 Parallel Scavenge 收集器的老年代版本,支持多线程收集,基于标记整理算法,JDK 6 才开始提供。

3、CMS(Concurrent Mark Sweep)

  JDK 1.4 之后出现,基于标记清除算法,在内存空间碎片化成都已经大到无法分配对象时,采用标记整理算法整理一次,运作过程分为 4 个步骤:

  1. 初始标记(CMS initial mark):单线程标记,初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快
  2. 并发标记(CMS concurrent mark):用户线程与 GC 线程可以一起工作,GC 线程遍历整个对象图
  3. 重新标记(CMS remark):多线程重新标记,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,可见<并发的可达性分析>的增量更新。
  4. 并发清除(CMS concurrent sweep):用户线程和 GC 线程可以一起工作,因为不需要移动存活的对象。

  初始标记和重新标记还需要 STW ,但是整个过程中,最耗时的是并发标记与并发清除阶段,而这两个阶段用户线程和 GC 线程可以同时工作,整体停顿时间比前两种更短。

CMS存在的问题:

  1. 在 CMS 并发标记与并发清除阶段,是随着用户线程一起工作的,所以程序可能会在这阶段生成新的垃圾,这些垃圾在本次无法清理,只好留到下次,这部分就被称为“浮动垃圾”。
    同样用户线程与 GC 同时进行,所以需要预留一部分内存供用户线程使用,JDK 5 的时候,使用了 68% 的空间会进行 Major GC(之所以叫 Major GC 是因为这时候的 GC 只清理老年代,并不是真正的 Full GC) ,而 JDK 6 的时候,CMS 收集器的阈值达到 92%。如果老年代增长速度不快,可以适当调高 GC 触发的百分比,使用 -XX:CMSInitiatingOccu-pancyFraction 参数来设置。

  2. 还有一个很严重的问题是:CMS 使用的是标记清除算法,会产生内存碎片,当对象要到老年代去的时候(并发标记或并发清除),但是预留的没有足够空间,或者因为内存碎片,存储不下足够大的对象,出现了“晋升失败”或者“并发失败”,那么 CMS 不得不进行一次 Full GC,冻结所有线程,并且临时使用的老年代 GC 回收器是 Serial Old,这样可能停顿的时间会很长

  3. 标记清除算法有个问题是会产生内存碎片,放大对象会很麻烦,如果找不到足够的空间,那么就会提前进行 Full GC,为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMS-CompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9 开始废弃),用于在 CMS 收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah 和 ZGC 出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数 -XX:CMSFullGCsBefore-Compaction(此参数从 JDK 9开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)

4、Garbage First 收集器(G1)

JDK9 发布之日,G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为默认的垃圾收集器

G1 可以面向堆内存任何部分来组成回收集(Collection Set 一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

不再追求一次把整个堆全部清理干净,这样应用在分配,同时收集器在回收,只要收集的速度能够跟上对象分配的速度,那一切就能完美运行。

G1 开创的基于 Region 的堆内布局是关键,虽然 G1 页仍是遵循分代收集理论设计的,但堆内存的布局已经与其他收集器有明显差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆内存划分为多个大小相等的独立区域(Region),每个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对应不同角色的 Region 采用不通的策略去处理,这样无论是新创建的对象还是旧对象都能获得很好的收集效果。

Region 是单词回收的最小单元。

Region 中还有一个 Humongous 区域,专门存储大对象

  1. G1 认为只要大小超过了一个 Region 容量一半的对象即判定为大对象,每个 Region 的大小可以通过参数 -XX:G1Heap RegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。
  2. 对于超过了 Region 容量的超级大对象,会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来看待

后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(-XX:MaxGCPauseMillis 指定)处理收益最大的 Region。具有优先级的区域回收方式,保证 G1 收集器在有线的时间内获取尽可能高的收集效率

G1 通过原始快照实现并发的可达性分析,此外,回收时不停止则必定涉及新对象的创建,所以 G1 会为每一个 Region 设计两个 TAMS(Top at Mark Start) 的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,回收时所有新分配的对象都要在这两个指针位置上,如果内存回收的速度赶不上内存分配的速度,G1 会被迫冻结用户线程进行 Full GC 而产生长时间 STW。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1 收集器的

运作过程大致可划分为以下四个步骤:

  1. 初始标记:单线程标记,标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确的在可用的 Region 中分配新对象,这个阶段需要停顿线程,但耗时很短
  2. 并发标记:从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,此阶段耗时较长,但可以与用户线程并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象
  3. 最终标记:对用户线程坐短暂暂停,用于处理并发阶段结束后仍然遗留下来的那少量的 SATB 记录
  4. 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。涉及到存活对象的移动,所以必须暂停用户线程,由多条收集器线程并行完成。

除了并发标记外,其余阶段都需要完全暂停用户线程。

可以由用户指定期望的停顿时间是 G1 收集器很强大的一个功能,不过 G1 要冻结用户线程来复制对象,所以再低也要有个限度,默认是 200 毫秒,一般来说回收阶段占几十到一百,甚至两百都很正常

但是如果设置非常低,后果就是由于停顿时间太短,导致每次选出来的回收集只占堆内存很小一部分,收集器收集速度赶不上分配器分配的速度,导致垃圾堆积,最终导致占满引发 Full GC 反而降低性能。

垃圾回收器选择

  1. 如果应用程序非常小(100MB内),那么选择:-XX:+UseSerialGC
  2. 如果应用程序是单核处理器 ,并且没有停顿时间的要求,那么让JVM自行选择,可选择:-XX:+UseSerialGC
  3. 如果应用有性能停顿时间的要求,如后台计算型应用,但停顿时间并不是太严格,或注重吞吐量或者处理器资源较为稀缺的场合,可以让 JVM 自行选择,可使用:-XX:+UseParallelGC
  4. 如果应用程序有一个非常严格的停顿时间要求,较为关注服务的响应速度,如互联网应用,可选择:-XX:+UseConcMarkSweepGC或者-XX:+UseG1GC