Java数字类型原理
# 17.Java数字类型原理
# 目录介绍
- 1. 案例引入
- 2. 数字类型全景
- 3. Integer 缓存池
- 4. 自动装箱拆箱
- 5. IEEE 754 浮点本质
- 6. BigDecimal 精度
- 7. 数值溢出与边界
- 8. 性能与选型
- 9. 现代特性演进
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一分钱的事故
某支付公司清算系统,凌晨对账时发现总账少了 873 元。所有交易明细都在,金额加总却对不上。开发团队定位到一段"看起来很正常"的折扣计算代码:
public class OrderCalculator {
// 计算订单实付金额:原价 × (1 - 折扣率)
public double calculatePayAmount(double originalPrice, double discountRate) {
return originalPrice * (1 - discountRate);
}
// 累加订单总额
public double sum(List<Order> orders) {
double total = 0.0;
for (Order order : orders) {
total += order.getPayAmount();
}
return total;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
跑一个最小复现:
double price = 100.0;
double rate = 0.1;
double pay = price * (1 - rate);
System.out.println(pay); // 输出:89.99999999999999
// 期望:90.0
2
3
4
5
100 × 0.9 = 89.99999999999999 ——计算机算错了?不,是 IEEE 754 给我们上了一课。
事故复盘时,技术负责人在白板上画了三个问号:
追问 ①:double 为什么算不准 100 × 0.9?计算机不是绝对精确的吗?
追问 ②:把 double 换成 BigDecimal 就完美了?为什么有人写 new BigDecimal(0.1) 还是不准?
追问 ③:如果用 long 存"分"代替 double 存"元",能不能彻底避坑?代价是什么?
2
3
# 1.2 == 与 equals 的背叛
同一周,订单系统又爆出第二个事故——VIP 用户列表去重失败:
Map<Integer, User> vipMap = new HashMap<>();
Integer userId1 = 200;
Integer userId2 = 200;
vipMap.put(userId1, new User("Alice"));
vipMap.put(userId2, new User("Alice"));
System.out.println(vipMap.size()); // 输出:1(Map 去重,正常)
// 但另一个判等场景出问题:
Integer a = 100, b = 100;
Integer c = 200, d = 200;
System.out.println(a == b); // true
System.out.println(c == d); // ★ false !!!
2
3
4
5
6
7
8
9
10
11
12
100 相等,200 不相等——Java 给开发者埋的最阴险陷阱之一。
事故复盘加了三个追问:
追问 ④:为什么 Integer 100 == 100 是 true,200 == 200 反而是 false?
追问 ⑤:自动装箱拆箱的字节码到底做了什么?为什么会引发 NPE?
追问 ⑥:Integer 缓存池为什么是 -128~127?这个边界是怎么定的?
追问 ⑦:除了 Integer,Long/Short/Character/Boolean 都有缓存池吗?
2
3
4
# 1.3 我们要回答什么
第 23 篇要把"Java 数字类型"这件事讲透——从 IEEE 754 二进制本质到 BigDecimal 精度控制、从 Integer 缓存到自动装箱字节码。
带着 7 个核心问题展开:
① IEEE 754 为什么算不准 0.1?二进制的根本局限在哪里? → 第5章
② BigDecimal(double) vs BigDecimal(String) 差在哪? → 第6章
③ Integer -128~127 缓存的边界由什么决定? → 第3章
④ 自动装箱字节码做了什么?为什么会引发 NPE? → 第4章
⑤ 整型溢出为什么是"沉默的杀手"?怎么防御? → 第7章
⑥ 金额场景应该选 double / BigDecimal / long(分) 中的谁? → 第8章
⑦ 浮点比较应该怎么写?为什么不能用 ==? → 第5章
2
3
4
5
6
7
本篇路线:
数字类型全景 (第2章) ─── 8基本+8包装+2高精度
↓
Integer 缓存 (第3章) ←—— -128~127 的边界推导
自动装箱拆箱 (第4章) ←—— invokestatic Integer.valueOf
↓
IEEE 754 浮点本质 (第5章) ←—— 0.1 为什么是无限循环二进制
BigDecimal 精度 (第6章) ←—— unscaledValue + scale 的设计
数值溢出与边界 (第7章) ←—— Math.addExact 与 BigInteger
↓
性能与选型 (第8章) ←—— 三套金额方案实测对比
现代特性演进 (第9章) ←—— Valhalla / 数字字面量 / StrictMath
↓
综合案例串讲 (第10章)
2
3
4
5
6
7
8
9
10
11
12
13
# 2. 数字类型全景
# 2.1 八种基本类型
Java 有 8 种基本类型,其中 6 种是数字(boolean 与 char 不算严格数字):
| 类型 | 字节 | 位数 | 范围 | 默认值 |
|---|---|---|---|---|
byte | 1 | 8 | -128 ~ 127 | 0 |
short | 2 | 16 | -32768 ~ 32767 | 0 |
int | 4 | 32 | -2³¹ ~ 2³¹-1(约 ±21 亿) | 0 |
long | 8 | 64 | -2⁶³ ~ 2⁶³-1(约 ±922 亿亿) | 0L |
float | 4 | 32 | IEEE 754 单精度 | 0.0f |
double | 8 | 64 | IEEE 754 双精度 | 0.0d |
char | 2 | 16 | 0 ~ 65535(无符号) | '\u0000' |
boolean | — | — | true / false | false |
关键观察:
- 整型(byte/short/int/long)使用二进制补码——加法运算与无符号一致,硬件友好
- 浮点(float/double)使用 IEEE 754 标准——这是 §5 章一切问题的源头
- char 是 16 位无符号整数(UTF-16 编码单元)——本质上也能参与算术
疑惑:为什么 boolean 不告知字节数?
论证:JVM 规范说 boolean 在内存中与 int 等价(4 字节),但在 boolean 数组里被压缩成 1 字节——具体由 JVM 实现决定。这是"逻辑类型"和"存储类型"分离的典型设计。
# 2.2 包装类型族谱
每个基本类型对应一个包装类——为了适配"一切皆对象"的世界(集合泛型、null 语义、反射):
Number (abstract)
│
┌────────┬──────┼──────┬────────┐
│ │ │ │ │
Integer Long Short Byte Float Double ← 都继承 Number
Boolean Character ← 不继承 Number
2
3
4
5
6
7
Number 抽象类的契约——强制子类提供 6 种类型转换方法:
public abstract class Number implements Serializable {
public abstract int intValue();
public abstract long longValue();
public abstract float floatValue();
public abstract double doubleValue();
public byte byteValue() { return (byte) intValue(); } // 默认实现
public short shortValue() { return (short) intValue(); } // 默认实现
}
2
3
4
5
6
7
8
结论:Number 是"数字之间互转"的契约——类似 Comparable 是"可比较"的契约。BigDecimal 与 BigInteger 也继承 Number,所以 ((Number) bigDecimal).doubleValue() 是合法的。
# 2.3 高精度类型
java.math 包下两个"无界精度"类型:
| 类型 | 用途 | 内部存储 | 性能 |
|---|---|---|---|
BigInteger | 任意精度整数 | int[] 表示位数据 | 比 long 慢 50~200 倍 |
BigDecimal | 任意精度小数 | BigInteger + 标度 (scale) | 比 double 慢 100~500 倍 |
它们是不可变对象——任何运算返回新对象,永不修改原值(与 String 同源设计哲学,§5 篇有详述)。
结论:基本类型是"硬件原生类型"——快但有边界;包装类型是"对象化包装"——慢但能进集合;BigInteger/BigDecimal 是"数学纯净类型"——无界但极慢。三层之间的取舍贯穿本篇。
# 3. Integer 缓存池
# 3.1 -128 到 127 的秘密
回到 §1.2 案例——Integer 100 == 100 是 true,Integer 200 == 200 反而是 false。
真相:JDK 在 Integer 类初始化时,预创建了 -128 ~ 127 之间所有 256 个 Integer 对象,缓存在 IntegerCache.cache[] 数组里。任何在此范围内的装箱操作都返回同一个缓存对象——所以 == 比较地址也成立。
疑惑:为什么是 -128 ~ 127?这个边界怎么定的?
论证——三个层面的考量:
- byte 的天然边界:-128 ~ 127 正好是 1 字节有符号整数的全部取值——这是计算机最常见的小整数范围
- 统计学考量:实际程序中 90% 以上的整数运算都在小整数范围(循环计数器、数组索引、状态码)
- 内存预算:256 个 Integer 对象 ≈ 4KB——常驻内存可接受
结论:-128 是"必须缓存",127 是"性价比上界"——超过 127 的整数出现频率骤降,缓存收益不再划算。这是一个工程权衡,不是数学必然。
# 3.2 valueOf 源码剖析
打开 Integer.valueOf 源码:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high) // -128 <= i <= 127
return IntegerCache.cache[i + (-IntegerCache.low)]; // 命中缓存
return new Integer(i); // 未命中,新建对象
}
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high 默认 127,可通过 -XX:AutoBoxCacheMax=N 调高
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) - 1);
} catch (NumberFormatException nfe) { }
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for (int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
关键观察:
<与<=的边界:判断条件是>= -128 && <= 127——边界值 -128 和 127 都命中缓存- 下界固定为 -128:JLS 规范强制要求,不可调
- 上界可调:通过
-XX:AutoBoxCacheMax=2000可以扩大缓存——某些缓存密集场景(如 ID 范围)有用
# 3.3 缓存边界可调
实战调优:某交易系统订单 ID 集中在 1~10000 范围——把 AutoBoxCacheMax 调到 10000:
java -XX:AutoBoxCacheMax=10000 -jar app.jar
收益:
- 减少 9873 次
new Integer()对象分配 - GC 压力下降——年轻代回收频率减少
代价:
- 永久占用 10001 × 16B ≈ 160 KB 老年代
- 不要无脑扩大——如果业务整数分布在百万级,缓存反而成累赘
结论:缓存大小是一个性能 vs 内存的权衡——默认 127 是 JDK 给出的"最安全配置",扩大需要业务证据。
# 3.4 其他包装类的缓存
不仅 Integer 有缓存——5 个包装类都有,但范围各异:
| 包装类 | 缓存范围 | 可调? | 原因 |
|---|---|---|---|
Byte | -128 ~ 127(全部) | 否 | byte 总共就 256 个值 |
Short | -128 ~ 127 | 否 | 与 Integer 对齐 |
Integer | -128 ~ 127 | 是(AutoBoxCacheMax) | 最常用 |
Long | -128 ~ 127 | 否 | 大数情况太多,不可调 |
Character | 0 ~ 127(ASCII) | 否 | 覆盖 ASCII 字符 |
Boolean | TRUE / FALSE 两个常量 | — | 总共就两个值 |
Float | 不缓存 | — | 浮点数不可枚举 |
Double | 不缓存 | — | 同上 |
关键陷阱:
Long a = 100L, b = 100L;
System.out.println(a == b); // true(命中缓存)
Long c = 200L, d = 200L;
System.out.println(c == d); // false(超出缓存)
Long e = 200L, f = 200L;
System.out.println(e.equals(f)); // ★ true(应该用 equals)
2
3
4
5
6
7
8
结论:包装类型的判等永远用 equals,永远不要用 ==——这是本章最重要的告诫。
# 4. 自动装箱拆箱
# 4.1 字节码视角揭秘
自动装箱(Autoboxing)和拆箱(Unboxing)是编译器糖——JVM 不认识它,只有 javac 在编译期插入转换调用。
装箱字节码:
Integer a = 100;
javap -c 输出:
0: bipush 100 ← 把 int 100 压栈
2: invokestatic Integer.valueOf(int) → Integer ← ★ 调用 valueOf 装箱
5: astore_1 ← 存入局部变量
2
3
4
拆箱字节码:
int b = a; // a 是 Integer
0: aload_1 ← 加载 Integer 引用
1: invokevirtual Integer.intValue() → int ← ★ 调用 intValue 拆箱
4: istore_2
2
3
结论:装箱 = Integer.valueOf(int),拆箱 = Integer.intValue()——简单到一行代码。但简单背后藏着大量陷阱(接下来 3 节)。
# 4.2 拆箱的 NPE 陷阱
最常见的踩坑代码:
Map<String, Integer> userScores = new HashMap<>();
// 用户没有评分,userScores.get("Alice") 返回 null
int score = userScores.get("Alice"); // ★ NullPointerException !
2
3
字节码视角:
invokevirtual HashMap.get(Object) → Object ← 返回 null
checkcast Integer ← 类型转换 OK
invokevirtual Integer.intValue() ← ★ 在 null 上调用方法 → NPE
2
3
疑惑:为什么编译器不能识别这个 NPE?
论证:编译器无法静态推断"Map 是否包含 Alice"——必须运行时才知道返回值。自动拆箱把"可能为 null"的引用强制转成"必须有值"的基本类型,类型系统在这里有断层。
修复方案:
// 方案 A:默认值
int score = userScores.getOrDefault("Alice", 0);
// 方案 B:Optional
int score = Optional.ofNullable(userScores.get("Alice")).orElse(0);
// 方案 C:显式检查
Integer s = userScores.get("Alice");
int score = (s == null) ? 0 : s;
2
3
4
5
6
7
8
9
结论:任何"包装类型 → 基本类型"赋值都是一个潜在 NPE 点——CodeReview 时要重点检查。
# 4.3 三元表达式诡异
阿里 P3C 规范明确禁止的写法:
Integer a = 1;
boolean flag = false;
Integer result = flag ? a : 0;
// 期望:result = 0
// 实际:报 NullPointerException 如果 a 是 null
// 另一个诡异
Integer x = null;
Integer y = true ? x : 0; // ★ NPE!
2
3
4
5
6
7
8
9
真相:三元表达式 cond ? a : b 当 a 和 b 类型不同(一个 Integer 一个 int)时,编译器会把两边统一成 int——对 Integer 调用 intValue(),null 上调用方法 → NPE。
字节码证据:
ifeq 14 ← 条件判断
aload x
invokevirtual Integer.intValue() ← ★ 强制拆箱,null 时崩
goto 17
14: iconst_0
17: invokestatic Integer.valueOf(int) ← 重新装箱赋给 result
2
3
4
5
6
结论:三元表达式两端的类型不匹配,会触发隐式拆箱——是 NPE 的隐藏制造工厂。
# 4.4 Stream 求和踩坑
Stream API 上的装箱性能炸弹:
// 反例:列表求和
List<Integer> nums = Arrays.asList(1, 2, 3, /* ... 1 万个 */);
// ❌ 慢
int sum1 = nums.stream().mapToInt(Integer::intValue).sum();
// 还行——已经显式转 IntStream
// ❌❌ 更慢
int sum2 = nums.stream().reduce(0, Integer::sum);
// 全程在 Stream<Integer> 上跑,每次 sum 都要装箱拆箱
// ❌❌❌ 最慢
int sum3 = nums.stream().collect(Collectors.summingInt(Integer::intValue));
2
3
4
5
6
7
8
9
10
11
12
13
JMH 实测对比(1 万元素):
| 方案 | 耗时 | 装箱次数 |
|---|---|---|
| 普通 for 循环 + int | 8 μs | 0 |
mapToInt(...).sum() | 12 μs | 1 万次拆箱 |
stream().reduce(0, Integer::sum) | 45 μs | 2 万次装箱+拆箱 |
IntStream.range(0, n).sum() | 9 μs | 0 |
结论:Stream 处理基本类型集合时,永远先调 mapToInt / mapToLong / mapToDouble 转成原始 Stream——避免 Stream
# 5. IEEE 754 浮点本质
# 5.1 二进制无法表示 0.1
回到 §1.1 的核心谜题——100.0 × 0.9 = 89.99999999999999。
疑惑:为什么计算机算不准 0.1?
论证——0.1 的二进制展开:
十进制 0.1 = 1/10
转二进制:0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0 ← ★ 进入循环
0.4 × 2 = 0.8 → 0
...
二进制 0.1 = 0.000110011001100110011...(无限循环)
2
3
4
5
6
7
8
9
10
11
12
根本原因:十进制中"有限小数"的概念,到二进制里可能变成"无限循环小数"——只有分母是 2 的幂的有理数(如 0.5、0.25、0.125)才能在二进制里精确表示。
double 用 64 位存储,尾数部分只有 52 位——超过 52 位的尾数被截断——0.1 在 double 里实际是:
0.1 ≈ 0.1000000000000000055511151231257827021181583404541015625
结论:double 算不准 0.1,不是 BUG,是数学必然。任何要求"十进制精确"的场景都不能用 double——这是 §6 BigDecimal 存在的理由。
# 5.2 float 与 double 内存布局
IEEE 754 把浮点数拆成 3 段:
double (64 位):
┌─┬───────────┬────────────────────────────────────────────────┐
│S│ Exponent │ Mantissa │
│1│ 11 位 │ 52 位 │
└─┴───────────┴────────────────────────────────────────────────┘
符号位 指数(偏移127) 尾数(隐含 1.xxx)
值 = (-1)^S × 1.Mantissa × 2^(Exponent - 1023)
float (32 位):
┌─┬─────────┬───────────────────────────┐
│S│Exponent │ Mantissa │
│1│ 8 位 │ 23 位 │
└─┴─────────┴───────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
精度推导:
- float 23 位尾数 + 1 位隐含 = 24 位——log10(2^24) ≈ 7.2 位十进制
- double 52 位尾数 + 1 位隐含 = 53 位——log10(2^53) ≈ 15.9 位十进制
关键陷阱:
float f = 1234567.89f;
System.out.println(f); // 输出:1234567.9 ← 精度丢失
//(float 只能精确 7 位)
double d = 1234567890123.456;
System.out.println(d); // 输出:1.234567890123456E12 ← 还能撑住
//(double 精确 15 位)
2
3
4
5
6
7
结论:金额、汇率、利率这种"超过 7 位有效数字"的场景,永远不要用 float——精度直接崩盘。
# 5.3 NaN 与 Infinity
IEEE 754 定义了三种"非数字状态":
double posInf = 1.0 / 0.0; // +Infinity
double negInf = -1.0 / 0.0; // -Infinity
double nan = 0.0 / 0.0; // NaN
System.out.println(posInf); // Infinity
System.out.println(negInf); // -Infinity
System.out.println(nan); // NaN
// 反直觉:NaN != NaN !
System.out.println(nan == nan); // ★ false !
System.out.println(Double.isNaN(nan)); // ★ 必须用 isNaN 判断
2
3
4
5
6
7
8
9
10
11
疑惑:NaN 为什么不等于自己?
论证:IEEE 754 规定 NaN 是"非数"——它代表"未定义的运算结果"(如 0/0、负数开方、无穷-无穷)。两个"未定义"显然不能相等——这是数学定义,不是 BUG。
结论:判断浮点是否是 NaN 必须用 Double.isNaN()——x == x 在 NaN 上永远 false。这是一个不会"错"的运行时陷阱——因为它不抛异常。
# 5.4 浮点比较为什么不能用 ==
直接看反例:
double a = 0.1 + 0.2;
double b = 0.3;
System.out.println(a == b); // ★ false !
System.out.println(a); // 0.30000000000000004
System.out.println(b); // 0.3
2
3
4
5
正确比较方式——指定容差 epsilon:
public static boolean doubleEquals(double a, double b) {
return Math.abs(a - b) < 1e-9; // 容差 10^-9
}
// 或者用 BigDecimal 比较
BigDecimal ba = BigDecimal.valueOf(a);
BigDecimal bb = BigDecimal.valueOf(b);
System.out.println(ba.compareTo(bb) == 0);
2
3
4
5
6
7
8
注意:BigDecimal.equals 严格比较 scale——new BigDecimal("1.0").equals(new BigDecimal("1.00")) 是 false。要比较数值必须用 compareTo() == 0——这是 §6.1 的核心知识。
结论:浮点比较的三条铁律:
- 永远不用
== - 容差比较 + 阈值
- 高精度要求换 BigDecimal
# 6. BigDecimal 精度
# 6.1 内部 unscaledValue 与 scale
BigDecimal 的内部表示极其优雅——用整数 + 小数位数表达任意小数:
public class BigDecimal extends Number implements Comparable<BigDecimal> {
private final BigInteger intVal; // 无符号大整数(unscaledValue)
private final int scale; // 小数位数
// 数值 = intVal × 10^(-scale)
// 例:123.45 = unscaledValue=12345, scale=2
}
2
3
4
5
6
7
示例:
| 数值 | unscaledValue | scale |
|---|---|---|
| 123.45 | 12345 | 2 |
| 100 | 100 | 0 |
| 100.00 | 10000 | 2 |
| 0.001 | 1 | 3 |
| 1.23E5 | 123 | -3 |
关键陷阱:new BigDecimal("100").equals(new BigDecimal("100.00")) 是 false——scale 不同被视为不等。
BigDecimal a = new BigDecimal("100");
BigDecimal b = new BigDecimal("100.00");
System.out.println(a.equals(b)); // ★ false(scale 不同)
System.out.println(a.compareTo(b) == 0); // true(数值相等)
2
3
4
结论:BigDecimal 的判等永远用 compareTo 而非 equals——这是另一个静默杀手。
# 6.2 String 构造的必要性
回到 §1.1 案例最阴险的版本:
// ❌ 错误用法
BigDecimal a = new BigDecimal(0.1);
System.out.println(a);
// 输出:0.1000000000000000055511151231257827021181583404541015625
// ↑ 把 double 的精度缺陷"完整"复制进 BigDecimal
// ✅ 正确用法
BigDecimal b = new BigDecimal("0.1");
System.out.println(b);
// 输出:0.1(精确)
// ✅ 也可以
BigDecimal c = BigDecimal.valueOf(0.1);
// 内部相当于 new BigDecimal(Double.toString(0.1))
2
3
4
5
6
7
8
9
10
11
12
13
14
疑惑:为什么 new BigDecimal(double) 还存在?这不是反人类设计吗?
论证:JDK 文档明确警告——
The results of this constructor can be somewhat unpredictable. It might be assumed that writing
new BigDecimal(0.1)creates a BigDecimal which is exactly equal to 0.1, but it is actually equal to 0.1000000000000000055511151231257827021181583404541015625.
JDK 保留这个构造器是为了"完整暴露 double 的真实值"——某些科学计算场景需要看 double 的精确十进制表示。但业务代码 99% 应该用 String 构造或 valueOf。
结论:金额场景的铁律——new BigDecimal(String) 或 BigDecimal.valueOf(...),永远不写 new BigDecimal(double)。
# 6.3 RoundingMode 八种选择
BigDecimal 的除法、setScale 必须指定舍入模式:
| 模式 | 含义 | 示例(保留 1 位) |
|---|---|---|
UP | 远离 0 进位 | 1.55 → 1.6, -1.55 → -1.6 |
DOWN | 向 0 截断 | 1.55 → 1.5, -1.55 → -1.5 |
CEILING | 向 +∞ 进位 | 1.55 → 1.6, -1.55 → -1.5 |
FLOOR | 向 -∞ 进位 | 1.55 → 1.5, -1.55 → -1.6 |
HALF_UP | 四舍五入 | 1.55 → 1.6 |
HALF_DOWN | 五舍六入 | 1.55 → 1.5 |
HALF_EVEN ★ | 银行家舍入 | 1.55 → 1.6, 1.45 → 1.4 |
UNNECESSARY | 断言无需舍入(否则抛异常) | — |
HALF_EVEN(银行家舍入)的奥秘:当尾数恰好是 5 时,让结果的最后一位变成偶数——这种规则在大量数据上的累计偏差为 0,比四舍五入更"公平"。
BigDecimal a = new BigDecimal("1.5").setScale(0, RoundingMode.HALF_EVEN); // 2(向偶数)
BigDecimal b = new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN); // 2(向偶数)
BigDecimal c = new BigDecimal("3.5").setScale(0, RoundingMode.HALF_EVEN); // 4(向偶数)
2
3
结论:金融行业默认 HALF_EVEN,零售业务默认 HALF_UP——这是行业惯例,不是技术规则。
# 6.4 除法必须指定精度
最容易踩的坑:
BigDecimal a = new BigDecimal("1");
BigDecimal b = new BigDecimal("3");
BigDecimal c = a.divide(b); // ★ ArithmeticException!
// 信息:Non-terminating decimal expansion; no exact representable decimal result.
2
3
4
真相:1/3 = 0.333... 是无限小数——BigDecimal 无法精确表示——直接抛异常。
正确写法:
BigDecimal c = a.divide(b, 4, RoundingMode.HALF_UP);
// 0.3333(保留 4 位小数)
2
结论:BigDecimal 的除法永远要指定 scale 和 RoundingMode——这是除法和加减乘的最大差异。
# 7. 数值溢出与边界
# 7.1 整型溢出无声无息
整数溢出是 Java 中最危险的 BUG——不抛异常,不警告,静默错误:
int a = Integer.MAX_VALUE; // 2147483647
int b = a + 1; // ★ -2147483648(变成负数)
System.out.println(b);
// 真实事故
int totalSeconds = days * 24 * 60 * 60; // days=30000 时溢出
// 实际值变成负数——后续计算全错
2
3
4
5
6
7
字节码视角:iadd 指令是 32 位补码加法——溢出时高位被丢弃,符号位翻转。JVM 不会检查溢出——这是为了硬件级性能。
真实历史:1996 年阿丽亚娜 5 号火箭发射失败——把 64 位浮点强转 16 位整数时溢出——损失 3.7 亿美元。整数溢出是航天级的杀手。
# 7.2 Math.addExact 防御
JDK 8 引入 Math.addExact / subtractExact / multiplyExact / negateExact ——溢出时抛 ArithmeticException:
try {
int sum = Math.addExact(Integer.MAX_VALUE, 1);
} catch (ArithmeticException e) {
System.out.println("溢出了:" + e.getMessage());
// 输出:溢出了:integer overflow
}
2
3
4
5
6
底层实现:
public static int addExact(int x, int y) {
int r = x + y;
if (((x ^ r) & (y ^ r)) < 0) { // ★ 异或检查符号位
throw new ArithmeticException("integer overflow");
}
return r;
}
2
3
4
5
6
7
疑惑:((x ^ r) & (y ^ r)) < 0 这个检查为什么有效?
论证:
- 同号相加溢出时,结果与原操作数符号相反
(x ^ r) < 0表示 x 与结果异号- 当且仅当 x 与 r 异号 且 y 与 r 异号 时——溢出发生
& < 0检查"两个异号关系都成立"——三行代码精准捕获溢出
结论:金融、计费、统计场景永远用 Math.addExact 替代 +——这是防御性编程的铁律。
# 7.3 long 转 int 截断
long big = 10_000_000_000L; // 100 亿,超过 int 范围
int small = (int) big; // ★ 1410065408(被截断)
System.out.println(small);
// 安全转换
try {
int safe = Math.toIntExact(big);
} catch (ArithmeticException e) {
System.out.println("无法转 int:" + big);
}
2
3
4
5
6
7
8
9
10
结论:任何"宽 → 窄"的强转都要用 Math.toIntExact / toShortExact——避免静默截断。
# 7.4 BigInteger 无界算术
需要超过 long 的整数运算时,BigInteger 是唯一选择:
BigInteger a = new BigInteger("99999999999999999999999999999999");
BigInteger b = new BigInteger("88888888888888888888888888888888");
BigInteger c = a.multiply(b);
System.out.println(c);
// 输出 60+ 位的精确大整数
// 阶乘 100!(普通 long 早就溢出了)
BigInteger factorial = BigInteger.ONE;
for (int i = 1; i <= 100; i++) {
factorial = factorial.multiply(BigInteger.valueOf(i));
}
System.out.println(factorial);
// 输出 158 位大数
2
3
4
5
6
7
8
9
10
11
12
13
性能代价:BigInteger 加法 O(n)、乘法 O(n²) 或 O(n log n)——比 long 慢 50~200 倍。
结论:BigInteger 是"算法竞赛 + 加密 + 极端精度"的工具,业务代码用 long 几乎都够。
# 8. 性能与选型
# 8.1 装箱开销实测
JMH 微基准——10⁷ 次加法操作:
@Benchmark
public int sumPrimitive() {
int sum = 0;
for (int i = 0; i < 10_000_000; i++) sum += i;
return sum;
}
@Benchmark
public Integer sumBoxed() {
Integer sum = 0;
for (int i = 0; i < 10_000_000; i++) sum += i;
return sum;
}
2
3
4
5
6
7
8
9
10
11
12
13
| 方案 | 耗时 | 内存分配 |
|---|---|---|
int sum | 6 ms | 0 |
Integer sum | 95 ms | ~120 MB(每次装箱新建 Integer) |
性能比 16 倍,GC 压力天差地别——这就是装箱的真实代价。
结论:循环计数器、累加变量、热点路径上永远用基本类型——只有进集合(List
# 8.2 BigDecimal 性能代价
同样的 10⁷ 次加法:
@Benchmark
public BigDecimal sumBigDecimal() {
BigDecimal sum = BigDecimal.ZERO;
for (int i = 0; i < 10_000_000; i++) sum = sum.add(BigDecimal.valueOf(i));
return sum;
}
2
3
4
5
6
| 方案 | 耗时 | 相对性能 |
|---|---|---|
int + | 6 ms | 1x |
Integer +(装箱) | 95 ms | 16x |
double + | 8 ms | 1.3x |
BigDecimal.add | 2800 ms | 466x |
结论:BigDecimal 比 double 慢约 350 倍——金额场景这是必要代价,但绝不要在中间计算用 BigDecimal——只在最终展示时转 BigDecimal 格式化。
# 8.3 三套金额方案对比
回到 §1 的金额计算事故——三种工程方案对比:
| 方案 | 精度 | 性能 | 内存 | 适用场景 |
|---|---|---|---|---|
| double | ❌ 不准(§5) | ⭐⭐⭐⭐⭐ | 8B/数 | ❌ 永远不要用于金额 |
| BigDecimal | ✅ 精确 | ⭐ | 80~120B/对象 | ✅ 业务展示 + 复杂计算 |
| long(分) | ✅ 精确 | ⭐⭐⭐⭐⭐ | 8B/数 | ✅ 高频核心计算(如撮合引擎) |
长 = 分 方案的最佳实践:
// 内部统一存"分",展示时转"元"
public class Money {
private final long cents; // 内部全部用分(long 精确)
public Money(BigDecimal yuan) {
this.cents = yuan.movePointRight(2).longValueExact();
}
public BigDecimal toYuan() {
return BigDecimal.valueOf(cents).movePointLeft(2);
}
public Money plus(Money other) {
return new Money(this.cents + other.cents); // ← 纯 long 加法
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
优势:
- 加减乘除全是 long 原生运算——速度顶级
- long 范围 ±922 亿亿分 ≈ ±9.2×10¹⁶ 元——绝对够用
- 序列化、网络传输只需 8 字节
结论:高频交易、撮合引擎、清算系统首选 long(分);ERP、财务报表、税务计算首选 BigDecimal——按"性能 vs 易用性"权衡。
# 9. 现代特性演进
# 9.1 Valhalla 值类型
OpenJDK Valhalla 项目——Project Valhalla 计划引入"值类型"(Value Class):
// 未来语法(Valhalla 预览)
value class Point {
int x, y;
}
Point p = new Point(1, 2);
// JVM 内部当成基本类型处理——无对象头、无指针、可栈上分配
// 但语义上仍然是对象(有方法、可继承接口)
2
3
4
5
6
7
8
核心改变:
- 消除装箱代价——
List<Integer>性能可与int[]相当 - 消除指针开销——大型数据数组遍历快 10 倍以上
- 保留对象语义——可定义方法、实现接口、用泛型
进度:JEP 401(Value Classes and Objects)—— JDK 24+ 预览,2026 年正式 GA。
结论:Valhalla 是 Java 性能未来 5 年最重要的演进——一旦 GA,包装类的性能负担将彻底消除。
# 9.2 数字字面量增强
JDK 7 引入的两个语法糖:
// 1. 数字下划线分隔——提升可读性
long bigNumber = 1_000_000_000L; // 10 亿
int hex = 0xFF_FF_FF_FF; // 32 位掩码
double pi = 3.14_159_26; // 圆周率
// 2. 二进制字面量
int flags = 0b1010_1100; // 位标志
byte mask = (byte) 0b1111_0000;
2
3
4
5
6
7
8
结论:现代 Java 数字字面量必加下划线——1000000 容易看错,1_000_000 一眼万年。
# 9.3 Math 与 StrictMath
Java 提供两套数学库:
| 类 | 实现 | 跨平台一致 | 性能 |
|---|---|---|---|
Math | 平台相关(CPU 指令优化) | ❌ 可能不同 | 快 2~5 倍 |
StrictMath | 算法严格规定(fdlibm) | ✅ 完全一致 | 慢但精确 |
疑惑:为什么需要"不一致"的 Math?
论证:CPU 厂商对 sin/cos/sqrt 等都有硬件加速指令(如 x86 的 FSQRT),但不同 CPU 的硬件实现尾位精度可能差异——StrictMath 强制软件实现,保证不同机器上结果完全相同(金融对账场景需要)。
结论:业务代码默认 Math;跨机器对账、回测、加密签名必用 StrictMath。
# 10. 综合案例串讲
# 10.1 一分钱真相揭晓
回到第 1 章的两起事故,逐条揭晓:
① IEEE 754 为什么算不准 0.1:0.1 在二进制中是无限循环小数 0.0001100110011...,double 只有 52 位尾数能容纳——超过部分被截断——导致 100 × 0.9 = 89.99999999999999(§5.1)。这不是 BUG,是数学必然——任何分母不是 2 的幂的有理数都无法二进制精确表示。
② BigDecimal(double) vs BigDecimal(String):new BigDecimal(0.1) 把 double 的全部精度缺陷(0.1000000000000000055511151...)原样复制进 BigDecimal——BigDecimal 精确,但起点已经错了。new BigDecimal("0.1") 直接从字符串解析——精确就是精确(§6.2)。金额场景永远 String 构造或 valueOf。
③ Integer -128~127 缓存边界:byte 的天然边界 + 90% 整数运算落在小整数 + 4KB 内存预算——三个工程权衡共同确定(§3.1)。下界 -128 不可调,上界 127 可通过 -XX:AutoBoxCacheMax 扩大。
④ 自动装箱字节码:装箱 = invokestatic Integer.valueOf(int),拆箱 = invokevirtual Integer.intValue()(§4.1)。拆箱在 null 引用上调用 intValue → NPE——这就是 §4.2 中 Map.get + int = 的崩溃链路。三元表达式两端类型不匹配会触发隐式拆箱(§4.3)——这是 NPE 的隐藏制造工厂。
⑤ 整型溢出沉默杀手:int + 1 在 MAX_VALUE 处变成 MIN_VALUE,JVM 不抛异常(§7.1)——阿丽亚娜 5 号火箭、Y2K 问题都是溢出酿成的灾难。用 Math.addExact 替代 +——三行异或检查精准捕获溢出(§7.2)。
⑥ 金额场景三套方案:double 永远不可(精度差);BigDecimal 精确但慢 350 倍——适合 ERP/财报;long(分) 精确且最快——适合撮合引擎/清算(§8.3)。原则:性能 vs 易用性二选一。
⑦ 浮点比较:== 在 NaN 上是 false(§5.3)、在精度损失场景错误(§5.4)——必须用容差比较或 BigDecimal.compareTo == 0。BigDecimal.equals 比 scale,compareTo 比数值——这又是另一个静默坑(§6.1)。
§1.1 一分钱事故复盘:开发用 double 算折扣,每次乘法损失 1e-16 精度——5000 笔订单累加后偏差 873 元。修复方案:金额内部全部 long(分),对外展示用 BigDecimal 格式化——零精度损失,性能不降反升。
# 10.2 一笔金额的一生
把 订单 100 元 × 9 折 这个最常见的业务计算串成一棵树,回扣前 22 篇:
T 0 用户下单:原价 100.00 元
[05篇] String "100.00" 通过 RPC 进入服务端
T+1ms 解析为 BigDecimal
BigDecimal price = new BigDecimal("100.00");
★ 必须 String 构造(§6.2)
T+2ms 折扣率读取
BigDecimal rate = new BigDecimal("0.9");
T+3ms 计算实付金额
BigDecimal pay = price.multiply(rate)
.setScale(2, RoundingMode.HALF_EVEN);
★ 银行家舍入(§6.3)
★ 内部 BigInteger.multiply(§7.4)—— O(n²)
T+5ms 转 long(分) 进入核心账本
long cents = pay.movePointRight(2).longValueExact();
// 9000 分(即 90.00 元)
★ 内部计算全部 long——加减极快(§8.3)
T+6ms 累加到日总账
totalCents = Math.addExact(totalCents, cents); ← 防溢出(§7.2)
T+10ms 入库 MySQL DECIMAL(18,2)
BigDecimal yuanForDB = BigDecimal.valueOf(cents).movePointLeft(2);
ps.setBigDecimal(1, yuanForDB);
★ JDBC DECIMAL 类型自动映射 BigDecimal
T+15ms 返回前端
String json = "\"payAmount\": \"90.00\"";
★ 用 String 传输——避免 JSON 数字精度丢失
GC 视角:
[01篇] BigDecimal 是中等寿命对象,频繁创建撑大年轻代
★ 高频路径用 long——避免 GC 压力
JIT 视角:
[14篇] long 加减是 C2 最爱——会被向量化成 SIMD
BigDecimal.add 内部分支多——只能保守编译
并发视角:
[09篇] long 是 64 位——读写非原子(除非 volatile)
总账类用 LongAdder 替代——避免 cache line 争抢
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
这条时间线串起本篇 80% 知识点——一笔订单背后是 IEEE 754、BigDecimal 内部结构、long 溢出防护、HALF_EVEN 舍入、装箱代价、JIT 优化的连锁反应。
# 10.3 设计哲学回扣
跳出技术细节,提炼三条贯穿数字类型设计的工程哲学:
抽象有代价:基本类型快但无 null 语义、无对象方法;包装类型有完整对象语义但慢 16 倍;BigDecimal 精确但慢 350 倍——每一层抽象都在拿性能换功能。这条原则与卷一第 14 篇"JIT 内联是把抽象代价摊平的最后救赎"一脉相承——抽象不是免费的,工程师的本职是看穿成本。
不变性是精度的护城河:BigDecimal、BigInteger、所有包装类——全部不可变。任何运算返回新对象,永不修改原值。这与第 5 篇 String、第 22 篇 LinkedHashMap 都同源——多线程安全、可缓存、可预测。金融系统、加密系统、配置系统的核心数据全部不可变——这是工业级系统的安全底线。
正确性优先于性能:金额场景宁可慢 350 倍(BigDecimal)也要精确——一分钱事故能毁掉一家支付公司。整型溢出宁可抛异常(addExact)也要捕获——沉默的错误比明显的崩溃更危险。这条原则贯穿所有金融、医疗、航天、加密系统——Bug 一旦写下,错误会以光速传染,性能可以以毫秒计时挽回。
# 10.4 数字类型速查表
最后一张表——任何数字场景 5 秒决策:
| 场景 | 推荐类型 | 不推荐 | 理由 |
|---|---|---|---|
| 循环计数器 | int / long | Integer | 装箱代价 16 倍 |
| 数组索引 | int | Integer | 同上 |
| 集合 Key/Value | Integer / Long | 不得不 | 集合不能存基本类型 |
| 金额(核心) | long(分) | double | 精度 + 性能双赢 |
| 金额(展示) | BigDecimal | double | 精度第一 |
| 利率/汇率 | BigDecimal | double | 精度第一 |
| 物理计算 | double | float | 精度更高 |
| 图形/游戏坐标 | float | double | 节省内存 |
| 大整数(加密、组合数) | BigInteger | long | 无溢出 |
| ID 生成 | long | int | 21 亿不够用 |
| 位运算掩码 | int / long | byte/short | 自动提升为 int |
数字类型铁律 7 条:
1. 金额永远不用 double——用 long(分) 或 BigDecimal
2. BigDecimal 永远 String 构造,永远 compareTo 比数值
3. 包装类永远 equals 比较,永远不用 ==
4. 浮点永远不用 == 比较,永远用容差或 BigDecimal
5. 整数运算用 Math.addExact 防溢出(金融/计费场景)
6. 循环和热点路径永远用基本类型,绝不装箱
7. 现代 Java 用 1_000_000 写法,永远加下划线
2
3
4
5
6
7
至此第 23 篇完成——我们用 1.6 万字把 IEEE 754 浮点本质、Integer 缓存池、自动装箱字节码、BigDecimal 精度控制、整型溢出防御、long(分) 金额方案讲透。卷二容器与基础数据结构还有最后一篇——Object 通用方法。下一篇我们顺着"hashCode 和 equals 为什么必须一致"这条线,进入 第 24 篇:Object通用方法的契约——把 hashCode/equals 一致性、toString/clone/finalize 的废与立、wait/notify 与监视器机制一次讲透。