JVM

JVM 学习路线(适合从零到进阶)

学习目标:始终围绕一个问题——Java 程序是如何运行起来的?

推荐主线:

Java源码 → javac编译 → .class字节码 → 类加载 → JVM内存 → 对象创建 → 方法调用 → 字节码执行 → 垃圾回收 → JVM调优


第一阶段:Java 编译过程(理解 JVM 的输入)

学习目标

理解 JVM 不运行 Java,而是运行 字节码(.class)

需要掌握

Java源码

javac 编译器

Class 文件

实践

答题区

.java文件是什么?

.java是给JVM生产.class字节码的原材料,JVM并不能理解.java文件,换句话说,.java文件是给程序员,给人看的。.class字节码文件才是JVM应该解析的。

.class实际上是一种二进制格式。开头CA FE BA BE就是Class文件的魔数(Magic Number)
JVM首先检查是不是CA FE BA BE。如果是,则判定为是一个合法的class文件。否则直接ClassFormatError

一个 .class 文件真正开始长这样:

CAFEBABE
0000
003D
......

分别表示:

CAFEBABE
↑
Magic Number(魔数)

0000
↑
Minor Version(次版本号)

003D
↑
Major Version(主版本号)转换成10进制->61,表示Java17

魔数(Magic Number) 是文件开头的一段固定字节,用来标识文件的类型。

ClassFormatError 是 JVM 在加载 Class 文件时发现文件格式不合法抛出的错误。

.class 文件
        │
        ▼
【格式检查】
是不是一个合法的 Class 文件?
        │
        ├── 否 → ClassFormatError
        ▼
【字节码验证】
虽然格式正确,但字节码是否安全、合法?
        │
        ├── 否 → VerifyError
        ▼
进入 JVM 执行

一个Java文件有哪些组成部分?

编译器的角度来看,一个Java文件有四个组成部分

.java
│
├── Package(包声明)
├── Import(导入)
├── Type(类型定义)
└── 注释

其中最重要的就是Type(类型定义),因为Java文件最终编译出来的就是各种类型(class)的字节码

  1. package(包声明),位于第一行。作用是告诉编译器这个类属于哪个包,决定了编译后的目录结构
  2. import(导入)
import java.util.List;
import java.util.ArrayList;

告诉编译器:后面写 List 时,其实是 java.util.List

否则必须写:

java.util.List<String> list;
  1. 注释:编译后注释全部消失,不保留注释。.class文件中没有注释
  2. Type(类型定义)
    Java中的类型包括
Type
├── class 普通class
├── interface  接口
├── enum  枚举
├── record(Java14+)  记录类,快速定义一个只用于存储数据的类。编译后会变成普通的class类
├── @interface  定义注解。真正理解注解的是 Spring,不是 JVM。JVM 只负责把注解信息保存下来,并通过反射 API 提供给框架读取。

从 JVM 的角度看,它们最终都会被编译成 .class 文件,并由类加载器加载。区别主要体现在 Class 文件中的标志位(Access Flags)和元数据

  • class:普通类标志
  • interface:带有 ACC_INTERFACE
  • enum:带有 ACC_ENUM
  • record:带有 ACC_RECORD(Java 16 起)
  • @interface:本质上是一个特殊的接口,同时带有 ACC_ANNOTATIONACC_INTERFACE
    Class的内部又可以继续拆
public class Student {
    ...
}

里面通常包括

  1. 字段(Field):保存在对象中
private String name;
private int age;
  1. 常量
public static final int MAX = 100;
  1. 构造方法(Constructor)
new Student()

时执行
4. 方法(Method)

public void study() {

}

JVM执行的大部分内容其实就是方法。
5. 静态代码块

static {
    System.out.println("加载类");
}

类第一次加载时执行
6. 普通代码块

{
    System.out.println("创建对象");
}

每次new都会执行
7. 内部类

class Inner {

}

编译之后

Student.class
Student$Inner.class

public class 与文件名的关系?

JVM既不读取.java文件,也不关心文件名。真正关心文件名的是Java编译器(javac)
public class必须与文件名一致,是Java编译器(javac) 执行的Java语言规范。

为什么设计成这样?
这是一个Java语言设计哲学的问题。这是Java设计者在可维护性和灵活性之间做出的权衡。

  • public class与文件名一致,方便开发者维护,能对程序做到见名知意
  • 方便IDE管理,可以做到输入类名直接跳转到对应文件
    为什么一个.java文件中允许存在多个非public的class?
    非public的普通class,一般作为辅助类出现,仅为public class这个主类服务。
    public class作为主类、以对外唯一接口的身份出现。其他类调用,只能调用这个主类。而无法调用它的辅助类。起到限制可见范围的作用

javac的作用?

javacJava Compiler(Java 编译器),它的作用就是:

将人类编写的 .java 源代码编译成 JVM 能够执行的 .class 字节码文件。

javac负责.java->.class。java负责.class->JVM运行

javac的编译流程?

              .java
                │
                ▼
        ① Lexer(词法分析)
                │
                ▼
        ② Parser(语法分析)
                │
                ▼
              AST
                │
                ▼
        ③ Semantic(语义分析)
                │
                ▼
      ④ Annotation Processing
                │
                ▼
       ⑤ 生成 .class

javac编译时都做了什么?

  1. 词法分析
int a = 3 + 5 * 2;

拆成Token

int
a
=
3
+
5
*
2
;

这时编译器只是把它当做字符。根本不知道

  • 哪个是变量?
  • 哪个先计算?
  • 哪个后计算?
张三 → 一个名字
打 → 一个动词
李四 → 一个名字
  1. 语法分析
    根据java语法抽象生成语法树
=
├── a
└── +
    ├── 3
    └── *
        ├── 5
        └── 2

注意:这里已经体现了

*
优先级高于
+

所以:
不是

(3+5)*2

而是

3+(5*2)

编译器就是根据这棵树知道应该先计算乘法。

注:倒着看
为什么叫"抽象"语法树(Abstract Syntax Tree)?
因为它会忽略很多没有意义的语法细节

a+b

(a+b)

AST(抽象语法树)中基本都会表示为

+
├── a
└── b

也就是说:

  • 空格
  • 换行
  • 分号
  • 注释
    这些通常不会出现在 AST 中。

此时,如果有这样的代码

int = a 10;

虽然每一个 Token 都合法,但是排列方式违反了 Java 文法。Parser 报语法错误。

Sentence
├──主语:张三
├──谓语:打
└──宾语:李四

已经知道:张三打李四。
而不是:李四打张三。

  1. 语义分析
    语义分析关注这句话有没有实际意义?
    例如
int a = "hello";

完全符合语法规则,javac开始分析它的语义。开始思考能不能把 String 放进 int
答案:不能。所以此阶段报错:Type mismatch

为什么此阶段属于语义分析?

a = b + c

构建AST:

+
├── b
└── c

语义分析开始理解他们分别是什么。到底是

int b;
int c;

属于算数加法,还是

String b;
String c;

属于字符串拼接。
相同的语法可能有不同的语义

假设有

int a;
String name;

语义分析阶段开始建立

a
↓
变量
↓
int

还有

name
↓
变量
↓
String

语义分析做的是:

  • 建立符号表
  • 绑定变量
  • 解析类型
  • 推导表达式类型
  • 检查访问权限(privateprotected 等)
  • 方法重载解析
  • 泛型类型检查
  • 自动装箱/拆箱处理
  • Lambda 表达式类型推导
  • 确定每个方法调用最终调用哪个方法
  • 检查错误
张三
↓
人
↓
李四
↓
人
↓
打
↓
人可以打人

如果:

桌子 打 李四

语法还是正确:

主语
谓语
宾语

但是:
语义分析会说:
桌子不会主动打人。

于是就会产生语义错误。

注:Attr 一般不会修改 AST 的结构(Structure),但会修改 AST 节点中保存的属性(Attribute)。

如:

int c = a + b;

Parser生成的AST:

VarDef
├── type : int
├── name : c
└── Binary(+)
    ├── Ident(a)
    └── Ident(b)

Attr会把Ident(a)补充成

Ident(a)
├── Symbol = LocalVariableSymbol
├── Type = int
└── Slot = 1

最后,树还是那棵树,只是节点变得更丰满了

总结:为AST节点绑定Symbol和Type对象,完成名称解析、类型推导、重载解析、可访问性检查等语义分析。

  1. 注解处理
    javac只负责处理编译期注解(Compile-time Annotation)

注解处理就是编译器把AST交给一些插件(如Lombok等),让它们看看有没有需要处理的注解
在正常的情况下,这些插件会读取AST->分析注解->生成新的.java文件。还可以报编译错误,理论上还能修改AST,但出于对编译器稳定的考虑,官方并不推荐这种方式,而是更推荐生成.java源码

编译器每发现一种注解,都会丢给Processor(处理器),由Processor自己决定该怎么处理。

javac怎么知道谁来处理?
Java有一个接口

javax.annotation.processing.Processor

所有注解处理器都实现这个接口,例如

public class MyProcessor extends AbstractProcessor {

}

里面有process(...)方法,因此,编译器每发现一种注解都会

Processor.process(...)

让处理器自己决定怎么办

但是其中注解还分几种情况,并不是所有的注解都会被处理的。

注解 javac 是否处理 谁真正处理
@MyAnnotation(无 Processor) 解析、保存,其他什么都不做 没有人
@MyAnnotation(有 Processor) 调用 Processor 你的 Processor
@Service 解析、保存,通常不处理 Spring(运行时)
@Autowired 解析、保存,通常不处理 Spring(运行时)
@GetMapping 解析、保存,通常不处理 Spring MVC(运行时)
@Data 调用 Lombok Lombok(编译期)

解释:用户自己编写的一般注解,javac不会进行处理。
如果用户自己不仅编写了注解,还编写了Processor,那么javac会把该注解丢给Processor,由处理器自己决定怎么处理(因为用户编写了对应的Processor,也就是交给用户自己编写的Processor去处理)
对于SpringBoot的注解,大部分注解javac都不会处理(小部分除外),如@Service Autowired等,因为该注解的设计并不是为了在编译时的注解处理期运行的。而是会交给程序运行期的SpringBoot去处理。

其中最特殊的是Lombok,上面说了,大部分调用Processor的注解处理会生成新的.java源码文件,这也是官方推荐的方式。但是Lombok会直接调用底层接口,修改AST。这样的设计是由其功能的特殊性而不得不采用的方式。这种方式往往会引起Lombok对不同的Java版本的通用性不高(因Java的内部结构可能会改变)。所以在使用Lombok时要挑选对应的版本。

  1. 字节码生成
    在进行Attr(语义分析) 后,Gen(Generate)(生成) 前,还要进行Lower(降级) 处理。
    Gen翻译的不是Attr语义解析过的树,而是经过Lower降级处理后的树。

Lower的作用是,消解语法糖,消解高级语法。返璞归真
Java语言≠JVM指令集
Java为了提高开发效率,引入了很多高级语法,如:

  • 增强 for
  • Lambda
  • 自动装箱/拆箱
    这些JVM根本不认识。因此在生成.class字节码前,必须把这些高级语法拆解开。
    也就是说,对Lower输入一棵AST会得到一棵新的,降级处理后的AST
原AST->Lower->新AST

举例:
这是一段增强for语法糖

for (String s : list) {
    System.out.println(s);
}

Parser 生成的 AST 中,会有一个专门表示增强 for 的节点:

EnhancedForLoop
├── variable
├── iterable
└── body

Gen不认识这种高级节点,于是Lower把它改写成普通循环,做了一个降级处理
逻辑上相当于:

Iterator<String> it = list.iterator();

while (it.hasNext()) {
    String s = it.next();
    System.out.println(s);
}

对应的新AST:

Block
├── VarDef(iterator)
└── WhileLoop

原来的 EnhancedForLoop 节点已经不存在了。


Lower处理到此讲解结束,下面是Gen(生成)部分,讲解.class是如何生成的


AST 本身不会"变成"字节码,而是编译器采用 Visitor 模式递归遍历 AST,每个节点根据自己的语义向当前方法的 Code 缓冲区追加若干条 JVM 指令。当整棵 AST 遍历完成后,这个 Code 缓冲区就构成了方法的字节码,最后再由 ClassWriter 按照 ClassFile 格式写入 .class 文件。

这里有三个关键词非常重要,也是以后阅读 OpenJDK javac 源码时反复会遇到的:

  • Visitor 模式:不同类型的 AST 节点(变量、表达式、方法调用、循环等)都有对应的 visitXXX() 方法负责生成字节码。
  • 递归遍历:父节点通常先递归处理子节点,再生成自己的指令,因此自然符合 JVM 基于操作数栈的执行模型。
  • Code 缓冲区:生成的字节码不是立即写入 .class 文件,而是先追加到当前方法的 Code 对象中,等整个方法处理完后,再由 ClassWriter 与常量池、字段表、方法表等一起封装成最终的 ClassFile。

什么是字节码?

字节码(Bytecode)就是JVM能直接理解的一套中间指令集。字节码就是javac编译器输出的结果。也是JVM的输入。
字节码只是.class文件的一部分,.class文件主要有以下几个部分组成

.class
│
├── Magic Number
├── 版本号
├── 常量池
├── 字段信息
├── 方法信息
└── Code属性(真正的字节码)

真正意义上的字节码,是 Code 属性里面的 code[] 数组

method_info
    └── Code
            ├── max_stack
            ├── max_locals
            ├── code_length
            └── code[]

字节码有哪些特点?

  1. 面向栈
    JVM不像CPU大量使用寄存器,而是主要依赖操作数栈来完成计算
  2. 一条指令完成一个简单动作
  3. 与平台无关
    具体平台的机器码,由JVM去翻译

总结:字节码(Bytecode)是 Java 编译器 javac.java 源代码转换后生成的一套平台无关的 JVM 指令。它存储在 .class 文件的 Code 属性中,由 JVM 在运行时解释执行或通过 JIT 编译成本地机器码执行,是连接 Java 源代码与 CPU 机器码之间的中间表示。

什么是标志位?

标志位其实就是一种位图,即每一位都对应着某种信息。JVM通过标志位来理解"public""final"等修饰符。

标志位是由javac编译产生的。编译器会把这些修饰符压缩成一个 16 位整数(u2)。例如:

0000 0000 0001 0001

其中

第0位 = public
第4位 = final

JVM只需要判断

if (第0位 == 1)
    说明它是public

if (第4位 == 1)
    说明它是final

Class文件中很多位置都会用到标志位

ClassFile
│
├── magic
├── version
├── constant_pool
├── access_flags      ← 类的标志位
├── this_class
├── super_class
├── interfaces
├── fields
│      └── access_flags   ← 字段标志位
├── methods
│      └── access_flags   ← 方法标志位
└── attributes

可以看到整个 Class 文件至少有三种标志位:

  • 类标志位
  • 字段标志位
  • 方法标志位
    常见的类标志位
标志名称 十六进制 二进制 含义
ACC_PUBLIC 0x0001 0000 0000 0000 0001 public 类
ACC_FINAL 0x0010 0000 0000 0001 0000 final 类
ACC_SUPER 0x0020 0000 0000 0010 0000 使用新的 invokespecial 调用语义(现代编译器几乎都会设置)
ACC_INTERFACE 0x0200 0000 0010 0000 0000 interface
ACC_ABSTRACT 0x0400 0000 0100 0000 0000 abstract
ACC_SYNTHETIC 0x1000 0001 0000 0000 0000 编译器生成
ACC_ANNOTATION 0x2000 0010 0000 0000 0000 注解类型
ACC_ENUM 0x4000 0100 0000 0000 0000 enum

常见的字段标志位

标志名称 十六进制 二进制 含义
ACC_PUBLIC 0x0001 0000 0000 0000 0001 public
ACC_PRIVATE 0x0002 0000 0000 0000 0010 private
ACC_PROTECTED 0x0004 0000 0000 0000 0100 protected
ACC_STATIC 0x0008 0000 0000 0000 1000 static
ACC_FINAL 0x0010 0000 0000 0001 0000 final
ACC_VOLATILE 0x0040 0000 0000 0100 0000 volatile
ACC_TRANSIENT 0x0080 0000 0000 1000 0000 transient
ACC_SYNTHETIC 0x1000 0001 0000 0000 0000 编译器生成
ACC_ENUM 0x4000 0100 0000 0000 0000 enum 常量

常见的方法标志位

标志名称 十六进制 二进制 含义
ACC_PUBLIC 0x0001 0000 0000 0000 0001 public
ACC_PRIVATE 0x0002 0000 0000 0000 0010 private
ACC_PROTECTED 0x0004 0000 0000 0000 0100 protected
ACC_STATIC 0x0008 0000 0000 0000 1000 static
ACC_FINAL 0x0010 0000 0000 0001 0000 final
ACC_SYNCHRONIZED 0x0020 0000 0000 0010 0000 synchronized
ACC_BRIDGE 0x0040 0000 0000 0100 0000 编译器生成的桥接方法
ACC_VARARGS 0x0080 0000 0000 1000 0000 可变参数方法
ACC_NATIVE 0x0100 0000 0001 0000 0000 native
ACC_ABSTRACT 0x0400 0000 0100 0000 0000 abstract
ACC_STRICT 0x0800 0000 1000 0000 0000 strictfp(较少使用)
ACC_SYNTHETIC 0x1000 0001 0000 0000 0000 编译器生成

什么是元数据

元数据就是不参与程序计算,而是用来描述程序结构的信息
元数据不是程序真正执行的内容,而是 JVM 用来理解程序结构的信息。

JVM加载Class文件的时候,首先需要知道

这是哪个类?

继承谁?

有哪些接口?

有哪些字段?

有哪些方法?

方法叫什么?

参数是什么?

返回值是什么?

访问权限是什么?

这些信息全部来自Class 文件中的元数据。

一个.class文件可以分为两大部分

Class 文件
│
├── 元数据(Metadata)
│
└── 字节码(Bytecode)

元数据例如:

