JVM

学习视频:

黑马程序员JVM完整教程,Java虚拟机快速入门,全程干货不拖沓_哔哩哔哩_bilibili

G1与zgc垃圾回收器剖析_哔哩哔哩_bilibili

1. 概述

  • 什么是 JVM?
    • 定义:java virtual machine 也就是 java 程序(准确说是 java 二进制字节码)的运行环境
      • java 程序源代码经过 javac 编译成 class 字节码,字节码再通过 java 程序加载到虚拟机里即可运行
    • 好处
      • 一次编写,到处运行(跨平台是在jvm层面实现的,屏蔽了字节码和底层操作系统的差异,对外提供了一致的运行环境)
      • 自动内存管理,垃圾回收功能(c和c++没有)
      • 数组下标越界检查(c和c++没有)
      • 多态是面向对象的基石(jvm内部使用虚方法表实现多态)
    • jdk & jre & jvm
      • jvm+基础类库 => jre+编译工具 => jdk

image-20240913162126431

  • jvm 只是一套规范,之后的笔记以 HotSpot 虚拟机为例

  • jvm 的主要组成

  • 类加载系统

  • 内存结构(运行时数据区域)

  • 执行引擎

  • 本地接口

image-20240913162159914

2. 内存结构

2.1 程序计数器(PC)

  • 代码执行流程
    • java 代码被编译为二进制字节码(jvm指令[在不同平台下一样,跨平台])
    • 解释器将其解释为机器码(过程中解释器根据指令的类型和执行结果更新程序计数器的值,使其指向下一条要执行的字节码指令)
    • cpu 执行机器码

image-20240913164747410

  • 作用
    • 记住下一条jvm指令的执行地址,保证程序执行的有序性
    • 多线程环境下,程序计数器也会记住当前线程上次执行结束的位置,当再抢夺到cpu时间片的时候会接着上次执行的位置继续执行
  • 物理上实现程序计数器是通过”寄存器”来实现(快),因为读取指令内存地址很频繁
  • 特点
    • 线程私有
    • 唯一一个不会存在内存溢出的内存结构

2.2 虚拟机栈

  • 每个线程运行时所需要的内存,线程私有

  • 每个栈由多个栈帧组成,对应着每个方法运行时需要的内存(参数、局部变量、返回地址)

  • 每个线程只能有一个活动栈帧,对应着正在执行的那个方法(栈顶的栈帧)

  • 可以在idea调试的时候查看左下角的栈帧

  • 问题辨析

    • 垃圾回收不涉及栈内存(一次次方法调用产生的栈帧内存,其在每次方法调用后都会被自动弹出栈,不用垃圾回收来管理)

    • jvm 可以通过 -Xss 参数设置栈内存(linux默认1024KB),并不是越大越好(虽然越大可以支持更多的递归调用),但其会增加每个线程占用内存,会使可执行的线程数变少(因为物理内存大小固定)

    • 方法内的局部变量是否线程安全要看局部变量有没有逃离方法的作用范围,若没有则为线程私有=>安全,否则如果局部变量引用了对象(方法的参数是一个对象),并逃离了方法的作用范围(比如将局部变量返回),需要考虑线程安全

  • 虚拟机栈的内存溢出问题:两种情况,java 会抛出 StackOverflowError 异常

    • 栈帧过多(如递归调用没有设置正确的终止条件)
    • 栈帧过大
    • 调用第三方库也可能出现栈溢出(如使用 JSON 库进行序列化或反序列化时,若实体类的属性之间存在循环引用则可能导致栈溢出等问题) => 若场景不可避免,可以使用注解啥的忽略某个字段
  • 线程诊断相关问题

    • cpu 占用过多
      • top:实时查看系统中各个进程的资源使用情况
      • ps -H -eo pid,tid,%cpu | grep <PID>:查看具体进程的线程资源使用情况
      • printf "%x\n" <TID>:将 TID 转换为十六进制格式
      • jps:列出所有 Java 进程
      • jstack <PID> > thread_dump.txt:获取指定 Java 进程的线程 dump,再根据TID的十六进制格式进行定位
    • 程序运行很长时间没有结果
      • 可能是死锁问题,使用 jstack <pID> 查看末尾会输出类似 Found one Java level deadlock 语句

2.3 本地方法栈

  • 本地(native)方法是用非 Java 语言(如 C 或 C++)编写的代码
  • 本地方法栈:在 java 虚拟机调用一些本地方法时为其提供内存空间,线程私有

image-20240913165029364

2.4 堆

  • 特点(通过 new 关键字,创建对象都会使用堆内存)

    • 程序计数器 虚拟机栈 本地方法栈都是线程私有的,堆是线程共享的,需要考虑线程安全的问题
    • 有垃圾回收机制
  • 堆内存溢出问题:抛出 OutofMemoryError 异常

    • 可以通过参数指定堆内存大小:(有时候运行时间短不容易发现堆内存溢出问题,设置小些可以方便暴露问题)
      • 堆的最小值:-Xms 如-Xms2m
      • 堆的最大值:-Xmx 如-Xmx8m
  • 堆内存诊断

    • jps 查找 java 进程 PID
    • jmap -heap <PID> 查看 java 进程的堆内存使用情况 => 只能查询当前时刻
    • jconsole 可连续检测,图形化界面,多功能
    • 案例:垃圾回收后,内存占用仍然很高
      • 使用 JVisualVM 来监控 java 程序 => 也是图形化界面,有个 dump 功能可以抓取堆的当前快照,分析堆转储文件里对内存占用大的对象

2.5 方法区

  • 线程共享,存储所有类和常量的元数据信息,如类加载信息、运行时常量池、字符串常量池(JDK7 开始,字符串常量池被移到了 Java 堆中)
  • 在虚拟机启动时创建,逻辑上是堆的组成部分(具体实现不一样,规范上不强制位置)
    • 如 Oracle 的 HotSpot,1.8以前方法区的实现叫做”永久代”(PermGen),使用堆的一部分内存作为方法区
    • 1.8以后,移除了”永久代”,换了一种实现叫”元空间”(Metaspace),用的是本地的内存,也就是操作系统的内存
    • 方法区都是一种规范,”永久代”和”元空间”都是它的一种实现

image-20240913170458211

  • 方法区的内存溢出问题

    • 1.8 以前会导致永久代内存溢出
    • 1.8 以后会导致元空间内存溢出
    • 1.8 以后用的元空间(使用系统内存),一般不容易溢出
    • -XX:MaxMetaspaceSize=<size> 设置元空间最大大小
    • 实际场景中动态产生并加载类的情况很多,如 spring(使用 CGLIB 或 Java 动态代理生成 AOP 代理类) 和 mybatis(使用 Java 动态代理生成映射器接口的实现类) 等代理对象
      • 在这些动态生成类的过程中,类的加载和卸载频繁,加上框架所创建的类通常都不是轻量级类,这些类的元数据需要存储在方法区(永久代或元空间)中
  • 方法区中的一个组成部分:运行时常量池

    • 常量池:在编译时生成,存在于 .class 文件中
      • 反编译 target 目录下的 .class 文件(命令:javap -v Hello.class) ,该文件包括
        • 类基本信息
        • 该类的常量池信息
        • 类方法定义(包含JVM指令)
      • 常量池中包括编译时生成的各种字面量(如字符串常量、数值常量)和符号引用(如类名、方法名、字段名等),作用是给JVM指令提供一些常量符号,保证虚拟机指令能够成功执行
    • 运行时常量池
      • 类加载时,.class 文件中的常量池内容会被加载到运行时常量池中
      • 并在类加载(加载、链接[验证,准备,解析]、初始化) 的解析阶段,将class常量池中的原先的”符号引用”解析为”直接引用”
      • 运行时常量池不仅包含编译时的常量池内容,还可以动态扩展,可能在运行阶段加入新的常量
    • 运行时常量池的一部分:字符串常量池
      • StringTable 是 JVM 内部用于实现字符串常量池的数据结构,底层是哈希表(固定大小的哈希表,带有开放地址法来处理哈希冲突)
      • 在类加载过程中,编译后的 .class 文件中的常量池内容被加载到运行时常量池中,字符串字面量在这个阶段被加载到字符串常量池中 => s3 = "ab"
      • Java 8 及之前:编译器将使用 + 操作符的字符串拼接(语法糖)优化为 StringBuilder 的拼接,如new StringBuilder().append("a").qppend("b").toString() ==> s4 = s1 + s2 存放在堆里
      • Java 9 及之后:引入 invokedynamic 指令来实现字符串拼接,允许 JVM 在运行时选择最优的拼接方式
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class StringConcatExample {
    public static void main(String[] args) {
    String s1 = "a";
    String s2 = "b";

    String s3 = "ab"; // 字面量形式
    String s4 = s1 + s2; // 变量拼接
    String s5 = "a" + "b"; // 常量拼接

    // 比较引用
    System.out.println(s3 == s4); // false
    System.out.println(s3 == s5); // true
    System.out.println(s4 == s5); // false
    }
    }
  • 字符串延迟加载

    • 在 Java 编译过程中,所有字符串字面量确实被存储在 .class 文件的常量池中。但这并不意味着它们会在类加载时立即全部被加入到运行时字符串常量池中去
    • 字符串字面量在被首次使用时,会被放入字符串常量池
    • JVM 使用懒加载(lazy loading)技术,按需加载和管理字符串对象,以减少不必要的内存占用
  • intern 方法(串池存放对象还是引用有争议,先按着视频里的来)

    https://blog.csdn.net/lotusPlant/article/details/125804308

    • 针对 JDK1.8,intern() 方法的工作机制是尝试将字符串对象放入串池,如果有则不放,如果没有则放,并把串池中的对象返回
    • 针对 JDK1.6,intern() 方法尝试将字符串对象放入串池,如果有则不放,如果没有则把对象复制一份放入串池,并把串池中的对象返回
  • StringTable 的位置

    • JDK1.6 StringTable 在永久代中,1.8 StringTable 在堆中,主要是为了优化垃圾回收效率,减少 OutOfMemoryError 的风险
    • 永久代的大小是固定的,字符串常量池在 JDK 1.6 中容易导致 OutOfMemoryError
    • 规则:当 GC(垃圾回收)耗时超过 98% 且仅回收不到 2% 的堆内存时,会抛出 OutOfMemoryError 异常
      • 这一规则是 JVM 为了避免系统陷入“GC回收但效果很差”的恶性循环而设计的
      • 如果垃圾回收的效率非常低,即 GC 几乎占用了大部分的 CPU 时间,但实际上回收的内存却很少,那么 JVM 会认为当前的内存配置已经无法支持正常运行,因此抛出 OutOfMemoryError
    • -XX:MaxPermSize:设置永久代的最大大小
    • -Xmx8m:设置堆的最大内存
  • StringTable 垃圾回收

    • -Xmx10m 指定堆内存大小
    • -XX:+PrintStringTableStatistics 打印字符串常量池信息
    • -XX:+PrintGCDetails
    • -verbose:gc 打印 gc 的次数,耗费时间等信息
  • StringTable 性能调优

  • StringTable 底层是固定大小的哈希表(数组+链表),如果数组长度较长,相当于存放的元素就会比较分散,哈希冲突的概率会小一些,查找的速度也会更快

  • 所以如果系统里字符串常量非常多的话,可以设置桶的个数多一些,如:-XX:StringTablesize=101010

