jdk7之前,匿名内部类访问局部变量加final修饰的问题(综合两种说法)

当使用匿名内部类的时候,如果匿名内部类需要访问匿名内部类所在方法中的局部变量的时候,必须给局部变量加final进行修饰。不加final修饰的局部变量,匿名内部类是访问不到的。这是为什么呢?

网上有两种说法,第一种说法是从java编程思想那本书演化出来的,然而遭到了第二种说法的 反驳。第二种说法是直接反编译代码,用事实说话,然而却并不认同第一种说法。下面是我的见解,欢迎大家留言讨论。

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

     参考:https://www.cnblogs.com/eniac12/p/5240100.html 

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

    2. 局部变量的生命周期:当该方法被调用时,该方法中的局部变量在栈中被创建,当方法调用结束时,退栈,这些局部变量全部死亡。而内部类对象生命周期与其它类对象一样:自创建一个匿名内部类对象,系统为该对象分配内存,直到没有引用变量指向分配给该对象的内存,它才有可能会死亡(被JVM垃圾回收)。所以完全可能出现的一种情况是:成员方法已调用结束,局部变量已死亡,但匿名内部类的对象仍然活着。
    3. 如果匿名内部类的对象访问了同一个方法中的局部变量,就要求只要匿名内部类对象还活着,那么栈中的那些它要所访问的局部变量就不能“死亡”。
 4. 解决方法:匿名内部类对象可以访问同一个方法中被定义为final类型的局部变量。定义为final后,编译器会把匿名内部类对象要访问的所有final类型局部变量,都拷贝一份作为该对象的成员变量。这样,即使栈中局部变量已经死亡,匿名内部类对象照样可以拿到该局部变量的值,因为它自己拷贝了一份,且与原局部变量的值始终保持一致(final类型不可变)。

  最后,Java 8更加智能:如果局部变量被匿名内部类访问,那么该局部变量相当于自动使用了final修饰

 

二.是因为匿名内部类访问局部变量,局部变量的拷贝会导致不一致。

    该问题有另外一种解释:参考

https://blog.csdn.net/hzy38324/article/details/77986095

下面贴出文章,方便大家阅读

 

我们先来看一道很简单的小题:

public class AnonymousDemo1
{
    public static void main(String args[])
    {
        new AnonymousDemo1().play();
    }

