Java内部类,相信大家都用过,但是多数同学可能对它了解的并不深入,只是靠记忆来完成日常工作,却不能融会贯通,遇到奇葩问题更是难以有思路去解决。这篇文章带大家一起死磕Java内部类的方方面面。 友情提示:这篇文章的讨论基于JDK版本 1.8.0_191

开篇问题

我一直觉得技术是工具,是一定要落地的,要切实解决某些问题的,所以我们通过先抛出问题,然后解决这些问题,在这个过程中来加深理解,最容易有收获。 so,先抛出几个问题。(如果这些问题你早已思考过,答案也了然于胸,那恭喜你,这篇文章可以关掉了)。

  • 为什么需要内部类?
  • 为什么内部类(包括匿名内部类、局部内部类),会持有外部类的引用?
  • 为什么匿名内部类使用到外部类方法中的局部变量时需要是final类型的?
  • 如何创建内部类实例,如何继承内部类?
  • Lambda表达式是如何实现的?

为什么需要内部类?

要回答这个问题,先要弄明白什么是内部类?我们知道Java有三种类型的内部类

普通的内部类

  1. public class Demo {
  2. // 普通内部类
  3. public class DemoRunnable implements Runnable {
  4. @Override
  5. public void run() {
  6. }
  7. }
  8. }
  9. 复制代码

匿名内部类

  1. public class Demo {
  2. // 匿名内部类
  3. private Runnable runnable = new Runnable() {
  4. @Override
  5. public void run() {
  6. }
  7. };
  8. }
  9. 复制代码

方法内局部内部类

  1. public class Demo {
  2. // 局部内部类
  3. public void work() {
  4. class InnerRunnable implements Runnable {
  5. @Override
  6. public void run() {
  7. }
  8. }
  9. InnerRunnable runnable = new InnerRunnable();
  10. }
  11. }
  12. 复制代码

这三种形式的内部类,大家肯定都用过,但是技术在设计之初肯定也是要用来解决某个问题或者某个痛点,那可以想想内部类相对比外部定义类有什么优势呢? 我们通过一个小例子来做说明

  1. public class Worker {
  2. private List<Job> mJobList = new ArrayList<>();
  3. public void addJob(Runnable task) {
  4. mJobList.add(new Job(task));
  5. }
  6. private class Job implements Runnable {
  7. Runnable task;
  8. public Job(Runnable task) {
  9. this.task = task;
  10. }
  11. @Override
  12. public void run() {
  13. runnable.run();
  14. System.out.println("left job size : " + mJobList.size());
  15. }
  16. }
  17. }
  18. 复制代码

定义了一个Worker类,暴露了一个addJob方法,一个参数task,类型是Runnable,然后定义 了一个内部类Job类对task进行了一层封装,这里Job是私有的,所以外界是感知不到Job的存在的,所以有了内部类第一个优势。

  • 内部类能够更好的封装,内聚,屏蔽细节

我们在Job的run方法中,打印了外部Worker的mJobList列表中剩余Job数量,代码这样写没问题,但是细想,内部类是如何拿到外部类的成员变量的呢?这里先卖个关子,但是已经可以先得出内部类的第二个优势了。

  • 内部类天然有访问外部类成员变量的能力

内部类主要就是上面的二个优势。当然还有一些其他的小优点,比如可以用来实现多重继承,可以将逻辑内聚在一个类方便维护等,这些见仁见智,先不去说它们。

我们接着看第二个问题!!!

为什么内部类(包括匿名内部类、局部内部类),会持有外部类的引用?

问这个问题,显得我是个杠精,您先别着急,其实我想问的是,内部类Java是怎么实现的。 我们还是举例说明,先以普通的内部类为例

普通内部类的实现

  1. public class Demo {
  2. // 普通内部类
  3. public class DemoRunnable implements Runnable {
  4. @Override
  5. public void run() {
  6. }
  7. }
  8. }
  9. 复制代码

切到Demo.java所在文件夹,命令行执行 javac Demo.java,在Demo类同目录下可以看到生成了二个class文件

Demo.class很好理解,另一个 类

  1. Demo$DemoRunnable.class
  2. 复制代码

就是我们的内部类编译出来的,它的命名也是有规律的,外部类名Demo+$+内部类名DemoRunnable。 查看反编译后的代码(IntelliJ IDEA本身就支持,直接查看class文件即可)

  1. package inner;
  2. public class Demo$DemoRunnable implements Runnable {
  3. public Demo$DemoRunnable(Demo var1) {
  4. this.this$0 = var1;
  5. }
  6. public void run() {
  7. }
  8. }
  9. 复制代码

生成的类只有一个构造器,参数就是Demo类型,而且保存到内部类本身的this$0字段中。到这里我们其实已经可以想到,内部类持有的外部类引用就是通过这个构造器传递进来的,它是一个强引用。

验证我们的想法

怎么验证呢?我们需要在Demo.class类中加一个方法,来实例化这个DemoRunnable内部类对象

  1. // Demo.java
  2. public void run() {
  3. DemoRunnable demoRunnable = new DemoRunnable();
  4. demoRunnable.run();
  5. }
  6. 复制代码

再次执行 javac Demo.java,再执行javap -verbose Demo.class,查看Demo类的字节码,前方高能,需要一些字节码知识,这里我们重点关注run方法(插一句题外话,字节码简单的要能看懂,-。-)

  1. public void run();
  2. descriptor: ()V
  3. flags: ACC_PUBLIC
  4. Code:
  5. stack=3, locals=2, args_size=1
  6. 0: new #2 // class inner/Demo$DemoRunnable
  7. 3: dup
  8. 4: aload_0
  9. 5: invokespecial #3 // Method inner/Demo$DemoRunnable."<init>":(Linner/Demo;)V
  10. 8: astore_1
  11. 9: aload_1
  12. 10: invokevirtual #4 // Method inner/Demo$DemoRunnable.run:()V
  13. 13: return
  14. 复制代码
  • 先通过new指令,新建了一个Demo$DemoRunnable对象
  • aload_0指令将外部类Demo对象自身加载到栈帧中
  • 调用Demo$DemoRunnable类的init方法,注意这里将Demo对象作为了参数传递进来了

到这一步其实已经很清楚了,就是将外部类对象自身作为参数传递给了内部类构造器,与我们上面的猜想一致。

匿名内部类的实现

  1. public class Demo {
  2. // 匿名内部类
  3. private Runnable runnable = new Runnable() {
  4. @Override
  5. public void run() {
  6. }
  7. };
  8. }
  9. 复制代码

同样执行javac Demo.java,这次多生成了一个Demo$1.class,反编译查看代码

  1. package inner;
  2. class Demo$1 implements Runnable {
  3. Demo$1(Demo var1) {
  4. this.this$0 = var1;
  5. }
  6. public void run() {
  7. }
  8. }
  9. 复制代码

可以看到匿名内部类和普通内部类实现基本一致,只是编译器自动给它拼了个名字,所以匿名内部类不能自定义构造器,因为名字编译完成后才能确定。 方法局部内部类,我这里就不赘述了,原理都是一样的,大家可以自行试验。 这样我们算是解答了第二个问题,来看第三个问题。

为什么匿名内部类使用到外部类方法中的局部变量时需要是final类型的?

这里先申明一下,这个问题本身是有问题的,问题在哪呢?因为java8中并不一定需要声明为final。我们来看个例子

  1. // Demo.java
  2. public void run() {
  3. int age = 10;
  4. Runnable runnable = new Runnable() {
  5. @Override
  6. public void run() {
  7. int myAge = age + 1;
  8. System.out.println(myAge);
  9. }
  10. };
  11. }
  12. 复制代码

