マルチスレッドにおけるMapオブジェクト

身近でjava.util.HashMapのインスタンスがスレッドセーフでない事に起因したトラブルの話があったので、実際にサンプルを作って試してみました。

まず各スレッドで動作するRunnableなworkerクラス。

import java.util.Map;

public class Worker implements Runnable {

	private Map<String, String> targetMap;

	public void setMap(Map<String, String> targetMap) {
		this.targetMap = targetMap;
	}

	public void run() {
		String threadId = String.valueOf(Thread.currentThread().getId());
		for (int i = 0; i < 100; i++) {
			try {
				targetMap.put(threadId, threadId);
				Thread.sleep(3);
				targetMap.remove(threadId);
			} catch (InterruptedException ignore) {
			}
		}
	}
}

次々にスレッドを起動するmainメソッドのクラス。

import java.util.HashMap;
import java.util.Map;

public class SampleMain {

	public static void main(String[] args) throws InterruptedException {
		Map<String, String> targetMap = new HashMap<String, String>();
		for (int i = 0; i < 100; i++) {
			Worker worker = new Worker();
			worker.setMap(targetMap);
			Thread t = new Thread(worker);
			t.start();
			Thread.sleep(8);
		}
		Thread.sleep(10000);
		System.out.println(targetMap.size());
	}

}

メインから100スレッドをスタートさせ、各スレッドの中では、共有するMapオブジェクトに対して自分のスレッドIDの文字列をキーにした要素をputしたりremoveしたりを繰り返しているだけのサンプルです。

論理的には最後の出力は必ず「0」になるはずです。実際、これを1CPUの環境で実行すると必ず結果は「0」となります。

しかし、2CPUの環境で数回実行してみると、結果は以下の通りバラバラ。

スレッド数や各スレッドの中の処理回数を増やせば、この値はより大きくなります。

0
0
-4
2
2
1
-3
-9
0
-3


では、マルチスレッド環境でMapオブジェクトを共有しなければならない場合にどうするかというと、以下の実装が考えられます。


まずは触るときに必ずsynchronizeするやり方ですが、これは破綻が目に見えているので難しいと思います。

// Worker.java
synchronized (targetMap) {
	targetMap.put(threadId, threadId);
}
Thread.sleep(3);
synchronized (targetMap) {
	targetMap.remove(threadId);
}


Javaのバージョンを問わない対策としては、Collections.synchronizedMapを使う方法があります。このメソッドから返されたオブジェクトはアクセスが同期化されます。
http://java.sun.com/j2se/1.3/ja/docs/ja/api/java/util/Collections.html#synchronizedMap(java.util.Map)

// SampleMain.java
Map<String, String> targetMap = Collections.synchronizedMap(new HashMap<String, String>());


Java5以降であれば、HashMapクラスでインスタンス化するのはやめてConcurrentHashMapクラスを使うことができます。
http://java.sun.com/j2se/1.5.0/ja/docs/ja/api/java/util/concurrent/ConcurrentHashMap.html

// SampleMain.java
Map<String, String> targetMap = new ConcurrentHashMap<String, String>();


ConcurrentHashMapクラスがどうやって高速な同期を実現しているかについての説明が以下のURLにありました。
http://www.itarchitect.jp/technology_and_programming/-/24161.html

このエントリの主題からは逸れますが、特にvolatile変数に関する説明のくだりはとても参考になりました。

JSR 133以前のメモリ・モデル、すなわちJava言語仕様第2版の17章に規定されているメモリ・モデルでは、volatile変数に関しては順序関係が必ず反映されるが、非volatile変数に関しては反映されるとは限らなかった。上の例で言えば、スレッドT1がvolatile変数に対する書き込み処理を行う前に実行したことでも、それが非volatile変数に対して行われた更新操作であった場合は、必ずしもスレッドT2に反映されるとは限らない。

  JSR 133ではより制限を強め、作業メモリとメイン・メモリの同期に関して、volatile変数とロックが同じように動作するように定めている。すなわち、 volatile変数に書き込むと作業メモリの内容をメイン・メモリに書き出し、volatile変数を読み出すと作業メモリの内容を無効にする。したがって、JSR 133により、ロックを利用しなくとも、共通のvolatile変数を持っていれば、2つのスレッド間に明確な順序づけを行うことが可能になった。これにより、volatile変数に書き込みを行うスレッドで、その書き込み処理以前に行ったすべての処理(非volatile変数を含む)が、 volatile変数に読み出しを行うスレッドに反映されることになる。

 volatile変数による順序づけは、ロックによる順序づけよりもコストがかからない。クラスConcurrentHashMapでは、これを利用して、以下のような処理を行っている。

○書き込みメソッドでは、すべての処理の終了後にvolatile変数に対する書き込み処理を行う
○読み出しメソッドでは、いちばん先にvolatile変数の読み出し処理を行う

http://www.itarchitect.jp/technology_and_programming/-/24161-4.html