為什麽說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.
可見,看待一個問題時,不能只從表面去解釋,要解釋一個問題,必須弄清背後的原理。
參考內容
- Cannot refer to a non-final variable inside an inner class defined in a different method
- Why a non-final “local” variable cannot be used inside an inner class, and instead a non-final field of the enclosing class can?
- Captured variable in a loop in C#
- Java 8 Lambda Limitations: Closures - DZone Java
- Difference between final and effectively final
- final (Java) - Wikipedia
- java為什麽匿名內部類的參數引用時final?
- Java bytecode instruction listings
- What are Free and Bound variables?
為什麽說Java匿名內部類是殘缺的閉包