(图源:超过1W字深度剖析JVM常量池(全网最详细最有深度))
运行时数据区
概述
JDK1.8
- 线程私有的
- 虚拟机栈
- 本地方法栈
- 程序计数器
- 线程共享的
- 堆
- 方法区(JDK1.7 Hotspot虚拟机是通过永久代来实现的,JDK1.8是通过元空间(与堆不相连)来实现的)
- 直接内存(非运行时数据区的一部分)
Java虚拟机栈
作用
Java中,除了native方法,其他所有方法的调用都是通过虚拟机栈来实现的。方法调用的数据需要通过栈进行传递,每一次方法调用,都会有一个栈帧被压入栈,每一个方法调用结束后,都会有一个栈帧被弹出。
每个栈帧都包含:局部变量表、操作数栈、动态链接、方法返回地址。
(图源:JVM成神之路(3):详解运行时数据区)
局部变量表
局部变量表是以数组的方式存放了各种编译器已知的变量,包括方法的this指针、方法的传参、方法中声明的局部变量。(局部变量表只存储基本数据类型和对象引用,不能存储数组和方法等类型)
一个存储单位是一个slot。局部变量表的长度,是编译期就已知的,字节码文件中的locals参数就是局部变量表的长度。局部变量表中有start、length、slot、name、signature这几个参数。
- Name:变量的名称
- Signature:变量的?
- Start:表示从哪个哪行字节码开始生效
- Length:表示生效的字节码行数长度
- Slot:表示这个变量占用局部变量表中的起始位置。long和double占用2个slot,其他变量占用1个slot(引用类型的变量呢?)
总结
- 局部变量表的存储位置是在栈帧上,不需要额外的内存分配,因此具有高效的空间利用率。
- 局部变量表的大小是由编译期决定的,不能在运行时修改。
- 局部变量表只能存储基本数据类型和对象引用,不能存储数组和方法等类型。
- 局部变量表的读写是由 Java 虚拟机直接管理的,因此访问局部变量的效率比访问成员变量要高。
操作数栈
(用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。)
操作数栈的作用就是根据字节码指令,往栈中写入或弹出数据。操作数栈的最大深度也是在编译期就确定的,可以在字节码文件的Code属性中的max_stack参数中看到。
当一个方法开始执行的时候,栈帧被创建出来之后,操作数栈的初始状态的空的。随着方法的执行,开始不断地有入栈和出栈的操作。
案例分析。。。
动态链接
主要用于:一个方法需要调用其他方法的场景。Class文件的常量池里保存有大量的符号引用,比如方法引用的符号引用。当一个方法要调用其他方法时,需要将常量池中指向方法的符号引导转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态链接
Java虚拟机栈的异常
栈空间是有限的,但一般正常调用的情况下不会出现问题。但是如果函数调用出现无限循环,就会导致栈中被压入太多栈帧,导致栈空间过深。如果此时栈的内存大小不允许动态扩展?,当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就会出现StackOverFlowError
错误。
除了StackOverFlowError
错误之外,栈还可能会出现OutOfMemoryError
。因为如果栈的内存大小可以动态扩展?,如果虚拟机在动态扩展时无法申请到足够的内存空间,就会抛出OutOfMemoryError
异常。
案例分析
源码
// 注意该方法不是static的
public class LocalVariTable {
public void main(String[] args) {
double d = 2.0d;
}
}
对字节码文件执行javap -v LocalVariTable.class
,结果如下
D:\JavaLearning\basic-notes\target\classes\jvm>javap -v LocalVariTable.class
Classfile /D:/JavaLearning/basic-notes/target/classes/jvm/LocalVariTable.class
Last modified 2024-2-24; size 468 bytes
MD5 checksum c32ea39bca66887ce1aec3bf0bb0c85c
Compiled from "LocalVariTable.java"
public class jvm.LocalVariTable
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
// 运行时常量池
Constant pool:
#1 = Methodref #5.#22 // java/lang/Object."<init>":()V
#2 = Double 2.0d
#4 = Class #23 // jvm/LocalVariTable
#5 = Class #24 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Ljvm/LocalVariTable;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 d
#18 = Utf8 D
#19 = Utf8 MethodParameters
#20 = Utf8 SourceFile
#21 = Utf8 LocalVariTable.java
#22 = NameAndType #6:#7 // "<init>":()V
#23 = Utf8 jvm/LocalVariTable
#24 = Utf8 java/lang/Object
{
public jvm.LocalVariTable();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ljvm/LocalVariTable;
public void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC
// 方法运行的字节码,前面是代表行号?
Code:
// 局部变量表的长度locals是4
// 操作数栈的最大深度statck是2
stack=2, locals=4, args_size=2
0: ldc2_w #2 // double 2.0d
3: dstore_2
4: return
LineNumberTable:
line 5: 0
line 6: 4
// 局部变量表详情
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ljvm/LocalVariTable; // this指针,占首位,作用域0-5行字节码全都有效
0 5 1 args [Ljava/lang/String;
4 1 2 d D
MethodParameters:
Name Flags
args
}
SourceFile: "LocalVariTable.java"
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
程序计数器
程序计数器只占用较小的内存空间,主要有以下2个作用:
- 程序计数器可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时,通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器可以起到记录当前线程执行位置的作用,等线程切换回来的时候,能够知道线程运行到哪了。
⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
Java堆
概述
Java虚拟机中占用内存最大的一块,堆是所有线程共享的区域,在虚拟机启动的时候创建。这个内存区域的作用就是存放对象实例。
Java中绝大部分的对象实例都是存放在堆中,但是有一部分实例是直接在栈上创建的(随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。)
Java堆是垃圾回收器管理的主要区域,因此也被称作GC堆。从垃圾回收的角度,因为现在收集器采用的都是分代回收,所以堆可以细分为:新生代、老年代。
在JDK7及以前,堆内存通常被分为3部分:新生代内存、老生代、永久代。 JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。 (我会在方法区这部分内容详细介绍到)。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
堆的异常
这里最容易出现的就是 OutOfMemoryError
错误,并且出现这种错误之后的表现形式还会有几种,比如:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置,若没有特别配置,将会使用默认值
方法区
运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。
概述
方法区是《Java虚拟机规范》中定义的一个逻辑区域。在JDK1.8前后,分别通过永久代和元空间来实现方法区的概念。
(图源:JavaGuide.cn)
方法区的作用
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
为什么要将永久代替换为元空间(了解即可)
详见:JavaGuide.cn
方法区常用参数有哪些
JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小。
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。下面是一些常用参数:
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
运行时常量池
有关运行时常量池的字节码解释可以看:JavaGuide-JVM-类文件结构详解-常量池
概述
字节码(.Class)文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
常量池表会在类加载后存放到方法区的运行时常量池中。
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError
错误。
字面量和符号引用
-
字面量
可以理解为实际值(包括整数、浮点数和字符串字面量),int a = 8中的8 和 String a = "hello"中的hello都是字面量
-
符号引用
常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
就是一个字符串,只要我们在代码中引用了一个非字面量的东西,不管它是变量还是常量,它都只是由一个字符串定义的符号,这个字符串存在常量池里,类加载的时候第一次加载到这个符号时,就会将这个符号引用(字符串)解析成直接引用(指针)
《深入理解 Java 虚拟机》7.34 节第三版对符号引用和直接引用的解释如下:
案例分析
。。。
字符串常量池
详见文章:JVM:字符串常量池
非运行时数据区
直接内存
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError
错误出现。
直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
堆外内存
堆外内存就是把内存对象分配在堆外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。