2013年6月10日 星期一

WeakHashMap 的使用

通常使用 HashMap 的目的,就是希望在記憶體中存放一張列表,而需要列表中的某個東西時,能夠很快地把想要的物件取出來。
但有時又希望當 HashMap 上的物件沒有使用時,能夠從記憶體中消失~
一般狀況下這必須要由程式設計師手動處理,也就是當確定某個 Key 不再需要時,手動將它從 HashMap 中移除。
不過也許也可以利用 Java 的 Weak Reference 特性來處理,即 WeakHashMap 這個物件。

參考資料中,我個人認為最值得看的,大概還是 [1-2] 吧。其中 [1] 就是官方的文件,上面其實就有蠻詳細的使用說明了~。

大體來說,WeakHashMap 是對 Key 做 Weak Reference
也就是當沒有任何 Strong Reference 指向那個 Key 時,Key 就有可能在任意時間自動被垃圾回收。
因此文件中會說 WeakHashMap 不保證只單純每次呼叫 size() 時,都會回應一樣的數字;呼叫 get() 時,每次都會回應一樣的結果
因為有可能有些原本存在的 Key,在某個時間點後自動就不在了。

具體來說,到底什麼狀況會讓 Key 從 WeakHashMap 中消失呢?可以參考 [2] 中網友 Yanflea 的回應。
以下轉錄網友 Yanflea 貼的簡易範例:

public class WeakMapTest {
  public static void main(String[] args) {
    // -- Fill a weak hash map with one entry
    WeakHashMap<Data, String> map = new WeakHashMap<Data, String>();
    Data someDataObject = new Data("foo");
    map.put(someDataObject, someDataObject.value);
    System.out.println("map contains someDataObject ? " + map.containsKey(someDataObject));

    // -- now make someDataObject elligible for garbage collection...
    someDataObject = null;

    for (int i = 0; i < 100000; i++) {
      if (map.size() != 0)
        System.out.println("At iteration " + i + " the map still holds the reference on someDataObject");
      else {
        System.out.println("somDataObject has finally been garbage collected at iteration " + i + ", hence the map is now empty");
        break;
      }
    }
  }

  static class Data {
    String value;

    Data(String value) {
      this.value = value;
    }
  }
}

在我的電腦上的執行結果如下:
map contains someDataObject ? true
At iteration 1 the map still holds the reference on someDataObject
At iteration 2 the map still holds the reference on someDataObject
...
At iteration 43018 the map still holds the reference on someDataObject
At iteration 43019 the map still holds the reference on someDataObject
somDataObject has finally been garbage collected at iteration 43020, hence the map is now empty

可以看出插入的 someDataObject 這個 Key 在被設定為 null 之後,過一段時間會自動從 WeakHashMap 中消失。
而如果把 someDataObject = null 那行註解掉,Key 就會一直留在記憶體裡面。
一直留在記憶體裡面的原因就是因為存在一個 "someDataObject" 這個變數,是使用 Strong Reference reference 到 someDataObject 的。
把 someDataObject 指定為 null,就是讓那個 Strong Refernece 消失,這時 Key 就只有單獨被 WeakHashMap reference 到而已。

若是換成多執行緒的環境呢?簡單從上述的範例改編成另外一個小實驗如下:
public class WeakMapTest {
  public static WeakHashMap<UUID, ReentrantReadWriteLock> map = null;
  public static Thread a = null, b = null;
  
  public static void main(String[] args) {
        // -- Fill a weak hash map with one entry
        map = new WeakHashMap<UUID, ReentrantReadWriteLock>();
        
        a = new Thread(new ThreadA());
        b = new Thread(new ThreadB());
        
        a.start();
        b.start();
    }
  
  private static class ThreadA implements Runnable {
    @Override
    public void run() {
      System.out.println("A: Start.");
      try {
        Thread.currentThread().sleep(10000);
      } catch (Exception e) {
        System.out.println("A: Start checking key.");
      }
      
      for (int i = 0; i < 1000000; i++) {
              if (map.size() != 0) {
                  System.out.println("A: At iteration " + i + " the map still holds the reference on key");
              } else {
                  System.out.println("A: Key has finally been garbage collected at iteration " + i + ", hence the map is now empty");
                  break;
              }
          }
    }
  }
  