匿名内部类对象runnable,使用了外部类方法中的age局部变量。编译运行完全没问题,而age并没有final修饰啊! 那我们再在run方法中,尝试修改age试试

  1. public void run() {
  2. int age = 10;
  3. Runnable runnable = new Runnable() {
  4. @Override
  5. public void run() {
  6. int myAge = age + 1;
  7. System.out.println(myAge);
  8. age = 20; // error
  9. }
  10. };
  11. }
  12. 复制代码

编译器报错了,提示信息是”age is access from inner class, need to be final or effectively final“。很显然编译器很智能,由于我们第一个例子并没有修改age的值,所以编译器认为这是effectively final,是安全的,可以编译通过,而第二个例子尝试修改age的值,编译器立马就报错了。

外部类变量是怎么传递给内部类的?

这里对于变量的类型分三种情况分别来说明

非final局部变量

我们去掉尝试修改age的代码,然后执行javac Demo.java,查看Demo$1.class的实现代码

  1. package inner;
  2. class Demo$1 implements Runnable {
  3. Demo$1(Demo var1, int var2) {
  4. this.this$0 = var1;
  5. this.val$age = var2;
  6. }
  7. public void run() {
  8. int var1 = this.val$age + 1;
  9. System.out.println(var1);
  10. }
  11. }
  12. 复制代码

可以看到对于非final局部变量,是通过构造器的方式传递进来的。

final局部变量

age修改为final

  1. public void run() {
  2. final int age = 10;
  3. Runnable runnable = new Runnable() {
  4. @Override
  5. public void run() {
  6. int myAge = age + 1;
  7. System.out.println(myAge);
  8. }
  9. };
  10. }
  11. 复制代码

同样执行javac Demo.java,查看Demo$1.class的实现代码

  1. class Demo$1 implements Runnable {
  2. Demo$1(Demo var1) {
  3. this.this$0 = var1;
  4. }
  5. public void run() {
  6. byte var1 = 11;
  7. System.out.println(var1);
  8. }
  9. }
  10. 复制代码

可以看到编译器很聪明的做了优化,age是final的,所以在编译期间是确定的,直接将+1优化为11。 为了测试编译器的智商,我们把age的赋值修改一下,改为运行时才能确定的,看编译器如何应对

  1. public void run() {
  2. final int age = (int) System.currentTimeMillis();
  3. Runnable runnable = new Runnable() {
  4. @Override
  5. public void run() {
  6. int myAge = age + 1;
  7. System.out.println(myAge);
  8. }
  9. };
  10. }
  11. 复制代码

再看Demo$1 字节码实现

  1. class Demo$1 implements Runnable {
  2. Demo$1(Demo var1, int var2) {
  3. this.this$0 = var1;
  4. this.val$age = var2;
  5. }
  6. public void run() {
  7. int var1 = this.val$age + 1;
  8. System.out.println(var1);
  9. }
  10. }
  11. 复制代码

编译器意识到编译期age的值不能确定,所以还是采用构造器传参的形式实现。现代编译器还是很机智的。

外部类成员变量

将age改为Demo的成员变量,注意没有加任何修饰符,是包级访问级别。

  1. public class Demo {
  2. int age = 10;
  3. public void run() {
  4. Runnable runnable = new Runnable() {
  5. @Override
  6. public void run() {
  7. int myAge = age + 1;
  8. System.out.println(myAge);
  9. age = 20;
  10. }
  11. };
  12. }
  13. }
  14. 复制代码

javac Demo.java,查看匿名内部内的实现

  1. class Demo$1 implements Runnable {
  2. Demo$1(Demo var1) {
  3. this.this$0 = var1;
  4. }
  5. public void run() {
  6. int var1 = this.this$0.age + 1;
  7. System.out.println(var1);
  8. this.this$0.age = 20;
  9. }
  10. }
  11. 复制代码

这一次编译器直接通过外部类的引用操作age,没毛病,由于age是包访问级别,所以这样是最高效的。 如果将age改为private,编译器会在Demo类中生成二个方法,分别用于读取age和设置age,篇幅关系,这种情况留给大家自行测试。

解答为何局部变量传递给匿名内部类需要是final?

通过上面的例子可以看到,不是一定需要局部变量是final的,但是你不能在匿名内部类中修改外部局部变量,因为Java对于匿名内部类传递变量的实现是基于构造器传参的,也就是说如果允许你在匿名内部类中修改值,你修改的是匿名内部类中的外部局部变量副本,最终并不会对外部类产生效果,因为已经是二个变量了。 这样就会让程序员产生困扰,原以为修改会生效,事实上却并不会,所以Java就禁止在匿名内部类中修改外部局部变量。

如何创建内部类实例,如何继承内部类?

由于内部类对象需要持有外部类对象的引用,所以必须得先有外部类对象

  1. Demo.DemoRunnable demoRunnable = new Demo().new DemoRunnable();
  2. 复制代码

那如何继承一个内部类呢,先给出示例

  1. public class Demo2 extends Demo.DemoRunnable {
  2. public Demo2(Demo demo) {
  3. demo.super();
  4. }
  5. @Override
  6. public void run() {
  7. super.run();
  8. }
  9. }
  10. 复制代码

必须在构造器中传入一个Demo对象,并且还需要调用demo.super(); 看个例子

  1. public class DemoKata {
  2. public static void main(String[] args) {
  3. Demo2 demo2 = new DemoKata().new Demo2(new Demo());
  4. }
  5. public class Demo2 extends Demo.DemoRunnable {
  6. public Demo2(Demo demo) {
  7. demo.super();
  8. }
  9. @Override
  10. public void run() {
  11. super.run();
  12. }
  13. }
  14. }
  15. 复制代码

由于Demo2也是一个内部类,所以需要先new一个DemoKata对象。 这一个问题描述的场景可能用的并不多,一般也不这么去用,这里提一下,大家知道有这么回事就行。

Lambda表达式是如何实现的?

Java8引入了Lambda表达式,一定程度上可以简化我们的代码,使代码结构看起来更优雅。做技术的还是要有刨根问底的那股劲,问问自己有没有想过Java中Lambda到底是如何实现的呢?

来看一个最简单的例子

  1. public class Animal {
  2. public void run(Runnable runnable) {
  3. }
  4. }
  5. 复制代码

Animal类中定义了一个run方法,参数是一个Runnable对象,Java8以前,我们可以传入一个匿名内部类对象

  1. run(new Runnable() {
  2. @Override
  3. public void run() {
  4. }
  5. });
  6. 复制代码

Java 8 之后编译器已经很智能的提示我们可以用Lambda表达式来替换。既然可以替换,那匿名内部类和Lambda表达式是不是底层实现是一样的呢,或者说Lambda表达式只是匿名内部类的语法糖呢? 要解答这个问题,我们还是要去字节码中找线索。通过前面的知识,我们知道javac Animal.java命令将类编译成class,匿名内部类的方式会产生一个额外的类。那用Lambda表达式会不会也会编译新类呢?我们试一下便知。

  1. public void run(Runnable runnable) {
  2. }
  3. public void test() {
  4. run(() -> {});
  5. }
  6. 复制代码

javac Animal.java,发现并没有生成额外的类!!! 我们继续使用javap -verbose Animal.class来查看Animal.class的字节码实现,重点关注test方法

  1. public void test();
  2. descriptor: ()V
  3. flags: ACC_PUBLIC
  4. Code:
  5. stack=2, locals=1, args_size=1
  6. 0: aload_0
  7. 1: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
  8. 6: invokevirtual #3 // Method run:(Ljava/lang/Runnable;)V
  9. 9: return
  10. SourceFile: "Demo.java"
  11. InnerClasses:
  12. public static final #34= #33 of #37; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
  13. BootstrapMethods:
  14. 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;
  15. Method arguments:
  16. #19 ()V
  17. #20 invokestatic com/company/inner/Demo.lambda$test$0:()V
  18. #19 ()V
  19. 复制代码

发现test方法字节码中多了一个invokedynamic #2 0指令,这是java7引入的新指令,其中#2 指向

  1. #2 = InvokeDynamic #0:#21 // #0:run:()Ljava/lang/Runnable;
  2. 复制代码

而0代表BootstrapMethods方法表中的第一个,java/lang/invoke/LambdaMetafactory.metafactory方法被调用。

  1. BootstrapMethods:
  2. 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;
  3. Method arguments:
  4. #19 ()V
  5. #20 invokestatic com/company/inner/Demo.lambda$test$0:()V
  6. #19 ()V
  7. 复制代码

这里面我们看到了com/company/inner/Demo.lambda$test$0这么个东西,看起来跟我们的匿名内部类的名称有些类似,而且中间还有lambda,有可能就是我们要找的生成的类。 我们不妨验证下我们的想法,可以通过下面的代码打印出Lambda对象的真实类名。

  1. public void run(Runnable runnable) {
  2. System.out.println(runnable.getClass().getCanonicalName());
  3. }
  4. public void test() {
  5. run(() -> {});
  6. }
  7. 复制代码

打印出runnable的类名,结果如下

  1. com.company.inner.Demo$$Lambda$1/764977973
  2. 复制代码

跟我们上面的猜测并不完全一致,我们继续找别的线索,既然我们有看到LambdaMetafactory.metafactory这个类被调用,不妨继续跟进看下它的实现

  1. public static CallSite metafactory(MethodHandles.Lookup caller,
  2. String invokedName,
  3. MethodType invokedType,
  4. MethodType samMethodType,
  5. MethodHandle implMethod,
  6. MethodType instantiatedMethodType)
  7. throws LambdaConversionException {
  8. AbstractValidatingLambdaMetafactory mf;
  9. mf = new InnerClassLambdaMetafactory(caller, invokedType,
  10. invokedName, samMethodType,
  11. implMethod, instantiatedMethodType,
  12. false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
  13. mf.validateMetafactoryArgs();
  14. return mf.buildCallSite();
  15. }
  16. 复制代码

内部new了一个InnerClassLambdaMetafactory对象。看名字很可疑,继续跟进

  1. public InnerClassLambdaMetafactory(...)
  2. throws LambdaConversionException {
  3. //....
  4. lambdaClassName = targetClass.getName().replace('.', '/') + "$$Lambda$" + counter.incrementAndGet();
  5. cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
  6. //....
  7. }
  8. 复制代码

省略了很多代码,我们重点看lambdaClassName这个字符串(通过名字就知道是干啥的),可以看到它的拼接结果跟我们上面打印的Lambda类名基本一致。而下面的ClassWriter也暴露了,其实Lambda运用的是Asm字节码技术,在运行时生成类文件。我感觉到这里就差不多了,再往下可能就有点太过细节了。-。-

Lambda实现总结

所以Lambda表达式并不是匿名内部类的语法糖,它是基于invokedynamic指令,在运行时使用ASM生成类文件来实现的。

死磕Java内部类的更多相关文章

  1. 死磕 java集合之DelayQueue源码分析

    问题 (1)DelayQueue是阻塞队列吗? (2)DelayQueue的实现方式? (3)DelayQueue主要用于什么场景? 简介 DelayQueue是java并发包下的延时阻塞队列,常用于 ...

  2. 死磕 java并发包之LongAdder源码分析

    问题 (1)java8中为什么要新增LongAdder? (2)LongAdder的实现方式? (3)LongAdder与AtomicLong的对比? 简介 LongAdder是java8中新增的原子 ...

  3. 死磕 java同步系列之AQS起篇

    问题 (1)AQS是什么? (2)AQS的定位? (3)AQS的实现原理? (4)基于AQS实现自己的锁? 简介 AQS的全称是AbstractQueuedSynchronizer,它的定位是为Jav ...

  4. 死磕 java同步系列之CyclicBarrier源码解析——有图有真相

    问题 (1)CyclicBarrier是什么? (2)CyclicBarrier具有什么特性? (3)CyclicBarrier与CountDownLatch的对比? 简介 CyclicBarrier ...

  5. 死磕 java同步系列之Phaser源码解析

    问题 (1)Phaser是什么? (2)Phaser具有哪些特性? (3)Phaser相对于CyclicBarrier和CountDownLatch的优势? 简介 Phaser,翻译为阶段,它适用于这 ...

  6. 死磕 java同步系列之zookeeper分布式锁

    问题 (1)zookeeper如何实现分布式锁? (2)zookeeper分布式锁有哪些优点? (3)zookeeper分布式锁有哪些缺点? 简介 zooKeeper是一个分布式的,开放源码的分布式应 ...

  7. 死磕 java线程系列之线程池深入解析——普通任务执行流程

    (手机横屏看源码更方便) 注:java源码分析部分如无特殊说明均基于 java8 版本. 注:线程池源码部分如无特殊说明均指ThreadPoolExecutor类. 简介 前面我们一起学习了Java中 ...

  8. 死磕 java线程系列之线程池深入解析——定时任务执行流程

    (手机横屏看源码更方便) 注:java源码分析部分如无特殊说明均基于 java8 版本. 注:本文基于ScheduledThreadPoolExecutor定时线程池类. 简介 前面我们一起学习了普通 ...

  9. 死磕 java同步系列之StampedLock源码解析

    问题 (1)StampedLock是什么? (2)StampedLock具有什么特性? (3)StampedLock是否支持可重入? (4)StampedLock与ReentrantReadWrite ...

随机推荐

  1. AtCoder Grand Contest 014

    AtCoder Grand Contest 014 A - Cookie Exchanges 有三个人,分别有\(A,B,C\)块饼干,每次每个人都会把自己的饼干分成相等的两份然后给其他两个人.当其中 ...

  2. Queue接口分析:add和offer区别,remove和poll方法到底啥区别

    Queue接口: public interface Queue<E> extends Collection<E> { /* * add方法,在不违背队列的容量限制的情况,往队列 ...

  3. SQL Server merge用法

    有两个表名:source 表和 target 表,并且要根据 source 表中匹配的值更新 target 表. 有三种情况: source 表有一些 target 表不存在的行.在这种情况下,需要将 ...

  4. c#专业的UVC摄像头深控类库-SharpCamera介绍

    SharpCamera是专业的UVC摄像头深控类库.允许您在C#代码内修改摄像头的高级参数,比如亮度.对比度.清晰度.色调.饱和度.伽玛值.白平衡.逆光对比.增益.缩放.焦点.曝光.光圈.全景.倾斜. ...

  5. lift提升图

    Lift图衡量的是,与不利用模型相比,模型的预测能力“变好”了多少,lift(提升指数)越大,模型的运行效果越好. TP:划一个阈值后的正样本. P:总体的正样本. 在模型评估中,我们常用到增益/提升 ...

  6. JS删除指定下标的元素

    在开发过程中,有时我们需要删除数组中某一下标的元素.JAVA中ArrayList有remove函数.但是在JavaScript中没有直接的删除方法.我们可以利用splice来实现.Array.spli ...

  7. 转:Windows系统环境下安装dlib

    原文链接 因为今天安装Face Recognition,需要先按照 dlib .需要在windows环境下做一些图片处理,所以需要在pycharm中配置环境,而其中需要的主要是dlib的安装: 下面说 ...

  8. 【转载】c# datatable 判断值是否存在

    在C#的数据表格DataTable操作过程中,有时候在操作DataTable前需要判断DataTable中的值是否存在,此时首选需要判断DataTable是否为null值,而后在判断DataTable ...

  9. xss学习

    1.了解xss的定义 2.理解xss的原理:反射型和存储型 3.理解xss的攻击方式 4.掌握xss的防御措施

  10. 外汇盈利EA

    >>>>>>>>>>>>>>>>>>>>>>>>> ...