ClassFile
│
├── magic
├── version
├── constant_pool
├── access_flags
├── this_class
├── super_class
├── interfaces
├── fields
├── methods
└── attributes

即除了Code的code[]里面存放的是字节码。几乎其他所有内容都属于元数据

元数据解释:

ClassFile(Class文件结构)
│
├── magic
│       魔数
│       用于判断当前文件是否是合法的 Java Class 文件
│       固定值:CAFEBABE
│
├── version
│       Class文件版本号
│       包含:
│       - minor_version(次版本号)
│       - major_version(主版本号)
│       用于告诉 JVM 该 class 文件由哪个 Java 版本编译
│
├── constant_pool
│       常量池
│       存储类中使用的各种常量和符号引用
│       包括:
│       - 字符串常量
│       - 类名
│       - 方法名
│       - 字段名
│       - 类型描述符
│       - 方法引用
│
├── access_flags
│       访问标志位
│       描述当前类的权限和特征
│       例如:
│       - public
│       - final
│       - abstract
│       - interface
│
├── this_class
│       当前类信息
│       指向常量池中的一个类引用
│       表示:
│       "当前 class 文件描述的是哪个类"
│
├── super_class
│       父类信息
│       指向常量池中的父类引用
│       表示:
│       "当前类继承自哪个类"
│       (Object除外,Object没有父类)
│
├── interfaces
│       接口列表
│       保存当前类实现的接口信息
│       例如:
│       class User implements Serializable
│
├── fields
│       字段表
│       描述类中的成员变量
│       包括:
│       - 字段名称
│       - 字段类型
│       - 访问权限
│       - static/final/volatile等属性
│
├── methods
│       方法表
│       描述类中的方法
│       包括:
│       - 方法名称
│       - 参数类型
│       - 返回值类型
│       - 访问权限
│       - 方法字节码
│
└── attributes
        属性表
        保存额外信息

        例如:
        - SourceFile(源码文件名)
        - Code(方法字节码)
        - LineNumberTable(源码行号映射)
        - LocalVariableTable(局部变量信息)
        - RuntimeVisibleAnnotations(运行时注解)

从JVM加载一个类需要什么信息的角度解释:

JVM想认识一个类
        │
        ├── 它叫什么?
        │       this_class
        │
        ├── 它是谁的子类?
        │       super_class
        │
        ├── 它实现了什么?
        │       interfaces
        │
        ├── 它有什么变量?
        │       fields
        │
        ├── 它有什么方法?
        │       methods
        │
        ├── 它有什么权限?
        │       access_flags
        │
        └── 它里面存什么代码?
                Code属性
                (属于attributes)

.class的完整文件结构

ClassFile(.class文件)
│
├── magic
│       魔数
│
├── minor_version
│       次版本号
│
├── major_version
│       主版本号
│
├── constant_pool_count
│       常量池数量
│
├── constant_pool
│       常量池
│
├── access_flags
│       类访问标志
│
├── this_class
│       当前类索引
│
├── super_class
│       父类索引
│
├── interfaces_count
│       实现接口数量
│
├── interfaces
│       接口列表
│
├── fields_count
│       字段数量
│
├── fields
│       字段表
│
├── methods_count
│       方法数量
│
├── methods
│       方法表
│
├── attributes_count
│       属性数量
│
└── attributes
        属性表

分析一个简单的java程序

package linshi;  
  
public class Main {  
    public static void main(String[] args) {  
        int a = 0;  
        int b = 0;  
        int c = 0;  
        a = b+c;  
        
        System.out.println("Hello Java!");  
    }  
}

十六进制的class文件

CA FE BA BE  魔数(Magic Number,标识这是一个 Java Class 文件)

00 00         次版本号(minor_version)

00 3D         主版本号(major_version,0x3D = Java 17)

00 1D         常量池数量(constant_pool_count = 29,实际索引 #1 ~ #28)


#1
0A 00 02 00 03
              CONSTANT_Methodref
              方法引用
              class_index = #2
              name_and_type_index = #3


#2
07 00 04      CONSTANT_Class
              类引用
              name_index = #4


#3
0C 00 05 00 06
              CONSTANT_NameAndType
              名称与类型描述
              name_index = #5
              descriptor_index = #6


#4
01 00 10
6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74
              CONSTANT_Utf8
              "java/lang/Object"


#5
01 00 06
3C 69 6E 69 74 3E
              CONSTANT_Utf8
              "<init>"
              构造方法名称


#6
01 00 03
28 29 56
              CONSTANT_Utf8
              "()V"
              方法描述符:
              无参数,返回 void


#7
09 00 08 00 09
              CONSTANT_Fieldref
              字段引用
              System.out


#8
07 00 0A      CONSTANT_Class
              类引用
              name_index = #10


#9
0C 00 0B 00 0C
              CONSTANT_NameAndType
              名称与类型描述
              name_index = #11
              descriptor_index = #12


#10
01 00 10
6A 61 76 61 2F 6C 61 6E 67 2F 53 79 73 74 65 6D
              CONSTANT_Utf8
              "java/lang/System"


#11
01 00 03
6F 75 74
              CONSTANT_Utf8
              "out"


#12
01 00 15
4C 6A 61 76 61 2F 69 6F 2F 50 72 69 6E 74 53 74 72 65 61 6D 3B
              CONSTANT_Utf8
              "Ljava/io/PrintStream;"
              字段类型描述符


#13
01 00 0B
48 65 6C 6C 6F 20 4A 61 76 61
              CONSTANT_Utf8
              "Hello Java"


#14
08 00 0E
              CONSTANT_String
              字符串引用
              string_index = #14
              实际内容:
              #13 "Hello Java"


#15
0A 00 10 00 11
              CONSTANT_Methodref
              方法引用:
              java/io/PrintStream.println


#16
07 00 12
              CONSTANT_Class
              类引用
              name_index = #18


#17
0C 00 13 00 14
              CONSTANT_NameAndType
              方法名称和描述符


#18
01 00 13
6A 61 76 61 2F 69 6F 2F 50 72 69 6E 74 53 74 72 65 61 6D
              CONSTANT_Utf8
              "java/io/PrintStream"


#19
01 00 07
70 72 69 6E 74 6C 6E
              CONSTANT_Utf8
              "println"


#20
01 00 15
28 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56
              CONSTANT_Utf8
              "(Ljava/lang/String;)V"
              方法描述符:
              参数 String
              返回 void


#21
07 00 16
              CONSTANT_Class
              当前类引用


#22
01 00 0B
6C 69 6E 73 68 69 2F 4D 61 69 6E
              CONSTANT_Utf8
              "linshi/Main"


#23
01 00 04
43 6F 64 65
              CONSTANT_Utf8
              "Code"
              字节码属性名称


#24
01 00 0F
4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65
              CONSTANT_Utf8
              "LineNumberTable"
              行号映射属性名称


#25
01 00 04
6D 61 69 6E
              CONSTANT_Utf8
              "main"


#26
01 00 16
28 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56
              CONSTANT_Utf8
              "([Ljava/lang/String;)V"

              方法描述:
              main(String[])
              返回 void


#27
01 00 0A
53 6F 75 72 63 65 46 69 6C 65
              CONSTANT_Utf8
              "SourceFile"


#28
01 00 09
4D 61 69 6E 2E 6A 61 76 61
              CONSTANT_Utf8
              "Main.java"



00 21         access_flags
              类访问标志:
              ACC_PUBLIC
              ACC_SUPER


00 15         this_class
              当前类索引:
              #21 → linshi/Main


00 02         super_class
              父类索引:
              #2 → java/lang/Object


00 00         interfaces_count
              实现接口数量:
              0


00 00         fields_count
              字段数量:
              0


00 02         methods_count
              方法数量:
              2



方法1:构造方法 Main()

00 01         access_flags
              public

00 05         name_index
              #5 → <init>

00 06         descriptor_index
              #6 → ()V


00 01         attributes_count


00 17         attribute_name_index
              #23 → Code

00 00 00 1D  attribute_length


00 01         max_stack
              操作数栈最大深度


00 01         max_locals
              局部变量表大小


00 00 00 05   code_length
              字节码长度 5    注:从这里开始,后面的5位是字节码内容


2A            aload_0
              加载 this


B7 00 01      invokespecial #1
              调用 Object.<init>()


B1            return
              返回



方法2:main(String[])

00 09         access_flags
              public static


00 19         name_index
              #25 → main


00 1A         descriptor_index
              #26 → ([Ljava/lang/String;)V


00 01         attributes_count


00 17         attribute_name_index
              #23 → Code


00 00 00 25   attribute_length


00 02         max_stack


00 01         max_locals


00 00 00 09   code_length
              字节码长度 9   注:从这里开始,后面的9位是字节码内容


B2 00 07      getstatic #7
              获取 System.out


12 0D         ldc #13
              加载字符串 "Hello Java"


B6 00 0F      invokevirtual #15
              调用 PrintStream.println(String)


B1            return



Code内部属性:

00 01         attributes_count


00 18         attribute_name_index
              #24 → LineNumberTable


00 00 00 06   attribute_length


00 01         line_number_table_length


00 00         start_pc


00 03         line_number



类属性:

00 01         attributes_count


00 1B         attribute_name_index
              #27 → SourceFile


00 00 00 02   attribute_length


00 1C         sourcefile_index
              #28 → Main.java

对应:

.class

├── magic
│      CAFEBABE
│
├── version
│      Java17
│
├── constant_pool
│
├── access_flags
│      public
│
├── this_class
│      linshi/Main
│
├── super_class
│      java/lang/Object
│
├── interfaces
│      0个
│
├── fields
│      0个
│
├── methods
│      2个
│      │
│      ├── Main()
│      │
│      └── main(String[])
│
└── attributes
       │
       └── SourceFile
              |
              └── Main.java

每个方法都有自己的 Code 属性,也即有自己的字节码

魔数CAFEBABE

在计算机文件格式中,魔数就是写在文件开头的一段固定字节,用来标识文件类型
魔数的主要作用就是帮助JVM判断这个文件是不是一个合法的.class文件。如果是则继续解析。如果不是,则抛出异常

常见的几种魔数如下:

文件类型 文件头魔数
Java Class CA FE BA BE
PNG图片 89 50 4E 47
PDF 25 50 44 46
ZIP 50 4B 03 04

主版本号、次版本号

主版本号和次版本号的作用是告诉JVM这是哪个java版本,该以哪个版本的方式去运行它。当前JVM支持就继续加载。不支持则抛出异常。

Java的设计理念是向后兼容,所以新版本的JVM往往能兼容旧版本的JVM(的绝大部分功能,因有些库可能过时被删除会受影响),所以JVM可以选择相应版本的运行方式。

Constant Pool(常量池)

Constant Pool 是 class 文件中的一个区域,用来集中保存程序中的常量和符号引用(如字符串、类名、方法名、字段名、类型描述符等),每个条目都有编号。字节码执行时通过这些编号找到对应的信息。

就是给可能需要反复调用的信息,如类名,public等标识符放入常量池,并编号。字节码执行时只需要调用编号就可以了。无需反复存储这些信息。

字段信息

在 JVM 的 .class 文件结构中,字段信息(Field Information)就是描述一个类中成员变量的信息

字段 = 类里面定义的变量。字段信息就是 JVM 用来描述这些变量的数据。

例如:

public class User {

    private String name;

    public int age;

    static String type = "student";

}

其中以下内容都是字段

name
age
type

JVM 加载类时,会读取字段表,知道:

  • 这个类有哪些变量
  • 变量叫什么
  • 类型是什么
  • 权限是什么
  • 有没有额外属性
    一个字段所包含的信息:
private int age;

编译后,字段信息大概为

Field:

access_flags:    访问标志
    ACC_PRIVATE

name_index:      字段名字(其具体内容存在常量池)
    #5

descriptor_index:   字段描述符
    #6

常见访问标志:

标志 含义
ACC_PUBLIC public字段
ACC_PRIVATE private字段
ACC_PROTECTED protected字段
ACC_STATIC static字段
ACC_FINAL final字段
ACC_VOLATILE volatile字段
ACC_TRANSIENT transient字段

常见字段描述符

Java类型 描述符
byte B
char C
double D
float F
int I
long J
short S
boolean Z
void V
String(对象) Ljava/lang/String;

注:字段信息描述“对象有什么字段”,但字段值属于对象实例。

方法信息

方法信息(Method Information)就是 class 文件中用于描述一个方法的所有元数据,告诉 JVM:这个方法叫什么、参数是什么、返回值是什么、权限如何,以及方法的字节码存放在哪里。

假设有两个方法

public class User {

    private int age;

    public void login() {
    }

    public int getAge() {
        return age;
    }
}

那么 methods 中会有:

Method Table

Method 1
    login()

Method 2
    getAge()

每一个 Method 就对应一个 method_info 结构。

一个 method_info是什么样的?

public int getAge() {
    return age;
}

编译后:

method_info

├── access_flags   标志符
│      ACC_PUBLIC
│
├── name_index  方法名字
│      #6
│       │
│       ▼
│    Utf8 "getAge"
│
├── descriptor_index   类型描述符:告诉 JVM 它的类型或参数、返回值信息。
│      #7
│       │
│       ▼
│    Utf8 "()I"   返回int型
│
├── attributes_count      `attributes_count` 表示当前字段、方法或其他结构所包含的属性(Attributes)数量,JVM 会根据这个数量继续读取后面的 `attributes[]`。
│      1
│
└── attributes
       │
       ▼
    Code
       │
       ├── max_stack = 1
       │
       ├── max_locals = 1
       │
       ├── code     这部分才是字节码
       │      │
       │      ├── aload_0
       │      ├── getfield #13
       │      └── ireturn
       │
       ├── exception_table
       │      空
       │
       └── attributes
              │
              └── LineNumberTable
}

Code的子项说明

一级子项 含义 作用
max_stack 操作数栈的最大深度 告诉 JVM 执行该方法时,栈最多需要多少个槽位(slot)。JVM 会一次性为该方法栈帧分配足够大的操作数栈。
max_locals 局部变量表的大小 告诉 JVM 该方法需要多少个局部变量槽位,用来存放 this、参数和局部变量。
code 真正的 JVM 字节码指令 JVM 按顺序解释或编译执行这里面的指令,例如 aload_0getfieldireturn
exception_table 异常处理表 记录 try-catch-finally 对应的异常处理范围。当方法抛出异常时,JVM 根据这张表决定跳转到哪个 catchfinally。没有 try-catch 时通常为空。
attributes Code 属性自己的属性表 保存与字节码相关的附加信息,例如 LineNumberTable(源码行号)、LocalVariableTable(局部变量名)、StackMapTable(字节码校验)等。

属性

Attribute(属性)不是类、字段或方法本身的一部分,而是挂载在它们上的"扩展信息块",JVM 用它来存放各种额外信息,使 .class 文件能够在不修改主体结构的前提下不断扩展新功能。

最大的优点就是:

扩展性。

例如:

Java 5 增加:

Annotation

不用修改:

method_info

只新增:

RuntimeVisibleAnnotations Attribute

Java 8 增加:

MethodParameters

继续新增 Attribute。

Java 14 增加:

Record

继续新增:

Record Attribute

整个 class 文件格式几乎不用改。


第二阶段:Class 文件与字节码

学习目标

学会阅读 JVM 指令。

需要掌握

JVM指令

例如

操作数栈

实践

观察:

分别生成什么字节码。

答题区

算数运算指令

算术运算指令负责对操作数栈中的数据进行计算,并将计算结果重新压回操作数栈。

注意,它们只操作操作数栈不会直接访问局部变量表

算术运算常见指令

int long float double 含义
iadd ladd fadd dadd 加法
isub lsub fsub dsub 减法
imul lmul fmul dmul 乘法
idiv ldiv fdiv ddiv 除法
irem lrem frem drem 取余
ineg lneg fneg dneg 取负

第一步

iload_1
局部变量表

slot1 = 10

↓

操作数栈

10

第二步

iload_2
操作数栈

20 ← 栈顶
10

第三步

iadd

JVM 会:

value2 = pop()
value1 = pop()

push(value1 + value2)

因此:

pop → 20

pop → 10

计算

10 + 20

push 30

结果:

操作数栈

30

对象创建

对象的创建实际上会经过 "申请内存 → 调用构造方法 → 保存引用" 三个阶段,因此通常需要 4~5 条字节码指令 协同完成。

User user = new User();

编译后的字节码为

0: new           #2
3: dup
4: invokespecial #3
7: astore_1

执行流程如下:

             new User()

                 │
                 ▼
        new 指令申请对象内存
                 │
                 ▼
      操作数栈: object reference
                 │
                 ▼
             dup 复制引用
                 │
                 ▼
      invokespecial 调用构造方法
                 │
                 ▼
      astore 保存到局部变量表
  1. new指令
    假设常量池中:
#2 = Class User

那么 JVM 会知道:

创建一个 User 类型对象

new指令的工作流程:
① 根据常量池找到类
② 如果类还没加载,先执行类加载

JVM 并不会在程序启动时一次性把所有 .class 文件都读进内存,而是在第一次真正需要某个类时,才把它加载到内存中。

③ 在堆中申请内存
④ 所有字段赋默认值

例如:

class User{
    int age;
    String name;
}

刚创建时:

Heap

User Object

age = 0
name = null

注意:这里构造函数还没有执行!
随后 JVM 会把对象引用压入操作数栈。例如:

操作数栈

┌──────────────┐
│ ref(User@01) │
└──────────────┘

这里只是一个引用(可以理解为指向堆对象的地址),不是对象本身。

  1. dup指令
    虽然已经有了一个引用了,但后面的构造方法需要一个对象引用,而最后还要把这个引用赋值给变量。

所以,只有一个引用是不够的,还需要再复制一份。

dup 复制的是"对象引用(Object Reference)",也就是指向堆中对象的引用,而不是常量池中的引用。

执行前:

操作数栈

ref

执行:

dup

得到:

操作数栈

ref
ref

现在有两个引用了。
对于绝大多数对象创建场景,两个引用就够用了。 因为整个对象创建过程中,真正需要长期保留引用的地方只有两个

  • 一个给构造方法 <init> 使用;
  • 一个留给后续字节码继续使用(例如赋值、作为表达式结果等)。

