JAVA 虚拟机类加载机制和字节码执行引擎
引言
我们知道java代码编译后生成的是字节码,那虚拟机是如何加载这些class字节码文件的呢?加载之后又是如何进行方法调用的呢?
一 类文件结构
无关性基石
java有一个口号叫做一次编写,到处运行
。实现这个口号的就是可以运行在不同平台上的虚拟机和与平台无关的字节码。这里要注意的是,虚拟机也是中立的,只要是符合规范的字节码,都可以被虚拟机接受,例如Groovy,JRuby等语言,都会生成符合规范的字节码,然后被虚拟机所运行,虚拟机不关心字节码由哪种语言生成。
类文件结构
class类文件是一组以8位字节为基础的二进制流,它包含以下几个部分:
魔数和class文件版本:类文件开头的四个字节被定义为CAFEBABE,只有开头为CAFEBABE的文件才可以被虚拟机接受,接下来四个字节为class文件的版本号,高版本JDK可以兼容以前版本的class文件,但不能运行以后版本的class文件。
常量池:可以理解为class文件中的资源仓库,它包含两大类常量:字面量和符号引用,字面量包含文本字符串,声明为final的常量值等,符号引用包含类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。
访问标志:常量池结束后,紧接着两个字节表示访问标志,用于识别一些类或接口层次的访问信息,例如是否是public,是否是static等。
类索引,父类索引,和接口索引集合:类索引用来确定这个类的全限定名,父类为父类的全限定名,接口索引集合为接口的全限定名。
字段表集合:用于描述接口或者类中声明的变量,但不包含方法中的变量。
方法表集合:用于表述接口或者类中的方法。
属性表集合:class文件,字段表,方法表中的属性都源自这里。
二 类加载机制
虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换分析和初始化,最终形成可以被虚拟节直接使用的JAVA类型,这就是虚拟机的类加载机制。
类从被加载到虚拟机内存到卸载出内存的生命周期包括:加载->连接(验证->准备->解析)->初始化->使用->卸载。
初始化的5种情况:
- 使用new关键字实例化对象时,读取或设置一个类的静态字段,除被final修饰经编译结果放在常量池的静态字段,调用类的静态方法时。
- 使用java.lang.reflect包方法对类进行反射调用时。(Class.forName())。
- 初始化子类时,如果父类没有初始化。
- 虚拟机启动时main方法所在的类。
- 当使用JDK1.7动态语言支持时,java.lang.invoke.MethodHandle实例解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,且对应类没有进行初始化。
类加载过程
加载
加载是类加载的第一个阶段,虚拟机要完成以下三个过程:
- 通过类的全限定名获取定义此类的二进制字节流。
- 将字节流的存储结构转化为方法区的运行时结构。
- 在内存中生成一个代表该类的Class对象,作为方法区各种数据的访问入口。
验证
目的是确保class文件字节流信息符合虚拟机的要求。准备
为static修饰的变量赋初值,例如int型默认为0,boolean默认为false。解析
虚拟机将常量池内的符号引用替换成直接引用。初始化
初始化是类加载的最后一个阶段,将执行类构造器< init>()方法,注意这里的方法不是构造方法。该方法将会显式调用父类构造器,接下来按照java语句顺序为类变量和静态语句块赋值。
类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性。举一个例子:
package com.sinaapp.gavinzhang.bean;
import java.io.InputStream;
public class App
{
public static void main( String[] args )
{
ClassLoader myClassLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try{
String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
InputStream is = getClass().getResourceAsStream(fileName);
if(is == null)
{
System.out.println(fileName+ "is not find");
return super.loadClass(name);
}
System.out.println("fileName: "+fileName);
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name,b,0,b.length);
}catch (Exception E)
{
throw new ClassCastException(name);
}
}
};
try {
Object obj = myClassLoader.loadClass("com.sinaapp.gavinzhang.bean.Resource").newInstance();
Object obj1 = Class.forName("com.sinaapp.gavinzhang.bean.Resource").newInstance();
System.out.println(obj instanceof com.sinaapp.gavinzhang.wesound.bean.Resource);
System.out.println(obj1 instanceof com.sinaapp.gavinzhang.wesound.bean.Resource);
}catch (Exception e)
{
e.printStackTrace();
}
}
}
结果为:
可以看到,由自定义的加载类只能获取同包下的class,而系统的class不能被加载,而且由Class.forName()获取的类与自定义加载类得到的类不是同一个类。
根据五种初始化的条件,父类也会被初始化,但是,上边的代码运行结果显示,父类和接口都没有被初始化,这又是怎么回事呢?
系统提供了三种类加载器,分别是:
- 启动类加载器(Bootstrap ClassLoader),该加载器会将<JAVA_HOME>\lib目录下能被虚拟机识别的类加载到内存中。
- 扩展类加载器(Extension ClassLoader),该加载器会将<JAVA_HOME>\lib\ext目录下的类库加载到内存。
- 应用程序类加载器(Application ClassLoader),该加载器负责加载用户路径上所指定的类库。
我们自定义的ClassLoader继承自应用程序类加载器,当自定义类加载器找不到所加在的类时,会使用启动类加载器进行加载,当启动类加载器加载不到时,由扩展类加载,扩展类加载不到时有应用程序类加载。这也是为什么上边的代码能够成功运行的原因。
三 字节码执行引擎
运行时栈帧结构
http://segmentfault.com/a/1190000002931555 中讲到虚拟机栈是线程私有的,线程中会为运行的方法创建栈帧。
栈帧是虚拟机栈的栈元素,栈帧存储了局部变量表,操作数栈,动态连接,返回地址等信息。每一个方法的调用都对应着一个栈帧在虚拟机栈中的入栈和出栈。
局部变量表
由方法参数,方法内定义的局部变量组成,容量以变量槽(Slot)为最小单位。如果该方法不是static方法,则局部变量表的第一个索引为该对象的引用,用this可以取到。操作数栈
最开始为空,由字节码指令往栈中存数据和取数据,方法的返回值也会存到上一个方法的操作数栈中。动态连接
含有一个指向常量池中该栈帧所属方法的引用,持有该引用是为了进行动态分派。方法返回地址
存放的是调用该方法的pc计数器值,当方法正常返回时,就会把返回值传递到上层方法调用者。当方法中发生没有可被捕获的异常,也会返回,但是不会向上层传递返回值。
方法调用
java是一门面向对象的语言,它具有多态性。那么虚拟机又是如何知道运行时该调用哪一个方法?
静态分派
是在编译期就决定了该调用哪一个方法而不是由虚拟机来确定,方法重载就是典型的静态分派。动态分派
是在虚拟机运行阶段才能决定调用哪一个方法,方法重写就是典型的动态分派。
动态分派的实现:当调用一个对象的方法时,会将该对象的引用压栈到操作数栈,然后字节码指令invokevirtual会去寻找该引用实际类型。如果在实际类型中找对应的方法,且访问权限足够,则直接返回该方法引用,否则会依照继承关系对父类进行查找。实际上,如果子类没有重写父类方法,则子类方法的引用会直接指向父类方法。
基于栈的字节码执行引擎
不管是解释型语言还是编译型语言,机器都无法理解非二进制语言。高级语言转化成机器语言都遵循现代经典编译原理。即执行前对程序源码进行词法和语法分析,构建抽象语法树。C语言等编译型语言会由单独的执行引擎做这些工作,而Java语言等解释型语言语法抽象树由jvm完成。jvm可以选择通过解释器来解释字节码执行还是通过优化器生成机器代码来执行。
常用的两套指令集架构分别是基于栈的指令集和基于寄存器的指令集。
基于栈的指令集更多的通过入栈出栈来实现计算功能,例如1+1
iconst_1 ;将1入栈
iconst_1 ;将1入栈
iadd ;将栈顶两个元素取出相加并将结果入栈
基于寄存器的指令集更多的是使用寄存器来进行操作,例如1+1
mov eax,1 ;向eax中存1
add eax,1 ;eax<-eax+1
总体来说,基于栈的指令集会慢一些,但是它与寄存器无关,更容易实现到处运行的目标。
总结
又到了该总结的时候了,类加载机制面试中很容易被问到,不幸的是,当时我并没有看这方面的知识。
class类文件结构的每一个部分都可以再深入下去,类文件结构是采用结构体的方式存储的,那么怎么知道集合的长度,各个属性又是怎么被标记的。
类加载机制中有且仅有的五种触发初始化的情况。类加载器的分类。
栈帧的结构,以及方法调用。
java语言的方法调用分为静态多分派,动态单分派。