深入理解 java 中的 Soft references & Weak references & Phantom reference

引言

Ethan Nicholas 在他的一篇文章中說:他面試了20多個Java高級工程師,他們每個人都至少有5年的Java從業經驗,當他問這些工程師對於Weak References 的理解時,只有其中的2個人知道Weak References 的存在,而這2個人中,只有1人知道如何去使用Weak References,而其他人甚至都不知道Java中有Weak References的存在。

大家可能會想,我接觸了Java這麼多年,從來都沒有使用過Weak References啊,它真的是一個有用的技術嗎?對於它有用沒用,我相信大家看完這篇文章,理解了它以後,自有判斷。如果你從業了3年以上的Java開發,你不知道如何使用Weak References也還可以原諒,有可能你的項目還沒有那麼複雜。但是如果你甚至都沒見過它在哪使用的,我覺得你可能讀的源碼太少了。我相信大家都知道ThreadLocal 這個類吧,你可以去看一下它的靜態內部類ThreadLocalMap, 如果你想見到更多它的應用,我給大家推薦個網站:searchcode,這個網站會根據你輸入的代碼片段,來搜索幾大源碼託管平臺上使用你輸入代碼片段的工程,大家可以輸入WeakReference試一試。

並不說只有你成爲一個Weak References方面的專家,你纔是一個優秀的Java開發者,但是,你至少要了解我們在什麼樣的場景下需要使用它,That’s enough.

由於理解Weak References && soft references 會涉及到JVM的垃圾收集的一些知識,如果你對這方面沒有了解,請你參考我的這篇文章:Hotspot虛擬機- 垃圾收集算法和垃圾收集器

Java中的4種reference

在Java中,有4種reference類型,它們從強到弱,依次如下:

  1. Strong reference : 大家平常寫代碼的引用都是這種類型的引用,它可以防止引用的對象被垃圾回收。
  2. Soft reference : 它引用的對象只有在內存不足時,纔會被回收。
  3. Weak reference : 它並不會延長對象的生命週期,即它不能阻止垃圾收集器回收它所引用的對象。
  4. Phantom reference : 它與上面的3種類型有很大的不同,它的get() 方法始終返回null,即通過這個引用,你甚至都不能獲取它所引用的對象,如果你看它的源碼,它的構造器必須要給定一個ReferenceQueue,當然了,你也可以把它設置爲空,但是這樣的引用 一點意義都沒有。我在下文中會結合Phantom reference的作用來解釋爲什麼會這樣。

在這一小節中,我總結了各個引用的作用。如果大家不太明白,沒關係,我會在下文中更詳細地解釋它們各自的用法。

Strong reference

如果大家對垃圾收集機制有所瞭解,你們就會知道JVM標記一個對象是否爲垃圾是根據可達性算法。 我們平常寫的代碼其實都是Strong reference,被Strong reference所引用的對象它會保持這個對象到GC roots的可達性,以防被JVM標記爲垃圾對象,從而被回收。比如下面的代碼就是一個Strong reference

String str = new String("hello world");

Soft reference

GC使Java程序員免除管理內存的痛苦,但是這並不意味着我們可以不關心對象的生命同期,如果我們不注意Java對象地生命週期,這很可能會導致Java出現內存泄露。

Object loitering

在我詳細解釋Soft reference之前,請大家先閱讀下面的這段代碼,仔細想一想它可能出現什麼樣的問題?

public class LeakyChecksum {
    private byte[] byteArray;
     
    public synchronized int getFileChecksum(String fileName) {
        int len = getFileSize(fileName);
        if (byteArray == null || byteArray.length < len)
            byteArray = new byte[len];
        readFileContents(fileName, byteArray);
        // calculate checksum and return it
    }
}

對於上面的程序而言,如果我把byteArray字節數組放到getFileChecksum方法中完全沒有問題,但是,上面的程序把byteArray字節數組從局部變量提升到實例變量會出現很多問題。比如,由於你需要共享byteArray變量,從而你不得不去考慮線程安全問題,而上面的程序在getFileChecksum方法上加上了synchronized 關鍵字,這大大降低了程序的可擴展性。

先不去深入討論上面程序出現的其它問題,讓我們來探討一下它出現的內存泄露問題。上述代碼的主要功能就是根據文件的內容去計算它的checksum,如果上述代碼的if 條件不成立,它會不斷地重用字節數組,而不是重新分配它。除非LeakyChecksum對象被gc,否則這個字節數組始終不會被gc,由於程序到它一直是可達的。而且更糟糕的是,隨着程序的不斷運行,這個字節數組只會不斷增大,不會減小,它的大小始終都和它處理過的最大的文件的大小一致,這樣很可能會導致JVM更頻繁地GC,降低應用程序地性能。大多數情況下,這個字節數組所佔的空間要比它實際要用的空間要大,而多餘的空間又不能被回收利用,這導致了內存泄露。

Soft references 解決上面的內存泄露問題

對於只被Soft references所引用的對象,我們稱它爲softly reachable objects. 只要可得到的內存很充足,softly reachable objects 通常不會被gc. JVM要比我們的程序更加了解內存的使用情況,如果可得到的內存緊張,那麼JVM就會頻繁地進行垃圾回收,從而釋放更多的內存空間,供我們使用。因此,上述程序的字節數組緩存由於一直是可達的,即使在內存很緊張的情況下,它也不會被回收掉,這無疑給垃圾收集器更大的壓力,使其更頻繁地GC.

那麼有沒有一種解決方案可以做到這樣呢,如果我們的內存很充足,我們就保持這樣的緩存在內存中,不被gc; 但是,當我們的內存吃緊時,就把它釋放掉。那麼大家想一想,誰可以做到這一點呢?答案是JVM,因爲它最瞭解內存的使用情況,我們可以藉助它的力量來達到我們的目標,而Soft references 可以幫我們Java 程序員藉助JVM的力量。下面,讓我們來看看如果用SoftReference 改寫上面的代碼。

public class CachingChecksum {
    private SoftReference<byte[]> bufferRef;
     
    public synchronized int getFileChecksum(String fileName) {
        int len = getFileSize(fileName);
        byte[] byteArray = bufferRef.get();
        if (byteArray == null || byteArray.length < len) {
            byteArray = new byte[len];
            bufferRef.set(byteArray);
        }
        readFileContents(fileName, byteArray);
        // calculate checksum and return it
    }
}

從上面的代碼我們可以看出,一旦走出if 語句,字節數組對象就只被Soft references 所引用,成爲了softly reachable objects. 對於垃圾收集器來說,它只會在真正需要內存的時候纔會去回收softly reachable objects. 現在,如果我們的內存不算吃緊,這個字節數組buffer會一直保存在內存中。在拋出OutOfMemoryError 之前,垃圾收集器一定會clear掉所有的soft references.

Soft references 與緩存

從上面的例子中,我們看到了如何用soft reference 去緩存1個對象,然後讓JVM去決定什麼時候應該把對象從緩存中清除。對於嚴重依賴緩存提升性能的應用而言,用Soft references 做緩存並不合適,我們應該去找一個更全面的緩存框架去做這件事。但是由於它 “cheap and dirty” 的緩存機制, 對於一些小的應用場景,它還是很有吸引力的。

Weak References

由於JVM幫我們管理Java程序的內存,我們總是希望當一個對象不被使用時,它會被立即回收,即一個對象的邏輯生命週期要與它的實際生命週期相一致。但是有些時候,由於寫程序人的疏忽,沒有注意對象的生命週期,導致對象的實際生命週期要比我們期望它的生命週期要長。這種情況叫做 unintentional object retention. 下面我們來看看由實例變量HashMap 導致的內存泄露問題。

HashMap 導致的內存泄露問題

用Map去關聯短暫對象的元數據很容易出現unintentional object retention 問題。比如你想關聯一個Socket連接與用戶的信息,由於你並不能干涉Socket 對象的實現,向裏面加用戶數據,因此最常用的做法就是用全局Map 來做這樣的事。代碼如下:

