Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

變數、型別與輸入輸出

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 全都一樣。進階篇要做的事,就是掀開 intdoublechar 這些型別名稱的蓋子,看看記憶體裡到底躺著什麼樣的位元(bit),以及為什麼理解這層真相,會直接影響你寫出的程式對不對。

變數、型別與輸入輸出進階概念示意圖

整數型別的本質:補數與環繞溢位

入門篇說 int 是「整數」,範圍大約是正負 21 億。但這個範圍從哪來?答案藏在「補數表示法」(two's complement)裡。

Java 的 int 固定是 32 位元。最高位是符號位,其餘 31 位表示數值,因此範圍是 $-2^{31}$ 到 $2^{31}-1$,也就是 Integer.MIN_VALUEInteger.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),讓 intInteger 看似可以隨意混用。但 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()

另一個拆箱地雷是 nullInteger 可以是 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)在大數時失效;拆箱 nullNullPointerException
  • char 是 16 位元整數:emoji 等需代理對,String.length() 算的是碼元不是字元;char 可做算術。
  • 型別提升決定結果byte/short/char 運算先升為 int;整數除法 5/22,要小數須讓至少一方為 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)」切入,這也是密碼學中許多運算的數學基礎。把型別當成「記憶體佈局加上一組允許的運算」來理解,你會發現高階語言的型別系統,與底層硬體的位元真相之間,始終存在一道需要程式設計師親自彌合的縫隙。

AI 共讀助教正在陪你讀:Java 型別的底層真相:溢位、浮點誤差與裝箱陷阱
嗨!我是這篇文章的共讀助教,只根據〈Java 型別的底層真相:溢位、浮點誤差與裝箱陷阱〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。