总述

主要分为运行时数据区域(JVM内存)本地内存

  1. 运行时数据区域(即**JVM内存**):JVM 规范中定义的所有内存区域的统称,是个逻辑区域。包含:
    1. 线程共享区域,包含“堆内存”、方法区(JDK1.7在共享区域,称为永久代;1.8之后被放到本地内存之中,称为元空间,但逻辑上仍属于JVM定义的规范之中)
    2. 线程私有区域,包含虚拟机栈、本地方法栈、程序计数器

jvm-memory-area

  1. 本地内存:除去“运行时数据区域”后其余的物理内存

    1. 直接内存:特殊的一种内存缓冲区

    2. 元空间:是JVM规范中的“方法区”的一种实现,方法区是认为在JVM内存中的。JVM 的运行时数据区域 有固定大小上限,会受到JVM内存的限制;而放到本地内存后,只限制于系统可用的内存

JVM内存_线程私有区_3个

程序计数器

每个线程都有一个程序计数器,是当前线程所执行的字节码的行号指示器。字节码是JVM能识别的代码,是Java编译后形成的,并由在JVM上解释执行,它的代码是有行号的。可以通过如下命令查看:

1
2
javac ClassName.java
javap -c -v ClassName

![字节码片段](../../../../../Library/Application Support/typora-user-images/image-20250625001158577.png)

总结来说程序计数器的作用是:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理;
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了;

虚拟机栈_4个

由一个个栈帧组成,每调用1次Java方法,就会产生压入一个栈帧。

每个栈帧由如下部分组成:

  1. 局部变量表:方法执行时的局部变量,以Slot为单位存储数据(Slot是32位, Int就是32位, Character和Short都是16位, Long和Double是32位),是个线性的数组结构,包括:

    1. 方法参数,会按照顺序依次存入局部变量表的起始位置。对于实例方法,局部变量表索引为0的位置通常是this引用;
    2. 方法内定义的局部变量,包含基本数据类型和对象引用:JVM在编译期会知道这些局部变量在变量表中的索引,每个索引有多少个Slot也是编译时确定的;longdouble是用2个slot,其他基本类型和对象引用都是用1个slot
  2. 操作数栈:临时数据存储区,暂存方法执行过程中的操作数,例如最简单的 c = a + b,就是先压a和b,再弹出2个计算加法,并将结果压入;更复杂地,方法调用时也需要使用操作数栈,例如:

    1
    2
    3
    4
    5
    6
    7
    public int add(int x, int y) {
    return x + y;
    }

    public void test() {
    int result = add(5, 10);
    }

    对应的字节码:

    1
    2
    3
    4
    5
    0: iconst_5         // 压入5
    1: iconst_10 // 压入10
    2: aload_0 // 压入this引用(当前对象)
    3: invokevirtual #2 // 调用add方法,#2为符号引用
    6: istore_1 // 存储返回值(15)到result
  3. 动态链接:将符号引用转换为调用方法的直接引用。编译期只知道其他方法的符号引用(即一种字符串的描述,这是存储在方法区的常量池中的,因为是类的信息),因此当方法需要调用其他方法时,JVM会调用invokevirtualinvokestatic等指令,找到该类并把它加载进来,然后查找该类中与符号引用匹配的方法,并沿着继承层次一路向上查找父类。
    多态就是依靠动态链接实现的,因为运行时是知道这个对象具体什么类型,而不是草草只用父类型或接口的方法。

动态链接

  1. 方法返回地址:当方法被调用时,JVM 会在调用方方法的当前执行点(即下一条指令的地址)记录下来。这个地址就是返回地址。调用方法执行完毕后 JVM 会取出这个返回地址,并将程序的控制权转交给这个地址所指向的指令;
    Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。栈帧随着方法调用而创建,不管哪种返回方式,都会导致栈帧被弹出,然后继续执行调用方的返回地址的指令。

本地方法栈

虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

在 HotSpot 虚拟机中,本地方法栈和 Java 虚拟机栈,共用1个底层操作系统线程的栈,他们会交错地存储在同一个物理栈上。

JVM内存_线程共享区_4个

堆,也称堆内存、GC堆

Java 虚拟机管理内存中最大的一块,是垃圾收集器管理的主要区域。从分代回收的角度,Java堆还能分为新生代老年代

“几乎”所有的对象都在堆中分配,但从 JDK 1.7 开始默认进行逃逸分析,即如果某些方法中的对象引用没有被返回或者未被外面使用,那么对象可以直接在栈上分配内存。


  1. 下图中的Eden和Survivor(S0和S1)都是新生代,对象首先在Eden区域分配
  2. 在新生代垃圾回收后,如果对象还存活,则进入S0或S1并年龄+1
  3. S0或S1区域的对象 在新生代垃圾回收(Minor GC)后,如果仍然存活,则年龄+1并在S0和S1之间切换;
  4. 当年龄增加到某个值时(即-XX:MaxTenuringThreshold,默认且最大为15,因为对象的年龄在对象头中用4位存储)则进入下图中的Tenured老年代;

堆内存结构

  1. JDK1.7之前方法区是永久代,属于堆内存的一部分,但后来被元空间取代;元空间使用本地内存,不受JVM内存大小限制,但也属于JVM逻辑内存区域。

其实对象的年龄是动态计算的,这是为了避免有太多年轻的对象撑满Survivor区域。所以会从小到大对年龄进行累加,直到超过Survivor的一半;此时取min(MaxTenuringThreshold, 最大的年龄),让进入老年代的阈值变低;不然要多次进行GC,让这些年轻对象的年龄都增大,才能进入GC

怎么从老年代传入永久代/元空间,在后续垃圾回收板块细说。

字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串类专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

  • JDK 1.7 为什么要将字符串常量池从永久代(方法区)移动到堆中?
    永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。通常会有大量字符串等待回收,堆中能够更高效及时地回收。

方法区,也称元空间/永久代

方法区类似接口,永久代是JDK1.8之前的实现,元空间Metaspace是JDK1.8之后的实现。

方法区存储的是已被虚拟机加载的 类信息、字段、方法、常量、静态变量、即时编译器编译后的代码缓存等数据,具体可以见类加载过程部分。

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

  1. 永久代受到 JVM 的大小上限限制,元空间则只收到系统内存的限制,从而能加载更多类的元信息;
  2. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低;
  3. 设置MaxMetaspaceSize来调整最大元空间大小,设置MaxPermSize来调整最大永久代大小;

运行时常量池

运行时常量池是方法区的一部分。方法区除了存放类的字段方法接口外,还有编译期的各种字面量(Literal)和符号引用(Symbolic Reference)的常量池表。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HelloWorld.java
public class HelloWorld {
private static final String GREETING = "Hello, Constant Pool!"; // 字面量
private int value = 100; // 字面量

public void sayHello() {
System.out.println(GREETING + " Value: " + value); // 字符串连接、字段引用、方法引用
}

public static void main(String[] args) {
HelloWorld hw = new HelloWorld();
hw.sayHello();
System.out.println("Program finished."); // 字面量
}
}

使用javac HelloWorld.java && javap -v HelloWorld.class命令反编译字节码,得到如下运行时常量池:

1
2
3
4
5
6
7
8
9
10
11
12
13
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // HelloWorld.value:I
#8 = Class #10 // HelloWorld
#9 = NameAndType #11:#12 // value:I
#10 = Utf8 HelloWorld
#11 = Utf8 value
#12 = Utf8 I

其中Methodref是方法的符号引用,Class是类的符号引用,Fieldref是字段的符号引用。