注:<init> 会消费(pop)一个对象引用,并且由于 <init> 返回类型为 void,不会将引用重新压回操作数栈,因此需要事先使用 dup 保留另一份引用供后续指令使用。
在执行init之前,该对象(Object)不能作为普通对象使用。而init因为消费了一份引用,所以可以直接让类进行初始化,即另一份引用因为指向的都是同一份对象,所以状态也会发生改变。变为已初始化状态。

  1. invokespecial (调用特殊方法:指构造方法)

invokespecial 会消费(pop)操作数栈顶的一份对象引用,将它作为新栈帧中 slot0this 传递给构造方法(或其他实例方法)。方法执行结束后,这份引用不会返回调用方,因此如果后续还需要使用该对象引用,就必须提前保留(如使用 dup)。

关于this的解释:

this 是指向当前对象的引用,例如在 user.printName() 中,方法内部的 this 就是 user 这个对象。

例如:

User user = new User("Tom");
user.printName();

JVM 实际上可以理解为:

printName(user);

也就是说:

this

实际上就是:

user

只是 Java 编译器帮你隐藏了这个参数。

  1. astore 保存到局部变量表

astore 会将操作数栈顶的对象引用弹出(pop),保存到局部变量表的指定槽位(slot)中,以便后续代码可以随时通过 aload 再次取出使用。

对象创建完成后,操作数栈中还剩下一份引用:

操作数栈

ref(User)

但是,操作数栈是临时的

方法继续执行时,新的指令会不断:

  • push 新数据
  • pop 旧数据

如果不把引用保存起来,很快就会丢失。

所以 JVM 需要执行:

astore_1

把它保存到局部变量表。

方法调用

5种invoke指令

指令 调用对象 是否多态
invokevirtual 普通成员方法
invokespecial 构造、private、super
invokestatic static方法
invokeinterface 接口方法
invokedynamic 动态调用 JVM运行时决定

方法调用大体可以总结为以下步骤,但其中各个方法调用的细节又各不相同

invoke*
      │
      ▼
解析常量池中的方法符号引用
      │
      ▼
确定真正的方法
      │
      ▼
创建新的栈帧
      │
      ▼
参数复制到局部变量表
      │
      ▼
PC跳转到目标方法
      │
      ▼
执行方法
      │
      ▼
return
      │
      ▼
弹出栈帧
      │
      ▼
恢复调用者

invokevirtual(最常见)普通方法的调用

user.sayHello();

编译后

aload_1    读取对象创建时保存到局部变量区的引用
invokevirtual User.sayHello:()V    实际调用函数。具体调用过程见下图

执行流程:


                 invokevirtual #18
                        │
         ┌──────────────┴──────────────┐
         │                             │
         ▼                             ▼
读取运行时常量池                 读取对象引用(reference)
得到 Methodref                      │
         │                          ▼
         ▼                   去堆中找到对象
方法解析(Resolution)                │
Methodref → Method                 ▼
(Animal.run)               读取 Klass Pointer
         │                          │
         │                          ▼
         │                    得到真实类型
         │                        Dog
         └──────────────┬──────────────┘
                        ▼
          虚方法查找(Virtual Lookup)
      根据 (Method + Klass) 找到 Dog.run
                        │
                        ▼
                   创建栈帧
                        │
                        ▼
                   执行 Code[]

这里最重要的是:

真正执行哪个方法,是运行时决定的。 例如:

Animal a = new Dog();

a.run();

从源码看见的是

Animal.run()

但是运行的时候对象其实是Dog,因此执行Dog.run()
这就是

动态绑定(Virtual Dispatch)

所以叫

invokevirtual

方法解析
示例:

user.sayHello();

class 文件里面实际上只有:

invokevirtual #18

而常量池第18项:

#18 = Methodref
        Class User
        Name sayHello
        Descriptor ()V

没有

  • 方法在哪里?
  • 方法地址是多少?
    只有
  • 类名
  • 方法名
  • 描述符
    这就是符号引用(Symbolic Reference)

方法解析,就是根据这个符号引用,去方法区(Metaspace) 找到User.class里面的方法。

User

Method[]

0 main()V

1 sayHello()V   这里就是想要实际调用的目标方法

2 eat()V

JVM就得到了Method*(可以理解成 HotSpot 内部 Method 结构的指针。)这是一个直接引用。
以后就不用再根据符号引用来绕圈子找了

而方法区里面就含有方法的描述与实际可执行的字节码,如:

User

Method sayHello

名字

描述符

访问权限

Code:     Code里含有字节码
    0:getstatic
    3:ldc
    5:invokevirtual
    8:return

虚方法查找过程

根据虚方法表来查找真实的对象的方法

当前类(指Dog.class)
    │
    ▼
有没有这个方法?
    │
    ├────有────► 返回
    │
    ▼
没有
    │
    ▼
父类
    │
    ├────有────► 返回
    │
    ▼
没有
    │
    ▼
继续父类

虚方法表结构

Dog

vtable

0 ─────► Method(Dog.run)

1 ─────► Method(Animal.eat)   注:这里因为Dog没有eat方法,但是父类Animal有,直接覆盖

2 ─────► Method(Dog.sleep)

创建栈帧过程说明
在 JVM 中,每个线程都有一个 Program Counter Register(程序计数器)
它的作用只有一个: 记录当前线程下一条将要执行的字节码指令的位置。 值得注意的一点是当前线程

创建栈帧过程如下图示例

main()

PC = 0
 ↓
PC = 1
 ↓
PC = 2
 ↓
...
 ↓
PC = 6 (invoke)   遇到invoke指令时

──────────── 调用方法 ────────────

创建 add() 栈帧,PC去执行具体方法,执行完后再继续执行主程序

PC = add:0
 ↓
PC = add:1
 ↓
PC = add:2
 ↓
PC = add:3 (return)

──────────── 返回 ────────────

弹出 add() 栈帧

PC = main:9   从main:9开始是因为 invokestatic 这一条指令本身占 3 个字节,和add有几条指令没有关系。add是一个全新的栈了
 ↓
继续执行 main()

invokespecial

对应private、super方法、构造函数
这一条指令不会发生多态。

例如

class A{

    private void test(){}

    public A(){}
}

对应都是

invokespecial

包括

new A()

private方法

super.xxx()

例如

super.run();

JVM 不会去找子类。

直接调用父类。

当前类
    │
    ▼
父类的方法

因此它叫

special

意思就是

特殊规则调用。

invokespecial

JVM 用来执行“明确指定目标”的方法调用,包括构造方法 <init>、父类 super 调用、private 方法调用。它不会进行多态动态分派,而是直接调用指定的方法。

invokespecial
        |
        ↓
常量池中的方法引用
        |
        ↓
确定的 类 + 方法
        |
        ↓
直接执行

invokevirtual

你说:

找张三的电话

但是有很多张三。

于是:

先看当前人的身份
      |
      ↓
找到对应张三

动态决定。

invokespecial

你说:

我要打给“通讯录第5页第3行的张三”

位置已经确定。

直接拨。

invokestatic

该指令适用于静态方法
例如

Math.max(a,b);

编译后

invokestatic Math.max

静态方法属于,不是对象,因此没有this,也不用读取对象。直接根据类找到方法。

Math
   │
   ▼
max

速度也最快。

invokeinterface

例如

// 这里的Runnable是接口,MyRunnable是实现了Runnable接口的类
Runnable r = new MyRunnable();

r.run();

源码只知道

Runnable

真正对象

MyRunnable

因此 JVM 要

接口
     │
     ▼
对象真实类型
     │
     ▼
实现类
     │
     ▼
run()

所以

invokeinterface

也属于动态绑定。

invokedynamic

这是 Java 7 新增的。

例如

Runnable r = ()->{
    System.out.println("Hello");
};

以前 JVM 并不知道 Lambda 是什么。

后来加入

invokedynamic

第一次执行时:

invokedynamic
      │
      ▼
Bootstrap Method
      │
      ▼
生成真正的方法
      │
      ▼
缓存CallSite

以后再次调用,就直接使用已经链接好的目标方法,而不需要重新建立链接。它也是 Java Lambda、方法引用等现代语言特性的基础。

返回指令

返回指令的核心作用总结:

  • 结束当前方法的执行
  • 如果方法有返回值,将返回值从当前栈帧的操作数栈传递到调用者栈帧的操作数栈
  • 销毁当前方法对应的栈帧(局部变量表、操作数栈等随之释放)
  • 恢复调用者的执行现场(包括程序计数器),让调用者从方法调用指令的下一条字节码继续执行。(可以结合上一条方法调用来看)
    返回指令会依次完成以下任务
① 当前方法执行结束
        │
        ▼
② 如果有返回值
   放入调用者操作数栈
        │
        ▼
③ 当前栈帧出栈
        │
        ▼
④ PC寄存器恢复到调用者
        │
        ▼
⑤ 调用者继续执行下一条字节码

返回指令不仅仅是"return",它真正做的是恢复调用现场。

JVM中返回指令的种类

返回值类型 字节码
int、boolean、byte、char、short ireturn
long lreturn
float freturn
double dreturn
引用(Object、String、数组...)
注:这里返回的不是整个对象,而是对象引用
areturn
void return

为什么 int、boolean、char 共用 ireturn?
原因是:在 JVM 内部,它们统一按照 int 处理。

例如

boolean flag = true;
char c = 'A';
short s = 10;
byte b = 2;

进入操作数栈以后实际上都是:

int

返回值是如何传递给调用者的?

示例:

int c = add(3, 5);

执行流程:

add()
计算
↓
8
↓
ireturn
↓
main操作数栈
↓
istore
↓
局部变量表(c)

也就是说,返回指令负责把返回值放回操作数栈,而不是直接写入局部变量表。局部变量表的写入是由istore指令实现的。

为什么JVM采用栈式计算机模型?

JVM 采用栈式计算机模型,主要有三个原因:

  1. 实现简单。 栈式指令集无需考虑寄存器分配,使 javac 生成字节码以及 JVM 解释执行都更加简单。
  2. 字节码更加紧凑。 栈式指令无需编码寄存器编号,只需描述压栈、出栈和运算,因此字节码通常更加紧凑,.class 文件占用的存储空间更小。
  3. 跨平台性更好。 不同 CPU 的寄存器数量和指令集各不相同,而栈式字节码只描述运算逻辑,不依赖任何具体硬件。JVM 在不同平台上分别将栈式字节码翻译成本地机器码,从而实现 Java 的"一次编译,到处运行"。

为什么JVM不采用寄存器模型?

JVM 不采用寄存器模型,并不是因为寄存器性能不好,而是因为栈模型更符合 JVM 的设计目标

  • 编译器更简单:无需解决复杂的寄存器分配问题。
  • 字节码更紧凑:指令无需携带寄存器编号,便于存储和传输。
  • 跨平台性更好:字节码不依赖任何具体 CPU 的寄存器结构,由 JVM 在不同平台完成映射。
  • 符合当时的技术背景:1995 年的 Java 以解释执行为主,栈式虚拟机更容易实现。

最后需要强调一点:JVM 字节码采用栈模型,并不意味着运行时真的一直依赖栈。 在 HotSpot 等现代 JVM 中,JIT 编译器会把热点代码优化成高效的本地机器码,并充分利用底层 CPU 的寄存器,因此既保留了栈式字节码的跨平台和简洁优势,又获得了接近原生代码的执行性能。

实践部分

一、if 语句

例如:

if (score >= 60) {
    ...
}

对应的核心字节码:

iload_1                          # 将局部变量表中第1个int变量(score)压入操作数栈
bipush        60                 # 将整数60压入操作数栈
if_icmplt    20                  # 弹出栈顶两个int进行比较,如果 score < 60,则跳转到字节码20处
...
goto          30                 # 无条件跳转到字节码30处

常见 if 比较指令

ifeq                           # 弹出一个值,为0则跳转
ifne                           # 弹出一个值,不为0则跳转
iflt                           # 弹出一个值,小于0则跳转
ifle                           # 弹出一个值,小于等于0则跳转
ifgt                           # 弹出一个值,大于0则跳转
ifge                           # 弹出一个值,大于等于0则跳转

if_icmpeq                      # 弹出两个int,相等则跳转
if_icmpne                      # 弹出两个int,不等则跳转
if_icmplt                      # 弹出两个int,小于则跳转
if_icmple                      # 弹出两个int,小于等于则跳转
if_icmpgt                      # 弹出两个int,大于则跳转
if_icmpge                      # 弹出两个int,大于等于则跳转

goto                           # 无条件跳转到指定字节码位置

二、while / for 循环

例如:

for (int i = 0; i < 3; i++) {
    ...
}

对应核心字节码:

iconst_0                       # 将整数0压入操作数栈
istore_1                       # 将栈顶保存到局部变量表1(i)

iload_1                        # 将i压入操作数栈
iconst_3                       # 将3压入操作数栈
if_icmpge     40               # 如果 i>=3,则跳出循环

...                            # 循环体

iinc          1,1              # 将局部变量表1(i)直接加1

goto          4                # 跳回循环判断位置

循环中常见指令

iload                          # 读取循环变量
istore                         # 保存循环变量

iinc                           # 局部变量直接加减(效率最高)

if_icmpxx                      # 判断是否继续循环

goto                           # 跳回循环开始继续执行

for 和 while 在 JVM 中本质完全一样,都是「条件判断 + goto 跳转」。


三、switch

例如:

switch(level){
    case "A":
        ...
        break;
    case "B":
        ...
        break;
    default:
        ...
}

对于整数 switch:

iload_1                         # 将switch表达式压入操作数栈

tableswitch                     # 根据整数值直接跳转到对应case

... case0 ...

goto                            # break

... case1 ...

goto

default ...

如果 case 不连续,则生成:

lookupswitch                    # 根据(key,value)查找对应case并跳转

switch 常见指令

tableswitch                     # 连续整数case,高效索引跳转

lookupswitch                    # 不连续整数case,采用键值查找跳转

goto                            # break本质就是goto

一个容易误解的地方

很多人认为 tableswitch 一定比 lookupswitch。从理论上讲确实如此,因为它是 O(1) 查找;但在实际 Java 程序中,两者的性能差异通常非常小。javac 已经会根据 case 的分布自动选择更合适的实现,因此几乎没有必要为了性能去刻意调整 switch 的写法。理解它们的区别,更重要的是帮助你阅读和理解 JVM 字节码,而不是手动优化 switch


四、方法调用

例如:

int result = add(10,20);

对应字节码:

bipush        10                # 把整数10压入操作数栈

bipush        20                # 把整数20压入操作数栈

invokestatic  #47              # 调用静态方法add(int,int),弹出栈顶两个参数,执行方法,返回值重新压入操作数栈

istore_3                       # 将返回值保存到局部变量表3(result)

第三阶段:类加载机制(Class Loading)

学习目标

理解 JVM 如何把 .class 加载到内存。

需要掌握

类生命周期

ClassLoader

双亲委派模型

static

理解:

答题区

类生命周期

        .class 文件
             │
             ▼
┌─────────────────────────────┐
│ 1.Loading(加载)            │
│ 将.class加载到JVM            │
└─────────────────────────────┘
             │
             ▼
┌─────────────────────────────┐
│ 2.Linking(连接)            │
│ 让Class变成"可运行状态"      │
│                             │
│ Verification(验证)         │
│ Preparation(准备)          │
│ Resolution(解析)           │
└─────────────────────────────┘
             │
             ▼
┌─────────────────────────────┐
│3.Initialization(初始化)    │
│执行静态变量赋值和static代码块 │
└─────────────────────────────┘
             │
             ▼
┌─────────────────────────────┐
│4.Using(使用)              │
│创建对象、调用方法           │
└─────────────────────────────┘
             │
             ▼
┌─────────────────────────────┐
│5.Unloading(卸载)          │
│Class对象被GC回收            │
└─────────────────────────────┘

但实际上,更建议理解为:

Class 文件 → JVM中的Class对象 → 可以创建对象 → 被程序使用 → 被JVM回收

Loading(加载)

它的任务只有一句话: 把磁盘中的 .class 文件加载到 JVM 内存中,生成对应的 Class 对象。

例如:

Person p = new Person();

第一次使用 Person 时:

磁盘
 ┌──────────────┐
 │ Person.class │
 └──────────────┘
        │
        │ ClassLoader
        ▼
JVM
 ┌─────────────────────┐
 │ Person.class信息     │
 │ 字段                │
 │ 方法                │
 │ 常量池              │
 │ Class对象           │
 └─────────────────────┘

关于ClassLoader的作用下面有详细介绍。简单来说就是告诉JVM去哪里找Class文件

这一阶段主要完成:

  • 找到 class 文件
  • 读取二进制字节流
  • 创建对应的 Class 对象
  • 放入方法区(JDK8以后实际存储在 Metaspace)
    此时创建的是类的元数据结构
.class文件
      |
      | ClassLoader
      ↓

JVM内部Class对象

{
    类名: Demo

    常量池:
        "count"
        "name"

    字段描述:
        static int count
        String name

    方法:
        main()
        <init>()

}

注意:这里只是把类读进来了,还没有开始进行初始化

注 :这里只关注static变量和方法。而实例变量则被忽略

例如:

class Person {

    String name;

}

注意:

name

不是 static。所以 Preparation 不处理它。因为实例变量属于对象,不是。真正发生:

new Person();

的时候:

堆:

Person对象

name = null

这个默认值来自:

对象创建过程

而不是类加载过程。

Linking(连接)

加载完成以后,JVM 并不能马上使用这个类。还需要进行"连接(Linking)"。

连接可以理解成:检查一下这个类是否合法,并做好运行前的准备。

连接又分三步。

1.Verification(验证)

这是JVM的第一道安全检查。目的是:确保Class文件没有被篡改,并且符合JVM规范

