# 第一章:JVM 概述

# 1、JVM 是什么?

  1. JVM :全称 Java Virtual Machine,即 Java 虚拟机,是 Java 程序的运行环境。(Java 二进制字节码的运行环境)。

  2. JVM 的特点:

  • Java 虚拟机基于二进制字节码执行,由一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆、一个方法区等组成
  • JVM 屏蔽了与操作系统平台相关的信息,从而能够让 Java 程序只需要生成能够在 JVM 上运行的字节码文件,通过该机制实现的跨平台性
  1. JVM 的好处:
  • 一次编译,到处运行
  • 自动内存管理,垃圾回收机制
  • 数组下标越界检查
  • 多态
  1. Java 代码的执行流程:Java 源代码 --(编译)--> 二进制字节码文件(JVM 指令) --(解释)--> 机器码 --(交给 CPU执行)-->

    image-20230916160844225

  2. JVM、JRE、JDK 之间的关系:

  • JRE (Java Runtime Environment):Java 运行环境,用于解释执行 Java 的字节码文件。

    JRE = JVM + Java SE 标准类库

  • JDK (Java SE Development Kit):Java 标准开发包,提供了编译、运行 Java 程序所需的各种工具和资源。

    JDK = JRE + 开发工具集(例如 Javac 编译工具等)

在这里插入图片描述

  1. 常见的 JVM:

image-20230916122611371

我们主要学习的是 HotSpot 版本的虚拟机。

# 2、JVM 结构

image-20230916122929515

  • ClassLoader(类加载器) :Java 代码编译成二进制后,需要经过类加载器,才能加载到 JVM 中运行。

  • Method Area(方法区) :存放类。

  • Heap(堆) :存放类的实例对象。

  • JVM Stack(虚拟机栈) :是线程私有的内存空间,用于存储每个线程执行 Java 方法时的栈帧,包括局部变量、操作数栈、动态链接、方法出口等信息。

  • PC Register(程序计数器) :是一块较小的内存空间,记录当前线程所执行的字节码行号指示器。

  • Native Method Stacks(本地方法栈) :为虚拟机使用到的本地(Native)方法服务。

  • Interpreter(解释器) :逐行执行方法的每行代码

  • JIT Compiler(即时编译器) :优化方法中的热点代码、频繁调用的方法

  • GC(垃圾回收) :回收堆中不引用的对象。

  • 本地方法接口 :和操作系统打交道。

# 3、JVM 生命周期

JVM 的生命周期分为三个阶段:

  • 启动:当启动一个 Java 程序时,通过引导类加载器(bootstrap class loader)创建一个初始类(initial class),对于拥有 main 函数的类就是 JVM 实例运行的起点
  • 运行
    • main () 方法是一个程序的初始起点,任何线程均可由在此处启动
    • 在 JVM 内部有两种线程类型,分别为: 用户线程守护线程JVM 使用的是守护线程,main () 和其他线程使用的是用户线程,守护线程会随着用户线程的结束而结束
    • 执行一个 Java 程序时,真真正正在执行的是一个 Java 虚拟机的进程
    • JVM 有两种运行模式 ServerClient ,两种模式的区别在于:
      • Client 模式启动速度较快,Server 模式启动较慢
      • 但是,启动进入稳定期并长期运行之后,Server 模式的程序运行速度比 Client 要快很多
      • Server 模式启动的 JVM 采用的是重量级的虚拟机,对程序采用了更多的优化;Client 模式启动的 JVM 采用的是轻量级的虚拟机
  • 死亡
    • 当程序中的用户线程都中止,JVM 才会退出
    • 程序正常执行结束、程序异常或错误而异常终止、操作系统错误导致终止
    • 线程调用 Runtime 类 halt 方法或 System 类 exit 方法,并且 Java 安全管理器允许这次 exit 或 halt 操作

# 第二章:JVM 内存结构

# 0、概述

内存结构是 JVM 中非常重要的一部分,是非常重要的系统资源,是硬盘和 CPU 的桥梁,承载着操作系统和应用程序的实时运行,又叫运行时数据区

JVM 内存结构规定了 Java 程序在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。

# Java7 的内存结构图

image-20230916132120271

Java7及以前版本的内存结构

颜色的意义:线程隔离的数据区线程共享的数据区

方法区是规范,永久代(PermGen)是 Hotspot 针对该规范进行的实现。堆和方法区(永久代)在逻辑上依旧是分开的,但在物理上来说,它们又是连续的一块内存。也就是说,方法区(永久代)和前面讲到的新生代、老年代是连续的。

Java7及以前版本的堆和方法区的物理存储

Java堆内存又溢出了!教你一招必杀技 - 知乎

# Java8 的内存结构图

HotSpot 取消了永久代,采用元空间(Metaspace)实现方法区,同时方法区(元空间)不再与堆连续,而是存在于本地内存中

本地内存(Native memory)是供 JVM 自身进程使用的。当 Java Heap 空间不足时会触发 GC,但 Native memory 空间不够却不会触发 GC。

img

Java8的内存结构

# 常见 OOM 错误

  • java.lang.StackOverflowError
  • java.lang.OutOfMemoryError:java heap space
  • java.lang.OutOfMemoryError:GC overhead limit exceeded
  • java.lang.OutOfMemoryError:Direct buffer memory
  • java.lang.OutOfMemoryError:unable to create new native thread
  • java.lang.OutOfMemoryError:Metaspace

# 1、程序计数器

JVM 内存,线程私有

  1. PC Register(程序计数器(寄存器)) :物理上通过寄存器实现

  2. 作用:记录当前线程要执行的下一条 JVM 指令的地址(行号)

    image-20230916161103415

    • 解释器会将 JVM 指令解释为机器码,交给 CPU 执行。

    • 程序计数器会记录下一条 JVM 指令的地址行号,这样下一次解释器会从程序计数器拿到指令,然后进行解释执行

    image-20230916161223844

    image

  3. 特点:

  • 线程私有

    多线程的环境下,如果两个线程发生了上下文切换,那么程序计数器会记录其线程下一行指令的地址行号,以便于接着往下执行。

  • 不存在内存溢出

    由 JVM 规范

# 2、虚拟机栈

JVM 内存,线程私有

# 定义

JVM Stacks(Java 虚拟机栈) :为每个线程提供运行时所需要的私有内存

  • 虚拟机栈由多个 栈帧(Frame) 组成,对应着每个调用方法占用的内存,存储了方法的:
    • 局部变量表:存储方法的参数、方法体内的局部变量,包含 8 种 Java 基本数据类型,以及引用数据类型
    • 操作数栈:随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈 / 入栈操作
    • 动态链接:每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接 (Dynamic Linking)
    • 方法返回地址:无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
  • 每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

image-20230920161330926

# 问题辨析

  1. 垃圾回收是否涉及栈内存?

    GC 不涉及栈内存。栈内存是方法调用产生的,方法调用结束后会自动弹出栈,相当于清空了数据

  2. 栈内存分配越大越好吗?

    栈内存并非越大越好。因为物理内存是一定的,栈内存分配越大,可以支持更多的递归调用,但是可执行的线程数就会越少

  3. 方法内的局部变量是否线程安全?

    • 如果方法内部的变量没有逃离方法的作用访问,它是线程安全的(逃逸分析)
    • 如果是局部变量引用了对象,并逃离了方法的作用范围,需要考虑线程安全

# 栈内存溢出

以下两种情况会导致栈内存溢出( java.lang.StackOverflowError 异常):

  • 栈帧过多,例如递归层数过多,或者递归终止条件不合理
  • 线程请求的栈深度超过最大值

# 线程运行诊断

案例 1:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高

解决方法:找到占用 CPU 过高的线程

  • top 命令:定位哪个进程占用 CPU 过高
  • ps H -eo pid,tid,%cpu | grep 进程id :进一步定位是哪个线程引起的 cpu 占用过高
  • jstack 进程id :可以根据线程 id 找到有问题的线程,进一步排查出问题代码的源码行号。注意 jstack 查找出的线程 id 是 16 进制的,需要转换。

案例 2:程序运行了很长时间都没有结果

解决方法与上类似,观察 jstack 命令的最后面的输出信息(例如死锁)

# 3、本地方法栈

JVM 内存,线程私有

Native Method Stack(本地方法栈) :当 Java 需要调用一些带有 native 关键字的本地的 C 或者 C++ 方法时,因为 JAVA 有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务于带 native 关键字的方法,为其提供内存空间

# 4、堆

JVM 内存,线程共享

# 定义

  1. Heap(堆) :JVM 内存中最大的一块内存区域,用于存储对象实例和数组。是垃圾回收器(Garbage Collector,GC)管理的主要区域,因此对 Java 程序的性能有着重要影响。
  2. 特点如下:
  • 由所有线程共享,堆中对象大部分都需要考虑线程安全的问题
  • 垃圾回收器管理的主要区域
  1. 存放以下资源:
  • 对象实例:类初始化生成的对象,基本数据类型的数组也是对象实例,new 创建对象都使用堆内存
  • 字符串常量池
    • 字符串常量池原本存放于方法区,JDK7 开始放置于堆中
    • 字符串常量池存储的是 String 对象的直接引用或者对象,是一张 string table
  • 静态变量:由 static 修饰的变量,JDK8 时从方法区迁移至堆中
  • 线程分配缓冲区(TLAB):Thread Local Allocation Buffer,线程私有但不影响堆的共性,可以提升对象分配的效率

# 溢出

设置堆内存大小的指令: -Xmx Size

堆内存溢出的异常信息: java.lang.OutOfMemoryError :java heap space.

# 堆内存诊断工具

控制台命令

  1. jps :查看当前系统中有哪些 Java 进程

  2. jmap:查看堆内存占用情况 jmap -heap 进程id

  3. jconsole :图形界面的,多功能的监测工具,可以连续监测

  4. jvisualvm :可视化展示 JVM,可以查看某时刻堆内存的信息,以及具体哪些对象占用堆内存最多

堆内存诊断案例:多次垃圾回收后,堆内存占用仍然很高,怎么办?试试 jvisualvm!

# Java7 中的堆内存组成(分代)

  • 年轻代(Young):存放新创建的对象,分为三部分:Eden 区和两个大小严格相同的 Survivor 区。
    • 新对象首先在 Eden 区分配。
    • Survivor 区某时刻只有其中一个是被使用的,另外一个留做垃圾回收时复制对象。
    • 在 Eden 区变满的时候,GC 就会将存活的对象移到空闲的 Survivor 区间中,根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到 Tenured 区间。
  • 老年代(Tenured):存放 Young 代中经过多次垃圾回收后仍然存活的对象,被认为是长期存活的老对象。当一些对象在 Young 复制转移一定的次数以后,对象就会被转移到 Tenured 区。
  • 永久代(Perm,方法区):主要保存 Class、ClassLoader、静态变量、常量、编译后的代码,在 Java7 中堆内方法区会受到 GC 的管理。

image-20230920165900317

分代原因:不同对象的生命周期不同,70%-99% 的对象都是临时对象,优化 GC 性能。

# Java8 中的堆内存组成(分代)

方法区的实现不再是永久代,而是位于本地内存中的元空间(Metaspace)

  • 年轻代
  • 老年代

img

# 5、方法区

线程共享,Java8 之前在堆内存,Java8 之后在本地内存

# 定义

  1. Method Area(方法区) :一块用于存储类和方法信息的内存区域,负责存储和管理类级别的数据,确保程序能够正确地运行时定位和访问类的结构信息。它在 Java 虚拟机规范中被定义为一种逻辑上的内存区域,其具体实现可以随虚拟机的不同而有所差异。通常情况下,方法区被所有线程共享,并且在虚拟机启动时创建

  2. 特点:

  • 线程共享:同一个 JVM 实例的所有线程共享方法区的数据。

  • 生命周期与类同步:方法区的数据随着类的加载而产生,随着类的卸载而消失,而不是随对象生命周期结束而释放。

  • 存储类的结构信息

    • 类元信息(Klass):在类编译期间放入方法区,包含类的基本信息和常量池表。

      • 类信息:存储每个加载到 JVM 的类的元数据,包括类的名称、访问修饰符、父类、接口列表等信息。
      • 字段信息:记录了类中的各种静态变量以及实例变量的名称、类型和访问权限等。
      • 静态变量:即类变量,与类的生命周期相同,由类的所有实例共享。
      • 方法信息:存储了类中的方法的名称、参数列表、返回值类型、字节码等。
    • 常量池(Constant Pool):存储编译期生成的类中的字面量和符号引用,如字符串字面量、类和方法的引用等。

      • 常量:如 final 修饰的常量值。
      • 符号引用:类和接口、字段、方法的符号引用。
    • 运行时常量池(Runtime Constant Pool):是常量池的一部分,在类加载后将被转存到方法区中,包含运行时解析的字面量和符号引用。

    • 即时编译器编译后的代码缓存:存储 JIT 编译器编译后的本地机器代码。

  • 内存回收特性:方法区内存回收主要针对常量池的回收、类型的卸载,这部分回收相对复杂且频率较低。

  • 内存溢出异常:如果方法区占用过大,可能导致 OutOfMemoryError 异常,例如类加载过多或类的元数据占用过大时可能出现此类问题。

  • 逻辑上的内存区域。尽管 JVM 规范认为方法区在逻辑上是堆的一部分,但简单的实现可能会选择不进行垃圾收集或压缩。JVM 规范不强制要求方法区的位置(例如 Hotspot JVM 在 Java7 中方法区位于堆中的永久代,而在 Java8 中采用元空间来实现方法区,位于本地内存中)或用于管理编译代码的策略。

    方法区是一个 JVM 规范,永久代元空间都是方法区的一种实现方式

  • 方法区的大小不必是固定的,可动态扩展

  • 方法区的内存不必是连续的

# 组成

Hotspot 虚拟机中的方法区在 Java1.6 与 Java1.8 的结构对比图

在 Java 1.6 中,方法区的实现方式是堆内存中的永久代(PermGen),包括:

  • Class
  • ClassLoader
  • 运行时常量池
    • StringTable

image-20230917130739069

Java 1.6 内存结构图

在 Java 1.8 中,将堆内的方法区(永久代)移动到了本地内存上,重新开辟了一块空间,叫做元空间(Metaspace),存储类的元信息:

  • Class
  • ClassLoader
  • 运行时常量池

StringTable 不再跟随方法区的实现了,而是留在堆内存中

image-20230917130751140

Java 1.8 内存结构图

# 溢出

如果方法区中的内存无法满足分配请求(例如加载的类太多),那么 JVM

  • 在 1.8 之前,将抛出永久代空间内存溢出异常 java.lang.OutOfMemoryError: PermGen space

    修改永久代的内存大小: -XX:MaxPermSize=8m

  • 在 1.8 之后,将抛出元空间内存溢出异常 java.lang.OutOfMemoryError: Metaspace

    修改元空间的内存大小: -XX:MaxMetaspaceSize=8m

场景:spring、mybatis 中大量使用动态生成类(二进制字节码),而后加载

# 运行时常量池

# 常量池表

Java 源代码经过编译后,生成 Java 字节码文件(即 Class 文件),分为以下三部分:

  • 类基本信息
  • 常量池表
  • 类方法定义:包含 JVM 指令

常量池表(Constant Pool Table) 是 Class 文件的一部分,存储了类在编译期间生成的字面量、符号引用JVM 为每个已加载的类维护一个常量池,JVM 指令根据常量池表查找要执行的类名、方法名、参数类型、字面量等信息。

  • 字面量:基本数据类型、String 类型常量、声明为 final 的常量值等
  • 符号引用:类、字段、方法、接口等的符号引用

# 运行时常量池

运行时常量池(Runtime Constant Pool)

  • 常量池中的数据会在对应类被加载后放入运行时常量池
  • 类在解析阶段将这些符号引用替换成直接引用
  • 除了在编译期生成的常量,还允许动态生成常量,例如 String 类的 intern ()

# StringTable

jdk 1.6 在永久代(即方法区的常量池)中,jdk 1.8 在堆内存中。

StringTable字符串常量池 / 串池 / String Pool )保存着所有字符串字面量(literal strings),这些字面量在编译时期就确定。是 HashTable 结构,不能扩容,通过 -XX:StringTableSize 设置大小,JDK 1.8 中默认 60013。

有以下特性,下文会逐一展开介绍:

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为 String 对象,添加到 StringTable(串池)中
  • 利用串池的机制(Hashtable 结构),字符串对象是唯一的,避免重复创建
  • 字符串变量拼接的原理是StringBuilder
  • 字符串常量拼接的原理是编译器优化
  • 可以使用String 的 intern 方法,尝试将字符串对象放入串池中,如果已存在则不会重复放入。最终返回串池中的该字符串对象。

# StringTable 与常量池的关系

字符串延迟实例化常量池中的字符串仅是符号,第一次使用时才变为 String 对象添加到 StringTable(串池)中

image-20230917152113099

image-20230917154323511

# 字符串拼接原理

  • 字符串变量的拼接的原理是StringBuilder 的 append () 方法

    image-20230917152827900

    s3 是 StringTable(串池)中的一个 String 对象,而 s4 是 new 出来的 String 对象,位于堆内存中,二者只是值相同,但地址不同!

  • 字符串常量拼接的原理是编译期优化,先判断拼接结果对应的 String 对象是否已存在于StringTable(串池)中,是则直接复用该 String 对象

    image-20230917153822577

# StringTable 如何添加字符串对象

使用 String 的 intern() 方法可尝试将字符串对象添加到 StringTable(串池)中:

无论如何,最终 intern () 都会返回 StringTable(串池)中的字符串对象的引用!

  • JDK 1.8:当一个字符串调用 intern () 方法时,如果 StringTable(串池)中:

    • 若存在一个字符串和该字符串值相等,则不会将该字符串对象放入传值

      此时,堆内存与串池中的字符串对象不是同一个对象

      image-20230917161639164

    • 若不存在,则会把该字符串对象的 **引用地址复制一份,放入串池 **

      此时,堆内存与串池中的字符串对象是同一个对象

      image-20230917161329890

  • JDK 1.6:将这个字符串对象尝试放入串池,

    • 若存在一个字符串和该字符串值相等,则不放入

      此时,堆内存与串池中的字符串对象不是同一个对象

      image-20230917162609942

    • 若不存在,则会把此 **对象复制一份,放入串池 **

      此时,堆内存与串池中的字符串对象不是同一个对象

      image-20230917162513392

# StringTable 面试题

image-20230917170027499

# StringTable 位置

Java 7 之前,StringTable 被放在永久代中的运行时常量池中。

image-20230917130739069

Java 1.6 内存结构图

Java 7 以后,StringTable 被移到中。

因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误

image-20230917130751140

Java 1.8 内存结构图

# StringTable 垃圾回收

  • -Xmx10m :指定堆内存大小为 10m
  • -XX:+PrintStringTableStatistics :打印 StringTable(串池)的统计信息
  • -XX:+PrintGCDetails :打印垃圾回收的详细信息
  • -verbose:gc :打印 gc 的次数,耗费时间等信息

# StringTable 性能调优

  • 适当增加 StringTableSize(即桶个数),来提高字符串放入串池的性能。

    因为 StringTable 是由 HashTable 实现的,底层是数组 + 链表,数组长度代表 HashTable 的桶个数,桶个数越多意味着元素存储地越分散,哈希冲突的概率越小,各个桶上的链表较短,查找性能越高。命令如下:

    -XX:StringTableSize=桶个数 (最少设置为 1009 以上)

  • 若数据中存在许多重复的字符串,可以通过 intern 方法将字符串对象放入 StringTable(串池),通过复用字符串的引用,减少内存占用

# 6、直接内存

本地内存,线程共享

# 定义

Direct Memory(直接内存) :是直接向系统申请的内存区间,位于本地内存,不是 JVM 运行时数据区的一部分,但操作系统和 Java 代码都可以访问。直接内存有以下特点:

  • 常见于 **NIO(同步非阻塞 IO)操作** 时,用于数据缓冲区

    三种 IO 模型:

    • BIO【Blocking I/O,同步阻塞 IO】:数据的写入和读取都必须阻塞在一个线程中执行,在写入完成或读取完成前,线程阻塞。

    • NIO【Non-Blocking I/O,同步非阻塞 IO】:出现于 JDK 1.4,NIO 相对于 BIO 来说出现了几个核心的组件:

      • Selector(选择器):可以让单个线程处理多个通道,达到复用的目的
      • Channle(通道):NIO 的所有 IO 操作都从 Channle 开始:
        • 从通道进行数据读取 :创建一个缓冲区,然后请求通道读取数据。
        • 从通道进行数据写入 :创建一个缓冲区,填充数据,并要求通道写入数据。
      • Buffer(缓冲区)缓冲区的出现导致了 NIO 和 BIO 的不同
        • 读数据时可以先读一部分到缓冲区中,然后处理其他事情;
        • 写数据时可以先写一部分到缓冲区中,然后处理其他事情。
        • 读和写操作可以不再持续,所以不会阻塞。
        • 当缓冲区满后才会将其放入真正地读 / 写。

      image-20230918003534675

      从以上代码实例可以看出,使用原生的 Buffer、Channel 和 Selector 来实现 NIO 还是很麻烦的,所以才会出现 Netty。Netty 能够让我们快速便捷地实现 NIO,是一个基于 Java NIO 类库的异步通信框架,它的架构特点是:异步非阻塞、基于事件驱动、高性能、高可靠性和高可定制性。使用 Netty 可以很好地替代掉繁琐的、难以使用的 Java NIO 类库,并且 Netty 提供了更多可用的功能。

      还有一个原因是,JDK 原生的 NIO 是基于操作系统的 epoll 函数的,而这个函数可能会引发阻塞,而 Netty 是给予 select 函数的。

    • AIO【Asynchronous I/O,异步非阻塞 IO】:出现于 JDK 1.7,是 NIO 的改进版,主要基于事件和回调机制来实现异步处理。

  • 分配回收成本较高,但 **读写性能高**

  • 不受 JVM 内存回收管理

# 直接内存的好处

# 文件读写流程

在这里插入图片描述

因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法进行操作,然后读取磁盘文件,会在系统内存中创建一个缓冲区,将数据读到系统缓冲区,然后在将系统缓冲区数据,复制到 java 堆内存中。

缺点:数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制。

# 使用了 DirectBuffer 文件读取流程

在这里插入图片描述

直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。

# 直接内存的回收原理

直接内存溢出异常会报错: java.lang.OutOfMemoryError:Direct buffer memory

  • 直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是使用 Unsafe 类 来完成直接内存的分配与回收:
    • 调用 allocateMemory 方法分配直接内存
    • 手动调用 freeMemory 方法回收直接内存
  • ByteBuffer 的实现类内部使用了 Cleaner(虚引用) 来检测 ByteBuffer 对象。一旦 ByteBuffer 对象被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法 调用 freeMemory 方法 来释放直接内存。

在 JVM 调优时,一般会加上以下参数:

-XX:+DisableExplicitGC  // 禁用显示的 GC

意思就是禁止手动的 GC,导致直接内存无法及时回收。比如手动 System.gc () 无效,它是一种 full gc,会回收新生代、老年代,会造成程序执行的时间比较长。

解决方案:所以我们就通过 unsafe 对象调用 freeMemory 的方式释放直接内存

# 第三章:垃圾回收(GC)

垃圾回收器的工作流程大体如下:

  1. 标记出哪些对象是存活的,哪些是垃圾(可回收);
  2. 进行回收(清除 / 复制 / 整理),如果有移动过对象(复制 / 整理),还需要更新引用。

