Java 型別的底層真相:溢位、浮點誤差與裝箱陷阱
從 0.1 + 0.2 ≠ 0.3 出發,掀開 int、double、char 的位元蓋子,看懂型別系統如何決定程式的對與錯
為什麼 0.1 + 0.2 不等於 0.3?型別底層的真相
入門篇我們學會了宣告變數、認識八種基本型別(primitive type),也用 Scanner 讀進了第一筆輸入。但如果你打開 jshell,輸入下面這一行,會看到一個讓人不安的結果:
jshell> System.out.println(0.1 + 0.2)
0.30000000000000004
這不是 Java 的 bug,也不是 JVM 偷懶。這是 IEEE 754 浮點數標準(floating-point standard)在所有現代程式語言中的共同行為——C、Python、JavaScript 全都一樣。進階篇要做的事,就是掀開 int、double、char 這些型別名稱的蓋子,看看記憶體裡到底躺著什麼樣的位元(bit),以及為什麼理解這層真相,會直接影響你寫出的程式對不對。

整數型別的本質:補數與環繞溢位
入門篇說 int 是「整數」,範圍大約是正負 21 億。但這個範圍從哪來?答案藏在「補數表示法」(two's complement)裡。
Java 的 int 固定是 32 位元。最高位是符號位,其餘 31 位表示數值,因此範圍是 $-2^{31}$ 到 $2^{31}-1$,也就是 Integer.MIN_VALUE 到 Integer.MAX_VALUE。關鍵在於 Java 規定整數運算「溢位不報錯,而是環繞」(wrap-around)。我們直接驗證:
System.out.println(Integer.MAX_VALUE); // 2147483647
System.out.println(Integer.MAX_VALUE + 1); // -2147483648 ← 變成最小值!
加一就從最大跳到最小,因為補數表示法在位元層面其實是一個首尾相接的「環」。最大值 2147483647 的位元是 0111...1111,加一進位後變成 1000...0000,而最高位為 1 在補數中代表負數,於是得到 -2147483648。
這個特性會釀成真實災難。1996 年歐洲太空總署的 Ariane 5 火箭首航爆炸,根源就是把一個 64 位元浮點數塞進 16 位元整數造成溢位。在 Java 中,一個常見的隱性 bug 是「二分搜尋的中點計算」:
// 危險寫法:當 low + high 超過 21 億時溢位變負數
int mid = (low + high) / 2;
// 安全寫法:避免相加產生中間溢位
int mid = low + (high - low) / 2;
如果你需要絕對不容許靜默溢位,Java 提供 Math.addExact,它會在溢位時直接拋出 ArithmeticException:
try {
int r = Math.addExact(Integer.MAX_VALUE, 1);
} catch (ArithmeticException e) {
System.out.println("偵測到溢位:" + e.getMessage());
}
浮點數為什麼「算不準」
回到開頭的問題。double 採用 IEEE 754 雙精度格式,64 位元拆成三段:1 位符號、11 位指數(exponent)、52 位尾數(mantissa)。它表示的值是:
$$(-1)^{sign} \times 1.mantissa \times 2^{exponent - 1023}$$
這是一套以 2 為底的科學記號。問題在於:十進位的 0.1,換成二進位是無限循環小數 0.0001100110011...,就像十進位無法精確表示 $\frac{1}{3}$ 一樣。52 位尾數放不下無限循環,只能截斷,於是 0.1 存進記憶體的瞬間就已經有了微小誤差。0.1 + 0.2 把兩個誤差相加,剛好露出馬腳。
由此推出一條鐵律:浮點數絕對不能用 == 比較。
double a = 0.1 + 0.2;
System.out.println(a == 0.3); // false ← 千萬別這樣判斷
// 正確做法:比較差距是否在容許誤差內
double eps = 1e-9;
System.out.println(Math.abs(a - 0.3) < eps); // true
那麼,涉及金錢的程式怎麼辦?答案是「不要用 double 算錢」。標準解法是 BigDecimal,它用十進位精確運算:
import java.math.BigDecimal;
// 注意:必須用「字串」建構,否則一開始就吃到 double 的誤差
BigDecimal x = new BigDecimal("0.1");
BigDecimal y = new BigDecimal("0.2");
System.out.println(x.add(y)); // 0.3 ← 完全精確
特別提醒:new BigDecimal(0.1)(傳 double)和 new BigDecimal("0.1")(傳字串)結果天差地遠,前者會把 double 的誤差原封不動帶進來。這是初學者最常踩的雷。
自動裝箱:藏在型別之間的陷阱
入門篇區分了基本型別(int)與其包裝類別(wrapper class,Integer)。進階篇要看的是兩者「自動轉換」時的暗門。
Java 會自動裝箱(autoboxing)與拆箱(unboxing),讓 int 與 Integer 看似可以隨意混用。但 Integer 是物件,== 比較的是「參考是否指向同一個物件」,而不是數值。更詭異的是,JVM 為了效能會快取 −128 到 127 的 Integer 物件:
Integer a = 100, b = 100;
System.out.println(a == b); // true ← 落在快取範圍,是同一物件
Integer c = 200, d = 200;
System.out.println(c == d); // false ← 超出快取,是兩個物件!
System.out.println(c.equals(d)); // true ← 比較數值才是正解
同一段邏輯,數字小的時候對、大的時候錯,這種 bug 極難察覺。記住:包裝類別比數值一律用 .equals()。
另一個拆箱地雷是 null。Integer 可以是 null,當它被自動拆箱成 int 時會拋出 NullPointerException:
Integer score = null;
int s = score; // 執行期爆炸:NullPointerException
看一個例子:讀檔解析的型別防護
把這些觀念串起來,看一個處理使用者輸入的真實情境。假設要從輸入讀一連串成績求平均,輸入可能夾雜空白或非數字:
import java.util.Scanner;
public class Average {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
long sum = 0; // 用 long 累加,預防多筆 int 相加溢位
int count = 0;
while (sc.hasNext()) {
String token = sc.next();
try {
int score = Integer.parseInt(token); // 自己解析,掌控錯誤
sum += score;
count++;
} catch (NumberFormatException e) {
System.out.println("略過非數字:" + token);
}
}
if (count == 0) {
System.out.println("沒有有效資料");
} else {
// 強制轉 double 才能得到小數平均,否則是整數除法
double avg = (double) sum / count;
System.out.printf("平均:%.2f%n", avg);
}
}
}
這段程式示範了三個進階要點:用 long 累加避免溢位、用 try-catch 取代脆弱的 nextInt()、用 (double) 強制轉型避免「整數除法把小數無情砍掉」。最後一點尤其常被忽略——5 / 2 在 Java 是 2 不是 2.5,因為兩個運算元都是整數。
字元與字串:Unicode 的真實寬度
入門篇把 char 描述成「一個字元」。但 char 在 Java 是 16 位元無號整數,存的是 UTF-16 的一個「碼元」(code unit),範圍 0 到 65535。問題是:Unicode 早就超過 65536 個字了。
像 emoji 或某些罕用漢字,需要兩個 char 組成一對「代理對」(surrogate pair)才能表示。這導致 String.length() 回傳的不是「人眼看到的字數」,而是碼元數:
String emoji = "😀";
System.out.println(emoji.length()); // 2 ← 不是 1!
System.out.println(emoji.codePointCount(0, emoji.length())); // 1 ← 真正的字元數
char 本質是整數,所以可以做算術,這也解釋了大小寫轉換的原理:
char c = 'A';
System.out.println((int) c); // 65
System.out.println((char)(c + 32)); // 'a' ← 大小寫差 32
動手算一下:型別提升(type promotion)
混合型別運算時,Java 會自動把「較小」的型別提升為「較大」的型別。試著預測這幾行的輸出,再驗證:
byte b = 10;
short s = 20;
// b + s 的結果型別是?答案:int(byte/short 一律先提升為 int)
// 因此下面這行無法編譯,需要強制轉回 byte:
// byte result = b + s; // 編譯錯誤
byte result = (byte)(b + s); // 30
char x = 'A';
int code = x + 1; // 66(char 提升為 int)
long big = 3_000_000_000L; // 超過 int 範圍,字面值後綴必須加 L
double mix = 5 / 2 + 0.0; // 2.0 ← 整數除法先算完才轉 double
double right = 5.0 / 2; // 2.5 ← 有一個是 double,整段就用浮點運算
關鍵在 5 / 2 + 0.0:除法先在整數世界算出 2,加 0.0 已經太遲。型別提升的時機,決定了答案的對錯。
重點回顧
- 整數會環繞溢位不報錯:
Integer.MAX_VALUE + 1變成最小值;累加用long,需嚴格檢查用Math.addExact。 - 浮點數不能用
==比:0.1 + 0.2 != 0.3;改比較差距Math.abs(a-b) < eps,算錢用BigDecimal並以字串建構。 - 包裝類別用
.equals():Integer的==因快取(−128~127)在大數時失效;拆箱null會NullPointerException。 char是 16 位元整數:emoji 等需代理對,String.length()算的是碼元不是字元;char可做算術。- 型別提升決定結果:
byte/short/char運算先升為int;整數除法5/2得2,要小數須讓至少一方為double。
深入探討(研究所視角)
單位最後一位(ULP)與浮點數的稠密度。 IEEE 754 的精度不是均勻的。兩個相鄰可表示浮點數之間的距離稱為 ULP(Unit in the Last Place),它隨數值大小而變化:數值越大,ULP 越大,可表示的數越稀疏。在 1.0 附近,double 的 ULP 約為 $2^{-52} \approx 2.2 \times 10^{-16}$;但到了 $2^{53}$ 之後,連續整數都無法全部精確表示——這正是 (long) 轉 double 可能失真的根源。理解 ULP,是分析數值演算法(如 Kahan 補償求和)誤差累積的基礎。
為什麼 Java 拿掉了 strictfp 的預設差異。 早期 Java 允許中間運算使用 x86 的 80 位元延伸精度暫存器,導致同一段程式在不同硬體上結果略有差異。Java 17(JEP 306)讓所有浮點運算一律嚴格遵守 IEEE 754,strictfp 關鍵字從此形同虛設。這反映了一個語言設計上的張力:可重現性(reproducibility)與硬體效能的取捨。對需要跨平台位元級一致的科學計算與金融系統,可重現性勝出。
值型別(value type)與 Project Valhalla。 入門篇的「基本型別 vs 物件」二分法,在 JVM 即將被打破。Integer 這類包裝物件存在記憶體間接定址(pointer indirection)與額外的物件標頭(object header)開銷,在大規模數值陣列上代價高昂。OpenJDK 的 Project Valhalla 正引入「值類別」(value class),讓物件能像基本型別一樣「攤平」(flatten)存放,消除裝箱開銷,同時保留物件的抽象能力。屆時「型別」的記憶體佈局將由 JVM 動態決定,而非由語法死板區分。
延伸閱讀方向: Goldberg 的經典論文〈What Every Computer Scientist Should Know About Floating-Point Arithmetic〉(1991)是浮點數議題的奠基文獻;想理解整數補數的代數結構,可從「模 $2^n$ 的環(ring)」切入,這也是密碼學中許多運算的數學基礎。把型別當成「記憶體佈局加上一組允許的運算」來理解,你會發現高階語言的型別系統,與底層硬體的位元真相之間,始終存在一道需要程式設計師親自彌合的縫隙。