匿名内部类访问局部变量为什么要加final修饰(JDK1.7及其之前)

1,813 阅读6分钟

1.内部类概述

  1. 内部类:一个类A定义在另一个类B的内部,这个类A叫做内部类,相应的类B叫做外部类。其中,内部类又可以分为成员内部类和局部内部类。

    • 成员内部类:定义在外部类中,与其他外部类中的属性,方法同级。
    • 局部内部类:定义在外部类的方法里面。
  2. 内部类可以访问外部类中的成员属性和方法。

  3. 内部类生成的class文件:

    内部类在编译完成之后,所产生的class文件名:外部类名$内部类名.class

    匿名内部类在编译完成之后,所产生的文件名为:外部类名$编号.class

  4. 测试代码

    public class AnonymousSubclassTest {//外部类
    
        public String ANIMAL = "动物";
    
        public void accessTest() {
            System.out.println("匿名内部类访问其外部类方法");
        }
    
        public void print() {
            bird.printAnimalName();
        }
        
        //内部类Animal
        class Animal {
    
            private String name;
    
            public Animal (String name) {
                this.name = name;
            }
    
            public void printAnimalName () {
                // 所以可以在这里用bird实例
                System.out.println(bird.name);
            }
        }
    
        //匿名内部类1
        Animal bird = new Animal("布谷鸟"){
            public int age = 12;
            @Override
            public void printAnimalName() {
                accessTest(); 
                System.out.println(ANIMAL); 
                super.printAnimalName();
            }
        };
    
        //匿名内部类2
        Animal Dog = new Animal("大黄"){
            @Override
            public void printAnimalName() {
                super.printAnimalName();
            }
        };
    }
    

    下图为编译完成后所产生的4个字节码文件:javac -encoding utf-8 AnonymousSubclassTest.java

    image-20211201233401200

2.匿名内部类

2.1.概念

  1. 匿名内部类的本质就是在某个类或者接口的基础上,定义一个(继承该类或者实现该接口的)的子类并同时并实例化一个对象。

  2. 一般的语法是:ClassName className = new ClassName( ){ };,其中ClassName是父类名或者接口名。在{ }中做匿名类的定义。在做定义的时候,可以通过新增属性,方法,代码块来制定一些自己想要的功能。

  3. 代码演示:

    public class AnonymousDemo1 {//外部类
        
        //定义一个匿名内部类,继承了父类Thread,并实例化一个该匿名内部类的对象
        Thread thread1 = new Thread("t1"){
            @Override
            public void run() {
                super.run();
            }
        };
    
        //定义一个匿名类内部类,实现了Runnable接口,并实例化一个该匿名内部类的对象
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                while (dog.getAge() < 10) {
                    dog.happyBirthday();
                    System.out.println(dog.getAge());
                }
            }
        };
    }
    

2.2.适用场景

  • 1.因为类的定义和对象的实例化是一步写出来的,类的定义没有专门拿出来。这样的写法,当实例对象被销毁时,类的定义也随着销毁。适用于只使用一次该匿名类的场景。

    当我们只想要使用一次某个功能时,同时发现这个功能需要依赖一个父类或者接口,但是我又不想通过继承父类或者实现接口来定义一个新的类放在那。那么就可以定制这样一个临时类。当这个功能用完后这个类就没用了,没有存在的必要了。此时就可以使用匿名内部类来实现,当实例对象销毁后,匿名类的定义也随之销毁,它很适合只使用一次该匿名类的场景。

  • 2.当你想用一个类的protected方法时,但是又不和这个类在同一个包下,你是没有办法直接调用的。这个时候匿名类就派上用场了。

    你可以声明一个匿名类继承该类,并定义一个方法,在这个方法内时使用super调用你想调用的那个protected方法。本质上就是写了一个类继承了这个类,然后调用父类的方法。但是我只想调用这个方法,只会在调这个方法的时候才使用这个类,所以如果只有这一个用途的话,用匿名内部类来实现会非常简洁,因为只是想调用这个方法而已,没必要在定义一个类。

2.3.特点

  • 1.同时定义出匿名内部类和实例化一个匿名内部类的实例。

  • 2.所以前提要有一个具体父类或者接口,匿名内部类的本质就是一个继承了父类的匿名子类或者实现了接口的匿名子类。

  • 3.匿名内部类可以访问外部类的所有成员变量和方法。内部类为什么能访问外部类的属性和方法,通过反编译或者反汇编可以看到,内部类有一个有参构造,其中的一个参数就是外部类实例,这个外部类实例参数被赋值给内部类的一个属性。所以内部类是通过外部类实例对象来访问外部类的成员属性和方法的。匿名内部类也一样(后面又介绍)。

  • 4.匿名内部类没有类名,也意味着没有构造,或者说无法显式的调用其构造。每个匿名内部类都会生成一个class文件,文件名为:外部类名$编号.class。反编译或者反汇编后,可以看到匿名内部类的类名就是class文件名,并且也有构造函数。(通过反汇编或者反编译是可以看到匿名内部类是有类名的,后面有介绍)。

  • 5.得到的匿名内部类的实例:是父类或者接口的引用指向这个匿名内部类实例对象,所以只能访问父类或者接口中定义的属性方法。而且由于匿名内部类的语法,它是没有显式类名的,所以也没法做向下转型。无法通过匿名内部类对象访问在匿名内部类中定义的属性和新的方法(可以访问重写的方法)

    代码演示:

    public class AnonymousSubclassTest {
    
        public String ANIMAL = "动物";
    
        public void print() {
            bird.printAnimalName();
        }
    
        public void accessTest() {
            System.out.println("匿名内部类访问其外部类方法");
        }
    
        class Animal {
            private String name;
            public Animal (String name) {
                this.name = name;
            }
            public void printAnimalName () {
                System.out.println(bird.name);
            }
        }
    
        //bird虽然实际上是Animal的匿名子类,但是使用父类Animal的引用指向的实例,所以无法通过bird.age访问age属性
        Animal bird = new Animal("布谷鸟"){
            //匿名内部类定义的age属性
            public int age = 12;
            @Override
            public void printAnimalName() {
                accessTest();
                System.out.println(ANIMAL);
                super.printAnimalName();
            }
        };
    
        public static void main(String[] args) {
            AnonymousSubclassTest subclassTest = new AnonymousSubclassTest();
            System.out.println(subclassTest.bird.name);
            /**
             * 注意这里为什么会报错:
             * 解析不出来age变量,Cannot resolve symbol 'age'
             * 因为bird虽然实际上是Animal的匿名子类,并且其中有age属性,但是创建bird实例时,使用Animal引用指向的
             * 所以,bird实例只能访问到Animal中定义的属性和方法和外部类AnonymousSubclassTest中的属性和方法
             *
             * 而且这还是个无解的问题:因为bird虽然本质上是Animal的子类,我们可能会想到向下转型,然后再访问其中的属性。
             * 但是它是一个匿名内部类,没有名字,自然就没法转型,进而无法用类名bird.age访问其中的属性。
             *
             */
            //System.out.println(subclassTest.bird.age);
        }
    }
    

3.为什么匿名内部类访问局部变量要加final修饰(JDK1.7及其之前)

  • 当使用匿名内部类的时候,如果匿名内部类需要访问匿名内部类所在成员方法中的局部变量的时候,必须给局部变量加final进行修饰(JDK1.7及其之前)。

    public class AnonymousDemo1 {
        public static void main(String args[]) {
            new AnonymousDemo1().play();
        }
    
        private void play() {
    
            //成员方法中的局部变量
            final Dog dog = new Dog();
    
            //在外部类的成员方法中创建了一个匿名内部类及其实例,该匿名内部类实现了Runnable接口
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    while (dog.getAge() < 10) {
                        dog.happyBirthday();
                        System.out.println(dog.getAge());
                    }
                }
            };
    
            new Thread(runnable).start();
        }
    }
    

3.1.说法一

​ 内部类对象的生命周期与局部变量的生命周期不一致

  • 1.这里所说的匿名内部类主要是指在其外部类的成员方法内定义的同时完成实例化的匿名内部类,若其访问外部类的成员方法中的局部变量时,局部变量必须要被final修饰。原因是编译器实现上的困难:内部类对象的生命周期很有可能会超过局部变量的声明周期

  • 2.局部变量的生命周期和内部类对象的生命周期:

    当所在的成员方法被调用时,该方法中的局部变量在栈中被创建,当方法调用结束后,退栈,这些局部变量全部死亡。而内部类对象的生命周期与其他类对象一样:当创建一个匿名内部类对象,系统为该对象分配内存,直到没有引用变量指向分配给该对象的内存时,它才有可能死亡(被JVM回收)。所以完全可能出现一种情况:成员方法已经调用结束,局部变量已经死亡,但匿名对象任然活着,而且匿名对象还在访问者局部变量。

  • 如果匿名内部类的对象访问了同一个方法中的局部变量,就要求只要匿名内部类对象还活着,那么栈中的那些它要访问的局部变量就不能”死亡“。

  • 解决办法:匿名内部类对象可以访问同一个方法中被定义为final类型的局部变量。定义为final后,编译器会把匿名内部类对象要访问的所有final类型得到局部变量,都拷贝一份作为该对象的成员变量。这样,即使栈中局部变量已经死亡,不会影响到匿名内部类中的成员变量,而且该成员变量是final修饰的不可变的。

3.2.说法二

匿名内部类经过编译后,内部类里用的变量和成员方法中的局部变量不是同一个,两个变量无法做到同步变更,所以编译器才要求我们给局部变量加上final,防止不同步的情况发生。

  1. 什么意思:先看代码(JDK1.7编译时,假设不用给局部变量加上final修饰也可以编译通过)

    public class AnonymousDemo1 {
        
        public static void main(String args[]) {
            new AnonymousDemo1().play();
        }
    
        //外部类的成员方法
        private void play() {
            
    		//成员方法中的局部变量
            Dog dog = new Dog();
    
            //定义一个匿名内部类,并创建一个该匿名内部类的对象
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    while (dog.getAge() < 10) {
                        dog.happyBirthday();
                        System.out.println(dog.getAge());
                    }
                }
            };
    
            new Thread(runnable).start();
        }
        
        //内部类Dog
        public class Dog {
            private int age;
            public int getAge() {
                return age;
            }
            public void setAge(int age) {
                this.age = age;
            }
            public void happyBirthday() {
                this.age++;
            }
        }
    }
    

    先说什么意思,就是成员方法中的局部变量dog和内部使用的dog,在经过编译后,不是同一个变量。

  2. 先将上述文件编译:得到3个class文件,注意class文件名。

    image-20211202223030483

    • 外部类编译得到的class文件名:AnonymousDemo1.class
    • 内部类Dog编译后得到的class文件名:AnonymousDemo1$Dog.class
    • 匿名内部类经过编译后得到的class文件名:AnonymousDemo1$1.class
  3. 复习一下java文件经过编译后,得到的class文件的命名规则:

    • class文件名只和类名严格相同!
    • 所以可能存在java文件名和类名不相同的情况:OK.java文件中,定义了一个Person类,那么经过编译后会得到一个Person.class文件
    • 如果你的Person类是public类,但是java文件名不是Person,编译不通过。
  4. 我们得到了匿名内部类的class文件而且class文件名是AnonymousDemo1$1.class,那么难道匿名内部类经过编译后有了类名,而且类名是外部类名$编号

    • 我们用javap反汇编匿名内部类的class文件看一下:javap -c AnonymousDemo1$1.class

    image-20211202224348362

    • 通过反汇编命令,我们可以看到,匿名内部类经过编译后生成了一个类名:AnonymousDemo1$1和一个构造函数AnonymousDemo1$1(AnonymousDemo1, AnonymousDemo1$Dog);

      其中参数1:就是外部类对象AnonymousDemo1

      参数2:就是外部类成员方法中的局部变量dog。

    • 通过两条putfield指令:发现有参构造的两个参数,赋值给了两个Field,Field就是字段属性的意思,this$0val$dog。所以匿名内部类其中肯定也有两个属性,分别是:

      this$0 = new AnonymousDemo1();//外部类对象
      val$dog = dog;   
      

      这里也可以看出为什么内部类可以访问外部类的成员和属性了,就是因为内部类维护了一个属性,这个属性的值是外部类对象。

  5. 那么这又能说明什么呢?

    • 局部变量dog和匿名内部类内部run()方法中的dog,本质上是两个引用指向同一个对象。

    image-20211202225240408

    • 那么就可能出现这样的情况:我可以在外部(成员方法中)改变dog的值

          //外部类的成员方法
          private void play() {
      
              Dog dog = new Dog();
      
              //定义一个匿名内部类,并创建一个该匿名内部类的对象
              Runnable runnable = new Runnable() {
                  @Override
                  public void run() {
                      while (dog.getAge() < 10) {
                          dog.happyBirthday();
                          System.out.println(dog.getAge());
                      }
                  }
              };
      
              new Thread(runnable).start();
              dog = new Dog(); //改变成员变量:
          }
      
    • 那么这样会造成什么样的结果呢?

      如果直接看代码:play()中的dog和run()中的dog,其中一个改变了肯定都会改变的,因为是一个变量。

      但是事实上:play()中的dog改变后,run()中用到的dog其实是匿名内部类的属性val$dog,它还是指向原来的dog对象,没有变化。

      结果:按照我们代码呈现的逻辑,两个dog同步改变;实际上两个dog没有做到同步变化。最后的代码结果一定是混乱的。

  6. 怎么办?

    • 最直接的办法:Java直接编译检查不通过,就是必须让dog的值不能变化,一直让它指向同一个对象。所以用final修饰。
    • 可以把局部变量放到外部类的成员变量中,内部类可以直接通过外部类对象访问到外部类的成员属性。
  7. 思考:为什么非要将局部变量,拷贝到匿名内部类的成员变量val$dog中呢?

    • 因为匿名内部类无法直接访问局部变量dog。

    • 考虑一下Java虚拟机的运行时数据区域:dog变量是位于方法内部的,因此dog局部变量是在虚拟机栈上,这也就意味着这个变量无法进行共享,匿名内部类也就无法直接访问。因此只能通过传递的方式,传递到匿名内部类中。

    • 那么有没有不需要拷贝的情形呢?

      把原来成员方法中的局部变量往外提,作为外部类的成员变量,此时dog成员变量就处在虚拟机里堆的位置上,那么匿名内部类就可以直接通过内部维护的外部类成员属性this$0,访问到外部类的成员属性。

