首页 >> 大全

java虚拟机运行原理(java虚拟机基础知识大全)

2022-11-19 大全 144 作者:考证青年

任何一个Class文件都对应着唯一的一个类或接口的定义信息[插图],但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

《Java虚拟机规范》

根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。

无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。

字节码由10部分组成,依次是魔数、版本号、常量池、访问权限、类索引、父类索引、接口索引、字段表索引、方法、。

魔数 每个Class文件的头4个字节被称为魔数(Magic ),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件

1个十六进制数对应 4 位 二进制数,那么 一共 8 个十六进制数,一共需要 32 位二进制数,对应就是 4 个字节

版本号 由(次版本号)(主版本号) 组成各占两个字节

常量池 Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

常量池中主要存放两大类常量:字面量()和符号引用( )。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,主要包括下面几类常量:

·被模块导出或者开放的包()

类和接口的全限定名(Fully Name)

·字段的名称和描述符()

·方法的名称和描述符·方法句柄和方法类型( 、 Type、 )

·动态调用点和动态常量(- Call Site、-)

访问标识 紧接着的2个字节代表访问标志(),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为类型;是否定义为类型;如果是类的话,是否被声明为final;等等

类索引()和父类索引()都是一个u2类型的数据,而接口索引集合()是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。

字段表()用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。段可以包括的修饰符有字段的作用域(、、修饰符)、是实例变量还是类变量(修饰符)、可变性(final)、并发可见性(修饰符,是否强制从主内存读写)、可否被序列化(修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。

描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、、float、int、long、short、)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示

对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.[][]”类型的二维数组将被记录成“[[Ljava/lang/;”,一个整型数组“int[]”将被记录成“[I”。

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.()的描述符为“()Ljava/lang/;”,方法int (char[],,int ,char[],int ,int ,)的描述符为“([CII[CIII)I”。

7.方法表集合 方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。

有可能会出现由编译器自动添加的方法,最常见的便是类构造器“()”方法和实例构造器“()”方法

8.属性表()在前面的讲解之中已经出现过数次,Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。《Java虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。

Java字节码指令就是Java虚拟机能够听得懂、可执行的指令,可以说是Jvm层面的汇编语言,或者说是Java代码的最小执行单元。

javac命令会将Java源文件编译成字节码文件,即.class文件,其中就包含了大量的字节码指令。

Java虚拟机采用面向操作数栈而不是面向寄存器的架构(这两种架构的执行过程、区别和影响将在第8章中探讨),所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。

字节码指令分类:

存储和加载类指令:主要包括load系列指令、store系列指令和ldc、push系列指令,主要用于在局部变量表、操作数栈和常量池三者之间进行数据调度;(关于常量池前面没有特别讲解,这个也很简单,顾名思义,就是这个池子里放着各种常量,好比片场的道具库)

对象操作指令(创建与读写访问):比如我们刚刚的和就属于读写访问的指令,此外还有/,还有new系列指令,以及等指令。

操作数栈管理指令:如pop和dup,他们只对操作数栈进行操作。

类型转换指令和运算指令:如add/div/l2i等系列指令,实际上这类指令一般也只对操作数栈进行操作。

控制跳转指令:这类里包含常用的if系列指令以及goto类指令。

方法调用和返回指令:主要包括系列指令和系列指令。这类指令也意味这一个方法空间的开辟和结束,即会唤醒一个新的java方法小宇宙(新的栈和局部变量表),而则意味着这个宇宙的结束回收。

虚拟机实现的方式主要有以下两种:

·将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集;

·将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器代码生成技术)。

精确定义的虚拟机行为和目标文件格式,不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机是被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的新的、有趣的解决方案。

Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。

Class文件是Java虚拟机执行引擎的数据入口,也是Java技术体系的基础支柱之一。

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

类的生命周期

java编译时不像其他语言需要连接,类型的加载、连接和初始化过程都是在程序运行期间完成的。编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。运行时加载广泛应用于Java程序之中。

《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

1)遇到new、、或这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:·使用new关键字实例化对象的时候。·读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。·调用一个类型的静态方法的时候。

2)使用java.lang.包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang..实例最后的解析结果为、、、四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

6)当一个接口中定义了JDK 8新加入的默认方法(被关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

获取类的二进制字节流的形式

·从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。

·从网络中获取,这种场景最典型的应用就是Web 。·运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang..Proxy中,就是用了.()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。

·由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。·从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP )可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

·可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

“停机问题”( )[插图],即不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。

正式为类中定义的变量(即静态变量,被修饰的变量)分配内存并设置类变量初始值的阶段。

首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

int value=123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。表7-1列出了Java中所有基本数据类型的零值。

Java虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关

1.类或接口的解析

需要判断该类是否是数组类型

如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:

·被访问类C是的,并且与访问类D处于同一个模块。

·被访问类C是的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。

·被访问类C不是的,但是它与访问类D处于同一个包中。

2.字段解析

首先将会对字段表内[插图]项中索引的符号引用进行解析,也就是字段所属的类或接口的符号引用;

3.方法解析

先解析出方法表的[插图]项中索引的方法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类。

1)由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现中索引的C是个接口的话,那就直接抛出java.lang.Error异常。

2)如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.异常。

5)否则,宣告方法查找失败,抛出java.lang.。最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.异常。

4.接口方法解析

方法解析类似

JDK 9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.异常。

初始化阶段就是执行类构造器()方法的过程。

·()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块({}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.。·由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的Class对象的()方法、()方法、()方法的返回结果,也包括了使用关键字做对象所属关系判定等各种情况。

从java虚拟机角度看有俩种不同的类加载器:

一:启动类加载器( ) C++实现

二:所有其他的类加载器(全部都继承自抽象类java.lang.) java实现

从开发人员角度看:

启动类加载器

作用:负责将存放在lib目录中的,或者被-参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机中。启动类加载器无法被java程序直接引用,用户在编写自定义类加载时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可,

扩展类加载器

这个加载器由sun.misc.$实现,它负责加载目录中的,或者被java.ext.dirs系统变量所指定的路径的所有类库,开发者可以直接使用扩展类加载器

应用程序加载器

这个类加载器是由sun.misc.$实现。由于这个类加载器是中的()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径()上所指定的类库,开发者可以直接使用这个类加载器。如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型的工作流程:

当类加载器接收到类加载的请求时,它不会自己去尝试加载这个类,而是把这个请求委派给父加载器去完成,每一个层次的类加载器都是如此,因此所有的请求最终都应该传送到启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。

优点:java类随着它的类加载器一起具备了一种带有优先级的层次关系。

举例:比如我们要加载java.lang.,它存放在rt.jar中,无论哪个类加载器要加载换个类,都会委派给处于模型最顶端的启动类加载器进行加载,因此类在程序的各种类加载器环境中都是同一个类(上面提到了如何比较俩个类是否'相等')。相反,如果没有双亲委派模型,那么各个类加载器都去自行加载的话,那么在程序中就会出现多个类,导致应用程序一片混乱。

双亲委派模型的实现

Class ( name, ) on{

//首先检查请求的类是否已经被加载过

Class c = (name);

if(c == null){

try{

if( != null){

//委派父类加载器加载

c = .(name, false);

}

else{

//委派启动类加载器加载

c = Null(name);

}

}catch(on e){

//父类加载器无法完成类加载请求

}

if(c == null){

//本身类加载器进行类加载

c = (name);

}

}

if(){

(c);

}

c;

}

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承()的关系来实现的,而是通常使用组合()关系来复用父加载器的代码。

JDK12才有双亲委派模型,面对已经存在的用户自定义类加载器的代码,为了兼容这些已有代码,无法再以技术手段避免()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.中添加一个新的方法(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在()中编写代码。

由这个模型自身的缺陷导致的,如果有基础类型又要调用回用户的代码。

由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署()等

OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为)都有一个自己的类加载器,当需要更换一个时,就把连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构

在JDK 9中引入的Java模块化系统(Java ,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。

·JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块( )里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。

·模块在模块路径的访问规则:模块路径下的具名模块(Named )只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。

·JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块( )。尽管不包含-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。

JDK9以后,扩展类加载器( Class )被平台类加载器( )取代。

当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈( Stack)[插图]的栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

局部变量表(Local Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的数据项中确定了该方法所需分配的局部变量表的最大容量。

一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有、byte、char、short、int、float、[插图]和这8种类型。

第7种类型表示对一个对象实例的引用,虚拟机实现至少都应当能通过这个引用做到两件事情,一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《Java语言规范》中定义的语法约定。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。

操作数栈( Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的数据项之中。

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。

每个栈帧都包含一个指向运行时常量池[插图]中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接( )。

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

当一个方法开始执行后,只有两种方式退出这个方法。

第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”( )。

另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。这种退出方法的方式称为“异常调用完成( )”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。

方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将符合“编译期可知,运行期不可变”的方法符号引用转化为直接引用。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析()。

静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-),与之相反,其他方法就被称为“虚方法”( )。

英文一般是“ ”,所以其实是个动态概念

class {

class Human{

}

class Man Human{

}

class Woman Human{

}

void (Human guy){

.out.("Hello guy");

}

void (Man guy){

.out.("Hello ");

}

void (Woman guy){

.out.("Hello lady");

}

void main([] args){

Human man = new Man();

Human woman = new Woman();

= new ();

.(man);

.(woman);

}

}

运行结果

Hello guy

Hello guy

Human hu = new Man():

面代码中的“Human”称为变量的静态类型( Type)或者外观类型( Type),后面的“Man”则称为变量的实际类型( Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么?如下面的代码:

//实际类型变化

Human man = new Man();

man = new Woman();

//静态类型变化

sd.((Man)man);

sd.((Woman)man);

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

class {

void (int i){

.out.("int 类型");

}

void ( obj){

.out.("obj 类型");

}

void (long i){

.out.("long 类型");

}

void (char i){

.out.("char 类型");

}

void main([] args) {

(1);

(1L);

('a');

}

}

笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系,它们是不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

自动转型还能继续发生多次,按照char>int>long>float>的顺序转型进行匹配,但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。

自动装箱

装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接上层的优先级越低。

可见变长参数的重载优先级是最低的,这时候字符'a'被当作了一个char[]数组的元素。

有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的

Java语言多态性的另外一个重要体现——重写()。

class {

class Human{

void ();

}

class Man Human{

@

void () {

.out.("man say hello!");

}

}

class Woman Human{

@

void () {

.out.("woman say hello!");

}

}

void main([] args) {

Human man=new Man();

Human woman=new Woman();

man.();

woman.();

man=new Woman();

man.();

}

}

输出结果:

man say hello!

woman say hello!

woman say hello!

根据《Java虚拟机规范》,指令的运行时解析过程[插图]大致分为以下几步:

1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.异常。

3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。

4)如果始终没有找到合适的方法,则抛出java.lang.异常。

正是因为指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

既然这种多态性的根源在于虚方法调用指令的执行逻辑,那自然我们得出的结论就只会对方法有效,对字段是无效的,因为字段不使用这条指令。事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了的构造函数,而构造函数中对()的调用是一次虚方法调用,实际执行的版本是Son::()方法,所以输出的是“I am Son”,这点经过前面的分析相信读者是没有疑问的了。而这时候虽然父类的money字段已经被初始化成2了,但Son::()方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2。

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

根据上述论证的结果,我们可以总结一句:如今(直至本书编写的Java 12和预览版的Java 13)的Java语言是一门静态多分派、动态单分派的语言。

JDK 7的发布的字节码首位新成员——指令。

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、、、、、Lisp、Lua、PHP、、、Ruby、、Tcl,等等。那相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方面:JDK 7以前的字节码指令集中,4条方法调用指令(、、、)的第一个参数都是被调用的方法的符号引用(nfo或者常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。

java.lang.包[插图]是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”( )。

指令与机制的作用是一样的,都是为了解决原有4条“*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。

读、理解,然后获得执行能力。大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要下图的步骤:

基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?举个最简单的例子,分别使用这两种指令集去计算“1+1”的结果,基于栈的指令集会是这样子的:

两条指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。

而如果用基于寄存器的指令集,那程序可能会是这个样子:

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存。

基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供[插图],程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构。

例如:

a=100;

b=200;

c=300;

(a+b)*c

javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间

的类加载

OSGi(Open )是OSGi联盟(OSGi )制订的一个基于Java语言的动态模块化规范(在JDK 9引入的JPMS是静态的模块系统)

字节码生成技术应用于:javac,Web服务器中的JSP编译器,编译时织入的AOP框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。

动态代理中所说的“动态”,是针对使用Java代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点编码工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。

跨越JDK版本之间的沟壑,把高版本JDK中编写的代码放到低版本JDK环境中去部署使用。为了解决这个问题,一种名为“Java逆向移植”的工具(Java Tools)应运而生,[插图]和是这类工具中的杰出代表。

JDK的每次升级新增的功能大致可以分为以下五类:

1)对Java类库API的代码增强。譬如JDK 1.2时代引入的java.util.等一系列集合类,在JDK 5时代引入的java.util.并发包、在JDK 7时引入的java.lang.包,等等。

2)在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱,实际上就是Javac编译器在程序中使用到包装对象的地方自动插入了很多.()、Float.()之类的代码;变长参数在编译之后就被自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经被擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码[插图]。

3)需要在字节码中进行支持的改动。如JDK 7里面新加入的语法特性——动态语言支持,就需要在虚拟机中新增一条字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对稳定的状态,这种要在字节码层面直接进行的改动是比较少见的。

4)需要在JDK整体结构层面进行支持的改进,典型的如JDK 9时引入的Java模块化系统,它就涉及了JDK结构、Java语法、类加载和连接过程、Java虚拟机等多个层面。

