面接官「Stringを==とequalsで比較する際の違いを教えてください」
自分「== はインスタンスの同一性を比較する一方、equalsはObjectによって定義されるメソッドで、同値性の比較に使われます。」
自分「従ってStringを比較するとき、==では同じ文字列でも違うインスンスの場合にfalseが返って来てしまうことがあるため、通常はequalsを使用します。」
面接官「はい、完璧です。」
自分「より詳細に言うと、Stringを定数として使用する場合はJVMのLDC命令によって定数プールからインスタンスが確保されるため、これら定数プールの文字列を比較する場合は==でもtrueが返ってきます。」
自分「逆に、文字列をプログラムで動的に構築する場合は定数プールとは関係ない全く新しいStringインスタンスが構築されるため、==はfalseとなります。」
面接「なるほど。」
String Constant Pool(文字列定数プール)とは
Javaで文字列リテラルを表現する場合、文字列は定数プールから確保される。これにより、同じ文字列が登場した場合にメモリの使用量を削減することができる。
String a = "test";
String b = "test";
System.out.println(a == b); // true
ただし、文字列をプログラムで動的に構築する場合は新しくStringインスタンスが構築される。
String a = "t";
String b = "t";
for(int i = 0; i < 3; i++) {
a += "e";
b += "e";
}
System.out.println(a == b); // false
自分「以上によりStringは==で比較できないわけですが、String#internメソッドを使用することで、文字列を明示的に定数プールに格納することができます。これにより、LDC命令により確保された文字列と動的に確保された文字列を==で比較することが可能になります。」
面接官「(へぇー)」
自分「今、internメソッドなんて使ったことないしどうでもいい、そう考えましたね?確かに文字列を==で比較するためにわざわざinternメソッドを使用することはありません。ですがsynchronizedとinternを組み合わせることで、複雑な同期処理が簡単にできるようになる場合があります。」
面接官「(この人怖い...)」
String#internとは
Javadocより抜粋
public String intern()
文字列オブジェクトの正準表現を返します。
文字列のプールは、初期状態では空で、クラスStringによってプライベートに保持されます。
internメソッドが呼び出されたときに、equals(Object)メソッドによってこのStringオブジェクトに等しいと判定される文字列がプールにすでにあった場合は、プール内の該当する文字列が返されます。そうでない場合は、このStringオブジェクトがプールに追加され、このStringオブジェクトへの参照が返されます。
したがって、任意の2つの文字列sとtについて、s.intern() == t.intern()がtrueになるのは、s.equals(t)がtrueの場合だけです。
どういうことかというと、internを呼び出したとき
- 文字列が定数プールにあるか確認
- ある場合は定数プールの文字列を返す。ない場合は定数プールに文字列を追加してStringを返す。
という挙動になる。以下のコードを見て欲しい。
String a = "teee";
String b = "t";
for(int i = 0; i < 3; i++) {
b += "e";
}
System.out.println(a == b.intern()); // true
このコードでは一見falseを表示すると思うかもしれないが、実際のところteeeという文字列は変数aを宣言した時点で定数プールに格納されているので、b#internを呼び出すとaが返ってくる。従ってこの場合はtrueになる。
これになんの意味があるのかと思うのかもしれないが、internはsynchronizedと組み合わせることで真価を発揮する。例えばファイルにデータをスレッドセーフに保存するようなメソッドを考えてみて欲しい。
public synchronized void saveData(Path path, String contents) throws IOException {
Files.writeString(path, contents);
}
これでも動くことは動く。だがよく考えてみれば、もしファイルが違うのであればsynchronizedによってロックをかける必要はない。そこでString#internを使う方法がある。
public void saveData(Path path, String contents) throws IOException {
String absolutePath = path.toAbsolutePath().normalize().toString();
synchronized ((getClass().getCanonicalName() + "-" + absolutePath).intern()) {
Files.writeString(path, contents);
}
}
このように書くことで、absolutePathが同じであればsynchronizedによるブロックが発生し、そうでなければブロックは発生しない。これにより簡潔で効率的な同期処理が可能となる。
ここで
getClass().getCanonicalName()
を呼び出しているのは、absolutePathだけだともしかしたら他のライブラリ等によってロックとして利用されている可能性があり、最悪の場合デッドロックが発生する可能性があるためである。getClass().getCanonicalName()等の文字列を前後に連結させることで、デッドロックが発生する可能性を事実上0にできる。
だがレビュアーに何か言われたりメンバーが混乱するようであれば、大人しく
Set<String> lock = Collections.newSetFromMap(new ConcurrentHashMap<>());
を使った実装に書き換えたほうが良いかもしれない。