3.3.JDK1.8引入的:effectively final

  • JDK1.8之后:编译器不要求匿名内部类访问局部变量时必须加上final了。但是有个前提条件,如果这个变量在后面的使用中没有发生变化,就可以通过编译。Java称这种情况为:effectively final

  • 虽然在JDK1.8之后,如果局部变量被匿名内部类访问,如果这个变量在后面的使用中没有发生变化,就可以通过编译。但是当你主动修改这个局部变量时,上面匿名内部类使用局部变量的地方,就会报错:'dog' is accessed from within inner class, needs to be final or effectively final

    image-20211202000344665

  • 代码演示:

    public class AnonymousDemo1 {
        public static void main(String args[]) {
          new AnonymousDemo1().play();
        }
    
        private void play() {
    
            Dog dog = new Dog();
    
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    while (dog.getAge() < 10) {
                        dog.happyBirthday();
                        System.out.println(dog.getAge());
                    }
              }
            };
    
            new Thread(runnable).start();
    
            /**
             * 当你主动修改这个局部变量是,上面匿名内部类使用局部变量的地方,又会报错:
             * 'dog' is accessed from within inner class, needs to be final or effectively final
             *
             * 变量加上final修饰后:
             *  如果这个变量是基本数据类型,那么它的值不能改变;
             *  如果这个变量是个指定对象的引用,那么它所指向的地址不能改变。
             */
            dog = new Dog(); 如果dog加上final修饰后,那么它的地址值(dog变量存储的是地址值)就不能改变了。
        }
    }