例如,检查

  • 是不是CAFEBABE
  • 版本是否受支持
  • 当前JVM是否支持
  • 字节码是否合法(例)
  • 类型是否合法
  • 方法是否合法
  • 指令是否合法
  • 是否越界
  • 是否非法访问 private
    字节码合法检查示例:
iconst_1
iadd

如果调用iadd指令,但是栈里只有一个整数,就会报如下异常,验证失败。

StackMap Error

在这一阶段如果失败会抛出如下 异常:

java.lang.VerifyError

因此: Verification 的作用就是 保证 JVM 不会执行非法字节码。

2.Preparation(准备)

Preparation(准备)阶段的任务是:为类变量(static变量)分配内存,并赋默认值。

例如:

public class Demo {

    static int a = 10;

    static String name = "Tom";
}

Preparation 时:

a = 0

name = null

注意:不是

a = 10

因为:

10

属于程序员写的初始化值。

JVM 现在只是:

给变量腾位置。

所以:

static int  ->0
static boolean  ->false
static long  ->0L
引用  ->null

这就是:默认值(Zero Value)

3.Resolution(解析)

这个阶段的任务是:把常量池里的符号引用替换成直接引用。

类里面:

Person p;

字节码中保存的是:

Person

并不是内存地址。

这叫:Symbolic Reference(符号引用)

例如:

java/lang/String
println:(Ljava/lang/String;)V

这些都是名字。

解析阶段:

JVM 去找java/lang/String真正对应哪个Class对象,找到以后Class* 记录下来,以后在调用String就不用再查一次了。

所以Resolution就是把"名字"变成"真正的对象地址"。

Initialization(初始化)

初始化才是真正开始执行 Java 代码。
本阶段的主要任务是:执行类初始化方法 <clinit>()

注:Initialization 阶段初始化的是 类变量(static变量)和静态代码块

例如:

class Demo {

    static int a = 10;

    static {
        System.out.println("Hello");
    }
}

编译后:

static {
    a = 10;

    System.out.println("Hello");
}

都会放进:

<clinit>()

JVM 执行:

<clinit>()

于是:

a = 10

然后打印出Hello
所以:Initialization 完成以后a0变成了10

内容 Initialization处理吗
static变量
static代码块
static方法
实例变量
构造方法

Using(使用)

初始化结束以后。

类终于可以正常工作了。
类初始化完成后,可以被主动使用,包括创建对象、调用静态方法、访问静态变量。

例如:

new Demo();

或者:

Demo.test();

或者:

Demo.a

所有:

  • 创建对象
  • 调用实例方法
  • 调用静态方法
  • 访问字段
    都属于:
Using

这一阶段通常持续整个程序运行期间。

Unloading(卸载)

最后,如果 JVM 发现这个 Class 已经没人用了。

例如:

  • ClassLoader 被回收
  • Class 对象没有引用
  • 所有实例都没有引用

那么:

Class对象

也可以被 GC。

这个过程就是:

Unloading

例如:

PluginClassLoader
        │
        ▼
 Plugin.class

插件卸载:

PluginClassLoader = null

GC:

Plugin.class

一起释放。

需要注意的是:

只有类加载器(ClassLoader)可以被回收时,它加载的类才有机会被卸载。

因此,在普通的 Java 应用中,由于应用程序类通常由应用类加载器(Application ClassLoader)加载,而这个类加载器会一直存活到 JVM 退出,所以这些类一般不会被卸载。类卸载更多发生在热部署、OSGi、Tomcat、插件框架等使用自定义类加载器的场景。

ClassLoader

ClassLoader就是负责完成Loading阶段的人。

JVM启动时:

硬盘

Demo.class


JVM

???

JVM还不知道Demo.class在哪里。所以需要ClassLoader告诉 JVM去哪里找Class 文件

然后:

读取class文件
解析class文件
创建Class对象

这整个过程就是Loading

ClassLoader是怎么做的?

Person p = new Person();

第一次执行时JVM发现:

Person.class还没有加载

于是请求:

ClassLoader

ClassLoader:

第一步:

寻找:

Person.class

可能位置:

项目target/classes

JDK rt.jar

第三方jar包

网络

数据库

找到以后,读取:

二进制字节流

例如:

CA FE BA BE
00 00
00 3D
...

然后调用:

JVM内部方法:

defineClass()

生成:

java.lang.Class<Person>

对象。


流程:

Person.class文件

       |
       |
       ↓

ClassLoader

       |
       |
       ↓

defineClass()

       |
       |
       ↓

JVM方法区

       |
       |
       ↓

Class<Person>对象

这就是 Loading。

Java默认有3个ClassLoader

1.Bootstrap ClassLoader(启动类加载器)

负责加载Java核心类。

例如:

java.lang.String
java.lang.Object

位置:

JAVA_HOME/lib

例如:

String.class

由它加载。

2.Platform ClassLoader(平台类加载器)

加载JDK平台模块。

例如:

java.sql
java.xml
3.Application ClassLoader(应用类加载器)

加载你自己写的代码。

例如:

target/classes/User.class

你的:

User user = new User();

通常就是它加载。

三者关系
                 Bootstrap 
                     |  
                     ↓
              Platform ClassLoader
                     |
                     ↓
            Application ClassLoader
                     |
                     ↓
               User ClassLoader

User ClassLoader不是创建User对象的,它只是负责把 User.class 加载成 JVM 中的 Class 对象。

真正创建 User 对象的是 `new User()`,发生在类加载完成之后。

这种结构叫:

双亲委派模型(Parent Delegation Model)

什么是双亲委派机制?

一个类加载器收到加载请求后,不会自己立即加载,而是先把请求交给自己的父加载器;父加载器无法加载时,子加载器才尝试加载。

为什么需要双亲委派?

先看一个问题。假设你的项目里面有:

package java.lang;

public class String {

}

你自己写了一个假的:

MyProject
 |
 └── java
      |
      └── lang
           |
           └── String.class

然后:

String s = new String();

如果 JVM 允许你的 ClassLoader 直接加载:
那么:

你的String.class

       ↓

JVM加载

       ↓

替换java.lang.String

JVM核心类被你的类给替换了。

可能导致:

  • 安全漏洞
  • 类型混乱
  • JVM崩溃

所以 JVM 规定:

核心类必须优先由系统类加载器加载。

这就是双亲委派产生的原因。

Java中的ClassLoader层级

                Bootstrap ClassLoader
                       ↑
                       |
                Platform ClassLoader
                       ↑
                       |
             Application ClassLoader
                       ↑
                       |
              自定义 ClassLoader

双亲委派机制的意义

  1. 防止核心类被替换
  2. 保证类的唯一性:JVM判断一个类是否相同,不只看类名,而是类名 + ClassLoader
  3. 避免重复加载:整个JVM只应存在一个java.lang.Object(举例)如果没有双亲委派机制,可能被三个类加载器同时加载,产生三个Object 类,整个类型系统就会混乱。

加载(Loading) 负责把 .class 文件读入 JVM 并创建 Class 对象;
连接(Linking) 负责验证类的合法性、为静态变量分配内存并将符号引用解析为直接引用,使类达到可运行状态;
初始化(Initialization) 执行 <clinit>() 方法,为静态变量赋程序员指定的初始值并执行静态代码块;
随后进入使用(Using) 阶段,程序可以创建对象、调用方法和访问成员;
最后,当加载该类的 ClassLoader 被回收且类不再被引用时,类进入卸载(Unloading) 阶段,其元数据会被 JVM 回收。

特殊的final static常量

一般的static常量会在Preparation(准备)阶段被创建,并在Initalization(初始化)阶段被赋上初始值。

final static常量则有所不同,因为编译器知道它永远不会改变,于是就直接把值放入常量池。例如

static final int MAX = 100;
System.out.println(Config.MAX);

编译后类似:

System.out.println(100);

而不是读取Config.MAX。所以运行时根本不需要加载Config类。
这是因为final static具有编译期确定,且永远无法改变的特点。JVM这样设计可以减少运行时的查找

但是,不是所有的final static都是编译期常量
例如:

public static final String NAME = new String("Tom");
public static final int RANDOM = getValue();
static final Integer C = 10;

这些都不是编译期常量,因为值需要在运行期计算

判断一个变量是不是编译期常量,必须满足三个条件

  1. static final
  2. 基本类型或者String
int
long
double
boolean
char
String
  1. 赋值必须编译期确定
static final int A = 10;//✅
static final String S = "hello";//✅
static final int B = new Random().nextInt();//❌
static final Integer C = 10;//❌

第四阶段:运行时数据区(Runtime Data Area)

学习目标

知道程序运行时,数据都存放在哪里。

需要掌握

程序计数器(Program Counter Register)

Java虚拟机栈(JVM Stack)

本地方法栈(Native Method Stack)

堆(Heap)

方法区(Method Area)

答题区

程序计数器

问题 答案
程序计数器作用 记录当前线程下一条要执行的字节码地址
为什么需要它 支持程序顺序执行和线程切换后恢复
为什么线程私有 每个线程执行位置不同,必须独立保存
存储内容 字节码指令地址
是否会OOM 不会
native方法 PC为空

Java虚拟机栈(JVM Stack)

组成 作用
栈帧 一个方法执行时的内存结构
局部变量表 保存方法参数和局部变量
操作数栈 字节码计算的临时空间
动态链接 将符号引用解析成真实方法地址
返回地址 方法执行结束后回到哪里继续执行

注:动态链接在当前语义中不是指动态链接这个过程,而是当前方法对运行时常量池的引用这个数据结构。
返回地址是一个实际的数据,不是过程。

栈帧

栈帧是方法运行时的基本单位,一个方法对应一个栈帧。

栈帧生命周期

创建

由方法调用来创建:

invokevirtual
invokestatic
invokespecial
invokeinterface

执行时:

创建栈帧
↓
压入虚拟机栈
↓
执行方法
销毁

有两种方式销毁栈帧

  1. 方法正常返回

例如:

return;
ireturn;
其他return指令

执行return指令时,栈帧出栈,并将返回结果(如果有)压入原操作数栈,程序计数器跳转到预定位置,继续执行下一条指令

  1. 异常返回

例如:

throw new Exception();

没有找到异常处理:

栈帧被异常弹出
线程结束

局部变量表(Local Variable Table)

保存方法中的

  • 参数
  • 局部变量
  • 对象引用
    其本质是一个数组结构。用slot保存变量

例如:

public void test(int a,double b){
    int c = 10;
}

局部变量表

slot编号

0     this   //普通方法隐藏传入this。static方法不传入this
1     a
2-3   b(double)
4     c

在jvm设计中

int
float
reference
boolean
char
byte
short

占1个slot


long
double

占2个slot

注:普通方法隐藏传入this。static方法不传入this

操作数栈(Operand Stack)

JVM执行字节码指令时进行计算的临时工作区。JVM是一种栈式计算机

动态链接(Dynamic Linking)

不是简单的把Java源代码转换成常量池索引

更准确的说:

  • 编译阶段:Java代码中的类、字段、方法引用会被转化成常量池中的符号引用
  • 运行阶段:动态链接负责将这些符号引用解析成为实际的内存地址、方法入口或对象偏移
Java源码
    ↓ javac
字节码 + 常量池符号引用
    ↓ JVM运行
动态链接
    ↓
真实内存地址/方法入口

动态链接是运行时解析过程,不是编译过程。

返回地址

保存:方法执行完成后,应该回到哪里继续执行。
例如:

public static void main(){
    int a=10;
    add();
    System.out.println(a);
}

执行:

main()

执行到:

add();

暂停

创建:

add栈帧

返回地址:
main()中println之前的位置

Native Method Stack本地方法栈

线程
 |
 |---- Java 方法调用
 |        ↓
 |   Java虚拟机栈
 |
 |---- native 方法调用
          ↓
     本地方法栈

什么是Native方法?

Native 方法是由 JVM 之外的代码实现的方法,通常是 C/C++。这个实现可能属于 JVM 本身,也可能属于操作系统或第三方库。

粗略划分为三类

  • 第一类:JVM自己实现的 Native 方法(最常见)
  • 第二类:调用操作系统能力的 Native 方法(如调用摄像头)
  • 第三类:Java调用第三方C/C++库

和普通Java方法对比

普通方法:

public int add(int a,int b){
    return a+b;
}

结构:

.class文件

Code属性

字节码:

iload
iadd
ireturn

JVM执行:

字节码解释器/JIT

Native方法:

public native int hashCode();

结构:

.class文件

method_info

ACC_NATIVE

没有Code属性

执行:

JVM发现native
        |
        ↓
查找JNI绑定
        |
        ↓
执行C/C++函数

Native方法不是“计算机系统自身的方法”,也不是“Java实现的方法”,而是Java提供一个入口,让JVM能够调用外部实现的代码。这个外部代码可能是JVM自己的C++代码,也可能是操作系统或第三方库代码。

什么是本地方法栈?

本地方法栈是 JVM 为执行 Native 方法(本地方法)准备的一块内存区域。

简单理解:

Java 虚拟机栈负责执行 Java 方法,本地方法栈负责执行非 Java 方法。

什么是JNI?

本地方法栈是 JVM 为执行 Native 方法(本地方法)准备的一块内存区域。

简单理解:Java 虚拟机栈负责执行 Java 方法,本地方法栈负责执行非 Java 方法。

示例:

public class Hello {
    public native void sayHello();
}

这里的native告诉JVM:不要找Java字节码,去找外部实现。

然后C语言:

void Java_Hello_sayHello(){
    printf("Hello JNI");
}

运行:

Hello h = new Hello();

h.sayHello();

流程:

Java调用

sayHello()

   ↓

JVM发现native

   ↓

JNI寻找对应C函数

   ↓

执行C代码

   ↓

返回Java

Native方法、JNI、本地方法栈关系

Native方法
    |
    | 通过
    ↓
JNI
    |
    ↓
本地方法栈
    |
    ↓
C/C++代码

对象为什么要放在堆区

1.对象的生命周期普遍无法确定

如果放在栈区中,方法执行结束栈帧就会被销毁。存活时间很短。
但是,返回后可能仍然需要使用这个对象

User u = createUser();

一个对象的生命周期可能是几小时、几天、甚至整个程序运行周期
所以对象不应该跟着方法栈帧被销毁

2.对象需要动态大小

对象所需要的内存空间大小往往不是确定的。

User user = new User();

JVM不知道:

  • User对象多大
  • 有多少字段
  • 是否继承其他类
    对象大小运行时才能确定。

而栈空间更适合intlong这些固定大小的数据。
堆区可以在运行时申请空间,动态扩展。更适合对象

3.方便垃圾回收(GC)

将对象都统一存放在一个位置(堆区)也为了方便垃圾回收。
因为对象的生命周期各不相同。那么这时候就要回收掉已经废弃的对象。

对象集中存放在堆区,而GC周期性扫描这里,回收无引用对象

为什么数组在堆区(Heap)

因为数组的本质也是对象

Java中定义一个数组

int[] arr;

实际上类型:

[ I

代表:int数组对象

所有数组都继承:

java.lang.Object

例如:

int[] arr = new int[10];

等价:

创建一个Array对象

所以:

数组 = 特殊对象

因此放Heap。

堆为什么要分新生代老生代?

如果所有对象都混着放在一起,那么每次GC扫描都需要全部扫描,效率很低。
大多数对象生命周期很短
例如:

for(int i=0;i<100000;i++){
    User u = new User();
}

产生100000个对象,但是99999个马上不用。只有少量对象长期存在。

于是提出:分代思想
把对象按照生命周期分类:

Heap
├── 新生代 Young Generation
│
└── 老年代 Old Generation

什么是新生代

新创建的对象默认进入新生代

new User();
Heap(堆)

└── Young Generation(新生代)
        |
        ├── Eden
        |
        ├── Survivor0
        |
        └── Survivor1

其中:

  • Eden:伊甸园区,新对象出生的地方
  • Survivor0 / Survivor1:幸存区,保存经过 GC 后仍然存活的对象

为什么有两个 Survivor?

为什么不是:

Eden
    |
    ↓
Survivor

而是:

Eden
   |
   ↓
Survivor0
   ↔
Survivor1

原因:

JVM 使用复制算法(Copying Algorithm)

复制算法思想

假设只有一个 Survivor:

Survivor

A
B
C
D

下一次 GC需要删除垃圾:

A 存活
B 死亡
C 存活
D 死亡

如果直接删除会产生内存碎片。

A
C


空洞

所以 JVM 使用两个 Survivor:

假设:

Survivor0

A
B
C
D

GC:

复制存活对象:

Survivor1

A
C

然后:

清空 Survivor0

变成:

Survivor0

空


Survivor1

A
C

下一次:

交换角色:

Survivor1
      |
      ↓

Survivor0

所以两个 Survivor 会轮流作为:

  • From 区
  • To 区

什么是老生代

什么对象进入老年代?

① 长时间存活对象

例如:

User user = new User();

while(true){
    use(user);
}

这个对象一直存在。

经历多次Minor GC后仍然存活,晋升 Old Generation

② 大对象

例如:

byte[] data = new byte[100MB];

这种对象年轻代放不下,可能直接进入老年代。

老生代的GC频率低,但是耗时长

常见GC触发时机

GC类型 主要触发条件
Minor GC Eden空间不足(最常见)
Minor GC 大对象分配失败
Minor GC Survivor空间不足
Major GC 老年代空间不足
Full GC 老年代不足
Full GC Metaspace不足
Full GC System.gc()请求
Full GC JVM退出
名称 范围
Minor GC 新生代
Major GC 老年代
Full GC 整个堆

方法区

方法区是 JVM 运行时数据区的一部分,用于存储类级别的信息

简单理解:

堆存对象,栈存方法执行过程,方法区存类的信息。

例如:

public class User {

    private static int count = 0;

    private String name;

    public void hello(){
        System.out.println("hello");
    }
}

当 JVM 加载 User.class 时:

  • User 类的信息 → 方法区
  • count 静态变量 → 方法区
  • name 字段描述 → 方法区
  • hello() 方法字节码 → 方法区
  • new User() 对象 → 堆

什么是类元信息

类元信息就是描述一个类本身的数据

类元信息来自User.class

加载:

User.class

      ↓ ClassLoader

方法区

+-----------------------+
| User类元信息           |
|                       |
| 类名                  |
| 父类                  |
| 接口                  |
| 字段                  |
| 方法                  |
| 常量池(运行时常量池)    |
| 方法字节码            |
+-----------------------+

静态变量(static变量)

public class Counter {
    static int count = 10;
}

一般来说,static变量放到方法区保存。因为static属于类,而不是对象

方法区:

static变量

这是历史说法。

实际:JDK7:

方法区
    |
    +-- static变量

JDK8以后:

HotSpot:

元空间 Metaspace

    |
    +-- 类元信息


堆

    |
    +-- static变量引用的对象

例如:

static User user = new User();

实际上:

方法区:

User类信息


堆:

User对象

static变量保存对象地址

所以:

静态变量逻辑上属于方法区,具体实现JDK8后有所变化。

常量池(Constant Pool)

分为两种

  • Class文件常量池
  • 运行时常量池(Runtime Constant Pool)

Class文件常量池

也叫:

Constant Pool

它存在于:

.class文件

例如:

public class Test {

    String name="Tom";

}

编译:

Test.class

里面:

constant_pool:

#1 Test
#2 java/lang/Object
#3 name
#4 Tom
#5 String
...

它保存:

  1. 类名
    例如:
java/lang/String
  1. 方法名
    例如:
println
  1. 字段名
    例如:
username
  1. 字符串字面量
    例如:
"hello"
  1. 符号引用

例如:

System.out.println()

编译阶段不知道真实地址:

保存:

java/lang/System
out
println

运行时解析。

运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)就是 JVM 在类加载时,将 .class 文件中的常量池信息加载到内存后形成的运行时数据结构。

