Backend

99 Ngày Java — Ngày 27: Math, Random & BigDecimal

SSite Admin
23 tháng 08, 2026 8 phút đọc 0 lượt xem
99 Ngày Java — Ngày 27: Math, Random & BigDecimal

Ngày 26 làm chủ thời gian — hôm nay ta làm chủ những con số, và bắt đầu bằng cú sốc kinh điển: 0.1 + 0.2 không bằng 0.3. Đó không phải bug mà là bản chất của số thực nhị phân — và hiểu nó quyết định bạn có dám để double giữ tiền hay không (gợi ý: không bao giờ). Bài hôm nay đi qua bẫy số thực, bộ công cụ Math với những góc ít ai để ý, ranh giới sống còn giữa RandomSecureRandom, và BigDecimal — kiểu dữ liệu xứng đáng đứng tên trên mọi hóa đơn.

Sketchnote Ngày 27: Math, Random và BigDecimal — bẫy 0.1 + 0.2, floorMod và addExact, Random vs SecureRandom, và BigDecimal với RoundingMode cho tiền tệ

Bẫy số thực — vì sao 0.1 + 0.2 không bằng 0.3

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

// Không phải bug của Java — là bản chất của số thực nhị phân (IEEE 754):
// 0.1 trong hệ NHỊ PHÂN là số vô hạn tuần hoàn 0.0001100110011...
// double chỉ giữ được 64 bit → phải CẮT — sai số sinh ra từ đó,
// rồi mỗi phép cộng nhân lại khuếch đại thêm.

// Hệ quả thực tế:
double tien = 0;
for (int i = 0; i < 10; i++) tien += 0.1;
tien == 1.0;                           // false! — 0.9999999999999999

// So sánh double đúng cách — sai số cho phép (epsilon):
Math.abs(a - b) < 1e-9;                // thay cho a == b

// Quy tắc sắt: double cho ĐO LƯỜNG (khoảng cách, tỉ lệ, tọa độ) —
// TUYỆT ĐỐI không cho TIỀN. Tiền dùng gì? Cuối bài.
  • Gốc rễ nằm ở hệ đếm: 0.1 gọn gàng trong hệ thập phân nhưng là số vô hạn tuần hoàn trong hệ nhị phân — hệt như 1/3 thành 0.333… trong hệ mười; double (Ngày 3) chỉ có 64 bit nên buộc phải cắt, và phần bị cắt chính là sai số.

  • Sai số tích lũy: cộng 0.1 mười lần không ra 1.0 — nên so sánh bằng == trên double gần như luôn sai ý định; dùng epsilon (Math.abs(a-b) < 1e-9) khi buộc phải so double.

  • Phân vai rõ ràng: double cho đo lường — tọa độ, tỉ lệ, khoảng cách, nơi sai số một phần tỷ vô hại; còn tiền, thuế, tỉ giá — nơi từng đồng lệch là sự cố kế toán — thuộc về BigDecimal ở cuối bài.

Math — quen mặt nhưng có góc khuất

// Math — bộ hàm static quen mặt (import sẵn, không cần new):
Math.max(a, b);  Math.min(a, b);  Math.abs(-5);      // 5
Math.pow(2, 10);                                     // 1024.0 (double!)
Math.sqrt(144);                                      // 12.0
Math.round(2.5);   // 3 — làm tròn NỬA LÊN theo số học thông thường
Math.floor(-1.5);  // -2.0 — sàn luôn đi XUỐNG
Math.ceil(-1.5);   // -1.0 — trần luôn đi LÊN

// Bẫy số âm của phép chia lấy dư:
-7 % 3;                    // -1 — dấu theo SỐ BỊ CHIA
Math.floorMod(-7, 3);      // 2  — luôn không âm khi chia dương
// floorMod là lựa chọn đúng cho: index vòng tròn, ngày trong tuần, phân bucket

// Tràn số im lặng (Ngày 3) — Math cho công cụ FAIL-FAST:
int big = Integer.MAX_VALUE;
big + 1;                   // -2147483648 — tràn IM LẶNG, không báo gì
Math.addExact(big, 1);     // ArithmeticException — nổ ngay, lỗi lộ sớm
// addExact / subtractExact / multiplyExact — đáng dùng ở mọi phép tính tiền, số lượng
  • Bẫy % với số âm bắt nhiều người: -7 % 3 ra -1 chứ không phải 2 — index vòng tròn kiểu (i - 1) % n vì thế nổ ArrayIndexOutOfBoundsException khi i về 0; Math.floorMod là câu trả lời đúng.

  • Tràn số của int (Ngày 3) im lặng tuyệt đối — họ addExact/multiplyExact đổi im lặng lấy fail-fast: triết lý quen thuộc của series, lỗi nổ sớm luôn rẻ hơn dữ liệu sai lan xa.

  • Chi tiết đáng nhớ: Math.round(2.5) ra 3 nhưng Math.round(-2.5) ra -2 (làm tròn nửa lên theo trục số) — khác với RoundingMode.HALF_UP của BigDecimal vốn đối xứng qua số 0; đừng để hai định nghĩa "làm tròn" lẫn nhau.

Random, ThreadLocalRandom & SecureRandom

// Random — giả ngẫu nhiên có SEED: cùng seed, cùng dãy số
Random r = new Random(42);
r.nextInt(100);            // luôn cùng kết quả với seed 42 — quà cho TEST

// Cách hiện đại (Java 17+): giao diện RandomGenerator
RandomGenerator gen = RandomGenerator.getDefault();
gen.nextInt(1, 7);         // xúc xắc 1-6 — chặn dưới GỘP, chặn trên LOẠI

// Đa luồng: ThreadLocalRandom — mỗi thread một nguồn, không tranh nhau
ThreadLocalRandom.current().nextInt(0, 100);

// Nhưng BẢO MẬT thì khác hẳn:
// Random đoán được — biết vài số đầu ra là suy ngược được seed!
// OTP, token đặt lại mật khẩu, mã mời → SecureRandom:
SecureRandom sr = new SecureRandom();      // nguồn entropy của hệ điều hành
byte[] token = new byte[32];
sr.nextBytes(token);
// chậm hơn Random — cái giá xứng đáng cho thứ kẻ tấn công muốn đoán
  • Randomgiả ngẫu nhiên: thuật toán tất định chạy từ seed — nghe như khuyết điểm nhưng là tính năng cho test: cố định seed là tái hiện được đúng kịch bản "ngẫu nhiên" gây lỗi.

  • Quy ước chặn biên đáng khắc cốt: nextInt(1, 7) cho 1–6, gộp dưới loại trên — nhất quán với substring (Ngày 9) và mọi API phạm vi của Java; xúc xắc viết nextInt(1, 7) chứ không phải (1, 6).

  • Ranh giới bảo mật là tuyệt đối: đầu ra Random suy ngược được seed — token đặt lại mật khẩu sinh bằng nó là lỗ hổng thật từng có tên CVE; mọi giá trị mà kẻ tấn công muốn đoán phải sinh từ SecureRandom, chậm hơn nhưng không thể dự đoán.

Bàn tay bấm máy tính cầm tay bên giấy tờ sổ sách — số học tiền bạc là lãnh địa của BigDecimal: từng đồng phải khớp sổ, không có chỗ cho sai số nhị phân của double

BigDecimal — kiểu dữ liệu của tiền

// BigDecimal — số THẬP PHÂN chính xác tùy ý: kiểu xứng đáng giữ tiền
BigDecimal gia = new BigDecimal("19.99");     // từ CHUỖI — chính xác tuyệt đối
new BigDecimal(0.1);   // ✗ 0.1000000000000000055511... — double đã hỏng sẵn!
BigDecimal.valueOf(0.1);   // ✓ đi qua chuỗi "0.1" — lối tắt an toàn

// Cộng trừ nhân: immutable — trả về bản mới (quen chưa?)
BigDecimal tong = gia.multiply(BigDecimal.valueOf(3));   // 59.97

// CHIA phải khai báo làm tròn — không thì 1/3 nổ ArithmeticException:
BigDecimal motPhanBa = BigDecimal.ONE
    .divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP);   // 0.33

// Tiền tệ: HALF_EVEN — "làm tròn chủ ngân hàng", không thiên vị hướng nào
gia.setScale(0, RoundingMode.HALF_EVEN);

