深入理解JVM-字节码执行引擎

  • Post author:
  • Post category:其他


前面我们不止一次的提到,

Java

是一种跨平台的语言,为什么可以跨平台,因为我们编译的结果是中间代码



字节码,而不是机器码,那字节码在整个

Java

平台扮演着什么样的角色的呢?

JDK1.2

之前对应的结构图如下所示:



JDK1.2

开始,迫于

Java

运行始终比

C++

慢的压力,

JVM

的结构也慢慢发生了一些变化,

JVM

在某些场景下可以操作一定的硬件平台,一些核心的

Java

库甚至也可以操作底层的硬件平台,从而大大提升了

Java

的执行效率,在前面

JVM

内存模型和垃圾回收中也给大家演示了如何操作物理内存,下图展示了

JDK1.2

之后的

JVM

结构模型。



C++



Java

在编译和运行时到底有啥不一样?为啥

Java

就能跨平台的呢?

我们从上图可以看出。


C++

发布的就是机器指令,而

Java

发布的是字节码,字节码在运行时通过

JVM

做一次转换生成机器指令,因此能够更好的跨平台运行。如图所示,展示了对应代码从编译到执行的一个效果图。

我们知道

JVM

是基于栈执行的,每个线程会建立一个操作栈,每个栈又包含了若干个栈帧,每个栈帧包含了三部分:局部变量区、操作数栈区、运行环境区(动态连接、正确的方法返回的相关信息,异常捕捉)。其实在我们编译的时候,需要多大的局部变量表、操作数深度等已经确定并写入了

Code

属性,因此运行时内存消耗的大小在启动时已经已知。

在栈帧中,最小的单位为

变量槽


(Variable Slot),

其中每个

Slot

占用

32

个字节。在

32bit



JVM



32

位的数据类型占用

1



Slot



64bit

数据占用

2



Slot

;在

64bit

中使用

64bit

字节填充来模拟

32bit

(又称补位),因此我们可以得出结论:

64bit



JVM



32bit

的更消耗内存,但是

由于32bit

机器的内存上限限制,有时候牺牲一部分还是值得的。

Java

的基本数据类型中,除了

long



double

两种数据类型为

64bit

以外,

boolean



byte



char



int



float



reference

等都是

32bit

的数据类型。

在栈帧中,局部变量区中的

Slot

是可以复用的,如在一个方法返回给上一个方法是就可以通过公用

Slot

的方法来节约内存控件,但这在一定程度省会影响垃圾回收,因此

JVM

不确定这块

Slot

空间是否还需要复用。

Slot复用会给

JVM

的垃圾回收带来一定影响,如下代码:

 1 package com.yhj.jvm.byteCode.slotFree;
 2 
 3 /**
 4 
 5  * @Described:Slot局部变量表 没有破坏GCRoot情况演示
 6 
 7  * @VM params :-XX:+PrintGCDetails -verbose:gc
 8 
 9  * @author YHJ create at 2012-2-22 下午04:37:29
10 
11  * @FileNmae com.yhj.jvm.byteCode.slotFree.SlotFreeTestCase.java
12 
13  */
14 
15 public class SlotFreeTestCase {
16 
17     /**
18 
19      * @param args
20 
21      * @Author YHJ create at 2012-2-22 下午04:37:25
22 
23      */
24 
25     @SuppressWarnings("unused")
26 
27     public static void main(String[] args) {
28 
29        //case 1
30 
31        byte[] testCase = new byte[10*1024*1024];
32 
33        System.gc();
34 
35 //     //case 2
36 
37 //     {
38 
39 //         byte[] testCase = new byte[10*1024*1024];
40 
41 //     }
42 
43 //     System.gc();
44 
45 //     //case 3
46 
47 //     {
48 
49 //         byte[] testCase = new byte[10*1024*1024];
50 
51 //     }
52 
53 //     int a = 0;
54 
55 //     System.gc();
56 
57 //     //case 5
58 
59 //     byte[] testCase = new byte[10*1024*1024];
60 
61 //     testCase=null;
62 
63 //     System.gc();
64 
65     }
66 }

如上所示,当我们执行这段代码的时候并不会引发

GC

的回收,因为很简单,我的

testCase

对象还在使用中,生命周期并未结束,因此运行结果如下

但是我们换下面的

case2

这种写法呢?

1 //case 2
2 
3        {
4 
5            byte[] testCase = new byte[10*1024*1024];
6 
7        }
8 
9        System.gc();

这种写法,testCase在大括号中生命周期已经结束了,会不会引发GC的呢?我们来看结果:

我们可以看到仍然没有进行回收。那我变通一下,再定义一个变量会怎么样的呢?
 1  //case 3
 2 
 3        {
 4 
 5            byte[] testCase = new byte[10*1024*1024];
 6 
 7        }
 8 
 9        int a = 0;
10 
11        System.gc();

这下我们貌似看到奇迹了

没错,


JVM

做了回收操作,因为

JVM

在做下面的操作时并没有发现公用的

Slot

,因此该内存区域被回收。但是我们这样写代码会让很多人感到迷惑,我们应该怎样写才能更好一些让人理解呢?

1  //case 5
2 
3        byte[] testCase = new byte[10*1024*1024];
4 
5        testCase=null;
6 
7        System.gc();

无疑,这样写才是最好的,这也是书本effective Java中强调了很多遍的写法,随手置空不用的对象。

我们知道

private int a

;这么一个语句在一个类中的话他的默认值是

0

,那么如果是在局部变量中的呢?我们开看这样一段代码:

 1 package com.yhj.jvm.byteCode.localVariableInit;
 2 
 3 /**
 4 
 5  * @Described:局部变量拒绝默认初始化
 6 
 7  * @author YHJ create at 2012-2-24 下午08:40:34
 8 
 9  * @FileNmae com.yhj.jvm.byteCode.localVariableInit.LocalVariableInit.java
10 
11  */
12 
13 public class LocalVariableInit {
14 
15  
16 
17     /**
18 
19      * @param args
20 
21      * @Author YHJ create at 2012-2-22 下午05:12:06
22 
23      */
24 
25     @SuppressWarnings("unused")
26 
27     public static void main(String[] args) {
28 
29        int a;
30 
31        System.out.println(a);
32 
33     }
34 
35 } 

这段代码的运营结果又是什么的呢?

很多人会回答

0.

我们来看一下运行结果:

没错,就是报错了,如果你使用的是


Eclipse

这种高级一点的

IDE

的 话,在编译阶段他就会提示你,该变量没有初始化。原因是什么的呢?原因就是,局部变量并没有类实例变量那样的链接过程,前面我们说过,类的加载分为三个阶段:加载(loading)、 链接(linking)、初始化(initializing),其中链接(linking)分为三个阶段:验证(verify)、准备(prepare)、解析(resolve),而验证(verify)是确保类加载的正确性、准备(prepare)是为类的静态变量分配内存,并初始化为默认值、解析(resolve)是把 类中的符号引用转换为直接引用。而初始化(initializing)是为类的静态变量赋值为正确显示定义的值。而局部变量并没有链接(linking)的阶段,因此没有赋值为默认值这一阶段,因此必须自己 初始化才能使用。

我们在类的加载中提到类的静态链接过程,但是还有一部分类是需要动态链接的,其中以下是需要动态链接的对象


1、

实例变量(类的变量或者局部变量)


2、

通过其他容器报告动态注入的变量

(

IOC

)


3、

通过代码注入的对象

(void setObj(Object obj))

所有的动态链接都只有准备和解析阶段,没有再次校验

(

校验发生在链接前类的加载阶段

)

,其中局部变量不会再次引发准备阶段。

前面我们提到

JVM

的生命周期,在以下四种情况下会引发

JVM

的生命周期结束


1、

执行了

System.exit()

方法


2、

程序正常运行结束


3、

程序在执行过程中遇到了异常或者错误导致异常终止


4、

由于操作系统出现错误而导致

JVM

进程终止

同样,在以下情况下会导致一个方法调用结束


1、

执行引擎遇到了方法返回的字节码指令


2、

执行引擎在执行过程中遇到了未在该方法内捕获的异常

这时候很多人会有一个疑问:当程序返回之后它怎么知道继续在哪里执行?这就用到了我们

JVM

内存模型中提到了的

PC

计数器。方法退出相当于当前栈出栈,出栈后主要做了以下事情:


1、

恢复上层方法的局部变量表


2、

如果有返回值的话将返回值压入到上层操作数栈


3、

调整

PC

计数器指向下一条指令

除了以上信息以外,栈帧中还有一些附加信息,如预留一部分内存用于实现一些特殊的功能,如调试信息,远程监控等信息。

接下来我们要说的是方法调用,方法调用并不等于方法执行,方法调用的任务是确定调用方法的版本

(

调用哪一个方法

)

,在实际过程中有可能发生在加载期也有可能发生在运行期。