但是它不是简单复制一份,而是 JVM 解析、转换后的结构。

类加载后:

.class文件

      ↓

加载

      ↓

方法区

      ↓

运行时常量池

例如:
class文件:

Constant Pool

加载后结构:

方法区(Method Area)

+----------------------+
| User类元信息          |
|                      |
|  类信息               |
|  字段信息             |
|  方法信息             |
|                      |
|  Runtime ConstantPool|
|                      |
+----------------------+

这里的:

Runtime Constant Pool

就是运行时常量池。

字符串常量池(String Pool)

设计目的:复用相同内容的字符串,减少内存浪费。

字符串常量池专门保存需要被共享的String对象
例如:

String a="hello";

执行:

JVM检查:

字符串常量池

有没有"hello"?

如果没有:

创建:

String对象

"hello"

放入字符串池

例如:

String a="hello";

String b="hello";

System.out.println(a==b);

结果:

true

原因:

字符串池:

+---------+
| hello   |
+---------+

     ↑
     |
 a---+
 b---+

两个变量指向同一个对象。


但是:

String a = new String("hello");

不同
这不是一个需要被共享的String对象,因此不会保存在String常量池

过程:

字符串池:

"hello"


堆:

new String对象

所以:

a=="hello"

结果:

false

第五阶段:对象创建

学习目标

理解 new 到底发生了什么。

需要掌握

执行:

Person p = new Person();

发生什么:

对象内存布局

引用

理解:

答题区

对象创建

在执行一段创建对象的Java代码时

Person p = new Person();

会经过以下过程

1.检查Person类是否已经加载

如果还没有加载,会先调用ClassLoader执行类加载过程。详见: 第三阶段:类加载机制(Class Loading)

注意:

new 操作本身会触发类的初始化,但前提是这个类还没有初始化。

如果 Person 已经初始化过,那么后面就不再执行类初始化。

2.JVM在堆中为对象分配一块内存空间

3.JVM将对象的字段初始化为默认值(不是初始值)

4.设置对象头

对象还需要有对象头。

对象头中可能包含:

对象头
├── Mark Word
│   ├── 锁状态
│   ├── GC 信息
│   └── 哈希码等
│
└── Klass Pointer
    └── 指向 Person 的类元数据

不同 JVM、不同压缩指针配置下,对象头的具体结构和大小会有所不同。

可以简单理解为:

对象头记录了这个对象自身的一些运行时信息,以及它属于哪个类。

于是对象大概变成:

Person 对象

┌──────────────────────────┐
│ Object Header             │
│                          │
│ Mark Word                │
│ Klass Pointer            │
├──────────────────────────┤
│ age = 0                  │
├──────────────────────────┤
│ name = null               │
└──────────────────────────┘

5.执行构造方法

执行构造方法,这时字段被赋予了初始值。

String name = "Tom";

6.返回对象引用

最后:

Person p = new Person();

new Person() 创建并初始化完成对象后,会得到一个对象引用

然后把这个引用赋值给局部变量 p

可以理解为:

栈帧
┌───────────────────┐
│ 局部变量表        │
│                   │
│ p ────────────────┼──────┐
└───────────────────┘      │
                           ↓
                       堆中的对象
                  ┌────────────────┐
                  │ Person         │
                  │ age = 18       │
                  │ name = "Tom"   │
                  └────────────────┘

这里需要特别注意:

p 本身是一个引用变量,它不是对象本身。

对象在内存中的布局结构

一个 Java 对象在内存中
┌──────────────────────────────┐
│ Object Header  对象头         │
│ ├─ Mark Word                 │
│ └─ Klass Pointer             │
├──────────────────────────────┤
│ Instance Data  实例数据       │
│ ├─ int age                   │
│ ├─ long id                   │
│ └─ Object name              │
├──────────────────────────────┤
│ Padding      对齐填充         │
└──────────────────────────────┘

Object Header 对象头

Object Header 就是对象头,位于对象的最前面。

它主要包含:

Object Header
├── Mark Word
└── Klass Pointer

如果是数组对象,还会额外有:

Array Object
├── Mark Word
├── Klass Pointer
└── Array Length     数组长度

所以可以先记住:

普通对象 = 对象头 + 实例数据 + 对齐填充

数组对象 = (对象头 + 数组长度) + 实例数据 + 对齐填充

Mark Word

Mark Word 是对象头中非常重要的一部分。

它主要用来保存一些与对象运行状态相关的信息。

例如:

  • 对象的哈希码
  • GC 年龄
  • 锁状态
  • 偏向锁相关信息(取决于 JVM 版本)
  • 线程锁信息
    它的一个重要特点是:

Mark Word 的内容会根据对象当前状态动态变化。是JVM 用来记录对象运行时状态的一块空间。

Class Pointer

Klass Pointer 可以理解为:

指向对象所属 Java 类的元数据的指针。

例如:

class Person {
    int age;
    String name;
}

Person p = new Person();

内存中有:

p
│
▼
Person 对象
┌──────────────────────┐
│ Mark Word            │
├──────────────────────┤
│ Klass Pointer ───────┼─────────┐
├──────────────────────┤         │
│ age = 18             │         ▼
├──────────────────────┤   Person Class 元数据
│ name ────────────────┼──►      │
└──────────────────────┘         │
                                 │
                         方法、字段信息等

Klass Pointer 让 JVM 知道:

这个对象到底属于哪个类?

Instance Data 实例数据

Instance Data 就是对象真正保存的实例字段数据。

例如:

class Person {
    int age;
    long id;
    boolean student;
}

创建:

Person p = new Person();

对象内部可以粗略理解为:

Person 对象
┌──────────────────────┐
│ Mark Word            │
├──────────────────────┤
│ Klass Pointer        │
├──────────────────────┤
│ age                  │  ← int
├──────────────────────┤
│ id                   │  ← long
├──────────────────────┤
│ student              │  ← boolean
└──────────────────────┘

需要注意:

如果字段是引用类型:

class Person {
    String name;
}

对象里面保存的不是整个 String 对象。而是一个引用(reference)

Padding 对齐填充

Padding 就是:为了让对象大小满足 JVM 的内存对齐要求而填充的无意义数据。

例如 JVM 要求对象大小按照:

8 字节对齐

假设一个对象实际占用:

30 字节

那么 JVM 可能填充:

30 + 2 = 32 字节

最终:

┌──────────────────────┐
│ Object Header        │
├──────────────────────┤
│ Instance Data        │
├──────────────────────┤
│ Padding              │
└──────────────────────┘
         ↓
      32 Bytes

Padding 本身不保存业务数据。

它的作用主要是:

让对象的大小满足内存对齐要求,提高内存访问效率,并方便 JVM 管理对象。

对象、引用、地址

对象:真正被创建出来的数据实体。
引用:Java 程序中用于找到、访问对象的抽象变量。
地址:对象在内存中的位置,是底层内存概念。
引用 ≠ 对象;引用 ≠ 必然等于地址。


第六阶段:方法调用

学习目标

理解方法是如何执行的。

需要掌握

栈帧

每个方法调用都会创建:

参数传递

理解:

方法调用指令

答题区

栈帧

每次方法调用时,JVM 会为该次方法执行创建一个栈帧(Stack Frame)
一个栈帧主要包含

栈帧组成 作用
局部变量表 保存方法参数和局部变量
操作数栈 JVM 执行字节码指令时的临时工作区
动态链接 支持当前方法的字节码中对其他方法、字段的符号引用解析
方法返回地址 方法执行结束后,告诉 JVM 应该回到哪里继续执行

参数传递

值传递就是:

调用方法时,把变量中保存的值复制一份,传递给方法的参数。

Java 只有值传递。

对于基本类型,传递的是基本类型值的副本。

对于对象类型,传递的是对象引用值的副本

因为复制后的引用值仍然指向同一个对象,所以可以通过这个引用修改对象的内容,但无法通过修改参数本身,让调用者的引用变量指向另一个对象。

示例1:传递普通基本类型

public static void change(int x) {
    x = 100;
}

public static void main(String[] args) {
    int a = 10;

    change(a);

    System.out.println(a); // 10
}

参数传递过程:
a保存10->change(a)->新建x变量->x保存10->x保存100->x变量销毁->print输出(a) 10

示例2:传递对象

class Person {
    String name;
}

public static void change(Person p) {
    p.name = "Bob";
}

public static void main(String[] args) {
    Person person = new Person();
    person.name = "Alice";

    change(person);

    System.out.println(person.name); // Bob
}
  1. 新建一个Person类型的变量person,保存对于新对象Person()的引用值(此处记为R1)
  2. 通过引用值,找到堆中的实际对象,并将其name字段赋值为"Alice"
  3. 调用change函数,将引用值R1复制出一份R2,将R2赋值给新的变量p
  4. 在change函数内部,根据引用值R2,找到堆中的实际对象,并修改name字段为"Bob"
  5. 最终print打印出的是,通过引用值R1找到的堆中实际对象的name字段->修改之后的Bob

核心点:能达到类似引用传递的效果,是因为复制的值恰好是对象的引用。因此R1和R2指向同一个对象,所以看起来可以通过引用传递修改对象

方法调用invoke

方法调用


第七阶段:Java 内存模型(JMM)

注意:这里的 JMM(Java Memory Model)不是 JVM 内存结构。

学习目标

理解多线程为什么可见、为什么会乱序。

需要掌握

可见性

原子性

有序性

答题区

首先要区分:

  • JVM 内存结构:描述 JVM 运行时有哪些内存区域(堆、栈、方法区等)
  • JMM(Java Memory Model):描述多线程环境下,线程之间如何访问共享变量
问题 原因 解决
可见性 线程缓存不同步 volatile、synchronized
原子性 操作被切割 synchronized、CAS、Atomic
有序性 CPU/编译器重排序 volatile、锁、happens-before

JMM的核心模型

          主内存(Main Memory)

              flag=false

        /                 \

       /                   \

线程1工作内存          线程2工作内存

flag=false             flag=false


每个线程都有自己的:

  • 工作内存
  • CPU缓存
  • 寄存器

共享变量存放在主内存

线程操作变量,不是直接操作主内存。而是:

主内存
   ↓
读取
   ↓
线程工作内存
   ↓
计算
   ↓
写回
   ↓
主内存

JMM 解决三个问题

  • 可见性:一个线程修改变量后,其他线程能不能立即看到
  • 原子性:一个操作能不能被线程切割
  • 有序性:代码执行顺序是不是一定按照编写顺序

可见性

int count=0;

//线程A:
count = 100;

//线程B:
System.out.println(count);

那么,此时线程B看到的是count=0还是count=100?
如果看到旧值,就是不可见。

volatile

volatile 是解决可见性的关键。

例如:

volatile boolean flag=false;

加入 volatile 后,线程修改:

线程1

flag=true

      ↓

立即刷新主内存

其他线程:

读取flag

      ↓

重新从主内存读取

所以:

线程1修改
        ↓
所有线程立即可见

voltile 原理

volatile 主要保证:

1. 写操作立即刷新

普通变量:

线程A

flag=true

↓

工作内存

↓

什么时候写回不知道

volatile:

线程A

flag=true

↓

立即刷新主内存
2. 读操作重新读取

普通:

线程B

读取缓存

volatile:

线程B

重新读取主内存

volatile不能保证原子性

例如:

volatile int count=0;

count++;

在这段代码中,其实线程并不安全。因为从表面上看count++是一步。但实际上分三个步骤

1.读取count

2.count+1

3.写回count

当两个线程同时操作时,如果出现了如下情况,就会引发线程安全问题

线程A:读取0

线程B:读取0

线程A:+1;写入1
线程B:+1;写入1

最终结果就是,count先被线程A写入1,然后又被线程B写入的1给覆盖。最后结果为1,而不是预期的2.

原子性

原子性就是不可分割性

例如count++看起来是一步,其实分为

1.读取count

2.count+1

3.写回count

这样的三步。所以不是原子操作。不符合原子性

synchronized

synchronized是原子性最经典的解决方案
例如:

synchronized(this){
    count++;
}

含义:
同一时间只能有一个线程进入。
线程A:

获得锁

count++

释放锁

线程B:

等待

获得锁

count++

结果:2

synchronized原理

其底层依靠对象监视器 Monitor
在堆中,一个对象内部有Mark Word,其中包含着对象的描述信息(包含Monitor)。其特点是会随着程序运行而动态改变。因此可以通过Monitor来判断锁的情况

Mark Word
   |
   |
Monitor

进入:

monitorenter

退出:

monitorexit

CAS

Compare And Swap 比较并交换。

核心思想:我修改之前,确认别人有没有改过。

例如:

当前count=0

线程A:

读取count==0

准备改成1

执行 CAS(0,1)
即:

如果现在还是0,就修改为1

如果现在已经不是0。
失败
重新尝试

CAS非常重要,很多并发工具底层都是CAS:

例如:

  • AtomicInteger
  • ConcurrentHashMap
  • AQS

Atomic类

例如:

AtomicInteger count =
        new AtomicInteger(0);

count.incrementAndGet();

内部不是:

count++

而是:

CAS循环

类似:

while(true){

    old=count;

    newValue=old+1;


    if(CAS(old,newValue)){
        break;
    }

}

有序性

对于一段代码:

int a=1;
int b=2;

int c=a+b;

程序员认为是从上往下依次执行的。但是实际上,编译器和CPU可能会优化代码:

b=2
↓
a=1
↓
c=a+b

这叫指令重排序

为什么允许重排序?

因为CPU的执行速度很快。如果严格按照代码会有很多等待。例如:CPU等待内存:

读取内存

等待1000ms

而在这期间,CPU可以去执行其他的指令。所以CPU会调整顺序。

重排序引发的问题

经典例子

public class Singleton {

    private static Singleton instance;

    public static Singleton getInstance(){
        if(instance==null){
            synchronized(Singleton.class){
                if(instance==null){
                    instance=new Singleton();
                }
            }
        }

        return instance;
    }
}

例子原意解析:
这个例子是双重检查锁单例(Double Check Locking Singleton),它的目的:

在多线程环境下,保证一个类只能创建一个对象,并且尽量减少加锁带来的性能损耗。

1. 首先理解单例是什么

普通创建对象:

Singleton s1 = new Singleton();
Singleton s2 = new Singleton();

会产生:

堆:

对象1  <--- s1

对象2  <--- s2

两个对象。

但是单例要求:

堆:

对象1

 ↑
 |
instance

s1
s2
s3

所有地方都使用同一个对象。

2. 这个变量是什么?
private static Singleton instance;

它表示:

保存唯一对象的引用。

一开始:

方法区(类变量)

instance
   |
   |
 null

因为还没有创建对象。

3. 第一次检查
if(instance==null)

意思:

有没有创建过对象?

第一次调用:

instance=null

所以进入:

synchronized
4. synchronized做什么?
synchronized(Singleton.class)

意思:

给 Singleton.class 这个对象加锁。

假设两个线程同时调用:

线程A                 线程B

getInstance()         getInstance()

instance==null        instance==null

准备创建对象          准备创建对象

       ↓

       抢锁

只能一个线程进入。

例如:

线程A:

获得锁


线程B:

等待
5.为什么里面还要判断一次

这里是双重检查锁单例的关键点

当线程A和线程B都过了第一个if检查,并且线程A已经获得锁,线程B陷入等待时。

此时,线程A会正常创建对象。
如果没有第二个if检查,等线程A创建对象结束后。线程B因为已经过了第一个if检查,会接着往下执行,也就是会创建第二个对象。此时,就已经破坏了单例的环境

所以必须要进入锁以后再次判断

此时完整流程:
第一次调用:

线程A
instance == null

        ↓
进入锁
        ↓
再次判断
        ↓
new Singleton()
        ↓
instance指向对象
        ↓
释放锁
        ↓
返回对象

第二次调用时:

线程B

instance != null

直接返回

不用加锁

volatile解决有序性

在上面的例子中,instance=new Singleton();实际上分3步执行

1. 分配对象内存

2. 初始化对象

3. 将引用指向对象

正常:

1
2
3

但是重排序:

1
3
2

结果线程B可能看到:

instance != null

但是此时对象还没有初始化。

此时可以通过加上volatile标识符来解决有序性问题
例如:

private volatile static Singleton instance;

volatile不仅保证可见性还保证禁止相关指令重排序

happens-before

这是JMM最核心规则。

意思是如果A happens-before B,那么A操作的结果一定对B可见。

