JVM学习之路(一)Java运行时区域、对象的创建过程、对象的内存布局、垃圾回收器

文章目录

本系列文章:
  JVM学习之路(一)Java运行时区域、对象的创建过程、对象的内存布局、垃圾回收器
  JVM学习之路(二)类加载、Java内存模型、JVM调试命令、JVM调优案例
  JVM学习之路(三)JVM调优实战

  该文章内容对应的JDK版本为1.7。

一、Java运行时区域

1.1 运行时数据区域


  JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口):

  1. Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
  2. Execution engine(执行引擎):执行classes中的指令。
  3. Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
  4. Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

  Java虚拟机在执行Java程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

  把本地内存也考虑进去的话:

  过程编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能
  Java程序运行机制步骤:

  1. 首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java;
  2. 再利用编译器将源代码编译成字节码文件,字节码文件的后缀名为.class;
  3. 运行字节码的工作是由解释器(java命令)来完成的。

      或者看这个:

      从上图可以看,java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。
      其实可以一句话来解释:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。

1.1.1 程序计数器(线程私有)

  程序计数器是一块较小的内存区域,可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  此内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM情况的区域
  虚拟机的运行,类似于这样的循环:

	while( not end ) {
    
    
	 ​	取PC中的位置,找到对应位置的指令;
	 ​	执行该指令;
	 ​	PC ++;
	 }
  • 1、作用
      记录当前线程所执行到的字节码的行号。字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 2、意义
      JVM的多线程是通过线程轮流切换并分配处理器来实现的,对于我们来说的并行事实上一个处理器也只会执行一条线程中的指令。所以,为了保证各线程指令的安全顺利执行,每条线程都有独立的私有的程序计数器(为什么要线程计数器?因为线程是不具备记忆功能)
  • 3、存储内容
      当线程中执行的是一个Java方法时,程序计数器中记录的是正在执行的线程的虚拟机字节码指令的地址

  程序计数器特点:

  1. 当前线程所执行的字节码行号指示器。
  2. 每个线程都有一个自己的PC计数器。
  3. 线程私有的,生命周期与线程相同,随JVM启动而生,JVM关闭而死。
  4. 线程执行Java方法时,记录其正在执行的虚拟机字节码指令地址。
  5. 线程执行Native方法时,程序计数器中的值为空(Undefined)。
  6. 唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。

  例子:假如线程A在执行任务a,线程B抢夺线程A的时间片,就会打断了线程A,线程A就会挂起,当线程B结束任务后,线程A要重新执行任务a,就需要程序计数器来帮其恢复被打断时的状态。

1.1.2 Java虚拟机栈(线程私有)

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

  栈帧里的四种组成元素的具体结构和功能:

  • 1、局部变量表
      局部变量表是 Java 虚拟机栈的一部分,是一组变量值的存储空间,用于存储方法参数和局部变量。 在 Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。
      局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:
       1)基本数据类型:boolean, byte, char, short, int, float, long, double等8种;
       2)对象引用类型 :reference,指向对象起始地址的引用指针;不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置;
       3)返回地址类型:returnAddress,返回地址的类型。指向了一条字节码指令的地址。

变量槽(Slot)是局部变量表的最小单位,规定大小为32位。对于64位的long和double变量而言,虚拟机会为其分配两个连续的Slot空间。

  • 2、操作数栈
      操作数栈(Operand Stack)也常称为操作栈,是一个后入先出栈。在 Class 文件的 Code 属性的 max_stacks 指定了执行过程中最大的栈深度。

Java虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的栈就是指操作数栈。

  操作数栈特点:
   1)和局部变量表一样,操作数栈也是一个以32字长为单位的数组。
   2)虚拟机在操作数栈中可存储的数据类型:int、long、float、double、reference和returnType等类型 (对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int)
   3)和局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作 — 压栈和出栈来访问。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。

  虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈

begin
iload_0    // push the int in local variable 0 onto the stack
iload_1    // push the int in local variable 1 onto the stack
iadd       // pop two ints, add them, push result
istore_2   // pop int, store into local variable 2
end

  在这个字节码序列里,前两个指令 iload_0 和 iload_1 将存储在局部变量表中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量表索引为2的位置。
  下图表述了这个过程中局部变量表和操作数栈的状态变化:

  • 3、动态链接
      每个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接

描述一个方法调用的另外的其它方法时,就是通过常量池中指向该方法的符号引用来表示,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

  Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:
   1)静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析
   2)动态解析:另一部分将在每一次的运行期间转化为直接引用,称为动态链接

  • 4、方法返回地址
      当一个方法开始执行以后,只有两种方法可以退出当前方法:
       1)正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址
       2)异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。

  当一个方法返回时,可能依次进行以下3个操作:

  1. 恢复上层方法的局部变量表和操作数栈。
  2. 把返回值压入调用者栈帧的操作数栈。
  3. 将PC计数器的值指向下一条方法指令位置。

  在Java虚拟机规范中,对虚拟机栈规定了两种异常:

1、如果当前线程请求的栈深度大于虚拟机栈所允许的深度,将会抛出 StackOverflowException(在虚拟机栈不允许动态扩展的情况下);
2、如果扩展时无法申请到足够的内存空间,就会抛出 OutOfMemoryError 异常。

1.1.3 本地方法栈(线程私有)

  本地方法栈和Java虚拟机栈发挥的作用非常相似,主要区别是Java虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈为虚拟机使用到的Native方法服务(Native 方法的源码大部分都是 C和C++ 的代码)。

有些虚拟机发行版本(譬如Sun HotSpot虚拟机)直接将本地方法栈和Java虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

1.1.4 Java堆(全局共享)

  对大多数应用而言,Java 堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一作用就是存放对象实例,几乎所有的对象实例都是在这里分配的(不绝对,在虚拟机的优化策略下,也会存在栈上分配、标量替换的情况)。
  Java 堆是 GC 回收的主要区域,因此很多时候也被称为 GC 堆。
  从内存回收的角度看,JDK1.8及之前的收集器基本都采用分代收集算法,所以在Java堆被划分成两个不同的区域:新生代 (Young Generation) 、老年代 (Old Generation) 。新生代 (Young) 又被划分为三个区域:一个Eden区和两个Survivor区 - From Survivor区和To Survivor区。分代划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。

简单总结:新的对象分配是首先放在年轻代 (Young Generation) 的Eden区,Survivor区作为Eden区和Old区的缓冲,在Survivor区的对象经历若干次收集仍然存活的,就会被转移到老年代Old中。

  从内存回收的角度看,线程共享的 Java 堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。TLAB属于线程共享的内存区域。
  堆可以是固定大小的,也可以通过设置配置文件设置该为可扩展的。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中。当前主流的虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制)。 如果堆上没有内存进行分配,并无法进行扩展时,将会抛出OutOfMemoryError异常。

