Java内部类,相信大家都用过,但是多数同学可能对它了解的并不深入,只是靠记忆来完成日常工作,却不能融会贯通,遇到奇葩问题更是难以有思路去解决。这篇文章带大家一起死磕Java内部类的方方面面。 友情提示:这篇文章的讨论基于JDK版本 1.8.0_191
我一直觉得技术是工具,是一定要落地的,要切实解决某些问题的,所以我们通过先抛出问题,然后解决这些问题,在这个过程中来加深理解,最容易有收获。 so,先抛出几个问题。(如果这些问题你早已思考过,答案也了然于胸,那恭喜你,这篇文章可以关掉了)。
要回答这个问题,先要弄明白什么是内部类?我们知道Java有三种类型的内部类
public class Demo { // 普通内部类 public class DemoRunnable implements Runnable { @Override public void run() { } } } 复制代码
public class Demo { // 匿名内部类 private Runnable runnable = new Runnable() { @Override public void run() { } }; } 复制代码
public class Demo { // 局部内部类 public void work() { class InnerRunnable implements Runnable { @Override public void run() { } } InnerRunnable runnable = new InnerRunnable(); } } 复制代码
这三种形式的内部类,大家肯定都用过,但是技术在设计之初肯定也是要用来解决某个问题或者某个痛点,那可以想想内部类相对比外部定义类有什么优势呢? 我们通过一个小例子来做说明
public class Worker { private List<Job> mJobList = new ArrayList<>(); public void addJob(Runnable task) { mJobList.add(new Job(task)); } private class Job implements Runnable { Runnable task; public Job(Runnable task) { this.task = task; } @Override public void run() { runnable.run(); System.out.println("left job size : " + mJobList.size()); } } } 复制代码
定义了一个Worker类,暴露了一个addJob方法,一个参数task,类型是Runnable,然后定义 了一个内部类Job类对task进行了一层封装,这里Job是私有的,所以外界是感知不到Job的存在的,所以有了内部类第一个优势。
我们在Job的run方法中,打印了外部Worker的mJobList列表中剩余Job数量,代码这样写没问题,但是细想,内部类是如何拿到外部类的成员变量的呢?这里先卖个关子,但是已经可以先得出内部类的第二个优势了。
内部类主要就是上面的二个优势。当然还有一些其他的小优点,比如可以用来实现多重继承,可以将逻辑内聚在一个类方便维护等,这些见仁见智,先不去说它们。
我们接着看第二个问题!!!
问这个问题,显得我是个杠精,您先别着急,其实我想问的是,内部类Java是怎么实现的。 我们还是举例说明,先以普通的内部类为例
public class Demo { // 普通内部类 public class DemoRunnable implements Runnable { @Override public void run() { } } } 复制代码
切到Demo.java所在文件夹,命令行执行 javac Demo.java,在Demo类同目录下可以看到生成了二个class文件
Demo.class很好理解,另一个 类
Demo$DemoRunnable.class 复制代码
就是我们的内部类编译出来的,它的命名也是有规律的,外部类名Demo+$+内部类名DemoRunnable。 查看反编译后的代码(IntelliJ IDEA本身就支持,直接查看class文件即可)
package inner; public class Demo$DemoRunnable implements Runnable { public Demo$DemoRunnable(Demo var1) { this.this$0 = var1; } public void run() { } } 复制代码
生成的类只有一个构造器,参数就是Demo类型,而且保存到内部类本身的this$0字段中。到这里我们其实已经可以想到,内部类持有的外部类引用就是通过这个构造器传递进来的,它是一个强引用。
怎么验证呢?我们需要在Demo.class类中加一个方法,来实例化这个DemoRunnable内部类对象
// Demo.java public void run() { DemoRunnable demoRunnable = new DemoRunnable(); demoRunnable.run(); } 复制代码
再次执行 javac Demo.java,再执行javap -verbose Demo.class,查看Demo类的字节码,前方高能,需要一些字节码知识,这里我们重点关注run方法(插一句题外话,字节码简单的要能看懂,-。-)
public void run(); descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=2, args_size=1 0: new #2 // class inner/Demo$DemoRunnable 3: dup 4: aload_0 5: invokespecial #3 // Method inner/Demo$DemoRunnable."<init>":(Linner/Demo;)V 8: astore_1 9: aload_1 10: invokevirtual #4 // Method inner/Demo$DemoRunnable.run:()V 13: return 复制代码
到这一步其实已经很清楚了,就是将外部类对象自身作为参数传递给了内部类构造器,与我们上面的猜想一致。
public class Demo { // 匿名内部类 private Runnable runnable = new Runnable() { @Override public void run() { } }; } 复制代码
同样执行javac Demo.java,这次多生成了一个Demo$1.class,反编译查看代码
package inner; class Demo$1 implements Runnable { Demo$1(Demo var1) { this.this$0 = var1; } public void run() { } } 复制代码
可以看到匿名内部类和普通内部类实现基本一致,只是编译器自动给它拼了个名字,所以匿名内部类不能自定义构造器,因为名字编译完成后才能确定。 方法局部内部类,我这里就不赘述了,原理都是一样的,大家可以自行试验。 这样我们算是解答了第二个问题,来看第三个问题。
这里先申明一下,这个问题本身是有问题的,问题在哪呢?因为java8中并不一定需要声明为final。我们来看个例子
// Demo.java public void run() { int age = 10; Runnable runnable = new Runnable() { @Override public void run() { int myAge = age + 1; System.out.println(myAge); } }; } 复制代码
匿名内部类对象runnable,使用了外部类方法中的age局部变量。编译运行完全没问题,而age并没有final修饰啊! 那我们再在run方法中,尝试修改age试试
public void run() { int age = 10; Runnable runnable = new Runnable() { @Override public void run() { int myAge = age + 1; System.out.println(myAge); age = 20; // error } }; } 复制代码
这里对于变量的类型分三种情况分别来说明
我们去掉尝试修改age的代码,然后执行javac Demo.java,查看Demo$1.class的实现代码
package inner; class Demo$1 implements Runnable { Demo$1(Demo var1, int var2) { this.this$0 = var1; this.val$age = var2; } public void run() { int var1 = this.val$age + 1; System.out.println(var1); } } 复制代码
可以看到对于非final局部变量,是通过构造器的方式传递进来的。
age修改为final
public void run() { final int age = 10; Runnable runnable = new Runnable() { @Override public void run() { int myAge = age + 1; System.out.println(myAge); } }; } 复制代码
同样执行javac Demo.java,查看Demo$1.class的实现代码
class Demo$1 implements Runnable { Demo$1(Demo var1) { this.this$0 = var1; } public void run() { byte var1 = 11; System.out.println(var1); } } 复制代码
可以看到编译器很聪明的做了优化,age是final的,所以在编译期间是确定的,直接将+1优化为11。 为了测试编译器的智商,我们把age的赋值修改一下,改为运行时才能确定的,看编译器如何应对
public void run() { final int age = (int) System.currentTimeMillis(); Runnable runnable = new Runnable() { @Override public void run() { int myAge = age + 1; System.out.println(myAge); } }; } 复制代码
再看Demo$1 字节码实现
class Demo$1 implements Runnable { Demo$1(Demo var1, int var2) { this.this$0 = var1; this.val$age = var2; } public void run() { int var1 = this.val$age + 1; System.out.println(var1); } } 复制代码
将age改为Demo的成员变量,注意没有加任何修饰符,是包级访问级别。
public class Demo { int age = 10; public void run() { Runnable runnable = new Runnable() { @Override public void run() { int myAge = age + 1; System.out.println(myAge); age = 20; } }; } } 复制代码
javac Demo.java,查看匿名内部内的实现
class Demo$1 implements Runnable { Demo$1(Demo var1) { this.this$0 = var1; } public void run() { int var1 = this.this$0.age + 1; System.out.println(var1); this.this$0.age = 20; } } 复制代码
这一次编译器直接通过外部类的引用操作age,没毛病,由于age是包访问级别,所以这样是最高效的。 如果将age改为private,编译器会在Demo类中生成二个方法,分别用于读取age和设置age,篇幅关系,这种情况留给大家自行测试。
通过上面的例子可以看到,不是一定需要局部变量是final的,但是你不能在匿名内部类中修改外部局部变量,因为Java对于匿名内部类传递变量的实现是基于构造器传参的,也就是说如果允许你在匿名内部类中修改值,你修改的是匿名内部类中的外部局部变量副本,最终并不会对外部类产生效果,因为已经是二个变量了。 这样就会让程序员产生困扰,原以为修改会生效,事实上却并不会,所以Java就禁止在匿名内部类中修改外部局部变量。
由于内部类对象需要持有外部类对象的引用,所以必须得先有外部类对象
Demo.DemoRunnable demoRunnable = new Demo().new DemoRunnable(); 复制代码
那如何继承一个内部类呢,先给出示例
public class Demo2 extends Demo.DemoRunnable { public Demo2(Demo demo) { demo.super(); } @Override public void run() { super.run(); } } 复制代码
必须在构造器中传入一个Demo对象,并且还需要调用demo.super(); 看个例子
public class DemoKata { public static void main(String[] args) { Demo2 demo2 = new DemoKata().new Demo2(new Demo()); } public class Demo2 extends Demo.DemoRunnable { public Demo2(Demo demo) { demo.super(); } @Override public void run() { super.run(); } } } 复制代码
由于Demo2也是一个内部类,所以需要先new一个DemoKata对象。 这一个问题描述的场景可能用的并不多,一般也不这么去用,这里提一下,大家知道有这么回事就行。
Java8引入了Lambda表达式,一定程度上可以简化我们的代码,使代码结构看起来更优雅。做技术的还是要有刨根问底的那股劲,问问自己有没有想过Java中Lambda到底是如何实现的呢?
来看一个最简单的例子
public class Animal { public void run(Runnable runnable) { } } 复制代码
Animal类中定义了一个run方法,参数是一个Runnable对象,Java8以前,我们可以传入一个匿名内部类对象
run(new Runnable() { @Override public void run() { } }); 复制代码
Java 8 之后编译器已经很智能的提示我们可以用Lambda表达式来替换。既然可以替换,那匿名内部类和Lambda表达式是不是底层实现是一样的呢,或者说Lambda表达式只是匿名内部类的语法糖呢? 要解答这个问题,我们还是要去字节码中找线索。通过前面的知识,我们知道javac Animal.java命令将类编译成class,匿名内部类的方式会产生一个额外的类。那用Lambda表达式会不会也会编译新类呢?我们试一下便知。
public void run(Runnable runnable) { } public void test() { run(() -> {}); } 复制代码
javac Animal.java,发现并没有生成额外的类!!! 我们继续使用javap -verbose Animal.class来查看Animal.class的字节码实现,重点关注test方法
public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: aload_0 1: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable; 6: invokevirtual #3 // Method run:(Ljava/lang/Runnable;)V 9: return SourceFile: "Demo.java" InnerClasses: public static final #34= #33 of #37; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles BootstrapMethods: 0: #18 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #19 ()V #20 invokestatic com/company/inner/Demo.lambda$test$0:()V #19 ()V 复制代码
发现test方法字节码中多了一个invokedynamic #2 0指令,这是java7引入的新指令,其中#2 指向
#2 = InvokeDynamic #0:#21 // #0:run:()Ljava/lang/Runnable; 复制代码
而0代表BootstrapMethods方法表中的第一个,java/lang/invoke/LambdaMetafactory.metafactory方法被调用。
BootstrapMethods: 0: #18 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #19 ()V #20 invokestatic com/company/inner/Demo.lambda$test$0:()V #19 ()V 复制代码
这里面我们看到了com/company/inner/Demo.lambda$test$0这么个东西,看起来跟我们的匿名内部类的名称有些类似,而且中间还有lambda,有可能就是我们要找的生成的类。 我们不妨验证下我们的想法,可以通过下面的代码打印出Lambda对象的真实类名。
public void run(Runnable runnable) { System.out.println(runnable.getClass().getCanonicalName()); } public void test() { run(() -> {}); } 复制代码
打印出runnable的类名,结果如下
com.company.inner.Demo$$Lambda$1/764977973 复制代码
跟我们上面的猜测并不完全一致,我们继续找别的线索,既然我们有看到LambdaMetafactory.metafactory这个类被调用,不妨继续跟进看下它的实现
public static CallSite metafactory(MethodHandles.Lookup caller, String invokedName, MethodType invokedType, MethodType samMethodType, MethodHandle implMethod, MethodType instantiatedMethodType) throws LambdaConversionException { AbstractValidatingLambdaMetafactory mf; mf = new InnerClassLambdaMetafactory(caller, invokedType, invokedName, samMethodType, implMethod, instantiatedMethodType, false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY); mf.validateMetafactoryArgs(); return mf.buildCallSite(); } 复制代码
内部new了一个InnerClassLambdaMetafactory对象。看名字很可疑,继续跟进
public InnerClassLambdaMetafactory(...) throws LambdaConversionException { //.... lambdaClassName = targetClass.getName().replace('.', '/') + "$$Lambda$" + counter.incrementAndGet(); cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); //.... } 复制代码
省略了很多代码,我们重点看lambdaClassName这个字符串(通过名字就知道是干啥的),可以看到它的拼接结果跟我们上面打印的Lambda类名基本一致。而下面的ClassWriter也暴露了,其实Lambda运用的是Asm字节码技术,在运行时生成类文件。我感觉到这里就差不多了,再往下可能就有点太过细节了。-。-
所以Lambda表达式并不是匿名内部类的语法糖,它是基于invokedynamic指令,在运行时使用ASM生成类文件来实现的。
这可能是我迄今写的最长的一篇技术文章了,写的过程中也在不断的加深自己对知识点的理解,颠覆了很多以往的错误认知。写技术文章这条路我会一直坚持下去。 非常喜欢得到里面的一句slogan,胡适先生说的话。 怕什么真理无穷,进一寸有一寸的欢喜 共勉!