JVM - JVM基础
文章目录
- [JVM - JVM基础](#JVM - JVM基础)
-
- 一:类字节码详解
- 二:类加载机制(重点)
-
- 1:类的生命周期
-
- 1.1:加载Loading
- 1.2:链接Linking
-
- 验证:确保被加载的类的正确性
- [准备: 静态变量分配内存,并初始化为默认值](#准备: 静态变量分配内存,并初始化为默认值)
- [解析: 把类中的符号引用转换为直接引用](#解析: 把类中的符号引用转换为直接引用)
- 1.3:初始化Initialization
- 1.4:卸载
- 2:类加载器
- 3:类加载机制
- 4:自定义类加载器
- 三:JVM内存结构(重点)
-
- 1:程序计数器PC
- 2:虚拟机栈
-
- [2.1:栈的存储单位 - 栈帧](#2.1:栈的存储单位 - 栈帧)
- 2.2:栈的运行原理
- 2.3:栈帧的内部结构详解
- 3:本地方法栈
- 4:堆
-
- 4.1:内存划分
- 4.2:设置堆内存大小和OOM
- 4.3:对象在堆中的生命周期
- 4.4:GC垃圾回收简介
-
- [Minor GC、Major GC、Full GC](#Minor GC、Major GC、Full GC)
- TLAB
- 5:方法区
一:类字节码详解
1:Java为何能够跨平台
计算机是不能直接运行java代码的,必须要先运行java虚拟机,再由java虚拟机运行编译后的java代码,这个编译后的java代码就是字节码
为什么jvm不能直接运行java代码呢,这是因为在cpu层面看来计算机中所有的操作都是一个个指令的运行汇集而成的
java是高级语言,只有人类才能理解其逻辑,计算机是无法识别的,所以java代码必须要先编译成字节码文件,jvm才能正确识别代码转换后的指令并将其运行
- Java代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的JVM虚拟机去读取执行,从而实现一次编写,到处运行的目的
- JVM也不再只支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。
- Java跨平台的原理是不同平台的JVM是不同的,各个平台的JVM要做的是保证字节码可以在本平台正确的执行
2:Java字节码文件
class文件本质上是一个以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件中
jvm根据其特定的规则解析该二进制数据,从而得到相关信息
Class文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。
2.1:Class文件的结构属性
在理解之前先从整体看下java字节码文件包含了哪些类型的数据:
2.2:从一个例子开始
java
public class Test04 {
private int m = 1;
public int inc() {
return m + 1;
}
}
通过以下命令, 可以在当前所在路径下生成一个Test04.class文件
bash
javac Test04.java
以文本的形式打开生成的class文件,内容如下:
cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4d61 696e 2e6a 6176 610c
0007 0008 0c00 0500 0601 0010 636f 6d2f
7268 7974 686d 372f 4d61 696e 0100 106a
6176 612f 6c61 6e67 2f4f 626a 6563 7400
2100 0300 0400 0000 0100 0200 0500 0600
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0003 0001
000b 000c 0001 0009 0000 001f 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0100
0a00 0000 0600 0100 0000 0800 0100 0d00
0000 0200 0e
-
文件开头的4个字节("cafe babe")称之为魔数,唯有以"cafe babe"开头的class文件方可被虚拟机所接受 ,这4个字节就是字节码文件的身份识别。
-
0000是编译器jdk版本的次版本号0,0034转化为十进制是52,是主版本号,java的版本号从45开始,除1.0和1.1都是使用45.x外,以后每升一个大版本,版本号加一 。也就是说,编译生成该class文件的jdk版本为1.8.0
2.3:反编译字节码文件
输入命令javap -v -p Test04.class
查看输出内容:
java
// Class文件当前所在位置
Classfile /E:/Project/java_item/offer/src/main/java/Test04.class
// 最后修改时间,文件的大小
Last modified 2023-6-10; size 269 bytes
// MD5值
MD5 checksum 32c2694b1319dbda0b9310c566f0b1a7
// 编译自哪个文件
Compiled from "Test04.java"
// 类的全限定名,jdk次版本号,主版本号。
public class Test04
minor version: 0
major version: 52
// 类的访问标志, public类型,允许使用invokespecial字节码指令的新语义.
flags: ACC_PUBLIC, ACC_SUPER
// ========== 常量池 ==================
Constant pool:
#1 = Methodref #4.#15 // 方法定义,指向了第4和第15个常量,拼成java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // 字段定义,执行了第3和第16个常量,拼成Test04.m:I
#3 = Class #17 // Test04
#4 = Class #18 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 Test04.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 Test04
#18 = Utf8 java/lang/Object
// ============= 方法表集合 ================
{
// 私有成员变量m
private int m;
descriptor: I // 返回值为int
flags: ACC_PRIVATE // 私有的
// 默认的无参构造方法
public Test04();
descriptor: ()V // 声明这是一个返回值为void(V)的方法
flags: ACC_PUBLIC // public方法
Code:
stack=1, locals=1, args_size=1
0: aload_0 // this入栈
1: invokespecial #1 // 特殊调用构造方法:Method java/lang/Object."<init>":()V
4: return // 返回
LineNumberTable:
line 1: 0
// inc方法
public int inc();
descriptor: ()I // I -> int ()-> 函数,所以表示返回类型为int的函数
flags: ACC_PUBLIC // public方法
Code:
// 将this入栈,获取字段#2并置于栈顶[m], 将int类型的1入栈[m + 1中的1],将栈内顶部的两个数值相加,返回一个int类型的值
stack=2, locals=1, args_size=1
0: aload_0 // this对象入栈
1: getfield #2 // 获取字段#2并置于栈顶[m] Field m:I
4: iconst_1 // 将int类型的1入栈[m + 1中的1]
5: iadd // 将栈内顶部的两个数值相加 m + 1
6: ireturn // 返回一个int类型的值
LineNumberTable: // 描述源码行号与字节码行号(字节码偏移量)之间的对应关系。
line 5: 0
}
SourceFile: "Test04.java"
补充说明:类的访问标志
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为Public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语义. |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
补充说明:常量池
常量池可以理解成Class文件中的资源仓库
主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)
- 字面量类似于java中的常量概念,如文本字符串,final常量等...
- 而符号引用则属于编译原理方面的概念,包括以下三种:【⚠️ 注意这个
符号引用
,后面类加载的生命周期还会提到】- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
不同于C/C++, JVM是在加载Class文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。
当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。
字节码的类型定义如下:
标识字符 | 含义 |
---|---|
B | 基本类型byte |
C | 基本类型char |
D | 基本类型double |
F | 基本类型float |
I | 基本类型int |
J | 基本类型long |
S | 基本类型short |
Z | 基本类型boolean |
V | 特殊类型void |
L | 对象类型,以分号结尾,如Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个java.lang.String[][]
类型的维数组,将被记录为[[Ljava/lang/String;
补充说明:方法表集合
code内的主要属性为:
- stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度
- locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。
- args_size: 方法参数的个数,因为每个实例方法都会有一个隐藏参数this,所以最小是1
- attribute_info: 方法体内容
- LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。
- LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。
二:类加载机制(重点)
1:类的生命周期
类的加载过程包括了:加载,验证,准备,解析,初始化五个过程
在这五个阶段中,加载
、验证
、准备
和初始化
这四个阶段发生的顺序是确定的,而解析
阶段则不一定
解析是在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。
另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段
1.1:加载Loading
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。(get .class)
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。(操作方法区)
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。(生成的对象存入堆区)
类加载器并不需要等到某个类被"首次主动使用"时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它
如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)
如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误
1.2:链接Linking
验证:确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成4个阶段的检验动作:
- 文件格式验证: 验证字节流是否符合Class文件格式的规范;例如: 是否以
cafe babe
开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型 - 元数据验证: 对字节码描述的信息进行语义分析,例如:这个类是否有父类,除了java.lang.Object之外
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
- 符号引用验证:确保解析动作能正确执行。(类,接口,字段属性,方法)
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,
如果所引用的类经过反复验证,那么可以考虑采用
-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备: 静态变量分配内存,并初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 这时候进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。 - 这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在Java代码中被显式地赋予的值- 假设一个类变量的定义为:
public static int value = 3
;那么变量value在准备阶段过后的初始值为0
,而不是3
- 因为这时候尚未开始执行任何Java方法,而把value赋值为3的
put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的 - 把value赋值为3的动作将在初始化阶段才会执行。
- 假设一个类变量的定义为:
下面这些java基础也要注意
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值
- 对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过
- 对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而且会在编译的时候就将赋值结果放入了类的常量池中了【ConstantValue】 - 只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
- 对于引用数据类型
reference
来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
。 - 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
解析: 把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类
或接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
和调用点
限定符7类符号引用进行。
- 符号引用就是一组符号来描述目标,可以是任何字面量。
- 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
也就是找到对应类,方法,字段等的物理地址
1.3:初始化Initialization
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量指定初始值
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机:
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如
Class.forName("com.pdai.jvm.Test")
) - 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
1.4:卸载
Java虚拟机将结束生命周期的几种情况:
- 执行了
System.exit()
方法 - 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
2:类加载器
2.1:类加载器分类
站在Java虚拟机的角度
只存在两种不同的类加载器:
- 启动类加载器: 它使用C++实现(这里仅限于
Hotspot
,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分; - 所有其他的类加载器: 这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类
java.lang.ClassLoader
,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
站在Java开发人员的角度看
类加载器可以大致划分为以下三类:
- 启动类加载器: Bootstrap ClassLoader,负责加载存放在
JDK\jre\lib
(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath
参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。 - 扩展类加载器: Extension ClassLoader,该加载器由
sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext
目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。 - 应用程序类加载器: Application ClassLoader,该类加载器由
sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
自定义加载器
- 自定义加载器:应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
- 在执行非置信代码之前,自动验证数字签名。
- 动态地创建符合用户特定需要的定制化构建类。
- 从特定的场所取得java class,例如数据库中和网络中。
2.2:寻找类加载器
可以通过代码的方式向上找:
java
public static void main(String[] args) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
System.out.println(contextClassLoader);
System.out.println(contextClassLoader.getParent());
System.out.println(contextClassLoader.getParent().getParent());
}
从上面的结果可以看出,并没有获取到ExtClassLoader
的父Loader,原因是BootstrapLoader
(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式
2.3:类的加载
类加载有三种方式:
1、命令行启动应用时候由JVM初始化加载
2、通过Class.forName()
方法动态加载
3、通过ClassLoader.loadClass()
方法动态加载
java
public class Test05 {
static {
System.out.println("静态代码块正在执行");
}
}
public class Test04 {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader = Test05.class.getClassLoader();
System.out.println(classLoader);
// 1:使用ClassLoader.loadClass()来加载类,不会执行初始化块
classLoader.loadClass("Test05");
// 2:使用Class.forName()来加载类,默认会执行初始化块,只有这个会打印 -> 静态代码块正在执行
Class.forName("Test05");
// 3:使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
Class.forName("Test05", false, classLoader);
}
}
Class.forName()
和ClassLoader.loadClass()
区别?
Class.forName()
: 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;ClassLoader.loadClass()
: 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。Class.forName(name, initialize, loader)
: 带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象
3:类加载机制
3.1:类加载机制的分类
全盘负责
,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入父类委托
,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类缓存机制
,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效双亲委派机制
, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
3.2:双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上
因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类
java
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先判断该类型是否已经被加载
Class c = findLoadedClass(name);
if (c == null) {
//如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
//如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
//如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
双亲委派机制的优势
- 系统类防止内存中出现多份同样的字节码
- 保证Java程序安全稳定运行
tomcat不遵循双亲委派机制,自定义WebappClassLoader
自定义WebappClassLoader的原因是:为了解决不同应用的同名类,所以要实现类加载的隔离,每一个应用都有一个WebappClassLoader,用自己的加载
4:自定义类加载器
java
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
/**
* 要自定义只要重写这个方法就好了
* @param name 类的名称
* @return 自定的类加载器
* @throws ClassNotFoundException 类没有找到
*/
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
/**
* 加载类数据(核心,如果是加密的字节码则需要在该类中对文件进行解密)
* @param className 类名称
* @return 数据字节数组
*/
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
try {
// 这里只是简单的进行了文件的读写操作,加密的字节码需要解密
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while((length = ins.read(buffer)) != -1) {
byteArrayOutputStream.write(buffer, 0, length);
}
return byteArrayOutputStream.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot() {
this.root = root;
}
}
⚠️ 需要注意下面几点:
- 这里传递的文件名需要是类的全限定性名称,因为 defineClass 方法是按这种格式进行处理的。
- 最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
- 这类Test 类本身可以被 AppClassLoader 类加载
三:JVM内存结构(重点)
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。
JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。
不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。
下图是 JVM 整体架构,中间部分就是 Java 虚拟机定义的各种运行时数据区域。
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。
另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程私有:程序计数器、虚拟机栈、本地方法区
- 线程共享 :堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
1:程序计数器PC
程序计数寄存器(Program Counter Register),Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的线程信息,CPU 只有把数据装载到寄存器才能够运行。
这里,并非是广义上所指的物理寄存器,叫程序计数器(或PC计数器或指令计数器)会更加贴切,并且也不容易引起一些不必要的误会。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器 。
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
PC寄存器为什么会被设定为线程私有的?
多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。
为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响
很小,线程私有,程序控制流的指示器,不会溢出
- 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
- 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法 。
- 如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址
- 如果是执行 native 方法,则是未指定值(undefined)
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
- 它是唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError
情况的区域
2:虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java 栈。
每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈 (进栈/压栈),方法执行结束出栈
- 栈不存在垃圾回收问题
栈中可能出现的异常:
Java 虚拟机规范允许Java虚拟机栈的大小是动态的或者是固定不变的
- 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个OutOfMemoryError异常
可以通过参数-Xss
来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度
2.1:栈的存储单位 - 栈帧
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自有对应的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
2.2:栈的运行原理
JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈 和出栈,遵循"先进后出/后进先出"原则
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出
2.3:栈帧的内部结构详解
局部变量表(本地变量表)
局部变量表也被称为局部变量数组或者本地变量表
是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型、对象引用和 returnAddress 类型
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
局部变量表所需要的容量大小是编译期确定下来的 ,并保存在方法的 Code 属性的 maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。
对一个函数而言,它的参数和局部变量越多,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
槽slot
局部变量表最基本的存储单元是 Slot(变量槽)
在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot
- byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
- long 和 double 则占据两个 Slot
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。
栈帧中的局部变量表中的槽位是可以重用的 ,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
🎉 在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
🎉 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈(表达式栈)
操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的 max_stack
数据项中
栈中的任何一个元素都可以是任意的 Java 数据类型
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
栈顶缓存(Top-of-stack-Cashing)
HotSpot 的执行引擎采用的并非是基于寄存器的架构,但这并不代表 HotSpot VM 的实现并没有间接利用到寄存器资源。
寄存器是物理 CPU 中的组成部分之一,它同时也是 CPU 中非常重要的高速存储资源。
一般来说,寄存器的读/写速度非常迅速,甚至可以比内存的读/写速度快上几十倍不止,不过寄存器资源却非常有限,不同平台下的CPU 寄存器数量是不同和不规律的。
寄存器主要用于缓存本地机器指令、数值和下一条需要被执行的指令地址等数据。
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。
为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
动态链接(指向运行时常量池的方法引用)
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
方法返回地址
用来存放调用该方法的 PC 寄存器的值。
一个方法的结束,有两种方式:正常执行完成或者出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置
。
- 方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
- 而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
-
执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口
一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
-
在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。
例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现
3:本地方法栈
3.1:本地方法接口
简单的讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。
为什么要使用本地方法(Native Method)?
Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来也不容易,或者我们对程序的效率很在意时,问题就来了
- 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
- 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互
- Sun's Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。
3.2:本地方法栈
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈也是线程私有的
允许线程固定或者可动态扩展的内存大小
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个
StackOverflowError
异常 - 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个
OutofMemoryError
异常
本地方法是使用 C 语言实现的,它的具体做法是 Native Method Stack
中登记 native 方法,在 Execution Engine
执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。
在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一
4:堆
4.1:内存划分
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
- 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。
实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx
和 -Xms
控制)
如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError
异常
年轻代 (Young Generation)
年轻代是所有新对象创建的地方。
当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。
年轻一代被分为三个部分:伊甸园(Eden Memory )和两个幸存区(Survivor Memory ,被称为from/to或s0/s1),默认比例是8:1:1
- 大多数新创建的对象都位于 Eden 内存空间中
- 当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中
- Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
- 经过多次 GC 循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的(默认15),然后他们才有资格提升到老一代
老年代 (Old Generation)
旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。
通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝
元空间 (Meta Space)
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
4.2:设置堆内存大小和OOM
设置堆内存大小
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx
和 -Xms
来设定
-Xms
用来表示堆的起始内存,等价于-XX:InitialHeapSize
-Xmx
用来表示堆的最大内存,等价于-XX:MaxHeapSize
如果堆的内存大小超过 -Xmx
设定的最大内存, 就会抛出 OutOfMemoryError
异常。
我们通常会将 -Xmx
和 -Xms
两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能
- 默认情况下,初始堆内存大小为:电脑内存大小/64
- 默认情况下,最大堆内存大小为:电脑内存大小/4
查看堆内存分配
在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小
-
新老内存分配:默认情况下
新生代:老年代 = 1:2
,可以通过--XX:NewRatio
来配置 -
伊甸园,s0, s1:新生代中的
Eden:From Survivor:To Survivor = 8:1:1
,可以通过-XX:SurvivorRatio
来配置
若在 JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy
,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄
- 此时
--XX:NewRatio
和-XX:SurvivorRatio
将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy
- 在 JDK 8中,不要随意关闭
-XX:+UseAdaptiveSizePolicy
,除非对堆内存的划分有明确的规划
每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小
4.3:对象在堆中的生命周期
-
在 JVM 内存模型的堆中,堆被划分为新生代和老年代 -> 新生代又被进一步划分为 Eden区 和 Survivor区,Survivor 区由 From Survivor 和 To Survivor 组成
-
当创建一个对象时,对象会被优先分配到新生代的 Eden 区, 此时 JVM 会给对象定义一个对象年轻计数器 (
-XX:MaxTenuringThreshold
) -
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
- 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
- 如果分配的对象超过了
-XX:PetenureSizeThreshold
,对象会直接被分配到老年代
4.4:GC垃圾回收简介
Minor GC、Major GC、Full GC
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
TLAB
TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)是 Java 中内存分配的一个概念,它是在 Java 堆中划分出来的针对每个线程的内存区域,专门在该区域为该线程创建的对象分配内存。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。
TLAB 本质上还是在 Java 堆中的,因此在 TLAB 区域的对象,也可以被其他线程访问。
如果没有启用 TLAB,多个并发执行的线程需要创建对象、申请分配内存的时候,有可能在 Java 堆的同一个位置申请,这时就需要对拟分配的内存区域进行加锁或者采用 CAS 等操作,保证这个区域只能分配给一个线程。
启用了 TLAB 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域,在预留这个动作发生的时候,需要进行加锁或者采用 CAS 等操作进行保护,避免多个线程预留同一个区域。一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作。
5:方法区
5.1:概述
方法区(method area)只是JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据
,并没有规定如何去实现它
不同的厂商有不同的实现。而永久代PermGen是Hotspot虚拟机特有的概念,Java8 的时候又被元空间
取代了,永久代和元空间都可以理解为方法区的落地实现。
JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)
所以对于方法区,Java8 之后的变化:
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)
5.2:设置方法区内存的大小
JDK8 及以后:
- 元数据区大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,替代上述原有的两个参数 - 默认值依赖于平台。Windows 下,
-XX:MetaspaceSize
是 21M,-XX:MaxMetaspacaSize
的值是 -1,即没有限制 - 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常
OutOfMemoryError:Metaspace
-XX:MetaspaceSize
:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize
的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。- 如果释放的空间不足,那么在不超过
MaxMetaspaceSize
时,适当提高该值。 - 如果释放空间过多,则适当降低该值
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。
- 为了避免频繁 GC,建议将
-XX:MetaspaceSize
设置为一个相对较高的值
- 如果释放的空间不足,那么在不超过
5.3:方法区内部结构
类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表
域(Field)信息
JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)
方法信息
JVM 必须保存所有方法的
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
5.4:运行时常量池
在加载类和结构到虚拟机后,就会创建对应的运行时常量池
常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性 ,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
intern()
方法就是这样的
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。
5.5:方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。
字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
判定一个类型是否属于"不再被使用的类",需要同时满足三个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是"被允许",而并不是和对象一样不使用了就必然会回收。
是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose:class
以及 -XX:+TraceClassLoading
查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。