面试整理——JVM

JVM

一. 综合

问:jre、jdk、jvm的关系 ?

  • jdk:最小开发环境,由jre + java工具组成。
  • jre:java运行的最小环境,由jvm + 核心类库组成。
  • jvm是虚拟机,是java字节码运行的容器,如果只有jvm是无法运行java的,因为缺少了核心类库

问:JVM如何做到跨平台?

无论任何平台,安装JRE后都有一个能运行.class文件的虚拟机,无论什么语言和平台只要能编译为标准的.class文件即可。

问: JVM参数主要有几种分类?

包括:

  • 标准参数:-开头
  • 非标准参数:-X开头
  • 不稳定参数:-XX开头

二. 内存区域

2.1 JVM内存布局

问:简单介绍一下JVM内存区域?⭐⭐⭐

  1. 程序计数器(Program Counter Register):存储当前线程正在执行的字节码指令的地址或索引,当线程切换后回到此线程仍能继续工作,每个线程都有独立的程序计数器,线程不共享。
  2. Java虚拟机栈(Java Virtual Machine Stacks):每个线程在创建时会分配一个虚拟机栈,用于存储局部变量表、操作数栈、方法出口等信息,保存此线程的运行状态。栈帧用于存储一个方法的局部变量表操作数栈指向运行时常量池的引用方法返回地址等信息,每个方法调用时都会创建一个栈帧,方法执行结束时栈帧被弹出。栈帧的大小在编译时确定,而栈的深度是在运行时动态扩展的,线程不共享。
  3. 本地方法栈(Native Method Stack): 本地方法栈类似于虚拟机栈,但是为本地(native)方法服务。它用于支持使用JNI(Java Native Interface)调用本地库中的方法。
  4. Java堆(Java Heap):存储对象实例。Java堆在启动时被创建,其大小可以通过-Xms和-Xmx等参数进行配置。堆被划分为新生代和老年代,新生代包括Eden空间和两个Survivor空间,线程共享
  5. 方法区(Method Area): 用于存储类的元数据信息,包括类的结构、方法、字段、静态变量、运行时常量池等。在HotSpot虚拟机中,方法区被称为“永久代”(Permanent Generation)。从JDK 8开始,永久代被移除,被一个称为“元空间”(Metaspace)的本地内存区域取代,线程共享
  6. 运行时常量池(Runtime Constant Pool): 运行时常量池是方法区的一部分,用于存储编译时生成的字面量和符号引用。它包含了类文件中常量池部分的内容,但不包括在运行时动态生成的常量。
  7. 直接内存(Direct Memory): 直接内存并不是JVM规范中定义的内存区域,但是通过ByteBuffer等类可以直接在堆外分配内存,这部分内存会被JVM管理。它的分配和释放不受Java堆大小的限制,可以通过NIO的相关类进行操作。

2.2 堆

问:堆空间分哪些部分?讲解新生代和老年代?以及如何设置各个部分?⭐⭐⭐

堆空间包括:采用分代收集算法

  • 新生代(Young Generation):对象一般会先放置在新生代,
    • Eden区:新对象的出生地。
    • From Survivor区
    • To Survivor区:
  • 老年代:大对象直接进入老年代,长期存活对象陆续进入老年代。
  • 永久代(Permanent Generation):已在JDK 8后被元空间(Metaspace)替代

配置参数:

  • -Xms :初始堆大小。
  • -Xmx :最大堆大小。
  1. 新生代(Young Generation)
    • Eden 区:新创建的对象大部分分配在 Eden 区。
    • Survivor From(S1)经历一次Minor GC后进入Survivor区增加一岁,超过年龄阈值就移到老年代。
    • Survivor To(S2)Minor GC时将另外两区存活的对象复制到此区,同时年龄+1,清空另外两区对象后,将To区和From区互换。
    • 对象生命周期:
      • Eden 满了 → 触发 Minor GC → 存活对象转移到 Survivor。
      • 在 Survivor 中年龄+1,超过阈值(默认 15)进入 老年代
  2. 老年代(Old Generation)
    • 存放长期存活对象、大对象(如长生命周期对象、数组、缓存)
    • 当老年代满了 → 触发 Full GC
  3. 元空间(Metaspace,JDK 8+)
    • 存放类的元数据(如方法区、类信息、运行时常量池)。
    • JDK 8 以前是“永久代(PermGen)”,JDK 8+ 被“元空间”替代

问:为什么除了Eden区还要有Survivor区,为什么要有两个survivor区?Eden区和Survivor区的比例应该是多少,为什么是这个比例,其工作过程是怎样的?⭐⭐⭐

为什么除了Eden区还要有Survivor区?

  1. 作为Eden和Old之间的缓冲,否则Eden中存活的对象要马上进Old
  2. 减少放到老年代的对象,减少Full GC的发生

为什么要有两个survivor区?

  • 减少碎片化:当Eden满了,触发一次Minor GC,存活的对象移入另一个S区,而Eden和S会清空。如果没有一个空的S区,Eden和S都有对象时,可能会缺少一个连续的内存空间。

Eden区和Survivor区的比例应该是多少?

  • 8 : 1。Eden:S1:S2 = 8:1:1,即新生代可用空间是新生代总空间的9 / 10

为什么是这个比例?

  • 据研究有95%以上的对象都存活期较短,所以Eden区应远大于Survivor区,具体比例应该取决于工程验证和数据统计的结果。

工作过程:

  1. 初始对象一般诞生于Eden区,除非对象比较大如字符串或数组直接进入老年代。
  2. 当Eden区域不够时会发生Minor GC,根据垃圾回收算法回收部分对象。
  3. 将当前存活对象(Eden区和From Survivor区剩余对象)转移到To Survivor区。
  4. 年龄+1(年龄足够的要进入老年代),并将To区和From区互换。
  5. 如果Survivor区剩余空间不足,还要向老年代做分配担保,并转移到老年代。

问:如何判断new一个Object的大小?对象的构成 ?对象的内存布局,涉及到锁的部分 ?⭐⭐⭐

一个对象分为3个区域:

  1. 对象头(Header)
    • 运行时数据(Mark Word):8 byte
      • 哈希码(HashCode):支持对象的哈希操作
      • 分代年龄:支持GC中的分代收集
      • 锁状态:支持对象同步操作,如是否被锁定、锁的类型(偏向锁、轻量级锁、重量级锁等)
      • 偏向线程ID:若使用了偏向锁,可能包含线程ID
      • GC 标志位: 用于标识对象是否可回收等垃圾回收相关的信息。
    • 类型指针(Class Pointer):4 byte / 8 byte (不同操作系统32/64)
      • 用于确定对象的类型信息,指向类的元数据,包括类的类型、方法、字段等,通过这个指针,JVM可以确定对象的实际类型,从而进行方法调用和字段的访问等操作。
    • 数组长度:4 byte
      • 可能存在的第三部分,对于数组类型,会多一块记录数组的长度(因为数组的长度是jvm判断不出来的,jvm只有元数据信息)
  2. 实例数据(Instance Data):会根据虚拟机分配策略来定,分配策略中,会把相同大小的类型放在一起,并按照定义顺序排列。不同类型的大小:
    • boolean:1 byte
    • short:2 byte
    • int:4 byte
    • long:8 byte
    • ref(string):4 byte / 8 byte
  3. 对齐填充(Padding):x byte,保证对象是8 byte的整数倍
    • 在虚拟机规范中对象必须是8字节的整数,所以当对象不满足这个情况时,就会用占位符填充。

当对象涉及到锁时,锁记录中的信息会根据锁的状态而有所不同:

  • 偏向锁(Biased Locking): 用于表示对象被某个线程偏向,当没有竞争时,可以快速获取锁。此时,Mark Word 中会包含偏向线程的 ID。
  • 轻量级锁(Lightweight Locking): 当多个线程竞争同一个锁时,JVM使用轻量级锁进行优化。此时,Mark Word 中包含指向线程栈中锁记录的指针。
  • 重量级锁(Heavyweight Locking): 当轻量级锁竞争不过多线程时,会升级为重量级锁。此时,Mark Word 中会包含指向锁的指针,对象本身的锁状态存储在锁上。

问:Java对象的指针压缩?

  1. JDK 1.6版本开始支持指针压缩。

  2. 如何启用指针压缩:

    • 使用以下参数启用普通指针压缩 -XX:+UseCompressedOops ,默认开启
    • 启用 Narrow-Oop 指针压缩 -XX:+UseCompressedClassPointers
    • 禁止使用: -XX:-UseCompressedOops
  3. 在没有启用指针压缩的情况下,Java 对象引用通常是占用8个字节(64位)

  4. 启用指针压缩后,对象引用可以缩小到4个字节,指针压缩主要有两种形式:

    • 普通指针压缩:使用32位的指针来表示对象引用。这种方式适用于堆空间小于4GB的情况,此时32位的寻址空间足够表示整个堆,因此可以使用更小的指针来节省内存。
    • Narrow-Oop 指针压缩:使用32位的指针,但只使用32位中的一部分来表示对象引用。这部分称为“Narrow-Oop”。Narrow-Oop 仅包含足够的信息来定位对象在堆中的位置。适用于堆空间小于32GB的情况。堆内存大于32G时指针压缩会失效,强制使用64位地址
  5. 启用指针压缩的优势主要在于:

    • 节省内存:通过减小对象引用的大小,可以在大型应用程序中显著减小内存占用。

    • 提高缓存效率:减小对象引用的大小可以提高缓存的命中率,因为可以在相同的缓存空间内存放更多的对象引用。

    • 减小GC开销:GC操作通常需要遍历对象引用,减小对象引用的大小可以减少GC时的遍历开销。

2.3 虚拟机栈

问:什么是栈帧?栈帧存储了什么?栈帧和动态链接的作用分别是什么?⭐⭐⭐

虚拟机栈中的栈帧是方法执行的内存模型,每个方法执行时会创建一个栈帧,栈帧会存放局部变量表、操作数栈、动态链接、方法出口等。

  1. 局部变量表(Local Variable Table):用于存储方法中的局部变量,包括方法参数和方法内部定义的局部变量。局部变量表的大小在编译期确定,并且各个局部变量在方法的作用域内都可以访问。
  2. 操作数栈(Operand Stack):用于执行方法的操作,包括方法的参数和临时变量。操作数栈是一个后进先出(LIFO)的栈,方法中的指令通过对操作数栈的操作来进行计算。
  3. 动态链接(Dynamic Linking):用于支持方法调用过程中的动态链接。动态链接主要包括两个部分:指向运行时常量池中该方法的引用以及指向该方法所属类的 Class 对象的引用。动态链接在运行时解析常量池中的符号引用,将其转换为直接引用。
  4. 返回地址(Return Address):用于存储方法调用结束后的返回地址,即在方法执行完毕后需要返回到哪个地址继续执行。返回地址通常指向方法调用指令的下一条指令。

栈帧和动态链接的作用分别如下:

  • 栈帧的作用:提供了方法调用和执行的运行时数据区域,包含了方法的状态信息。每个线程都有自己的栈帧栈,方法调用时会创建新的栈帧,方法返回时栈帧被销毁。栈帧的存在使得方法调用能够按照先进先出的顺序进行管理,确保方法的嵌套调用能够正确执行。
  • 动态链接的作用:在方法调用时,通过动态链接将符号引用解析为直接引用,确保方法能够正确地被调用。这样做的好处是在编译期间无需确定方法的具体地址,而是在运行时进行解析,使得程序的灵活性更高。这也支持了 Java 的多态性,允许在运行时替换和链接不同的类。

2.4 方法区

问:JDK 1.7到JDK 1.8虚拟机发生的变化?JVM为什么要使用元空间代替永久代?⭐⭐

变化?

  1. 移除了PermGen(永久代),通过元空间(Metaspace)代替
  2. 字符串常量池从永久代移动到Java堆中(在1.7已迁移),而运行时常量池一直在方法区/元空间
  3. 引入Lambda表达式
  4. 新的日期和时间API
  5. Stream
  6. 等等

JVM为什么要使用元空间代替永久代?

  1. 内存限制:永久代的大小要在启动时配置好,无法动态调整,而元空间无指定大小限制,且可以动态调整。JVM加载的class的总数、方法的大小很难确定,所以不好制定大小
  2. 降低OOM:元空间放在本地内存,不影响JVM占用的内存,降低了OOM发生的概率
  3. 提升GC性能:永久代和老年代都通过full GC来实现垃圾回收,替换元空间后可以简化此流程
  4. JRockit:Oracle合并了HotSpot和JRockit,而后者没有永久代

问:字符串常量池相关?

字符串常量池是Java中的一个特殊的存储区域,用于存储字符串常量。

  1. 常量池是字符串的缓存区域: 字符串常量池是一块特殊的内存区域,用于存储编译时期生成的字符串常量。

  2. 重用性: 当程序中有相同的字符串常量时,它们会被存储在常量池中,并被多个引用所共享,以节省内存。

  3. new关键字: 使用new关键字创建字符串对象时,会在堆内存中新建一个对象,而不会放入常量池。但是,如果已经存在相同内容的字符串常量,它不会再次创建,而是返回常量池中的引用。

  4. 字符串池的实现: 字符串常量池是通过String类的特殊设计来实现的。当创建字符串常量时,会首先检查常量池中是否已存在相同内容的字符串,如果存在,则返回常量池中的引用;如果不存在,则在常量池中创建一个新的字符串常量。

1
2
3
4
String str1 = "Hello"; // 存储在字符串常量池
String str2 = "Hello"; // 直接引用字符串常量池中的"Hello"
String str3 = new String("Hello"); // 在堆内存中创建新的对象,但不放入常量池
String str4 = str3.intern(); // 将字符串对象放入常量池,并返回常量池中的引用
  1. 字符串常量池的位置: 在早期的Java版本中,字符串常量池位于永久代(Permanent Generation)中。然而,在Java 7 及以后的版本中,永久代被元空间(Metaspace)所取代,而字符串常量池被移至堆内存。

  2. 避免使用+拼接大量字符串: 在循环或频繁拼接字符串时,建议使用StringBuilderStringBuffer,以避免大量创建无意义的中间字符串,减少常量池的压力。

2.5 其它

问:堆外内存的优缺点?

Ehcache中的一些版本,各种 NIO 框架,Dubbo,Memcache 等中会用到,NIO包下ByteBuffer来创建堆外内存,其实就是不受JVM控制的内存。

NIO直接通过Native函数库分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

优点:

  • 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。
  • 加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了复制这项工作。 可以扩展至更大的内存空间。比如超过 1TB 甚至比主存还大的空间。

缺点:

  • 难以控制,内存泄漏很难排查;
  • 很难存储较复杂对象。

堆外内存难以控制,如果内存泄漏,那么很难排查,通过 -XX:MaxDirectMemerySize 来指定,当达到阈值的时候,调用 system.gc 来进行一次full gc堆外内存相对来说,不适合存储很复杂的对象。

问:说一下JVM的线程模型?这些区域都分别是干啥用的?java线程模型和jvm线程模型注意区分?

Java使用的是一对一线程模型,即每个Java线程都对应一个底层的本地操作系统线程。调度完全交给JVM来处理,由JVM负责线程的创建、调度、销毁等管理操作。

JVM内部的主要线程分为:

  • NamedThread:支持命名的非Java线程
    • VMThread:VM原始线程,用于执行VM操作
    • ConcurrentGCThread:并发GC线程
    • WorkerThread:工作线程
      • GangWorker:一组线程,类似线程池
      • GCTaskThread:GC任务线程
  • JavaThread:C++层面的Java线程实现
    • 各种子类,如:编译器线程,服务线程
  • WatcherThread:监视器线程,用于模拟计时器中断

