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

本文首发于我的个人博客 —— Bridge for You,转载请标明出处。

前言

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

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
        // ....
    }
}

其中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++;
    }
}

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

不过,这段代码并不能通过编译,为什么,仔细看一下!
.
.
.
.
.
.
看出来了吗?是的,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());
    }
  }
}

这代码看着不合常理:

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

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

javap -c AnonymousDemo1$1.class

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

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

这段指令的重点在于第二个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());
    }
  }
}

至于外部类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();
  }
}

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

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

bind-to-the-same.png

那么为什么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();

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

bind-diff.png

这样做导致的结果就是内部类里的变量和外部环境的变量不同步,指向了不同的对象
因此,编译器才会要求我们给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
        // ....
    }
}

这里的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());
    }
  }
}

在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());
    }
  }
}

相比于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();

这段代码使用了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);
   }
});

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

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

值得注意的是,从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)
);

花絮

在研究这个问题时,我在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.

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

参考内容

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,045评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,114评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,120评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,902评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,828评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,132评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,590评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,258评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,408评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,335评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,385评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,068评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,660评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,747评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,967评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,406评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,970评论 2 341

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,537评论 18 399
  • 面向对象主要针对面向过程。 面向过程的基本单元是函数。 什么是对象:EVERYTHING IS OBJECT(万物...
    sinpi阅读 1,038评论 0 4
  • 1.import static是Java 5增加的功能,就是将Import类中的静态方法,可以作为本类的静态方法来...
    XLsn0w阅读 1,203评论 0 2
  • 在家靠父母,出门靠朋友。 喝酒误事。对于已经决定戒酒的我来说,不论在什么场合,面对什么样的人,我都会拒绝喝酒,不是...
    郢郢阅读 505评论 4 1
  • 健康&外型 1.皮肤护理——保湿、防晒 每周做一次皮肤大扫除,至少两次面贴膜,任何时候做好保湿。夏天防晒,不晒黑。...
    薄小宝阅读 162评论 0 1