Utf8 类型的常量池项存储的是字符串字面量,这些字符串通常是类名、字段名、方法名、方法描述符等。像 MethodrefFieldref 这些复杂的符号引用,它们内部会通过索引指向这些 Utf8 项来获取具体的名称和描述,例如#1这个方法引用指向#2 类引用和#3类名和类型引用,然后#2.#3指向的则都是Utf8项,即字符串字面量。

本地内存

元空间

这是JDK1.7之后的方法区;JDK1.7之前的方法区是堆中的永久代

直接内存 / 堆外内存

不属于JVM内存,不是虚拟机规范中定义的内存区域。
本地内存在线程之间是共享的,如果多线程共享访问,则需要进行并发控制。

好处:它不会受到 GC 的管理,避免 GC 暂停,减少延迟;避免在 Java 堆和 OS 缓冲区之间的复制,提升性能。
坏处:内存泄漏;分配和回收成本高(但是使用时性能高)

一般通过java.nio.ByteBufferallocateDirect()来分配;当ByteBuffer对象被回收后,或手动调用Cleaner 进行释放。这块区域往往在如下场景中会用到:

  1. NIO(New IO)文件。创建一个缓冲区来读写大文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) throws IOException {
// 假设有一个 large_file.txt
// 创建一个直接缓冲区
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB

try (FileChannel fileChannel = FileChannel.open(
Paths.get("large_file.txt"), StandardOpenOption.READ)) {
// 直接从文件通道读取数据到直接缓冲区
int bytesRead = fileChannel.read(directBuffer);
System.out.println("Read " + bytesRead + " bytes from file.");
directBuffer.flip(); // 切换到读模式

// 处理缓冲区中的数据
while (directBuffer.hasRemaining()) {
// System.out.print((char) directBuffer.get()); // 处理数据
}
}
// 当 directBuffer 不再被引用或其 Cleaner 被执行时,底层内存会被释放
}
  1. 网络操作。使用NIO和Channel进行网络数据传输,例如 Netty 框架大量使用直接内存,并且使用内存池来管理,避免频繁向 OS 分配和释放内存。下面是一个示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class DirectMemoryNetworkIO {
public static void main(String[] args) throws IOException {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8080));

ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); // 用于发送或接收数据

// 写入数据到缓冲区
directBuffer.put("Hello from client".getBytes());
directBuffer.flip(); // 切换到读模式

// 直接从缓冲区发送数据到网络
while (directBuffer.hasRemaining()) {
socketChannel.write(directBuffer);
}
System.out.println("Sent data using direct buffer.");
}
}

类加载和对象创建

类加载过程-22223

本质是加载.class文件到方法区

1. 加载 Loading

主要是通过类加载器完成的。类加载器包括

  1. BootStrapClassLoader
    jvm中用C++实现的,负责加载最核心的类库 (jre/lib下的jar包),例如String类
  2. ExtClassLoader
    Java实现,加载jdk提供的扩展类 (jre/lib/ext目录下的类)
  3. AppClassLoader
    主要加载classpath所指定的目录
  • 一个类由哪个类加载器加载,是由双亲委派决定的:当用AppClassLoader去加载一个类时,AppClassLoader有一个parent属性指向了ExtClassLoader,当我们用AppClassLoader去加载一个类时,会先委托给ExtClassLoader去加载,而ExtClassLoader没有parent属性,所以会委派给BootstrapClassLoader去加载。只有BootstrapClassLoader没有加载到,才会由ExtClassLoader去加载,也只有ExtClassLoader没有加载到,才会由AppClassLoader来加载,这就是双亲委派。
双亲委派

每个类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

2/3/4. 连接 / 链接 Linking

2. 验证 Verification

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

包含四个校验阶段,有待研究⚠️

3. 准备 Preparation

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这时候进行内存分配的仅包括类变量,而不包括实例变量(注意对象在分配内存的时候会根据垃圾回收机制的不同,有不同的分配方法,即指针碰撞和空闲列表)。

从概念上讲,类变量所使用的内存都应当在方法区中进行分配。在 JDK 7 之前使用永久代来实现方法区时,符合“在方法区”的逻辑;但而在 JDK 7 及之后,原本在永久代的字符串常量池、静态变量等移动到了堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。