public class SocketManager {
    private Map<Socket,User> m = new HashMap<Socket,User>();
     
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
    public void removeUser(Socket s) {
        m.remove(s);
    }
}

通常情況下,Socket 對象的生命週期要比整個應用的生命週期要短,同時,它也會比用到它的方法調用要長。上述代碼把User 對象的生命週期與Socket 對象綁在一起,因爲我們不能準確地知道Socket連接在什麼時候被關閉,所以我們不能手動地去把它從Map中移除。而只要SocketManager 對象不死,HashMap 對象就始終是可達的。這樣就會出現一個問題,就是即使服務完來自客戶端的請求,Socket已經關閉,但是SocketUser 對象一直都不會被gc,它們會一直被保留在內存中。如果這樣一直下去,就會導致程序出現內存泄露的錯誤。

用 WeakHashMap 解決問題

既然上面的代碼有內存泄露的問題,我們應該如何解決呢?如果有一種手段可以做到,比如: 當Map中Entry的Key不再被使用了,就會把這個Entry自動移除,這樣我們就可以解決上面的問題了。幸運的是,Java團隊給我們提供了一個這樣的類可以做到這點,它就是WeakHashMap ,我們只要把我們的代碼做如下修改就行:

public class SocketManager {
    private Map<Socket,User> m = new WeakHashMap<Socket,User>();
     
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
}

哈哈,是不是很簡單,就把HashMap 替換成WeakHashMap 就行了。下面的內容引用自Java官方文檔 的說明:

Hash table based implementation of the Map interface, with weak keys. An entry in a WeakHashMap will automatically be removed when its key is no longer in ordinary use. More precisely, the presence of a mapping for a given key will not prevent the key from being discarded by the garbage collector, that is, made finalizable, finalized, and then reclaimed. When a key has been discarded its entry is effectively removed from the map, so this class behaves somewhat differently from other Map implementations.

理解 Weak references

WeakHashMap 爲什麼會有這麼神奇的功能,而我們的HashMap 卻沒有呢?下面是WeakHashMap 中的部分源碼:

    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;

        /**
         * Creates new entry.
         */
        Entry(Object key, V value,
              ReferenceQueue<Object> queue,
              int hash, Entry<K,V> next) {
            super(key, queue);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
        }
  }

大家可以看到,它的Entry 繼承了WeakReference 類,而我們的HashMap 卻沒有。再看它的構造函數,它的key被Weak references 所引用,但是它的value並沒有。接下來,我來解釋一下Weak references的作用。

一個只被Weak references所引用的對象,它被稱作weakly reachable object. 而這樣的對象不能阻止垃圾收集器對它的回收。 就像上面的源碼一樣,我們會在構造的時候,用Weak references去引用對象,如果被引用的對象沒有被gc,那麼可以通過WeakReferenceget() 方法去獲取被引用的對象。但是,如果被引用的對象已經被垃圾回收或者有人調用了WeakReference.clear() ,那麼get() 方法將始終返回null. 如果你想用get() 方法返回的結果,一個最佳的實踐就是你應該做一下非空檢查。總之,Weak reference並不會延長對象的生命週期。

現在我們回到上面那個Socket的問題上,當Socket 對象在其它地方被使用時,它不會被回收,而且我們依然可以用WeakHashMapget() 方法去獲取它相關聯的數據,但是一旦Socket連接被關閉,即我們不再需要這個對象時,WeakHashMap 將不能阻止Socket 對象被回收,因此這完全達到了我們想要的結果。下面,讓我們來看看WeakHashMapget() 方法的源碼:

public V get(Object key) {
    Object k = maskNull(key); // 如果給定的key是null,則用NULL_KEY
    int h = hash(k); // 根據key算出它的hash值
    Entry<K,V>[] tab = getTable();
    int index = indexFor(h, tab.length); // 找到當前hash值所對應的bucket下標
    Entry<K,V> e = tab[index];
    while (e != null) { // 如果有hash衝突的情況下,會沿着鏈表找下去
        if (e.hash == h && eq(k, e.get()))
            return e.value;
        e = e.next;
    }
    return null;
}

