本ページはプロモーションが含まれています

Java入門

【Java初心者向け】スレッドセーフの実装方法と落とし穴

トム

・都内自社開発IT企業勤務/javaのバックエンドエンジニア
/java歴10年以上 ・首都圏在住30代
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎

「マルチスレッド処理を書くときに、何に気をつければ良いのだろう?」

Javaを学び始め、複数の処理を同時に動かす「マルチスレッド」に挑戦しようとすると、必ず「スレッドセーフ」という壁にぶつかります。私自身もJava開発を始めた頃、このスレッドセーフの概念を理解するのに苦労しました。

しかし、スレッドセーフは、安全で信頼性の高いJavaアプリケーションを開発するためには避けて通れない知識です。

この記事を読めば、Javaのスレッドセーフの基本が分かり、複数のスレッドが同時に動いても問題を起こさない、安全なプログラムを書くための第一歩を踏み出せます。Javaのスレッドセーフについて、初心者の方にも分かりやすく解説していきます。

スレッドセーフとは何か?

まず、「スレッドセーフ」とは何かを理解するために、Javaにおけるスレッドとその重要性から見ていきましょう。

Javaにおけるスレッド(thread)とは?

プログラムの実行単位を「プロセス」と呼びます。一つのプロセスの中で、さらに枝分かれして同時に実行される処理の流れが「スレッド」です。

一つのプログラムで複数のスレッドを動かすことを「マルチスレッド」と呼びます。マルチスレッドを利用すると、例えば以下のようなメリットがあります。

メリット

  • 応答性の向上: 時間のかかる処理を別のスレッドに任せることで、ユーザーインターフェースが固まらず、操作を受け付け続けられる。
  • 処理能力の向上: マルチコアCPUの場合、複数のスレッドを別々のコアで同時に実行することで、全体の処理時間を短縮できる。
  • リソースの有効活用: ネットワークからの応答待ちなど、処理が一時的に停止する時間に、他のスレッドを実行することで、CPUを有効に活用できる。

このように、マルチスレッドはJavaアプリケーションのパフォーマンスや使いやすさを向上させるために欠かせない技術です。

スレッドセーフの定義

スレッドセーフとは、複数のスレッドから同時にアクセスされても、プログラムが意図した通りに正しく動作することを指します。

言い換えると、あるクラスやメソッドがスレッドセーフであるとは、そのクラスのインスタンスやメソッドを、特別な制御なしに複数のスレッドから同時に利用しても、データの不整合や予期せぬエラーが発生しない状態を意味します。

スレッドセーフでないと何が起きる?

もしプログラムがスレッドセーフでない場合、複数のスレッドが同時にデータにアクセスすると、問題が発生することがあります。これを「競合状態(Race Condition)」と呼びます。

代表的な問題は以下のとおりです。

データの不整合

あるスレッドがデータを更新している途中で、別のスレッドがそのデータを読み込んだり、さらに更新したりすると、データが中途半端な状態になったり、意図しない値になったりします。

: 銀行口座の残高を更新する処理で、あるスレッドが入金処理をしている最中に、別のスレッドが出金処理をすると、最終的な残高が合わなくなる。

予期せぬ例外

データが不正な状態になることで、NullPointerExceptionArrayIndexOutOfBoundsExceptionなどの予期せぬ例外が発生することがあります。

デッドロック

複数のスレッドが互いに相手のリソース(データやロック)を待ってしまい、処理が全く進まなくなる状態です。こうなると、アプリケーションが停止してしまうこともあります。

Javaでスレッドセーフを実現する方法

Javaでスレッドセーフなプログラムを書くには、どうすれば良いのでしょうか。主な方法を3つ紹介します。

同期化(synchronized)の基本

最も基本的なスレッドセーフ実現方法は「同期化(Synchronization)」です。Javaではsynchronizedキーワードを使って実現します。

synchronizedは、メソッドや特定のコードブロックに対して、一度に一つのスレッドしか実行できないように制限をかけます。これを「排他制御」や「ロック」と呼びます。