    private void play()
    {
        Dog dog = new Dog();
        Runnable runnable = new Runnable()
        {
            public void run()
            {
                while(dog.getAge()<100)
                {
                    // 过生日,年龄加一
                    dog.happyBirthday();
                    // 打印年龄
                    System.out.println(dog.getAge());
                }
            }
        };
        new Thread(runnable).start();

        // do other thing below when dog's age is increasing
        // ....
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

其中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++;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

这段程序的功能非常简单,就是启动一个线程,来模拟一只小狗不断过生日的一个过程。

不过,这段代码并不能通过编译,为什么,仔细看一下! 






看出来了吗?是的,play()方法中,dog变量要加上final修饰符,否则会提示:

Cannot refer to a non-final variable dog inside an inner class defined in a different method

加上final后,编译通过,程序正常运行。 
但是,这里为什么一定要加final呢? 
学Java的时候,我们都听过这句话(或者类似的话):

匿名内部类来自外部闭包环境自由变量必须是final的

那时候一听就懵逼了,什么是闭包?什么叫自由变量?最后不求甚解,反正以后遇到这种情况就加个final就好了。 
显然,这种对待知识的态度是不好的,必须“知其然并知其所以然”,最近就这个问题做了一番研究,希望通过比较通俗易懂的言语分享给大家。

我们学框架、看源码、学设计模式、学并发编程、学缓存,甚至了解大型网站架构设计,可回过头来看看一些非常简单的Java代码,却发现还有那么几个旮旯,是自己没完全看透的。

匿名内部类的真相

既然不加final无法通过编译,那么就加上final,成功编译后,查看class文件反编译出来的结果。 
在class目录下面,我们会看到有两个class文件:AnonymousDemo1.class和AnonymousDemo1$1.class,其中,带美元符号$的那个class,就是我们代码里面的那个匿名内部类。接下来,使用 jd-gui 反编译一下,查看这个匿名内部类:

class AnonymousDemo1$1
  implements Runnable
{
  AnonymousDemo1$1(AnonymousDemo1 paramAnonymousDemo1, Dog paramDog) {}

  public void run()
  {
    while (this.val$dog.getAge() < 100)
    {
      this.val$dog.happyBirthday();

      System.out.println(this.val$dog.getAge());
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

这代码看着不合常理:

  • 首先,构造函数里传入了两个变量,一个是AnonymousDemo1类型的,另一个是Dog类型,但是方法体却是空的,看来是反编译时遗漏了;
  • 再者,run方法里this.val$dog这个成员变量并没有在类中定义,看样子也是在反编译的过程中遗漏掉了。

既然 jd-gui 的反编译无法完整的展示编译后的代码,那就只能使用 javap 命令来反汇编了,在命令行中执行:

javap -c AnonymousDemo1$1.class
  • 1

执行完命令后,可以在控制台看到一些汇编指令,这里主要看下内部类的构造函数:

com.bridgeforyou.anonymous.AnonymousDemo1$1(com.bridgeforyou.anonymous.Anonymo
usDemo1, com.bridgeforyou.anonymous.Dog);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #14                 // Field this$0:Lcom/bridgeforyou/an
onymous/AnonymousDemo1;
       5: aload_0
       6: aload_2
       7: putfield      #16                 // Field val$dog:Lcom/bridgeforyou/a
nonymous/Dog;
      10: aload_0
      11: invokespecial #18                 // Method java/lang/Object."<init>":
()V
      14: return
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

这段指令的重点在于第二个putfield指令,结合注释,我们可以知道,构造器函数将传入的dog变量赋值给了另一个变量,现在,我们可以手动填补一下上面那段信息遗漏掉的反编译后的代码:

class AnonymousDemo1$1
  implements Runnable
{
  private Dog val$dog;
  private AnonymousDemo1 myAnonymousDemo1;

  AnonymousDemo1$1(AnonymousDemo1 paramAnonymousDemo1, Dog paramDog) {
    this.myAnonymousDemo1 = paramAnonymousDemo1;
    this.val$dog = paramDog;
  }

  public void run()
  {
    while (this.val$dog.getAge() < 100)
    {
      this.val$dog.happyBirthday();

      System.out.println(this.val$dog.getAge());
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

至于外部类AnonymousDemo1,则是把dog变量传递给AnonymousDemo1$1的构造器,然后创建一个内部类的实例罢了,就像这样:

public class AnonymousDemo1
{
  public static void main(String[] args)
  {
    new AnonymousDemo1().play();
  }

  private void play()
  {
    final Dog dog = new Dog();
    Runnable runnable = new AnonymousDemo1$1(this, dog);
    new Thread(runnable).start();
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

关于Java汇编指令,可以参考 Java bytecode instruction listings

到这里我们已经看清匿名内部类的全貌了,其实Java就是把外部类的一个变量拷贝给了内部类里面的另一个变量。 
我之前在 用画小狗的方法来解释Java值传递 这篇文章里提到过,Java里面的变量都不是对象,这个例子中,无论是内部类的val$dog变量,还是外部类的dog变量,他们都只是一个存储着对象实例地址的变量而已,而由于做了拷贝,这两个变量指向的其实是同一只狗(对象)。

这里写图片描述

那么为什么Java会要求外部类的dog一定要加上final呢? 
一个被final修饰的变量:

  • 如果这个变量是基本数据类型,那么它的值不能改变;
  • 如果这个变量是个指向对象的引用,那么它所指向的地址不能改变。

关于final,维基百科说的非常清楚 final (Java) - Wikipedia

因此,这个例子中,假如我们不加上final,那么我可以在代码后面加上这么一句dog = new Dog(); 就像下面这样:

// ...
new Thread(runnable).start();

// do other thing below when dog's age is increasing
dog = new Dog();
  • 1
  • 2
  • 3
  • 4
  • 5

这样,外面的dog变量就指向另一只狗了,而内部类里的val$dog,还是指向原先那一只,就像这样:

这里写图片描述

这样做导致的结果就是内部类里的变量和外部环境的变量不同步,指向了不同的对象。 
因此,编译器才会要求我们给dog变量加上final,防止这种不同步情况的发生。

为什么要拷贝

现在我们知道了,是由于一个拷贝的动作,使得内外两个变量无法实时同步,其中一方修改,另外一方都无法同步修改,因此要加上final限制变量不能修改。

那么为什么要拷贝呢,不拷贝不就没那么多事了吗?

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

那么有没有不需要拷贝的情形呢?有的,请继续看。

一定要加final吗

我们已经理解了要加final背后的原因,现在我把原来在函数内部的dog变量,往外提,“提拔”为类的成员变量,就像这样:

public class AnonymousDemo2
{
    private Dog dog = new Dog();

    public static void main(String args[])
    {
        new AnonymousDemo2().play();
    }

    private void play()
    {
        Runnable runnable = new Runnable()
        {
            public void run()
            {
                while (dog.getAge() < 100)
                {
                    // 过生日,年龄加一
                    dog.happyBirthday();
                    // 打印年龄
                    System.out.println(dog.getAge());
                }
            }
        };
        new Thread(runnable).start();

        // do other thing below when dog's age is increasing
        // ....
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

这里的dog成了成员变量,对应的在虚拟机里是在堆的位置,而且无论在这个类的哪个地方,我们只需要通过 this.dog,就可以获得这个变量。因此,在创建内部类时,无需进行拷贝,甚至都无需将这个dog传递给内部类。

通过反编译,可以看到这一次,内部类的构造函数只有一个参数:

class AnonymousDemo2$1
  implements Runnable
{
  AnonymousDemo2$1(AnonymousDemo2 paramAnonymousDemo2) {}

  public void run()
  {
    while (AnonymousDemo2.access$0(this.this$0).getAge() < 100)
    {
      AnonymousDemo2.access$0(this.this$0).happyBirthday();

      System.out.println(AnonymousDemo2.access$0(this.this$0).getAge());
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

在run方法里,是直接通过AnonymousDemo2类来获取到dog这个对象的,结合javap反汇编出来的指令,我们同样可以还原出代码:

class AnonymousDemo2$1
  implements Runnable
{
  private AnonymousDemo2 myAnonymousDemo2;

  AnonymousDemo2$1(AnonymousDemo2 paramAnonymousDemo2) {
    this.myAnonymousDemo2 = paramAnonymousDemo2;
  }

  public void run()
  {
    while (this.myAnonymousDemo2.getAge() < 100)
    {
      this.myAnonymousDemo2.happyBirthday();

      System.out.println(this.myAnonymousDemo2.getAge());
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

相比于demo1,demo2的dog变量具有”天然同步”的优势,因此就无需拷贝,因而编译器也就不要求加上final了。

回看那句经典的话

上文提到了这句话 —— “匿名内部类来自外部闭包环境的自由变量必须是final的”,一开始我不理解,所以看着很蒙圈,现在再来回看一下:

首先,自由变量是什么? 
一个函数的“自由变量”就是既不是函数参数也不是函数内部局部变量的变量,这种变量一般处于函数运行时的上下文,就像demo中的dog,有可能第一次运行时,这个dog指向的是age是10的狗,但是到了第二次运行时,就是age是11的狗了。

然后,外部闭包环境是什么? 
外部环境如果持有内部函数所使用的自由变量,就会对内部函数形成“闭包”,demo1中,外部play方法中,持有了内部类中的dog变量,因此形成了闭包。 
当然,demo2中,也可以理解为是一种闭包,如果这样理解,那么这句经典的话就应该改为这样更为准确:

匿名内部类来自外部闭包环境的自由变量必须是final的,除非自由变量来自类的成员变量

对比JavaScript的闭包

从上面我们也知道了,如果说Java匿名内部类时一种闭包的话,那么这是一种有点“残缺”的闭包,因为他要求外部环境持有的自由变量必须是final的

而对于其他语言,比如C#和JavaScript,是没有这种要求的,而且内外部的变量可以自动同步,比如下面这段JavaScript代码(运行时直接按F12,在打开的浏览器调试窗口里,把代码粘贴到Console页签,回车就可以了):

function fn() {
    var myVar = 42;
    var lambdaFun = () => myVar;
    console.log(lambdaFun()); // print 42
    myVar++;
    console.log(lambdaFun()); // print 43
}
fn();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

这段代码使用了lambda表达式(Java8也提供了,后面会介绍)创建了一个函数,函数直接返回了myVar这个外部变量,在创建了这个函数之后,对myVar进行修改,可以看到函数内部的变量也同步修改了。 
应该说,这种闭包,才是比较“正常“和“完整”的闭包。

Java8之后的变动

在JDK1.8中,也提供了lambda表达式,使得我们可以对匿名内部类进行简化,比如这段代码:

int answer = 42;
Thread t = new Thread(new Runnable() {
    public void run() {
        System.out.println("The answer is: " + answer);
   }
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

使用lambda表达式进行改造之后,就是这样:

int answer = 42;
Thread t = new Thread(
    () -> System.out.println("The answer is: " + answer)
);
  • 1
  • 2
  • 3
  • 4

值得注意的是,从JDK1.8开始,编译器不要求自由变量一定要声明为final,如果这个变量在后面的使用中没有发生变化,就可以通过编译,Java称这种情况为“effectively final”。 
上面那个例子就是“effectively final”,因为answer变量在定义之后没有变化,而下面这个例子,则无法通过编译:

int answer = 42;
answer ++; // don't do this !
Thread t = new Thread(
   () -> System.out.println("The answer is: " + answer)
);

 

 

当然这两种说法,第一种没有经过例子验证,第二个原因是成立的。但接受第二个原因的人,认为,第一个原因是有问题的,他们对生命周期的理解是, 方法局部变量和形参生命周期比内部类短,容易回收了导致内部类失去引用造成错误的。

 

再看一个文章梳理一下:

https://www.cnblogs.com/xh0102/p/5729381.html

方便阅读,贴出来

 

内部类访问局部变量的时候,为什么变量必须加上final修饰

 

这里的局部变量就是在类方法中的变量,能访问方法中变量的类当然也是局部内部类了。
我们都知道,局部变量在所处的函数执行完之后就释放了,但是内部类对象如果还有引用指向的话它是还存在的。例如下面的代码:

class Outer{                                                                       
        public static void main(String[] args){ Outer out = new Outer(); Object obj = out.method(); } Object method(){ int locvar = 1; class Inner{ void displayLocvar(){ System.out.println("locvar = " + locvar); } } Object in = new Inner(); return in; } } 

当out.method()方法执行结束后,局部变量 locvar 就消失了,但是在method()方法中 obj in = new Inner() 产生的 in 对象还存在引用obj,这样对象就访问了一个不存在的变量,是不允许的。这种矛盾是由局部内部类可以访问局部变量但是局部内部类对象和局部变量的生命周期不同而引起的。

局部内部类访问局部变量的机制

在java中,类是封装的,内部类也不例外。我们知道,非静态内部类能够访问外部类成员是因为它持有外部类对象的引用 Outer.this, 就像子类对像能够访问父类成员是持有父类对象引用super一样。局部内部类也和一般内部类一样,只持有了Outer.this,能够访问外部类成员,但是它又是如何访问到局部变量的呢?

实际上java是将局部变量作为参数传给了局部内部类的构造函数,而将其作为内部类的成员属性封装在了类中。我们看到的内部类访问局部变量实际上只是访问了自己的成员属性而已,这和类的封装性是一致的。那么上面的代码实际上是这样:

Object method(){
                int locvar = 1;
                class Inner{
                    private int obj; public Inner(int obj){ this.obj = obj; } void displayLocvar(){ System.out.println("locvar = " + locvar); } } Object in = new Inner(locvar); //将locvar作为参数传给构造,以初始话成员 return in; } 

那么问题又来了,我们写代码的目的是在内部类中直接控制局部变量和引用,但是java这么整我们就不高兴了,我在内部类中整半天想着是在操作外部变量,结果你给整个副本给我,我搞半天丫是整我自己的东西啊?要是java不这么整吧,由破坏了封装性--------你个局部内部类牛啊,啥都没有还能看局部变量呢。这不是java风格,肯定不能这么干。这咋整呢?
想想,类的封装性咱们一定是要遵守的,不能破坏大局啊。但又要保证两个东西是一模一样的,包括对象和普通变量,那就使用final嘛,当传递普通变量的之前我把它变成一个常量给你,当传递引用对象的时候加上final就声明了这个引用就只能指着这一个对象了。这样就保证了内外统一。

================================================

从上面的三个文章看出,主要问题是拷贝导致的。第二种解释,只考虑到了一个变量的拷贝,就是局部内部类的拷贝。

但我们看反编译代码:

private Dog val$dog;
  private AnonymousDemo1 myAnonymousDemo1;

  AnonymousDemo1$1(AnonymousDemo1 paramAnonymousDemo1, Dog paramDog) {
    this.myAnonymousDemo1 = paramAnonymousDemo1;
    this.val$dog = paramDog;
  }
实际上是有两份拷贝的,一份是第二种解释中的dog的拷贝。其实,还有一个是外部类
paramAnonymousDemo1
的拷贝。如果不拷贝的情况下,这个dog的拷贝数据应该是谁的呢,是属于外部类的对象的。而现在两个都被进行了拷贝。这会导致两种类型的不一致。一: 外部类引用已经引用指向为null,而实际在内部类中使用的拷贝还存在。这就是第一种说的生命周期不一致问题。第二种解释的比较清楚了。就是局部变量的拷贝会导致访问上的不一致

 

总结:无论是第一种生命周期不一致,还是说因为修改引用导致不一致,其实本质上都是因为拷贝导致的。但是由两种拷贝1,外部类对象的拷贝2.局部变量的拷贝 。两种拷贝分别导致上面的两种问题。第一种:局部内部类访问局部变量的时候,首先会拷贝一份外部类对象使用的是外部类的一份拷贝,这份拷贝的原本如果不存在了,那么就不会访问到局部变量。第二种其实忽略了一点,就是,拷贝不仅是dog对象的拷贝,也就是局部内部类的对象有拷贝,而且,外部类对象,其实也是一个拷贝,而这个拷贝在存在的过程中,可能会出现外面的原本已经不存在了。当这个拷贝跟原本生命周期出现不一致的时候,自然会出现垃圾回收的问题或者访问上的不一致。

 

©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页
实付 79.90元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值