【Java面試題】之記憶體洩漏

JacobGo發表於2017-10-28

本文轉載自:http://blog.csdn.net/anxpp/article/details/51325838


Java的一個重要特性就是通過垃圾收集器(GC)自動管理記憶體的回收,而不需要程式設計師自己來釋放記憶體。理論上Java中所有不會再被利用的物件所佔用的記憶體,都可以被GC回收,但是Java也存在記憶體洩露,但它的表現與C++不同。

01

 

JAVA 中的記憶體管理

    要了解Java中的記憶體洩露,首先就得知道Java中的記憶體是如何管理的。

    在Java程式中,我們通常使用new為物件分配記憶體,而這些記憶體空間都在堆(Heap)上。

    下面看一個示例:

  1. public class Simple {
  2. public static void main(String args[]){
  3. Object object1 = new Object();//obj1
  4. Object object2 = new Object();//obj2
  5. object2 = object1;
  6. //...此時,obj2是可以被清理的
  7. }
  8. }

    Java使用有向圖的方式進行記憶體管理:

    02

    在有向圖中,我們叫作obj1是可達的,obj2就是不可達的,顯然不可達的可以被清理。

    記憶體的釋放,也即清理那些不可達的物件,是由GC決定和執行的,所以GC會監控每一個物件的狀態,包括申請、引用、被引用和賦值等。釋放物件的根本原則就是物件不會再被使用

  •     給物件賦予了空值null,之後再沒有呼叫過。
  •     另一個是給物件賦予了新值,這樣重新分配了記憶體空間。

    通常,會認為在堆上分配物件的代價比較大,但是GC卻優化了這一操作:C++中,在堆上分配一塊記憶體,會查詢一塊適用的記憶體加以分配,如果物件銷燬,這塊記憶體就可以重用;而Java中,就想一條長的帶子,每分配一個新的物件,Java的“堆指標”就向後移動到尚未分配的區域。所以,Java分配記憶體的效率,可與C++媲美。

    但是這種工作方式有一個問題:如果頻繁的申請記憶體,資源將會耗盡。這時GC就介入了進來,它會回收空間,並使堆中的物件排列更緊湊。這樣,就始終會有足夠大的記憶體空間可以分配。

    gc清理時的引用計數方式:當引用連線至新物件時,引用計數+1;當某個引用離開作用域或被設定為null時,引用計數-1,GC發現這個計數為0時,就回收其佔用的記憶體。這個開銷會在引用程式的整個生命週期發生,並且不能處理迴圈引用的情況。所以這種方式只是用來說明GC的工作方式,而不會被任何一種Java虛擬機器應用。

    多數GC採用一種自適應的清理方式(加上其他附加的用於提升速度的技術),主要依據是找出任何“活”的物件,然後採用“自適應的、分代的、停止-複製、標記-清理”式的垃圾回收器。具體不介紹太多,這不是本文重點。

 

JAVA 中的記憶體洩露

    Java中的記憶體洩露,廣義並通俗的說,就是:不再會被使用的物件的記憶體不能被回收,就是記憶體洩露。

    Java中的記憶體洩露與C++中的表現有所不同。

    在C++中,所有被分配了記憶體的物件,不再使用後,都必須程式設計師手動的釋放他們。所以,每個類,都會含有一個解構函式,作用就是完成清理工作,如果我們忘記了某些物件的釋放,就會造成記憶體洩露。

    但是在Java中,我們不用(也沒辦法)自己釋放記憶體,無用的物件由GC自動清理,這也極大的簡化了我們的程式設計工作。但,實際有時候一些不再會被使用的物件,在GC看來不能被釋放,就會造成記憶體洩露。

    我們知道,物件都是有生命週期的,有的長,有的短,如果長生命週期的物件持有短生命週期的引用,就很可能會出現記憶體洩露。我們舉一個簡單的例子:

  1. public class Simple {
  2. Object object;
  3. public void method1(){
  4. object = new Object();
  5. //...其他程式碼
  6. }
  7. }

    這裡的object例項,其實我們期望它只作用於method1()方法中,且其他地方不會再用到它,但是,當method1()方法執行完成後,object物件所分配的記憶體不會馬上被認為是可以被釋放的物件,只有在Simple類建立的物件被釋放後才會被釋放,嚴格的說,這就是一種記憶體洩露。解決方法就是將object作為method1()方法中的區域性變數。當然,如果一定要這麼寫,可以改為這樣:

  1. public class Simple {
  2. Object object;
  3. public void method1(){
  4. object = new Object();
  5. //...其他程式碼
  6. object = null;
  7. }
  8. }

    這樣,之前“new Object()”分配的記憶體,就可以被GC回收。

    到這裡,Java的記憶體洩露應該都比較清楚了。下面再進一步說明:

  •     在堆中的分配的記憶體,在沒有將其釋放掉的時候,就將所有能訪問這塊記憶體的方式都刪掉(如指標重新賦值),這是針對c++等語言的,Java中的GC會幫我們處理這種情況,所以我們無需關心。
  •     在記憶體物件明明已經不需要的時候,還仍然保留著這塊記憶體和它的訪問方式(引用),這是所有語言都有可能會出現的記憶體洩漏方式。程式設計時如果不小心,我們很容易發生這種情況,如果不太嚴重,可能就只是短暫的記憶體洩露。

 

