10JVM入门和栈

JVM入门

JVM 是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现。。。。可以看作是 Java 程序执行的环境,它隐藏了底层操作系统和硬件的复杂性,提供了一个统一、稳定和安全的运行平台。

JVM 大致可以划分为三个部门,分别是类加载器(Class Loader)、运行时数据区(Runtime Data Areas)和执行引擎(Excution Engine)

类加载器用来加载类文件,也就是 .class 文件。如果类文件加载失败,也就没有运行时数据区和执行引擎什么事了,它们什么也干不了。类加载器负责将字节码文件加载到内存中,主要会经历加载->连接->实例化这三个阶段,我们会放在后面的章节单独来讲。

运行时数据区就是Java 程序运行期间需要使用到的内存区域,简单来说,这块内存区域存放了字节码信息以及程序执行过程的数据,垃圾收集器也会针对运行时数据区进行对象回收的工作。

执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者

JVM如何运行指令

Java 代码首先被编译器转换为字节码,然后在 JVM 上运行。在运行时,JVM 通过解释执行或即时编译(JIT)将字节码转换为机器码。解释执行直接运行字节码,而 JIT 在运行时将热点代码编译优化为机器码以提升性能。

这中间需要运行时数据区来存储字节码数据以及运行时中间数据。

字节码是 JVM 中非常关键的内容,涉及到类的加载机制、字节码文件的结构、字节码指令的执行流程等等,

类加载器的介绍

之前也提到了它是负责加载.class 文件的,它们在文件开头会有特定的文件标示,将 class 文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且 ClassLoader 只负责 class 文件的加载,而是否能够运行则由 Execution Engine 来决定

创建对象的过程?

img在Java中创建对象的过程包括以下几个步骤:

  1. 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程
  2. 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始——构造函数,即class文件中的方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
维度 类加载 对象创建
本质 将类的字节码加载到JVM 生成类的实例(对象)
发生次数 只发生一次(类首次使用) 每次new都发生
谁负责 ClassLoader(JVM内部) JVM(执行new指令)
结果 生成 Class 对象(类的”蓝图”) 生成具体对象实例(”房子”)
依赖关系 必须先加载类,才能创建对象 依赖已加载的类
错误示例 类未加载 → ClassNotFoundException 类已加载,但 new 时出错(如构造方法异常)

2.1 先进行类的加载

从类被加载到虚拟机内存中开始,到释放内存总共有 7 个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接

[2.1.1 加载

  1. 将 class 文件加载到内存
  2. 将静态数据结构转化成方法区中运行时的数据结构
  3. 在堆中生成一个代表这个类的 java.lang.Class 对象作为数据访问的入口

[2.1.2 链接]

  1. 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查
  2. 准备:为 static 变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的)
  3. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在 import java.util.ArrayList 这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行)

[2.1.3 初始化]

初始化其实就是执行类构造器方法的<clinit>()的过程,而且要保证执行前父类的<clinit>()方法执行完毕。这个方法由编译器收集,顺序执行所有类变量(static 修饰的成员变量)显式初始化和静态代码块中语句。此时准备阶段时的那个 static int a 由默认初始化的 0 变成了显式初始化的 3。 由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了更改,会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值。

注意:字节码文件中初始化方法有两种,非静态资源初始化的<init>和静态资源初始化的<clinit>,类构造器方法<clinit>()不同于类的构造器,这些方法都是字节码文件中只能给 JVM 识别的特殊方法。

[2.1.4 卸载]

GC 将无用对象从内存中卸载

[2.2 类加载器的加载顺序]

加载一个 Class 类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

  1. BootStrap ClassLoader:rt.jar
  2. Extension ClassLoader: 加载扩展的 jar 包
  3. App ClassLoader:指定的 classpath 下面的 jar 包
  4. Custom ClassLoader:自定义的类加载器

双亲委派

双亲委派模型(Parent Delegation Model)是 Java 类加载器使用的一种机制,用于确保 Java 程序的稳定性和安全性。在这个模型中,类加载器在尝试加载一个类时,首先会委派给其父加载器去尝试加载这个类,只有在父加载器无法加载该类时,子加载器才会尝试自己去加载。

  1. 委派给父加载器:当一个类加载器接收到类加载的请求时,它首先不会尝试自己去加载这个类,而是将这个请求委派给它的父加载器。
  2. 递归委派:这个过程会递归向上进行,从启动类加载器到扩展加载器到根加载器)。
  3. 加载类:如果父加载器可以加载这个类,那么就使用父加载器的结果。如果父加载器无法加载这个类(它没有找到这个类),子加载器才会尝试自己去加载。
  4. 安全性和避免重复加载:这种机制可以确保不会重复加载类,并保护 Java 核心 API 的类不被恶意替换。

执行引擎Execution Engine

Execution Engine执行引擎负责解释命令,提交操作系统执行。

以下的为运行时数据区

本地方法接口Native Interface

本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序,Java 诞生的时候是 C/C++横行的时候,要想立足,必须有调用 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies

Native Method Stack

它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

PC寄存器(程序计数器)

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,即 将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

两个常见问题:

1、使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

答:JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

2、PC寄存器为什么会被设定为线程私有?

答:我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?

为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

Method Area方法区

方法区存储什么:

方法区是被所有线程共享。《深入理解Java虚拟机》书中对方法区存储内容的经典描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等:

运行时常量池,运行时常量池是每一个类或接口的常量在运行时的表现形式,它包括了编译器可知的数值字面量,以及运行期解析后才能获得的方法或字段的引用。简而言之,当一个方法或者变量被引用时,JVM 通过运行时常量区来查找方法或者变量在内存里的实际地址。

方法区演进细节:

Hotspot中方法区的变化:

补充:只有Hotspot才有永久代。BEA JRockit、IBM J9等来说,是不存在永久代的概念的

虚拟机栈stack

常见问题:栈溢出(是Error)

通常出现在递归调用时:

例如,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
package com.atguigu.jvmdemo.chap02;

public class StackRecurrenceDemo {

public static void main(String[] args) {
StackRecurrenceDemo.test();
}

public static void test(){
test();
}
}

1、Stack 栈是什么?

  • 栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,每个线程都有自己的栈,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,是线程私有的

  • 线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)

2、栈运行原理

  • JVM对Java栈的操作只有两个,就是对栈帧的压栈出栈,遵循“先进后出”或者“后进先出”原则。

栈帧是什么?

  • 一个线程中只能由一个正在执行的方法(当前方法),因此对应只会有一个活动的栈帧(当前栈帧)

当一个方法1被调用时就产生了一个栈帧1 并被压入到栈中,栈帧1位于栈底位置

方法1又调用了方法2,于是产生栈帧2 也被压入栈,

方法2又调用了方法3,于是产生栈帧3 也被压入栈,

……

执行完毕后,先弹出栈帧4,再弹出栈帧3,再弹出栈帧2,再弹出栈帧1,线程结束,栈释放。

例如,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.atguigu.jvmdemo.chap02;

public class StackDemo {

public static void main(String[] args) {
StackDemo test = new StackDemo();
test.method2();
System.out.println("main()结束");
}

public void method2(){
System.out.println("method2()执行...");
this.method3();
System.out.println("method2()结束...");
}

public void method3() {
System.out.println("method3()执行...");
this.method4();
System.out.println("method3()结束...");
}

public void method4() {
System.out.println("method4()执行...");
System.out.println("method4()结束...");
}
}

3、栈帧存储什么?

栈中的数据都是以栈帧(Stack Frame)的格式存在。栈帧是一个内存区块,是一个数据集,包含方法执行过程中的各种数据信息

3.1、局部变量表(Local Variables)

也叫本地变量表。

作用: 存储方法参数和方法体内的局部变量:8种基本类型变量、对象引用(reference)。

可以用如下方式查看字节码中一个方法内定义的的局部变量,当程序运行时,这些局部变量会被加载到局部变量表中。定义代码如下:

1
2
3
4
5
6
7
8
9
10
package com.atguigu.jvmdemo.chap02;
public class LocalVariableTableDemo {

public static void main(String[] args) {
int i = 100;
String s = "hello";
char c = 'c';
Date date = new Date();
}
}

查看局部变量:

1
类路径> javap -v 类名.class

3.2、操作数栈(Operand Stack)

作用:也是一个栈,在方法执行过程中根据字节码指令记录当前操作的数据,将它们入栈或出栈。用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间

3.3、帧数据区(Frame Data)

包括类信息、方法信息等等。

3.3.1、动态链接(Dynamic Linking)

作用:可以知道当前帧执行的是哪个方法。指向运行时常量池中方法的引用。

完整的内存结构图如下:

3.3.2、方法返回地址(Return Address)

方法返回地址存储的是调用该方法的程序计数器的值。

作用:可以知道调用完当前方法后,上一层方法接着做什么。

一个方法的结束,有两种方式,分别是正常执行完成结束和出现异常导致非正常结束。

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。

  • 方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。

  • 而通过异常退出的,返回地址是要通过异常表来确定。

3.3.3、一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。、

JVM内存模型里的堆和栈有什么区别?

  • 用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)
  • 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在GC检测到对象不再被引用时才被回收。
  • 存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
  • 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
  • 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。

栈中存的到底是指针还是对象?

当我们在栈中讨论“存储”时,实际上指的是存储基本类型的数据(如int, double等)和对象的引用,而不是对象本身。这里的关键点是,栈中存储的不是对象,而是对象的引用

方法区中的方法的执行过程?

当程序中通过对象或类直接调用某个方法时,主要包括以下几个步骤:

  • 解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
  • 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
  • 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。

内存泄漏和内存溢出的理解?

内存泄露:内存泄漏是指程序在运行过程中不再使用的对象仍然被引用,而无法被垃圾收集器回收,逐渐慢慢导致可用内存逐渐减少。虽然在Java中,垃圾回收机制会自动回收不再使用的对象,但如果有对象仍被不再使用的引用持有,垃圾收集器无法回收这些内存,最终可能导致程序的内存使用不断增加。

内存泄露常见原因:

  • 静态集合:使用静态数据结构(如HashMapArrayList)存储对象,且未清理。
  • 事件监听:未取消对事件源的监听,导致对象持续被引用。
  • 线程:未停止的线程可能持有对象引用,无法被回收。

内存溢出:内存溢出是指Java虚拟机(JVM)在申请内存时,无法找到足够的内存,最终引发OutOfMemoryError。这通常发生在堆内存不足以存放新创建的对象时。

内存溢出常见原因:

  • 大量对象创建:程序中不断创建大量对象,超出JVM堆的限制。
  • 持久引用:大型数据结构(如缓存、集合等)长时间持有对象引用,导致内存累积。
  • 递归调用:深度递归导致栈溢出

end


10JVM入门和栈
http://example.com/2025/11/14/10JVM入门和栈/
作者
無鎏雲
发布于
2025年11月14日
许可协议