我书架上那本《Java核心技术 卷一》被翻得最烂的是第 4 章和第 9 章,书脊都快散了,而第 10、11 章讲图形界面的部分几乎是全新的。这个细节基本能说明一个问题:这本书的价值不在于“有没有”,而在于“读了哪几章、动手验了哪几段”。这些年陆续有刚入行的朋友问我,Java 基础到底该怎么补,市面上资料那么多,为什么还要回头看这本厚厚的纸砖头。我的回答一直是同一句:如果你想系统地把 Java 基础、面向对象、集合框架、泛型、异常处理这些概念一次性捋顺,并且希望每个结论都能落到能跑的代码上,那这本书仍然是性价比很高的选择。它不像速成教程那样只告诉你“这么写就对了”,而是会花篇幅解释“为什么这么设计、不这么写会踩什么坑”。这篇文章我就按自己带人、自己复盘的经验,把这本书的阅读顺序、核心难点、实操验证方法以及常见翻车点完整拆一遍,适合刚学完语法想进阶的人,也适合工作两三年、发现基础有空洞想回头补的人。
1. 先把这本书拆开看:它到底在讲什么
很多人拿到书的第一反应是从第一页开始按顺序读,读到第 3 章讲运算符优先级就开始犯困,然后这本书就永久停在第三章。这不是毅力问题,是阅读策略问题。这本书的章节编排是“由语言到工具再到应用”的线性结构,但人的学习效率并不是线性的,尤其是已经有部分基础的人。
1.1 卷一的章节骨架与难度曲线
按我的理解,卷一可以粗暴地分成四块:语言地基(基本语法、对象与类、继承)、表达能力扩展(接口、lambda、内部类)、工程化能力(异常、断言、日志、泛型、集合)、以及界面与并发两个相对独立的专题。
| 板块 | 大致章节 | 难度 | 是否必须精读 | 我的建议投入 |
|---|---|---|---|---|
| 语言地基 | 基本结构、对象与类、继承 | 低到中 | 精读 | 40% 时间 |
| 表达能力 | 接口、lambda、内部类 | 中 | 精读 | 20% 时间 |
| 工程化能力 | 异常、泛型、集合 | 中到高 | 精读并逐段验证 | 30% 时间 |
| 专题 | 图形界面、并发 | 高 | 选读,按需 | 10% 时间 |
难度曲线不是平滑上升的,而是在泛型和集合这两章陡然拔高,然后在并发章节再上一个台阶。图形界面那两章属于典型的“时代产物”,今天的桌面客户端需求已经很少,除非你要维护老项目,否则快速翻一遍知道有这么回事就行,不必逐行敲示例。
真正的分水岭在集合那一章。前面的内容靠“看懂”基本能糊弄过去,集合不行——不理解哈希、不理解扩容、不理解迭代器失效,代码跑起来就是偶发的诡异 bug,而且这类 bug 往往在你最不想它出现的时候出现。
1.2 三类读者的不同切入顺序
第一类是零基础或者只在学校学过一学期 Java 的人。我的建议是老老实实从基本程序设计结构读到继承,但读法要改:每读完一小节立刻合上书,把书上的例子凭记忆重写一遍,写不出来再翻。这个动作看着笨,但它是把“阅读记忆”转成“肌肉记忆”的唯一低成本方式。
第二类是有其他语言基础、需要快速迁移到 Java 的人。可以跳过前面三章的语法细节,直接看第 4 章对象与类,重点盯住 Java 和原来那门语言不一样的地方:值传递的语义、对象相等性的判定、字符串不可变、数组协变带来的运行时检查。这些差异点才是你未来写 bug 的高发区。
第三类是工作几年、感觉基础不牢的人。这类人最适合的读法是“倒着读”:先翻集合和泛型两章,遇到不懂的概念再往前回溯。因为你的痛点通常不是不会写 for 循环,而是不知道为什么 HashMap 的 key 要用不可变对象、不知道为什么泛型不能 new 数组、不知道为什么重写 equals 必须重写 hashCode。带着具体困惑回去查,吸收率比顺序读高得多。
提示:不管你属于哪一类,都不要把这本书当小说读。它的定位是“工具书加教材”的混合体,正确姿势是读一段、停一下、写一版、跑一遍。
2. 地基章节怎么啃:语法、对象与继承
2.1 基本程序设计结构:别急着跳过的那三章
很多人觉得基本语法太简单,扫一眼就翻过去了。我见过太多工作两三年的人在这些“简单”地方翻车:浮点数用 == 比较、字符串拼接在循环里用加号、数组越界前不做长度判断、switch 忘了 break 导致穿透。
书里讲数值类型那一节有个细节值得单独拎出来:浮点数不能用 == 判断相等,因为二进制无法精确表示大部分十进制小数。这个道理人人都懂,但真正写代码时还是会有人写if (a == 0.1)。正确做法是用一个误差范围做比较,或者干脆用BigDecimal。而BigDecimal又有坑:用 double 构造会带入精度误差,必须用字符串构造。这一点书里写得很清楚,但很多人只看结论不看原因,结果该错还是错。
// 错误示范:double 构造会把误差带进来 BigDecimal wrong = new BigDecimal(0.1); // 正确做法:用字符串构造 BigDecimal right = new BigDecimal("0.1"); // 除法必须显式指定精度和舍入模式,否则除不尽直接抛异常 BigDecimal result = right.divide(new BigDecimal("3"), 4, RoundingMode.HALF_UP);再说字符串。书里明确讲了String是不可变对象,每次用加号拼接都会产生新对象。单次拼接无所谓,循环里拼接就是灾难。我给人的建议是:循环内拼接一律用StringBuilder,而且如果有大致长度预期,构造时就把容量传进去,默认容量是 16,超过就扩容并复制,元素多的时候这个复制开销非常可观。
// 预计拼一千个片段,先给个够用的容量,减少扩容复制 StringBuilder sb = new StringBuilder(1024); for (int i = 0; i < 1000; i++) { sb.append(i).append(','); }还有一点常被忽略:String的+拼接在编译期对常量是会被优化的,但如果参与拼接的是变量,编译器会转成StringBuilder。所以“用加号一定慢”这个说法不严谨,准确说法是“在循环里用加号一定慢”。这类细微差别,恰恰是这本书区别于快餐教程的地方。
2.2 对象与类:equals、hashCode、构造器与封装
第 4 章是我认为全书最该反复读的一章。它讲的不只是“怎么定义一个类”,而是“一个对象在 Java 世界里应该遵守哪些契约”。
第一个契约是相等性。书里花了不少篇幅讲equals方法,核心结论是:重写equals就必须重写hashCode,否则对象放进HashMap或HashSet会出现“明明相等却找不到”的诡异现象。原理不复杂:哈希容器先用hashCode定位桶,再用equals在桶内比较。两个对象equals为真但hashCode不同,就会落到不同的桶里,自然找不到。
写一个正确的equals有一套固定套路,我照着书上的思路整理成下面这样,基本可以当模板用:
public final class Money { private final long cents; private final String currency; public Money(long cents, String currency) { this.cents = cents; this.currency = currency; } @Override public boolean equals(Object other) { // 1. 引用相同直接返回 if (this == other) return true; // 2. null 直接返回 false if (other == null) return false; // 3. 类型不一致返回 false。这里用 getClass 而不是 instanceof, // 是为了保证子类不会和父类互相相等,避免对称性被破坏 if (getClass() != other.getClass()) return false; Money that = (Money) other; // 4. 基本类型用 ==,对象用 Objects.equals return cents == that.cents && Objects.equals(currency, that.currency); } @Override public int hashCode() { return Objects.hash(cents, currency); } }这里有两个容易写错的点。一是用getClass还是instanceof。如果你希望父类和子类可以互相相等,用instanceof;但那样会破坏对称性——父类认为和子类相等,子类可能认为和父类不相等。书里倾向的做法是用getClass,语义更干净。二是hashCode不要手写一堆乘法加法,Objects.hash已经帮你处理了 null 和组合问题,除非你有极致的性能诉求。
第二个契约是构造器与封装。书里强调了一点:不要返回可变对象的引用。比如一个类内部有个Date字段,getter直接返回这个Date,外部拿到后一改,你内部状态就变了。正确做法是返回副本,或者用不可变类型替代。
public Date getHiredate() { return (Date) hireDay.clone(); }这条经验非常实用。后来 Java 8 引入java.time包之后,更好的做法是直接用LocalDate这类不可变类型,从源头上消灭这个问题。
2.3 继承与多态:从语法到设计取舍
继承这一章,语法部分半小时能看完,真正值得琢磨的是“什么时候该用继承、什么时候该用组合”。书里给的判断标准我至今在用:只有当子类确实是父类的一种、并且你希望子类能替换父类使用时,才考虑继承;如果只是想复用代码,用组合。
举个常见的反面例子。有人写了一个UserService,为了复用几个工具方法,让它继承BaseService。这就是典型的“为复用而继承”。一旦后面BaseService加了个方法或者改了某个受保护字段的语义,所有子类都被牵连。组合的写法是持有一个BaseService的实例,需要什么调什么,耦合面小得多。
多态部分要重点理解“编译期类型”和“运行期类型”的区别。变量声明的类型决定你能调哪些方法,对象的实际类型决定调用哪个实现。这个规则解释了很多“为什么编译不过”和“为什么调的不是我写的那个方法”的问题。
还有一个必须提的点:final、static、private三种方法不参与多态。书中专门讲了静态绑定和动态绑定的区别。如果你在父类构造器里调用一个可被子类重写的方法,会踩到一个非常隐蔽的坑:此时子类字段还没初始化,方法里访问子类字段拿到的是默认值。这个坑我在实际项目里真见过,表现为某个配置字段莫名是 0,排查了大半天。
注意:构造函数里尽量不要调用可被重写的方法。如果一定要调,把方法声明成
final或者private。
3. 进阶三件套:接口、lambda、异常与泛型
3.1 接口与lambda:函数式写法的边界
接口这一章从传统接口讲到了默认方法,再过渡到 lambda,逻辑链条很顺。我建议读的时候关注三个问题:接口和抽象类怎么选、默认方法解决了什么问题、lambda 到底捕获了什么。
接口和抽象类的选择,书里给的判断很干脆:接口描述“能做什么”,抽象类描述“是什么”。一个类只能继承一个抽象类,但可以实现多个接口,所以当你需要多重能力组合时,接口是唯一选择。Java 8 之后接口可以有默认方法,这条界限变得更模糊了,但基本原则没变:接口不该持有状态。
默认方法的引入初衷是为了在不破坏已有实现类的前提下给接口加方法。这个设计很聪明,但也有副作用:如果一个类同时实现两个接口,而两个接口都有同签名的默认方法,编译器会强制你显式覆盖并指定用哪一个。
interface A { default String name() { return "A"; } } interface B { default String name() { return "B"; } } class C implements A, B { @Override public String name() { // 必须显式指定,否则编译不过 return A.super.name() + B.super.name(); } }lambda 部分要理解的是“捕获”。lambda 里引用的外部局部变量必须是 effectively final,也就是初始化后不再改变。原因是 lambda 可能在一个完全不同的线程、完全不同的时间点执行,如果允许改变捕获变量,语义就没法保证了。这个限制初看很烦,但理解原因后你会发现它其实是在保护你。
还有一个实践建议:lambda 适合写短小的行为,超过五六行就该抽成方法再用方法引用。我见过太多人把一个几十行的逻辑塞进stream().map()里,调试的时候栈信息完全没法看。
3.2 异常与日志:工程化的分水岭
异常这一章是很多人从“会写代码”到“能写可维护代码”的分界线。书里把异常分成受检异常和非受检异常,并且给了使用建议。我的总结是:受检异常用于“调用方有能力恢复”的场景,非受检异常用于“程序写错了”的场景。
最常见的错误写法是捕获了异常什么都不做:
try { doSomething(); } catch (Exception e) { // 空着,或者只打印一句 }这种写法把问题隐藏了,等到线上出故障,日志里什么都没有,排查成本成倍增加。正确做法是要么处理,要么包装后往上抛,要么至少完整记录上下文。
try { doSomething(userId, orderId); } catch (IOException e) { // 包装时保留原始异常,别把 cause 丢了 throw new BizException("处理订单失败, userId=" + userId + ", orderId=" + orderId, e); }try-with-resources是书里重点推荐的写法,凡是实现了AutoCloseable的资源都应该用它,别手写finally里 close。手写的问题在于,如果 try 块和 close 都抛异常,close 的异常会覆盖掉原始异常,真正的错误信息就丢了。try-with-resources会把 close 的异常作为 suppressed 挂上去,原始异常保留。
日志部分书里讲得比较基础,但有个原则值得强调:日志用占位符,不要用字符串拼接。因为日志级别不满足时,拼接的字符串白拼了,白白消耗性能。而且占位符写法在记录异常时更清晰。
// 推荐 log.info("用户 {} 下单 {} 件, 金额 {}", userId, count, amount, e); // 不推荐:无论级别是否开启都会执行拼接 log.info("用户 " + userId + " 下单 " + count + " 件");3.3 泛型与集合:擦除机制下的正确用法
泛型是全书公认的难点。要理解泛型,先要接受一个事实:Java 的泛型是编译期概念,运行期会被擦除。List<String>和List<Integer>在运行时是同一个类。这解释了一堆“看起来很奇怪”的限制。
第一个限制是不能用基本类型做类型参数,只能写List<Integer>不能写List<int>。原因是擦除后类型参数会变成Object,而基本类型不是对象。自动装箱让你感觉不到这个差异,但性能敏感的地方要注意。
第二个限制是不能创建泛型数组,new T[10]编译不过。原因是数组在运行时知道自己的元素类型,而泛型被擦除了,两者冲突。变通做法是用ArrayList或者反射创建。
第三个限制是静态成员不能使用类的类型参数。因为静态成员属于类本身,而类型参数属于实例。
通配符部分,书里讲的 PECS 原则非常实用:Producer Extends, Consumer Super。如果一个容器只往外读数据,用? extends T;如果只往里写数据,用? super T;既读又写就别用通配符。
// 只读:可以安全地取出 Number void printAll(List<? extends Number> list) { for (Number n : list) System.out.println(n); } // 只写:可以安全地放入 Integer void fill(List<? super Integer> list) { list.add(1); }集合章节和泛型是连在一起的。书里把集合分成几大类:List 有序可重复、Set 不可重复、Map 键值对、Queue 队列。选型的核心是搞清楚你的访问模式:是按下标随机访问、还是频繁在头部插入、还是需要去重、还是需要排序。
ArrayList和LinkedList的选择是个经典问题。很多老资料会说 LinkedList 插入快,其实这个结论有前提。LinkedList 插入本身是 O(1),但找到插入位置是 O(n),而且每个节点都有额外对象开销,缓存不友好。实际测试下来,绝大多数场景 ArrayList 更快。我自己的经验是:除非你确实需要在头部频繁插入删除,否则默认用 ArrayList。
4. 把知识跑起来:三个可直接复现的实操
光学不练,过两周就忘。这一节我把自己带新人时最常用的三个练习整理出来,都能在半小时内跑完,但能把书里几章的核心知识串起来。
4.1 实操一:一个值对象类的完整实现与自测
目标:写一个不可变的值对象,正确实现equals、hashCode、toString,并验证它能正确放进HashSet去重。
import java.util.*; public final class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Point p = (Point) o; return x == p.x && y == p.y; } @Override public int hashCode() { return Objects.hash(x, y); } @Override public String toString() { return "Point(" + x + ", " + y + ")"; } public static void main(String[] args) { Set<Point> set = new HashSet<>(); set.add(new Point(1, 2)); set.add(new Point(1, 2)); set.add(new Point(2, 1)); // 期望输出 2,验证去重生效 System.out.println("size = " + set.size()); System.out.println(set.contains(new Point(1, 2))); // 期望 true } }做完这个练习后,把hashCode方法注释掉再跑一次,你会看到size变成 3。这个实验比看十遍文字解释都管用,因为它让你亲眼看到契约被破坏的后果。
4.2 实操二:集合容量规划与性能对比
目标:验证ArrayList和HashMap的扩容行为,理解初始容量的影响。
ArrayList默认容量是 10,但首次添加元素时才真正分配。扩容时新容量是旧容量的 1.5 倍左右,具体算法是oldCapacity + (oldCapacity >> 1),然后复制整个数组。所以如果你明确知道要放一万个元素,直接指定容量可以省掉十几次扩容复制。
HashMap默认初始容量是 16,负载因子是 0.75,阈值是 12。也就是说存入第 13 个元素时触发扩容,容量翻倍到 32,阈值变成 24。这里有个关键计算:如果你预估要存 n 个元素,初始容量应该设成多少?
需要容量 = n / 0.75 + 1 然后向上取到最近的 2 的幂举例:预计存 1000 个键值对。1000 / 0.75 ≈ 1333,最近的 2 的幂是 2048。所以new HashMap<>(2048)可以做到全程不扩容。如果你图省事写new HashMap<>(1000),实际容量会被调整到 1024,存到第 769 个(1024 * 0.75)就要扩容一次,白白多一次全量 rehash。
| 集合 | 默认初始容量 | 扩容规则 | 预留容量算法 |
|---|---|---|---|
| ArrayList | 10(首次添加时分配) | 约 1.5 倍 | 直接设预估元素数 |
| HashMap | 16 | 2 倍 | n / 0.75 + 1 后取 2 的幂 |
| HashSet | 内部用 HashMap | 2 倍 | 同上 |
| StringBuilder | 16 | 2 倍 + 2 | 直接设预估字符数 |
写一段对照实验:分别用默认容量和预设容量往ArrayList里加一百万条数据,用System.nanoTime()记录耗时,重复五轮取中位数。你会发现差距在不同 JDK 版本上大小不一,但预设容量那一组通常稳定更快。这个实验的目的不是让你记住某个具体数字,而是建立“容量可规划”的意识。
4.3 实操三:线程池参数计算与观察
并发章节在卷一里分量不轻,但很多人看完只会背“核心线程数、最大线程数、队列”,不知道这些数字怎么定。书里给了思路,我结合经验补充计算过程。
先看任务类型。CPU 密集型任务,线程大部分时间在计算,线程数设成 CPU 核数加一是常见做法,加的那一是为了在偶发缺页中断时顶上。IO 密集型任务,线程大量时间在等待,线程数可以远超核数。
精确一点的估算公式:
线程数 = CPU 核数 * 目标利用率 * (1 + 等待时间 / 计算时间)举个例子,8 核机器,任务里有 80% 的时间在等数据库返回,20% 在计算,目标利用率按 1 算:
线程数 = 8 * 1 * (1 + 0.8 / 0.2) = 8 * 5 = 40再确定队列长度。队列太长会导致任务堆积、响应变慢,队列太短会导致频繁触发拒绝策略。我的经验是把队列长度设成能容纳 1 到 2 秒的正常流量,然后配合有界队列和明确的拒绝策略,宁可快速失败也不要无限堆积。
int cores = Runtime.getRuntime().availableProcessors(); // IO 密集任务,按等待占比 4 倍估算 int poolSize = cores * 5; ThreadPoolExecutor executor = new ThreadPoolExecutor( poolSize, // 核心线程数 poolSize, // 最大线程数,和核心保持一致避免频繁创建销毁 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(1000), // 有界队列,防止无限制堆积 new ThreadPoolExecutor.AbortPolicy() // 满了直接拒绝,快速暴露问题 );跑起来之后,用executor.getActiveCount()、getQueue().size()、getCompletedTaskCount()周期性打印监控数据,观察实际并发度和队列深度,再回来调整参数。这一步很重要,公式只是起点,真实负载只有跑起来才知道。
注意:不要用
Executors.newFixedThreadPool()这类工厂方法直接上线,它们的队列是无界的,任务堆积时可能把内存吃光。手动new ThreadPoolExecutor虽然啰嗦,但参数可见可控。
5. 学习节奏与练习设计
5.1 按周拆任务:一份可执行的路线
我把这本书的精华内容压缩成 8 周路线,每周投入 6 到 8 小时,适合下班后推进。这个节奏的前提是你已经有基本编程概念,完全零基础的话把周期拉到 12 周。
| 周次 | 内容 | 产出物 |
|---|---|---|
| 第 1 周 | 基本语法、字符串、数组 | 20 道小题,覆盖数值精度和字符串拼接 |
| 第 2 周 | 对象与类、封装、构造器 | 一个不可变值对象类,带完整 equals/hashCode |
| 第 3 周 | 继承、多态、抽象类 | 一个三层继承的小型示例,验证动态绑定 |
| 第 4 周 | 接口、lambda、内部类 | 用 lambda 改写三个匿名内部类 |
| 第 5 周 | 异常、断言、日志 | 给已有代码加上分层异常和日志 |
| 第 6 周 | 泛型、通配符 | 写一个泛型工具类,验证 PECS |
| 第 7 周 | 集合框架 | 完成容量规划和性能对照实验 |
| 第 8 周 | 并发基础 | 写一个带监控的线程池 Demo |
关键不是速度,而是每周必须有产出物。产出物不一定要多漂亮,但必须是能跑的代码。我见过太多人笔记记了几十页,一让写代码就卡壳,问题就出在没有产出物这个环节。
5.2 笔记与代码回读机制
我的笔记方法是“三栏法”:左边抄书上的结论,中间写自己的理解,右边写一个反例。比如左边写“重写 equals 必须重写 hashCode”,中间写“因为哈希容器先用 hashCode 定位桶”,右边写“只重写 equals 时 HashSet 去重失效,size 变成 3”。反例这一栏是最有价值的,因为它是你未来排查问题的索引。
代码回读指的是隔一周把自己上周写的代码重新读一遍,重点看三件事:有没有可以简化的地方、有没有遗漏的边界条件、命名是否还能更清楚。这个动作成本很低,但提升很明显。我自己很多设计上的改进都是从回读旧代码里来的。
另一个技巧是给代码写“反向注释”。不是解释代码在做什么,而是记录“为什么不用另一种写法”。比如在ArrayList的初始化处写一句“预估 5000 条,避免多次扩容”,半年后你或者你的同事看到这行注释,就不会手贱把它改成默认构造。
6. 常见问题与排查技巧实录
6.1 环境类问题速查
环境问题占了新手求助的一大半,而且症状往往很迷惑。整理成表格方便对照。
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 编译报“找不到符号” | 类路径没包含依赖,或包名拼错 | 检查 import 和构建配置 |
| 运行报 NoClassDefFoundError | 编译时有、运行时缺 | 检查运行时类路径 |
| 中文输出乱码 | 源文件编码与编译编码不一致 | 统一成 UTF-8,编译参数显式指定 |
| 版本相关的语法报错 | JDK 版本低于代码所用特性 | 确认编译和运行是同一版本 |
| 改了代码没生效 | 跑的是旧的 class 文件 | 清理输出目录重新构建 |
编码问题值得单独说。跨平台开发时,如果源文件是 UTF-8 而编译时没指定编码,某些环境会按系统默认编码解析,中文注释和字符串就可能变成乱码。稳妥做法是在构建配置里显式写明源文件编码,不要依赖默认值。
6.2 语言类问题速查
| 现象 | 根因 | 正确做法 |
|---|---|---|
| 两个内容相同的对象在 HashSet 里都存进去了 | 只重写了 equals 没重写 hashCode | 两个一起重写 |
| 循环里拼接字符串特别慢 | 每次加号都创建新对象 | 改用 StringBuilder |
| 金额计算出小数误差 | 用 double 做货币运算 | 改用 BigDecimal 加字符串构造 |
| 遍历集合时删除元素抛异常 | 用了增强 for 循环直接删除 | 用迭代器的 remove 方法 |
| 泛型方法编译不过 | 试图 new 一个类型参数数组 | 用集合或反射绕开 |
遍历删除这个坑几乎每个人都踩过。增强 for 循环底层是迭代器,你在循环体里调集合的 remove,迭代器就不知道结构变了,下一次 next 时抛并发修改异常。正确写法是用迭代器自己的 remove。
Iterator<String> it = list.iterator(); while (it.hasNext()) { if (it.next().isEmpty()) { it.remove(); // 正确:用迭代器删除 } }Java 8 之后也可以用removeIf,一行搞定,语义还更清楚。
6.3 调试与定位的土办法
有些问题不是靠读代码能看出来的,得靠观察。我常用的几个土办法:
第一,怀疑相等性时,直接把两个对象的hashCode和equals结果打印出来,别靠脑补。第二,怀疑并发问题时,先把线程数降到 1 看问题是否消失,如果消失基本就是并发问题。第三,怀疑集合问题时,把集合内容整个打印出来,看看是不是有你没预期到的元素。第四,怀疑性能时,用最朴素的计时打点,先定位到哪一段慢,再考虑用工具做深入分析。
还有一条经验:遇到“偶发”的问题,先别急着改代码,先想办法让它稳定复现。复现不了的问题,改了也不知道有没有改对。加日志、加断言、缩小输入范围,都是让它复现的手段。
7. 我自己的几点体会
这本书我前后翻过四遍,每一遍的关注点都不一样。第一遍在学生时代,关注的是语法怎么写;第二遍刚工作,关注的是异常和集合怎么用;第三遍带新人,关注的是怎么把概念讲清楚;第四遍是最近,关注的是那些当年一扫而过、现在才发现有深意的设计取舍,比如为什么String要设计成不可变、为什么泛型要擦除、为什么集合框架的接口要拆得这么细。
一个很实在的建议:别追求一次读懂。这本书里有些段落,你只有先在实际项目里踩过对应的坑,回头再看才会有“原来是这个意思”的感觉。所以读不懂的地方做个标记跳过,不影响继续往下走,等遇到相关问题时再回来查。
另一个建议是把它当参考书而不是小说。目录要熟,知道哪个知识点大概在哪一章,遇到问题时能快速定位。我现在基本不从头读了,都是带着具体问题去翻对应的那几页,翻完顺手把那一节的示例敲一遍,验证一下自己的理解有没有偏差。
最后说一句关于版本的话。这本书不同版本之间差异不小,尤其是 Java 8 之后 lambda 和流的部分被大幅重写。如果你手上的版本还在用匿名内部类讲回调,说明版本偏老,建议找一本覆盖 lambda 的版本,否则你会错过现代 Java 最常用的写法。至于图形界面那两章,除非你的工作确实涉及桌面应用,否则快速翻过就行,把时间留给集合和并发,那才是日常写代码真正用得上的部分。