跳到主要内容

Jvm全解析:理论基础 简版

·26893 字·54 分钟

这篇文章是对JVM(Java虚拟机)相关知识的全面解析,包括运行时数据区结构、双亲委派机制、判断class对象相同的条件、程序计数器、栈与堆的特点、堆的相关内容(如TLAB、对象分配过程、垃圾回收区域频次等)、方法区、对象实例化及直接内存、执行引擎、StringTable、垃圾回收的概述及算法、相关概念、垃圾回收器、GC日志分析等。涵盖了JVM的众多核心知识点。

1. 运行时数据区的结构 #

共享:

1. 堆
2. 方法区

独有:

1. 程序计数器
2. 虚拟机栈
3. 本地方法栈

2. 双亲委派机制 #

向上委托,向下加载

优点:

  1. 避免类的重复加载
  2. 保护安全,防止核心API被篡改

Bootstrap ClassLoader 加载java核心类:以java、javax、sun开头的类

Extension ClassLoader 加载扩展类:jre/lib/ext 下的包

App ClassLoader java应用的类

3. JVM中判断class对象是否相同的条件 #

  1. 完整类名必须一致。包括包名
  2. 加载这个类的ClassLoader必须相同

4. 程序计数器(PC寄存器)是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域 #

程序计数器记录当前线程的执行地址

5. 栈是运行时的单位,堆是存储时的单位 #

栈负责数据的处理;而堆负责数据的存储

5.1 虚拟机是由栈帧组成,栈帧对应Java方法调用 #

5.2 虚拟机栈帧保存方法的局部变量、部分结果,并参与方法的调用和返回 #

5.3 入栈、出栈 #

5.4 栈不存在垃圾回收,但存在溢出的情况 #

5.5 栈中可能出现的异常(栈容量可以固定,也可以动态变化): #

  1. StackOverflowError:栈大小固定。线程请求分配的栈容量超过Java栈允许的最大容量
  2. OutOfMemoryError:栈动态扩展。动态扩展时,没有足够的内存分配。

5.6 设置栈内存大小:-Xss #

5.7 栈帧包含内容 #

  1. 局部变量表:存储单元slot(槽)
  2. 操作数栈

5.8 类变量有两次初始化机会 #

  1. “准备阶段”(读取class文件),执行系统初始化,对变量设置零值。
  2. “初始化”阶段,赋予代码中的初始值。

局部变量表不存在系统初始化过程,因此需要人为显示赋值。

5.9 局部变量表中的变量是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。 #

5.10 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。 #

5.11 栈顶缓存技术 #

栈在内存中,频繁入栈出栈其实就是读写内存,影响执行速度,所以引入栈顶缓存技术。

栈顶缓存:将栈顶元素全部缓存到物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

5.12 重写与重载 #

重载实现的是编译时的多态性,是静态的;

重写实现的是运行时的多态性,是动态的。

重载发生在一个类中,同名方法的参数列表要不同;

重写发生在子类与父类之间,参数列表要相同。

6. 堆 #

6.1 TLAB #

所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

TLAB在Eden区中,JVM为每个线程分配了一个私有缓存的空间。

优点:

  1. 避免多线程分配内存时产生的线程不安全问题
  2. 提升内存分配的吞吐量

将这种内存分配方式称为快速分配策略。

默认占Eden区的1%

6.1.1 JVM将TLAB作为内存分配的首选,一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。 #

6.2 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置。 #

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

6.3 堆是GC执行垃圾回收的重点区域。 #

6.4 不同Java版本堆的划分 #

6.5 设置堆空间大小 #

默认
-Xms堆区起始内存物理内存/64
-Xmx堆区最大内存物理内存/4

堆中内存超过-Xmx,会报OutOfMemoryError

通常将-Xms和-Xmx设置相同值,为了能够在JAVA垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

6.6 堆区老年代年轻代比例 #

年轻代:老年代 = 1:2

-XX:NewRatio = 2

-Xmn 年轻代内存大小

6.7 对象分配过程 #

new的对象放入Eden区,Eden区满了发生Minor GC(只有Eden满了触发,S0/S1区满了不触发),未被回收的对象移动到S0区,再次触发GC,未被回收的对象转移至S1区,转移15次之后进入到Old区(不一定),Old区内存不足时,发生Major GC,进行Major GC后,内存仍不足,则抛出OOM。

6.7.1 为什么要从Eden转移至S0,S0转移至S1区? #

作为一个缓冲,尽可能让生命周期短的对象不进入到老年区,筛选出更长久的对象放入到老年区。

6.7.2 3种进入到Old区的情况 #

  1. Survivor区转移15次之后,进入到老年区;
  2. 通过动态年龄判断,如果Survivor区中相同年龄的所有对象大小的总和大于Survivor区空间的一半,则年龄大于等于该年龄的对象可以直接进入Old区;
  3. 大对象直接进入Old区(大对象连续所需内存大于Eden区内存)。