在JVM内部产生一个线程的方法有两种:

  • 调用 java.lang.Threadstart() 方法。
  • 通过 JNI attach到一个已经存在的本地线程。

java.lang.Thread 启动时会分别创建一个相关联的JavaThread和OSThread对象,最终创建本地线程。

Java线程是由Java应用程序直接创建和控制的,而JVM线程是由JVM内部创建和管理的。

三. 内存分配

问:Java对象的创建过程是什么?⭐⭐⭐

  1. 类加载检查:检查当前指令的参数是否能在常量池定位到一个类的符号引用,并检查对应的类是否已被加载、解析和初始化过,若没有就执行类加载过程。(参考类加载流程)。
  2. 分配内存:检查通过后,JVM为新生对象分配内存,所需大小已确定,划分一块堆空间。
    • 指针碰撞:默认方式,若堆是绝对规整的,即用过的都在一边,空闲在另一边,中间有指针作为分界,分配内存只需将指针移动所需对象大小的距离。
    • 空闲列表:堆中内存不规整,需要维护一个列表记录哪些内存块可用,分配时找到一块足够大的分配给对象。
  3. 初始化“零值”:保证对象的实例字段在Java代码中可以不赋初始值就能直接使用,程序可以直接访问到这些字段的数据类型对应的零值,如int=0,string=null,若使用TLAB时该过程也可以提前在TLAB分配时进行。
  4. 设置对象头:再对对象进行必要的配置,比如该对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。对象的存储可以分为3块区域:对象头、实例数据、对齐填充。对象头分为两类信息:
    • 存储对象自身的运行时数据(标记字段 Mark word):
      • 哈希码:支持对象的哈希操作
      • 分代年龄:支持GC中的分代收集
      • 锁状态:支持对象同步操作,如是否被锁定、锁的类型(偏向锁、轻量级锁、重量级锁等)
      • 偏向线程ID:若使用了偏向锁,可能包含线程ID
      • GC 标志位: 用于标识对象是否可回收等垃圾回收相关的信息。
    • 类型指针(类元数据):用于确定对象的类型信息,指向类的元数据,包括类的类型、方法、字段等,通过这个指针,JVM可以确定对象的实际类型,从而进行方法调用和字段的访问等操作。
  5. 执行init:可以按照程序员意愿初始化,init为Java文件编译后在字节码里生成的实例构造器。为属性赋值、执行构造方法。
  6. 对象栈上分配:Java对象都是在堆上进行分配,但具体分配的区域,需要逃逸分析来确定。

Java 对象创建过程的流程图:

1
类加载检查 -> 分配内存 -> 初始化“零值” -> 设置对象头 -> 执行<init>方法 -> 对象栈上分配(逃逸分析)

详细流程:

  1. 类加载检查:在创建对象之前,JVM 需要确保对象的类已经被加载、解析和初始化。如果类尚未加载,JVM 会执行类加载过程。

    类加载流程

    1. 加载:查找并加载类的字节码文件。
    2. 验证:确保字节码文件的正确性和安全性。
    3. 准备:为类的静态变量分配内存并设置默认值。
    4. 解析:将符号引用转换为直接引用。
    5. 初始化:执行类的静态初始化代码(<clinit> 方法)。
  2. 分配内存:在类加载检查通过后,JVM 会为新生对象分配内存。内存分配的方式取决于堆内存的布局。

    2.1 内存分配方式

    1. 指针碰撞(Bump the Pointer)
      • 适用于堆内存规整的情况(如使用 Serial、ParNew 等垃圾收集器)。
      • 通过移动指针来分配内存。
    2. 空闲列表(Free List)
      • 适用于堆内存不规整的情况(如使用 CMS 垃圾收集器)。
      • 维护一个空闲内存块列表,分配时从列表中找到合适的内存块。

    2.2 TLAB(Thread Local Allocation Buffer)

    • 为了减少多线程竞争,JVM 为每个线程分配一块私有内存区域(TLAB)。
    • 对象优先在 TLAB 中分配,如果 TLAB 不足,则使用共享的堆内存。
  3. 初始化“零值”:在分配内存后,JVM 会将对象的内存空间初始化为“零值”,确保对象的实例字段可以不赋初始值直接使用。

    • 零值
      • 基本类型:int = 0,boolean = false,float = 0.0f 等。
      • 引用类型:null
  4. 设置对象头:对象头是对象的重要组成部分,包含对象的元数据和运行时状态。

  5. 执行 <init> 方法:在对象头和实例数据初始化完成后,JVM 会执行对象的 <init> 方法(实例构造器),按照程序员的意愿初始化对象。

    • 为对象的实例字段赋值。
    • 执行构造方法中的代码。
  6. 对象栈上分配(逃逸分析):Java 对象通常在堆上分配,但通过逃逸分析(Escape Analysis),JVM 可以将某些对象分配在栈上。

    6.1 逃逸分析

    • 逃逸:对象的作用域超出了当前方法或线程。
    • 非逃逸:对象的作用域仅限于当前方法或线程。

    6.2 栈上分配

    • 如果对象是非逃逸的,JVM 可以将对象分配在栈上,从而减少堆内存的压力和垃圾回收的开销。
    • 栈上分配的对象会随着方法的结束而自动销毁。

关键点

步骤 描述
类加载检查 确保类已被加载、解析和初始化。
分配内存 使用指针碰撞或空闲列表分配内存,优先使用 TLAB。
初始化“零值” 将对象的内存空间初始化为零值。
设置对象头 设置 Mark Word 和类型指针,存储对象的运行时数据和类型信息。
执行 <init> 方法 按照程序员的意愿初始化对象,包括字段赋值和构造方法执行。
对象栈上分配 通过逃逸分析,将非逃逸对象分配在栈上。

通过理解 Java 对象的创建过程,可以更好地掌握 JVM 的内存管理和对象生命周期。

问:对象申请内存空间的过程是什么?⭐⭐⭐

  1. 新建对象申请内存, Eden区是否有足够空间,有则申请成功。
  2. 没有,则经历一次 Minor/Young GC,Eden区的存活对象会被复制到Survivor区,再判断Eden区是否有足够空间。经过一定数量的minor gc仍存活的对象会被晋升到老年代。
  3. 没有,则判断Survivor区是否有足够空间。
  4. 没有,则判断老年代是否有足够空间。
  5. 有,则需要将Survivor区复制到老年代,再将Eden区复制到Survivor区,最终申请成功。
  6. 没有,则经历一次 Full GC,会清理一次新生代和老年代空间,再判断老年代是否有足够空间。
  7. 最终仍失败则抛出OOM异常。

1️⃣ 新对象创建,检查 Eden 区

  • Eden 区有足够空间 → 直接分配内存,完成分配 ✅
  • Eden 区空间不足 → 触发 Minor GC(清理新生代对象)。

2️⃣ 执行 Minor GC,存活对象进入 Survivor 区

  • Eden 区的存活对象 复制到 Survivor(From -> To 交换)。
  • Survivor 存不下?→ 晋升老年代(满足晋升条件)。
  • GC 过后 Eden 仍然不够? → 进入下一步。

3️⃣ 检查 Survivor 区是否有足够空间

  • 有足够空间 → Eden 存活对象进入 Survivor,申请成功 ✅
  • Survivor 空间不足 → 进入下一步。

4️⃣ 检查老年代是否有足够空间

  • 老年代有空间 → 直接晋升老年代,申请成功 ✅
  • 老年代空间不足 → 触发 Full GC(清理老年代 & 整个堆)。

5️⃣ 执行 Full GC

  • 清理老年代 & 新生代对象,尝试释放更多空间。
  • Full GC 过后仍然没有空间?OOM(OutOfMemoryError)

问:对象内存分配时的指针碰撞和空闲列表机制分别是?⭐

  • 指针碰撞:默认方式,若堆是绝对规整的,即用过的都在一边,空闲在另一边,中间有指针作为分界,分配内存只需将指针移动所需对象大小的距离。
  • 空闲列表:堆中内存不规整,需要维护一个列表记录哪些内存块可用,分配时找到一块足够大的分配给对象,并更新列表记录。

问:JVM分配内存时并发场景如何处理的?⭐⭐

并发场景下,可能给对象A分配内存,指针还未来得及修改,对象B就使用了原指针来分配内存,解决办法:

  1. JVM采用CAS(Compare and Swap)加失败重试机制来保证对象创建等操作的原子性。多个线程尝试修改同一个内存地址时,CAS 可以确保只有一个线程成功更新该地址,其他线程会进行重试。
  2. 采用本地线程分配缓冲技术(Thread Local Allocation Buffer TLAB)把分配内存的动作按照线程划分在不同空间进行,减少线程竞争,每个线程在Java堆中预先分配一小块内存,通过 XX: +UseTLAB 参数来设定是否使用TLAB,XX:TLABSize 指定TLAB大小。

问:线程本地分配缓冲区(TLAB: Thread Local Allocation Buffer) ?⭐⭐

线程本地分配缓冲区(Thread Local Allocation Buffer,TLAB)是Java虚拟机为了提高对象分配效率而设计的一种内存分配策略。TLAB的主要思想是为每个线程分配一个私有的、专属的小块内存区域,用于对象的快速分配,减少多线程竞争分配内存的问题。

TLAB的工作原理如下:

  1. TLAB的分配: 当一个线程需要分配对象时,它会先从自己的TLAB中尝试分配。这个TLAB是线程独占的,不会被其他线程访问。如果TLAB空间足够,对象就直接在TLAB上分配,避免了全局锁的竞争。

  2. TLAB的扩展: 如果一个线程的TLAB空间不够,需要进行扩展。扩展时,线程会请求全局的内存分配器获取额外的TLAB空间。这个过程可能涉及到锁的竞争,但由于是在较小的范围内进行,锁的争用相对较小。

  3. TLAB的回收: 在对象分配完毕后,TLAB并不会立即回收,而是在发生Minor GC时,才会进行回收。这样做可以降低内存分配时的竞争,并减少全局垃圾回收时的停顿时间。

TLAB的优势在于减小了多线程下全局分配内存的竞争,提高了内存分配的效率。每个线程都拥有自己的TLAB,减少了锁的争用,从而提高了并发性能。这对于一些多线程密集型的应用来说是非常重要的。

问:Java对象栈上的分配流程⭐⭐

  1. JVM的栈上分配是一种优化技术,其基本思想是将一些线程私有的对象分配在线程的栈上而不是在堆上,在函数调用结束后自行销毁。这样的优化目的在于提高对象的分配和访问速度,减少堆上内存的动态分配和垃圾回收的开销。
  2. 栈上分配通常是通过逃逸分析来实现的。

问:对象逃逸分析⭐⭐

  1. 对象逃逸分析是JVM的一项优化技术,用于分析对象在程序中的作用域,确定其是否会逃逸到方法外部。
  2. 如果对象的引用不会逃逸,即仅在当前方法内部使用,JVM可以采取一些优化措施,比如栈上分配,以提高程序的性能。
  3. 几个阶段:
    1. 标量替换: 首先,逃逸分析会尝试将一些对象的字段拆分成独立的局部变量,这被称为标量替换。如果对象的字段不会被外部代码引用,那么可以将它们拆分为局部变量,避免了创建对象。
    2. 栈上分配: 如果逃逸分析确定一个对象的引用不会逃逸到方法外部,JVM可以选择在当前线程的栈上为该对象分配内存,而不是在堆上动态分配。这减少了对象的创建和销毁的开销,提高了程序的性能。
    3. 同步消除: 逃逸分析还可以帮助JVM进行同步消除。如果对象的引用没有逃逸到其他线程,那么对该对象的访问可能不需要同步措施,从而提高程序的并发性能。
    4. 锁消除: 如果逃逸分析确定某个对象在整个程序中只有一个线程访问,那么对该对象的锁可能可以被消除,从而减少了不必要的同步开销。
  4. 逃逸分析通常在即时编译(Just-In-Time Compilation,JIT)过程中进行。

四. 垃圾回收

4.1 垃圾回收

问:GC 是什么?为什么要有 GC?⭐

垃圾回收(Garbage Collection)在JVM中是一种自动内存管理机制,负责识别和释放不再被程序引用的对象,以回收内存空间。

  1. 防止内存泄漏
  2. 提高内存利用率
  3. 避免繁琐的手动回收,简化内存管理

问:怎么设置永久代和堆的大小、怎么减少 Full GC?⭐⭐⭐

设置堆的大小:

  1. 初始堆大小和最大堆大小: 使用 -Xms-Xmx 参数来设置初始堆大小和最大堆大小。例如,-Xms512m -Xmx1024m 表示初始堆大小为512MB,最大堆大小为1024MB。

  2. 新生代与老年代比例: 使用 -XX:NewRatio 参数可以调整新生代与老年代的大小比例。默认值是2,表示新生代占整个堆的1/3。可以根据应用的特性进行调整。

减少 Full GC:

  1. 调整新生代的大小: 较小的新生代可以减少每次Minor GC的时间,从而减少Full GC的频率。使用 -Xmn 参数可以设置新生代的大小。

  2. 调整新生代的垃圾收集策略: 使用 -XX:+UseParNewGC-XX:+UseG1GC 等垃圾收集器,根据应用的需求选择合适的新生代垃圾收集策略。

  3. 调整老年代的大小: 较大的老年代可以减少老年代的填充速度,从而延缓Full GC的发生。使用 -XX:MaxTenuringThreshold 调整晋升到老年代的年龄。

  4. 减少创建临时对象: 避免在应用程序中频繁创建临时对象,尤其是大对象。这可以通过对象池等技术来实现。

  5. 合理设置内存参数: 根据应用程序的特性和运行环境,合理设置 -XX:MaxMetaspaceSize-XX:MaxDirectMemorySize 等参数。

  6. 监控和调整: 使用监控工具(如VisualVM、JConsole或 Prometheus + Grafana)对应用程序进行监控,根据监控数据调整JVM参数。关注堆内存的使用情况、垃圾收集频率以及Full GC的原因。

问:如何确认一个对象是垃圾?对象什么时候可以被垃圾回收?GC什么时候触发?导致fullGC的原因?⭐⭐⭐

如果判断一个对象是否存活?

  1. 引用计数法:每个对象维护一个计数器,记录有多少引用指向该对象;当计数器为 0 时,说明该对象不再被引用,可以被回收。
    • 但引用计数法无法处理循环引用的情况,所以没有JVM使用它。
  2. 可达性分析:通过GC ROOTs的根对象,遍历对象间引用关系形成的对象图,确认能否到达此对象
    • 能作为GC ROOT:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等

对象什么时候可以被垃圾回收?对象垃圾回收的条件?

  1. 对象不可达(即从 GC Roots 无法到达该对象)。
  2. 对象的强引用被替换为弱引用、软引用或虚引用。

GC什么时候触发?导致fullGC的原因?

  • 触发Young/Minor GC:Eden、Survivor区内存不足
  • 触发Full GC:
    1. Old 代空间不足
      • 长时间存活的对象过多,导致 Old 代空间不足。
    2. 方法区(元空间)空间不足
      • 加载的类过多或动态生成的类过多。
    3. 内存分配担保失败
      • Minor GC 后,Survivor 区无法容纳存活对象,且 Old 代也无法容纳。
    4. **显式调用 System.gc()**:
      • 虽然不一定会触发 Full GC,但在某些情况下可能会触发。

问:JVM是根据什么来执行可达性分析的 ?GC Roots有几种?⭐⭐⭐

可达性分析的依据

  • JVM 通过 GC Roots 遍历对象引用链,判断对象是否可达。
  • 如果一个对象无法通过 GC Roots 到达,则被认为是垃圾。

可被视为GC Roots的有:

  1. 类加载器
  2. 活跃线程(Thread)
  3. 虚拟机栈中的本地变量表
  4. 方法区中的静态属性引用的对象
  5. 方法区中的常量引用的对象(如字符串常量池)。
  6. 本地方法栈中 JNI 引用的对象

问:各种GC执行频繁的影响?⭐⭐

Minor GC 频繁的影响

  • 影响:每当发生一次垃圾收集的动作,所有的用户线程都必须跑到最近的一个安全点(SafePoint),然后挂起线程等待垃圾回收。这样过于频繁的GC就会导致很多没有必要的安全点检测、线程挂起及恢复操作。如果是新生代GC频繁发生,是由于虚拟机分配给新生代的空间太小而导致的,使用 -Xmn 参数调整新生代的大小。
    • 每次 Minor GC 都会导致用户线程暂停(Stop-The-World)。
    • 频繁的 Minor GC 会增加安全点检测、线程挂起和恢复的开销。
  • 原因
    • 新生代空间过小,导致对象频繁晋升到老年代。
  • 解决方案
    • 调整新生代大小(-Xmn 参数)。

Full GC 频繁的影响

  • 影响
    • Full GC 耗时较长,会导致程序卡顿。
    • 频繁的 Full GC 会严重影响系统性能。
  • 原因
    • Old 代空间不足。
    • 方法区(元空间)空间不足。
    • 内存分配担保失败。
  • 解决方案
    • 调整 Old 代大小(-Xmx-Xms 参数)。
    • 调整 JVM 参数(如 -XX:MaxGCPauseMillis-XX:GCTimeRatio)来优化 Full GC 的频率和耗时。
    • 优化代码,减少长时间存活的对象。

问:GC安全点与安全区域?⭐

GC需要代码运行到安全点或安全区域才能做:

  • 安全点(SafePoint)指线程运行到这些位置时它的状态是确定的,这样JVM可以安全的进行GC操作。

    常见安全点包括:

    1. 方法返回之前
    2. 调用某个方法之后
    3. 抛出异常的位置
    4. 循环的末尾

    线程中断机制

    • 当 GC 需要中断线程时,会设置一个标志位。
    • 线程运行到安全点后,会检查此标志并主动挂起。
  • 安全区域:安全区域指在一段代码片段中,引用关系不会发生变化,则该区域的任意地方进行GC都是安全的。安全点是对运行中的线程生效的,而Sleep或中断状态的线程无法主动响应并运行到安全点。

    适用场景

    • 当线程处于 Sleep 或阻塞状态时,无法主动运行到安全点。
    • 在安全区域内,JVM 可以安全地进行 GC 操作。

问:Java中会存在内存泄漏吗,简述一下?既然jvm有垃圾回收,为什么还会出现内存溢出的情况?⭐⭐

会存在,首先内存泄漏即不再会被使用的对象等一直占据内存空间

JVM通过垃圾回收机制来自动清除不再有用的内存,但仍有多种情况会导致内存泄漏。

  • 长生命周期的对象持有短生命周期对象的引用
  • 未正确关闭资源,如文件、数据库连接、网络连接等
  • 静态集合的对象引用

问:如何设置参数生成GC日志?

通过在JVM启动时添加一些参数来配置。以下是常用的参数设置:

  1. -Xloggc:

    • 该参数用于指定GC日志的输出文件路径。可以设置为文件路径,例如-Xloggc:/path/to/gc.log,或者将其设置为特殊的值,例如-Xloggc:stdout(输出到控制台)或-Xloggc:stderr(输出到标准错误流)。
  2. -XX:+PrintGCDetails:

    • 启用详细的GC日志输出,包括每次GC事件的详细信息。
  3. -XX:+PrintGCDateStamps:

    • 在GC日志中输出日期时间戳,方便分析日志的时间序列。
  4. -XX:+PrintHeapAtGC:

    • 在每次GC之后输出堆的详细信息,包括堆的使用情况和各个区域的大小等。
  5. -XX:+UseGCLogFileRotation:

    • 启用GC日志文件的轮换,可以设置-XX:NumberOfGCLogFiles-XX:GCLogFileSize来配置轮换的文件数和每个文件的大小。

综合使用这些参数,你可以在启动Java应用程序时生成GC日志。例如:

1
java -Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=10M -jar YourApplication.jar

这样设置后,GC日志将输出到/path/to/gc.log文件中,包括详细信息、日期时间戳,同时启用了日志文件的轮换功能。

问:Java 中都有哪些引用类型?对象4种引用?以及 GC 对他们执行怎样的操作 ?⭐⭐

在Java中,对象引用主要分为四种类型:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。这些引用类型是在 java.lang.ref 包中定义的。

  1. 强引用:最常见的引用类型,通过关键字 new 创建的引用都属于强引用。

    • 内存泄露主因,不会被回收:当一个对象被强引用关联时,即使内存不足,垃圾收集器也不会回收这个对象。
    • 强引用:Object obj = new Object();
  2. 软引用:用于描述一些还有用但非必需的对象。内存即将溢出时会被回收,适合缓存用。

    • 当一个对象只有软引用的话,空间不足将被回收

    • 通过SoftReference类来创建软引用:

      1
      SoftReference<Object> softRef = new SoftReference<>(new Object());
  3. 弱引用:用于描述非必需的对象。下一次GC时就会被回收。

    • 被GC检测到时,就会被回收。

    • 通过WeakReference类来创建弱引用:

      1
      WeakReference<Object> weakRef = new WeakReference<>(new Object());
  4. 虚引用:最弱的一种引用关系,不影响回收,但会在回收时通知系统。

    • get方法始终返回null无法通过虚引用访问对象,用于跟踪对象被垃圾回收的状态,而不是为了获取对象的引用或者影响对象的生命周期。

    • 虚引用必须和引用队列(ReferenceQueue)一起使用。

    • 用于跟踪GC状态,用于管理堆外内存

    • 通过PhantomReference类来创建虚引用:

      1
      PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);

GC 对引用的操作:

  • 强引用: 不会被垃圾收集器回收,除非没有任何强引用指向该对象。
  • 软引用: 当系统内存不足时,垃圾收集器会尝试回收软引用,如果回收成功,软引用会被置为null
  • 弱引用: 弱引用可能会在垃圾收集器的任意时刻被回收,不受系统内存状况的影响。
  • 虚引用: 虚引用本身对对象的生命周期没有直接影响,但当虚引用关联的对象被回收时,虚引用会被放入引用队列,应用程序可以通过监控引用队列来了解对象被回收的情况。

问:Java 是否可以 GC 直接内存 ?⭐⭐

直接内存(Direct Memory)是通过 java.nio 包提供的 ByteBuffer 类来分配的,而不是通过 Java 虚拟机的垃圾回收器进行管理。在频繁创建和销毁 ByteBuffer 对象时,及时释放不再需要的对象。

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
28
29
import java.nio.ByteBuffer;

public class DirectMemoryExample {

public static void main(String[] args) {
allocateAndFreeDirectMemory();
}

private static void allocateAndFreeDirectMemory() {
// 分配直接内存,大小为 1 MB
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);

// 使用直接内存进行一些操作
directBuffer.putInt(123);
directBuffer.putDouble(3.14);

// 手动释放直接内存
freeDirectMemory(directBuffer);
}

private static void freeDirectMemory(ByteBuffer buffer) {
// 获取 Cleaner 对象
sun.misc.Cleaner cleaner = ((sun.nio.ch.DirectBuffer) buffer).cleaner();

// 手动释放直接内存
cleaner.clean();
}
}

直接内存的回收

  • 与 Java 对象绑定:
    尽管直接内存不在堆上,但其分配通常与一个 Java 对象(例如 DirectByteBuffer)关联。这个对象在堆上保存了一个指向直接内存的地址以及其他管理信息。

  • 回收机制:

    • 当一个 DirectByteBuffer 对象不再被引用,变为不可达时,正常来说它会进入垃圾回收流程。

    • 在较早的 Java 版本中,这类对象通常重写了 finalize() 方法,在对象被回收时通过 finalize 释放直接内存;

    • 从 Java 7 以后(特别是 Java 9 之后),为了提高效率和避免 finalize 带来的问题,JVM 采用了

      Cleaner

      机制。

      • Cleaner/PhantomReference 机制: 当 DirectByteBuffer 对象变得不可达时,与其关联的 Cleaner(或通过 PhantomReference 注册的回收器)会在后台线程中执行回调,调用 native 方法释放底层直接内存。
  • 非确定性释放:
    由于回收直接内存依赖于垃圾回收器对关联对象的处理,释放的时机并不是完全确定的。这意味着即使 DirectByteBuffer 对象不再使用,其对应的直接内存也可能在一段时间内未被及时回收,造成“滞留”的情况。

监控与限制:

  • JVM 内部会记录直接内存的分配情况,并通过 -XX:MaxDirectMemorySize 参数限制直接内存的总量,防止因过度申请而导致 native 内存耗尽。
  • 当达到该限制时,再申请新的直接内存就会抛出 OutOfMemoryError

垃圾回收的间接管理:
虽然直接内存本身不在 GC 扫描范围内,但与之关联的 DirectByteBuffer 对象在堆上,会被垃圾收集器管理。一旦这些对象被判定为不可达,JVM 会触发 Cleaner 机制去释放对应的直接内存。因此,间接上,直接内存的释放依赖于堆上对象的垃圾回收。

优化建议:

  • 显式释放(谨慎使用): 在某些场景下,为了更快地释放直接内存,可以通过反射调用 DirectByteBuffer 内部的 Cleaner 对象的 clean() 方法(这种做法依赖于 JVM 的内部实现,不属于标准 API,使用时需要谨慎)。
  • 避免长期持有: 尽量不要长时间保存对 DirectByteBuffer 的引用,确保其生命周期较短,以便及时回收对应的直接内存。
  • 合理设置参数: 根据应用需要,合理调整 -XX:MaxDirectMemorySize,同时监控直接内存使用情况,防止因滥用直接内存而导致 native 内存耗尽。

4.2 垃圾回收器

问:什么是STW?JVM为什么要设计STW机制⭐

STW,即Stop the World,指Gc事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉。如可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

被STW中断的应用程序线程会在完成GC之后恢复,频繁的中断会让用户感觉像是网速不快造成的电影卡顿一样,所以我们要减少STW的发生。STW事件和采用哪款GC无关,所有的GC都有这个事件。哪怕是G1也不能完全避免STW情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
开发中采用 System.gc() 会导致STW的发生。

JVM为什么要设计STW机制?

  1. 确保一致性:比如初始标记阶段要确保GC ROOTs的标记是正确的。
  2. 操作简化、易于管理:在某些阶段无需考虑并发操作的复杂性,STW期间更容易跟踪对象间的引用关系。

问:强制young gc会有什么问题?

  1. 性能开销: 强制执行Young GC会导致应用程序的某些线程被停顿STW,以便进行垃圾回收操作。这会造成一定的性能开销,可能会影响应用程序的响应时间和吞吐量。在实时要求较高的应用中,这可能是不可接受的。
  2. 内存占用: 强制Young GC可能会导致未回收的对象被晋升到老年代,增加老年代的内存占用。如果频繁强制执行Young GC,可能会加速老年代的填满,导致更频繁的Full GC。
  3. 停顿时间: 强制执行Young GC可能会导致较长的停顿时间,特别是在某些应用场景下,如果新生代中有大量的对象需要回收,停顿时间可能会较长。

问:JVM有哪几种垃圾收集器 ?⭐⭐⭐

新生代 老年代 不区分
Serial Serial Old
ParNew / Parallel Scavenge Parallel Old
CMS G1
ZGCShenandoah
  1. Serial收集器(Serial Garbage Collector):
    • 适用于单线程环境,收集时要暂停工作线程,意味着过多的停顿。
    • 新生代使用复制算法,老年代使用标记-整理算法,即Serial Old 收集器
    • 通过 -XX:+UseSerialGC 启用
  2. Parallel收集器(Parallel Garbage Collector):
    • 也称为吞吐量收集器,适用于多线程环境,但用户线程的卡顿等待仍不可控

    • 新生代使用复制算法,老年代使用标记-整理算法

    • ParNew 收集器:专注于新生代的垃圾回收, 具有多线程和并行的特性,通常与 CMS 搭配使用。

    • Parallel Scavenge 收集器:专注于新生代的垃圾回收, 相比别的收集器更加关注吞吐量和自适应调整,可以通过设置 -XX:+UseAdaptiveSizePolicy 开启自适应策略。

    • Parallel Old 收集器:标记-整理算法,对老年代进行GC

    • 注重吞吐量的场景下,jdk8默认采用 Parallel Scavenge + Parallel Old 的组合

  3. CMS 收集器(Concurrent Mark Sweep)
    • 适用于需要减少垃圾回收停顿时间的应用。
    • 专注于老年代,需要配合别的收集器
    • 使用标记-清除算法,垃圾回收线程几乎能做到与用户线程同时工作。
      1. 初始标记:标记根节点可关联存活对象,该过程较快,短暂停顿
      2. 并发标记:与用户线程同步,进行GC ROOTS TRACING,并发标记所有可达存活对象
      3. 重新标记:标记可能因为第2步时间段内用户线程进行操作导致的遗漏修改,短暂停顿
      4. 并发清除:与用户线程同步,清除不可达的对象
    • 吞吐量低,生成大量的内存碎片。以牺牲吞吐量为代价来获得最短回收停顿时间。
    • 通过 -XX:+UseConcMarkSweepGC 启用。
    • jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
    • jdk1.9 默认垃圾收集器G1
  4. G1收集器(Garbage-First Garbage Collector):
    • 适用于需要更加可控的垃圾回收停顿时间的应用。
    • 使用分代收集算法,保留有新生代和老年代的概念,但二者不再物理隔离,都是一部分Region(不需要连续)的集合,通过标记-整理算法进行垃圾回收,两个region间通过复制算法实现。
      1. 初始标记:短暂停顿
      2. 并发标记:并发耗时
      3. 最终标记:短暂停顿
      4. 筛选回收:对各个region进行回收价值和成本排序,指定回收计划,用户可控制时间
    • 实际上是空间整理,将对象移动到一个新的连续空间,所以没有碎片问题
    • 通过 -XX:+UseG1GC 启用。
  5. ZGC(Z Garbage Collector):由HotSpot官方实现
    • 适用于大堆内存且需要低延迟的应用。在任意堆大小(TB 级别)下,都可以把垃圾收集的停顿时间控制在10ms以内。ZGC 的大部分操作都是并发执行的,减少了Stop-the-World的时间。
    • 使用分代算法,采用类似G1的标记-整理算法,但采用并发处理。
    • 通过 -XX:+UseZGC 启用。
  6. Shenandoah收集器:由OpenJDK实现
    • 适用于大堆内存且需要低延迟的应用。
    • 采用分区算法,通过并发标记-整理算法进行垃圾回收。
    • 通过 -XX:+UseShenandoahGC 启用。

问:JVM垃圾收集器的几种实现算法?优缺点?⭐⭐

  1. 复制算法:单线程,解决碎片问题但空间成本高,当存活率高时,过多的复制操作影响性能
    1. 划分空间: 将新生代的内存空间划分为两个区域,一块大的为Eden区,另一块小的为Survivor区(通常是两个Survivor区,但在Serial收集器中,每次只使用其中一个)。
    2. 对象分配: 新创建的对象首先被分配到Eden区。
    3. Minor GC: 当Eden区满时,触发一次Minor GC(新生代垃圾回收)。在Minor GC中,存活的对象将被复制到Survivor区,并且Eden区会被清空。
    4. 对象晋升: 经过一定次数的Minor GC后,仍然存活的对象会被晋升到老年代。
    5. 内存分配担保:当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。内存担保失败触发FullGC。
    6. Survivor区复制: Survivor区中的存活对象会被复制到另一个Survivor区,而不是原来的那个。
  2. 标记-整理算法:标记过程类似于标记-清除算法,只是不直接清除,而是让对象向一个方向移动,清除边界外的内存。
    1. 标记阶段(Mark):从根节点开始,通过可达性分析标记所有存活的对象。这包括从根节点出发,沿着对象引用链逐步标记可达的对象。标记过程确保所有被应用程序引用的对象都被标记为存活。
    2. 标记完成后的整理阶段(Sweep):遍历整个堆内存,对标记的对象进行整理。未被标记的对象被认为是垃圾,将其释放并回收内存。整理的目标是将所有存活的对象紧凑地排列在一起,形成一个连续的内存空间。
    3. 内存整理(Compact):将所有存活的对象移动到堆的一端,使得它们占用的空间是连续的。这一步的目的是减少碎片化,提高内存的利用率。
    4. 更新引用关系(Update References):由于整理阶段可能涉及对象的移动,需要更新引用关系,确保对象的引用关系正确。所有指向被移动对象的引用都需要被更新,使其指向对象的新位置。
  3. 标记-清除算法
    • 原理:分为标记和清除两阶段。首先先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
    • 优点:不需要额外的内存空间,直接在原地回收。
    • 缺点:清除后内存空间不连续,容易产生大量碎片,从而影响后续内存分配效率。标记和清除过程都不够高效
  4. 分代收集算法

问:CMS垃圾回收过程?CMS的3个缺点?⭐⭐⭐

CMS 收集器(Concurrent Mark Sweep)

  • 适用于需要减少垃圾回收停顿时间的应用。
  • 专注于老年代,需要配合别的收集器
  • 使用标记-清除算法,垃圾回收线程几乎能做到与用户线程同时工作。
    1. 初始标记:STW,记录下GC Roots可直接关联的对象,该过程较快,短暂停顿
    2. 并发标记:与用户线程同步,进行GC ROOTS TRACING,从根对象开始并发标记所有可达存活对象,因为用户线程仍在运行,所以该过程会有已标记对象状态转变
    3. 重新标记:STW,短暂停顿,标记可能因为第2步时间段内用户线程进行操作导致的遗漏修改,主要使用三色标记的增量更新算法
    4. 并发清除:与用户线程同步,清除不可达的对象
  • 吞吐量低,生成大量的内存碎片。以牺牲吞吐量为代价来获得最短回收停顿时间。

缺点:

  1. 对CPU资源十分敏感:在并发阶段,虽然不会导致用户线程停顿,但会因为占用了一部分线程或者说CPU资源而导致应用程序变慢,总吞吐量会降低。当CPU不足4个时,CMS对用户程序的影响就可能变得很大,如果CPU本身负载就比较大,还要分出一半的运算能力去执行收集器线程,就会导致用户程序的执行速度突然下降50%。
  2. 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。因为CMS并发清理阶段用户线程还在运行,所以自然会不断地产生垃圾,这部分垃圾产生在标记过程之后,所以CMS无法在此次收集中处理它们,只能留到下次GC时再进行清理。
  3. 生成大量的空间碎片。因为CMS收集器基于“标记-清除”算法,所以自然会在收集结束后产生大量的空间碎片,给大对象的分配造成困难,经常老年代仍有大量空间未使用,却无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

问:对于CMS收集器,并发阶段又触发了Full GC是怎么处理的?⭐

如果上次GC还未结束,就触发了下次GC,特别是处于并发标记和并发清除阶段,此时会进入STW-Stop the world,使用serial old收集器来进行回收

问:三色标记法的实现原理?漏标的解决方案?⭐⭐⭐

出现背景:并发标记阶段因为用户线程还在运行,所以标记过的对象仍有可能发生状态改变,甚至还会导致多标和漏标的情况发生,此时引入了三色标记法来解决。

三色,根据是否访问过分为:

  • White:尚未被GC访问过,刚开始节点所有对象都是白色,结束时仍为白色的表示对象不可达。
  • Gray:已被GC访问过,但存在引用还未被扫描。
  • Black:已被GC访问过,且引用都被扫描。黑色表示对象是安全存活的。

过程:

  1. GC 标记开始的时候,所有的对象均为白色;
  2. 在将所有的 GC Roots 直接引用的对象标记为灰色集合;
  3. 如果判断灰色集合中的对象不存在子引用,则将其放入黑色集合,若存在子引用对象,则将其所有的子引用对象存放到灰色集合,当前对象放入黑色集合。
  4. 按照此步骤 3 ,依此类推,直至灰色集合中所有的对象变黑后,本轮标记完成,并且在白色集合内的对象称为不可达对象,即垃圾对象。
  5. 标记结束后,为白色的对象为 GC Roots 不可达,可以进行垃圾回收。

img

存在的问题:

  1. 浮动垃圾:在标记过程中才断开引用的Gray,最终会变成浮动垃圾,只能在下次GC后回收
  2. 漏标:标记过程中重新建立的引用,导致有引用的对象被当作White清理。满足两个条件:灰色对象断开了对白色对象的引用,而黑色对象重新引用了此白色对象
    • CMS采用写屏障+增量更新(Incremental Update)
      • 写屏障指对象引用发生变化时告知垃圾回收器
      • 增量更新关注新增的引用:就是黑色对象插入新的指向白色对象的引用时,记录一下该引用关系,等并发扫描结束,再将这些记录过的黑色对象作为根重新扫描,可以理解为:只要重新添加引用,黑色对象就退回灰色对象
      • 在重新标记阶段,除了需要遍历写屏障的记录,还需要重新扫描遍历GC Roots
    • G1采用写屏障+原始快照(Snapshot At The Begining SATB)
      • 原始快照则关注删除的引用:当灰色对象要删除指向白色对象的引用时,记录一下要删除的引用,等并发扫描结束,再将这些记录过的灰色对象作为根重新扫描,因为保留了原始的引用关系,所以能扫描到白色对象并置为黑色。
    • ZGC采用读屏障+指针染色
      • 读屏障指应用访问对象引用时,检查对象是否已被垃圾回收,确保读操作时GC能追踪到对象的引用关系。
      • 指针染色:读屏障首先检查指向对象的指针的颜色信息。如果指针表示对象已经被移动(例如,在垃圾回收过程中),读屏障将确保返回对象的新位置。通过这种方式,ZGC 能够在并发移动对象时保持内存访问的一致性,从而减少对应用程序停顿的需要。

问:G1收集器的垃圾回收过程?⭐⭐⭐

G1收集器(Garbage-First Garbage Collector):

  • 适用于需要更加可控的垃圾回收停顿时间的应用。
  • 使用分代收集算法,保留有新生代和老年代的概念,但二者不再物理隔离,都是一部分Region(不需要连续)的集合,通过标记-整理算法进行垃圾回收,两个region间通过复制算法实现。
    1. 初始标记:STW,记录下GC Roots能直接引用到的对象。
    2. 并发标记:并发耗时,同CMS
    3. 最终标记:STW,此阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。
    4. 筛选回收:STW,但停顿时间可控。首先对各个region进行回收价值和成本排序,根据用户期望的GC停顿STW时间(可以通过 -XX:MaxGCPauseMillis 来指定)来制定回收计划,用户可控制时间,所以根据时间限制,一次可能回收不完。回收时采用复制算法,将一个region的存活对象复制到另一个region中,所以没有碎片问题。
  • G1的回收阶段没有实现并发,但到了ZGC、Shenandoah就实现了并发回收。

问:G1一次不回收全堆,但对象在各Region中可能相互引用,如何避免每次都要全堆扫描?为什么不在引用赋值语句处直接更新RS呢?⭐⭐?

如何避免每次都要全堆扫描?

  • G1收集器中Region间的对象引用,其他收集器中新生代和老年代之间的对象引用,都是通过 Remembered Set 来避免全堆扫描。
  • G1中每个 Region 都有一个对应的 Remembered Set ,当虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 写屏障的暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代的对象引用了新生代的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在GC根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

CardTable?

  • 设计目的:减少无用的垃圾扫描范围,使用类似操作系统或者数据库的脏页表的形式,来做类似快表的查询。
  • CardTable:单字节数组,每一个区域对应一个标记位,为1表示持有新生代对象,从而解决跨代问题。
  • 写屏障(write barrier):保证所有更新引用操作能把卡表的脏位设置到最新状态。并发标记时,如果某个对象的引用发生了变化,标记该对象所在的 Card 为 Dirty Card(通过 write-barrier)。在重新标记时,只需要重新扫描 Dirty Cards 即可,同时清除 Dirty 标记。

Remembered Set?

  • G1为什么要把堆空间分成 Region 呢?让各个 Region 相对独立,可以分别进行 GC,而不是一次性地把所有垃圾收集掉。
  • 每个 Region 会有一个对应的 Remember Set,它记录了哪些内存区域中存在对当前 Region 中对象的引用。不是直接记录对象地址,而是记录了那些对象所在的 Card 编号。所谓 Card 就是表示一小块(512 bytes)的内存空间,这里面很可能存在不止一个对象。当我们需要确定当前 Region 有哪些对象存在外部引用时(这些对象是可达的,不能被回收),只要扫描一下这块 Card 中的所有对象即可,这比扫描所有 live objects 要容易的多。
  • Remember Set 的实现就是一个 Card 的 Hash Set,并且为每个 GC 线程都有一个本地的 Hash Set,最后的 Remember Set 实际上是这些 Hash Set 的并集。
  • G1 的 Write Barrier 实际上只是一个“通知”:将当前 set 引用的事件放到 Remember Set Log 队列中,交给后台专门的 GC 线程处理。后台的 GC 线程则负责从 Remember Set Log 不断取出这些引用赋值发生的 Cards,扫描上面所有的对象,然后更新相应 Region 的 Remember Set。在并发标记发生之前,G1 会确保 Remember Set Log 中的记录都处理完,从而保证并发标记算法一定能拿到最新的、正确的 Remember Set。
  • 当 Region 被引用较多的情况,RSet 占用空间会上升,因此对 RSet 的记录划分了三种存储粒度:
    • 稀疏表(Sparse):直接通过哈希表来存储,key 是 region index,value 是 card 数组(记录 card index)
    • 细粒度(Fine):当一个 region 的 card 数量超过阈值时,退化为一个 bitmap,每一位对应一个 card(index)
    • 粗粒度(Coarse):当被引用的 region 数量超过阈值时,退化为只记录 regin 引用情况,由 bitmap 存储,每一位对应一个 region(index)

G1 收集器在整个回收过程中,为了减少不必要的扫描工作,会为每个 Region 维护一个 Remembered Set。这个 Remembered Set 记录了其他 Region 中的对象对本 Region 内对象的引用,从而在进行回收时,不必遍历全堆,只扫描那些存在外部引用的区域。

G1 的 Remembered Set 实现依赖于“卡表”技术。堆内存被划分为许多固定大小的小块(通常为 512 字节或类似大小),每一小块称为一个“卡(Card)”。

  • 写屏障与标记脏卡:当应用线程修改对象引用时,G1 的写屏障会检查修改的内存区域。如果该修改可能导致跨 Region 的引用(即外部 Region 对本 Region 对象的引用发生变化),则对应的卡会被标记为“脏”。
  • 卡表数据结构:卡表通常是一个简单的数组,每个数组元素对应堆中的一张卡,通过字节或位来表示该卡是否被标记为脏。
  • 扫描脏卡: 在垃圾收集的准备阶段或并发标记阶段,G1 会扫描卡表中被标记为脏的卡,进而定位出具体的对象引用。
  • 生成 Remembered Set: 根据脏卡中记录的对象引用信息,G1 构造出每个 Region 的 Remembered Set,该集合只包含那些从外部 Region 指向本 Region 的引用。

为了在回收单个内存分段的时候不必对整个堆内存的对象进行扫描(单个内存分段中的对象可能被其他内存分段中的对象引用)引入了RS数据结构。RS使得G1可以在年轻代回收的时候不必去扫描老年代的对象,从而提高了性能。每一个内存分段都对应一个RS,RS保存了来自其他分段内的对象对于此分段的引用

JVM会对应用程序的每一个引用赋值语句object.field=object进行记录和处理,把引用关系更新到RS中。但是这个RS的更新并不是实时的。G1维护了一个Dirty Card Queue

为什么不在引用赋值语句处直接更新RS呢?

  • 这是为了性能的需要,使用队列性能会好很多。

问:G1的最大停顿时间如何实现?⭐

G1维护了一个region优先列表,会根据垃圾量、活跃性、收集效益等排序,根据用户期望的GC停顿STW时间(可以通过 -XX:MaxGCPauseMillis 来指定)来制定回收计划,从优先列表中选择出合适的region来进行回收。

问:G1什么时候发生Full GC呢 ?⭐⭐?

比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。

  • 尽管G1堆内存仍然是分代的,但是同一个代的内存不再采用连续的内存结构。年轻代分为Eden和Survivor两个区,老年代分为Old和Humongous两个区。
  • 新分配的对象会被分配到Eden区的内存分段上,
  • Humongous区用于保存大对象,如果一个对象占用的空间超过内存分段Region的一半;如果对象的大小超过一个甚至几个分段的大小,则对象会分配在物理连续的多个Humongous分段上。Humongous对象因为占用内存较大并且连续会被优先回收

问:G1的TLAB和PLAB?⭐⭐?

  1. 线程本地分配缓冲区(Thread Local Allocation Buffer,TLAB)是Java虚拟机为了提高对象分配效率而设计的一种内存分配策略。TLAB的主要思想是为每个线程分配一个私有的、专属的小块内存区域,用于对象的快速分配,减少多线程竞争分配内存的问题。
    • 背景:由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步。为了避免加锁,提高性能每一个应用程序的线程会被分配一个TLAB。
    • 与G1的关系:TLAB在G1中主要与新生代的内存分配有关。每个线程都会在新生代中拥有自己的TLAB,用于快速分配对象。这有助于减小多线程场景下的竞争,提高对象分配的效率。
    • TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁。
    • 栈上分配->tlab->堆上分配
  2. PLAB(Parallel Local Allocation Buffer): PLAB则与老年代有关。
    • 在G1垃圾收集器中,G1使用了一个分阶段的方式来处理老年代的垃圾回收,其中包括一个阶段叫做“Mixed GC”(混合垃圾收集)。在Mixed GC阶段,为了提高老年代的垃圾回收效率,G1引入了PLAB。PLAB是在Parallel Old收集阶段使用的本地缓冲区,用于提高老年代对象的分配性能。
    • G1会在年轻代回收过程中把Eden区中的对象复制(“提升”)到Survivor区中,Survivor区中的对象复制到Old区中。G1的回收过程是多线程执行的,为了避免多个线程往同一个内存分段进行复制,那么复制的过程也需要加锁。为了避免加锁,G1的每个线程都关联了一个PLAB,这样就不需要进行加锁了

问:G1的SATB算法?为什么比CMS的增量标记快?⭐⭐?

二者都是三色标记算法漏标的解决方案快照初始标记(Snapshot-At-The-Beginning,SATB)算法,SATB通过空间换时间,使用RS不需要再进行对象引用的深度扫描。

增量更新要深入扫描白色对象及它的引用对象。G1很多对象都处于不同的Region,重新深度扫描的成本要比CMS高很多,所以通过配合RS来避免扫描的过程。

SATB算法在并发标记开始时,获取堆中所有存活对象的快照。当应用线程在并发标记阶段对对象引用进行修改时,SATB通过写屏障(Write Barrier)将旧的引用记录下来。这样,即使某个对象的引用被删除或更新,原先的引用关系仍被保留,确保垃圾收集器不会遗漏任何存活对象。

相比之下,增量更新算法在对象引用发生变化时,记录新的引用。当应用线程为一个对象新增引用时,增量更新会将新的引用关系记录下来,以确保在后续的标记过程中,这些新引用的对象不会被遗漏。

由于SATB仅需记录旧引用,而不需要处理新引用的情况,其写屏障的实现相对简单,开销较小。而增量更新需要处理新引用的记录和维护,涉及的操作更复杂,开销也更大。因此,G1收集器的SATB算法在并发标记阶段的性能通常优于CMS收集器的增量更新算法

此外,SATB算法通过保留对象在并发标记开始时的状态,避免了在标记过程中遗漏存活对象的风险,提高了垃圾收集的准确性和效率。

综上,G1收集器采用SATB算法,使其在并发标记阶段具有更高的性能和可靠性。

问:谈谈对ZGC垃圾回收器的理解?指针染色的实现原理?⭐⭐⭐

  1. 什么是ZGC垃圾回收器?

    • HotSpot官方于JDK11发布的垃圾回收器,还有一个同代的产品是Shenandoah是OpenJDK做的,诞生于JDK 11,在JDK 15后脱离实验性质。
    • 相比别的收集器,如Serial、Parallel、CMS和G1,ZGC的特点是支持TB级别的大堆内存、极短的停顿时间,以及并行处理能力。
    • 低停顿(Low Latency):ZGC 的核心目标是实现极低的 GC 停顿时间,通常控制在毫秒甚至亚毫秒级别,即使在 TB 级别的大堆上也能保持较短的停顿时间。
    • 高并发与可扩展性:ZGC 采用大量并发算法,在大部分垃圾回收工作中与应用线程并发执行,使得系统能够充分利用多核 CPU,适用于大内存场景。
    • 可预测性与稳定性:通过引入指针染色、读屏障等技术,ZGC 在并发搬迁和标记过程中保持高一致性,降低因并发修改而产生的漏标风险。
  2. HotSpot虚拟机的几种收集器不同的标记实现方案:

    • 把标记直接记录在对象头上:Serial收集器
    • 把标记记录在与对象相互独立的数据结构上:G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息
    • ZGC的染色指针直接把标记信息记在引用对象的指针上(这个时候,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。)
  3. 什么是染色指针?

    • 染色指针是一种直接将少量额外的信息存储在指针上的技术。在 64 位 Linux 中,对象指针是 64 位,如下图:在这个 64 位的指针上,高 18 位都是 0,暂时不用来寻址。剩余的 46 位指针所能支持内存可以达到 64TB ,这可以满足多数大型服务器的需要了。不过 ZGC 并没有把 46 位都用来保存对象信息,而是用高 4 位保存了四个标志位,导致 ZGC 可以管理的最大内存不超过 4 TB 。

      通过这四个标志位,JVM 可以从指针上直接看到对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(Remapped)、是否需要通过 finalize 方法来访问到(Finalizable)等信息。无需进行对象访问就可以获得 GC 信息,这大大提高了 GC 效率。

      unused bits(18 bits)、finalizable、remapped、marked0、marked1、object address(42 bits)

      • finalizable:黄色,表示是否需要通过finalize方法来访问到
      • remapped:蓝色,表示是否进入了重分配集(即最开始和被移动过)JVM初始化以及对象刚创建时,对象的指针都是处于 remapped 视图。
      • marked0:绿色,表示标识过,本次GC阶段。不同周期选择marked0和marked1视图的其中一个。
      • marked1:红色,标识标识过,上次GC阶段。
  4. 读屏障(Read Barrier):应用线程访问对象引用时先触发,检查指针颜色。

    • 解决了并发转移时对象指针的更新问题,在转移期间,如果移动对象但不更新引用对象的传入指针(移动的对象可能被任意其它对象引用),就会产生悬空指针。读屏障可以捕获悬空指针对象,更新对象的新位置。ZGC使用转发表(Forwarding tables)来重新定位旧地址映射到新地址。
  5. Forwarding Table-转发表:哈希结构,存储旧地址到新地址的映射,用于快速查找迁移后的对象。

  6. Relocation Set:记录需要迁移的内存页,按策略排序以提高效率。

  7. 内存布局:ZGC将堆分为small、medium、large三类region/page,没有了分代的概念。每个堆大概可以有2048个region、每个region大概1~32MB(必须是2的次方)

    • small region:固定2MB,存放小于256KB的对象
    • medium region:固定32MB,存放大于等于256KB但小于4MB的对象
    • large region:容量为2MB的整数倍,存放4MB及以上的对象,每个大型region只存放一个large,由于大对象移动代价过大,所以不会被重新分配。
  8. 内存多重映射:ZGC为了高效和灵活管理内存,实现两级内存管理:虚拟内存和物理内存,通过mmap函数实现,多对一关系。

    • 应用创建对象时,首先在堆上申请一个虚拟地址,ZGC同时为该对象在remapped、marked0、marked1三个视图空间各申请一个虚拟地址,且对应同一个物理地址。通过mmap函数可以由虚拟地址找到物理地址。
    • remapped、marked0、marked1三个视图空间在同一个时间点只能有一个有效,ZGC通过这三个视图的切换来完成并发的垃圾回收,三个视图的切换发生在垃圾回收的不同阶段,冗余空间来换取并发的时间。
    • ZGC的三个阶段:初始阶段(整个堆内存空间的视图地址都被设置为remapped)、标记阶段(视图转变为marked0或marked1)、转移阶段(视图再被设置为remapped)
    • 进行GC操作时会对page进行压缩,所以没有空间碎片问题。
  9. ZGC垃圾回收流程

    1. 初始标记(STW):从 GC Roots 出发,快速标记直接引用的对象。
      • 切换视图:将当前标记颜色(如 marked0marked1)切换,与 remapped 视图区分。
        • remapped 视图:表示对象的指针处于“默认”状态,即还没有被本次 GC 周期标记过或迁移。在 JVM 初始化和对象首次分配时,对象指针均处于 remapped 状态。
        • marked0 与 marked1 视图:这两个视图交替用于不同的 GC 周期,作为当前周期的“标记”状态。在某次 GC 周期中,系统选用其中一个(例如 marked1)作为活跃的标记位,用于将从 GC Roots 开始扫描到的对象染色。下一个 GC 周期时,则交换使用,即使用 marked0 作为当前周期的标记,而前一周期的标记失效。这种交替方式实现了“清除”上一周期标记状态而无需遍历整个堆重新清零。
      • 染色指针:遍历 GC Roots(如线程栈、静态变量等),将直接引用的对象指针染色为当前标记颜色。例如,如果当前周期选用 marked1,则所有直接引用对象的指针会被染成 marked1,表示它们已被标记为存活。其他对象仍保留在 remapped 状态。
      • 全局停顿(STW):此阶段需要暂停所有应用线程,确保标记一致性。
    2. 并发标记:根据初始标记的对象并发遍历对象图,标记所有存活对象。GC线程和Java线程同时运行。
      • 并发遍历:GC 线程根据初始标记得到的种子,从那些已经染色为 marked1(或 marked0,取决于当前周期)的对象开始,继续遍历其它对象,递归标记所有可达对象。在这一过程中,如果发现某个对象的指针仍处于 remapped 状态,则说明其尚未被标记,GC 将会更新其指针为当前标记视图(如 marked1)。此外,在并发标记中,如果应用线程修改了引用,读屏障会检测到指针状态不一致(例如,指针仍是 remapped 或旧周期的标记),进而触发补救,确保最终所有可达对象都被正确标记。
        • GC线程访问对象时,若对象地址视图为remapped,就把其切换到marked0,如果已经是marked0,则表示其已被其它GC标记线程访问过了,直接跳过
        • 标记过程中Java线程新创建的对象直接进入marked0视图
        • 标记过程中Java线程访问对象,若对象的地址视图为remapped,就把其切换到marked0(读屏障)
        • 标记过程中Java线程修改了某对象的引用,则将引用对象移入remapped,表示此对象要重新分配。
        • 标记结束后,若对象的地址视图为marked0,就是存活的,若是remapped,就是不活跃的需要被回收。
      • 检测到指针状态不一致:指的是当应用线程通过读屏障访问对象时,发现该对象的指针染色情况不符合当前 GC 周期预期的标记视图。例如,如果当前 GC 周期要求对象应呈现为 marked1,但读到的指针仍显示为 remapped或旧的 marked 状态,则说明该引用信息未跟上并发修改的变化。
        • 补救措施:当读屏障发现指针状态与当前期望不一致时,会通过查询 Forwarding Table(转发表)或其他元数据,确定对象是否已经迁移或是否应更新状态。如果需要更新,读屏障会立即将该指针修正为当前正确的状态(例如转为 remapped 状态,或更新到当前周期所用的 marked 视图)。
      • 记录存活信息:统计每个内存页(Region)中存活对象的字节数,用于后续迁移决策。
      • 修复坏指针:在后续 GC 中,修复因对象迁移而产生的旧指针(首次 GC 可能无此操作)。
    3. 重新标记(STW):修正并发标记期间可能漏标的对象。经过并发标记后,由于 Mutator 活动可能仍会遗漏部分对象,此阶段在短暂 STW 停顿中进行快速扫描。重新标记阶段会将所有漏掉的存活对象补上,并确保它们均呈现当前周期的标记视图(例如 marked1)。此时,整个堆中所有“活跃”对象都应显示为当前使用的标记(marked1)。
      • 处理剩余任务:完成未处理的标记任务,若未完成则继续并发标记。
      • 短时间停顿:控制在 1ms 内,否则延长并发标记时间。
      • 全局停顿:第二次短暂停顿,确保标记阶段最终完成。
    4. 并发转移准备:根据标记结果统计每个区域(页)的存活对象情况,为后续对象迁移做准备。筛选所有可以被回收的页面/region,选择垃圾比较多的页面作为转移集。系统依据各区域中对象的存活状态(由指针视图区分)判断哪些区域需要搬迁。
      • 处理非强引用:清理弱引用、软引用等。
      • 重置迁移集合:清空 Relocation Set(记录待迁移的内存页)。
      • 选择迁移页
        • 存活页筛选:根据存活对象比例选择需要迁移的页。
        • 排序策略:按页大小(大页、中页、小页)和存活对象升序排列。
        • 填充迁移集合:将选中的页信息封装为 ForwardingEntry,加入 Relocation Set 并排序。
      • 更新 Forwarding Table:记录迁移前后的地址映射。
    5. 初始转移(STW):快速处理 GC Roots 直接引用的对象迁移。只转移根对象相关。将地址视图由m0或m1转回remapped,表示进入转移阶段,因为地址视图的调整,要重新定位TLAB,从根集合触发,遍历根对象直接引用的对象并进行转移。
      • 切换视图:从标记视图(如 marked1)切换回 remapped
      • 扫描根对象
        • 好指针:指向当前视图的有效对象,无需处理。
        • 坏指针:指向旧地址的指针,需通过 Forwarding Table 迁移。
      • 迁移对象
        • 申请新内存,复制对象,更新 Forwarding Table
        • 修改 GC Roots 指针指向新地址。
      • 全局停顿(STW):短暂停顿,仅处理根对象引用。
    6. 并发转移:在并发模式下搬迁剩余对象。对转移集的每一页执行转移。GC 遍历之前生成的迁移集合,搬迁对象并更新指针映射。在搬迁过程中,如果应用线程访问对象,读屏障会检查指针颜色。
      • 遍历迁移页:对每个页中的存活对象进行迁移。
      • 对象复制:将对象复制到新内存,更新 Forwarding Table
      • 读屏障修复
        • 应用线程访问迁移对象时,触发读屏障检测指针状态。
        • 若为坏指针(未更新,即仍显示旧的标记,如 marked1,但实际上对象已经搬迁),通过 Forwarding Table 自动修复为新地址,更新为新搬迁后的 remapped 状态。
        • 这保证了即使部分对象在并发转移时未能及时更新,也能通过应用访问时的动态修正完成更新。
      • 回收旧页:迁移完成后,释放旧页内存。

五. 类加载

5.1 类加载器

问:Java虚拟机是如何判定两个Java类是相同的?⭐⭐

  1. 类的全限定名相等。
  2. 类加载器相等(不同的类加载器加载相同字节代码也是两个不同的类实例)。

问:聊聊你对类加载器的理解?什么是类加载?类加载器有哪些?类加载的过程?⭐⭐⭐

类加载是将类的字节码加载到内存中,并转换成运行时的数据结构。类加载器的主要任务是将类从它的二进制形式转换成在JVM中运行时使用的Java对象。

类加载器的种类:

  1. 启动类加载器(Bootstrap Class Loader):

    • 是最顶层的类加载器,负责加载Java的核心类库,如java.lang包。
  2. 扩展类加载器(Extension Class Loader):

    • 负责加载Java的扩展类库,如javax包。其父加载器为启动类加载器。
  3. 应用程序类加载器(Application Class Loader):

    • 也称为系统类加载器,负责加载应用程序的类,是最常用的类加载器。其父加载器为扩展类加载器。
  4. 自定义类加载器:

    • 用户可以通过继承 ClassLoader 类,实现自定义的类加载器,用于加载特定位置或格式的类。

类的生命周期包括:7个阶段。

  1. 加载(Loading):根据类名查找并加载类的二进制数据,将其加载到内存,静态存储结构转换为方法区的运行时数据结构,并在内存创建一个Class对象
  2. 连接(Linking):又包括
    • 验证(Verification):确保类的二进制数据符合Java虚拟机规范,如文件格式验证,元数据验证,字节码验证,符号引用验证。
    • 准备(Preparation):为类的静态变量分配内存并初始化为默认值,在方法区中对类的static变量分配内存并设置类变量数据类型默认的初始值,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中。
    • 解析(Resolution):将常量池内的符号引用替换为直接引用的过程,将符号引用解析为直接引用,为虚拟机内部使用。符号引用指向的目标未必已加载,而直接引用则是直接/间接指向目标的指针/句柄等,目标必然已在内存中。
  3. 初始化(Initialization):在这个阶段,执行类的初始化代码,包括对静态变量的赋值和执行静态代码块。初始化阶段是类加载的最后一个阶段。
  4. 使用(Using)
  5. 卸载(Unloading)

问:什么时候需要自定义类加载器?如何自定义类加载器?⭐⭐

  1. 动态加载类: 自定义类加载器允许在程序运行时从非标准的数据源(如网络、数据库、文件系统等)加载类。这对于实现插件系统、热部署或动态加载模块等场景非常有用。

  2. 类隔离: 自定义类加载器可以实现类隔离,即不同的类加载器加载同名类,使它们在内存中互不影响。这在一些特定场景下有用,比如在同一个应用中加载多个版本的相同库。

  3. 防止类被篡改: 自定义类加载器可以实现一些额外的安全检查,以确保加载的类没有被篡改。这对于防止恶意代码注入或确保类文件的完整性很重要。

  4. 字节码增强: 通过自定义类加载器,可以在类加载的过程中对字节码进行增强,例如通过字节码操纵库实现AOP(面向切面编程)。

  5. 定制类加载逻辑: 自定义类加载器可以根据特定的需求定制类加载逻辑。例如,可以实现自己的类查找策略、资源加载策略等。

  6. 加载非标准格式的类文件: 自定义类加载器可以用于加载非标准格式的类文件,例如从数据库中加载编码过的类文件。

