### [JMH 的引入](https://lingcoder.gitee.io/onjava8/#/book/16-Validating-Your-Code?id=jmh-%e7%9a%84%e5%bc%95%e5%85%a5)
截止目前为止,唯一能产生像样结果的 Java 微基准测试系统就是 Java Microbenchmarking Harness,简称 JMH。本书的**build.gradle**自动引入了 JMH 的设置,所以你可以轻松地使用它。
你可以在命令行编写 JMH 代码并运行它,但是推荐的方式是让 JMH 系统为你运行测试;**build.gradle**文件已经配置成只需要一条命令就能运行 JMH 测试。
JMH 尝试使基准测试变得尽可能简单。例如,我们将使用 JMH 重新编写**BadMicroBenchmark.java**。这里只有**@State**和**@Benchmark**这两个注解是必要的。其余的注解要么是为了产生更多易懂的输出,要么是加快基准测试的运行速度(JMH 基准测试通常需要运行很长时间):
~~~
// validating/jmh/JMH1.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// Increase these three for more accuracy:
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH1 {
private long[] la;
@Setup
public void setup() {
la = new long[250_000_000];
}
@Benchmark
public void setAll() {
Arrays.setAll(la, n -> n);
}
public void parallelSetAll() {
Arrays.parallelSetAll(la, n -> n);
}
}
~~~
“forks” 的默认值是 10,意味着每个测试都运行 10 次。为了减少运行时间,这里使用了**@Fork**注解来减少这个次数到 1。我还使用了**@Warmup**和**@Measurement**注解将它们默认的运行次数从 20 减少到 5 次。尽管这降低了整体的准确率,但是结果几乎与使用默认值相同。可以尝试将**@Warmup**、\*\*@Measurement\*\* 和**@Fork**都注释掉然后看使用它们的默认值,结果会有多大显著的差异;一般来说,你应该只能看到长期运行的测试使错误因素减少,而结果没有多大变化。
需要使用显式的 gradle 命令才能运行基准测试(在示例代码的根目录处运行)。这能防止耗时的基准测试运行其他的**gradlew**命令:
**gradlew validating:jmh**
这会花费几分钟的时间,取决于你的机器(如果没有注解上的调整,可能需要几个小时)。控制台会显示**results.txt**文件的路径,这个文件统计了运行结果。注意,**results.txt**包含这一章所有**jmh**测试的结果:**JMH1.java**,**JMH2.java**和**JMH3.java**。
因为输出是绝对时间,所以在不同的机器和操作系统上结果各不相同。重要的因素不是绝对时间,我们真正观察的是一个算法和另一个算法的比较,尤其是哪一个运行得更快,快多少。如果你在自己的机器上运行测试,你将看到不同的结果却有着相同的模式。
我在大量的机器上运行了这些测试,尽管不同的机器上得到的绝对值结果不同,但是相对值保持着合理的稳定性。我只列出了**results.txt**中适当的片段并加以编辑使输出更加易懂,而且内容大小适合页面。所有测试中的**Mode**都以**avgt**展示,代表 “平均时长”。**Cnt**(测试的数目)的值是 200,尽管这里的一个例子中配置的**Cnt**值是 5。**Units**是**us/op**,是 “Microseconds per operation” 的缩写,因此,这个值越小代表性能越高。
我同样也展示了使用 warmups、measurements 和 forks 默认值的输出。我删除了示例中相应的注解,就是为了获取更加准确的测试结果(这将花费数小时)。结果中数字的模式应该仍然看起来相同,不论你如何运行测试。
下面是**JMH1.java**的运行结果:
**Benchmark Score**
**JMH1.setAll 196280.2**
**JMH1.parallelSetAll 195412.9**
即使像 JMH 这么高级的基准测试工具,基准测试的过程也不容易,练习时需要倍加小心。这里测试产生了反直觉的结果:并行的版本**parallelSetAll()**花费了与非并行版本的**setAll()**相同的时间,两者似乎都运行了相当长的时间。
当创建这个示例时,我假设如果我们要测试数组初始化的话,那么使用非常大的数组是有意义的。所以我选择了尽可能大的数组;如果你实验的话会发现一旦数组的大小超过 2亿5000万,你就开始会得到内存溢出的异常。然而,在这么大的数组上执行大量的操作从而震荡内存系统,产生无法预料的结果是有可能的。不管这个假设是否正确,看上去我们正在测试的并非是我们想测试的内容。
考虑其他的因素:
C:客户端执行操作的线程数量
P:并行算法使用的并行数量
N:数组的大小:\**10^(2*k)**,通常来说,**k=1..7\*\* 足够来练习不同的缓存占用。
Q:setter 的操作成本
这个 C/P/N/Q 模型在早期 JDK 8 的 Lambda 开发期间浮出水面,大多数并行的 Stream 操作(**parallelSetAll()**也基本相似)都满足这些结论:\**N*Q\*\*(主要工作量)对于并发性能尤为重要。并行算法在工作量较少时可能实际运行得更慢。
在一些情况下操作竞争如此激烈使得并行毫无帮助,而不管 \**N*Q\*\* 有多大。当**C**很大时,**P**就变得不太相关(内部并行在大量的外部并行面前显得多余)。此外,在一些情况下,并行分解会让相同的**C**个客户端运行得比它们顺序运行代码更慢。
基于这些信息,我们重新运行测试,并在这些测试中使用不同大小的数组(改变**N**):
~~~
// validating/jmh/JMH2.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH2 {
private long[] la;
@Param({
"1",
"10",
"100",
"1000",
"10000",
"100000",
"1000000",
"10000000",
"100000000",
"250000000"
})
int size;
@Setup
public void setup() {
la = new long[size];
}
@Benchmark
public void setAll() {
Arrays.setAll(la, n -> n);
}
@Benchmark
public void parallelSetAll() {
Arrays.parallelSetAll(la, n -> n);
}
}
~~~
**@Param**会自动地将其自身的值注入到变量中。其自身的值必须是字符串类型,并可以转化为适当的类型,在这个例子中是**int**类型。
下面是已经编辑过的结果,包含精确计算出的加速数值:
| JMH2 Benchmark | Size | Score % | Speedup |
| --- | --- | --- | --- |
| **setAll** | 1 | 0.001 | |
| **parallelSetAll** | 1 | 0.036 | 0.028 |
| **setAll** | 10 | 0.005 | |
| **parallelSetAll** | 10 | 3.965 | 0.001 |
| **setAll** | 100 | 0.031 | |
| **parallelSetAll** | 100 | 3.145 | 0.010 |
| **setAll** | 1000 | 0.302 | |
| **parallelSetAll** | 1000 | 3.285 | 0.092 |
| **setAll** | 10000 | 3.152 | |
| **parallelSetAll** | 10000 | 9.669 | 0.326 |
| **setAll** | 100000 | 34.971 | |
| **parallelSetAll** | 100000 | 20.153 | 1.735 |
| **setAll** | 1000000 | 420.581 | |
| **parallelSetAll** | 1000000 | 165.388 | 2.543 |
| **setAll** | 10000000 | 8160.054 | |
| **parallelSetAll** | 10000000 | 7610.190 | 1.072 |
| **setAll** | 100000000 | 79128.752 | |
| **parallelSetAll** | 100000000 | 76734.671 | 1.031 |
| **setAll** | 250000000 | 199552.121 | |
| **parallelSetAll** | 250000000 | 191791.927 | 1.040 |
| 可以看到当数组大小达到 10 万左右时,**parallelSetAll()**开始反超,而后趋于与非并行的运行速度相同。即使它运行速度上胜了,看起来也不足以证明由于并行的存在而使速度变快。 | | | |
**setAll()/parallelSetAll()**中工作的计算量起很大影响吗?在前面的例子中,我们所做的只有对数组的赋值操作,这可能是最简单的任务。所以即使**N**值变大,\**N*Q\*\* 也仍然没有达到巨大,所以看起来像是我们没有为并行提供足够的机会(JMH 提供了一种模拟变量 Q 的途径;如果想了解更多的话,可搜索**Blackhole.consumeCPU**)。
我们通过使方法**f()**中的任务变得更加复杂,从而产生更多的并行机会:
~~~
// validating/jmh/JMH3.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH3 {
private long[] la;
@Param({
"1",
"10",
"100",
"1000",
"10000",
"100000",
"1000000",
"10000000",
"100000000",
"250000000"
})
int size;
@Setup
public void setup() {
la = new long[size];
}
public static long f(long x) {
long quadratic = 42 * x * x + 19 * x + 47;
return Long.divideUnsigned(quadratic, x + 1);
}
@Benchmark
public void setAll() {
Arrays.setAll(la, n -> f(n));
}
@Benchmark
public void parallelSetAll() {
Arrays.parallelSetAll(la, n -> f(n));
}
}
~~~
**f()**方法提供了更加复杂且耗时的操作。现在除了简单的给数组赋值外,**setAll()**和**parallelSetAll()**都有更多的工作去做,这肯定会影响结果。
| JMH2 Benchmark | Size | Score % | Speedup |
| --- | --- | --- | --- |
| **setAll** | 1 | 0.012 | |
| **parallelSetAll** | 1 | 0.047 | 0.255 |
| **setAll** | 10 | 0.107 | |
| **parallelSetAll** | 10 | 3.894 | 0.027 |
| **setAll** | 100 | 0.990 | |
| **parallelSetAll** | 100 | 3.708 | 0.267 |
| **setAll** | 1000 | 133.814 | |
| **parallelSetAll** | 1000 | 11.747 | 11.391 |
| **setAll** | 10000 | 97.954 | |
| **parallelSetAll** | 10000 | 37.259 | 2.629 |
| **setAll** | 100000 | 988.475 | |
| **parallelSetAll** | 100000 | 276.264 | 3.578 |
| **setAll** | 1000000 | 9203.103 | |
| **parallelSetAll** | 1000000 | 2826.974 | 3.255 |
| **setAll** | 10000000 | 92144.951 | |
| **parallelSetAll** | 10000000 | 28126.202 | 3.276 |
| **setAll** | 100000000 | 921701.863 | |
| **parallelSetAll** | 100000000 | 266750.543 | 3.455 |
| **setAll** | 250000000 | 2299127.273 | |
| **parallelSetAll** | 250000000 | 538173.425 | 4.272 |
可以看到当数组的大小达到 1000 左右时,**parallelSetAll()**的运行速度反超了**setAll()\*\*。看来 \*\*parallelSetAll()**严重依赖数组中计算的复杂度。这正是基准测试的价值所在,因为我们已经得到了关于**setAll()**和**parallelSetAll()**间微妙的信息,知道在何时使用它们。
这显然不是从阅读 Javadocs 就能得到的。
大多数时候,JMH 的简单应用会产生好的结果(正如你将在本书后面例子中所见),但是我们从这里知道,你不能一直假定 JMH 会产生好的结果。 JMH 网站上的范例可以帮助你开始。
- 译者的话
- 前言
- 简介
- 第一章 对象的概念
- 抽象
- 接口
- 服务提供
- 封装
- 复用
- 继承
- "是一个"与"像是一个"的关系
- 多态
- 单继承结构
- 集合
- 对象创建与生命周期
- 异常处理
- 本章小结
- 第二章 安装Java和本书用例
- 编辑器
- Shell
- Java安装
- 校验安装
- 安装和运行代码示例
- 第三章 万物皆对象
- 对象操纵
- 对象创建
- 数据存储
- 基本类型的存储
- 高精度数值
- 数组的存储
- 代码注释
- 对象清理
- 作用域
- 对象作用域
- 类的创建
- 类型
- 字段
- 基本类型默认值
- 方法使用
- 返回类型
- 参数列表
- 程序编写
- 命名可见性
- 使用其他组件
- static关键字
- 小试牛刀
- 编译和运行
- 编码风格
- 本章小结
- 第四章 运算符
- 开始使用
- 优先级
- 赋值
- 方法调用中的别名现象
- 算术运算符
- 一元加减运算符
- 递增和递减
- 关系运算符
- 测试对象等价
- 逻辑运算符
- 短路
- 字面值常量
- 下划线
- 指数计数法
- 位运算符
- 移位运算符
- 三元运算符
- 字符串运算符
- 常见陷阱
- 类型转换
- 截断和舍入
- 类型提升
- Java没有sizeof
- 运算符总结
- 本章小结
- 第五章 控制流
- true和false
- if-else
- 迭代语句
- while
- do-while
- for
- 逗号操作符
- for-in 语法
- return
- break 和 continue
- 臭名昭著的 goto
- switch
- switch 字符串
- 本章小结
- 第六章 初始化和清理
- 利用构造器保证初始化
- 方法重载
- 区分重载方法
- 重载与基本类型
- 返回值的重载
- 无参构造器
- this关键字
- 在构造器中调用构造器
- static 的含义
- 垃圾回收器
- finalize()的用途
- 你必须实施清理
- 终结条件
- 垃圾回收器如何工作
- 成员初始化
- 指定初始化
- 构造器初始化
- 初始化的顺序
- 静态数据的初始化
- 显式的静态初始化
- 非静态实例初始化
- 数组初始化
- 动态数组创建
- 可变参数列表
- 枚举类型
- 本章小结
- 第七章 封装
- 包的概念
- 代码组织
- 创建独一无二的包名
- 冲突
- 定制工具库
- 使用 import 改变行为
- 使用包的忠告
- 访问权限修饰符
- 包访问权限
- public: 接口访问权限
- 默认包
- private: 你无法访问
- protected: 继承访问权限
- 包访问权限 Vs Public 构造器
- 接口和实现
- 类访问权限
- 本章小结
- 第八章 复用
- 组合语法
- 继承语法
- 初始化基类
- 带参数的构造函数
- 委托
- 结合组合与继承
- 保证适当的清理
- 名称隐藏
- 组合与继承的选择
- protected
- 向上转型
- 再论组合和继承
- final关键字
- final 数据
- 空白 final
- final 参数
- final 方法
- final 和 private
- final 类
- final 忠告
- 类初始化和加载
- 继承和初始化
- 本章小结
- 第九章 多态
- 向上转型回顾
- 忘掉对象类型
- 转机
- 方法调用绑定
- 产生正确的行为
- 可扩展性
- 陷阱:“重写”私有方法
- 陷阱:属性与静态方法
- 构造器和多态
- 构造器调用顺序
- 继承和清理
- 构造器内部多态方法的行为
- 协变返回类型
- 使用继承设计
- 替代 vs 扩展
- 向下转型与运行时类型信息
- 本章小结
- 第十章 接口
- 抽象类和方法
- 接口创建
- 默认方法
- 多继承
- 接口中的静态方法
- Instrument 作为接口
- 抽象类和接口
- 完全解耦
- 多接口结合
- 使用继承扩展接口
- 结合接口时的命名冲突
- 接口适配
- 接口字段
- 初始化接口中的字段
- 接口嵌套
- 接口和工厂方法模式
- 本章小结
- 第十一章 内部类
- 创建内部类
- 链接外部类
- 使用 .this 和 .new
- 内部类与向上转型
- 内部类方法和作用域
- 匿名内部类
- 嵌套类
- 接口内部的类
- 从多层嵌套类中访问外部类的成员
- 为什么需要内部类
- 闭包与回调
- 内部类与控制框架
- 继承内部类
- 内部类可以被覆盖么?
- 局部内部类
- 内部类标识符
- 本章小结
- 第十二章 集合
- 泛型和类型安全的集合
- 基本概念
- 添加元素组
- 集合的打印
- 迭代器Iterators
- ListIterator
- 链表LinkedList
- 堆栈Stack
- 集合Set
- 映射Map
- 队列Queue
- 优先级队列PriorityQueue
- 集合与迭代器
- for-in和迭代器
- 适配器方法惯用法
- 本章小结
- 简单集合分类
- 第十三章 函数式编程
- 新旧对比
- Lambda表达式
- 递归
- 方法引用
- Runnable接口
- 未绑定的方法引用
- 构造函数引用
- 函数式接口
- 多参数函数式接口
- 缺少基本类型的函数
- 高阶函数
- 闭包
- 作为闭包的内部类
- 函数组合
- 柯里化和部分求值
- 纯函数式编程
- 本章小结
- 第十四章 流式编程
- 流支持
- 流创建
- 随机数流
- int 类型的范围
- generate()
- iterate()
- 流的建造者模式
- Arrays
- 正则表达式
- 中间操作
- 跟踪和调试
- 流元素排序
- 移除元素
- 应用函数到元素
- 在map()中组合流
- Optional类
- 便利函数
- 创建 Optional
- Optional 对象操作
- Optional 流
- 终端操作
- 数组
- 集合
- 组合
- 匹配
- 查找
- 信息
- 数字流信息
- 本章小结
- 第十五章 异常
- 异常概念
- 基本异常
- 异常参数
- 异常捕获
- try 语句块
- 异常处理程序
- 终止与恢复
- 自定义异常
- 异常与记录日志
- 异常声明
- 捕获所有异常
- 多重捕获
- 栈轨迹
- 重新抛出异常
- 精准的重新抛出异常
- 异常链
- Java 标准异常
- 特例:RuntimeException
- 使用 finally 进行清理
- finally 用来做什么?
- 在 return 中使用 finally
- 缺憾:异常丢失
- 异常限制
- 构造器
- Try-With-Resources 用法
- 揭示细节
- 异常匹配
- 其他可选方式
- 历史
- 观点
- 把异常传递给控制台
- 把“被检查的异常”转换为“不检查的异常”
- 异常指南
- 本章小结
- 后记:Exception Bizarro World
- 第十六章 代码校验
- 测试
- 如果没有测试过,它就是不能工作的
- 单元测试
- JUnit
- 测试覆盖率的幻觉
- 前置条件
- 断言(Assertions)
- Java 断言语法
- Guava断言
- 使用断言进行契约式设计
- 检查指令
- 前置条件
- 后置条件
- 不变性
- 放松 DbC 检查或非严格的 DbC
- DbC + 单元测试
- 使用Guava前置条件
- 测试驱动开发
- 测试驱动 vs. 测试优先
- 日志
- 日志会给出正在运行的程序的各种信息
- 日志等级
- 调试
- 使用 JDB 调试
- 图形化调试器
- 基准测试
- 微基准测试
- JMH 的引入
- 剖析和优化
- 优化准则
- 风格检测
- 静态错误分析
- 代码重审
- 结对编程
- 重构
- 重构基石
- 持续集成
- 本章小结
- 第十七章 文件
- 文件和目录路径
- 选取路径部分片段
- 路径分析
- Paths的增减修改
- 目录
- 文件系统
- 路径监听
- 文件查找
- 文件读写
- 本章小结
- 第十八章 字符串
- 字符串的不可变
- +的重载与StringBuilder
- 意外递归
- 字符串操作
- 格式化输出
- printf()
- System.out.format()
- Formatter类
- 格式化修饰符
- Formatter转换
- String.format()
- 一个十六进制转储(dump)工具
- 正则表达式
- 基础
- 创建正则表达式
- 量词
- CharSequence
- Pattern和Matcher
- find()
- 组(Groups)
- start()和end()
- Pattern标记
- split()
- 替换操作
- 正则表达式与 Java I/O
- 扫描输入
- Scanner分隔符
- 用正则表达式扫描
- StringTokenizer类
- 本章小结
- 第十九章 类型信息
- 为什么需要 RTTI
- Class对象
- 类字面常量
- 泛化的Class引用
- cast()方法
- 类型转换检测
- 使用类字面量
- 递归计数
- 一个动态instanceof函数
- 注册工厂
- 类的等价比较
- 反射:运行时类信息
- 类方法提取器
- 动态代理
- Optional类
- 标记接口
- Mock 对象和桩
- 接口和类型
- 本章小结
- 第二十章 泛型
- 简单泛型
- 泛型接口
- 泛型方法
- 复杂模型构建
- 泛型擦除
- 补偿擦除
- 边界
- 通配符
- 问题
- 自限定的类型
- 动态类型安全
- 泛型异常
- 混型
- 潜在类型机制
- 对缺乏潜在类型机制的补偿
- Java8 中的辅助潜在类型
- 总结:类型转换真的如此之糟吗?
- 进阶阅读
- 第二十一章 数组
- 数组特性
- 一等对象
- 返回数组
- 多维数组
- 泛型数组
- Arrays的fill方法
- Arrays的setAll方法
- 增量生成
- 随机生成
- 泛型和基本数组
- 数组元素修改
- 数组并行
- Arrays工具类
- 数组比较
- 数组拷贝
- 流和数组
- 数组排序
- Arrays.sort()的使用
- 并行排序
- binarySearch二分查找
- parallelPrefix并行前缀
- 本章小结
- 第二十二章 枚举
- 基本 enum 特性
- 将静态类型导入用于 enum
- 方法添加
- 覆盖 enum 的方法
- switch 语句中的 enum
- values 方法的神秘之处
- 实现而非继承
- 随机选择
- 使用接口组织枚举
- 使用 EnumSet 替代 Flags
- 使用 EnumMap
- 常量特定方法
- 使用 enum 的职责链
- 使用 enum 的状态机
- 多路分发
- 使用 enum 分发
- 使用常量相关的方法
- 使用 EnumMap 进行分发
- 使用二维数组
- 本章小结
- 第二十三章 注解
- 基本语法
- 定义注解
- 元注解
- 编写注解处理器
- 注解元素
- 默认值限制
- 替代方案
- 注解不支持继承
- 实现处理器
- 使用javac处理注解
- 最简单的处理器
- 更复杂的处理器
- 基于注解的单元测试
- 在 @Unit 中使用泛型
- 实现 @Unit
- 本章小结
- 第二十四章 并发编程
- 术语问题
- 并发的新定义
- 并发的超能力
- 并发为速度而生
- 四句格言
- 1.不要这样做
- 2.没有什么是真的,一切可能都有问题
- 3.它起作用,并不意味着它没有问题
- 4.你必须仍然理解
- 残酷的真相
- 本章其余部分
- 并行流
- 创建和运行任务
- 终止耗时任务
- CompletableFuture类
- 基本用法
- 结合 CompletableFuture
- 模拟
- 异常
- 流异常(Stream Exception)
- 检查性异常
- 死锁
- 构造方法非线程安全
- 复杂性和代价
- 本章小结
- 缺点
- 共享内存陷阱
- This Albatross is Big
- 其他类库
- 考虑为并发设计的语言
- 拓展阅读
- 第二十五章 设计模式
- 概念
- 单例模式
- 模式分类
- 构建应用程序框架
- 面向实现
- 工厂模式
- 动态工厂
- 多态工厂
- 抽象工厂
- 函数对象
- 命令模式
- 策略模式
- 责任链模式
- 改变接口
- 适配器模式(Adapter)
- 外观模式(Façade)
- 包(Package)作为外观模式的变体
- 解释器:运行时的弹性
- 回调
- 多次调度
- 模式重构
- 抽象用法
- 多次派遣
- 访问者模式
- RTTI的优劣
- 本章小结
- 附录:补充
- 附录:编程指南
- 附录:文档注释
- 附录:对象传递和返回
- 附录:流式IO
- 输入流类型
- 输出流类型
- 添加属性和有用的接口
- 通过FilterInputStream 从 InputStream 读取
- 通过 FilterOutputStream 向 OutputStream 写入
- Reader和Writer
- 数据的来源和去处
- 更改流的行为
- 未发生改变的类
- RandomAccessFile类
- IO流典型用途
- 缓冲输入文件
- 从内存输入
- 格式化内存输入
- 基本文件的输出
- 文本文件输出快捷方式
- 存储和恢复数据
- 读写随机访问文件
- 本章小结
- 附录:标准IO
- 附录:新IO
- ByteBuffer
- 数据转换
- 基本类型获取
- 视图缓冲区
- 字节存储次序
- 缓冲区数据操作
- 缓冲区细节
- 内存映射文件
- 性能
- 文件锁定
- 映射文件的部分锁定
- 附录:理解equals和hashCode方法
- 附录:集合主题
- 附录:并发底层原理
- 附录:数据压缩
- 附录:对象序列化
- 附录:静态语言类型检查
- 附录:C++和Java的优良传统
- 附录:成为一名程序员