2.6 操作系统的内存-直接内存

  • 特点
    • 常见于 NIO 操作时,用于数据缓冲区
    • 分配回收成本较高,但读写性能高
    • 不受 JVM 内存回收管理
  • 直接内存也会有内存溢出的问题,其底层的分配和释放直接内存的原理:
    • 底层使用 unsafe 对象来管理
      • base = unsafe.allocateMemory(size);
      • unsafe.freeMemory(address);
    • Cleaner 继承虚引用类型,会将 unsafe 分配的直接内存地址与一个虚引用关联起来,并监控这个引用,当直接内存对象不再被使用时,虚引用会被添加到引用队列中,通知 Cleaner 需要执行清理操作
    • Cleaner 的清理线程会从引用队列中取出这个虚引用,并调用其 run() 方法(其内部调用unsafe的freeMemory方法来释放内存)
  • JVM 调优有个参数 -XX:+DisableExplicitGC,用于防止手动用 System.gc() 释放内存,但是该参数可能会对直接内存的垃圾回收造成影响 => 解决办法是对于直接内存回收直接使用底层的 unsafe.freeMemory()

3. 垃圾回收

3.1 判断对象是否可以回收

  • 两种算法:引用计数、可达性分析(JVM使用的)

  • 引用计数:每个对象都维护一个引用计数器,用来记录有多少个引用指向该对象

    • 当对象被一个变量引用时+1,计数为0时说明可以被销毁
    • 缺点是无法处理循环引用,如果两个对象相互引用,但没有其他对象引用它们

    image-20240913170900311

  • 可达性分析:看是否被根对象直接或间接的引用,不是则可以被回收

  • 补充:内存泄漏是指程序在运行过程中分配了内存,但这些内存由于某些原因没有被释放或回收,从而导致内存资源逐渐被耗尽

    • 未被释放的内存:程序动态分配了一块内存,但在不再需要这块内存时,没有显式或隐式地释放它
    • 不可达但未被回收:在垃圾回收机制的语言中(如 Java),即使对象已经不可达(即没有任何引用指向它),但如果垃圾回收器没有及时或无法识别并回收这些内存,就会造成内存泄漏
    • 持续的无用占用:虽然内存泄漏的内存仍然被分配和占用,但它已经对程序没有任何作用,这些内存无法再被程序利用,从而浪费了资源

3.2 JVM 五种引用

参考:https://juejin.cn/post/7131175540874018830

  • 强、软、弱、虚、终结器

    • 强引用:平时用的都是强引用(如new出来的对象被赋值给了某个变量,那么这个变量就强引用该对象),特点就是只要能够沿着GC Root的引用链找到它,就不会被回收

    • 软引用:用 SoftReference 类实现,在没有强引用引用它的前提下,当垃圾回收且内存不足时会被回收掉

    • 弱引用:用 WeakReference 类实现,在没有强引用引用它的前提下,当垃圾回收时就会被回收掉

    • 虚引用:主要配置 ByteBuffer 使用(有关直接内存的释放=>因为直接内存不受JVM垃圾回收的管理),当ByteBuffer对象没有强引用时,JVM 会将其内部关联的 Cleaner 对象(虚引用)放入引用队列中,系统中有一个专门的线程定期检查这个队列,一旦发现 Cleaner 对象,便会调用其 clean 方法,该方法内部会通过 unsafe 类的 freeMemory 方法来释放直接内存,从而避免内存泄漏

    • 终结器引用:Object 父类中的 finalize 方法(不推荐使用),当对象被垃圾回收时,如果该对象重写了 finalize 方法,JVM 会生成一个与该对象关联的 终结器引用,并将这个 终结器引用 放入 终结器队列,JVM 会通过一个低优先级的后台线程调用队列中引用关联的对象的 finalize 方法,如果对象再次可达,它就会被自救,避免被回收

  • 软引用和弱引用也可以配合”引用队列”来使用,也可以不配合,虚引用和终结器引用必须配合引用队列使用

  • [软引用][弱引用][虚引用][终结器引用]本身都是对象,当这些对象引用的对象被回收后,它们就可以放到”引用队列”中去方便后续处理(如遍历队列释放内存它们占用的内存)

image-20240913171009096

  • 软引用的应用:针对内存敏感的资源(如图片等),可以使用软引用引用,在内存紧张时,将该占有的内存释放掉,以后要使用到时再读取一遍

image-20240913171439542

  • 软引用和引用队列 (弱引用类似)
    • 软引用引用的对象在内存紧张时被回收,但软引用对象本身也是占用内存的 => 需要使用引用队列来清理
    • 在创建软引用对象的时候,将[引用队列]作为参数传入,当软引用所关联的对象被回收时,软引用自己会加入到队列中
    • 编写代码去轮询或检查这个引用队列,确定哪些软引用对象已经失效,并根据业务逻辑执行清理操作(如从缓存中移除条目或释放其他相关资源)

image-20240913171509781

  • 补充

    • 直接内存回收的具体工作流程(涉及虚引用):
      • ByteBuffer 分配直接内存:当创建一个直接内存 ByteBuffer 时,底层会通过 JNI (Java Native Interface) 调用操作系统分配一块直接内存,并使用 ByteBuffer 对象对其进行封装
      • Cleaner 对象:ByteBuffer 内部通常会关联一个 Cleaner 对象,Cleaner 是一个虚引用,它注册了一个清理任务,该任务定义了如何在 ByteBuffer 被垃圾回收时释放对应的直接内存
      • 虚引用的使用:Cleaner 继承自 PhantomReference,当 ByteBuffer 对象没有强引用(即它不再被任何其他对象引用)时 JVM 的垃圾回收器会将 Cleaner 对象放入到一个引用队列(ReferenceQueue)中
      • 清理任务的执行:系统中有一个专门的线程(通常是 ReferenceHandler 线程)负责定期检查这个引用队列
        当 Cleaner 对象被放入队列中时,ReferenceHandler 线程会调用 Cleaner 中的清理任务。这个清理任务通常会调用 Unsafe 类的 freeMemory 方法来释放分配的直接内存,从而避免内存泄漏
      • 防止直接内存泄漏:通过这种机制,即使 ByteBuffer 对象被垃圾回收了,系统仍然能够确保其对应的直接内存被安全释放,防止内存泄漏
    1
    总结:虚引用主要配合 ByteBuffer 使用(有关直接内存的释放=>因为直接内存不受JVM垃圾回收的管理),当ByteBuffer对象没有强引用时,JVM 会将其内部关联的 Cleaner 对象(虚引用)放入引用队列中,系统中有一个专门的线程定期检查这个队列,一旦发现 Cleaner 对象,便会调用其 `clean` 方法,该方法内部会通过 `unsafe` 类的 `freeMemory` 方法来释放直接内存,从而避免内存泄漏。
    • 虚引用在直接内存管理中的作用
      • 虚引用通过 Cleaner 机制确保 ByteBuffer 在被垃圾回收时,其对应的直接内存也能够被安全释放
      • 这是因为 JVM 不会自动管理直接内存的回收,必须通过这种机制来避免内存泄漏
    • 终结器引用
      • 终结器引用和 finalize 方法是 Java 对象生命周期管理中的一个机制,主要用于在对象被垃圾回收之前执行一些清理操作
      • 然而,由于性能和可靠性问题,finalize 方法已经被认为是不推荐使用的,并且在现代 Java 开发中基本上被废弃了。不推荐的原因:
        • finalize 方法工作效率低
        • 处理引用队列的线程优先级低,执行机会少,可能对象占用内存迟迟不被释放

3.3 垃圾回收算法

  • 标记清除(两个阶段)
    • 对没有被 GC root 直接或间接引用的对象进行标记,然后将其所占用的空间进行释放(将起始结束地址记录在空闲表里)
      • 标记阶段:通过引用链从 GC Roots 开始遍历所有可达的对象
      • 清除阶段:JVM 开始遍历整个堆内存区域,发现没有标记的对象则将其视为垃圾对象并释放其占用的内存(这里的释放是指标记未被使用的内存块的起始地址和结束地址,将这些空闲内存记录到一个“空闲列表”中以便后续内存分配时可以使用这些空闲内存)
    • 优点:速度快;缺点:内存碎片

image-20240913172418578

  • 标记整理
    • 标记阶段都类似,整理阶段垃圾回收器会扫描整个堆内存区域,将所有标记为存活的对象移动到堆的一个连续区域(通常是内存的起始端),然后更新所有相关的引用,确保引用指向的是对象的新地址
    • 优点:解决内存碎片问题,提高内存的利用率;缺点:时间长,耗性能(涉及对象的移动)

image-20240913172439486

  • 标记复制
    • 内存空间被划分为两个大小相等的区域,假设为 From 和 To 区域。开始时,所有对象都分配在 From 中,To 空闲
    • 当标记阶段完成后,垃圾回收器会将所有存活的对象从 From/伊甸园 复制到 To 中,并进行寿命+1操作,然后交换 From 和 To
    • 适合年轻代,存活对象较少,复制的成本相对较低
    • 缺点:会占用双份的内存

image-20240913172518307

image-20240913172526797

image-20240913172535385

image-20240913172542893

  • 总结:JVM 中这三种算法垃圾回收算法都是结合着一起使用,不会只使用一种

3.4 分代垃圾回收

  • 分代垃圾回收
    • JVM 协同三种算法进行垃圾回收的具体实现就是分代的垃圾回收机制,将堆内存分为老年代和新生代(包括伊甸园、幸存区from、幸存区to)
    • 长时间使用的放在老年代(回收频率低),有些用的时间短的放在新生代(回收频率高)
  • 分代垃圾回收的工作机制
    • 创建的新对象默认会放在伊甸园,如果伊甸园放不下就会触发一次垃圾回收(新生代的垃圾回收一般被称为”Minor GC”),然后根据可达性分析算法进行标记,执行标记复制算法,将伊甸园和from区中存活的对象复制到to区中,并将其寿命+1,然后回收伊甸园的对象,交换 from 和 to 的指针引用的指向,当幸存区的对象寿命超过了阈值(15),晋升到老年代
    • 如果新来的对象在伊甸园/幸存区/老年代都放不下,就会触发 “Full GC”

image-20240913172632578

  • 总结分代垃圾回收

    • 对象首先分配在伊甸园区
    • 新生代空间不足时触发 Minor GC,使用标记复制算法将伊甸园和from区存活的对象复制到to区,存活的对象年龄+1并且交换 from 和 to 指针
    • Minor GC 会引发 **stop the word(STW)**,暂停其他用户线程(因为回收过程中可能会导致对象地址的改变),minor gc 暂停时间并不长(新生代大部分都是被回收,复制的存活的不多)
    • 当对象寿命超过阈值时(最大15=>保存在对象的对象头中[4bit最大就是1111也就是15]=>不同垃圾回收器也不一样,也不一定到阈值了才晋升,可能空间不够了也晋升),会晋升至老年代
    • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍然不足则触发 full gc,STW 时间更长,如果空间还是不够 => out of memory error
  • 案例演示垃圾回收过程

    • 添加 -XX:+PrintGCDetails -verbose:gc 的参数可以查看垃圾回收的详细日志记录

    新生代 10M => 伊甸园:from:to = 8:1:1
    老年代 10M
    图中的 total 没有算上 to 区的大小(因为 to 区要空着)

    image-20240913172757606

    • 大对象如果一开始新生代都放不下,会先尝试直接放到老年代,如果还放不下,会触发 minor gc 和 full gc,还是不行就 OutOfMemoryError
    • java中某一个线程内发生了内存溢出异常,并不会导致整个java进程的结束

image-20240913173353913

3.5 垃圾回收器

这块黑马视频讲的不太容易懂,参考下尚硅谷的 P176-185

同时参考下 书《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》——周志明

  • 垃圾回收器

    • 垃圾回收算法是方法论,垃圾回收器则是方法论的实践者;不同的垃圾回收器负责不同的区域,并采取不同的垃圾回收算法

    • 分类

      • 串行(单线程,适用于堆内存较小的)
      • 吞吐量优先(多线程,允许较长时间的 STW 事件以减少总的垃圾回收次数,进而减少垃圾回收的总时间)

      image-20240913173529054

      • 响应时间优先(多线程,单次STW的时间最短,但可能会更频繁地触发垃圾回收,适用于延迟敏感的应用)

image-20240911140502885

  • 串行垃圾回收器 Serial
    • 开启参数:-XX:+UseSerialGC=Serial+SerialOld
    • 分为两个部分,Serial 工作在新生代(采用复制算法),SerialOld 工作在老年代(采用标记整理算法)

image-20240913173404353

  • 并行的垃圾回收器-吞吐量优先 Parallel (Java8默认的 PS[新生代] 和 PO[老年代])
    • 开启参数(1.8默认开启):-XX:+UseParallelGC-XX:+UseParallelOldGC(前者新生代后者老年代,开启一个另一个就连带开启,算法也是新生代复制,老年代标记整理
    • 控制线程数(默认开启,和CPU核数相关):-XX:ParallelGCThreads=n
    • 目标是达到可控制的吞吐量,还可以通过设置参数达到自适应的条件策略,适合后台运算不需要太多交互的任务
    • 并行垃圾回收 指的是垃圾回收器使用多个线程来执行垃圾回收工作,但在执行垃圾回收时,会完全暂停应用程序的所有线程(STW)

image-20240913173412117

  • 并发的垃圾回收器-响应时间优先(CMS)

    • 开启参数:-XX:UseConcMarkSweepGC
    • 应用线程执行的同时,并发地(即在同一时间段内)执行部分垃圾回收工作,避免长时间的 STW 事件,适合对响应时间敏感的应用
    • 使用的是标记清除算法,过程是:
      • 初始标记(只标记GC Roots直接关联的对象,速度快)
      • 并发标记(遍历整个对象图,耗时长,但并发),会产生浮动垃圾/错标的情况
        • 浮动垃圾:并发标记时标记对象是存活的,但因为用户线程的影响变为了垃圾对象
        • 错标:并发标记时标记对象是垃圾对象,但因为用户线程的影响变为了非垃圾对象
        • 浮动垃圾无所谓,在下次垃圾回收的时候会把该垃圾回收即可;但是**”错标”会导致要使用的对象被回收了,所以接下来的”重新标记”阶段就是解决”错标”的问题!!**
      • 重新标记(修正并发标记期间由于用户线程继续运作导致标记变动的记录)
      • 并发清理(用的清除,也是并发)

    image-20240913173445561

    • 在初始标记和重新标记的时候会发生短暂的STW
    • 当 CMS 运行时,如果老年代内存不足且预留的空间不够分配新对象会导致并发失败,会先冻结用户线程,然后启动Serial Old来收集老年代
    • 缺点:产生内存碎片(导致提前触发full gc)、无法处理浮动垃圾(并发标记阶段用户线程又产生新的垃圾对象,CMS无法进行标记,这些对象就无法被及时回收) => JDK9废弃了
  • G1 垃圾回收器

    • JDK9 之后默认的垃圾回收器
    • 将堆内存进行分区,优先回收垃圾最多的区间(Region)
    • 优势:**兼具并行(多个GC线程同时工作)和并发(拥有与应用程序交替执行的能力)**;也是分代的(但空间上不要求连续,内存的回收以region为单位);region之间是复制算法(但整体上看是标记整理,可以避免内存碎片),大内存上有优势(内存小的时候和 CMS 差不多)
    • 伊甸园空间耗尽则触发年轻代垃圾回收
    • 工作流程
      • **初始标记(STW)**:短暂停顿,标记 GC Roots 能直接关联到的对象
      • **并发标记(不会STW)**:与用户线程并发运行,可达性分析递归扫描堆中对象图,找出要回收的对象(通过写屏障的技术记录下会发生错标的对象)
      • **最终标记(STW)**:解决发生的错标问题,通过原始快照的算法解决,重新扫描被记录下来的灰色对象
      • **混合回收(STW-标记整理算法)**:
        • 根据G1跟踪生成的对于不同区的优先级列表,优先选择回收价值大的区
        • 基于用户设定的 最大停顿时间(默认 200 毫秒) 生成回收计划,回收多个 Region,组成一个 回收集
        • 存活对象会被 复制到空闲的 Region 中,而旧的 Region 将被完全清空,因为涉及对象的移动,这一步需要 STW

    • 与 CMS 比较
      • 回收算法:CMS 是 标记-清除,会产生内存碎片;G1 是 标记-整理,会进行内存压缩,避免碎片化,有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集
      • 内存占用:虽然G1和CMS都使用卡表来处理跨代引用,但G1的卡表实现更为复杂,而且堆中每个Region,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间
      • 停顿时间:G1 支持用户设定最大停顿时间,具有更高的预测性,而 CMS 在老年代满时可能引发长时间的 Full GC

3.6 GC 相关参数

image-20240913173627895

补充:算法细节

记忆集和卡表

  • 记忆集:一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,用于记录跨代引用,减少 GC Roots 扫描范围
  • 卡表:是记忆集的一种实现形式,通过维护卡表记录内存区域的跨代引用情况
    • 在新生代上建立一个记忆集(Remembered Set),把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用,如果当前某个card引用了新生代中的某对象,则这个card被称为脏card
    • 卡表元素何时变脏?=> 有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻
      • CMS中因为只需要维护一份卡表(老年代和新生代的引用问题),所以卡表的维护是同步的方式
      • G1中每一份region都需要持有一个Remembered Set(记忆集),卡表的维护较为繁琐,所以采取异步的方式(不会立即更新,会将脏卡的指令放到一个dirty card队列之中,将来由一个线程完成脏card的更新操作)
    • 卡表元素如何变脏?=> HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的
  • 跨代引用问题
    • 分代垃圾回收
      • 新生代对象可能被老年代对象引用,GC 在收集新生代时为了少量的跨代引用去扫描整个老年代并不划算
      • 使用记忆集和卡表,在发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。避免了minor GC时需要对老年代全扫描一遍的低效
    • 分区垃圾回收
      • 将堆划分为多个大小相等的 Region,每个 Region 都有自己的记忆集,用于记录跨区域的引用,机制与分代垃圾回收相似
      • 占用内存消耗更大

写屏障

  • HotSpot 中通过”写屏障”的技术维护了卡表的更新状态

  • 写屏障可以看作在虚拟机层面对 “引用类型字段赋值” 这个动作的AOP切面,在引用对象赋值时会产生一个环绕(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内

image-20240911194844290

  • 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的
  • 应用
    • 对卡表的状态的维护
    • 并发标记阶段 记录根对象的引用变化(配合satb_mark_queue队列),解决并发标记阶段的错标问题

三色标记

  • 想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
  • 引入三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
    • **黑色(不该被回收的对象)**:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的
    • **灰色(在该对象中找垃圾)**:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过
    • **白色(需要被回收的垃圾)**:表示对象尚未被垃圾收集器访问过,即代表不可达

image-20240911195722993

  • 对象消失问题:在并发标记过程中,用户线程可能会修改对象的引用关系,比如:
    • 新增引用:原本被标记为黑色(安全存活)的对象突然引用了一个白色对象(还没被标记的对象)
    • 删除引用:灰色对象(正在扫描)突然不再引用某个白色对象
    • 这些变化可能会导致垃圾回收器的标记过程出错,如:未标记的白色对象被错误回收,或者丢失了对已存在引用的对象的追踪
  • CMS和G1解决错标问题采用了三色标记法来辅助完成对对象状态的标记,解决并发标记时“对象消失问题”的两种方式:增量更新和原始快照 => 都是通过写屏障实现的,在HotSpot虚拟机中,如CMS是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现
    • 增量更新:在并发标记时,跟踪并标记新创建的引用。当用户线程修改对象引用时,系统会通过写屏障记录这些变化,确保在垃圾回收时不遗漏任何引用
      • 解决的是 黑色对象新增引用 导致的“错标”问题,通过写屏障记录新引用的白色对象,确保这些对象不会被错误地回收
    • 原始快照:保持并发标记开始时的对象引用状态,即即使用户线程修改了引用,垃圾回收器仍然按照开始时的引用状态来标记。这种方式减少了对新引用的依赖,避免标记遗漏
      • 解决的是 灰色对象删除引用 导致的“错标”问题,通过写屏障保留最初的引用关系,确保原本引用的白色对象不会被漏标
增量更新(CMS) 原始快照(G1)
关注新增引用:特别是黑色对象新增对白色对象的引用,需要重新扫描这些引用。 关注删除引用:即使灰色对象删除了对某些白色对象的引用,依然按照原始快照继续标记。
例子:黑色对象 A 新增了对白色对象 B 的引用,需要确保 B 不被误回收。 例子:灰色对象 C 删除了对白色对象 D 的引用,D 仍然会被标记为存活对象。
写屏障记录引用更新:跟踪对象引用新增的变化,确保垃圾回收不遗漏这些新增的白色对象。 写屏障记录原始引用:即使引用关系发生变化,仍然按照并发标记开始时的快照继续标记。
主要避免因为 引用新增 导致对象漏标。 主要避免因为 引用删除 导致对象漏标。

4. 垃圾回收调优

调优和应用、环境有关,因地制宜

4.1 GC 调优

  • 调优领域

    • 内存

    • 锁竞争

    • cpu 占用

    • io

  • 确定目标

    • 低延迟(CMS,G1,ZGC)还是高吞吐(PS和PO)?
  • 最快的GC是不发生GC,考虑思考几个问题

    • 是否加载太多数据到内存
    • 数据表示是否太臃肿
    • 是否存在内存泄漏,尝试用软弱引用或者考虑第三方缓存实现?

4.2 新生代调优

内存调优的点

  • 先检查代码由于自身问题可以优化的点
  • 再进行内存调优,优先从新生代中调优…
  • 新生代特点

    • 所有new操作的内存分配非常廉价 => 创建对象效率高
      • **TLAB(thread-local allocation buffer)**:加速 对象分配,避免多线程之间对共享内存区域(Eden 区)的竞争和锁定开销
        • 每一个线程都会在伊甸园中给它分配一个私有的区域 TLAB
        • 每次new一个对象,会检查TLAB缓冲区中有没有足够的空间来分配对象,如果有,会优先在这个区域里面进行对象内存分配,否则 JVM 会重新分配一个新的 TLAB 或者从 Eden 区的共享部分为对象分配内存
        • 对象分配也会有线程安全问题,TLAB作用就是让每个线程用自己私有的这块伊甸园内存来进行对象分配
    • 死亡对象回收代价是零 => 复制算法
    • 大部分对象用过即死
    • minor gc 时间远低于 full gc
  • 新生代空间太小会导致多次的 minor gc(多次的 STW),太大会导致老年代空间小,容易触发 full gc(更长的 STW),oracle 推荐新生代空间占堆的 25%-50% 之间

  • 幸存区大到要能保留【当前活跃对象+需要晋升的对象】

image-20240912092019491

4.3 老年代调优

  • 以 CMS 为例,老年代内存越大越好(避免CMS的产生的浮动垃圾过多而导致的并发失败)
    • 先尝试不调老年代,再尝试新生代,除非还是有很多 full gc

5. 类加载和字节码技术

  • 在类加载器加载class文件到JVM虚拟机中,虚拟机中执行引擎的解释器会对jvm指令解释,解释阶段也会对一些代码进行”即使编译处理”,就是在虚拟机中是”解释+编译”

image-20240912092746712

5.1 类文件结构

执行 javac -parameters -d . HelloWorld.java 编译java类

得到 class 文件,od -t xC HelloWorld.class

  • 规范:魔数(前4个字节)、Class文件版本、常量池信息、访问标志、类索引,父类索引,接口索引集合、字段表集合、方法表集合、属性表集合

image-20240912092939463

  • 魔数:0-3字节,表示它是否是合法的 class 类型文件,java 的类的魔数:ca fe ba be
  • 版本:4-7字节,表示类的版本,小版本和主版本各占俩字节,主要是后者,34(16进制)=>56 代表 JDK8
  • 常量池:8-9字节,表示常量池有几项,从1开始记录,如 00 23 表示十进制的 35,故有 #1-#34 共 34 项,后续就是这 34 项,(1个字节代表方法信息,后几个字节代表引用了常量池中的第几项来获得该方法的所属类和方法名)

image-20240912094143058

5.2 反编译 class 文件

使用 javap 工具反编译 class 文件

  • 使用方式:javap -v HelloWorld.class-v 表示输出类文件的详细信息)
  • 分析运行流程
    • **常量池载入运行时常量池(方法区的一部分)**:将class文件常量池中的信息载入方法区中的运行时常量池,并在解析阶段将符号引用转换为直接引用
    • 方法字节码载入方法区:小的数字和字节码指令存一起,超过 short 范围的数字存入常量池
    • main 线程运行,分配栈帧内存.class 文件中的 stacklocals 值代表了 方法的栈帧 中的 最大操作数栈深度局部变量表的大小。这些信息由编译器在编译时确定(编译器基于方法的代码分析得出),并在运行时用于分配栈帧资源
    • 执行引擎开始执行字节码

image-20240912100420482

5.3 构造方法

  • 字节码指令 <cinit>()V

    • 类构造器方法,用于初始化类变量和执行静态代码块
    • <cinit>()V 方法会在类加载的初始化阶段被调用
    • 编译器会按照从上至下的顺序,收集所有 static 静态代码块和静态成员复制的代码,合并成一个特殊的方法 <cinit>()V
    • 方法是类级别的,只执行一次,当类被首次使用时触发(如首次创建实例、访问静态字段、调用静态方法等)
  • 字节码指令 <init>()V

    • 对象构造器方法,通常是你在 Java 代码中定义的构造函数,以及任何实例初始化代码块(非静态代码块)和字段初始化表达式(确保了每个对象构造时其字段按照程序员的意图被正确初始化)
    • 每次创建类的新实例时,<init>()V 方法都会被调用
    • 编译器会按照从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后
    • 方法是对象级别的,每创建一个实例就会调用一次
  • 举例

    • 类初始化阶段(只一次)
      • staticValue = 10;
      • staticValue = 20; (静态代码块)
    • 每次实例化时
      • instanceValue = 100; (实例变量初始化)
      • instanceValue = 200; (实例初始化块)
      • instanceValue = 300; (构造器)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyClass {
static int staticValue = 10; // 静态变量初始化
static { // 静态初始化块
staticValue = 20;
}

int instanceValue = 100; // 实例变量初始化
{ // 实例初始化块
instanceValue = 200;
}

public MyClass() { // 构造器
instanceValue = 300;
}
}

5.4 字节码指令解释

  • istore:将栈顶 int 类型的值弹出,并存储到局部变量表中的指定索引位置

    • 使用场景:在方法执行中,对于基本类型的局部变量赋值操作,如将计算结果保存到一个局部变量中
  • astore:将栈顶引用类型的值弹出,并存储到局部变量表中的指定索引位置

    • 用于对象和数组类型的局部变量赋值,例如将一个对象引用存储到局部变量中
  • aload:从局部变量表中加载一个引用类型的值到操作数栈顶

    • 使用场景:在需要使用局部变量表中的对象或数组时,如访问对象的成员或调用方法前
  • bipush:将一个 byte 类型的立即数(即直接指定的数值)压入至栈顶

    • 使用场景:通常用于小范围的整数值赋值,如循环计数器的初始化。因为 bipush 可以直接将一个小整数(-128 到 127)推送到栈顶,常用于需要小整数的场景

5.5 方法调用和多态原理

  • 私有方法和 final 方法 使用 invokespecial 进行调用,因为它们在编译时已经确定,采用静态绑定

    1
    2
    3
    4
    5
    6
     0: new           #3 // class MyClass
    3: dup
    4: invokespecial #1 // Method MyClass."<init>":()V
    7: aload_1
    8: invokespecial #2 // Method MyClass.privateMethod:()V
    11: return
  • 公共方法 使用 invokevirtual 进行调用,采用动态绑定,支持多态,运行时根据实际类型决定调用的具体实现

    • 多态原理:每个类都有一个虚方法表 vtable,存储在类结构中,包含了指向类中所有虚方法的直接引用(虚方法是那些可能会被子类重写的方法);虚方法表 通常是在类加载的 链接阶段 (解析之后)生成的;类似 C++ 使用虚函数表来实现多态

    image-20240912111357605

  • 静态方法 使用 invokestatic,因为它不依赖对象实例,属于类级别的调用。使用对象调用静态方法时,JVM 需要先加载对象引用,会多出两条不必要的指令,因此推荐使用 类名 调用静态方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 对象引用调用静态方法(不推荐)
    0: new #3 // class MyClass
    3: dup
    4: invokespecial #1 // Method MyClass."<init>":()V

    // 两条多余指令
    7: aload_1 // 加载对象引用
    8: pop // 弹出对象引用,因为静态方法不需要它

    9: invokestatic #2 // Method MyClass.staticMethod:()V
    12: return

5.6 异常处理

  • 异常表:当Java代码中包含try-catch结构时,编译器会在字节码中生成一个异常表。这个表里面记录了:

    • 哪些区域的代码是try块(哪行开始,哪行结束)
    • 如果在这些区域中抛出异常,应该跳转到哪个位置去处理这个异常
    • 每种异常类型对应的处理代码在哪里
  • 多个 catch 块:当一个try块后面跟着多个catch块时:

    • 如果异常发生,JVM会查看异常表,找到第一个匹配的catch块,然后跳转到那里去执行
    • 这些catch块通常会共用一些局部变量槽位。比如,多个catch块可能都会有一个用来存储异常信息的局部变量。这是一种内存优化手段,避免为每个catch块分配新的存储空间
  • multi-catch:在 Java 7 及以上版本,可以使用一个 catch 块捕获多种类型的异常,如 catch (IOException | SQLException ex)

  • finally 块:确保无论 try 块是否抛出异常,finally 块中的代码都会被执行

    • 实现原理:在字节码中,finally 块的代码被复制到每个可能的退出路径上(确保了无论是try块、catch块正常返回还是抛出异常,finally 块的代码都会被执行)。具体来说,finally 块的代码被复制了三份
      • 一份在 try正常执行完毕后执行
      • 一份在 catch 块处理完异常后执行
      • 一份在 catch 处理不到的剩余异常(any 类型)时执行
    • 注意
      • **不要在finally块中使用return**:方法会在执行 finally 块的 return 之后直接返回,而抛出的异常会被忽略掉(控制台不会看到异常抛出)
      • finally中的变量赋值不影响返回值:如果在try块中有一个return语句,而finally块中修改了该返回值,这个修改不会影响方法的实际返回值。因为执行时先把返回结果暂存,等finally执行完了再把暂存结果返回(恢复到栈顶),所以最后返回的还是 try 块执行时已经存储的值,不影响返回结果

5.7 synchronized

从字节码的角度分析 synchronized 代码块

方法级别的 synchronized 不会体现在字节码中

这里是对象级别的 synchronized

  • 先 new 对象,并复制(dup指令)了一份该对象的引用

    • 一份用于调用构造方法(会消耗一个引用),一份存储到局部变量表中
  • new 对象后将锁对象的引用加载到操作数栈顶

    • 再次复制一份该对象的引用,一个给加锁指令 monitorenter 用,一个给解锁指令 monitorexit 用
  • 加锁之后,当代码块中代码执行完毕,如果没有异常,则再执行monitorexit 来给对象解锁;如果代码块中出现了异常,会通过”异常表”也会进行解锁

image-20240912140006491

5.8 语法糖

语法糖:java 编译器将源码 .java 文件编译为 .class 字节码的过程中自动生成和转换的代码,目的是减轻程序员编码负担

  • 默认构造器:若本身代码没实现构造器,编译后默认生成的构造方法里调用 super() 的无参构造方法
  • 自动拆装箱:JDK5 后 java 的基本类型和包装类型的自动转换
  • 泛型集合取值:JDK5 后的泛型,java 在编译泛型代码会有泛型擦除的动作,即泛型信息在编译为字节码后就丢失了,实际的类型都当作了 Object 类型处理
    • 泛型是为了编译期间的类型检查,泛型擦除是为了便于字节码处理(方便向下兼容)
    • 通过反射只可以拿到方法返回值和方法参数上的泛型信息,其他地方的泛型信息都会被”泛型擦除”
  • 可变参数:如编译器会在编译期间把参数从 String... args 变为 String[] args
  • foreach 循环:如果遍历数组,会被编译器转换为 for 循环;如果遍历集合,会被转换成对迭代器的调用

image-20240912145244637

  • switch:JDK7 开始 switch 可以作用于字符串和枚举类

    • 对 String 的支持,转换后的代码:

      • 执行了两遍 switch,第一遍根据字符串的 hashCode 和 equals 将字符串转换为相应 byte 类型,第二遍利用 byte 进行 switch 判断比较
      • 先比较 hashCode 是为了提高比较效率,再比较 equals 是为了防止哈希冲突(因为hashCode相等不代表两个对象相同)
    • 对 enum 的支持,转换后的代码:

    image-20240912150334768

  • 枚举类

image-20240912150506659

  • try-with-resources:JDK7 引入的用于简化资源的关闭,省略 finally 块,前提是资源对象需要实现 AutoCloseable 接口

    • 代码示例

    image-20240912151100950

    • 转换后:不管是自己代码的异常还是关闭资源的异常,只要有就都会抛出

    image-20240912151206091

  • 方法重写时的桥接方法:方法重写时对返回值分两种情况

    • 父子类返回值类型一致
    • 子类返回值可以是父类返回值的子类

image-20240912152318986

  • 匿名内部类:编译时额外生成了一个类,如果匿名内部类引用了局部变量,那么编译时生成的类里会新增一个属性,通过构造方法参数传入
    • 所以语法上匿名内部类引用局部变量时,局部变量必须是 final 的,因为底层内部类里的属性没有机会跟着一起变动,保证一致性

image-20240912152813205

6. 类加载阶段

加载、链接、初始化

6.1 加载

方法区中的二进制字节码元数据(c++中一个数据结构,用来描述java类的) 和 堆中class类对象xxx.class

  • java 类编译成 class 字节码后,想要运行,需要通过 类加载器 把类的字节码加载到方法区中(底层用的 C++ 的 instanceKlass 描述 Java 类)
    • instanceKlass 存储在方法区(1.8后也就是在元空间),其包含的属性包括 _java_mirror 和类的一些其他信息
    • _java_mirror 是 Java 的类镜像(相当于桥梁,java 只能通过这个镜像来访问类的一些信息 => 存储在堆中),其实就是堆中的 Class类对象,如对于 String 类来说,_java_mirror 就是 String.class
    • 可以理解为 堆中的class对象是方法区中该类二进制字节码元数据的镜像

image-20240912154518799

  • 如果这个类还有父类没有加载,会先加载父类
  • 加载和链接可能是交替运行的

6.2 链接

验证、准备、解析

  • 验证:验证类是否符合 JVM 字节码规范 和 安全性检查(是否会对jvm虚拟机造成伤害),比如魔数啥的是否正确
  • 准备:为静态变量开辟内存空间并设置默认值
    • JDK8 后静态变量和class类对象(java_mirror)存储在一起,都是存储在堆中(JDK8 之前存储在方法区中的二进制元数据instanceKlass中)
    • 静态变量分配空间和复制是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成,但
      • 如果静态变量是 final 的基本类型以及字符串常量,那么编译阶段值就确定了,赋值就在准备阶段完成
      • 如果静态变量是 final 的引用类型(new 出来的)或者包装类型,那么赋值还是推迟到初始化阶段完成
  • 解析:将运行时常量池中符号引用解析为直接引用(能确切知道类/方法/属性在内存中的位置)

6.3 初始化

执行 clinit() 方法的过程,会将自己给静态变量赋值语句和静态代码块拼接起来组成 clinit() 方法

<clinit>() 代表 “class initialization method”,即类初始化方法

  • 触发的时机

image-20240912161444041

  • 类加载阶段的前两个阶段是会执行的,但是初始化阶段并不一定会被执行
    • 当 JVM 开始加载某个类时,加载链接 阶段会自动进行,这些是类加载过程的必要步骤
    • 初始化 阶段只有在类被主动使用时才会触发。如果一个类被加载到 JVM,但从未主动使用,那么类的初始化阶段(包括静态变量的赋值和静态代码块的执行)可能永远不会发生,JVM 规范中定义了类的主动使用场景,例如:
      • 创建类的实例
      • 访问类的静态字段(非常量静态字段)
      • 调用类的静态方法
      • 反射使用这个类
      • 子类初始化时会初始化父类
  • 练习-利用静态内部类的初始化机制实现懒汉式的单例模式:(线程安全)
    • **main 方法调用 getInstance()**:在 main 方法中调用 Singleton.getInstance() 时,JVM 开始查找 Singleton 类中的静态方法 getInstance()
    • 类的初始化触发
      • 因为访问了 Singleton 类的静态方法(getInstance()),这会触发 Singleton 类的加载和初始化
      • 但此时,静态内部类 SingletonHolder 还没有被加载,因为 SingletonHolder 只会在首次使用时才被加载(即懒加载)
    • 调用 getInstance() 方法:
      • getInstance() 方法返回的是 SingletonHolder.INSTANCE,这就触发了 SingletonHolder 静态内部类的加载和初始化
    • 加载 SingletonHolder
      • SingletonHolder 被加载时,它的静态成员 INSTANCESingleton 类的唯一实例)会被初始化。这是单例实例创建的时刻
      • JVM 会自动执行 SingletonHolder 类中的静态代码块或静态成员初始化,因此 INSTANCE 会被创建
    • 返回单例实例
      • 现在,getInstance() 方法返回 SingletonHolder.INSTANCE,这就是 Singleton 类的唯一实例
      • 因为静态成员 INSTANCE 是在 SingletonHolder 被加载时创建的,并且只会初始化一次,所以无论你调用 getInstance() 多少次,返回的都是同一个实例

