Dữ liệu & CPU/Số thực IEEE 754 — vì sao 0.1 + 0.2 không bằng 0.3
4/23
Bài 4 / 23~18 phútBiểu diễn dữ liệuMiễn phí lượt xem

Số thực IEEE 754 — vì sao 0.1 + 0.2 không bằng 0.3

Cách máy lưu số thực bằng dấu/mũ/định trị, vì sao 0.1 không biểu diễn chính xác, và vì sao không bao giờ dùng float cho tiền. Kèm cách so sánh float đúng.

TL;DR: Máy lưu số thực theo chuẩn IEEE 754: chia bit thành 3 phần — dấu, mũ, định trị (mantissa). Vấn đề cốt lõi: 0.1 trong nhị phân là phân số tuần hoàn vô hạn, phải cắt ngắn → sai số nhỏ xuất hiện → tích luỹ qua nhiều phép tính. Hệ quả trực tiếp: không bao giờ so sánh float bằng ==, và không bao giờ dùng float/double cho tiền. Bài này giải thích cơ chế bên dưới để bạn hiểu vì sao — không chỉ biết luật.

Bạn mở Python, gõ 0.1 + 0.2, kỳ vọng 0.3. Kết quả hiện ra là 0.30000000000000004. Bạn thử JavaScript, Java, C — cùng kết quả kỳ lạ đó. Tệ hơn: một hệ thống thu tiền dồn 10.000 giao dịch mỗi ngày, mỗi giao dịch lệch 0.000000000000001 đồng — sau một năm sổ sách không khớp, kế toán tá hoả.

Bài này giải thích vì sao 0.1 + 0.2 không bằng 0.3, cơ chế bên dưới chuẩn IEEE 754, và cách lập trình viên xử lý số thực đúng trong code thực tế.

1. Analogy — bút chì và thước kẻ có giới hạn

Thử biểu diễn 1/3 bằng số thập phân: 0.333333... — vô hạn chữ số. Nếu tờ giấy chỉ có chỗ cho 10 chữ số, bạn viết 0.3333333333 rồi dừng. Giá trị đã mất chính xác một chút.

Nhân 0.3333333333 với 3 không ra đúng 1, mà ra 0.9999999999. Sai số tích luỹ từ lần cắt ngắn ban đầu.

Máy tính gặp đúng vấn đề đó với số thực — nhưng trong hệ nhị phân. Một số như 0.1 trong nhị phân là phân số tuần hoàn vô hạn, phải cắt tại giới hạn bit, dẫn đến sai số nhỏ không thể tránh.

Đời thườngSố thực trong máy
Biểu diễn 1/3 dạng thập phânBiểu diễn 0.1 dạng nhị phân
Phải cắt sau chữ số thứ 10Phải cắt sau bit thứ 52 (double)
Kết quả 0.3333333333, không phải 1/3 chính xácKết quả xấp xỉ 0.1, không phải 0.1 chính xác
Nhân lại × 3 không ra đúng 1Cộng 0.1 + 0.2 không ra đúng 0.3
💡 Cách nhớ

Không phải lỗi của ngôn ngữ hay CPU — đây là giới hạn toán học. Nhị phân không thể biểu diễn chính xác mọi số thập phân, giống thập phân không thể biểu diễn chính xác 1/3.

2. Số thực được lưu thế nào trong bộ nhớ?

Chuẩn IEEE 754 (1985, cập nhật 2008) định nghĩa cách lưu số thực nhị phân. Ý tưởng: viết số dạng ký hiệu khoa học nhị phân, rồi lưu từng thành phần riêng.

Ký hiệu khoa học nhị phân:

(-1)^dau  x  1.dinh_tri  x  2^mu

(với số normalized; số subnormal xem phần Đào sâu)

Ví dụ: số thập phân 6.5 = 110.1 nhị phân = 1.101 × 2² → dấu=0, mũ=2, định trị=101.

Float 32-bit (single precision):

Dấu
1 bit
bit 31
Mũ (Exponent)
8 bit
bit 30–23
Định trị (Mantissa)
23 bit
bit 22–0

Double 64-bit (double precision):

Dấu
1 bit
bit 63
Mũ (Exponent)
11 bit
bit 62–52
Định trị (Mantissa)
52 bit
bit 51–0
LoạiTổng bitDấuĐịnh trịChữ số thập phân chính xác
float (single)321823~7 chữ số
double6411152~15–16 chữ số

Mũ được lưu dạng biased exponent: cộng thêm một hằng số (127 với float, 1023 với double) để tránh cần bit dấu riêng cho mũ. Double có mũ 11 bit → biểu diễn được số cực lớn (lên đến khoảng 1.8 × 10³⁰⁸, tức DBL_MAX) và cực nhỏ (gần 0).

3. Vì sao 0.1 không biểu diễn chính xác

Đây là cơ chế quan trọng nhất của bài. Khi chuyển 0.1 sang nhị phân, bạn nhân phần thập phân liên tục với 2 và lấy phần nguyên:

0.1 × 2 = 0.2  → lay 0
0.2 × 2 = 0.4  → lay 0
0.4 × 2 = 0.8  → lay 0
0.8 × 2 = 1.6  → lay 1
0.6 × 2 = 1.2  → lay 1
0.2 × 2 = 0.4  → lay 0  (lap lai!)
...

Kết quả: 0.1 thập phân = 0.0001100110011... nhị phân — tuần hoàn vô hạn. IEEE 754 double chỉ có 52 bit cho định trị, phải cắt tại đó. Giá trị được làm tròn về số nhị phân gần nhất có thể biểu diễn được.

Số thực gần nhất với 0.1 mà double lưu được là:

0.1000000000000000055511151231257827021181583404541015625

Tương tự, số gần nhất với 0.2 là xấp xỉ 0.2 + sai số nhỏ, và khi cộng hai xấp xỉ đó lại, kết quả là xấp xỉ 0.3 nhưng không phải chính xác — mà là 0.30000000000000004.

# Minh hoa: 0.1 + 0.2 trong Python
x = 0.1
y = 0.2
print(x + y)        # 0.30000000000000004
print(x + y == 0.3) # False -- so sanh sai

# In chuoi binary float thuc su luu trong RAM
import struct, binascii
packed = struct.pack('d', 0.1)
print(binascii.hexlify(packed)) # 9a9999999999b93f (little-endian)
// Tuong tu trong Java
double a = 0.1;
double b = 0.2;
System.out.println(a + b);        // 0.30000000000000004
System.out.println(a + b == 0.3); // false
// JavaScript -- cung engine V8
console.log(0.1 + 0.2);        // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

4. Hệ quả: không so sánh float bằng ==

Vì mọi phép tính float đều có thể tích luỹ sai số, so sánh bằng == là bẫy phổ biến nhất với số thực.

// SAI -- co the false du nhin co ve bang nhau
if (a + b == 0.3) {
    System.out.println("bang");
}

// DUNG -- so sanh voi epsilon (tolerance)
double eps = 1e-9; // nguong sai so chap nhan duoc
if (Math.abs((a + b) - 0.3) < eps) {
    System.out.println("xap xi bang");
}
# Python -- tuong tu
import math
if math.isclose(a + b, 0.3, rel_tol=1e-9):
    print("xap xi bang")

Chọn epsilon phù hợp với bài toán: tính vật lý (~1e-9), tính tiền (~1e-10), tính đồ hoạ (~1e-6). Không có epsilon "đúng cho mọi trường hợp".

5. Giá trị đặc biệt: NaN, Infinity, âm không

IEEE 754 dành một số tổ hợp bit cho các giá trị đặc biệt:

NaN (Not a Number): kết quả của phép tính không xác định như 0.0 / 0.0, Math.sqrt(-1), Infinity - Infinity. Đặc điểm kỳ lạ nhất: NaN không bằng chính nó.

double nan = Double.NaN;
System.out.println(nan == nan);           // false (!)
System.out.println(Double.isNaN(nan));    // true -- cach kiem tra dung
import math
nan = float('nan')
print(nan == nan)       # False
print(math.isnan(nan))  # True

Infinity-Infinity: kết quả của 1.0 / 0.0 (trong Java/C — không phải exception với float!). Lan truyền qua phép tính: Infinity + 1 == Infinity.

System.out.println(1.0 / 0.0);   // Infinity
System.out.println(-1.0 / 0.0);  // -Infinity
System.out.println(1.0 / 0.0 > 1e308); // true

-0.0 (âm không): 0.0 == -0.0 trả về true trong hầu hết ngôn ngữ, nhưng 1.0 / 0.0 != 1.0 / -0.0 (khác dấu Infinity). Hiếm khi quan trọng trong thực tế nhưng cần biết.

flowchart LR
  A["Phep tinh float"] --> B{"Ket qua?"}
  B -->|"0/0 hoac sqrt(-1)"| C["NaN\n(khong bang chinh no)"]
  B -->|"x/0 (x != 0)"| D["Infinity / -Infinity"]
  B -->|"binh thuong"| E["float huu han\n(co sai so lam tron)"]
  B -->|"qua nho gan 0"| F["Subnormal\n(mat precision)"]

6. float vs double: khi nào đủ chính xác

float (32-bit) cho khoảng 7 chữ số thập phân chính xác; double (64-bit) cho khoảng 15–16.

Dùng float (32-bit) khiDùng double (64-bit) khi
Đồ hoạ 3D, shader, GPU (vec3 trong GLSL)Tính khoa học, tài chính (nhưng xem bên dưới)
Lưu mảng hàng triệu điểm (tiết kiệm RAM)Mặc định trong hầu hết ngôn ngữ
Sai số 0.001% chấp nhận đượcCần độ chính xác cao hơn float

Trong Java và C#, double là kiểu mặc định cho literal số thực (như 3.14). Float phải thêm suffix f (vd 3.14f). Hầu hết CPU hiện đại tính double nhanh ngang float, nên không có lý do dùng float trừ khi tiết kiệm bộ nhớ là ưu tiên.

📚 Đào sâu (tuỳ chọn) — Số subnormal và catastrophic cancellation

Số subnormal (denormal): IEEE 754 dành một dải đặc biệt cho số rất gần 0 mà không thể biểu diễn dạng chuẩn 1.xxx × 2^n. Subnormal mất dần các bit định trị (precision degrades gracefully) thay vì underflow thẳng về 0. Tuy nhiên, tính toán với subnormal chậm hơn đáng kể trên nhiều CPU (10–100× chậm hơn) vì phải xử lý phần mềm.

Catastrophic cancellation (triệt tiêu thảm hoạ): khi trừ hai số rất gần nhau, hầu hết bit có nghĩa triệt tiêu nhau, chỉ còn lại các bit nhiễu. Ví dụ: 1000000.1 - 1000000.0 = 0.1 nhưng cả hai số đều tích luỹ sai số → kết quả mất hoàn toàn chính xác.

# Vi du catastrophic cancellation
a = 1000000.1
b = 1000000.0
print(a - b)  # 0.09999847412109375 -- chi con ~5 chu so chinh xac

Absorption (hấp thu): 1e16 + 0.1 == 1e16 trong double — số nhỏ 0.1 "biến mất" vì không có bit nào trong định trị đủ nhỏ để biểu diễn nó khi mũ đã lớn như vậy.

7. Áp dụng vào code của bạn

Tuyệt đối không dùng float/double cho tiền

Đây là lỗi có thể gây thiệt hại tài chính thật. Sai số nhỏ trong mỗi giao dịch tích luỹ thành số lệch sổ sách.

// SAI -- float/double cho tien
double price = 19.99;
double qty   = 3;
double total = price * qty;
System.out.println(total); // 59.97 -- co ve dung, nhung...

double sum = 0.0;
for (int i = 0; i < 10; i++) sum += 0.1;
System.out.println(sum);         // 0.9999999999999999 -- khong phai 1.0
System.out.println(sum == 1.0);  // false
// DUNG -- dung BigDecimal cho tien
import java.math.BigDecimal;
import java.math.RoundingMode;

BigDecimal price = new BigDecimal("19.99"); // PHAI dung String, khong dung 19.99 literal!
BigDecimal qty   = new BigDecimal("3");
BigDecimal total = price.multiply(qty);
System.out.println(total); // 59.97 -- chinh xac

BigDecimal sum = BigDecimal.ZERO;
for (int i = 0; i < 10; i++) {
    sum = sum.add(new BigDecimal("0.1"));
}
System.out.println(sum); // 1.0 -- chinh xac

Lưu ý: new BigDecimal(19.99) — truyền double vào constructor — vẫn sai (double đã mất chính xác trước khi vào BigDecimal). Luôn dùng new BigDecimal("19.99") với chuỗi.

Cách khác phổ biến hơn cho hệ thống tiền: lưu bằng đơn vị nhỏ nhất (xu/cents) dưới dạng số nguyên (long). Tính toán hoàn toàn trên số nguyên, chỉ chuyển về đơn vị lớn khi hiển thị.

// Luu so du bang xu (cents), kieu long
long balanceCents = 199900L; // 1999.00 VND
long priceCents   = 1999L;   // 19.99 VND
long qty          = 3;
long totalCents   = priceCents * qty; // 5997 cents = 59.97 VND -- chinh xac tuyet doi

So sánh float đúng cách

// SAI
if (a == b) { ... }

// DUNG -- epsilon tuong doi cho so lon
double eps = Math.abs(a) * 1e-9 + 1e-15; // relative + absolute floor
if (Math.abs(a - b) < eps) { ... }

// Hoac trong Java -- cho so thuong
if (Double.compare(a, b) == 0) { ... } // xu ly NaN va -0.0 dung
# Python
import math
math.isclose(a, b, rel_tol=1e-9)   # True neu |a-b| / max(|a|,|b|) < 1e-9
math.isclose(a, b, abs_tol=1e-12)  # them absolute tolerance
// C# decimal -- tuong tu BigDecimal
decimal price = 19.99m; // suffix m cho decimal literal
decimal qty   = 3m;
decimal total = price * qty; // 59.97 -- chinh xac

8. Liên hệ các bài khác

9. Tóm tắt

  • IEEE 754 lưu số thực bằng 3 phần: dấu (1 bit) + mũ + định trị. Float 32-bit (1+8+23), double 64-bit (1+11+52).
  • 0.1 trong nhị phân là phân số tuần hoàn vô hạn — phải cắt → sai số nhỏ không thể tránh. Đây là giới hạn toán học, không phải lỗi phần mềm.
  • Float chính xác ~7 chữ số thập phân, double ~15–16. Sai số tích luỹ qua nhiều phép tính.
  • Không so sánh float bằng == — dùng epsilon (tolerance). Python: math.isclose, Java: Math.abs(a-b) < eps.
  • Giá trị đặc biệt: NaN (không bằng chính nó — kiểm tra bằng isNaN()), Infinity/-Infinity, -0.0.
  • Tuyệt đối không dùng float/double cho tiền: dùng BigDecimal (Java/C#) với constructor String, decimal (C#/Python), hoặc số nguyên đơn vị nhỏ nhất (cents).
  • Double là mặc định an toàn cho tính toán khoa học; float chỉ khi tiết kiệm bộ nhớ là ưu tiên (đồ hoạ, mảng điểm lớn).

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao 0.1 + 0.2 không bằng 0.3 trong hầu hết ngôn ngữ lập trình?
Vì 0.1 trong nhị phân là phân số tuần hoàn vô hạn (0.0001100110011...) — không thể biểu diễn chính xác trong hữu hạn bit. IEEE 754 double cắt tại bit thứ 52 và làm tròn → lưu giá trị xấp xỉ 0.1, không phải chính xác. Khi cộng hai xấp xỉ lại, sai số tích luỹ cho kết quả lệch khỏi 0.3 một lượng nhỏ. Đây không phải lỗi ngôn ngữ — mà là giới hạn toán học của biểu diễn nhị phân hữu hạn bit.
Q2
IEEE 754 double (64-bit) chia 64 bit đó thành những phần nào? Mỗi phần bao nhiêu bit?
Ba phần: dấu 1 bit (0 = dương, 1 = âm), mũ 11 bit (biased exponent — cộng thêm 1023 để tránh cần dấu riêng cho mũ), và định trị 52 bit (phần thập phân của 1.xxx trong ký hiệu khoa học nhị phân). Giá trị số = (-1)^dấu × 1.định_trị × 2^(mũ-1023). Định trị càng nhiều bit thì độ chính xác càng cao — double (52 bit) chính xác ~15–16 chữ số thập phân, gấp đôi float (23 bit, ~7 chữ số).
Q3
Đoạn Java sau in gì? Giải thích tại sao.
System.out.println(Double.NaN == Double.NaN);
In ra false. Đây là đặc điểm của NaN theo chuẩn IEEE 754: NaN không bằng bất kỳ giá trị nào, kể cả chính nó. Lý do: NaN biểu diễn "kết quả không xác định" (0/0, sqrt(-1)...) và có nhiều bit pattern khác nhau đều là NaN — không có định nghĩa "NaN này bằng NaN kia". Hệ quả thực tế: không bao giờ kiểm tra NaN bằng ==; phải dùng Double.isNaN(x) (Java) hoặc math.isnan(x) (Python).
Q4
Tại sao new BigDecimal(19.99) vẫn sai, dù đã dùng BigDecimal?
Vì literal 19.99 trong Java là kiểu double. Trước khi vào constructor BigDecimal, giá trị đã bị làm tròn sang số nhị phân gần nhất (khoảng 19.9899999999999999289457264239899814128875732421875). BigDecimal nhận và lưu chính xác cái giá trị sai đó. Đúng cách phải dùng new BigDecimal("19.99") — truyền String để BigDecimal tự parse từ chuỗi thập phân, tránh bước chuyển đổi double sai lệch ở giữa.
Q5
Hệ thống thanh toán lưu số dư tài khoản bằng double. Vấn đề gì có thể xảy ra với 10 triệu giao dịch/ngày?
Mỗi phép tính (cộng tiền vào, trừ tiền ra) có sai số nhỏ cỡ 1e-15 đến 1e-12. Với 10 triệu giao dịch/ngày, sai số tích luỹ đến mức sổ sách không khớp, báo cáo tài chính sai, và kiểm toán phát hiện. Trong thực tế, các vụ "salami slicing" gian lận tài chính từng khai thác sai số làm tròn để ăn cắp phần lẻ. Giải pháp đúng: lưu bằng BigDecimal với String constructor, hoặc lưu bằng số nguyên đơn vị nhỏ nhất (xu/cents) dưới dạng long.
Q6
Khi nào nên dùng float (32-bit) thay vì double (64-bit)?
Chỉ khi tiết kiệm bộ nhớ là ưu tiên rõ ràng và độ chính xác ~7 chữ số là đủ. Trường hợp phổ biến nhất: đồ hoạ 3D và GPU shader (GLSL dùng vec3 float vì GPU tối ưu cho 32-bit), mảng hàng triệu điểm đo lường (giảm một nửa RAM so với double). CPU máy tính để bàn hiện đại tính double nhanh ngang float, nên không có lợi về tốc độ. Tài chính, khoa học, mặc định hàng ngày: dùng double — hoặc tốt hơn nữa, BigDecimal cho tiền.
Q7
Vì sao 1e16 + 0.1 == 1e16 trong double?
Vì double chỉ có 52 bit định trị. Số 1e16 đã dùng hết các bit có nghĩa để biểu diễn phần nguyên lớn — không còn bit nào đủ nhỏ (precision đủ mịn) để biểu diễn thêm 0.1. Khoảng cách giữa hai số double liền kề quanh 1e16 lớn hơn 0.1, nên 0.1 "biến mất" — hiện tượng gọi là absorption. Đây là lý do tích luỹ tiền bằng float nguy hiểm: số dư lớn + giao dịch nhỏ → giao dịch nhỏ không được ghi nhận.

Bài tiếp theo: Văn bản: Unicode và UTF-8

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên