分享几个“装逼”知识点
本文源于wanandroid 每日一问。
下面这篇文章,将会突出几个关键词:Synthetic,BRIDG,泛型擦除相关的知识。
如果这几个词不熟悉,很正常,没关系。因为正常我们写代码是感知不到这些东西的存在的,只有一些无意中的发现、思考,才能带出这些知识点。
下面我们一起去发现和思考吧。
我们来看一坨代码:
public class A{
public static void main(String[] args){
B b = new A.B();
}
private static class B{
}
}
上述代码,如果我们执行javac A.java编译,会产生几个class文件?
我们分析下:
A.class 肯定有
有个静态内部类B,还有个A$B.class
两个。
实际运行你会发现有三个:
A.class
A$B.class
A$1.class
为什么会多了个A$1.class呢?
回答人:陈小缘&taodashuai。
为什么会多了个A$1.class呢?
其实就是为了实例化class B。
是这样的:
大家都知道,在java文件中编写的内部类,在编译成class之后,都会像是被抽出来一个单独的类一样,也就是从内部类变成了普通的类。
因为内部类B没有定义构造方法,在编译时编译器就会自动帮它生成一个,访问权限都是跟随这个类的访问权限的,比如:
public class B,生成的构造方法的访问权限就是public;
protected class B ——> protected B() {};
class B ——> B() {};
private class B ——> private B() {};
编译成class之后,内部类会以普通类的方式存在,而且构造方法又是private,那怎么能在A中实例化它呢?
编译器在编译时会:
既然原来的private构造方法无法改变,那我还可以新增一个可以让外部访问的构造方法啊!
不过,现在已经有一个无参构造方法了,再加一个的话,怎么保证不会跟现有的构造方法签名(参数类型)冲突呢?
于是,内部类A$1就诞生了!
反编译看一下就很清楚了 :
这里我们可以使用 javap -private A$B.class 来简单查看
Compiled from "A.java"
class A$B {
private A$B(); // 这里就是编译器默认生成的构造函数
A$B(A$1); // 这里是编译器额外生成的构造函数
}
再看看 A$1 到底是个啥 javap -c A$1.class
Compiled from "A.java"
class A$1 {
}
很简单,甚至连构造函数都没有,这个就是所谓的合成类(synthetic class),由编译器自行生成,常用于各种内部类。
而我们在A类里使用 new A.B() ,其实调用的就是 B类里的 A$B(A$1),可以使用 javap -c A.class 来看看A类里到底干了啥
Compiled from "A.java"
class A {
A();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class A$B
3: dup
4: aconst_null
5: invokespecial #3 // Method A$B."<init>":(LA$1;)V // 在这里调用
8: astore_1
9: return
}
Method A$B."<init>":(LA$1;)V 从这个方法注释就能很清晰的看出来调用的是 A$B(A$1) 构造方法,aconst_null代表其参数就是一个null。
ok,上面我大概看懂了,但是这些知识和标题中的:Synthetic有什么关系呢?
我们继续反编译看下:
javap -v A\$1.class
class A$1
minor version: 0
major version: 52
flags: ACC_SUPER, ACC_SYNTHETIC
可以看到 A$1,这个编译器额外生成的类,有个 flag 为:ACC_SYNTHETIC。
同理,我们想看看 A$B中,那个编译器额外生成的方法的flag:
A$B(A$1);
descriptor: (LA$1;)V
flags: ACC_SYNTHETIC
没错,熟悉的ACC_SYNTHETIC又出现了了。
所以,可以总结下,编译器为了方便我们编写代码,被背着我们做一些弥补性的工作,中间会产生一些类或者方法,在这些类和方法中都有ACC_SYNTHETIC标记。
你可能会有困惑,这都是编译器做的,我们平时写代码遇不到吧?
是有可能遇到的,比如你在反射获取 A$B 的构造方法时,忽然发现一个不是你写的构造方法,是不是一脸懵逼?
当然 Java 层也给大家提供了 API用于识别,该类 or 方法是否是ACC_SYNTHETIC标识的。
ok,继续。
再来看一个例子:
首先我们编写个接口:
interface Animal<T>{
void test(T t);
}
这个接口有个实现类:
class Dog implements Animal<String>{
@override
public void test(String str){
}
}
符合我们平时的写法对吧。
但是你仔细推敲一下:
接口 Animal 类的泛型,在编译成 class 后,会经历泛型擦除,会变成这样:
interface Animal{
void test(Object obj);
}
而实现类Dog里面有个方法test(String str),注意这个方法和接口类的方法参数并不一致。
那么也就是说,并没有实现接口中的方法,但是,接口的方法,实现类是必须实现的。
问题来了:
和我们理解的接口中声明的方法,实现类必须实现的理念相悖,为何可以呢?
回答人:陈小缘。
想知道为什么,肯定离不开反编译了:
编译题目给出代码,再像上次那样javap看一下Dog的字节码:
javap -v Dog.class:
可以看到,Dog里面除了我们在代码中编写的test(String),还多了一个test(Object)(还可以看到它在checkcast之后也会调用test(String)方法),注意看这个多出来的test(Object)方法的flags:ACC_PUBLIC 、 ACC_BRIDGE 、 ACC_SYNTHETIC 。
第一个(ACC_PUBLIC)不用说,从第三个(ACC_SYNTHETIC)可以知道这个方法是编译器帮我们生成的,而且我们在代码中不能通过常规方式来访问。
中间的ACC_BRIDGE是什么呢?
正则表达式全局搜一下,看看在哪里声明:
emmmm,跟上次的SYNTHETIC一样,也是在Modifier里面。
这个由编译器帮我们生成的test(Object)方法,就像一座桥一样,将接口方法与我们自己实现的真实方法连接起来。
回到题目中的问题:
为什么没有手动实现接口经过泛型擦除后的test(Object)方法也能正常通过编译呢?
因为这一步编译器已经帮我们做了,在自动生成test(Object)方法后,还会加上一个ACC_BRIDGE标记,在程序中可以通过Method的isBridge方法来判断。
那么我们再来看一个例子:
interface TestInter<T> {
T getName(String name);
}
实现类:
class TestImpl implements TestInter<String> {
@Override
public String getName(String name) {
return null;
}
}
这次猜我的关注点在哪?
我们反编译一下TestImpl:
public java.lang.String getName(java.lang.String);
descriptor: (Ljava/lang/String;)Ljava/lang/String;
flags: ACC_PUBLIC
public java.lang.Object getName(java.lang.String);
descriptor: (Ljava/lang/String;)Ljava/lang/Object;
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
又看到了ACC_BRIDGE,ACC_SYNTHETIC,已知知识。
可以看到生成的两个方法,我们转成Java的展现形式:
String getName(String str)
Object getName(String str);
有没有觉得奇怪?
我贴张图,你就能看明白了:
这两个方法的方法名,参数均相同,只有返回值不同,在我们Java平时编写中是不允许的。
问题来了:
为何在这个场景下,允许「一个类中有方法名,参数均相同,只有返回值不同」两个方法?
回答人:陈小缘。
为什么在Java代码中定义多个【名字和参数都一样,返回值类型不同】的方法会报错呢?
因为Java语言层面,它的方法签名是不带返回值类型的,只有方法名和参数类型。所以即使两方法的返回值类型不同,它也一样当作重复声明了。
也可以通过一段代码来帮助理解:
public class Test {
{
Number n = getNumber(1);
}
Long getNumber(int i) {
return 1L + i;
}
Double getNumber(int i) {
return 1D + i;
}
}
Test类里面定义了两个getNumber方法,它们的名字和参数类型都完全一样,只是一个返回Double一个返回Long。
如果我在某个地方调用getNumber方法来获取Number对象,你怎么知道我想要调用哪一个?
根本无法辨别的好吧。
那为什么class文件里就可以有多个【名字和参数都一样,返回值类型不同】的方法存在?
JVM是怎么把它们区分开来的?
因为在class常量池中,就已经有对这些方法的完整描述,包括方法的返回值类型。
这样的话,就算方法名和参数都一样,只要返回值不同,那它们也是合法的,JVM也能正确分辨出具体调用的是哪一个。
修改一下题目中的TestImpl,加个构造函数并在里面调用getName方法:
class TestImpl implements TestInter<String> {
TestImpl() {
getName(null);
}
@Override
public String getName(String name) {
return null;
}
}
编译,然后javap -v TestImpl看下:
看构造方法里highlight的那一行:
invokevirtual的操作数#2,就是要调用的方法符号索引,可以对照着上面常量池中的信息来找到(其实右边的注释已经列出来了)完整的方法签名:
#2 = #3.#18
#3 = #19 = TestImpl
#18 = #10:#11
#10 = getName
#11 = (Ljava/lang/String;)Ljava/lang/String;(还可以看到#11下面的#12就是返回值为Object的方法)
把它们组合起来,就是TestImpl.getName:(Ljava/lang/String;)Ljava/lang/String;了。
如果它调用的是返回值为Object的方法,那么就会有#n = #10:#12 、 #m = #3.#n,下面构造函数中的invokevirtual指令的操作数就是#m(当然了这个方法无法在代码中直接调用到,所以是看不到这种场景的)。
能看到这里,恭喜你,大概率你是有收获的。
你可能会迷惑,为什么要掌握这些底层知识,好像我们平时开发时候完全用不到吧。
其实怎么说呢,说用不到,可能还没有遇到相关的场景,当你知道编译器编译期间会增加一些类、方法,假设你有修改字节码的需求的时候,你就可以评估到这些生成的类、方法带来的影响。
甚至,当我们在反射某个类里面方法的时候,也要注意避开编译器生成的桥接方法。
例如上例,Dog 里面在我们看来只有一个test 方法:
class Dog implements Animal<String>{
@override
public void test(String str){
}
}
实际上你通过反射去取,test 方法包含两个,如果不注意就可能取错。
其次,今天这三个点,如果平时注意发现或者思考,应该也会有这样的困惑,这几个点都是有违我们平时开发中的知识的。
后续发现奇怪的东西,就尝试探索破解吧,说不定发现新大陆呢。
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!