image-20240912171116449

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Singleton {
// 私有构造函数,防止外部实例化
private Singleton() {}

// 静态内部类负责持有 Singleton 的唯一实例
private static class SingletonHolder {
// 在内部类被加载和初始化时,创建单例实例
private static final Singleton INSTANCE = new Singleton();
}

// 公共静态方法,供外界获取单例实例
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}

// 主函数,演示单例模式的调用
public static void main(String[] args) {
// 获取 Singleton 的实例
Singleton instance1 = Singleton.getInstance();
Singleton instance2 = Singleton.getInstance();

// 检查两次获取的是否为同一个实例
System.out.println("Instance 1 hash: " + instance1.hashCode());
System.out.println("Instance 2 hash: " + instance2.hashCode());
System.out.println("Same instance? " + (instance1 == instance2));
}
}
  • JVM 内部是如何保证一个类的静态代码块只会被执行一次?并且每个类只有一次?
    • 类加载过程中的初始化阶段是执行 clinit() 方法的过程
    • <clinit>() 方法:静态变量初始化和静态代码块会被编译器合并成一个特殊的类初始化方法 <clinit>(),这个方法会在类加载的初始化阶段执行,并且只会被调用一次
    • 当多个线程同时访问一个类的静态成员时,只有第一个访问该类的线程会触发类的加载和初始化,其他线程会被阻塞,等待初始化完成。JVM 在初始化类时对类加载过程进行加锁控制,以确保静态初始化代码(即 <clinit>() 方法)在多线程环境下的安全性

7. 类加载器

7.1 概述

  • 向上委托,向下加载(先由下向上询问,再自上而下加载)
  • 父级加载器并不是父类加载器,并没有继承关系!!
  • 对于一个普通类的加载过程
    • 先会自下而上去询问父级加载器是否加载过,加载过就终止,没有加载过就一直往父级询问
    • 然后加载的时候自上而下的先寻找自己负责加载的目录下有没有该类,有该类的直接加载,没有就会让子加载器自己加载,从而保证类加载的安全性和类的唯一性

启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器

image-20240912183244471

  • 启动类加载器

    • 可以携带一些虚拟机启动参数设置”启动类加载器”来加载指定的类

    • 获取加载当前class类对象的类加载器:Class.forName("xxx.xx...").getClassLoader();

    • 启动类加载器无法通过 java 代码直接获得,打印获得到的是null,因为这个加载器是由 c++ 写的

  • 扩展类加载器

    • 当你想让扩展类加载器加载一个类的时候,把该类打成jar包,然后放到 Java_HOME/jre/lib/ext 目录下
    • 扩展类加载器就会加载这个目录下的jar包类,这样就轮不到应用程序类加载器来加载了

7.2 双亲委派模式

  • 双亲委派指的是调用类加载器的 loadClass 方法时查找类的规则
  • 源码分析
    • loadclass()
      • 当某个类加载器收到类加载请求时,它首先不会自己去加载,而是将请求委托给父加载器的 loadClass() 方法
      • 递归调用父级加载的loadClass()方法来委托父级来进行加载
    • findclass():如果父加载器找不到类,当前的类加载器会调用自己的 findClass() 方法,尝试加载这个类

image-20240912190620066

image-20240912190637001

  • 好处
    • 安全性:防止自定义类加载器加载与核心类库同名的类,防止类篡改。例如,你不能通过自定义类加载器去加载一个伪造的 java.lang.String
    • 避免重复加载:通过委派机制,每个类只会被加载一次。如果类已经被某个父级加载器加载,子加载器就不再重新加载同一个类,避免了类的重复加载

7.3 线程上下文类加载器

  • SPI(Service Provider Interface)

    • 目的:允许第三方提供类和接口的具体实现,这些实现可以在运行时被替换或添加,提供了极高的可扩展性

    • 工作原理

      • 定义服务接口:首先定义一个服务接口,这是服务提供者和服务消费者共同遵守的合同
      • 服务提供者实现接口:服务提供者根据定义好的接口实现具体的服务
      • 注册服务实现:服务提供者在自己的 JAR 文件中通过配置文件声明服务实现。这个配置文件通常位于 META-INF/services 目录下,并以服务接口的全限定类名命名。文件内容包含实现该服务接口的具体类的全限定名
      • 服务加载:使用 ServiceLoader 类,应用程序可以加载这些服务。ServiceLoader 读取配置文件,然后使用当前线程的上下文类加载器加载这些实现类
    • 举例说明

      • 在 JDBC 的案例中,服务接口是 java.sql.Driver。这是 Java 提供的一个接口,所有的 JDBC 驱动都必须实现这个接口,以便能被 java.sql.DriverManager 管理和使用
      • 每个 JDBC 驱动的 JAR 文件中,会包含一个特定的配置文件,位于 META-INF/services 目录下。对于 java.sql.Driver 接口,配置文件的名称会是:META-INF/services/java.sql.Driver
      • 这个文件内部会列出实现了 java.sql.Driver 授课的具体类的全限定名。例如,如果你有一个 MySQL 数据库驱动的 JAR,文件内容可能会是:com.mysql.cj.jdbc.Driver
      • 当应用程序启动时,或者当你需要连接到数据库时,DriverManager 会尝试加载这些驱动。它会使用 ServiceLoader 来查找和加载所有可用的 JDBC 驱动程序
      1
      2
      3
      4
      5
      ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
      for (Driver driver : loader) {
      // 这里可以访问到每个加载的驱动实例
      System.out.println("Loaded driver: " + driver.getClass().getName());
      }
    • 当每个驱动类被 ServiceLoader 加载时,通常在它们的静态初始化块中,它们会自动将自己注册到 DriverManager 中。这通常是通过调用 DriverManager.registerDriver() 方法实现的

    • 一旦驱动程序注册,你就可以通过 DriverManager 来获取数据库连接了:Connection conn = DriverManager.getConnection("jdbc:mysql://example.com:3306/mydb", "user", "password");