# 1、垃圾判断(标记)

垃圾:如果一个或多个对象没有任何的引用指向它了,那么这个对象就是垃圾

作用:释放未被引用的对象,清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象

GC 主要是针对堆和方法区内的基本数据类型和对象,程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收

在堆里存放着几乎所有的 Java 对象实例,在 GC 执行之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程可以称为垃圾标记阶段。

判断对象是否存活一般有两种方式:引用计数算法可达性分析算法

# 引用计数法

未被 JVM 采用

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

  • 对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;
  • 当引用失效时,引用计数器就减 1;
  • 当对象 A 的引用计数器的值为 0,即表示对象 A 不再被引用,可进行回收

优点:

  • 回收没有延迟性,无需等到内存不够的时候才开始回收,运行时根据对象计数器是否为 0,可以直接回收
  • 在垃圾回收过程中,应用无需挂起;如果申请内存时,内存不足,则立刻报 OOM 错误
  • 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象

缺点:

  • 每次对象被引用时,都需要去更新计数器,有一点时间开销
  • 浪费 CPU 资源,即使内存够用,仍然在运行时进行计数器的统计
  • 无法解决循环引用问题,会引发内存泄露【最大的缺点】。如下图所示,两个对象的计数都为 1,导致两个对象都无法被释放。

image-20230919202610889

# 可达性分析算法

JVM 采用的垃圾判断算法,据此探索所有存活的对象

也称为根搜索算法、追踪性垃圾收集。

# GC Roots 对象

GC Roots 是一组活跃的引用,不是对象,放在 GC Roots Set 集合,一般包含以下对象:

  • 虚拟机栈(栈帧)的局部变量表中引用的对象:各个线程的各个调用方法中使用到的参数、局部变量等
  • 本地方法栈中引用的对象
  • 堆中类静态属性中引用的对象
  • 方法区中的常量中引用的对象
  • 字符串常量池(StringTable)中引用的对象
  • 同步锁 synchronized 持有的对象

可以借助 Eclipse Memory Analyzer(MAT)工具 来查找 GC Roots 对象。

# 算法原理

扫描堆内存中的对象,以 GC Roots 对象集合为起始点,沿着引用链查找目标对象是否被连接,是则表示目标对象仍然存活,否则表示可以被回收。基本原理:

  • 可达性分析算法后,内存中的存活对象都会被 GC Roots 对象集合直接或间接连接着,搜索走过的路径称为 引用链
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象
  • 在可达性分析算法中,只有能够被 GC Roots 对象集合直接或者间接连接的对象才是存活对象

img

分析工作必须在一个保障一致性的快照中进行,否则结果的准确性无法保证,这意味着 GC 进行时必须 Stop The World (STW,停顿用户线程进行GC标记) ,若堆中存储的对象很多,那么 GC roots 图越复杂,需要标记更多的节点,停顿更长的时间,对于用户不友好。

# 三色标记算法

是可达性分析算法的一种,也可以说是标记清除算法的一种升级版本,可削弱 STW 所耗费的时间

# 标记算法

三色标记法把遍历对象图过程中遇到的对象,标记成以下三种颜色

“本对象” 可以理解成一个遍历指针,指向当前对象,从 GC Roots 对象开始。

  • 白色:尚未访问过
  • 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问
  • 黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问完成

Stop The World (STW) 时,对象间的引用是不会发生变化的,可以轻松完成标记,遍历访问过程为:

  1. 初始时,所有对象都在 【白色集合】中;

  2. 将 GC Roots 直接引用到的对象 挪到 【灰色集合】中;

  3. 从灰色集合中获取对象:
    3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;

    3.2. 将本对象 挪到 【黑色集合】里面。

  4. 重复步骤 3,直至【灰色集合】为空时结束

  5. 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收

img

# 并发标记

当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化。此时,多标漏标的情况就有可能发生。

# 多标情况

当 E 变为灰色或黑色时,其他线程断开的 D 对 E 的引用,导致这部分对象仍会被标记为存活,本轮 GC 不会回收这部分内存,这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾

  • 针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,也算浮动垃圾
  • 浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除

img

# 漏标情况
  • 条件一:灰色对象断开了对一个白色对象的引用(直接或间接),即灰色对象原成员变量的引用发生了变化
  • 条件二:其他线程中修改了黑色对象,插入了一条或多条对该白色对象的新引用

结果:导致该白色对象当作垃圾被 GC,影响到了程序的正确性。

img

代码角度解释漏标:

Object G = objE.fieldG; // 读
objE.fieldG = null;  	// 写
objD.fieldG = G;     	// 写

为了解决问题,可以操作上面三步,将对象 G 记录起来,然后作为灰色对象再进行遍历,比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),再遍历该集合(重新标记)

所以重新标记需要 STW,应用程序一直在运行,该集合可能会一直增加新的对象,导致永远都运行不完

解决方法:添加读写屏障,读屏障拦截第一步,写屏障拦截第二三步,在读写前后进行一些后置处理:

  • 写屏障 + 增量更新:黑色对象新增引用,会将黑色对象变成灰色对象,最后对该节点重新扫描

    增量更新 (Incremental Update) 破坏了条件二,从而保证了不会漏标

    缺点:对黑色变灰的对象重新扫描所有引用,比较耗费时间

  • 写屏障 (Store Barrier) + SATB:当原来成员变量的引用发生变化之前,记录下原来的引用对象

    保留 GC 开始时的对象图,即原始快照 SATB,当 GC Roots 确定后,对象图就已经确定,那后续的标记也应该是按照这个时刻的对象图走,如果期间对白色对象有了新的引用会记录下来,并且将白色对象变灰(说明可达了,并且原始快照中本来就应该是灰色对象),最后重新扫描该对象的引用关系

    SATB (Snapshot At The Beginning) 破坏了条件一,从而保证了不会漏标

  • 读屏障 (Load Barrier):破坏条件二,黑色对象引用白色对象的前提是获取到该对象,此时读屏障发挥作用

以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1:写屏障 + SATB
  • ZGC:读屏障

# 五种引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与【引用】有关,Java 提供了 4 种 (5?) 强度不同的引用类型。

五种引用

# 强引用

被强引用关联的对象不会被回收,只有所有 GC Roots 都不通过强引用引用该对象,才能被垃圾回收

  • 强引用可以直接访问目标对象
  • 虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象
  • 强引用可能导致内存泄漏
Object obj = new Object();// 使用 new 一个新对象的方式来创建强引用

# 软引用(SoftReference)

