
今天这篇文章,将会突出几个关键词:Synthetic,BRIDG,泛型擦除相关的知识。
如果这几个词不熟悉,很正常,没关系。因为正常我们写代码是感知不到这些东西的存在的,只有一些无意中的发现、思考,才能带出这些知识点。
下面我们一起去发现和思考吧。
1、初识Synthetic
我们来看一坨代码:
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); // 这里是编译器额外生成的构造函数
}
再看看 A1.class
Compiled from "A.java"
class A$1 {
}
很简单,甚至连构造函数都没有,这个就是所谓的合成类(synthetic class),由编译器自行生成,常用于各种内部类。
而我们在A类里使用 new A.B() ,其实调用的就是 B类里的 A1),可以使用 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 A1;)V 从这个方法注释就能很清晰的看出来调用的是 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,继续。
2、再遇BRIDGE
再来看一个例子:
首先我们编写个接口:
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方法来判断。
3、再深入一些吧
那么我们再来看一个例子:
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,就是要调用的方法符号索引,可以对照着上面常量池中的信息来找到(其实右边的注释已经列出来了)完整的方法签名:

把它们组合起来,就是TestImpl.getName:(Ljava/lang/String;)Ljava/lang/String;了。
如果它调用的是返回值为Object的方法,那么就会有#n = #10:#12 、 #m = #3.#n,下面构造函数中的invokevirtual指令的操作数就是#m(当然了这个方法无法在代码中直接调用到,所以是看不到这种场景的)。
4、总结
能看到这里,恭喜你,大概率你是有收获的。
你可能会迷惑,为什么要掌握这些底层知识,好像我们平时开发时候完全用不到吧。
其实怎么说呢,说用不到,可能还没有遇到相关的场景,当你知道编译器编译期间会增加一些类、方法,假设你有修改字节码的需求的时候,你就可以评估到这些生成的类、方法带来的影响。
甚至,当我们在反射某个类里面方法的时候,也要注意避开编译器生成的桥接方法。
例如上例,Dog 里面在我们看来只有一个test 方法:
class Dog implements Animal<String>{
@override
public void test(String str){
}
}
实际上你通过反射去取,test 方法包含两个,如果不注意就可能取错。
其次,今天这三个点,如果平时注意发现或者思考,应该也会有这样的困惑,这几个点都是有违我们平时开发中的知识的。
后续发现奇怪的东西,就尝试探索破解吧,说不定发现新大陆呢。
最后小编想说:对于程序员来说,要学习的知识内容、技术有太多太多,要想不被环境淘汰就只有不断提升自己,从来都是我们去适应环境,而不是环境来适应我们!
这里分享一下这段时间从朋友,大佬那里收集到的一些2019-2020BAT 面试真题解析,里面内容很多也很系统,包含了很多内容:Android 基础、Java 基础、Android 源码相关分析、常见的一些原理性问题
等等,可以很好地帮助我们深刻理解Android相关知识点的原理以及面试相关知识。
还有《960全网最全Android开发笔记》、《379页Android开发面试宝典》、《507页Android开发相关源码解析》等等学习资源。
相信它会给大家带来很多收获:


这里也分享给广大面试同胞们,希望每位程序猿们都能成为最好的自己~
以上内容均放在了开源项目:【github】 中已收录,里面包含不同方向的自学Android路线、面试题集合/面经、及系列技术文章等,资源持续更新中...
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
网友评论