Java中的引用

qq_42687144發表於2020-11-15

介紹

在Java中有四種型別的引用:

  • 強引用
  • 軟引用
  • 弱引用
  • 虛引用

這些引用的區別僅在於垃圾收集器的處理方式。如果你從來沒有聽說過這些引用,說明你一直在使用強引用。瞭解這些區別是很有幫助的,尤其是在你需要儲存臨時物件同時又無法使用eHcache或者Guava等快取庫時。

由於這些引用型別都與JVM的垃圾收集器高度相關,所以先對Java中的垃圾收集器做一個簡單的介紹,然後開始分析這些不同的引用型別。

垃圾回收器

Java與C++的主要區別之一就在於記憶體管理。在Java中,開發人員不需要了解儲存部分是如何工作的,因為JVM會解決垃圾回收的問題。

當你建立一個物件時,JVM會在堆記憶體中為其分配空間。堆記憶體的空間是有限的,因此JVM通常需要刪除一些物件來釋放空間。如果要銷燬一個物件,JVM需要先知道這個物件是否有效。如果一個物件被一個垃圾回收根節點(傳遞式)引用,那麼該物件仍然有效。

舉例來說:

  • 如果物件C被物件B引用,物件B被物件A引用,而物件A被一個垃圾回收根節點引用,那麼C、B、A物件都被當做有效的(情形1)。
  • 如果物件B不再被物件A引用,那麼物件C和B也就變為無效物件,可以被銷燬(情形2)。

引用舉例

由於本文並不是關於垃圾回收器的,這裡不再深入介紹,只列出JVM中的四類垃圾回收根節點供參考:

  1. 區域性變數
  2. 活躍Java執行緒
  3. 靜態變數
  4. JNI引用,即包含原生程式碼,儲存不受JVM管理的Java物件。

Oracle沒有指定如何管理記憶體,因此每個JVM各自都有一系列演算法。但是內在的思想都是一致的:

  • JVM使用一個遞迴演算法查詢並標記無效物件

  • 被標記的物件會被析構(呼叫*finalize()*方法)並銷燬

  • JVM有時會移動部分存活物件,以期在堆記憶體中構建大段的連續可用空間。

問題

既然JVM會管理記憶體,為什麼開發者還需要關注這些?因為無法保證不會出現記憶體洩漏

其實你在大多數時間都在使用垃圾回收根節點,只是你沒有意識到。舉例說,假設你需要在你的程式的生命週期記憶體儲一些物件(因為這些物件的初始化代價很大),你可能會使用一個靜態集合(List,Map等),方便在程式碼中的任何地方對這些物件進行儲存或訪問。

但是,如果這樣做的話,將會阻止JVM銷燬集合中的那些物件,甚至一時疏忽就會出現記憶體溢位OutOfMemoryError錯誤。舉例來說:

public class OOM {
    public static List<Integer> myCachedObjects = new ArrayList<>();
 
    public static void main(String[] args) {
        for (int i = 0; i < 100_000_000; i++) {
            myCachedObjects.add(i);
        }
    }
}
複製程式碼

輸出資訊是:

Exception in thread “main” java.lang.OutOfMemoryError: Java heap space

Java提供了不同型別的引用來避免記憶體溢位錯誤。對於其中一些引用型別來說,即使這些物件被程式需要,仍然允許JVM釋放這些物件,相應地,處理這些情況就成為了開發者的責任。

強引用

強引用是Java中的標準引用。當使用如下方式建立一個物件obj時:

MyClass obj = new MyClass()
複製程式碼

實際上,你建立了一個名為“obj”的強引用,指向了新建的MyClass的例項。當垃圾回收器查詢無效物件時,只會檢查物件是否具有強可達性,即是否能從垃圾回收根節點通過強引用傳遞式地連結到該物件。

使用這一類引用會強制JVM將這些物件保留在堆記憶體中,直到這些物件如“垃圾回收器”一節所述不再被使用時,才會被回收。

軟引用

根據Java API,軟引用的特點為:

“軟引用物件,由垃圾收集器根據記憶體需求自行決定是否回收”

也就是說,你在不同的JVM(Oracle HotSpot,Oracle JRockit,IBM J9等)上執行程式時,軟引用物件的執行狀態可能會有所不同。

