JVM1-内存模型和垃圾回收器

目录

一、历史简介

1.1 jdk编年史

1.2 虚拟机家族

1.3 即时编译器

1.4 向Native迈进

二、java内存区域与内存溢出异常

2.1 运行时数据区

  2.1.1 程序计数器

2.1.2 java 虚拟机栈

2.1.3 本地方法栈

2.1.4   Java堆

2.1.5 方法区

2.1.6  运行时常量池

2.1.7 直接内存

2.2  HotSpot虚拟机对象探秘

2.2.1对象的创建

2.2.2 对象的内存布局

2.2.3 对象的访问定位

2.3 OutOfMemoryError异常

2.3.1 堆溢出

2.3.2 虚拟机栈和本地方法栈溢出

2.3.3 方法区和运行时常量池溢出

2.3.4 本机直接内存溢出

三、垃圾回收器与内存分配策略

3.1 判断对象是否回收

3.1.1、引用计数算法

3.1.2、可达性分析算法

3.1.3、引用的分类

3.1.4 、生存和死亡的判断

3.1.5、回收方法区

3.2、垃圾收集算法

3.2.1、分代收集理论

3.2.2、标记-清除算法

3.2.3、标记-复制算法

3.2.4、标记-整理算法

3.3 HotSpot的算法实现细节

3.3.1根节点枚举

3.3.2 安全点和安全区域

3.3.3  记忆集与卡表

3.3.4 写屏障

3.3.5  并发的可达性分析

3.4 经典垃圾回收器

3.4.1 Serial收集器

3.4.2 ParNew收集器

3.4.3 Parallel Scavenge

3.4.4 Serial old 收集器

3.4.5 Parallel Old

3.4.6 CMS收集器

3.4.7 GarBage First收集器

3.5 低延迟的垃圾收集器

3.5.1Shenandoah收集器

3.5.2 ZGC收集器

    3.6 选择合适的垃圾收集器

3.6.1虚拟机与垃圾收集器日志

3.6.3 日志参数对比

3.6.4垃圾收集器参数总结

3.7 内存分配与回收策略

3.7.1对象优先在Eden分配

3.7.2大对象直接进入老年代

3.7.2 长期存活的对象将进入老年代

3.7.5 空间分配担保


一、历史简介

1.1 jdk编年史

jdk1.1 

     jdbc,jar文件格式,javaBeans,rmi等。 内部类和反射也是这个时候出现的

jdk1.2

     这个版本把java技术体系拆分为三个方向,分别是面向桌面应用开发的J2SE,面向企业开发的J2EE,和面向手机等终端的J2ME。这个版本出现的技术包含EJB,Java Plug-in ,Java IDL,Swing等。这个版本第一次内置了JIT(just in time)即时编译器。

     jdk1.2曾经并存过三个虚拟机(Classic VM 、HotSpot VM 和ExactVm)

jdk1.3

     hotspot虚拟机诞生后,成为jdk1.3及之后所有jdk版本的默认java虚拟机

      jdk1.3的改进主要体现在Java类库上(如数学运算和新的Timer API等),JNDI服务从扩展服务作为平台级服务提供,使用CORBAIIOP来实现rmi通信协议,提供了大量新的java 2d API ,并且新添加了JavaSound类库。

jdk1.4

      标志着java真正走向了成熟的版本,各大著名公司都参与功能开发。jdk1.4带来了正则表达式,异常链,NIO,日志类,XML解析器和XSLT转换器。

jdk1.5

      在语法易用性上做出了非常大的改进,比如自动装箱,泛型,动态注解,枚举,可变长参数,遍历循环,改进了java内存模型,提供了concurrent并发包。

jdk1.6

     提供了初步的动态语言支持(通过内置Mozilla javaScript Rhino 引擎实现)、提供编译期主解处理器和微信HTTP服务器Api,这个版本对虚拟机内部做了大量的改进,包括锁与同步、垃圾收集、类加载等方面的实现都有相当多的改动

jdk1.7

    提供新的G1收集器,加强对非java语言的调用支持(JSR-292,这项特性在JDK11还在改动)、可并行的类加载架构。

    在jdk1.7期间oracle公司收购了sun公司,基于很多原因,jdk1.7提出的愿景很多都没实现,所以推迟到jdk1.8才实现。oracle由于此前从BEA手中获得JRockit,现在又得到了sun的HotSpot。所以oracle取得了三大商用虚拟机的两个

    javaSE的核心功能正式为Mac OS X操作系统提供支持

jdk1.8

     对lambda表达式的支持,内置Nashorn javaScript的支持,新的时间、日期API,彻底移除HotSpot的永久代

jdk9

    jigSaw(模块化),增强了若干工具(JS Shell 、JLink、JHSDB等),整顿了HotSpot各个模块各自为战的日志系统,支持HTTP2客户端API等91个JEP

jdk10

   内部重构,统一源仓库,统一垃圾收集器接口,统一即时编译接口。

jdk11

   ZGC垃圾收集器出现

1.2 虚拟机家族

Classic VM:纯解释器方式执行java代码,如果使用即时编译器,那就必须外挂,外挂后,即时编译器就会完全接管虚拟机执行系统,解释器不能再工作。由于解释器和编译器不能配合工作,如果使用即时编译器,则编译器必须每一行,每个方法都进行编译,无论它们的执行效率是否具有编译价值。所以运行效率很低

Exact VM 为了为了解决Classic Vm的问题而出现的,使用准确式内存管理,抛弃Classic Vm的使用句柄(Handle)的对象查找方式(原因是垃圾收集后,对象将可能移动位置,如果地址为123456 的对象移动到654321,在没有明确信息表明内存中哪些数据是引用类型前提下,虚拟机肯定是不敢把内存中所有为123456的值改成654321的,所以要使用句柄来保持引用值的稳定),每次定位对象都少了一次间接查找的开销,显著提升性能。

HotSpot 和ExactVm是Sun公司中同时期出现的,最后hotSpot淘汰了ExactVM。HotSpot虚拟机的热点代码探测能力可以通过执行计数器找到最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调用,或在方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译。通过编译器和解释器恰当的协同工作,在最优化的程序响应时间和最佳执行性能中取得平衡。

BEA  公司的JRockit ,它不关注程序启动速度,因此JRockit内部不包含解释器实现,全部代码都靠即时编译器编译后执行

IBM j9 职责分离与模块化做得比hotSpot更优秀,由j9虚拟机中抽象封装出来的核心组件库(包括垃圾收集器,即时编译器、诊诊断监控系统)就单独构成了IBM OMR项目。

Zing虚拟机从hotspot旧版分支重新开发,重写新的垃圾收集器,zing的pgc、c4收集器,可以轻易支持TB级别的java堆内存,保证暂停时间仍然可以不超过10毫秒。而hotSpot在jdk11的zgc和jdk12的Shenandoah收集器才能达到相同目标

1.3 即时编译器

需要长时间运行的应用,由于经过充分预热、热点代码会被hotSpot的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,这类应用中的java运行效率很大程度取决于即时编译器所输出代码质量。

  Hotspot虚拟机中含有两个即时编译器,分别是耗时短但输出代码优化程度较低的客户端编译器(C1)和编译耗时长但输出代码优化质量高的服务端编译器( C2),通常他们会在分层编译机制下与解释器互相配合来共同构成Hotspot虚拟机执行子系统

jdk10起,Hotspot又加入了Graal编译器,在保持输出相近质量的编译代码同时,开发效率和扩展性上都显著优于C2编译器。Grall编译器未来前途可期,作为java虚拟机执行代码的最新引擎。

1.4 向Native迈进

不需要长时间运行,或者小型化的应用而言,java天生就具有劣势,近几年从大型单体应用架构向小型微服务应用架构发展的技术潮流下,java就表现得不适应,在微服务架构下,无需追求单个服务的7x24小时的运行,他们随时可以中断和更新。相应的java的启动时间较长,需要预热才能达到最高性能等特点就相悖于这样的应用场景。

在最新的几个jdk版本中已经陆续推出了跨进程、可以面向用户程序的类型信息共享(Application Class Data Sharing,AppCDS 把加载解析后的类型信息缓存起来,提升下次启动速度,以前的cds只支持java标准库,在jdk10的cds开始支持用户程序代码)、无操作的垃圾回收器(Epsilon,只做内存分批而不做回收的收集器,对于运行完就退出的应用十分适合)。

 更彻底的解决方案,是逐步开放提前编译(Ahead of Time Compilation ,AOT ),提前编译是相对于即时编译,提前编译能带来的最大好处是java虚拟机加载这些已经预编译成二进制之后能够直接调用,无需等待即时编译器在运行时将其编译成二级制机器码。提前编译的坏处也很明显,它破坏了‘一次编写,到处运行’的承诺,必须为不同的硬件,操作系统去编译对应的发行包,也显著降低了java链接过程的动态性,必须要求加载的代码在编译期就是全部已知的。

Substrate VM的出现,它是Grall Vm 新出现的一个极小型运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理(垃圾收集)和JNI访问等组件。它还包含了一个本地镜像的构造器,用于为用户建立基于Substrate VM 的本地运行时镜像。这个构造器采用指针分析技术。显著降低了内存占用和启动时间。

二、java内存区域与内存溢出异常

2.1 运行时数据区

        java运行时数据区包含:方法区,虚拟机栈,本地方法栈,堆,程序计数器。

  2.1.1 程序计数器

      可以看做当前线程所执行的字节码的行号指示器,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成。java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,所以程序计数器是线程私有内存。

       如果一个线程正在执行一个java方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行本地(native)方法,则这个计数器值应该为空,此内存区域是唯一一个在《java虚拟机规范》中没有规定任何outofMemoryError情况的区域。

2.1.2 java 虚拟机栈

   虚拟机栈描述的是java方法执行线程内存模型:每个方法被执行的时候,java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接,方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈上从入栈到出栈的过程。

   活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法

   栈中的局部变量表存放了编译期可知的各种java虚拟机基本数据类型(boolean/byte/char/short/int/float/long/double)、对象引用(reference类型,它不是对象本身是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置),和returnAddress类型(指向了一条字节码指令的地址)

     这些数据类型在局部变量表中的存储空间以局部变量槽(slot)来表示,其中64位长度的long和double类型的数据会占两个slot,其余只用占据一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,方法运行期间不会改变局部变量表的大小(这里的大小是指槽的数量,虚拟机真正使用多大内存空间来实现一个变量槽,由具体虚拟机实现)

       在《java虚拟机规范中》,栈规定了两类异常情况,如果线程请求的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.1.3 本地方法栈

  虚拟机栈为执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用Native方法服务。Hot-spot直接把本地方法栈和虚拟机栈合二为一