image-20240912192208389

image-20240912192214896

  • 以 JDBC 驱动程序加载为例
    • java.sql.DriverManager 类负责管理数据库驱动程序的注册和连接,本身是由启动类加载器加载的,因为java.sql 包是 JDK 的标准 API 的一部分,位于 rt.jar 之中,这是 Java 的核心类库。因此,**java.sql.DriverManager 和其他 JDBC 核心接口位于启动类加载器的加载路径下**
    • 但是 jdbc 的驱动是各个厂商来实现的,不在启动类加载路径下,启动类无法加载,而驱动管理需要用到这些驱动
    • 只能打破双亲委派,启动类直接请求启动类加载器去classpath下加载驱动(正常是向上委托,这个反过来了),而打破双亲委派的就是这个线程上下文类加载器
  • 线程上下文类加载器的作用
    • 注册驱动:线程上下文类加载器用于在运行时加载外部驱动程序,这些驱动程序通常不是由 Java 系统类加载器加载的,因为它们不位于 Java 核心库的路径中
    • 在java.sql.DriverManager被类加载(启动类加载器)的时候,该类的静态代码块中成对驱动的加载,但对驱动的加载并不是通过启动类加载器,而是利用java的SPI机制,先获取线程上下文类加载器,该加载器默认为应用程序加载器,然后ServiceLoader.load() 方法中会通过线程上下文类加载器来进行对驱动的加载
  • SPI 主要是为了解耦,允许服务接口的实现在运行时被动态加载和替换,而不需要修改原有的代码,使得应用程序可以插拔不同的服务实现 => 【面向接口编程+解耦】思想

image-20240912201119134

7.4 自定义类加载器

  • 什么时候需要自定义类加载器

image-20240912202936863

  • 步骤

image-20240912203028421

  • 在 Java 中,类的唯一性由其全限定名(即包名和类名的组合)和加载它的类加载器共同决定
    • 如果包名类名一样,但是类加载器对象不一样,就会被认为是不同的类,会被加载两次,是隔离的

8. 运行期优化

8.1 逃逸分析

JVM系列之:关于逃逸分析的学习_cannot use jvmci compiler: no jvmci compiler found-CSDN博客

[逃逸分析]属于C2即时编译器优化手段的一种

image-20240913084340242

  • 原因分析

image-20240913084543858

  • 解释器和即时编译器(JIT-Just In Time Compiler)的区别
    • 解释器每次遇到字节码都会一个字节一个字节解释成为机器码,下次遇到相同的字节码,还会解释执行
    • 即时编译器会识别一些热点代码(字节码),会将这些字节码编译为机器码并存入Code Cache,下次遇到相同代码,直接执行,无需再编译
    • 解释器是将字节码解释为针对所有平台都通用的机器码
    • JIT 会根据平台类型,生成平台特定的机器码

编译器:在程序执行之前,将源代码一次性编译成字节码或机器码

即时编译器(JIT 编译器):在程序运行期间,将热点字节码编译为机器码,以提升运行时性能

  • C1即时编译器和C2即时编译器优化程度不同
    • [逃逸分析]是在C2即时编译器中做的优化!

image-20240913085229194

image-20240913085248935

  • 逃逸分析(Escape Analysis) 并不是直接的优化手段,而是通过动态分析对象的作用域为其它优化手段提供依据的分析技术,用来分析一个对象的作用范围,判断该对象是否会逃逸出当前的作用域(如方法或线程)

    • 根据逃逸分析的结果,JVM 可以对对象的分配和垃圾回收进行优化,主要有以下几种典型的优化策略:
      • 栈上分配:通常,Java 中的对象是在堆上分配内存的,但是如果通过逃逸分析确定对象没有逃逸出当前方法,那么 JVM 可以将对象直接分配在栈上而不是堆上。栈上的内存分配和回收比堆内存更快,也不需要垃圾回收的参与
      • 标量替换:如果逃逸分析表明对象可以完全分解成一组标量(如对象的属性),并且这些标量可以在栈上分配,JVM 可以将对象分解为多个基本数据类型,而不需要真正创建对象。这种方式进一步减少了对象的分配和垃圾回收压力
      • **同步省略(锁消除)**:如果逃逸分析表明某个对象不会被其他线程访问,JVM 可以去掉不必要的同步代码。比如,在单线程方法中,如果对象只在当前线程中使用,JVM 可以认为不需要对该对象加锁,从而消除同步开销
    • 事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换
  • HotSpot JVM 并未真正实现 栈上分配,而是使用了 标量替换

    • 标量:不可再分割的基本数据类型,如 intfloatboolean
    • 聚合量:可以分割的数据结构,比如对象。一个对象由多个标量组成,比如一个 Point 对象有 xy 两个 int 字段
    • 标量替换的原理
      • 标量替换可以将原先对对象字段(聚合量)的访问,替换代替成为对一些局部变量(标量)的访问,就是将聚合量分解成为一个个标量,减少对象创建和垃圾回收的负担,提高了程序的执行效率
      • 例如,如果你创建了一个 Point 对象,并且该对象没有逃逸,JVM 可以将 Pointxy 字段作为局部变量直接在栈中存储,而无需真正创建 Point 对象
    • 默认情况下,标量替换是开启的,但你可以通过 JVM 参数(如 -XX:-EliminateAllocations)来禁用它
  • 对象的内存分配策略

    • 优先在堆的伊甸园区分配:小对象通常会在堆的年轻代中(Eden 区)分配内存。这是默认的分配区域
    • TLAB(Thread-Local Allocation Buffer)分配:如果开启了 TLAB(线程本地分配缓冲区),每个线程会有一个私有的小块内存(来自 Eden 区),用来加速对象的分配。这样可以减少多线程环境下的同步开销
    • 大对象直接分配到老年代:如果对象很大(通常是长数组或大数据结构),它会直接分配到堆的老年代,以避免年轻代垃圾回收的频繁触发
    • 逃逸分析和栈上分配:如果 JIT 编译器通过逃逸分析确定对象不会逃逸(即对象的生命周期局限于一个方法内),则理论上该对象可以直接分配在栈上,这样对象在方法结束时内存就会自动释放,无需依赖垃圾回收。不过在 HotSpot JVM 中,这种栈上分配通常通过标量替换的方式实现

8.2 方法内联

[方法内联]也属于即时编译器优化手段的一种

  • 内联就是把方法内代码拷贝粘贴到调用者的位置

image-20240913091234098

  • 代码示例

image-20240913091351334

8.3 字段优化

  • 字段优化:主要是针对”成员变量”和”静态变量”读写操作的优化
  • 开启方法内联的情况下(默认开启),会结合字段优化来优化

image-20240913093112752

  • **也可自己手动优化或编译器优化(foreach语法糖)**:

image-20240913093313836

  • 补充JMH(Java Microbenchmark Harness) 是一个专门用于 Java 语言的基准测试框架,用来准确地测量 Java 代码性能

    • 关键注解

      • **@Benchmark**:标记基准测试方法。JMH 会运行这个方法多次,并统计其执行时间

      • **@BenchmarkMode**:指定测试模式,如吞吐量、平均时间、单次执行时间等

      • **@OutputTimeUnit**:指定测试结果的输出时间单位,如秒、毫秒、纳秒等

      • **@State**:指定基准测试的状态范围,可以是线程级、进程级等,确保对象在基准测试中的状态

      • **@Warmup**:指定预热的轮数和时间,帮助 JIT 编译器优化代码

    • JMH 是 Java 的微基准测试框架,用于测量方法、代码段等小粒度代码的性能,添加依赖编写测试代码即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.openjdk.jmh.annotations.*;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 使用平均时间模式
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 输出结果以毫秒为单位
@State(Scope.Thread) // 定义测试状态为每个线程独立
public class MyBenchmark {

// 基准测试方法
@Benchmark
public void testMethod() {
calculate();
}

// 要测试的方法
public void calculate() {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
}
}