ただし、synchronizedを多用すると、スレッドがロックを待つ時間が増え、パフォーマンスが低下する可能性があるため、注意が必要です。

synchronizedメソッド

public synchronized void increment() {
    count++; // この操作は一度に一つのスレッドしか実行できない
}

メソッド全体を同期化します。このメソッドが実行されている間、他のスレッドは同じインスタンスのsynchronizedメソッドを実行できません。

synchronizedブロック

public void increment() {
    // ... 他の処理 ...
    synchronized (this) {
        count++; // このブロックだけを同期化 
    }
    // ... 他の処理 ... 
}

コードの一部だけを同期化します。同期化の範囲を最小限にすることで、パフォーマンスへの影響を抑えられます。synchronizedの後ろの(this)は、ロックを取得する対象(モニターオブジェクト)を指定します。

volatile修飾子の役割

volatile修飾子は、主に「メモリの可視性」を保証するために使われます。

マルチスレッド環境では、各スレッドが変数の値をCPUのキャッシュに保持することがあります。そのため、あるスレッドが変数の値を変更しても、他のスレッドにはすぐに見えない場合があります。

volatileを付けた変数は、常にメインメモリから読み込まれ、書き込みも常にメインメモリに対して行われます。これにより、あるスレッドによる変更が、他のすべてのスレッドから必ず見えるようになります。

private volatile boolean running = true;

public void stop() {
    running = false; // この変更は他のスレッドからすぐに見える
}

public void run() {
    while (running) {
        // ... 処理 ...
    }
}

volatileは、変数の変更を他のスレッドに即座に知らせることで、特定の状況でのスレッドセーフに貢献します。

ただし、volatileはアトミック性(複数の操作を一体として実行すること)は保証しません。例えば、count++のような「読み込み→変更→書き込み」という一連の操作は、volatileだけではスレッドセーフになりません。この場合はsynchronizedや後述するAtomicクラスが必要です。

java.util.concurrentパッケージの活用

Java5以降、java.util.concurrentパッケージが導入され、より高度で効率的なスレッドセーフの仕組みが提供されるようになりました。

このパッケージには、スレッドセーフなコレクションや、より柔軟なロック機構、アトミックな操作を行うクラスなどが含まれます。

Atomicクラス

AtomicInteger, AtomicLong, AtomicReferenceなどがあります。これらは、count++のような複合操作をアトミックに実行できます。synchronizedよりも軽量で高速な場合があります。

private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet(); // アトミックにインクリメント
}

Lockインターフェース

ReentrantLockなどがあります。synchronizedよりも高度なロック機能を提供します。例えば、ロックの取得を試みて、取得できなかったら待たずに別の処理を行う、といった制御が可能です。

Concurrentコレクション

ConcurrentHashMap, CopyOnWriteArrayListなどがあります。

これらは、synchronizedでラップされたコレクションよりも、特定の状況で高いパフォーマンスを発揮するように設計されたスレッドセーフなコレクションです。

代表的なスレッドセーフなクラスと非スレッドセーフなクラス

Javaの標準APIには、最初からスレッドセーフに設計されているクラスと、そうでないクラスがあります。

スレッドセーフなクラス

以下がスレッドセーフなクラスの例です。

  • Vector: ArrayListの古いバージョンで、すべてのパブリックメソッドがsynchronizedされています。そのため、ArrayListに比べてパフォーマンスが劣ります。
  • Hashtable: HashMapの古いバージョンです。パフォーマンス面でConcurrentHashMapに劣ることが多いです。
  • StringBuffer: 文字列を扱うクラスで、メソッドが同期化されています。
  • ConcurrentHashMap: 高い並行性を実現するように設計されたHashMapです。読み取り操作はロックなしで行え、書き込み操作も部分的なロックで行うため、Hashtableよりもはるかに高いパフォーマンスを発揮します。
  • CopyOnWriteArrayList: 読み取り操作が多く、書き込み操作が少ない場合に有効なリストです。書き込みが発生すると、元の配列をコピーして新しい配列を作成するため、読み取り操作はロックなしで安全に行えます。

非スレッドセーフなクラス

日常的によく使う多くのクラスは、パフォーマンスを優先するため、スレッドセーフではありません。マルチスレッド環境で共有する場合は、注意が必要です。

  • ArrayList: Vectorとは対照的に、同期化されていません。高速ですが、マルチスレッドで共有するとデータ破壊の危険があります。
  • HashMap: Hashtableとは異なり、同期化されていません。高速ですが、マルチスレッドで共有すると、無限ループに陥る可能性があります。
  • StringBuilder: StringBufferとは違い、同期化されていません。シングルスレッド環境での文字列操作に適しています。
  • SimpleDateFormat: 日付フォーマットを行うクラスですが、内部状態を持つためスレッドセーフではありません。複数のスレッドで共有すると、予期しない結果になることがあります。

ラップしてスレッドセーフにする方法

非スレッドセーフなコレクションを、どうしてもマルチスレッド環境で使いたい場合、Collectionsクラスのユーティリティメソッドを使って、スレッドセーフなビュー(ラッパー)を作成する方法があります。

  • Collections.synchronizedList(new ArrayList<>())
  • Collections.synchronizedMap(new HashMap<>())
  • Collections.synchronizedSet(new HashSet<>())

これらのメソッドは、元のコレクションの各メソッド呼び出しをsynchronizedブロックで囲んだ新しいコレクションを返します。

Collectionsクラスのラッパーを使えば、非スレッドセーフなコレクションを簡単にスレッドセーフ化できます。

ただし、この方法はコレクション全体をロックするため、ConcurrentHashMapなどのjava.util.concurrentパッケージのクラスに比べてパフォーマンスが劣る場合があります。

また、イテレータ(拡張for文など)を使う場合は、手動で同期化する必要がある点に注意が必要です。

List<String> list = Collections.synchronizedList(new ArrayList<>());
// ... listに要素を追加 ...

// イテレータを使う場合は、手動で同期化が必要
synchronized (list) {
    for (String item : list) {
        System.out.println(item);
    }
}

実践でよくあるスレッドセーフの落とし穴

スレッドセーフの基本を理解しても、実際の開発では思わぬ落とし穴にはまることがあります。ここでは、よくある問題とその対策を見ていきましょう。

複数の操作を安全に行うには?

synchronizedメソッドやスレッドセーフなクラスを使っても、複数の操作を組み合わせる場合には注意が必要です。個々の操作がスレッドセーフでも、それらを組み合わせた一連の処理がスレッドセーフであるとは限らないからです。

例えば、Vectorはスレッドセーフですが、以下のコードはスレッドセーフではありません。

Vector<String> vector = new Vector<>();
// ... vectorに要素を追加 ...

// Check-then-Act: スレッドセーフではない!①チェックと②削除の間に別のスレッドが割り込む可能性
if (!vector.isEmpty()) { // ①チェック
    String item = vector.remove(0); // ②削除
    // ... itemを使った処理 ...
}

このコードでは、isEmpty()で空でないことを確認してからremove(0)を呼び出しています。しかし、①のチェックと②の削除の間に、別のスレッドがvectorから最後の要素を削除してしまう可能性があります。すると、②のremove(0)は要素がないのに削除しようとして、ArrayIndexOutOfBoundsExceptionが発生します。

このような問題を避けるには、一連の操作全体を一つのアトミックな操作として同期化する必要があります。

synchronized (vector) { // vectorをロックして一連の処理を実行
    if (!vector.isEmpty()) {
        String item = vector.remove(0);
        // ... itemを使った処理 ...
    }
}

複数の操作を組み合わせる場合は、その一連の処理全体をsynchronizedブロックで囲む必要があります。

個々の操作が安全でも、操作の間に他のスレッドが割り込むと、前提条件が崩れる可能性があるためです。

スレッドセーフでも不十分なケースとは

スレッドセーフなクラスを使っているからといって、常に安全とは限りません。使い方によっては、やはり競合状態が発生することがあります。

例えば、「キーが存在しなければ追加する」という処理を考えてみましょう。

ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

String key = "test";
String value = "value";

// スレッドセーフではない! ①getと②putの間に別のスレッドがputする可能性
if (map.get(key) == null) { // ①get
    map.put(key, value); // ②put
}

ConcurrentHashMapgetputはスレッドセーフですが、①と②の間はアトミックではありません。

このような場合は、ConcurrentHashMapが提供するアトミックなメソッドputIfAbsentを使います。

map.putIfAbsent(key, value); // キーが存在しない場合のみアトミックにputする

スレッドセーフなクラスを使う場合でも、提供されているアトミックなメソッドを適切に利用することが重要です。

自前で複数の操作を組み合わせると、意図せず競合状態を生み出す可能性があるからです。

パフォーマンスとのトレードオフ

スレッドセーフを実現するための同期化には、必ずコストがかかります。synchronizedを使うと、ロックの取得と解放にオーバーヘッドが生じ、複数のスレッドがロックを待つことで、全体の処理速度が低下する可能性があります。

そのため、スレッドセーフは重要ですが、必要以上に同期化を行うことは避けるべきです。

ポイント

  • 同期化の範囲を最小限にする: synchronizedメソッドよりもsynchronizedブロックを使い、ロック範囲をできるだけ狭く。
  • ロックの粒度を考える: ConcurrentHashMapのように、オブジェクト全体ではなく部分的にロックする仕組みを利用。
  • 不変(Immutable)オブジェクトを活用する: 変更されないオブジェクトは、そもそもスレッドセーフについて心配する必要がありません。可能な限りオブジェクトを不変に設計することは、スレッドセーフなプログラムを書くための強力な戦略。
  • スレッドローカル(ThreadLocal)を使う: 各スレッドが自分専用の変数を持つようにすれば、共有による問題を避けられます。ただし、メモリ使用量が増える可能性に注意が必要。

まとめと学びを深めるポイント

Javaにおけるスレッドセーフは、堅牢なアプリケーション開発に不可欠な概念です。最後に、スレッドセーフを判断するための視点と、開発時に意識すべきこと、さらに学びを深めるための資料を紹介します。

スレッドセーフを判断する3つの視点

あるコードがスレッドセーフかどうかを考える際には、以下の3つの視点が役立ちます。

  1. 状態の共有 (Shared State): 複数のスレッドからアクセスされる可能性のあるデータ(インスタンス変数、静的変数など)はあるか? 共有されているデータがなければ、通常スレッドセーフの問題は起きない。
  2. 可変性 (Mutability): 共有されているデータは変更されるか? データが不変(読み取り専用)であれば、複数のスレッドから同時にアクセスされても問題ない。
  3. アトミック性 (Atomicity): 共有されている可変データへのアクセス(特に読み書きを伴う一連の操作)は、アトミックに行われているか? synchronizedAtomicクラスなどで保護されているか確認。

開発時に意識したいこと

スレッドセーフに関して開発時に意識したいことは以下のとおりです。

  • 設計段階から考慮する: マルチスレッドを後から付け足すのは困難のため、最初からスレッドセーフを意識して設計する。
  • 共有するデータは最小限に: 可能な限り、スレッド間でデータを共有しない設計をする。
  • 不変性を活用する: 変更する必要のないデータは、final修飾子を使うなどして不変にする。
  • 適切な同期化手法を選ぶ: 状況に応じてsynchronized, volatile, Lock, Atomicクラス、Concurrentコレクションを使い分ける。
  • テストを徹底する: マルチスレッドのバグは再現が難しいため、様々な状況を想定したテストや、負荷テストを行うことが重要。

おすすめの参考資料・書籍

さらにJavaのスレッドセーフや並行処理について深く学びたい方には、以下の資料がおすすめです。

『Java並行処理プログラミング ―その「基盤」と「最新API」を究める―』はJavaの並行処理に関するバイブル的な一冊。java.util.concurrentパッケージの作者自身による解説で、理論から実践まで深く学べます。少し難易度は高いですが読む価値はあります。

  • この記事を書いた人
  • 最新記事

トム

・都内自社開発IT企業勤務/javaのバックエンドエンジニア
/java歴10年以上 ・首都圏在住30代
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎

-Java入門