2.1.4   Java堆

  此内存区域唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换使java对象在堆上分配也不那么绝对了)。所有线程共享的java堆中可以划分出多个线程私有的分批缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。

   java堆既可以实现成固定大小的,也可以是可扩展的(通过-Xmx,-Xms设定),当前主流的java虚拟机都是设置成可扩展的。当java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.1.5 方法区

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。jdk1.8之前,hotspot垃圾收集器为了像管理java堆一样管理方法区,使用永久代来实现方法区而已(所以以前把方法区也称为永久代)。

jdk1.7把原本放在永久代的字符串常量池、静态变量移出

jdk8放弃永久代,改用了JRockit、j9一样在本地内存中实现的元空间来代替,把jdk7剩余的内容(主要是类型信息)全部移到元空间

《java虚拟机规范》对方法区的约束是非常宽松的,这区域的内存回收目标主要针对是常量池和对类型的卸载

2.1.6  运行时常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生产的各种字面量与符号引用,这部分内容将在类加载后存放到方法区运行时常量池中。Java语言并不要求常量一定只有编译期才能产生,也就是说并非预置入Class文件常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中(比如String类的intern()方法)

2.1.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,但是这部分内存也被频繁的使用。

在jdk1.4中新加入NIO(New input/Output)类,引入了一种基于通道与缓冲区i/o方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用。避免了java堆和Native堆中来回复制数据,显著提高性能。

   本机直接内存不会受到java堆内存大小的限制,但是受到本机总内存的限制,如果忽略掉直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时chuxOutOfMemoryError异常

直接内存(堆外内存)与堆内存比较

  1. 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
  2. 直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
package com.xnccs.cn.share;

import java.nio.ByteBuffer;


/**
 * 直接内存 与  堆内存的比较
 */
public class ByteBufferCompare {


    public static void main(String[] args) {
        allocateCompare();   //分配比较
        operateCompare();    //读写比较
    }

    /**
     * 直接内存 和 堆内存的 分配空间比较
     * 
     * 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
     * 
     */
    public static void allocateCompare(){
        int time = 10000000;    //操作次数                           


        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //ByteBuffer.allocate(int capacity)   分配一个新的字节缓冲区。
            ByteBuffer buffer = ByteBuffer.allocate(2);      //非直接内存分配申请     
        }
        long et = System.currentTimeMillis();

        System.out.println("在进行"+time+"次分配操作时,堆内存 分配耗时:" + (et-st) +"ms" );

        long st_heap = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
            ByteBuffer buffer = ByteBuffer.allocateDirect(2); //直接内存分配申请
        }
        long et_direct = System.currentTimeMillis();

        System.out.println("在进行"+time+"次分配操作时,直接内存 分配耗时:" + (et_direct-st_heap) +"ms" );

    }

    /**
     * 直接内存 和 堆内存的 读写性能比较
     * 
     * 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
     * 
     */
    public static void operateCompare(){
        int time = 1000000000;

        ByteBuffer buffer = ByteBuffer.allocate(2*time);  
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //  putChar(char value) 用来写入 char 值的相对 put 方法
            buffer.putChar('a');
        }
        buffer.flip();
        for (int i = 0; i < time; i++) {
            buffer.getChar();
        }
        long et = System.currentTimeMillis();

        System.out.println("在进行"+time+"次读写操作时,非直接内存读写耗时:" + (et-st) +"ms");

        ByteBuffer buffer_d = ByteBuffer.allocateDirect(2*time);
        long st_direct = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //  putChar(char value) 用来写入 char 值的相对 put 方法
            buffer_d.putChar('a');
        }
        buffer_d.flip();
        for (int i = 0; i < time; i++) {
            buffer_d.getChar();
        }
        long et_direct = System.currentTimeMillis();

        System.out.println("在进行"+time+"次读写操作时,直接内存读写耗时:" + (et_direct - st_direct) +"ms");
    }
}

输出:
在进行10000000次分配操作时,堆内存 分配耗时:12ms
在进行10000000次分配操作时,直接内存 分配耗时:8233ms
在进行1000000000次读写操作时,非直接内存读写耗时:4055ms
在进行1000000000次读写操作时,直接内存读写耗时:745ms

直接内存使用场景

  • 有很大的数据需要存储,它的生命周期很长
  • 适合频繁的IO操作,例如网络并发场景

有原始类型(boolean,byte,short,char,int,long,float,double)的局部变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的局部变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。

堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的局部变量,它都会被存储在堆区。

一个局部变量如果是原始类型,那么它会被完全存储到栈区。 一个局部变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。

对于一个对象的成员方法,这些方法中包含局部变量,仍需要存储在栈区,即使它们所属的对象在堆区。 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

2.2  HotSpot虚拟机对象探秘

2.2.1对象的创建

  (1) 当jvm遇到一条字节码new命令时,首先将去检查这个指令的参数是否内在常量池中定位到一个类的符号引用,并且检查这个引用是否已被加载、解析和初始化过。如果没有则执行相应的类加载过程。

 (2)在类加载检查通过后,jvm将为新生对象分配内存,对象所需内存大小在这里已经被确定。

      有两种内存分配方式,选择哪种分配方式由java堆是否规整决定,而java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理能力决定。因此Serial、parNew带有压缩整理过程的收集器,系统采用的是指针碰撞,既简单又高效。而当使用CMS这种基于清除算法时,理论上就采用较为复杂的空闲列表来分配内存(理论上是指,CMS为了在多数情况下分配更快,设计了一个分批缓存区,通过空闲列表拿到一大块分配缓存区后,在它里面仍然使用指针碰撞来分配)。

指针碰撞:

     java堆已经使用和空闲的内存如果是规整的, 一个指针作为分界点的指示器,在分配内存时,仅仅把指针从空闲空间方向挪动一段与对象大小相等的距离,这种分配方式就叫指针碰撞

空闲列表:

     如果已使用内存和空闲内存相互交错在一起,那么就无法使用指针碰撞,只能由虚拟机维护一个列表,记录哪块内存是可用的,然后在分配内存给对象时,从列表中找到一块足够大的空间划给对象,并且更新表中的记录。

创建对象的时候,存在线程安全问题:

可能出现正在给对象A分配内存,指针还没来及修改,对象B又同时使用了原来的指针来分批内存。

 解决安全问题有两种:

   一种是对分配内存空间的动作进行同步处理-——实际上虚拟机采用的CAS配上失败重试的方式保证跟下操作的原子性;

  另外一种是把内存分批按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,这里就不存在资源竞争了,这叫做本地线程分配缓冲(TLAB)。虚拟机是否使用TLAB可以通过-XX:+/-UseTLAB来设定。

  (3)内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在java代码中可以不赋初值就可以直接使用,使程序能访问到这些字段数据类型所对应的零值

   (4)jvm对对象进行了必要的设置,对象是哪个类的实例、如何才能找到类的元数据信息、对象的hash码(实际上对象的hash码会延后到真正调用Object::hashCode()方法时才会计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

在上面工作完成后,从jvm角度看新的对象已经产生。但是从java程序上来看,对象创建才刚刚开始——构造函数,即CLass文件中的init()方法还没有执行。

2.2.2 对象的内存布局

在hotspot中,对象在堆内存中的布局可以划分为三个部分:对象头(Header)、实例数据(Instance data)和对齐填充

(1)对象头

     对象头包括两类信息,第一类是用于存储对象自身运行时数据,如hash码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程id、偏向时间戳等。这部分数据的长度在32位虚拟机和64位虚拟机分别是32比特和64比特,官方称它为MarkWord。在32位的hotspot中,如果对象违背同步锁锁定的状态下,25位用于存储对象hash码,4个比特存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0;

    对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,java虚拟机通过这个指针来确定该对象是哪个类的实例。当然并不是所有虚拟机实现都必须在对象数据上保留类型指针。如果对象是一个java数组,那么在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是如果数组大小不确定,则无法通过元数据信息推断出数组大小。

(2)实例数据

   实例数据部分是对象真正存储的有效信息,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来,这部分存储顺序会收到虚拟机分配策略参数(-XX:FieldsAllocationStyle)和字段在Java源码中定义的顺序影响。

   HotSPot默认的顺序是(longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary object Pointers)),以上的默认分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件下,在父类中定义的变量出现在子类之前。(+XX:CompactFields  默认为true)

(3)对齐填充

    HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说任何对象的大小都必须是8的整数倍,对象头已经被设计成8字节的倍数(1倍或者2倍),因此对象实例数据部分没有对齐的话,就需要对齐填充来补全。

2.2.3 对象的访问定位

java程序会通过栈上的reference数据来操作堆上的具体对象。主流的访问方式有使用句柄和直接指针两种

句柄:java堆中可能会划分出一块内存作为句柄池,reference存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。

直接指针访问:java堆中对象的内存布局就必须考虑如何防止访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话就不需要多一次间接访问的开销

两种对象的访问方式各有优势,使用句柄访问的最大好处就是reference中存储的是句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

hotSpot主要使用直接指针进行对象访问(如果使用了Shenandoah收集器的话,也会有一次额外的转发)

2.3 OutOfMemoryError异常

2.3.1 堆溢出

java堆用于存储对象实例,我们只有不断创建对象,并且保证GC ROOTS到对象直接可达路径,避免垃圾回收机制清除这些对象,那么随着对象的数据增加,总容量触及最大容量限制后,就会产生内存溢出。(-Xms 堆最小值,-Xmx 堆最大值)

如果是内存泄漏,可进一步通过查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样得引用路径、与哪些GC Roots相关联,才导致垃圾回收器无法回收他们。

如果不是内存泄漏,在参堆内存参数设置合理的情况下,从代码中检查实收有些对象生命周期过长、持有状态过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class HeapOOM {
	static class OOMOBject{}
	public static void main(String[] args) {
		List<OOMOBject> list=new ArrayList<OOMOBject>();
		while(true){
			list.add(new OOMOBject());
		}
	}

}

2.3.2 虚拟机栈和本地方法栈溢出

hotspot并不区分虚拟机栈和本地方法栈,所以-Xoss(设置本地方法栈大小)并不会生效,栈容量在HotSpot中只能由-Xss来设定。

在《java虚拟机规范》中描述两种异常:

   一是如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常

 二是如果虚拟机的栈内存允许动态扩展,当扩展容量无法申请到足够的内存时,将抛出OutOfMemoryError异常

hotSpot选择的是不扩展,除非在创建线程申请内存时就无法获得足够内存而出现OutOfMemoryError,否则在线程运行时是不会因为扩展而导致的OutOfmMemoryError的,只会出StackOverflowError。

 栈上StackOverFlowError(单线程)

  使用-Xss参数减少栈内存容量

//Vm Args:-Xss128K

public class JavaVMstackSoF {
	private int stackLength = 1;

	public void stackLeak() {
		stackLength++;
		stackLeak();
	}

	public static void main(String[] args) {
		JavaVMstackSoF oom = new JavaVMstackSoF();
		try {
			oom.stackLeak();
		} catch (Throwable e) {
			System.out.println("stack length: " + oom.stackLength);
			throw e;
		}

	}
}

结果:Exception in thread "main" java.lang.StackOverflowError

在递归函数中,定义大量本地变量,增大此方法帧中本地变量表的长度,这样会让每个栈帧的内存占用变大。

。。。。。。。。。。。。。。。

实验结果表明:无论是栈帧太大,还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot都是抛出StackOverFlowError。如果是允许动态扩展栈容量大小的虚拟机(Classic VM),在栈内存不够,会进行栈扩展,最终结果导致的是内存溢出。

多线程情况下,通过不断创建线程导致OutOfMemoryError(32位虚拟机上)

32位Windows单个进程最大内存限制为2GB,HotSpot虚拟机提供了参数可以控制的java堆和方法区,剩余的内存=2GB-最大堆容量-最大方法区容量,由于程序计数器消耗内存很小,如果把直接内存和虚拟机进程本身消耗内存去掉,剩下的内存就由虚拟机和本地方法栈来分配了,因此每个线程分配到的栈内存越大,可以建立的线程自然越少,建立线程时就越容易把剩下内存耗尽。

tips:2*32=4G,cpu的寻址能力就位4G,所以32位仅能操作4G的存储空间,大了CPU也找不到对应地址

//VM Args:-Xss2M(在32位系统下运行)
public class JavaVmStackOOM {
	private int stackLength = 1;

	private void dontStop(){
		while(true){
			
		}
	}
	public void stackLeakByThread(){
		while(true){
			Thread thread=new Thread(new Runnable(){

				@Override
				public void run() {
					dontStop();
				}
				
			});
			thread.start();
		}
	}

	public static void main(String[] args) {
		JavaVmStackOOM oom=new JavaVmStackOOM();
		oom.stackLeakByThread();

	}
}
//在32位操作系统下运行结果
Exception in thread "main" java.lang.OutOfMemoryError:unable to create native thread

32位操作系统在多线程的情况下,通过不断建立线程的方式,在hotspot虚拟机上也是可以产生OutOfMemoryError的。在不能减少线程和更换为64位虚拟机的情况下,就只能减少最大堆内存和减少栈容量来换取更多的线程。从JDK7,以上信息"unable to create native thread"后面,还会注明原因"possible out of memory or process/resource limits reached"

2.3.3 方法区和运行时常量池溢出

(1)jdk8完全使用元空间来代替永久代。下面我们来测试一下,使用永久代还是元空间来实现方法区,对程序有什么实际影响

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此Stirng对象的字符串,则返回代表这个字符串的String对象的引用;否则,就会把String对象包含的字符串添加到常量池,并且返回此对象的引用。在jdk6或者之前,常量池都是分配在永久代,我们可以通过-XX:PermSize 和-XX:MaxPermSize限制永久代的大小,可以间接控制常量池的容量。

使用jdk1.6来测试

/**
 * vm Args: -XX:PermSize=6m -XX:MaxPermSize=6M
 * @author Administrator
 *
 */
public class JavaVmStackOOM {
	

	public static void main(String[] args) {
		Set<String>set=new HashSet<String>();
		short i=0;
		while (true){
			set.add(String.valueOf(i++).intern());
		}

	}
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError:PermGen space

   at java.lang.String.intern(Native Method)............................

通过提示信息"PermGen space",说明运行时常量池确实属于方法区

而在1.7以及以后,原本存在永久代的字符串常量池被移动到java堆中,所以限制方法区的容量对上面测试代码无效。这时候通过修改-Xmx参数限制最大堆为6M就能看到内存溢出。

(2)字符串常量池的实现在哪里,可以引申出更有意思的影响

public class RuntimeConstantPoolOOM {
	public static void main(String[] args) {
		String str1=new StringBuilder("计算机").append("软件").toString();
		System.out.println(str1.intern()==str1);
		String str2=new StringBuilder("ja").append("va").toString();
		System.out.println(str2.intern()==str2);
	}
}

这段代码在1.6的时候,会得到两个false,而在1.7及之后会的得到true和false。

1.6,intern()会把首次遇到的字符串实例复制到永久代,并且返回永久代的引用,很显然永久代的引用和StringBuilder创建的实例在堆上的引用str1是不相等的。

1.7,intern()不需要在拷贝字符串到永久代了,intern记录首次出现"计算机软件"的引用,既然字符串常量池都移动到java堆中,自然str1和str1.intern()返回的都是该实例的引用。

   而java这个字符串首次出现的是 加载sun.misc.Verson的时候,所以intern记录的引用和StringBuilder创建"java"的引用不相等。

(3)方法区的主要职责是用于存放类型的相关信息,如类名、方位修饰符、常量池、字段描述、方法描述等,对于这部分区域的测试基本思路是运行时产生大量的类去填满方法区,直到溢出为止。

可以通过动态代理产生类,这里使用CGLib直接操作字节码运行时生成大量动态类。在实际生产中,当增强的类越多,就需要越大的方法区以及保证动态生成的新类型可以载入内存。

public class JavaMethodAreaOOM{
   public static void main(String[] args){
        while(true){
            Enhancer enhancer= new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
             enhancer.setUseCache(false);
             enhancer.setCallback(new MethodInterceptor(){
             public Object intercept(Object obj,Method method,Object[] args,MethodProxy  
             proxy)throws Throwablee{
               return proxy.invokeSuper(obj.args);
             }
             });
            enhancer.create();
        }
   }
static class OOMObject(){
 }
}

在jdk1.7的运行结果
Caused by:java.lang.OutOfMemoryError:PermGen space

方法区的溢出也是一种常见溢出,一个类如果要被垃圾回收器回收,要达到的条件是非常苛刻的,所以在运行时生成大量动态类的应用场景,就应该特别关注回收状况。

 除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有大量的jsp文件应用(jsp第一次运行时需要编译为java类)、基于OSGI的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)。

(4)jdk1.8,永久代没有了,元空间出现,前面动态创建新类型的代码已经很难使方法区产生内存溢出。

   hotspot还是提供了一些参数作为元空间的防御措施

   -XX:MaxMetaspaceSize:设置元空间最大值,默认-1,即不限制,或者说受限于本地内存大小

  -XX:MetaspaceSize:指定元空间初始大小,以字节为单位,达到该值就触发垃圾回收进行类型卸载,同时收集器会对该值进行调整,如果释放了大量空间则降低该值,如果释放了很少空间,在不超过MaxMetaspaceSize的情况下,适当提高该值

 -XX:MinMetaspaceFreeRatio:在垃圾回收之后,控制最小元空间剩余容量百分比,可减少因为元空间不足导致垃圾回收频率

 -XX:MaxMetaspaceFreeRatio,用于控制最大的元空间剩余容量百分比    

2.3.4 本机直接内存溢出

  直接内存,容量大小可以通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认和Java堆的最大值一致。

 直接内存很明显的就是 Heap Dump文件中不会看到有什么异常,如果发现内存溢出后产生的dump文件很小,而程序中又直接后间接的使用了DirectMemory(典型的就是NIO),那么就可以考虑直接内存方面了

三、垃圾回收器与内存分配策略

    为了排查内存溢出、内存泄漏等问题,当垃圾收集器成为系统达到更高并发的瓶颈时,我们就必须对垃圾回收器非常了解和知道如何监控和调节。对于java内存运行时的5个区域,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈帧随着方法进入和退出而有条不紊的执行着出栈和入栈,每个栈帧分配多少内存,基本在类结构确定下来时就是已知的(尽管会由即时编译进行一些优化,但是在概念模型中,大体认为是编译期可知的)。因此这3个区域的内存分配和回收都具有确定性。

      java堆和方法区就有显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的不同条件分支所需要的内存也不一样,只有在内存运行期间我们才能知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

3.1 判断对象是否回收

3.1.1、引用计数算法

   在对象中添加一个引用计数器,有一个地方引用,计数器就加一;当引用失效时,计数器就减一;任何时刻计数器为零的对象就是不可能再被引用。

   引用计数算法虽然占用了额外的内存空间,但是它的效率很高。而java虚拟机没有使用其来管理内存,这个看似简单的算法,必须要配合大量额外的处理才能保证正确性,比如,对象间的循环引用(对象objA和objB都有字段instace,ojbA.instace=objB,objB.instance=objA,除此之外这个两个对象再无其他引用,他们互相引用对方,他们的引用计数都不为零,但是java虚拟机确可以对两者进行回收,证明JVM不是通过引用计数法实现对象生存与否的判断)。

3.1.2、可达性分析算法

       通过GC ROOTs的根对象作为起始节点,从这些节点开始根据引用关系向下搜索时,搜索过程所走过的路径称为引用链,如果对象到GCRoots之间没有任何引用链相连,则证明此对象不可能再被使用。

    在java技术体系中能作为GC Roots对象包括以下几种

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各线程被调用的方法栈中使用到的参数、局部变量、临时变量等
  • 方法区中类静态属性引用的对象,比如类的引用类型静态变量
  • 方法区中引用的对象,比如字符串常量池里的引用
  • 本地方法栈中(Native方法)引用的对象
  • java虚拟机内部的引用,比如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointerException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC ROOTS外,根据用户所选择的垃圾回收期以及当前回收的区域不同,还可以有其他对象临时性加入,共同构成完证的GCROOTS集合。

