天天看點

萬字長文!回歸Java基礎,詳解 Java 内部類

内部類的嵌套,即為内部類中再定義内部類,這個問題從内部類的分類角度去考慮比較合适: 

普通内部類:在這裡我們可以把它看成一個外部類的普通成員方法,在其内部可以定義普通内部類(嵌套的普通内部類),但是無法定義 static 修飾的内部類,就像你無法在成員方法中定義 static 類型的變量一樣,當然也可以定義匿名内部類和局部内部類;

靜态内部類:因為這個類獨立于外部類對象而存在,我們完全可以将其拿出來,去掉修飾它的 static 關鍵字,他就是一個完整的類,是以在靜态内部類内部可以定義普通内部類,也可以定義靜态内部類,同時也可以定義 static 成員;

匿名内部類:和普通内部類一樣,定義的普通内部類隻能在這個匿名内部類中使用,定義的局部内部類隻能在對應定義域内使用;

局部内部類:和匿名内部類一樣,但是嵌套定義的内部類隻能在對應定義域内使用。

深入了解内部類

不知道小夥伴們對上面的代碼有沒有産生疑惑:非靜态内部類可以通路外部類所有通路權限修飾的字段(即包括了 private 權限的),同時,外部類也可以通路内部類的所有通路權限修飾的字段。而我們知道,private 權限的字段隻能被目前類本身通路。然而在上面我們确實在代碼中直接通路了對應外部類 / 内部類的 private 權限的字段,要解除這個疑惑,隻能從編譯出來的類下手了,為了簡便,這裡采用下面的代碼進行測試:

搜尋公衆号程式員小樂回複關鍵字“offer”,擷取算法面試題和答案。

我在外部類中定義了一個預設通路權限(同一個包内的類可以通路)的字段 field1, 和一個 private 權限的字段 field2 ,并且定義了一個内部類 innerclassa ,并且在這個内部類中也同樣定義了兩個和外部類中定義的相同修飾權限的字段,并且通路了外部類對應的字段。最後在外部類的構造方法中我定義了一個方法内變量指派為内部類中 private 權限的字段。我們用 javac 指令(javac innerclasstest.java)編譯這個 .java 檔案,會得到兩個 .classs 檔案。innerclasstest.class 和 innerclasstest$innerclassa.class,我們再用 javap -c 指令(javap -c innerclasstest 和 javap -c innerclasstest$innerclassa)分别反編譯這兩個 .class 檔案,innerclasstest.class 的位元組碼如下:

萬字長文!回歸Java基礎,詳解 Java 内部類

我們注意到位元組碼中多了一個預設修飾權限并且名為 access$100 的靜态方法,其接受一個 innerclasstest 類型的參數,即其接受一個外部類對象作為參數,方法内部用三條指令取到參數對象的 field2 字段的值并傳回。由此,我們現在大概能猜到内部類對象是怎麼取到外部類的 private 權限的字段了:就是通過這個外部類提供的靜态方法。 

類似的,我們注意到 24 行位元組碼指令 invokestatic ,這裡代表執行了一個靜态方法,而後面的注釋也寫的很清楚,調用的是 innerclasstest$innerclassa.access$000 方法,即調用了内部類中名為 access$000 的靜态方法,根據我們上面的外部類位元組碼規律,我們也能猜到這個方法就是内部類編譯過程中編譯器自動生成的,那麼我們趕緊來看一下 innerclasstest$innerclassa 類的位元組碼吧: 

萬字長文!回歸Java基礎,詳解 Java 内部類

果然,我們在這裡發現了名為 access$000 的靜态方法,并且這個靜态方法接受一個 innerclasstest$innerclassa 類型的參數,方法的作用也很簡單:傳回參數代表的内部類對象的 x2 字段值。 

我們還注意到編譯器給内部類提供了一個接受 innerclasstest 類型對象(即外部類對象)的構造方法,内部類本身還定義了一個名為 this$0 的 innerclasstest 類型的引用,這個引用在構造方法中指向了參數所對應的外部類對象。 

最後,我們在 25 行位元組碼指令發現:内部類的構造方法通過 invokestatic 指令執行外部類的 access$100 靜态方法(在 innerclasstest 的位元組碼中已經介紹了)得到外部類對象的 field2 字段的值,并且在後面指派給 x2 字段。這樣的話内部類就成功的通過外部類提供的靜态方法得到了對應外部類對象的 field2 。

上面我們隻是對普通内部類進行了分析,但其實匿名内部類和局部内部類的原理和普通内部類是類似的,隻是在通路上有些不同:外部類無法通路匿名内部類和局部内部類對象的字段,因為外部類根本就不知道匿名内部類 / 局部内部類的類型資訊(匿名内部類的類名被隐匿,局部内部類隻能在定義域内使用)。但是匿名内部類和局部内部類卻可以通路外部類的私有成員,原理也是通過外部類提供的靜态方法來得到對應外部類對象的私有成員的值。而對于靜态内部類來說,因為其實獨立于外部類對象而存在,是以編譯器不會為靜态内部類對象提供外部類對象的引用,因為靜态内部類對象的建立根本不需要外部類對象支援。但是外部類對象還是可以通路靜态内部類對象的私有成員,因為外部類可以知道靜态内部類的類型資訊,即可以得到靜态内部類的對象,那麼就可以通過靜态内部類提供的靜态方法來獲得對應的私有成員值。來看一個簡單的代碼證明:

同樣的編譯步驟,得到了兩個 .class 檔案,這裡看一下内部類的 .class 檔案反編譯的位元組碼 innerclasstest$innerclassa:

萬字長文!回歸Java基礎,詳解 Java 内部類

仔細看一下,确實沒有找到指向外部類對象的引用,編譯器隻為這個靜态内部類提供了一個無參構造方法。 

而且因為外部類對象需要通路目前類的私有成員,編譯器給這個靜态内部類生成了一個名為 access$000 的靜态方法,作用已不用我多說了。如果我們不看類名,這個類完全可以作為一個普通的外部類來看,這正是靜态内部類和其餘的内部類的差別所在:靜态内部類對象不依賴其外部類對象存在,而其餘的内部類對象必須依賴其外部類對象而存在。

ok,到這裡問題都得到了解釋:在非靜态内部類通路外部類私有成員 / 外部類通路内部類私有成員 的時候,對應的外部類 / 外部類會生成一個靜态方法,用來傳回對應私有成員的值,而對應外部類對象 / 内部類對象通過調用其内部類 / 外部類提供的靜态方法來擷取對應的私有成員的值。

内部類和多重繼承

我們已經知道,java 中的類不允許多重繼承,也就是說 java 中的類隻能有一個直接父類,而 java 本身提供了内部類的機制,這是否可以在一定程度上彌補 java 不允許多重繼承的缺陷呢?我們這樣來思考這個問題:假設我們有三個基類分别為 a、b、c,我們希望有一個類 d 達成這樣的功能:通過這個 d 類的對象,可以同時産生 a 、b 、c 類的對象,通過剛剛的内部類的介紹,我們也應該想到了怎麼完成這個需求了,建立一個類 d.java:

程式正确運作。而且因為普通内部類可以通路外部類的所有成員并且外部類也可以通路普通内部類的所有成員,是以這種方式在某種程度上可以說是 java 多重繼承的一種實作機制。但是這種方法也是有一定代價的,首先這種結構在一定程度上破壞了類結構,一般來說,建議一個 .java 檔案隻包含一個類,除非兩個類之間有非常明确的依賴關系(比如說某種汽車和其專用型号的輪子),或者說一個類本來就是為了輔助另一個類而存在的(比如說上篇文章介紹的 hashmap 類和其内部用于周遊其元素的 hashiterator 類),那麼這個時候使用内部類會有較好代碼結構和實作效果。而在其他情況,将類分開寫會有較好的代碼可讀性和代碼維護性。

内部類和記憶體洩露

在這一小節開始前介紹一下什麼是記憶體洩露:即指在記憶體中存在一些其記憶體空間可以被回收的對象因為某些原因又沒有被回收,是以産生了記憶體洩露,如果應用程式頻繁發生記憶體洩露可能會産生很嚴重的後果(記憶體中可用的空間不足導緻程式崩潰,甚至導緻整個系統卡死)。 

聽起來怪吓人的,這個問題在一些需要開發者手動申請和釋放記憶體的程式設計語言(c/c++)中會比較容易産生,因為開發者申請的記憶體需要手動釋放,如果忘記了就會導緻記憶體洩露,舉個簡單的例子(c++):

在這段代碼裡我有意而為之:在為指針 p 申請完記憶體之後将其直接指派為 nullptr ,這是 c++ 11 中一個表示空指針的關鍵字,我們平時常用的 null 隻是一個值為 0 的常量值,在進行方法重載傳參的時候可能會引起混淆。之後我直接傳回了,雖然在程式結束之後作業系統會回收我們程式中申請的記憶體,但是不可否認的是上面的代碼确實産生了記憶體洩露(申請的 100 個 int 元素所占的記憶體無法被回收)。這隻是一個最簡單不過的例子。我們在寫這類程式的時候當動态申請的記憶體不再使用時,應該要主動釋放申請的記憶體:

而在 java 中,因為 jvm 有垃圾回收功能,對于我們自己建立的對象無需手動回收這些對象的記憶體空間,這種機制确實在一定程度上減輕了開發者的負擔,但是也增加了開發者對 jvm 垃圾回收機制的依賴性,從某個方面來說,也是弱化了開發者防止記憶體洩露的意識。當然,jvm 的垃圾回收機制的利是遠遠大于弊的,隻是我們在開發過程中不應該喪失了這種對象和記憶體的意識。

回到正題,内部類和記憶體洩露又有什麼關系呢?在繼續閱讀之前,請確定你對 jvm 的在進行垃圾回收時如何找出記憶體中不再需要的對象有一定的了解,如果你對這個過程不太了解,你可以參考一下 這篇文章 中對這個過程的簡單介紹。我們在上面已經知道了,建立非靜态内部類的對象時,建立的非靜态内部類對象會持有對外部類對象的引用,這個我們在上面的源碼反編譯中已經介紹過了,正是因為非靜态内部類對象會持有外部類對象的引用,是以如果說這個非靜态内部類對象因為某些原因無法被回收,就會導緻這個外部類對象也無法被回收,這個聽起來是有道理的,因為我們在上文也已經介紹了:非靜态内部類對象依賴于外部類對象而存在,是以内部類對象沒被回收,其外部類對象自然也不能被回收。但是可能存在這種情況:非靜态内部類對象在某個時刻已經不在被使用,或者說這個内部類對象可以在不影響程式正确運作的情況下被回收,而因為我們對這個内部類的使用不當而使得其無法被 jvm 回收,同時會導緻其外部類對象無法被回收,即為發生記憶體洩露。那麼這個 “使用不當” 具體指的是哪個方面呢?看一個簡單的例子,建立一個 memoryleaktest 的類:

我們在代碼中添加一些斷點,然後采用 debug 模式檢視: 

萬字長文!回歸Java基礎,詳解 Java 内部類

程式執行到 72 行代碼,此時 72 行代碼還未執行,是以 mycomponent 引用和其對象還未建立,繼續執行:

搜尋公衆号程式員小樂回複關鍵字“java”,擷取java面試題和答案。

萬字長文!回歸Java基礎,詳解 Java 内部類

這裡成功建立了一個 mycomponent 對象,但是其 create 方法還未執行,是以 mywindow 字段為 null,這裡可能有小夥伴會問了,mycomponent 對象的 clicklistener 字段呢?怎麼不見了?其實這和我們在代碼中定義 clicklistener 字段的形式有關,我們定義的是 static onclicklistener clicklistener; ,是以 clicklistener 是一個靜态字段,其在類加載的完成的時候儲存在 jvm 中記憶體區域的 方法區 中,而建立的 java 對象儲存在 jvm 的堆記憶體中,兩者不在同一塊記憶體區域。關于這些細節,想深入了解的小夥伴建議閱讀《深入了解jvm虛拟機》。好了,我們繼續執行代碼: 

萬字長文!回歸Java基礎,詳解 Java 内部類

mycomponent.create 方法執行完成之後建立了 onclicklistener 内部類對象,并且為 mywindow 對象設定 onclicklistener 單擊事件監聽。我們繼續: 

萬字長文!回歸Java基礎,詳解 Java 内部類

mycomponent.destroy 方法執行完成之後,mywindow.removeclicklistener 方法也執行完成,此時 mywindow 對象中的 clicklistener字段為 null。我們繼續:

萬字長文!回歸Java基礎,詳解 Java 内部類

代碼執行到了 80 行,在此之前,所有的代碼和解釋都沒有什麼難度,跟着運作圖走,一切都那麼順利成章,其實這張圖的運作結果也很好了解,隻不過圖中的文字需要思考一下:mycomponent 引用指向的對象真的被回收了嗎?要解答這個問題,我們需要借助 java 中提供的記憶體分析工具 jvisualvm (以前它還不叫這個名字…),它一般在你安裝 jdk 的目錄下的 bin 子目錄下: 

萬字長文!回歸Java基礎,詳解 Java 内部類

我們運作這個程式: 

萬字長文!回歸Java基礎,詳解 Java 内部類

在程式左邊可以找到我們目前正在執行的 java 程序,輕按兩下進入:

萬字長文!回歸Java基礎,詳解 Java 内部類

單擊 tab 中的 監視 頁籤,可以看到目前正在執行的 java 程序的一些資源占用資訊,當然我們現在的主要目的是分析記憶體,那麼們單擊右上角的 堆 dump :

萬字長文!回歸Java基礎,詳解 Java 内部類

