Java程序里其实有很多我们看不到的代码,这些代码由Java编译器在编译过程中生成帮助程序更准确地运行。本文就来深入了解一下由编译器加入到Java代码中的方法(Method),特别是合成方法(Synthetic Method)。
合成成员(Synthetic Member)在 JVM细则里可以找到简单的定义
A class member that does not appear in the source code must be marked using a Synthetic attribute, or else it must have its ACC_SYNTHETIC flag set.
合成成员包含有合成类(Synthetic Class),合成变量(Synthetic Variable),合成方法(Synthetic Method),本文主要讨论合成方法。
合成方法不出现在.java文件中,不过编译之后会在.class里出现,并可以通过以下几个方法发现它们:
对于一般的Java程序而言并没有什么大的问题,只不过是额外的方法调用,代价比较低。但是像Android,它的 Dex文件对于Java的方法有一定的约束 ,就值得去考虑如何避免额外的方法被生成。下边就来介绍一些常见的会出现合成方法的情况让我们有清楚地了解Java的运行机制。
先来看一个代码案例,代码中定义了一个顶层类和嵌套类,互相会方法对方的一些private修饰的方法和字段。
public class Outer { private int privateField = 21; private Inner inner = new Inner(); private int privateMethod() { return inner.privateMethod(); } private void print() { inner.print(); } private static int privateStaticMethod() { return 7; } class Inner { private int privateMethod() { return privateField; } private void print() { System.out.println(Outer.this.privateMethod()); System.out.println(privateStaticMethod()); } } public static void main(String[] args) { Outer outer = new Outer(); outer.print(); } }
之前的文章有介绍了 Java的修饰访问符的限制范围 ,类中由private修饰的成员,只能被该类自身所访问,因此嵌套类的私有成员不应该能被外部类访问。另一方面嵌套类虽然在外部类里面,但其实并不属于外部类的一部分,比如编译上边的代码会得到两个.class文件,Inner类被编译成了package可见的独立类。
可实际代码里,内部类和外部类之间互相调用对方的私有成员却没有任何的编译错误,这其中就是合成方法在起作用。
我们通过 javap -p
指令,我们可以查看到编译后的类中包含的方法中多了包含 acesse$
前缀的静态方法,它们就是编译器添加的合成方法。另外值得一提的是这些方法都是pacakge可见的。(同时还有 this$0
一个类的合成成员变量,这里不错详细讨论)
$ javap -p Outer.class Compiled from "Outer.java" public class Outer { private int privateField; private Outer$Inner inner; public Outer(); private int privateMethod(); private void print(); private static int privateStaticMethod(); public static void main(java.lang.String[]); static int access$200(Outer); static int access$300(Outer); static int access$400(); } $ javap -p Outer/$Inner.class Compiled from "Outer.java" class Outer$Inner { final Outer this$0; Outer$Inner(Outer); private int privateMethod(); private void print(); static int access$000(Outer$Inner); static void access$100(Outer$Inner); }
通过 javap -v
指令查看可以确认这些方法含有 ACC_SYNTHETIC
标记
javap -v Outer.class …… static int access$200(Outer); descriptor: (LOuter;)I flags: ACC_STATIC, ACC_SYNTHETIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #3 // Field privateField:I 4: ireturn LineNumberTable: line 1: 0 static int access$300(Outer); descriptor: (LOuter;)I flags: ACC_STATIC, ACC_SYNTHETIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #2 // Method privateMethod:()I 4: ireturn LineNumberTable: line 1: 0 static int access$400(); descriptor: ()I flags: ACC_STATIC, ACC_SYNTHETIC Code: stack=1, locals=0, args_size=0 0: invokestatic #1 // Method privateStaticMethod:()I 3: ireturn LineNumberTable: line 1: 0
用JD-GUI来查看 Outer$Inner.class 可以明显看出对私有成员的具体调用方式其实是通过调用这些合成的静态方法来间接调用的实际的方法或字段。这些静态方法是package可见,而编译后的类也在同一个package下,所以对它们的调用不违反访问修饰符的约束范围;而这些方法又是定义在类里面的,所以允许直接访问同一个类的私有成员。
那Java编译器为什么要弄合成方法这么复杂,直接改变编译后的访问修饰符不是更简单吗?具体原因我也不清楚,我的理解是这样:用了合成方法,编译后作为第三方库提供出去也不会在 .class 中找到私有成员;即使利用反射,这些访问修饰符也有一定的约束;而合成方法的名字是由编译器决定的,所以也不容易被猜到用到反射上。
至于为什么合成方法要用静态方法就比较好解释了:防止覆盖(Override)。
虽然编译器不会扩张访问修饰符,我们可以手动修改代码改变 private
到 (package)
,或者用一些第三方工具比如ProGuard来修改生成的代码,来防止合成方法的出现来减少方法数量和调用层次。
Java的泛型存在 类型擦除(Type Erasure) 的机制,在二进制的代码中实际只有一份原始类型。当某个类继承了参数化的泛型类(parameterized generic class)并用实际类型(actual type)覆盖了带类型参数(type paramter)的方法时,编译器就会合成一个使用原始类型的方法,在该方法内用强制转换到实际类型来调用定义出来的方法,这个合成方法也被称为 桥接方法(bridage method) 。
话有点绕还是直接用代码来解释吧。
public class Generic { public void method(T t) { System.out.println(t); } } class Overrider extends Generic { // public void method(Object o) {} /* Compile Error */ @Override public void method(String s) { System.out.println("Overrider: " + s); } } class Extender extends Generic {}
首先当我们想在Overrider里定义一个 public void method(Object o)
方法时,看起来它并不和 public void method(String s)
方法有冲突,但我们却得到了编译错误
name clash: method(java.lang.Object) in Overrider and method(T) in Generic have the same erasure, yet neither overrides the other
使用 javap
来查看编译得到的Overrider.class文件就能清楚看到该类里多了 public void method(java.lang.Object);
,所以我们就不能再进行一次定义。
$ javap Overrider.class Compiled from "Generic.java" class Overrider extends Generic { Overrider(); public void method(java.lang.String); public void method(java.lang.Object); }
再用 javap -v
也能验证这个方法是合成方法,也能看出它的具体实现其实就是把 Object
实参强制转换成 String
再传给接受 String
类型的同名方法。
$ javap -v Overrider.class …… public void method(java.lang.Object); descriptor: (Ljava/lang/Object;)V flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: aload_1 2: checkcast #9 // class java/lang/String 5: invokevirtual #10 // Method method:(Ljava/lang/String;)V 8: return LineNumberTable: line 8: 0 ……
所以这个方法的具体实现其实如下所示,而它才是真正覆盖了父类的方法
public void method(Object o) { this.method((String) o); }
不过这个方法只有当出现方法覆盖并且实际类型与擦除后泛型参数不同时才会出现,比如上边的代码里Extender没有任何覆盖方法,就不会生成对应的桥接方法,用 javap
查看也只会看到默认构造函数
$ javap -p Extender.class Compiled from "Generic.java" class Extender extends Generic { Extender(); }
桥接方法的加入,可以保证Java的多态(polymorphism)可以正常起效,毕竟参数化后的覆盖方法和类型擦除后的基类方法在签名上是不同的。桥接方法实现中的强制类型转换也保证了在代码运行期间,错误的类型不会被代码接受。对于不包含桥接方法的类型,如果我们将对象赋值给泛型的原始类型,虽然我们会得到编译器的警告,但是代码依然可以正常地执行,只不过这样就造成了 Heap Pollution 。因为变量指向的对象类型并不是我们预期要求的类型。
下边的代码就是所有类型的调用例子,可以看出来虽然所有类型都要求String类型,赋值给原始类型Generic后我们会得到 Unchecked call to ‘method(T)’ as a member of raw type ‘com.ider.Generic’的编译警告,但是通过这个类型的变量,我们却可以顺利的使用非String类型去调用这些方法,只有Overrider会抛出ClassCastException来终止程序。
public static void main(String[] args) { Generic stringGeneric = new Generic<>(); // stringGeneric.method(7); /* Compile error */ Generic generic = stringGeneric; generic.method("Zheng"); /* OK */ generic.method(21); /* OK */ Generic extender = new Extender(); extender.method("Ider"); /* OK */ extender.method(4); /* OK */ Generic overrider = new Overrider(); overrider.method("Iderson"); /* OK */ try { overrider.method(13); /* ClassCastException */ } catch (ClassCastException e) { e.printStackTrace(); } } /* Result: Zheng 21 Ider 4 Overrider: Iderson java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String at com.ider.Overrider.method(Generic.java:33) at com.ider.Generic.main(Generic.java:26) */
其实在我念想中,编译器也可以把覆盖的public void method(String s)编译成下边的实现,这样就既能保证多态的实现,类型的准确性,也不需要引入桥接方法。
public void method(Object o) { String s = (String) o; ...... }
不过这导致的一个不好的结果则是:Overrider.class.getDeclaredMethod(“method”, String.class);这段Java反射代码就会找不到对应的方法。而这让人觉得与源码有些不符。
Java在方法继承上有个非常有趣的特性叫“ 协变返回值类型(Covariant Return Types) ”,简单来说:它允许子类覆盖父类方法是返回不同的类型,但要求子类返回类型可以赋值给父类返回类型,且两者都是引用类型”。
这个特性并不是没有任何副作用的,它也会引入合成方法。并且这个合成方法仅仅是返回值不同,而方法名和参数都完全一样的重载方法。这其实有些违背我对面向对象的理解,因为一直以来我都觉得构成方法签名(Method Signature)的是方法名和参数数量及参数类型,当其中一个不同时就会申明不同的方法,返回值是不作为方法的一个标识的。当我们写Java代码时,也是遵循这一原则,一个类里不能定义只有返回值不同其他都一样的两个方法:
但在编译后的Java代码里,它却真实存在着,比如下边的继承和方法覆盖就用到了协变返回值类型
class Supper{ Object method() { return null; } } class Covariant extends Supper { @Override Thread method() { return null; } }
当我们用 javap
查看Covariant.class时,就能看的两个只有返回值不同的方法
$ javap Covariant.class Compiled from "Covariant.java" class Covariant extends Supper { Covariant(); java.lang.Thread method(); java.lang.Object method(); }
同样的,通过 javap -v
指令我们可以看出 Object method();
是一个合成方法,并且它的具体实现就是直接调用另一个我们定义的方法。
$ javap -v Covariant.class …… java.lang.Object method(); descriptor: ()Ljava/lang/Object; flags: ACC_BRIDGE, ACC_SYNTHETIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokevirtual #2 // Method method:()Ljava/lang/Thread; 4: areturn LineNumberTable: line 8: 0 ……
不过我依然不清楚为什么需要这个桥接方法,也不明白其内部怎么调用另一个完全一样的方法毕竟返回值是不会作为方法寻址的条件。协变返回值类型是在Java 1.5引入的语言的特性,因此或许是一些向后兼容的需要吧,而目前很少有人用1.5以前的版本,我在网上也很难找到对于老版本Java的介绍和对该情况的分析。
上边介绍几个常见的出现合成方法的情况,下边再来讲几个也是由编译器加入的方法,但是他们被Java规范视为特殊情况而没有标记为合成方法。大致原因是由于我们还是可以在代码中直接访问到他们(可见bug 讨论1 , 讨论2 )。
The only exceptions to this requirement are compiler-generated methods which are not considered implementation artifacts, namely the instance initialization method representing a default constructor of the Java programming language (§2.9), the class initialization method (§2.9), and the Enum.values() and Enum.valueOf() methods.
当类没有申明任何构造函数时,Java会自动添加一个 默认构造函数(default constructor) ,这个构造函数没有任何参数,其实现也只是简单地调用父类的不带参数的构造函数。比如下边的代码:
public class Constructor extends Thread { }
用 javap
来查看编译后的Constructor.class文件可以看到多了一个默认构造函数
$ javap Constructor.class Compiled from "Constructor.java" public class Constructor extends java.lang.Thread { public Constructor(); }
进一步用 javap -v
查看细节,方法里只是调用了父类的初始化方法(这是Java里的与构造函数对应的特殊方法,不在本文介绍范围),但是方法并没有 ACC_SYNTHETIC
的标记。
$ javap -v Constructor.class …… public Constructor(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Thread."":()V 4: return LineNumberTable: line 1: 0 ……
此外要注意的是这个默认构造函数的访问修饰并不总是 public
的,它其实是跟类的访问修饰一致。类定义在文件顶层时,它可以有public或者没有任何访问修饰符(即package可见);当类是嵌套类(Nested Class)时,就可以使用所有4种访问修饰符。
public class Constructor { public static class Public {} static class Package {} static protected class Protected {} private static class Private {} } class Package extends Constructor {}
通过 javap -p
可以看出对于上边每个类编译后都会有一个默认构造函数,且其访问修饰符与类在代码里申明的访问修饰符完全一致。
$ javap -p Package.class Constructor/$Public.class Constructor/$Package.class Constructor/$Protected.class Constructor/$Private.class Compiled from "Constructor.java" class Package extends Constructor { Package(); } Compiled from "Constructor.java" public class Constructor$Public { public Constructor$Public(); } Compiled from "Constructor.java" class Constructor$Package { Constructor$Package(); } Compiled from "Constructor.java" public class Constructor$Protected { protected Constructor$Protected(); } Compiled from "Constructor.java" class Constructor$Private { private Constructor$Private(); }
既然private修饰的类对应的默认构造函数也是private,那就像之前说的外部类应该无法调用掉呀。事实也确实如此,只是当外部类去调用私有内部类的时候,编译器又会添加一个新的无访问修饰符的构造函数。
public class Constructor { private static class Private {} public static void main(String[] args) { Private p = new Private(); } }
比如上边的代码编译后,会额外得到一个新的类,而那个额外的合成构造函数的参数类型就是这个新的类型,这个合成构造函数的内部实现就是调用默认构造函数。
$ javap -p Constructor/$Private.class Compiled from "Constructor.java" class Constructor$Private { private Constructor$Private(); Constructor$Private(Constructor$1); }
$ javap -v -p Constructor/$Private.class …… Constructor$Private(Constructor$1); descriptor: (LConstructor$1;)V flags: ACC_SYNTHETIC Code: stack=1, locals=2, args_size=2 0: aload_0 1: invokespecial #1 // Method "":()V 4: return LineNumberTable: line 3: 0 ……
再来查看构造函数调用处编译后的代码,其实只是传入了null值,
$ javap -v -p Constructor.class …… public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=2, args_size=1 0: new #2 // class Constructor$Private 3: dup 4: aconst_null 5: invokespecial #3 // Method Constructor$Private."":(LConstructor$1;)V 8: astore_1 9: return LineNumberTable: line 6: 0 line 7: 9 ……
用 javap
查看额外的类可以看出这个类是一个合成类,它里面什么都没有,也不会真正被构建,只是作为标识来找寻到对应的package可见的构造函数,再调用 private
的构造函数来创建出所需的对象实例。
$ javap -v -p Constructor/$1.class …… class Constructor$1 minor version: 0 major version: 52 flags: ACC_SUPER, ACC_SYNTHETIC ……
所以,如果嵌套函数被外部函数构建,而嵌套函数的构造函数是私有的,那么Java编译器就会增加一个合成构造函数。解决方案也很简单,就是不要用private来修饰嵌套类的构造函数。
另外前面的例子用的是静态嵌套类,对于内部类(Inner Class)的构造函数,编译器会对每一个构造函数再额外添加一个外部类类型的参数。创建内部类时会传入外部类的实例,让内部类的一个成员变量去引用,所以对于外部类的访问其实都是通过这个变量实现的。这个变量是一个合成变量(Synthetic Field),但是这个构造函数却不是合成方法。
$ javap -p -v Outer/$Inner.class …… final Outer this$0; descriptor: LOuter; flags: ACC_FINAL, ACC_SYNTHETIC Outer$Inner(Outer); descriptor: (LOuter;)V flags: Code: stack=2, locals=2, args_size=2 0: aload_0 1: aload_1 2: putfield #3 // Field this$0:LOuter; 5: aload_0 6: invokespecial #4 // Method java/lang/Object."":()V 9: return LineNumberTable: line 18: 0 ……
在Java中,enum类型其实只是特殊的类,在编译后它会生成继承与Enum<E extends Enum<E>>的类。但除了获得父类中定义的方法,编译器会加入很多辅助方法。(对于enum类型以后会写文章详细分析)
enum Day { SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY; }
上边的例子在编译后获得.class文件,在 javap -p
里就可以清楚的看出来多出来的方法,并且他们都是public修饰的所以可以在代码中被使用。不过这些方法Java都没有将他们算作合成方法(Synthetic Method)。
$ javap -p Day.class Compiled from "Day.java" final class Day extends java.lang.Enum { public static final Day SUNDAY; public static final Day MONDAY; public static final Day TUESDAY; public static final Day WEDNESDAY; public static final Day THURSDAY; public static final Day FRIDAY; public static final Day SATURDAY; private static final Day[] $VALUES; public static Day[] values(); public static Day valueOf(java.lang.String); private Day(); static {}; }
对于这些合成方法,对于日常的开发不用特别在意,虽然会导致额外的方法调用但造成的代价其实并不是很大。相反的,泛型类型、协变返回值类型的存在都让代码维护性变得更好。如果真的这些合成方法变成了代码运行效率的瓶颈,那我们也不应该人为地去避免它们的引入,更彻底地解决方法应该是修改编译流程,甚至修改JVM来让代码运行更高效,同时保证源码的高可维护性。