為什么說Java匿名內部類是殘缺的閉包

本文首發(fā)于我的個人博客 —— 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的時候,我們都聽過這句話(或者類似的話):

匿名內部類來自外部閉包環(huán)境自由變量必須是final的

那時候一聽就懵逼了霎奢,什么是閉包户誓?什么叫自由變量?最后不求甚解幕侠,反正以后遇到這種情況就加個final就好了帝美。
顯然,這種對待知識的態(tài)度是不好的晤硕,必須“知其然并知其所以然”悼潭,最近就這個問題做了一番研究,希望通過比較通俗易懂的言語分享給大家舞箍。

我們學框架舰褪、看源碼、學設計模式疏橄、學并發(fā)編程占拍、學緩存,甚至了解大型網(wǎng)站架構設計捎迫,可回過頭來看看一些非常簡單的Java代碼晃酒,卻發(fā)現(xiàn)還有那么幾個旮旯,是自己沒完全看透的立砸。

匿名內部類的真相

既然不加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());
    }
  }
}

這代碼看著不合常理:

  • 首先,構造函數(shù)里傳入了兩個變量翩腐,一個是AnonymousDemo1類型的鸟款,另一個是Dog類型,但是方法體卻是空的茂卦,看來是反編譯時遺漏了何什;
  • 再者,run方法里this.val$dog這個成員變量并沒有在類中定義等龙,看樣子也是在反編譯的過程中遺漏掉了处渣。

既然 jd-gui 的反編譯無法完整的展示編譯后的代碼,那就只能使用 javap 命令來反匯編了蛛砰,在命令行中執(zhí)行:

javap -c AnonymousDemo1$1.class

執(zhí)行完命令后罐栈,可以在控制臺看到一些匯編指令,這里主要看下內部類的構造函數(shù):

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指令泥畅,結合注釋荠诬,我們可以知道,構造器函數(shù)將傳入的dog變量賦值給了另一個變量位仁,現(xiàn)在浅妆,我們可以手動填補一下上面那段信息遺漏掉的反編譯后的代碼:

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的構造器障癌,然后創(chuàng)建一個內部類的實例罷了凌外,就像這樣:

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

到這里我們已經(jīng)看清匿名內部類的全貌了涛浙,其實Java就是把外部類的一個變量拷貝給了內部類里面的另一個變量康辑。
我之前在 用畫小狗的方法來解釋Java值傳遞 這篇文章里提到過,Java里面的變量都不是對象轿亮,這個例子中疮薇,無論是內部類的val$dog變量,還是外部類的dog變量,他們都只是一個存儲著對象實例地址的變量而已我注,而由于做了拷貝按咒,這兩個變量指向的其實是同一只狗(對象)。

bind-to-the-same.png

那么為什么Java會要求外部類的dog一定要加上final呢但骨?
一個被final修飾的變量:

  • 如果這個變量是基本數(shù)據(jù)類型励七,那么它的值不能改變智袭;
  • 如果這個變量是個指向對象的引用,那么它所指向的地址不能改變掠抬。

關于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

這樣做導致的結果就是內部類里的變量和外部環(huán)境的變量不同步劣坊,指向了不同的對象馏臭。
因此,編譯器才會要求我們給dog變量加上final讼稚,防止這種不同步情況的發(fā)生。

為什么要拷貝

現(xiàn)在我們知道了绕沈,是由于一個拷貝的動作锐想,使得內外兩個變量無法實時同步,其中一方修改乍狐,另外一方都無法同步修改赠摇,因此要加上final限制變量不能修改。

那么為什么要拷貝呢浅蚪,不拷貝不就沒那么多事了嗎藕帜?

這時候就得考慮一下Java虛擬機的運行時數(shù)據(jù)區(qū)域了,dog變量是位于方法內部的惜傲,因此dog是在虛擬機棧上洽故,也就意味著這個變量無法進行共享,匿名內部類也就無法直接訪問盗誊,因此只能通過值傳遞的方式时甚,傳遞到匿名內部類中。

那么有沒有不需要拷貝的情形呢哈踱?有的荒适,請繼續(xù)看。

一定要加final嗎

我們已經(jīng)理解了要加final背后的原因开镣,現(xiàn)在我把原來在函數(shù)內部的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把敞,就可以獲得這個變量。因此榨惠,在創(chuàng)建內部類時奋早,無需進行拷貝,甚至都無需將這個dog傳遞給內部類赠橙。

通過反編譯耽装,可以看到這一次,內部類的構造函數(shù)只有一個參數(shù):

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變量具有"天然同步"的優(yōu)勢姓建,因此就無需拷貝,因而編譯器也就不要求加上final了缤苫。

回看那句經(jīng)典的話

上文提到了這句話 —— “匿名內部類來自外部閉包環(huán)境的自由變量必須是final的”速兔,一開始我不理解,所以看著很蒙圈活玲,現(xiàn)在再來回看一下:

首先涣狗,自由變量是什么?
一個函數(shù)的“自由變量”就是既不是函數(shù)參數(shù)也不是函數(shù)內部局部變量的變量舒憾,這種變量一般處于函數(shù)運行時的上下文镀钓,就像demo中的dog,有可能第一次運行時镀迂,這個dog指向的是age是10的狗丁溅,但是到了第二次運行時,就是age是11的狗了探遵。

然后唧瘾,外部閉包環(huán)境是什么?
外部環(huán)境如果持有內部函數(shù)所使用的自由變量别凤,就會對內部函數(shù)形成“閉包”饰序,demo1中,外部play方法中规哪,持有了內部類中的dog變量求豫,因此形成了閉包。
當然,demo2中蝠嘉,也可以理解為是一種閉包最疆,如果這樣理解,那么這句經(jīng)典的話就應該改為這樣更為準確:

匿名內部類來自外部閉包環(huán)境的自由變量必須是final的蚤告,除非自由變量來自類的成員變量努酸。

對比JavaScript的閉包

從上面我們也知道了,如果說Java匿名內部類時一種閉包的話杜恰,那么這是一種有點“殘缺”的閉包获诈,因為他要求外部環(huán)境持有的自由變量必須是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也提供了挟冠,后面會介紹)創(chuàng)建了一個函數(shù),函數(shù)直接返回了myVar這個外部變量袍睡,在創(chuàng)建了這個函數(shù)之后知染,對myVar進行修改,可以看到函數(shù)內部的變量也同步修改了女蜈。
應該說,這種閉包色瘩,才是比較“正澄苯眩“和“完整”的閉包。

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吧享,如果這個變量在后面的使用中沒有發(fā)生變化魏割,就可以通過編譯,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常量泵督,這樣即使外部類的變量銷毀了,內部類還是可以使用庶喜。

這么淺顯小腊、無根無據(jù)的解釋居然也獲得了那么多贊,后來評論區(qū)有人指出了錯誤溃卡,回答者才在他的回答里加了一句:

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

可見溢豆,看待一個問題時,不能只從表面去解釋瘸羡,要解釋一個問題漩仙,必須弄清背后的原理。

參考內容

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末青灼,一起剝皮案震驚了整個濱河市捏境,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌麸折,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,888評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件粘昨,死亡現(xiàn)場離奇詭異垢啼,居然都是意外死亡,警方通過查閱死者的電腦和手機张肾,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,677評論 3 399
  • 文/潘曉璐 我一進店門芭析,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人吞瞪,你說我怎么就攤上這事馁启。” “怎么了芍秆?”我有些...
    開封第一講書人閱讀 168,386評論 0 360
  • 文/不壞的土叔 我叫張陵惯疙,是天一觀的道長。 經(jīng)常有香客問我妖啥,道長霉颠,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,726評論 1 297
  • 正文 為了忘掉前任荆虱,我火速辦了婚禮掉分,結果婚禮上俭缓,老公的妹妹穿的比我還像新娘。我一直安慰自己酥郭,他們只是感情好华坦,可當我...
    茶點故事閱讀 68,729評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著不从,像睡著了一般惜姐。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上椿息,一...
    開封第一講書人閱讀 52,337評論 1 310
  • 那天歹袁,我揣著相機與錄音,去河邊找鬼寝优。 笑死条舔,一個胖子當著我的面吹牛,可吹牛的內容都是我干的乏矾。 我是一名探鬼主播孟抗,決...
    沈念sama閱讀 40,902評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼钻心!你這毒婦竟也來了凄硼?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,807評論 0 276
  • 序言:老撾萬榮一對情侶失蹤捷沸,失蹤者是張志新(化名)和其女友劉穎摊沉,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體痒给,經(jīng)...
    沈念sama閱讀 46,349評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡说墨,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,439評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了苍柏。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片尼斧。...
    茶點故事閱讀 40,567評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖序仙,靈堂內的尸體忽然破棺而出突颊,到底是詐尸還是另有隱情鲁豪,我是刑警寧澤潘悼,帶...
    沈念sama閱讀 36,242評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站爬橡,受9級特大地震影響治唤,放射性物質發(fā)生泄漏。R本人自食惡果不足惜糙申,卻給世界環(huán)境...
    茶點故事閱讀 41,933評論 3 334
  • 文/蒙蒙 一宾添、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦缕陕、人聲如沸粱锐。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,420評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽怜浅。三九已至,卻和暖如春蔬崩,著一層夾襖步出監(jiān)牢的瞬間恶座,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,531評論 1 272
  • 我被黑心中介騙來泰國打工沥阳, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留跨琳,地道東北人。 一個月前我還...
    沈念sama閱讀 48,995評論 3 377
  • 正文 我出身青樓桐罕,卻偏偏與公主長得像脉让,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子冈绊,可洞房花燭夜當晚...
    茶點故事閱讀 45,585評論 2 359

推薦閱讀更多精彩內容

  • 1. Java基礎部分 基礎部分的順序:基本語法侠鳄,類相關的語法,內部類的語法死宣,繼承相關的語法伟恶,異常的語法,線程的語...
    子非魚_t_閱讀 31,664評論 18 399
  • 面向對象主要針對面向過程毅该。 面向過程的基本單元是函數(shù)博秫。 什么是對象:EVERYTHING IS OBJECT(萬物...
    sinpi閱讀 1,059評論 0 4
  • 1.import static是Java 5增加的功能,就是將Import類中的靜態(tài)方法,可以作為本類的靜態(tài)方法來...
    XLsn0w閱讀 1,233評論 0 2
  • 在家靠父母眶掌,出門靠朋友挡育。 喝酒誤事。對于已經(jīng)決定戒酒的我來說朴爬,不論在什么場合即寒,面對什么樣的人,我都會拒絕喝酒召噩,不是...
    郢郢閱讀 512評論 4 1
  • 健康&外型 1.皮膚護理——保濕母赵、防曬 每周做一次皮膚大掃除,至少兩次面貼膜具滴,任何時候做好保濕凹嘲。夏天防曬,不曬黑构韵。...
    薄小寶閱讀 164評論 0 1