被软引用关联的对象只有在内存不够的情况下才会被回收

  • 仅(可能有强引用,一个对象可以被多个引用)有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象
  • 可以配合 引用队列(ReferenceQueue) 来释放软引用自身,在构造软引用时,可以指定一个引用队列,当软引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
  • 软引用通常用来实现内存敏感的缓存,比如高速缓存就有用到软引用;如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时不会耗尽内存
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联
# 演示
/**
 * 演示 软引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class Code_08_SoftReferenceTest {
    public static int _4MB = 4 * 1024 * 1024;
    public static void main(String[] args) throws IOException {
        method2();
    }
    // 设置 -Xmx20m , 演示堆内存不足
    public static void method1() throws IOException {
        ArrayList<byte[]> list = new ArrayList<>();
        for(int i = 0; i < 5; i++) {
            list.add(new byte[_4MB]);
        }
        System.in.read();
    }
    // 演示 软引用
    public static void method2() throws IOException {
        ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
        for(int i = 0; i < 5; i++) {
            SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
            System.out.println(ref.get());
            list.add(ref);
            System.out.println(list.size());
        }
        System.out.println("循环结束:" + list.size());
        for(SoftReference<byte[]> ref : list) {
            System.out.println(ref.get());
        }
    }
}
  • method1 () 方法解析:
    首先会设置一个堆内存的大小为 20m,然后运行 mehtod1 方法,会抛异常,堆内存溢出,因为 mehtod1 中的 list 都是强引用。

    在这里插入图片描述

  • method2 () 方法解析:
    在 list 集合中存放了软引用对象,当内存不足时,会触发 full gc,将软引用的对象回收。细节如图:

    在这里插入图片描述

上面的代码中,当软引用引用的对象被回收了,但是软引用自身还存在(但值为 null,也需要回收清理),所以一般搭配一个 引用队列(ReferenceQueue) 一起使用
将 method2 () 修改为 method3 () 如下:

由于软引用自身关联了引用队列,当软引用所引用的 byte [] 被回收时,此时已经无用的软引用(值为 null)会加入到引用队列中

因此,可从引用队列的队头获取无用的软引用(值为 null),并移除

// 演示 软引用 搭配引用队列
    public static void method3() throws IOException {
        ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
        // 引用队列
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        for(int i = 0; i < 5; i++) {
            // 关联了引用队列,当软引用所关联的 byte [] 被回收时,软引用(值为 null)自己会加入到 queue 中去
            SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
            System.out.println(ref.get());
            list.add(ref);
            System.out.println(list.size());
        }
        // 从引用队列中获取值为 null 的软引用对象,并移除
        Reference<? extends byte[]> poll = queue.poll(); // 弹出队头元素
        while(poll != null) {
            list.remove(poll);
            poll = queue.poll();
        }
        System.out.println("=====================");
        for(SoftReference<byte[]> ref : list) {
            System.out.println(ref.get()); // 只会打印非 null 的软引用对象
        }
    }

在这里插入图片描述

# 弱引用(WeakReference)

被弱引用关联的对象在 GC 时一定会被回收,只能存活到下一次 GC 发生之前

  • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
  • 可以配合 引用队列(ReferenceQueue) 来释放弱引用自身
  • WeakHashMap 用来存储图片信息,可以在内存不足的时候及时回收,避免了 OOM
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
# 演示
/**
 * 演示 弱引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class Code_09_WeakReferenceTest {
    public static void main(String[] args) {
//        method1();
        method2();
    }
    public static int _4MB = 4 * 1024 *1024;
    // 演示 弱引用
    public static void method1() {
        List<WeakReference<byte[]>> list = new ArrayList<>();
        for(int i = 0; i < 10; i++) {
            WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB]);
            list.add(weakReference);
            for(WeakReference<byte[]> wake : list) {
                System.out.print(wake.get() + ",");
            }
            System.out.println();
        }
    }
    // 演示 弱引用搭配 引用队列
    public static void method2() {
        List<WeakReference<byte[]>> list = new ArrayList<>();
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        for(int i = 0; i < 9; i++) {
            WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB], queue);
            list.add(weakReference);
            for(WeakReference<byte[]> wake : list) {
                System.out.print(wake.get() + ",");
            }
            System.out.println();
        }
        System.out.println("===========================================");
        Reference<? extends byte[]> poll = queue.poll();
        while (poll != null) {
            list.remove(poll);
            poll = queue.poll();
        }
        for(WeakReference<byte[]> wake : list) {
            System.out.print(wake.get() + ",");
        }
    }
    
}

# 虚引用(PhantomReference)

例如在直接内存中提到的 Cleaner 虚引用

也称为幽灵引用或者幻影引用,是最弱的引用类型

  • 一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象
  • 为对象设置虚引用的唯一目的是在于跟踪垃圾回收过程,能在这个对象被回收时收到一个系统通知
  • 必须配合 引用队列(ReferenceQueue) 使用,主要配合 ByteBuffer 使用,被引用对象回收时会将虚引用自身入队,由 Reference Handler 线程 调用虚引用相关方法(例如 Unsafe 类的 freeMemory 方法)释放直接内存
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

# 终结器引用(finalization)

无需手动编码,但其内部必须配合 引用队列(ReferenceQueue) 使用

  • 第一次 GC 时,终结器引用自身首先入队(被引用对象暂时没有被回收)

  • 再由 Finalizer 线程 通过终结器引用,找到被引用对象,并调用它的 finalize 方法第二次 GC 时才能回收被引用对象

    并不推荐使用 finalize 方法释放资源,因为可能导致资源迟迟不能释放。

# 2、GC 算法

# 标记 - 清除 算法

标记-清除(Mark-Sweep)算法 将垃圾回收分为两个阶段,分别是标记和清除

  • 标记:Collector 从引用 GC Roots 节点开始遍历,标记所有被引用的对象,一般是在对象的 Header 中记录为可达对象,标记的是可达对象,不是垃圾
  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收,把分块连接到空闲链表的单向链表,
    • 判断回收后的分块与前一个空闲分块是否连续,若连续会合并这两个分块
    • 之后进行分配时只需要遍历这个空闲列表,就可以找到分块
  • 分配阶段:程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block,
    • 如果找到的块等于 size,会直接返回这个分块
    • 如果找到的块大于 size,会将块分割成大小为 size 与 block - size 的两部分,返回大小为 size 的分块,并把大小为 block - size 的块返回给空闲列表

算法缺点:

  • 标记和清除过程效率都不高
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存,需要维护一个空闲链表

JVM 如何标记垃圾对象 之 可达性算法 - 知乎

# 标记 - 整理 算法

标记-整理(Mark-Compact)算法 是在标记清除算法的基础之上,做了优化改进的算法。

  • 标记:和标记 - 清除算法一样,也是从根节点开始,对对象的引用进行标记
  • 整理:并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了内存碎片化的问题

优点:不会产生内存碎片

缺点:需要移动大量对象,处理效率比较低

Java性能优化之JVM GC(垃圾回收机制) - 知乎

# 标记 - 复制 算法

大多 JVM 的新生代都使用这个

标记-复制(Mark-Copy)算法 的核心就是,将原有的内存空间划分为大小相等的【FROM 空间】和【TO 空间】,每次分配给新建对象的内存都在【FROM 空间】

这里的【FROM 空间】又称【分配空间(Allocation Space)】,【TO 空间】又称【幸存者空间(Survivor Space)】。

  • 标记:和标记 - 清除算法一样,也是从根节点开始,对【FROM 空间】中的对象引用进行标记
  • 复制:在垃圾回收时,将【FROM 空间】中的存活对象(可达对象)复制到【TO 空间】
  • 清空:清空【FROM 空间】的内存
  • 交换:交换【FROM 空间】、【TO 空间】的角色,完成垃圾的回收

在这里插入图片描述

应用场景:如果内存中的垃圾对象较多,需要复制的对象(存活对象)就较少,这种情况下适合使用该方式并且效率比较高,反之则不适合。

算法优点:

  • 不需要构造空闲链表,实现简单,运行速度快
  • 复制过去以后保证空间的连续性,不会出现内存碎片问题

算法缺点:

  • 由于总有一块内存区域是空的,主要不足是只使用了内存的一半,所以 GC 复制算法的难点在于定义【From 空间】与【To 空间】的比例
  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销都不小

现在的商业 JVM 都采用这种收集算法回收堆内存的新生代,因为新生代的 GC 频繁并且对象的存活率不高,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间(【FROM 空间】和【TO 空间】),比例是 8:1:1

# 小结

标记 - 清除(Mark-Sweep)标记 - 整理(Mark-Compact)标记 - 复制(Mark-Copy)
速度中等最慢最快
空间开销少(会产生内存碎片)少(不存在内存碎片)需要占用双倍内存(但不存在内存碎片)
移动对象

# 3、分代 GC

JVM 的垃圾回收机制中并不会只采用某一种 GC 算法,而是结合多种 GC 算法来协同工作。

# 前置知识

# 堆内存是如何分代的?

介绍内存结构中的堆内存时,Java7 中的堆内存组成(分代)提到 Java7 中堆、方法区分为三份:年轻代、老年代、永久代(方法区)

image-20230920165900317

Java8 中的堆内存组成(分代)提到 Java8 中方法区不再和堆内存连续,而是被移到了本地内存中,此时堆内存分为年轻代、老年代

  • 年轻代与老年代的默认比例是 1:2
  • 而年轻代又分为 Eden、Survivor0(from)、Survivor1(to),默认比例为 8:1:1

image-20230921104531387

# 不同代的 GC 算法

分代 GC 算法:

  • 年轻代复制算法
  • 老年代标记-清除 或者 标记-整理 算法

# Minor GC、Major GC 和 Full GC

这三种 GC 都会触发 STW,暂停其他线程,等垃圾回收结束后,恢复用户线程运行。

  • Minor GC:也称为新生代 GC,是指在新生代空间(包括 Eden、Survivor0 和 Survivor1 区域)回收内存的过程。
    • 当 JVM 无法为一个新对象分配空间时(例如当 Eden 区满了),会触发 Minor GC
    • 执行 Minor GC 时,不会影响到老年代、永久代
    • 但是Minor GC 会引发 STW,暂停其他线程,等垃圾回收结束后,恢复用户线程运行
    • 因为新生代对象存活时间很短,所以 Minor GC 的发生频率较高,回收速度较快
  • Major GC:也称为老年代 GC,是指在老年代空间回收内存的过程。
    • Major GC 的速度一般会比 Minor GC 慢 10 倍以上
  • Full GC:也称为完全 GC,是指清理整个堆空间(包括年轻代、老年代)的过程。
    • Full GC 的触发条件:
      • 调用 System.gc 时,系统建议执行 Full GC,但是不必然执行
      • 老年代空间不足
        • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
        • 由 Eden 区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
      • 方法区(永久代)空间不足
    • 因为老年代对象其存活时间长,所以 Full GC 很少执行,执行速度也会比 Minor GC 慢很多
    • Full GC 会停止应用程序的线程,因此可能导致较长的停顿时间

# 工作机制

image-20230921124136455

# 新生代的内存分配策略

  • 新对象优先在 Eden 分配:当创建一个对象的时候,对象会被分配在新生代的 Eden 区
  • ** 当 Eden 区要满时,触发 Minor GC **,引发 STW ,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行:
    • 扫描 Eden 区、from 区,利用 可达性分析算法 ,沿着 GC Roots 对象的引用链,标记可达对象(存活对象)
    • 使用 复制算法 将 Eden 区、from 区的存活对象复制到 to 区,并且让存活对象的年龄加 1
    • 清空 Eden 区、from 区,回收垃圾
    • 交换 from 区、to 区的角色【保证 to 区始终为空
  • To 区永远为空,From 区是有数据的,每次 Minor GC 后交换这两个区域

  • From 区和 To 区 也可以叫做 S0 区和 S1 区

# 老年代的内存分配策略

  • 长期存活的对象晋升到老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中

    -XX:MaxTenuringThreshold :定义年龄的阈值,对象的 header 中用 4 个 bit 存储,所以最大值是 15,也是默认值

  • 大对象直接进入老年代:需要连续内存空间的对象,最典型的大对象是很长的字符串以及数组;避免在 Eden 和 Survivor 之间的大量复制;经常出现大对象会提前触发 GC 以获取足够的连续空间分配给大对象

    -XX:PretenureSizeThreshold :大于此值的对象直接在老年代分配

  • 动态对象年龄判定:如果在 Survivor 区中相同年龄的对象的所有大小之和超过 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代

  • 当老年代空间不足时,先尝试触发 Minor GC 回收新生代空间,如果仍不足,则触发 Full GC,采用 标记-清除 或者 标记-整理 算法回收老年代垃圾STW 的时间更长。

# 相关 VM 参数

含义参数
堆初始大小-Xms
堆最大大小-Xmx 或 -XX:MaxHeapSize=size
新生代大小-Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size)
幸存区比例(动态)-XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例-XX:SurvivorRatio=ratio
晋升阈值-XX:MaxTenuringThreshold=threshold
晋升详情-XX:+PrintTenuringDistribution
GC 详情-XX:+PrintGCDetails -verbose:gc
FullGC 前 MinorGC-XX:+ScavengeBeforeFullGC

# GC 演示与分析

在 main 函数为空时运行程序,GC 详细信息如下:

public class Code_10_GCTest {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;
    // -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
    public static void main(String[] args) {
    }
}

image-20230921125529072

通过下面的代码,给 list 分配内存,来观察新生代和老年代的情况,什么时候触发 minor gc,什么时候触发 full gc 等情况,使用前需要设置 jvm 参数。

public class Code_10_GCTest {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;
    // -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        list.add(new byte[_6MB]);
        list.add(new byte[_512KB]);
        list.add(new byte[_6MB]);
        list.add(new byte[_512KB]);
        list.add(new byte[_6MB]);
    }
}

# 4、GC 器

# 前置知识

# GC 器如何分类

  • GC 线程数分,可以分为串行垃圾回收器和并行垃圾回收器
    • 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行
  • 工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
    • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
    • 独占式垃圾回收器(STW,Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束

注意区分并行 GC 器与并发 GC 器:

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上。
  • 碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器
    • 压缩式垃圾回收器在回收完成后进行压缩整理,消除回收后的碎片,再分配对象空间使用指针碰撞
    • 非压缩式的垃圾回收器不进行这步操作,再分配对象空间使用空闲列表
  • 工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器

# GC 性能指标

  • 吞吐量:用户程序的运行时间占总运行时间的比例(吞吐量 = 用户程序的运行时间 / (用户程序的运行时间 + GC 的时间))

    例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。

  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例

  • 暂停(STW)时间:执行垃圾收集时,程序的工作线程被暂停的时间

  • 收集频率:相对于应用程序的执行,收集操作发生的频率

  • 内存占用:Java 堆区所占的内存大小

  • 快速:一个对象从诞生到被回收所经历的时间

# GC 器的组合使用关系

新生代的 GC 器:Serial、ParNew、Parallel Scavenge

新生代采用的 GC 算法:复制算法

老年代的 GC 器:Serial old、Parallel old、CMS

老年代采用的 GC 算法:标记 - 整理算法

整堆收集器:G1

img

红色虚线在 JDK9 移除、绿色虚线在 JDK14 弃用该组合、青色虚线在 JDK14 删除 CMS 垃圾回收器

查看默认的垃圾收回收器:

  • -XX:+PrintcommandLineFlags :查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令: jinfo -flag 相关垃圾回收器参数 进程ID

# 串行

特点:

  • 单线程
  • 堆内存较少,适合个人电脑

开启参数: -XX:+UseSerialGC = Serial + SerialOld ,新生代用 Serial GC 且老年代用 Serial old GC。

image-20230921200154073

安全点:让其他线程都在这个点停下来,以免 GC 时移动对象地址,使得其他线程找不到被移动的对象。

因为是串行的,所以只有一个 GC 线程,且在该线程执行回收工作时,其他线程进入阻塞状态

# Serial 收集器

Serial:串行垃圾收集器,作用于新生代,使用单线程进行垃圾回收,采用的 GC 算法是复制算法,会导致 STW 。

STW(Stop-The-World):垃圾回收时,只有一个线程在工作,并且 Java 应用中的所有其他线程都要暂停,等待垃圾回收的完成。

# Serial Old 收集器

Serial old:串行垃圾收集器,作用于老年代,使用单线程进行垃圾回收,采用的 GC 算法是标记 - 整理算法,会导致 STW 。

  • Serial old 是 Client 模式下默认的老年代的垃圾回收器
  • Serial old 在 Server 模式下主要有两个用途:
    • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用
    • 作为老年代 CMS 收集器的后备垃圾回收方案,在并发收集发生 Concurrent Mode Failure 时使用

img

# 优缺点

优点:简单高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,可以获得最高的单线程收集效率

缺点:对于交互性较强的应用而言,这种垃圾收集器是不能够接受的,比如 JavaWeb 应用

# ParNew 收集器

并行的收集器

ParNew:Par 是 Parallel 并行的缩写,New 是只能处理的是新生代。采用的 GC 算法是复制算法,将单线程改为了 **多线程** 进行垃圾回收,可以缩短垃圾回收的时间。

区分并行与并发:并行(Parallelism)、并发(Concurrency)

img

对于其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同 Serial 收集器一样,应用在年轻代,除 Serial 外,只有 ParNew GC 能与 CMS 收集器配合工作

ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器

  • 对于新生代,回收次数频繁,使用并行方式高效
  • 对于老年代,回收次数少,使用串行方式节省资源(CPU 并行需要切换线程,串行可以省去切换线程的资源)

相关参数:

  • -XX:+UseParNewGC :表示年轻代使用并行收集器,不影响老年代
  • -XX:ParallelGCThreads :默认开启和 CPU 数量相同的线程数

# 吞吐量优先

并行的收集器

特点:

  • 多线程
  • 堆内存较大,多核 cpu
  • 希望一定时间内的 STW 总时长最短 0.2 0.2 = 0.4

image-20230921203308852

img

# Parallel Scavenge 收集器

Parallel Scavenge:应用于新生代并行垃圾回收器,GC 算法采用复制算法,会导致 STW

# Parallel Old 收集器

Parallel Old:应用于老年代并行垃圾回收器,GC 算法采用标记 - 整理算法

# 相关 VM 参数

开启参数:

  • -XX:+UseParallelGC :指定年轻代使用 Parallel Scavenge 并行收集器

  • -XX:+UseParalleOldGC :指定老年代使用 Parallel Old 收集器

    上面两个参数,默认开启一个,另一个也会被开启(互相激活), JDK8 是默认开启

以下参数都是调整新生代的 Parallel Scavenge 收集器

  • -XX:+UseAdaptiveSizePolicy :设置自适应调节策略。自动调整以下参数:
    • 新生代的大小
    • Eden 和 Survivor 的比例
    • 新生代的对象晋升老年代的年龄阈值
  • -XX:ParallelGCThreads=n :设置收集器的线程数,一般与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能
    • 在默认情况下,当 CPU 数量小于 8 个,ParallelGCThreads 的值等于 CPU 数量
    • 当 CPU 数量大于 8 个,ParallelGCThreads 的值等于 3+[5*CPU Count]/8]
  • -XX:MaxGCPauseMillis=ms :设置垃圾收集器的 **最大停顿时间(即 STW 的时间)**,单位是毫秒,默认是 200ms
    • 对于用户来讲,停顿时间越短体验越好;在服务器端,注重高并发,整体的吞吐量
    • 为了把停顿时间控制在 MaxGCPauseMillis 以内,收集器在工作时会调整 Java 堆大小或其他一些参数
  • -XX:GCTimeRatio=ratio :设置 **吞吐量的大小**,计算公式是 1/(1+radio),ratio 越大,计算结果越小,GC 时间的目标占比越小,吞吐量的目标越大。
    • ratio 的取值范围(0,100),默认是 99,也就是垃圾回收时间不超过 1
    • -XX:MaxGCPauseMillis 参数有一定矛盾性,暂停时间越长,Radio 参数就容易超过设定的比例

# 小结

对比其他回收器:

  • 其它收集器目标是尽可能缩短 GC 时用户线程的停顿时间
  • Parallel 目标是达到一个可控制的吞吐量,被称为吞吐量优先收集器
  • Parallel Scavenge 对比 ParNew 拥有自适应调节策略

应用场景:

  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验
  • 高吞吐量可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互

暂停时间和吞吐量的关系:新生代空间变小 → 缩短暂停时间 → 垃圾回收变得频繁 → 导致吞吐量下降

在注重吞吐量及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 收集器,在 Server 模式下的内存回收性能很好,Java8 默认是此垃圾收集器组合

# 停顿时间优先(低延时)

特点:

  • 多线程
  • 堆内存较大,多核 cpu
  • 希望 STW 的单次时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5

# CMS 收集器

并发的收集器

# CMS 特点

CMS(Concurrent Mark Sweep):是一款并发的、使用标记 - 清除算法、针对老年代的垃圾回收器,其最大特点是让垃圾收集线程与用户线程同时工作,但可能产生内存碎片

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

# CMS 工作过程

image-20230922152445957

img

分为以下四个流程:

  • 初始标记:使用 STW 出现短暂停顿,仅标记 GC Roots 直接关联的对象,速度很快

  • 并发标记:从 GC Roots 对象出发沿着引用链遍历整个对象图,找出所有可达对象(存活对象),在整个回收过程中耗时最长,不需要 STW,可以与用户线程并发运行

  • 重新标记:扫描 **整个堆内存上** 的对象,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象,比初始标记时间长,但远比并发标记时间短,需要 STW

    采用写屏障(pre-write barrier)技术,在对象引用改变前,将对象加入到 satb_mark_queue 中,将来可以对此重新标记。

  • 并发清除:清除可以回收的垃圾对象,不需要移动存活对象,因此会造成内存碎片。可以与用户线程同时并发执行,因此仍然可能产生新的垃圾,即 **浮动垃圾**(本轮 GC 本应该回收,但是没有回收到的内存)。

    • 不采用标记 - 整理算法的原因:标记 - 整理算法会整理内存,导致用户线程使用的对象地址改变,影响用户线程继续执行。
    • 当用户需要存入一个大对象,而新生代空间不足时,老年代由于浮动垃圾过多,就会退化为 serial Old 收集器,将老年代垃圾进行标记 - 整理,当然这也是很耗费时间的!
# 相关 VM 参数
  • -XX:+UseConcMarkSweepGC :指定老年代使用 CMS 收集器执行内存回收任务

    开启该参数后会自动将 -XX:+UseParNewGC 打开,即新生代使用的 ParNew 收集器,是并行的,采用复制算法。即 ParNew + CMS + Serial old 的组合

  • -XX:ParallelGCThreads :设置并行时的 GC 线程数,一般与 CPU 核数保持一致

  • -XX:ConcGCThreads :设置并发时的 GC 线程数,一般设为 CPU 核数的 1/4。即 3/4 作用户线程,1/4 作 GC 线程,可见 CMS 收集器对 CPU 的占用率并不高,但是对吞吐量是有影响的。

  • -XX:CMSInitiatingOccupancyFraction=percent :设置老年代在堆内存中的初始占用率,代表触发老年代 GC 的阈值,一旦达到该阈值,便开始采用 CMS 收集器进行垃圾回收

    • JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68% 时,会执行一次 CMS 回收
    • JDK6 及以上版本默认值为 92%
  • -XX:+CMSScavengeBeforeRemark在重新标记阶段前,对新生代进行一次 GC(ParNew 收集器),以减轻重新标记所做的无用功。

  • -XX:+UseCMSCompactAtFullCollection :用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生,由于内存压缩整理过程无法并发执行,所带来的问题就是 STW 时间变得更长

  • -XX:CMSFullGCsBeforeCompaction设置在执行多少次 Full GC 后对内存空间进行压缩整理

# CMS 优缺点

优点:

  • 并发收集
  • 低延迟

缺点:

  • 吞吐量降低:在并发阶段虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,CPU 利用率不够高

  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure 导致另一次 Full GC 的产生

    浮动垃圾:并发清除阶段由于用户线程继续运行而产生的垃圾(产生了新对象),这部分垃圾只能到下一次 GC 时才能进行回收。

    由于浮动垃圾的存在,CMS 收集需要预留出一部分内存,不能等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时 CMS 收集器将退化为 Serial Old 收集器,导致很长的停顿时间

  • 内存碎片:往往导致老年代空间无法找到一块足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

# G1 收集器

Garbage-First,一种 ** 基于区域(region)** 的垃圾回收器,用于代替 CMS,JDK9 开始成为默认的 GC 器

# G1 特点

G1 是一款应用于新生代和老年代的垃圾收集器,整体采用标记 - 整理算法,区域之间采用复制算法、软实时、低延迟、** 可设定目标(最大 STW 停顿时间,默认 200ms)** 的垃圾回收器,用于代替 CMS,适用于超大堆内存(>4 ~ 6G),会将堆内存划分为多个大小相等的区域。

G1 的优点:

  • 并行、并发

    • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时用户线程 STW
    • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时并发执行,因此不会在整个回收阶段发生完全阻塞应用程序的情况
    • 其他的垃圾收集器使用 内置的 JVM 线程 执行 GC 的多线程操作,而 G1 收集器可以采用应用线程承担后台运行的 GC 工作。JVM 的 GC 线程处理速度慢时,系统会调用 应用程序线程 加速垃圾回收过程
  • 分区算法

    • 从分代上看,G1 属于分代型垃圾回收器,区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。从堆结构上看,新生代和老年代不再物理隔离,不用担心每个代内存是否足够,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC

    • 将整个堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块可以单独进行 GC,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32 MB 之间且为 2 的 N 次幂,所有 Region 大小相同,在 JVM 生命周期内不会被改变。

    • 新的区域 Humongous本身属于老年代,当出现了一个巨型对象超出了分区容量的一半,该对象就会进入到该区域。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储,为了能找到连续的 H 区,有时候不得不启动 Full GC

    • G1 不会对巨型对象进行拷贝,回收时被优先考虑。G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉

    • Region 结构图

      img

  • 空间整合策略

    • CMS:标记 - 清除算法、内存碎片、若干次 GC 后进行一次碎片整理
    • G1:整体来看是标记 - 整理算法的收集器,从局部(Region 之间)上来看是基于复制算法实现的,两种算法都可以避免内存碎片
  • 可预测的停顿时间模型(软实时 soft real-time):可以指定在 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒

    • 由于分块的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,对于全局停顿情况也能得到较好的控制

    • G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间,通过过去回收的经验获得),在后台维护一个优先列表,每次根据允许的收集时间优先回收价值最大的 Region,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率

    • 相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多

G1 的缺点:

  • 相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用,还是程序运行时的额外执行负载,都要比 CMS 要高
  • 从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势,平衡点在 6-8GB 之间

应用场景:

  • 面向服务端应用,针对具有大内存、多处理器的机器
  • 需要低 GC 延迟,并具有大堆的应用程序提供解决方案
# 记忆集(Rset)

记忆集 Rset(Remembered Set) 在 **新生代** 中,每个 Region 都有一个 Remembered Set,用来记录自身被哪些 Region 中的对象引用(谁引用了我就记录谁)。

img

  • 程序对 Reference 类型数据写操作时,产生一个 Write Barrier 暂时中断操作,检查该对象和 Reference 类型数据是否在不同的 Region(跨代引用),不同就将相关引用信息记录到 Reference 类型所属的 Region 的 Remembered Set 之中
  • 进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏

垃圾收集器在新生代中建立了记忆集这样的数据结构,可以将 Rset 理解为一个抽象类,具体有三种实现方式

  • 字长精度
  • 对象精度
  • 卡精度 (卡表)
# 卡表(Card Table)

卡表(Card Table) 在 **老年代中,是一种对记忆集的具体实现,主要定义了记忆集的记录精度、与堆内存的映射关系等,卡表中的每一个元素都对应着一块特定大小的内存块,这个内存块称之为 卡页(card page) 。当存在跨代引用(老年代对象引用新生代对象)** 时,会将卡页标记为 dirty,JVM 对于卡页的维护也是通过写屏障(post

-write barrier + dirty card queue)的方式

收集集合 CSet 代表每次 GC 暂停时回收的一系列目标分区,在任意一次收集暂停中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。年轻代收集 CSet 只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到 CSet 中

  • CSet of Young Collection
  • CSet of Mix Collection
# G1 工作过程
# 概述

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

  • 当堆内存的使用比例达到预设的阈值(默认 45%,可以通过 -XX:InitiatingHeapOccupancyPercent 设置)时,开始在整个堆上进行并发标记
  • 标记完成马上开始 Mixed GC

image-20230923000741533

垃圾回收顺序(顺时针):Young GC → Young GC + 并发标记 → Mixed GC

# Young GC

发生在年轻代的 GC 算法。

一般对象(除了巨型对象)都是在 eden region 中分配内存

image-20230923002237743

当所有 eden region 被耗尽无法申请内存时,就会触发一次 Young GC引发 STW ,把活跃对象通过复制算法放入 survivor 区

image-20230923002252284

当 Survivor region 的空间紧张时,又会触发一次 Young GC,通过复制算法移动到其他 Survivor region 中。当活跃对象的年龄达到阈值时,晋升到老年代

image-20230923002306276

Young GC 的具体过程

  1. 扫描根:根引用连同 RSet 记录的外部引用作为扫描存活对象的入口

  2. 更新 RSet:处理 dirty card queue 更新 RSet,此后 RSet 准确的反映对象的引用关系

    • dirty card queue:类似缓存,产生了引用先记录在这里,然后更新到 RSet
    • 作用:产生引用直接更新 RSet 需要线程同步开销很大,使用队列性能好
  3. 处理 RSet:识别被老年代对象指向的 Eden 中的对象,这些被指向的对象被认为是存活的对象,把需要回收的分区放入 Young CSet 中进行回收

    Young GC 的跨代引用问题:即老年代对象引用新生代对象。

    卡表(Card Table) :使用在 **老年代中,是一种对记忆集 Rset 的具体实现,卡表中的每一个元素都对应着一块特定大小的内存块,称为 卡页(card page) 。当存在跨代引用时,会将卡页标记为 dirty,即脏卡 **。

    记忆集(Rset) 存在于 **新生代** 中,用于记录自身被哪些 Region 中的对象引用,因此可用于记录新生代对象对应的脏卡

    好处:将来进行 GC Roots 对象遍历时,不需要扫描整个老年代(卡表),只需要关注标记为 dirty 的卡页即可,缩小了搜索范围。

    image-20230924102519002

  4. 复制对象

    • Eden 区内存段中存活的对象会被复制到 survivor 区
    • survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加 1
    • survivor 区内达到阈值的存活对象会被复制到 old 区中空的内存分段
    • 如果 survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间
  5. 处理引用:处理 Soft,Weak,Phantom,JNI Weak 等引用,最终 Eden 空间的数据为空,GC 停止工作

# Concurrent Mark

并发标记,触发条件:堆空间的使用比例达到预设阈值(InitiatingHeapOccupancyPercent,默认 45%)时

img

  • 初始标记:在 Young GC 时会标记从根节点直接可达的对象,这个阶段是 STW 的,并不占用并发标记的时间。
  • 并发标记堆空间的使用比例达到预设阈值(InitiatingHeapOccupancyPercent,默认 45%)时,会在整个堆中进行并发标记(不会 STW,可能被 Young GC 中断。此过程会计算每个区域的存活对象比例,若区域中的所有对象都是垃圾,则这个区域会被立即回收(实时回收),为浮动垃圾准备出更多的空间,把需要收集的 Region 放入 CSet 当中。
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程(STW),但是可并行执行防止漏标
  • 筛选回收:并发清理阶段,首先对 CSet 中各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,也需要 STW
# Mixed GC

当很多对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,除了回收整个 young region,还会回收一部分回收价值高的 old region,过程同 Young GC

注意:是一部分老年代(回收价值高的),而不是全部老年代,因为指定了最大停顿时间( -XX:MaxGCPauseMillis ),如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,Mixed GC 仅回收最有价值的老年代(回收后,能够得到更多内存)。

image-20230923193042183

# Full GC

当对象内存分配速度过快,Mixed GC 来不及回收,导致老年代被填满时,就会触发一次 Full GC。G1 的 Full GC 算法就是多线程执行的垃圾回收,会导致异常长时间的暂停(STW)时间,需要进行不断的调优,尽可能的避免 Full GC 。

产生 Full GC 的原因:

  • 新生代晋升时没有足够的老年代空间
  • 并发处理过程中产生浮动垃圾,导致空间耗尽
# 相关 VM 参数
  • -XX:+UseG1GC :使用 G1 垃圾收集器执行内存回收任务
  • -XX:G1HeapRegionSize :设置每个 Region 的大小。取值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域,默认是堆内存的 1/2000
  • -XX:MaxGCPauseMillis :设置期望的最长 GC 停顿时间指标,JVM 会尽力实现,但不保证达到,默认值是 200ms
  • -XX:+ParallelGcThread :设置 STW 时 GC 线程数的值,最多设置为 8
  • -XX:ConcGCThreads :设置并发标记线程数,设置为并行垃圾回收线程数 ParallelGcThreads 的 1/4 左右
  • -XX:InitiatingHeapoccupancyPercent :设置触发并发 Mixed GC 周期的 Java 堆占用率阈值,超过此值,就触发 GC,默认值是 45
  • -XX:+ClassUnloadingWithConcurrentMark :并发标记类卸载,默认启用,所有对象都经过并发标记后,就可以知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类
  • -XX:G1NewSizePercent :新生代占用整个堆内存的最小百分比(默认 5%)
  • -XX:G1MaxNewSizePercent :新生代占用整个堆内存的最大百分比(默认 60%)
  • -XX:G1ReservePercent=10 :保留内存区域,防止 to space(Survivor 中的 to 区)溢出
# G1 优化
# 字符串去重

JDK 8u20

问题:

String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}

过程:

  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1 并发检查是否有重复的字符串
  • 如果字符串的值一样,就让它们引用同一个字符串对象
  • 注意,其与 String.intern() 的区别
    • String.intern () 关注的是字符串对象(引用地址)
    • 字符串去重关注的是 char []
    • 在 JVM 内部,使用了不同的字符串表

优点:节省了大量内存

缺点:新生代回收时间略微增加,导致略微多占用 CPU

VM 参数: -XX:+UseStringDeduplication ,默认开启

# 并发标记时的类卸载

JDK 8u40

在并发标记结束后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不再使用,则卸载它所加载的所有类。

VM 参数: -XX:+ClassUnloadingWithConcurrentMark ,默认开启

# 回收巨型对象

JDK 8u60

  • 一个对象大于 region 的一半时,就称为巨型对象
  • G1 不会对巨型对象进行拷贝
  • 回收时,巨型对象被优先考虑
  • G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉

在这里插入图片描述

# 并发标记的起始时间的调整
  • 并发标记的触发条件:当堆内存的使用比例达到预设阈值(默认 45%)时,开始在整个堆上进行并发标记
  • 并发标记必须在堆空间占满前完成,否则会退化为 Full GC
  • JDK 9 之前:通过 -XX:InitiatingHeapOccupancyPercent 设置阈值
  • JDK 9 :可以动态调整阈值
    • -XX:InitiatingHeapOccupancyPercent 用来设置阈值的初始值
    • GC 时进行对数据采样,并动态调整阈值
    • 总是会添加一个安全的空挡空间,以容纳浮动垃圾,尽可能避免老年代被填满而导致 Full GC
# G1 调优

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

  1. 开启 G1 垃圾收集器
  2. 设置堆的最大内存
  3. 设置最大的停顿时间(STW)

不断调优停顿时间指标

  • XX:MaxGCPauseMillis=x 可以设置启动应用程序暂停的时间,G1 会根据这个参数选择 CSet 来满足响应时间的设置
  • 设置到 100ms 或者 200ms 都可以(不同情况下会不一样),但设置成 50ms 就不太合理
  • 暂停时间设置的太短,就会导致出现 G1 跟不上垃圾产生的速度,最终退化成 Full GC
  • 对这个参数的调优是一个持续的过程,逐步调整到最佳状态

不要显示地设置新生代和老年代的大小

  • 避免使用 -Xmn-XX:NewRatio 等相关选项显式设置年轻代大小,G1 收集器在运行的时候会调整新生代和老年代的大小,从而达到我们为收集器设置的暂停时间目标
  • 设置了新生代大小相当于放弃了 G1 的自动调优,我们只需要设置整个堆内存的大小,剩下的交给 G1 自己去分配各个代的大小

# ZGC 收集器

在 JDK11 中引入,追求低延时,希望停顿时间不会超过 10ms

# ZGC 特点

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

# 基于 Region 的内存布局

和 G1 一样,ZGC 也采取基于 Region 的堆内存布局,但与他们不同的是,ZGC 的 Region 具有动态性(动态的创建、销毁,以及动态的区域容量大小),可以分为三类:

  • 小型 Region:容量固定为 2MB,用于放置小于 256KB 的小对象。
  • 中型 Region:容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象。
  • 大型 Region容量可以动态变化,但必须为 2MB 的整数倍,用于存放 4MB 或以上的大对象。并且每个大型 Region只存放一个对象

在这里插入图片描述

# 可并发的标记 - 整理算法

ZGC 使用了读屏障、染色指针和内存多重映射等技术来实现 **可并发的标记 - 整理算法**。

  • 在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障

  • 染色指针:直接将少量的标记信息存储在引用对象的指针上,从 64 位的指针中拿高 4 位来标识对象此时的状态

    染色指针示意图

    • 染色指针可以使某个 Region 的存活对象被移走之后,这个 Region 能被立即释放和重用
    • 可以直接从指针中看到引用对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集、是否被移动过(Remapped)、是否只能通过 finalize () 方法才能被访问到(Finalizable)
    • 可以大幅减少在垃圾收集过程中内存屏障的使用数量,写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作
    • 可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据
  • 内存多重映射:多个虚拟地址指向同一个物理地址

可并发的标记 - 整理算法:染色指针标识对象是否被标记或移动,读屏障保证在每次应用程序或 GC 程序访问对象时先根据染色指针的标识判断是否被移动,如果被移动就根据转发表访问新的移动对象,并更新引用,不会像 G1 一样必须等待垃圾回收完成才能访问。

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

ZGC 的运作过程大致可划分为以下四个大的阶段,每阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段

ZGC运行过程

  • 并发标记(Concurrent Mark): 遍历对象图做可达性分析的阶段,也要经过初始标记和最终标记,需要短暂停顿
  • 并发预备重分配(Concurrent Prepare for Relocate):根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set)
  • 并发重分配(Concurrent Relocate): 重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个 转发表(Forward Table) ,记录从旧地址到新地址的转向关系
  • 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中旧对象的所有引用,ZGC 的并发映射并不是一个必须要立即完成的任务,ZGC 很巧妙地把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段里去完成,因为都是要遍历所有对象,这样合并节省了一次遍历的开销

ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的,但这部分的实际时间是非常少的,所以响应速度快,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟

# ZGC 优缺点

优点:高吞吐量、低延迟

缺点:浮动垃圾,当 ZGC 准备要对一个很大的堆做一次完整的并发收集,其全过程要持续十分钟以上,由于应用的对象分配速率很高,将创造大量的新对象产生浮动垃圾

参考文章:https://www.cnblogs.com/jimoer/p/13170249.html

# 总结

Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 的不同:

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

img

# 5、GC 调优

# 前言

# 预备知识

  • 掌握 GC 相关的 VM 参数,会基本的空间调整

    查看虚拟机的 VM 参数的命令: D:\JavaJDK1.8\bin\java -XX:+PrintFlagsFinal -version | findstr "GC" ,可以根据参数去查询具体的信息。

  • 掌握相关工具

  • 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则

# 调优领域

  • 内存
  • 锁竞争
  • cpu 占用
  • io
  • GC

# 确定目标

高吞吐量 / 低延时? 选择合适的 GC 器

  • 高吞吐量:Parallel GC

  • 低延时(响应时间优先):CMS -> G1 -> ZGC

Zing 虚拟机的垃圾回收效率比 Hotspot 虚拟机更高!

# 最快的 GC

最快的 GC 是不发生 GC !首先排除减少因为自身编写的代码而引发的内存问题

  • 查看 Full GC 前后的内存占用,考虑以下几个问题
    • 数据是不是太多?
      • resultSet = statement.executeQuery (“select * from 大表 limit n”)
    • 数据类型表示是否太臃肿?
      • 对象图
      • 对象大小 16 Integer 24 int 4
    • 是否存在内存泄漏?
      • static Map map …
      • 第三方缓存实现

# 新生代调优

新生代的特点:

  • 所有的 new 操作分配内存都是非常廉价的

    • TLAB:thread-local allocation buffer ,线程私有的分配缓存区,每个线程使用自己私有的 Eden 区内存来进行对象的内存分配,效率很高

      image-20230924162720790

  • 死亡对象的回收是零代价的

    • 所有 GC 器在新生代的回收算法都是复制算法,将 Eden 区、From 区中的存活对象复制到 To 区中,复制完毕后 Eden 区、From 区的内存都被释放。
  • 大部分对象用过即死(朝生夕死)

  • 因为上一点,所以 Minor GC 所用时间远小于 Full GC

新生代内存越大越好么?当然不是!

  • 新生代内存太小:频繁触发 Minor GC ,会 STW ,会使得吞吐量下降

  • 新生代内存太大:老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长。

    新生代空间大小与吞吐量的关系
  • 新生代内存大小设置为能容纳所有【并发量 * (请求 - 响应)】的数据为宜,大约 512M

新生代的 Survivor 区需要能够保存【当前活跃对象 + 需要晋升的对象】

晋升阈值需要配置得当,让长时间存活的对象尽快晋升。

  • -XX:MaxTenuringThreshold=threshold :设置晋升阈值

  • -XX:+PrintTenuringDistrubution :打印 Survivor 区的详细信息

# 老年代调优

以 CMS 为例:

  • CMS 的老年代内存越大越好
  • 先尝试不做调优,如果并没有 Full GC 那么说明老年代的空间充裕,否则,也是先尝试调优新生代。如果还是不奏效,再考虑老年代调优。
  • 观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
    • -XX:CMSInitiatingOccupancyFraction=percent :设置老年代在堆内存中的预设占用率,代表触发 Full GC 的阈值。一般设为 75%~80%,也就是预留大概 25% 的空间给浮动垃圾。一旦达到该阈值,便开始采用 CMS 收集器进行垃圾回收。

# 案例

案例 1:Full GC 和 Minor GC 频繁

解决方法:

  1. 增大新生代内存:新生代的内存充裕了,自然就减少了 Minor GC
  2. 增大 Survivor 区空间,以及晋升阈值:让很多生命周期较短的对象能尽可能留在新生代,而不进入老年代,减少 Full GC

案例 2:请求高峰期发生 Full GC,单次暂停时间特别长(CMS)

解决方法:

使用 CMS 垃圾回收器时单次暂停时间特别长,通过查看 GC 日志确定是哪个阶段最耗时:初始标记、并发标记、重新标记、并发清理。发现是重新标记阶段最耗时,因为会扫描整个堆内存上的对象。

因此可以考虑 **开启 VM 参数 -XX:+CMSScavengeBeforeRemark ,在重新标记前,对新生代进行一次 GC(ParNew 收集器),以缩小重新标记阶段的搜索范围。**

案例 3:老年代充裕情况下,发生 Full GC(CMS、jdk1.7)

CMS 中产生 Full GC 的原因:

  1. 空间不足,导致并发失败
  2. 内存碎片过多

但是老年代空间其实是充裕的,不存在以上问题。而是因为 jdk1.7,采用的是永久代作为方法区的实现,而堆和永久代在逻辑上虽然是分开的,但在物理上来说,它们是连续的一块内存! 因此如果永久代的空间不足时,会触发堆的 Full GC !

解决方案:增大永久代空间大小的初始值、最大值,确保了 Full GC 不再发生。

# 第四章:类加载与字节码技术

image-20230924182346694

# 1、类文件的结构

类文件(.class),也叫二进制字节码文件,内容是 JVM 的字节码指令,不是机器码,经过编译得到,而后供虚拟机解释执行。

C、C++ 经由编译器直接生成机器码,所以执行效率比 Java 高。

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/

# 概述

一个简单的 HelloWorld.java:

package cn.itcast.jvm.t5;
// HelloWorld 示例
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

通过命令 javac -parameters -d . HellowWorld.java 编译后,会生成一个类文件(.class):

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 
0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07 
0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29 
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e 
0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63 
0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01 
0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63 
0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f 
0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16 
0000220 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 
0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13 
0000260 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 
0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61 
0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46 
0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64
0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e 
0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64 
0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74 
0000440 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 
0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61 
0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61 
0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f 
0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72 
0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76 
0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d 
0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a 
0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 
0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01 
0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00 
0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00 
0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00 
0001000 0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00 
0001020 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a 
0001040 00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b 
0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00 
0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00 
0001120 00 00 02 00 14

根据 JVM 规范,类文件(.class)的结构如下:

ClassFile {
	u4 				magic;						
    u2 				minor_version;						
    u2 				major_version;						
    u2 				constant_pool_count;
    cp_info			constant_pool[constant_pool_count-1];
    u2	 			access_flags;
    u2 				this_class;
    u2 				super_class;
    u2 				interfaces_count;
    u2 				interfaces[interfaces_count];
    u2 				fields_count;
    field_info 		fields[fields_count];
    u2 				methods_count;
    method_info 	methods[methods_count];
    u2 				attributes_count;
    attribute_info 	attributes[attributes_count];
}

具体信息如下表:

类型(字节数)名称含义长度数量
u4magic魔数,标识类文件的格式4 个字节1
u2minor_version副版本号 (小版本)2 个字节1
u2major_version主版本号 (大版本)2 个字节1
u2constant_pool_count常量池计数器,表示常量池的长度2 个字节1
cp_infoconstant_pool常量池表n 个字节constant_pool_count-1
u2access_flags访问标识2 个字节1
u2this_class本类(的全限定名的)索引2 个字节1
u2super_class父类(的全限定名的)索引2 个字节1
u2interfaces_count接口计数2 个字节1
u2interfaces接口索引集合2 个字节interfaces_count
u2fields_count字段计数器2 个字节1
field_infofields字段表n 个字节fields_count
u2methods_count方法计数器2 个字节1
method_infomethods方法表n 个字节methods_count
u2attributes_count属性计数器2 个字节1
attribute_infoattributes属性表n 个字节attributes_count

img

Class 文件的结构中只有两种数据类型:

  • 无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串
  • :是由多个无符号数或者其他表作为数据项构成的复合数据类型,表都_info 结尾,用于描述有层次关系的数据,整个 Class 文件本质上就是一张表,由于表没有固定长度,所以通常会在其前面加上个数说明

下面对一些重要的信息展开介绍。

# 魔数

魔数(u4,magic):每个 Class 文件开头的4 个字节的无符号整数,是 Class 文件的标识符

  • 0~3 字节即为魔数值,固定为 0xCAFEBABE ,不符合则会抛出错误

  • 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动

# 版本

  • 副版本号(u2,minor_version)

  • 主版本号(u2,major_version)

举例:4~7 字节 00 00 00 34 表示主版本号是 00 34(16 进制) = 52(10 进制),代表 JDK8

主版本(十进制)副版本(十进制)编译器版本
4531.1
4601.2
4701.3
4801.4
4901.5
5001.6
5101.7
5201.8
5301.9
5401.10
5501.11

不同版本的 Java 编译器编译的 Class 文件对应的版本是不一样的,高版本的 Java 虚拟机可以执行由低版本编译器生成的 Class 文件,反之 JVM 会抛出异常 java.lang.UnsupportedClassVersionError

# 常量池 (表)

  • 常量池计数器(u2,constant_pool_count):记录常量池的长度(即常量的数目)

    • 8~9 字节表示常量池长度, 00 23 (35) 表示常量池有 #1~#34 项,注意 #0 项不计入,也没有值
      0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09`
    • 第 0 项不记录,是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达不引用任何一个常量池项目,这种情况可用索引值 0 来表示
  • 常量池表(cp_info,constant_pool):一种表结构,以 1 ~ constant_pool_count - 1 为索引,表明有多少个常量池表项。表中存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池

    • 字面量(Literal) :基本数据类型、字符串类型常量、声明为 final 的常量值等

    • 符号引用(Symbolic References):类和接口的全限定名、字段的名称和描述符、方法的名称和描述符

      • 全限定名:com/test/Demo 这个就是类的全限定名,仅仅是把包名的 . 替换成 / ,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个 ; 表示全限定名结束

      • 简单名称:指没有类型和参数修饰的方法或者字段名称,比如字段 x 的简单名称就是 x

      • 描述符:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值

        标志符含义
        B基本数据类型 byte
        C基本数据类型 char
        D基本数据类型 double
        F基本数据类型 float
        I基本数据类型 int
        J基本数据类型 long
        S基本数据类型 short
        Z基本数据类型 boolean
        V代表 void 类型
        L对象类型,比如: Ljava/lang/Object; ,不同方法间用 ; 隔开
        [数组类型,代表一维数组。比如: double[][][] is [[[D
        ()无参

常量类型和结构

标识类型描述取值的含义
1CONSTANT_utf8_infoUTF-8 编码的字符串【字符串长度】和【字符串】
3CONSTANT_Integer_info整型字面量
4CONSTANT_Float_info浮点型字面量
5CONSTANT_Long_info长整型字面量
6CONSTANT_Double_info双精度浮点型字面量
7CONSTANT_Class_info类或接口的符号引用【引用】
8CONSTANT_String_info字符串类型字面量【引用】
9CONSTANT_Fieldref_info字段的符号引用字段的【所属类】和【字段名】
10CONSTANT_Methodref_info类中方法的符号引用方法的【所属类】和【方法名】
11CONSTANT_InterfaceMethodref_info接口中方法的符号引用
12CONSTANT_NameAndType_info字段或方法的符号引用【名】和【类型】
15CONSTANT_MethodHandle_info表示方法句柄
16CONSTANT_MethodType_info标志方法类型
18CONSTANT_InvokeDynamic_info表示一个动态方法调用点

18 种常量没有出现 byte、short、char,boolean 的原因:编译之后都可以理解为 Integer

# 访问标识

访问标识(u2,access_flag)用 2 个字节表示,用于识别一些类或者接口层次的访问信息,包括这个 Class 是类还是接口,是否定义为 public 类型,是否定义为 abstract 类型等

  • 类的访问权限通常为 ACC_ 开头的常量
  • 每一种类型的表示都是通过设置访问标记的 32 位中的特定位来实现的,比如若是 public final 的类,则该标记为 ACC_PUBLIC | ACC_FINAL
  • 使用 ACC_SUPER 可以让类更准确地定位到父类的方法,确定类或接口里面的 invokespecial 指令使用的是哪一种执行语义,现代编译器都会设置并且使用这个标记
标志名称标志值含义
ACC_PUBLIC0x0001标志为 public 类型
ACC_FINAL0x0010标志被声明为 final,只有类可以设置
ACC_SUPER0x0020标志允许使用 invokespecial 字节码指令的新语义,JDK1.0.2 之后编译出来的类的这个标志默认为真,使用增强的方法调用父类方法
ACC_INTERFACE0x0200标志这是一个接口
ACC_ABSTRACT0x0400是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC0x1000标志此类并非由用户代码产生(由编译器产生的类,没有源码对应)
ACC_ANNOTATION0x2000标志这是一个注解
ACC_ENUM0x4000标志这是一个枚举

# 继承信息(索引集合)

  • 类索引(u2,this_class):本类的全限定名的索引

  • 父类索引(u2,super_class):父类的全限定名的索引

    Java 语言不允许多重继承,所以父类索引只有一个,除了 Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0

  • 接口数量(u2,interfaces_count):当前类或接口的直接超接口数量

  • 接口索引集合(u2,interfaces [interfaces_count]):描述这个类实现了哪些接口,被实现的接口将按 implements 语句后的接口顺序从左到右排列在接口索引集合中

# 字段表

字段 fields 用于描述接口或类中声明的变量,包括类变量、实例变量,但不包括方法内部、代码块内部声明的局部变量以及从父类或父接口继承。字段叫什么名字、被定义为什么数据类型,都是无法固定的,因此只能引用常量池中的常量来描述字段的名字、数据类型

字段计数器(u2,fields_count):表示当前 class 文件 fields 表的成员个数,用两个字节来表示

字段表(field_info,fields [fields_count])

  • 表中的每个成员都是一个 fields_info 结构的数据项,用于表示当前类或接口中某个字段的完整描述

  • 字段访问标识

    标志名称标志值含义
    ACC_PUBLIC0x0001字段是否为 public
    ACC_PRIVATE0x0002字段是否为 private
    ACC_PROTECTED0x0004字段是否为 protected
    ACC_STATIC0x0008字段是否为 static
    ACC_FINAL0x0010字段是否为 final
    ACC_VOLATILE0x0040字段是否为 volatile
    ACC_TRANSTENT0x0080字段是否为 transient
    ACC_SYNCHETIC0x1000字段是否为由编译器自动产生
    ACC_ENUM0x4000字段是否为 enum
  • 字段名索引:根据该值查询常量池中的指定索引项即可

  • 描述符索引:用来描述字段的数据类型、方法的参数列表和返回值

    字符类型含义
    Bbyte有符号字节型树
    CcharUnicode 字符,UTF-16 编码
    Ddouble双精度浮点数
    Ffloat单精度浮点数
    Iint整型数
    Jlong长整数
    Sshort有符号短整数
    Zboolean布尔值 true/false
    Vvoid代表 void 类型
    L Classname;reference一个名为 Classname 的实例
    [reference一个一维数组
  • 属性表集合:属性个数存放在 attribute_count 中,属性具体内容存放在 attribute 数组中,一个字段还可能拥有一些属性,用于存储更多的额外信息,比如初始化值、一些注释信息等

    ConstantValue_attribute{
        u2 attribute_name_index;
        u4 attribute_length;
        u2 constantvalue_index;
    }
    

    对于常量属性而言,attribute_length 值恒为 2

# 方法表

方法表是 methods 指向常量池索引集合,其中每一个 method_info 项都对应着一个类或者接口中的方法信息,完整描述了每个方法的签名

  • 如果这个方法不是抽象的或者不是 native 的,字节码中就会体现出来
  • methods 表只描述当前类或当前接口中声明的方法,不包括从父类或父接口继承的方法
  • methods 表可能会出现由编译器自动添加的方法,比如 初始化方法 和 实例化方法

** 重载(Overload)** 一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存

方法计数器(u2,methods_count):表示 class 文件 methods 表的成员个数,使用两个字节来表示

方法表(method_info,methods [methods_count]):每个表项都是一个 method_info 结构,表示当前类或接口中某个方法的完整描述

  • 一个方法的组成:

    • 访问修饰符
    • 方法名称
    • 参数描述
    • 方法的属性数量
    • 方法的属性集合
  • 方法表结构如下:

    类型名称含义数量
    u2access_flags访问标志1
    u2name_index方法名索引1
    u2descriptor_index方法参数的描述符索引1
    u2attrubutes_count方法的属性计数器1
    attribute_infoattributes方法的属性集合attributes_count
  • 方法表访问标志:

    标志名称标志值含义
    ACC_PUBLIC0x0001字段是否为 public
    ACC_PRIVATE0x0002字段是否为 private
    ACC_PROTECTED0x0004字段是否为 protected
    ACC_STATIC0x0008字段是否为 static
    ACC_FINAL0x0010字段是否为 final
    ACC_VOLATILE0x0040字段是否为 volatile
    ACC_TRANSTENT0x0080字段是否为 transient
    ACC_SYNCHETIC0x1000字段是否为由编译器自动产生
    ACC_ENUM0x4000字段是否为 enum

# 属性表

属性表,指的是 Class 文件所携带的辅助信息,比如该 Class 文件的源文件的名称,以及任何带有 RetentionPolicy.CLASS 或者 RetentionPolicy.RUNTIME 的注解,这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试字段表、方法表都可以有自己的属性表,用于描述某些场景专有的信息

属性计数器(u2,attributes_count):表示当前文件属性表的成员个数

属性表(attribute_info,attributes [attributes_count]):属性表的每个项的值必须是 attribute_info 结构

  • 属性的通用格式

    ConstantValue_attribute{
        u2 attribute_name_index;	//属性名索引
        u4 attribute_length;		//属性的长度
        u2 attribute_info;			//属性表
    }
    
  • 属性类型:

    属性名称使用位置含义
    Code方法表Java 代码编译成的字节码指令
    ConstantValue字段表final 关键字定义的常量池
    Deprecated类、方法、字段表被声明为 deprecated 的方法和字段
    Exceptions方法表方法抛出的异常
    EnclosingMethod类文件仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
    InnerClass类文件内部类列表
    LineNumberTableCode 属性Java 源码的行号与字节码指令的对应关系
    LocalVariableTableCode 属性方法的局部变量描述
    StackMapTableCode 属性JDK1.6 中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
    Signature类,方法表,字段表用于支持泛型情况下的方法签名
    SourceFile类文件记录源文件名称
    SourceDebugExtension类文件用于存储额外的调试信息
    Syothetic类,方法表,字段表标志方法或字段为编泽器自动生成的
    LocalVariableTypeTable使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
    RuntimeVisibleAnnotations类,方法表,字段表为动态注解提供支持
    RuntimelnvisibleAnnotations类,方法表,字段表用于指明哪些注解是运行时不可见的
    RuntimeVisibleParameterAnnotation方法表作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法
    RuntirmelnvisibleParameterAnniotation方法表作用与 RuntimelnvisibleAnnotations 属性类似,作用对象哪个为方法参数
    AnnotationDefauit方法表用于记录注解类元素的默认值
    BootstrapMethods类文件用于保存 invokeddynanic 指令引用的引导方式限定符

# 2、字节码指令

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5

# javac 工具

javac:编译命令,将 java 源文件编译成 class 字节码文件

用法: javac xx.java 不会再生成对应的局部变量表等信息,使用 javac -g xx.java 可以生成所有相关信息

# javap 工具

javap:反编译生成的 class 字节码文件,反解析出当前类对应的 code 区 (字节码指令)局部变量表异常表代码行偏移量映射表常量池等信息

用法: javap -v xx.class

-help  --help  -?        输出此用法消息
-version                 版本信息
-public                  仅显示公共类和成员
-protected               显示受保护的/公共类和成员
-package                 显示程序包/受保护的/公共类和成员 (默认)
-p  -private             显示所有类和成员
						 #常用的以下三个
-v  -verbose             输出附加信息
-l                       输出行号和本地变量表
-c                       对代码进行反汇编	#反编译

-s                       输出内部类型签名
-sysinfo                 显示正在处理的类的系统信息 (路径, 大小, 日期, MD5 散列)
-constants               显示最终常量
-classpath <path>        指定查找用户类文件的位置
-cp <path>               指定查找用户类文件的位置
-bootclasspath <path>    覆盖引导类文件的位置

# 图解:方法的执行流程

# 1)原始 java 代码

// 演示 字节码指令 和 操作数栈、常量池的关系
public class Demo3_1 {    
	public static void main(String[] args) {        
		int a = 10;        
		int b = Short.MAX_VALUE + 1;        
		int c = a + b;        
		System.out.println(c);   
    } 
}

# 2)编译后的字节码文件

通过 javap 工具对字节码文件进行反编译

# 3)常量池载入运行时常量池

image-20230927094818385

# 4)方法字节码载入方法区

image-20230927095219061

# 5)main 线程开始运行,分配栈帧内存

(局部变量表 locals=4,操作数栈 stack=2)

image-20230927095114023

# 6)执行引擎开始执行字节码

首先回顾一下 8 种基本数据类型各自所占用的字节数:

基本数据类型占用字节数取值范围
byte1 字节-128(-27)~ 127(27-1),默认为 0
boolean至少 1 字节(根据编译环境而定)只作为一种标志来记录 true/false 情况,默认为 false
short2 字节-32768(-215) ~ 32767(215-1),默认为 0
char2 字节最小值是 \u0000(即为 0);最大值是 \uffff(即为 65,535);默认为 \u0000
int4 字节-231 ~ 231-1,默认为 0
long8 字节-263 ~ 263-1,默认为 0
float4 字节单精度浮点数字长 32 位,尾数长度 23,指数长度 8, 指数偏移量 127;默认为 0.0f
double8 字节双精度浮点数字长 64 位,尾数长度 52,指数长度 11,指数偏移量 1023;默认为 0.0d
# int a = 10
# bipush 10

将一个 byte (占 1 字节)类型的值压入操作数栈

  • 因为 10 在 -128~127 之间,属于 byte ,占用 1 个字节
  • 操作数栈的宽度是 4 个字节,但是 10 仅占 1 个字节,因此操作数栈的长度会通过填 0/1 来补齐

类似的指令还有:

  • sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
  • ldc 将一个 int 压入操作数栈
  • ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
  • 小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池,需要引用

image-20230927100649301

# istore 1

将操作数栈的栈顶数据弹出,存入局部变量表的 slot 1

image-20230927113307176

image-20230927113312719

# int b = Short.MAX_VALUE + 1
# ldc #3

从 **运行时常量池** 加载 #3 数据到操作数栈

注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的(常量折叠),因其超过了 Short 的范围,所以存入了常量池中,只能通过引用获取

image-20230927113704346

# istore 2

将操作数栈的栈顶数据弹出,存入局部变量表的 slot 2

image-20230927114136275

image-20230927114142111

# int c = a + b
# iload_1

将局部变量表的 slot 1 数据压入操作数栈

image-20230927114708877

# iload_2

将局部变量表的 slot 2 数据压入操作数栈

image-20230927114722537

# iadd

在操作数栈中执行相加操作

image-20230927114742467

将操作数弹出,将相加的结果压入栈

image-20230927114830526

# istore_3

将操作数栈的栈顶数据(相加的结果)弹出,存入局部变量表的 slot 3

image-20230927114916175

image-20230927115059701

# System.out.println(c)
# getstatic #4

从运行时常量池中找到 #4 项 —— 成员变量的引用,即 System 的 out 字段,找到堆中的 System.out 对象,将其引用压入操作数栈中

image-20230927115229920

image-20230927115250117

# iload_3

将局部变量表的 slot 3 数据压入操作数栈

image-20230927115414899

image-20230927115335556

# invokevirtual #5
  • 找到运行时常量池中的 #5 项,即方法的引用
  • 定位到方法区 java/io/PrintStream.println:(I) V 方法
  • 为新调用的方法生成新的栈帧(分配 locals、stack 等)
  • 传递参数,执行新栈帧中的字节码

image-20230927115523537

  • 执行完毕,弹出栈帧
  • 清除 main 操作数栈内容

image-20230927115635271

# return
  • 完成 main 方法调用,弹出 main 栈帧

  • 程序结束

# 练习:分析 i++

目的:从字节码的角度,分析 a++ 相关题目

源码:

public class Demo {
    public static void main(String[] args) {
        int a = 10;
        int b = a++ + ++a + a--; //10 + 12 + 12
        System.out.println(a);	//11
        System.out.println(b);	//34
    }
}

分析:

  • iinc 指令:直接在局部变量表的 slot 上进行运算

  • a++ 和 ++a 的区别:先执行 iload 还是 iinc,二者的字节码指令对应如下:

     4 iload_1		//存入操作数栈
     5 iinc 1 by 1	//自增i++
     8 istore_3		//把操作数栈没有自增的数据的存入局部变量表
     
     9 iinc 2 by 1	//++i
    12 iload_2		//加载到操作数栈
    13 istore 4		//存入局部变量表,这个存入没有 _ 符号,_只能到3
    

# int a = 10

image-20230927151854418

image-20230927151938029

# int b = a++ + ++a + a--

image-20230927152108793

对局部变量表中的 slot 1 数据自增 1

image-20230927152156880

image-20230927152325762

image-20230927152348727

image-20230927152414569

image-20230927152444354

image-20230927152449972

image-20230927152457700

image-20230927152544313

# 条件判断 指令

指令助记符说明
0x99ifeqequals,当栈顶 int 类型数值 == 0 时跳转
0x9aifnenot equals,当栈顶 int 类型数值!= 0 时跳转
0x9bifltlower than,当栈顶 int 类型数值 < 0 时跳转
0x9cifgegreater or equals,当栈顶 in 类型数值 >= 0 时跳转
0x9difgtgreater than,当栈顶 int 类型数组 > 0 时跳转
0x9eiflelower or equals,当栈顶 in 类型数值 <= 0 时跳转
0x9fif_icmpeq两个 int == 时跳转
0xa0if_icmpne两个 int != 时跳转
0xa1if_icmplt两个 int < 时跳转
0xa2if_icmpge两个 int >= 时跳转
0xa3if_icmpgt两个 int > 时跳转
0xa4if_icmple两个 int <= 时跳转
0xa5if_acmpeq两个引用 == 时跳转
0xa6if_acmpne两个引用!= 时跳转
0xc6ifnull为 null 时跳转
0xc7ifnonnull不为 null 时跳转

多条件分支跳转指令:

  • tableswitch:用于 switch 条件跳转,case 值连续
  • lookupswitch:用于 switch 条件跳转,case 值不连续

无条件跳转指令:

  • goto:用来进行跳转到指定行号的字节码
  • goto_w:无条件跳转(宽索引)

例如,对于源码:

public class Demo3_3 {
    public static void main(String[] args) {
        int a = 0;
        if(a == 0) {
        	a = 10;
        } else {
        	a = 20;
        }
    }
}

对应字节码:

0: iconst_0
1: istore_1
2: iload_1
3: ifne 12
6: bipush 10
8: istore_1
9: goto 15
12: bipush 20
14: istore_1
15: return

# 循环控制 指令

其实循环控制还是前面介绍的那些指令,例如 while 循环:

public class Demo3_4 {
    public static void main(String[] args) {
        int a = 0;
        while (a < 10) {
            a++;
        }
    }
}

字节码:

0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

再比如 do while 循环:

public class Demo3_5 {
    public static void main(String[] args) {
        int a = 0;
        do {
            a++;
        } while (a < 10);
    }
}

字节码:

0: iconst_0
1: istore_1
2: iinc 1, 1
5: iload_1
6: bipush 10
8: if_icmplt 2
11: return

for 循环:

public class Demo3_6 {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
        }
    }
}

字节码是:

0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

注意:比较 while 和 for 的字节码,你发现它们是一模一样的,殊途也能同归😊

# 练习:判断结果

请从字节码角度分析,下列代码运行的结果:

public class Demo3_6_1 {
    public static void main(String[] args) {
        int i = 0; // iconst_0 ; istore_1
        int x = 0; // bipush 0 ; istore_2
        while (i < 10) { //iload_1 ; bipush 10; if_icmpge 跳过 while
            x = x++; //iload_1 (将 0 加载到操作数栈); iinc 2 by 1 (对临时变量表中的 x 自增 1); istore_2(将操作数栈的栈顶元素 0 赋给临时变量表中的 x,x 从 1 变成 0)
            i++;
        } 
        System.out.println(x); // 结果是 0
    }
}

# 构造方法

# <clinit>()V

cl 代表 class,意为整个类的构造方法

public class Demo3_8_1 {
    static int i = 10;
    
    static {
    	i = 20;
    } 
    
    static {
    	i = 30;
    }
}

编译器会按从上至下的顺序,收集所有静态代码块、静态成员赋值的代码,合并为一个特殊的方法 <clinit>() V

0: bipush 10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return

<clinit>() V 方法会在类加载的初始化阶段被调用

# <init>()V

每个实例对象的构造方法

public class Demo3_8_2 {
    private String a = "s1";
    {
    	b = 20;
    } 
    private int b = 10;
    {
    	a = "s2";
    }
    public Demo3_8_2(String a, int b) {
        this.a = a;
        this.b = b;
    } 
    public static void main(String[] args) {
        Demo3_8_2 d = new Demo3_8_2("s3", 30);
        System.out.println(d.a);
        System.out.println(d.b);
    }
}

编译器会按从上至下的顺序,收集所有 {} 代码块、成员变量赋值的代码,形成新的构造方法,并将原始构造方法内的代码附加在最后

image-20230927163103068

# 方法调用 指令

普通调用指令:

  • invokestatic :调用静态方法
  • invokespecial :调用私有方法、构造器,和父类的实例方法或构造器,以及所实现接口的默认方法
  • invokevirtual :调用所有虚方法(虚方法分派)
  • invokeinterface :调用接口方法

动态调用指令:

  • invokedynamic :动态解析出需要调用的方法
    • Java7 为了实现动态类型语言支持而引入了该指令,但是并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令
    • Java8 的 lambda 表达式的出现,invokedynamic 指令在 Java 中才有了直接生成方式

指令对比:

  • 普通调用指令固化在虚拟机内部,方法的调用执行不可干预,根据方法的符号引用链接到具体的目标方法
  • 动态调用指令支持用户确定方法
  • invokestatic 和 invokespecial 指令调用的方法称为非虚方法,虚拟机能够直接识别具体的目标方法
  • invokevirtual 和 invokeinterface 指令调用的方法称为虚方法,虚拟机需要在执行过程中根据调用者的动态类型来确定目标方法

指令说明:

  • 如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为 final,那么可以不通过动态绑定,直接确定目标方法
  • 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态

举个例子,看一下几种不同的方法调用对应的字节码指令:

public class Demo3_9 {
    public Demo3_9() { }
    private void test1() { }
    private final void test2() { }
    public void test3() { }
    public static void test4() { }
    public static void main(String[] args) {
        Demo3_9 d = new Demo3_9();
        d.test1();
        d.test2();
        d.test3();
        d.test4();
        Demo3_9.test4();
    }
}

字节码:

0: new #2 // class cn/itcast/jvm/t3/bytecode/Demo3_9
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return
  • new 是创建【对象】,给对象分配堆内存,执行成功 **会将【对象引用】压入操作数栈**
  • dup 是 **复制操作数栈栈顶的内容,并压入栈顶**。本例即为复制一份【对象引用】,为什么需要两份引用呢,一个是要配合 invokespecial 调用该对象的构造方法 "<init>"😦)V (会消耗掉栈顶一个引用),另一个要配合 astore_1 赋值给局部变量(又消耗栈顶一个引用
  • 最终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定
  • 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态
  • 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
  • 比较有意思的是 d.test4 (); 是通过【对象引用】调用一个静态方法,可以看到在调用 invokestatic 之前执行了 pop 指令,把【对象引用】从操作数栈弹掉了😂
  • 还有一个执行 invokespecial 的情况是通过 super 调用父类方法

# 多态的原理

详见 pdf

当执行 invokevirtual 指令时,

  1. 先通过栈帧中的对象引用找到对象
  2. 分析对象头,找到对象的实际 Class
  3. Class 结构中有虚方法表 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
  4. 查找虚方法表,得到方法的具体地址
  5. 执行方法的字节码

img

# 异常处理

# try-catch

源码:

public class Demo3_11_1 {
    public static void main(String[] args) {
        int i = 0;
        try {
        	i = 10;
        } catch (Exception e) {
        	i = 20;
        }
    }
}

字节码(省略了不重要的部分):

image-20230928000437382

  • 可以看到多出来一个 异常表 Exception table 的结构,[from, to) 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
  • 8 行的字节码指令 astore_2 是将异常对象引用 e 存入局部变量表的 slot 2 位置

# 多个 single-catch 块

public class Demo3_11_2 {
    public static void main(String[] args) {
        int i = 0;
        try {
        	i = 10;
        } catch (ArithmeticException e) {
        	i = 30;
        } catch (NullPointerException e) {
        	i = 40;
        } catch (Exception e) {
        	i = 50;
        }
    }
}

因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用:

image-20230928001745610

# multi-catch

public class Demo3_11_3 {finally
    public static void main(String[] args) {
        try {
        	Method test = Demo3_11_3.class.getMethod("test");
        	test.invoke(null);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
        	e.printStackTrace();
        }
     }
     public static void test() {
        System.out.println("ok");
    }
}

image-20230928002232897

# finally

public class Demo3_11_4 {
    public static void main(String[] args) {
        int i = 0;
        try {
        	i = 10;
        } catch (Exception e) {
        	i = 20;
        } finally {
        	i = 30;
        }
    }
}

可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程、 catch 剩余的异常类型 的流程

image-20230928003413523

# 练习:finally 面试题

总结:如果 finally 块中出现了 return,返回结果肯定以它为准。但此时就不会正常抛出异常了。因此,不建议在 finally 块中进行 return。

# finally 块中出现了 return

public class Demo3_12_2 {
    public static void main(String[] args) {
        int result = test();
        System.out.println(result); // 20
    } 
	public static int test() {
        try {
        	return 10;
        } finally {
        	return 20;
        }
    }
}

对应的字节码:

image-20230928111512962

  • 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以finally 的为准
  • 至于字节码中第 2 行,似乎没啥用,且留个伏笔,看下个例子
  • 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会吞掉异常
  • 所以不要在 finally 中进行返回操作
public class Demo3_12_1 {
    public static void main(String[] args) {
        int result = test();
        System.out.println(result);
    } 
    public static int test() {
        try {
        	int i = 1/0;
        	return 10;
        } finally {
        	return 20;
        }
    }
}

会发现打印结果为 20 ,并未抛出异常。

# finally 块对返回值的影响

public class Demo3_12_2 {
    public static void main(String[] args) {
        int result = test();
        System.out.println(result); // 10
    } 
    public static int test() {
        int i = 10;
        try {
        	return i;
        } finally {
        	i = 20;
        }
    }
}

image-20230928111944576

# 同步控制 synchronized 块

当临界区中的代码出现异常时,如何确保正确地释放锁?

方法内指定指令序列的同步:有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义

  • montiorenter :进入并获取对象监视器,即为栈顶对象加锁
  • monitorexit :释放并退出对象监视器,即为栈顶对象解锁

image-20230928114017078

对于方法级的同步(即添加 synchronized 关键词到方法上),它是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法。

public class Demo3_13 {
    public static void main(String[] args) {
        Object lock = new Object();
        synchronized (lock) {
        	System.out.println("ok");
    	}
    }
}

image-20230928124410943

  • new 是创建【对象】,即 lock 对象,给对象分配堆内存,执行成功 **会将【对象引用】压入操作数栈**
  • dup 是 **复制操作数栈栈顶的内容,并压入栈顶**。本例即为复制一份 lock 的【对象引用】:
    • 一份是要配合 invokespecial 调用其构造方法 "<init>"😦)V
    • 另一份要配合 astore_1 赋值给局部变量表的 slot 1
  • 25~27 指令旨在释放 lock,如若出现异常,会反复尝试,直至成功,然后抛出异常

# 3、编译期处理(代码优化)

# 语法糖

指 Java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担。

注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。另外,编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了 几乎等价 的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。

# 默认构造器

public class Candy1 {
}

编译成 class 后的等价代码:

public class Candy1 {
    // 这个无参构造是编译器帮助我们加上的
    public Candy1() {
        super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":() V
    }
}

# 自动拆装箱

在 JDK 5 引入

Integer x = 1;
int y = x;

这段代码在 JDK 5 之前是无法编译通过的,必须改写为:

Integer x = Integer.valueOf(1); // 装箱:基本类型 -> 包装类型
int y = x.intValue(); // 拆箱:包装类型 -> 基本类型

JDK5 以后编译阶段自动转换成上述片段。

# 泛型集合取值(泛型擦除)

泛型也是在 JDK 5 开始加入的特性

Java 在编译泛型代码后会执行泛型擦除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都 **当做了 Object 类型** 来处理:

List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add (Object e),参数类型是 Object,而非 Integer,泛型信息 Integer 被擦除了
Integer x = list.get(0); // 实际调用的是 Object obj = List.get (int index),实际返回的是 Object,而非 Integer,泛型信息 Integer 被擦除了

所以在取值时,编译器真正生成的字节码中,还要额外做一个类型转换的操作:

// 需要将 Object 转为 Integer
Integer x = (Integer)list.get(0);

如果前面的 x 变量类型修改为 int 基本类型,那么还要额外做一个自动拆箱的操作,最终生成的字节码是:

// 需要将 Object 转为 Integer, 并执行拆箱操作
int x = ((Integer)list.get(0)).intValue();

这些转换都是编译器帮我们做的。

# 可变参数

JDK 5 引入的特性

public class Candy4 {
    public static void foo(String... args) {
        String[] array = args; // 直接赋值
        System.out.println(array);
    } 
    public static void main(String[] args) {
    	foo("hello", "world");
    }
}

可变参数 String... args 其实是一个 String[] args ,从代码中的赋值语句中就可以看出来。
同样 java 编译器会在编译期间将上述代码变换为:

public class Candy4 {
    public static void foo(String[] args) {
    	String[] array = args; // 直接赋值
    	System.out.println(array);
    } 
    public static void main(String[] args) {
    	foo(new String[]{"hello", "world"}); // 将参数包装成 String []
    }
}

注意:如果调用的是无参 foo() 则等价代码为 foo(new String[]{}) ,创建了一个空的数组,而不会传递 null 进去

# foreach 循环

JDK 5

数组的循环:

int[] array = {1, 2, 3, 4, 5}; // 语法糖 1:数组赋初值的简化写
for (int e : array) { // 语法糖 2
	System.out.println(e);
}

对于语法糖 2,编译后为遍历下标取数

for(int i = 0; i < array.length; ++i) {
	int e = array[i];
	System.out.println(e);
}

集合的循环:

List<Integer> list = Arrays.asList(1,2,3,4,5);
for (Integer i : list) {
	System.out.println(i);
}

编译后转换为对迭代器的调用

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iter = list.iterator();
while(iter.hasNext()) {
    Integer e = (Integer)iter.next(); // 因为泛型擦除,所以需要强转
    System.out.println(e);
}

注意:foreach 循环写法,能够配合以下结构一起使用:

  1. 数组
  2. 实现了 Iterable 接口的集合类,其中 Iterable 用来获取集合的迭代器

# switch 字符串

JDK 7

switch (str) {
    case "hello": {
        System.out.println("h");
        break;
    }
    case "world": {
        System.out.println("w");
        break;
    }
}

注意:switch 配合 String 和枚举使用时,变量不能为 null,因为编译时会调用其 hashCode() 方法、 equals() 方法

会被编译器转换为:

byte x = -1;
switch(str.hashCode()) { // 1. 先通过 hashCode () 来快速比较判断
    case 99162322: //hello 的 hashCode
        if (str.equals("hello")) { // 2. 再借助 equals () 比较,防止哈希冲突
        	x = 0;
        }
    	break;
    case 113318802: //world 的 hashCode
        if (str.equals("world")) {
        	x = 1;
        }
}
switch(x) {
    case 0:
    	System.out.println("h");
    	break;
    case 1:
    	System.out.println("w");
        break;
}

总结:

  • 执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二遍才是利用 byte 执行进行比较
  • hashCode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突

# switch 枚举

JDK 7

enum Sex {
	MALE, FEMALE
}
public class Candy7 {
    public static void foo(Sex sex) {
        switch (sex) {
            case MALE:
                System.out.println("男"); 
                break;
            case FEMALE:
                System.out.println("女"); 
                break;
        }
	}
}

编译转换后的代码:

/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal ()=0,FEMALE 的 ordinal ()=1
*/
static class $MAP {
    // 数组大小即为枚举元素个数,里面存储 case 用来对比的数字
    static int[] map = new int[2];
    static {
    	map[Sex.MALE.ordinal()] = 1;
    	map[Sex.FEMALE.ordinal()] = 2;
	}
}
public static void foo(Sex sex) {
    int x = $MAP.map[sex.ordinal()];
    switch (x) {
        case 1:
        	System.out.println("男");
        	break;
        case 2:
        	System.out.println("女");
        	break;
    }
}