6.8 关于垃圾回收区域频次 #

频繁在新生代收集,很少在老年代收集。几乎不再永久代和元空间收集。

6.9 Old GC、Mixed GC、Full GC #

Old GC:只有CMS GC有单独的Old GC的行为。

Mixed GC:收集整个新生代以及部分老年代的垃圾收集(只有G1 GC有这种行为)。

Full GC:收集整个java堆和方法区的垃圾收集。

6.10 Minor GC(Young GC)会触发STW(Stop The World) #

暂停其它用户线程,等垃圾回收结束,用户线程才恢复运行。

6.11 Major GC速度比Minor GC慢10倍以上,STW时间更长。 #

6.12 Full GC触发条件 #

  1. 调用System.gc(),建议执行Full GC,但不一定必然执行;
  2. 老年代空间不足;
  3. 年轻代空间不足。

6.13 堆空间分代思想:为了优化GC性能。 #

6.14 JDK6后,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC #

6.15 逃逸分析 #

6.15.1 如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术。 #

6.15.2 逃逸分析的基本行为就是分析对象动态作用域 #

  1. 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  2. 当一个对象在方法被定义后,它被外部方法所引用,则认为发生了逃逸。例如作为调用参数传递到其他地方中。

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除了。

6.15.3 开发中能使用局部变量的,就不要使用在方法外定义。 #

6.15.4 逃逸分析:代码优化 #

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配
  2. 同步省略(锁消除)
    1. 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
  3. 分离对象或标量替换
    1. 标量:不可再分解,Java原始数据类型。
    2. 聚合量:可分解,Java中的对象。
    3. 标量替换:在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替,这个过程就是标量替换。
    4. 优点:大大减少堆内存的占用,因为一旦不需要创建对象,那么就不再需要分配堆内存了。
    5. 变量替换为栈上分配提供了很好的基础。

6.15.5 哪些行为会发生逃逸? #

  1. 给成员变量赋值;
  2. 方法返回值;
  3. 实例引用传递。

6.15.6 逃逸分析不成熟 #

无法保证逃逸分析的性能消耗一定高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配和锁消除。但逃逸分析自身也耗时,在极端情况下,经过逃逸分析后,所有对象都逃逸,白白浪费时间了。

7. 方法区 #

7.1 堆、栈、方法区关系 #

7.2 方法区概念等 #

  1. 方法区看作是一块独立于Java堆的内存空间
  2. 方法区与Java堆一样,是各个线程共享的内存区域
  3. 方法区的大小决定了系统可以保存多少个类。
    1. 如果类过多(加载大量第三方的jar包,Tomcat部署的工程过多(30-50个);或者大量动态的生成反射类),也会内存溢出:
      1. java.lang.OutOfMemoryError:PermGen spave
      2. java.lang.OutOfMemoryError:MetaSpace
  4. 关闭JVM会释放这个区域的内存。

7.3 不同JDK版本方法区的不同 #

JDK7及以前把方法区称为永久代;JDK8开始把方法区称为元空间。

仅对hotspot而言,方法区和永久代等价。

7.4 元空间与永久代最大的区别: #

元空间不在虚拟机设置的内存中,而是使用本地内存。

7.5 设置方法区内存的大小 #

方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

JDK7以前:

设置永久代初始化分配空间:-XX:Permsize 默认20.75m

设置永久代最大可分配空间:-XX:MaxPermsize 默认:32位:64m;64位:82m

超容量会报:OutOfMemoryError:PermGen space

JDK8及以后:

元空间大小:-XX:MetaspaceSize 默认21m

-XX:MaxMetaspaceSize 默认没有限制

与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机抛OutOfMemoryError:MetaSpace

-XX:MetaspaceSize 默认21m,达到21m,会触发Full GC,并提高水平线,如果初始化值过小会频繁Full GC,建议升高此值。

7.6 方法区存储哪些 #

  1. 类型信息(类全名;父类全名;修饰符;接口列表)
  2. 运行时常量池
  3. 静态变量
    1. 被声明为final的类变量(static final)的处理方法不同,每个全局常量在编译的时候就会被分配了。
  4. JIT代码缓存
  5. 域信息(字段,域名称、域类型、域修饰符)
  6. 方法信息

7.7 为什么需要常量池? #

一个java源文件中的类、接口,编译后产生一个字节码文件,而java中的字节码需要数据支持,通常这种数据会很大,以至于不能直接存到字节码中。可以存到常量池中,这个字节码包含了指向常量池的引用。

在动态链接时会用到运行时常量池。

常量池内存储的数据类型包括:

  1. 数量值
  2. 字符串值
  3. 类引用
  4. 字段引用
  5. 方法引用

常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

7.8 运行时常量池是方法区的一部分 #

运行时常量池中不再是常量池的符号地址,而是换成了真是地址。