8.4 反射优化

  • 反射调用的性能相对较低,因为它需要通过 Method.invoke() 进行间接调用,涉及到安全检查和参数处理等
    • 为了提高反射调用的性能,JVM 会采用一种 “膨胀” 机制:当某个反射调用非常频繁时,JVM 会将它优化为一种更高效的直接调用形式,从而减少反射调用的开销
    • 具体来说,膨胀就是指将原本使用通用的反射调用路径(Method.invoke())的操作,转换为更高效的字节码路径,类似于直接调用的方法。JVM 通过这种方式来加速频繁的反射调用
  • **膨胀阈值(Inflation Threshold)**:是控制 “膨胀” 机制何时触发的关键参数。也就是说,当反射调用的次数达到一定的阈值后,JVM 就会触发膨胀机制,将反射调用转化为更高效的直接调用
    • ReflectionFactory 源码的实现中,这个膨胀阈值默认值是 15,表示当某个反射方法被调用 15 次之后,JVM 就会对该方法进行膨胀,将其优化为直接调用路径
    • 如果反射调用次数低于 15 次,JVM 使用 NativeMethodAccessorImpl 处理反射调用,它是基于本地方法的实现,适合处理少量调用
    • 膨胀发生:当反射调用次数超过膨胀阈值(15 次)后,MethodAccessor 会被替换为一个更高效的实现,称为 GeneratedMethodAccessor,它是通过字节码生成直接调用路径的方法

9. JMM-Java 内存模型

可以配合 黑马 的并发编程视频学习,这里快速过下

  • JMM 规定了在多线程下对共享数据的读写时对数据的原子性、有序性、可见性的规则和保障

9.1 原子性

  • 例如:Java 中对静态变量的自增、自减不是原子操作,有可能被 CPU 交错执行

image-20240913095254956

  • 从 JMM 角度分析:对静态变量的自增、自减需要再主内存和线程内存(工作内存)中进行数据交换

    • 共享的变量信息是放在主内存中的,线程是在工作内存中

    image-20240913095415931

    • 多线程情况下(轮流使用CPU)指令交错产生的问题

    image-20240913095659878

    image-20240913095707201

    image-20240913095756344

  • 问题解决:java中通过synchronized来保证了原子性

9.2 可见性

  • 举例:main 线程对 run 变量的修改对 t 线程不可见,导致了 t 线程无法停止

image-20240913100118675

  • 原因分析:t 线程频繁的读取 boolean 这个变量,然后即时编译器就会视为热点代码,将boolean的值缓存到高速缓存中,所以 t 线程每次读取都是从自己的工作内存中读取,主内存中改了值,其实 t 线程是感知不到的

image-20240913100422046

  • 解决方法

    • 使用 volatile 关键字修饰变量

      image-20240913100604050

      • volatile 可以保证可见性和有序性,但是不能保证原子性,它只适用于一个写线程,多个读线程的情况

      • 通过读写屏障保证线程之间的可见性和禁止编译器/处理器对指令进行指令重排序

    • synchronized 既可以保证原子性,也可以保证可见性,但属于重量级的操作,是一种重量级锁,线程的用户态->内核态的转变,比较重量级操作

  • 注意

image-20240913101110351

9.3 有序性

  • 指令重排是指编译器和 CPU 在不改变单线程语义的前提下,出于性能优化的目的,可以对指令的执行顺序进行调整。指令重排可以提高程序的执行效率,但在多线程环境下,它可能会导致并发问题

  • 在多线程环境下,指令重排可能导致线程之间的数据可见性问题,举例:双重检查锁定的单例模式 => 变量需要加volatile,否则会指令重排造成问题,使得返回的对象不完整

    • instance = new Singleton() 实际上它可能会被分解为以下三个步骤:
      • 分配内存给 Singleton 对象
      • 调用 Singleton 的构造方法
      • 将分配的内存地址赋值给 instance
    • 由于指令重排的存在,步骤 2 和步骤 3 可能会被重排,可能会出现 instance 已经指向了分配的内存(步骤 3 已经执行),但对象还没有完全初始化(步骤 2 尚未执行完毕)。导致另一个线程在未完全初始化的情况下获取了 instance,从而导致程序异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private static volatile Singleton instance;

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 指令重排可能发生在这里
}
}
}
return instance;
}
}

image-20240913123834849

9.4 happens-before 规则

  • happens-before 规则描述了哪些写操作可以对其他线程的读操作可见,是保证可见性与有序性的一套规则:
    • 简单地说,如果一个操作 A happens-before 另一个操作 B,那么 A 的结果对 B 可见,A 的操作完成后,B 才会开始
    • 这里的变量都是共享变量,也就是成员变量或静态变量
  • 总结:并不是指代码在物理上的执行顺序,而是确保逻辑上的操作顺序
    • 程序顺序:同一线程中,前面的操作 happens-before 后面的操作
    • 锁规则:解锁 happens-before 后续的加锁
    • volatile 规则volatile 写操作 happens-before 读操作
    • 线程启动start() happens-before 子线程开始运行
    • 线程终止:线程结束 happens-before 其他线程检测到它结束(如 join()
    • 中断规则interrupt() happens-before 中断状态的检测
    • 终结规则:对象的构造 happens-before finalize() 方法
    • 传递性:A happens-before B 且 B happens-before C,那么 A happens-before C

image-20240913140702468

10. CAS

  • CAS(Compare-and-Swap) 是配合 volatile 使用的一项技术,体现的是一种乐观锁的思想,是一种无锁并发技术
    • 获取”主内存中值时”,为了保证变量的可见性,需要使用volatile来修饰!集合CAS和volatile可以实现无锁并发!
    • CAS 适用场景:竞争不激烈,多核CPU的情况下
      • 因为等待的线程并不是进行进入阻塞状态,而是一种在尝试尝试 => 效率提升的原因之一(在低竞争时效率比synchronized高)
      • 但其他线程也需要占用CPU资源,如果竞争激烈,会影响效率

image-20240913125937970

  • CAS 操作底层依赖于一个 Unsafe 类直接调用操作系统底层的 CAS 指令
    • CAS 在操作系统中就是一条指令,所以是原子性的
  • 原子操作类
    • java中的悲观锁:synchronized
    • java中的乐观锁:CAS

image-20240913131020822

image-20240913131209281

11. Synchronized 优化

synchronized 的传统实现是一种重量级锁,性能相对较低,尤其是在频繁竞争和上下文切换时。为了解决性能问题,Java 虚拟机(JVM)引入了一系列锁优化机制,包括偏向锁轻量级锁重量级锁

注意锁只能升级不能降级!

  • 对象头:Java 对象头主要分为两部分
    • Mark Word:存储对象的状态信息(如锁信息、GC 信息等)
    • Class Pointer:指向该对象所属类的元数据(Class 元数据)

image-20240913131316057

image-20240913141301268

  • 锁状态:对象头的Mark Word中”锁标志位”分别对应四种锁状态

    • 无锁
    • 偏向锁
    • 轻量级锁
    • 重量级锁
  • 轻量级锁

    • 采用了 CAS(Compare-And-Swap)操作 来尝试获取锁,而不直接进入重量级锁状态
    • 适用场景:适合线程竞争不激烈(锁持有时间短)的场景,轻量级锁可以通过自旋来减少线程上下文切换的开销
    • 当一个线程持有轻量级锁时,另一个线程尝试获取该锁。如果失败,会进行自旋。自旋一定次数后,如果仍然无法获取锁,轻量级锁会升级为重量级锁,则轻量级锁会升级为重量级锁(锁膨胀)
  • 重量级锁

    • 当锁升级为重量级锁时,JVM 会采用操作系统级的线程阻塞机制来进行线程同步。获取锁失败的线程会进入阻塞状态,直到锁释放
    • 重量级锁会导致线程上下文切换,性能较差
    • 适用场景:适合锁竞争激烈的场景,但由于线程阻塞和唤醒的开销较大,效率较低
  • 偏向锁:偏向锁的设计是为了消除轻量级锁在单线程无竞争情况下的锁开销。如果只有一个线程反复获取锁,偏向锁不需要执行 CAS 操作,而是直接让该线程“偏向”该锁

    • JVM 会在锁对象的对象头(Mark Word)中记录持有偏向锁的线程 ID,在接下来该线程的加锁和解锁操作中,无需执行 CAS 操作,直接进入临界区
    • 如果另一个线程尝试获取已经偏向某个线程的锁时,偏向锁会被撤销,Java 15 中默认禁用偏向锁,并在后续版本中完全移除,因为偏向锁的撤销过程会带来额外的性能开销,对性能的提升有限
  • 其他优化

    • 减少上锁时间:尽量缩短锁定临界区的代码块
    • 减少锁粒度:减少不必要的锁竞争,提高并发性能
    • 锁粗化:将多个连续的小的锁合并为一个大锁,以避免频繁的加锁和解锁操作,减少锁重入的开销
    • 锁消除:JVM 在运行时通过逃逸分析判断锁是否有必要存在,如果确定某个锁只在单线程内使用,且不会逃逸到其他线程,则可以消除不必要的锁操作
    • 读写分离:使用 读写锁(ReadWriteLock 来区分读操作和写操作,允许多个线程同时进行读操作,但写操作依然是互斥的