3.1.3、引用的分类

     如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。从这种定义来看,也有一定局限性,对于描述一些"食之无味,弃之可惜"的对象,比如:当系统内存空间还足够时,能保留在内存中,如果内存比较紧张,则抛弃这些对象(比如说缓存)。

     所以在jdk1.2之后,就对引用的概念进行了扩充。

  • 强引用,Object obj=new  Object(),这种引用关系只要还存在,垃圾回收器就永远不会回收被引用的对象
  • 软引用,用来描述还有用,但非必须的对象。只被软引用关联着的对象,在系统发生内存溢出前,会对这些对象进行回收,通过SoftReference来实现软引用
  • 弱引用,在垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,通过WeakReference类来实现弱引用
  • 虚引用,设置虚引用的唯一目的,只是为了能在这个对象被收集器回收时收到一个系统通知,通过PhantomReference类来实现虚引用。

3.1.4 、生存和死亡的判断

     在通过可达性分析算法,判定为不可达的对象,也不是非死不可,这时候处于缓刑阶段,要判断死亡,至少要经历两次标记过程。

第一次:没有与GC ROOTS相连接。finalize方法被覆盖并且没有调用过,则进入F-Queue队列。这个是在优先级低的finalizer线程执行,不保证等待线程结束。如果其中一个运行太久,其他对象的finalize会一直等待。

    如果这个对象被判定为有必要执行fianlize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,稍后由一条JVM自动建立的、低调度优先级的Finalizer去执行它们的fianlize()方法。这里的"执行",是指虚拟机会触发这个方法开始,但是不一定会等待它运行结束,原因就是如果某个对象的finalize()方法执行缓慢或者死循环,会导致其他对象永久等待,会导致内存回收子系统崩溃。

    

第二次:判断第一次标记的对象中,是否还是没有与GC ROOTS连接。如归是则回收,并且不会执行finalize(要么已经在第一次标记时执行,要么没有覆写

PS:finalize是完成自救的最后机会。可以把this复制给GC ROOTS,避免第二次标记时被回收。不一定能救,因为优先级低。fianlize()释放非java资源(数据库连接,文件等),它的调用时机具有不确定性,从一个对象变得不可到达开始,到finalize()方法被执行,所花费的时间这段时间是任意长的,所以最好用try ,finally来实现资源的关闭。不过finalize方法已经是Deprecated,由于可能导致性能,死锁和挂起等问题将会被移除   

自救

   

public class FinalizeEscapeGC {
	public static FinalizeEscapeGC SAVE_HOOK = null;

	@Override
	protected void finalize() throws Throwable {
		System.out.println("User-->finalize()");
		SAVE_HOOK = this;
	}

	public static void main(String[] args) throws InterruptedException {
		SAVE_HOOK = new FinalizeEscapeGC();
		SAVE_HOOK = null;
		System.gc();
		Thread.sleep(1000);
		System.out.println(SAVE_HOOK != null);// true
		
		SAVE_HOOK = null;
		System.gc();
		Thread.sleep(1000);
		System.out.println(SAVE_HOOK != null);// false
	}
}

两个代码片段是一样的,一次是逃脱成功,一次是失败,这是因为任何一个对象的finalize()方法都只会被系统调用一次,如果下一次回收,它的finalize()方法不会再次执行。

 3.1.5、回收方法区

    有人认为方法区(hotspot中的元空间或永久代)是没有垃圾回收的,《java虚拟机规范》中提到可以不要求虚拟机在方法区中实现垃圾收集,方法区的垃圾回收收益较低,回收条件也比较苛刻,所以,事实上确实未实现或者完整实现方法区类型卸载的收集器存在。

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

     常量中的字符串,类接口、方法、字段、符号引用和堆的对象回收类似,判断是否引用则可以判断是否回收。而判断一个类型是否"不再被使用的类",这个条件就比较苛刻,需要同时满足下面三个条件:

  • 该类的所有实例都被回收,也就是java堆中不存在该类及任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过设计的可替换类加载器,否则很难达成
  • 该类对应的java.lang.class对象没有被任何地方引用,无法在任何地方通过反射访问该类的方法。

    在大量使用反射、动态代理、CGLib等字节码框架,动态生成jsp以及OSGI这类频繁自动以类加载器的场景中,需要java虚拟机具备类型卸载的能力,来保证方法区内存压力不会过大

3.2、垃圾收集算法

垃圾收集算法分"引用计数垃圾收集"和"追踪垃圾收集",主流JVM都是通过追踪垃圾收集

3.2.1、分代收集理论

   当前商业虚拟机的垃圾收集器,都遵循"分代收集",这个理论建立在两个分带假说上:

         1) 弱分代假说:绝大多数对象都是朝生夕灭的

        2)强分代假说:熬过越多次垃圾收集过程的对象,就越难以消亡。

    这个假说,奠定了大多数垃圾收集器的设计原则,java堆应该划分出不同的区域,将回收对象依据年龄分配到不同的区域之中存储。每次回收,只关注如何保留少量存活而不是标记大量将要被回收的对象,就能以较低代价回收到大量空间。而剩下难以消亡的对象,可以把它们集中放在一块,用较低的频率来回收这个区域。

     划分了不同的区域,才会有不同的回收类型和回收算法,这里也带了一个难点:对象不是孤立的,对象之间会存在跨代引用。如果进行一次只局限于新生代的垃圾收集,但是新生代对象如果被老年代引用,为了找出新生代存活的对象,不得不GCROOTS之外,再遍历整个老年代,这样带来了很大的性能负担。(除了CMS收集器,不存在只针对老年代的收集)为了解决这个问题,对分代收集器添加了第三条假说:
         3)跨代引用假说:跨代引用相比于同代引用仅占少数。

       存在引用关系的两个对象,应该是倾向于同生同灭的(某个新生代跨代引用,由于老年代难以消亡,会使得新生代存活,然后在年龄增长后晋升到老年代),这样我们不用去扫描整个老年代,我们只需要在新生代建立一个全局的数据结构(称为"记忆集"),这个结构把老年代划分成若干小块,表示出老年代哪一块存在跨代引用。此后,当发生minorGC时,只有包含了小部分内存被加入到GcROOts进行扫描。但是这种数据结构需要在对象改变引用关系是维护记忆集的正确性。

  •  部分收集(Partial Gc)

         新生代收集(MinorGC/Young GC)

         老年代收集(MajorGC/Old GC),只有CMS才会单独收集老年代,MajorGC在其他资料上也会存在被称为整堆GC(full GC)

        混合收集(Mixed GC):目前只有G1收集器会有这种行为

  • 整堆收集(fullGC),收集整个Java堆和方法区的

 3.2.2、标记-清除算法

     标记清除算法有两个缺点:

           执行效率不太稳定,如果java堆中存在大量对象,而其中大部分是需要被回收的,这是必须进行大量的标记和清除

           会产生大量的内存碎片

 3.2.3、标记-复制算法

    复制算法只需要复制少量存活的对象即可,它解决了标记清除算法的两个缺点,也不会产生空间碎片,但是它的代价就是内存缩小为原来的一半。现在商用JVM基本上都是用的这个算法来进行新生代的回收,而根据新生代的朝生夕灭现象,则不需要按照1:1来分配空间,现在称为Appel式回收。

   HotSpot虚拟机的Serial/ParNew等新生代收集器均采用了Appel式回收。把新生代分为一块较大的Eden空间和两块较小的Survivor空间,8:1:1每次分配内存只使用Eden和一块Survivor,所以只有10%的新生代是会被"浪费的"。如果存在某次回收的对象大于10%,当survivor空间不足以容纳一次MinorGC之后存活的对象,那么这些对象就会通过分配担保机制直接进入老年代。

  3.2.4、标记-整理算法

     标记复制算法,在对象存活率较高的时候,就需要进行较多的复制操作,效率会降低,而且还需要额外的空间进行分配担保,所以老年代不能直接选用这种算法。针对老年代,提出了标记整理算法,和标记-清除算法的区别就是,需要移动回收后存活的对象。是否移动对象会带来优缺点共存:

      移动存活对象,并更新所有引用这些对象的地方,必须要全程暂停用户程序,这样的停顿叫做,stop the world

      不移动对象,只能依赖更为复杂的内存分配器和内存访问其来解决空间碎片化,比如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理地址连续,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。而访问内存是用户最繁重的操作,所以这样直接影响应用程序的吞吐量。

        移动对象,内存回收更复杂,不移动对象内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不停顿,但是从吞吐量来看,移动对象又会更划算。吞吐量的实质是使用垃圾收集的用户程序和收集器的效率总和。hotspot中关注吞吐量的Parallel Old收集器是基于标记整理算法的  和关注延迟的CMS收集器是基于标记清除算法的。还有一种解决方案就是,让虚拟机平时都大多数时间都基于标记-清除算法,暂时容忍空间碎片,直到空间碎片化程度达到影响对象分配时,再采用标记-整理算法。其实CMS收集器面临空间碎片过多时,也采用的这种处理办法

3.3 HotSpot的算法实现细节

   3.3.1根节点枚举

     固定作为GC Roots的节点主要在全局性的引用(常量或类静态属性)和执行上下文(栈帧中的本地变量表),根节点枚举必须在能保障一致性的快照中才得以进行,整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合对象引用关系还在变化。这就是垃圾回收过程中必须停顿用户线程其中一个重要的原因。即使是号称停顿时间可控,或者几乎不停顿的CMS、G1、ZGC等收集器,枚举根节点也是必须要停顿的。

     目前主流JVM都是使用的准确式垃圾回收,虚拟机并不需要一个不漏的检查所有执行上下文和全局的引用位置,HotSpot通过一组OopMap数据结构来达到这个目的,一旦类加载完成后,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定位置记录下栈里和寄存器里哪些位置是引用。