在這個界面,單擊 類 頁籤,會出現目前 java 程序中用到的所有的類,我們已經知道我們要查找的類的對象隻建立了一個,是以我們根據右上角的 執行個體數 來進行排除:我們成功的找到了我們建立的對象!而這樣也意味着當我們在上面代碼中調用 jvm 的垃圾回收動作沒有回收這三個對象,這其實就是一個真真切切的記憶體洩露!因為我們将 main 方法中的 mycomponent 引用指派為 null,就意味着我們已經不再使用這個元件和裡面的一些子元件(mywindow 對象),即這個元件和其内部的一些元件應該被回收。但是調用 jvm 的垃圾回收卻并沒有将其對應的對象回收。造成這個問題的原因在哪呢? 

其實就在于我們剛剛在 mycomponent 類中定義的 clicklistener 字段,我們在代碼中将其定義成了 static 類型的,同時這個字段又指向了一個匿名内部類對象(在 create 方法中 建立了一個 onclicklistener 接口對象,即通過一個匿名内部類實作這個接口并建立其對象),根據 jvm 尋找和标記無用對象的規則(可達性分析算法),其會将 clicklistener 字段作為一個 “root” ,并通過它來尋找還有用的對象,在這個例子中,clicklistener 字段指向一個匿名内部類對象,這個匿名内部類對象有一個外部類對象(mycomponent 類型的對象)的引用,而外部類對象中又有一個 mywindow 類型的對象引用。是以 jvm 會将這三個對象都視為有用的對象不會回收。用圖來解釋吧: 

萬字長文!回歸Java基礎,詳解 Java 内部類

ok,通過這個過程,相信你已經了解了造成此次記憶體洩露的原因了,那麼我們該如何解決呢?對于目前這個例子,我們隻需要改一些代碼: 

把 mycomponent 類中的 clicklistener 字段前面的 static 修飾符去掉就可以了(static onclicklistener clicklistener; -> onclicklistener clicklistener;),這樣的話 clicklistener 指向的對象,就作為 mycomponent 類的對象的一部分了,在 mycomponent 對象被回收時裡面的子元件也會被回收。同時它們之間也隻是互相引用(mycomponent 外部類對象中有一個指向 onclicklistener 内部類對象的引用,onclicklistener 内部類對象有一個指向 mycomponent 外部類對象的引用),根據 jvm 的 “可達性分析” 算法,在兩個對象都不再被外部使用時,jvm 的垃圾回收機制是可以标記并回收這兩個對象的。 雖然不強制要求你在 mycomponent 類中的 ondestroy 方法中将其 clicklistener 引用指派為 null,但是我還是建議你這樣做,因為這樣更能確定你的程式的安全性(減少發生記憶體洩露的機率,畢竟匿名内部類對象會持有外部類對象的引用),在某個元件被銷毀時将其内部的一些子元件進行合理的處理是一個很好的習慣。 

你也可以自定義一個靜态内部類或者是另外自定義一個類檔案,并實作 onclicklistener 接口,之後通過這個類建立對象,這樣就可以避免通過非靜态内部類的形式建立 onclicklistener 對象增加記憶體洩露的可能性。

避免記憶體洩漏

那麼我們在日常開發中怎麼合理的使用内部類來避免産生記憶體洩露呢?這裡給出一點我個人的了解: 

能用靜态内部類就盡量使用靜态内部類,從上文中我們也知道了,靜态内部類的對象建立不依賴外部類對象,即靜态内部對象不會持有外部類對象的引用,自然不會因為靜态内部類對象而導緻記憶體洩露,是以如果你的内部類中不需要通路外部類中的一些非 static 成員,那麼請把這個内部類改造成靜态内部類;

對于一些自定義類的對象,慎用 static 關鍵字修飾(除非這個類的對象的聲明周期确實應該很長),我們已經知道,jvm 在進行垃圾回收時會将 static 關鍵字修飾的一些靜态字段作為 “root” 來進行存活對象的查找,是以程式中 static 修飾的對象越多,對應的 “root” 也就越多,每一次 jvm 能回收的對象就越少。 當然這并不是建議你不使用 static 關鍵字,隻是在使用這個關鍵字之前可以考慮一下這個對象使用 static 關鍵字修飾對程式的執行确實更有利嗎?

為某些元件(大型)提供一個當這個大型元件需要被回收的時候用于合理處理其中的一些小元件的方法(例如上面代碼中 mycomponent 的 ondestroy 方法),在這個方法中,確定正确的處理一些需要處理的對象(将某些引用置為 null、釋放一些其他(cpu…)資源)。

結語

好了,關于 java 内部類的介紹就到這裡了,通過這篇文章相信你對 java 内部類已經有了一個比較深入的了解。 如果部落格中有什麼不正确的地方,還請多多指點。

萬字長文!回歸Java基礎,詳解 Java 内部類