はじめに
チーム内でJavaプログラミング初心者向けの勉強会をしていて、そのときに出したマルチスレッドプログラミングの勉強用の題材とメンバーからの回答例です。
よくある事例からお題を作ってみたら、いろんな回答が返ってきました。
お題
以下のプログラムをスレッドセーフにしてみよう
スレッドアンセーフ(お題)
package study.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.Getter;
public class ThreadUnsafeMain {
private static ExecutorService executor = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch = new CountDownLatch(1);
long start = System.currentTimeMillis();
executor.execute(() -> {
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.countDown();
});
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.await();
long end = System.currentTimeMillis();
// countは200,000を期待
System.out.println(String.format("count=%d, 処理時間=%dms", counter.getCount(), (end - start)));
}
private static class Counter {
@Getter
private int count = 0;
/**
* 1. count の値を読み出す<br>
* 2. 読み出した値に 1 を足す<br>
* 3. 足した結果を count に保存する
*/
public void countUp() {
this.count++;
}
}
}
メンバーの回答例
synchronizedメソッドを利用
synchronizedメソッド
package study.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SynchronizedMain {
private static ExecutorService executor = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch = new CountDownLatch(1);
long start = System.currentTimeMillis();
executor.execute(() -> {
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.countDown();
});
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.await();
long end = System.currentTimeMillis();
// countは200,000を期待
System.out.println(String.format("count=%d, 処理時間=%dms", counter.getCount(), (end - start)));
}
private static class Counter {
private int count = 0;
/**
* 1. count の値を読み出す<br>
* 2. 読み出した値に 1 を足す<br>
* 3. 足した結果を count に保存する
*/
public synchronized void countUp() {
this.count++;
}
public synchronized int getCount() {
return this.count;
}
}
}
- 簡単な解説
- この場合、インスタンス自身をロックオブジェクトとして排他制御している
synchronizedブロックでオブジェクトを同期化
synchronizedブロックで同期
package study.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SynchronizedMain {
private static ExecutorService executor = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch = new CountDownLatch(1);
long start = System.currentTimeMillis();
executor.execute(() -> {
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.countDown();
});
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.await();
long end = System.currentTimeMillis();
// countは200,000を期待
System.out.println(String.format("count=%d, 処理時間=%dms", counter.getCount(), (end - start)));
}
private static class Counter {
private int count = 0;
private final Object lock = new Object();
/**
* 1. count の値を読み出す<br>
* 2. 読み出した値に 1 を足す<br>
* 3. 足した結果を count に保存する
*/
public void countUp() {
synchronized (lock) {
this.count++;
}
}
public int getCount() {
synchronized (lock) {
return this.count;
}
}
}
}
- 簡単な解説
- 明示的にロックオブジェクトを使って排他制御をしている
Lockクラスを利用
ReentrantLockクラス
package study.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockMain {
private static ExecutorService executor = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch = new CountDownLatch(1);
long start = System.currentTimeMillis();
executor.execute(() -> {
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.countDown();
});
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.await();
long end = System.currentTimeMillis();
// countは200,000を期待
System.out.println(String.format("count=%d, 処理時間=%dms", counter.getCount(), (end - start)));
}
private static class Counter {
private int count = 0;
private Lock lock = new ReentrantLock();
/**
* 1. count の値を読み出す<br>
* 2. 読み出した値に 1 を足す<br>
* 3. 足した結果を count に保存する
*/
public void countUp() {
lock.lock();
try {
this.count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return this.count;
} finally {
lock.unlock();
}
}
}
}
- 簡単な解説
- ロックオブジェクト+synchronizedと比較して、柔軟な排他制御によるスケーラビリティ(read/writeの分離)やタイムアウト指定が可能なため、安全(このサンプルでは登場していない)
volatile修飾子を利用
volatile修飾子
package study.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.Getter;
public class ThreadUnsafeVolatileMain {
private static ExecutorService executor = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch = new CountDownLatch(1);
long start = System.currentTimeMillis();
executor.execute(() -> {
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.countDown();
});
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.await();
long end = System.currentTimeMillis();
// countは200,000を期待
System.out.println(String.format("count=%d, 処理時間=%dms", counter.getCount(), (end - start)));
}
private static class Counter {
@Getter
private volatile int count = 0;
/**
* 1. count の値を読み出す<br>
* 2. 読み出した値に 1 を足す<br>
* 3. 足した結果を count に保存する
*/
public void countUp() {
this.count++;
}
}
}
- 簡単な解説
- volatile修飾子は可視性(メインメモリの最新の値が見える)のみを保証するだけで、Compare-and-Swap(CAS)の原子性は保証されないので、スレッドセーフではない。期待する結果が得られない
Atomic変数を利用(このお題に対しての、私の中のベストアンサー)
Atomic変数
package study.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AtomicIntegerMain {
private static ExecutorService executor = Executors.newSingleThreadExecutor();
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch = new CountDownLatch(1);
long start = System.currentTimeMillis();
executor.execute(() -> {
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.countDown();
});
for (int i = 0; i < 100_000; i++) {
counter.countUp();
}
latch.await();
long end = System.currentTimeMillis();
// countは200,000を期待
System.out.println(String.format("count=%d, 処理時間=%dms", counter.getCount(), (end - start)));
}
private static class Counter {
private AtomicInteger count = new AtomicInteger(0);
/**
* 1. count の値を読み出す<br>
* 2. 読み出した値に 1 を足す<br>
* 3. 足した結果を count に保存する
*/
public void countUp() {
this.count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
}
- 簡単な解説
- Atomic変数は、Compare-and-Swap(CAS)の原子性を保証しつつ、ロックフリー同期を行うため、synchronizedやLockよりもパフォーマンスが劣化しにくい。かつ、デッドロックも起きないため安全
- ただし、1つの処理単位で複数の変数の更新を行う場合には、複雑な実装が必要になるため、synchronizedやLockを使った方が簡単にはなる
補足)Javaのメモリモデル
Javaのスレッドプログラミングを正しく理解するには、Javaのメモリモデルの理解が必須(スレッドごとのローカルメモリとヒープの関係)
さいごに
スレッドセーフなプログラムは複数通りの書き方ができますが、人によって違う答えが帰ってきて面白いなーと思ったので、ここに書いてみました。それぞれの違いを正しく理解して適材適所でプログラミングできることが重要だと再認識。
他にも、初心者向けの解説や最適な題材があれば、教えていただけると嬉しいです!