目录
- 一、JVM 基本认识
- 二、类加载子系统(Class Loader SubSystem)介绍
- 三、类加载器
- 四、双亲委派机制(Parents Delegation Model)
- 五、运行时数据区
- 六、运行时数据区——程序计数器(Program Counter Register)
- 七、运行时数据区——虚拟机栈(Virtual Machine Stacks)
- 八、运行时数据区——本地方法栈(Native Method Stack)
- 九、运行时数据区——堆(Heap)
一、JVM 基本认识
1、虚拟机 与 JVM
- 虚拟机(Virtual Machine)
可以理解为一台虚拟的计算机,其是一款软件,用来执行一系列虚拟的计算机指令
可以分为:系统(硬件)虚拟机、程序(软件)虚拟机。
- 系统(硬件)虚拟机
系统虚拟机是一个可以运行完整操作系统的一个平台,其模拟了物理计算机的硬件。即相当于在物理计算机上 模拟出 一台计算机(操作系统)。比如:VMware。
- 程序(软件)虚拟机
程序虚拟机是一个可以运行某个计算机程序的一个平台,其模拟了物理计算机某些硬件功能(比如:处理器、堆栈、寄存器等)、具备相应的指令系统(字节码指令)。即相当于在操作系统上 模拟出 一个软件运行平台。比如:JVM。
- JVM
JVM(Java Virtual Machine),是一台执行字节码指令并运行程序的虚拟机,其字节码并不一定由 Java 语言编译而成,任何一个语言通过 编译器 生成 具备 JVM 规范的字节码文件时,均可以被 JVM 解释并执行(即 JVM 是一个跨语言的平台)。
特点:自动内存管理、自动垃圾回收。字节码一次编译,到处运行。
官方文档地址(自行选择合适的版本):https://docs.oracle.com/javase/specs/index.html
- 学习 JVM 的目的
一般进行 Java 开发时,不需要关注太底层的东西,专注于业务逻辑层面。这是因为 JVM 已经对底层技术、硬件、操作系统这些方面做了相应的处理(JVM 已经帮我们完成了 硬件平台的兼容以及内存资源管理 等工作)。
但由于 JVM 跨平台的特性,其会牺牲一些硬件相关的性能以达到 统一虚拟平台 的效果。当 程序使用人数 增大、业务逻辑复杂时,程序的性能、稳定性、可靠性会受到影响,往往提升硬件的性能也不能成比例的提高程序的性能。
所以有必要了解 JVM 一些底层运行原理,写出适合 JVM 运行、优化 的代码,从而提高程序性能(当然也可以快速定位、解决内存溢出等问题)。
2、JVM 整体结构
Java 文件编译过程图
如下图,Java 源码经过 Java 编译器,将源码编译为字节码,再使用 JVM 解析运行字节码。
JVM 结构图
如下图,字节码文件被类加载器导入,加载、验证字节码文件的正确性并分配初始化内存。
通过执行引擎解释执行字节码文件,并与 运行时数据区 进行数据交互(当然,其中逻辑实现没那么简单,此处略过)。
JVM 分类
虚拟机 内部处理指令流可以分为两种:基于栈
的指令集架构、基于寄存器
的指令集架构。
- 基于栈架构特点:
不需要硬件的支持,可移植性好(跨平台方便)、设计与实现简单、指令集小但指令会变多(可能会影响效率)。一般 JVM 都是基于栈的,比如:HotSpot 虚拟机。
- 基于寄存器架构特点:
依赖于硬件,可移植性差、但指令少(使用更少的指令执行更多的操作,执行效率稍高)。比如: Android 的 Dalvik 虚拟机
【举例:(执行如下操作时)】
int a = 2;
int b = 3;
a += b;
【基于栈的指令集架构:(输出字节码如下)】
0: iconst_2 常量 2
1: istore_1 常量 2 入栈
2: iconst_3 常量 3
3: istore_2 常量 3 入栈
4: iload_1
5: iload_2
6: iadd 2 + 3 相加
7: istore_1 将相加结果 5 入栈
【基于寄存器的指令集架构:(没有实际操做、大致指令如下)】
mov a, 2 将 2 赋给 a
add a, 3 将 a 加 3 后再将结果 赋给 a
可以看到 使用寄存器时,指令数量明显少于栈。
注:
直接打开 class 字节码文件会乱码,可以通过 javap -c
字节码文件 来反编译,得到可读的字节码文件。(也可以使用 IDEA 插件 bytecode viewer 或者 jclasslib bytecode viewer 去查看字节码,此处不做过多介绍)
javap -v XXX.class
对代码进行反编译,并显示额外信息(比如:常量池等信息)
JVM 生命周期简述
JVM 生命周期 即 JVM 从创建、使用、销毁的整个过程。
- JVM 启动
通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来启动 JVM。这个初始类由虚拟机的具体实现指定(JVM 种类很多)。
- JVM 使用(执行)
JVM 用于运行程序,每个 java 代码编写的程序启动运行都会存在一个 JVM 进程与之对应。程序结束后,JVM 也就结束。
- JVM 销毁
【正常销毁:】
程序正常结束。
【异常销毁:】
程序执行中出现异常,且异常未被处理导致 JVM 终止。
由于操作系统异常,导致 JVM 进程结束。
调用 System.exit() 方法,参数为非 0 时 JVM 退出。
简单了解几个虚拟机
- 解释器:根据字节码文件,一行一行读取解析并执行(立即执行,响应时间短,效率较低)。
- 即时编译器:把整个字节码文件编译成 可执行的机器码(需要响应时间、造成卡顿),机器码能直接在平台运行,将一些重复出现的代码(热点代码)缓存起来提高执行效率。
(1)Sun Classic VM(被淘汰)
Sun 公司开发的第一款商用虚拟机(在JDK 1.4 时被淘汰)。
内部只提供解释器
(解释器、即时编译器不能配合工作,二选一使用)。
(2)Sun Exact VM(被淘汰)
为了解决 Classic VM 的问题,Sun 公司提供了此虚拟机(被 HotSpot 替代)。
解释器、编译器混合工作模式。且具备热点探测功能。
使用 Exact Memory Management(准确式内存管理),可以知道内存中某位置的数据的类型。
(3)HotSpot(主流)
HotSpot 虚拟机采用 解释器、即时编译器 并存的架构,是 JVM 高性能代表作之一。一家小公司开发,被 Sun 公司收购。
HotSpot 即热点(热点探测功能),通过 计数器 找到最具有编译价值的代码,触发即时编译(方法被频繁调用)或者栈上替换(方法中循环次数多)。
通过解释器、即时编译器协同工作,在响应时间与执行性能中取得平衡。
如下图:Java 8 依旧采用 HotSpot 作为 JVM。
(4)BEA JRockit(主流)
专注于服务端应用,代码由 即时编译器 编译执行,不包含解释器(即不关心程序启动速度)。
是 JVM 高性能代表作之一,执行速度最快(大量行业数据测试后得出)。
BEA 被 Sun 公司收购,Sun 公司被 Oracle 收购。Oracle 以 HotSpot 为基础,融合了 JRockit 的优秀特性(垃圾回收器、MissionControl)。
(5) IBM J9(主流)
市场定位与 HotSpot 接近。广泛应用于 IBM 各种 Java 产品。也是高性能 JVM 代表作之一。
二、类加载子系统(Class Loader SubSystem)介绍
1、类加载子系统作用、流程
作用
类加载子系统负责从 文件系统 或者 网络 中加载 class 文件(class 文件头部有特殊标识)。
将 class 文件加载到系统内存中,并对数据进行 校验、解析 以及 初始化操作,最终形成可以被虚拟机使用的 Java 类型。
注:
类加载器只负责 class 文件的加载,由执行引擎决定是否能够运行。
加载的类信息 存放于名为 方法区 的内存空间中,方法区还会存放 运行时常量池等信息。
工作流程
类的生命周期
指的是 类从被加载到内存开始、到从内存中移除结束。
过程如下图所示:
而类加载过程
,需要关心的就是前几步(加载 到 初始化)。需要注意的是,解析操作可能会在 初始化之后执行(比如:Java 的运行期绑定)。
流程图如下:
2、加载(Loading)
(1)目的:
加载 class 二进制字节流文件到内存中。
(2)步骤:
- 使用类加载器 通过一个类的全限定名 去获取此类的 二进制字节流(获取方式开放)。
- 将字节流 对应的静态存储结构 转为 方法区 运行时的数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区中这个类的各种数据访问的外部入口(HotSpot 中,该 Class 对象存放于方法区中)。
(3)获取 class 二进制字节流方式(简单列举几个)
- 从本地系统直接加载。
- 从网络中加载(比如: Applet)。
- 从 zip 压缩包中读取(jar、war 包等)。
- 运行时计算生成(动态代理技术)。
- 从数据库中读取(比较少见)。
- 从其他文件中读取(JSP 文件生成对应的 Class 类)。
3、连接(Linking)——验证(Verification)
(1)目的:
确保 class 文件的二进制字节流中包含的信息符合当前虚拟机的要求,保证数据的正确性 而不会影响虚拟机自身的安全(比如:通过某种方式修改了 class 文件,若不去验证字节流是否符合格式,则可能导致虚拟机载入错误字节流而崩溃)。
注:
验证阶段非常重要但不一定必要,如果代码是经过反复使用、验证过后并没有出现问题,可以考虑将验证关闭-Xverify:none
,从而缩短类加载时间。
(2)验证方式:
具体细节自行查阅相关文档、书籍,此处来源于 “深入理解 JAVA 虚拟机 第二版 周志明 著”。
Step1:文件格式验证
验证字节流是否符合 class 文件格式规范,并能够被当前虚拟机处理。
比如:class 文件要以 CAFEBABE 开头
Step2:元数据验证
对类的 元数据 信息进行语义校验,验证当前数据是否符合 Java 语言规范。
比如:类是否存在父类、是否继承了 final 修饰的类等。
Step3:字节码验证
对类的方法体进行语义校验。
比如:方法体中类型的转换是否有效。
Step4:符号引用验证。
对常量池中各符号引用进行匹配性校验(一般发生在 解析阶段)。
比如:符号引用中通过字符串描述的全限定名能否找到对应的类。
注:
文件格式验证 是 基于 二进制字节流 进行的,通过验证后,会将数据存入 内存的方法区。后续三种验证均是对方法区数据进行操作。
4、连接(Linking)——准备(Preparation)
(1)目的:
为类变量
分配内存并设置类变量的默认初始值为 零值(比如:int 为 0, boolean 为 false)。
注:
此处的类变量是 static 修饰的变量,但不包含 final static 修饰的变量。
final static 修饰的即为常量,在编译时就已经设置好了,在 准备阶段(preparation)会赋值。
static 修饰的变量在 准备阶段赋零值,在 初始化阶段(Initialization)执行真正赋值操作。
非 static 修饰的变量为 实例变量,随着对象分配到 堆中,并非存在于方法区中。
【举例:】
public static int value = 123;
此时 value 属于类变量,准备阶段 value = 0,初始化阶段 value = 123
public static final int value = 123;
此时 value 属于常量,准备阶段 value = 123
(2)零值
数据类型 零值
int 0
long 0L
short (short)0
byte (byte)0
char '\u0000'
float 0.0f
double 0.0d
boolean false
reference null
5、连接(Linking)——解析(Resolution)
(1)目的:
将常量池中的 符号引用 转换为 直接引用。
注:
符号引用(Symbolic References)
:指用一组符号(字面量)来描述所引用的目标,但引用目标并不一定加载到了内存中。字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
public class StringAndStringBuilder{
public static void main(String[] args){
String s ="asdfa";
System.out.println ("s=" + s);
}
}
直接引用(Direct References)
:指直接指向目标的指针 或 能间接定位到目标的句柄,引用目标一定存在于内存中。
public class StringAndStringBuilder{
public static void main(String[] args){
System.out.println ("s=" + "asdfa");
}
}
(2)解析动作
解析动作主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符 这 7 类符号引用(具体解析过程此处略过,自行查阅文档、书籍)。
6、初始化(Initialization)
(1)目的
在准备阶段是为 类变量赋零值,而初始化阶段就是真正执行类中相关代码操作,是执行类构造器 <clinit>() 方法的过程。
(2)值得注意的点
<clinit>() 方法是 编译器自动收集类中所有 类变量的赋值操作、静态语句块(static{}) 等语句合并而成的。且其顺序是由 语句在源文件中出现的顺序而定的。
<clinit>() 方法不同于 类的构造函数(实例构造器 <init>()),若当前类具有父类,则当前类执行 <clinit>() 之前 父类的 <clinit>() 方法就已经执行完毕了。对于父接口,当前类执行时不会执行父类接口的 <clinit>() 方法,只有使用到类变量时才会去实例化(接口中不能定义 静态语句块,可以存在类变量,即常量)。
若一个类中没有类变量以及静态语句块,则不会生成 <clinit>()。
在多线程下,虚拟机会保证一个类的 <clinit>() 方法被加锁、同步,即一个线程执行 <clinit>() 后,其余执行到此处的线程均会阻塞,直至当前线程执行完毕。其他线程不会再次执行 <clinit>()。
(3)初始化的方式
当类被主动使用时,会导致类的初始化。而被动使用时,不会导致类的初始化。
主动使用:
- 使用 new 关键字实例化对象时。
- 读取、设置某个类、接口的静态变量时(非 final static 修饰的常量)。
- 调用某个类的静态方法时。
- 初始化一个类的子类时(先触发父类初始化)。
- JVM 启动时被标明为启动类的类(main 方法所在的类)。
- 反射调用某类时。
- java.lang.invoke.MethodHandle 实例(JDK 7 之后提供的动态语言支持)的解析结果REF_static、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化。
被动使用:
除了上面 7 种情况之外的情况都是被动使用,不会导致类的初始化。
三、类加载器
目的
前面加载过程的第一步:使用类加载器 通过一个类的全限定名 去获取此类的 二进制字节流。这个类加载器可以由用户自定义实现(在 JVM 外部去实现),使程序可以自定义以何种方式去获取需要的类(当然一般使用 JVM 提供的即可)。
注:
每一个类加载器,都有一个独立的类名称空间,对于任何一个类,该类与加载它的类加载器共同确定它在 JVM 中的唯一性(即判断两个类是否相同,需要保证两个类由同一个 JVM 且同一个类加载器加载时才有可能相等)。
分类
JVM层面
- 引导类加载器(Bootstrap ClassLoader)、其他所有类的类加载器。注:引导类加载器,由 C/C++ 语言编写,是 JVM 的一部分,其实例对象无法被获取。
- 其他所有类的类加载器,由 Java 语言开发,独立于 JVM,且派生于 java.lang.ClassLoader。
开发人员层面
- 引导类加载器(Bootstrap ClassLoader)
用来加载 Java 的核心类库(JAVA_HOME/jre/lib 或者 sun.boot.class.path 下的内容),且出于安全考虑,其只加载包名为 java、javax、sun 等开头的类。
- 扩展类加载器(Extension ClassLoader)
由 Java 语言编写,派生于 ClassLoader(sun.misc.Launcher$ExtClassLoader),其父类为引导类加载器(但是代码中获取不到),用来加载 Java 的扩展类(加载系统属性 java.ext.dirs 或者 jre/lib/ext 下的内容)。
- 应用程序类加载器(Application ClassLoader)
由 Java 语言编写,派生于 ClassLoader(sun.misc.Launcher$AppClassLoader),其父类为扩展类加载器。是程序中默认的类加载器(一般类均由其完成加载),负责加载环境变量(classpath) 或者系统属性 java.class.path 指定的路径下的内容。
- 用户自定义类加载器(User-Defined ClassLoader)
自定义类的加载方式,可以用于拓展加载源、修改类的加载方式。
ClassLoader
ClassLoader 是一个抽象类,除了引导类加载器,其余所有类加载器均由其派生而来。
常见获取方式
【方式一:获取当前类的 ClassLoader(调用当前类的 getClassLoader() 方法))】
ClassLoader classLoader = String.class.getClassLoader();
【方式二:获取当前系统的 ClassLoader(即 sun.misc.Launcher$AppClassLoader)】
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
【方式三:获取当前线程上下文的 ClassLoader】
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
【举例:】
public class JVMDemo {
public static void main(String[] args) {
// 自定义类(JVMDemo),使用默认类加载器加载(系统类加载器)
ClassLoader jvmDemoClassLoader = JVMDemo.class.getClassLoader();
// 获取自定义类 的类加载器
System.out.println(jvmDemoClassLoader); // 为默认类加载器sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取自定义类 的父类加载器(拓展类加载器)
System.out.println(jvmDemoClassLoader.getParent()); // 为拓展类加载器 sun.misc.Launcher$ExtClassLoader@4554617c
// 获取拓展 类加载器 的父类加载器(引导类加载器)
System.out.println(jvmDemoClassLoader.getParent().getParent()); // 为引导类加载器,获取不到,为 null
// 核心类(String),使用引导类加载器加载
ClassLoader stringClassLoader = String.class.getClassLoader();
// 获取核心类 的类加载器
System.out.println(stringClassLoader); // 为引导类加载器,获取不到,为 null
// 获取系统类加载器
System.out.println(jvmDemoClassLoader.getSystemClassLoader()); // 为 sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(stringClassLoader.getSystemClassLoader()); // 为 sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取当前线程的 类加载器
System.out.println(Thread.currentThread().getContextClassLoader()); // 为 sun.misc.Launcher$AppClassLoader@18b4aac2
}
}
常见 方法
【常见 ClassLoader 方法:】
ClassLoader getParent(); 返回该类加载器的 父类加载器
Class<?> loadClass(String name); 加载名为 name 的类,返回 Class 对象。
Class<?> findClass(String name); 查找名为 name 的类,返回 Class 对象。
Class<?> findLoadedClass(String name); 查找名为 name 被加载过的类,返回 Class 对象。
void resolveClass(Class<?> c); 解析指定的 Java 类。
【自定义类加载器步骤:(一般格式)】
Step1:继承 java.lang.ClassLoader,实现自定义类加载器。
Step2:重写 findClass() 逻辑。
【自定义类加载器步骤:(简单版)】
Step1:继承 java.net.URLClassLoader,该类已编写 findClass() 方法以及获取字节码流的方式。
四、双亲委派机制(Parents Delegation Model)
目的
- 使类加载器间具备层级结构。
- 防止类被重复加载。
- 保护程序安全,防止核心 API 被篡改。
(2)双亲委派机制原理
JVM 是采用懒加载的方式去加载 class 文件的,即使用到该类时,才会去加载其 class 文件到内存生成 class 对象。并且是采用双亲委派机制去加载。
原理
- 除了顶层的 引导类加载器外,其余的类加载器应该存在其 父类加载器。
- 如果一个类加载器 收到了 类加载 请求,其并不会立即去加载,而是把这个请求委托给 父类加载器 进行加载,若父类加载器 仍有 父类加载器,则继续向上委托,直至到达 引导类加载器。
- 如果父类加载器可以完成 类加载 请求,则成功返回,否则子类加载器才会去尝试加载。
如下为 ClassLoader 中的双亲委派实现:
- 先检查类是否被加载过,若该类没有被加载过,则调用父类加载器的 loadClass() 方法去加载。
- 若父类加载器不存在,则默认使用 引导类加载器为 父类加载器。如果父类加载失败后,则抛出异常,并执行子类的 findClass() 方法进行加载。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
沙箱安全机制
沙箱即限制一个程序的运行环境。
而 JVM 中沙箱安全机制 指将 Java代码限定在 JVM 运行范围内,限制代码对本地资源的访问,从而对代码隔离,防止核心 API 被修改。
如下图所示:
自定义一个 java.lang.String.class
,由于 JVM 存在双亲委派机制 这个类最终会使用 引导类加载器 对其加载,而 JVM 会先去加载 /jre/lib/rt.jar 下的 java.lang.String.class,但是其并没有 main 方法,所以报错。
再如下图所示:
自定义一个 java.lang.StringTe.class,同样 JVM 会使用 引导类加载器 加载,但是并没有加载到此类,所以会报错(SecurityException)。
五、运行时数据区
1、JVM 内存布局
(1)内存布局
内存是非常重要的系统资源,为了保证 JVM 高效稳定的运行,JVM 内存布局规定了 Java 在运行过程中内存 申请、分配、管理 的策略。不同 JVM 对于内存的划分方式以及管理机制存在部分差异。
(2)基本 JVM 内存布局
JVM 在执行 Java 程序过程中,会将其管理的内存 分为 若干个不同的数据区域,每个区域均有各自的用途(堆、方法区等)。有些区域随着 JVM 启动、退出 而创建、销毁,有的区域随着 用户线程的开始、结束 而创建、销毁。
如下图所示:
多个线程共享 堆、以及方法区(永久代、元空间)。
每个线程独有 程序计数器、虚拟机栈、本地方法栈。
2、运行时数据区各内存空间划分
(1)按线程是否共享划分
堆、方法区(元空间 或 永久代)线程共享。
虚拟机栈、本地方法栈、 程序计数器 线程私有。
(2)按抛出异常划分
堆、方法区 会发生 GC 以及 抛出 OOM(OutOfMemoryError)。
虚拟机栈、本地方法栈 会抛出 OOM 或者 StackOverflowError,不会发生 GC。
程序计数器 不会发生 GC 以及抛出 OOM 异常。
六、运行时数据区——程序计数器(Program Counter Register)
1、什么是程序计数器?
程序计数器是一块很小的内存空间,用于存放 下一条字节码指令 所在地址(即 即将执行的指令,由执行引擎读取下一条指令)。
是线程私有的(每个线程创建时均会创建)。
是 JVM 中唯一一个不会出现 OOM(OutOfMemory,内存溢出) 的区域。也不会存在 GC(Garbage Collection,垃圾回收)。
注:字节码解释器工作时,通过改变程序计数器的值来获取下一条需要执行的字节码指令(比如:分支、循环、跳转、异常处理、线程恢复等操作)。
2、每个线程独有程序计数器。
JVM多线程 通过 线程轮流切换 并 分配处理器执行时间 的方式 实现的。在任意一个时间点,一个处理器只会处理一个线程的指令,而为了使线程切换后能回到正确的位置(执行正确的指令),每个线程均会有个独立的程序计数器,各个线程间互不影响,通过各自的程序计数器执行正确的指令。
注:
若线程执行的是 Java 方法,程序计数器保存的是 即将执行的字节码指令的地址。
若线程执行的是 Native 方法,程序计数器保存的是 Undefined。
七、运行时数据区——虚拟机栈(Virtual Machine Stacks)
1、栈与堆?虚拟机栈?
(1)栈与堆?
- 在 JVM 运行时数据区 中可以理解
栈是运行时的单位
、堆时存储时的单位
。 - 栈解决的是程序运行问题,即 程序怎么执行、处理数据。
- 堆解决的是数据存储问题,即 数据怎么存储、放在何处。
(2)什么是虚拟机栈?
每个线程创建时均会创建一个虚拟机栈(线程私有),其内部保存着一个一个栈帧(Stack Frame),用于存储局部变量、操作结果,参与方法调用和返回。
注:
一个栈帧对应一个方法调用。即 一个栈帧从入栈 到 出栈 的过程,即为 一个方法从调用到完成的过程。
栈帧是一个内存区块,内部维护着方法执行过程中的各种数据信息(局部变量表、操作数栈、动态链接、方法出口、以及附加信息)。
2、虚拟机栈的常见异常?基本运行原理?基本内部结构?
(1)虚拟机栈常见异常?
JVM 规范中允许 虚拟机栈 的大小 是动态的 或者 是固定不变的。
如果采用固定大小的 Java 虚拟机栈,那每一个线程的虚拟机栈大小可以在 线程创建时指定,若线程请求分配的栈容量(深度)超过了虚拟机栈的最大容量(深度),将会导致 JVM 抛出 StackOverflowError 异常。
如果采用动态扩展容量的 虚拟机栈,若在尝试拓展的过程中无法申请到足够的内存(或者创建线程时没有足够的内存去创建对应的虚拟机栈),将会导致 JVM 抛出 OutOfMemoryError 异常。
如下图:
main 方法内存递归调用 main 方法,形成一个死循环(导致栈空间耗尽),最终导致 StackOverflowError。可以通过 -Xss 参数去设置 栈的大小。
(2)基本运行原理
虚拟机栈的操作只有两个:每个方法执行触发入栈操作,方法执行结束触发出栈操作。即栈帧的入栈、出栈操作(遵循 先进后出 FILO、后进先出 原则 LIFO)。
一个线程运行时,在一个时间点上只会存在一个活动的栈帧(方法),即当前栈顶栈帧 是有效的,如果当前方法中调用了其他方法,则会创建新的栈帧并入栈成为 新的栈顶栈帧。当新的栈帧执行结束后,会将执行结果返回给上一个栈帧,丢弃当前栈帧并将上一个栈帧重新作为新的栈顶栈帧。
(3)栈帧的内部结构分类
- 局部变量表(Local Variables)或者 局部变量数组。
- 操作数栈(Operand Stack)或者 表达式栈。
- 动态链接(Dynamic Linking)或者 指向运行时常量池(Constant pool)的方法引用。
- 方法返回地址(Return Address)。
- 附加信息。
3、栈帧结构——局部变量表(Local Variables)
(1)什么是局部变量表?
一组变量值存储空间(可以理解为 数组),用于存储方法参数以及定义在方法体内部的局部变量。其包括的数据类型为:基本数据类型(int、long、double 等)对象引用(reference)以及 方法返回地址(returnAddress)。
局部变量表建立在 虚拟机栈 上,属于线程独有数据,即不会出现线程安全问题。
被局部变量表直接或间接引用的对象不会被 GC(垃圾回收)。
局部变量表所需容量大小是在编译期就确定下来的,方法运行期间不会改变其大小(即编译期就可以知道该方法需要几个局部变量 以及 其所占用的 slot 空间)。
注:
32 位以内长度类型只会占用一个 局部变量表空间(slot),比如:short、byte、boolean 等。
64 位类型会占用两个 局部变量表空间,比如:long、double。
(2)举例
如下图:
静态方法没有 this 变量,若为 构造器方法 或者 实例方法,会存在一个 this 变量。
此处 main() 方法中存在 4 个变量,其中 b 为 double 型,占用两个 slot 空间,args 为引用类型,占用 1 个空间,也即总空间为 5。
start 表示变量开始生效的 字节码指令 行数。
如下图:
slot 可以被重用,当某个局部变量作用域结束后,其后续定义的新的局部变量可以占用 过期的 slot,可用于节省资源(但可能会影响 垃圾回收)。
4、栈帧结构——操作数栈(Operand Stack)
(1)什么是操作数栈?
每一个栈帧中包含一个 后进先出的 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据(入栈push)或者提取数据(出栈pop)。操作数栈主要用于保存计算过程中的中间结果、并作为计算过程中 变量 临时的存储空间。
如果被调用的方法(栈帧)存在返回值,则将其返回值压入操作数栈中,并更新程序计数器中下一条需要执行的字节码指令。
注:JVM 基于栈的架构,其中栈 指的即为 操作数栈。基于栈时 为了完成一项操作,会存在更多的入栈、出栈操作,而操作数存储在内存中,频繁入栈、出栈操作(内存写、读操作)必然会影响执行速度。HotSpot 设计者提出 栈顶缓存技术(TOS,Top-of-Stack Cashing) 解决这个问题,将栈顶元素全部缓存到 物理 CPU 的寄存器中,以降低内存的 读、写次数,提高执行引擎的执行效率。
5、栈帧结构——方法返回地址(return address)
(1)什么是方法返回地址?
方法结束的方式有两种:正常执行完成(Normal Method Invocation Completion)、出现异常退出(Abort Method Invocation Completion)。无论哪种方式退出,均需要回到方法被调用的位置。
方法正常退出时,即当前栈帧出栈,并恢复上一次栈帧(可能涉及操作:恢复上一次栈帧的局部变量表以及操作数栈、存在返回值时会将返回值压入操作数栈、调整 程序计数器 使其指向下一条指令)。
方法异常退出时,通过异常表(保存返回地址)查找是否有匹配的异常处理器,若没有对应的异常处理器,则会导致方法退出(栈帧一般不会保存返回地址,且一般不会产生返回值给 上一个栈帧)。
注:方法正常退出时,使用哪个返回指令由 方法返回值 的实际数据类型决定。
ireturn 返回值为 boolean、byte、char、short、int 时的返回指令
lreturn 返回值为 long 时的返回指令
freturn 返回值为 float 时的返回指令
dreturn 返回值为 double 时的返回指令
areturn 返回值为 引用类型 时的返回指令
return 返回值为 void、构造器方法等 无返回值时的返回指令
6、栈帧结构——动态链接(Dynamic Linking)
Java 源文件编译成字节码文件时,所有的 变量 以及 方法 都作为符号引用保存在 class 文件的运行时常量池中(对字节码文件执行 javap -v XXX.class
即可看到)。每一个栈帧内部都包含一个指向 运行时常量池中 该栈帧对应的 方法的引用(即符号引用),使用符号引用的目的 是为了使 当前方法的代码 支持 动态链接(详见下面的方法调用)。
7、方法调用(方法重载、方法重写)
Java 常用方法操作 有方法重载、方法重写。那编译器如何去识别 真实调用的方法呢?
(1)基本概念
-
静态链接:类加载字节码文件时,若被调用的目标方法 在编译期可知且运行期不变时,此时将调用方法的符号引用转为直接引用的过程叫 静态链接(发生在 类加载的 连接 的 解析阶段)。
-
注:类加载的 连接 的 解析(Resolution)阶段,会将常量池中一部分符号引用 转为 直接引用。而解析的前提就是:方法在程序运行之前(编译时就已确定)能够确定下来,不会发生改变。
-
动态链接:若被调用的目标方法在 编译期无法被确定下来,即需要在程序运行时将 符号引用转为 直接引用 的过程 叫做动态链接。
-
方法绑定:绑定是一个字段、方法或者 类 的符号引用 被替换到 直接引用的过程,仅发生一次。可以分为早期绑定、晚期绑定。早期绑定是 方法编译期可知且运行期不变时进行绑定,也即通过静态链接的方式绑定。晚期绑定是 方法运行期根据实际类型绑定,即通过动态链接的方式绑定。
-
非虚方法:非虚方法指的是 编译期就确定且 运行期不可变的方法。在类加载阶段就会将 符号引用 解析为 直接引用。
-
常见类型为:静态方法、私有方法、final 方法、实例构造器、父类方法(即不可被重写的方法)。
-
虚方法:非虚方法之外的方法(即需要运行期确定的方法)。
(2)方法调用相关虚拟机指令
【普通指令:】
invokestatic 调用静态方法
invokespecial 调用实例构造器 <init> 方法、私有方法、父类方法
invokevirtual 调用虚方法(final 方法除外)
invokeinterface 调用接口方法(运行期确定实现此接口的对象)
注:
这四条指令固化在虚拟机内部,方法调用执行不可被人为干预。
invokestatic、invokespecial 指令调用的方法为 非虚方法,
invokevirtual(除 final 方法)、invokeinterface 指令调用的方法为 虚方法。
final 修饰的方法也由 invokevirtual 指令调用,但其为 非虚方法。
【动态调用指令:】
invokedynamic 动态解析出需要调用的方法并执行
注:
支持人为干预。
Java 为了支持 动态类型语言,在 JDK 7 中增加了 invokedynamic 指令,
但 JDK 7 中并没有直接提供该指令,需要借助 ASM 等底层字节码工具实现。
直至 JDK 8 中 Lambda 表达式出现才有直接生成 invokedynamic 指令的方式。
【动态类型语言、静态类型语言:】
二者区别在于 类型检查 发生的时期。
动态类型语言 对类型检查 是在运行期,即变量没有类型信息、变量值才有类型信息(比如: JavaScript)。
静态类型语言 对类型检查 是在编译期,即变量有类型信息(比如:Java)。
比如:
Java: String hello = "hello"; hello = 10; // 编译报错
JS: var hello = "hello"; hello = 10; // 可以运行成功
(3)方法重载
接下来再看看 方法重载 与 方法重写。涉及到多个方法(多态),虚拟机如何去确定真实调用的是哪个方法呢(分派)?
如下代码(方法重载),最终输出结果是什么?
对于上述代码中:
Human man = new Man();
Human 为父类,Man 为子类,将 Human 称为变量 man 的静态类型(Static Type)
或者 外观类型(Apparent Type)
,将 Man 称为变量的实际类型(Actual Type)
。
静态类型 在编译期是可知的,而实际类型只有在 运行期才可以确定。
在编译期根据 静态类型 去定位方法执行 的(分派)动作称为 静态分派,而静态分派的典型代表就是 方法重载。静态分派发生在编译阶段,其动作不需要 JVM 去执行。
在运行期根据 实际类型 去定位方法执行 的(分派)动作称为 动态分派,而动态分派的典型代表就是方法重写。动态分派发生在运行阶段,其动作需要 JVM 去执行。
上述代码中,man 与 woman 的静态类型实际都是 Human,方法重载时,编译器根据静态类型去决定重载方法,也即在编译期就能确定到是 sayHello(Human human) 最终执行,故输出结果均为 Human。
(4)方法重写
方法重写的过程:
Step1:找到操作数栈顶的 第一个元素 所指向对象的实际类型,记为 C。
Step2:如果在类型 C 中查找到与常量池中 符号引用 所代表的描述符、简单名称都相符的方法,则进行访问权限校验,如果通过校验则返回该方法的直接引用,结束查找。若校验失败,则抛出异常 java.lang.IllegalAccessError。
Step3:若在类型 C 中未查找到相关方法,则根据继承关系从下到上 以及对 C 的父类执行 Step2 的查找与验证过程。
Step4:如果始终没有合适的方法,则抛出异常 java.lang.AbstractMethodError。
注:invokevirtual 指令执行第一步就是在运行期 确定 参数的实际类型,所以尽管两次执行的是 Human 的 sayHello() 方法,但最终执行的是 man 与 woman 的 sayHello() 方法。
8、虚方法表
平常开发中,方法重写是非常常见的,也即 动态分派 会频繁操作,如果每次动态分配都去 执行一遍 查找逻辑(在类的方法元数据中查找合适的目标方法),那么将有可能影响执行效率。为了提高性能, JVM 在类的方法区中 建立了一个 虚方法表(virtual method table,vtable)实现,使用虚方法表的索引来替代元数据 以提高性能。类似的,在 invokeinterface 指令执行时会用到接口方法表(interface method table,itable)。
虚方法表会在 类加载的链接阶段被创建并初始化,准备阶段 给类变量 赋初始值后,JVM 会把该类的方法表也进行初始化。
虚方法表中存放着每个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址 与 父类方法的地址一致(均指向父类的 方法入口)。如果子类重写了某方法,则子类的虚方法表的地址将 为指向子类的 方法入口。
如下图(图片来源于网络):
Father、Son 均没有重写 Object 的方法,所以虚方法表中均指向 Object。
而 Son 重写了 Father 的两个方法,所以 Son 的两个方法均指向自己,没有指向其父类 Father。
八、运行时数据区——本地方法栈(Native Method Stack)
1、本地方法接口(Native Method Interface)
(1)什么是本地方法?
本地方法(Native Method)是非 Java 语言编写的方法,比如 C、C++ 语言编写,而 Java 可以通过调用 本地方法接口 去使用 本地方法。
(2)为什么使用本地方法?
可以与 Java 外面的环境进行交互,简化逻辑。比如涉及一些底层操作时,使用 Java 较难实现,但使用 C 或者 C++ 可以很方便的实现,而本地方法采用 C 或者 C++ 很好的实现了功能,我们只需要调用这个本地方法接口 就可以很方便的使用其功能(不需要关心其实现逻辑)。
2、本地方法栈(Native Method Stack)
本地方法栈与 Java 虚拟机栈类似。但是 Java 虚拟机栈用来管理 Java 方法的调用。而 本地方法栈用来管理 本地方法的调用。
本地方法栈是线程私有的,在异常方面与 Java 虚拟机栈相同。
当线程调用 Java 方法时,JVM 会创建一个栈帧 并压入 虚拟机栈,但是调用 native 方法时,JVM 直接动态连接并指向 native 方法。
本地方法栈可以由 JVM 自由实现,比如:在 HotSpot 中,本地方法栈 与 虚拟机栈 合二为一。
九、运行时数据区——堆(Heap)
1、堆定义
Java 中的 堆 是 JVM 所管理内存中最大的一块区域,被所有线程共享(堆中也存有线程私有的分配缓冲区 Thread Local Allocation Buffer,TLAB)。
在 JVM 启动时创建(空间大小确定,可通过 -Xms、-Xmx调节)。
其目的是用于 存放 实例对象(对象实例、数组等)。
注:
- -Xms 用于设置堆的初始内存,等价于 -XX:InitialHeapSize。默认初始内存 = 物理内存 / 64。
- -Xmx 用于设置堆的最大内存,等价于 -XX:MaxHeapSize。默认最大内存 = 物理内存 / 4。
- 如果 堆 中内存大小超过 Xmx 所指定的最大内存时,将会抛出 OutOfMemoryError 异常。
- 一般将 -Xms 与 -Xmx 两个参数设置成相同的值,防止 GC 垃圾回收完 堆区 对象后重新计算堆区的大小,从而提高性能。
2、堆内存细分
现代垃圾收集器 大部分 基于分代收集理论,可以将堆空间 细分为如下几个区:
(1)JDK7 及 以前对 堆内存 划分:
- 新生区(年轻代、新生代、Young Generation Space)
- 养老区(老年代、老年区、Old Generation Space)
- 永久区(Permanent Space)
(2)JDK8 及 之后对 堆内存 划分:
- 新生区(年轻代、新生代、Young Generation Space)
- 养老区(老年代、老年区、Old Generation Space)
- 元空间(Meta Space)
一般讲堆空间,讲的是 新生代 与 老年代。
永久区 与 元空间 属于方法区的实现。
JVM 规范中指出 方法区 逻辑上属于堆,但并没有规定方法区具体实现方式,由 JVM 自行实现。
使用 java -Xlog:gc*
可以打印 GC 详细信息(可以看到堆相关信息)。
使用 JDK 自带的 jvisualvm
工具,可以分析 JVM 运行时的 JVM 参数、堆栈、CPU 等信息。
3、年轻代、老年代
无论年轻代 还是 老年代 都是用来存储 对象的,其不同的是 存储对象的 生命周期。
(1)什么是 年轻代、老年代?
堆中 对象按照生命周期 可以划分为两类:
生命周期较短的对象,这类对象的创建、消亡很快。
生命周期较长的对象,某些极端情况下 可能与 JVM 生命周期保持一致。
年轻代一般用于存储生命周期较短的对象,老年代一般用于存储生命周期较长的对象。
默认 年轻代 与 老年代 的比例为 1:2,即 年轻代 占堆空间 的 1/3。可以通过 -XX:NewRatio
来设置。比如: -XX:NewRatio=4,此时年轻代 : 老年代 = 1:4,即 年轻代占堆空间 1/5(但一般不会修改)。
(2)年轻代内部结构
年轻代内部又可以分为 Eden Space(伊甸园区)、Survivor0 Space(幸存0区)、Survivor1 Space(幸存1区)。其中 Survivor 又可以称为 from、to。from、to 大小相同,用于保存经过垃圾回收 幸存下来的 对象,且总有一个为空。
在 HotSpot 中,默认 Eden : Survivor0 : Survivor1 = 8:1:1(但是经过自适应后,显示出来的是 6:1:1,可以通过 -XX:SurvivorRatio=8 设置)。
几乎所有的对象 均创建在 Eden(80%,大于 Eden 内存的对象可直接进入 老年代),可以通过 -Xmn 设置新生代最大内存。
(3)为什么给堆分代?不分代就不能正常工作吗?
分代的唯一理由是 优化 GC 性能。
堆中存储对象的生命周期不同,且大部分生命周期非常短暂,如果不加管理(不分代)全部放在一起,则每次 GC 都需要全局扫描一次才可以知道哪些是需要被 回收的对象,每次都会扫描到很多不需要被回收的对象(生命周期长的对象),这样会极大影响效率。
而使用分代后(年轻代、老年代),将生命周期短的对象保存在年轻代,GC 多回收此处的对象,这样可以减少扫描数据,从而提高效率。
4、Minor GC、Major GC、Full GC
JVM 进行 GC 时,根据不同的内存空间 会有不同的 GC 算法与之对应。
(1)HotSpot 根据回收区域划分:
部分收集(Partial GC):
- Minor GC 针对 年轻代 进行 GC
- Major GC 针对 老年代 进行 GC
- Mixed GC 针对 整个新生代以及部分老年代 进行 GC
整堆收集(Full GC):
Full GC 针对 整个堆以及方法区 进行 GC
(2)Minor GC 触发时机:
年轻代空间(Eden)不足时,会触发 Minor GC。而 Java 对象生命周期一般较短,所以 Minor GC 非常频繁且回收速度也较快。Minor GC 执行会引发 STW(Stop The World),会暂停其他线程直至 GC 结束(可能造成 程序卡顿)。
(3)Major GC 触发时机:
老年代空间不足时,会触发 Major GC。Major GC 速度一般比 Minor GC 慢 10 倍以上(STW 时间更长),若经过一次 Major GC 后内存仍不足,则会抛出 OOM 异常。
(4)Full GC 触发时机:
调用 System.gc() 时,系统会建议执行 Full GC,但是不一定执行(应尽量避免此操作)。
大对象(占用大量连续内存空间的 java 对象)直接进入老年代,但老年代没有连续的空间存储,此时会触发 Full GC。
通过 Minor GC 进入老年代的平均大小 大于老年代的 可用内存,会触发 Full GC。
方法区空间不足时,会触发 Full GC。