- JAVA
Java AtomicIntegerとConcurrentHashMapの使い方
AtomicInteger は「1つの数値を、複数のスレッドから同時に足しても数がずれないようにするクラス」、ConcurrentHashMap は「複数のスレッドから同時に読み書きしても壊れない Map」です。 どちらも java.util.concurrent に入っている標準クラスで、synchronized を自分で書かなくてもスレッドセーフ(複数スレッドから使っても正しく動くこと)なカウンタや集計が作れます。
ただし「ConcurrentHashMap に替えれば自動的に正しくなる」わけではなく、get してから put する2手の書き方を残すと、数は同じようにずれます。この記事では、int++・synchronized・AtomicInteger・LongAdder の4方式で同じ回数を数えた結果と、単語カウントがずれる書き方・正しくなる書き方を、すべて手元で動かした出力つきで並べます。
確認環境:Ubuntu 24.04 / OpenJDK 21.0.10(CPU 2コア)。掲載したコードはすべて java ファイル名.java で実行し、出力をそのまま載せています。時間の数値はマシンや実行のたびに変わるため、大小関係の目安として読んでください。
AtomicIntegerとConcurrentHashMapの早見表
| やりたいこと | 使うもの | 書き方 | ポイント |
|---|---|---|---|
| 1つのカウンタを増やす | AtomicInteger | counter.incrementAndGet() |
読む・足す・書くが1手で終わる |
| 更新がとても多い集計用カウンタ | LongAdder | adder.increment() / adder.sum() |
合計を読むのは集計時だけ、という用途向け |
| 条件つきで値を書き換える | AtomicInteger | compareAndSet(期待値, 新しい値) |
読んだときの値のままなら書き換える |
| 式で更新する(上限つきなど) | AtomicInteger | updateAndGet(x -> ...) |
関数は何度か呼び直されることがある |
| キーごとに数える | ConcurrentHashMap | map.merge(key, 1, Integer::sum) |
get→put の2手を1手にする |
| 今の値から新しい値を計算する | ConcurrentHashMap | map.compute(key, (k, v) -> ...) |
v はキーが無いとき null |
| 無ければ作って入れる | ConcurrentHashMap | map.computeIfAbsent(key, k -> ...) |
作る処理は1キーにつき1回 |
| キーごとの高頻度カウンタ | ConcurrentHashMap + LongAdder | map.computeIfAbsent(key, k -> new LongAdder()).increment() |
公式ドキュメントに載っている形 |
| 複数の変数をまとめて守る | synchronized / Lock | synchronized (lock) { ... } |
2つ以上の値の整合性はロックで守る |
| 方式 | 合計は正しいか | 待たされ方 | 向く場面 |
|---|---|---|---|
int の count++ |
ずれる | 待たない | 1スレッドだけで使う変数 |
| synchronized | 正しい | ロックを取れるまで待つ | 複数の変数・複数の手順をまとめて守る |
| AtomicInteger | 正しい | 待たずにやり直す(CAS) | 1つの数値のカウンタ、連番、フラグ |
| LongAdder | 正しい | 待たずに分散して足す | 更新が多く、読むのは時々の集計 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java AtomicIntegerとConcurrentHashMapの使い方」
count++ はなぜスレッドが増えるとずれるのか
count++ は1行ですが、中では 「読む → 1を足す → 書き戻す」の3手 に分かれています。2つのスレッドが同じ値を読んでしまうと、どちらも同じ結果を書き戻すので、2回足したのに1しか増えません。
| 順番 | スレッドA | スレッドB | count |
|---|---|---|---|
| 1 | 読む(100) | 100 | |
| 2 | 読む(100) | 100 | |
| 3 | 101 を書く | 101 | |
| 4 | 101 を書く | 101(2回足したのに1しか増えない) |
この「読んでから書くまでの間に、ほかのスレッドが入り込む」形が、カウンタでも Map でも、数がずれる原因のほぼすべてです。対策は2通りあります。
- 入り込ませない:3手の間、ほかのスレッドを待たせる(synchronized、ReentrantLock)
- 入り込まれたらやり直す:書く直前に「読んだときの値のままか」を確かめ、違えば読み直す(AtomicInteger、ConcurrentHashMap の merge など)
この「確かめてから書く」を、途中で割り込まれない1回の操作として行うのが CAS(compare-and-set) で、AtomicInteger の compareAndSet がこれにあたります。
4方式で10スレッド×10万回カウントした結果
int++・synchronized・AtomicInteger・LongAdder で、10スレッドが10万回ずつ足すプログラムです。CountDownLatch(合図を待つための部品)で10スレッドを同時にスタートさせています。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public class CounterCompare {
static final int THREADS = 10;
static int loops = 100_000;
static int plain = 0;
static int locked = 0;
static final Object LOCK = new Object();
static final AtomicInteger atomic = new AtomicInteger();
static final LongAdder adder = new LongAdder();
// 10スレッドを同時にスタートさせ、全員が終わるまでの時間(ミリ秒)を返す
static double run(Runnable increment) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
return;
}
for (int j = 0; j < loops; j++) {
increment.run();
}
});
threads[i].start();
}
long begin = System.nanoTime();
start.countDown();
for (Thread t : threads) {
t.join();
}
return (System.nanoTime() - begin) / 1_000_000.0;
}
public static void main(String[] args) throws InterruptedException {
if (args.length > 0) {
loops = Integer.parseInt(args[0]);
}
System.out.printf("期待する合計: %,d(%dスレッド×%,d回)%n", THREADS * loops, THREADS, loops);
double t1 = run(() -> plain++);
System.out.printf("int++ 合計=%,11d 時間=%7.1f ms%n", plain, t1);
double t2 = run(() -> { synchronized (LOCK) { locked++; } });
System.out.printf("synchronized 合計=%,11d 時間=%7.1f ms%n", locked, t2);
double t3 = run(() -> atomic.incrementAndGet());
System.out.printf("AtomicInteger 合計=%,11d 時間=%7.1f ms%n", atomic.get(), t3);
double t4 = run(() -> adder.increment());
System.out.printf("LongAdder 合計=%,11d 時間=%7.1f ms%n", adder.sum(), t4);
}
}
実行結果(java CounterCompare.java):
期待する合計: 1,000,000(10スレッド×100,000回)
int++ 合計= 982,338 時間= 100.3 ms
synchronized 合計= 1,000,000 時間= 137.1 ms
AtomicInteger 合計= 1,000,000 時間= 57.4 ms
LongAdder 合計= 1,000,000 時間= 62.4 ms
int++ だけが 982,338 になり、17,662回ぶんの加算が消えました。 ほかの3方式は 1,000,000 ちょうどです。
同じコマンドを5回実行したところ、int++ の合計は 1,000,000 / 982,338 / 1,000,000 / 982,249 / 1,000,000 でした。10万回のループは一瞬で終わるため、スレッド同士がたまたま重ならずに正しい値になる回もあります。「テストで合っていたから大丈夫」とは言えない のが、この種の不具合の特徴です。
回数を増やすと差がはっきり出る
1スレッド500万回(合計5,000万回)に増やして実行した結果です(java CounterCompare.java 5000000)。
期待する合計: 50,000,000(10スレッド×5,000,000回)
int++ 合計= 49,427,640 時間= 266.1 ms
synchronized 合計= 50,000,000 時間= 1933.5 ms
AtomicInteger 合計= 50,000,000 時間= 957.0 ms
LongAdder 合計= 50,000,000 時間= 953.7 ms
5回実行した範囲をまとめると次のとおりです。
| 方式 | 合計(5回の結果) | 時間(5回の範囲) | わかること |
|---|---|---|---|
| int++ | 44,082,405〜49,996,967(5回ともずれた) | 59〜626 ms | 速いが、数が合わない |
| synchronized | 5回とも 50,000,000 | 1,452〜4,572 ms | 正しい。ロックの取り合いで時間がかかる |
| AtomicInteger | 5回とも 50,000,000 | 616〜1,113 ms | 正しく、synchronized のおよそ半分の時間 |
| LongAdder | 5回とも 50,000,000 | 732〜1,185 ms | 正しい。この2コア環境では AtomicInteger と同程度 |
- 回数が多いと int++ は毎回ずれました。ずれる幅も実行のたびに違います。
- AtomicInteger は synchronized より短い時間で、同じ正しい合計 になりました。
- LongAdder は、公式ドキュメントで「更新の競合が少ないときは AtomicLong と似た特性で、競合が多いときは期待されるスループットが大きく上回る。その代わりメモリを多く使う」と説明されています。今回の2コア環境では AtomicInteger とほぼ同じ時間でした。コア数とスレッド数が多い環境ほど差が出やすい、という性質のクラスです。
LongAdder を選ぶ基準
| 見るところ | AtomicInteger / AtomicLong | LongAdder |
|---|---|---|
| 今の値を使って判断する(compareAndSet) | できる | できない(足すだけ) |
| 足した直後の値を受け取る | incrementAndGet() で受け取れる |
受け取れない(sum() で別途読む) |
| 合計を読むときの正確さ | その瞬間の値 | 更新と同時に読むと、途中の更新が含まれないことがある |
| 向く用途 | 連番、残数、フラグ | アクセス回数などの統計 |
公式ドキュメントには、sum() について「返す値はアトミックなスナップショットではない。同時に更新が無ければ正確な結果を返すが、合計の計算中に起きた更新は含まれないことがある」と書かれています。連番の採番や残数の管理は AtomicInteger、統計の集計は LongAdder と覚えると選びやすくなります。
AtomicIntegerのメソッド早見表
名前のルールは 「先に書いてある動作が先に行われる」 です。incrementAndGet は「足してから取る」、getAndIncrement は「取ってから足す」です。
| メソッド | 動き | 戻り値 | int での相当 |
|---|---|---|---|
get() |
今の値を読む | 今の値 | x |
set(v) |
値を入れる | なし | x = v |
incrementAndGet() |
1足す | 足した後の値 | ++x |
getAndIncrement() |
1足す | 足す前の値 | x++ |
decrementAndGet() |
1引く | 引いた後の値 | --x |
getAndDecrement() |
1引く | 引く前の値 | x-- |
addAndGet(d) |
d を足す | 足した後の値 | x += d |
getAndAdd(d) |
d を足す | 足す前の値 | なし |
getAndSet(v) |
v に置き換える | 置き換える前の値 | なし |
compareAndSet(期待値, 新値) |
今の値が期待値と同じなら新値にする | 書き換えたら true | なし |
compareAndExchange(期待値, 新値) |
同上(Java 9 以降) | 書き換える前に見えた値 | なし |
updateAndGet(関数) |
関数の結果に置き換える(Java 8 以降) | 更新後の値 | なし |
getAndUpdate(関数) |
関数の結果に置き換える | 更新前の値 | なし |
accumulateAndGet(v, 関数) |
今の値と v を関数に渡した結果に置き換える | 更新後の値 | なし |
intValue() |
今の値を int で読む | 今の値 | x |
new AtomicInteger(10) から始めて、上から順に呼んだときの出力です(左が呼んだメソッド、右が戻り値)。
get() -> 10
incrementAndGet() -> 11 (足した後の値)
getAndIncrement() -> 11 (足す前の値) 現在=12
decrementAndGet() -> 11
addAndGet(5) -> 16
getAndAdd(5) -> 16 現在=21
compareAndSet(21, 100) -> true 現在=100
compareAndSet(21, 200) -> false 現在=100
updateAndGet(x -> x * 2) -> 200
getAndUpdate(x -> x / 4) -> 200 現在=50
accumulateAndGet(70, max) -> 70
getAndSet(0) -> 70 現在=0
set(7) の後 intValue() -> 7
compareAndExchange(7, 8) -> 7 現在=8
compareAndExchange(7, 9) -> 8 現在=8
compareAndSet は「今の値が21なら100にする」という条件つきの書き換え です。2回目は値がすでに100になっているので false が返り、何も変わりません。compareAndExchange は true / false の代わりに「書き換える前に見えた値」を返すので、うまくいかなかったときに今の値がわかります。
同じ java.util.concurrent.atomic パッケージには、配列の要素ごとに同じ操作ができる AtomicIntegerArray などもあります。
「確認してから更新」は compareAndSet か updateAndGet で1手にする
AtomicInteger を使っていても、get() で確認してから別のメソッドで更新すると、その間にほかのスレッドが入り込めます。残り100個の在庫を10スレッドで取り合う例で確かめます。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
public class StockTake {
static final AtomicInteger naive = new AtomicInteger(100);
static final AtomicInteger cas = new AtomicInteger(100);
static final AtomicInteger update = new AtomicInteger(100);
// 確認(get)と更新(decrementAndGet)が2手に分かれている
static boolean takeNaive() {
if (naive.get() > 0) {
Thread.yield(); // 割り込まれやすくして、ずれを見えやすくする
naive.decrementAndGet();
return true;
}
return false;
}
// compareAndSet のループ:読んだときの値のままなら書き換える
static boolean takeWithCas() {
while (true) {
int current = cas.get();
if (current <= 0) {
return false;
}
Thread.yield(); // 条件をそろえるため同じ位置に入れる
if (cas.compareAndSet(current, current - 1)) {
return true;
}
}
}
// getAndUpdate で同じことを1行で書く(0より下には下げない)
static boolean takeWithUpdate() {
return update.getAndUpdate(x -> x > 0 ? x - 1 : x) > 0;
}
static int race(BooleanSupplier take) throws InterruptedException {
AtomicInteger success = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
return;
}
for (int j = 0; j < 1000; j++) {
if (take.getAsBoolean()) {
success.incrementAndGet();
}
}
});
threads[i].start();
}
start.countDown();
for (Thread t : threads) {
t.join();
}
return success.get();
}
public static void main(String[] args) throws InterruptedException {
System.out.println("get→decrementAndGet : 取れた数=" + race(StockTake::takeNaive) + " 残り=" + naive.get());
System.out.println("compareAndSet ループ : 取れた数=" + race(StockTake::takeWithCas) + " 残り=" + cas.get());
System.out.println("getAndUpdate : 取れた数=" + race(StockTake::takeWithUpdate) + " 残り=" + update.get());
}
}
get→decrementAndGet : 取れた数=109 残り=-9
compareAndSet ループ : 取れた数=100 残り=0
getAndUpdate : 取れた数=100 残り=0
2手に分けた書き方では、100個しかないのに109個取れて、残りが -9 になりました(3回実行して3回とも同じ結果でした)。1つ1つのメソッドはアトミック(途中に割り込まれない)でも、2つを続けて呼んだ「組み合わせ」はアトミックではないからです。
- compareAndSet のループ:読んだ値 current のままなら current - 1 に書き換え、ほかのスレッドが先に変えていたら false が返るので読み直します。
- updateAndGet / getAndUpdate:上のループを内部でやってくれるメソッドです。関数はやり直しのたびに呼ばれることがあるので、関数の中にはログ出力や外部への書き込みなどの副作用を入れず、計算だけ を書きます。
ConcurrentHashMapで単語を数える:ずれる書き方と正しい書き方
次は Map です。6語の並び(apple が3回、banana が2回、cherry が1回)を、10スレッドが1万回ずつ数えます。正しければ apple=300000、banana=200000、cherry=100000 になります。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public class WordCount {
static final String[] WORDS = {"apple", "banana", "cherry", "apple", "banana", "apple"};
static final int THREADS = 10;
static final int LOOPS = 10_000; // 1スレッドが単語の並びを1万回読む
interface Counter { void count(String word); }
static void run(String label, Map<String, ?> map, Counter counter) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
Thread[] threads = new Thread[THREADS];
AtomicInteger errors = new AtomicInteger();
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
try {
start.await();
for (int j = 0; j < LOOPS; j++) {
for (String w : WORDS) counter.count(w);
}
} catch (Exception e) {
errors.incrementAndGet();
}
});
threads[i].start();
}
start.countDown();
for (Thread t : threads) t.join();
System.out.printf("%-34s apple=%s banana=%s cherry=%s%s%n", label,
map.get("apple"), map.get("banana"), map.get("cherry"),
errors.get() > 0 ? " 例外で止まったスレッド=" + errors.get() : "");
}
public static void main(String[] args) throws InterruptedException {
System.out.println("期待する値 apple=300000 banana=200000 cherry=100000");
// 1. HashMap で get → put
Map<String, Integer> m1 = new HashMap<>();
run("1 HashMap get→put", m1, w -> {
Integer old = m1.get(w);
m1.put(w, old == null ? 1 : old + 1);
});
// 2. ConcurrentHashMap でも get → put だと数がずれる
Map<String, Integer> m2 = new ConcurrentHashMap<>();
run("2 ConcurrentHashMap get→put", m2, w -> {
Integer old = m2.get(w);
m2.put(w, old == null ? 1 : old + 1);
});
// 3. merge
Map<String, Integer> m3 = new ConcurrentHashMap<>();
run("3 ConcurrentHashMap merge", m3, w -> m3.merge(w, 1, Integer::sum));
// 4. compute
Map<String, Integer> m4 = new ConcurrentHashMap<>();
run("4 ConcurrentHashMap compute", m4, w -> m4.compute(w, (k, v) -> v == null ? 1 : v + 1));
// 5. computeIfAbsent + AtomicInteger
Map<String, AtomicInteger> m5 = new ConcurrentHashMap<>();
run("5 computeIfAbsent+AtomicInteger", m5, w -> m5.computeIfAbsent(w, k -> new AtomicInteger()).incrementAndGet());
// 6. computeIfAbsent + LongAdder
Map<String, LongAdder> m6 = new ConcurrentHashMap<>();
run("6 computeIfAbsent+LongAdder", m6, w -> m6.computeIfAbsent(w, k -> new LongAdder()).increment());
}
}
期待する値 apple=300000 banana=200000 cherry=100000
1 HashMap get→put apple=152581 banana=102452 cherry=99442
2 ConcurrentHashMap get→put apple=122441 banana=124156 cherry=45002
3 ConcurrentHashMap merge apple=300000 banana=200000 cherry=100000
4 ConcurrentHashMap compute apple=300000 banana=200000 cherry=100000
5 computeIfAbsent+AtomicInteger apple=300000 banana=200000 cherry=100000
6 computeIfAbsent+LongAdder apple=300000 banana=200000 cherry=100000
2番がこの記事でいちばん伝えたい点です。get と put のそれぞれは安全でも、2手の間にほかのスレッドが入り込めます。ConcurrentHashMap に替えても、get→put の書き方を残すと apple は 300000 のはずが 122441 でした。 もう一度実行すると apple=55742、banana=182430、cherry=59719 と、ずれ方も毎回変わりました。ConcurrentHashMap が守ってくれるのは「Map の内部構造が壊れないこと」と「メソッド1回ぶんの動作」であり、メソッド2回にまたがる手順までは守りません。
merge・compute・computeIfAbsent の使い分け
公式ドキュメントでは、この3つのメソッドについて「メソッド呼び出し全体がアトミックに行われる」と明記されています。
| メソッド | 関数に渡るもの | キーが無いとき | 関数が null を返すと | 向く場面 |
|---|---|---|---|---|
merge(key, 値, (今の値, 値) -> 新値) |
今の値と、渡した値 | 渡した値をそのまま入れる(関数は呼ばれない) | そのキーを消す | 数える、足し合わせる、文字列をつなぐ |
compute(key, (k, v) -> 新値) |
キーと今の値(無ければ null) | v が null で関数が呼ばれる | そのキーを消す | 今の値に応じて処理を分けたい |
computeIfAbsent(key, k -> 値) |
キー | 関数の結果を入れる | 何も入れない | 無ければ作る(キャッシュ、入れ物の用意) |
computeIfPresent(key, (k, v) -> 新値) |
キーと今の値 | 何もしない | そのキーを消す | あるときだけ更新する |
putIfAbsent(key, 値) |
なし(値を先に作って渡す) | 値を入れる | 対象外 | 値を作るコストが小さいとき |
カウントなら map.merge(key, 1, Integer::sum) がいちばん短く書けます。「キーが無ければ1を入れ、あれば今の値と1を足す」を1回の呼び出しで行います。
「無ければ作って入れる」は computeIfAbsent に任せる
containsKey で確認してから put する書き方も、get→put と同じ2手です。重い初期化を1回だけにしたい場面で、呼ばれた回数を数えてみます。
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class CheckThenAct {
static final AtomicInteger created = new AtomicInteger();
static String load(String key) { // 重い初期化のつもり
created.incrementAndGet();
return "value-of-" + key;
}
static int race(Map<String, String> cache, boolean atomicWay) throws InterruptedException {
created.set(0);
CountDownLatch start = new CountDownLatch(1);
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
return;
}
for (int j = 0; j < 1000; j++) {
String key = "item" + j;
if (atomicWay) {
cache.computeIfAbsent(key, CheckThenAct::load);
} else if (!cache.containsKey(key)) { // 確認して
cache.put(key, load(key)); // 入れる(2手)
}
}
});
threads[i].start();
}
start.countDown();
for (Thread t : threads) {
t.join();
}
return created.get();
}
public static void main(String[] args) throws InterruptedException {
System.out.println("キーは1000種類。load が呼ばれた回数:");
System.out.println("containsKey→put : " + race(new ConcurrentHashMap<>(), false));
System.out.println("computeIfAbsent : " + race(new ConcurrentHashMap<>(), true));
}
}
キーは1000種類。load が呼ばれた回数:
containsKey→put : 1002
computeIfAbsent : 1000
containsKey→put では、同じキーの初期化が2回余分に走りました(もう一度実行したときは 1000 回ちょうどで、こちらも実行のたびに変わります)。computeIfAbsent なら、1つのキーにつき関数が呼ばれるのは1回 です。
公式ドキュメントには続けて「計算の間、ほかのスレッドによるこの Map への更新操作の一部が待たされることがあるので、計算は短く単純にし、この Map のほかのマッピングを更新しようとしてはならない」とあります。関数の中で時間のかかる通信をしたり、同じ Map を書き換えたりするのは避けます。
nullを入れるとNullPointerExceptionになる
ConcurrentHashMap はキーにも値にも null を入れられません。 HashMap から置き換えたときに最初に出会いやすいエラーです。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class NullCheck {
static void tryIt(String label, Runnable r) {
try {
r.run();
System.out.println(label + " -> OK");
} catch (Exception e) {
System.out.println(label + " -> " + e.getClass().getName()
+ (e.getMessage() == null ? "" : ": " + e.getMessage()));
}
}
public static void main(String[] args) {
Map<String, String> hash = new HashMap<>();
Map<String, String> conc = new ConcurrentHashMap<>();
tryIt("HashMap.put(\"a\", null) ", () -> hash.put("a", null));
tryIt("HashMap.put(null, \"x\") ", () -> hash.put(null, "x"));
tryIt("ConcurrentHashMap.put(\"a\", null) ", () -> conc.put("a", null));
tryIt("ConcurrentHashMap.put(null, \"x\") ", () -> conc.put(null, "x"));
tryIt("ConcurrentHashMap.get(null) ", () -> conc.get(null));
tryIt("ConcurrentHashMap.containsKey(null)", () -> conc.containsKey(null));
tryIt("ConcurrentHashMap.merge(\"a\", null)", () -> conc.merge("a", null, (x, y) -> x + y));
// compute の関数が null を返すのは「そのキーを消す」という意味で、例外にはならない
conc.put("b", "1");
conc.compute("b", (k, v) -> null);
System.out.println("compute で null を返した後 containsKey(\"b\") -> " + conc.containsKey("b"));
// getOrDefault ならキーが無いときの null を避けられる
System.out.println("getOrDefault(\"none\", \"0\") -> " + conc.getOrDefault("none", "0"));
}
}
HashMap.put("a", null) -> OK
HashMap.put(null, "x") -> OK
ConcurrentHashMap.put("a", null) -> java.lang.NullPointerException
ConcurrentHashMap.put(null, "x") -> java.lang.NullPointerException
ConcurrentHashMap.get(null) -> java.lang.NullPointerException: Cannot invoke "Object.hashCode()" because "key" is null
ConcurrentHashMap.containsKey(null) -> java.lang.NullPointerException: Cannot invoke "Object.hashCode()" because "key" is null
ConcurrentHashMap.merge("a", null) -> java.lang.NullPointerException
compute で null を返した後 containsKey("b") -> false
getOrDefault("none", "0") -> 0
実際のスタックトレースは次のように出ます。put のメッセージには理由が書かれないので、putVal の行を見たら「キーか値が null」と読み替えます。
Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.concurrent.ConcurrentHashMap.putVal(ConcurrentHashMap.java:1011)
at java.base/java.util.concurrent.ConcurrentHashMap.put(ConcurrentHashMap.java:1006)
at NpeTrace.main(NpeTrace.java:8)
null を入れられないのには理由があります。HashMap では get(key) が null のとき「キーが無い」のか「値として null が入っている」のかを containsKey で確かめられますが、複数スレッドが同時に動く Map では、確かめている間に中身が変わりえます。null を禁止しておけば、「get が null を返した=そのキーは無い」と1回の呼び出しで言い切れます。
| やりたいこと | 書き方 |
|---|---|
| 値が無いことを表したい | キーごと入れない。または Optional.empty() や空文字など、意味のわかる値を入れる |
| 無いときの既定値がほしい | map.getOrDefault(key, 既定値) |
| null かもしれない値を入れる | if (value != null) map.put(key, value); と入れる前に確かめる |
| キーを消したい | map.remove(key)、または compute の関数で null を返す |
ループ中の更新と、そのほかの注意点
for で回している途中に追加・削除しても例外にならない
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class IterateWhileUpdate {
static void loop(String label, Map<String, Integer> map) {
for (int i = 1; i <= 5; i++) {
map.put("k" + i, i);
}
try {
for (String key : map.keySet()) {
if (key.equals("k2")) {
map.put("added", 99); // ループの途中で要素を足す
map.remove("k4"); // ループの途中で要素を消す
}
}
System.out.println(label + " -> 最後まで回った。中身=" + map);
} catch (Exception e) {
System.out.println(label + " -> " + e.getClass().getName());
}
}
public static void main(String[] args) {
loop("HashMap ", new HashMap<>());
loop("ConcurrentHashMap", new ConcurrentHashMap<>());
// computeIfAbsent の関数の中で同じマップを更新するとエラーになる
Map<String, Integer> map = new ConcurrentHashMap<>();
try {
map.computeIfAbsent("a", k -> map.computeIfAbsent("a", k2 -> 1));
} catch (Exception e) {
System.out.println("関数の中で同じキーを更新 -> " + e.getClass().getName() + ": " + e.getMessage());
}
}
}
HashMap -> java.util.ConcurrentModificationException
ConcurrentHashMap -> 最後まで回った。中身={added=99, k1=1, k2=2, k3=3, k5=5}
関数の中で同じキーを更新 -> java.lang.IllegalStateException: Recursive update
- HashMap はループ中に中身を変えると ConcurrentModificationException、ConcurrentHashMap は最後まで回ります。公式ドキュメントではこの性質を「弱い整合性(weakly consistent)」と呼んでいます。ループを始めた後の変更が見えることも見えないこともある、という意味です。
- computeIfAbsent などの関数の中で同じ Map を更新すると、検出できた場合は IllegalStateException: Recursive update になります。関数は「値を作って返すだけ」にします。
size() は「その瞬間の目安」として使う
公式ドキュメントには「size、isEmpty、containsValue などの集計メソッドの結果は、ほかのスレッドが同時に更新していないときにだけ役に立つのが普通」とあります。更新が続いている最中の size() は、監視や見積もりのための値として扱い、if (map.size() < 上限) map.put(...) のような制御には使わないようにします(これも確認→更新の2手です)。
AtomicInteger を equals で比べない
値が5の AtomicInteger を2つ作って a.equals(b) を実行すると false、a.get() == b.get() は true でした。公式ドキュメントにも「アトミックに増やすカウンタなどに使うもので、Integer の代わりにはならない」とあります。比べるときは get() で取り出した int 同士 を比べ、Map のキーや Set の要素には使わないようにします。
synchronizedとの違いと選び方
| 見るところ | synchronized | AtomicInteger | ConcurrentHashMap |
|---|---|---|---|
| 守れる範囲 | ブロックの中に書いた処理すべて | 1つの数値への1回の操作 | Map への1回のメソッド呼び出し |
| 複数の変数をまとめて守る | できる | できない | できない(1キーぶんの更新まで) |
| ほかのスレッドの待ち方 | ロックが空くまで待つ | 待たずにやり直す | 読み取りは基本的に待たない。compute などの計算中は一部の更新が待つことがある |
| 書く量 | ロックの範囲を自分で決める | メソッドを呼ぶだけ | メソッドを呼ぶだけ |
| 気をつける点 | ロックの取り忘れ、範囲の広げすぎ | 2つのメソッドを続けて呼ぶと2手になる | get→put、containsKey→put が2手になる |
ConcurrentHashMap の読み取りについては、公式ドキュメントに「get を含む取得操作は通常ブロックせず、put や remove などの更新操作と重なることがある」と書かれています。Map 全体を自前の synchronized ブロックで囲むと読み取り同士も順番待ちになりますが、ConcurrentHashMap では読み取りが待たされにくいので、読む回数が多い Map ほど向いています。
判断の流れは次の表が目安になります。
| 状況 | 選ぶもの |
|---|---|
| 守りたいのは数値1つ | AtomicInteger / AtomicLong |
| 数値1つで、更新がとても多く、読むのは集計時だけ | LongAdder |
| キーごとの値を複数スレッドで更新する | ConcurrentHashMap + merge / compute / computeIfAbsent |
| 「残数を減らす」と「履歴に足す」など、2つ以上の値を必ずセットで変える | synchronized または ReentrantLock |
| 1スレッドからしか触らない | int や HashMap のままで十分 |
「1つの値への1回の操作」で済むなら Atomic 系か ConcurrentHashMap、複数の値の整合性が必要ならロック という分け方です。両方を組み合わせてもかまいません。
つまずきやすい点のチェックリスト
| 確認すること | そのままにするとこうなる | こう書くとよい |
|---|---|---|
| ConcurrentHashMap で get→put していないか | 数が合わない(今回は 300000 が 122441) | merge / compute を使う |
| containsKey→put していないか | 初期化が複数回走る | computeIfAbsent を使う |
| AtomicInteger で get→set / get→decrement していないか | 残数がマイナスになる(今回は -9) | compareAndSet のループ、updateAndGet |
| null を put していないか | NullPointerException | 入れる前に確かめる、getOrDefault を使う |
| compute 系の関数の中で同じ Map を更新していないか | IllegalStateException: Recursive update | 関数は値を返すだけにする |
| updateAndGet の関数に副作用を入れていないか | やり直しのたびに複数回実行される | 計算だけを書く |
| size() の結果で分岐していないか | 上限を超えて入る | 上限の管理は AtomicInteger やロックで行う |
| AtomicInteger を equals で比べていないか | 同じ値でも false | get() した int を比べる |
カウンタを static int のまま複数スレッドから足していないか |
合計がずれる(毎回ではない) | AtomicInteger / LongAdder にする |
よくある質問
Q. volatile を付けた int なら count++ しても大丈夫ですか? A. 合計はずれます。volatile は「ほかのスレッドが書いた最新の値が見える」ことを保証しますが、「読む → 足す → 書く」の3手を1手にはしません。カウンタには AtomicInteger を使います。
Q. HashMap を複数スレッドから更新するとどうなりますか? A. 今回の実行では、単語カウントの apple が 300000 のはずが 152581 になりました。HashMap は複数スレッドからの同時更新を想定していないクラスです。複数スレッドで共有して更新する Map には ConcurrentHashMap を選び、更新は merge や compute で1手にします。
Q. ConcurrentHashMap の値を Integer にするのと AtomicInteger にするのは、どちらがよいですか?
A. どちらも正しく数えられます(今回の3・4・5番)。merge(key, 1, Integer::sum) は短く書けて、値をそのまま読めます。値を AtomicInteger や LongAdder にすると、入れ物を1回作った後は Map 側の更新が要らなくなるので、同じキーへの更新が集中する集計に向きます。
Q. スレッドセーフな Set がほしいときは?
A. ConcurrentHashMap.newKeySet() で、ConcurrentHashMap を土台にした Set が作れます。add・contains・remove を複数スレッドから呼べます。
まとめ
count++は「読む・足す・書く」の3手。10スレッド×10万回では 1,000,000 のはずが 982,338 になる回があり、5,000万回では5回ともずれた- AtomicInteger は1つの数値を1手で更新するクラス。synchronized と同じ正しい合計を、今回はおよそ半分の時間で出した
- LongAdder は更新が多い集計向け。足すだけに特化し、合計は sum() で読む
- ConcurrentHashMap でも get→put は数がずれる(300000 のはずが 122441)。merge / compute / computeIfAbsent で1手にする
- AtomicInteger でも get→更新の2手はずれる(残りが -9)。compareAndSet のループか updateAndGet を使う
- ConcurrentHashMap はキーにも値にも null を入れられない。NullPointerException が出たら putVal の行を手がかりにする
- 複数の値をセットで守るときは synchronized やロック、1つの値なら Atomic 系、と使い分ける
参考・出典
- Java SE 21 API ドキュメント:java.util.concurrent.atomic.AtomicInteger(docs.oracle.com)
- Java SE 21 API ドキュメント:java.util.concurrent.atomic.LongAdder(docs.oracle.com)
- Java SE 21 API ドキュメント:java.util.concurrent.ConcurrentHashMap(docs.oracle.com)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java AtomicIntegerとConcurrentHashMapの使い方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。