1.1.5 方法区(全局共享)

  方法区和Java堆一样,为多个线程共享,它用于存储类信息、常量、静态常量和即时编译后的代码等数据
  永久代(1.7)Perm Generation/ 元数据区(1.8) Metaspace,其实是方法区在不同JDK版本的称呼/实现。
  Perm Generation元数据可以设置,也可以不设置,无上限(受限于物理内存)。元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。
  方法区的内存回收效率低。 方法区中的信息一般需要长期存在,回收一遍之后可能只有少量信息无效。主要回收目标是:对常量池的回收;对类型的卸载。

  • 运行时常量池
      运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant Pool Table),用于存放编译期生成的各种字面常量和符号引用,这部分内容会在类加载后进入方法区的运行时常量池
      方法区中存放:类信息、常量、静态变量、即时编译器编译后的代码。常量就存放在运行时常量池中。当类被 Java 虚拟机加载后, .class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如 String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。
      运行时常量池,则是JVM虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。
  1. HotSpot虚拟机中,将方法区称为“永久代”,本质上两者并不等价,仅仅是因为HotSpot虚拟机把GC分代收集扩展至方法区。
  2. JDK 7的HotSpot中,已经将原本存放于永久代中的字符串常量池移出。
  3. 根据虚拟机规范的规定,当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。当常量池无法再申请到内存时也会抛出OutOfMemoryError异常。
  4. JDK 8的HotSpot中,已经将永久代废除,用元数据实现了方法区。元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的,需要配置参数。

  在JDK1.8之前,字符串常量位于永久代,FGC不会清理
  在JDK1.8之后,字符串常量位于堆,会触发FGC清理
  方法区在JDK1.8之前,是有固定大小限制的,所以容易发生内存不足的现象;1.8之后,其大小限制和物理内存等同。

  当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.1.6 直接内存

  直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。Java 中的 NIO 可以使用 Native 函数直接分配堆外内存,通常直接内存的速度会优于Java堆内存,然后通过一个存储在 Java 堆中的 DiectByteBuffer 对象作为这块内存的引用进行操作。对于读写频繁、性能要求高的场景,可以考虑使用直接内存,因为避免了在 Java 堆和 Native 堆中来回复制数据。直接内存不受 Java 堆大小的限制。

1.2 一个方法调用另一个方法,会创建很多栈帧吗?递归的调用自己会创建很多栈帧吗?

  会创建。如果一个栈中有动态链接调用别的方法,就会去创建新的栈帧,栈中是由顺序的,一个栈帧调用另一个栈帧,另一个栈帧就会排在调用者下面。
  递归的话也会创建多个栈帧,就是在栈中一直从上往下排下去。

1.3 堆栈的区别

  • 1、物理地址
      堆的物理地址分配对象是不连续的,因此性能慢些
      使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的所以性能快
  • 2、内存分别
      堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定一般堆大小远远大于栈
      栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的
  • 3、存放的内容
      堆存放的是对象的实例和数组。因此该区更关注的是数据的存储。
      栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

PS:静态变量放在方法区,静态的对象还是放在堆。

  • 4、程序的可见度
      堆对于整个应用程序都是共享、可见的
      栈只对于线程是可见的。所以也是线程私有,其生命周期和线程相同

二、HotSpot虚拟机对象

2.1 对象的创建过程

  Java 中提供的几种对象创建方式:

方式 解释
使用new关键字 调用了构造函数
使用Class的newInstance方法 调用了构造函数
使用Constructor类的newInstance方法 调用了构造函数
使用clone方法 没有调用构造函数
使用反序列化 没有调用构造函数

  对象创建的主要流程:

  虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。划分内存时还需要考虑一个问题-并发,也有两种方式:CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。然后内存空间初始化(设置为零值)操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行<init>方法。

  对象的创建通常是通过new关键字创建一个对象的,当虚拟机接收到一个new指令时,它会做如下的操作:

2.1.1 判断对象对应的类是否加载、链接、初始化

  虚拟机接收到一条new指令时,首先会去检查这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初始化过,如果没有则先执行相应的类加载过程

2.1.2 为对象分配内存

  类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
   1)指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
   2)空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。

选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

2.1.3 处理并发安全问题

  对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
   1)对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性);
   2)【堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的。JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB】。
    把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。

2.1.4 初始化分配到的内存空间

  内存分配完后,虚拟机要将分配到的内存空间初始化为零值(不包括对象头)。如果使用了 TLAB,这一步会提前到 TLAB 分配时进行。这一步保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用。

2.1.5 设置对象的对象头

  接下来设置对象头(Object Header)信息,包括对象的所属类、对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。

2.1.6 执行init方法进行初始化

  执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建了出来。

2.2 对象的内存布局

  java -XX:+PrintCommandLineFlags -version命令可以查看虚拟机配置。
  HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  Java对象的内存:

  • 1、对象头
      在HotSpot虚拟机中,对象头有两部分信息组成:运行时数据 和 类型指针,如果是数组对象,还有一个保存数组长度的空间
       1)Mark Word(运行时数据):用于存储对象自身运行时的数据,如哈希码(hashCode)、GC分带年龄、线程持有的锁、偏向线程ID等信息。在32位系统占4字节,在64位系统中占8字节
      HotSpot虚拟机对象头Mark Word在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示:

      或者看这个:

      上图中的hashCode分为两种情况:是否冲写过hashCode。如果没重写过hashCode方法,则上图中是identityHashCode,其意义是根据对象物理内存地址产生的hash值;如果重新过hashCode,则会存储重新后计算的hashCode值。

从上图中,也能看出为什么GC年龄默认分代为15,因为在对象头中的分代年龄的最大值是15。

  当一个对象计算过identityHashCode之后,不能进入偏向锁状态。

   2)Class Pointer(类型指针):用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节
   3)Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;

  • 2、实例数据
      实例数据 是对象真正存储的有效信息,无论是从父类继承下来的还是该类自身的,都需要记录下来,而这部分的存储顺序受虚拟机的分配策略和定义的顺序的影响。
      实例数据部分就是成员变量的值,其中包括父类成员变量和本类成员变量。
      默认分配策略:

long/double -> int/float -> short/char -> byte/boolean -> reference

  从分配策略可以看出,相同宽度的字段总是被分配到一起
  如果设置了-XX:FieldsAllocationStyle=0(默认是1),那么引用类型数据就会优先分配存储空间:

reference -> long/double -> int/float -> short/char -> byte/boolean

  结论:分配策略总是按照字节大小由大到小的顺序排列,相同字节大小的放在一起。

  • 3、对齐填充
      无特殊含义,不是必须存在的,仅作为占位符。
      HotSpot虚拟机要求每个对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(32位为1倍,64位为2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全

2.3 一个Object对象/数组占多少字节

  即以Object obj = new Object()创建的对象占多少个字节?
  答案是16个字节(在64位机器中)。由上节内容可知,Mark Word占8个字节,Class Pointer占8个字节。此时会涉及到一个虚拟机参数:java -XX:+UseCompressedClassPointers。这个参数表示是否开启类指针压缩,如果不开启,答案就是8+8=16个字节。如果开启的话,类指针就会占4个字节,此时对齐就会再加上4个字节,8+4+4=16个字节。
  以一个int[ ]数组为例,Mark Word占8个字节;Class Pointer占8个字节,压缩后占4个字节,还有数组长度4个字节,8+4+4=16个字节。

2.4 对象的访问定位

  Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。
   1)指针: 指向对象,代表一个对象在内存中的起始地址。
   2)句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

  • 1、句柄访问
      Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据(即T.class)各自的具体地址信息,具体构造如下图所示:

      优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。
  • 2、直接指针
      如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

      优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种直接指针方式

2.5 OutOfMemoryError示例

  内存泄露:代码中的某个对象本应该被虚拟机回收,但因为拥有GCRoot引用而没有被回收。
  内存溢出:虚拟机由于堆中拥有太多不可回收对象没有回收,导致无法继续创建新对象。
  Dump机制会帮助我们了解JVM运行时的各种信息,可以通过加上VM参数-XX:+HeapDumpOnOutOfMemoryError让虚拟机在出现内存溢出异常时生成dump文件,然后通过外部工具(如VisualVM)来具体分析异常的原因。
  除了程序计数器外,Java虚拟机的其他运行时区域都有可能发生OutOfMemoryError的异常。

  • 1、Java堆溢出
      Java堆用来存储对象,因此只要不断创建对象,并保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么当对象数量达到最大堆容量时就会产生 OOM。
/**
 * java堆内存溢出测试
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    
    

    static class OOMObject{
    
    }

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

  测试结果:

java.lang.OutOfMemoryError: Java heap space 
Dumping heap to java_pid7164.hprofHeap dump file created [27880921 bytes in 0.193 secs] 
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space 
at java.util.Arrays.copyOf(Arrays.java:2245) 
at java.util.Arrays.copyOf(Arrays.java:2219) 
at java.util.ArrayList.grow(ArrayList.java:242) 
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216) 
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208) 
at java.util.ArrayList.add(ArrayList.java:440) 
at com.jvm.oom.HeapOOM.main(HeapOOM.java:17)

  堆内存 OOM 是经常会出现的问题,异常信息会进一步提示 Java heap space

  • 2、虚拟机栈和本地方法栈溢出
      在 HotSpot 虚拟机中不区分虚拟机栈和本地方法栈,栈容量只由 -Xss 参数设定。关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:
       1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
       2)如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
/**
 * 虚拟机栈和本地方法栈内存溢出测试,抛出stackoverflow exception
 * VM ARGS: -Xss128k 减少栈内存容量
 */
public class JavaVMStackSOF {
    
    

    private int stackLength = 1;
    public void stackLeak () {
    
    
        stackLength++;
        stackLeak();
    }

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

  测试结果:

stack length = 11420 
Exception in thread “main” java.lang.StackOverflowError 
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12) 
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13) 
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13) 

  该代码在单线程环境下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,抛出的都是StackOverflowError 异常。
  如果测试环境是多线程环境,通过不断建立线程的方式可以产生内存溢出异常,代码如下所示。但是这样产生的 OOM 与栈空间是否足够大不存在任何联系,在这种情况下,为每个线程的栈分配的内存足够大,反而越容易产生OOM 异常。这点不难理解,每个线程分配到的栈容量越大,可以建立的线程数就变少,建立多线程时就越容易把剩下的内存耗尽。这点在开发多线程的应用时要特别注意。如果建立过多线程导致内存溢出,在不能减少线程数或更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程

/**
 * JVM 虚拟机栈内存溢出测试, 注意在windows平台运行时可能会导致操作系统假死
 * VM Args: -Xss2M -XX:+HeapDumpOnOutOfMemoryError
 */

public class JVMStackOOM {
    
    

    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) {
    
    
        JVMStackOOM oom = new JVMStackOOM();
        oom.stackLeakByThread();
    }
}
  • 3、方法区和运行时常量池溢出
      方法区用于存放Class的相关信息,对这个区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。使用CGLib实现。
      方法区溢出也是一种常见的内存溢出异常,在经常生成大量Class的应用中,需要特别注意类的回收情况,这类场景除了使用了CGLib字节码增强和动态语言外,常见的还有JSP文件的应用(JSP第一次运行时要编译为Java类)、基于OSGI的应用等。
/**
 * 测试JVM方法区内存溢出
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class MethodAreaOOM {
    
    

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

    static class OOMObject{
    
    }
}
  • 4、本机直接内存溢出
      DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定,如不指定,则默认与Java堆最大值(-Xmx指定)一样。测试代码使用了 Unsafe 实例进行内存分配。
      由 DirectMemory 导致的内存溢出,一个明显的特征是在Heap Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。
/**
 * 测试本地直接内存溢出
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
    
    

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
    
    
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
    
    
            unsafe.allocateMemory(_1MB);
        }
    }
}

三、垃圾回收器

  GC需要考虑3件事:哪些内存需要回收?什么时候回收?如何回收?

3.1 如何判断对象是否已死

  垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是"存活"的,是不可以被回收的;哪些对象已经"死掉"了,需要被回收。

3.1.1 引用计数法

  Java 堆 中每个具体对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。
  优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。
  缺点:难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。
  循环引用示例:

		ClassA objA=new ClassA();
		ClassA objB=new ClassA();
		objA.instance=objB;
		objB.instance=objA;

  当使用引用计数法时,即便将objA和objB都=null,对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

3.1.2 可达性分析算法

  可达性分析算法又叫根搜索算法,该算法的基本思想就是通过一系列称为「GC Roots」的对象作为起始点,从这些起始点开始往下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 对象之间没有任何引用链的时候(不可达),证明该对象是不可用的,于是就会被判定为可回收对象
  在下图中, Object5、Object6、Object7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象。

  在 Java 中可作为 GC Roots 的对象包含以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  2. 方法区中类静态变量引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈中 JNI(Native 方法)引用的对象。

3.1.3 四种引用

  在JDK1.2以前的版本中,当一个对象不被任何变量引用,那么程序就无法再使用这个对象。但是,总存在着这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。于是在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。

  • 1、强引用
      强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种强引用对象。示例:
public class StrongReferenceTest {
    
    
	public static void main(String[] args) {
    
    
		new StrongReferenceTest().test();
	}
	public void test(){
    
    
		Object[] objArr=new Object[Integer.MAX_VALUE];
	}
}

  测试结果为:

  如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null,这样一来的话,JVM在合适的时间就会回收该对象。

  • 2、软引用
      软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在内存不足、系统将要发生内存溢出异常之前,将会把这些软引用对象列进回收范围之中进行第二次回收。在JDK 1.2之后,提供了java.lang.ref.SoftReference类来实现软引用。
  • 3、弱引用
      弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,也就是说,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了java.lang.ref.WeakReference类来实现弱引用。此处可以看一个简单的小例子:
public class WeakReferenceTest {
    
    
    public static void main(String[] args) {
    
    
        WeakReference<String> sr = new WeakReference<String>(new String("helloworld"));
        System.out.println(sr.get());
        /*通知JVM的gc进行垃圾回收*/
        System.gc();                
        System.out.println(sr.get());
    }
}

  测试结果:

helloworld
null

  需要注意的是,当显示调用System.gc()时,JVM不一定会立刻执行,也就是说这句是无法确保此时JVM一定会进行垃圾回收的。

  • 4、虚引用
      虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,它并不影响对象的生命周期。在JDK1.2之后,用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。
  • 5、几种引用的对比
