深入Java虚拟机本地方法栈.docx
《深入Java虚拟机本地方法栈.docx》由会员分享,可在线阅读,更多相关《深入Java虚拟机本地方法栈.docx(13页珍藏版)》请在冰点文库上搜索。
深入Java虚拟机本地方法栈
深入Java虚拟机——本地方法栈
本地方法栈
前面提到的所有运行时数据区都是在Java虚拟机规范中明确定义的,除此之外,对于一个运行中的Java程序而言,它还可能会用到一些跟本地方法相关的数据区。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。
本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止于此,它还可以做任何它想做的事情。
比如它甚至可以直接使用本地处理器中的寄存器,或者直接从本地内存的堆中分配任意数量的内存等等。
总之,它和虚拟机拥有同样的权限(或者说能力)。
本地方法本质上是依赖于实现的,虚拟机实现的设计者们可以自由地决定使用怎样的机制来让Java程序调用本地方法。
任何本地方法接口都会使用某种本地方法栈。
当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。
然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态链接并直接调用指定的本地方法。
可以把这看作是虚拟机利用本地方法来动态扩展自己。
就如共Java虚拟机是的实现在按照其中运行的Java程序的吩咐,调用属于虚拟机内部的另一(动态链接的)方法。
如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那么它的本地方法栈就是C栈。
我们知道,当C程序调用一个C函数时,其栈操作都是确定的。
传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。
同样,这就是该虚拟机实现中本地方法栈的行为。
很可能本地方法接口需要回调Java虚拟机中的Java方法(这也是由设计者决定的),在这种情形下,该线程会保存本地方法栈的状态并进入到另一个Java栈。
图5-13描绘了这种情况,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。
该图展示了Java虚拟机内部线程运行的全景图。
一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。
如图5-13所示,该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。
图中的本地方法栈显示为一个连续的内存空间。
假设这是一个C语言栈,期间有两个C函数,它们都以保卫在虚线中的灰色块表示。
第一个C函数被第二个Java方法当作本地方法调用,而这个C函数又调用了第二个C函数。
之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。
就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,它可以根据需要动态扩展或者收缩。
某些实现也允许用户或者程序员指定该内存区的初始大小以及最大,最小值。
5.3.10执行引擎
任何Java虚拟机实现的核心都是它的执行引擎。
在Java虚拟机规范中,执行引擎的行为使用指令集来定义。
对于每条指令、规范都详细规定了当实现执行到该指令时应该处理什么,但是却对如何处理言之甚少。
在前面的章节中提到过,实现的设计者有权决定如何执行字节码,实现可以采取解释、即时编译或者直接用芯片上的指令执行,还可以是它们的混合,或任何你能想到的新技术。
和本章开头提到的对“Java虚拟机”这个术语有三种不同的理解一样,“执行引擎”这个属于也可以有三种解释:
一个是抽象的规范,一个是具体的实现,另一个是正在运行的实力。
抽象规范使用指令集规定了执行引擎的行为。
具体实现可能使用多种不同的技术——包括软件方面、硬件方面或数种技术的集合。
作为运行时实例的执行引擎就是一个线程。
运行中Java程序的每一个线程都是一个独立的虚拟机执行引擎的实例。
从线程生命周期的开始到结束,它要么在执行字节码,要么在执行本地方法。
一个线程可能通过解释或者使用芯片级指令直接执行字节码,或者间接通过即时编译器质性编译过的本地代码。
Java虚拟机的实现可能用一些对用户程序不可见的线程,比如垃圾收集器。
这样的线程不需要是实现的执行引擎的实例,所有属于用户运行程序的线程,都是在实际工作的执行引擎。
指令集方法的字节码流是由Java虚拟机的指令序列构成的,每一条指令包含一个单字节的操作吗,后面跟随0个或多个操作数。
操作码表明需要执行的操作;操作数向Java虚拟机提供执行操作码需要的额外信息。
操作码本身就已经规定了它是否需要跟随操作数,以及如果有操作数的话,它是什么形式的。
很多Java虚拟机的指令不包含操作数,仅仅是由一个操作码字节构成的。
根据额操作码的需要,虚拟机可能除了跟随操作码的操作数之外,还需要从另外一些存储区域得到操作数。
当虚拟机执行一条指令的时候,可能使用当前常量池中的项,当前帧的局部变量中的值,或者位于当前帧操作数栈顶端的值。
抽象的执行引擎每次执行一条字节码指令。
Java虚拟机中运行的程序的每个线程(执行引擎实例)都执行这个操作。
执行引擎取得操作码,如果操作码有操作数,取得它的操作数。
它执行操作码和跟随的操作数规定的动作,然后再取得下一个操作码。
这个执行字节码的过程在线程完成前将一直持续,通过从它的初始方法返回,或者没有捕获抛出的异常都可以标志着线程的完成。
执行引擎会不时遇到请求本地方法调用的指令。
在这个时候,虚拟机负责试着发起这个本地方法调用。
如果本地方法返回了(假设是正常返回,而不是抛出了一个异常),执行引擎会继续执行字节码流中的下一条指令。
可以这样来看,本地方法是Java虚拟机指令集的一种可编程扩展。
如果一条指令请求一个对本地方法的调用,执行引擎就会调用这个本地方法。
运行这个本地方法就是Java虚拟机对这条指令的执行。
当本地方法返回了,虚拟机继续执行下一条指令。
如果本地方法异常中止了(抛出了一个异常),虚拟机就按照好比是这条指令抛出这个异常一样的步骤来处理这个异常。
执行一条指令包含的任务之一就是决定下一条要执行的是什么指令。
执行引擎决定下一个操作码时有三种方法。
很多指令的下一个操作码就是在当前操作码和操作数之后紧跟的那个字节(如果字节码流里面还有的话)。
另外一些指令,比如goto或者return,执行引擎决定下一个操作码时把它当作当前执行指令的一部分。
假若一条指令抛出了一个异常,那么执行引擎将搜索合适的catch子句,以决定下一个执行的操作码是什么。
有些指令可以抛出异常。
比如,athrow指令,就明确地抛出一个异常。
这条指令就是Java源代码中的throw语句的编译后形式。
每当执行一条athrow指令的时候,它都将抛出一个异常。
其他抛出异常的指令都只有在满足某些特定条件的时候才抛出异常。
比如,假若Java迅即发现程序试图用0除一个整数,它就会抛出一个ArithmeticException异常。
这只有在执行四条特定的除法(idev,idiv,irem和lrem)的时候,或者计算int或者long的余数的时候,才可能发生。
Java虚拟机指令集的每种操作码都有助记符。
使用典型的汇编语言风格,Java字节码流可以用助记符跟着(可选的)操作数值来表示。
方法的字节码流和助记符的例子如下所示,考虑这个类里的doMathForever()方法:
publicclassAct{
publicstaticvoiddoMathForever(){
inti=0;
for(;;){
i+=1;
i+=2;
}
}
}
doMathForever()方法的字节码流可以被反汇编成下面的助记符。
Java虚拟机规范中没有定义正式的方法字节码的助记符的语法。
下面显示的代码说明了本书所采用的用助记符表示字节码的方式。
左边的列表表示每条指令开始时从字节码的开头开始算起的每天指令的字节偏移量;中间的列表表示指令和它的操作数;右边的列包含注释,用双斜杠隔开,如同Java源代码的格式。
这种表示助记符的方式和Sun的Java2SDK里的javap程序的输出很相似。
使用javap可以查看任何class文件中方法的字节码助记符。
请注意跳转地址是按照从方法起始开始算起的偏移量来给出的。
Goto指令导致虚拟机跳转到从方法起始计算的位于偏移量2的指令。
实际上操作数是负7.要执行这条指令,虚拟机在当前PC寄存器的内容上加上这个操作数。
记过就是iinc指令的地址:
偏移量2.为了让助记符更加易读,所看到的这条跳转指令后面的操作数已经是经过计算后的结果了。
主机符显示的是“goto2”,而不是”goto-7”。
Java虚拟机指令集关注的中心是操作数栈。
一般是把将要使用的值会压入栈中。
虽然Java虚拟机没有保存任意值的寄存器,但每个方法都有一个局部变量集合。
指令集实际的工作方式就是把局部变量当作寄存器,用索引来访问。
不过,不同于iinc指令——它可以直接增加一个局部变量的值,要是用吧偶你在局部变量中的值之前,必须先将它压入操作数栈。
举例来说,用一个局部变量除另外一个,虚拟机必须把它们都压入栈,执行除法,然后把结果重新保存到局部变量。
要把数组元素或对象的字段保存到局部变量中,虚拟机必须先把值压入栈,然后保存到局部变量中去。
要把保存在局部变量中的值赋予数组元素或者对象字段,虚拟机必须按照相反的步骤操作。
它首先必须把局部变量的值压入栈,然后从栈中弹出,再放入位于堆上的数组元素或对象字段中。
Java虚拟机指令集的设计遵循几个不同的目标,但它们之间是有冲突的。
这几个目标就是本书的前面所描述的整个Java体系结构的目的所在:
平台无关性、网络移动性以及安全性。
平台无关性是影响指令集设计的最大因素。
指令集的这种以栈为中心、而非以寄存器为中心的设计方法,使得在那些只有很少的寄存器、或者寄存器很没有规律的机器上实现Java更便利,Intel80X86就是一个例子。
由于指令集具有这种以栈为中心的特征,所以在很多平台体系结构上都很容易实现Java虚拟机。
Java以栈为中心设计指令集的另一个动机是,编译器一般采用以栈为基础的结构向连接器或优化器传递编译的中间结果。
Javaclass文件在很多方面都和C编译器产生的o文件或者.obj文件很相似,实际上他表示了一种Java程序的中间编译结果形式。
对于Java的情况来,虚拟机是作为(动态)连接器使用的,也可以作为优化器。
在Java虚拟机指令集设计中,以栈为中心的体系结构可以将运行时进行的优化工作与执行即时编译或者自适应优化的执行引擎结合起来。
在第4章中讲过,设计中一个主要考虑因素是class文件的紧凑性。
紧凑性对于提高在网络上传递class文件的速度是很重要的。
在class文件中保存的字节码,除了两个处理表跳转的指令之外,都是按照字节对齐的。
操作码的总数很小,所以操作码可以只占据一个字节。
这种设计策略有助于class文件的紧凑,但却是以可能影响程序运行的性能为代价的。
某些Java虚拟机实现,特别是那些在芯片上执行字节码的实现,但字节的操作码可能使得一些可以提高性能的优化无法实现。
同样,假若字节码流是以字对齐而非字节对齐的话,某些实现可能会得到更好的性能。
(实现可以重新对齐字节码流,或者在装载类的时候把操作码转换成更加有效的形式。
字节码在class文件中是按字节对齐的,在抽象方法区和执行引擎的规范中也是这么规定的。
不同的具体实现可以用它们喜欢的任何形式保存装载后的字节码流。
)
指导指令集设计的另一个目标就是进行字节码验证的能力,特别是使用数据流分析器进行的一次性验证。
Java的安全框架需要这种验证能力。
在装载字节码的时候使用数据流分析器进行一次性验证,而非在执行每条指令的时候进行验证,这样做有助于提高执行速度。
在指令集中体现这个目标的表现之一,就是绝大部分操作码都指明了它们需要操作的类型。
比如说,不是简单地采用一条指令(该指令从操作数栈中取出一个字并保存到局部变量中),Java虚拟机的指令集而是采用两条指令。
一条指令是istore——弹出并保存int类型;另一条指令是fstore——弹出并保存float类型。
在执行的时候这两条指令所完成的功能是完全一致的;弹出一个字并保存。
要区分弹出并保存的到底是int类型还是float类型,只对验证过程有重要作用。
对于某些指令,虚拟机需要知道被操作的类型,以决定如果执行操作。
比如,Java虚拟机支持两种把两个字加起来并得到一个结果字的操作。
一种是把字当作int处理,另一种是当作float处理。
这两条指令的区别在于方便验证,同时也告诉虚拟机需要的整数操作还是浮点数操作。
有一些指令可以操作任何类型。
比如说dup指令不管栈顶的字是什么类型都可以复制它。
还有一些指令不操作有类型的值,比如goto。
但是大部分指令都操作特定的类型。
这种“有类型”的指令,可以使用助记符通过一个字符前缀来表明它们操作的类型。
表5-2列举了不同类型的前缀。
有一些指令不包含前缀,比如arraylength或instanceof,因为他们的类型是再明显不过的。
Arraylength操作码需要一个数组引用。
Instanceof操作码需要一个对象引用。
操作数栈中的数值必须按照适合它们类型的方式使用。
比如说压入4个int,但却把它们当作两个long来做加法,这是非法的。
把一个float值从局部变量压入操作数栈,然后把它作为int保存到堆中的数组中去,这也是非法的。
从一个位于堆中的对象字段压入一个double值,然后把栈中最顶端的两个字作为类型引用保存到局部变量,这也是非法的。
Java编译器所坚持的强类型规则对Java虚拟机实现同样也是适用的。
当执行那些与类型无关的一般性栈操作指令时,实现也必须遵守一些规则。
前面讲过,不管是什么类型,dup指令压入栈中顶端那个字的拷贝。
这条指令可以用在任何占据一个字的值类型上,如int,flot、引用或returnAddress。
但是,如果栈顶包含的是long或者double类型,它们占据了两个连续的栈空间,这时使用dup就是非法的。
位于栈顶的long或者double需要用dup2指令复制两个字,在操作数栈中压入栈顶的两个字的拷贝。
一般性指令不能用来切割双字值。
为了使指令集足够小,用单字节表示每一个操作码,但并不是在所有类型上都支持所有的操作。
很多操作对byte、short和char都不支持。
这些类型在从堆或者方法区转移到栈帧的时候被转换成int,当它们被当作int来进行操作,然后在操作完成后重新保存到堆或方法去的时候再转换为byte、short或者char。
表5-3展示了Java虚拟机中保存的每个类型所对应的计算类型。
这里,保存类型是堆中类型值所体现的形式。
保存类型对应Java源代码中变量的类型。
计算类型是这些类型在Java栈帧中体现的形式。
Java虚拟机实现必须以某种方法确保数值是被对应其类型的指令所操作。
实现可以在类验证过程中就预先验证字节码,或者在执行的时候验证,或者采用前两种验证方式的混合方式。
字节码验证在第7章详细描述。
第10章到第20章详细描述了整个指令集。
执行技术实现可以使用多种执行技术:
解释、即时编译、自适应优化、芯片级直接执行,这些在第1章中介绍过。
关于执行技术要记住的最主要的一点就是,实现可以自由选择任何技术来执行字节码,只要它遵守Java虚拟机指令集的定义。
最有意义也是最迅速的执行技术之一是自适应优化。
自适应优化已经在几种现有的Java虚拟机实现中使用了,如Sun的Hotspot虚拟机。
它们都从早期虚拟机实现所使用的技术中得到了很多借鉴。
最初的虚拟机每次解释一条字节码;第二代虚拟机加入了即时编译器,在第一次执行方法的时候先编译本地代码,然后执行这段本地代码。
也就是说,不管什么时候调用方法,总是执行本地代码。
自适应优化器搜集那些只在运行时才有效的信息,试图以某种方式把字节码解释和编译成本地代码结合起来,以得到最优化的性能。
自适应优化的虚拟机开始的时候对所有的代码都是解释运行,但是它会监视代码的执行情况。
大多数程序花费80%~90%的时间用来执行10%~20%的代码,它们占整个执行时间的80%~90%。
当自适应优化的虚拟机判断出某个特定的方法是瓶颈的时候,它启动一个后台线程,把字节码编译成本地代码,非常仔细地优化这些本地代码。
同时,程序仍然通过解释来执行字节码。
因为程序没有中途挂起,并且只编译和优化那些“热区”虚拟机可以比传统的即时编译更注重优化性能。
自适应优化技术使程序最终能把原来占80%~90%运行时间的代码变为极度优化的,静态链接的C++本地代码,而使用的总内存数并不比全部解释Java程序大多少。
换句话说,就是更快了。
自适应优化的虚拟机可以保留原来的字节码,等待方法从热区移出(程序的热区在执行的过程中可能会转移)。
当方法变得不再是热区的时候,取消那些编译过的代码,重新开始解释执行那些字节码。
读者可能会注意到,自适应优化方法令Java程序运行得更快,它采取的办法和程序员用来提高程序性能的方法是很相似的。
不同于通常的即时编译虚拟机,自适应优化的虚拟机并不进行“过早的优化”。
自适应优化的虚拟机通过解释执行字节码开始,当程序运行的时候,虚拟机统计程序,找到程序的热区——就是那10%~20%的代码,它们花费了80%~90%的运行时间。
就如同一个优秀的程序员那样,自适应优化的虚拟机只对那些对性能产生重大影响的代码进行仔细优化。
但是自适应优化的情况不止这些,它还有另外的着眼点。
自适应优化器可以在运行时根据Java程序的特征进行微调——特别是对“设计良好”的Java程序。
根据JavaSoftHotspot的经历DavidGriswold的说法,“Java比C++更加面向对象。
你可以测量它,可以发现方法调用的频度,动态派发的频度,等等。
这些频度要比C++中高的多。
”现在,在一个设计良好的Java程序中,这种方法调用和动态派发的频度更加高了,因为Java程序良好设计的尺度之一就是高效率、高产出的设计——换句话就是,使方法和对象更紧凑及内聚性更高。
这些Java程序的运行时特征,就是方法调用和动态派发的高频度发生,它们从两个方面影响性能。
首先,每次动态派发都会产生相关的管理费用,其次,更重要的是方法调用降低了编译器优化的有效性。
方法调用会使优化器的有效性降低,因为优化器在不同的方法调用间不能够有效地工作,因此优化器在方法调用的时候就无法专注于代码了。
方法调用频度越高,方法调用之间可以用来优化的代码就越少,优化器就变得越低效。
这个问题的标准解决方案就是内嵌——把被调用方法的方法体直接拷贝到发起调用的方法中。
内嵌调出了方法调用,因此可以让优化器处理更多的代码。
这可能令优化器工作更有效,代价就需要更多的运行时内存。
麻烦之处在于,在面向对象的语言(比如Java和C++)中实现内嵌,要比非面向对象的语言(比如C)更加困难,因为面向对象语言使用了动态派发。
在Java中比在C++中更加严重,因为Java的方法调用和动态派发的频度要比C++高得多。
一个C程序的标准优化静态编译器可以直接使用内嵌,因为每一个函数调用都有一个函数实现。
对于面向对象语言来说,内嵌就变得复杂了,因为动态方法派发以为着一个函数调用可能有多个函数实现(方法)。
换句话说,虚拟机运行时根据方法调用的对象类,可能会有很多不同的方法实现可供选择。
内嵌一个动态派发的方法调用,一种解决办法就是把所有可能在运行时被选择的方法实现都内嵌进去,这种思路的问题在于,如果有很多方法实现,就会让优化后的代码变得非常大。
自适应编译比静态编译的优点在于,因为它是在运行时工作的,它可以使用静态编译器所无法得到的信息。
比如说,对于一个特定的方法调用,就算有30个可能的方法实现,运行时可能只会有其中的两个被调用。
自适应方法就可以只把这两个方法内嵌,有效地减少过了优化后的代码大小。
线程Java虚拟机规范定义了线程模型,这个模型的目标是要有助于在很多体系结构上都实现它。
Java线程模型的一个目标就是使实现的设计者,在可能的情况下使用本地线程。
否则,设计者可以在他们的虚拟机实现内部实现线程机制。
在一台多处理器的主机上使用本地线程的好处就是,Java程序不同的线程可以在不同的处理器上并行工作。
Java线程模型的折中之一就是优先级的规范考虑最小公分母问题。
Java线程可以运行于10个优先级的任何一个。
级别1是优先级最低的,而级别10是最高的。
如果设计者使用本地线程,他可以用合适的方法把10个Java优先级映射到机器本地的优先级上。
Java虚拟机规范对于不同优先级别的线程行为,只规定了所有高优先级的线程会得到大多数的CPU时间。
低级别的线程在级别高的线程没有被阻塞的时候也可能得到CPU时间,但是这没有任何保证。
规范没有假设不同优先级的线程采用时间分片方式。
因为并不是所有的体系结构都采用时间片(在这里,时间分片的含义是:
就算没有线程被阻塞,所有优先级的所有线程都会保证得到一些CPU时间)。
就算在那些采用时间片的体系结构上,用来分配时间片给不同优先级线程的算法也存在非常大的差异。
第2章中讲到,程序的正确运行不能依靠时间分片。
只有在向Java虚拟机给出提示,某个线程应该比其他线程使用更多的时间,这时候才使用线程优先级。
要协调多线程之间的活动,应该使用同步。
任何Java虚拟机的线程实现都必须支持同步的两个方面:
对象锁定,线程等待和通知。
对象锁定使独立运行的线程访问共享数据的时候互斥。
线程等待和通知使得线程为了达到同一个目标而互相协同工作。
运行中的程序通过Java虚拟机指令集来访问上锁机制,还通过Object类的wait()方法、notify()方法和notifyAll()方法来访问线程等待和通知机制。
在Java虚拟机规范中,Java线程的行为是通过术语——变量、内存和工作内存——来定义的。
每一个Java虚拟机实例都有一个主存,用于保存所有的程序变量(对象的实例变量、数组的元素以及类变量)。
每一个线程都有一个工作内存,线程用它保存所使用和赋值的变量的“工作拷贝”,局部变量和参数,因为他们是每个线程私有的,可以从逻辑上看成是工作内存或者主存的一部分。
Java虚拟机规范定义了许多规则,用来管理线程和主存之间的额低层交互行为。
比如,一条规则声明:
所有对基本类型的操作,除了某些对long类型和double类型的操作之外,都必须是源自级的。
再比如,如果两个线程竞争,对一个int变量写了不同的两个值,就算不存在同步,变量最终会采用二者之一。
变量不会包含一个不正确的值。
或者说,如果一个线程赢得了竞争,把它要写的值先写入到了变量。
但失败的那个线程也可以重写那个变量,覆盖那个以为自己“胜利”的线程所写入的值。
这条规则也有例外情况,即任何没有生命为volatile的long或者double变量。
某些实现可能把它们作为两个原子性的32位值对待,而非一个原子性的64位值。
比如说,把一个非volatile的long保存到内存,可能是两次32位的写操作。
这种对于long和double的非原子操作可能导致两个竞争性的线程在试图写入不同的值到一个long或者double变量时,最终得到的是一个不正确的结果。
虽然实现的设计者不是必须对非volatile的long和double进行原子处理,但Java虚拟机规范鼓励他们这么做。
这种对long和double的非源自操作,对那条“对所有基本类型的操作都必须是原子级的”的规则而言,就是一个例外,这个例外的目的是,如果处理器不有效的支持和内存交换64位的值,线程模型也能经济的实现。
将来这个例外可能被终止。
然而现在,Java程序员必须确保通过同步来操作共享的long和double。
基本上,管理低层线程行为的规则,规定了一个线程合适可以做及何时必须做以下的事情