String#intern したオブジェクトをロックオブジェクトとして使ってはいけない

「文字列をロックオブジェクトとして利用したい」場合がある。
例えば渡されたファイルに処理をするメソッドで、ファイルごとに排他処理をしたいとする。しかしファイルをそのままロックオブジェクトとして使ってはいけないのは明らかだ。File インスタンスは同じファイルを指していても別のインスタンスだからだ。

// これをやってはいけない!
public void doSomething(File file){
    synchronized(file){
        ...
    }
}


これを回避しようとして、ファイルの絶対パスをロックオブジェクトとして使う案が出てくる。しかしこれもやってはいけない。理由は上記ファイルと同様、同じ文字列を持つオブジェクトであっても別々のインスタンスであることがあるからだ。

// これをやってはいけない!
public void doSomething(File file){
    String path = file.getAbsolutePath();
    synchronized(path){
        ...
    }
}


ここでさらに次の案が浮かぶ。「intern メソッドで等しいオブジェクトにすれば良いのではないか?」と。

文字列オブジェクトの正準表現を返します。
文字列のプールは、初期状態では空で、クラス String によってプライベートに保持されます。

intern メソッドが呼び出されたときに、equals(Object) メソッドによってこの String オブジェクトに等しいと判定される文字列がプールにすでにあった場合は、プール内の該当する文字列が返されます。そうでない場合は、この String オブジェクトがプールに追加され、この String オブジェクトへの参照が返されます。

このため、任意の 2 つの文字列 s と t においては、s.equals(t) が true の場合にのみ、s.intern() == t.intern() は true になります。

すべてのリテラル文字列および文字列値定数式が保持されます。文字列リテラルは、『Java 言語仕様』の §3.10.5 で定義されています。

戻り値:
この文字列と同じ内容だが、一意の文字列のプールからのものであることが保証されている文字列

String#intern - Java Platform SE

このメソッドによって「同じ文字列なら同じオブジェクト」が保証されるので、下記のようなコードを書きたくなるかもしれない。

// これをやってはいけない!
public void doSomething(File file){
    String path = file.getAbsolutePath();
    synchronized(path.intern()){ // intern してロック
        ...
    }
}

しかしこれは、絶対にやってはいけない。


intern メソッドで得た文字列をロックオブジェクトとして使用してはいけない理由

intern メソッドを用いて返されるインスタンスJVM のメモリプール内のインスタンスを返す。違うインスタンスであっても同じ文字列なら等しいオブジェクトを返す。ここがポイント。「等しいオブジェクトを返す」のだから、そのオブジェクトは JVM 内でのグローバルオブジェクトと同等のオブジェクトである。つまり、別の箇所でそのオブジェクトをロックしていた場合不必要な同期を行うことになる上、ロックの順序によってはデッドロックを起こす。

自分がプロジェクト内の全コードのロック機構を把握している、かつライブラリ内のそれも含めて全て把握してるなら問題無い。しかし現実的にそんなことは不可能だろう。これだけコードが溢れている世の中で、メモリプールから返されるオブジェクト、つまりグローバルオブジェクトがどこでどうロックに使われているかを把握するのは不可能だ。「intern() された文字列をロックにつかうなんてやっているわけがない」と思っても、今現実にそんなコードを書こうとしているのだったらなおさらロックに用いてはいけない。

サンプルコード

早速試してみよう。下記コードは、ランダムに取得した文字列(といってもこの例では1文字だが)を intern してロックする。それを二度行う。

public class Sample {
    public static void main(String[] args) {
        // 文字列を二度ロックするクラスを2つ実行
        new Thread(new StringLock()).start();
        new Thread(new StringLock()).start();
    }
}

class StringLock implements Runnable {
    @Override
    public void run() {
        while (true) {
            // ランダムな文字を取得
            String str1 = RandomStringUtils.randomAlphabetic(1);
            String str2 = RandomStringUtils.randomAlphabetic(1);
            synchronized (str1.intern()) { // str1 をロック
                synchronized (str2.intern()) { // str2 をロック
                }
            }
        }
    }
}


上記コードを実行すると、たやすくデッドロックが発生する。スレッドダンプを出して確認しよう。


$ jstack 35053
...
"Thread-1":
at sample.StringLock.run(Sample.java:21)
- waiting to lock <0x00000006fb1454e8> (a java.lang.String)
- locked <0x00000006fb144cd0> (a java.lang.String)
at java.lang.Thread.run(Thread.java:679)
"Thread-0":
at sample.StringLock.run(Sample.java:21)
- waiting to lock <0x00000006fb144cd0> (a java.lang.String)
- locked <0x00000006fb1454e8> (a java.lang.String)
at java.lang.Thread.run(Thread.java:679)

Found 1 deadlock.

これはスレッドAのランダムな文字列が "X","Y" 、スレッドBのランダムな文字列が "Y","X" のように互いに同じ文字列で順序が反転している場合に起こる。

  1. スレッドA: "X" をロック
  2. スレッドB: "Y" をロック
  3. スレッドA: "Y" のロックを試みる
  4. スレッドB: "X" のロックを試みる

つまり

文字列の intern で返されるオブジェクトはどこでどう使用されているかわからないので、決してロックオブジェクトとして用いてはいけない。ある一箇所でしかロックしないと確信しても、今後別の箇所で使われるかもしれず、かつ今のプログラミング言語では別の箇所で使用してはいけないような保証をするすべは無い。
そして更にもう一点。intern していない通常の文字列オブジェクトが intern で返されるオブジェクトと異なることを Java は保証していないので、intern していない通常の文字列であってもロックに用いてはいけない。

回避策

名前を元に等しいロックオブジェクトを返すライブラリを作って回避する。このようなライブラリの作成は簡単だ。

public class NamedLocks {

    private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<String, Object>();

    // 名前に対応するロックオブジェクトを取得する
    public Object get(String name) {
        locks.putIfAbsent(name, new Object());
        return locks.get(name);
    }
}


上記クラスを用いて、冒頭のコードを下記のように修正すれば、望んだ通りの挙動になる。

private static NamedLocks LOCKS = new NamedLocks();

public void doSomething(File file){
    String path = file.getAbsolutePath();

    // 同じ文字列なら同じロックオブジェクトが返されるので OK
    synchronized(LOCKS.get(path)){
        ...
    }
}

これで、うかつにデッドロックが起こるような状況は避けられるだろう。


まとめ

どこで生成されたかわからないオブジェクトをロックオブジェクトに用いてはいけない。
ロックオブジェクトは自分の管理下に置き、ロックオブジェクトの生成を管理外のコードに譲ってはいけない。