运行时常量池相当于class文件常量池具有动态性(运行期解析后获取到的方法和字段引用)。

7.9 不同JDK版本字符串常量池、静态变量存储在哪里 #

JDK6永久代字符串常量池、静态变量存储在永久代中
JDK7永久代字符串常量池、静态变量存储在堆中
JDK8元空间字符串常量池、静态变量存储在堆中

7.10 为什么元空间替换了永久代? #

元空间:与堆不相连的本地内存区域。

因为永久代设置空间大小很难确定,在某些情况下,动态加载类过多,就容易造成Perm区的OOM,因此换成了元空间。

7.11 方法区的垃圾收集主要回收两部分内容 #

  1. 常量池中废弃的常量
  2. 不再使用的类型

7.12 为什么StringTable(字符串常量池)由永久代转移到堆中? #

为了提升StringTable的回收率。Full GC才会回收永久代。

7.13 常量池回收策略 #

只要常量池中的常量没有被任何地方引用,就可以被回收。

7.14 类型回收策略(不再使用的类): #

  1. 该类的所有实例均已被回收
  2. 加载该类的类加载器已被回收
  3. 该类对应的java.lang.Class对象没有再任何地方被引用(即无法在任何地方通过反射访问该类的方法)

7.15 Survivor存在的意义 #

减少被运到老年代的对象

8. 对象实例化及直接内存 #

8.1 创建对象的方式 #

  • new:最常见的方式、Xxx的静态方法,XxxBuilder/XxxFactory的静态方法
  • Class的newInstance方法:反射的方式,只能调用空参的构造器,权限必须是public
  • Constructor的newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()
  • 使用序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库 Objenesis

8.2 创建对象的步骤 #

首先判断类是否加载、链接、初始化:查找在Metaspace中是否有这个类,如果没有,则通过双亲委派机制进行加载;然后在堆中给对象分配内存,如果内存规整使用指针碰撞进行分配,如果内存不规整使用空闲列表进行分配;处理并发问题(采用CAS失败重试、区域加锁保证更新的原子性;每个线程预分配TLAB独有内存);初始化分配到的内存(所有属性设置默认值,保证对象实例字段在不赋值时可以进行使用);设置对象的对象头;执行init方法进行初始化。

8.3 对象内存布局 #

8.4 对象的访问定位 #

分为两种:句柄访问和直接指针,JVM用的是直接指针。

句柄访问

栈帧reference中存储稳定句柄地址,对象被移动(垃圾收集时对象移动很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被改变。

优点:垃圾回收时,reference引用指针不变,只需要改变对象实例数据的指针就可。

缺点:定位速度慢

直接指针

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

优点:定位速度快,节省一次指针定位的开销。 缺点:对象回收时,指针都需要重新定位,开销大。

8.5 直接内存 #

直接内存在java堆外,访问直接内存的速度会优于java堆,读写性能高。

出于性能考虑,读写频繁的场合可能会考虑使用直接内存。

Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。

8.5.1 非直接缓冲区 #

使用IO读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低。

8.5.2 直接缓存区 #

使用NIO时,操作系统划出的直接缓存区可以被java代码直接访问,只有一份。NIO适合对大文件的读写操作。

缺点:

  • 分配回收成本较高
  • 不受JVM内存回收管理

9. 执行引擎 #

9.1 什么是解释器(Interpreter)?什么是JIT编译器? #

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

9.2 Java是半编译半解释型语言 #

基于解释器执行已经沦落为低效的代名词。

即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

9.3 HotSpot VM中已经内置JIT编译器了,为什么还需要解释器? #

Java虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

9.4 机器在热机状态可以承受的负载要大于冷机状态。 #

如果以热机状态时的流量进行切流,可能使处于冷机状态发服务器因无法承受流量而假死。

9.4.1 热机冷机切流案例 #

在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。

9.5 前端编译器、后端运行期编译器、静态提前编译器 #

  • 前端编译器:把.java文件转变成.class文件的过程
  • 后期运行期编译器:JIT编译器,把字节码转变为机器码的过程
  • 静态提前编译器:AOT编译器,直接把.java文件编译成本地机器代码的过程。

9.6 热点代码 #

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”。

9.7 热点探测功能 #

一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。

目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。

采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

  • 方法调用计数器用于统计方法的调用次数
  • 回边计数器则用于统计循环体执行的循环次数

9.8 热点衰减 #

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给及时编译器编译,那这个方法的调用计数器就会被减少 一半,这个过程称为方法调用计数器的衰减,而这段时间就称为此方法统计的半衰周期。

可以关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。

9.9 HotSpot VM中JIT编译器分类:C1和C2 #

C1编译器 :Client Compiler

对字节码进行简单和可靠的优化,优化耗时短,编译速度快。

使用方法内联、去虚拟化、冗余消除

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现类进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

C2编译器 :Server Compiler

进行耗时较长的优化,优化激进,但优化的代码执行效率更高。

基于逃逸分析进行优化:

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指synchronized

二者可以分层编译。

一般来讲,JIT编译出来的机器码性能比解释器高。C2编译器启动时长比C1慢,系统稳定执行以后,C2编译器执行速度远快于C1编译器

10. StringTable #

10.1 String基本特征 #

String声明为final,不可被继承。

String在jdk8及以前内部定义了final char[] value用于存储字符串数据。Jdk9时改为byte[]。

10.2 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。 #

10.3 字符串常量池是不会存储相同内容的字符串的。 #

10.4 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。 #

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。

10.5 Java6及以前,字符串常量池放在永久代;Java7及以后,字符串常量池的位置调整到Java堆内。 #

10.6 字符串拼接操作 #

  • 常量与常量的拼接结果在常量池,原理是编译期优化
  • 常量池中不会存在相同内容的变量
  • 只要其中一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
  • 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
  • 不使用final修饰,即为变量。使用+连接会通过new StringBuilder进行拼接
  • 使用final修饰,即为常量。会在编译期进行代码优化。在实际开发中,能够使用final的,尽量使用

10.7 String、StringBuilder、StringBuffer #

String:拼接速度最慢

StringBuilder:拼接速度最快,线程不安全

StringBuffer:拼接速度适中,线程安全

10.8 intern()的使用 #

  • 当调用intern方法时,如果池子里已经包含了一个与这个String对象相等的字符串,正如equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个String对象被添加到池中,并返回这个String对象的引用。
  • 对于任何两个字符串s和t,当且仅当s.equals(t)为真时,s.intern() == t.intern()为真。
  • intern是一个native方法,调用的是底层C的方法

10.8.1 intern的使用:JDK6 vs JDK7/8 #

/**
* ① String s = new String("1")
* 创建了两个对象
*       堆空间中一个new对象
*       字符串常量池中一个字符串常量"1"(注意:此时字符串常量池中已有"1")
* ② s.intern()由于字符串常量池中已存在"1"
* 
* s  指向的是堆空间中的对象地址
* s2 指向的是堆空间中常量池中"1"的地址
* 所以不相等
*/
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s==s2); // jdk1.6 false jdk7/8 false

