SJ blog
backend
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

インクリメント・算術演算とメモリの関係 ── i++ が何をしているか、オーバーフローはなぜ起きるか

前置/後置インクリメントの挙動・2の補数による整数表現・オーバーフローの正体・浮動小数点の精度問題を、ビットとメモリの視点から解説。Java Silver 頻出の「なぜ?」を暗記ではなく構造から理解する。

一言結論

i++ と ++i は「評価タイミング」が違う。オーバーフローは「ビットが溢れた」から。0.1+0.2が0.3にならないのは「2進数で表現できないから」。これらはすべてメモリ上のビット表現から説明できる。

int i = 0;
System.out.println(i++); // 0
System.out.println(i);   // 1

byte b = 127;
b++;
System.out.println(b);   // -128 ←なんで?

System.out.println(0.1 + 0.2); // 0.30000000000000004 ←なんで?

これらは「Javaの仕様」として暗記されがちだが、すべてメモリ上のビット表現から必然的に導かれる。仕組みを知れば暗記不要になる。


1. 整数はメモリ上でどう表現されているか

int i = 5; と書いたとき、メモリ上には何が入っているか。

答えは2進数のビット列だ。int は 32 ビット(4バイト)なので、5 はこう格納される:

10進数: 5
2進数: 00000000 00000000 00000000 00000101
       ←────────────── 32 ビット ──────────────────→

各ビット型のサイズをおさらいする:

byte    8 ビット  = 1 バイト   範囲: -128 〜 127
short  16 ビット  = 2 バイト   範囲: -32,768 〜 32,767
int    32 ビット  = 4 バイト   範囲: -2,147,483,648 〜 2,147,483,647
long   64 ビット  = 8 バイト   範囲: 約 -922京 〜 922京

負の数はどう表現するか ── 2の補数

コンピュータは「マイナス記号」を持っていない。ビットは 0 か 1 だけだ。では -1 をどう表現するか。

答えが 2の補数(two’s complement) という方式だ。

ルール:正の数のビットを全部反転して 1 を加えると、その負の数になる。

+1 の表現(8ビット):
  00000001

ビットを全反転:
  11111110

1 を加える:
  11111111  ← これが -1 の表現

確認:

 -1 の表現: 11111111
 +1 の表現: 00000001
─────────────────────
足し算:     00000000  ← 9ビット目に繰り上がりが出るが8ビットでは無視
                        → 結果は 0  ✓(-1 + 1 = 0)

重要なのは最上位ビット(MSB)が符号ビットになる点だ:

0xxxxxxx → 0 から始まる → 正の数または0
1xxxxxxx → 1 から始まる → 負の数
【byte(8ビット)の全表現】

バイト値   ビット表現
  127      01111111  ← 正の最大値
  126      01111110
  ...
    1      00000001
    0      00000000
   -1      11111111
   -2      11111110
  ...
 -127      10000001
 -128      10000000  ← 負の最小値

2. オーバーフローの正体

byte b = 127; b++;-128 になる理由を、ビットで見る。

b = 127 のビット表現(8ビット):
  01111111

+1 する:
  01111111
+ 00000001
──────────
  10000000  ← これが -128 のビット表現

ビットが溢れて符号ビットが立った。 これがオーバーフローの正体だ。

【byte の数直線 ── 実はリング構造になっている】

... → 125 → 126 → 127 → -128 → -127 → ... → -1 → 0 → 1 → ...

                   最大値の次が最小値に折り返す

Java は byte のオーバーフローを例外にしない(C 言語も同様)。ただし黙って値が化ける。これは試験でも実務でもバグの温床になる。

int の場合も同じ:

int max = Integer.MAX_VALUE; // 2,147,483,647
System.out.println(max + 1); // -2,147,483,648(最小値に折り返す)
max のビット(32ビット):
  01111111 11111111 11111111 11111111

+1 すると:
  10000000 00000000 00000000 00000000  ← Integer.MIN_VALUE

実務での注意点: 大きな数の加算(累積カウンタ・ファイルサイズ計算など)で int を使い続けると、数十億を超えたとき静かに負数になる。金額・バイト数などは最初から long を使う習慣をつけると安全。


3. インクリメント・デクリメントとは CPU 命令レベルで何をしているか

i++ は「i の値を 1 増やす」操作だが、CPU 命令レベルでは次の 3 ステップだ:

1. メモリ(またはレジスタ)から i の値を読む
2. 1 を加算する
3. 結果をメモリ(またはレジスタ)に書き戻す