一些容易發生記憶體洩露的例子和解決方法

    像上面例子中的情況很容易發生,也是我們最容易忽略並引發記憶體洩露的情況,解決的原則就是儘量減小物件的作用域(比如android studio中,上面的程式碼就會發出警告,並給出的建議是將類的成員變數改寫為方法內的區域性變數)以及手動設定null值。

    至於作用域,需要在我們編寫程式碼時多注意;null值的手動設定,我們可以看一下Java容器LinkedList原始碼(可參考:Java之LinkedList原始碼解讀(JDK 1.8))的刪除指定節點的內部方法:

  1. //刪除指定節點並返回被刪除的元素值
  2. E unlink(Node<E> x) {
  3. //獲取當前值和前後節點
  4. final E element = x.item;
  5. final Node<E> next = x.next;
  6. final Node<E> prev = x.prev;
  7. if (prev == null) {
  8. first = next; //如果前一個節點為空(如當前節點為首節點),後一個節點成為新的首節點
  9. } else {
  10. prev.next = next;//如果前一個節點不為空,那麼他先後指向當前的下一個節點
  11. x.prev = null;
  12. }
  13. if (next == null) {
  14. last = prev; //如果後一個節點為空(如當前節點為尾節點),當前節點前一個成為新的尾節點
  15. } else {
  16. next.prev = prev;//如果後一個節點不為空,後一個節點向前指向當前的前一個節點
  17. x.next = null;
  18. }
  19. x.item = null;
  20. size--;
  21. modCount++;
  22. return element;
  23. }

    除了修改節點間的關聯關係,我們還要做的就是賦值為null的操作,不管GC何時會開始清理,我們都應及時的將無用的物件標記為可被清理的物件。

    我們知道Java容器ArrayList是陣列實現的(可參考:Java之ArrayList原始碼解讀(JDK 1.8)),如果我們要為其寫一個pop()(彈出)方法,可能會是這樣:

  1. public E pop(){
  2. if(size == 0)
  3. return null;
  4. else
  5. return (E) elementData[--size];
  6. }

    寫法很簡潔,但這裡卻會造成記憶體溢位:elementData[size-1]依然持有E型別物件的引用,並且暫時不能被GC回收。我們可以如下修改:

  1. public E pop(){
  2. if(size == 0)
  3. return null;
  4. else{
  5. E e = (E) elementData[--size];
  6. elementData[size] = null;
  7. return e;
  8. }
  9. }

    我們寫程式碼並不能一味的追求簡潔,首要是保證其正確性。

    容器使用時的記憶體洩露

    在很多文章中可能看到一個如下記憶體洩露例子:

  1. Vector v = new Vector();
  2. for (int i = 1; i<100; i++)
  3. {
  4. Object o = new Object();
  5. v.add(o);
  6. o = null;
  7. }

    可能很多人一開始並不理解,下面我們將上面的程式碼完整一下就好理解了:

  1. void method(){
  2. Vector vector = new Vector();
  3. for (int i = 1; i<100; i++)
  4. {
  5. Object object = new Object();
  6. vector.add(object);
  7. object = null;
  8. }
  9. //...對vector的操作
  10. //...與vector無關的其他操作
  11. }

    這裡記憶體洩露指的是在對vector操作完成之後,執行下面與vector無關的程式碼時,如果發生了GC操作,這一系列的object是沒法被回收的,而此處的記憶體洩露可能是短暫的,因為在整個method()方法執行完成後,那些物件還是可以被回收。這裡要解決很簡單,手動賦值為null即可:

  1. void method(){
  2. Vector vector = new Vector();
  3. for (int i = 1; i<100; i++)
  4. {
  5. Object object = new Object();
  6. vector.add(object);
  7. object = null;
  8. }
  9. //...對v的操作
  10. vector = null;
  11. //...與v無關的其他操作
  12. }

    上面Vector已經過時了,不過只是使用老的例子來做記憶體洩露的介紹。我們使用容器時很容易發生記憶體洩露,就如上面的例子,不過上例中,容器時方法內的區域性變數,造成的記憶體洩漏影響可能不算很大(但我們也應該避免),但是,如果這個容器作為一個類的成員變數,甚至是一個靜態(static)的成員變數時,就要更加註意記憶體洩露了。

    下面也是一種使用容器時可能會發生的錯誤:

  1. public class CollectionMemory {
  2. public static void main(String s[]){
  3. Set<MyObject> objects = new LinkedHashSet<MyObject>();
  4. objects.add(new MyObject());
  5. objects.add(new MyObject());
  6. objects.add(new MyObject());
  7. System.out.println(objects.size());
  8. while(true){
  9. objects.add(new MyObject());
  10. }
  11. }
  12. }
  13. class MyObject{
  14. //設定預設陣列長度為99999更快的發生OutOfMemoryError
  15. List<String> list = new ArrayList<>(99999);
  16. }

    執行上面的程式碼將很快報錯:

  1. 3
  2. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  3. at java.util.ArrayList.<init>(ArrayList.java:152)
  4. at com.anxpp.memory.MyObject.<init>(CollectionMemory.java:21)
  5. at com.anxpp.memory.CollectionMemory.main(CollectionMemory.java:16)

    如果足夠了解Java的容器,上面的錯誤是不可能發生的。這裡也推薦一篇本人介紹Java容器的文章:...

    容器Set只存放唯一的元素,是通過物件的equals()方法來比較的,但是Java中所有類都直接或間接繼承至Object類,Object類的equals()方法比較的是物件的地址,上例中,就會一直新增元素直到記憶體溢位。

    所以,上例嚴格的說是容器的錯誤使用導致的記憶體溢位。

    就Set而言,remove()方法也是通過equals()方法來刪除匹配的元素的,如果一個物件確實提供了正確的equals()方法,但是切記不要在修改這個物件後使用remove(Object o),這也可能會發生記憶體洩露。

    各種提供了close()方法的物件

    比如資料庫連線(dataSourse.getConnection()),網路連線(socket)和io連線,以及使用其他框架的時候,除非其顯式的呼叫了其close()方法(或類似方法)將其連線關閉,否則是不會自動被GC回收的。其實原因依然是長生命週期物件持有短生命週期物件的引用。

    可能很多人使用過Hibernate,我們運算元據庫時,通過SessionFactory獲取一個session:

  1. Session session=sessionFactory.openSession();

    完成後我們必須呼叫close()方法關閉:

  1. session.close();

    SessionFactory就是一個長生命週期的物件,而session相對是個短生命週期的物件,但是框架這麼設計是合理的:它並不清楚我們要使用session到多久,於是只能提供一個方法讓我們自己決定何時不再使用。

    因為在close()方法呼叫之前,可能會丟擲異常而導致方法不能被呼叫,我們通常使用try語言,然後再finally語句中執行close()等清理工作:

  1. try{
  2. session=sessionFactory.openSession();
  3. //...其他操作
  4. }finally{
  5. session.close();
  6. }

    單例模式導致的記憶體洩露

    單例模式,很多時候我們可以把它的生命週期與整個程式的生命週期看做差不多的,所以是一個長生命週期的物件。如果這個物件持有其他物件的引用,也很容易發生記憶體洩露。

    內部類和外部模組的引用

    其實原理依然是一樣的,只是出現的方式不一樣而已。

 