我們來看看Oracle的虛擬機器HotSpot(規範且使用最多的JVM)是如何管理軟引用的。根據Oracle的文件:

“預設值為每MB 1000 ms,也就是說堆記憶體中每多1 MB的可用空間,軟引用就可以多存活1s(在物件的最後一個強引用被回收之後)”

這裡有一個具體的例子:假設堆記憶體大小為512MB,並且有400MB空閒。新建一個物件A,通過軟引用被物件cache引用,同時被物件B強引用。由於A被B強引用,因此它具有強可達性,不會被垃圾回收器刪除(情形1)。

假設B被刪除,則A僅被物件cache軟引用。如果物件A在接下來的400秒內沒有被強引用,則其將在超時之後被刪除(情形2)。

軟引用

下面是使用軟引用的對應程式碼:

public class ExampleSoftRef {
    public static class A{
 
    }
    public static class B{
        private A strongRef;
 
        public void setStrongRef(A ref) {
            this.strongRef = ref;
        }
    }
    public static SoftReference<A> cache;
 
    public static void main(String[] args) throws InterruptedException{
        //初始化cache,通過軟引用指向實體A
        ExampleSoftRef.A instanceA = new ExampleSoftRef.A();
        cache = new SoftReference<ExampleSoftRef.A>(instanceA);
        instanceA=null;
        // 實體A現在只有軟可達性,可以被垃圾回收器刪除
        Thread.sleep(5000);
 
        ...
        ExampleSoftRef.B instanceB = new ExampleSoftRef.B();
        //由於cache僅有指向instanceA的軟引用,我們無法斷定instanceA是否仍然存在
        //我們需要進行判斷,如果需要還得新建一個instanceA
        instanceA=cache.get();
        if (instanceA ==null){
            instanceA = new ExampleSoftRef.A();
            cache = new SoftReference<ExampleSoftRef.A>(instanceA);
        }
        instanceB.setStrongRef(instanceA);
        instanceA=null;
        //instanceA目前被cache軟引用,同時被instanceB強引用,因此不會再被垃圾回收器清除
 
        ...
    }
}
複製程式碼

即使軟引用指向的物件被垃圾回收器自動刪除,軟引用(引用本身也是物件)並沒有被刪除。因此,你還是需要清除它們。舉例來說,假如堆記憶體容量較小比如64MB(Xmx64m),下面的程式碼會由於軟引用的使用發生記憶體溢位異常。

public class TestSoftReference1 {
 
    public static class MyBigObject{
        //each instance has 128 bytes of data
        int[] data = new int[128];
    }
    public static int CACHE_INITIAL_CAPACITY = 1_000_000;
    public static Set<SoftReference<MyBigObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
 
    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            MyBigObject obj = new MyBigObject();
            cache.add(new SoftReference<>(obj));
            if (i%200_000 == 0){
                System.out.println("size of cache:" + cache.size());
            }
        }
        System.out.println("End");
    }
}
複製程式碼

輸出內容為:

size of cache:1 size of cache:200001 size of cache:400001 size of cache:600001 Exception in thread “main” java.lang.OutOfMemoryError: GC overhead limit exceeded

Oracle提供了ReferenceQueue,裡面儲存這軟引用,這些軟引用都指向只具備軟可達性的物件。使用這個佇列,就可以清除軟引用並避免記憶體溢位錯誤。

使用ReferenceQueue,上面同樣大小的堆記憶體(64MB)同樣的程式碼卻可以支援更多的資料執行(5000000 vs 1000000):

public class TestSoftReference2 {
    public static int removedSoftRefs = 0;
 
    public static class MyBigObject {
        //each instance has 128 bytes of data
        int[] data = new int[128];
    }
 
    public static int CACHE_INITIAL_CAPACITY = 1_000_000;
    public static Set<SoftReference<MyBigObject>> cache = new HashSet<>(
            CACHE_INITIAL_CAPACITY);
    public static ReferenceQueue<MyBigObject> unusedRefToDelete = new ReferenceQueue<>();
 
    public static void main(String[] args) {
        for (int i = 0; i < 5_000_000; i++) {
            MyBigObject obj = new MyBigObject();
            cache.add(new SoftReference<>(obj, unusedRefToDelete));
            clearUselessReferences();
        }
        System.out.println("End, removed soft references=" + removedSoftRefs);
    }
 