编译器会为枚举类生成一个静态内部类(合成类),内置一个 static int [],数组代销即为枚举元素的个数

# 枚举类

JDK 7

enum Sex {
	MALE, FEMALE
}

编译转换后:

public final class Sex extends Enum<Sex> {
    public static final Sex MALE;
    public static final Sex FEMALE;
    private static final Sex[] $VALUES;
    static {
        MALE = new Sex("MALE", 0);
        FEMALE = new Sex("FEMALE", 1);
        $VALUES = new Sex[]{MALE, FEMALE};
    }
    private Sex(String name, int ordinal) {
    	super(name, ordinal);
    }
    public static Sex[] values() {
    	return $VALUES.clone();
    }
    public static Sex valueOf(String name) {
    	return Enum.valueOf(Sex.class, name);
    }
}

# try-with-resources

JDK 7

对需要关闭的资源处理的特殊语法 try-with-resources ,格式:

try(资源变量 = 创建资源对象){
} catch( ) {
}

其中资源对象需要实现 AutoCloseable 接口,例如 InputStream、OutputStream、Connection、Statement、ResultSet 等接口都实现了 AutoCloseable ,使用 try-with-resources可以不用写 finally 语句块,编译器会帮助生成关闭资源代码

try(InputStream is = new FileInputStream("d:\\1.txt")) {
	System.out.println(is);
} catch (IOException e) {
	e.printStackTrace();
}