3.3.2 安全点和安全区域

      oopMap可以快速的完成GCRoots枚举,但是导致oopMap内容变化的指令特别多,如果每条指令都生成对应的oopMap,那将需要大量的额外空间,HotSpot只是在特定的位置才会生成oopMap来记录这些信息,这些特定的位置成为安全点。用户执行代码时,强制要求必须执行到达安全点后才能暂停。安全点的选择不能太少以至于回收器等待时间太长,也不能太频繁导致oopMap数据结构内容太多。安全点的选择以“是否具有让程序长时间执行的特征”来选定的,而每条指令执行时间都是非常短暂的,所以在方法调用、循环跳转、异常跳转等属于指令序列复用这些指令才会产生安全点。

       现在考虑的问题就是,如何让垃圾回收时,让所有线程(这里不包括JNI调用的线程),都跑到最近的安全点,然后停顿下来。抢先式中断和主动式中断。抢先式中断,不需要线程执行代码主动配合,系统首先中断所有用户线程。如果发现用户线程中断地方不在安全点,就恢复这条线程执行,直到跑到安全点上再中断。现在几乎没有JVM采用这种方式。

        主动式中断,不直接对线程操作,仅仅设置一个标志位,各线程在执行过程中不断轮询标志位,一旦发现标志为真,就在最近的安全点上主动中断挂起。标志位设置在安全点的位置和所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾回收,避免没有足够内存分配新对象。由于轮询操作会频繁出现,这要求它必须足够高效,HotSpot使用内存保护陷阱,把轮询精简到只有一条汇编指令的程度。

      安全点保证了程序执行时,程序不执行(没有分配处理器时间,比如线程Sleep和Blocked状态)。能够确保在某一段代码中,引用关心不会发生变化,因此在这个区域的任何地方开始回收垃圾都是安全的,这就称为安全区域。当用户线程执行到安全区域代码时,首先会标识进入了安全区域,垃圾回收时,不必管这用户线程了。当线程要离开安全区域是,用户线程要检查虚拟机是否已经完成跟节点枚举(或者其他需要用户线程暂停的阶段),如果没完成,则需要等待收到可以离开安全区域的信号为止。

3.3.3  记忆集与卡表

       记忆集是用于记录非收集区域指向收集区域的指针集合的抽象数据结构,基本上是针对新生代,在垃圾收集场景中,记忆集只需要记录某一块非收集区域是否存在有指向收集区域的指针就可以了。下面列出可供选项(当然也可以选择这个范围以外的)记录精度。字长精度(每个记录精确到每个机器字长(也就是处理器的寻址位数,32位或者64位),该字包含跨代指针),对象精度(记录精确到一个对象,该对象有字段含有跨代指针),卡精度(精确到一块内存区域,该区域内有对象含有跨代指针)

         卡表就是卡精度实现记忆集的一种方式,卡表最简单的形式就是只是一个字节数组,每个字节数组元素都对应其标识的内存区域中一块特定大小的内存块,这个内存块称为卡页,卡页大小都是以2的N次幂的字节数,HotSpot使用的是2的9次(512字节)。一个卡页的内存中通常包含不止一个对象,只要卡页中有一个对象的字段存在跨带指针,那么对应卡表数组就标识为1,称为变脏,没有则0。在垃圾回收时,只要筛选出卡表中没有变脏的元素,就能轻易得到跨代指针,然后把它们加入GC ROOTs中就行了。

    若果卡表标识的内存区域起始地址是0x0000的话,数组CARD_TABLE的第0,1,2号元素分别对应了地址范围的0x0000~0x01FF、0x0200~0x03FF,0x0400~0x05FF的卡页内存块。

    

3.3.4 写屏障

         卡表是什么时候变脏,谁来把它们变脏。当其他分带区域中对象引用了本区域对象时,对应的卡表就应该变脏,时间点原则上就是引用类型字段赋值那一刻。编译后的代码,已经是机器指令流了,HotSpot是通过 写屏障技术维护卡表的。写屏障可以看做在虚拟机层面,“引用类型字段赋值”这个动作的AOP切面,在赋值时,产生一个环形通知,也就是说在赋值前后都在写屏障覆盖范围之内。分为写前屏障和写后屏障。每次引用行为的更新,都会产生额外的开销,但是相对MinorGc 扫描整个老年代的代价相比还是比较低的。HotSpot虚拟机许多收集器都有使用到写屏障,直到G1收集器的出现之前,其他收集器都只用到了写后屏障。

        除了写屏障开销外,卡表还存在高并发场景面临的“伪共享”。cpu的缓存系统是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行(Cache Line),就会彼此影响(写回,无效化或者同步)而导致性能降低。假设处理器缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行,而64个卡表元素对应的卡页总的内存为32KB(64*512字节),也就是说,如果不同线程更新的对象正好处于这32KB的内存区域中,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了解决这个问题,提出了卡表更新条件判断,只有卡表元素未被标记,才将其变脏。JDK7之后,HotSpot增加了一个-XX:+UseCondCardMark,来决定是否开启卡表更新的条件判断。

3.3.5  并发的可达性分析

       在OopMap的加持下,根节点枚举所带来的停顿时间是非常短暂而且相对固定的(不会随堆容量增长),而堆越大,存储的对象越多,对象结构越复杂,从根节点出发,要标记的对象而产生的停顿时间就更长。

        标记对象的三色理论来搞清楚,为什么标记时必须要在一个能保证一致性的快照上才能进行图像的遍历。

  • 白色:对象从没被垃圾回收器访问过,在可达性分析刚开始,所有对象都是白色的,若在分析结束后,仍是白色,即代表不可达
  • 黑色:表示对象已经被垃圾回收器访问过,而且这个对象的所有引用都已经扫描过。黑色对象不可能(不经过灰色对象)直接指向白色对象
  • 灰色:标识对象已经被垃圾收集器访问过,但是这个对象上至少存在一个引用还没有被扫描过

                         

  并发阶段,  对象误判为存活,比较简单,只是产生了浮动垃圾而已,是可以忍受的。

    但是对象消失就比较严重,当且仅当下面两个条件同时满足时,会产生"对象消失"的问题,即原本应该是黑色的对象被误标记为白色:

          1)赋值器插入了一条或多条从黑色对象到该白色对象的新引用

           2)赋值器删除了全部从灰色对象到该白色对象的直接引用

       因此我们要解决并发扫描对象消失问题,只需要破坏这两个任意一个即可,对应着两种解决方案:

         增量更新:当黑色对象插入了新的指向白色对象的引用关系时,将这段关系记录下来。在并发扫描之后,再将这些记录过引用关系黑色对象为根,重新扫描一次。可以简单理解为,黑色对象一旦插入新指向白色对象的引用后,它就变成灰色对象。

          原始快照,破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就要将这个要删除的引用关系记录下了,在并发扫描结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。可以简单理解为无论删除关系与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

         而对以上引用关系记录都是通过写屏障实现的。CMS基于增量更新来做并发标记的,G1/Shenandoah则是用原始快照实现的。

3.4 经典垃圾回收器

两个收集器直接存在连线,则代表它们可以搭配使用,在jdk8时将serial+CMS,ParNew+Serial old这两个组合声明废弃。

3.4.1 Serial收集器

jdk 1.3.1之前唯一的新生代收集器,目前它依然是HotSpot虚拟机运行的客户端模式下默认的新生代收集器,基于标记复制算法,它是所有收集器里额外内存(保证垃圾收集器能够高效的进行而存储的额外信息)消耗最小的,由于它的单线程特性,它能获得最高的单线程收集效率,在内存使用较小,停顿时间就可以控制在十几毫秒,而对于垃圾收集频率不高的应用来说,这个停顿用户是可以接受的。它是单线程的,不仅仅说明它只会使用一个处理器或者一条线程完成垃圾收集,更强调的是,它在进行垃圾收集时,必须暂停其他所有工作线程。

3.4.2 ParNew收集器

    Serial收集器多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余与Serial完全一致。它是运行在服务端模式下的虚拟机,除了Serial外,目前只有它能与CMS配合工作。

    jdk5发布时,HotSpot推出了第一款真正意义上支持并发的垃圾收集器CMS,它首次实现了垃圾收集线程与用户线程(基本上)同时工作。CMS的出现巩固了ParNew的地位,JDK9开始,官方取消了ParNew+serial Old,和serial+CMS,这意味着ParNew和CMS从此只能互相搭配使用。

  并行:多条垃圾收集器线程之间的关系,标识同一时间有多条这样的线程在同时工作,默认此时用户线程是处于等待状态

  并发:描述的是垃圾收集器线程与用户线程之间的关系,由于用户线程并未冻结,此时程序能响应服务请求,由于垃圾收集器占用了一部分系统资源,此时应用程序的吞吐量将受到影响。

3.4.3 Parallel Scavenge

    也是基于标记-复制算法,新生代。CMS关注点是尽可能的缩短垃圾收集时,用户线程的停顿时间。而Parallel Scavenge则是达到可控制的吞吐量。吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集收集)。可以理解为,Parallel Scavenge目标在于控制垃圾收集时间。停顿时间越短,越适合需要与用户交互,良好的响应速度。而高吞吐量则是最高效的利用Cpu,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

     Parallel Scavenge提供两个参数用于精确控制吞吐量,最大垃圾收集时间-XX:MaxGcPauseMillis,和直接设置吞吐量大小:-XXGCTimeRatio。

      设置了最大垃圾收集时间,收集器将尽力保证内存回收时间不超过用户设定值。垃圾收集时间缩短是以牺牲吞吐量和为代价换取的。如果设置最大垃圾收集过小,系统把新生代调得越小,收集时间是缩短了,但是收集频率则增高了。以前10S收集一次,每次消费100毫秒,现在5S收集一次,每次消耗70毫秒。收集时间是缩短了,但是吞吐量也下来了。

        -XXGCTimeRatio,参数的值为大于0小于100的整数,默认99,相当于1/(1+99),相当于允许1%的最大垃圾收集时间

        Parallel Scavenge,经常被称为吞吐量优先收集器。它还有个参数-XX:++UseAdaptiveSizePolicy。这个参数开启,就不需要人工设置新生代和老年代的比例和晋升老年代对象的大小了 。虚拟机会根据当前系统动态调整这些参数,来提供最合适的停顿时间或者最大吞吐量。只需要设置最大堆,然后使用--XX:GCTimeRatio或者-XX:MaxGPasueMilis再配合UseAdaptiveSizePolicy就可以了。

3.4.4 Serial old 收集器

serial old的老年版本,基于标记-整理算法,也是客户端模式下使用。如果在服务端模式下有两种用途:一种是在JDK5之前的版本与Parallel Scavenge收集器搭配使用。一种是CMS收集器发生失败的后备预案。

3.4.5 Parallel Old

Parallel old 是Parallel Scavenge,基于标记整理算法,在Parallel old出现之前,Parallel Scavenge一直处于非常尴尬得位置,老年代除了Serial old以外别无选择。而老年代选择了Serial old,在老年代内存空间很大,硬件规格比较高的运行环境中,单线程老年代收集无法利用多处理器的并行能力,导致这种组合的吞吐量不一定比ParNew+CMS组合来得优秀。

3.4.6 CMS收集器

cms(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,目前互联网网址或者基于浏览器的B/S系统的客户端上,比较关注服务的响应速度,希望停顿时间尽可能短,就比较适合。从名称上看就知道,CMS是基于标记-清除算法的,它的运作过程相当于前面几种收集器来说,更复杂一些。

 1)初始标记 ->并发标记->重新标记->并发清除

  初始标记和重新标记两个步骤仍然需要"Stop The World",

   初始标记只是标记一下GC Roots 能直接关联到的对象,速度很快,

  并发标记就是从GC Roots 开始遍历整个对象图,这个过程耗时很长但是不需要停顿用户线程。

    重新标记阶段则是为了修正并发标记期间,用户同时更新引用的那一部分内容。这个阶段的停顿时间通常比初始标记要长一点,但是也远比并发标记停顿时间短

     并发清除,清除判断消亡的对象,由于不需要移动存活对象,所以这个阶段是和用户并发的

   整个过程耗时最长的是并发标记和并发清除阶段,从整体来,它的内存回收过程与用户线程一起并发执行的。它的特点是并发收集,低停顿。它也有三个明显的缺点:

    CMS对处理器资源非常敏感,事实上面向并发设计的程序都对Cpu敏感。它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认开启回收线程数(Cpu数+3)/4,当Cpu核心数不足四个时,CMS对用户程序影响就很大了。 

     CMS无法处理浮动垃圾,用户线程在并发标记和并发清理的过程中,用户线程还在运行,所以会有新的垃圾产生,但是这部分垃圾是出现在标记过程结束以后,CMS无法在此次垃圾收集中处理他们。这种很可能导致“Concurrent Mode Failure”失败而导致另一次完全“Stop The World”的Full GC产生。同样,需要为了预留足够的内存空间给用户线程使用,CMS收集器不能等老年代几乎填满再收集,必须预留一部分空间挺高并发收集时的用户程序使用。JDK5默认,CMS收集器当老年代使用了68%的空间是,就会被激活,也可以通过参数-XX:CMSInitiatingOccuupancyFraction修改。到了JDK6时,这个阈值被提升至92%,这个配置就会更容面临:要是CMS运行期间预留的内存无法满足用户线程并发时分配新对象的需要,则会出现并发失败,这个时候虚拟机只能启动后备预案,临时启动Serial Old收集器来进行老年代的垃圾收集。所以配置这个空间使用百分百需要根据实际来均衡,配置太高将会导致大量的并发失败,性能反而降低。

CMS 基于标记-清除算法,会产生空间碎片,会出现分配大对象时,老年代还有很多剩余空间,由于无法找到足够大的连续空间,会提前触发FullGc,为了解决这个问题,CMS提供一个 -XX:CompactAtFullCollection,用在CMS不得不进行FullGC时开启内存碎片整理合并。由于需要移动存活对象,这个过程是无法并发的。空间碎片整理问题解决了,但是停顿时间又变长了。CMS最后又提供一个-XX:CMSFullGCsBeforeCompaction,这个参数是要求CMS在执行参数这么多次不整理空间的FullGC之后,下一次进入FullGC前先进行碎片整理(默认参数为0)。这两个参数都在JDK9废弃。

3.4.7 GarBage First收集器

   JDK6开始试验,jdk7 update4开始商用,JDK8 G1提供并发的类卸载支持。G1 是一款主要面向服务端的垃圾收集器。JDK9 G1成为服务端模式下的默认垃圾收集器,并且CMS被声明为不推荐使用的收集器。作为CMS收集器的替代者和继承者,G1被设计成能够建立“停顿时间模型”的收集器。G1是混合回收。

    G1的开创是基于Region的堆内存布局,虽然G1也是遵循分带收集理论设计的,但是内存布局不同于其他收集器:G1不再坚持以固定大小及固定数量的分代区域划分,而是把连续的java堆划分为多个大小相等的独立区域。每个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间或者老年代。收集器能够对不同角色的Region采用不同的策略去处理。

    Region还有一类特殊的Humongous区域,专门存储大对象。G1认为只要大小超过一个Region容量的一半的对象即可判断为大对象。每个Region的大小可以通过-XX:G1HeapRegionSize设定,取值范围为1MB-32MB,为2的N次幂。超过了整个Region容量的超大对象,,将会被存储在N个连续的HumongousRegionzhong ,G1大多数行为都把Humongous Region作为老年代的一部分。

   G1之所以能建立可预测的停顿时间模型,因为它将Region作为单次回收的最小单元,所以每次回收到的内存空间都是Region大小的整数倍,这样可以有计划的避免整个java堆中进行全区域的垃圾收集。G1去跟踪每个Region里面垃圾堆积的“价值”大小,价值即所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先队列,每次根据用户设定的收集停顿时间(-XX:MaxGCPauseMillis,默认值是200毫秒),优先回收价值收益最大的region,这也是"GarBage First"名字的由来。这种具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1收集器开发了近10年才开发出来,它有以下几个难点:

  •  跨Region引用,使用记忆集来避免全堆作为GC Roots扫描,G1的记忆集本质上是一个哈希表,key是Region的起始地址,value是一个集合,里面存储的元素是卡表的索引号。这种双向卡表结构实现起来更复杂。而且Region数量多,G1就会有更高的内存占用负担。G1至少要耗费大约相当于java堆容量的10%到20%的额外内存。
  • 解决并发过程中,用户线程改变对象引用关系的问题,G1用的原始快照(SATB)算法来实现的。此外,并发过程中,用户创建的新对象存放在哪里?G1为每个Region设计了两个名为TAMS(TOP at Mark Start)的指针,把Region一部分空间划分出来,用于并发回收过程中新对象的分配,并发回收时新分配对象地址必须都在这两个指针之上。G1默认在这个地址之上的对象都是存活的,不回收。如果内存回收速度赶不上内存分配速度,G1收集器也和CMS一样被迫冻结用户线程执行,导致FullGC而产生长时间“Stop the World”
  • 如何建立可靠的停顿预测模型,来达到停顿时间可控。G1的停顿预测模型是衰减均值,在垃圾回收过程中,G1会记录每个Region的回收耗时,每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本。衰减平均值是指它会比普通的平均值更容易受到新数据的影响,而恰恰衰减平均值能更准确的代表"最近"的平均状态,这样Region的统计状态越新越能决定其回收的价值。

G1收集器的 运作过程

初始标记->并发标记->最终标记->筛选回收

初始标记,仅仅标记GC Roots能直接关联的对象,并且修改TAMS指针的值,这个过程会停顿用户线程,但是耗时很短,而且是借助Minor GC同步完成的。

 并发标记,根据GC Roots进行可达性分析,找出可回收对象。在可达性分析完成后,重新记录STAB记录下并发时有引用变动的对象。

最终标记,对用户线程做另外一个短暂的暂停,扫描并且标记STAB记录。

筛选回收,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间制定回收计划。把决定回收的Region对象复制到空的Region中,然后清理掉整个旧的Region。这里涉及到存活对象的移动,必须暂停用户线程,由多条线程并行完成。

从上面描述可以看出,除了并发标记外,其余阶段也是要完全暂停用户线程的,G1并发纯粹的追求低延迟,而是在延迟达到用户期望值的情况下,尽可能提高吞吐量。设置不同的期望停顿时间,可以在G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。默认停顿时间是200毫秒,如果停顿时间设置很小,每次收集只占内存很小部分,收集速度跟不上内存分配速度,这样时间一长就会慢慢导致垃圾堆积,然后导致FullGc反而降低性能。

从G1开始,最先进的垃圾收集器设计导向,都不约而同的追求能够应付应用的内部分配速率,而不追求一次把整个java堆全部清理干净。

G1从整体来看是基于"标记-整理算法"实现的,而从局部(两个Region之间),上看又是基于"标记-复制"算法实现。G1不会导致内存空间碎片。G1相比于CMS的缺点是,垃圾收集收集时会占用额外很高的内存和CPU。

   就内存占用来说,G1和CMS虽然都使用卡表来处理跨代指针,每个Region无论扮演新生代还是老年代,都必须有一份卡表,而且会存储指向其他region和其他region指向自己的内容。这个会导致内存消耗过大。CMS卡表就相对简单,只有一份,而且只需要处理老年代到新生代的引用。

   对于CPU来说,虽然他们都用了写屏障,CMS用写后屏障维护卡表。而G1除了使用写后屏障来进行同样的卡表维护,为了实现原始快照(STAB),还需要使用写前屏障来跟踪并发时指针变化情况。相比于CMS的增量更新算法,原始快照能够减少并发标记和重新标记阶段的消耗,从而避免CMS那样在最终标记用户停顿时间过长的缺点,但是这样会产生额外的负担。由于G1的复杂操作,所以CMS的写屏障是同步操作的,而G1是类似于消息队列,异步操作。

   目前小内存应用上CMS的表现大概率会高于G1,这个内存平衡点通常在6GB-8GB左右

3.5 低延迟的垃圾收集器

    衡量垃圾收集器的三个重要指标:内存占用,吞吐量,延迟,他们共同构成了不可能三角。随着计算机硬件的发展,对内存的占用是可以容忍的,随着硬件规格和性能越高,吞吐量也越高。而硬件规格的提升也就是内存的扩大,反而会给延迟带来负面影响,比如回收1TB的内存肯定要比回收1GB的内存耗费更多时间。Shenadoah和ZGC,几乎整个过程都是并发的,只有初始标记和最终标记这些阶段有短暂的停顿,这部分的停顿时间基本上是固定的,与堆的容量,堆中对象数量没有正比关系。

3.5.1Shenandoah收集器

    不是Oracle官方开发的插件,是RedHat开发的。这个收集器的目标是实现一种任何堆内存大小都可以把垃圾收集器的停顿时间限制在10毫秒以内,这个目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理工作。

    相比官方ZGC,Shenandoah更像G1的继承者。Shenandoah是也是基于Region的内存布局,也有存放大对象的Humongous Region,默认的回收策略也是先处理回收价值大的Region。但是在管理堆内存方面,它只是有三个不同,最重要的就是支持并发的整理算法,在性价比的权衡下,Shenandoah没有实现分代;SHenandoah摒弃了G1耗费大量内存和计算机资源去维护记忆集,而采用“连接矩阵”的全局数据结构来记录跨Region的引用关系(降低了记忆集的维护消耗,也降低了伪共享发生的概率)。

     如果RegionN有对象指向RegionM,就在表格的N行M列打上标记。如图,Region5的对象Baz引用了Region3的Foo,Foo又引用了Region1的bar,那么5行3列,3行1列就打上标记。回收时,通过这张表格就能知道哪些Region直接存在跨Region的引用。

   

  在收集过程中,大致分为9各阶段:
     初始标记:与G1一样,GC根枚举,短暂停顿,停顿时间与堆大小无关,只与GC Roots数量相关。

     并发标记:与G1一样,与用户线程并发标记出全部可达对象,对象扫描完成后,通过STAB记录下并发时有引用变动的对象。

    最终标记:与G1一样,对STAB扫描,并在这个阶段统计出回收价值高的Region,将这些Region构成回收集。最终标记阶段也会有一小段停顿

      并发清理:清理存活对象一个都没找到的Region

      并发回收:把回收集里面的存活对象先复制一份到其他未被使用的Region之中。这个过程是并发的,通过读屏障和Brooks Pointers转发指针来解决的。并发回收阶段运行的时间长短取决于回收集的大小。

      初始引用更新:在并发回收阶段复制对象结束后,把堆中所有指向就对象的引用修正到复制后的新地址,这个操作称为引用更新。这个阶段实际没做具体处理,只是建立一个线程集合点,确保所有移动对象的线程已经完成移动。这个是非常短暂的停顿。

     并发引用更新:真正引用更新的地方,与用户线程并发的,它不需要沿对象图搜索,只需要按照内存地址的顺序,线性的搜索出引用类型,把旧值改为新值即可。

     最终引用更新:解决了堆中的引用更新,还要修正GC Roots中的引用,这是Shenandoah最后一次停顿,停顿时间只与GC Roots的数量有关。

    并发清理:经历过并发回收和引用更新后,整个回收集所有的Region无存活对象,最后清理这些Region供新对象分配使用

了解并发标记,并发回收,并发引用更新,就比较容易理解Shenandoah如何运作的。

  并发回收时Shenandoah与HotSPot中其他收集器的核心差异,使用转发指针,来实现对象移动与用户程序并发的一种解决方案。要做到类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱,一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入提前预设的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。这种操作在没有操作系统层面上的直接支持的话,将导致用户态频繁切换到核心态,代价非常大。

   Brooks Pointer则是,在原有对象的布局结构最前面增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。这个和早期的句柄定位有相似之处,两者都是一种间接性的对象访问,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是存放在每一个对象头前面。所有间接对象访问技术的缺点都是相同的,每次对象访问会带来一次额外的转向开销。转发指针带来的收益就是,当对象拥有了一份新的副本时,只需要修改一处指针的值,即对象上转发指针的引用位置。

  Brooks 设计上决定了它是必然会出现多线程竞争问题。当收集器与用户线程发生的只是并发读取,无论是读到旧对象还是新对象,返回的结果是一样的。如果是发生在并发写入,就必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中

     

    1)收集器线程复制了新的对象副本   2)用户线程更新对象的某个字段 3)收集器线程更新转发指针的引用值为新副本地址。

  如果事件2 发生在 事件1、事件3之间,将导致用户线程对对象的更新发生在旧对象上。所以收集器和用户对象对转发指针的访问只能有一个能成功,必须采取同步措施,通过cas操作来实现。

   尽管通过同步,达到访问的一致性。但是对“对象的访问”这四个字非常重的,对象的读取,写入,对象的比较,为对象计算hash值,对象加锁。这些操作都是属于对象访问的范畴。要覆全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截。而代码里对象读取的出现频率比对象写入的频率高出很多,读屏障带来的性能开销也是Shenandoah被诟病的关键。计划在JDK13优化为 "引用访问屏障",内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型,这能够省去大量对原生类型、对象比较、对象加锁场景中的消耗。

Shenandoah的停顿时间是有了质的飞跃,但是并没有达到声称的10ms,而且吞吐量相比其他收集器是降低了不少。

3.5.2 ZGC收集器

      如果说Shenandoah像Oracle G1收集器的实际继承者,Oracle公司开发的ZGC更像是Azul System公司独步天下的PGC和C4收集器的同胞兄弟。Azul VM上的PGC在2005年,已经实现了标记和整理阶段都全程与用户线程并发运行的垃圾收集器了,而运行在Zing VM上的C4收集器是PGC继续演进的产物。

       ZGC收集器是一款基于Region内存布局的,(暂时)不设分代,使用了读屏障,染色指针和内存多重映射等技术来是实现可并发的标记-整理算法的,以低延迟为首要目标的垃圾收集器。

     ZGC也是基于Region堆内存布局的(官方称Region为page或者Zpage),Region具有动态性-动态创建和销毁,它的容量具有大中小三类:
      小型Region:固定容量2MB,用于放置小于256KB的小对象

      中型:固定容量32MB,用于放置大于等于256KB但小于4MB的对象

       大型:容量不固定,可以动态变化,但是必须为2MB的整数倍,用来放置4MB或以上的大对象。每个大型Region只会存放一个大对象,而且它的实际容量有可能小于中型Region,最低容量可低至4MB。大型Region在ZGC不会重分配,因为复制一个大对象的代价非常高昂。

         Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样使用了读屏障,但是用的却是与Shenandoah完全,更加复杂和精巧的解题思路。

   1、 ZGC染色指针技术

     从前,我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录。这种方式在有对象的访问场景下是很流畅的,不会有额外负担。但是如果对象存在被移动的可能,即不能保证对象访问能成功或者,根本不希望访问对象,而又希望得知该对象的某些信息。如果能从指针或者与对象无关的地方得到这些信息,比如能够看出对象是移动过。

    追踪式算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身。某个对象的存活与否取决于它的引用关系,对象上的其他属性都不能够影响它的存活判断结果。HotSpot有不同的标记实现方案,有的把标记直接记录在对象头上(Serial收集器),有的把标记记在对象相互独立的数据结构(G1、Shenandoah使用了一种相当于堆内存的1/64大小,称为BitMap的结构来记录标记信息)。 而ZGC的染色指针是最直接、最纯粹的,它直接把标记信息记在引用对象的指针上,这时候与其说可达性分析是遍历对象图来标记对象,还不如说是遍历"引用图"来标记引用。

     染色指针是一种直接将少了额外信息存储在指针上的技术,Linux下64位指针高18位不能用来寻址,剩余的46位指针所能支持的64TB内存在现目前是够用的,ZGC将46位的高4位提取出来存储四个标志信息。可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,由于这些标记为,ZGC能管理的只有2的42次方(4TB)内存。

       染色指针的三大优势:

        染色指针使得一旦某个Region的存活对象被移走后,这个Region立即能够被释放,不必等待整个堆中所有指向该Region的引用被修正后才能清理。而Shenandoah需要等到引用更新阶段结束后才能释放回收集中的Region,而如果堆中所有对象都存活的极端情况下,需要1:1复制到新的Region话,还必须要有一半的空闲Region来完成收集。

        染色指针可以大幅减少垃圾回收过程中写屏障的使用数量。写屏障的目的通常是为了记录对象引用变得情况,如果将这些情况直接维护在指针中,可省去写屏障。目前,ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC目前还不支持分带收集,天然没有跨代引用的问题)。没有写屏障,ZGC对吞吐量的影响也相对较低。

      染色指针,作为一种可扩展的存储结构。来记录了一些标记。在为了发展中,如果开发了64位指针的高18位,用这高18,可以存储额外的其他信息。比如存储追踪信息来让垃圾收集器在移动对象时,能将低频次使用的对象,移动到不常访问的内存区域。

2、ZGC多重映射

  JVM作为普通的进程,想要重新定义内存中的指针的几位,要么通过硬件层面操作系统的支持。而ZGC是通过虚拟内存映射技术实现的。

    在远古时代,所有进程都是共用同一块物理空间,这样会导致进程之间的内存会无法隔离,当一个进程污染了别的内存只能对整个系统进行复位。为了解决这个问题,从Intel 80386处理器开始,提供“保护模式”用于隔离线程。处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块,这些内存块被称为"页"。可以理解为两块内存,通过"相同的地址"定位到两个完全独立的物理位置,这是内存地址与物理位置是一对多的关系映射。

      如何完成地址的转换,是一对一,多对一还是一对多,也可以根据实际需要来设计,ZGC使用了多重映射,将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是多对一的映射,意味着ZGC在虚拟内存中看到的地址空间比实际的堆容量要大。经过多重映射的转换,就可以使用染色指针正常寻址了。可以理解为,一个内存地址46位(高四位是标记为,低42位为实际内存),而ZGC只管理2的42次方内存,所以从不同的地址段可以映射到同一个物理地址。使用染色指针复制大对象会更便利。

3、ZGC的运作过程

   并发标记:做可达性分析,与G1、Shenandoah不同的是,ZGC的标记在指针上而不是对象上,标记阶段就会更新染色指针中                       的Mark0/Marked1标记位。

   并发预备重分配:统计出本次收集过程要清理哪些Region,将这些Region组成重分配集。ZGC的重分配集与G1收集器的回收集还是有区别的,ZGC并非为了像G1那样做收益优先的增量回收,而是每次回收都会扫描所有Region,用范围更大的扫描成本省去G1记忆集的维护成本。

  并发重分配:重分配是ZGC执行的核心阶段,这个阶段是把重分配集的存活对象复制到新的Region上,并且为重分配集中的每个Region维护一个转发表,记录旧对象到新对象的转向关系。得益于染色指针,ZGC仅从引用上就能知道对象是否处于重分配集,如果用户线程此时并发的访问了位于重分配集上的对象,这次访问将被内存屏障所截获,然后根据Region上的转发记录将转发访问转发到新复制的对象上,并同时修正更新该引用的值,这就叫自愈。直接执行新对象,这样的好处就是只有第一次访问旧对象会慢。 还有个好处就是,一旦重分配集中某个Region的存活对象都复制完毕,这个Region就可以立即释放(转发表还需要留着),哪怕堆中还有很多指向这个对象的更新指针也没有关系,一旦就指针被使用,它们都是可自愈的

 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。这里的并发重映射,并不是很迫切,因为旧引用是可以自愈的。合并到了下次垃圾回收的并发标记阶段去完成的,因为反正要遍历所有对象,这样合并就节省了一次遍历对象图的开销。重映射清理就引用的目的是为了不变慢和清理结束后,可以释放转发表。

ZGC没有使用记忆集,没有分代,没有卡表,没有用到写屏障,所以给用户线程带来的运行负担小得多。没有分代,就会有没有分代的缺点,就像分代产生理论依据一样,因为大多数对象都是找朝生夕灭,如果应用的对象分配速率过高,这些新对象很难进入到当次的回收标记范围,通常只能全部当做存活对象,这样就会产生大量浮动垃圾。如果这种高速分配持续维持,每一次完成的并发收集周期就会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占空间,堆中剩余的可挪动的空间就会越来越小了。目前唯一的办法就是尽可能得增加堆容量。