/*
* ① String s3 = new String("1") + new String("1")
* 等价于new String("11"),但是,常量池中并不生成字符串"11";
*
* ② s3.intern()
* 由于此时常量池中并无"11",所以把s3中记录的对象的地址存入常量池
* 所以s3 和 s4 指向的都是一个地址
*/
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3==s4); //jdk1.6 false jdk7/8 true

字面量赋值

首先会去常量池里找有没有这个字符串,有就直接指向常量池的该字符串,没有就先往常量池中添加一个,再指向它。

new创建

new一个字符串时,做了两件事。首先在堆中生成了该字符串对象,然后去看常量池中有没有该字符串,如果有就不管了,没有就往常量池中添加一个。

总结String的intern()的使用:

JDK1.6中,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址

JDK1.7起,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址

10.8.2 对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间。 #

11. 垃圾回收概述及算法 #

11.1 关于垃圾收集有三个经典问题 #

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

11.2 什么是垃圾? #

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

11.3 GC主要作用区域 #

GC主要关注于方法区和堆中的垃圾回收。

Java堆是垃圾收集器的工作重点。

从频次上讲:

  • 频繁收集Young区
  • 较少收集Old区
  • 基本不收集Perm区(元空间)

11.4 垃圾回收相关算法 #

对象判断存活

判断对象是否死亡的阶段称为垃圾标记阶段。一般有两种方式:

  • 引用计数算法
  • 可达性分析算法

11.4.1 标记阶段:引用计数算法 #

每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

优点:实现简单、垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 增加了存储空间的开销
  • 需要计算,增加了时间开销
  • 无法处理循环引用,因此java的垃圾回收器没有引用此算法

循环引用

当p的指针断开的时候,内部的引用形成一个循环,这就是循环引用

11.4.2 标记阶段:可达性分析算法 #

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

所谓"GCRoots”根集合就是一组必须活跃的引用。

基本思路 #

  • 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

在Java语言中,GC Roots包括以下几类元素:

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

小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。

11.4.3 对象的finalization机制 #

finalize()方法–对象被销毁之前的自定义处理逻辑。

finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

永远不要主动调用某个对象的finalize()方法I应该交给垃圾回收机制调用。理由包括下面三点:

  • 在finalize()时可能会导致对象复活。
  • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
  • 一个糟糕的finalize()会严重影响Gc的性能。

一个无法触及的对象有可能在某一个条件下“复活”自己

由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。

具体过程 #

判定一个对象objA是否可回收,至少要经历两次标记过程:

  1. 如果对象objA到GC Roots没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行finalize()方法
  3. 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
  4. 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
  5. finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。

