为什么说Java匿名内部类是残缺的闭包

前言

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

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)
);
  • 1
  • 2
  • 3
  • 4
  • 5

花絮

在研究这个问题时,我在StackOverflow参考了这个问题:Cannot refer to a non-final variable inside an inner class defined in a different method

其中一个获得最高点赞、同时也是被采纳的回答,是这样解释的:

When the main() method returns, local variables (such as lastPrice and price) will be cleaned up from the stack, so they won’t exist anymore after main() returns. 
But the anonymous class object references these variables. Things would go horribly wrong if the anonymous class object tries to access the variables after they have been cleaned up. 
By making lastPrice and price final, they are not really variables anymore, but constants. The compiler can then just replace the use of lastPrice and price in the anonymous class with the values of the constants (at compile time, of course), and you won’t have the problem with accessing non-existent variables anymore.

大致的意思是:由于外部的变量会在方法结束后被销毁,因此要将他们声明为final常量,这样即使外部类的变量销毁了,内部类还是可以使用。

这么浅显、无根无据的解释居然也获得了那么多赞,后来评论区有人指出了错误,回答者才在他的回答里加了一句:

edit - See the comments below - the following is not a correct explanation, as KeeperOfTheSoul points out.

可见,看待一个问题时,不能只从表面去解释,要解释一个问题,必须弄清背后的原理。

参考内容

阅读全文

时间: 2024-10-12 21:58:54

为什么说Java匿名内部类是残缺的闭包的相关文章

java 匿名内部类 js 闭包

最近在看js,看到closure(闭包)这一块儿的时候就想到了 java的匿名内部类 两者都有涉及到变量/参数的引用问题. 先说java的匿名内部类,他的定义我就不多做说明了,可以参考地址 http://docs.oracle.com/javase/tutorial/java/javaOO/anonymousclasses.html .我们今天主要说: 1.Why cannot an anonymous class access local variables in its enclosing

#随笔之java匿名内部类

随笔之java匿名内部类 从今天起开始每日一篇技术博客,当然这只是我当天所学的一些随笔,里面或多或少会有理解不当的地方,希望大家多多指教,一起进步! 在讲匿名内部类之前,先讲讲内部类的一些概念. 1.内部类:顾名思义,内部类就是写在一个类里面的类(废话),这里大家可能会问了,为什么我们不直接写一个class呢,非要写在内部.原因很简单,内部类一方面是有很好的封装性,要通过外部类对象才能访问(具体实例见下方代码).另一方便定义起来很方便.最后,内部类还可以骚气地实现多重继承,因为外部类是否继承或实

第一章.java&amp;golang的区别之:闭包

对于golang一直存有觊觎之心,但一直苦于没有下定决心去学习研究,最近开始接触golang.就我个人来说,学习golang的原动力是因为想要站在java语言之外来审视java和其它语言的区别,再就是想瞻仰一下如此NB的语言.年前就想在2019年做一件事情,希望能从各个细节处做一次java和golang的对比分析,不评判语言的优劣,只想用简单的语言和可以随时执行的代码来表达出两者的区别和底层涉及到的原理.今天是情人节,馒头妈妈在加班,送给自己一件贴心的礼物,写下第一篇对比文章:java&gola

【Java学习笔记之二十六】深入理解Java匿名内部类

在[Java学习笔记之二十五]初步认知Java内部类中对匿名内部类做了一个简单的介绍,但是内部类还存在很多其他细节问题,所以就衍生出这篇博客.在这篇博客中你可以了解到匿名内部类的使用.匿名内部类要注意的事项.如何初始化匿名内部类.匿名内部类使用的形参为何要为final. 一.使用匿名内部类内部类 匿名内部类由于没有名字,所以它的创建方式有点儿奇怪.创建格式如下: new 父类构造器(参数列表)|实现接口() { //匿名内部类的类体部分 } 在这里我们看到使用匿名内部类我们必须要继承一个父类或者

Think in java读书笔记之:java匿名内部类的继承和覆盖

内部类的继承 Java中继承匿名内部类一个要结局的问题就是,内部类的构造器必须要指向其外围对象的引用,所以要采取特殊的语法.书上给的示例代码的可有编译版本,必须在继承的内部类的构造器内添加enclosingClassReference.super(). 1 class WithInner{ 2 class Inner{} 3 } 4 public class InheritInner extends WithInner.Inner{ 5 InheritInner(){ 6 enclosingCl

【50】java 匿名内部类剖析

匿名内部类介绍: 匿名内部类也就是没有名字的内部类 正因为没有名字,所以匿名内部类只能使用一次,它通常用来简化代码编写 但使用匿名内部类还有个前提条件:必须继承一个父类或实现一个接口 匿名内部类的声明格式如下: new ParentName(){ ...// 内部类的定义 } 匿名内部类的使用场景: .只用到类的一个实例 . ·类在定义后马上用到. ·类非常小(SUN推荐是在4行代码以下) ·给类命名并不会导致你的代码更容易被理解. 匿名内部类的使用原则: ·匿名内部类不能有构造方法. ·匿名内

Java 匿名内部类 &amp; 内部类

一.JAVA中内部类和匿名内部类的区别 内部类:内部类可以是static的或者非static的,static内部类只能包含静态方法和静态类变量,只能访问外部类的静态元素,内部类可以实例化,多次使用. 匿名内部类:它只能使用一次,不区分static和非static.如果用到外部类的变量的话,必须是类变量或者实例变量,就是必须是类定义的变量,或者final的局部变量.匿名内部类如果是继承某个类的话是可以重写那个类的方法的,这个和普通内部类一样.  总结:可以用匿名内部类的地方都可以用内部类替换,但是

为什么Java匿名内部类访问的外部局部变量或参数需要被final修饰

大部分时候,类被定义成一个独立的程序单元.在某些情况下,也会把一个类放在另一个类的内部定义,这个定义在其他类内部的类就被称为内部类,包含内部类的类也被称为外部类. class Outer { private int a; public class Inner { private int a; public void method(int a) { a++; //局部变量 this.a++; //Inner类成员变量 Outer.this.a++; //Outer类成员变量 } } } 一般做法是

从一次意外开始说java匿名内部类

java的内部类.匿名类本来以为自己用的已经很溜了, 结果, 就在昨天晚上12点来钟发生了重大事故.要说事故的严重性呢,那就是导致我一晚上没有睡着觉. 那下面先用一段模拟代码来描述下我出现的问题的: public class Test { public static void main(String[] args) throws InterruptedException { View v = new View(); v.show(1); Thread.sleep(500); v.mTextVie