Class

的编译过程并不包含类似

C++

的链接过程,只有在类的加载期或者运行期才将对应的符号引用修正为真正的直接引用,大大的提升了

Java

的灵活性,但是也大大增加了

Java

的复杂性。

在类加载的第二阶段链接的第三阶段解析,这一部分是在编译时就确定下来的,属于编译期可知运行期不可变。在字节码中主要包含以下两种


1、


invokestatic

主要用于调用静态方法,属于绑定类的调用


2、


invokespecial

主要用于调用私有方法,外部不可访问,绑定实例对象

还有一种是在运行时候解析的,只有在运行时才能确定下来的,主要包含以下两方面


1、


invokevirtual

调用虚方法,不确定调用那一个实现类


2、


invokeinterface

调用接口方法,不确定调用哪一个实现类

我们可以通过

javap

的命令查看对应的字节码文件方法调用的方式,如下图所示

Java

方法在调用过程中,把

invokestatic



invokespecial

定义为非虚方法的调用,非虚方法的调用都是在编译器已经确定具体要调用哪一个方法,在类的加载阶段就完成了符号引用到直接引用的转换。除了非虚方法以外,还有一种被

final

修饰的方法,因被

final

修饰以后调用无法通过其他版本来覆盖,因此被

final

修饰的方法也是在编译的时候就已知的非虚方法。

除了解析,

Java

中还有一个概念叫分派,分派是多态的最基本表现形式,可分为单分派、多分派两种;同时分派又可以分为静态分派和动态分派,因此一组合,可以有四种组合方式。其实最本质的体现就是方法的重载和重写。我们来看一个例子

 1 package com.yhj.jvm.byteCode.staticDispatch;
 2 
 3 /**
 4 
 5  * @Described:静态分配
 6 
 7  * @author YHJ create at 2012-2-24 下午08:20:06
 8 
 9  * @FileNmae com.yhj.jvm.byteCode.staticDispatch.StaticDispatch.java
10 
11  */
12 
13 public class StaticDispatch {
14 
15  
16 
17     static abstract class Human{};
18 
19     static class Man extends Human{} ;
20 
21     static class Woman extends Human{} ;
22 
23  
24 
25     public void say(Human human) {
26 
27        System.out.println("hi,you are a good human!");
28 
29     }
30 
31     public void say(Man human) {
32 
33        System.out.println("hi,gentleman!");
34 
35     }
36 
37     public void say(Woman human) {
38 
39        System.out.println("hi,yong lady!");
40 
41     }
42 
43     /**
44 
45      * @param args
46 
47      * @Author YHJ create at 2012-2-24 下午08:20:00
48 
49      */
50 
51     public static void main(String[] args) {
52 
53        Human man = new Man();
54 
55        Human woman = new Woman();
56 
57        StaticDispatch dispatch = new StaticDispatch();
58 
59        dispatch.say(man);
60 
61        dispatch.say(woman);
62 
63     }
64 
65 }

这个例子的执行结果会是什么呢?我们来看一下结果

和你的预期一致么?这个其实是一个静态分派的杯具,


man



woman

两个对象被转型以后,通过特征签名匹配,只能匹配到对应的父类的重载方法,因此导致最终的结构都是执行父类的代码。因为具体的类是在运行期才知道具体是什么类型,而编译器只确定是

Human

这种类型的数据。

这种写法曾经在我们项目中也发生过一次。如下代码所示

 1 package com.yhj.jvm.byteCode.staticDispatch;
 2 
 3 import java.util.ArrayList;
 4 
 5 import java.util.List;
 6 
 7 /**
 8 
 9  * @Described:蝌蚪网曾经的杯具
10 
11  * @author YHJ create at 2012-2-26 下午09:43:20
12 
13  * @FileNmae com.yhj.jvm.byteCode.staticDispatch.CothurnusInPassport.java
14 
15  */
16 
17 public class CothurnusInPassport {
18 
19     /**
20 
21      * 主函数入口
22 
23      * @param args
24 
25      * @Author YHJ create at 2012-2-26 下午09:48:02
26 
27      */
28 
29     public static void main(String[] args) {
30 
31        List<CothurnusInPassport> inPassports = new ArrayList<CothurnusInPassport>();
32 
33        inPassports.add(new CothurnusInPassport());
34 
35        String xml = XML_Util.createXML(inPassports);
36 
37        System.out.println(xml);
38 
39     }
40 
41 }
42 
43 class XML_Util{
44 
45     public static String createXML(Object obj){
46 
47        return  。。。// ... 通过反射遍历属性 生成对应的XML节点
48 
49     }
50 
51     public static String createXML(List<Object> objs){
52 
53        StringBuilder sb = new StringBuilder();
54 
55        for(Object obj : objs)
56 
57            sb.append(createXML(obj));
58 
59        return new String(sb);
60 
61     }
62 
63 }

当时我们项目组写了以恶搞XML_Util的一个类用于生成各种XML数据,其中一个实例传入的参数是Object,一个是一个List类型的数据,如上面代码所示,我的调用结果会执行哪一个的呢?结果大家已经很清楚了,他调用了createXML(Object obj)这个方法,因此生成过程中老是报错,原因很简单,就是因为我调用的时候泛型 不匹配,进行了隐式的类型转换,因此无法匹配到对应的List<Object>最终调用了createXML(Object obj)这个方法。

下面我们来看一道恶心的面试题,代码如下:

 1 package com.yhj.jvm.byteCode.polymorphic;
 2 
 3 import java.io.Serializable;
 4 
 5 /**
 6 
 7  * @Described:重载测试
 8 
 9  * @author YHJ create at 2012-2-24 下午08:41:12
10 
11  * @FileNmae com.yhj.jvm.byteCode.polymorphic.OverLoadTestCase.java
12 
13  */
14 
15 public class OverLoadTestCase {
16 
17     public static void say(Object obj){ System.out.println("Object"); }
18    
19     public static void say(char obj){ System.out.println("char"); }
20 
21     public static void say(int obj){ System.out.println("int"); }
22 
23     public static void say(long obj){ System.out.println("long"); }
24 
25     public static void say(float obj){ System.out.println("float"); }
26 
27     public static void say(double obj){ System.out.println("double"); }
28 
29     public static void say(Character obj){ System.out.println("Character"); }
30 
31     public static void say(Serializable obj){ System.out.println("Serializable"); }
32 
33     public static void say(char... obj){ System.out.println("char..."); }
34 
35     public static void main(String[] args) {
36 
37        OverLoadTestCase.say('a');
38 
39     }
40 
41 }

这样的代码会执行什么呢?这个很简单的了,是

char

,那如果我注释掉

char

这个方法,再执行呢?是

int

,继续注释,接下来是什么的呢?大家可以自己测试一下,你会发现这段代码有多么的恶心。

我们接下来再看一段代码:

 1 package com.yhj.jvm.byteCode.dynamicDispatch;
 2 
 3 /**
 4 
 5  * @Described:动态分派测试
 6 
 7  * @author YHJ create at 2012-2-26 下午10:05:43
 8 
 9  * @FileNmae com.yhj.jvm.byteCode.dynamicDispatch.DynamicDispatch.java
10 
11  */
12 
13 public class DynamicDispatch {
14 
15     static abstract class Human{
16 
17        public abstract void say();
18 
19     };
20 
21     static class Man extends Human{
22 
23        @Override
24 
25        public void say(){
26 
27            System.out.println("hi,you are a good man!");
28 
29        }
30 
31     } ;
32 
33     static class Woman extends Human{
34 
35        @Override
36 
37        public void say(){
38 
39            System.out.println("hi,young lady!");
40 
41        }
42 
43     } ;
44 
45     //主函数入口
46 
47     public static void main(String[] args) {
48 
49        Human man = new Man();
50 
51        Human woman = new Woman();
52 
53        man.say();
54 
55        woman.say();
56 
57        woman = new Man();
58 
59        woman.say();
60 
61     }
62 
63 }

这段代码执行的结果会是什么的呢?这个不用说了吧?企业级的应用经常会使用这些方法重写,这是动态分配的一个具体体现,也就是说只有运行期才知道具体执行的是哪一个类,在编译期前并不知道会调用哪一个类的这个方法执行。