5)集中在虚拟机内部的改进。如JDK 5中实现的JSR-133[插图]规范重新定义的Java内存模型(Java Model,JMM),以及在JDK 7、JDK 11、JDK 12中新增的G1、ZGC和收集器之类的改动,这种改动对于程序员编写代码基本是透明的,只会在程序运行时产生影响。

前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;

Java虚拟机的即时编译器(常称JIT编译器,Just In Time )运行期把字节码转变成本地机器码的过程;

指使用静态的提前编译器(常称AOT编译器,Ahead Of Time )。

Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。

编译——1个准备3个处理过程

1)准备过程:初始化插入式注解处理器。

2)解析与填充符号表过程,包括:·词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。·填充符号表。产生符号地址和符号信息。

3)插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。

4)分析与字节码生成过程,包括:·标注检查。对语法的静态信息进行检查。·数据流及控制流分析。对程序动态运行过程进行检查。·解语法糖。将简化代码编写的语法糖还原为原有的形式。·字节码生成。将前面各个步骤所生成的信息转化成字节码。

执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号

插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)。

语义分析与字节码生成

1.标注检查

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配。

常量折叠( )的代码优化:在代码里面定义“a=1+2”比起直接定义“a=3”来,并不会增加程序运行期哪怕仅仅一个处理器时钟周期的处理工作量。