转换成:

addSuppressed(Throwable e)添加被压制异常,防止异常信息的丢失(fianlly 中如果抛出了异常)

try {
    InputStream is = new FileInputStream("d:\\1.txt");
    Throwable t = null;
    try {
    	System.out.println(is);
    } catch (Throwable e1) {
    	//t 是我们代码出现的异常
    	t = e1;
    	throw e1;
    } finally {
        // 判断了资源不为空
        if (is != null) {
            // 如果我们代码有异常
            if (t != null) {
                try {
                	is.close();
                } catch (Throwable e2) {
                    // 如果 close 出现异常,作为被压制异常添加
                    t.addSuppressed(e2);
                }
            } else {
                // 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
                is.close();
            }
		}
	}
} catch (IOException e) {
    e.printStackTrace();
}

# 方法重写

方法重写时对返回值分两种情况:

  • 父子类的返回值完全一致
  • 子类返回值可以是父类返回值的子类(编译期优化)
class A {
    public Number m() {
		return 1;
    }
}
class B extends A {
    @Override
    // 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类
    public Integer m() {
    	return 2;
    }
}

对于子类,Java 编译器会做如下处理:

class B extends A {
    public Integer m() {
    	return 2;
    }
	// 桥接方法才是真正重写了父类 public Number m () 方法
	public synthetic bridge Number m() {
    	// 调用 public Integer m ()
    	return m();
    }
}

其中 桥接方法 才是真正重写了父类方法的方法,仅对 Java 虚拟机可见,并且与原来的 public Integer m () 没有命名冲突。

# 匿名内部类

会生成一个额外的类,并实现对应接口及内部方法

# 无参优化

public class Candy11 {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
            	System.out.println("ok");
            }
        };
    }
}

转化后代码:

// 额外生成的类
final class Candy11$1 implements Runnable {
    Candy11$1() {
    }
    public void run() {
    	System.out.println("ok");
    }
}
public class Candy11 {
    public static void main(String[] args) {
    	Runnable runnable = new Candy11$1();
    }
}

# 带参优化

引用局部变量(必须是 final)的匿名内部类,源代码:

public class Candy11 {
    public static void test(final int x) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
            	System.out.println("ok:" + x);
            }
        };
    }
}

转换后代码:

final class Candy11$1 implements Runnable {
    int val$x;
    Candy11$1(int x) {
    	this.val$x = x;
    }
    public void run() {
    	System.out.println("ok:" + this.val$x);
    }
}
public class Candy11 {
    public static void test(final int x) {
    	Runnable runnable = new Candy11$1(x);
    }
}

局部变量在底层创建为内部类的成员变量,必须是 final 的原因:

  • 在 Java 中方法调用是值传递的,在匿名内部类中对变量的操作都是基于原变量的副本,不会影响到原变量的值,所以原变量的值的改变也无法同步到副本中

  • 外部变量为 final 是在编译期以强制手段确保用户不会在内部类中做修改原变量值的操作,也是防止外部操作修改了变量而内部类无法随之变化出现的影响

    在创建 Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val 属性,x 不应该再发生变化了,因为发生变化,this.val$x 属性没有机会再跟着变化

# 4、类加载

# 类的生命周期

类是在运行期间第一次使用时动态加载的(不使用不加载),而不是一次性加载所有类,因为一次性加载会占用很多的内存,加载的类信息存放于方法区中

类从被加载到虚拟机内存中开始,到卸载出内存为止,类的生命周期可概括为 7 个阶段

  • 加载(Loading)
  • 链接(Linking)
    • 验证(Verification)
    • 准备(Preparation)
    • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

一个类的完整生命周期

# 类加载的过程

虚拟机加载 Class 类型的文件主要三步:加载 -> 链接 -> 初始化。而链接过程又可分为三步:验证 -> 准备 -> 解析

类加载过程

# 加载

加载(Loading)是类加载过程的第一步,主要完成下面 3 件事情:

  1. 通过全类名获取定义此类的二进制字节流

  2. 借助类加载器,将类的字节流加载到方法区(元空间)中

  3. 在堆中生成一个代表该类的 Class 对象,作为该类在方法区中各种数据的访问入口

    堆中的 Class 对象持有元空间中 instanceKlass 的地址,而 instanceKlass 中的 _java_mirror 持有 Class 对象的地址。

image-20230930153621614

方法区的内部采用 C++ 的 instanceKlass 描述 Java 类的数据结构,有以下重要的 field:

  • _java_mirrorJava 的类镜像,作用是把 Klass 暴露给 Java 使用,例如对 String 来说就是 String.class
  • _super :父类
  • _fields :成员变量
  • _methods :方法
  • _constants :常量池
  • _class_loader :类加载器
  • _vtable虚方法表
  • _itable :接口方法表

注意:

  • instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),但 _java_mirror 是存储在堆中
  • Java 实例对象无法直接访问 instanceKlass ,而是先找到 _java_mirror 指向的 Class 对象,进而才能访问 instanceKlass 中的数据
  • 如果这个类还有父类没有加载,先加载父类
  • 加载和链接可能是交替运行的

# 链接

# 验证

验证 Class 文件的字节流中包含的信息是否符合 《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段这一步在整个类加载过程中耗费的资源相对较多,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。

不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码 (包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码) 都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

验证阶段主要由四个检验阶段组成:

  1. 文件格式验证(Class 文件格式检查):基于该类的二进制字节流进行,目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。

    除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

  2. 元数据验证(语义检查)

  3. 字节码验证(判断字节码是否可以被正确地执行)

  4. 符号引用验证(验证类的正确性):发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候。目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:

    • java.lang.IllegalAccessError :当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。
    • java.lang.NoSuchFieldError :当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。
    • java.lang.NoSuchMethodError :当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。
    • ......

验证阶段示意图

# 准备

为 static 变量(类变量)分配内存,并设置初始值