  private static class ThreadB implements Runnable {
    @Override
    public void run() {
      System.out.println("B: Start.");
      System.out.println("B: Put key into map.");
      UUID id = UUID.randomUUID();
          String randUUID = id.toString();
          map.put(id, new ReentrantReadWriteLock());
          System.out.println("B: Map size is " + map.size());
          
          a.interrupt();
    }
  }
}

main() 初始化了 WeakHashMap 之後,同時啟動 Thread A 和 Thread B
ThreadA 一啟動時會先暫停執行,等待 Thread B 對 WeakHashMap 插入資料~
Thread B 產生一個 UUID 並插入到 WeakHashMap 以後,叫醒 Thread A,然後自己就執行結束了。
這時理論上 Thread B 會因為執行結束,持有的 Strong Reference 會消失,就會只剩下 WeakHashMap 有參考到那個 Key。

執行結果如下:
A: Start.
B: Start.
B: Put key into map.
B: Map size is 1
A: At iteration 1 the map still holds the reference on key
A: At iteration 2 the map still holds the reference on key
...
A: At iteration 40196 the map still holds the reference on key
A: At iteration 40197 the map still holds the reference on key
A: Key has finally been garbage collected at iteration 40198, hence the map is now empty

結果一如預期~。
由此可見,如果想要 Key 不被刪除,必須要想個辦法留住 Strong Reference 才行。

但老實說,這是個有點 tricky 的事情,WeakHashMap 似乎不太適合作為 cache 之類的?因為它做 Weak Reference 的地方是 Key 而不是 Value。
在 WeakHashMap 的官方文件中,有提到一件蠻重要的事情~
This class will work perfectly well with key objects whose equals methods are not based upon object identity, such as String instances. With such recreatable key objects, however, the automatic removal of WeakHashMap entries whose keys have been discarded may prove to be confusing.
其實我並沒有完全了解這段話的意思是什麼,但依據目前的測試,如果說我的 Key 是用某種 Object 來產生
在多執行緒的環境中,其他執行緒如果想取得同樣的 Value,可以透過一般的 HashMap 操作方法
先產生 Key(例如 UUID.fromString() 之類的方法),再用產生的 Key 去 WeakHashMap 中取得 Value。
取得 Value 是可以正常取得正確的 Value,但這似乎並沒有表示新產生的 Key 就是對 WeakHashMap 上的 Key 的 Strong Reference?!
在我的測試中,即使我用另一個 Thread 產生了同樣的 UUID,然後去 WeakHashMap 中取得了 UUID 對應的 Value
那個 UUID 仍然會在一會兒之後自動從 WeakHashMap 中消失。

這個行為其實應該是可以想像的,因為不論透過什麼方法,重新產生一個 instance 時,所指向的記憶體位置一定不會跟原本一樣
而依據 WeakHashMap 官方文件中的說明,它會用 == 來做 Object Identification
也就是必須指向同一個記憶體位置時,才會認定那個 Key 有被 Strong Reference 到。
那問題就來了,在多執行緒的環境之下,到底要怎麼樣可以在其他 Thread 裡產生同一個記憶體位置的連結呢?
要怎麼樣才能保持一個 Key 不被自動回收?.....

參考資料中的 [5-6] 都在說,WeakHashMap 其實不應該用來實作 Cache,有興趣可以參考看看.....
對於這種實際需要的是 Weak Value 而不是 Weak Key 的狀況,可以參考續篇「利用 Guava 的 MapMaker 產生 weak value 版的 WeakHashMap
利用 Guava 這個 Open Source Library 來產生需要的 Map。

參考資料:
1、java.util.WeakHashMap
2、WeakHashMap example
3、WeakHashMap的神话
4、深入理解WeakHashmap
5、Java's WeakHashMap and caching: Why is it referencing the keys, not the values?
6、WeakHashMap is not a cache! Understanding WeakReference and SoftReference

沒有留言: