Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

變數、型別與輸入輸出

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 型別的底層真相:溢位、浮點誤差與裝箱陷阱〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。