Java刚诞生的宣传口号:一次编写,到处运行(Write Once, Run Anywhere),其中字节码是构成平台无关的基石,也是语言无关性的基础。
Java虚拟机不和包括Java在内的任何语言绑定,它只与Class文件这种特定的二进制文件格式所关联,这使得任何语言的都可以使用特定的编译器将其源码编译成Class文件,从而在虚拟机上运行。
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。
Class文件是一组以8位字节为基础单位的二进制流。
各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。 Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。
整个class类的文件结构如下表所示:
占用大小 | 字段描述 | 数量 |
---|---|---|
占用大小 | 字段描述 | 数量 |
u4 | magic:魔数,用于标识文件类型,对于java来说是0xCAFEBABE | 1 |
u2 | minor_version:次版本号 | 1 |
u2 | major_version:主版本号 | 1 |
u2 | constant_pool_count:常量池大小,从1开始而不是0。当这个值为0时,表示后面没有常量 | 1 |
cp_info | constant_pool:#常量池 | constant_pool_count-1 |
u2 | access_flags:访问标志,标识这个class是类还是接口、public、abstract、final等 | 1 |
u2 | this_class:类索引 #类索引查找全限定名的过程 | 1 |
u2 | super_class:父类索引 | 1 |
u2 | interfaces_count:接口计数器 | 1 |
u2 | interfaces:接口索引集合 | interfaces_count |
u2 | fields_count:字段的数量 | 1 |
field_info | fields:#字段表 | fields_count |
u2 | methods_count:方法数量 | 1 |
method_info | methods:#方法表 | methods_count |
u2 | attributes_count:属性数量 | 1 |
attribute_info | attrbutes:#属性表 | attributes_count |
可以使用javap -verbose输出class文件的字节码内容。
下面按顺序对这些字段进行介绍。
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(MinorVersion),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
用于识别一些类或者接口层次的访问信息,包括:
这三项数据来确定这个类的继承关系。
描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量。 而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
描述了方法的定义,但是方法里的Java代码,经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为“Code”的属性里面。
与字段表集合相类似的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”
存储Class文件、字段表、方法表都自己的属性表集合,以用于描述某些场景专有的信息。如方法的代码就存储在Code属性表中。
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。
由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条。
大多数的指令都包含了其操作所对应的数据类型信息。例如: iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。不是每种数据类型和每一种操作都有对应的指令,有一些单独的指令可以在必要的时候用在将一些不支持的类型转换为可被支持的类型。大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
加载和存储指令用于将数据在帧栈中的局部变量表和操作数栈之间来回传递。
上面带尖括号的指令实际上是代表的一组指令,如iload_0、iload_1、iload_2和iload_3。这些指令把操作数隐含在名称内,不需要进行取操作数的动作。
运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶,可分为整型数据和浮点型数据指令。byte、short、char和boolean类型的算术指令使用int类型的指令代替。
可以将两种不同的数值类型进行相互转换,
控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令如下。
是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
在java程序中,显式抛出异常的操作都由athrow指令来实现。而在java虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的
java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。方法级的同步是隐式的,利用方法表结构中的ACC_SYNCHRONIZED访问标志得知。指令序列的同步是由monitorenter和monitorexit两条指令支持。
这是一个非常典型的面试题,标准回答如下:
一般来说,我们把 Java 的类加载过程分为三个主要步骤:加载、链接、初始化。
1. 加载(Loading)
此阶段中Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。 加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
2. 链接(Linking)
这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:
3. 初始化(initialization)
这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。
双亲委派模型:
简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verificatio)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)。
于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
关于静态变量的初始化,必须要注意以下三种情况下是不会触发类的初始化的:
下面是测试程序:
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("Subclass init!");
}
}
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD_STRING = "hello world";
}
以下是对三种情况的测试程序:
public class NotInitialization {
public static void main(String[] args) {
// 1. 只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
// Result: SuperClass init! 123
System.out.println(SubClass.value);
// 2. 通过数组定义来引用类,不会触发此类的初始化
SuperClass[] superClasses = new SubClass[10];
// 3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
// Result: hello world
System.out.println(ConstClass.HELLOWORLD_STRING);
}
}
在加载阶段,虚拟机需要完成下列3件事:
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面4个阶段的检验动作:
是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value=123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
表7-1列出了Java中所有基本数据类型的零值:
假设上面类变量value的定义变为:public static final int value=123; 编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
是虚拟机将常量池内的符号引用替换为直接引用的过程。
类初始化是类加载过程的最后一步,在这个阶段才真正开始执行类中的字节码。初始化阶段是执行类构造器<clinit>()
方法的过程。
<clinit>()
方法与类的构造函数(<init>()
方法)不同,它不需要显式调用父类构造器,虚拟机会保证在子类的<clinit>()
方法执行之前,父类的<clinit>()
方法已经执行完毕。<clinit>()
方法先执行,因此父类中定义的静态语句块要先于子类执行。<clinit>()
方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。<clinit>()
方法,但与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法,只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。类加载器虽然只用于实现类的加载动作,但在java程序中起到的作用却远不止类加载阶段。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类命名空间。当一个Class文件被不同的类加载器加载时,加载生成的两个类必定不相等(equals()、isAssignableFrom()、isInstance()、instanceof关键字的结果为false)。
从java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用c++实现,是虚拟机的一部分;另一种是所有其他的类加载器,这些类加载器都由java实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。java提供的类加载器主要分以下三种:
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
首先看一下实现双亲委派模型的代码,逻辑就是先检查类是否已经被加载,如果没有则调用父加载器的loadClass()方法,如果父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 先从缓存查找该class对象,找到就不用重新加载
Class<?> c = findLoadedClass(name);
if (c == null) {
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
// 如果都没有找到,则通过自定义实现的findClass去查找并加载
c = findClass(name);
}
}
if (resolve) {//是否需要在加载时进行解析
resolveClass(c);
}
return c;
}
}
在实现自己的类加载器时,通常有两种做法,一种是重写loadClass方法,另一种是重写findClass方法。其实这两种方法本质上差不多,毕竟loadClass也会调用findClass,但是最好不要直接修改loadClass的内部逻辑,以免破坏双亲委派的逻辑。推荐的做法是只在findClass里重写自定义类的加载方法。
下面例子实现了文件系统类加载器,
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
Class.forName是Class类的方法public static Class<?> forName(String className) throws ClassNotFoundException ClassLoader.loadClass是ClassLoader类的方法public Class<?> loadClass(String name) throws ClassNotFoundException
Class.forName和ClassLoader.loadClass都可以用来进行类型加载,而在Java进行类型加载的时刻,一般会有多个ClassLoader可以使用,并可以使用多种方式进行类型加载。
class A {
public void m() {
A.class.getClassLoader().loadClass(“B”);
}
}
在A.class.getClassLoader().loadClass(“B”)
;代码执行B的加载过程时,一般会有三个概念上的ClassLoader提供使用。
SCL和TCCL可以理解为在代码中使用ClassLoader的引用进行类加载,而CCL却无法获取到其引用,虽然在代码中CCL == A.class.getClassLoader() == SCL。CCL的加载过程是由JVM运行时来控制的,是无法通过Java编程来更改的。
为什么需要破坏双亲委派?
因为在某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。
Tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托。
如何破坏?
Tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托。
Tomcat是个web容器, 那么它要解决什么问题:
Tomcat 如果使用默认的类加载机制行不行 ?
答案是不行的。为什么?
第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。 第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。 第三个问题和第一个问题一样。 第四个问题,我们要怎么实现jsp文件的热修改(楼主起的名字),jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
Tomcat 如何实现自己独特的类加载机制?
前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*
、/server/*
、/shared/*
(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*
中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
从图中的委派关系中可以看出:
CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。 WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。 而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。
下图展示了Tomcat的类加载流程:
当tomcat启动时,会创建几种类加载器:
1. Bootstrap 引导类加载器
加载JVM启动所需的类,以及标准扩展类(位于jre/lib/ext下)
2. System 系统类加载器
加载tomcat启动的类,比如bootstrap.jar,通常在catalina.bat或者catalina.sh中指定。位于CATALINA_HOME/bin下。
3. Common 通用类加载器
加载tomcat使用以及应用通用的一些类,位于CATALINA_HOME/lib下,比如servlet-api.jar
4. webapp 应用类加载器
每个应用在部署后,都会创建一个唯一的类加载器。该类加载器会加载位于 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。
典型面试题
tomcat 违背了java 推荐的双亲委派模型了吗?
违背了,双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。
如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?
可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。
参考:
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。典型栈帧结构:
下面对各个部分进行仔细介绍:
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指定一个Slot应占用的内存空间大小,只是规定每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这样可以屏蔽32位跟64位虚拟机在内存空间上的差异。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0到最大Slot数量,索引n对应第n个Slot。局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,即this。
为了尽可能的节省栈帧空间,局部变量表中的Slot是可以重用的,同时这也影响了垃圾收集行为。即对已使用完毕的变量,局部变量表仍持有该对象的引用,导致对象无法被GC回收,占用大量内存。这也是“不使用的对象应手动赋值为null”这条推荐编码规则的原因。不过从执行角度使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上,代码在经过编译器优化后才是虚拟机真正需要执行的代码,这时赋null值会被消除掉,因此更优雅的解决办法是以恰当的变量作用域来控制变量回收时间。
操作数栈(Operand Stack)也常称操作栈,它是一个后入先出(Last In First Out,LIFO)栈。方法在执行过程中,通过各种字节码指令对栈进行操作,出栈/入栈。java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了执行方法调用过程中的动态连接(Dynamic Linking)。
当一个方法开始执行后,只有两种方式可以退出这个方法:
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,称之为栈帧信息。
方法调用并不等同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本,即调用哪一个方法,暂时还不涉及方法内部的具体运行过程,就是类加载过程中的类方法解析。
解析就是将Class的常量池中的符号引用转化为直接引用(内存布局中的入口地址)。
在java虚拟机中提供了5条方法调用字节码指令:
System.exit(1);
==>编译
iconst_1 ;将1放入栈内
;执行System.exit()
invokestatic java/lang/System/exit(I)V
//<init>方法
new StringBuffer()
==>编译
new java/lang/StringBuffer ;创建一个StringBuffer对象
dup ;将对象弹出栈顶
;执行<init>()来初始化对象
invokespecial java/lang/StringBuffer/<init>()V
//父类方法
super.equals(x);
==>编译
aload_0 ;将this入栈
aload_1 ;将第一个参数入栈
;执行Object的equals()方法
invokespecial java/lang/Object/equals(Ljava/lang/Object;)Z
//私有方法
与父类方法类似
X x;
...
x.equals("abc");
==>编译
aload_1 ;将x入栈
ldc "abc" ;将“abc”入栈
;执行equals()方法
invokevirtual X/equals(Ljava/lang/Object;)Z
List x;
...
x.toString();
==>编译
aload_1 ;将x入栈
;执行toString()方法
invokeinterface java/util/List/toString()Z
在编译阶段就可以确定唯一调用版本的方法有:静态方法(类名)、私有方法、实例构造器()、父类方法(super)、final方法。其它统称为虚方法,在编译阶段无法确定调用版本,需要在运行期通过分派将符号引用转变为直接引用。
指在运行时对类内相同名称的方法根据描述符来确定执行版本的分派,多见于方法的重载。
下面的例子中,输出结果均为hello guy
。
“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
指对于相同方法签名的方法根据实际执行对象来确定执行版本的分派。编译器是根据引用类型来判断方法是否可执行,真正执行的是实际对象方法。多见于类多态的实现。
动态分配的实现,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。PPT图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
Java语言经常被人们定位为“解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念。
基于栈的指令集:指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
基于寄存器的指令集:最典型的就是X86的地址指令集,通俗一点,就是现在我们主流的PC机中直接支持的指令集架构,这些指令集依赖寄存器工作。
举个简单例子,分别使用这两种指令计算1+1的结果,基于栈的指令集会是这个样子:
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后将结果放回栈顶,最后istore_0把栈顶的值放到局部变量表中的第0个Slot中。
如果基于寄存器的指令集,那程序可能会是这个样子:
mov eax, 1
add eax, 1
mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,将结果就保存在EAX寄存器里面。
基于栈的指令集: 优点:可移植、代码相对更紧凑、编译器实现更简单等 缺点:执行速度慢、完成相同功能的指令数量更多、栈位于内存中 基于寄存器的指令集: 优点:速度快 缺点:与硬件结合紧密
参考链接: