JVM 编程(Oolong)学习札记

JVM 编程(Oolong)学习笔记
近期学习Engel的《Programming for the Java Virtual Machine》。这本书实际上讲的是JVM原理和机制,只不过用一种汇编语言的形式来展开讲解。Oolong是大牛Engel先生自己发明的一种基于JVM的汇编语言,按他自己的介绍,Oolong实际只是JVM bytecode的一个易于理解的教学版本,其本身似乎并不具备什么开发应用的价值。

由于对jvm本身已经有一定的熟悉,所以这里没有什么详细的笔记可做,仅就书中一些比较迷惑的地方做一些说明。


数组操作:
对数组元素的写操作,书中有个例子在1.5以上的jvm中是错的:
iconst_5     ;设定数组size
anewarray java/lang/String ;相当于 new String[5]
dup          ;复制数组引用供此段代码执行后的代码使用
ldc "Hello"  ;将要替换的数组元素的对象压入栈顶
iconst_0     ;指定目标元素的下标是0
aastore      ;替换元素

经过试验发现,数组元素替换的压栈顺序错了,正确的应该如下:
...
dup
iconst_0
ldc "Hello"
aastore

如果运行书上的代码,jvm会在企图从栈顶pop出一个String对象的时候,发现拿到的东西是个int,于是报错。


Exception:
.throws语句 是可选的,jvm会抛出所有没有本地截取的异常,不论.throws是否明示了这个异常类型。但.throws有积极的设计意义:对于一个代码正确系统,一个方法只应该抛出.throws里注明的异常,其上层caller(如另一个方法)应该建立在这个目标方法只会抛出.throws所注异常的假设之上。在一个方法里尝试捕捉一些它自己不知道的异常是诡异的,是违反“最小惊讶原则”的。


invokeinterface:
有三点需要注意:
1. 其最后一个参数是invoke该方法需要使用的栈槽(stack slot)的数量。假设该方法像这样: myObj.myMethod(long a, int b), 那么所需栈槽为 1(ref)+2(long)+1(int)=4
2. 该参数其实并无太大意义,因为这个数字其实是可以推测的。只是因为jvm bytecode就是这样的,Oolong为了保持对bytecode的直译性,才保留下来。
3. invokeinterface的性能相对invokevirtual要慢上一个数量级,使用应慎重。


Constructor:
在子类的<init>里,对超类的<init>的调用必须采用invokespecial的方式。逻辑很简单,假如你用invokevirtual的方式来调用超类的constructor,那么jvm会采用既有的分发方式进行调用,于是又回回转到子类的<init>里来,性能无限递归循环。


Constant Propagation:
事实上,一个较好的实践是,写java的时候就对所有具不变性质的变量加上final修饰。CPU的寄存器远比内存要快,所以:
;变量x在1的位置,值为32
iload_1  ;push x

总是不及
bipush 32 ;push x

来得快。对x的声明加上final,编译器就可以得到明确的指示,可以按照第二种方式进行优化(即使没有final,编译器也可能完成优化但这只能取决与编译器的人品)。


Optimization:
编译器如javac会将java代码转换为bytecode会做一些优化,在jvm实例加载类的时候会由JIT进一步对bytecode进行优化。Inline究竟在那个阶段发生,Engel没有明确提出,需要进一步学习研究。
Inline在C++里也是一个重要的编译器优化选项,目的是把透明的方法或字段的调用,替换为确定的具体代码,比如:
class Demo {
  void method_1() {
    method_2();
    method_3();
  }

  void method_2() {
    System.out.println("Method_2");
  }

  void method_3() {
    System.out.println("Method_3");
  }
}

经过inline优化,会变成:
class Demo {
  void method_1() {
    System.out.println("Method_2"); //method_2的代码原封不动搬过来
    System.out.println("Method_3"); //method_3代码
  }

  void method_2() {
    System.out.println("Method_2");
  }

  void method_3() {
    System.out.println("Method_3");
  }
}

当然,优化的结果只是bytecode形式的,这里用java只是方便说明。
inline之后节省的开销有:参数压栈,创建被叫方法栈帧,销毁被叫方法栈帧,等。

由于java有polymorph的特性,inline有时无法实行,因为jvm和编译器无法确定究竟应该copy哪一段代码,是超类的方法,还是子类的覆盖方法。我们能从一定程度上帮助jvm,比如对超类的方法加上final修饰符,则jvm将可以确定inline只会使用这个方法的代码。
对于字段的inline,值得注意的是,Engel在14.3.1章中给出的TeaParty的例子,其对结果的描述至少在JDK5/6中是错的,具体例子可以参考我这篇文章:
更新常量后,请重新编译你的class


Thread Lock:
使用线程锁时,如果不明白里面的机制,则不免心惊胆跳。也不奇怪,因为在bytecode级别,锁的获取与释放分别由两个指令来实现:
monitorenter  ;获得锁
monitorexit   ;释放锁

这两个指令必须成对出现,一旦有遗漏,比如少了一个monitorexit,锁就不能释放给其它线程,如果线程不死,锁就永远被该线程hold住了,变成了所谓‘死锁’-deadlock。所以,java编译器为了避免死锁出现,对这段java代码:
synchronized (obj) {
  //做点什么
}

总是编译成:
.catch all from begin to end using handler
begin:

aload_1       ;将锁对象压入栈顶
monitorenter  ;将aload_1的对象锁住
;做点什么
monitorexit   ;正常释放锁

end:
goto next_code ;继续执行synchronized后的代码

handler:
aload_1
monitorexit   ;在非正常跳出代码块时,无论如何都要释放锁

next_code:
;其它代码

因此,在java里面使用锁还是很安全的,Oolong或bytecode里就得时刻小心泄露问题。

多重锁又是如何实现的呢?锁上加锁,不免有些诡异,其实原理很简单,比如下面的java代码:
class LockDemo {
  synchronized void mainLock() {
    subLock1();
    subLock2();
  }

  synchronized void subLock1() {
    //做点什么1
  }

  synchronized void subLock2() {
    //做点什么2
  }
}

编译bytecode时,每遇到一个synchronized关键字,就套上一对monitorenter/monitorexit,上面的java代码翻译成Oolong就成了(为清晰起见,我把所有方法的代码都inline到一起了):
;最初,lock_count = 0
aload_0
monitorenter  ;mainLock()上锁, lock_count = 1

aload_0
monitorenter  ;subLock1()上锁, lock_count = 2
;做点什么1
aload_0
monitorexit   ;subLock1()解锁, lock_count = 1

aload_0
monitorenter  ;subLock2()上锁, lock_count = 2
;做点什么2
aload_0
monitorexit   ;subLock2()解锁, lock_count = 1

aload_0
monitorexit   ;mainLock()解锁, lock_count = 0

可见,每执行一次monitorenter,lock_count就增加1;每执行一次monitorexit,lock_count就减少1。
JVM规定,只有当lock_count = 0 时,别的线程才有机会获得锁。因此,在subLock1()和subLock2()之间不可能有其它的线程介入,mainLock()与subLock1()和subLock2()所持都是同一个锁。