说明:实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起分配在 Java 堆中。类加载发生在所有实例化操作之前,并且类加载只进行一次,而实例化可以进行多次。

  • static 变量在 JDK 7 之前存储于永久代( instanceKlass 末尾)中。从 JDK 7 开始,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中( _java_mirror 末尾)。

  • static 变量分配内存和赋值是两个步骤:分配内存在准备阶段完成,此时会将初始值设为数据类型默认的零值;而具体赋值是在初始化阶段完成的

    初始默认值 ≠ 赋值

    基本数据类型的零值

    • 如果 static 变量是 final 的基本类型或者字符串常量,那么赋值发生在准备阶段
    • 如果 static 变量是 final 的,但属于引用类型或者构造器方法的字符串,那么赋值发生在初始化阶段

举例:

  • 初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123:

    public static int value = 123;
  • 常量 value 被初始化为 123 而不是 0:

    public static final int value = 123;
  • Java 并不支持 boolean 类型,对于 boolean 类型,内部实现是 int,由于 int 的默认值是 0,故 boolean 的默认值就是 false

# 解析

将常量池内的符号引用替换为直接引用(内存地址)的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

  • 符号引用 (Symbolic References):以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中。主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。如:包括类和接口的全限名、字段的名称和描述符、方法的名称和方法描述符(因为类还没有加载完,很多方法是找不到的)。
  • 直接引用 (Direct References):是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在

举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

# 初始化

简介

执行初始化方法 <clinit> () 方法的过程,进行 static 变量初始化和执行 static 代码块,是类加载的最后一步,此时 JVM 才开始真正执行类中定义的 Java 程序代码 (字节码)。

在编译生成 class 文件时,编译器会产生两个方法加于 class 文件中,一个是类的初始化方法 clinit ,另一个是实例的初始化方法 init

类构造器 () 与实例构造器 () 不同,它不需要程序员进行显式调用,在一个类的生命周期中,类构造器最多被虚拟机调用一次,而实例构造器则会被虚拟机调用多次,只要程序员创建对象。

类只在第一次实例化时加载一次,把 class 读入内存,后续实例化不再加载,引用第一次加载的类。

<clinit> ()

类构造器,由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。

作用:是在类加载过程中的初始化阶段进行静态变量初始化和执行静态代码块

静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问

public class Test {
    static {
        //i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  	// 这句编译器会提示 “非法向前引用”
    }
    static int i = 1;
}
  • 如果类中没有静态变量或静态代码块,那么 clinit 方法将不会被生成

  • clinit 方法只执行一次,在执行 clinit 方法时,必须先执行父类的 clinit 方法

  • static 变量的赋值操作和静态代码块的合并顺序由源文件中出现的顺序决定【从上至下】

  • static 不加 final 的变量以及加 final 的引用类型或者构造器方法的字符串都在初始化环节赋值

    static 加 final 的基本类型或者字符串常量都在准备阶段复制

对于 <clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。

初始化的时机

对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化 (类的初始化是【懒惰】的,只有在首次使用时才会被装载)

  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段 (未被 final 修饰)、或调用一个类的静态方法时。
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序首次访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时Class.forname("...") , newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发其父类的初始化
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会首先初始化 main 方法所在的类
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 「补充,来自 issue745 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

不会导致初始化的情况

  • 访问类中被 final 修饰的 static 常量(基本类型和字符串)时
  • 类对象.class
  • 创建该类的数组
  • 类加载器的 loadClass 方法
  • Class.forName 的参数 2 为 false 时

<init>()

实例构造器,主要作用是在类实例化过程中执行,执行内容包括成员变量初始化和代码块的执行

类实例化过程:父类的类构造器 () -> 子类的类构造器 () -> 父类的成员变量和实例代码块 -> 父类的构造函数 -> 子类的成员变量和实例代码块 -> 子类的构造函数

# 练习

从字节码分析,使用 a,b,c 这三个常量是否会导致类 E 初始化:

public class Load2 {
    public static void main(String[] args) {
       	//a、b 不会导致 E 类初始化,因为这两个 static 变量被 final 修饰,且为基本数据类型、字符串常量,在准备阶段就已经完成赋值
        System.out.println(E.a);
        System.out.println(E.b);
        // 会导致 E 类初始化,因为 Integer 是包装类,需要在初始化阶段完成赋值
        System.out.println(E.c);
    }
}
class E {
    public static final int a = 10;
    public static final String b = "hello";
    public static final Integer c = 20;
    static {
        System.out.println("E cinit");
    }
}

典型应用 - 完成懒惰初始化单例模式:

public class Singleton {
    private Singleton() { } // 私有构造方法,确保只有自己能调用
    // 创建 static 内部类(好处是能访问外部类的资源,例如构造方法),并定义 static 成员变量,保存单例
    private static class LazyHolder { 
        static final Singleton INSTANCE = new Singleton(); 
    }
    // 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员 
    public static Singleton getInstance() { 
        return LazyHolder.INSTANCE;  // 用到时才会加载 LazyHolder 类,从而才会触发初始化(此阶段会进行 static 变量的初始化,执行 static 代码块),从而实现【懒惰单例模式】
    }
}

实现特点是:

  • 懒惰实例化
  • 初始化时的线程安全是有保障的

# 类卸载

即该类的 Class 对象被 GC。

类卸载的时机:

  • 执行了 System.exit () 方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致 Java 虚拟机进程终止

卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,即堆中不存在该类的实例对象
  2. 该类没有被引用
  3. 该类的类加载器的实例已被 GC

所以,在 JVM 生命周期内,由 JVM 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。因为 JVM 会始终引用启动类加载器、扩展类加载器、应用程序类加载器,这些类加载器始终引用它们所加载的类,这些类始终是可及的。JDK 自带的 BootstrapClassLoader , ExtClassLoader , AppClassLoader 负责加载 JDK 提供的类,所以它们 (类加载器的实例) 肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

# 5、类加载器

# 前置知识

类加载方式

  • 隐式加载:不直接在代码中调用 ClassLoader 的方法加载类对象
    • 创建类对象、使用类的静态域、创建子类对象、使用子类的静态域
    • 在 JVM 启动时,通过三大类加载器加载 class
  • 显式加载
    • ClassLoader.loadClass(className) :只加载和连接,不会进行初始化
    • Class.forName(String name, boolean initialize, ClassLoader loader) :使用 loader 进行加载和连接,根据参数 initialize 决定是否初始化

类的唯一性

  • 在 JVM 中表示两个 class 对象判断为同一个类存在的两个必要条件:
    • 类的完整类名必须一致,包括包名
    • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同
  • 这里的相等,包括类的 Class 对象的 equals () 方法、isAssignableFrom () 方法、isInstance () 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true

命名空间

  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
  • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类

类加载器的基本特征

  • 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的
  • 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,不会在子加载器中重复加载

类加载规则

  • JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

  • 对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次

  • public abstract class ClassLoader {
      ...
      private final ClassLoader parent;
      // 由这个类加载器加载的类(Vector)
      private final Vector<Class<?>> classes = new Vector<>();
      // 由 VM 调用,用此类加载器记录每个已加载类。
      void addClass(Class<?> c) {
            classes.addElement(c);
       }
      ...
    }

# 概述

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。

  • 每个 Java 类都有一个引用,指向加载它的 ClassLoader

  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

class Class<T> {
  ...
  private final ClassLoader classLoader;
  @CallerSensitive
  public ClassLoader getClassLoader() {
     //...
  }
  ...
}

简单来说,类加载器的主要作用就是 ** 奖 Java 类的字节码( .class 文件)加载到 JVM 内存中(在堆中生成一个 Class 对象)。** 字节码可以是 Java 源程序( .java 文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。

以 JDK 8 为例,类加载器包括:

名称加载的类路径说明
Bootstrap ClassLoader(启动类加载器)%JAVA_HOME%/jre/lib由 c++ 编写,无法直接访问,显示为 null
Extension ClassLoader(扩展类加载器)%JAVA_HOME%/jre/lib/ext上级为 Bootstrap
Application ClassLoader(应用程序类加载器)classpath上级为 Extension
自定义类加载器自定义上级为 Application

类加载器的层次关系图(双亲委派模型)

# 内置的类加载器

# 启动类加载器

Bootstrap ClassLoader

最顶层的加载类,由 C++ 实现,通常表示为 null,并且没有父级,用来加载 JDK 内部的核心类库%JAVA_HOME%/jre/lib 目录下的 rt.jarresources.jarcharsets.jar 等 jar 包和类)以及被 -Xbootclasspath 参数指定的路径下的所有类。

rt.jar :rt 代表 “RunTime”, rt.jar 是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.* 都在里面,比如 java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*

  • 出于安全考虑,Bootstrap 启动类加载器只加载包名的开头为 java、javax、sun 的类
  • 仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在 lib 目录中也不会被加载
  • 启动类加载器无法被 Java 程序直接引用,编写自定义类加载器时,如果要把加载请求委派给启动类加载器,直接使用 null 代替

# 扩展类加载器

Extension ClassLoader

Java 9 引入了模块系统,该加载器被改名为平台类加载器(platform class loader)

Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载

用来加载 %JAVA_HOME%/jre/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

  • ExtClassLoader (sun.misc.Launcher$ExtClassLoader) 实现,上级为 Bootstrap,显示为 null
  • JAVA_HOME/jre/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中
  • 开发者可以将创建的 JAR 放在此目录下,会由扩展类加载器自动加载

# 应用程序类加载器

Application ClassLoader,也称为系统类加载器

面向用户的类加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

  • AppClassLoader(sun.misc.Launcher$AppClassLoader) 实现,上级为 Extension
  • 负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
  • 这个类加载器是 ClassLoader 中的 getSystemClassLoader () 方法的返回值,因此也称为系统类加载器
  • 可以直接使用这个类加载器,如果应用程序中没有自定义类加载器,这个就是程序中默认的类加载器

# 小结

如何获取各个类加载器:

public static void main(String[] args) {
    // 获取系统类加载器(应用程序类加载器)
    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
    // 获取其上层:扩展类加载器
    ClassLoader extClassLoader = systemClassLoader.getParent();
    System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@610455d6
    // 获取其上层:获取不到启动类加载器,返回 null
    ClassLoader bootStrapClassLoader = extClassLoader.getParent();
    System.out.println(bootStrapClassLoader);//null
    // 对于用户自定义类来说:使用系统类加载器进行加载
    ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
    System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
    //String 类使用引导类加载器进行加载的 --> java 核心类库都是使用启动类加载器加载的
    ClassLoader classLoader1 = String.class.getClassLoader();
    System.out.println(classLoader1);//null
}

除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader 抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

每个 ClassLoader 可以通过 getParent() 获取其父 ClassLoader ,如果获取到 ClassLoadernull 的话,那么该类是通过 BootstrapClassLoader 加载的。

因为 BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。

public abstract class ClassLoader {
  ...
  // 父加载器
  private final ClassLoader parent;
  @CallerSensitive
  public final ClassLoader getParent() {
     //...
  }
  ...
}

下面我们来看一个获取 ClassLoader 的小案例:

public class PrintClassLoaderTree {
    public static void main(String[] args) {
        ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader();
        StringBuilder split = new StringBuilder("|--");
        boolean needContinue = true;
        while (needContinue){
            System.out.println(split.toString() + classLoader);
            if(classLoader == null){
                needContinue = false;
            }else{
                classLoader = classLoader.getParent();
                split.insert(0, "\t");
            }
        }
    }
}

输出结果 (JDK 8):

|--sun.misc.Launcher$AppClassLoader@18b4aac2
    |--sun.misc.Launcher$ExtClassLoader@53bd815b
        |--null

从输出结果可以看出:

  • 我们编写的 Java 类 PrintClassLoaderTreeClassLoaderAppClassLoader
  • AppClassLoader 的父 ClassLoaderExtClassLoader
  • ExtClassLoader 的父 ClassLoaderBootstrap ClassLoader ,因此输出结果为 null。

# 自定义类加载器

# 动机

开发人员可以通过自定义类加载器来进行拓展,那么什么时候需要自定义类加载器呢?

  • 希望加载任意路径下的类文件

  • 希望在框架设计时,通过接口使用不同的实现,达到解耦的目的

  • 同一个类有多个版本,它们的包名、类名都一样,但字节码不一样,希望它们能够互相隔离、同时工作,不要冲突

    tomcat 容器

# 介绍

自定义类加载器的上级是 Application。前文说到,除了 BootstrapClassLoader ,其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader 。所以如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader 抽象类

ClassLoader 类有两个关键的方法

  • protected Class loadClass(String name, boolean resolve)加载指定二进制名称的类,实现了双亲委派机制
    • name 为类的二进制名称
    • resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name) :根据类的二进制名称来查找类,默认实现是空方法。

官方 API 文档中写到:建议 ClassLoader 的子类重写 findClass(String name) 方法而不是 loadClass(String name, boolean resolve) 方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

# 步骤

  1. 继承 ClassLoader 父类

  2. 要遵从双亲委派机制,重写 findClass() 方法

    不是重写 loadClass () 方法,否则会打破双亲委派机制

  3. 读取类文件的字节码(byte [])

  4. 调用父类的 defineClass() 方法来加载类

  5. 使用者调用该类加载器的 loadClass() 方法

举例:

image-20231004155454908

# 双亲委派模型

一种加载类的策略

# 介绍

类加载器有很多种,当我们想要加载某个类时,具体是使用哪个类加载器进行加载呢?这就需要提到双亲委派模型,就是指调用类加载器的 loadClass () 方法时,所采用的查找类的规则

这里的双亲,翻译为上级更合适,因为它们之间并没有继承关系

  • ClassLoader 类使用委托模型来搜索类、资源。

  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器

  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器

类加载器的层次关系图(双亲委派模型)

⚠️注意:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。因为某些特殊需求,我们可以打破双亲委派模型,后文会介绍具体的方法。

另外,类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合关系(Composition)来复用父加载器的代码

public abstract class ClassLoader {
  ...
  // 组合
  private final ClassLoader parent;
  protected ClassLoader(ClassLoader parent) {
       this(checkCreateClassLoader(), parent);
  }
  ...
}

在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合,少用继承。

# 执行流程

每当一个类加载器接收到加载请求时,它首先会判断当前类是否被加载过,若没加载过,则会先将加载请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoaderloadClass() 中,相关代码如下所示:

调用父类加载器的 loadClass() 方法,调用自身类加载器的 findClass() 方法

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            // 如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 当父类的加载器不为空,则通过父类的 loadClass 来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    // 当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 非空父类的类加载器无法找到相应的类,则抛出异常
            }
            if (c == null) {
                // 当父类加载器无法加载时,则调用 findClass 方法 (由类加载器自己扩展)来加载该类
                // 用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);
                // 用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            // 对类进行 link 操作
            resolveClass(c);
        }
        return c;
    }
}

结合上面的源码,简单总结一下双亲委派模型的执行流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass() 方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
  • 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

🌈 拓展一下:

JVM 判定两个 Java 类是否相同的具体规则JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。

# 优缺点

双亲委派机制的优点:

  • 可以避免某一个类被重复加载,当父类加载器已经加载后则无需重复加载,保证全局唯一性

  • 保护程序安全,防止类库的核心 API 被随意篡改

    例如:在工程中新建 java.lang 包,接着在该包下新建 String 类,并定义 main 函数

    public class String {
        public static void main(String[] args) {
            System.out.println("demo info");
        }
    }

    此时执行 main 函数会出现异常,在类 java.lang.String 中找不到 main 方法。因为双亲委派的机制,java.lang.String 在启动类加载器(Bootstrap)得到加载,启动类加载器优先级更高,在核心 jre 库中有其相同名字的类文件,但该类中并没有 main 方法。

双亲委派机制的缺点:

  • 检查类是否加载的委托过程是单向的,这个方式虽然从结构上看比较清晰,使各个 ClassLoader 的职责非常明确,但顶层的 ClassLoader 无法访问底层的 ClassLoader 所加载的类(可见性)

img

# 打破双亲委派模型

双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种类加载器实现方式。因为某些特殊需求,我们可以打破双亲委派模型

# 方式 1:自定义类加载器

如果想要避免双亲委派机制,可以自定义一个类加载器,继承 ClassLoader ,重写 loadClass() 方法

  • 如果不想破坏双亲委派模型,继承 ClassLoader 后,只需要重写 findClass () 方法
  • 如果想要破坏双亲委派模型,继承 CLassLoader 后,需要重写 loadClass () 方法

举个例子,Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。

Tomcat 的类加载器的层次结构如下:

Tomcat 的类加载器的层次结构

Tomcat 这四个自定义的类加载器对应的目录如下:

  • CommonClassLoader 对应 <Tomcat>/common/*
  • CatalinaClassLoader 对应 <Tomcat >/server/*
  • SharedClassLoader 对应 <Tomcat >/shared/*
  • WebAppClassloader 对应 <Tomcat >/webapps/<app>/WEB-INF/*

从图中的委派关系中可以看出:

  • CommonClassLoader 作为 CatalinaClassLoaderSharedClassLoader 的父加载器。 CommonClassLoader 能加载的类都可以被 CatalinaClassLoaderSharedClassLoader 使用。因此, CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。
  • CatalinaClassLoaderSharedClassLoader 能加载的类则与对方相互隔离。 CatalinaClassLoader 用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。 SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、Mybatis。
  • 每个 Web 应用都会创建一个单独的 WebAppClassLoader ,并在启动 Web 应用的线程里设置线程上下文类加载器为 WebAppClassLoader 。各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间的类隔。
# 方式 2:线程上下文类加载器

单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类

Java 提供了很多 服务提供者接口(Service Provider Interface,SPI) ,允许第三方为这些接口提供实现。常见的有 JDBC、JCE、JNDI 等。这些 SPI 接口由 Java 核心库来提供,而 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径 classpath 里,SPI 接口中的代码需要加载具体的实现类

  • SPI 的接口是 Java 核心库的一部分,由 BootstrapClassloader 加载
  • SPI 的实现类是由 ApplicationClassLoader 加载,BootstrapClassloader 是无法找到 SPI 的实现类,因为双亲委派模型中 BootstrapClassloader 无法委派 ApplicationClassLoader 来加载类

JDK 开发人员引入了 线程上下文类加载器(Thread Context ClassLoader) ,这种类加载器可以通过 Thread 类的 setContextClassLoader 方法进行设置线程上下文类加载器,在执行线程中抛弃双亲委派加载模式,使程序可以逆向委派类加载器,使 BootstrapClassloader 拿到了 ApplicationClassLoader 加载的类,破坏了双亲委派模型

原理:将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的。

Java.lang.Thread 中的 getContextClassLoader()setContextClassLoader(ClassLoader cl) 分别用来获取和设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl) 进行设置的话,线程将默认继承其父线程的上下文类加载器

Spring 获取线程上下文类加载器的代码如下:

cl = Thread.currentThread().getContextClassLoader();

# 6、运行期优化

在解释执行时,JVM 会对热点代码进行优化

# 即时编译(JIT)

Just-in-time Compilation

即时编译(JIT)通过在运行时将运行次数多的热点字节码编译成机器码,从而改善性能。HotSpot 内嵌了两个 JIT 编译器,分别为 Client Compiler 和 Server Compiler,简称 C1 编译器和 C2 编译器。

C1 编译器会对字节码进行简单的优化,耗时短,编译速度快,具体的优化方法如下:

  • 方法内联将调用的函数代码编译到调用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。

    常量折叠

  • 冗余消除:根据运行时状况进行代码折叠或削除

  • 内联缓存:是一种加快动态绑定的优化技术(方法调用部分详解)

C2 编译器会对字节码进行激进的优化,耗时长,编译速度慢,但优化后的代码执行效率更高,当激进优化的假设不成立时,再退回使用 C1 编译,这也是使用分层编译的原因。C2 的优化主要是在全局层面,逃逸分析是优化的基础:

  • 同步锁消除
  • 标量替换
  • 栈上分配

HotSpot 在实现 JIT 时有三种选择: C1C2C1+C2 (分层编译)

  • C1:编译速度快,优化方式比较保守
  • C2:编译速度慢,优化方式比较激进
  • C1+C2:在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 C2 重新编译

对应的 VM 参数设置:

  • -client:指定 Java 虚拟机运行在 Client 模式下,并使用 C1 编译器
  • -server:指定 Java 虚拟机运行在 Server 模式下,并使用 C2 编译器
  • -server -XX:+TieredCompilation :在 1.8 之前,分层编译默认是关闭的,可以添加该参数开启

# 分层编译

Tiered Compilation

分层编译策略 (Tiered Compilation):程序解释执行可以触发 C1 编译,将字节码编译成机器码,加上性能监控,C2 编译会根据性能监控信息进行激进优化,JVM 将执行状态分成了 5 个层次:

  • 0 层,使用解释器(Interpreter)进行解释执行
  • 1 层,使用 C1 即时编译器进行编译执行(不带 profiling)
  • 2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
  • 3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
  • 4 层,使用 C2 即时编译器编译执行(C1 和 C2 协作运行)

说明:profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的回边次数】等

解释器(Interpreter)v.s 即时编译器(JIT Compiler):

  • 解释器
    • 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
    • 是将字节码解释为所有平台通用的机器码
  • 即时编译器
    • 将一些热点的字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
    • 根据平台类型,生成平台特定的机器码

总的目标是发现热点代码,并优化

  • 对于大部分的不常用代码,采取解释执行的方式运行
  • 对于小部分的热点代码,可以将其编译成机器码,以达到理想的运行速度

下面介绍 JIT 的几种优化手段:逃逸分析、方法内联、字段优化。

# 逃逸分析

Escape Analysis

Java Hotspot 虚拟机可以分析新创建对象的作用域(判断是否逃逸),并决定是否在堆上分配内存

逃逸分析的 JVM 参数如下:

  • 开启逃逸分析: -XX:+DoEscapeAnalysis
  • 关闭逃逸分析: -XX:-DoEscapeAnalysis
  • 显示分析结果: -XX:+PrintEscapeAnalysis

有两种逃逸情况:

  • 方法逃逸:当一个对象在方法中定义之后,被外部方法引用
    • 全局逃逸:一个对象的作用范围逃出了当前方法或者当前线程
      • 对象是一个静态变量
      • 对象是一个全局变量赋值
      • 对象是一个已经发生逃逸的对象
      • 对象作为当前方法的返回值
    • 参数逃逸:一个对象被作为方法参数传递或者被参数引用
  • 线程逃逸:如类变量或实例变量,可能被其它线程访问到

如果不存在逃逸行为,则可以对该对象进行如下优化:

  • 同步锁消除
  • 标量替换
  • 栈上分配

# 方法内联

In Lining

如果 JVM 监测到一些小方法被频繁执行,它会把方法的调用替换成方法体本身。方法内联能够消除方法调用的固定开销,任何方法除非被内联,否则调用都会有固定开销,来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

private static int square(final int i) {
	return i * i;
}
System.out.println(square(9));

square 是热点方法,会进行内联,把方法内代码拷贝粘贴到调用者的位置

System.out.println(9 * 9);

还能够进行常量折叠(constant folding)的优化:

System.out.println(81);

# 字段优化

尽可能减少对成员变量的读取次数,考虑使用局部变量作缓存来代替。

可以手动优化,也可以借助方法内联自动优化。

# 反射优化

public class Reflect1 {
    public static void foo() {
    	System.out.println("foo...");
    }
    public static void main(String[] args) throws Exception {
        // 通过反射调用 static 方法 foo ()
        Method foo = Reflect1.class.getMethod("foo");
        for (int i = 0; i <= 16; i++) {
            System.out.printf("%d\t", i);
            // 因为是 static 方法,所以对象参数为 null
            foo.invoke(null);
        }
        System.in.read();
    }
}

foo.invoke 在 0 ~ 15 次调用的是 MethodAccessor 的实现类 NativeMethodAccessorImpl.invoke0() ,本地方法执行速度慢。

public Object invoke(Object obj, Object[] args)throws Exception {
    //inflationThreshold 膨胀阈值,默认 15
    if (++numInvocations > ReflectionFactory.inflationThreshold()
        && !ReflectUtil.isVMAnonymousClass(method.getDeclaringClass())) {
        // 使用 ASM 动态生成的新实现类代替本地实现,速度较本地实现快 20 倍左右
        MethodAccessorImpl generatedMethodAccessor = (MethodAccessorImpl)
            new MethodAccessorGenerator().
            generateMethod(method.getDeclaringClass(),
                           method.getName(),
                           method.getParameterTypes(),
                           method.getReturnType(),
                           method.getExceptionTypes(),
                           method.getModifiers());
        parent.setDelegate(generatedMethodAccessor);
    }
    // 【调用本地方法实现】
    return invoke0(method, obj, args);
}
private static native Object invoke0(Method m, Object obj, Object[] args);

当调用到 16 次时,会采用运行时生成的类 sun.reflect.GeneratedMethodAccessor1 代替。

可以使用阿里的 arthas 工具进行反编译

package sun.reflect;
import cn.itcast.jvm.t3.reflect.Reflect1;
import java.lang.reflect.InvocationTargetException;
import sun.reflect.MethodAccessorImpl;
public class GeneratedMethodAccessor1
extends MethodAccessorImpl {
    /*
    * Loose catch block
    * Enabled aggressive block sorting
    * Enabled unnecessary exception pruning
    * Enabled aggressive exception aggregation
    * Lifted jumps to return sites
    */
    public Object invoke(Object object, Object[] arrobject) throws InvocationTargetException {
        // 比较奇葩的做法,如果有参数,那么抛非法参数异常
        block4 : {
            if (arrobject == null || arrobject.length == 0) break block4;
            throw new IllegalArgumentException();
        } 
        try {
            // 可以看到,已经是直接调用了😱😱😱
            Reflect1.foo();
            // 没有返回值
            return null;
        } 
        catch (Throwable throwable) {
        	throw new InvocationTargetException(throwable);
        } 
        catch (ClassCastException | NullPointerException runtimeException) {
        	throw new IllegalArgumentException(Object.super.toString());
        }
    }
}

通过查看 ReflectionFactory 源码可知:

  • sun.reflect.noInflation 可以用来禁用膨胀,直接生成 GeneratedMethodAccessor1,但首次生成比较耗时,如果仅反射调用一次,不划算
  • sun.reflect.inflationThreshold 可以修改膨胀阈值

# 第五章:JVM 内存模型(JMM)

# 0、前置知识

# CPU 缓存模型

为什么要弄一个 CPU 高速缓存呢?类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。CPU 缓存是为了解决 CPU 处理速度和内存处理速度不对等的问题

我们甚至可以把内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结: CPU Cache 缓存的是内存数据,用于解决 CPU 处理速度和内存处理速度不匹配的问题; 内存 缓存的是硬盘数据,用于解决硬盘访问速度过慢的问题。

为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示:

现代的 CPU Cache 通常分为三层,分别叫 L1、L2 和 L3 Cache

CPU 缓存模型示意图

CPU三级缓存架构

CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

**CPU 为了解决内存缓存不一致性问题,可以通过制定 缓存一致协议 (比如 MESI 协议)或者其他手段来解决。** 这个缓存一致性协议指的是在 CPU Cache 与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。

缓存一致性协议

我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。

操作系统通过 ** 内存模型(Memory Model)** 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。

# 指令重排序

为了提升执行速度 / 性能,计算机在执行程序代码的时候,会对指令进行重排序。

什么是指令重排序?简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行

常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术 (Instruction-Level Parallelism,ILP) 来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

另外,内存系统也会有 “重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

编译器和处理器的指令重排序的处理方式不一样。

  • 编译器:通过禁止特定类型的编译器重排序的方式来禁止重排序。
  • 处理器:通过插入 内存屏障(Memory Barrier) 的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

# 1、JMM(Java Memory Model)

# 简介

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异

简单的说,JMM 定义了一套在多线程读写共享数据(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障。可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,JMM 主要目的是为了简化多线程并发编程,增强程序可移植性的

为什么要遵守这些并发相关的原则和规范呢?这是因为并发编程下,像 CPU 多级缓存和指令重排序这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则来解决多线程下的指令重排序问题

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用 JMM 规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized 、各种 Lock )即可开发出并发安全的程序。

# 对线程、主内存的抽象

Java 内存模型(JMM)抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。

在 JDK1.2 之前,Java 的内存模型实现总是从 主内存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。这和我们上面讲到的 CPU 缓存模型非常相似。

什么是主内存?什么是本地内存?

  • 主内存所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量 (也称局部变量)
  • 本地内存:每个线程私有的本地内存,存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。

Java 内存模型的抽象示意图如下:

JMM(Java 内存模型)

从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  2. 线程 2 到主内存中读取对应的共享变量的值。

也就是说,JMM 为共享变量提供了可见性的保障

不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:

  1. 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
  2. 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load (载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use (使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。

  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。

  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。

  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。

  • ......

# 与 Java 内存区域的区别

这是一个比较常见的问题,很多初学者非常容易搞混。Java 内存区域和内存模型是完全不一样的两个东西:

  • Java 内存结构:与 JVM 的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型(JMM):与 Java 的并发编程相关,抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的

# 2、并发编程的三大特性

# 原子性

对于一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行

在 Java 中,对原子性的实现可以借助:

  • synchronized :可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性
  • 各种 Lock :可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性
  • 各种原子类:利用 CAS (compare and swap) 操作(可能也会用到 volatile 或者 final 关键字)来保证原子操作。

# 问题分析:static 变量的自增、自减

Java 中对 static 变量的自增、自减并不是原子操作,而是由 4 条 JVM 字节码指令组成。

问题提出:两个线程对初始值为 0 的 static 变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

答:以上的结果可能是正数、负数、零。因为 Java 中对 static 变量的自增、自减并不是原子操作,在多线程下 JVM 字节码指令可能交错执行

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i 	// 获取静态变量i的值(压入栈顶)
iconst_1 		// 准备常量1(压入栈顶)
iadd 			// 加法(对栈顶两个元素执行)
putstatic i 	// 将修改后的值存入静态变量i

而对应 i-- 也是类似:

getstatic i 	// 获取静态变量i的值(压入栈顶)
iconst_1 		// 准备常量1(压入栈顶)
isub 			// 减法(对栈顶两个元素执行)
putstatic i 	// 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增、自减需要在主内存和线程私有的工作内存中进行数据交换:

JMM(内存模型)

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题。但多线程下这 8 行代码可能交错运行,导致结果出现正数 / 负数。

# 解决方法:synchronized

synchronized (同步关键字),语法如下:

synchronized(对象) {
	原子操作的代码
}

利用它来解决并发编程中的原子性问题:

static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) { // 同步锁
            	i++;
        	}
    	}
    });
    
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) { // 同步锁
            	i--;
            }
        }
    });
    
    t1.start();
    t2.start();
    
    t1.join();
    t2.join();
    System.out.println(i);
}

注意:

  1. 尽可能增大 synchronized 加锁的粒度,这样能减少 montiorentermontiorexit 指令的执行次数,避免重复地加锁与解锁。
  2. 上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1 对象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

# 可见性

当一个线程对共享变量进行了修改,那么另外的线程都可以立即看到修改后的最新值。不能保证原子性,仅用在一个写线程,多个读线程的情况。

在 Java 中,对可见性的实现,可以借助:

  • synchronized
  • 各种 Lock
  • volatile :如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

# 问题分析:退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();
    
    Thread.sleep(1000);
    run = false; // 线程 t 不会如预想的停下来
}
  1. 初始状态下,t 线程刚开始从主内存读取静态变量 run 的值到 t 线程私有的工作内存(本地内存)

    image-20231005211810258

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率:

    image-20231005212027650

  3. 1 秒之后,main 线程读取并修改了 run 的值,最后同步至主内存。而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

    image-20231005212320605

# 解决方法:volatile

volatile(易变)关键字:可以用来修饰成员变量、静态成员变量,可以避免线程从自己工作内存的高速缓存中查找变量的值,必须到主存中获取变量的值,线程操作 volatile 变量都是直接操作主存。

如果在前面示例的死循环中加入 System.out.println () 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了。这是因为 println () 底层使用了 synchronized 关键字,它也强制要求当前线程只能从主存中获取变量的值!

# 有序性

【指令重排】:JVM 为了优化,会在不影响正确性的前提下,调整代码的执行顺序,但在多线程下指令重排会影响正确性!

在 Java 中(JDK 5 以上), volatile 关键字可以禁止指令进行重排序优化。

# 问题分析:诡异的求和结果

int num = 0;
boolean ready = false;
// 线程 1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
    	r.r1 = num + num;
    } else {
    	r.r1 = 1;
    }
} 
// 线程 2 执行此方法
public void actor2(I_Result r) {
	num = 2;
	ready = true;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,请问可能的结果有几种?

  • 情况 1:线程 1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 情况 2:线程 2 先执行 num = 2,但没来得及执行 ready = true,线程 1 执行,还是进入 else 分支,结果为 1
  • 情况 3:线程 2 执行到 ready = true,线程 1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
  • 但我告诉你,结果还有可能是 0 😁😁😁,信不信吧!这种情况下是:线程 2 执行 ready = true,切换到线程 1,进入 if 分支,相加为 0,再切回线程 2 执行 num = 2。这种现象叫做【指令重排】,是 JIT 编译器在运行时的一些优化。

# 解决方法:volatile

volatile 修饰的变量,可以禁用指令重排

# 3、三大特性的实现方法

# synchronized 关键字

针对:原子性、可见性

# 简介

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,确保多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

在 Java 早期版本中, synchronized 属于重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术,来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

# 如何使用

Java 中的每个对象都可以作为锁,具体变现为以下 3 种形式:

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的 Class 对象
  3. 对于同步方法块,锁是 synchronized 括号里配置的对象

一个线程试图访问同步代码块时,必须获取锁,在退出或者抛出异常时,必须释放锁。

synchronized 关键字的使用方式主要有下面 3 种:

1、修饰实例方法 (锁是当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
    // 业务代码
}

2、修饰静态方法 (锁是当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
    // 业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用并不互斥,因为二者的锁对象不同!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁是指定对象 / 类)

对括号里指定的对象 / 类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
    // 业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

# 无法修饰构造方法

先说结论:构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

# 底层原理:获取 Monitor

# Monitor 工作流程

Monitor(监视器 / 管程):每个 Java 对象都可以关联一个 Monitor 对象,Monitor 也是 class,其实例存储在堆中如果使用 synchronized 给对象上锁(重量级),该对象的对象头中的 Mark Word 中就被设置指向 Monitor 对象的指针。

在 HotSpot 虚拟机中,Monitor 是基于 C++ 的 ObjectMonitor 类 实现的,其主要成员包括:

  • _owner:指向持有 ObjectMonitor 对象的线程
  • _WaitSet:存放处于 wait 状态的线程队列,即调用 wait () 方法的线程
  • _EntryList:存放处于等待锁 block 状态的线程队列
  • _count:约为_WaitSet 和 _EntryList 的节点数之和
  • _cxq: 多个线程争抢锁,会先存入这个单向链表
  • _recursions: 记录重入次数

ObjectMonitor 的基本工作机制:

  • 开始时 Monitor 中 Owner 为 null

  • 当 Thread-2 执行 synchronized (obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)

    img

  • 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized (obj),就会进入 EntryList BLOCKED(双向链表)

  • Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord

  • 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞

  • WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)

注意:

  • synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则
# 代码块同步

JVM 基于进入和退出 Monitor 对象来实现代码块同步和方法同步,但是两者的实现细节不一样。

  1. 代码块同步:通过使用 monitorentermonitorexit 指令实现的
  2. 同步方法: ACC_SYNCHRONIZED 修饰

不过两者的本质都是对对象监视器 monitor 的获取。

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理

从上面我们可以看出: synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束、异常位置。

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行,以及出现异常的这两种情况下都能被正确释放。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的。每个对象中都内置了一个 ObjectMonitor 对象。

另外, wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。

在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

执行 monitorenter 获取锁

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

执行 monitorexit 释放锁

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

# 方法同步
public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁

# 小结

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。

synchronized 同步方法的实现使用的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

# JDK 1.6 后的优化

# 概述

在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重了。

JDK 1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁粗化、锁消除等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

关于这几种优化的详细信息可以查看下面这篇文章:Java6 及以上版本对 synchronized 的优化

# Java 对象头

在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:

  • 对象头
  • 实例数据
  • 对齐填充
image-20231009112425668

其中,对象头中包含两部分:

如果是数组对象的话,对象头还有一部分是存储数组长度

  • Mark Word平时存储对象自身的运行时数据,如 HashCode、GC 分代年龄;当加锁时,就替换为锁信息,例如线程 ID、偏向锁标志、锁标志、锁记录的地址、锁监视器的地址等。

    Mark Word 所占用的内存大小与虚拟机的位长一致。

  • class 指针:虚拟机通过这个指针确定该对象是哪个类的实例

普通对象的对象头

(普通对象的对象头)

数组对象的对象头

(数组对象的对象头)

多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行 CAS 操作。

# 锁的升级

JDK 1.6 前的 synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化。

可重入:指一个线程首次获得了这把锁后,它有权利再次获取这把锁。如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

不公平:等待锁的线程不会排队,而是可抢占的。

级别从低到高依次是,随着竞争的激烈而逐渐升级:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁只能升级,不能降级,这种策略是为了提高获得锁和释放锁的效率。

image-20231009130158308

以 32 位系统为例,对于不同的锁状态,对象头中的 MarkWord 的内容如下:

image-20231009111747426
# 无锁(Normal)
25bit(对象的 hashCode)4bit(age,对象的分代年龄)1bit (是否是偏向锁)2bit (锁标志位)
001

对象的 hashCode 是指 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。

# 偏向锁(Biased)

偏向锁是三种锁中加锁消耗最小的,不需要操作系统的介入。经验表明大部分情况下,都会是同一个线程进入同一块同步代码块。即大多数情况下锁不存在竞争关系,而是总会被一个线程持有,因此不需要反复获取锁

23bit(线程 ID)2bit(epoch)4bit(age,对象的分代年龄)1bit (是否是偏向锁)2bit (锁标志位)
101

a、偏向锁的思想偏向于第一个获取锁对象的线程,该线程获得锁后就不会再有解锁操作了,之后重新获取该锁不需要同步操作,可以节省很多开销。假如有两个线程来竞争锁的话,那么偏向锁就失效了,进而升级成轻量级锁了。

  • 当锁对象第一次被线程获得的时候进入偏向状态,JVM 使用 CAS 操作将线程 ID 记录到锁对象的对象头的 Mark Word 中。于是该线程就获得了锁,可以执行 synchronized 同步的代码。当这个线程再次进入这个锁对象相关的同步块时,先查看这个线程 ID 是不是自己,是则就表示没有竞争,不需要再次获得锁
  • 当有另外一个线程也尝试去获取这个锁对象时,偏向状态就宣告结束,此时 ** 撤销偏向(Revoke Bias)** 后恢复到无锁状态 / 轻量级锁状态

b、偏向锁对象的创建

  • 偏向锁默认是开启的,那么对象创建后,处于一个可偏向而未偏向的状态,其对象头中的 Mark Word 值为 0x05 ,即 **线程 ID、epoch、age 都为 0,后 3 位标识为 101**
    • 线程 ID = 0 表示未加锁
    • 添加 VM 参数 -XX:-UseBiasedLocking 可以禁用偏向锁
  • 偏向锁默认是延迟的,不会在程序启动时立即生效
    • 可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
    • JDK 8 延迟 4s 开启偏向锁的原因:刚开始执行代码时,会有很多线程来抢锁,如果一开始就开启偏向锁,效率反而降低
  • 当一个对象已经计算过 hashCode,就再也无法进入偏向状态了,因为线程 ID、epoch 占用的是 hashCode 的位置
    • 对象什么时候会计算其 HashCode 呢?比如:将该对象作为 Map 的 Key 时。
    • 但大部分情况不会触发计算 hashCode,比如:List,日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等。
  • 反过来,如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁

c、偏向锁对象的加锁

  • 如果锁对象是未偏向状态(MarkWord 中的线程 ID=0),使用 CAS 操作将线程 ID 设置为自己的线程 ID,

    • 如果 CAS 操作成功,则成功获取偏向锁

    • 如果 CAS 操作失败,说明该锁对象被其他线程竞争获取了,则需要进行锁升级(撤销)

  • 如果锁对象是已偏向状态(MarkWord 中的线程 ID≠0)

    • 如果 MarkWord 中的线程 ID 是自己的线程 ID,则成功获取偏向锁

    • 如果 MarkWord 中的线程 ID 不是自己的线程 ID,则需要进行锁升级(撤销)

d、偏向锁对象的撤销

偏向锁的锁升级需要进行偏向锁的撤销

撤销的原因:

  • 调用对象的 hashCode 方法
  • 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
  • 调用了 wait/notify 方法,需要申请 Monitor,进入 WaitSet。(调用 wait 方法会导致锁膨胀而使用重量级锁)

撤销的时机:全局安全点(GC 运行之前所有线程需要在安全点阻塞暂停,这就 GC 过程中常说的 Stop The World ),将偏向状态改为 0,验证已获取锁的线程是否存活,

  • 如果死亡,将锁标志位恢复到无锁状态,重新加锁
  • 如果存活,将锁标志位升级为轻量级锁(00)

撤销的前提:锁对象处于已偏向状态(MarkWord 中的线程 ID≠0)

撤销的步骤:只有当偏向锁对象的 MarkWord 中指向的线程仍存活,且仍拥有锁时,才需要升级为轻量级锁。否则,根据是否允许重偏向( rebiasing ),让锁对象变为可偏向但未偏向的状态。

  • 如果 MarkWord 中指向的线程已死亡
    • 允许重偏向( rebiasing ):退回到可偏向但未偏向的状态(线程 ID 置为 0,后 3 位置为 101)
    • 不允许重偏向:变为无锁状态(前 29 位清空,后 3 位置为 001),重新加偏向锁(后 3 位置为 101)
  • 如果 MarkWord 中指向的线程仍存活
    • 如果该线程不再拥有锁
      • 允许重偏向:退回到可偏向但未偏向的状态(线程 ID 置为 0,后 3 位置为 101)
      • 不允许重偏向:变为无锁状态(前 29 位清空,后 3 位置为 001),重新加偏向锁(后 3 位置为 101)
    • 如果该线程仍然拥有锁
      • 升级为轻量级锁

e、小结

偏向级锁就是为了消除资源在无竞争情况下的同步原语,进一步提高了程序的运行性能。

我们假设线程 A 曾经拥有锁(不确定是否释放锁),线程 B 来竞争锁对象。

  • 如果线程 A 仍然存活,且仍然拥有锁,那么偏向锁升级为轻量级锁,线程 B 自旋请求获得锁
  • 如果线程 A 已死亡,或者线程 A 不再拥有锁,线程 B 直接去尝试获得锁

偏向锁的获取、撤销过程如下图:

img

# 轻量级锁(LightWeight Locked)

当一个线程运行同步代码块时,另一个线程也加入想要运行这个同步代码块时,偏向锁就会升级为轻量级锁。

之所以是轻量级,是因为它仅仅使用 CAS 进行操作,实现获取锁

30bit(指针,指向线程栈帧中的锁记录(Lock Record)2bit (锁标志位)
00

a、轻量级锁的思想:采用 CAS 自旋锁的方式来完成加锁,相对于重量级锁加锁的代价相对小一些。如果一直获取不到锁状态,自旋占用的资源会超过重量级锁,所以轻量级锁膨胀为重量级锁的条件就是自旋达超过一定次数(默认为 10,可以修改 PreBlockSpin 参数调整)。

b、轻量级锁的加锁

image-20231012104146340

  • 首先,JVM 会在参与竞争的各个线程的栈帧中各自分配一块空间,称为锁记录( Lock Record,并将锁对象的 Mark Word 拷贝到其中,称为 Displaced Mark Word ,用于记录原始的 Mark Word,便于后续的 CAS 操作。

    Lock Record 中除了 Displaced Mark Word,还有 owner (指向锁对象)。

  • 然后一个线程尝试获取锁:尝试通过 CAS,将锁对象的 Mark Word 中的锁记录指针,指向自身栈帧中的 Lock Record

    • 如果 CAS 成功,再将锁对象中 MarkWord 的锁标识位设置为 00,则当前线程成功获得轻量级锁

      img

    • 如果 CAS 失败,有两种情况:

      • 如果是其他线程已经持有了该轻量级锁对象,则当前线程也不会阻塞,而是通过自旋的方式,重新等待尝试获取锁。当自旋达到一定次数(默认为 10)仍然未能获取锁,那么轻量级锁会膨胀成重量级锁

      • 如果是线程自身执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数

        img

  • 当持有锁的线程退出 synchronized 代码块时(解锁,即轻量级锁的撤销

    • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
    • 如果锁记录的值不为 null,这时通过 CAS 将该线程栈中的 Displaced Mark Word 恢复给锁对象的 Mark Word,
      • 如果 CAS 成功,则解锁成功,此时其它线程可以尝试获取锁
      • 如果 CAS 失败,说明轻量级锁进行了锁膨胀,此时进入重量级锁解锁流程

c、小结

img

# 重量级锁(HeavyWeight Locked)

上面提到,当多个线程竞争同一个锁时,会导致除锁的拥有者外,其余线程都会自旋,这将导致自旋次数过多,cpu 效率下降,所以会将锁升级为重量级锁。

30bit(指针,指向锁监视器(Monitor)2bit (锁标志位)
10

a、重量级锁的思想:重量级锁是使用操作系统底层的互斥量( mutex来实现的传统锁,会让抢占锁的线程从用户态转变为内核态,开销很大。与轻量级锁不同,竞争的线程不再通过自旋来等待获取锁,而是进入 BLOCKED(锁阻塞)状态,此时不消耗 CPU,然后等待持有锁的线程释放锁,唤醒阻塞的线程,再次竞争锁

b、重量级锁的加锁

image-20231012123713960

image-20231012124053959

  • 当 Thread-1 尝试进行轻量级加锁时,发现 Thread-0 已经对该对象加了轻量级锁

img

(轻量级锁的锁标识位为00)
  • Thread-1 加轻量级锁失败,进入 BLOCKED 状态,开始锁膨胀流程。首先,JVM 会为锁对象创建一个 Monitor 对象

    • 让锁对象的 MarkWord 指向 Monitor 对象的地址
    • 让 Monitor 对象的 Owner 指向当前持有锁的线程(Thread-0)
    • 将处于 BLOCKED(锁阻塞)状态的线程(Thread-1)添加到 Monitor 对象的 EntryList 中

    img

    (轻量级锁膨胀为重量级锁,锁标识位为10)
  • 当 Thread-0 退出同步块解锁时,

    • 此时先尝试轻量级锁的撤销(因为 Thread-0 还不知道锁已膨胀,锁标识位已经由 00 变为了 10),即尝试通过 CAS 操作将其栈中的 Displaced Mark Word 恢复给锁对象的 Mark Word,发现 CAS 失败,这时进入重量级锁的解锁流程
    • 将 Monitor 对象的 Owner 设置为 null,同时唤醒 EntryList 中的 BLOCKED 线程
# 小结

引入这些锁是为了提高获取锁的效率,要明白每种锁的使用场景。

  • 偏向锁适合一个线程对一个锁的多次获取的情况
  • 轻量级锁适合锁执行体比较简单 (即减少锁粒度或时间),自旋一会儿就可以成功获取锁的情况

偏向锁:针对一个线程来说的,主要作用是优化同一个线程多次获取一个锁的情况。当一个线程执行了一个 synchronized 方法的时候,肯定能得到对象的 monitor ,这个方法所在的对象就会在 Mark Work 处设为偏向锁标记,还会有一个字段指向拥有锁的这个线程的线程 ID 。当这个线程再次访问同一个 synchronized 方法的时候,如果按照通常的方法,这个线程还是要尝试获取这个对象的 monitor ,再执行这个 synchronized 方法。但是由于 Mark Word 的存在,当第二个线程再次来访问的时候,就会检查这个对象的 Mark Word 的偏向锁标记,再判断一下这个字段记录的线程 ID 是不是跟第二个线程的 ID 是否相同的。如果相同,就无需再获取 monitor 了,直接进入方法体中

如果是另一个线程访问这个 synchronized 方法,那么偏向锁就会被撤销,进而升级成轻量级锁。

轻量级锁:若第一个线程已经获取到了当前对象的锁,这是第二个线程又开始尝试争抢该对象的锁,由于该对象的锁已经被第一个线程获取到,因此它是偏向锁。而第二个线程再争抢时,会发现该对象头中的 Mark Word 已经是偏向锁,但里面储存的线程 ID 并不是自己(是第一个线程),那么她会进行 CAS,从而获取到锁,这里面存在两种情况:

  • 获取到锁成功(一共只有两个线程):那么它会将 Mark Word 中的线程 ID 由第一个线程变成自己 (偏向锁标记位保持不表),这样该对象依然会保持偏向锁的状态
  • 获取锁失败(一共不止两个线程):则表示这时可能会有多个线程同时再尝试争抢该对象的锁,那么这是偏向锁就会进行升级,升级为轻量级锁
    旋锁。若自旋失败,那么锁就会转化为重量级锁。在这种情况下,无法获取到锁的线程都会进入到 moniter (即内核态),自旋最大的特点是避免了线程从用户态进入到内核态。
# 其他优化
  1. 减少上锁时间:同步代码块中尽量短

  2. 减少锁的粒度,将一个锁拆分为多个锁提高并发度,例如:

    • ConcurrentHashMap
    • LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
    • LinkedBlockingQueue 入队和出队使用不同的锁,相对于 LinkedBlockingArray 只有一个锁效率要高
  3. 读写分离

    • CopyOnWriteArrayList
    • ConyOnWriteSet
# 自旋锁

多个线程竞争锁时,线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁。

  • Java 6 之后自旋锁是自适应的。比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  • Java 7 之后不能控制是否开启自旋功能,由 JVM 控制。

注意:自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势

优点:不会进入阻塞状态,减少线程上下文切换的消耗

缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源

自旋锁情况:

  • 自旋成功的线程会获得锁,执行同步块:

    在这里插入图片描述

  • 自旋失败的线程会进入 BLOCKED (锁阻塞) 状态

    在这里插入图片描述

# 锁消除

对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器的优化

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)。

# 锁粗化

对相同对象多次加锁,会导致线程发生多次重入,频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化。

如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部

举例:

  • 多次循环进入同步块,不如同步块内多次循环

  • 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

    new StringBuffer().append("a").append("b").append("c");

# 与 volatile 的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。
  • volatile 关键字只能用于变量,而 synchronized 关键字可以修饰方法以及代码块。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

# volatile 关键字

针对:可见性、有序性(禁止指令重排)

# 保证变量的可见性

在 Java 中, volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile 表明这个变量是共享且不稳定的,每次使用它都到主存中进行读取

JMM(Java 内存模型)强制在主存中进行读取

volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。 synchronized 关键字两者都能保证。

# 禁止指令重排

在 JDK 5 以上, volatile 关键字可以禁止指令重排。

** 在 Java 中, volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排。** 当一个变量被 volatile 修饰时,编译器和处理器会通过插入特定的【内存屏障】的方式,禁止对其进行指令重排序,从而保证程序的正确性。

在 Java 中, Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和 volatile 禁止重排序一样的效果,只是会麻烦一些。


下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

double-checked locking (双重校验锁)实现对象单例(线程安全)

  • 懒惰实例化
  • 首次使用 getUniqueInstance () 才使用 synchronized 加锁,后续无需加锁
public class Singleton {
    // 用 volatile 修饰,预防多线程下【指令重排】带来的不正确性
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    
    public static Singleton getUniqueInstance() {
       // 第一重校验:先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            // 类对象加锁
            synchronized (Singleton.class) {
                // 第二重校验:也许其他线程已创建实例,故再判断一次
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();// 非原子操作,可能发生【指令重排】
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的,因为 uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回 uniqueInstance ,但此时 uniqueInstance 还未被初始化。

对应的字节码为:

0: new #2 			// 分配内存:class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // 初始化:Method "<init>":() V
7: putstatic #4 	// 赋值:Field INSTANCE:Lcn/itcast/jvm/t4/Singleton;

其中 4 7 两步的顺序不是固定的,也许 jvm 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间序列执行:

时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null7 处)
时间4 t2 线程进入 getInstance() 方法,发现 INSTANCE != nullsynchronized块外),直接返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排。

# 无法保证原子性

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

我们通过下面的代码即可证明:

/**
 * 微信搜 JavaGuide 回复 "面试突击" 即可免费领取个人原创的 Java 面试手册
 *
 * @author Guide 哥
 * @date 2022/08/03 13:40
 **/
public class VolatoleAtomicityDemo {
    public volatile static int inc = 0;
    public void increase() {
        inc++;
    }
    public static void main(String[] args) throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
        for (int i = 0; i < 5; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 500; j++) {
                    volatoleAtomicityDemo.increase();
                }
            });
        }
        // 等待 1.5 秒,保证上面程序执行完成
        Thread.sleep(1500);
        System.out.println(inc);
        threadPool.shutdown();
    }
}

正常情况下,运行上面的代码理应输出 2500 。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500

为什么会出现这种情况呢?不是说好了, volatile 可以保证变量的可见性嘛!

也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

很多人会误认为自增操作 inc++ 是原子性的,实际上, inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc 的值并对其进行修改(+1),再将 inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc 的值进行修改(+1),再将 inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后, inc 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,使用 synchronized 改进:

public synchronized void increase() {
    inc++;
}

# happens-before 原则

针对:可见性、有序性(指令重排)

# 设计思想

happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结。

JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性

为什么需要 happens-before 原则?happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序

下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图:

image-20231005150335213

了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序

我们看下面这段代码:

int userNum = getUserNum(); 	// 1
int teacherNum = getTeacherNum();	 // 2
int totalNum = userNum + teacherNum;	// 3
  • 1 happens-before 2
  • 2 happens-before 3
  • 1 happens-before 3

虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。

# 常见规则

若违反以下 happens-before 规则,JMM 就不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

也就是说,happens-before 是用于确保可见性的

happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;

  2. 解锁规则:解锁 happens-before 于加锁。线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    static int x;
    static Object m = new Object();
    new Thread(()->{
        synchronized(m) {
        	x = 10;
        }
    },"t1").start();
    new Thread(()->{
        synchronized(m) {
        	System.out.println(x);
        }
    },"t2").start();
  3. volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是一个线程对 volatile 变量的写操作的结果,对于发生于其后的其它线程对该变量的任何操作都是可见的。

    volatile static int x;
    new Thread(()->{
    	x = 10;
    },"t1").start();
    new Thread(()->{
    	System.out.println(x);
    },"t2").start();
  4. 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;

  5. 线程启动规则

    1. Thread 对象的 start() 方法 happens-before 于此线程的每一个动作。
    2. 线程 start 前对变量的写,对该线程开始后对该变量的读可见。
    static int x;
    x = 10;
    new Thread(()->{
    	System.out.println(x);
    },"t2").start();
    1. 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive () 或 t1.join () 等待它结束)
    static int x;
    Thread t1 = new Thread(()->{
    	x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
    1. 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
    static int x;
    public static void main(String[] args) {
        Thread t2 = new Thread(()->{
            while(true) {
                if(Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    break;
                }
            }
        },"t2");
        t2.start();
        
        new Thread(()->{
            try {
            	Thread.sleep(1000);
            } catch (InterruptedException e) {
            	e.printStackTrace();
            } 
            x= 10;
            t2.interrupt(); // 打断 t2
        },"t1").start();
        
        while(!t2.isInterrupted()) {
        	Thread.yield();
        }
        System.out.println(x);
    }

如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序

# 与 JMM 是什么关系

happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。

happens-before 与 JMM 的关系

# 乐观锁与悲观锁

针对:原子性

在程序世界中,乐观锁和悲观锁的最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。但是,相比于乐观锁,悲观锁对性能的影响更大

# 悲观锁

悲观锁像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题 (比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用(无论读写),其它线程阻塞,用完后再把资源转让给其它线程

像 Java 中 synchronizedReentrantLock 等独占锁就是悲观锁思想的实现。

public void performSynchronisedTask() {
    synchronized (this) {
        // 需要同步的操作
    }
}
private Lock lock = new ReentrantLock();
lock.lock();
try {
   // 需要同步的操作
} finally {
    lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

# 乐观锁

乐观锁像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也不会阻塞,只在提交修改时验证对应资源是否被其它线程修改了,若被其它线程修改了,则重新尝试(具体方法可以使用版本号机制或 CAS 算法)。

像 Java 中 java.util.concurrent.atomic 包下面的原子变量类(比如 AtomicIntegerLongAdder )就是使用了乐观锁的一种实现方式 CAS 实现的。

JUC原子类概览

// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
LongAdder longAdder = new LongAdder();
// 自增
longAdder.increment();
// 获取结果
longAdder.sum();

高并发的场景下,相比悲观锁来说,乐观锁不存在锁竞争,不会造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升

不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder 以空间换时间的方式就解决了这个问题。

理论上来说:

  • 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如 LongAdder ),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考 java.util.concurrent.atomic 包下面的原子变量类)。

# 如何实现乐观锁?

乐观锁一般会使用版本号机制 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

# 版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时, version 字段的值会加 1 。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功


举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version =1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version =1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号( version =1 ),连同帐户扣除后余额( balance =$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号( version =1 )试图向数据库提交数据( balance =$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “提交版本必须等于当前版本才能执行更新” 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样就避免了操作员 B 用基于 version =1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

# CAS 算法

乐观锁思想:不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

JDK 已经为开发人员封装好了 CAS ,即原子类!

CAS 的全称是 Compare And Swap(比较与交换) ,经常搭配 volatile 用于实现乐观锁,思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值 (Var)
  • E:预期值 (Expected)
  • N:拟写入的新值 (New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。


举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等,则说明没被其他线程修改,可以被设置为 6 。
  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程被告知失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许放弃操作

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此,CAS 的具体实现和操作系统以及 CPU 都有关系。

sun.misc 包下的 Unsafe 类提供了 compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong 方法来实现的对 Objectintlong 类型的 CAS 操作:

/**
	*  CAS
  * @param o         包含要修改 field 的对象
  * @param offset    对象中某 field 的偏移量
  * @param expected  期望值
  * @param update    更新值
  * @return          true | false
  */
public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);
public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

关于 Unsafe 类的详细介绍可以看这篇文章:Java 魔法类 Unsafe 详解 - JavaGuide - 2022

# 乐观锁存在哪些问题?

# ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA" 问题 。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}
# 循环时间长、开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升,pause 指令有两个作用:

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
# 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了  AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。

# 小结

  • 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
  • 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些。
  • CAS 的全称是 Compare And Swap(比较与交换),用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
  • 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。

# Atomic 原子类

juc(java.util.concurrent) 中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean 等,它们底层就是采用 CAS 技术 + volatile 来实现的。
可以使用 AtomicInteger 改写之前的例子:

// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            i.getAndIncrement(); // 获取并且自增 i++
            //i.incrementAndGet (); // 自增并且获取 ++i
        }
    });
    
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
        	i.getAndDecrement(); // 获取并且自减 i--
        }
    });
    
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
}