在实现自定义类加载器时,通常建议重写的是 findClass 方法,而不是直接重写 loadClass 方法。原因如下:

  1. 保留双亲委派机制
    默认的 loadClass 方法实现了双亲委派机制和缓存逻辑:
    • 它首先会检查该类是否已经被加载(缓存检查);
    • 然后委派给父加载器尝试加载;
    • 如果父加载器无法加载,最后才调用 findClass 方法。
      重写 findClass 允许你在父加载器无法加载时,采用自定义的方式加载类,而不会破坏父加载器委派的逻辑,从而保证系统类和共享类的一致性。
  2. 避免破坏缓存机制
    loadClass 方法内部实现了对已加载类的缓存处理(例如调用 findLoadedClass 检查),如果直接重写 loadClass,可能会绕过这一机制,导致同一类被重复加载或加载失败,从而引发类冲突问题。
  3. 降低错误风险
    如果重写 loadClass,可能不小心破坏了双亲委派模型的正确行为,而重写 findClass 则只是在父加载器无法加载时才起作用,风险更低,更符合“委派优先”的设计理念。
  4. 实现简便性
    重写 findClass 只需要关注如何从特定资源(如文件系统、网络等)读取字节码并调用 defineClass,而无需处理类加载器内部缓存、双亲委派等复杂逻辑,这使得代码更清晰、实现更简单。

自定义类加载器:

  • 继承ClassLoader类重写findClass方法,在findClass中获取被加载类数据,调用defineClass完成类加载;
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

// 1.继承ClassLoader
public class MyClassLoader extends ClassLoader {
// 定义类文件的根目录
private String classPath;

public MyClassLoader(String classPath) {
this.classPath = classPath;
}

/**
* 2.重写 findClass 方法,按照自定义的方式加载类
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 加载类数据
byte[] data = loadClassData(name);
if (data == null) {
throw new ClassNotFoundException("Cannot load class: " + name);
}
// defineClass 方法将字节数组转换为 Class 对象
return defineClass(name, data, 0, data.length);
}

/**
* 根据类的全限定名加载对应的字节码数据
*/
private byte[] loadClassData(String name) {
// 将包名转换为路径,如 com.example.Test -> com/example/Test.class
String path = classPath + "/" + name.replace('.', '/') + ".class";
try (InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
int ch;
while ((ch = is.read()) != -1) {
baos.write(ch);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}

5.2 双亲委派

问:双亲委派机制 ?有啥作用 ?双亲委派机制缺陷?⭐⭐⭐

双亲委派机制 ?

  • 类加载器采用了双亲委派模型,即除了顶层的启动类加载器,其余的类加载器都应当有一个父类加载器

原理:

  • 层级结构:Java 的类加载器体系采用层级结构,每个类加载器都有一个父加载器(除了顶层的启动类加载器)。
  • 委派模型:当一个类加载器收到加载请求时,它首先将请求委派给父加载器。只有当父加载器无法加载该类时,子加载器才会尝试自己加载
  • 实现方式:loadClass 方法中,通常先调用 findLoadedClass 查看该类是否已被加载;接着调用父加载器的 loadClass;如果父加载器加载失败,则调用当前加载器的 findClass 方法加载类。

作用:

  • 保证类的唯一性与避免重复加载:同一份类代码只会由一个类加载器加载一次,从而确保一个类在 JVM 中只有一个定义,避免同一个类被不同加载器重复加载产生混乱。
  • 安全性:核心类库(例如 java.lang.* 等)由启动类加载器加载,防止用户自定义类覆盖系统类库,保证 Java 核心功能的安全性和稳定性。

缺陷:

  1. 灵活性受限:子加载器必须依赖父加载器,无法独立加载类,难以实现模块化隔离(如Web容器需隔离不同应用的类)。
  2. 版本冲突:父加载器加载的类版本可能与子加载器需求不符(如父加载器加载了旧版库,子加载器无法使用新版)。
  3. 动态性不足:无法支持热部署或动态更新,类一旦被父加载器加载,子加载器无法重新加载新版本。
  4. 链路耦合:类加载器层级过深时,加载效率降低,且父子加载器间耦合度高。

总结:有安全性和稳定性的优点,就有了灵活性和定制性的缺点。

问:为什么要打破双亲委派模型,如何打破?tomcat是如何打破双亲委派模型?SPI机制?⭐⭐⭐

为什么要打破双亲委派模型?在一些特定的场合,双亲委派无法满足需求:如数据库框架JDBC DriverWeb容器Tomcat/Jboss

  • 模块化和隔离性:一些框架和容器需要保证各个模块之间互不影响,打破双亲委派后可以在不同模块使用不同版本的库。如Tomcat需为每个Web应用提供独立类空间,避免应用间类冲突。
    • JDBC要加载外部各类第三方的实现类,以及可能会使用不同版本的数据库驱动
    • 一个Web容器可能要部署多个应用,要保证每个应用的类库是独立和隔离的。
    • Web容器也有自己的类库,要和应用的类库隔离。
  • 动态更新和热部署:需要在运行时加载类,会与双亲委派机制冲突。JDBC需加载不同厂商驱动,SPI需运行时发现实现类。
    • JDBC动态加载驱动类。
    • Web容器支持JSP修改后无需重启。
  • 灵活性和自定义:双亲委派限制了一些框架实现特定的加载策略。

如何打破双亲委派模型?

  1. 自定义类加载器

    • 继承ClassLoader类重写loadClass方法,在loadClass中获取被加载类数据,调用defineClass完成类加载;
    • 例如,可以在 loadClass 中先判断是否为某些特定的类(例如 Web 应用内部类或第三方库),直接调用 findClass 自行加载,而不委派给父加载器。
  2. SPI机制(Service Provider Interface)提供了一种在运行时动态加载实现模块的机制

    • 核心思想是定义一个接口,然后允许不同的实现类在运行时注册到该接口

    • 流程:

      1. 定义服务接口,并在META-INF/services目录下创建一个以服务接口全限定名为名字的文件。
      2. 文件中的内容是服务提供者接口的实现类的全限定名,每行一个实现类。
      3. 在运行时,通过Java的ServiceLoader类动态加载实现类,使其实例化并提供服务。
    • 例子:

      • 服务接口 MyService

        1
        2
        3
        4
        5
        // MyService.java
        public interface MyService {
        void doSomething();
        }

      • 实现类 MyServiceImpl

        1
        2
        3
        4
        5
        6
        7
        // MyServiceImpl.java
        public class MyServiceImpl implements MyService {
        @Override
        public void doSomething() {
        System.out.println("Doing something in MyServiceImpl");
        }
        }
      • 在META-INF/services目录下创建一个以服务接口全限定名为名字的文件 com.example.MyService,并在文件中写入实现类的全限定名:

        1
        2
        # META-INF/services/com.example.MyService
        com.example.MyServiceImpl
      • 使用 ServiceLoader 动态加载并使用服务:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        // MyServiceConsumer.java
        import java.util.ServiceLoader;

        public class MyServiceConsumer {
        public static void main(String[] args) {
        ServiceLoader<MyService> serviceLoader = ServiceLoader.load(MyService.class);

        for (MyService service : serviceLoader) {
        service.doSomething();
        }
        }
        }
    • 实际上使用的是线程上下文类加载器(TCCL),可以通过Thread.currentThread().getContextClassLoader() 获取,也可以使用自定义类加载器。

    • bootstrap加载DriverManager,然后DriverManager通过spi机制加载其它类

      1
      2
      3
      // JDBC Driver加载示例
      Connection conn = DriverManager.getConnection(url);
      // DriverManager使用SPI加载Driver实现类
  3. OSGI按照模块热部署打破双亲委派

Tomcat的打破方式

  • 自定义类加载器WebAppClassLoader,重写了JVM的类加载器ClassLoader的findClass方法和loadClass方法,以优先加载Web应用目录下的类。

    • WebAppClassLoader优先加载/WEB-INF/classes/WEB-INF/lib下的类,若未找到再委派父加载器。
    • 实现应用间类隔离,同时共享Common ClassLoader加载的类。
  • Tomcat类加载器的层次结构:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
                              boostrapClassLoader 
    |
    ExtensionClassLoader
    |
    ApplicationClassLoader
    |
    CommonClassLoader
    / \
    CatalinaClassLoader ShareClassLoader
    (容器本身的加载器) (共享的)
    |
    WebAppClassLoader

    Bootstrap → Ext → App → Common → WebApp1, WebApp2...
  • WebAppClassLoader:每个Web应用自己的Java类和依赖的JAR包,分别放在WEB-INF/classesWEB-INF/lib目录下,都是WebAppClassLoader加载的。

  • 两个Web应用之间怎么共享库类,并且不能重复加载相同的类?

    • 若WebAppClassLoader未加载到某类,就委托父加载器SharedClassLoader去加载该类,SharedClassLoader会在指定目录下加载共享类,之后返回给WebAppClassLoader,即可解决共享问题。
  • 如何隔离Tomcat本身的类和Web应用的类?

    • 两个类加载器是平行的,它们可能拥有同一父加载器,但两个兄弟类加载器加载的类是隔离的。于是,Tomcat搞了CatalinaClassLoader,专门加载Tomcat自身的类
  • 线程上下文加载器,一种类加载器传递机制。因为该类加载器保存在线程私有数据里,只要是同一个线程,一旦设置了线程上下文加载器,在线程后续执行过程中就能把这个类加载器取出来用。因此Tomcat为每个Web应用创建一个WebAppClassLoader类加载器,并在启动Web应用的线程里设置线程上下文加载器,这样Spring在启动时就将线程上下文加载器取出来,用来加载Bean。

问:Class.forName 是否会初始化类?

Class.forName 方法在加载类的同时默认会初始化类,但也可以通过额外的参数来控制是否初始化类 Class<?> myClass = Class.forName("MyClass", false, classLoader); 第二个参数中指定了 initializefalse 则不会进行类的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyClass {
static {
System.out.println("Static initialization block executed");
}

public static void main(String[] args) {
try {
// 使用 Class.forName 加载类并进行初始化
Class<?> myClass = Class.forName("MyClass");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}

上述代码中,当 main 方法执行时,调用 Class.forName("MyClass") 会加载 MyClass 类,并触发其中的静态初始化块执行。输出结果将包含 “Static initialization block executed”。

问:static 块会执行几次?怎么让它执行第二次?

static 初始化块只会在类加载的过程中执行一次。它会在类加载时进行初始化工作,确保类的静态成员在首次使用之前已经被正确初始化。如果你想让 static 初始化块执行第二次,通常需要重新加载类。

  1. 使用自定义类加载器:创建一个自定义类加载器,并使用它加载类。每次使用该自定义类加载器加载类时,都会重新加载类并执行 static 初始化块。注意,Java虚拟机不允许相同类加载器对同一个类进行二次加载,因此需要使用不同的类加载器实例。
  2. 使用类加载器的 defineClass 方法:利用类加载器的 defineClass 方法手动加载类字节码。这样也可以达到重新加载类并执行 static 初始化块的效果。

需要注意的是,重新加载类可能会引起一系列问题,包括类的实例状态丢失、静态变量重置等。重新加载类是一个比较复杂且潜在风险较大的操作,因此在实际应用中需要慎重考虑


六. 调优

问:内存泄漏和内存溢出有什么区别 ?⭐

  • 内存溢出:程序运行时申请的内存大于系统能提供的内存,出现Out Of Memory,包括:
    • 永久代溢出(java.lang.OutOfMemoryError: PermGen space)、
    • 堆溢出(java.lang.OutOfMemoryError: Java heap space)、
    • 虚拟机栈(StackOverflowError)和本地方法栈溢出(OutOfMemoryError
  • 内存泄漏:长生命周期的对象持有了短生命周期的对象,导致其不再被需要后仍无法被回收

问:你用过哪些JDK自带的调优命令?常用的 JVM 调优参数 ?⭐⭐⭐

常用的 JDK 自带调优命令包括:

  • jps:列出当前机器正在运行的JVM进程及其主类或 JAR 文件名称。jps -l 可以确认Java程序是否启动

    1
    2
    3
    $ jps -l
    12345 org.example.Main
    23456 sun.tools.jps.Jps

    其中 12345 是我们应用的主类 org.example.Main,23456 是 jps 自身

  • jmap:用于生成堆 dump、显示堆内对象直方图(例如 jmap -histo )以及查看内存分布等。生成堆转储快照 jmap -dump:format=b,file=<filename> <pid> dump文件,查看JVM各个区域的使用情况。例如:

  • jmap -heap PID查看新生代,老生代堆内存的分配大小以及使用情况;

    • 注意:生产环境一般不建议使用jmap,如果线上服务器堆内存特别大,该命令可能会卡死,以及导出Dump文件风险较高。
    • -XX:+HeapDumpOnOutOfMemoryError
    • 使用Arthas 的 heapdump 命令
    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
    28
    $ jmap -dump:format=b,file=heap.hprof <pid>
    $ jmap -heap 12345

    Attaching to process ID 12345, please wait...
    Debugger attached successfully.
    Server compiler detected.
    JVM version is 25.202-b08

    Heap Configuration:
    MinHeapFreeRatio = 40
    MaxHeapFreeRatio = 70
    MaxHeapSize = 1073741824 (1024.0MB)
    NewSize = 1310720 (1.25MB)
    MaxNewSize = 17592186044416 (16777216.0MB)
    OldSize = 54525952 (52.0MB)
    NewGeneration size (bytes) = 734003200
    OldGeneration size (bytes) = 335544320

    生成当前进程(由 pid 指定)的堆内存对象直方图,只显示输出的前 20 行
    $ jmap -histo pid | head -20
    $ jmap -histo 12345 | head -20

    num #instances #bytes class name
    ----------------------------------------------
    1: 1000000 32000000 [C
    2: 500000 20000000 java.lang.String
    3: 300000 12000000 com.example.SomeClass
    ...
  • jstack:用于获取线程 dump,分析线程状态、锁竞争和死锁问题。生成JVM当前时刻线程快照,比如通过JPS找到死锁的PID,然后jstack打印死锁情况 jstack <pid> ,查看线程堆栈,查看有没有哪些现场阻塞或出现死锁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    $ jstack <pid>
    $ jstack 12345

    Found one Java-level deadlock:
    =============================
    "Thread-12":
    waiting to lock monitor 0x00007f9c4800a8, which is held by "Thread-8"
    "Thread-8":
    waiting to lock monitor 0x00007f9c4800b0, which is held by "Thread-12"

    Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.202-b08):
    ...
    "Thread-12" prio=5 tid=0x00007f9c50101800 nid=0x2b03 waiting for monitor entry [0x00007f9c3b4f3000]
    java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.Demo.method(Demo.java:25)
    - waiting to lock <0x000000076b0a1c70> (a java.lang.Object)
    ...
  • jstat:实时监控 GC 活动、内存使用、类加载信息等,能输出多种统计数据。监视JVM各种运行状态信息 jstat -gc <pid> <interval> <count> 查看垃圾回收情况,尤其是fullGC。

    1
    2
    3
    4
    5
    6
    7
    $ jstat -gc <pid> 1000
    $ jstat -gc 12345 1000 5
    每 1000 毫秒采样一次,共采集 5 次数据。
    S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
    10240.0 10240.0 0.0 5120.0 61440.0 20480.0 122880.0 61440.0 6144.0 3072.0 6144.0 3072.0 10 0.123 2 0.456 0.579
    ...
    说明:各列表示新生代(S0、S1、Eden)、老年代(OC、OU)及 GC 次数与时间等指标,可用于判断 GC 频率和堆内存变化。
  • jcmd:集成了多种功能,如 GC 日志、Heap Dump、JVM 配置信息查询等,使用起来更加灵活和高效。

    1
    2
    3
    4
    5
    6
    7
    8
    $ jcmd <pid> GC.heap_dump /path/to/heap.hprof
    查看 JVM 启动参数
    $ jcmd 12345 VM.flags
    -XX:InitialHeapSize=536870912 -XX:MaxHeapSize=1073741824 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 ...
    导出 Heap Dump
    $ jcmd 12345 GC.heap_dump /path/to/heap.hprof
    Dump heap to /path/to/heap.hprof
    Heap dump file created
  • jinfo:用于查看 JVM 启动参数、系统属性等信息,对调优时验证参数设置非常有帮助。查看或修改JVM参数 jinfo -flag <name> PID 查看某个java进程的某个属性值

    1
    2
    3
    4
    5
    6
    7
    8
    $ jinfo <pid>
    $ jinfo -flags 12345

    -XX:InitialHeapSize=536870912
    -XX:MaxHeapSize=1073741824
    -XX:+UseG1GC
    -XX:MaxGCPauseMillis=200
    ...
  • vmstat: 查看操作系统层面的虚拟内存、进程、I/O、系统 CPU 等统计信息(适用于 Linux)。

    1
    2
    3
    4
    5
    6
    7
    8
    $ vmstat 1 5
    解释:每秒采样一次,总共采样 5 次。
    procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
    r b swpd free buff cache si so bi bo in cs us sy id wa st
    1 0 0 102400 5120 20480 0 0 10 20 150 300 5 2 90 3 0
    0 0 0 103000 5200 20700 0 0 5 10 140 280 4 1 94 1 0
    ...
    说明:显示了系统中运行队列、内存使用情况、交换区、I/O、系统调用及 CPU 状态等指标。
  • iostat: 查看系统 I/O 统计信息,监控磁盘读写负载。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    $ iostat -x 1 5
    解释:以扩展模式输出,每秒采样一次,总共采样 5 次。
    Linux 4.15.0-112-generic (server1) 05/10/2021 _x86_64_ (8 CPU)

    avg-cpu: %user %nice %system %iowait %steal %idle
    10.50 0.00 5.20 2.30 0.00 81.00

    Device r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await svctm %util
    sda 3.50 2.10 0.10 0.08 45.00 0.02 5.00 0.80 0.50
    sdb 1.20 0.90 0.05 0.04 40.00 0.01 3.50 0.60 0.30
    ...
    说明:展示了各设备的读写速率、平均请求大小、等待时间、服务时间及利用率,帮助分析磁盘 I/O 性能。

检查磁盘:

  • df: 查看各磁盘分区的挂载情况以及使用率。

    1
    2
    3
    4
    5
    6
    $ df -h
    bashCopyEditFilesystem Size Used Avail Use% Mounted on
    /dev/sda1 50G 20G 28G 42% /
    tmpfs 7.8G 0 7.8G 0% /dev/shm
    /dev/sdb1 100G 70G 25G 74% /data
    说明:显示了每个挂载点的总容量、已用、可用空间及使用百分比。
  • du: 统计指定目录或文件的磁盘占用情况。

    1
    2
    3
    4
    $ du -sh /var/log

    1.2G /var/log
    说明:显示 /var/log 目录的总占用空间为 1.2G。
  • lsblk: 查看系统中所有块设备及分区信息。

    1
    2
    3
    4
    5
    6
    7
    $ lsblk
    pgsqlCopyEditNAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
    sda 8:0 0 100G 0 disk
    ├─sda1 8:1 0 50G 0 part /
    ├─sda2 8:2 0 30G 0 part /home
    └─sda3 8:3 0 20G 0 part [SWAP]
    说明:显示了磁盘设备及其分区布局,帮助了解磁盘结构和挂载情况。

内存配置相关:

  • -Xms-Xmx:设置初始堆大小和最大堆大小。
    例如:-Xms512m -Xmx1024m
  • -Xmn-XX:NewRatio:设置新生代大小或新生代与老年代的比例。
    例如:-Xmn256m-XX:NewRatio=2
  • Java 7 及以前的永久代参数:-XX:PermSize-XX:MaxPermSize
    Java 8 及以后的元空间参数:-XX:MetaspaceSize-XX:MaxMetaspaceSize

垃圾收集器选择:

  • Serial GC: -XX:+UseSerialGC
  • Parallel GC: -XX:+UseParallelGC-XX:+UseParallelOldGC
  • CMS GC: -XX:+UseConcMarkSweepGC
  • G1 GC: -XX:+UseG1GC
  • ZGC(JDK 11+): -XX:+UseZGC

GC 调优参数:

  • -XX:+PrintGCDetails-XX:+PrintGCDateStamps:输出详细的 GC 日志,方便分析停顿和回收时间。
  • -XX:+UseGCLogFileRotation-Xloggc:<file>:配置 GC 日志文件和日志轮换。
  • -XX:MaxTenuringThreshold:设置新生代对象晋升到老年代的年龄阈值。
  • -XX:SurvivorRatio:调整 Eden 区和 Survivor 区的比例。
  • -XX:ParallelGCThreads-XX:ConcGCThreads:设置 GC 线程数,适用于多核系统优化垃圾回收并发性。

问:你用过哪些JVM性能监视工具?⭐

  1. JDK自带的调优命令
    • jstat:命令行工具,用于监控 JVM 内存、GC 活动、类加载情况等,适合用于采集统计数据做趋势分析。
    • jstack、jmap、jcmd:这些命令行工具分别用于获取线程 dump、堆直方图、以及 JVM 各种内部状态信息(如 JVM 配置信息、GC 状态等),能帮助定位性能瓶颈和问题所在。
  2. JConsole图形工具:由 JDK 自带,通过 JMX 监控 JVM 的内存、线程、类加载情况等,可实时查看 GC 情况和 CPU 使用情况。
  3. VisualVM:也是 JDK 自带的图形化监控工具,支持堆 dump、线程分析、CPU 采样、GC 日志查看等功能,适用于性能诊断和调试。
  4. 阿里Arthas:

问:举一些常见的JVM问题排查和调优思路?你有哪些JVM调优思路及解决方案?⭐

常见的JVM问题:

  • CPU飙升
  • 内存泄漏
  • GC频繁,停顿问题
  • 线程竞争与死锁问题
  1. 预调优原则:堆设置、年轻代和老年代设置、方法区设置、GC设置
    • 堆设置:根据应用的内存需求设置好堆的初始大小和最大大小**-Xms 和 -Xmx**
    • 年轻代和老年代设置:一般设置为堆的1/3或1/4 ,可以根据情况调整新老代的比例
      • -Xmn<size>:设置新生代的大小。
      • -XX:NewRatio=<ratio>:设置新生代和老年代的比例。
    • 方法区设置
      • -XX:PermSize=<size>:设置永久代的初始大小。
      • -XX:MaxPermSize=<size>:设置永久代的最大大小(Java 7及之前)。
      • -XX:MaxMetaspaceSize=<size>:设置元空间的最大大小(Java 8及以后)。
    • GC设置:一般小内存使用CMS,大内存使用G1,临界点介于6~8G
      • -XX:+UseSerialGC:使用串行垃圾回收器。
      • -XX:+UseParallelGC:使用并行垃圾回收器。
      • -XX:+UseConcMarkSweepGC:使用CMS垃圾回收器。
      • -XX:+UseG1GC:使用G1垃圾回收器。
  2. 监控分析:使用调优命令和工具,分析GC日志和dump文件
  3. 定位问题并分析
  • 定位瓶颈:在进行调优前,首先使用监控、日志、性能分析工具确认瓶颈点(CPU、内存、GC、线程等)。
  • 渐进调优:调整参数或代码前先进行小范围验证,确认改动效果后再推广到生产环境。
  • 全面监控:建立完善的监控系统(JMX、Prometheus、Grafana 等),实时观察 JVM 指标和应用性能,及时捕捉异常情况。

1. 预调优原则

  • 合理设置内存
    • 堆内存:初始值(-Xms)与最大值(-Xmx)设为相同,避免动态扩容引发GC。
    • 根据对象存活情况调整新生代与老年代比例(-XX:NewRatio, -Xmn)。
    • 元空间:设置-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m,避免元空间动态扩容触发Full GC。
  • 选择垃圾回收器
    • 低延迟场景:G1(-XX:+UseG1GC)、ZGC(JDK11+)。
    • 高吞吐场景:Parallel GC(默认)。
  • 优化GC参数
    • 新生代比例-XX:NewRatio=2(老年代:新生代=2:1)。
    • Survivor区优化-XX:SurvivorRatio=8(Eden:Survivor =8:1:1)。

2. 针对性调优

  • Young GC频繁
    • 增大新生代:调整-Xmn-XX:NewRatio
    • 降低对象分配速率:优化代码,减少短生命周期、临时对象创建。采用对象池或缓存机制,提高内存利用率。
  • Full GC频繁
    • 避免内存泄漏:通过堆快照分析泄漏点。
      • 调整 GC 日志参数(如 -XX:+PrintGCDetails)以获取详细信息。
      • 根据 GC 日志结果,调整各代大小、晋升阈值(-XX:MaxTenuringThreshold)等参数,降低 Full GC 频率和停顿时间。
    • 增大老年代:调整-XX:NewRatio减少新生代占比。
    • 调整晋升阈值-XX:MaxTenuringThreshold=15(默认15次Young GC后晋升)。

3. 工具与监控

  • Arthas:实时监控线程状态、方法执行耗时、类加载信息。

    1
    2
    # 查看最慢的接口
    trace com.example.Controller *
  • Prometheus + Grafana:监控JVM内存、GC次数、线程数等指标。

  • GC日志分析工具:GCeasy、G1 GC的-XX:+PrintAdaptiveSizePolicy

问:CPU飙升问题?⭐⭐⭐

  1. 问题表现:
    • 系统监控工具(如 top、htop)显示 JVM 进程 CPU 占用率异常高(如90%以上);应用响应缓慢或出现卡顿现象。
    • 某些线程长时间占用 CPU(可能处于死循环、计算密集型任务或锁竞争严重);
    • JDK 工具(如 jstack)中线程状态显示为 RUNNABLE,且热点调用栈中出现重复计算或频繁循环调用。
  2. 问题产生原因:
    • 长时间运行的高并发系统,尤其是业务逻辑或算法效率低下时;
    • 锁竞争激烈或死锁导致某些线程一直处于忙碌状态;
    • 代码死循环:如while(true)未休眠或退出条件错误。
    • 频繁GC:如Young GC频繁导致CPU周期性飙升(需结合GC日志分析)。导致 GC 线程占用大量 CPU。
  3. 排查步骤与定位:
    • 定位进程top命令查看CPU占用最高的进程PID。
    • 定位线程top -H -p PID 查看进程中占用CPU高的线程ID(TID)。
    • 线程转十六进制:将TID转为十六进制(printf "%x\n" TID)。
    • 分析线程栈jstack PID > thread_dump.log,在日志中搜索十六进制线程ID,查看线程状态(如死循环、频繁GC)。jstack PID|grep -A 10 十六进制线程ID 查看对应线程的线程堆栈信息后面10行内容。
    • 分析代码:根据线程堆栈信息可以定位线程正在执行的代码位置,分析代码确认问题。
    • 分析工具:使用如Arthas、Jprofile等进行更深入的性能分析。
    • 若怀疑是GC问题,则按GC思路排查。
  4. 解决和优化思路
    • 优化算法和业务逻辑:重新审视热点方法的算法复杂度,减少重复计算,优化循环和递归调用。
    • 减少锁竞争:对共享资源采用更细粒度的锁或无锁设计,降低锁持有时间;采用分段锁、读写锁等策略改善并发性能。
    • 调整 GC 策略(若 GC 导致 CPU 占用高):根据应用场景选择合适的垃圾收集器(如 G1、CMS 或 ZGC),并调优 GC 参数,降低 GC 停顿和 CPU 负载。

问:内存泄漏问题?内存溢出OOM问题?OOM问题定位方法 ?⭐⭐⭐

OutOfMemory问题:

  • 问题表现:

    • 程序运行一段时间后,堆内存持续快速增长,最终出现 OutOfMemoryError 异常(如 Java heap space、Metaspace 等错误)。
    • 堆 dump 分析显示大量对象未被 GC 回收,存在异常引用链(GC Roots 引用链中存在不应该长期保存的对象)。
  • 处理流程or定位方法:

    1. 发现问题,如监控报警、手动排查发现:

      • jps获取PID

      • jstack PID:查看指定进程的线程情况,若有许多线程处于等待状态,则可能出现问题。

      • jstat -gc 线程ID:查看GC收集情况

      • jmap -heap PID查看新生代,老生代堆内存的分配大小以及使用情况;

        • 注意:生产环境一般不建议使用jmap,如果线上服务器堆内存特别大,该命令可能会卡死,以及导出Dump文件风险较高。
        • -XX:+HeapDumpOnOutOfMemoryError
        • 使用Arthas 的 heapdump 命令
      • jmap -histo pid | head -20 让 JVM 生成当前进程(由 pid 指定)的堆内存对象直方图。“head -20”命令可以只显示输出的前 20 行。直方图中会列出每个类的实例数量以及这些实例占用的总字节数。通过这个命令,你可以直观地看到哪些类在堆中占用了较多的对象或内存,这对于定位内存泄漏或分析内存使用情况非常有帮助。

        常见输出示例(部分)

        1
        2
        3
        4
        5
        6
        yamlCopyEdit num     #instances         #bytes  class name
        ----------------------------------------------
        1: 1000000 32000000 [C
        2: 500000 20000000 java.lang.String
        3: 300000 12000000 com.example.SomeClass
        ...
    2. 确认OOM类型:

      • java.lang.OutOfMemoryError:Java heap space :堆内存不足。
      • java.lang.OutOfMemoryError:Metaspace :元空间不足。
      • java.lang.OutOfMemoryError:GC overhead limit exceeded :GC时间过长。
    3. 收集诊断信息:

      • Error日志:OOM发生时的日志片段。
      • Heap Dump:获取堆转储文件(可以使用-XX:+HeapDumpOnOutOfMemoryError自动生成)
        • 使用 jmap -dump:format=b,file=heap.hprof <PID> 生成堆快照。
      • GC日志:启用GC日志来分析垃圾回收活动。

      如:

      1
      -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump -Xlog:gc*:file=gc.log:time
    4. 分析HeapDump:使用工具分析堆转储文件,找出内存泄漏的来源,或者是占用最多空间的对象。根据 GC Roots 分析引用链,找出导致对象无法回收的根源。

      工具:Eclipse Memory Analyzer(MAT)、VisualVM 或 JProfiler

      步骤:

      1. 加载HeapDump:使用MAT打开Dump文件。
      2. 查找大对象:使用MAT的Histogram视图查看占用内存最大的对象类型。
      3. 查找泄漏疑点:使用LeakSuspectsReport功能自动分析可能的内存泄漏点。

      结合 MAT 提供的泄漏嫌疑报告,对热点问题模块进行重点审查

    5. 分析代码:检查代码中可能导致OOM的地方。

      • 大对象集合
      • 长生命周期对象。

      常见:

      1. 缓存设计不当:使用了自定义缓存,没有合适的过期或清理机制。
      2. 无限增长的数据结构:不停的向某个集合塞入数据并且无法回收。
      3. 事件监听器未注销、静态集合不断增长
    6. 优化代码和配置:

      1. 优化数据结构
      2. 清理长生命周期对象
      3. 调整JVM参数:应用确实需要更大的内存(调整 -Xms/-Xmx、-XX:MaxMetaspaceSize 等参数)。
    7. 验证和监控:

      能够复现问题,并通过负载测试验证修改后的程序性能和内存使用情况。

      持续对内存进行监控,避免再次出现OOM。

      通过 JMX、Prometheus、Grafana 等监控堆内存使用情况,观察内存占用随时间的变化趋势。

问:GC频繁,停顿问题?定位频繁full GC?⭐⭐⭐

  • 问题表现:
    • GC 日志中频繁出现 Minor GC 或 Full GC;
    • 应用响应突然变慢,出现明显的 STW(Stop-The-World)停顿现象。
    • Full GC 停顿时间较长,日志中可见 “Full GC” 记录;
  • 问题产生原因:
    • 对象创建过快、对象晋升老年代过多;
    • 不合理的堆内存配置或 GC 参数设置;
    • 某些场景下缓存设计不当、长生命周期对象过多。
  • 排查步骤与定位:
    1. 开启 GC 日志:启用 GC 日志参数(如 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log),收集详细 GC 日志。
    2. 分析 GC 日志:使用 GCViewer、GCEasy 等工具分析 GC 日志,查看各代回收频率、停顿时长、回收效率等指标。
      • 如Full GC频率(10分钟1次)、每次耗时(3秒)、老年代使用情况(每次GC后还保持>80%)
    3. 观察内存占用变化:监控堆内存使用曲线,判断是否因为内存配置不合理导致频繁触发 GC。
      • 使用如VisualVM或JConsole实时监控JVM内存使用
      • 如发现:Eden区频繁被填满触发MinorGC,Survivor区经常接近满载,Old Gen持续增长且在FullGC后仍难以降低。
    4. 分析堆内存:生成堆转储文件,使用MAT分析。
      • 如发现大量的XXX对象占用了老年代的空间。
    5. 检查代码中对象分配情况:分析是否存在大量短生命周期对象创建或长生命周期对象不当存储在年轻代、导致频繁晋升老年代。
      • 如根据XXX对象,排查业务代码,发现XXX对象大量生成以及难以回收的原因。
  • 解决和优化思路:
    • 调优堆内存配置:临时解决问题
      • 根据对象特点调整堆大小(-Xms/-Xmx)、年轻代大小(-Xmn、-XX:NewRatio);
      • 调整晋升策略(-XX:MaxTenuringThreshold)以减少老年代压力。
    • 选择合适的垃圾收集器
      • 根据应用需求(低停顿或高吞吐量)选择适当的 GC,如 CMS、G1 或 ZGC。
    • 优化代码对象分配
      • 尽量减少临时对象创建,采用对象池或缓存机制,降低内存分配压力。

问:线程竞争和死锁问题?程序慢但无异常?⭐⭐

  • 排查步骤
    1. 检查线程状态jstack查看是否有大量线程阻塞(BLOCKED、WAITING)。
    2. 锁竞争分析:使用jstackArthasthread -b定位死锁或锁等待。
    3. I/O或网络瓶颈:结合vmstatiostat排查磁盘I/O或网络延迟。
  • 典型案例
    • 数据库连接池耗尽:线程等待获取数据库连接。
    • 锁竞争激烈:如synchronized修饰全局方法。

表象与特征

  • 表象:
    • 程序长时间无响应或部分功能卡死,线程状态异常;
    • jstack 输出中发现大量线程处于 BLOCKED 或 WAITING 状态,可能存在死锁提示(Deadlock)。
  • 特征:
    • 线程 dump 中显示存在循环依赖,多个线程互相等待;
    • 高并发场景下锁争用严重,导致 CPU 利用率低而响应时间变长。

出现场景

  • 高并发环境下共享资源访问频繁;
  • 同步代码块设计不当、锁粒度过大;
  • 多线程间存在隐性依赖关系,未合理释放锁。

排查步骤与定位

  1. 生成线程 Dump:
    • 使用 jstack 生成线程 dump,观察线程状态和堆栈信息,找出死锁或长时间等待的线程。
  2. 锁争用分析:
    • 利用 VisualVM、JProfiler 等工具监控锁竞争情况,找出热点锁。
  3. 代码审查:
    • 检查容易产生竞争的代码段,确认是否可以优化锁粒度或采用无锁数据结构。

解决和优化思路

  • 调整锁粒度:
    • 缩小同步块的范围,避免长时间持有锁;采用细粒度锁或分段锁。
  • 采用并发工具:
    • 采用 JDK 并发包中的数据结构(如 ConcurrentHashMap)替代手写同步代码。
  • 避免死锁:
    • 在代码中统一锁定顺序或使用超时机制,避免多个线程形成锁依赖循环。

问:StackOverflow异常有没有遇到过?一般你猜测会在什么情况下被触发?⭐

StackOverflowError 通常在递归调用无法终止时触发。这个错误是由于调用栈(call stack)无限增长,超出了虚拟机的栈深度限制而引发的。每次方法调用都会在栈上分配一些空间,递归调用没有终止条件或者终止条件不被满足时,调用栈就会不断增长,最终导致栈溢出。

一些可能导致 StackOverflowError 的情况:

  1. 递归深度过大: 当递归调用没有正确的终止条件,或者终止条件写得不当时,递归深度可能会无限增长,导致栈溢出。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class StackOverflowExample {
    public static void main(String[] args) {
    recursiveMethod(1);
    }

    private static void recursiveMethod(int i) {
    System.out.println(i);
    recursiveMethod(i + 1); // 递归调用没有终止条件
    }
    }
  2. 无限循环调用: 在方法之间进行无限循环调用,也可能导致栈溢出。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class StackOverflowExample {
    public static void main(String[] args) {
    methodA();
    }

    private static void methodA() {
    methodB();
    }

    private static void methodB() {
    methodA(); // 无限循环调用
    }
    }

避免 StackOverflowError 的最好方法是确保递归调用具有正确的终止条件,并且循环调用能够正常结束。在编写递归代码时,务必小心终止条件的设计。

问:程序死循环问题?⭐⭐

  • 问题表现:

    • 高 CPU 占用:当应用程序进入死循环时,往往会导致相关线程持续占用 CPU 资源,监控工具(如 top、htop)会显示该进程或某个线程的 CPU 占用率异常升高(接近 100% 或多个 CPU 核心利用率飙升)。
    • 响应迟缓或无响应:如果死循环线程占用过多资源,整个应用可能变得响应迟缓或出现明显卡顿,甚至无法处理新的请求。
    • 线程数异常:如果多个线程因相似问题进入死循环,线程池中可能出现大量处于运行状态但没有实际进展的线程。
    • 内存变化:部分死循环可能伴随对象不断生成(如循环中创建新对象),进而引发内存占用不断上升,但最主要还是 CPU 利用率异常高。
  • 定位死循环问题的步骤:

    步骤 1:初步确认

    • 使用系统监控工具:
      通过 top、htop 等工具观察 CPU 占用情况,确认是否有进程或线程异常消耗 CPU 资源。
    • 查看应用日志:
      检查日志是否存在异常输出或重复日志记录(有时死循环会伴随大量相同日志),作为排查的线索。

    步骤 2:获取线程 Dump

    • 使用 jstack 生成线程 dump:
      执行命令 jstack <PID> 获取当前线程堆栈信息,重点关注 CPU 占用高的线程。
    • 分析线程状态:
      在线程 dump 中,查找那些处于 RUNNABLE 状态且调用栈重复、没有退出条件的线程;通常死循环的线程会在调用栈中反复出现同一组方法调用或循环调用的情况。

    步骤 3:借助 Profiling 工具

    • 性能采样:
      利用 async-profiler、VisualVM、JProfiler 或 YourKit 等工具对应用进行采样,找出热点方法和循环调用的具体位置。
    • 定位代码位置:
      根据采样数据确定具体的代码逻辑或方法,重点检查循环条件、递归调用、计数器或退出条件是否正确。

    步骤 4:代码排查与复现

    • 复现问题:
      如果环境允许,在测试或预发布环境中重现该死循环问题,便于逐步调试。
    • 调试代码:
      使用 IDE 的断点调试功能,跟踪进入死循环的代码段,确认循环条件或退出条件是否存在逻辑错误。
  • 解决和处理死循环问题的思路与方案:

    修复代码逻辑

    • 修正循环条件:
      核查并修正循环中的退出条件,确保在适当情况下能够终止循环。
    • 添加超时或计数器:
      对循环增加超时或计数器检查,避免异常情况下一直循环。例如,在循环中增加一个最大迭代次数,超过则记录日志并退出或抛出异常。
    • 代码重构:
      如果发现死循环原因在于设计问题,考虑对算法或业务逻辑进行重构,避免产生无限迭代的风险。

    临时处理措施

    • 重启应用:
      如果问题线上发生影响业务,可先通过重启应用来恢复服务,同时开启详细日志记录以便后续分析。
    • 降级或限流:
      临时采取请求限流、降级等策略减少负载,减轻死循环对系统整体性能的影响。

    部署和验证

    • 单元测试与压力测试:
      修复代码后,编写单元测试覆盖相关循环逻辑,并在测试环境中进行压力测试,确保死循环问题彻底解决。
    • 监控和日志监测:
      部署后继续监控 CPU、线程、响应时间等指标,确保问题不会复现。

问:应用程序突然挂掉如何排查?⭐⭐⭐

步骤:

  1. 确认程序挂掉:Jps查看程序
  2. 日志分析:
    • 查看应用程序本身的日志,查看最后打印了什么,附近有没有异常错误信息。
    • 查看操作系统日志有没有记录什么突发事件。
  3. 生成和分析HeapDump
  4. 检查系统资源:
    • top、htop:查看CPU负载,有没有异常的负载尖峰。
    • free -m、vmstat:检查内存使用情况,是否有过度的内存消耗。
    • df -h:检查磁盘空间,是否有发生磁盘写满的情况。
  5. 分析代码
    • 是否最近的代码变更引入了错误,比如最近的补丁包。
    • 是否使用了不兼容或者有漏洞的第三方库。
  6. 检查环境配置
    • 确认JVM启动参数是否合适。
    • 确认Java版本、操作系统版本、依赖的第三方插件的版本等。
  7. 验证和监控
    • 使用Prometheus、Grafana等工具对系统和应用进行实时监控。
    • 设置合适的报警阈值,及时通知。
    • 定位问题并解决后,在类似生产环境的测试服务器上进行问题复现,以及修复版本的验证。

问:arthas 监控工具 ?⭐⭐⭐

(1):dashboard命令查看总体jvm运行情况

(2):jvm显示jvm详细信息

(3):thread 显示jvm里面所有线程信息(类似于jstack) 查看死锁线程命令thread -b

(4):sc * 显示所有类(search class)

(5):trace 跟踪方法

Arthas 是阿里开源的 Java 诊断工具,支持实时监控、动态追踪、热更新代码等功能。

一、核心命令详解

1. dashboard:全局监控面板

  • 功能:实时展示 JVM 运行状态(CPU、内存、线程、GC 等)。
  • 使用场景:快速定位 CPU 飙升、内存泄漏、线程阻塞等问题。
  • 参数
    • -i 2000:刷新间隔(毫秒,默认 5000)。
    • -n 5:刷新次数(默认持续刷新)。
  • 示例
    1
    dashboard -i 1000  # 每秒刷新一次

2. thread:线程分析

  • 功能:查看线程状态、堆栈信息。
  • 使用场景
    • CPU 高thread -n 3 查看最繁忙的 3 个线程。
    • 死锁thread -b 直接定位死锁线程。
    • 线程阻塞thread --state BLOCKED 查看所有阻塞线程。
  • 示例
    1
    thread 22  # 查看线程ID=22的堆栈(jstack中的nid=0x16)

3. trace:方法调用追踪

  • 功能:统计方法内部调用路径及耗时。
  • 使用场景:定位接口性能瓶颈(如慢 SQL、循环耗时)。
  • 参数
    • -n 3:限制输出次数(默认 100)。
    • '#cost > 100':过滤耗时超过 100ms 的调用。
  • 示例
    1
    trace com.example.UserService getUserById '#cost > 50' -n 5

4. watch:方法观测

  • 功能:监听方法入参、返回值、异常。
  • 使用场景:动态调试参数传递、异常捕获。
  • 参数
    • -b:方法调用前观测(入参)。
    • -e:方法抛出异常时观测。
    • -x 3:展开对象层级深度(默认 1)。
  • 示例
    1
    watch com.example.OrderService createOrder "{params, returnObj}" -x 2

5. sc / sm:类与方法搜索

  • 功能
    • sc:查找已加载的类信息(Search Class)。
    • sm:查找类的方法(Search Method)。
  • 使用场景:确认类是否加载、方法是否存在。
  • 示例
    1
    2
    sc *UserController*  # 查找名称包含UserController的类
    sm com.example.UserService getUser* # 查找UserService中以getUser开头的方法

二、实战场景指南

场景1:CPU 100% 问题

  1. 定位高CPU线程
    1
    thread -n 3  # 显示CPU占用最高的3个线程
  2. 查看线程堆栈
    1
    thread 46  # 分析线程ID=46的代码位置
  3. 反编译代码(若怀疑代码逻辑):
    1
    jad com.example.ServiceImpl  # 反编译类查看源码

场景2:接口响应慢

  1. 追踪方法耗时
    1
    trace com.example.ApiController handleRequest '#cost > 200'  # 过滤耗时>200ms的调用
  2. 观测方法参数
    1
    watch com.example.DAO queryData "{params[0]}"  # 查看SQL参数是否异常

场景3:内存泄漏排查

  1. 检查堆内存分布
    1
    heapdump /tmp/heap.hprof  # 导出堆快照(需MAT分析)
  2. 监控对象创建
    1
    ognl '@com.example.LeakTracker@getInstance().getLeakedObjects().size()'  # 自定义泄漏计数器

三、高级技巧

  • 热更新代码
    1
    redefine /tmp/UserService.class  # 替换已加载的类(无需重启)
  • 动态执行代码
    1
    ognl '@com.example.Config@get("timeout")'  # 运行时读取配置
  • 监控HTTP请求
    1
    profiler start --event http  # 统计HTTP请求耗时(需Arthas 3.6+)

四、注意事项

  1. 生产环境慎用redefine 可能导致类状态不一致。
  2. 权限控制:限制Arthas端口访问,避免安全风险。
  3. 结合日志:与GC日志(-Xloggc)、应用日志联动分析。

问:单机几十万并发的系统JVM如何调优⭐⭐

针对 JVM 层面的调优,主要包括以下几个方面:

  1. 堆内存设置: 根据系统的物理内存和应用程序的需求,调整堆内存的大小。通过 -Xms(初始堆大小)和 -Xmx(最大堆大小)进行设置,确保合理利用内存空间。

    1
    java -Xms2g -Xmx4g -jar your-application.jar
  2. 选择垃圾收集器: 根据应用程序的性能需求选择合适的垃圾收集器。例如,对于需要低延迟和可预测性的场景,可以考虑使用 G1 收集器。

    1
    java -XX:+UseG1GC -jar your-application.jar
  3. 调整垃圾收集器参数: 根据应用程序的特性,调整垃圾收集器的参数,例如设置新生代和老年代的大小、GC 线程数量等。

    1
    java -XX:NewSize=1g -XX:MaxNewSize=1g -XX:ParallelGCThreads=4 -jar your-application.jar
  4. 堆内存分代比例: 根据应用程序的特性,合理设置新生代和老年代的分代比例。可以使用参数 -XX:NewRatio 进行调整。

    1
    java -XX:NewRatio=2 -jar your-application.jar
  5. 调整栈大小: 根据线程数量和调用深度,合理设置栈大小。可以使用参数 -Xss 进行设置。

    1
    java -Xss256k -jar your-application.jar
  6. 启用并行处理: 根据硬件环境,启用并行处理来提高系统的并发性能。例如,使用参数 -XX:+UseParallelGC 来启用并行垃圾收集器。

    1
    java -XX:+UseParallelGC -jar your-application.jar
  7. 调整线程池参数: 如果应用程序使用线程池,调整线程池的大小和其他参数,确保合理利用系统资源。

  8. JVM 监控: 使用 JVM 监控工具,如 VisualVM、JConsole 等,监测堆内存使用、垃圾收集频率等指标,及时发现和解决性能问题。

问:谈谈工作中实战过的JVM调优案例?⭐⭐⭐

参考项目介绍篇。

  • 数据中台在某个版本的压力测试下性能不达标
  • 生产环境中服务变卡