JVM概述
JVM 整体结构
Java 执行流程
JVM 架构模型
Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构是基于寄存器的指令集架构
具体来说,这两种架构之间的区别:
- 基于栈的指令集架构
- 设计和实现更简单,适用于资源受限的系统;
- 避开了寄存器的分配难题:使用零地址指令方式分配;
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译更容易实现;
- 不需要硬件支持,可移植性好,更好的实现跨平台
- 基于寄存器的指令集架构
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机;
- 指令集架构则完全依赖硬件,可移植性差;
- 性能优秀且执行更高;
- 花费更少的指令去完成一项操作;
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
JVM 生命周期
虚拟机启动
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的
虚拟机执行
- 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。
- 程序开始执行时才运行,程序结束就结束。
- 执行一个所谓的Java程序的时候,真正执行的是一个叫做Java虚拟机的进程
虚拟机退出
发生以下情况时退出:
- 程序正常执行结束;
- 程序执行过程中发生异常或错误而异常终止;
- 由于操作系统出现错误而导致Java虚拟机进程终止;
- 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作;
- 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机的退出情况。
JVM 发展历程
1. Sun Classic VM
- 早在1996年Java1.0时,Sun公司发布了一款名为Sun Classic VM的Java虚拟机,他同时也是世界上第一款商用Java虚拟机,JDK1.4时被完全淘汰。
- 这款虚拟机内部只提供解释器。
- 如果使用JIT编译器,就需要进行外挂,但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统,解释器就不在工作。解析器和编译器不能配合工作
- 现在hotspot内置了此虚拟机
2. Exact VM
- 为了解决上一个虚拟机问题,JDK1.2时,Sun提供了此虚拟机
- Exact Memory Management:准确式内存管理
- 也可以叫Non-Conservative/Accurate Memory Management
- 虚拟机可以知道内存中的某个位置的数据具体是什么类型
- 具备现代高性能虚拟机的雏形
- 热点探测
- 编译器与解释器混合工作模式
- 只在Solaris平台短暂使用,其他平台还是classic vm
- 英雄气短,终被Hotspot虚拟机替换
3. HotSpot VM
- HotSpot历史
- 最初由一家名为“Longview Technologies”的小公司设计
- 1997年,此公司被Sun收购;2009年,Sun公司被家顾问公司收购。
- JDK1.3时,HotSpot VM成为默认虚拟机
- 目前HotSpot占有绝对的市场地位
- 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是HotSpot
- Sun/Oracle JDK和Open JDK的默认虚拟机
- 因此不做额外说明,该笔记都是针对HotSpot虚拟机
- 从服务器、桌面、嵌入式都有应用
- 名称中的HotSpot指的就是它的热点代码探测技术
- 通过计数器找到最具编译价值代码,触发及时编译或栈上替换
- 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中获得平衡
4. BEA 的 JRockit
- 专注于服务器端应用
- 它可以不太关注应用的启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行。
- 大量的行业基准测试显示,JRockit JVM是世界上最快的JVM
- 使用JRockit产品,客户已经体验到了显著的性能提升(一些超过了70%)和硬件成本减少(达50%)
- 优势:全面的Java运行时解决方案组合
- JRockit面向延迟敏感型应用的解决方案JRockit Real Time提供以毫秒或微妙级的JVM响应时间,适合财务、军事、电信网络的需要
- MissionCOntrol服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具。
- 2008年,BEA被Oracle收购。
- Oracle表达了整合两大优秀虚拟机的工作,大致在JDK8中完成。整合方式是在HotSpot的基础上,移植JRockit的优秀特性。
5. IBM 的 J9
- 全程:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9
- 市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM
- 广泛应用于IBM的各种Java产品
- 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机
- 2017年左右,IBM发布了开源J9 VM,命名为OpenJ9,交给Eclipse基金会管理,也成为Eclipse OpenJ9
6. KVM和CDC/CLDC Hotspot
- Oracle在Java ME产品线上的两款虚拟机为:CDC/CLDC HotSpot Implementation VM
- KVM(Kilobyte)是CLDC-HI早期产品
- 目前移动领域地位尴尬,智能手机被Android和iOS二分天下
- KVM简单、轻量、高可移植,面向更低端的设备上还维持自己的一片市场
- 智能控制器、传感器
- 老人手机、经济欠发达地区的功能手机
- 所有的虚拟机原则:一次编译,到处运行
7. Azul VM
- 前面三大“高性能Java虚拟机”使用在通用硬件平台上
- 这里Auzl VM和BEA Liquid VM是与特定硬件平台绑定、软硬件配合的专有虚拟机
- 高性能Java虚拟机中的战斗机
- Azul VM是Azul Systems公司在HotSpot基础上进行大量改进,运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机
- 每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内可控的GC时间的垃圾收集器、专有硬件优化的线程调度等优秀特性。
- 2010年,Azul Systems公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性
8. Liquid VM
- 高性能Java虚拟机中的战斗机
- BEA公司开发的,直接运行在自家的Hypevisor系统上
- Liquid VM即是现在的JRockit VE(Virtual Edition),Liquid VM不需要操作系统的支持,或者说他自己本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等
- 随着Jrockit虚拟机终止开发,LiquidVM项目也终止了
9. Apache Harmony
- Apache也曾推出过与JDK 1.5和JDK 1.6兼容的Java运行平台 Apache Harmony
- 它是IBM和Intel联合开发的开源JVM,受同样开源的OpenJDK的压制,Sun坚决不让Harmnoy获得JCP认证,最终于2011年退役,IBM转而参与OpenJDK
- 虽然目前没有Apache Harmnoy被大规模商用的案例,但是它的Java类库代码被吸纳进了Android SDK。
10. Miscrosoft JVM
- 微软为了让IE3浏览器中支持Java Applets,开发了Microsoft JVM。
- 只能在windows平台运行,是当时Windows下性能最好的Java VM。
- 1997年,Sun以侵犯商标、不正当竞争罪名指控微软成功。微软在WindowsXP SP3中抹掉了其VM。现在Windows上安装的JDK都是HotSpot
11. TaobaoJVM
- 由AliJVM团队发布。
- 基于OpenJDK开发了自己定制版本的AlibabaJDK,简称AJDK。是整个阿里Java体系的基石。
- 基于OpenJDK HotSpot VM 发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机。
- 创新的GCIH(GC invisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
- GCIH 中的对象还能在多个Java虚拟机
- 进程中实现共享
- 使用crc32指令实现JVM intrinsic 降低JNI的调用开销
- PMU hardware的Java profiling tool 和诊断协助功能
- 针对大数据场景的ZenGC
- taobao vm应用在阿里产品上性能高,硬件严重依赖intel的cpu,损失了兼容性,但提高了性能
- 目前已经在淘宝、天猫上限,把Oracle 官方的JVM版本全部替换
12. Dalvik VM
- 谷歌开发的,应用于Android系统,并且在Android2.2中提供了JIT,发展迅猛。
- Dalvik VM只能称作虚拟机,而不能称作Java虚拟机,它没有遵循Java虚拟机规范
- 不能直接执行Java的class文件
- 基于寄存器架构,不是JVM的栈架构
- 执行的是编译以后的dex(Dalvik Executable)文件。执行效率比较高。
- 他执行的dex文件可以通过Class文件转化而来,使用Java语法编写的应用程序,可以直接使用大部分的Java API等
- Android 5.0 使用支持提前编译(Ahead Of Time Compilation,AOT)的ART VM替换Dalvik VM。
13. Graal VM
- 2018.4,Oracle Labs公开了Graal VM,号称**“Run Programs Faster Anywhere”**,勃勃野心。与1995年Java的“write once,run anywhere”遥相呼应
- Graal VM在HotSpotVM基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台,其中包括:Java、Scala、Groovy、Kotlin;C、C++、JavaScript、Ruby、Python、R等
- 支持不同语言中混用对方的接口和对象,支持这些语言已经编写好的本地库文件
- 工作原理是将这些语言的源代码或编译后的中间格式,通过解释器转换为能被Graal VM接受的中间表示。Graal VM提供Truffle工具极快速构建面向一种新语言的解释器。在运行时还能进行即时编译优化,获得原生编译器更优秀的执行效率。
- 如果说HotSpot有一天真的被取代,Graal VM希望最大。但是Java的软件生态没有丝毫变化。
内存与垃圾回收篇
类加载子系统
- 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件表示。
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
- 加载的类信息存放于一块称为
方法区
的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
类加载器ClassLoader角色
- class file 存在于本地硬盘上,可以理解为设计师画在纸上的模块,而最终这个模板在执行时是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
- class file 加载到JVM中,被称为DNA元数据模板,放在方法区
- 在 .class 文件 -> JVM -> 最终成为元数据模板,此过程就要一个运输工具(类装载器 ClassLoader),扮演一个快递员的角色
类加载过程
加载
- 通过一个类的全限定类名定义此类的二进制字节流
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
补充:加载 .class 文件的方式
- 从本地系统中直接加载
- 通过网络获取,典型场景:Web Applet
- 从zip压缩包中读取,成为日后jar、war格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生产,典型场景:JSP应用
- 从专有数据库中提取 .class 文件,比较少见
- 从加密文件中获取,典型的防class文件被反编译的保护措施
链接
初始化
- 初始化阶段就是执行类构造器方法( )的过程。
- 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
- 构造器方法中的指令语句在原文件中出现的顺序执行
- ( )不同于类的构造器。(关联:构造器是虚拟机视角下的( ) )
- 若该类具有父类,JVM会保证子类的( ) 执行前,父类的( )已经执行完毕。
- 虚拟机必须保证一个类的( ) 方法在多线程下被同步加锁
public class Test1 {
private int num0 = 0; // 实例变量
private static int num1 = 1; // 静态变量
static {
num1 = 2;
num2 = 10;
System.out.println(num1);
// System.out.println(num2); 非法的前项引用
}
private static int num2 = 20;
// num2 -> (链接-prepare)0 -> (初始化) 10(从上到下执行) -> 20(从上到下执行)
public static void main(String[] args) {
System.out.println(Test1.num1);
System.out.println(Test1.num2);
}
}
类加载器分类
- JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
- 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
- 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下图所示:
虚拟机自带的加载器
-
启动类加载器(引导类加载器,Bootstrap ClassLoader)
- 这个类加载器使用C/C++语言实现,嵌套在JVM内部
- 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
- 并不需要继承自java.lang.ClassLoader,没有父加载器
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
- 出去安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun开头的类
-
扩展类加载器(Extension ClassLoader)
- Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
- 派生于ClassLoader类
- 父类加载器为引导类加载器
- 从java.ext.dirs系统属性指定的目录中加载类库,或从JDK的安装目录jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
-
应用程序类加载器(系统类加载器 ,AppClassLoader)
- Java语言编写,由sun.misc.Launcher$AppClassLoader实现
- 派生于ClassLoader类
- 父类加载器为扩展类加载器
- 该类加载器是程序中默认的了加载器,一般来说,Java应用的类都是由它来完成加载
- 通过ClassLoader#getSystemClassLoader( )方法可以获取到该类加载器
用户自定义加载器
- 在Java的日常应用程序开发中,类的加载几乎是有上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来制定类的加载方式
- 为什么要自定义类加载器?
- 隔离加载类
- 修改类加载方式
- 扩展加载源
- 防止源码泄露
- 如何自定义加载器?
- 继承 java.lang.ClassLoader 类
- 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadCloass( )方法,从而实现自定义的类加载,但JDK1.2之后已不建议重写该方法,而是建议把自定义的类加载逻辑写在findClass( )方法中
- 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写 findClass( )方法及其获取字节码流的方式,使自定义类加载器编写更加简洁
关于ClassLoader
ClassLoader是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)
方法名称 | 描述 |
---|---|
getParent( ) | 返回该类加载器的超类加载器 |
loadClass(String name) | 加载名为 name 的类,返回结果为 java.lang.Class 类的实例 |
findClass(String name) | 查找名为 name 的类,返回结果为 java.lang.Class 类的实例 |
findLoadedClass(String name) | 查找名为 name 的已经被加载过的类,返回结果为 java.lang.Class 类的实例 |
defineCLass(String name, byte[] b, int off, int len) | 把字节数组b中的内容转换为一个Java类,返回结果为 java.lang.Class类的实例 |
resolveClass(Class<?> c) | 连接指定的一个Java类 |
获取ClassLoader的三种方式
- class.getClassLoader( ) - 获取当前类的
- Thread.currentThread( ).getContextClassLoader( ) - 获取当前线程上下文的
- ClassLoader.getSystemClassLoader( ) - 获取当前系统的
- DriverManager.getCallerClassLoader( ) - 获取调用者的
双亲委派机制
http://www.javashuo.com/article/p-cfrecdij-dx.html
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派机制,即把请求交由父类处理,它是一种任务委派模式。
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
// -----??-----
protected 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 {
// 直到最上面的Bootstrap类加载器
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.
c = findClass(name);
}
}
return c;
}
优势
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
沙箱安全机制
Java安全模型的核心就是Java沙箱。沙箱机制就是将Java代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地资源的访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏
自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载过程中会先加载JDK自带的文件(rt.jar包中的java.lang.String ),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对Java核心源代码的保护,这就是沙箱安全机制
其他
- 在JVM中表示两个class对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader必须相同
- 换句话话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的
对类加载器的引用
JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由后者加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
类的主动使用和被动使用
Java程序对类的使用方式分为:主动使用和被动使用
- 主动使用,又分为七种情况:
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(比如:Class.forName(“com.example.Test”) )
- 初始化一个类的子类
- Java虚拟机启动时被标明为启动类的类
- JDK 7 开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
- 除了以上七种情况,其他使用Java类的方式都被看做类的被动使用,都不会导致类的初始化
运行时数据区
线程介绍
- 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行
- 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
- 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,他就会调用Java线程中的 run( ) 方法
JVM系统线程
- 如果使用jconsole或者任何一个调试工具,都能看到后台有许多线程在运行。这些后台线程不包括调用public static void main(String[ ] ) 的main线程以及所有这个main线程自己创建的线程
- 这些主要的后台系统线程在HotSpot JVM里主要是以以下几个:
- 虚拟机线程:该线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括“stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
- GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
- 编译线程:这种线程在运行时会将字节码编译到本地
- 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过适当的方法进行处理
程序计数器
JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。
这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
作用
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
- 它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是唯一一个存在Java虚拟机规范中没有规定任何OutOfMemoryErroy情况的区域
举例说明
两个常见问题
① 使用PC寄存器存储字节码指令地址有什么用?
为什么使用PC寄存器记录当前线程的执行地址?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
② PC寄存器为什么会被设为线程私有?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中的某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或回复,如何保证分毫无差呢?为了能够准确记录各个线程正在执行的当前字节码指令地址(最好的办法就是每人一份),最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立运算,从而不会出现相互干扰的情况。
由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,所以每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
虚拟机栈
概述
由于跨平台的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
优点是跨平台、指令集小、编译器容易实现;缺点是性能下降,实现同样的功能需要更多的指令。
栈是运行时单位,而堆是存储的单位。
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程创建时都会创建一个虚拟机栈,其内部保存一个个栈帧(Stack Frame),对应一次次的Java方法调用。Java虚拟机栈是线程私有的,生命周期和线程一致。其主要作用是主管Java程序的运行,它保存方法的局部变量(8种数据基本类型和对象的引用地址)、部分结果,参与方法的调用和返回。
栈的优点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
- JVM直接对Java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
面试题:开发中遇到的异常有哪些?
- Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的,那么每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许得到最大容量,那么虚拟机将抛出一个 StackOveflowError 异常
- 如果采用动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将抛出一个 OutOfMemoryError 异常
- -Xss256M 可以设置每个虚拟机栈大小
- 默认虚拟机栈大小
栈中存储什么?
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自对应一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
栈运行原理
- JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈
- 在一条活动线程上,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
- 不同线程中所包含的栈帧是不允许相互引用的,即不可能存在一个栈帧之中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回时,当前栈帧会传回次防范的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为栈顶栈帧
- Java方法有两种返回函数的方式,一种是正常的返回,使用return;另一个是抛出异常。不管是用哪种,都会导致栈帧被弹出。
栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)或称为表达式栈
- 动态链接(Dynamic Linking)或指向运行时常量池的方法引用
- 方法返回地址(Return Address)或方法正常退出或者异常退出的定义
- 一些附加信息
局部变量表
- 局部变量表也被称为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
- 由于局部变量是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- **局部变量表所需的容量大小是在编译期确定下来的,**并保存在方法的Code属性的maximum locall variables数据项中。在方法运行期间是不会改变局部变量表的
关于Slot(槽)的理解
- 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束
- 局部变量表,最基本的存储单元是Slot(变量槽)
- 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
- 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
- byte、short、char 在存储前被转换为int,boolean 也被转换为int,0表示false,非0表示true
- long 和 double 则占两个slot
Slot的重复利用
举例:静态变量与局部变量的对比
静态变量有两次赋初值机会,局部变量没有
- 成员变量:在使用前(实例后),都经过默认初始化赋值
- 类变量(static):linking(prepare)->initialization
- 实例变量(no static):随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量 :在使用前,必须显式赋值
补充说明
- 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会回收
操作数栈
- 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)
- 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/ 出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。
- 比如:执行复制、交换、求和等操作
代码演示
栈顶缓存技术
动态链接(指向运行时常量池的方法引用)
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的是为了支持当前方法的代码能够实现动态链接。比如 invokedynamic 指令
- 在Java源文件被编译到字节码文件时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中的指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
- 为什么要用常量池?
- 为了提供一些符号和常量,便于指令的识别
方法调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
- 静态链接
当一个字节码文件被装载进JVM内部时,如果**被调用的目标方法在编译期可知,且运行期保持不变。**这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
- 动态链接
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此称之为动态链接。
对应的方法绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用
- 晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,称之为晚期绑定。
方法的调用:虚方法与非虚方法
- 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为 非虚方法
- 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
- 其他方法称为虚方法。
- 虚拟机中提供了以下几条方法调用指令:
- 普通调用指令:
- invokestatic:调用静态方法,解析阶段唯一确定
- invokespecial:调用方法、私有及父类方法,解析阶段唯一确定
- invokevirtual:调用所有虚方法,不一定真的是是虚方法
- invokeinterface:调用接口方法,虚方法
- 动态调用指令:
- invokeddynamic:动态解析出需要调用的方法,然后执行
- 普通调用指令:
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中 invokestatic 和 invokespecial 指令调用的方法称为非虚方法,其余的称为虚方法(final修饰除外)。
方法调用:关于invokedynamic指令
- JVM字节码指令集一直比较稳定,知道Java7中才增加了一个invokedynamic指令,这是为了Java实现【动态类型语言】支持而做的一种改进。
- 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种字节码工具来产生invokedynamic指令。知道Java8中的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
- Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来说比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言编译器。
方法调用:方法重写的本质
Java 语言中方法重写的本质:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
- 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
IllegalAccessError介绍
程序视图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。如果这个错误发生在运行时,就说明一个类发生了不兼容的改变。
方法调用:虚方法表
- 在面向对象编程中,会很频繁的使用到动态分配,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
- 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
- 那么虚方法表什么时候创建?
- 虚方法表会在类加载的链接-解析阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
方法返回地址
- 存放调用该方法的PC寄存器的值。
- 一个方法的结束,有两种方式:
- 正常结束
- 异常退出
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,**调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。**而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
- 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
- 在字节码指令中,返回指令包括 ireturn(当前返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用
一些附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
虚拟机栈的相关面试题
- 举例栈溢出的情况(StackOverflowError)
- https://blog.csdn.net/weixin_40667145/article/details/78556182
- 通过-Xss设置每个线程的栈的总大小;OOM(内存溢出)不同于栈溢出
- 调整栈大小,就能保证不出现溢出吗?
- 不能
- 分配的栈内存越大越好吗?
- 栈容量变大,总内存一定,栈的数目就会减少,或者说能开辟的线程变少,所以并不是越大越好
- 垃圾回收是否会涉及到虚拟机栈?