查看原文
其他

分享几个“装逼”知识点

鸿洋 鸿洋 2021-10-13

本文源于wanandroid 每日一问。


下面这篇文章,将会突出几个关键词: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文件?


我们分析下:


  1. A.class 肯定有

  2. 有个静态内部类B,还有个A$B.class


两个。


实际运行你会发现有三个:



  1. A.class

  2. A$B.class

  3. 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
       4return

  public static void main(java.lang.String[]);
    Code:
       0new           #2                  // class A$B
       3: dup
       4: aconst_null
       5: invokespecial #3                  // Method A$B."<init>":(LA$1;)V   // 在这里调用
       8: astore_1
       9return
}

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,继续。


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的展现形式:


  1. String getName(String str)

  2. 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(当然了这个方法无法在代码中直接调用到,所以是看不到这种场景的)。


4总结


能看到这里,恭喜你,大概率你是有收获的。


你可能会迷惑,为什么要掌握这些底层知识,好像我们平时开发时候完全用不到吧。


其实怎么说呢,说用不到,可能还没有遇到相关的场景,当你知道编译器编译期间会增加一些类、方法,假设你有修改字节码的需求的时候,你就可以评估到这些生成的类、方法带来的影响。


甚至,当我们在反射某个类里面方法的时候,也要注意避开编译器生成的桥接方法。


例如上例,Dog 里面在我们看来只有一个test 方法:


class Dog implements Animal<String>{

    @override
    public void test(String str){
    }
}


实际上你通过反射去取,test 方法包含两个,如果不注意就可能取错。


其次,今天这三个点,如果平时注意发现或者思考,应该也会有这样的困惑,这几个点都是有违我们平时开发中的知识的。


后续发现奇怪的东西,就尝试探索破解吧,说不定发现新大陆呢。




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!



推荐阅读

我是怎么把业务代码越写越复杂的?

App黑白化实现扩展,一键护眼模式

新技术又又又又又又来了?




扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存