我已經把上面的代碼加了相應地註釋,相信大家理解起來會很容易。還有一點值得說的就是:由於給定的key已經被方法參數所引用,因此在get() 方法中,key並不會被垃圾回收。如果你想把WeakHashMap 變成線程安全的,你可以簡單地用Collections.synchronizedMap() 把它包裝一下就行。

Reference queues

大家現在可以看一看上面構造器的源碼,其中的一個參數對象是ReferenceQueue, 那麼問題來了,這個東西是幹什麼用的呢?再具體說它的作用之前,讓我們來探討一下上面Weak references存在的問題。

上面我已經說過了,只有key是被Weak references所引用的,這樣就會出現一個問題,只要SocketManager 對象不被gc,那麼WeakHashMap 對象就不會被gc,然後除非你手動地調用remove() 方法,不然它裏面的Entry 也不會被gc,那麼問題來了,即使你的key已經被gc了,但是key對應的value,整個Entry對象依然會被保留在內存中,如果一直這樣下去的話,就會導致內存泄漏。

那麼,我們如何解決上面出現的問題呢?按照一個正常人的思路來說,我覺得應該去週期性地掃描一下Map,根據get() 方法是否返回null來判斷當前Entry是否應該被清除。但是,如果Map中包含了大量的Entry,你這樣做會效率很低。現在,Reference queues 大顯身手的時候到了。如果在你構造Weak references的時候,你給它關聯一個ReferenceQueue 對象,那麼當一個Reference 對象被clear的時候,它會被放入給定的隊列當中。因此,你只需要從這個隊列中獲取Reference 對象,然後做相應地清理工作就行了。

WeakHashMap 中有個私有方法expungeStaleEntries,下面讓我們來看看它的源碼:

private void expungeStaleEntries() {
    for (Object x; (x = queue.poll()) != null; ) { // 遍歷引用隊列
        synchronized (queue) {
            @SuppressWarnings("unchecked")
                Entry<K,V> e = (Entry<K,V>) x; // Entry對象就是Weak references
            int i = indexFor(e.hash, table.length); // 根據hash值找到相應的bucket下標

            Entry<K,V> prev = table[i];
            Entry<K,V> p = prev;
            while (p != null) { // 在鏈表中找出給定key對應的Entry,然後清除指向它的引用
                Entry<K,V> next = p.next;
                if (p == e) {
                    if (prev == e)
                        table[i] = next;
                    else
                        prev.next = next;
                    // Must not null out e.next;
                    // stale entries may be in use by a HashIterator
                    e.value = null; // Help GC
                    size--;
                    break;
                }
                prev = p;
                p = next;
            }
        }
    }
}

對於上面的代碼我已經給出了相應地註釋,主要就是while 循環中的算法你需要好好理解一下,其實也不難,它其實就是從鏈表中移除一個節點。其實大家可以去看一看WeakHashMap 的源碼,它的大部分操作都會調用這個私有方法,比如上面的get 方法中的getTable 方法。

至此,我已經結合WeakHashMap 的源碼,把Weak references的知識講完了,相信有了這個強大的武器,大家可以更自如地控制對象地可達性了。

Phantom References

Phantom References 與上面的幾個引用存在很大的不同,至少上面的Reference 對象通過它們的get() 方法可以獲取到它們所引用的對象,但是,PhantomReference 的只會返回null. 大家可能會想,既然這個引用連對象都取不到,那要它有什麼用呢?如果你去看這個Reference 對象的源碼,你會發現只有PhantomReference 類的構造器必須指定一個ReferenceQueue 對象,而這就是重點,當然了,你也可以把它設置爲null,但是那樣將沒有任何意義。因此,Phantom reference 的唯一作用就是它可以監測到對象的死亡,即,當你的對象真正從內存中移除時,指向這個對象的PhantomReference 就會被加入到隊列中。 下面是一個有助於你理解PhantomReference 的Demo:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {
	private final static ReferenceQueue<Object> queue = new ReferenceQueue<>();
	
	public static void main(String[] args) {
		Person p1 = new Person("小明");
		Person p2 = new Person("小花");
		Animal a1 = new Animal(p1, "dog");
		Animal a2 = new Animal(p2, "cat");
		p1 = null;
		p2 = null;
		Runtime.getRuntime().gc();
		waitMoment(2000); // 給gc點時間收集,有時gc收集速度很快,可以不用加這句代碼,我只不過是保險起見
		printReferenceQueue(queue);
	}
	
	static class Person {
		private String name;

		public Person(String name) {
			this.name = name;
		}
		
		public String getName() {
			return name;
		}
	}
	
	static class Animal extends PhantomReference<Object>  {
		private String name;
		public Animal(Person referent, String name) {
			super(referent, queue);
			this.name = name;
		}
		public String getName() {
			return name;
		}
	}
	
	private static void waitMoment(long time) {
		try {
			Thread.sleep(time);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	
	private static void printReferenceQueue(ReferenceQueue<Object> rq) {
		int size = 0;
		Object obj;
		while ( ( obj = rq.poll() ) != null ) {
			Animal a = (Animal) obj;
			System.out.println(a.getName());
			size++;
		}
		System.out.println("引用隊列大小爲: " + size);
	}
}

一旦Person 對象被回收,那麼指向它的Animal 對象就會被放進隊列中,大家可以把上面的12,13行代碼註釋掉,看看有什麼不同的效果。還有一種場景有可能會用到Phantom reference,比如你已經把一張很大的圖片加載到內存當中,只有當你確定這張圖片從內存移除之後,我纔會加載下一張圖片,這樣可以防止內存溢出錯誤。

用Phantom reference 驗證不靠譜的finalize 方法

在這一小節中,我讓大家來看看Java中的finalize方法有多“不靠譜”。假設我現在有1個重寫了finalize 方法的對象obj,它被收集的過程如下:

  1. 假設現在有足夠多的垃圾使得JVM進行gc,並標記了obj 是不可達的
  2. 接着,它會把obj 放到finalization隊列中,等待執行它的finalize 方法
  3. 如果執行完它的finalize方法,再次gc時纔會把這個對象回收掉。如果這個方法一直沒有執行完,這個對象就一直不會被回收。

上面我只說了大致的過程,更詳細的請參考:When is the finalize() method called in Java?

下面來看我寫的一段Demo代碼:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo2 {
	private final static ReferenceQueue<Object> queue = new ReferenceQueue<>();
	
	public static void main(String[] args) {
		Person p = new Person();
		PhantomReference<Person> pr = new PhantomReference<>(p, queue);
		p = null; // 使Person對象變的不可達
		
		// 這次gc會把Person對象標記爲不可達的,由於它重寫了finalize,因此它會被放入到finalization隊列
		Runtime.getRuntime().gc();
		waitMoment(2000); // 給gc更多的時間去處理,並且去執行隊列中的finalize方法
		Runtime.getRuntime().gc(); // 再次發起gc,收集Person對象
		waitMoment(2000); // 給gc更多的時間去處理
		printReferenceQueue(queue); // 如果Person對象已經被回收,這個隊列中應該有值
	}
	
	static class Person {
		@Override
		protected void finalize() throws Throwable {
			System.out.println("finalize method in Person");
		}
	}
	
	private static void waitMoment(long time) {
		try {
			Thread.sleep(time);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	private static void printReferenceQueue(ReferenceQueue<Object> rq) {
		int size = 0;
		Object obj;
		while ( ( obj = rq.poll() ) != null ) {
			System.out.println(obj);
			size++;
		}
		System.out.println("引用隊列大小爲: " + size);
	}
}

上面的代碼我已經加了很詳細的註釋,這裏我們用Phantom reference 去監控Person 對象的存活狀態。大家再看看下面的代碼:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo2 {
	private final static ReferenceQueue<Object> queue = new ReferenceQueue<>();
	
	public static void main(String[] args) {
		BlockFinalization bf = new BlockFinalization();
		PhantomReference<BlockFinalization> prbf = new PhantomReference<>(bf, queue);
		bf = null; // 使BlockFinalization對象變的不可達
		// 我讓BlockFinalization對象中的finalize方法睡了1000秒,這樣會導致主線程即使結束,finalize方法也不會執行完
		Runtime.getRuntime().gc();
		
		Person p = new Person();
		PhantomReference<Person> pr = new PhantomReference<>(p, queue);
		p = null; // 使Person對象變的不可達
		
		// 這次會把Person對象放入到finalization隊列
		Runtime.getRuntime().gc();
		waitMoment(2000);
		Runtime.getRuntime().gc();
		waitMoment(2000);
		// 如果這2個對象中的finalize方法不被執行完,它們都不會被回收,根據隊列輸出的值就可以看出來了
		printReferenceQueue(queue);
	}
	
	static class BlockFinalization {
		@Override
		protected void finalize() throws Throwable {
			System.out.println("finalize method in BlockFinalization");
			Thread.sleep(1000000);
		}
	}
}

上面的代碼有時會很詭異,在我的Ubuntu系統上,有時會出現先執行Person 中的finalize 方法的可能,這樣就會導致Person 會被垃圾回收,如果是BlockFinalization 對象中的方法先被執行,那麼2個對象都不會被回收。大家可以把第31行代碼註釋掉,看看效果。從上面的例子可以看到,finalize方法是非常不靠譜的,它不但有可能會導致對象無法回收,而且還有可能出現在程序的生命週期之內不被執行的可能。

所以建議大家千萬不要使用這個方法,如果你非要使用它,我建議你去看Effective Java中的Item 7: Avoid finalizers ,這本書中介紹了關於使用它的2個場景,以及一些技巧。

垃圾收集器如何對待 Reference 對象

如果你去看WeakReferenceSoftReferencePhantomReference的源碼,你會發現它們都繼承了抽象的 Reference 類。那麼現在我們來看看當垃圾收集器遇到 Reference 對象時,是如何對待它們的?

當垃圾收集器在追蹤heap,遇到Reference 對象時,它並不會標記Reference 對象所引用的對象。它會把遇到的Reference 對象放到一個隊列中,追蹤heap過後,它會標識出softly reachable objects。垃圾收集器會基於當前GC回收的內存大小和其它的一些原則,來決定soft references是否需要被clear. 大家可以看一看Reference 類的clear 方法。

如果垃圾收集器決定clear這些soft references ,並且這些soft references有相應地ReferenceQueue ,那麼這些被clear 的Reference 對象會被放到ReferenceQueue 隊列中。注意:clear Reference 對象並把它放入到隊列中是發生在被引用對象的finalization 或 garbage collection 實際發生之前。

如果垃圾收集器並不打算clear這些Reference 對象,那麼它們對應地softly reachable objects會被當作GC roots,並用這些GC roots繼續追蹤heap,使得這些通過soft references可達的對象被標記。

處理完soft references 過後,接下來會找出weakly reachable objects. Weak references 會被直接clear掉,然後放到對應地 ReferenceQueue中。

所有的Reference 類型都會在放入ReferenceQueue 前被clear掉,因此後續的處理你將不可能訪問到Reference 類型引用的對象。

由於要特殊地對待Reference 類型,因此在垃圾收集的過程中,無疑會增加額外的開銷。如果Reference 對象不被放到對應地ReferenceQueue 中,那麼它本身也會被回收的,而且它可能會在它的引用對象回收之前被回收。

參考資料

Plugging memory leaks with soft references

Plugging memory leaks with weak references

Understanding Weak References Blog

What is the difference between a soft reference and a weak reference in Java?

When is the finalize() method called in Java?

Have you ever used Phantom reference in any project?

What is the difference between a soft reference and a weak reference in Java?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章