為什么說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閱讀 229,001評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,786評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,986評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,204評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,964評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,354評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,410評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,554評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,106評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,918評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,093評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,648評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,342評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,755評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,009評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,839評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,107評論 2 375

推薦閱讀更多精彩內容

  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,726評論 18 399
  • 面向對象主要針對面向過程。 面向過程的基本單元是函數。 什么是對象:EVERYTHING IS OBJECT(萬物...
    sinpi閱讀 1,076評論 0 4
  • 1.import static是Java 5增加的功能,就是將Import類中的靜態方法,可以作為本類的靜態方法來...
    XLsn0w閱讀 1,257評論 0 2
  • 在家靠父母,出門靠朋友。 喝酒誤事。對于已經決定戒酒的我來說,不論在什么場合,面對什么樣的人,我都會拒絕喝酒,不是...
    郢郢閱讀 516評論 4 1
  • 健康&外型 1.皮膚護理——保濕、防曬 每周做一次皮膚大掃除,至少兩次面貼膜,任何時候做好保濕。夏天防曬,不曬黑。...
    薄小寶閱讀 166評論 0 1