2.数据及控制流分析

数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。

泛型的本质是参数化类型( Type)或者参数化多态(sm)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。

Java选择的泛型实现方式叫作“类型擦除式泛型”(Type ),而C#选择的泛型实现方式是“具现化式泛型”( )。

Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type,稍后我们会讲解裸类型具体是什么)了,并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,与其实是同一个类型

在没有泛型的时代,由于Java中的数组是支持协变()的,引入泛型后可以选择:

1)需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型。

2)直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版。

我们继续以为例来介绍Java泛型的类型擦除具体是如何实现的。由于Java选择了第二条路,直接把已有的类型泛型化。要让所有需要泛型化的已有类型,譬如,原地泛型化后变成了,而且保证以前直接用的代码在泛型新版本里必须还能继续用这同一个容器,这就必须让所有泛型化的实例类型,譬如、这些全部自动成为的子类型才能可以,否则类型转换就是不安全的。由此就引出了“裸类型”(Raw Type)的概念,裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type)。

如何实现裸类型。这里又有了两种选择:一种是在运行期由Java虚拟机来自动地、真实地构造出这样的类型,并且自动实现从派生自的继承关系来满足裸类型的定义;另外一种是索性简单粗暴地直接在编译时把还原回,只在元素访问、修改时自动插入一些强制类型转换和检查指令。

基于这种方法实现的泛型称为伪泛型。

void main([] args){

Map map=new ();

map.put("hello","nihao");

map.put("how are you","");

.out.(map.get("hello"));

.out.(map.get("how are you"));

}

这段代码编译成Class文件,然后用字节码反编译工具进行反编译后,泛型类型都变回了原生类型

void main([] args){

Map map=new ();

map.put("hello","nihao");

map.put("how are you","");

.out.(()map.get("hello"));

.out.(()map.get("how are you"));

}

java泛型擦除式实现的缺陷:

1.对原始类型( Types)数据的支持又成了新的麻烦,既然没法转换那就索性别支持原生类型的泛型了吧,你们都用、,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了得了。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为Java泛型慢的重要原因,也成为今天项目要重点解决的问题之一。

2.运行期无法取到泛型类型信息。

由于List和List擦除后是同一个类型,我们只能添加两个并不需要实际使用到的返回值才能完成重载。

另外,从属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

条件编译

定义一个 final 的变量,然后在 if 语句用中它隔开代码。

class Hello {

void main([] args) {

final DEBUG = true;

if (DEBUG) {

.out.("Hello, world!");

} else {

// some code

}

}

}

因为编译器会对代码进行优化,对于条件永远为 false 的语句,Java 编译器将不会对其生成字节码。

应用场景:实现一个区分DEBUG和模式的程序。

逆变与协变用来描述类型转换(type )后的继承关系,其定义:如果A、B表示类型,f(?)表示类型转换,≤表示继承关系(比如,A≤B表示A是由B派生出来的子类);

f(?)是逆变()的,当A≤B时有f(B)≤f(A)成立;

f(?)是协变()的,当A≤B时有f(A)≤f(B)成立;

f(?)是不变()的,当A≤B时上述两个式子均不成立,即f(A)与f(B)相互之间没有继承关系。

数组是协变的

Food food = new Fruit();

// or

food = new Meat(); // 即 把子类赋值给父类引用

Fruit [] = new Fruit[3];

Food [] = ; // 数组协变的

泛型是不变的

List = new ();

List = ; //错误:不可协变

= ; // 错误 :不可逆变

eat();// 错误::不可协变

void (List list){

list.add(new Apple());

}

泛型使用通配符实现协变与逆变。 PECS: -, -super.

关于我们

最火推荐

小编推荐

联系我们


版权声明:本站内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 88@qq.com 举报,一经查实,本站将立刻删除。备案号:桂ICP备2021009421号
Powered By Z-BlogPHP.
复制成功
微信号:
我知道了