引用类型 被回收时间 用途 生存时间
强引用 从来不会 对象的一般状态 JVM停止运行时
软引用 内存不足时 对象缓存 内存不足时
弱引用 jvm垃圾回收时 对象缓存 gc运行后
虚引用 未知 未知 未知

  在实际开发中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OOM)等问题的产生。

3.1.4 两次标记

  在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程(即一个对象是否应该在垃圾回收器在GC时回收,至少要经历两次标记过程):
  第一次标记:如果对象在进行可达性分析后被判定为不可达对象,那么它将被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。对象没有覆盖 finalize() 方法或者该对象的 finalize() 方法曾经被虚拟机调用过,则判定为没必要执行。
  finalize()第二次标记:如果被判定为有必要执行 finalize() 方法,那么这个对象会被放置到一个 F-Queue 队列中,并在稍后由虚拟机自动创建的、低优先级的 Finalizer 线程去执行该对象的 finalize() 方法。但是虚拟机并不承诺会等待该方法结束,这样做是因为,如果一个对象的 finalize() 方法比较耗时或者发生了死循环,就可能导致 F-Queue 队列中的其他对象永远处于等待状态,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,如果对象要在 finalize() 中挽救自己,只要重新与 GC Roots 引用链关联上就可以了。这样在第二次标记时它将被移除「即将回收」的集合,如果对象在这个时候还没有逃脱,那么它基本上就真的被回收了。
  示例代码:

