Java虚拟机不只是适用于java语言,也适用于其它语言,只要其它语言通过编译器生成的字节码文件遵循java虚拟机的规则,java虚拟机就可以运行
1.5. 虚拟机与 Java 虚拟机
虚拟机
所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。
- 大名鼎鼎的 Visual Box,Mware 就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
- 程序虚拟机的典型代表就是 Java 虚拟机,它专门为执行单个计算机程序而设计,在 Java 虚拟机中执行的指令我们称为 Java 字节码指令。
无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
Java 虚拟机
- Java 虚拟机是一台执行 Java 字节码的虚拟计算机,它拥有独立的运行机制,其运行的 Java 字节码也未必由 Java 语言编译而成。
- JVM 平台的各种语言可以共享 Java 虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。
- Java 技术的核心就是 Java 虚拟机(JVM,Java Virtual Machine),因为所有的 Java 程序都运行在 Java 虚拟机内部。
作用
- Java 虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条 Java 指令,Java 虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
特点
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
JVM 的位置
JVM 是运行在操作系统之上的,它与硬件没有直接的交互
1.6. JVM 的整体结构
- HotSpot VM 是目前市面上高性能虚拟机的代表作之一。
- 它采用解释器与即时编译器并存的架构。
- 在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++程序一较高下的地步。
1.7. Java 代码执行流程
操作系统并不能识别字节码指令,只能识别机器指令
1.8. JVM 的架构模型
Java 编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。
具体来说:这两种架构之间的区别:
基于栈式架构的特点
- 设计和实现更简单,适用于资源受限的系统
- 避开了寄存器的分配难题:使用零地址指令方式分配
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
- 不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器架构的特点
- 典型的应用是 x86 的二进制指令集:比如传统的 PC 以及 Android 的 Davlik 虚拟机
- 指令集架构则完全依赖硬件,可移植性差
- 性能优秀和执行更高效
- 花费更少的指令去完成一项操作
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
举例 1
同样执行 2+3 这种逻辑操作,其指令分别如下:
基于栈的计算流程(以 Java 虚拟机为例):
1 | iconst_2 //常量2入栈 |
而基于寄存器的计算流程
1 | mov eax,2 //将eax寄存器的值设为1 |
举例 2
1 | public int calc(){ |
1 | > javap -c Test.class |
总结
由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
时至今日,尽管嵌入式平台已经不是 Java 程序的主流运行平台了(准确来说应该是 HotSpotVM 的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?
编译器前端(将源文件编译生成字节码文件),编译器后端(将字节码指令编译成机器指令)
因为机器指令是反复执行的热点代码,所以缓存起来,下次可以直接调用
栈式架构采用的是8位作为一个基本单位的,所以栈的指令集更小,但是指令数多
寄存器架构采用的是16位的双字节的进行设计的,所以指令集大,但指令数更少
比如:
总结
由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。
优点:是跨平台,指令集小,编译器容易实现。
缺点:是性能下降,实现同样的功能需要更多的指令。
栈:
跨平台、指令集小、指令多,执行性能比寄存器差
虚拟机的生命周期
虚拟机的启动
Java 虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
像上面我们自定义的类,是由系统类加载器来加载的
但(如果)没有明确指定的父类,它的父类就是Object,Object作为核心api,由引导类加载器(bootstrap class loader)加载的
我们要启动一个类,而父类是要早于子类先加载的,但是父类还没加载而它的子类要用,所以我们就需要先启动Java虚拟机
虚拟机的执行
-
一个运行中的 Java 虚拟机有着一个清晰的任务:执行 Java 程序。
-
程序开始执行时他才运行,程序结束时他就停止。
-
执行一个所谓的 Java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机的进程。
虚拟机的退出
有如下的几种情况:
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统用现错误而导致 Java 虚拟机进程终止
- 某线程调用 Runtime 类或 system 类的 exit 方法,或 Runtime 类的 halt 方法,并且 Java 安全管理器也允许这次 exit 或 halt 操作。
- 除此之外,JNI(Java Native Interface)规范描述了用 JNI Invocation API 来加载或卸载 Java 虚拟机时,Java 虚拟机的退出情况。
编译器前端(将源文件编译生成字节码文件),编译器后端(将字节码指令编译成机器指令)
因为机器指令是反复执行的热点代码,所以缓存起来,下次可以直接调用
HotSpot VM
- HotSpot 历史
- 最初由一家名为“Longview Technologies”的小公司设计
- 1997 年,此公司被 sun 收购;2009 年,Sun 公司被甲骨文收购。
- JDK1.3 时,HotSpot VM 成为默认虚拟机
- 目前 Hotspot 占有绝对的市场地位,称霸武林。
- 不管是现在仍在广泛使用的 JDK6,还是使用比例较多的 JDK8 中,默认的虚拟机都是 HotSpot
- Sun / Oracle JDK 和 OpenJDK 的默认虚拟机
- 因此本课程中默认介绍的虚拟机都是 HotSpot,相关机制也主要是指 HotSpot 的 Gc 机制。(比如其他两个商用虚机都没有方法区的概念)
- 从服务器、桌面到移动端、嵌入式都有应用。
- 名称中的 HotSpot 指的就是它的热点代码探测技术。
- 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
- 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡
JRockit
-
专注于服务器端应用
- 它可以不太关注程序启动速度,因此 JRockit 内部不包含解析器实现,全部代码都靠即时编译器编译后执行。
-
大量的行业基准测试显示,JRockit JVM 是世界上最快的 JVM。
- 使用 JRockit 产品,客户已经体验到了显著的性能提高(一些超过了 70%)和硬件成本的减少(达 50%)。
-
优势:全面的 Java 运行时解决方案组合
- JRockit 面向延迟敏感型应用的解决方案 JRockit Real Time 提供以毫秒或微秒级的 JVM 响应时间,适合财务、军事指挥、电信网络的需要
- MissionControl 服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具。
-
2008 年,JRockit 被 oracle 收购。
-
Oracle 表达了整合两大优秀虚拟机的工作,大致在 JDK8 中完成。整合的方式是在 HotSpot 的基础上,移植 JRockit 的优秀特性。
-
高斯林:目前就职于谷歌,研究人工智能和水下机器人
IBM 的 J9
- 全称:IBM Technology for Java Virtual Machine,简称 IT4J,内部代号:J9
- 市场定位与 HotSpot 接近,服务器端、桌面应用、嵌入式等多用途 VM
- 广泛用于 IBM 的各种 Java 产品(一般用于自己的产品)。
- 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的 Java 虚拟机(但J9只是在自己的产品上使用时比较快,而通用性的话,还是JRockit好点)。
- 2017 年左右,IBM 发布了开源 J9VM,命名为 openJ9,交给 EClipse 基金会管理,也称为 Eclipse OpenJ9
…
总结
具体 JVM 的内存结构,其实取决于其实现,不同厂商的 JVM,或者同一厂商发布的不同版本,都有可能存在一定差异。主要以 Oracle HotSpot VM 为默认虚拟机。
2. 类加载子系统
如果自己想手写一个 Java 虚拟机的话,主要考虑哪些结构呢?
- 类加载器
- 执行引擎
**类加载器子系统:**获取字节码文件的信息(常量信息,变量信息,方法,指令等),然后有组织地分配到内存当中(运行时数据区)
执行引擎: 需要去解释这些指令
如果不是一个合法的字节码文件,就会在加载的过程中抛出异常。如果有些恶意攻击的话,就会对这个字节码文件进行修改,就不合法了
初始化阶段
- 初始化阶段就是执行类构造器方法<clinit>()的过程。
- 此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
- 构造器方法中指令按语句在源文件中出现的顺序执行。
- <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())
- 若该类具有父类,JVM 会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。
- 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。
当要只能main方法时,先把这个ClinitTest1类加载到内存当中(当然在加载这个类之前先把它的父类加载),加载完后,调用main静态方法,里面要加载Son这个类,要把Son这个类加载进来,但在加载Son类之前要先加载Son的父类Father,加载完父类后,在加载Son的时候,在初始化这个环节,把A的值赋过来,这时A已经等于2,所以main中打印出来的结果就是2
虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。
线程2进来了,但是线程2出不来,它还在初始化这个DeadThread类,线程1也进不去,因为线程正在处于一个加锁的状态
static代码块只执行一次原因:
static代码块只在类加载时执行,类是用类加载器来读取的,类加载器是带有一个缓存区的,它会把读取到的类缓存起来,所以在一次虚拟机运行期间,一个类只会被加载一次,这样的话静态代码块只会运行一次
2.3.1. 虚拟机自带的加载器
启动类加载器(引导类加载器,Boostrap ClassLoader),我们获取不到,它是由C/C++语言编写的
**扩展类加载器(Extension ClassLoader)**和 **应用程序类加载器(系统类加载器,AppClassLoader)**都是由java语言编写的
还要记住什么样的类加载器加载什么样的类文件
为什么要自定义类加载器?
- 隔离加载类 (避免类的冲突)
- 在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。
- 修改类加载的方式 (可以实现动态的加载)
- 类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载。
- 扩展加载源
- 比如从数据库、网络、甚至是电视机机顶盒进行加载。
- 防止源码泄漏
- Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。
获取 ClassLoader 的途径
-
方式一:获取当前 ClassLoader
1
clazz.getClassLoader()
-
方式二:获取当前线程上下文的 ClassLoader
1
Thread.currentThread().getContextClassLoader()
-
方式三:获取系统的 ClassLoader
1
ClassLoader.getSystemClassLoader()
-
方式四:获取调用者的 ClassLoader
1
DriverManager.getCallerClassLoader()
2.5. 双亲委派机制
Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
工作原理
- 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
- 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 3)如果父类加载器可以完成类加载任务,就成功返回(就不会由子类加载器去加载了),倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
举例:
出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类。一看你是java开头的,引导类加载就说了。这是归我管我来加载String(核心API里的String)。因此有父类来加载后,就不会再向下委托了,所以我们new 的这个String对象就是核心API里面的String类对象,而不是我们自定义的String,因此就没有打印出自定义String里的static静态资源里的语句
委托到引导类加载器,它发现你这个包是jvm开头的,不归引导类加载管,就向下委托,也不归扩展类加载器管,所以最后回到系统类加载器来加载,因此最后输出结果就是系统类加载来进行的加载
一直往上委托,就交给到了引导类加载器,它加载了String类以后,然后就想去执行main方法,但是核心API的String里面是没有main方法的,所以就报了 错误: 在类 java.lang.String 中找不到 main 方法. 可知,根本就没有试着想去加载我们自定义的String类,完全忽略掉你了
当我们加载 jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar 是基于 SPI 接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI 核心类,然后在加载 SPI 接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类 jdbc.jar 的加载。
优势
- 避免类的重复加载
- 保护程序安全,防止核心 API 被随意篡改
- 自定义类:java.lang.String
- 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)
引导类加载器看到是 java.lang开头的,就表示这是归它管,于是就要去加载这个ShkStart类了,但直接直接给它报错了,相当于,要加载java.lang这个包,要想访问是要有权限的,现在报错就是阻止我们去直接用这个java.lang包来自定义这个ShkStart类。其实这也是起到了保护作用和出于安全的考虑,如果允许去加载这个类,加载成功的话,就会导致对引导类加载器本身造成影响,所以这里是直接把引导类加载器给整挂了。所以我们也禁止去用java.lang这样的包名去命名
其实这也是起到了保护作用和出于安全的考虑,如果允许去加载这个种自定义的类,加载成功的话,但里面可能会有一些恶意代码,就可能会会对现有的项目和程序进行破坏
沙箱安全机制
自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。
2.6. 其他
如何判断两个 class 对象是否相同
在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名。
- 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。
换句话说,在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。