【Java】スレッドセーフでないプログラムとは?実際にコードを動かして学ぶ競合状態

Javaの学習を進めている中で、「スレッドセーフ」という言葉をよく見かけるようになりました。

普段はSpring Bootなどで開発していても、自分で Thread クラスを使ってスレッドを作る機会はあまりありません。けれど、Webアプリケーションでは複数のリクエストが同時に処理されることがあります。

そのため、自分では並行処理を書いているつもりがなくても、スレッドセーフでないコードを書いてしまうと、まれにしか起きない不具合につながることがあるようです。

今回は、「スレッドセーフでないプログラムとはどんなものなのか」を理解するために、実際にコードを書いて動かしてみました。期待値と実際の値がかなりずれて、思っていた以上に分かりやすい結果になったので、学習ログとしてまとめます。

今回のサンプルは、私の手元ではJava 22.0.2で動作確認しました。

java version "22.0.2" 2024-07-16
Java(TM) SE Runtime Environment (build 22.0.2+9-70)
Java HotSpot(TM) 64-Bit Server VM (build 22.0.2+9-70, mixed mode, sharing)

ただし、この記事で扱っている Threadstart()join()synchronized の基本的な考え方は、以前のJavaから使われてきた内容です。コード内でラムダ式を使っているため、最低限Java 8以降を前提にしています。

スレッドセーフとは?

スレッドセーフ(Thread Safe)とは、複数のスレッドから同時にアクセスされても、データの不整合や予期しない結果が起きない状態のことです。

逆に、複数のスレッドが同じデータを書き換えることで結果が変わってしまう場合は、スレッドセーフではありません。

参考

Oracle Java Tutorials:Thread Interference
JDK 8までの情報のようですが、Oracle公式のJavaチュートリアルに、スレッド干渉についての説明がありました。
https://docs.oracle.com/javase/tutorial/essential/concurrency/interfere.html

例えば、次のような処理を考えます。

count++;

一見すると1行なので安全そうに見えますが、実際には次のような処理に分かれています。

countを読み込む
1を足す
countへ書き戻す

つまり、count++ は「1回で安全に加算している処理」ではなく、読み込み・計算・書き戻しという複数の処理で成り立っています。

参考
Java Language Specification Java SE 22:Postfix Increment Operator
count++ のような後置インクリメント演算子の仕様が説明されています。
https://docs.oracle.com/javase/specs/jls/se22/html/jls-15.html#jls-15.14.2

もし複数のスレッドが同時にこの処理を実行すると、お互いの更新が上書きされてしまい、一部の加算結果が失われることがあります。

このような状態を競合状態と呼ぶそうです。

実際に動かしてみる

今回はこちらのコードを作成しました。

public class NonThreadSafeCounterSample {

private static int counter = 0;

public static void main(String[] args) throws InterruptedException {

Thread[] threads = new Thread[100];

for (int i = 0; i < threads.length; i++) {

threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter++;
}
});

threads[i].start();
}

for (Thread thread : threads) {
thread.join();
}

System.out.println("期待する値: " + (100 * 10000));
System.out.println("実際の値: " + counter);
}
}

このプログラムでは、100個のスレッドを作成し、それぞれ1万回ずつカウンタを増やしています。

そのため、本来であれば次の値になるはずです。

100 × 10000 = 1000000

ところが、実際に実行すると次のような結果になりました。

期待する値: 1000000
実際の値: 293039

実行するたびに値が変わり、100万にはほとんど届きませんでした。

ここで使っている start() は、スレッドの処理を開始するメソッドです。一方で join() は、対象のスレッドが終了するまで、現在のスレッドを待たせるためのメソッドです。

今回のコードでは、すべてのスレッドを開始したあとに join() しているため、メインスレッドは100個のスレッドが終わるのを待ってから、最後に counter の値を表示しています。

参考
Java SE 22 APIドキュメント:java.lang.Thread
Thread クラス、start()join() などの公式API説明です。
https://docs.oracle.com/javase/jp/22/docs/api/java.base/java/lang/Thread.html

なぜ値が減ってしまうのか

例えば counter が100だったとします。

スレッドAとスレッドBが同時に counter++ を実行すると、次のような流れになる可能性があります。

スレッドA: 100を読み込む
スレッドB: 100を読み込む

スレッドA: 101を書き込む
スレッドB: 101を書き込む

本来なら、2回加算されているので次のようになってほしいです。

100
↓
101
↓
102

しかし、両方のスレッドが同じ 100 を読み込んでしまうと、どちらも 101 を書き戻します。

100
↓
101

結果として、1回分の加算が消えたような状態になります。

この「更新が消えてしまう」現象が何度も発生することで、最終的な値が期待よりかなり少なくなります。

ただし、スレッドセーフでないコードは、毎回必ず失敗するわけではありません。スレッドの実行順によっては、たまたま正しい結果になることもあります。

ここがかなり厄介だと感じました。「動くときは動くけれど、条件がそろうと壊れる」ため、テストでは見つからず、本番でだけ発生するような不具合になりやすそうです。

synchronizedを使うとどうなる?

この問題は、同期処理を行うことで防げます。

例えば、加算処理を synchronized なメソッドにすると、次のようになります。

public class SynchronizedCounterSample {

private static int count = 0;

public static synchronized void increment() {
count++;
}

public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
int incrementCount = 100_000;

Thread[] threads = new Thread[threadCount];

for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < incrementCount; j++) {
increment();
}
});
}

for (Thread thread : threads) {
thread.start();
}

for (Thread thread : threads) {
thread.join();
}

System.out.println("期待する値: " + (threadCount * incrementCount));
System.out.println("実際の値: " + count);
}
}

実行すると、次のように期待した値になります。

期待する値: 1000000
実際の値: 1000000

synchronized を使うと、一度に1つのスレッドしか increment() を実行できなくなります。そのため、複数のスレッドが同時に count++ を実行して、更新が消えることを防げます。

ただ、同期処理を増やせばよいという話でもなさそうです。同時に処理できる範囲が減るので、性能面では不利になる場合もあります。必要な範囲だけに使うことが大切だと感じました。

Java 21からは仮想スレッドも使える

今回のサンプルでは、従来の Thread クラスを使ってスレッドを作成しました。

Javaには、Java 21から仮想スレッドという仕組みも正式に入っています。まだ私自身も深く使い込んでいるわけではありませんが、最近のJavaの並行処理を調べているとよく出てくる機能です。

仮想スレッドは、かなりざっくり言うと、従来よりも軽量に扱えるスレッドです。

従来のスレッドは、OSのスレッドと結びついて動くため、大量に作るにはそれなりにコストがかかります。一方で仮想スレッドは、Java側でより軽く扱えるようにしたスレッドで、たくさんの処理をシンプルなコードで書きやすくするための仕組みです。

例えば、Java 21以降では次のように仮想スレッドを開始できます。

Thread thread = Thread.ofVirtual().start(() -> {
    System.out.println("仮想スレッドで実行中");
});

thread.join();

また、次のように短く書くこともできます。

Thread.startVirtualThread(() -> {
    System.out.println("仮想スレッドで実行中");
});

仮想スレッドが特に向いているのは、DBアクセスや外部API呼び出しのように、処理の途中で待ち時間が発生しやすい場面だそうです。

例えばWebアプリケーションでは、リクエストを受け取ったあとに、DBからデータを取得したり、外部サービスの応答を待ったりすることがあります。このような「待っている時間」が多い処理では、仮想スレッドを使うことで、従来よりも多くの処理を扱いやすくなるようです。

ただし、仮想スレッドは処理が何でも速くなる魔法ではなく、CPUで重い計算をし続ける処理よりも、I/O待ちが多い処理でメリットが出やすい仕組みだと理解しました。

また、仮想スレッドを使う場合、synchronized の使い方にも注意点があります。

Oracleのドキュメントでは、仮想スレッドが synchronized ブロックや synchronized メソッドの中でブロッキング処理を行うと、キャリアスレッドに固定される場合があると説明されています。

少し難しいですが、私の理解では、仮想スレッドのよさは「待ち時間のある処理を効率よく扱いやすい」点にあります。そのため、DBアクセスや外部API呼び出しのような時間のかかる処理を、長く synchronized の中に入れてしまうと、仮想スレッドのよさを活かしにくくなる場合があるようです。

今回の記事では、まず通常の Thread クラスを使って、スレッドが同時に動くとどのようなことが起きるのかを確認しました。Java 21からは仮想スレッドという選択肢もありますが、並行処理を書くときに共有データの扱いに注意する、という基本は変わらないと感じました。

参考
JEP 444: Virtual Threads
Java 21で仮想スレッドが正式機能として導入されたことが説明されています。
https://openjdk.org/jeps/444

Oracle Java 21 Documentation: Virtual Threads
https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html

Java SE 21 API Documentation: java.lang.Thread
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html

実務ではどんな場面で注意する?

普段の業務では、一からスレッド処理を書くことはあまりありません。私の場合も、自分で Thread クラスを作る場面は少ないです。

それでも、Webアプリケーションでは複数ユーザーから同時アクセスされます。また、Springを使っている場合、@Service@Component として管理されるBeanは、デフォルトでは singleton scope です。

参考
Spring Framework Reference:Bean Scopes
SpringのBeanは、デフォルトでは singleton scope です。1つのSpring IoCコンテナ内で、1つのBeanインスタンスを共有するという説明があります。
https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html

Beanとは、簡単にいうとSpringが管理してくれるオブジェクトのことです。

参考
Spring Framework Reference:Introduction to the Spring IoC Container and Beans
Beanは、Spring IoCコンテナによって生成・組み立て・管理されるオブジェクトと説明されています。
https://docs.spring.io/spring-framework/reference/core/beans/introduction.html

つまり、SpringのSingleton Beanは、リクエストごとに毎回新しく作られるわけではありません。1つのインスタンスを、複数のリクエスト処理から共有して使うことがあります。

同じBeanを共有すること自体は問題ではありません。問題になりやすいのは、Beanのフィールドにリクエストごとの値を持たせてしまう場合です。

SpringのSingleton Beanで起きそうな例

例えば、Springの @Service で、リクエストごとのユーザーIDをフィールドに持たせてしまった場合を考えます。

public class OrderService {

private String currentUserId;

public void createOrder(String userId, String itemName) {
currentUserId = userId;

// DB登録や外部API呼び出しなど、少し時間がかかる処理のつもり
sleep(100);

System.out.println("注文ユーザー: " + currentUserId + ", 商品: " + itemName);
}

private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

一見すると、createOrder() の中で userId をセットして、その後に注文処理をしているだけなので問題なさそうに見えます。

しかし、OrderService がSpringのSingleton Beanとして複数リクエストから共有されている場合、currentUserId も共有されます。

次のように2つのスレッドから同時に呼び出すと、問題が再現できます。

public class NonThreadSafeServiceSample {

public static void main(String[] args) throws InterruptedException {
OrderService orderService = new OrderService();

Thread threadA = new Thread(() -> {
orderService.createOrder("userA", "ノートPC");
});

Thread threadB = new Thread(() -> {
orderService.createOrder("userB", "キーボード");
});

threadA.start();
threadB.start();

threadA.join();
threadB.join();
}
}

実行結果は、次のようになりました。

注文ユーザー: userB, 商品: ノートPC
注文ユーザー: userB, 商品: キーボード

本来は、ノートPCは userA の注文として処理されてほしいはずです。

しかし、threadAcurrentUserId = "userA" をセットした後、処理の途中で threadBcurrentUserId = "userB" に上書きしてしまうと、threadA 側の処理でも userB が使われてしまいます。

今回のサンプルでは、sleep(100) を入れることで、処理の途中に別スレッドが割り込む状況を再現しています。実務で sleep() を書くことはあまりないと思いますが、DBアクセスや外部API呼び出しなど時間のかかる処理があると、その間に別のリクエストが同じSingleton Beanのフィールドを書き換えてしまう可能性があります。

このような不具合は、ログを見ると「なぜか別ユーザーの情報で処理されている」ように見えます。しかも、アクセスが少ない環境では再現せず、本番環境で同時アクセスが増えたときだけ発生することがあります。原因の特定が難しくなるのも納得しました。

修正する場合は、リクエストごとの値をフィールドに持たせず、メソッド内のローカル変数として扱います。

public class OrderService {

public void createOrder(String userId, String itemName) {
String currentUserId = userId;

sleep(100);

System.out.println("注文ユーザー: " + currentUserId + ", 商品: " + itemName);
}

private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

ローカル変数は基本的にそのメソッド呼び出しの中だけで使われるため、他のリクエストから上書きされません。

この例から考えると、SpringのSingleton Beanでは、currentUserIdrequestId、検索条件、処理中のステータスなど、リクエストごとに変わる値をフィールドに持たせないように注意した方がよさそうです。

スレッドセーフでない不具合が難しい理由

今回試してみて、スレッドセーフでない不具合が難しいと言われる理由が少し分かりました。

単純にエラーが出るわけではありません。コンパイルも通りますし、実行もできます。しかも、毎回必ずおかしな結果になるとは限りません。

タイミングによっては正常に見えるため、ログを見ても「たまたま処理順が変だったのか」「データがおかしいのか」「どこかで上書きされたのか」が分かりにくくなりそうです。

実務では、共有データをできるだけ持たないこと、リクエストごとの値はローカル変数や引数で扱うことを意識したいと思いました。

共有カウンタのようにどうしても複数スレッドから更新する値がある場合は、AtomicIntegerConcurrentHashMap などのスレッドセーフなクラスを使う方法もあります。必要に応じて synchronized などで同期することもありますが、どこまで同期するかはよく考える必要がありそうです。

お恥ずかしながら、私はスレッドセーフの概念をなんとなくでしか理解していませんでした。ただ、実際にコードを動かして、値が大きくずれるのを見ると、「これは知っておかないと怖いな」と感じました。

とても分かりやすかった解説動画

せかチャンのすがさんのJava動画は、無料にもかかわらずとても分かりやすかったです。演習問題まで準備されていて、手を動かしながら理解できたのがありがたかったです。

私はいろいろなUdemy講座やJava入門書を見てきましたが、今回の内容は、実際に動かしてみてやっと腑に落ちた部分がありました。細かい疑問にも答えてくれる動画内容で、コメント欄での他の視聴者の方への返信も、初心者目線では「そういうことか」と参考になりました。

もっと早く知りたかったです。

まとめ

今回は、スレッドセーフでないプログラムを実際に動かしながら学習しました。

count++ は1行に見えますが、内部的には読み込み・加算・書き戻しに分かれています。そのため、複数スレッドから同時に実行されると、更新が失われることがあります。

Java 21からは仮想スレッドという選択肢もあります。仮想スレッドは、待ち時間の多い処理を扱いやすくする仕組みとして便利そうですが、並行処理を書くときに共有データの扱いに注意する、という基本は変わらないと感じました。

また、SpringのSingleton Beanでは、1つのBeanインスタンスが複数のリクエストから使われることがあります。リクエストごとの値をフィールドに持たせてしまうと、別リクエストの値で上書きされる可能性があるため、ローカル変数やメソッド引数で扱う方が安全そうです。

普段はフレームワークが多くのことを隠してくれているため、意識する機会は少ないかもしれません。それでも、並行処理の基本を理解しておくことで、不具合の原因調査やコードレビューで役立つ場面が増えそうだと感じました。

フレームワークの基礎的な知識も、改めて調べていくと知らないことがたくさんありますね。

今後は AtomicIntegerConcurrentHashMapReentrantLock なども実際に試しながら、それぞれの使い分けについても学んでいきたいと思います。