也就是:

A发生在B之前

A的结果一定能被B看到
常见规则
1. 程序顺序规则

同一个线程:

int a=1;
int b=2;

一定是

1 a=1;
2 b=2;

依次执行

2.锁规则

释放锁unlock()之前的操作一定对之后获得锁的线程可见

例如:
线程A:

lock

x = 100

unlock

线程B:

lock

读取x

一定看到

100
3. volatile规则

写:

volatile变量=值

之前的操作:

对之后读取volatile变量的线程可见。

4. 线程启动规则

线程A:

thread.start();

之前修改的数据

线程B:

run()

可见

示例:

public class Test {

    static int value = 0;

    public static void main(String[] args) {

        value = 100;  // 主线程修改

        Thread t = new Thread(() -> {
            System.out.println(value);
        });

        t.start();   // 启动线程
    }
}

因为有该条规则的保障,value在主线程将值改为100println打印的结果一定是100
因为value值的修改发生在线程t.start()之前

如果没有该条规则,执行过程可能是:

  1. 主线程value=100;
  2. value的值在主线程工作内存中发生变化,但是没有刷新到主内存
  3. 然后创建线程t,线程t读取value到自己的工作内存,结果:0
    这就是可见性问题。所以 JMM 规定,调用:
start()

之前的数据修改:

必须对新线程可见。

5.线程结束规则

线程执行结束时:

thread.join();

join之后,其他线程能看到它的所有动作。


第八阶段:垃圾回收(GC)

学习目标

理解对象什么时候会被回收。

需要掌握

判断垃圾

引用类型

垃圾回收算法

GC收集器

答题区

什么是垃圾对象

所谓垃圾:

已经无法被程序继续访问的对象。

例如:

public void test(){
    Person p = new Person();
}

执行过程:

进入test()

栈:
p ---------> Person对象


方法结束


栈:
p消失


堆:
Person对象没有任何引用指向

↓

垃圾对象

因为:

程序无法再访问它

所以可以回收。

判断垃圾的两种方式

JVM历史上主要有两种判断方式:

  1. 引用计数法
  2. 可达性分析法

目前 Java JVM 使用:

可达性分析法

引用计数法(弃用)

思想

给每个对象维护一个计数器,表示:有多少个引用指向这个对象

例如:

Person p = new Person();

对象:

Person对象

引用数量:

1

内存:

栈

p
|
|
↓

堆

Person
count = 1

再增加一个引用:

Person p2 = p;

变成:

p ----\
       \
        ---> Person
       /
p2 ---/

count = 2

如果:

p = null;

那么:

p2
|
↓

Person

count = 1

还不能回收。

如果:

p2 = null;

变成:

Person

count = 0

说明:

没有引用指向它。可以回收。

引用计数法最大的问题:无法解决循环引用

例如:

class A{
    B b;
}

class B{
    A a;
}

依赖关系

a
|
v
A -----> B
^        |
|        |
+--------+

现在即使没有任何外部引用时,他们的count仍都为1 。 因为存在互相引用的关系。结果就是,垃圾无法回收,产生内存泄漏

可达性分析(Reachability Analysis)当下采用的方法

核心思想:

从一些特殊对象开始,如果对象能够沿着引用链找到,就不是垃圾。

如果找不到,就是垃圾。

所谓的特殊对象,也就是GC RootsGC Roots就是垃圾回收的起点。可以理解为,程序认为一定还活着的对象

GC Roots

常见的GC Roots

1.栈中的局部变量

例如:

public void test(){
    Person p = new Person();
}

此时,线程栈中存在p,指向Person对象

2.静态变量

例如

class Test{
    static Person p = new Person();
}

结构

方法区

Test.class

static p
 |
 |
 v

Person对象

static变量属于 GC Root。

3.常量引用

例如:

static final Person P =
        new Person();

常量池中的引用也是 GC Root。

4.正在运行的线程对象

线程本身:

Thread对象

属于 GC Root。

5.JNI引用

例如:

Java 调用 C:

Java对象
    |
 JNI
    |
 C代码

JNI持有的对象引用也属于 GC Root。

JNI:

Java Native Interface(Java本地接口)

它是 JVM 提供的一套机制,用来让:

Java代码调用其他语言(主要是 C/C++)写的本地代码。

可达性分析过程

假设堆

                 GC Roots

                    |
                    |
                    v

                 Object A

                /       \

               v         v

           Object B    Object C


                      

           Object D

分析:

从 GC Roots 出发:

GC Roots
 |
 v
A
 |
 +----B
 |
 +----C

这些:

A B C

都是可达对象。


而:

D

没有任何路径:

GC Roots

      X

      D

所以:

D = 垃圾

引用类型

Java 中对象是否能够被 GC 回收,不只取决于“有没有引用”。

Java 设计了 4种引用强度,强度从高到低依次为:

  1. 强引用(Strong Reference)
  2. 软引用(Soft Reference)
  3. 弱引用(Weak Reference)
  4. 虚引用(Phantom Reference)

它们的区别:

引用越强,对象越不容易被 GC 回收。

引用类型 是否阻止GC 什么时候回收 用途
强引用 没有引用时 普通对象
软引用 部分阻止 内存不足时 缓存
弱引用 不阻止 GC时 缓存、WeakHashMap
虚引用 不阻止 对象回收前通知 资源管理

1.强引用

我们平常写的普通引用,就是强引用

例如:

Person p = new Person();

只要p != null,那么对象就是存活状态

即使内存不足,GC发现强引用也不会回收。除非p == null或引用离开作用域

void test(){
    Person p = new Person();
}

2.软引用(Soft Reference)

软引用:

内存足够时保留对象,内存不足时回收。

Java提供:

SoftReference<T>

示例:

Person person = new Person();

SoftReference<Person> ref =
        new SoftReference<>(person);

内存充足时,GC不会回收。内存不足时,GC会释放软引用对象,腾出空间

使用场景:

  • 缓存

3.弱引用(Weak Reference)

弱引用:

只要发生 GC,就会被回收。

WeakReference<T>

示例:

Person p = new Person();

WeakReference<Person> ref =
        new WeakReference<>(p);

注意:如果同时还有强引用:

Person p = new Person();

那么:对象不会回收。

使用场景:WeakHashMap
Java提供:

WeakHashMap

例如:

Map<Key,Object> map =
        new WeakHashMap<>();

特点:

如果 Key 没有其他引用:

Key对象

↓

GC

↓

自动删除Entry

典型用途:

缓存、监听器管理。

4.虚引用(Phantom Reference)

虚引用是最弱的引用。

Java:

PhantomReference<T>

特点:

无法通过虚引用获得对象。
会在引用对象被回收时向程序发出通知

示例:

Person p = new Person();

PhantomReference<Person> ref =
        new PhantomReference<>(
            p,
            queue
        );

你不能:

ref.get();

因为永远返回:

null

用途:

监听对象什么时候被 GC 回收。

例如:

Person对象

   |
   |
虚引用

   |
   |
ReferenceQueue

示例:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {

    public static void main(String[] args) throws Exception {

        // 创建引用队列
        ReferenceQueue<Person> queue = new ReferenceQueue<>();

        // 创建对象
        Person person = new Person("张三");


        // 创建虚引用
        PhantomReference<Person> phantomReference =
                new PhantomReference<>(person, queue);


        // 删除强引用
        person = null;


        // 手动触发GC
        System.gc();


        // 等待GC通知
        Thread.sleep(1000);


        // 从队列获取虚引用
        PhantomReference<Person> ref =
                (PhantomReference<Person>) queue.poll();


        if (ref != null) {
            System.out.println("对象已经被GC回收");

            // 释放其他资源
            // free native memory...
        } else {
            System.out.println("还没有回收");
        }
    }
}


class Person {

    private String name;

    public Person(String name) {
        this.name = name;
    }

    @Override
    protected void finalize() {
        System.out.println("finalize执行");
    }
}

主要用于:

  • 管理堆外内存
  • 资源释放通知

例如:

DirectByteBuffer

Java对象

    |
    |
    v

本地内存(C)

Java对象被回收后:

通知释放 C 内存。

垃圾回收算法

现在的问题是:

找到垃圾以后,JVM 如何把它清理掉?

主要有四种思想:

  1. 标记-清除(Mark-Sweep)
  2. 标记-整理(Mark-Compact)
  3. 复制算法(Copying)
  4. 分代收集(Generational Collection)
算法 过程 优点 缺点 适合
标记-清除 标记垃圾→删除 简单 碎片多 老算法
标记-整理 标记→移动存活对象 无碎片 移动成本高 老年代
复制算法 复制存活对象 无碎片,快 浪费空间 新生代
分代收集 不同区域使用不同算法 符合对象生命周期 实现复杂 现代JVM

标记-清除算法(Mark-Sweep)

分两个阶段:

第一阶段:标记

找到垃圾对象:

堆:

A对象   存活
B对象   垃圾
C对象   存活
D对象   垃圾


标记:

B ✓
D ✓
第二阶段:清除

删除被标记的对象:

GC前:

[A][B][C][D]


清除:

[A][ ][C][ ]


GC后:

[A][C]

优点

  • 简单
  • 实现成本低
    缺点
  • 缺点1:产生内存碎片

例如GC前:

|A|垃圾|B|垃圾|C|垃圾|D|

清除:

|A|   |B|   |C|   |D|

如果此时需要:

new BigObject();

需要连续空间:

|        BigObject        |

虽然总空闲空间够:

空闲:

2KB + 3KB + 5KB

=10KB

但是:

没有连续10KB

仍然无法分配。

  • 缺点2:效率低
    需要:
遍历所有对象
+
删除垃圾对象

堆越大越慢。

标记-整理算法(Mark-Compact)

标记整理解决碎片问题

第一步:标记

和标记清除一样

|A|垃圾|B|垃圾|C|垃圾|D|
第二步:整理

把存活对象移动到一端

[A][B][C][垃圾][垃圾]

然后清理垃圾所占的空间

优点

  • 解决了内存碎片问题
  • 分配对象效率高
    缺点
  • 需要移动对象。移动对象意味着
    • 修改引用地址
    • 消耗CPU
      适用场景:老年代
      因为老年代对象存活时间长,移动频率低

复制算法(Copying)

复制算法思想:

不在原空间清理,而是把存活对象复制到另一块空间。

假设两个区域:

From区          To区

[对象A]
[垃圾]
[对象B]
[垃圾]

        空

GC:

把存活对象复制:

From区          To区

[对象A]    ---> [对象A]
[垃圾]
[对象B]    ---> [对象B]
[垃圾]

然后:

清空From区

优点

  • 没有空间碎片。因为复制过去的空间自然是连续排列的
    缺点
  • 浪费空间。需要两块内存From区To区。但是实际只能使用一半

适用场景:新生代。

因为新生代对象大部分生命周期很短。

创建很多的新生代对象,GC后大部分都会死亡,只有少部分存活。复制成本低

分代收集算法(Generational Collection)

这是 JVM 实际采用的思想。

注意:

分代收集不是一种新的算法,而是根据对象生命周期不同,组合不同算法。

新生代:采用复制算法
老生代:采用标记-整理标记-删除

GC收集器

上面描述的几种算法,由GC收集器来做为执行者
GC收集器就是JVM中真正负责垃圾回收的模块

不同收集器:

  • 使用不同的垃圾回收算法
  • 适合不同场景
  • 关注不同目标(吞吐量、低延迟、内存占用)
收集器 线程 目标 算法 适合
Serial 单线程 简单稳定 复制+整理 小程序
Parallel 多线程 高吞吐 复制+整理 批处理服务器
CMS 并发 低停顿 标记清除 老服务器(已废弃)
G1 并发+分区 可预测低延迟 复制+整理 现代服务器
ZGC 高度并发 极低延迟 染色指针等 超大堆

Serial 收集器

单线程垃圾收集器。

GC时:

Java线程
    |
    v
暂停

GC线程
    |
    v
回收垃圾

这个过程叫:Stop and World(STW)

使用算法

新生代:复制算法
老生代:标记-整理
结构:

Young
 |
 |-- Serial

Old
 |
 |-- Serial Old

优点

  • 简单
  • 没有线程竞争
  • 效率稳定
    缺点
  • 暂停时间长。因为只有一个GC线程
    使用场景
  • 小内存程序
  • 单核机器
  • 客户端程序

Parallel 收集器

并行垃圾收集器。

与Serial最大的区别就是
Parallel:

GC线程1
GC线程2
GC线程3
GC线程4

一起GC

结构

            JVM

             |
             v

        多个GC线程

      /    |    |    \

    GC    GC   GC    GC

目标

Parallel关注吞吐量

吞吐量:业务运行时间/总运行时间

例如:
程序运行100秒
业务执行95秒
GC5秒

则吞吐量:95%

使用算法

新生代:复制算法
老生代:标记-整理

优点

  • 吞吐量高
  • 适合后台计算任务
    缺点
  • 暂停时间仍然存在
    使用场景
    服务器:
  • 批处理系统
  • 大数据计算
  • 离线任务

CMS 收集器(了解)

Concurrent Mark Sweep

中文:并发标记清除

它是早期为了降低停顿时间设计的。

核心目标:

让 GC 尽量和用户线程同时运行。

CMS流程

1.初始标记

暂停业务:Stop The World
快速找到GC Roots

GC Roots
 |
 v
对象

时间短

2.并发标记

用户线程继续

业务线程
---------------->

GC线程
---------------->

扫描对象

二者同时进行

3.重新标记

重新暂停:Stop The World
修正并发标记期间产生的变化

4.并发清除

GC线程清理:垃圾对象删除
用户线程继续

优点

  • 低延迟
  • 暂停时间短
    缺点
  • 会产生内存碎片。因为采用了标记-清除
  • CPU占用高。因为GC线程和业务线程同时运行。对CPU压力大
  • 浮动垃圾。并发清理期间,业务线程继续创建垃圾
    现状
    Java9以后,CMS已经废弃,被G1收集器替代

G1收集器(重点)

G1:

Garbage First

中文:

垃圾优先收集器。

它是现在服务器 JVM 常用收集器。

最大变化

以前的堆:

Young
-------------
Eden Survivor

Old
-------------

区域固定。

G1把堆划分成很多小块:

Heap

+---+---+---+
|R1 |R2 |R3 |
+---+---+---+
|R4 |R5 |R6 |
+---+---+---+
|R7 |R8 |R9 |
+---+---+---+

每个 Region可能是:

Eden
Survivor
Old
Humongous

每次 GC优先选择:

垃圾最多的 Region。

例如:

Region A

垃圾90%


Region B

垃圾20%

优先回收:

Region A

G1算法

整体:标记-整理
局部:复制算法

例如,回收一个Region:

Region A

[A][垃圾][B]

复制存活对象

↓

Region C

[A][B]

清空Region A

优点

  • 可预测停顿。例如可以设置-XX:MaxGCPauseMillis=200 希望GC暂停不要超过200ms
  • 大堆友好。适合40GB+。几十GB堆
    缺点
  • 实现复杂
  • CPU开销较高

ZGC(了解)

Z Garbage Collector

目标:极低延迟
特点:暂停时间<10ms,甚至几毫秒

核心特点

  1. 大堆支持。可以支持TB级堆
  2. 几乎并发完成GC
    传统:
标记
暂停

清理
暂停

ZGC的大量工作:

GC线程

和

业务线程

同时进行
  1. 使用染色指针
    在对象引用地址中加入GC信息

例如:
普通引用:

0x123456

ZGC:

0xAB123456

里面携带对象状态

缺点

  • 实现复杂
  • 对硬件和JDK版本要求较高

第九阶段:JIT 编译

学习目标

理解 JVM 为什么越来越快。

需要掌握

答题区

Interpreter(解释器)

解释器是一种一边读取字节码,一边执行字节码的执行方式
Python是其中的典型代表,即边翻译边执行。

与编译器的先整体翻译,再执行的方式有明显区别

对比点 解释器 编译器
英文 Interpreter Compiler
执行方式 逐条解释 整体翻译
输入 字节码 字节码/源码
输出 机器码
启动速度
运行速度
优化能力
适合 短程序 长期运行程序

JIT(Just In Time Compiler)

中文:即时编译器
JIT的思想是,把经常执行的字节码,直接编译成本地机器码

例如:

public void test(){
    for(int i=0;i<100000;i++){
        test(i);
    }
}

刚开始是解释器执行。过了段时间,重复次数超过某个阈值后,JVM发现test()调用次数很多。于是将其编译成机器码:

test()
 ↓
JIT编译
 ↓
机器码

编译以后,CPU直接运行机器码,速度大幅提高。

热点代码(Hot Code)

JIT不会把所有代码都编译。因为大部分情况下,80%的时间花在20%的代码上
很多代码都只是执行一次,没必要编译。

所以JVM会寻找经常执行的代码,称为热点代码

热点代码主要分为两种

1.被大量调用的方法

例如:

for(int i=0;i<100000;i++){
    test();
}

test()调用了100000次,成为热点。

2.执行次数很多的循环

例如:

while(true){

}

循环体执行次数巨大,也是热点

JVM通过计数器统计,例如

  • 方法调用计数器
  • 循环回边计数器
    一旦达到阈值,就会触发JIT。

回边计数器(Back Edge Counter):
goto while_start向前跳转一次,计数器增加

C1编译器

C1:Client Compiler

特点

  • 编译时间短
  • 优化程度低
    适用场景
  • 桌面程序
  • 启动速度要求高
    例如:
启动Java程序
        |
        v
C1快速编译
        |
        v
马上获得性能提升

C1优化比较简单,如方法内联
示例:

add(){
    return a+b;
}
main(){
    add();
}

优化为

main(){
    return a+b;
}

C2编译器

C2:Server Compiler

特点

  • 编译慢
  • 优化程度高
  • 运行性能最好
    适用场景
  • 服务器程序
  • 长时间运行程序
    示例:
    SpringBoot
启动
 ↓
解释执行
 ↓
热点出现
 ↓
C2优化
 ↓
长期高速运行

C2优化很多:

