Javaのsynchronizedとvolatileの違いと使い方

synchronized

スレッドは、自身のスタックスペースを持ち、スクリプトのようにコードを順に実行していきます。しかし、各スレッドが孤立して動作するだけでは、その価値は限られます。複数のスレッドがデータを共有し、協調して作業を行うことで、大きな価値が生まれます。

Javaは、複数のスレッドが同じオブジェクトやそのメンバ変数に同時にアクセスすることをサポートしています。キーワードsynchronizedは、メソッドやブロックに修飾して使用され、主に同じ時刻に一つのスレッドのみがメソッドまたはブロック内にいることを保証します。これにより、スレッドによる変数へのアクセスの可視性排他性が確保され、組み込みロック機構とも呼ばれます。

1.1 インスタンスロック

インスタンスロックは、オブジェクトのインスタンスごとに存在するロックです。

private static int sharedCounter = 0;

public static void main(String[] args) throws InterruptedException {
    Object mutex = new Object();
    Thread thread1 = new Thread(() -> {
        synchronized (mutex) {
            for (int i = 0; i < 10000; i++) {
                sharedCounter++;
            }
        }
    });
    Thread thread2 = new Thread(() -> {
        synchronized (mutex) {
            for (int i = 0; i < 10000; i++) {
                sharedCounter++;
            }
        }
    });
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(sharedCounter);
}

1.2 クラスロック

クラスロックは、実際のロックではなく概念上のものです。クラスロックは、クラスに対応するClassオブジェクトをロックの対象とします。インスタンスロックとは干渉しません。

private static int classLockedCounter = 0;

public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(() -> {
        incrementClassCounter();
    });
    Thread thread2 = new Thread(() -> {
        incrementClassCounter();
    });
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(classLockedCounter);
}
private static synchronized void incrementClassCounter() {
    for (int i = 0; i < 10000; i++) {
        classLockedCounter++;
    }
}

1.3 ロックの無効化シナリオ

1. 複数のスレッドがリソースにロックをかけない場合。

private static int sharedCounter = 0;

public static void main(String[] args) throws InterruptedException {
    // thread1はロックを使用しない
    Thread thread1 = new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            sharedCounter++;
        }
    });
    // thread2はクラスロックを使用
    Thread thread2 = new Thread(() -> {
        incrementClassCounter();
    });
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(sharedCounter);
}
private static synchronized void incrementClassCounter() {
    for (int i = 0; i < 10000; i++) {
        sharedCounter++;
    }
}

2. 複数のスレッドが、異なるロックを使用する場合。

private static int sharedCounter = 0;

public static void main(String[] args) throws InterruptedException {
    Object mutex = new Object();
    Object anotherLock = new Object();
    // thread1はmutexオブジェクトのロックを使用
    Thread thread1 = new Thread(() -> {
        synchronized (mutex) {
            for (int i = 0; i < 1000; i++) {
                sharedCounter++;
            }
        }
    });
    // thread2はanotherLockオブジェクトのロックを使用
    Thread thread2 = new Thread(() -> {
        synchronized (anotherLock) {
            for (int i = 0; i < 1000; i++) {
                sharedCounter++;
            }
        }
    });
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(sharedCounter);
}

1.4 スレッドセーフティの問題

チケットの重複販売問題をシミュレーションします。ロックが追加されていません。

private static int ticketsRemaining = 10;

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        sellTicket();
    });
    Thread t2 = new Thread(() -> {
        sellTicket();
    });
    t1.start();
    t2.start();
}

private static void sellTicket() {
    while (ticketsRemaining > 0) {
        try {
            Thread.sleep(50); // 業務処理の遅延をシミュレート
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(Thread.currentThread().getName() + "が" + ticketsRemaining-- + "番目のチケットを販売しました");
    }
}

volatile

一つのスレッドが書き込み、複数のスレッドが読み取りを行うシナリオで使用されます。これにより、毎回の読み取りで最新の値が保証されます。しかし、最新の値を読み取った後の処理中に値が変更される可能性は排除できません。最新の値が変更され、古いデータが使用されると、データの不整合(非同期)の問題が発生します。

排他性:

特定の時点で、唯一のスレッドのみがロックを取得し、プログラムロジックを実行でき、他のスレッドはそのロックが解放されるまで待機する必要があります。

可視性:

特定の時点で、複数のスレッドが共有変数を操作することを許可します。

スレッドがスケジュールされると、変数の値はCPUのキャッシュに読み込まれ、処理が完了した後にメインメモリに再書き込みされます。この間に他のスレッドが変数の値を変更しても、そのスレッドは知りません。

もし変数にvolatileキーワードが付加されている場合、一つのスレッドが変数の値を変更すると、他のスレッドは実行中に最新の値を取得できます。

しかし、これはスレッドセーフであることを意味しません。排他性の欠如により、最新の値を取得したとしても、他のスレッドも変数を操作できるため、volatileがスレッドセーフを保証できない理由です。

例:メインスレッドがshouldTerminateの値を変更し、別のスレッドがその値を判断します。最終的にSystem.out.println("end");は実行されません。なぜなら、別のスレッドは起動時にshouldTerminateのコピーを生成し、以降の実行ではそのコピーのみを読み取るためです。メインスレッドが値を変更しても、別のスレッドはそれを知りません。

public class VolatileExample extends Thread {
    private volatile boolean shouldTerminate = false;

    @Override
    public void run() {
        System.out.println("開始");
        while (!shouldTerminate) {
            // ループ内で何もしない
        }
        System.out.println("終了");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        example.start();
        Thread.sleep(2000);
        example.shouldTerminate = true;
        System.out.println("メインスレッド終了");
    }
}

注:もしwhileループ内でSystem.out.printlnを実行すると、スレッドはコピーを再読み込みします。その結果、スレッドは最新の値を読み取ることになります。これはprintlnメソッド内でsynchronizedコードブロックが実行され、synchronizedが可視性機能を持つためです。

shouldTerminatevolatileキーワードを追加することで、効果を直接確認できます。

タグ: Java synchronized volatile スレッドセーフ 排他制御

7月7日 00:38 投稿