/**
*1.对象可以在被GC时自我拯救。
*2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC{
    
    
	public static FinalizeEscapeGC SAVE_HOOK=null;
	public void isAlive(){
    
    
		System.out.println("yes,i am still alive");
	}
	@Override
	protected void finalize()throws Throwable{
    
    
		super.finalize();
		System.out.println("finalize mehtod executed!");
		FinalizeEscapeGC.SAVE_HOOK=this;
	}
	public static void main(String[]args)throws Throwable{
    
    
		SAVE_HOOK=new FinalizeEscapeGC();
		//对象第一次成功拯救自己
		SAVE_HOOK=null;
		System.gc();
		//因为finalize方法优先级很低,所以暂停0.5秒以等待它
		Thread.sleep(500);
		if(SAVE_HOOK != null){
    
    
			SAVE_HOOK.isAlive();
		}else{
    
    
			System.out.println("no,i am dead 1");
		}
		//下面这段代码与上面的完全相同,但是这次自救却失败了
		SAVE_HOOK=null;
		System.gc();
		//因为finalize方法优先级很低,所以暂停0.5秒以等待它
		Thread.sleep(500);
		if(SAVE_HOOK != null){
    
    
			SAVE_HOOK.isAlive();
		}else{
    
    
			System.out.println("no,i am dead 2");
		}
	}
}

  测试结果:

finalize mehtod executed!
yes,i am still alive
no,i am dead 2

3.1.5 回收方法区

  前面介绍过,方法区在 HotSpot 虚拟机中被划分为永久代。在 Java 虚拟机规范中没有要求方法区实现垃圾收集,而且方法区垃圾收集的性价比也很低方法区(永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。废弃常量的回收和 Java 堆中对象的回收非常类似:只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。比如,一个字符串 “bingo” 进入了常量池,但是当前系统没有任何一个 String 对象引用常量池中的 “bingo” 常量,也没有其它地方引用这个字面量,必要的话,"bingo"常量会被清理出常量池。

  类的回收条件就比较苛刻了。要判定一个类是否可以被回收,要满足以下三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

  虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。

3.2 垃圾收集算法

  • Card Table
      由于YGC时,需要扫描整个OLD区(因为需要找从Old区指向Eden区的对象),效率非常低,所以JVM设计了CardTable, 如果一个OLD区CardTable中有对象指向Y区,就将它设为Dirty,下次扫描时,只需要扫描Dirty Card。
      在结构上,Card Table用BitMap来实现。

3.2.1 标记-清除算法

  标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

  1. 标记阶段:标记出可以回收的对象。
  2. 清除阶段:回收被标记的对象所占用的空间。

  标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。
  标记-清除算法的优点

  1. 实现简单,不需要对象进行移动;
  2. 存活对象较多的时候效率比较高。

  标记-清除算法的缺点

  1. 标记、清除过程效率低;
  2. 产生大量不连续的内存碎片

  标记-清除算法的执行的过程示例:

3.2.2 复制算法

  为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收
  复制算法的优点

  1. 按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片;
  2. 适合于对象较少的情况。

  复制算法的缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
  复制算法的执行过程示例:

3.2.3 标记-整理算法

  在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-清除算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。
  标记-整理算法的优点:解决了标记-清理算法存在的内存碎片问题。
  标记-整理算法的缺点:仍需要进行局部对象移动,一定程度上降低了效率。
  标记-整理算法的执行过程示例:

3.2.4 分代收集算法

  当前商业虚拟机都采用分代收集的垃圾收集算法。除Epsilon、ZGC、Shenandoah之外的GC都是使用逻辑分代模型。
  分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块,示例:

  新生代和老年代的比例是1:2,Eden、S1和S2的比例是8:1:1,这种关系都是默认(JDK1.8)的,可以改变。

  • 1、新生代(Young generation)
      绝大多数最新被创建的对象会被分配到这里,由于大部分对象在创建后会很快变得不可达,所以很多对象被创建在新生代,然后消失。对象从这个区域消失的过程我们称之为 minor GC
      新生代 中存在一个Eden区和两个Survivor区。新对象会首先分配在Eden中(如果新对象过大,会直接分配在老年代中)。在YGC(minor GC)中,Eden中的对象会被移动到Survivor中,正常情况下,会持续在S1和S2区域之间进行移动,直至对象满足一定的年纪(定义为熬过GC的次数),会被移动到老年代。
      可以设置新生代和老年代的相对大小。这种方式的优点是新生代大小会随着整个堆大小动态扩展。参数 -XX:NewRatio 设置老年代与新生代的比例。例如 -XX:NewRatio=8 指定 老年代/新生代 为8/1. 老年代 占堆大小的 7/8 ,新生代 占堆大小的 1/8(默认即是 1/8)。例如:
	-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8
  • 2、老年代(Old generation)
      对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这里。其所占用的空间要比新生代多。也正由于其相对较大的空间,发生在老年代上的GC要比新生代要少得多。对象从老年代中消失的过程,可以称之为major GC(或者full GC)。
  • 3、永久代(permanent generation)
      像一些类的层级信息,方法数据 和方法信息(如字节码,栈 和 变量大小),运行时常量池(JDK7之后移出永久代),已确定的符号引用和虚方法表等等。它们几乎都是静态的并且很少被卸载和回收,在JDK8之前的HotSpot虚拟机中,类的这些永久的 数据存放在一个叫做永久代的区域。
      永久代是一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize的值来控制永久代的大小。但是JDK8之后取消了永久代,这些元数据被移到了一个与堆不相连的称为元空间 (Metaspace) 的本地内存区域

  当执行一次Minor GC时,Eden空间的存活对象会被复制到To Survivor空间,并且之前经过一次Minor GC并在From Survivor空间存活的仍年轻的对象也会复制到To Survivor空间
  有两种情况Eden空间和From Survivor空间存活的对象不会复制到To Survivor空间,而是晋升到老年代:

  1. 一种是存活的对象的分代年龄超过-XX:MaxTenuringThreshold(用于控制对象经历多少次Minor GC才晋升到老年代)所指定的阈值。该参数表示的是对象在S1和S2经过多少次调整后进入老年代。在不同的GC有不同的默认值:
  2. 动态年龄。

-XX:TargetSurvivorRatio 目标存活率,默认为50%

  目标存活率的使用过程:

  1. 通过这个比率来计算一个期望值,desired_survivor_size 。
  2. 然后用一个total计数器,累加每个年龄段对象大小的总和。
  3. 当total大于desired_survivor_size 停止。
  4. 然后用当前age和MaxTenuringThreshold 对比找出最小值作为结果

  总体特征就是,年龄从小到大进行累加,当加入某个年龄段后,累加和超过survivor区域*TargetSurvivorRatio的时候,就从这个年龄段网上的年龄的对象进行晋升。例如:年龄1的占用了33%,年龄2的占用了33%,累加和超过默认的TargetSurvivorRatio(50%),年龄2和年龄3的对象都要晋升。

  当所有存活的对象被复制到To Survivor空间,或者晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收对象,示例:

  这时GC执行Minor GC,Eden空间和From Survivor空间都会被清空,而存活的对象都存放在To Survivor空间。
  接下来将From Survivor空间和To Survivor空间互换位置,也就是此前的From Survivor空间成为了现在的To Survivor空间,每次Survivor空间互换都要保证To Survivor空间是空的,这就是复制算法在新生代中的应用。在老年代则采用了标记-压缩算法。
  JDK8堆内存一般是划分为年轻代和老年代,不同年代 根据自身特性采用不同的垃圾收集算法。
  对于新生代,每次GC时都有大量的对象死亡,只有少量对象存活。考虑到复制成本低,适合采用复制算法。因此有了From Survivor和To Survivor区域。
  对于老年代,因为对象存活率高,没有额外的内存空间对它进行担保。因而适合采用标记-清理算法和标记-整理算法进行回收

  由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。垃圾回收有两种类型,Minor GC 和 Full GC:

  • 1、Minor GC
      新生代垃圾收集。对新生代进行回收,不会影响到年老代。因为新生代的 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使垃圾回收能尽快完成。
  • 2、Full GC
      也叫 Major GC,对整个堆进行回收,包括新生代和老年代(JDK8 取消永久代)。由于Full GC需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。它的收集频率较低,耗时较长。

3.2.5 对象的分配过程

  • 1、栈上分配
      当进行栈上分配时,需要具备以下条件:

      在进行JVM调优时,该模块一般不需要改变
      栈上分配指的是:将线程中的私有对象打散(即图中user),让它在栈上分配,而不是在堆上分配

      比如方法中的user引用,就是方法的局部变量,new的User()实例在堆上,我们需要的就是把这个实例打散:比如我们的实例user中有两个字段,就把这个实例认作它内部的两个字段以局部变量的形式分配在栈上也就是打散,这个操作称为:标量替换。
      使用栈上分配策略除了需要开启标量替换,还需要开启逃逸分析。什么是逃逸分析?其实就是判断我们将这个user对象会不会return出去,出去了的话,这时候我们对于这个对象来说就不会受用栈上分配,因为后续的代码可能还需要使用这个对象实例,可以说只要是多个线程共享的对象就是逃逸对象。
      栈上分配有什么好处?不需要GC介入去回收这个对象,出栈即释放资源,可以提高性能,原理:由于我们GC每次回收对象的时候,都会触发Stop The World(STW),这时候所有线程都停止了,然后我们的GC去进行垃圾回收,如果对象频繁创建在我们的堆中,也就意味这我们也要频繁的暂停所有线程,这对于用户无非是非常影响体验的,栈上分配就是为了减少垃圾回收的次数。

  • 2、TLAB线程本地分配
      TLAB线程本地分配缓存区是什么?工作原理分析,TLAB全称Thread Local Allocation Buffer,即线程本地分配缓存区,是一个线程专用的内存分配区域。在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用。
      TLAB是虚拟机在堆内存的eden划分出来的一块专用空间线程专属。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如需要分配内存,在自己的空间上分配,在不存在竞争的情况大大提升分配效率。
      该种分配的特点是:

      在进行JVM调优时,该模块一般不需要改变

  • 3、完整的对象分配过程

  • 4、分配担保机制
      在JVM的内存分配时,也有这样的内存分配担保机制。就是当在新生代无法分配内存的时候,把新生代的对象转移到老年代,然后把新对象放入腾空的新生代

3.3 垃圾收集器

  JDK1.8默认用的是Parallel Scavenge(新生代)+ Parallel Old(老年代)

  如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

Epsilon调试使用,不用关注;
后两个GC回收器(ZGC、Shenandoah)不再进行分代;
前6个逻辑和物理都分代;
G1逻辑分代,物理不分代。

  • Serial收集器复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
  • ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
  • Parallel Scavenge收集器(复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
  • Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
  • Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
  • G1(Garbage First)收集器(标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代

  从上面可以看出,三种新生代垃圾回收器(Serial、ParNew、Parallel Scavenge),都用的是复制算法。
  三种老年代垃圾回收器和G1整堆回收器,除了CMS用的是标记-清除算法外,别的用的都是标记整理算法。

3.3.1 七种垃圾回收器概述

  在 JVM 中,具体实现有 Serial、ParNew、Parallel Scavenge、CMS、Serial Old(MSC)、Parallel Old、G1 等。在下图中,你可以看到 不同垃圾回收器 适合于 不同的内存区域,如果两个垃圾回收器之间 存在连线,那么表示两者可以 配合使用。
  如果当垃圾回收器 进行垃圾清理时,必须 暂停 其他所有的 工作线程,直到它完全收集结束。我们称这种需要暂停工作线程才能进行清理的策略为 Stop-the-World(STW)。以上回收器中, Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old 均采用的是 STW 的策略

  图中的前7种垃圾回收器,它们分别用于不同分代的垃圾回收:

  1. 新生代回收器:Serial、ParNew、Parallel Scavenge
  2. 老年代回收器:Serial Old、Parallel Old、CMS
  3. 整堆回收器:G1

  两个 垃圾回收器 之间有连线表示它们可以 搭配使用,可选的搭配方案如下:

  目前常用的组合为:Serial和SerialOld、ParNew和CMS、Parallel Scavenge和Parallel Old
  简单总结几种GC回收器的特点:

  • 1、Serial回收器
      复制算法,单线程,在GC时,STW(停止一切用户线程),新生代。
  • 2、Serial Old回收器
      标记-整理算法,单线程,老年代。
  • 3、ParNew回收器
      复制算法,Serial 回收器的 多线程版本,根据 CPU 核数,开启 不同的线程数(默认和CPU核数一致),新生代。
  • 4、Parallel Scavenge回收器
      复制算法,吞吐量(即一段时间内,用户代码 运行时间占 总运行时间 的百分比)优先,新生代。
  • 5、Parallel Old回收器
      标记-整理算法,吞吐量优先,老年代。
  • 6、CMS回收器
      标记-清除,最短回收停顿时间为目标。
  • 7、G1回收器
      在大多数情况下可以实现指定的GC暂停时间,同时还能保证较高的吞吐量。

3.3.2 单线程垃圾回收器

  • 1、Serial(-XX:+UseSerialGC)
      Serial 回收器是最基本的 新生代 垃圾回收器,是 单线程 的垃圾回收器。由于垃圾清理时,Serial 回收器 不存在 线程间的切换,因此,特别是在单 CPU 的环境下,它的 垃圾清除效率比较高。对于 Client 运行模式的程序,选择 Serial 回收器是一个不错的选择。
      Serial是一类用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条单线程执行垃圾收集工作,它还在收集的同时,所用的用户必须暂停。

  只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程(Stop The World)。一般客户端应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。因此 Serial 垃圾收集器适合客户端使用。由于 Serial 收集器只使用一条 GC 线程,避免了线程切换的开销,从而简单高效。

  Serial回收器的优势:简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。
  Serial回收器的缺点:会在用户不知道的情况下停止所有工作线程,用户体验感极差,令人难以接受。
  Serial回收器的适用场景:Client 模式(桌面应用);单核服务器

  • 2、Serial Old(-XX:+UseSerialGC)
      Serial Old 回收器是 Serial 回收器的 老生代版本,属于 单线程回收器,它使用标记-整理算法。对于 Server 模式下的虚拟机,在 JDK1.5 及其以前,它常与 Parallel Scavenge 回收器配合使用,达到较好的 吞吐量,另外它也是 CMS 回收器在 Concurrent Mode Failure 时的 后备方案
      此处提到了Concurrent Mode Failure,这是CMS垃圾收集器特有的错误。该问题是在执行CMS GC的过程中同时业务线程将对象放入老年代,而此时老年代空间不足,或者在做Minor GC的时候,新生代Survivor空间放不下,需要放入老年代,而老年代也放不下而产生的。
      Serial 回收器和 Serial Old 回收器的执行示例:

      适用场景:Client模式;单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用

3.3.3 多线程垃圾回收器(吞吐量优先)

  • 1、ParNew(-XX:+UseParNewGC)
      ParNew 回收器是在 Serial 回收器的基础上演化而来的,采用的是复制算法,属于 Serial 回收器的 多线程版本,同样运行在 新生代区域。在实现上,两者共用很多代码。在不同运行环境下,根据 CPU 核数,开启 不同的线程数,从而达到 最优 的垃圾回收效果。对于那些 Server 模式的应用程序,如果考虑采用 CMS 作为 老生代回收器 时,ParNew 回收器是一个不错的选择。

      ParNew是Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升。

ParNew一般和CMS配合使用,是Parallel Scavenge的演进版本。
ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。

  • 2、Parallel Scavenge(-XX:+UseParallelGC)
      和 ParNew 回收一样,Parallel Scavenge 回收器也是运行在 新生代区域,属于 多线程 的回收器。

ParNew 回收器是通过控制 垃圾回收 的 线程数 来进行参数调整,而 Parallel Scavenge 回收器更关心的是程序运行的吞吐量。即一段时间内,用户代码 运行时间占 总运行时间 的百分比。

  Parallel Scavenge也是一款用于新生代的多线程收集器,也是采用复制算法
  Parallel Scavenge 和 ParNew 一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。ParNew:追求降低用户停顿时间,适合交互式应用。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

  Parallel Scavenge追求高吞吐量,可以通过减少 GC 执行实际工作的时间,然而,仅仅偶尔运行 GC 意味着每当 GC 运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。单个 GC 需要花更多的时间来完成,从而导致更高的暂停时间。而考虑到低暂停时间,最好频繁运行 GC 以便更快速完成,反过来又导致吞吐量下降。

  通过参数 -XX:GCTimeRadio 设置垃圾回收时间占总 CPU 时间的百分比。
  通过参数 -XX:MaxGCPauseMillis 设置垃圾处理过程最久停顿时间。
  通过命令 -XX:+UseAdaptiveSizePolicy 开启自适应策略。我们只要设置好堆的大小和 MaxGCPauseMillis 或 GCTimeRadio,收集器会自动调整新生代的大小、Eden 和 Survivor 的比例、对象进入老年代的年龄,以最大程度上接近我们设置的 MaxGCPauseMillis 或 GCTimeRadio。

  • 3、Parallel Old(-XX:+UseParallelOldGC)
      Parallel Old 回收器是 Parallel Scavenge 回收器的 老生代版本,属于多线程回收器,采用标记-整理算法。Parallel Old 回收器和 Parallel Scavenge 回收器同样考虑了 吞吐量优先 这一指标,非常适合那些 注重吞吐量 和 CPU 资源敏感 的场合。

3.3.4 CMS(-XX:+UseConcMarkSweepGC)

  Serial回收器诞生后,为了提高效率,诞生了PS;为了配合CMS,诞生了PN。CMS是1.4版本后期引入,CMS是里程碑式的GC,它开启了并发回收(并发垃圾回收是因为无法忍受STW)的过程,但是CMS毛病较多,因此目前没有任何一个JDK版本默认是CMS。

  CMS是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。
  CMS(Concurrent Mark Sweep)是老年代GC回收器。是在最短回收停顿时间为前提的回收器,属于 多线程回收器,采用标记-清除算法。


  相比之前的回收器,CMS 回收器的运作过程比较复杂,分为四步:

  1. 初始标记(CMS initial mark)
      初始标记 仅仅是标记 GC Roots直接关联 的对象。这个阶段 速度很快,需要 Stop the World。
  2. 并发标记(CMS concurrent mark)
      并发标记 进行的是 GC Tracing,从 GC Roots 开始对堆进行 可达性分析,找出 存活对象。

此回收器的大部分GC时间都浪费在这个阶段,但不产生STW,因为是并发的。

  1. 重新标记(CMS remark)
      重新标记 阶段为了 修正 并发期间由于 用户进行运作 导致的 标记变动 的那一部分对象的 标记记录。这个阶段的 停顿时间 一般会比 初始标记阶段 稍长一些,但远比 并发标记 的时间短,也需要 Stop The World。简单来说,就是标记上个阶段进行时,新产生的垃圾。
  2. 并发清除(CMS concurrent sweep)
      并发清除 阶段会清除垃圾对象。但因为是并发的,所以这个阶段仍然会产生新的垃圾,称为“浮动垃圾”。

  初始标记(CMS initial mark)和 重新标记(CMS remark)会导致 用户线程 卡顿,STW现象发生

  在整个过程中,CMS 回收器的 内存回收 基本上和 用户线程 并发执行。由于 CMS 回收器 并发收集、停顿低,因此有些地方称为 并发低停顿回收器

  CMS 回收器的缺点:

  1. CMS回收器对CPU资源非常依赖(相对后两个缺点不重要)
      CMS 回收器过分依赖于 多线程环境,默认情况下,开启的 线程数 为(CPU 的数量 + 3)/ 4,当 CPU 数量少于 4 个时,CMS 对 用户查询 的影响将会很大,因为他们要分出一半的运算能力去 执行回收器线程;
  2. CMS回收器无法清除浮动垃圾
      由于 CMS 回收器 清除已标记的垃圾 (处于最后一个阶段)时,用户线程 还在运行,因此会有新的垃圾产生。但是这部分垃圾 未被标记,在下一次 GC 才能清除,因此被成为 浮动垃圾。
      由于 内存回收 和 用户线程 是同时进行的,内存在被 回收 的同时,也在被 分配。当 老生代 中的内存使用超过一定的比例时,系统将会进行 垃圾回收;当 剩余内存 不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时采用 Serial Old 算法进行 清除,此时的 性能 将会降低

concurrent mode failure是什么?
  CMS垃圾收集器特有的错误,CMS的垃圾清理和引用线程是并行进行的,如果在并行清理的过程中老年代的空间不足以容纳应用产生的对象(也就是老年代正在清理,从年轻代晋升了新的对象,或者直接分配大对象年轻代放不下导致直接在老年代生成,这时候老年代也放不下),则会抛出“concurrent mode failure”。

  一些解决浮动垃圾的方法:

降低触发CMS的阈值,即调节CMSInitiatingOccupancyFraction参数,该值在JDK1.8中默认是92%,可以尝试降低为68%。该参数的意义是:指定当老年代空间使用的阈值达到多少才进行一次CMS垃圾回收。

  1. 垃圾收集结束后残余大量空间碎片
      CMS 回收器采用的 标记清除算法,本身存在垃圾收集结束后残余 大量空间碎片 的缺点。CMS 配合适当的 内存整理策略,在一定程度上可以解决这个问题。

一旦空间碎片过多,无法再存储新对象,此时,就要采取一些措施了,比如使用Serial Old。

  对于产生碎片空间的问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。设置参数 -XX:CMSFullGCsBeforeCompaction告诉 CMS,经过了 N 次 Full GC 之后再进行一次内存整理。

3.3.5 G1回收器(垃圾区域Region优先)

  G1是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同时还能保证较高的吞吐量
  G1 是 JDK 1.7 中正式投入使用的用于取代 CMS 的 压缩回收器。它虽然没有在物理上隔断 新生代 与 老生代,但是仍然属于 分代垃圾回收器。G1 仍然会区分 年轻代 与 老年代,年轻代依然分有 Eden 区与 Survivor 区。

  G1中有个CSet(Collection Set)的概念,是一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间或老年代。CSet会占用不到整个堆空间的1%大小。
  G1中还有个RSet(Remembered Set)分区,如图:

  每个蓝色区域代表一个Region,黄色区域代表RSet,该区域记录了其他Region中对象到本Region的引用。RSet的价值在于:使得垃圾收集器不需要扫描整个堆栈,就能找到谁引用了当前分区的对象,只需要扫描RSet即可。
  G1 首先将 堆 分为 大小相等 的 Region,避免 全区域 的垃圾回收。然后追踪每个 Region 垃圾 堆积的价值大小,在后台维护一个 优先列表(CSet),根据允许的回收时间优先回收价值最大的 Region。同时G1采用 Remembered Set 来存放 Region 之间的 对象引用 ,其他回收器中的 新生代 与 老年代 之间的对象引用,从而避免 全堆扫描。G1 的分区示例如图所示:

Humongous是大对象区域。其标准有两个:超过单个Region的50%;跨多个Region。

  从上面的图中也能看出,G1的内存区域不是固定的E或O。
  G1新生年区域比例是动态的:5%-60%,一般不用也不必手工指定。因为这是G1预测停顿时间的基准。
  虽然G1的新老年代是动态的,但是也有YGC和FGC,其触发条件和普通的垃圾回收器也一样,即:Eden空间不足和Old空间不足。G1的FGC在JDK10之前是串行的,在JDK10之后是并行的

  G1的MixedGC(MixedGC是不分代的)相当于CMS。用InitiatingHeapOccupancyPercent 参数表示,默认是45%,当Old区比例超过该值时,启动MixedGC。MixedGC的过程和CMS相似,也是有四个阶段:

  和CMS有差异的是第四个阶段,具有个筛选的过程。筛选那些最需要回收的、垃圾占的最多的Region,然后把Region中的存货对象复制到别的Region,复制的过程中还有压缩,这样碎片就会减少。

  这种使用 Region 划分 内存空间 以及有 优先级 的区域回收方式,保证 G1 回收器在有限的时间内可以获得尽可能 高的回收效率。
  G1 和 CMS 运作过程有很多相似之处,整个过程也分为 4 个步骤:

  • 1、 初始标记(CMS initial mark)
      初始标记 仅仅是标记 GC Roots 内 直接关联 的对象。这个阶段 速度很快,需要 Stop the World。
  • 2、 并发标记(CMS concurrent mark)
      并发标记 进行的是 GC Tracing,从 GC Roots 开始对堆进行 可达性分析,找出 存活对象。
  • 3、重新标记(CMS remark)
      重新标记 阶段为了 修正 并发期间由于 用户进行运作 导致的 标记变动 的那一部分对象的 标记记录。这个阶段的 停顿时间 一般会比 初始标记阶段 稍长一些,但远比 并发标记 的时间短,也需要 Stop The World。
  • 4、筛选回收
      首先对各个 Region 的 回收价值 和 成本 进行排序,根据用户所期望的 GC 停顿时间 来制定回收计划。这个阶段可以与用户程序一起 并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿 用户线程 将大幅提高回收效率。

  与其它 GC 回收相比,G1 具备如下 4 个特点:

  • 1、并行与并发
      使用多个 CPU 来缩短 Stop-the-World 的 停顿时间,部分其他回收器需要停顿 Java 线程执行的 GC 动作,G1 回收器仍然可以通过 并发的方式 让 Java 程序继续执行。
  • 2、分代回收
      与其他回收器一样,分代概念 在 G1 中依然得以保留。虽然 G1 可以不需要 其他回收器配合 就能独立管理 整个GC堆,但它能够采用 不同的策略 去处理 新创建的对象 和 已经存活 一段时间、熬过多次 GC 的旧对象,以获取更好的回收效果。新生代 和 老年代 不再是 物理隔离,是多个 大小相等 的独立 Region。
  • 3、空间整合
      与 CMS 的 标记—清理 算法不同,G1 从 整体 来看是基于 标记—整理 算法实现的回收器。从 局部(两个 Region 之间)上来看是基于 复制算法 实现的。
      但无论如何,这 两种算法 都意味着 G1 运作期间 不会产生内存空间碎片,回收后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象 时不会因为无法找到 连续内存空间 而提前触发 下一次 GC。
  • 4、可预测的停顿
      这是 G1 相对于 CMS 的另一大优势,降低停顿时间 是 G1 和 CMS 共同的关注点G1 除了追求 低停顿 外,还能建立 可预测 的 停顿时间模型,能让使用者明确指定在一个 长度 为 M 毫秒的 时间片段 内,消耗在 垃圾回收 上的时间不得超过 N 毫秒。(后台维护的 优先列表,优先回收 价值大 的 Region)。

  或者这样总结:

  1. 并发收集
      并发标记,并发回收。
  2. 压缩空闲时间,不会延长GC的暂停时间
  3. 更容易预测GC的暂停时间
  4. 适用于不需要特别高的吐吞量的场景

  G1和CMS的调优目标之一就是尽量别有FGC。

3.3.6 并发标记算法

  G1和CMS的并发标记算法用的都是三色标记算法。该算法的难点在于:在标记的过程中,对象的引用关系在发生改变。该将对象分成三种类型:黑色、灰色和白色。

  • 1、黑色
      根对象,自身和成员变量均已被标记(成员变量表示引用的对象)。
  • 2、灰色
      自身被标记,成员变量未被标记。
  • 3、白色
      自身未被标记。
      三色标记会产生两种问题:错标和漏标
  • 1、错标

      在该情况中,D不是垃圾,但也会被清除掉。
      CMS的解决方案是Incremental Update,对应到上面的例子中,就是把A重新标记成灰色。
  • 2、漏标
      漏标指的是:本来是存活的对象,但是由于没被遍历到,做垃圾给回收了。看下面的例子:当进行并发标记的时候此时C是一个垃圾对象,是要被回收的,如果此时对象B指向对象C的引用没有了,但是A指向了C,那么此时这个对象C就会找不到了,原因是因为通过B已经找不到C了,但是此时A指向了C,C是有新引用的不能被回收(A是黑色的不会再被扫描,在重新标记阶段就不会找到C)


  只要能跟踪到A指向了C,或者跟踪到B指向C消失,就能解决漏标问题:

  1. incremental update--增量更新,关注引用的增加,当A指向C的时候就把这个A变成一个灰色对象,这样在重新扫描的时候就可以扫描的到
  2. SATB snapshot at the beginning --关注引用的消失,当时B指向C的引用消失了,就把这个引用推到GC的堆栈(都是灰色对象指向白色对象的引用),保证C还可以被GC扫描到(因为存在这个引用的一个记录在GC的堆栈中,所以扫描的时候还是可以找到C这个对象)

  CMS中使用,漏标采用增量更新;G1中使用,漏标采用SATB。
  G1中采用SATB的原因:

  1. 因为增量更新会在重新标记的时候将从黑色变成灰色的对象在扫描一遍,会更费时。
  2. 使用SATB就会大大减少扫描对象,原因是只多扫描在GC堆栈中发生改变的引用(和G1的一个RSet进行配合,效率高)

3.3.7 ZGC

  Zgc是Jdk11中要发布的最新垃圾收集器。完全没有分代的概念,先说下它的优点吧,官方给出的是无碎片,时间可控,超大堆。 Z Garbage Collector,即ZGC,是一个可伸缩的、低延迟的垃圾收集器,主要为了满足如下目标进行设计:

  • 停顿时间不会超过10ms;
  • 停顿时间不会随着堆的增大而增大(不管多大的堆都能保持在10ms以下);
  • 可支持几百M,甚至几T的堆大小(最大支持4T)。

  ZGC为什么可以这么优秀,主要是因为以下几个特性:

  • 1、Concurrent
      ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。
  • 2、Region-based
      ZGC中没有新生代和老年代的概念,只有一块一块的内存区域page,以page单位进行对象的分配和回收。
  • 3、Compacting
      每次进行GC时,都会对page进行压缩操作,所以完全避免了CMS算法中的碎片化问题。
  • 4、NUMA-aware
      现在多CPU插槽的服务器都是Numa架构,比如两颗CPU插槽(24核),64G内存的服务器,那其中一颗CPU上的12个核,访问从属于它的32G本地内存,要比访问另外32G远端内存要快得多。
    ZGC默认支持NUMA架构,在创建对象时,根据当前线程在哪个CPU执行,优先在靠近这个CPU的内存进行分配,这样可以显著的提高性能,在SPEC JBB 2005 基准测试里获得40%的提升。
  • 5、Using colored pointers
      和以往的标记算法比较不同,CMS和G1会在对象的对象头进行标记,而ZGC是标记对象的指针。

      其中低42位对象的地址,42-45位用来做指标标记。
  • 6、Using load barriers
      因为在标记和移动过程中,GC线程和应用线程是并发执行的,所以存在这种情况:对象A内部的引用所指的对象B在标记或者移动状态,为了保证应用线程拿到的B对象是对的,那么在读取B的指针时会经过一个 “load barriers” 读屏障,这个屏障可以保证在执行GC时,数据读取的正确性。

3.3.8 Java11 新的Epsilon垃圾收集器

  Epsilon(A No-Op Garbage Collector)垃圾回收器控制内存分配,但是不执行任何垃圾回收工作。一旦java的堆被耗尽,jvm就直接关闭。设计的目的是提供一个完全消极的GC实现,分配有限的内存分配,最大限度降低消费内存占用量和内存吞吐时的延迟时间。一个好的实现是隔离代码变化,不影响其他GC,最小限度的改变其他的JVM代码。
  各个收集器的组合流程图:

  常用的垃圾回收器组合有三种:Serial和Serial Old、CMS和ParNew、Parallel Scavenge和Parallel Old。
  Serial使用较少,因为随着内存越来越大,执行效率太慢。

3.3.9 阿里的多租户JVM

  每租户单空间:在一个服务器上用一个JVM管理不同用户使用的多个空间。
  session based GC:基于session的GC,针对Web 应用。回收session产生的垃圾。

3.4 内存分配与回收策略

  所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我们介绍了内存回收,这里我们再来聊聊内存分配。
  对象的内存分配通常是在 Java 堆上分配,对象主要分配在新生代的 Eden 区,如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种"通用"规则:

  • 1、对象优先在 Eden 区分配
      多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。
      Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所有 Minor GC 非常频繁,一般回收速度也非常快;
      Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。
  • 2、大对象直接进入老年代
      所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。
      前面我们介绍过新生代使用的是标记-清除算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。
  • 3、长期存活对象将进入老年代
       虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。使用 -XXMaxTenuringThreshold 设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。
  • 4、动态对象年龄判定
      为了更好的适应不同程序的内存情况,虚拟机并不是永远要求对象的年龄必需达到某个固定的值(比如前面说的 15)才会被晋升到老年代,而是会去动态的判断对象年龄。如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。如果当前新生代的 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  • 5、空间分配担保
      在新生代触发 Minor GC 后,如果 Survivor 中任然有大量的对象存活就需要老年堆来进行分配担保,让 Survivor 区中无法容纳的对象直接进入到老年代。

3.5 对象什么时候进入老年代

  都跟年龄有关。

  • 1、age超过-XX:MaxTenuringThreshold指定次数(TGC)
      对象头,markword里面,GC age标识位占用4位,所以对象的年龄最大为15:

Parallel Scavenge 15
CMS 6
G1 15

  • 2、动态年龄(不重要)
      假设有次的YGC是Eden&S1->S2,如果S2中的存活对象超过了S2空间的一半,就把S2中年龄最大的对象放入老年代。
  • 3、分配担保(不重要)
      YGC期间 survivor区空间不够了 空间担保直接进入老年代。

3.6 简述分代垃圾回收器是怎么工作的

  分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
  新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程:

  • 1、把 Eden + From Survivor 存活的对象放入 To Survivor 区。
  • 2、清空 Eden 和 From Survivor 分区。
  • 3、From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
  • 4、每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
  • 5、老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

猜你喜欢

转载自blog.csdn.net/m0_37741420/article/details/121481642