1.逃逸分析

判断对象是否出逃方法

例如:

public void test(){
    Person p = new Person();
}

如果发现p只在方法内部调用,可能不创建堆对象,直接在栈上分配

2.标量替换

对象

Person{
    int age;
    String name;
}

拆成

age变量
name变量

减少对象创建。

3.循环优化

例如

for(int i=0;i<100;i++){

}

优化执行方式

  • 循环展开(Loop Unrolling)
  • 循环不变量外提(Loop Invariant Code Motion)
  • 范围检查消除(Range Check Elimination)
  • 循环删除(Loop Elimination)
  • 循环向量化(Loop Vectorization)
    详细内容自己了解,这里不作为重点展开讲解
比较点 C1 C2
名称 Client Compiler Server Compiler
速度
优化
编译时间
目标 快速响应 最高性能
适合 客户端 服务器
HotSpot 存在 存在

C1和C2通常不是2选1,而是分层编译

          字节码

             |
             v

        解释器

             |
        热点发现

             |
             v

          C1编译

             |
        继续运行

             |
             v

          C2编译

             |
             v

        高性能机器码

OSR(On-Stack Replacement,栈上替换)

他解决的问题是:一个正在执行的长时间运行方法,如何在不退出方法的情况下,从解释执行切换到JIT编译后的机器码执行

public class Test {

    public static void main(String[] args) {

        long sum = 0;

        while (true) {
            sum++;

            if(sum == 10000000000L){
                break;
            }
        }

    }
}

运行一段时间后,JVM就会发现这个while循环执行的次数非常多。于是判断:这是热点代码,然后JIT开始编译,生成while循环对应的机器码

那么问题来了: 现在程序在哪里?
答:它已经在循环结构里面了。即现在已经执行到一半了,

线程栈

main方法栈帧
----------------

局部变量:
sum = 500000

当前执行位置:
while循环中间

----------------
解释器正在执行

如果没有OSR,JVM就只能退出main方法,重新调用main方法,然后执行JIT代码。
但是如果这样做,局部变量sum、循环位置、程序计数器PC这些执行状态都会丢失。

所以需要在当前栈帧上直接替换执行代码,这就是OSR

OSR的核心思想:

把正在执行的解释器栈帧,替换成对应的JIT编译栈帧。

原来:

解释器版本

      |
      |
      v

+----------------+
| main()栈帧     |
|                |
| sum=500000     |
|                |
| while循环      |
|                |
| 字节码PC位置   |
+----------------+

替换:

JIT版本

+----------------+
| main()栈帧     |
|                |
| sum=500000     |
|                |
| 机器码执行位置 |
+----------------+

状态保持:

sum没有丢

循环位置没有丢

继续执行

OSR具体实现过程

1.解释器执行

开始:

while(i < 1000000000){

    i++;

}

JVM:

解释器
 |
 v

执行字节码

同时维护:回边计数器(Back Edge Counter)

什么叫回边?就是循环跳转。

字节码:

goto while_start

这种向前跳转:

     |
     v
while_start

每跳一次,计数器增加:

loop_counter++

例如:

loop_counter = 10000

2.发现热点循环

达到阈值,例如:

loop_counter > 10000

JVM认为:

这个循环值得优化

于是:

触发 JIT。


注意:这里和普通方法编译不同。

普通JIT:

整个方法编译

method()
   |
   v
机器码

OSR:

只编译当前正在执行的循环入口

例如:

原方法:

void test(){

    初始化();

    while(){

        核心计算();

    }

    结束();

}

OSR可能只编译:

while部分

因为:

热点在那里。


3. JIT生成OSR入口

普通机器码:

入口:

method入口

例如:

test()
 |
 v
机器码开始

OSR机器码:

入口:

循环内部

例如:

test()

   初始化()

        |
        |
        v

    OSR入口

        |
        |
        v

    编译后的while循环

也就是说:

OSR代码不是从方法第一行开始。

而是:

从循环当前的位置接管。


4. 最关键:状态迁移

这是OSR最核心的部分。

解释器现在拥有:

解释器状态

局部变量:

i = 500000

sum = 999999

操作数栈:

[123]

程序位置:

while循环第500000次

JIT代码需要:

机器状态

寄存器:

RAX = i

RBX = sum

PC = 循环机器码地址

所以 JVM 要做:变量映射

例如解释器:

局部变量表:

slot0:
i

slot1:
sum

转换:

机器寄存器:

RAX = i

RBX = sum

类似:

解释器世界

i
 |
 |
 v

OSR适配层

 |
 |
 v

机器码世界

RAX

这个过程叫:

栈帧重构(frame reconstruction)


5. 切换瞬间发生什么?

假设:

当前:

解释器执行:

while(i<100000)

i++;

执行到:

i=50000

JVM:

发现:

OSR代码已经准备好了

暂停线程:

线程暂停一下

保存:

i=50000
sum=xxx
当前循环位置

然后:

创建新的栈帧状态:

JIT栈帧:

i=50000
sum=xxx

PC:
机器码循环入口

然后:

继续:

CPU执行机器码

用户代码感觉:

没有暂停

循环继续运行

6. OSR和普通JIT编译区别

比较点 普通JIT OSR
编译对象 整个方法 方法中的热点循环
进入位置 方法入口 循环中间
发生时间 调用方法时 方法已经运行时
是否退出方法 可以重新进入 不退出
目的 优化未来调用 优化当前执行

第十阶段:JVM 调优

学习目标

能够分析线上 JVM 问题。

JVM参数

常用工具

常见问题

答题区

JVM参数

参数 作用 控制对象
-Xms 初始堆大小 Heap
-Xmx 最大堆大小 Heap
-Xmn 新生代大小 Young Generation
-Xss 线程栈大小 JVM Stack
-XX:+PrintGC 打印GC日志 GC
-XX:+UseG1GC 使用G1垃圾收集器 GC算法

1. -Xms 初始堆大小

设置 JVM 启动时堆内存大小。

示例:

java -Xms512m -jar app.jar

含义:

JVM启动时:
Heap = 512MB

2. -Xmx 最大堆大小

限制 JVM 最大堆内存。

示例:

java -Xmx2g -jar app.jar

含义:

Heap最大只能增长到:

2GB

3. -Xmn 新生代大小

设置 Young Generation 大小。

示例:

java -Xms4g -Xmx4g -Xmn1g -jar app.jar

表示:

整个堆:

4GB

其中:

Young Generation:
1GB

Old Generation:
约3GB

4. -Xss 线程栈大小

设置每个线程 Stack 大小。

示例:

java -Xss1m -jar app.jar

表示:

每个线程:

JVM Stack = 1MB

例如:

100个线程:

100 × 1MB

≈ 100MB 栈空间

5. -XX:+PrintGC

打印 GC 日志。

示例:

java -XX:+PrintGC -jar app.jar

运行时可能输出:

[GC (Allocation Failure) 1024K->512K(2048K), 0.002 secs]

表示发生了一次 GC。


6. -XX:+UseG1GC

指定使用 G1 垃圾收集器。

示例:

java -XX:+UseG1GC -jar app.jar

表示:

JVM使用:

G1 Garbage Collector

实际生产常见组合

例如一个 Spring Boot 服务:

java \
-Xms4g \
-Xmx4g \
-Xmn2g \
-Xss1m \
-XX:+UseG1GC \
-Xlog:gc* \
-jar app.jar

含义:

堆:
4GB

新生代:
2GB

线程栈:
每线程1MB

垃圾收集器:
G1

GC日志:
开启

查看 JVM 参数是否生效

启动后:

jps

找到 PID:

例如:

12345 app.jar

查看参数:

jcmd 12345 VM.flags

输出类似:

-XX:+UseG1GC
-XX:InitialHeapSize=4294967296
-XX:MaxHeapSize=4294967296
-XX:ThreadStackSize=1024

可以确认 JVM 实际采用的配置。

常用工具

问题 工具
查看有哪些 Java 进程 jps
查看线程运行情况、死锁 jstack
查看堆内存、对象数量 jmap
查看 GC、类加载、内存变化 jstat
综合诊断 JVM jcmd
图形化监控 JVM jvisualvm
在线诊断生产环境 Arthas

1. jps(Java Process Status)

作用

查看当前机器上的 Java 进程。

类似 Linux:

ps

但是专门针对 JVM。


命令
jps

示例:

12345 BackendApplication
23456 org.apache.catalina.startup.Bootstrap

表示:

PID 程序
12345 Spring Boot应用
23456 Tomcat

查看详细信息
jps -l

输出:

12345 com.example.backend.BackendApplication

显示完整类名。


常见用途

后面的 JVM 工具都需要 PID:

jstack 12345
jmap 12345
jstat 12345

所以通常第一步:

jps

2. jstack(Java Stack Trace)

作用

查看 JVM 中所有线程的运行状态。

对应 JVM:

线程
 |
 JVM Stack
 |
 栈帧

命令
jstack PID

例如:

jstack 12345

输出:

"http-nio-8080-exec-1"

java.lang.Thread.State: WAITING

at java.lang.Object.wait()
at com.example.Service.run()

常见用途
(1)查看死锁

例如:

线程 A:

持有锁1
等待锁2

线程 B:

持有锁2
等待锁1

执行:

jstack 12345

可能发现:

Found one Java-level deadlock

(2)排查 CPU 占用过高

例如:

while(true){

}

导致线程:

RUNNABLE

通过:

jstack PID

找到异常线程。


3. jmap(Java Memory Map)

作用

查看 JVM 堆内存信息。

对应:

Heap

Young Generation
Old Generation

查看堆信息

命令:

jmap -heap PID

示例:

jmap -heap 12345

输出:

Heap Configuration:

MaxHeapSize = 4294967296

Heap Usage:

eden space:
80%

old space:
40%

查看对象数量

命令:

jmap -histo PID

示例:

num     instances     class

1:      500000       java.lang.String

2:      300000       byte[]

3:      100000       User

可以发现:

  • 哪些对象数量最多
  • 是否存在异常对象增长

导出堆 Dump 文件

命令:

jmap -dump:format=b,file=heap.hprof PID

生成:

heap.hprof

可以使用:

  • Eclipse MAT
  • VisualVM

分析对象引用关系。


4. jstat(JVM Statistics Monitoring Tool)

作用

实时查看 JVM 运行状态。

主要用于:

  • GC情况
  • 新生代变化
  • 老年代变化
  • 类加载情况

查看 GC 信息

命令:

jstat -gc PID

示例:

jstat -gc 12345

输出:

S0C   S1C   EC    OC

1024  1024  4096  8192

YGC   YGCT

100   2.5

常见字段
缩写 英文全称 含义 对应JVM区域
S0C Survivor 0 Capacity Survivor区0容量 年轻代
S1C Survivor 1 Capacity Survivor区1容量 年轻代
S0U Survivor 0 Used Survivor区0已使用 年轻代
S1U Survivor 1 Used Survivor区1已使用 年轻代
EC Eden Capacity Eden区容量 年轻代
EU Eden Used Eden区已使用 年轻代
OC Old Capacity 老年代容量 老年代
OU Old Used 老年代已使用 老年代
MC Metaspace Capacity 元空间容量 方法区
MU Metaspace Used 元空间已使用 方法区
CCSC Compressed Class Space Capacity 压缩类空间容量 元空间
CCSU Compressed Class Space Used 压缩类空间已使用 元空间
YGC Young GC Count 年轻代GC次数 GC统计
YGCT Young GC Time 年轻代GC总耗时 GC统计
FGC Full GC Count Full GC次数 GC统计
FGCT Full GC Time Full GC总耗时 GC统计
CGC Concurrent GC Count 并发GC次数 G1相关
CGCT Concurrent GC Time 并发GC总耗时 G1相关
GCT GC Time Total GC总耗时 GC统计

持续监控

每秒刷新一次:

jstat -gc 12345 1000

表示:

PID

每1000ms刷新一次

可以观察:

YGC
100
101
102

判断 Minor GC 是否频繁。


5. jcmd(JVM Command)

作用

JDK 官方推荐的综合诊断工具。

相比:

jstack
jmap
jstat

功能更加统一。


查看 JVM 信息

命令:

jcmd PID VM.info

示例:

jcmd 12345 VM.info

查看 JVM 参数

命令:

jcmd PID VM.flags

输出:

-XX:+UseG1GC
-XX:MaxHeapSize=4294967296

查看线程信息

命令:

jcmd PID Thread.print

类似:

jstack PID

查看堆信息

命令:

jcmd PID GC.heap_info

导出堆文件

命令:

jcmd PID GC.heap_dump heap.hprof

6. jvisualvm(Java VisualVM)

作用

Java 图形化监控工具。

启动:

jvisualvm

打开:

VisualVM窗口

可以查看
(1)CPU

查看:

CPU使用率

(2)内存

查看:

Heap:

Eden
Survivor
Old

(3)GC

查看:

GC次数
GC耗时

(4)线程

查看:

RUNNABLE
WAITING
BLOCKED

(5)堆 Dump 分析

打开:

heap.hprof

查看:

  • 对象数量
  • 引用关系
  • 内存泄漏

7. Arthas(阿里开源 JVM 诊断工具)

官方文档:

Arthas 官方文档


作用

在线诊断运行中的 Java 程序。

相比:

jstack
jmap

不需要:

  • 重启应用
  • 停止服务
  • 导出大量文件

启动

命令:

java -jar arthas-boot.jar

选择:

1. BackendApplication

进入:

[arthas@12345]

查看线程

命令:

thread

输出:

ID   NAME          STATE

23   http-worker   RUNNABLE

查看方法耗时

例如:

UserService.login()

执行:

trace com.example.UserService login

结果:

login()

 |
 |-- mysql 200ms
 |
 |-- redis 50ms

查看方法参数和返回值

命令:

watch com.example.UserService login "{params,returnObj}"

查看:

params:
abc@qq.com

return:
success

查看类信息

命令:

sc UserService

反编译线上代码

命令:

jad com.example.UserService

8. JVM 问题排查流程

第一步:查找 JVM 进程
jps

获取 PID。


第二步:查看 GC 状态
jstat -gc PID

判断:

  • Minor GC 是否频繁
  • Old 区是否增长过快

第三步:分析线程问题
jstack PID

排查:

  • 死锁
  • 阻塞
  • CPU异常线程

第四步:分析内存问题
jmap
jcmd

排查:

  • 对象增长
  • 内存泄漏
  • 堆使用异常

第五步:线上深入分析
Arthas

查看:

  • 方法调用
  • 参数
  • 返回值
  • 性能瓶颈

总结

工具 作用 常用命令
jps 查看 Java 进程 jps -l
jstack 线程分析 jstack PID
jmap 堆内存分析 jmap -heap PID
jstat GC监控 jstat -gc PID
jcmd 综合诊断 jcmd PID VM.info
jvisualvm 图形化监控 jvisualvm
Arthas 生产在线诊断 java -jar arthas-boot.jar

常见问题

1. OOM(Out Of Memory)

作用

OOM 表示:

JVM 可用内存不足,无法继续为对象分配空间。

异常:

java.lang.OutOfMemoryError

常见类型
(1)Java Heap Space

最常见:

java.lang.OutOfMemoryError: Java heap space

原因:

堆空间不足。

例如:

List<Object> list = new ArrayList<>();

while(true){
    list.add(new Object());
}

对象不断进入:

Heap

Young
 |
Old

持续增长
 |
达到 -Xmx
 |
OOM

(2)GC Overhead Limit Exceeded

异常:

java.lang.OutOfMemoryError:
GC overhead limit exceeded

表示:

GC 花费大量时间,但是释放空间很少。

例如:

GC:

99% 时间用于回收

但是:

只释放 1% 内存

JVM 判断:

回收已经没有意义

抛出异常。


(3)Metaspace

异常:

java.lang.OutOfMemoryError:
Metaspace

原因:

方法区(Metaspace)空间不足。

常见于:

  • 动态生成大量类
  • 大量 ClassLoader 泄漏

例如:

while(true){
    Class.forName("xxx");
}

排查方法

查看堆:

jmap -heap PID

查看对象:

jmap -histo PID

导出堆:

jmap -dump:format=b,file=heap.hprof PID

分析:

  • Eclipse MAT
  • VisualVM
  • Arthas

2. StackOverflowError

作用

表示:

JVM 栈空间不足。

异常:

java.lang.StackOverflowError

常见原因
(1)无限递归

例如:

public void test(){

    test();

}

执行:

test()

 |
test()

 |
test()

...

不断创建栈帧:

JVM Stack

Frame
Frame
Frame
Frame

最终:

StackOverflowError

(2)递归层级过深

例如:

factorial(100000)

递归调用过多。


解决方式

增加线程栈:

-Xss2m

或者:

修改递归逻辑:

  • 使用循环替代递归
  • 限制递归深度

排查方法

查看线程:

jstack PID

寻找:

at xxx.method()
at xxx.method()
at xxx.method()

重复调用。


3. Full GC 频繁

作用

Full GC:

对整个 Java 堆进行垃圾回收。

包括:

  • Young Generation
  • Old Generation
  • Metaspace

常见现象

日志:

Full GC (Allocation Failure)

表现:

  • CPU 高
  • 应用停顿
  • 请求变慢

常见原因
(1)老年代空间不足

例如:

Old Generation

对象不断进入

↓

空间不足

↓

Full GC

(2)大对象过多

例如:

byte[] data = new byte[100000000];

大量大对象:

直接进入 Old

导致 Old 快速占满。


(3)内存泄漏

对象无法回收:

GC

↓

对象仍然被引用

↓

Old持续增长

↓

Full GC

排查方法

查看 GC:

jstat -gc PID

查看日志:

-Xlog:gc*

查看对象:

jmap -histo PID

4. 内存泄漏(Memory Leak)

作用

内存泄漏:

对象已经不再使用,但是仍然被引用,导致 GC 无法回收。


正常情况

对象:

创建

↓

使用

↓

没有引用

↓

GC回收

内存泄漏情况

对象:

创建

↓

使用结束

↓

仍然存在引用

↓

GC无法回收

↓

堆不断增长

常见原因
(1)静态集合保存对象

例如:

public static List<Object> list =
        new ArrayList<>();

不断:

list.add(object);

但是:

list.clear();

没有执行。


(2)缓存无限增长

例如:

Map<String,Object> cache =
        new HashMap<>();

不断添加:

cache.put(key,value);

没有过期机制。


(3)监听器未释放

例如:

addListener()

但是没有:

removeListener()

导致对象一直被引用。


排查方法

导出 Heap Dump:

jmap -dump:format=b,file=heap.hprof PID

分析:

  • GC Roots
  • 对象引用链
  • 大对象

工具:

  • Eclipse MAT
  • VisualVM
  • Arthas

5. CPU 飙高

作用

CPU 飙高:

表示:

某些线程消耗大量 CPU 时间。


常见原因
(1)死循环

例如:

while(true){

}

线程状态:

RUNNABLE

持续运行。


(2)频繁计算

例如:

while(true){

    calculate();

}

大量 CPU 运算。


(3)频繁 GC

例如:

创建大量对象

↓

频繁 Minor GC

↓

CPU消耗增加

排查流程
第一步:查看 Java 进程
jps

第二步:查看线程
top -H -p PID

找到高 CPU 线程:

例如:

PID

12345

第三步:转换线程 ID
printf "%x\n" 12345

得到:

3039

第四步:查看线程栈
jstack PID

查找:

nid=0x3039

定位代码。


6. 死锁(Deadlock)

作用

死锁:

多个线程互相等待对方释放锁,导致永久阻塞。


示例

线程 A:

synchronized(lockA){

    synchronized(lockB){

    }

}

线程 B:

synchronized(lockB){

    synchronized(lockA){

    }

}

执行过程:

线程A:

持有 lockA
等待 lockB


线程B:

持有 lockB
等待 lockA

形成:

A 等 B

B 等 A

程序无法继续。


死锁表现

线程状态:

BLOCKED

应用:

  • 请求超时
  • 接口无响应
  • CPU可能正常

排查方法

使用:

jstack PID

输出:

Found one Java-level deadlock

查看:

Thread-1 waiting for lock

Thread-2 waiting for lock

解决方式
(1)固定锁顺序

错误:

lockA -> lockB

另一个:

lockB -> lockA

改成:

lockA -> lockB

统一顺序。


(2)减少锁嵌套

避免:

synchronized(A){

    synchronized(B){

    }

}

(3)使用超时锁

例如:

lock.tryLock(
    5,
    TimeUnit.SECONDS
);

避免永久等待。


JVM 常见问题排查流程

1. 内存问题

OOM
 |
jmap
 |
Heap Dump
 |
MAT分析

2. GC问题

Full GC频繁

↓

jstat

↓

GC日志

↓

调整堆参数

3. CPU问题

CPU高

↓

top

↓

jstack

↓

定位线程

4. 线程问题

死锁

↓

jstack

↓

查看锁等待关系

总结表

问题 原因 排查工具
OOM 堆/方法区空间不足 jmap、jcmd、MAT
StackOverflowError 递归过深、栈不足 jstack
Full GC频繁 Old区不足、对象无法回收 jstat、GC日志
内存泄漏 对象仍被引用 Heap Dump、MAT
CPU飙高 死循环、计算密集、GC top、jstack
死锁 线程互相等待锁 jstack

第十一阶段:源码阅读(进阶)

推荐阅读顺序


推荐学习实践

每学习一个知识点,都自己写一个最小示例,然后:

javac Demo.java
javap -c -v Demo

结合字节码和 JVM 原理分析代码执行过程。

例如:


学习主线(牢记)

Java源码
    │
    ▼
javac 编译
    │
    ▼
.class 字节码
    │
    ▼
ClassLoader 类加载
    │
    ▼
运行时数据区
    │
    ▼
对象创建
    │
    ▼
方法调用(栈帧)
    │
    ▼
字节码执行
    │
    ▼
JIT 编译
    │
    ▼
GC 垃圾回收
    │
    ▼
JVM 调优

学习建议:不要孤立地背概念,而是始终围绕一段简单的 Java 代码追问:"这一行代码在 JVM 中发生了什么?"
当你能解释 new Person()person.show()System.out.println() 在 JVM 中的完整执行过程时,JVM 的主体知识已经真正串联起来了。

杂项知识补充

Integer缓存池

public static Integer valueOf(int i) {

    if (i >= IntegerCache.low &&
        i <= IntegerCache.high)

        return IntegerCache.cache[i + (-IntegerCache.low)];

    return new Integer(i);
}

这里有一个非常重要的机制:

Integer缓存池默认为:-128~127

范围内的 Integer 不会重复创建。所以:

Integer integer = 100;

实际过程:

Integer.valueOf(100)

        |
        |
        发现100在缓存范围

        |
        |
        从IntegerCache取对象

        |
        |
        返回已有对象引用

也就是说:

可能根本没有创建新的堆对象。

Integer缓存结构

JVM启动时:

加载Integer类:

Integer.class
        |
        |
初始化静态变量
        |
        |
创建IntegerCache
        |
        |
缓存数组

类似:

static final Integer cache[] =
        new Integer[256];

里面:

cache[0]
 |
 +--> Integer(-128)

cache[1]
 |
 +--> Integer(-127)

...

cache[228]
 |
 +--> Integer(100)

所以:

Integer a = 100;
Integer b = 100;

实际上:

a
 |
 |
 +----------+
            |
            v
       Integer对象(100)
            ^
            |
 +----------+
 |
b

两个引用指向同一个对象。


验证:

System.out.println(a == b);

结果:

true

三种包装类缓存

类型 创建方式 是否缓存 对象位置
Integer Integer.valueOf() -128~127缓存
Double new Double() 无缓存
Boolean Boolean.valueOf() TRUE/FALSE缓存

数组与集合的区别

对比 数组 集合
本质 JVM内置结构 Java类库对象
底层 JVM直接管理 通常依赖数组/链表/树
长度 固定 动态
存储 数据本身 对象引用
基本类型 支持 不支持,需要包装类
性能 更高 有额外开销
类型检查 泛型约束
扩容 不支持 支持
灵活性

数组是 JVM 提供的“原始仓库”,它追求性能;集合是 Java 提供的“高级仓库”,它牺牲一点性能换取灵活性和丰富功能。

从 JVM 内存角度看:

  • int[] 是一个特殊的堆对象,里面直接存 int 数据。
  • ArrayList<Integer> 是普通堆对象,里面保存 Object[],里面保存 Integer 对象引用。这个区别也是为什么集合会产生更多对象和 GC 压力。

元空间与堆对象的生命周期区别

对比点 元空间
存储内容 对象 类信息
区域 年轻代+老年代 方法区实现
GC Minor GC、Major GC、Full GC 类卸载
大小限制 -Xmx MaxMetaspaceSize
存储位置 JVM管理内存 操作系统本地内存

新生代、老年代属于堆(Heap)的区域;元空间(Metaspace)不属于堆,而属于方法区(Method Area)的实现。

JVM 内存大致结构:

JVM运行时数据区

├── 堆 Heap
│   ├── 新生代 Young Generation
│   │   ├── Eden
│   │   ├── Survivor0
│   │   └── Survivor1
│   │
│   └── 老年代 Old Generation
│
├── 方法区 Method Area
│   └── 元空间 Metaspace(JDK8之后 HotSpot实现)
│
├── Java虚拟机栈 Stack
│
├── 程序计数器 PC Register
│
└── 本地方法栈 Native Method Stack

所以:

  • 堆:存放 Java 对象
  • 元空间:存放类相关信息

元空间(Metaspace)的作用

1. 存储类的元数据(Class Metadata)

Java 程序运行时,并不是只有对象需要保存。

例如:

public class User {

    private String name;

    public void sayHello(){
        System.out.println("hello");
    }
}

当 JVM 加载 User.class 时,需要保存:

User类

├── 类名 User
├── 父类 Object
├── 实现接口信息
├── 字段信息
│   └── name:String
├── 方法信息
│   └── sayHello()
├── 方法字节码
├── 常量池
└── 注解信息

这些内容不是对象数据,而是描述这个类的信息

这些信息存放在哪里?

JDK8之前:

永久代 PermGen

JDK8之后:

元空间 Metaspace

2. 存储 Class 对象相关信息

例如:

User user = new User();

内存中实际存在:

堆:

user对象
-------------
name=null


元空间:

User.class信息
-------------
类结构
方法
字段
字节码

注意:

User对象User.class信息 是两个东西。


3. 存储运行时常量池

例如:

String name = "Tom";

编译后:

class文件中:

Constant Pool

"Tom"
User
sayHello
java/lang/Object

加载进入 JVM 后:

部分常量信息进入运行时常量池。

例如:

System.out.println("hello");

字符串:

"hello"

会进入字符串相关区域。


4. 支撑类加载机制

当 JVM 执行:

User user = new User();

第一次使用 User:

JVM需要:

1. 加载 User.class

        ↓

2. 元空间创建 User 类信息

        ↓

3. 初始化静态变量

        ↓

4. 创建 User对象

        ↓

5. 堆中分配对象空间

例如:

public class User {

    static int count = 10;

}

加载:

元空间:

User类信息
count字段


堆:

暂时没有User对象

初始化:

静态变量count=10

元空间为什么从永久代改成了独立空间?

JDK7及以前:

堆

├── 新生代
├── 老年代
└── 永久代(PermGen)

永久代虽然名字叫代,但实际上属于堆的一部分。

问题:

1. 容易发生 OOM

例如:

大量动态生成类:

while(true){

    createNewClass();

}

不断生成:

Class1
Class2
Class3
...
Class100000

永久代空间耗尽:

java.lang.OutOfMemoryError:
PermGen space

2. 永久代大小不好设置

例如:

-Xmx2g

堆最大2G


其中:

年轻代
老年代
永久代

永久代占多少?

不好控制。

如果:

永久代设置小:

类多 → OOM


永久代设置大:

浪费内存

元空间的改进

JDK8:

堆:

年轻代
老年代


堆外:

元空间

元空间使用:

本机内存(Native Memory)

例如:

启动:

java -Xmx4g Demo

表示:

Java Heap最大4G

但是:

Metaspace

默认可以继续向操作系统申请。

可以限制:

-XX:MetaspaceSize=128m

-XX:MaxMetaspaceSize=512m

元空间什么时候释放?

当一个类不再被使用:

例如:

ClassLoader loader = new URLClassLoader(...);

loader.loadClass("Test");

产生:

ClassLoader

     ↓

Test.class

     ↓

元空间保存类信息

如果:

ClassLoader被GC回收

那么:

Test.class
相关元数据

也可以被回收

所以:

元空间回收依赖:

类卸载(Class Unloading)

Javac编译含有中文字符的Java程序

需要指定编码为UTF-8

javac -encoding UTF-8 HeapOOM.java

Shallow Heap(浅层堆)与Retained Heap(深层堆)

浅层堆(Shallow Heap)

定义
  • 浅层堆 = 一个对象自身占用的内存大小
  • 不包括该对象引用的其他对象。

简单理解:

"如果只删除这个对象本身,需要释放多少内存?"

深层堆(Retained Heap)

定义

深层堆表示:

一个对象被 GC 回收后,可以释放的全部内存。

也就是:

对象自身大小
+
只能通过它访问到的对象大小
比较 浅层堆 Shallow Heap 深层堆 Retained Heap
计算对象自己
计算引用对象
表示对象本身大小
表示删除对象后释放空间
排查内存泄漏价值 一般 非常高

在MAT中可以通过Leak Suupects>>System Overview >> Class Histogram来查看各个类的对象数、浅层堆和深层堆

具体JVM问题排查

OOM

1.创建Java源文件

import java.util.ArrayList;  
import java.util.List;  
  
public class HeapOOM {  
  
  
    // 模拟缓存  
    private static List<byte[]> cache = new ArrayList<>();  
  
  
    public static void main(String[] args) throws Exception {  
  
  
        while (true) {  
  
            // 每次申请 1MB            byte[] data = new byte[1024 * 1024];  
  
  
            // 放入缓存  
            cache.add(data);  
  
  
            System.out.println(  
                    "当前缓存数量:" + cache.size()  
            );  
  
  
            Thread.sleep(100);  
  
        }  
    }  
}

2.生成class文件

javac -encoding UTF-8 HeapOOM.java

因为java文件内有中文注释,所以需要指定编码

3.预设定JVM参数并启动程序

这里的`是powershell格式下的换行符。如果采用其他环境,请使用对应换行符。
在powershell里粘贴多行的快捷键是Ctrl+Shift+V

换行版本:

java `
-Xms100m `
-Xmx100m `
-XX:+HeapDumpOnOutOfMemoryError `
-XX:HeapDumpPath=./heapdump.hprof `
HeapOOM

单行版本:

java -Xms100m -Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof HeapOOM

4.1利用heapdump.hprof文件来排查问题

因为在启动的时候,设置了JVM参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof
表示开启功能

  • 当JVM发生OOM时,自动生成Heap Dump
  • 指定dump文件的保存路径

在程序运行出现OOM后,把生成的dump文件通过MAT打开

打开之后进入Overview界面查看详细情况
assets/JVM/file-20260729164348228.png

然后点击Leak Suspects。它的作用是帮你自动找出“最可能导致内存泄漏的对象”。帮你缩小排查范围

Leak Suspects主要做三件事

  • 找占用大量内存的对象
  • 分析对象为什么无法回收——GC要看有没有GC Root引用
  • 自动生成泄漏报告
    然后分析大内存对象的GC Root、深堆层等参数,判断问题出在哪里

4.2 利用JVM自带的工具排查程序运行期间的问题

启动程序后,先用

jps

找到目标进程。例如

38540 HeapOOM

前面的数字是PID,后面的名称是启动主类类名

然后再通过

jstat -gc 38540

查看堆状态

    S0C         S1C         S0U         S1U          EC           EU           OC           OU          MC         MU       CCSC      CCSU     YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT
        0.0     49152.0         0.0     49152.0     327680.0      69632.0    7217152.0    5780480.0      448.0      251.6     128.0      12.9     34     3.142     0     0.000    10     0.032     3.174

重点看:

  • EC:Eden区
  • OC:老年代总容量(如果不在启动阶段做限制,JVM可能会随着老年代不断增多,自动扩容其大小。老年代无法再扩容时,才会触发FGC)
  • OU:老年代使用量
  • YGC:Young GC次数
  • FGC:Full GC次数
缩写 英文全称 中文名称 含义
S0C Survivor 0 Capacity Survivor区0容量 幸存者区0(From区)的总容量
S1C Survivor 1 Capacity Survivor区1容量 幸存者区1(To区)的总容量
S0U Survivor 0 Used Survivor区0已使用 Survivor0当前使用大小
S1U Survivor 1 Used Survivor区1已使用 Survivor1当前使用大小
EC Eden Capacity Eden区容量 新生代Eden区总容量
EU Eden Used Eden区已使用 Eden区当前使用大小
OC Old Capacity 老年代容量 老年代总容量
OU Old Used 老年代已使用 老年代当前使用大小
MC Metaspace Capacity 元空间容量 元空间(Metaspace)已申请容量
MU Metaspace Used 元空间已使用 元空间实际使用大小
CCSC Compressed Class Space Capacity 压缩类空间容量 存放Class元数据的压缩空间容量
CCSU Compressed Class Space Used 压缩类空间已使用 压缩类空间实际使用大小
YGC Young GC Count 年轻代GC次数 Minor GC发生次数
YGCT Young GC Time 年轻代GC总耗时 所有Young GC累计耗时(秒)
FGC Full GC Count Full GC次数 完整垃圾回收次数
FGCT Full GC Time Full GC总耗时 Full GC累计耗时(秒)
CGC Concurrent GC Count 并发GC次数 并发垃圾回收次数(主要用于CMS/G1等)
CGCT Concurrent GC Time 并发GC总耗时 并发GC累计耗时(秒)
GCT Garbage Collection Time GC总耗时 所有GC累计耗时

调优案例

metaspace导致频繁FGC问题(Full GC)

问题现象:服务频繁出现FGC

原因分析:反射调用导致创建大量DelegatingClassLoader,占用了较大的元空间内存,同时存在内存碎片化现象,导致元空间利用率不高,从而较快达到阈值,触发FGC。

优化策略:

  1. 适当调大metaspace的空间大小
  2. 优化不合理的反射调用。例如最常见的属性拷贝工具类BeanUtils.copyProperties可以使用mapstruct替换
    优化效果:频繁FGC问题得到解决

CMS内存碎片化导致FGC问题

问题现象:C端核心业务在高峰期服务器发生FGC,导致部分请求超时报错,影响用户体验

原因分析:CMS使用标记清除算法,不再进行任何压缩和整理的工作,意味着老年代随着应用的运行会变得碎片化;碎片过多会影响大对象的分配,虽然老年代还有很大的剩余空间,但是没有连续的空间来分配大对象。长期如此,最终可能会导致FGC的发生。

优化策略:
业务低峰期显式触发FGC,优化内存碎片并压缩堆,降低业务高峰期发生FGC的概率

  • System.gc(),没有开启-XX:+DisableExplicitGC
  • jmap -histo:live pid
    优化效果:业务高峰期基本没有出现FGC

YGC和OLD GC频繁

问题现象:服务器YGC和OLD GC频繁导致TP999耗时较高,YGC每分钟 50次,每次25毫秒,OLD GC几分钟1次,每次200毫秒。

原因分析:该服务要求低延迟,为了YGC较快完成,年轻代设置较小,但是由于年轻代较小,反而导致YGC次数过多,查看GC日志发现,由于动态年龄的因素,大量对象较早晋升到老年代,从而导致OLD GC频繁

优化策略:扩大新生代内存到原来的3倍

优化效果:单次YGC耗时增加了5%,频率降低了60%,服务TP999降低了10ms+,OLD GC频率降级为几小时1次

posted @ 2026-07-14 22:31  畅畅c  阅读(8)  评论(0)    收藏  举报