JVM运行原理
JVM是运行原理,Class文件介绍,虚拟机类加载机制,类加载的全过程,虚拟机执行引擎
什么是JVM
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种对于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译器只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。
Java技术体系
Java技术体系包括:
1、Java程序设计语言
2、各种硬件平台上的Java虚拟机
3、Class文件格式
4、Java API 类库
5、来自商业机构和开源社区的第三方Java类库
我们可以把Java程序设计语言、Java虚拟机、Java API类库这三个部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境。
另外,我们可以把Java API类库中的Java SE API子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境。
JVM运行原理
JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,可以在上面执行java的字节码程序。java编译器只需面向JVM,生成JVM能理解的字节码文件。
Java程序在JVM上运行的一般步骤:
1、Java源文件经编译器,编译成字节码(其中方法被编译为字节码指令)。
2、通过类加载器将字节码加载到虚拟机内存,并将字节码所代表的静态存储结构转化为方法区的运行时数据结构。
3、通过JVM解释器将每一条字节码指令翻译成特定平台上的机器码,然后通过特定平台运行。
字节码
首先,我们需要弄清楚什么是字节码?字节码是如何来描述类的静态结构的呢?
代码编译的结果从本地机器码转变为字节码是存储格式发展的一小步,却是编程语言发展的一大步。为什么这么说呢?
一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。例如,我们将c/c++的源程序编译生成的目标代码(可执行文件)拷贝到其他机器上运行,可能会因为运行环境不匹配而无法执行。因为编译生成的机器码是与特定平台相关的(如指令集,字宽),因此当我们将可执行程序在其他平台上执行,可能会因为指令不支持或者指令格式不兼容等原因而无法执行。
Java语言中,程序编译后生成的是字节码而不是机器码。字节码不包含任何平台相关的信息,故具有平台无关性。但是任何程序的执行最终都是需要先转换成平台相关的机器码才能被物理计算机执行,因此就需要在不同的平台上具有不同的虚拟机实现,从而将字节码转换成平台相关的机器码。当我们将程序编译生成的字节码拷贝到不同的平台上,只要该平台上具有平台相关的Java虚拟机,我们就能正确的运行字节码,这也就是所谓的“一次编译,到处运行”。实质上,到处运行的能力,是建立在各种不同平台的虚拟机基础上的,而不是单单依靠字节码的平台无关性。
因此说各种不同平台的虚拟机和字节码共同构成了平台无关性的基石。
Java虚拟机的语言无关性
此外,Java虚拟机不和包括Java语言在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,也就是说,虚拟机并不关心Class(描述类静态结构的字节码)的来源是何种语言。
Class类文件的结构
注意:任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(比如类或接口也可以直接通过类加载器直接生成)。本文中,将任意一个有效的类或接口所应该满足的格式成为“Class文件格式”,实际上它并不一定以磁盘文件的形式存在。
Class文件是一组以字节(8bit)为基础单位的二进制流,各个数据项严格按照顺序紧凑地排列在Class文件中,中间没有任何间隔。Class文件用于描述类或接口的静态存储结构。
Class文件格式采用一种伪结构来存储数据,类似于数据库中元组的存储结构。之所以说,这是一种伪结构,是因为Class文件中的数据并没有使用额外的信息去描述这种结构,而是我们将Class文件中的数据项按照约定好的格式(结构)进行存储,这样我们在解析时也可以同样按照特定的约定去解析Class文件。
这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以u1、u2、u4和u8来分别表示1个字节、2个字节、4个字节和8个字节的无符号数,无符号数用来描述:数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型(表的类型名习惯以_info结尾)。可以这样理解,每种类型的表就是一种数据格式的约定,其规定了表中允许出现哪些数据项、以及它们的数据类型(无符号数或表)以及它们在表中出现的顺序。
例如,整个Class文件本质上就是一张表,而该表中又包含多种其他类型的表。
魔术(magic)
类型为u4、数量为1,即1个4字节的无符号数,用于确定这个文件是否为一个能被虚拟机接受的Class文件(即进行身份识别)。
次版本(minor_version)和主版本(major_version)
类型均为u2、数量均为1,这两个数据项用于描述编译生成该Class文件的JDK版本,高版本的JDK能兼容以前版本的Class文件。
常量池容量(constant_pool_count)和常量池(constant_pool)
1、常量池容量
常量池可以理解为Class文件之中的资源仓库,由于常量池中常量的数量不是固定的,所以需要前置一个容量计数器来描述常量池中常量的个数,类型为u2,称为常量池容量。
2、常量池
常量池主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串,声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
Class文件中为什么存在符号引用
Java代码在进行Javac编译的时候,并不像c/c++那样有“连接”这个步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法直接得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获取对应的符号引用,然后在类创建时或运行时解析、翻译到具体的内存地址之中。
常量池中每一项常量都是一个表,每个表的开始都有一个u1类型的标志位,代表当前这个常量属于那种常量类型。
访问标志(access_flags)
类型u2、数量为1,这个标志用于识别一些类或者接口层次的访问信息,包括:这个class是类还是接口;是否定义为public类型;是否定义了abstract类型;如果是类的话,是否被声明为final等。
类索引、父类索引和接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,这里也验证了java是单继承体系。因为类实现接口的数量是不确定的,因此接口索引集合有一个前置的容量计数器(interfaces_count),类型为u2。此外,类索引、父类索引和接口索引都是u2类型的索引值,它们各自指向一个常量池中的常量。
Class文件中由这三个数据项来确定这个类的继承关系。
字段表集合(fields)
字段(field)包括类级变量以及实例变量(不包括方法内部声明的局部变量),由于其数量是不确定的,因此字段表集合有一个前置的容量计数器(fields_count),类型为u2。字段(field)的类型为field_info。我们可以想一想,在Java中描述一个字段需要哪些方面的信息?(public、private和protected)、static、final、volatile、transient等修饰符,以及字段数据类型和字段名称。Java支持的修饰符是确定的,对于各个修饰符,只需要一个bit位标记即可。而字段数据类型和字段名称,这些都是无法固定的,只能引用常量池中的常量来描述。
access_flags就是用于标记修饰符的数据项。name_index和descriptor_index,他们都是对常量池的引用,分别代表字段名称和字段数据类型的描述符。
何为描述符?
例如,方法inc()和字段m的名称描述符就是inc和m,比较直接。对于字段和方法的描述符就相对复杂。如,类型java.lang.String[][],其描述符为“[[Ljava/lang/String”,方法java.lang.String toString()的描述符为“()Ljava.lang.String”。不难发现,描述符也是一种伪结构,数据按照约定的格式组织,解析的时候按照约定进行解析。为什么使用描述符?因为每个特定的数据类型对应的描述符是一样的,如果我们有多个这样的类型,我们只需要在常量池中维护一个这样的描述符(常量),而描述字段类型的时候我们只需要一个对常量池中常量的引用。显然,这样就可以缩小Class文件的大小。
注:字段表集合中不会列出从超类或者父接口中继承而来的字段,但是有可能列出原来Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
方法表集合(methods)
方法表的结构如同字段表一样,依次包含了访问标志、名称索引、描述符索引(指向方法特征签名的描述符)、属性表集合。
有人不禁会问,那么方法里面的Java代码区那里了呢?
方法里边的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为Code的属性里。
与字段表集合相对应的,如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类的构造器“<clinit>"方法和实例构造器”<init>“方法。
在Java语言中,要重写(Override)一个方法,除了要与原来方法具有相同的简单名称之外,还需要具有相同的特征签名(包括参数列表和返回值)。
属性表集合(attributes)
在Class文件、字段表、方法表中都可以携带自己的属性表集合,用于描述某些场景专有的信息。常见属性如下:
Code属性:用于描述代码。
Exception属性:列出方法中可能抛出的受检查异常。
ConstantValue属性:该属性的作用是通知虚拟机自动为静态常量赋值。
InnerClasses属性:用于记录内部类与宿主类之间的关联关系。
虚拟机类加载机制
在Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。而虚拟机如何加载这些Class文件呢?Class文件中的信息进入到虚拟机后会发生什么变化呢?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译时需要进行连接工作的语言不同,在java语言里,类型的加载、连接和初始化过程都是在运行期间完成的,这种策略虽然会令类加载时稍微增加了一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
类的生命周期
其中验证、准备、解析部分统称为连接(Linking)。
类的加载时机
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的。而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
初始化
什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范并没有强制约束,这点依赖虚拟机的具体实现。
但是对于初始化阶段,虚拟机规范则严格规定了如下几种情况必须立即进行”初始化"(而加载、验证、准备自然需要在此之前开始)
1、使用new关键字实例化对象的时候、读取或设置一个类的静态字段的时候(被final修饰,已在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
2、使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
以上场景中的行为称为对一个类的主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
被动引用的例子
1、通过子类引用父类的静态字段,不会导致子类的初始化
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 NoInitialization {//测试类
public static void main(String[] args) {
System.out.println(SubClass.value);//通过子类来引用父类中定义的静态字段
}
}
输出:
SuperClass init!
123
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
2、通过数组定义来引用类,不会触发此类的初始化
public class NoInitialization {
public static void main(String[] args) {
SuperClass[] sca=new SuperClass[10];
}
}
运行后发现:没有输出。这说明没有触发SuperClass类的初始化。但是,这段代码却触发了另一个名为“[Lorg.SuperClass”的类的初始化,对于用户代码而言,这并不是一个合法的类名,它是由虚拟机自动生成的、直接继承于java.lang.Object的子类。
这个类代表了一个元素类型为org.SuperClass的一维数组,数组中应有的属性和方法(length属性和clone方法)都实现在这个类里。Java语言对数组的访问比c/c++相对安全是因为这个类封装了数组元素的访问方法,而c/c++直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常。
3、常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
public class ConstantClass {
static{
System.out.println("ConstantClass init!");
}
public static final double PI=3.14159;//定义静态常量
}
public class NoInitialization {
public static void main(String[] args) {
double r=5.5;
System.out.println("area:"+ConstantClass.PI*r*r);
}
}
输出:
area:95.0330975
没有输出“ConstantClass init!”,这是因为虽然NoInitialization类在Java源码中引用了ConstantClass类中的常量PI,但其实在编译阶段通过常量传播化,已经将此常量的值“3.14159”存储到NoInitialization类的常量池中,以后NoInitialization对常量PI的引用实际上都被转换为对自身常量池中常量的引用。也就是说,实际上NoInitialization的Class文件之中并没有ConstantClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。
接口的加载过程
接口的加载过程和类加载过程稍微有些不同。
上面的代码中都使用静态代码块“static{}”来输出初始化信息,而接口中不能使用静态代码块,但是编译器仍然为接口生成“<clinit>()”类构造器,用于初始化接口中所定义的成员变量。
接口与类真正区别在于:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
类加载的详细过程
加载
“加载”是“类加载”过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:
1、通过一个类的全限定名来获取定义此类的二进制字节流
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
对于第一条“通过一个类的全限定名来获取此类的二进制字节流”,虚拟机没有指明二进制字节流要从一个Class文件中获取,准确的说是根本没有指明要从哪里获取、怎么获取。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的“舞台”,很多Java技术都建立在这一基础之上。
1、从zip、jar、war中获取。
2、从网络中获取,如Applet。
3、运行时计算生成,这种场景使用最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generate ProxyClass()方法来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
4、由其他文件生成,典型场景就是JSP应用,即由JSP文件生成对应的Class类。
5、从数据库中读取,这种场景相对少见,例如中间件服务器,可以选择将程序安装到数据库中来完成程序代码在集群间的分发。
相对于类加载阶段的其他阶段,一个非数组类的加载阶段(准确的说,是加载阶段中获取类的二进制字节流的动作)时开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器取完成,开发人员可以通过自定义自己的类加载器取控制字节流的获取方式。
加载完成之后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义。然后在内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的该数据类型的入口。(虚拟机并没有明确规定该Class对象是放在堆上,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面)
验证
验证就是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备
正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
注意:这时候进行内存分配的仅包括类变量(被static关键字修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对行啊一起分配在Java堆中。
其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量定义为:
public static int value=123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何java方法。把value赋值为123的指令时程序编译后,存在于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
此外,还有一种特殊情况:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设一个类变量value的定义变为:
public static final int value =123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用
直接引用可以是直接指向目标地址的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化
类初始化阶段是类加载过程的最后一步。前面的类记载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。
准备阶段,变量已经赋过一次系统要求的初始值,而初始化阶段是根据程序员通过程序制定的主观计划去初始化变量和其他资源。初始化阶段就是执行类构造器<clinit>()方法的过程。
类构造器<clinit>()
1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
public class Test {
static{
i=2;//给变量赋值是可以正常编译通过
System.out.println(i);//编译器会提示“非法向前引用”
}
static int i=1;
}
2、<clinit>()方法与类的构造器<init>()不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完成。
3、由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
4、<clinit>()方法对于方法和接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
5、接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但是与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化,另外,接口的实现类在初始化时也不一定会执行接口的<clinit>()方法。
6、虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁、同步,如果多个线程同时取初始化一个类,那么只会有一个线程取执行这个类的<clinit>()方法。
虚拟机字节码执行引擎
执行引擎是java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行执行指令集与执行引擎的体系结构,并且能够执行那些不被硬件直接支持的指令集格式。
在java虚拟规范中制定了虚拟机字节码执行引擎的概念模型。这个概念模型称为各个虚拟机执行引擎的统一外观。在不同虚拟机实现里面,执行引擎在执行java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译产生本地代码执行)两种选择,也可以二者兼备,甚至可以包含几个不同级别的编译器执行引擎。
但从外观上看,所有的java虚拟机的执行引擎都是一致的:输入的是字节码文件、处理过程是字节码执行引擎解析的等效过程,输出的是执行结果。下面主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译期间,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程对应一个虚拟机栈,虚拟机栈是线程私有的,一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态,因此虚拟机栈中存放很多栈帧。对于执行引擎来说,在活动的线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码执行都只针对当前栈帧进行操作。
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在该方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大荣来那个,局部变量表的容量以Slot为最小单位。在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static),那局部变量表中第0个Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。其余方法参数则按照参数表顺序排序,占用从1开始的Slot,参数表分配完成后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的。因为,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。
注意:关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。我们知道,类变量有两次赋初始值的过程,一次是在准备阶段,赋予系统初始值;另一次是在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,因为虚拟机不会为其指定默认初始值,还好编译器能够在编译期间就能检查到并提示这一点(即使编译能通过或者手动生成字节码的方式制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败)。
public static void main(String[] args){
int a;
System.out.println(a);//编译器提示错误
}
为什么执行引擎不给局部变量设置默认初始值呢?我们可以试想一下,对于局部变量都是程序员主观定义并有责任对其设置有意义的初始值,如果虚拟机给局部变量设置了默认的初始值,那么即使程序员忘记了设定有意义的初始值,程序也能“正常运行”,这样可能在运行时会报异常或者说程序一直“错误的执行”。而编译器通过编译期检查,强制程序员遵循这样一种约束,可以避免大量由于疏忽而产生的错误,对于具有潜在错误的代码,在编译期间给出错误提示远比在运行期间报异常要好得多。此外,由于局部变量可能重用Slot,假设我们能够正常运行,那么变量的初始值将是不可预期的(使用该Slot的上一个局部变量的值),这显然不够安全的。那么,如果虚拟机执行引擎每次给局部变量分配了Slot之后都首先设置默认的初始值,对于方法参数、局部变量,我们一般都会给定特定环境下具有特定意义的初始值而非系统的默认值,这样我们设置默认初始化值之后,又需要设置我们指定的初始值,显然绝大多数时候设定默认的初始值是一种无用功。
此外,局部变量表建立在线程的堆栈(虚拟机栈)上,因此是线程似有的。
操作数栈
操作数栈也常称为操作栈,它是一个“先入后出”栈。同局部变量表一样,操作数栈的最大深度也是在编译的时候写入到Code属性的max_stacks数据项中的。
在概念模型上,两个栈帧作为虚拟机栈的元素,是完全独立的,但是大多数虚拟机实现中都会做一些优化处理,令两个帧帧出现一部分重叠。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”(Java虚拟机采用“面向操作数栈”的架构),其中所指的“栈”就是操作数栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池中存在大量的符号引用,这些符号引用一部分会在类加载阶段或者第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分在每次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式,执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,这种退出方法的方式称为正常完成出口。第二种方式,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
方法退出的过程实际上就等同于把当前栈帧出战,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈,调用PC计数器的值以指向方法调用指令后面的一条指令等。
基于栈的字节码解释执行引擎
虚拟机是如何执行方法中的字节码指令的呢?之前提到,许多Java虚拟机的执行系统在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。我们先探讨解释执行时,虚拟机执行引擎是如何工作的。
解释执行
不论是解释还是编译执行,也不论是物理机还是虚拟机,大部分程序的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过如下各个步骤:
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作时在Java虚拟机之外进行的,而解释器在虚拟机内部,所有Java程序的编译就是半独立的实现。
基于栈的指令集与基于寄存器的指令集
Java虚拟机的指令由一个字节长度的操作码(Opcode 代表某种特定操作)+零个或者多个操作数(Operands)构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只是一个操作码。
用一个字节来代表操作码,也是为了尽可能获取短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由Java语言设计之初面向网络、智能家电的技术背景所决定的,并一直沿用至今。
那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
优点:基于栈的指令集主要的优点就是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器不可避免地要受到硬件资源的约束。如果使用基于栈的指令集架构,用户程序不会直接使用这些寄存器,而是由虚拟机实现来自行决定把一些访问最频繁的数据放到寄存器中以获取尽可能好的性能,这样实现起来也更加简单。此外,栈架构的指令集还有一些其他优点,如代码相对紧凑、编译器实现更加简单(不需要考虑空间分配问题,所有空间都在栈上操作)等。
缺点:执行速度相对来说慢一些。 栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。
基于栈的解释器执行过程
执行指令bipush,将100压入操作数栈栈顶。
执行指令istore_1,将操作数栈顶的100存入第1个Slot。
前六条指令执行完成之后,我们定义的三个局部变量就都已经存储到了对应的Slot中。
接下来我们需要开始执行加法运算,而加法运算需要两个操作数,因此我们先把我们需要的两个操作数压入操作数栈。
当加法指令需要的操作数准备好之后,我们从操作数栈中取出两个操作数并执行加法指令,然后将执行结果放回操作数栈。
接着我们需要执行乘法,首先我们把另外一个操作数(300)压入操作数栈,然后从操作数栈中取出两个操作数并执行乘法,将结果放回操作数栈,最后方法执行结束并返回。
上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提升性能。
内容源自:
《深入理解Java虚拟机》
更多推荐
所有评论(0)