與清理相關的方法

    本節主要談論gc()和finalize()方法。

    gc()

    對於程式設計師來說,GC基本是透明的,不可見的。執行GC的函式是System.gc(),呼叫後啟動垃圾回收器開始清理。

    但是根據Java語言規範定義, 該函式不保證JVM的垃圾收集器一定會執行。因為,不同的JVM實現者可能使用不同的演算法管理GC。通常,GC的執行緒的優先順序別較低。

    JVM呼叫GC的策略也有很多種,有的是記憶體使用到達一定程度時,GC才開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。除非在一些特定的場合,GC的執行影響應用程式的效能,例如對於基於Web的實時系統,如網路遊戲等,使用者不希望GC突然中斷應用程式執行而進行垃圾回收,那麼我們需要調整GC的引數,讓GC能夠通過平緩的方式釋放記憶體,例如將垃圾回收分解為一系列的小步驟執行,Sun提供的HotSpot JVM就支援這一特性。

    finalize()

    finalize()是Object類中的方法。

    瞭解C++的都知道有個解構函式,但是注意,finalize()絕不等於C++中的解構函式。

    Java程式設計思想中是這麼解釋的:一旦GC準備好釋放物件所佔用的的儲存空間,將先呼叫其finalize()方法,並在下一次GC回收動作發生時,才會真正回收物件佔用的記憶體,所以一些清理工作,我們可以放到finalize()中。

    該方法的一個重要的用途是:當在java中呼叫非java程式碼(如c和c++)時,在這些非java程式碼中可能會用到相應的申請記憶體的操作(如c的malloc()函式),而在這些非java程式碼中並沒有有效的釋放這些記憶體,就可以使用finalize()方法,並在裡面呼叫本地方法的free()等函式。

    所以finalize()並不適合用作普通的清理工作。

    不過有時候,該方法也有一定的用處:

    如果存在一系列物件,物件中有一個狀態為false,如果我們已經處理過這個物件,狀態會變為true,為了避免有被遺漏而沒有處理的物件,就可以使用finalize()方法:

  1. class MyObject{
  2. boolean state = false;
  3. public void deal(){
  4. //...一些處理操作
  5. state = true;
  6. }
  7. @Override
  8. protected void finalize(){
  9. if(!state){
  10. System.out.println("ERROR:" + "物件未處理!");
  11. }
  12. }
  13. //...
  14. }

    但是從很多方面瞭解,該方法都是被推薦不要使用的,並被認為是多餘的。

 


    總的來說,記憶體洩露問題,還是編碼不認真導致的,我們並不能責怪JVM沒有更合理的清理。





相關文章