実際には JVM や JIT コンパイラが CPU の INC 命令(インクリメント専用命令)に最適化することが多いが、概念的には「読む → 加算 → 書く」の 3 ステップだ。

これがマルチスレッドで i++ が危険な理由でもある:

スレッド A                     スレッド B
i の値を読む(i = 0)
                               i の値を読む(i = 0)  ← 同時に読んだ
1 を加算する(1)
i に書き戻す(i = 1)
                               1 を加算する(1)
                               i に書き戻す(i = 1)  ← 上書き!

2 つのスレッドが同時に i++ したのに i1 にしかならない(2 になるべき)。これが**競合状態(Race Condition)**だ。synchronizedAtomicInteger が必要になる理由はここにある。


4. 前置インクリメント(++i)と後置インクリメント(i++)の違い

Java Silver で最も頻出の落とし穴の一つ。

int i = 5;

int a = i++;  // a = 5, i = 6
int b = ++i;  // b = 7, i = 7

ルール:

  • i++(後置): 今の値を返してから、インクリメントする
  • ++i(前置): インクリメントしてから、新しい値を返す

処理の順序をステップで追う:

int a = i++;
┌──────────────────────────────────────────────────┐
│ Step 1: i の現在値(5)を一時的に保存            │
│ Step 2: i をインクリメントする(i = 6)          │
│ Step 3: Step 1 で保存した値(5)を a に代入      │
│ → a = 5, i = 6                                   │
└──────────────────────────────────────────────────┘

int b = ++i;
┌──────────────────────────────────────────────────┐
│ Step 1: i をインクリメントする(i = 7)          │
│ Step 2: インクリメント後の値(7)を b に代入     │
│ → b = 7, i = 7                                   │
└──────────────────────────────────────────────────┘

試験でよく出るパターン

int i = 3;
int result = i++ + ++i;
// 計算順:
//   i++ → 3 を返し、i が 4 になる
//   ++i → i が 5 になり、5 を返す
//   result = 3 + 5 = 8
System.out.println(result); // 8
System.out.println(i);      // 5
int i = 0;
i = i++;
System.out.println(i); // 0 ← 1 ではない!

最後のケースが特に引っかかりやすい。i++ は「i の元の値(0)を返してから i を 1 にする」。その後 i = 0(元の値)が代入されるので、結果は 0 になる。

実務では: 前置と後置を複雑に組み合わせるのは読みにくいだけなので書かない。for (int i = 0; i < n; i++)i++ のように「インクリメントの結果を使わない」場面では前置でも後置でも動作は同じ。


5. 複合代入演算子の「隠れたキャスト」

これも試験に出る。

byte b = 10;
b = b + 1;   // コンパイルエラー!
b += 1;      // OK

なぜ b + 1 がエラーで b += 1 が OK なのか。

b + 1 の場合: Java は算術演算を行う前に、byteint に自動拡大変換する(プロモーション)。計算結果は int(32ビット)になる。intbyte 変数に代入しようとすると、縮小変換になるためコンパイルエラー。

byte(8bit) + int(1) → int(32bit) の結果を byte(8bit) に代入 → 縮小変換 → エラー

b += 1 の場合: 複合代入演算子には暗黙のキャストが含まれている。仕様上 b += 1b = (byte)(b + 1) と等価だ。

byte b = 100;
b += 50;  // b = (byte)(100 + 50) = (byte)(150)
          // 150 は byte の範囲(127)を超えているのでオーバーフロー
          // 150 のビット: 10010110 → 符号ビットが 1 → 負の数
System.out.println(b); // -106

確認:

150 のビット表現(8ビット):
  10010110

これは負の数(符号ビット=1)。2の補数で元の値を求める:
  ビット反転: 01101001
  1を加える:  01101010  = 106

→ -106 が正解

覚え方: +=, -=, *=, /= などの複合代入演算子は、右辺の計算後に左辺の型に強制的にキャストする。オーバーフローしても黙って化ける。


6. 浮動小数点演算 ── なぜ 0.1 + 0.2 が 0.3 にならないのか

System.out.println(0.1 + 0.2);              // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3);      // false

これも「Javaのバグ」ではなく、2進数で小数を表現する限り避けられない問題だ。

10進数の小数を2進数で表現すると

0.1 を 2 進数で書いてみる。10進数の小数を2進数に変換するには「×2 して整数部を取る」を繰り返す:

0.1 × 2 = 0.2  → 整数部: 0
0.2 × 2 = 0.4  → 整数部: 0
0.4 × 2 = 0.8  → 整数部: 0
0.8 × 2 = 1.6  → 整数部: 1
0.6 × 2 = 1.2  → 整数部: 1
0.2 × 2 = 0.4  → 整数部: 0  ← 0.2 が再出現!無限ループ