    public static void clearUselessReferences() {
        Reference<? extends MyBigObject> ref = unusedRefToDelete.poll();
        while (ref != null) {
            if (cache.remove(ref)) {
                removedSoftRefs++;
            }
            ref = unusedRefToDelete.poll();
        }
 
    }
}
複製程式碼

輸出內容為:

End, removed soft references=4976899

當你需要儲存很多物件,而且這些物件一旦被JVM刪除,也可以(花費一些代價)重新例項化,那麼軟引用還是比較有用的。

弱引用

弱引用是比軟引用更不穩定的一個概念。根據Java API的描述:

“假設垃圾收集器在某個時間點確定物件是弱可達的。 那時它將自動清除指向該物件的所有弱引用,對於通過強引用或弱引用與該物件關聯的其它弱可達物件,也會清除指向這些物件的所有弱引用,通過一系列強引用和軟引用可以從該物件到達該物件。 同時,它將宣告之前所有弱可達的物件為可終結的(finalizable)。 在同一時間或稍後,垃圾回收器會將新清除的弱引用放入建立弱引用物件時所指定的引用佇列”

也就是說,當垃圾回收器檢查所有物件時,如果發現某個物件僅通過弱引用與垃圾回收根節點關聯(即沒有強引用或者軟引用連結到該物件),該物件會被立即標記為可清除的。使用WeakReference的方法與使用SoftReference的方法是相同的,可以直接參考“軟引用”一節的示例。

Oracle提供了一個基於弱引用的類:WeakHashMap,在這個類中可以使用弱引用的鍵值。WeakHashMap可以當做標準的Map來使用,唯一的區別在於當鍵從堆中被銷燬後,它會自動完成自我清除

public class ExampleWeakHashMap {
    public static Map<Integer,String> cache = new WeakHashMap<Integer, String>();
 
    public static void main(String[] args) {
        Integer i5 = new Integer(5);
        cache.put(i5, "five");
        i5=null;
        // {5,"five"} 會一直存活到下次垃圾回收
 
        Integer i2 = 2;
        // {2,"two"} 會在Map中存活到,i2不再具有強可達性
        cache.put(i2, "two");
 
        //這裡不會出現OutOfMemoryError
        // 因為Map會清除其中的項
        for (int i = 6; i < 100_000_000; i++) {
            cache.put(i,String.valueOf(i));
        }
    }
}
複製程式碼

舉例說明,我們使用WeakHashMap儲存多筆交易資訊,使用的結構如下:WeakHashMap<String,Map<K,V>>,其中WeakHashMap的鍵值為包含交易ID的字串,對應的Map中儲存的是交易期間所有需要儲存的資訊。使用該結構的優勢在於,我們一定可以從WeakHashMap中獲取到需要的資訊,因為鍵值字串中包含的交易ID直到交易之後才會被銷燬,同時我們也無需考慮清除Map結構。

Oracle建議將WeakHashMap作為規範對映結構使用。

虛引用

在垃圾回收器的處理過程中,與垃圾回收根節點之間沒有強/軟引用連線的物件將會被清除。在這些物件被清除之前,會呼叫其對應的finalize()方法。當一個物件被析構且還未被清除時,它就變為具有“虛可達性”,也即是說,在該物件與垃圾回收根節點之間只有虛引用。

與弱引用及軟引用不同,顯式使用虛引用可以防止物件被清除。程式設計師需要顯式或隱式地移除虛引用,如何其指向的可終結的物件才會被銷燬。如果要隱式地清除一個虛引用,一般都需要使用ReferenceQueue,當一個物件被析構,該佇列中就會插入一個虛引用。

通過虛引用無法獲得其指向的物件:虛引用的get()方法通常會返回null,因此程式設計人員無法將一個虛可達的物件重新置為強/軟/弱可達。這是有道理的,因為虛可達物件已經被析構,所以如果覆蓋finalize()函式已經清除了物件所需資源,該物件可能不再起作用。

虛物件幾乎沒有什麼用途,因為其指向的物件無法訪問。一個可能的使用場景就是能在物件被GC時收到系統通知。

總結

大多數情況下,我們並不會顯示地使用這些引用,但是很多框架內部都會使用它們,如果你想理解框架的工作原理,那麼瞭解這些知識就是比較有用的。

相關文章