我们再来看一段代码,这段代码被称为“一个艰难的决定”

 1 //动态单分派静态多分派    宗量选择
 2 
 3 package com.yhj.jvm.byteCode.dynamicOneStaticMoreDispatch;
 4 
 5 /**
 6 
 7  * @Described:一个艰难的决定
 8 
 9  * @author YHJ create at 2012-2-24 下午09:23:26
10 
11  * @FileNmae com.yhj.jvm.byteCode.dynamicOneStaticMore.OneHardMind.java
12 
13  */
14 
15 public class OneHardMind {
16     static class QQ{}                //腾讯QQ
17 
18     static class _360{}             //360安全卫士
19 
20     static class QQ2011 extends QQ{} //腾讯QQ2011
21 
22     static class QQ2012 extends QQ{} //腾讯QQ2012
23 
24     //百度
25 
26     static class BaiDu{
27 
28        public static void choose(QQ qq){ System.out.println("BaiDu choose QQ"); }
29 
30        public static void choose(QQ2011 qq){ System.out.println("BaiDu choose QQ2011"); }
31 
32        public static void choose(QQ2012 qq){ System.out.println("BaiDu choose QQ2012"); }
33 
34        public static void choose(_360 _){ System.out.println("BaiDu choose 360 safe"); }
35 
36     }
37 
38     //迅雷
39 
40     static class Thunder{
41 
42        public static void choose(QQ qq){ System.out.println("Thunder choose QQ"); }
43 
44        public static void choose(QQ2011 qq){ System.out.println("Thunder choose QQ2011"); }
45 
46        public static void choose(QQ2012 qq){ System.out.println("Thunder choose QQ2012"); }
47 
48        public static void choose(_360 qq){ System.out.println("Thunder choose 360 safe"); }
49 
50     }
51 
52     //主函数入口
53 
54     @SuppressWarnings("static-access")
55 
56     public static void main(String[] args) {
57 
58        BaiDu baiDu = new BaiDu();
59 
60        Thunder thunder = new Thunder();
61 
62        QQ qq = new QQ();
63 
64        _360 _360_safe = new _360();
65 
66        baiDu.choose(qq);
67 
68        thunder.choose(_360_safe);
69 
70        qq = new QQ2011();
71 
72        baiDu.choose(qq);
73 
74        qq = new QQ2012();
75 
76        baiDu.choose(qq);
77 
78     }
79 }

这段代码的执行结果又是什么?现在可以很简单的说出对应的结果了吧!

从这个例子我们可以看出,


Java

是静态多分派动态单分派的 同理,

C#3.0



C++

也是静态多分配,动态单分派的

C#4.0

后引入类型

dynamic

可以实现动态多分派,

sun

公司在

JSR-292

中提出了动态多分派的实现,规划在

JDK1.7

推出,但是被

oracle

收购后,截至目前,

JDK1.7

已经发布了多个版本,但尚未实现动态多分派。至于动态多分派究竟是怎么样子的?我们可以参考



Python

的多分派实例


那虚拟机为什么能够实现不同的类加载不同的方法,什么时候使用静态分派?什么时候又使用动态分派呢?我们把上面的示例用一个图来表示,大家就很清楚了!

当 子类有重写父类的方法时,在系统进行解析的时候,子类没有重写的方法则将对应的符号引用解析为父类的方法的直接引用,否则解析为自己的直接引用,因此重写 永远都会指向自己的直接引用,但是重载在解析时并不知道具体的直接引用对象是哪一个?所以只能解析为对应的表象类型的方法。

我们在前面已经提到,新的

Java

编译器已经不会纯粹的走解释执行之路,在一些情况下还会走编译之路。如下图所示:

我们知道,程序之所以能运行,是因为有指令集,而


JVM

主要是基于栈的一个指令集,而还有一部分程序是基于寄存器的指令集,两者有什么区别的呢?

基 于栈的指令集有接入简单、硬件无关性、代码紧凑、栈上分配无需考虑物理的空间分配等优势,但是由于相同的操作需要更多的出入栈操作,因此消耗的内存更大。 而基于寄存器的指令集最大的好处就是指令少,速度快,但是操作相对繁琐。下面我们来看一段代码,看一下同样一段代码在不同引擎下的执行效果有啥不同。

 1 public class Demo {
 2 
 3     public static void foo() {
 4 
 5        int a = 1;
 6 
 7        int b = 2;
 8 
 9        int c = (a + b) * 5;
10 
11     }
12 
13 }



Client/Server VM

的模式下,我们可以使用

javap –verbose ${ClassName}

的方式来查看对应的字节码,而基于

java



DalvikVM

亦可以通过

platforms\android-1.6\tools

目录中的

dx

工具查看对应的字节码。具体命令为

dx –dex –verbose –dump-to=packageName –dump-method=Demo.foo –verbose-dump Demo.class

基于栈的

Hotspot

的执行过程如下:

基于栈的


DalvikVM

执行过程如下所示:

而基于汇编语言的展示就是这样的了

附:基于

JVM

的逻辑运算模型如下图所示

因此执行到


JVM

上的过程就是下面的形式