# 总结

  • Java 是最早尝试提供内存模型的语言,JMM 的主要目的是为了简化多线程编程,增强程序可移植性的

  • CPU 可以通过制定缓存一致协议(比如 MESI 协议)来解决内存缓存不一致性问题。

  • 为了提升执行速度 / 性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下,指令重排序可能会导致一些问题。

  • 可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,目的就是解决指令重排序在多线程环境下的问题,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

  • JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性

# 重要的 JVM 参数

# 1、堆内存

堆是 Java 虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

内存区域常见配置参数

# 显式指定堆内存

与性能有关的最常见实践之一是根据应用程序要求初始化堆内存。如果我们需要指定堆大小的最小值与最大值(推荐显示指定大小),以下参数可以帮助你实现:

-Xms<heap size>[unit] # 堆的最小值
-Xmx<heap size>[unit] # 堆的最大值
  • heap size:表示要初始化内存的具体大小。
  • unit:表示要初始化内存的单位。单位为 “ g” (GB)、“ m”(MB)、“ k”(KB)。

举个栗子 🌰,如果我们要为 JVM 分配最小 2 GB 和最大 5 GB 的堆内存大小,我们的参数应该这样来写:

-Xms2G -Xmx5G

# 显式指定新生代内存

根据 Oracle 官方文档,在堆总可用内存配置完成之后,第二大影响因素是为 Young Generation 在堆内存所占的比例。默认情况下,YG 的最小大小为 1310 MB,最大大小为无限制

一共有两种指定新生代内存 (Young Generation) 大小的方法:

1. 通过 -XX:NewSize-XX:MaxNewSize 指定

-XX:NewSize=<young size>[unit] # 新生代的最小值
-XX:MaxNewSize=<young size>[unit] # 新生代的最大值

举个栗子 🌰,如果我们要为 新生代分配 最小 256m 的内存,最大 1024m 的内存我们的参数应该这样来写:

-XX:NewSize=256m
-XX:MaxNewSize=1024m

2. 通过 -Xmn<young size>[unit] 指定

举个栗子 🌰,如果我们要为 新生代分配 256m 的内存(NewSize 与 MaxNewSize 设为一致),我们的参数应该这样来写:

-Xmn256m # 新生代的大小(最小值 = 最大值)

GC 调优策略中很重要的一条经验总结是这样说的:

将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过 “-Xmn” 命令调节新生代大小,最大限度降低新对象直接进入老年代的情况

另外,你还可以通过 -XX:NewRatio=<int> 来设置老年代与新生代内存的比值

比如下面的参数就是设置老年代与新生代内存的比值为 1。也就是说老年代和新生代所占比值为 1:1,新生代占整个堆栈的 1/2。

-XX:NewRatio=1

# 显式指定方法区的大小

从 Java 8 开始,如果我们没有指定 Metaspace(元空间) 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)。

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

-XX:PermSize=N #方法区 (永久代) 初始大小
-XX:MaxPermSize=N #方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就 “永久存在” 了。

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。

下面是一些常用参数:

-XX:MetaspaceSize=N #设置 Metaspace 的初始大小(其实这是一个常见的误区,Metaspace 的初始容量是固定的)
-XX:MaxMetaspaceSize=N #设置 Metaspace 的最大大小,表示 Metaspace 使用过程中触发 Full GC 的阈值

# 2、GC

# 垃圾回收器

为了提高应用程序的稳定性,选择正确的垃圾收集算法至关重要。

JVM 具有四种类型的 GC 实现:

  • 串行垃圾收集器
  • 并行垃圾收集器
  • CMS 垃圾收集器
  • G1 垃圾收集器

可以使用以下参数声明这些实现:

-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParNewGC
-XX:+UseG1GC

有关垃圾回收实施的更多详细信息,请参见此处

# GC 日志记录

生产环境上,或者其他要测试 GC 问题的环境上,一定会配置上打印 GC 日志的参数,便于分析 GC 相关的问题。

# 必选
# 打印基本 GC 信息
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
# 打印对象分布
-XX:+PrintTenuringDistribution
# 打印堆数据
-XX:+PrintHeapAtGC
# 打印 Reference 处理信息
# 强引用 / 弱引用 / 软引用 / 虚引用 /finalize 相关的方法
-XX:+PrintReferenceGC
# 打印 STW 时间
-XX:+PrintGCApplicationStoppedTime
# 可选
# 打印 safepoint 信息,进入 STW 阶段之前,需要要找到一个合适的 safepoint
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1
# GC 日志输出的文件路径
-Xloggc:/path/to/gc-%t.log
# 开启日志文件分割
-XX:+UseGCLogFileRotation
# 最多分割几个文件,超过之后从头文件开始写
-XX:NumberOfGCLogFiles=14
# 每个文件上限大小,超过就触发分割
-XX:GCLogFileSize=50M

# 3、OOM

对于大型应用程序来说,面对内存不足错误是非常常见的,这反过来会导致应用程序崩溃。这是一个非常关键的场景,很难通过复制来解决这个问题。

这就是为什么 JVM 提供了一些参数,这些参数将堆内存转储到一个物理文件中,以后可以用来查找泄漏:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./java_pid<pid>.hprof
-XX:OnOutOfMemoryError="< cmd args >;< cmd args >"
-XX:+UseGCOverheadLimit

这里有几点需要注意:

  • HeapDumpOnOutOfMemoryError 指示 JVM 在遇到 OutOfMemoryError 错误时将 heap 转储到物理文件中。
  • HeapDumpPath 表示要写入文件的路径;可以给出任何文件名;但是,如果 JVM 在名称中找到一个 <pid> 标记,则当前进程的进程 id 将附加到文件名中,并使用 .hprof 格式
  • OnOutOfMemoryError 用于发出紧急命令,以便在内存不足的情况下执行;应该在 cmd args 空间中使用适当的命令。例如,如果我们想在内存不足时重启服务器,我们可以设置参数: -XX:OnOutOfMemoryError="shutdown -r"
  • UseGCOverheadLimit 是一种策略,它限制在抛出 OutOfMemory 错误之前在 GC 中花费的 VM 时间的比例

# 4、其他

  • -server : 启用 “Server Hotspot VM”; 此参数默认用于 64 位 JVM

  • -XX:+UseStringDeduplication : Java 8u20 引入了这个 JVM 参数,通过创建太多相同 String 的实例来减少不必要的内存使用;这通过将重复 String 值减少为单个全局 char [] 数组来优化堆内存

  • -XX:+UseLWPSynchronization : 设置基于 LWP (轻量级进程) 的同步策略,而不是基于线程的同步。

  • -XX:LargePageSizeInBytes : 设置用于 Java 堆的较大页面大小;它采用 GB/MB/KB 的参数;页面大小越大,我们可以更好地利用虚拟内存硬件资源;然而,这可能会导致 PermGen 的空间大小更大,这反过来又会迫使 Java 堆空间的大小减小。

  • -XX:MaxHeapFreeRatio : 设置 GC 后,堆空闲的最大百分比,以避免收缩。

  • -XX:SurvivorRatio : eden/survivor 空间的比例,例如 -XX:SurvivorRatio=6 设置每个 survivor 和 eden 之间的比例为 1:6。

  • -XX:+UseLargePages : 如果系统支持,则使用大页面内存;请注意,如果使用这个 JVM 参数,OpenJDK 7 可能会崩溃。

  • -XX:+UseStringCache : 启用 String 池中可用的常用分配字符串的缓存。

  • -XX:+UseCompressedStrings : 对 String 对象使用 byte [] 类型,该类型可以用纯 ASCII 格式表示。

  • -XX:+OptimizeStringConcat : 它尽可能优化字符串串联操作。

# JDK 监控和故障处理工具

# 1、JDK 命令行工具

这些命令在 JDK 安装目录下的 bin 目录下:

  • jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;
  • jstat (JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机的各种运行状态数据;
  • jinfo (Configuration Info for Java) : Configuration Info for Java,用于查看、调整虚拟机的各项配置信息;
  • jmap (Memory Map for Java) : 生成堆转储快照;
  • jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;
  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。

# 2、JDK 可视化分析工具

  • JConsole :Java 监视与管理控制台,可以很方便地监视本地及远程服务器的 java 进程的内存使用情况
  • Visual VM :多合一故障处理工具,可以方便地查看多个 Java 应用程序的相关信息

# JVM 线上问题排查与性能调优的案例

一次线上 OOM 问题分析 - 艾小仙 - 2023

  • 现象:线上某个服务有接口非常慢,通过监控链路查看发现,中间的 GAP 时间非常大,实际接口并没有消耗很多时间,并且在那段时间里有很多这样的请求。
  • 分析:使用 JDK 自带的 jvisualvm 分析 dump 文件 (MAT 也能分析)。
  • 建议:对于 SQL 语句,如果监测到没有 where 条件的全表查询应该默认增加一个合适的 limit 作为限制,防止这种问题拖垮整个系统
  • 资料实战案例:记一次 dump 文件分析历程转载 - HeapDump - 2022

生产事故 - 记一次特殊的 OOM 排查 - 程语有云 - 2023

  • 现象:网络没有问题的情况下,系统某开放接口从 2023 年 3 月 10 日 14 时许开始无法访问和使用。
  • 临时解决办法:紧急回滚至上一稳定版本。
  • 分析:使用 MAT (Memory Analyzer Tool) 工具分析 dump 文件。
  • 建议:正常情况下, -Xmn 参数(控制 Young 区的大小)总是应当小于 -Xmx 参数(控制堆内存的最大大小),否则就会触发 OOM 错误。
  • 资料最重要的 JVM 参数总结 - JavaGuide - 2023

一次大量 JVM Native 内存泄露的排查分析(64M 问题) - 掘金 - 2022

  • 现象:线上项目刚启动完使用 top 命令查看 RES 占用了超过 1.5G。
  • 分析:整个分析流程用到了较多工作,可以跟着作者思路一步一步来,值得学习借鉴。
  • 建议:远离 Hibernate。
  • 资料Linux top 命令里的内存相关字段(VIRT, RES, SHR, CODE, DATA)

YGC 问题排查,又让我涨姿势了! - IT 人的职场进阶 - 2021

  • 现象:广告服务在新版本上线后,收到了大量的服务超时告警。
  • 分析:使用 MAT (Memory Analyzer Tool) 工具分析 dump 文件。
  • 建议:学会 YGC(Young GC) 问题的排查思路,掌握 YGC 的相关知识点。

听说 JVM 性能优化很难?今天我小试了一把! - 陈树义 - 2021

通过观察 GC 频率和停顿时间,来进行 JVM 内存空间调整,使其达到最合理的状态。调整过程记得小步快跑,避免内存剧烈波动影响线上服务。这其实是最为简单的一种 JVM 性能调优方式了,可以算是粗调吧。

你们要的线上 GC 问题案例来啦 - 编了个程 - 2021

  • 案例 1:使用 guava cache 的时候,没有设置最大缓存数量和弱引用,导致频繁触发 Young GC
  • 案例 2: 对于一个查询和排序分页的 SQL,同时这个 SQL 需要 join 多张表,在分库分表下,直接调用 SQL 性能很差。于是,查单表,再在内存排序分页,用了一个 List 来保存数据,而有些数据量大,造成了这个现象。

Java 中 9 种常见的 CMS GC 问题分析与解决 - 美团技术团 - 2020

这篇文章共 2w+ 字,详细介绍了 GC 基础,总结了 CMS GC 的一些常见问题分析与解决办法。

更新于 阅读次数