// Bẫy cuối: equals so cả SCALE — compareTo mới so giá trị
new BigDecimal("2.0").equals(new BigDecimal("2.00"));      // false!
new BigDecimal("2.0").compareTo(new BigDecimal("2.00"));   // 0 — bằng nhau
// → so sánh BigDecimal: LUÔN compareTo (và cẩn thận khi làm key HashMap — Ngày 24)
  • Khởi tạo quyết định tất cả: new BigDecimal(0.1) kế thừa nguyên sai số của double (đã hỏng từ trước khi BigDecimal kịp nhìn thấy), còn new BigDecimal("0.1")valueOf đi từ dạng thập phân — luôn khởi tạo từ chuỗi.

  • Phép chia bắt khai báo scale + RoundingMode là thiết kế có chủ đích: 1/3 không biểu diễn hữu hạn được, và làm tròn tiền là quyết định nghiệp vụHALF_EVEN (làm tròn chủ ngân hàng) không thiên vị hướng nào nên là chuẩn của xử lý tài chính.

  • Cặp equals/compareTo lệch nhau — 2.0 không equals 2.00 vì khác scale — là ngoại lệ hiếm hoi của contract Ngày 24: so sánh giá trị luôn dùng compareTo, và cân nhắc kỹ trước khi cho BigDecimal làm key của HashMap.

Bài tập nhỏ

  • In 0.1 + 0.2, rồi cộng 0.1 một nghìn lần và so với 100.0 — đo sai số tích lũy bằng Math.abs.

  • Viết int chiSoVong(int i, int n) trả về index vòng tròn đúng với i âm — so %floorMod trên i = -1.

  • Sinh chuỗi token 32 byte bằng SecureRandom + HexFormat — rồi giải thích vì sao new Random(42) không được phép làm việc này.

  • Tính hóa đơn: 3 món giá "19.99", giảm 10%, VAT 8% — làm tròn 2 chữ số HALF_EVEN từng bước bằng BigDecimal; làm lại bằng double và so kết quả.

  • Kiểm chứng bẫy scale: đặt new BigDecimal("2.0")"2.00" vào HashSet — set có mấy phần tử? Vì sao?

Kết luận

Bốn ý mang về: sai số của doublebản chất nhị phân, không phải bug — đo lường thì được, tiền thì không; Math có hai công cụ bị bỏ quên là floorMod cho số âm và họ addExact chống tràn im lặng; Random tất định theo seed — quà cho test, thảm họa cho bảo mật, nơi SecureRandom là bắt buộc; và BigDecimal giữ tiền với ba kỷ luật — khởi tạo từ chuỗi, chia phải khai báo làm tròn, so sánh bằng compareTo. Ngày 28 ta học ngôn ngữ tìm kiếm mẫu mạnh nhất của lập trình: RegexPattern, Matcher và những biểu thức đọc như mật mã nhưng tiết kiệm hàng trăm dòng code. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 27: Lazy vs Eager & bài toán N+1

Ngày 27 của 99 Ngày Spring: FetchType và cơ chế proxy của LAZY (cùng LazyInitializationException và sự thật về open-in-view), tự tay tái hiện bài toán N+1 với 21 câu SQL cho 20 bài viết, sửa bằng JOIN FETCH với cặp LEFT + DISTINCT, @EntityGraph cho derived query, và bẫy phân trang trong bộ nhớ HHH000104.

23 thg 8, 20268 phút0
99 Ngày Java — Ngày 26: Date/Time API (java.time)

Ngày 26 của 99 Ngày Java: java.time thay thế trọn bộ Date/Calendar với thiết kế immutable — bộ ba LocalDate/LocalTime/LocalDateTime, cặp khái niệm giờ máy Instant vs giờ người ZonedDateTime cùng quy tắc vàng lưu UTC hiển thị theo múi giờ, Duration vs Period và DateTimeFormatter thread-safe.

22 thg 8, 20267 phút33
99 Ngày Spring — Ngày 26: Quan hệ N-N

Ngày 26 của 99 Ngày Spring: quan hệ N-N luôn sống trong bảng trung gian — @ManyToMany với @JoinTable cho quan hệ thuần túy, owning side và Set với luật cascade cấm REMOVE trên tài nguyên chia sẻ, và entity trung gian tách N-N thành hai lần 1-N khi quan hệ cần cột riêng, ....

22 thg 8, 20267 phút2