Skip to content

计算机组成与二进制 ​

S2-001选修入门约 45 分钟JDK 21计算机基础 · 计算机导论

1. 本节定位 ​

这是 S2 计算机基础的第一节,也是整条技术路线的地基:

text
二进制 → 数据类型与精度 → 溢出与浮点误差 → 哈希与位运算

本节不讲硬件电路,只讲**"二进制的三个后果"** ——它们每天都在真实项目里制造 bug ✓

2. 学习目标 ​

  • 能写出负数在计算机里的表示(补码),并说出为什么要用补码 ★★★;
  • 知道整数溢出为什么不报错,以及怎么让它报错 ★★★;
  • 能解释「0.1 + 0.2 != 0.3」,并知道金额计算该怎么写 ★★★;
  • 知道什么时候该用位运算、什么时候不该 ★★。

3. 前置知识检查 ​

  • 会在 Java 里声明 int 与 long 吗?✓
  • 知道 == 与 equals 的区别吗?(《继承与重写》)✓

4. 为什么需要这个知识 ​

先看三个由"二进制"直接引发的真实事故 ✗:

事故根因后果
订单金额差了 0.01 元用 double 算钱 ✗对账不平,人工修数据
超时时间是负数int 溢出(毫秒 × 1000)✗客户端认为"已超时",请求全部立即失败 ✗✗
Map 查不到刚放进去的对象两把 key 的 hashCode 不一致 ✗数据"消失" ✗✗

★ 这三条的共同点:代码能编译、能运行、不报错 ✗ ——只是结果是错的 ✓✓

5. 核心概念 ​

5.1 补码:负数为什么长这样 ★★★ ​

本节的实测输出(labs/cs-basics 的 BinaryLesson):

text
十进制:-42
二进制:11111111111111111111111111010110(补码表示)
十六进制:ffffffd6
无符号解释:4294967254

补码的定义:取反加一 ✓

text
 42  = 00000000000000000000000000101010
~42  = 11111111111111111111111111010101   ← 取反
~42+1= 11111111111111111111111111010110   ← 加一,这就是 -42

★ 为什么不用"最高位当符号位"的简单做法 ✓✓:

方案问题
原码(最高位当符号)✗+0 与 -0 是两个值,且减法要单独做减法电路 ✗
补码 ★★0 只有一个 ✓,且减法可以用加法实现 ✓✓

★ 最后一条是硬件的胜利:

text
a - b  等价于  a + (~b + 1)

于是 CPU 里只需要一个加法器,不需要减法器 ✓✓ ——芯片面积与功耗都省一半 ✓

★ 顺带一个实用推论:

java
System.out.println(-42 & 0xFF);      // 214:取低 8 位(无符号解释)
System.out.println(Integer.toUnsignedString(-42));   // 4294967254

位运算与无符号解释是处理"协议字节""哈希""编码"的日常工具 ✓

5.2 整数溢出:不报错的错误 ★★★ ​

实测输出:

text
Integer.MAX_VALUE = 2147483647
MAX_VALUE + 1     = -2147483648   ← 绕回最小值,没有异常
MAX_VALUE * 2     = -2

★ 为什么会这样 ✓:因为补码的加法就是二进制加法,进位被丢弃 ✓

text
  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 = 负数

★ 一个真实事故的模型(实测输出):

text
原始毫秒: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 ✓

5.3 浮点数:0.1 + 0.2 的真相 ★★★ ​

实测输出:

text
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 ✓

★ 正确的金额计算(实测输出) ✓✓:

text
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 只用于"科学计算、图形、统计"这类允许误差的场景 ✓

5.4 位运算该用在哪 ★★ ​

实测数据(各 2 亿次,已预热):

text
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 位 ✓

★ 分桶那条值得展开:

java
// HashMap 定位桶:等价于 hash % capacity,但更快,且要求 capacity 是 2 的幂
int index = hash & (capacity - 1);

这解释了为什么 HashMap 的容量总是 2 的幂 ✓✓ ——因为这样取模可以退化成位与 ✓

6. 递进示例 ​

6.1 最小示例:看一眼位模式 ​

java
int value = -42;
System.out.println(Integer.toBinaryString(value));   // 11111111111111111111111111010110
System.out.println(Integer.toUnsignedString(value)); // 4294967254

6.2 业务示例:安全的超时计算 ​

java
// ✗ 危险:毫秒 × 1000 可能溢出
long micros = timeoutMillis * 1000;

// ✓ 安全:溢出立刻抛异常
long micros = Math.multiplyExact(timeoutMillis, 1000);

6.3 反例:用 double 算钱 ​

java
double price = 0.1;
double total = price * 3;
System.out.println(total);        // 0.30000000000000004 ✗

7. 完整可运行代码与实测 ​

代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/BinaryLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.BinaryLesson

实测输出(节选):

text
===== 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

★ 三条可写进检查清单的结论 ✓:

  1. 金额与时间的运算不用 double/float ✗;
  2. 可能溢出的地方用 Math.*Exact ✓;
  3. 位运算用于表达位语义,不用于"提速" ✓。

8. Debug 观察点 ​

  1. 把 int 换成 long 再跑溢出那段:观察溢出消失 ✓;
  2. 用 new BigDecimal(0.1)(不带引号)构造:观察它打出的值带着一长串误差 ★★;
  3. 把 i * 4 改成 i * 3:观察"乘 2 的幂"与"乘普通数"的差异(前者被 JIT 优化成位移)★;
  4. 打印 HashMap 的容量(用反射或 new HashMap<>(100).size() 感受):理解"容量是 2 的幂" ✓。

9. 常见错误 ​

#错误说明
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 会比较精度)★

10. 动手实验 ​

目标:亲手制造一次"静默的错误结果"。

步骤:

  1. 跑一次 BinaryLesson,确认溢出与浮点的输出 ✓;
  2. 写一段代码:把"当前时间戳(毫秒)× 1000"存进 int,打印结果 ✗ → 观察负数 ★★;
  3. 改成 Math.multiplyExact → 观察异常 ✓;
  4. 算一次"3 件 0.1 元的商品总价",分别用 double 和 BigDecimal ★;
  5. 用 Integer.toBinaryString 打印 1、2、4、8、16 → 观察"只有一位是 1" ✓。

验收方式:能说出"哪一步产生了错误结果、为什么编译器不报错"。

扩展任务:用位运算实现一个权限系统:

  • 用 int 定义 READ=1, WRITE=2, EXEC=4 ✓;
  • 实现 hasPermission(userPerm, requiredPerm) ✓;
  • 说明:为什么这比"用三个 boolean 字段"更省空间与更易扩展(提示:一个 int 存 32 种权限,且组合判断是一次位运算)★

11. 练习题 ​

  1. 判断:Java 的整数溢出会抛出 ArithmeticException。(对 / 错)
  2. 填空:-42 的补码求法是 ______,用补码的好处是 ______。
  3. 简答:为什么 0.1 + 0.2 != 0.3?
  4. (代码阅读)
java
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 是否会溢出。

参考答案(要点)
  1. 错。整数溢出完全静默 ✓ ——要抛异常得用 Math.multiplyExact 等 ✓★
  2. 取反加一;减法可以用加法电路实现(硬件省一半)✓
  3. 因为 0.1 在二进制里是无限循环小数,只能存近似值 ✓(5.3)
  4. 输出 false ✗ ——因为 19.99 * 3 得到 59.97000000000001 之类的近似值 ✓ 正确写法:BigDecimal 或整数分 ✓
  5. 要点:用 a != 0 && (a * b) / a != b ✓ 或更严谨:先判断 a > Long.MAX_VALUE / b ✓(这也是 JDK 内部的实现思路)✓

12. 掌握标准 ​

  • [ ] 会写负数的补码,并知道补码的两个好处 ★★★;
  • [ ] 知道整数溢出静默发生,会用 Math.*Exact 防护 ★★★;
  • [ ] 能解释 0.1+0.2 的误差,并且知道金额计算该用什么类型 ★★★;
  • [ ] 知道 new BigDecimal(double) 是个陷阱 ★★;
  • [ ] 知道"位运算不用于提速",但知道它该用在哪些场景 ★★;
  • [ ] 知道 HashMap 容量取 2 的幂与位运算的关系 ✓。

13. 扩展资源 ​

14. 下一步学习 ​

《编程语言的发展与分类》:为什么会有这么多语言,以及 Java 在其中处于什么位置。

前置知识

无,可直接开始

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引