虚拟机执行子系统
1.类文件结构
无关的基石
一次编写,到处运行。不同平台的虚拟机使用统一的存储格式--字节码。
除了平台无关性,开始出现语言无关性。不同语言的编译器把代码编译为字节码,由Java虚拟机执行。Java虚拟机并不关系字节码是由什么语言的代码编译而成。
Class文件的结构
以类似C语言结构体的伪结构存储,只有2种数据类型:无符号数和表。
- 无符号数:基本数据类型,u1、u2、u4、u8表示1、2、4、8个字节的无符号数。无符号数用来标识数字、索引引用、数量值,或字符串值
- 表:由多个无符号数和其他表作为数据项,构成的复合数据结构,"_info"结尾,整个class文件就是一张表
[图片上传失败...(image-9d5a49-1650841238045)]
无论是无符号数还是表,当需要描述统一类型的多个数据时,用一个前置的容量计数器+若干连续的数据项。这时称为某一类型的集合
魔数和class文件版本号
开头四个字节,用来标识这个文件是能被虚拟机读取的class文件
紧接着魔数的,是版本号(次版本号、主版本号)
常量池
-
版本号后面,接着是常量池。
-
常量池入口,有一个u2,用来表示常量池的容量,从1开始计数。0表示不引用任何一个常量池项目,所以被空出来了,其他class文件结构还是正常从0开始。
-
主要包括两类常量:
-
字面量:接近Java语言层面的常量概念
-
符号引用:接近编译原理的概念
-
包括类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
-
编译时,class文件不会保存各个方法和字段的最终内存布局信息,虚拟机运行的时候,需要从常量池获取对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。
-
常量池中每一项常量都是一个表,共有11种,第一位都有一个u1型的标志位,代表属于哪种常量类型
访问标志
常量池后面紧接着2个字节,表示访问标志。用来标识类或接口层次的访问信息。类/接口,public/private/protect,是否final等等
类索引、父类索引、接口索引集合
类索引、父类索引都是一个u2类型数据,接口索引是一组u2类型数据集合
除Object类外,所有类都有一个父类,因此索引都不为0
接口索引集合描述类实现的所有接口,如果类本身时一个接口。
类索引查找全限定类名的过程:
[图片上传失败...(image-ada011-1650841238045)]
字段表
字段表集合中,不会列出父类继承的字段,但可能列出代码中没有的字段,比如内部类自动添加外部类字段。
Java语言中,字段无法重载,字段名称不能相同,哪怕用了不同的数据类型或者修饰符。
而在字节码层面,只要字段描述符不同就是合法的。
全限定名:
字段/方法的描述符:字段的数据类型,方法的参数列表、返回值
方法表集合
结构:访问标志、名称索引、描述符索引、属性表集合。
方法里的代码,在属性表集合中的code属性里
与字段类似,方法表集合中不会出现父类方法,除非重载。
Java语言中,要重载一个方法,需要名称相同,特征签名(参数在常量池中的字段符号引用的集合)不同,注意,返回值不再特征签名里,因此只有返回值不同无法重载方法。
属性表集合
class文件、字段表、方法表都可以有自己的属性表集合
[图片上传失败...(image-eaa99-1650841247990)]
- code属性出现在方法表,但并不是所有方法表都有,接口/抽象类的方法就不存在code属性
2.虚拟机类加载机制
2.1 概述
把描述类的数据,从class文件加载到内存中,对数据进行校验、转换解析、初始化,最终变为虚拟机可以直接使用的Java类型,就是类加载机制
Java中的类型的加载和连接都是在运行期间完成的
2.2 类加载的时机
[图片上传失败...(image-53e97b-1650841262566)]
注意:解析阶段可能在初始化之后才开始。这是为了支持Java的动态绑定
有且只有4中场景,触发类的初始化
-
1、遇到new、getstatic、setstatic或者invokestatic这4个字节码指令时。对应的java代码场景为:
-
new 关键字、实例化对象的时候
-
读取或设置一个类的静态字段的时候(final修饰的已在编译期把结果放入常量池的静态字段除外。见上文中,准备阶段)
-
调用一个类的静态方法
-
2、通过java.lang.reflect包中的方法对类进行反射调用的时候,如果类没有初始化,需要调用其初始化方法初始化
-
3、当初始化一个类时,发现其父类还没有进行初始化,则需要先触发其父类初始化。
-
4、当虚拟机启动时,用户指定了一个要执行的包含 main 方法的主类,虚拟机会初始化这个主类。
测试
场景一:
static {
System.out.println("SuperClass inti");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init");
}
}
public class TestNonInit {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
打印结果:
SuperClass inti
123
可以看到,对于静态字段来说,使用它的时候,只有直接定义它的类才会被初始化。
场景二:
public static void main(String[] args) {
SuperClass[] arr = new SuperClass[10];
}
观察结果发现,SuperClass并没有被初始化。但实际上虚拟机会触发一个名叫"Lorg.fenixsoft.classloading.SuperClass"的类的初始化阶段。这个类是虚拟机自动生成的,创建动作对应字节码指令newarray。这个类包装了数组元素的访问方法,Java语言对数组的访问更安全,只要越界,就会抛异常。
场景三:
public class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLO_WORLD = "hello world";
}
public class TestNonInit {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_WORLD);
}
}
观察结果,ConstClass也没有初始化。这是因为,在编译阶段,就把常量放到了TestNonInit的常量池中。使用的时候,不是通过ConstClass入口找到常量,而是直接在TestNonInit的常量池中找到对应的常量。也就是说在编译成class文件之后,两个类就不存在联系了。
接口与类的加载有些不同:类初始化的时候,要求父类全部初始化过了,但是接口初始化的时候,不用所有父接口 只要真正用到父接口(比如引用接口中定义的常量)的时候,才会初始化
2.3 类加载的过程
加载
1.通过类的全限定名,获取该类的二进制字节流(没有指定一定要从class文件读取,也可以从jar包、zip、运行时计算生成等)
2.将字节流代表的静态存储结构,转化为方法区中的运行时数据结构
3.在Java堆中生成这个类的java.lang.class对象,作为方法区这些数据的访问入口
加载与连接可能交叉进行,加载可能还没有结束,连接就已经开始(比如一部分文件格式校验)
连接
a.验证
1.文件格式验证:目的是保证字节流能正确解析并存储到方法区中。这阶段基于字节流,后面三个验证都是基于方法区中的数据结构
2.元数据验证:校验类的元数据信息,比如是否有父类、是否继承了final类、字段方法是否与父类矛盾
3.字节码验证:校验方法体
4.符号引用验证:对类自身以外的信息做校验,比如通过全限定名能不能找到对应的类,指定类里是否存在符合方法的字段描述符及简单名称所描述的方法和字段,符号引用中的方法、字段的访问性是否可被当前类访问
b.准备
为类变量分配内存,并设置初始值
类变量分配在方法区,类变量指的是static修饰的变量,而实例变量在类实例化的时候分配在Java堆中。初始值指的是0值,而不是程序指定要赋的值。
如下面value就是赋0,而不是123,真正把值赋123的putstatatic指令,在初始化阶段执行<cinit>才会执行
public static int value = 123
如果是常量,比如 public static final int value = 123,那么在准备阶段就会赋值123
c.解析
符号引用->直接引用
符号引用:描述所引用的目标,可以是任何形式的字面量,与虚拟机布局无关。目标不一定被加载到了虚拟机中
直接引用:直接指向目标的指针/偏移量/间接定位目标的句柄。同一符号引用,不同虚拟机实例翻译的直接引用不同。如果有了直接引用,那目标肯定在虚拟机里
初始化
其实就是执行<cinit>的过程。准备阶段对类变量赋了一次初始值,初始化阶段是真正赋值代码里写的值
● <cinit>是编译器自动收集所有类变量的赋值动作、静态代码块合并而成。如果没有就不会生成<cinit>
● 不需要显示调用父类构造器,它会保证父类的<cinit>已经执行完毕才执行
● 因此父类中的静态代码块要先于子类的类变量赋值操作
● 多线程下执行会加锁同步
2.4 类加载器
两个类,即时来源于同一个class文件,如果类加载器不同,也不能算作同一个类
双亲委派模型
● 类加载器种类
1.启动类加载器
2.扩展类加载器
3.应用程序类加载器
● 类加载器和父加载器的关系不是继承,而是通过组合来复用代码
● 双亲委派模型的实现:
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
}
// 父类加载失败 则自己调findClass()加载
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;
}
}
● 双亲委派模型的破坏
○ JNDI:代码由启动类加载器加载,但是由于启动类加载器加载不了用户代码,所以引入了线程上下文类加载器。由启动类加载器请求线程上下文类加载器加载所需要的SPI代码
○ 代码热替换、模块热部署:OSGi模块化标准,每个模块即Bundle都有自己的类加载器,更换Bundle的时候,就把Bundle连同类加载器一起替换掉。类的查找是平级的
3.虚拟机字节码执行引擎
3.1 概述
执行引擎可能有:解释执行和编译执行两种选择,也可能两者同时
3.2 运行时栈帧结构
多大的局部变量表与操作数栈的深度,在编译期就确定了,写入了方法表的code属性里。
局部变量表:方法code属性的max_locals
操作数栈:code属性的max_stacks
因此栈帧分配的内存,不受运行期变量数据影响
局部变量表
slot为最小单位,一个slot可以存放32位以内的数据类型
32位以内的有:byte boolean char short int float reference returnAddress
其中:
reference:对象引用,虚拟机规范没有说明长度和结构,但是虚拟机要能从引用中直接或间接地找到对象在堆中的地址和方法区中的对象类型数据
returnAddress:为字节码指令jsr jsr_w ret服务,指向了一条字节码指令的地址
Java语言规定64位只有: double long。reference可能也会有64位。64位数据会分配两个连续slot。
局部变量表是线程私有的,因此不会有线程安全问题。
使用索引来定位使用的slot,从0开始。32位索引n表示用的是第n个slot,64位索引n表示用的是第n个和第n+1个slot
对于实例方法,第0个slot都是存方法所属对象的引用,即this,对于static方法则没有。
slot是可以复用的,如果PC计数器的值已经超过了某个变量的作用域,那该变量对应的slot就可以给其他变量用。这样不仅可以节省栈空间,还对垃圾收集有好处
测试对GC的影响
public static void main(String[] args) {
byte[] bytes = new byte[1024 * 1024 * 1000];
System.gc();
}
[GC (System.gc()) 1030389K->1025189K(1266176K), 0.0099416 secs]
[Full GC (System.gc()) 1025189K->1025004K(1266176K), 0.0195329 secs]
可以看到,GC并没有回收掉bytes占用的内存,因为执行System.gc()的时候,bytes还在作用域里,肯定不会回收
public static void main(String[] args) {
byte[] bytes = new byte[1024 * 1024 * 1000];
bytes = null;
System.gc();
}
[GC (System.gc()) 1030389K->1025221K(1266176K), 0.0072992 secs]
[Full GC (System.gc()) 1025221K->1004K(1266176K), 0.0190020 secs]
如果手动把bytes引用指向null,那么就可以回收掉这部分内存
public static void main(String[] args) {
{
byte[] bytes = new byte[1024 * 1024 * 1000];
}
int a = 0;
System.gc();
}
[GC (System.gc()) 1030389K->1025157K(1266176K), 0.0089494 secs]
[Full GC (System.gc()) 1025157K->1004K(1266176K), 0.0174896 secs]
如果给bytes加上括号,等执行到system.gc()的时候,已经不在它的作用域范围内,同时用变量a去占用bytes对应的slot,就能回收内存。
操作数栈
后入先出栈
操作数栈中的元素数据类型,必须和字节码指令序列严格匹配
栈帧虽然从概念上来说是相互独立的的,但是实际上虚拟机会做优化,会有重叠共享部分,这样方法调用的时候,可以共用一部分数据,不用额外传参
动态连接
每个栈帧中包含一个运行时常量池中,指向该方法的引用,持有这个引用,是为了支持方法调用过程中的动态连接
class文件常量池中,有大量符号引用,字节码中的方法调用指令,就以常量池中,指向方法的符号引用为参数。
一部分符号引用,在加载阶段或第一次使用的时候,转化为直接引用
另一部分符号引用,在每一次运行期间,转化为直接引用。这部分称为动态连接
方法返回地址
方法退出的两种情况:
1.正常完成出口:遇到任何一个方法返回指令,这种情况下,会把返回值传给方法调用者。这种情况,调用者的PC计数器就可以作为返回地址,栈帧中可能保存这个计数器值
2.异常完成出口:遇到异常(可能虚拟机内部异常或代码里athrow指令),只要在本方法的异常表没有搜索到匹配的异常处理器,方法就会退出。这种情况,返回地址要通过异常处理器表来确定,栈帧中不会保存
方法退出可能执行的操作:
1.恢复调用者的局部变量表和操作数栈
2.如果有返回值,要压入调用者的操作数栈
3.PC计数器的值,指向方法调用指令的后一条指令
3.3 方法调用
不等于方法执行,只是确实要调用的方法的版本
方法调用中的目标方法,在class文件中是是常量池中的符号引用。类加载阶段,一部分符号引用会转换成直接引用。不过转换的前提是,运行前就能确定版本,并且不会改变,也就是说编译期就必须确定下来。这类方法调用称为解析
字节码指令:
invokestatic:调用静态方法(非虚方法)
invokespecial:调用实例构造器方法<init>、私有方法、父类方法(非虚方法)
invokevirtual:调用final方法(非虚方法)、还有所有虚方法
invokeinterface:调用接口方法(虚方法)
动态分派:
1.找到操作数栈栈顶的第一个元素所执行的对象的类型,记为C
2.如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过,则返回这个方法的直接引用;如果不通过则抛异常java.lang.IllegalAccessError异常
3.否则按继承关系,从下往上,对C的各个父类进行第二步的搜索和校验
4.如果始终没有找到合适的方法,则抛java.lang.AbstractMethodError异常
Java属于静态单分派、动态多分派语言
动态分派是个非常频繁的过程,因此JVM采用虚方法表来提高性能。对应的执行invokeinterface也会用到Interface Method Table。
如果某个方法,没有被子类重写,那么在虚方法表中的地址与父类方法的地址相同。如果子类中重写了这个方法,子类方法表中的地址会被替换指向子类实现版本的入口地址。
具有相同签名的方法,在父类、子类的虚方法表中应具有一样的索引号,这样当类型变换,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需要的入口地址
3.4 基于栈的字节码解释执行引擎
基于栈的指令集与基于寄存器的指令集
栈架构指令集,优点在于可移植性,缺点是执行速度慢。
执行速度慢的原因:
1.完成相同的功能需要的指令更多,
2.栈实现在内存,频繁的栈访问意味着频繁的内存访问。相对于CPU,内存始终是执行速度的瓶颈。虽然可以采用栈顶缓存技术,把常用操作映射到寄存器中,避免直接内存访问,但无法根本上解决问题
4.类加载及执行子系统的案例与实战
1.Tomcat:
web服务器需要解决几个问题:
1.web应用之间依赖的类库需要相互独立
2.web应用之间共同的类库需要可以共享。如果不能共享,而是都单独加载到内存,会有很大问题
3.服务器使用的类库应该与应用程序的类库相互独立
4.jsp生成类的热替换
tomcat的目录:
/common:tomcat和所有应用程序公用
/server:tomcat使用,对应用程序不可见
/shared:被所有应用程序使用,但对tomcat不可见
/WebApp/WEB-INF:仅可被此Web应用程序使用
6.x之后目录已经合并到了一起
2.OSGi
著名例子:Eclipse IDE
基于OSGi的程序,可以实现模块级的热插拔
缺点在于,引入了额外的复杂度,并且有死锁和内存泄漏的风险
3.字节码生成技术与动态代理的实现
public class TestDynamicProxy {
public static void main(String[] args) {
IHello iHello = (IHello) (new DynamicProxy().bind(new Hello()));
iHello.sayHello();
}
interface IHello{
void sayHello();
}
static class Hello implements IHello{
@Override
public void sayHello() {
System.out.println("hello world");
}
}
static class DynamicProxy implements InvocationHandler{
Object originalObj;
Object bind(Object originalObj){
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),originalObj.getClass().getInterfaces(),this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj,args);
}
}
}
4.Retrotranslator:
可以把高版本class文件变为能在低版本JDK部署
网友评论