主题
界面
主题
这是 S2 计算机基础的第一节,也是整条技术路线的地基:
二进制 → 数据类型与精度 → 溢出与浮点误差 → 哈希与位运算本节不讲硬件电路,只讲**"二进制的三个后果"** ——它们每天都在真实项目里制造 bug ✓
int 与 long 吗?✓== 与 equals 的区别吗?(《继承与重写》)✓先看三个由"二进制"直接引发的真实事故 ✗:
| 事故 | 根因 | 后果 |
|---|---|---|
| 订单金额差了 0.01 元 | 用 double 算钱 ✗ | 对账不平,人工修数据 |
| 超时时间是负数 | int 溢出(毫秒 × 1000)✗ | 客户端认为"已超时",请求全部立即失败 ✗✗ |
| Map 查不到刚放进去的对象 | 两把 key 的 hashCode 不一致 ✗ | 数据"消失" ✗✗ |
★ 这三条的共同点:代码能编译、能运行、不报错 ✗ ——只是结果是错的 ✓✓
本节的实测输出(labs/cs-basics 的 BinaryLesson):
十进制:-42
二进制:11111111111111111111111111010110(补码表示)
十六进制:ffffffd6
无符号解释:4294967254补码的定义:取反加一 ✓
42 = 00000000000000000000000000101010
~42 = 11111111111111111111111111010101 ← 取反
~42+1= 11111111111111111111111111010110 ← 加一,这就是 -42★ 为什么不用"最高位当符号位"的简单做法 ✓✓:
| 方案 | 问题 |
|---|---|
| 原码(最高位当符号)✗ | +0 与 -0 是两个值,且减法要单独做减法电路 ✗ |
| 补码 ★★ | 0 只有一个 ✓,且减法可以用加法实现 ✓✓ |
★ 最后一条是硬件的胜利:
a - b 等价于 a + (~b + 1)于是 CPU 里只需要一个加法器,不需要减法器 ✓✓ ——芯片面积与功耗都省一半 ✓
★ 顺带一个实用推论:
System.out.println(-42 & 0xFF); // 214:取低 8 位(无符号解释)
System.out.println(Integer.toUnsignedString(-42)); // 4294967254位运算与无符号解释是处理"协议字节""哈希""编码"的日常工具 ✓
实测输出:
Integer.MAX_VALUE = 2147483647
MAX_VALUE + 1 = -2147483648 ← 绕回最小值,没有异常
MAX_VALUE * 2 = -2★ 为什么会这样 ✓:因为补码的加法就是二进制加法,进位被丢弃 ✓
0111 1111 1111 1111 1111 1111 1111 1111 (MAX_VALUE)
+ 0000 0000 0000 0000 0000 0000 0000 0001
= 1000 0000 0000 0000 0000 0000 0000 0000 ← 最高位变 1 = 负数★ 一个真实事故的模型(实测输出):
原始毫秒:300000000000
强转成 int:-647710720 ← 高位被截断,变成负数 ✗
保持 long:300000000000 ✓
Math.multiplyExact:300000000000000三种写法,三种命运:
| 写法 | 结果 |
|---|---|
(int) millis ✗✗ | 高位直接丢掉 ——负数,且没有任何提示 |
long ✓ | 正确 |
Math.multiplyExact(a, b) ★★★ | 溢出时抛 ArithmeticException ✓✓ |
★ 这是本节最该带走的一条习惯:
涉及"时间戳、金额、计数"的运算,用
Math.addExact/multiplyExact系列 ✓✓让溢出在测试环境就炸出来,而不是在线上悄悄算错 ✓
Java 8 起这些方法已成套提供:Math.addExact、subtractExact、multiplyExact、toIntExact ✓
实测输出:
0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 == 0.3 = false ← false ✗
0.1 的实际值 = 0.1000000000000000055511151231257827021181583404541015625★ 原因:十进制小数 0.1 在二进制里是无限循环小数 ✓✓
类比:就像十进制写不出精确的 1/3(0.333…) ✓ ——二进制也写不出精确的 0.1 ✓
所以:它只能存一个"最接近的近似值" ✗ ——两个近似值相加,自然不等于精确的 0.3 ✓
★ 正确的金额计算(实测输出) ✓✓:
new BigDecimal("0.1").add(new BigDecimal("0.2")) = 0.3
用整数分:10 + 20 = 30 分 = 0.30 元 ✓金额计算的三种正确做法:
| 做法 | 说明 |
|---|---|
BigDecimal ★★ | 注意必须用字符串构造:new BigDecimal("0.1") ✓ ——用 new BigDecimal(0.1) 会把误差也带进来 ✗✗ |
| 整数分(long) ★★★ | 最简单也最快 ——存 10 而不是 0.1 ✓✓ |
数据库 DECIMAL ★★ | 存储层也要用定点类型,不能用 FLOAT/DOUBLE ✗ |
★ 一条可以直接用的判断:
只要涉及"钱、税率、比例",一律不用
float/double✓✓
double只用于"科学计算、图形、统计"这类允许误差的场景 ✓
实测数据(各 2 亿次,已预热):
i * 4(乘法) : 92 ms
i << 2(位移) : 89 ms
i / 4(除法) :151 ms★ 结论出乎意料:
乘 4 与左移 2 的耗时几乎一样 ✗ ——"位运算更快"在这个场景下不成立 ✓✓
两个原因:
| 原因 | 说明 |
|---|---|
| 现代 CPU 的乘法只要 3~5 个周期 | 与加法相差不大 ✓ |
| JIT 会把「乘 2 的幂」自动优化成位移 ★★ | 你写的 * 4,编译后可能就是 << 2 ✓ |
★ 所以:
用位移是为了表达"位语义",不是为了性能 ✓✓
为了性能而把
i * 2写成i << 1,属于给自己降低可读性 ✗
★ 真正该用位运算的场景:
| 场景 | 例子 |
|---|---|
| 权限位 / 标志位 ★★ | PERM_READ | PERM_WRITE,一个 int 存 32 种权限 ✓ |
| 哈希与分桶 ★★ | index = hash & (capacity - 1)(HashMap 就是这么做的)✓ |
| 位图(Bitmap) ★★ | 10 亿用户的签到状态只要 125 MB ✓ |
| 协议解析 / 编解码 | 从字节流里取第 3~7 位 ✓ |
★ 分桶那条值得展开:
// HashMap 定位桶:等价于 hash % capacity,但更快,且要求 capacity 是 2 的幂
int index = hash & (capacity - 1);这解释了为什么 HashMap 的容量总是 2 的幂 ✓✓ ——因为这样取模可以退化成位与 ✓
int value = -42;
System.out.println(Integer.toBinaryString(value)); // 11111111111111111111111111010110
System.out.println(Integer.toUnsignedString(value)); // 4294967254// ✗ 危险:毫秒 × 1000 可能溢出
long micros = timeoutMillis * 1000;
// ✓ 安全:溢出立刻抛异常
long micros = Math.multiplyExact(timeoutMillis, 1000);double price = 0.1;
double total = price * 3;
System.out.println(total); // 0.30000000000000004 ✗代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/BinaryLesson.java
运行:
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.BinaryLesson实测输出(节选):
===== 2. 整数溢出:不报错的错误 =====
Integer.MAX_VALUE = 2147483647
MAX_VALUE + 1 = -2147483648 ← 绕回最小值,没有异常
原始毫秒:300000000000
强转成 int:-647710720 ← 高位被截断,变成负数 ✗
Math.multiplyExact 抛出了异常 ✓ —— integer overflow
===== 4. 位运算真的比乘除快吗 =====
i * 4(乘法) : 92 ms
i << 2(位移) : 89 ms
i / 4(除法) :151 ms★ 三条可写进检查清单的结论 ✓:
double/float ✗;Math.*Exact ✓;int 换成 long 再跑溢出那段:观察溢出消失 ✓;new BigDecimal(0.1)(不带引号)构造:观察它打出的值带着一长串误差 ★★;i * 4 改成 i * 3:观察"乘 2 的幂"与"乘普通数"的差异(前者被 JIT 优化成位移)★;HashMap 的容量(用反射或 new HashMap<>(100).size() 感受):理解"容量是 2 的幂" ✓。| # | 错误 | 说明 |
|---|---|---|
| 1 | 用 double 算金额 ✗✗ | 0.1+0.2 ≠ 0.3(5.3)✗✗ |
| 2 | 用 new BigDecimal(0.1) ✗✗ | 误差被带进来,必须用字符串 ✓ |
| 3 | 强转 long → int ✗✗ | 高位静默丢失(5.2)✗✗ |
| 4 | 以为溢出会抛异常 ✗✗ | Java 的整数溢出完全静默 ✓ |
| 5 | 为性能把 * 2 写成 << 1 ✗ | JIT 早就优化了(5.4)✗ |
| 6 | 用 == 比较两个 BigDecimal ✗ | 必须用 compareTo(equals 会比较精度)★ |
目标:亲手制造一次"静默的错误结果"。
步骤:
BinaryLesson,确认溢出与浮点的输出 ✓;int,打印结果 ✗ → 观察负数 ★★;Math.multiplyExact → 观察异常 ✓;double 和 BigDecimal ★;Integer.toBinaryString 打印 1、2、4、8、16 → 观察"只有一位是 1" ✓。验收方式:能说出"哪一步产生了错误结果、为什么编译器不报错"。
扩展任务:用位运算实现一个权限系统:
int 定义 READ=1, WRITE=2, EXEC=4 ✓;hasPermission(userPerm, requiredPerm) ✓;ArithmeticException。(对 / 错)-42 的补码求法是 ______,用补码的好处是 ______。0.1 + 0.2 != 0.3?double price = 19.99;
int count = 3;
double total = price * count;
System.out.println(total == 59.97);输出什么?为什么?正确写法是什么? 5. (编程)写一个 isOverflow(long a, long b) 方法,不用 try-catch 判断 a * b 是否会溢出。
Math.multiplyExact 等 ✓★false ✗ ——因为 19.99 * 3 得到 59.97000000000001 之类的近似值 ✓ 正确写法:BigDecimal 或整数分 ✓a != 0 && (a * b) / a != b ✓ 或更严谨:先判断 a > Long.MAX_VALUE / b ✓(这也是 JDK 内部的实现思路)✓Math.*Exact 防护 ★★★;new BigDecimal(double) 是个陷阱 ★★;《编程语言的发展与分类》:为什么会有这么多语言,以及 Java 在其中处于什么位置。
无,可直接开始