11.4.4 MAT与JProfiler的GC Roots溯源 #

MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

JProfiler:我们在实际的开发中,一般不会查找全部的GC Roots,可能只是查找某个对象的整个链路,或者称为GC Roots溯源,这个时候,我们就可以使用JProfiler

11.4.5 清除阶段:标记-清除算法 #

目前JVM比较常见的三种垃圾收集算法是:

  1. 标记-清除算法(Mark-Sweep)
  2. 复制算法(Coping)
  3. 标记-压缩算法(Mark-Compact)

执行过程

当堆中的有效内存空间被耗尽的时候,就会停止整个程序(STW),然后进行两项工作,第一个是标记,第二个是清除:

  • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
  • 清除:Collector对堆从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

缺点:

  • 效率不高
  • GC时,需要STW,用户体验差
  • 清理出来的空闲内存不连续,产生内存碎片,需要维护一个空闲列表

11.4.6 清除阶段:复制算法 #

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 保证了空间连续性,不会出现内存碎片

缺点

  • 对内存需求大,需要两倍内存空间
  • 对于G1这种拆分成为大量region的GC,复制而不是移动,意味着GC需要维护region之前对象引用关系,不管是内存占用或者时间开销都不小

应用场景

比较适合用于新生代,因为新生代垃圾对象多,要复制的存活对象比较少,性价比高。

11.4.7 清除阶段:标记-压缩算法 #

执行过程

  1. 从根节点开始标记所有被引用对象
  2. 将所有存活的对象压缩到内存的一端(自上到下,从左到右),按顺序排放
  3. 清理边界外所有的空间

标记-清除算法和标记-压缩算法

  1. 标记-清除算法是非移动式的回收算法;而标记-压缩算法是移动式的。
  2. 标记清除算法由于存在内存碎片,需要维护一个空闲列表;标记压缩算法只需要持有内存起始地址即可,因此会比标记清除算法少许多开销。

指针碰撞

当为新对象分配内存时,通过修改指针偏移量将新对象分配到第一个空闲内存位置上的分配方式叫做指针碰撞。

优点

  • 内存连续,jvm只需要持有一个内存的起始地址即可
  • 消除了复制算法当中,内存减半的高额代价

缺点

  • 从效率上来说,标记-压缩算法低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要STW,影响用户体验

11.4.8 三种清除算法对比 #

Mark-SweepMark-CompactCopying
速率中等最慢最快
空间开销少(但会堆积碎片)少(不堆积碎片)通常需要活对象的2倍空间(不堆积碎片)
移动对象

11.4.9 分代收集算法 #

不同生命周期的对象可以采取不同的收集方式。

目前几乎所有的GC都采用分代收集算法执行垃圾回收。

年轻代(Young Gen)

年轻代的特点:区域相对老年代较小,对象生命周期短、存活率低、回收频繁。

年轻代使用复制算法。复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高、回收不及年轻代频繁。

老年代:一般由标记-清除或者标记-清除与标记-整理的混合实现。

  • Mark阶段的开销与存活对象的数量成正比。
  • Sweep阶段的开销与所管理区域的大小成正相关。
  • Compact阶段的开销与存活对象的数据成正比。

以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施;当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。

11.4.10 分区算法 #

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从何减少一次GC所产生的停顿。

分代算法是按照对象的声明周期长短划分成两个部分,分区算法是将整个堆空间划分成连续的不同小区间。

每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

12. 垃圾回收相关概念 #

12.1 System.gc() #

调用System.gc()不一定立即触发,有JVM决定。

12.2 内存溢出和内存泄露 #

12.2.1 内存溢出(OOM) #

OutOfMemoryError:没有空闲内存,并且垃圾收集器也无法提供更多内存。

没有空闲内存,说明Java虚拟机的堆内存不够,原因有二:

  1. Java虚拟机的堆内存设置不够。
  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

12.2.2 内存泄露 #

只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露。

内存泄露举例

  1. 单例模式

单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄露的产生。

  1. 一些提供close的资源未关闭导致内存泄露

数据库连接(dataSource.getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

12.3 STW #

STW–Stop The World,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

12.4 垃圾回收的并行与并发 #

12.4.1 程序的并行(Parallel)与并发(Concurrent) #

  • 并发,指的是多个事情,在同一时间段内同时发生了。
  • 并行,指的是多个事情,在同一时间点上同时发生了。
  • 并发的多个任务之间是相互抢占资源的。
  • 并行的多个任务之间是不互相抢占资源的。
  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行。
  • 否则,看似同时发生的事情,其实都是并发执行的。

12.4.2 引用:强软弱虚 #

强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?

  • 强引用(Strong Reference):是指在程序代码之中普遍存在的引用赋值,即类似Object obj = new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(Soft Reference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次会后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉弱引用关联的对象。
  • 虚引用(Phantom Reference):一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

强引用

强引用是造成Java内存泄露的主要原因之一。

软引用

内存不足即回收

软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用

发现即回收

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

面试题:你开发中使用过WeakHashMap吗?

WeakHashMap用来存储图片信息,可以在内存不足的时候,及时回收,避免了OOM

虚引用

对象回收跟踪

它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。

13. 垃圾回收器 #

13.1 GC性能指标 #

  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间=程序的运行时间+内存回收的时间)。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 内存占用:Java堆区所占的内存大小。

主要两个:吞吐量和暂停时间(低延迟)。

一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折中。

现在标准:在最大吞吐量优先的情况下,降低停顿时间

13.2 不同的垃圾回收器概述 #

13.2.1 Serial GC:串行回收 单线程 #

Serial GC采用复制算法、串行回收、STW机制执行内存回收。用于年轻代。

Serial Old GC采用标记-压缩算法、串行回收、STW机制执行内存回收。用于老年代。

现在Serial Old一般有两个用途:

  1. 与新生代的Parllel Scavenge配合使用
  2. 作为老年代CMS GC的后背垃圾收集方案(CMS是标记-清除算法,存在内存垃圾,到一定程度会触发Serial Old进行标记-压缩对内存碎片进行整理)

优势:

简单高效(单线程,省去了与其他线程交互的开销)

缺点:

如果频繁GC,由于单线程,GC时必须暂停其他的工作线程,STW时间长,影响用户体验。

注:对于交互性强的应用,不采用串行垃圾回收器。(Java Web不采用这种垃圾收集器)

13.2.2 ParNew GC:并行回收 多线程 #

ParNew GC采用复制算法、并行回收、STW的机制执行内存回收。用于年轻代。

ParNew GC一定比Serial GC收集高效吗?

  • 运行在多cpu环境下,是。
  • 在单个CPU环境下,ParNew收集器比一定比Serial收集器更高效。

ParNew+CMS

13.2.3 Parallel Scavenge GC:并行回收 吞吐量优先 #

Parallel Scavenge GC采用复制算法、并行回收、STW机制执行内存回收。用于年轻代。

Parallel Old GC采用标记-压缩算法、并行回收、STW机制执行内存回收。用于老年代。

Parallel Scavenge GC和ParNew GC区别:

  • Parallel Scavenge GC是以吞吐量为优先的垃圾收集器
  • Parallel Scavenge 可以自适应调节策略

高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,哪些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Java8默认垃圾收集器:Parallel Scavenge + Parallel Old

13.2.4 CMS GC:并发回收 低延迟 #

CMS GC采用标记-清除算法、并发回收、STW机制执行内存回收。用于老年代。

CMS(Concurrent-Mark-Sweep):第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工作。

CMS收集器尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合于用户交互的程序,良好的响应速度能提升用户体验。

  • 目前很大一分部的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

CMS无法与Parallel Scavenge配合(这个很好理解,CMS是低延迟的,Parallel Scavenge是高吞吐量的,二者互斥),只能与ParNew或者Serial配合使用。

CMS的GC过程主要分为四个阶段:初始标记阶段、并发标记阶段、重新标记阶段、并发清除阶段:

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
  • 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

CMS的优点:

  • 并发收集
  • 低延迟

CMS的缺点:

  • 会产生内存碎片
  • CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure“失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。

组合:ParNew + CMS + Serial Old

13.2.5 小结 #

  • 想要最小化地使用内存和并行开销,选Serial GC
  • 想要最大化应用程序的吞吐量,选Parallel GC
  • 想要最小化GC的中断或停顿时间,选CMS GC

13.2.6 G1(Garbage First) #

G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。

G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

13.2.6.1 G1回收器的特点(优势) #

与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:

并行与并发 #
  • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
分代收集 #
  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;
空间整合 #
  • CMS:“标记-清除”算法、内存碎片、若干次Gc后进行一次碎片整理
  • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
可预测的停顿时间模型(即:软实时soft real-time) #

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于CMSGC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

13.2.6.2 G1垃圾收集器的缺点 #

相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

13.2.6.3 G1回收器的参数设置 #

  • -XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务
  • -XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
  • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms(人的平均反应速度)
  • -XX:+ParallelGCThread 设置STW工作线程数的值。最多设置为8(上面说过Parallel回收器的线程计算公式,当CPU_Count > 8时,ParallelGCThreads 也会大于8)
  • -XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。

13.2.6.4 G1收集器的常见操作步骤 #

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:

  • 第一步:开启G1垃圾收集器
  • 第二步:设置堆的最大内存
  • 第三步:设置最大的停顿时间

G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发。

13.2.6.5 G1收集器的适用场景 #

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。

用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:

  • 超过50%的Java堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC停顿时间过长(长于0.5至1秒)

HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

13.2.6.6 分区Region:化整为零 #

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

一个region有可能属于Eden,Survivor或者Old/Tenured内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,S表示属于survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。

设置H的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。

每个Region都是通过指针碰撞来分配空间

13.2.6.7 G1垃圾回收器的回收过程 #

G1GC的垃圾回收过程主要包括如下三个环节:

  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC) (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)

