Java-JVM-运行时数据区
参考:
0.是什么
本文基于HotSpotJVM
JVM
是Java Virtual Machine的缩写,即Java虚拟机。它能够运行编译后的 Java 字节码,使 Java 程序具有跨平台的特性。
JVM 并不会在安装JDK或JRE时自动启动,当我们启动一个Java程序的时候,JVM才会启动并加载字节码文件。
以hello world为例:
1.编写Java代码
创建一个Java源文件,例如
HelloWorld.java
。
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
2.编译Java代码
使用
javac
命令将Java源文件编译为字节码文件(.class)。
javac HelloWorld.java
3.执行Java程序
使用
java
命令执行编译后的字节码文件,这时JVM会启动并加载字节码文件。
java HelloWorld
当运行 java HelloWorld
时,JVM的启动过程如下:
-
加载类文件:JVM会从类路径中查找
HelloWorld
类,并将其加载到内存中。 -
执行类初始化:执行类的静态初始化块和静态变量的初始化。
-
执行main方法:找到并执行
HelloWorld
类的main
方法。
1.为什么
JVM的存在主要是为了实现Java语言的跨平台特性,使得编写一次代码即可在任何安装了JVM的平台上运行,同时提供自动内存管理和垃圾回收机制,简化了开发者的内存管理工作。
序号 | 优势 | 描述 |
---|---|---|
1 | 跨平台性 | 实现了 "Write Once, Run Anywhere"(WORA)理念,确保 Java 程序可以在任何安装了 JVM 的平台上运行。 |
2 | 内存管理和垃圾回收 | 提供自动内存管理和垃圾回收机制,减少内存泄漏和其他内存管理错误的风险。 |
3 | 安全性 | 通过字节码验证和沙箱机制,确保 Java 应用程序的运行安全。 |
4 | 高效的性能优化 | 包含即时编译(JIT)、热点代码优化和逃逸分析等多种性能优化技术,提高 Java 程序的执行效率。 |
5 | 多语言支持 | 支持多种编程语言(如 Scala、Kotlin、Groovy、Clojure 等),使得 JVM 成为一个多语言的运行时平台。 |
6 | 良好的生态系统和社区支持 | 拥有庞大的开发者社区和成熟的生态系统,提供丰富的工具和资源。 |
7 | 方便的多线程支持 | 提供强大的多线程支持,简化并发编程,管理线程的创建和调度。 |
2.JVM的逻辑结构
程序启动了嘛,内存跑起来了,内存管理的时候,JVM定义了一些逻辑上的概念。
为了执行字节码,JVM在内存中定义了一系列的数据区,用于在运行时存储各类数据,即运行时数据区(Runtime Data Areas)
莫慌,大概就5个主要结构。
中文 | 英文 | 功能描述 | 线程共享情况 |
---|---|---|---|
堆 | Heap Area | 存储所有Java对象实例,此区域是对象被分配内存和垃圾回收的场所。 | 共享 |
方法区 | Method Area | 存储已被虚拟机加载的类信息、常量、静态变量及即时编译后的代码。 | 共享 |
程序计数器 | Program Counter Register | 记录线程当前执行的指令地址。若执行Java方法,则指向当前指令的地址;若执行Native方法,则计数器值为空(Undefined)。 | 私有 |
虚拟机栈 | JVM Stack | 存储着该线程执行方法的所有栈帧。每个栈帧包含局部变量表、操作数栈、动态链接信息等。 | 私有 |
本地方法栈 | Native Method Stack | 用于支持Native方法的执行。服务于执行Native代码的栈,功能与虚拟机栈类似。 | 私有 |
2.1 程序计数器
PC Register,Register注册的意思嘛,意思是,这玩意是物理寄存器的一个逻辑抽象。
简单点理解,这玩意就是记录下线程当前要执行的指令地址,别的啥都不干。
那要这么个玩意干嘛?假设线程中断或者切换了,好,gg了,回来的时候,从哪继续?有这个就方便多了,别管,反正我按照这个地址搞就完事。
特点 | 描述 |
---|---|
线程私有 | 每个线程都有自己的程序计数器,这是线程私有的内存区域。每个线程在创建时会分配一个程序计数器,初始值通常设置为0。 线程执行完了自己销毁了,无需管理吧,没有GC。 |
指示待执行的指令地址 | Java方法:记录当前要执行的字节码指令的地址,根据这个执行完了后,被解释器更新为下一条指令地址。 Native方法:程序计数器的值为未定义(Undefined)。 |
无需垃圾回收 | 程序计数器是一块相对简单的内存区域,在生命周期内不需要垃圾回收。 没有OOM(OutOfMemoryError),操作嘛无非就是去更新覆盖值,也不会追加千百个,怎么个溢出法? |
用代码说明下计数器的作用。
public class Za {
public static void main(String[] args) {
System.out.println("hello");
}
}
看下字节码
0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
3 ldc #3 <hello>
5 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
8 return
PC干嘛呢?
1. 初始化阶段
PC初始值:程序计数器(PC)初始值为0,指向第一条指令getstatic。
2. 执行getstatic指令
PC = 0
当前指令:getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
动作:将System.out加载到操作数栈。
执行完毕,PC更新为3,指向ldc指令。
3. 执行ldc指令
PC = 3
当前指令:ldc #3 <hello>
动作:将常量池中的字符串"hello"加载到操作数栈。
执行完毕,PC更新为5,指向invokevirtual指令。
4. 执行invokevirtual指令
PC = 5
当前指令:invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
动作:调用PrintStream的println方法,打印栈顶的字符串"hello"。
执行完毕,PC更新为8,指向return指令。
5. 执行return指令
PC = 8
当前指令:return
动作:从当前方法返回。
执行完毕,方法执行结束,PC的值不再指向当前方法的任何指令。
那么PC这个值是谁来更新的呢?字节码解释器
解释器负责读取、解释和执行每一条字节码指令,并在执行完当前指令后,计算下一条指令的地址并更新程序计数器。以下是详细的解释和机制:
字节码解释器的工作机制
- 取指令:解释器从程序计数器(PC)指向的位置读取当前字节码指令。
- 解释指令:解释器根据读取的字节码指令(操作码和操作数)进行相应的操作。
- 更新PC:在执行完当前指令后,解释器根据当前指令的长度计算下一条指令的地址,并更新程序计数器的值。
2.2 虚拟机栈
Java栈、Java方法栈,都是说的这玩意,注意啊,方法栈。
栈呢,就是你想的那个栈,线程里无非就是执行方法吧?每个方法就是所谓的栈帧,叠在一起就是所谓的方法栈。
有啥用?执行方法的时候入栈,执行完了出栈。哈,那为啥不用队列?m1方法没执行完你main方法还想跑出来啊。
特点 | 描述 |
---|---|
线程私有 | 每个线程都有自己的Java栈,与线程的生命周期同步创建和销毁。线程执行完事,对应的栈就消失了,无需GC。 |
生命周期 | Java栈的生命周期与其所属线程的生命周期相同。 |
存储结构 | 由多个栈帧组成,每个栈帧包含局部变量表、操作数栈和动态链接信息等。 |
固定大小 | 局部变量表的大小在编译时确定,运行时不变。 |
异常处理 | 可能抛出StackOverflowError(栈深度过大)和OutOfMemoryError(内存不足或栈扩展失败)。 |
功能 | 是执行Java方法的工作区,负责方法调用的执行和完成。 |
2.2.1 栈的栈帧
上面提到了虚拟机栈是由栈帧组成的,栈帧呢,内部结构是这样。
组件 | 描述 |
---|---|
局部变量表 (Local Variables) | 存储方法参数和方法内定义的局部变量。 根据数据类型,每个变量可能占用一个或两个槽位(如 long 和double 类型)。局部变量表的大小在编译时已确定,并在运行时不变。 |
操作数栈 (Operand Stack) | 栈结构,故是后进先出(LIFO)的,用于存放操作指令过程中的输入和输出参数。 操作数栈的最大深度在编译时确定。 |
动态链接 (Dynamic Linking) | 每个栈帧内部包含对运行时常量池的引用,支持方法、字段或类的动态链接。 |
方法返回地址 (Return Address) | 当方法调用完成后,控制权需返回到调用者。方法返回地址记录了这一跳转位置的程序计数器(PC)值。 |
附加信息 (Frame Data) | 可包括调试信息和异常处理表,后者用于在方法执行中处理异常情况。 |
下面介绍两个比较重点的局部变量表和操作数栈
2.2.1.1 局部变量表
局部变量表,就是存放局部变量用的,代码编译完了,我们就能知道哪些是局部变量。
在载入局部变量表时,局部变量没有静态变量那样的初始化操作,故局部变量没有初值,需手动设置初值,否则编译不通过。
局部变量表中每个变量的值咋来的?
-
线程在执行Java方法时,首先会将方法参数放入到局部变量表。
-
根据执行时的需要,进行赋值。
- 基本类型:值直接存储在局部变量表中。
- 引用类型:先在堆中创建对象,再将引用放入到局部变量表中。
槽位是什么?
那上面的表格里不是又在扯啥垃圾话槽位
,啥是槽位,就是一个逻辑上的单元,那我说上面的key-value
结合起来构成一个槽位也没问题吧?再来个属性也没问题吧,只是一个概念性的东西。
局部变量表是一个数组,其中每个槽位可以存储一个int
、float
、reference
(引用类型),或者是一个returnAddress
类型的数据。
对于long
和double
类型的数据,因为它们占用两个单位的存储空间,所以会占用连续的两个槽位。
对象很大时值存的下吗?
-
基本类型,如
int
或double
,局部变量表直接存储它们的值。 -
对象类型,局部变量表存储指向堆中对象的引用。
2.2.1.2 操作数栈
为计算而生,存储计算过程中用到的临时数据。
操作数栈,就是放运行中的实际数据的,比如说像a=10
这里,10就是我们的操作数。
2.2.2 运行实例
现在结合一段代码来理解下:
public class Za {
public static void main(String[] args) {
int a = 10;
int b = 20;
int c = a + b;
System.out.println(c);
}
}
注意啊,我们现在每个方法对应的是栈帧,此处的字节码如下。
0 bipush 10
2 istore_1
3 bipush 20
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
13 iload_3
14 invokevirtual #3 <java/io/PrintStream.println : (I)V>
17 return
你看到上面,很容易能意识到,a、b、c和形参的args都是当前方法的,局部变量表如下。
好,注意,这是Class文件编译后的局部变量表,不是运行时结构,你看不到具体的值是啥。
结合这个方法的字节码来看下运行过程。
- 最开始的时候,局部变量表中没有值,操作数栈也是空的。
- 0 bipush 10
bipush
(byte push)指令用于将一个单字节(-128至127)的常量值立即推送到操作数栈上。
- 2 istore_1
istore
(integer store)指令用于将栈顶的整数值存储到局部变量表中指定的索引位置,这里是位置1。
- 3 bipush 20
-
5 istore_2
存到2号位置
-
6 iload_1
iload
(integer load)指令则用于将指定局部变量表中的整数值加载到操作数栈上,这里是加载1号位置。
- 7 iload_2
- 8 iadd
iadd
(integer add)是一个执行整数加法的指令,它从操作数栈中弹出两个整数,将它们相加,然后将结果再次推送到栈顶。
- 9 istore_3
存到3号位置
-
10 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
- 获取指定类的静态字段值,并将其推送到操作数栈顶。
- 这里,它从
java.lang.System
类获取静态字段out
。 - 字段的描述符为
Ljava/io/PrintStream;
,表示这是PrintStream
类的一个对象引用。
总结下就是:将
PrintStream
类的静态字段System.out
对应的引用(一个指向PrintStream
对象的地址)压入操作数栈顶。 -
13 iload_3
-
14 invokevirtual #3 <java/io/PrintStream.println : (I)V>
- invokevirtual 调用实例方法
- 调用之前压入栈顶的
PrintStream
对象对应的实例方法println
,该方法接受一个整型参数(从操作数栈顶获取,这里是iload_3
加载的值) - 执行打印操作
-
17 return
使用无返回值的
return
指令
就这么两个玩意就能玩出花,值也存了,计算也ok了,太厉害啦。
同样的,可以类比,比如我形参是3个,压栈的时候是不是可以压栈3个参数进来。
public class Za {
public static void main(String[] args) {
System.out.println(m1(10, 20, 30));
}
public static int m1(int a, int b, int c) {
return a + b + c;
}
}
看下main方法的字节码。
0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
3 bipush 10
5 bipush 20
7 bipush 30
9 invokestatic #3 <cn/yang37/za/Za.m1 : (III)I>
12 invokevirtual #4 <java/io/PrintStream.println : (I)V>
15 return
加载方法,压栈3个参数,调用Za.m1。
那么m1方法呢,原来连加就是套用两个数的加法呀。
0 iload_0
1 iload_1
2 iadd
3 iload_2
4 iadd
5 ireturn
2.2.3 栈中常见的异常
- StackOverflowError:方法太多啦,栈放不下了,死循环递归分分钟打满。-Xss参数,设置虚拟机栈大小,eg:-Xss256k
- OutOfMemoryError:线程搞的太多了,每个线程都想搂一个自己的栈,搂不出来了,没内存用了。
2.3 本地方法栈
本地方法栈用于管理本地方法(Native方法)调用,本地方法栈也是线程私有的。
额,不多写了,可以参考这个:理解JVM运行时数据区(四)本地方法栈
2.4 方法区
用于存储已被虚拟机加载的类信息、常量、静态变量,以及即时编译器编译后的代码等。
在HotSpot JVM中,方法区是一个逻辑上的概念,也被称为非堆(Non-Heap),一般用来存储类加载信息、static变量、JIT实时编译缓存的代码、常量池(Constants Pool)等。
不同版本的Java其方法区的实现方式不同,在JDK 8之前,采用的是“永久代”来实现方法区,而在JDK 8之后则是采用MetaSpace的方式来实现。
特征 | 描述 |
---|---|
类信息 | 存储每个类的结构信息,包括类的名称、父类、接口、方法和变量的数据,以及字节码。 |
运行时常量池 | 方法区的一部分,包含编译期生成的字面量和符号引用,这些内容在类加载后进入方法区的运行时常量池。 |
静态变量 | 存储类的所有静态变量,包括基本数据类型和引用类型的静态变量。 |
编译后的代码 | 存储即时编译器(JIT)编译后的代码,以提高程序执行的性能。 |
实现依赖 | 方法区的具体实现依赖于JVM的供应商,如HotSpot的永久代(PermGen)被Java 8中的元空间(MetaSpace)替代。 |
内存管理 | 元空间使用本地内存,其大小仅受本地内存的限制,这有助于减少OutOfMemoryError 的可能性。 |
异常情况 | 当方法区内存不足时,可能会抛出OutOfMemoryError 。 |
参考下这篇文章:【JVM系列】运行时Java类的内存营地——方法区详解
2.4.1 Java6到7
在Java 6中,方法区主要是通过永久代(PermGen)实现的,这个区域存储了类信息、静态变量、运行时常量池等。
由于永久代的大小是固定的,当加载大量类或大字符串时,容易造成内存溢出(OutOfMemoryError)。
到了Java 7,为了减轻这种溢出的风险,做了以下两个主要的调整:
- StringTable的移动
- 静态变量的移动
2.4.2 Java7到8
Java 8中的最大变革是完全去除了永久代,引入了元空间(Metaspace)。
- 元空间的引入:元空间使用本地内存(native memory),而不是虚拟机的内存。这意味着元空间的大小不再由Java堆的大小直接限制,而是受到系统可用内存的限制,从而大大提高了灵活性和可扩展性。
- 内存管理的改进:元空间的动态扩展能力减少了因类元数据过多导致的内存溢出的可能性。同时,类元数据的回收变得更加依赖于类加载器的生命周期,而不是简单的垃圾收集周期。
2.4.3 方法区和永久代?
注意啊,这里很绕。
方法区实在虚拟机规范里面被定义的,不同的虚拟机对这个定义的实现不同。
在HotSpot 虚拟机中在 jdk1.7 版本之前的方法区实现叫永久代(PermGen space),jdk1.8 之后叫做元空间(Metaspace)。
方法区是JVM规范中定义的,永久代是JVM实现(HotSpot)中对于方法区的实现。
2.5 堆
在Java虚拟机(JVM)中,堆(Heap)是Java内存管理的核心区域,主要用于存储Java应用程序创建的对象和数组。
这部分内存是由所有线程共享的,并且是垃圾收集器进行活动的主要区域。
堆的主要组成:
永久代在Java 8以后由元空间Metaspace取代
-
年轻代:新创建的对象首先被分配到年轻代。年轻代包含一个或多个Eden区以及两个Survivor空间。
-
老年代:存活时间较长的对象会从年轻代晋升到老年代。老年代的大小和生存周期比年轻代大和长得多。
-
永久代/元空间
- 1.7 永久代:永久代主要用于存储Java类和方法的元数据,还包括字符串常量池和静态变量(看2.4节的JDK7)。永久代有固定的物理大小,容易在加载大量类和大量字符串时溢出,导致
OutOfMemoryError: PermGen space
错误。 - 1.8 元空间:元空间的最大空间受本地内存限制,不再受Java堆的大小限制,从而避免了永久代的溢出问题。可以动态调整大小,减少了内存溢出的风险。
- 1.7 永久代:永久代主要用于存储Java类和方法的元数据,还包括字符串常量池和静态变量(看2.4节的JDK7)。永久代有固定的物理大小,容易在加载大量类和大量字符串时溢出,导致
堆的具体划分和垃圾回收,单独放另一篇文章说。
3.常见问题
3.1 堆和方法区?
在Java虚拟机(JVM)内存模型中,堆(Heap)和方法区(Method Area)是两个不同的概念,尽管它们有时在讨论中会有重叠,特别是在JDK 8之前的永久代(PermGen)实现中。
1.堆 (Heap)
- 主要作用:堆是用于存储所有Java对象实例和数组的内存区域。它是由所有线程共享的,Java对象的生命周期在这里进行管理,包括对象的创建和垃圾回收。
- 结构
- 年轻代 (Young Generation):包含新创建的对象,分为Eden区和两个Survivor区。
- 老年代 (Old Generation):包含从年轻代晋升上来的存活时间较长的对象。
- 永久代/元空间 (PermGen/Metaspace):在JDK 7之前,永久代是堆的一部分,用于存储类的元数据。JDK 8之后,永久代被元空间取代,元空间使用本地内存而非堆内存。
2.方法区 (Method Area)
- 主要作用:方法区是JVM规范中定义的内存区域,用于存储每个类的元数据、常量、静态变量和即时编译后的代码等。它是所有线程共享的。
- 实现
- 永久代 (PermGen):在JDK 7及之前版本中,方法区的实现依赖于永久代。永久代是堆的一部分,存储类元数据、运行时常量池、静态变量和字符串常量。
- 元空间 (Metaspace):在JDK 8及之后版本中,方法区的实现改为元空间。元空间不再是堆的一部分,而是使用本地内存。这减少了因固定大小的永久代带来的内存溢出问题。
3.区别和关系
- 永久代和方法区:在JDK 7及之前,永久代是方法区的一种实现方式,属于堆的一部分,用于存储类元数据、静态变量等。
- 元空间和方法区:在JDK 8及之后,元空间取代了永久代,作为方法区的实现方式。元空间使用本地内存,存储类元数据、运行时常量池等。
4.总结
-
方法区
是JVM规范定义的一部分,具体实现方式在不同版本的JDK中有所不同。
- 在JDK 6和JDK 7中,方法区由永久代实现,属于堆的一部分。
- 在JDK 8及之后,方法区由元空间实现,使用本地内存,不再是堆的一部分。
通过这些变动,Oracle在Java 8中显著优化了内存管理,提高了Java虚拟机的性能和可扩展性,减少了因永久代大小固定导致的内存溢出风险。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步