从字节码角度解析synchronized和反射实现原理
作者:悦
引言
前几天,关于字节码技术,我们讲了字节码的基础, 常见的字节码框架以及在软件破解和APM链路监控方面的一些应用.
今天我们回到Java本身, 看下我们常用的synchronized关键字和反射在字节码层面是如何实现的.
synchronized
代码块级别的 synchronized
如下方法的内部使用了synchronized关键字
private Object lock = new Object(); public void foo() { synchronized (lock) { bar(); } } public void bar() { }
编译成字节码如下
public void foo(); Code: 0: aload_0 1: getfield #3 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: aload_0 8: invokevirtual #4 // Method bar:()V 11: aload_1 12: monitorexit 13: goto 21 16: astore_2 17: aload_1 18: monitorexit 19: aload_2 20: athrow 21: return Exception table: from to target type 7 13 16 any 16 19 16 any
Java 虚拟机中代码块的同步是通过 monitorenter 和 monitorexit 两个支持 synchronized 关键字语意的。比如上面的字节码
- 0 ~ 5:将 lock 对象入栈,使用 dup 指令复制栈顶元素,并将它存入局部变量表位置 1 的地方,现在栈上还剩下一个 lock 对象
- 6:以栈顶元素 lock 做为锁,使用 monitorenter 开始同步
- 7 ~ 8:调用 bar() 方法
- 11 ~ 12:将 lock 对象入栈,调用 monitorexit 释放锁
monitorenter 对操作数栈的影响如下
- 16 ~ 20:执行异常处理,我们代码中本来没有 try-catch 的代码,为什么字节码会帮忙加上这段逻辑呢?
因为编译器必须保证,无论同步代码块中的代码以何种方式结束(正常 return 或者异常退出),代码中每次调用 monitorenter 必须执行对应的 monitorexit 指令。为了保证这一点,编译器会自动生成一个异常处理器,这个异常处理器的目的就是为了同步代码块抛出异常时能执行 monitorexit。这也是字节码中,只有一个 monitorenter 却有两个 monitorexit 的原因
可理解为这样的一段 Java 代码
public void _foo() throws Throwable { monitorenter(lock); try { bar(); } finally { monitorexit(lock); } }
根据我们之前介绍的 try-catch-finally 的字节码实现原理,复制 finally 语句块到所有可能函数退出的地方,上面的代码等价于
public void _foo() throws Throwable { monitorenter(lock); try { bar(); monitorexit(lock); } catch (Throwable e) { monitorexit(lock); throw e; } }
方法级的 synchronized
方法级的同步与上述有所不同,它是由常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。
synchronized public void testMe() { } 对应字节码 public synchronized void testMe(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED
JVM 不会使用特殊的字节码来调用同步方法,当 JVM 解析方法的符号引用时,它会判断方法是不是同步的(检查方法 ACC_SYNCHRONIZED 是否被设置)。如果是,执行线程会先尝试获取锁。如果是实例方法,JVM 会尝试获取实例对象的锁,如果是类方法,JVM 会尝试获取类锁。在同步方法完成以后,不管是正常返回还是异常返回,都会释放锁.
反射
在 Java 中反射随处可见,它底层的原也比较有意思,这篇文章来详细介绍反射背后的原理。
先来看下面这个例子:
public class ReflectionTest { private static int count = 0; public static void foo() { new Exception("test#" + (count++)).printStackTrace(); } public static void main(String[] args) throws Exception { Class<?> clz = Class.forName("ReflectionTest"); Method method = clz.getMethod("foo"); for (int i = 0; i < 20; i++) { method.invoke(null); } } }
运行结果如下
可以看到同一段代码,运行的堆栈结果与执行次数有关系,在 0 ~ 15 次调用方式为sun.reflect.NativeMethodAccessorImpl.invoke0
,从第 16 次开始调用方式变为了sun.reflect.GeneratedMethodAccessor1.invoke
。原因是什么呢?继续往下看。
反射方法源码分析
Method.invoke 源码如下:
可以最终调用了MethodAccessor.invoke
方法,MethodAccessor 是一个接口
public interface MethodAccessor { public Object invoke(Object obj, Object[] args) throws IllegalArgumentException, InvocationTargetException; }
从输出的堆栈可以看到 MethodAccessor 的实现类是委托类DelegatingMethodAccessorImpl,它的 invoke 函数非常简单,就是把调用委托给了真正的实现类。
class DelegatingMethodAccessorImpl extends MethodAccessorImpl { private MethodAccessorImpl delegate; public Object invoke(Object obj, Object[] args) throws IllegalArgumentException, InvocationTargetException { return delegate.invoke(obj, args); }
通过堆栈可以看到在第 0 ~ 15 次调用中,实现类是 NativeMethodAccessorImpl
,从第 16 次调用开始实现类是 GeneratedMethodAccessor1
,为什么是这样呢?玄机就在 NativeMethodAccessorImpl 的 invoke 方法中
前 0 ~ 15 次都会调用到invoke0,这是一个 native 的函数。
private static native Object invoke0(Method m, Object obj, Object[] args);
有兴趣的同学可以去看一下 Hotspot 的源码,依次跟踪下面的代码和函数:
./jdk/src/share/native/sun/reflect/NativeAccessors.c JNIEXPORT jobject JNICALL Java_sun_reflect_NativeMethodAccessorImpl_invoke0 (JNIEnv *env, jclass unused, jobject m, jobject obj, jobjectArray args) ./hotspot/src/share/vm/prims/jvm.cpp JVM_ENTRY(jobject, JVM_InvokeMethod(JNIEnv *env, jobject method, jobject obj, jobjectArray args0)) ./hotspot/src/share/vm/runtime/reflection.cpp oop Reflection::invoke_method(oop method_mirror, Handle receiver, objArrayHandle args, TRAPS)
这里不详细展开 native 实现的细节。
15 次以后会走新的逻辑,使用 GeneratedMethodAccessor1 来调用反射的方法。MethodAccessorGenerator 的作用是通过 ASM 生成新的类 sun.reflect.GeneratedMethodAccessor1
。为了查看整个类的内容,可以使用阿里的 arthas 工具。修改上面的代码,在 main 函数的最后加上System.in.read()
;让 JVM 进程不要退出。 执行 arthas 工具中的./as.sh,会要求输入 JVM 进程
选择在运行的 ReflectionTest 进程号 7 就进入到了 arthas 交互性界面。执行 dump sun.reflect.GeneratedMethodAccessor1
文件就保存到了本地。
来看下这个类的字节码
翻译一下这个字节码,忽略掉异常处理以后的代码如下
public class GeneratedMethodAccessor1 extends MethodAccessorImpl { @Override public Object invoke(Object obj, Object[] args) throws IllegalArgumentException, InvocationTargetException { ReflectionTest.foo(); return null; } }
那为什么要采用 0 ~ 15 次使用 native 方式来调用,15 次以后使用 ASM 新生成的类来处理反射的调用呢?
一切都是基于性能的考虑。JNI native 调用的方式要比动态生成类调用的方式慢 20 倍,但是又由于第一次字节码生成的过程比较慢。如果反射仅调用一次的话,采用生成字节码的方式反而比 native 调用的方式慢 3 ~ 4 倍。
inflation 机制
因为很多情况下,反射只会调用一次,因此 JVM 想了一招,设置了 15 这个 sun.reflect.inflationThreshold
阈值,反射方法调用超过 15 次时(从 0 开始),采用 ASM 生成新的类,保证后面的调用比 native 要快。如果小于 15 次的情况下,还不如生成直接 native 来的简单直接,还不造成额外类的生成、校验、加载。这种方式被称为 「inflation 机制」。inflation 这个单词也比较有意思,它的字面意思是「膨胀;通货膨胀」。
JVM 与 inflation 相关的属性有两个,一个是刚提到的阈值 sun.reflect.inflationThreshold
,还有一个是是否禁用 inflation的属性 sun.reflect.noInflation
,默认值为 false。如果把这个值设置成true 的话,从第 0 次开始就使用动态生成类的方式来调用反射方法了,不会使用 native 的方式。
增加 noInflation 属性重新执行上述 Java 代码
java -cp . -Dsun.reflect.noInflation=true ReflectionTest
输出结果为
java.lang.Exception: test#0
at ReflectionTest.foo(ReflectionTest.java:10)
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at java.lang.reflect.Method.invoke(Method.java:497)
at ReflectionTest.main(ReflectionTest.java:18)
java.lang.Exception: test#1
at ReflectionTest.foo(ReflectionTest.java:10)
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at java.lang.reflect.Method.invoke(Method.java:497)
at ReflectionTest.main(ReflectionTest.java:18)
可以看到,从第 0 次开始就已经没有使用 native 方法来调用反射方法了。
小结
这篇文章主要从字节码角度看了Java中的synchronized和射调用底层的原理,当然还有一些其他比较有意思的语法比如lambda, switch等, 感兴趣的小伙伴也可以从字节码角度去了解一下, 相信你会有很多不一样的收获,更多关于字节码解析synchronized反射的资料请关注脚本之家其它相关文章!