总之,这些类变量始终在堆,只不过之前方法区也在堆中,所以也算在方法区了;而后来方法区在本地内存,但这些类变量仍然在堆中。

注意:为类变量分配内存使用的是类加载器,同一时间只有一个线程在执行某个类的加载过程。

4. 解析 Resolution

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。

符号引用和直接引用

解析分为静态解析和动态解析,动态解析是多态的关键。例如某个类 TreeA 在某个方法中调用了 TreeBfuncB() 方法:

  1. 在编译阶段,TreeA 的字节码会包含一个指向 TreeB.funcB符号引用,存储在 TreeA 类的运行时常量池中。

  2. 在运行时,当第一次执行到这个调用点时,JVM 会对这个符号引用进行解析

  3. 如果 b 方法是虚方法(非 final、非 static、非 private),JVM 会根据当前实际调用对象的运行时类型(例如 MyTreeC),查找其虚方法表(VTable)

  4. MyTreeC 的虚方法表中,找到 b 方法对应的槽位,该槽位存储了指向实际 b 方法实现的直接引用

  5. 这个直接引用随后会用来替换掉 MyTreeA 运行时常量池中原来的符号引用。这样,后续再次调用时,就可以直接使用这个已解析的直接引用,而无需重新进行查找和解析过程,提升效率。

5. 初始化 Initialization

执行类的 <clinit>() 方法(class-init),该方法是由编译器按顺序收集以下两部分内容而产生的:1. 静态变量的赋值操作;2. 静态代码块。

其内部执行顺序按照源代码的定义顺序展开。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyClass {
// 1. 静态变量的赋值操作 (定义顺序:先是 intValue,后是 stringValue)
public static int intValue = 10;

// 2. 静态代码块 (定义顺序:在 intValue 和 stringValue 之间)
static {
System.out.println("Executing static block 1: intValue = " + intValue);
// intValue = 20; // 可以在静态代码块中修改静态变量
}

// 1. 静态变量的赋值操作 (定义顺序:在静态代码块之后)
public static String stringValue = "Hello";
}

6. 卸载

卸载类,即该类的 Class 对象被 GC,往往发生在Full GC,需要满足下面三个条件:

  1. 该类的所有的实例对象都已被GC。
  2. 对应的 Class 对象没有被引用,无法通过反射访问该类的。
  3. 该类的类加载器 ClassLoader 的实例已被GC

对象创建过程-54556

1. 类加载检查

当遇到 new 指令时,首先检查是否能在该类的运行时常量池中定位到这个类的符号引用,并且检查该符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2. 分配内存

对象所需的内存大小在类加载完成后便可确定,需要从 Java 堆中划分一块确定大小的区域。

  • 内存分配的两种方式:指针碰撞、空闲列表

  • 内存分配并发问题:对象的创建是频繁的,不同线程分配给对象的空间区域可能重叠。

    1. TLAB(Thread-Local Allocation Buffer): 每个线程预先在 Eden 区分配一块内存,JVM 优先使用 TLAB 内存,当不够用时,再采用 CAS 机制。
    2. CAS + 失败重试: CAS 是乐观锁的一种实现方式,即每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
      具体地,比如有一个 top 指针指向当前可用的内存起始位置。线程A先读取top旧值,然后计算分配完内存后top新值,在设置top新值的时候看旧值是否被改变;如果不符,则重试。

3. 初始化零值

把这块内存空间的所有类型全部初始化为零值,保证对象的实例字段可以不赋初始值就直接使用。常见零值如下:

基本数据类型的零值

4. 设置对象头

对象头包括两部分信息:

  1. 标记字段 (Mark Word)
    用于存储对象的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,标记清除的标记
  2. 类型指针 (Klass pointer)
    对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,在动态链接或多态的时候会用到

5. 执行构造方法