顺时针,Young gc -> Young gc + Concurrent mark->Mixed GC顺序,进行垃圾回收。

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

13.2.6.8 Remembered Set #

  • 一个对象被不同区域引用的问题
  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
  • 在其他的分代收集器,也存在这样的问题(而G1更突出)回收新生代也不得不同时扫描老年代?
  • 这样的话会降低MinorGC的效率;

解决方法:

无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:

每个Region都有一个对应的Remembered Set;

每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;

然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);

如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;

当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

13.2.6.9 G1回收过程一:年轻代GC #

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收Eden区和Survivor区。

首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

然后开始如下回收过程:

  1. 第一阶段,扫描根。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  2. 第二阶段,更新RSet。处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
  3. 第三阶段,处理RSet。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  4. 第四阶段,复制对象。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用。处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

13.2.6.10. G1回收过程二:并发标记过程 #

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
  2. 根区域扫描(Root Region Scanning):G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在YoungGC之前完成。
  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被YoungGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。
  5. 独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。

13.2.6.11 G1回收过程三:混合回收 #

当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

13.2.6.12 G1回收可选的过程四:Full GC #

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

  • Evacuation的时候没有足够的to-space来存放晋升的对象;
  • 并发处理过程完成之前空间耗尽。

13.2.6.13 补充 #

从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

13.2.6.14 G1回收器优化建议 #

年轻代大小

  • 避免使用-Xmn-XX:NewRatio等相关选项显式设置年轻代大小
  • 固定年轻代的大小会覆盖暂停时间目标

暂停时间目标不要太过严苛

  • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
  • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

13.3 垃圾回收器总结 #

13.3.1 7种经典垃圾回收器总结 #

截止JDK1.8,一共有7款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器分类作用位置使用算法特点适用场景
Serial串行运行作用于新生代复制算法响应速度优先适用于单CPU环境下的client模式
ParNew并行运行作用于新生代复制算法响应速度优先多CPU环境Server模式下与CMS配合使用
Parallel并行运行作用于新生代复制算法吞吐量优先适用于后台运算而不需要太多交互的场景
Serial Old串行运行作用于老年代标记-压缩算法响应速度优先适用于单CPU环境下的Client模式
Parallel Old并行运行作用于老年代标记-压缩算法吞吐量优先适用于后台运算而不需要太多交互的场景
CMS并发运行作用于老年代标记-清除算法响应速度优先适用于互联网或B/S业务
G1并发、并行运行作用于新生代、老年代标记-压缩算法、复制算法响应速度优先面向服务端应用

GC发展阶段:Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC

13.3.2. 垃圾回收器组合 #

不同厂商、不同版本的虚拟机实现差距比较大。HotSpot虚拟机在JDK7/8后所有收集器及组合如下图

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  2. 其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案。
  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial old这两个组合声明为Deprecated(JEP 173),并在JDK 9中

完全取消了这些组合的支持(JEP214),即:移除。

  1. (绿色虚线)JDK 14中:弃用ParallelScavenge和SeriaOold GC组合(JEP 366)
  2. (绿色虚框)JDK 14中:删除CMS垃圾回收器(JEP 363)

13.3.3. 怎么选择垃圾回收器 #

Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。

怎么选择垃圾收集器?

  1. 优先调整堆的大小让JVM自适应完成。
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
  4. 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
  5. 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。

最后需要明确一个观点:

  1. 没有最好的收集器,更没有万能的收集
  2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器

面试

对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。 这里较通用、基础性的部分如下:

  • 垃圾收集的算法有哪些?如何判断一个对象是否可以回收?
  • 垃圾收集器工作的基本流程。

另外,大家需要多关注垃圾回收器这一章的各种常用的参数

13.4 GC日志分析 #

通过阅读Gc日志,我们可以了解Java虚拟机内存分配与回收策略。 内存分配与垃圾回收的参数列表

  • -XX:+PrintGC 输出GC日志。类似:-verbose:gc
  • -XX:+PrintGCDetails 输出GC的详细日志
  • -XX:+PrintGCTimestamps 输出GC的时间戳(以基准时间的形式)
  • -XX:+PrintGCDatestamps 输出GcC的时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

打开GC日志

-verbose:gc

这个只会显示总的GC堆的变化,如下:

[GC (Allocation Failure) 80832K->19298K(227840K),0.0084018 secs]
[GC (Metadata GC Threshold) 109499K->21465K(228352K),0.0184066 secs]
[Full GC (Metadata GC Threshold) 21465K->16716K(201728K),0.0619261 secs]

参数解析

GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代,老年代。
Allocation Failure:GC发生的原因。
80832K->19298K:堆在GC前的大小和GC后的大小。
228840k:现在的堆大小。
0.0084018 secs:GC持续的时间。

打开GC日志

-verbose:gc -XX:+PrintGCDetails

输入信息如下

[GC (Allocation Failure) [PSYoungGen:70640K->10116K(141312K)] 80541K->20017K(227328K),0.0172573 secs] [Times:user=0.03 sys=0.00,real=0.02 secs]
[GC (Metadata GC Threshold) [PSYoungGen:98859K->8154K(142336K)] 108760K->21261K(228352K),0.0151573 secs] [Times:user=0.00 sys=0.01,real=0.02 secs]
[Full GC (Metadata GC Threshold)[PSYoungGen:8154K->0K(142336K)]
[ParOldGen:13107K->16809K(62464K)] 21261K->16809K(204800K),[Metaspace:20599K->20599K(1067008K)],0.0639732 secs]
[Times:user=0.14 sys=0.00,real=0.06 secs]

参数解析

GC,Full FC:同样是GC的类型
Allocation Failure:GC原因
PSYoungGen:使用了Parallel Scavenge并行垃圾收集器的新生代GC前后大小的变化
ParOldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
Metaspace: 元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
xxx secs:指GC花费的时间
Times:user:指的是垃圾收集器花费的所有CPU时间,sys:花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。

打开GC日志

-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimestamps -XX:+PrintGCDatestamps

输入信息如下

2019-09-24T22:15:24.518+0800: 3.287: [GC (Allocation Failure) [PSYoungGen:136162K->5113K(136192K)] 141425K->17632K(222208K),0.0248249 secs] [Times:user=0.05 sys=0.00,real=0.03 secs]

2019-09-24T22:15:25.559+0800: 4.329: [GC (Metadata GC Threshold) [PSYoungGen:97578K->10068K(274944K)] 110096K->22658K(360960K),0.0094071 secs] [Times: user=0.00 sys=0.00,real=0.01 secs]

2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [PSYoungGen:10068K->0K(274944K)][ParoldGen:12590K->13564K(56320K)] 22658K->13564K(331264K),[Metaspace:20590K->20590K(1067008K)],0.0494875 secs] [Times: user=0.17 sys=0.02,real=0.05 secs]

说明:带上了日期和实践

如果想把GC日志存到文件的话,是下面的参数:

-Xloggc:/path/to/gc.log

日志补充说明

  • [GC“和”[Full GC“说明了这次垃圾收集的停顿类型,如果有"Full"则说明GC发生了"Stop The World”
  • 使用Serial收集器在新生代的名字是Default New Generation,因此显示的是”[DefNew
  • 使用ParNew收集器在新生代的名字会变成"[ParNew",意思是"Parallel New Generation"
  • 使用Parallel scavenge收集器在新生代的名字是”[PSYoungGen"
  • 老年代的收集和新生代道理一样,名字也是收集器决定的
  • 使用G1收集器的话,会显示为"garbage-first heap"
  • Allocation Failure 表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
  • [PSYoungGen:5986K->696K(8704K) ] 5986K->704K(9216K) 中括号内:GC回收前年轻代大小,回收后大小,(年轻代总大小) 括号外:GC回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
  • user代表用户态回收耗时,sys内核态回收耗时,rea实际耗时。由于多核的原因,时间总和可能会超过real时间
Heap(堆)
PSYoungGen(Parallel Scavenge收集器新生代)total 9216K,used 6234K [0x00000000ff600000,0x0000000100000000,0x0000000100000000)
eden space(堆中的Eden区默认占比是8)8192K,768 used [0x00000000ff600000,0x00000000ffc16b08,0x00000000ffe00000)
from space(堆中的Survivor,这里是From Survivor区默认占比是1)1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space(堆中的Survivor,这里是to Survivor区默认占比是1,需要先了解一下堆的分配策略)1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
                                                                         
ParOldGen(老年代总大小和使用大小)total 10240K, used 7001K [0x00000000fec00000,0x00000000ff600000,0x00000000ff600000)
object space(显示个使用百分比)10240K,688 used [0x00000000fec00000,0x00000000ff2d6630,0x00000000ff600000)

PSPermGen(永久代总大小和使用大小)total 21504K, used 4949K [0x00000000f9a00000,0x00000000faf00000,0x00000000fec00000)
object space(显示个使用百分比,自己能算出来)21504K, 238 used [0x00000000f9a00000,0x00000000f9ed55e0,0x00000000faf00000)

Minor GC日志 #

Full GC日志 #

举例

private static final int _1MB = 1024 * 1024;

public static void testAllocation() {
    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) {
    testAllocation();
}

设置JVM参数

-Xms10m -Xmx10m -XX:+PrintGCDetails

图示

可以用一些工具去分析这些GC日志

常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等