0.1 (10進) = 0.0001100110011001100... (2進、無限循環小数)

0.12 進数では無限に続く小数になる。double(64ビット)は有限のビット数で打ち切るため、誤差が生まれる。

double の格納(IEEE 754 倍精度):

符号(1ビット) | 指数部(11ビット) | 仮数部(52ビット)
      0       | 01111111011    | 1001100110011...011(52ビットで打ち切り)

打ち切った瞬間に 0.1 の近似値になる
→ 0.1000000000000000055511151231257827021181583404541015625...

0.1 + 0.2 は、それぞれの「0.1 の近似値」と「0.2 の近似値」を足した結果なので、微小な誤差が積み重なって 0.30000000000000004 になる。

実務での対処法

// ❌ 浮動小数点の直接比較(やってはいけない)
if (0.1 + 0.2 == 0.3) { ... }

// ✅ 誤差の許容範囲を設ける
double result = 0.1 + 0.2;
double epsilon = 1e-10;
if (Math.abs(result - 0.3) < epsilon) { ... }

// ✅ 金額計算には BigDecimal を使う(10進数を正確に扱える)
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
BigDecimal sum = a.add(b);
System.out.println(sum); // 0.3(正確)

// ⚠️ double から BigDecimal を作るのは NG(すでに誤差が入っている)
new BigDecimal(0.1);  // 0.1000000000000000055511...(誤差込み)
new BigDecimal("0.1"); // 0.1(文字列から作るのが正解)

整数の除算に注意: int / int は小数点以下を切り捨てる(四捨五入ではない)。

System.out.println(7 / 2);    // 3(3.5 ではなく切り捨て)
System.out.println(7.0 / 2);  // 3.5(どちらかが double なら double 演算)
System.out.println((double) 7 / 2); // 3.5(明示的キャスト)

7. 算術演算の型プロモーション ── 計算前に型が変わる

Java は算術演算の前に、小さい型を大きい型に自動変換(プロモーション)する。

プロモーションの優先順位(低 → 高):
byte → short → int → long → float → double

            char もここに合流(char は int として計算)
byte  a = 10;
short b = 20;
int   c = a + b;  // byte + short → int + int → int  ✓

int  x = 1000000;
long y = x * x;   // ← 落とし穴!

// x * x は int × int = int で計算される
// 1,000,000 × 1,000,000 = 1,000,000,000,000 → int 範囲超え → オーバーフロー
// その後 long に入るが、すでに壊れた値が入る

long z = (long) x * x;  // ✓ 先に long にキャストしてから掛ける
正しい計算:
(long) x * x
= (long)1000000 * 1000000
= 1000000L * 1000000    ← ここで long × int → long として計算
= 1,000,000,000,000L    ← 正しい結果

8. 全体像を繋げる

現象原因
byte b = 127; b++;-1288ビットが溢れ、符号ビットが立つ(オーバーフロー)
i++++i の違い評価タイミングの違い(後置は元の値を先に返す)
b = b + 1 がコンパイルエラー算術演算で byteint にプロモーションされ、戻せない
b += 1 はOK複合代入演算子に暗黙のキャストが含まれている
0.1 + 0.2 ≠ 0.30.1 が 2 進数で無限小数になり、有限ビットで打ち切られる
int × int のオーバーフロー掛け算前に long へキャストしないと結果が int の範囲で溢れる

まとめ

「インクリメントは +1 するだけ」と思うと、マルチスレッドの競合や前置後置の違いで詰まる。「浮動小数点は気をつけろ」と暗記するより「2 進数で小数が表現できない」を知れば理由ごと理解できる。

算術演算の背後にあるのは常に「ビットの操作」だ。ここを押さえると:

  • オーバーフローは「予測可能な現象」になる
  • キャスト・プロモーションのルールが「必然」として見える
  • BigDecimal をいつ使うべきかが「構造から」判断できる

次に深掘りするなら

  • ビット演算(&, |, ^, ~, <<, >>: 2の補数の理解があると、ビットシフトで2の冪乗の掛け算をする仕組みなどが一気に理解できる
  • IEEE 754 浮動小数点規格: float/double の内部表現の詳細。符号・指数部・仮数部の構造
  • Math クラスの主要メソッド: Math.abs(), Math.pow(), Math.sqrt(), Math.round() など。Math.round() は「四捨五入に見えて実は +0.5 して floor する」仕様が Silver 頻出
  • AtomicInteger: マルチスレッドでの安全なインクリメント。競合状態の解決策として