ZGC还有一个技术上的优点就是,NUMA-Aware的内存分配,在Numa架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效的内存访问。

ZGC的弱项是吞吐量,以低延迟为首要目标ZGC已经达到了以高吞吐量为目标Parallel Scavenge的%99%,直接超越G1.ZGC的停顿是高于hotspot之前所有的收集器。

    3.6 选择合适的垃圾收集器

    选择垃圾收集器,主要受以下三个因素影响

    1)如果是数据分析,科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至导致事务超时,延迟就是主要关注点。如果是客户端或者嵌入式应用,那垃圾收集的内存就是关注点。

  2)运行应用的基础设施,比如硬件规格,处理器数量,分配内存的大小,操作系统等

  3)JDK的发行商,版本号,是ZingJdk/Zulu,OracleJDK、OpenJDK,OpenJ9的发行版,或者该JDK对应了<java虚拟机规范>的哪个版本。

3.6.1虚拟机与垃圾收集器日志

在JDK9之前,hotSpot并没有提供统一的日志处理框架,虚拟机的各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同的功能上都要单独解决。知道JDK9,这种混乱不堪的局面才终于消失,HotSpot所有功能的日志都归到的"-Xlog"上,-Xlog[:[selector] [:[output] [:[decorators] [:output -options]]]     ]

  命令行中 最关键的参数是选择器selector,它有标签(tag)和日志级别(Level)共同组成。标签可以理解为虚拟机某个功能模块的名字,它告诉日志框架用户希望得到那一模块的日志输出。比如说垃圾收集器的标签名称为“gc”,HotSpot支持以下标签

add,age,alloc,annotation,aot,arguments,attach,barrier,biasedlocking,blocks,bot,breakpoint,bytecode,census..........................................等等等等

日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别。HotSpot的日志规则与Log4j、SlF4j这类Java日志框架大体一致。另外可以使用decorator来要求每行日志都附加上额外的内容,支持附加在日支行的信息包括:

time:当前日期和时间
uptime:虚拟机启动到现在经过的时间,以秒为单位
timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()
uptimemillis:虚拟机从启动到现在经过的毫秒数
timenanos:当前时间的纳秒数,相当于System.nanoTime()
uptimenanos:
pid:进程id
tid:线程id
level:日志级别
tags:日志输出的标签集

如果不指定,默认值是updatime、level、tags。
类似输出
[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s

JDK9之前和JDK9以及之后的例子

1)查看GC基本信息,jdk9之前-XX:PrintGC,jdk9之后使用-Xlog:gc

2)查看GC详情信息,在jdk9之前使用-XX:+PrintGCDetails,在JDK9之后使用-X-log:gc*,用通配符将GC标签下的所有细分过程都打印出来。

3)查看GC前后堆、方法区可用容量变化,JDK9之前使用-XX:+PrintHeapAtGc,JDK9之后使用-Xlog:gc+heap=debug

4)查看GC过程中用户线程并发时间以及停顿时间,在JDK9之前使用-XX:PrintGCApplicationConcurrentTime以及-XX:PrintGcApplicationStoppedTime,JDk9之后使用-Xlog:safepoint

5)查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。在Jdk9之前使用-XX:PrintAdaptiveSizePolicy,在Jdk9之后使用-Xlog:gc+ergo*=trace.

6)查看熬过收集之后剩余对象的年龄分布信息,在JDK9之前使用-XX:+PrintTenuringDistribution,JDK9之后使用-Xlog:gc+age=trace

3.6.3 日志参数对比

3.6.4垃圾收集器参数总结

3.7 内存分配与回收策略

    对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配),本节内容都是使用Serial加Serial Old客户端默认收集器组合下的内存分配和回收的策略。

3.7.1对象优先在Eden分配

   对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor Gc。 

   -XX:+PrintGCDetails,告诉虚拟机在垃圾收集时,打印内存回收日志,并且在进程退出的时候,输出当前内存区域分配情况。在实际问题排差中,收集日志常会打印到文件,然后通过工具进行分析。

  

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:+UseSerialGC
 * @author Administrator
 *
 */
public class TestAllocation {
	private static final int _1MB=1042*1024;

	protected static void test()  {
		byte[] allocation1,allocation2,allocation3,allocation4;
		allocation1=new byte[2*_1MB];
		allocation2=new byte[2*_1MB];
		allocation3=new byte[2*_1MB];
		allocation4=new byte[4*_1MB];
	}

	public static void main(String[] args) throws InterruptedException {
		test();
	}

实验结果如下:

  [GC (Allocation Failure) [DefNew: 7399K->561K(9216K), 0.0029561 secs] 7399K->6813K(19456K), 0.0034435 secs] [Times: user=0.00 sys=0.01, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4811K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff026930, 0x00000000ff400000)
  from space 1024K,  54% used [0x00000000ff500000, 0x00000000ff58c440, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 6252K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  61% used [0x00000000ff600000, 0x00000000ffc1b030, 0x00000000ffc1b200, 0x0000000100000000)
 Metaspace       used 2792K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 296K, capacity 386K, committed 512K, reserved 1048576K

Allocation Failure

表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了

new Generation:新生代,tenured generation,老年代。

在分配allocation4的时候,发现Eden已经被占用了6MB,剩余空间不足以分配,则发生MinorGc。然后在垃圾收集期间,又发现已有三个2MB的对象无法放入Survivor(Survivor只有1MB),所以只好通过分配担保机制提前转移到老年代。

3.7.2大对象直接进入老年代

大对象最典型的就是,很长的字符串或者元素数量很庞大的数组。大对象会提前触发垃圾收集,以获取足够的连续空间才能放下,当复制对象时,大对象意味着高额的内存复制开销。HotSPot提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是并在Eden区及两个Survivor区之间来回复制。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:+UseSerialGC -XX:PretenureSizeThreshold=3145728
 * @author Administrator
 *
 */
public class TestPreTenureSizeThreshold {
	private static final int _1MB=1042*1024;

	protected static void test()  {
		byte[] allocation;
		allocation=new byte[4*_1MB];
	}

	public static void main(String[] args) throws InterruptedException {
		test();
	}
}

实验结果:

Heap
 def new generation   total 9216K, used 1147K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  14% used [0x00000000fec00000, 0x00000000fed1efb0, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 4168K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa12010, 0x00000000ffa12200, 0x0000000100000000)
 Metaspace       used 2756K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 295K, capacity 386K, committed 512K, reserved 1048576K
 

-XX:PretenureSizeThreshold 参数只对Serial和ParNew两款新生代收集器有效。从实验结果来看,Eden几乎是空的,但是老年代10MB空间被使用了 40%,因为-XX:PretenureSizeThreshold设置成了3MB,所以超过3MB的对象直接在老年代设置。

3.7.2 长期存活的对象将进入老年代

Eden诞生的对象,在第一次minor Gc后,仍然存活的对象,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,对象年龄会被设置为1岁,对象每熬过一次MinorGc,年龄就增加1岁,当他的年龄增加到一定程度(默认15岁),就会被晋升到老年代。对象晋升到老年代的年龄阈值,可以通过-XX:MaxTenuringThreshold设置。比如-XX:MaxTenuringThreshold=1时,在经历过一次MinorGc后,第二次MinorGC时就会直接进入老年代。

  1. 当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次出发Minor gc的时候,会扫描Eden区和From区,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden区和From区清空。
  2. 当后续Eden区又发生Minor gc的时候,会对Eden区和To区进行垃圾回收,存活的对象复制到From区,并将Eden区和To区清空
  3. 部分对象会在From区域和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还存活,就存入老年代。

这里的实验结果存疑
package erwan.jvm;
/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
 * @author Administrator
 *
 */
public class TestTenuringThreshold {
	private static final int _1MB=1042*1024;

	protected static void test()  {
		byte[] allocation1,allocation2,allocation3,allocation4;
		allocation1=new byte[_1MB/4];
		allocation2=new byte[4*_1MB];
		allocation3=new byte[4*_1MB];
		allocation3=null;
		allocation4=new byte[4*_1MB];
	}

	public static void main(String[] args) throws InterruptedException {
		test();
	}
}
当-XX:MaxTenuringThreshold=1时:测试结果:
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age   1:     837360 bytes,     837360 total
: 5412K->817K(9216K), 0.0037946 secs] 5412K->4985K(19456K), 0.0038270 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 5068K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff026930, 0x00000000ff400000)
  from space 1024K,  79% used [0x00000000ff500000, 0x00000000ff5cc6f0, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 4168K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa12010, 0x00000000ffa12200, 0x0000000100000000)
 Metaspace       used 2756K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 295K, capacity 386K, committed 512K, reserved 1048576K


当-XX:MaxTenuringThreshold=15时:测试结果:

[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:     841480 bytes,     841480 total
: 5576K->821K(9216K), 0.0023293 secs] 5576K->4989K(19456K), 0.0023617 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
: 4989K->0K(9216K), 0.0008012 secs] 9157K->4988K(19456K), 0.0008127 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4250K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff026930, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 4988K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  48% used [0x00000000ff600000, 0x00000000ffadf348, 0x00000000ffadf400, 0x0000000100000000)
 Metaspace       used 2792K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 296K, capacity 386K, committed 512K, reserved 1048576K

这里为什么会出现两次MinorGC呢,当allocation3分配对象时,Eden本来只有8M大,现在已经被占用了4+1/4M,现在分配不了4M给allocation3,所以触发第一次MinorGc,第二次也是如此。

3.7.5 空间分配担保

在发生Minor Gc之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果是则这次是安全的

如果不是,虚拟机会先检查-XX:HandlePromotionFailure参数是否运行担保失败,如果允许,将会继续检查老年代最大可用的连续空间是否大于历次晋升到老年对象的平均大小。如果大于,将会尝试MinorGc,尽管这次MinorGc是有风险的(平均值大小也是一种赌概率的办法,如果这次存活对象远高于历史平均,就会担保失败,就会引发fullGc,这样停顿时间就很长了);如果小于,或者-XX:HandlePromotionFailure设置为不冒险,那么就要改为进行一次FullGc。

JDK6update24 后,-XX:HandlePromotionFailure不会在实际虚拟机中使用,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行MinorGC,否则进行FullGC.


版权声明:本文为jinjidexiaoxiong原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。