异常处理的核心是“把失败交代给能处理它的人”,并发正确的核心是“把共享状态的修改收进最小临界区”。这两件事的共同点是:出了问题必须留下线索,而不是让它悄悄过去。
一、异常体系与捕获
Throwable 之下分两支:Error 是 JVM 层面的严重问题(OutOfMemoryError、StackOverflowError),程序员无法处理;Exception 是程序可能处理的失败,又分两支:
| 分类 | 编译期 | 常见例子 | 含义 |
|---|---|---|---|
Error |
不强制 | OutOfMemoryError、StackOverflowError |
严重系统问题 |
| 受检异常 | 强制处理或声明 | IOException、InterruptedException、SQLException |
外部环境或可恢复问题 |
| 运行时异常 | 不强制 | NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException |
编程或逻辑错误 |
throw 制造并抛出一个异常对象,出现在方法体里;throws 在方法签名上公布“这个方法可能失败”,不制造异常。受检异常一出现,调用方只剩两个选择:处理掉,或者在签名上继续声明。
static void loadReport() throws IOException { } // 公布风险catch 的匹配规则很简单:类型必须等于实际异常类型或它是父类型。所以下面四个都能接住数组越界:
catch (ArrayIndexOutOfBoundsException e)
catch (IndexOutOfBoundsException e)
catch (RuntimeException e)
catch (Exception e)而 catch (NullPointerException e) 接不住数组越界——两者没有父子关系。顺序上具体的在前、宽泛的在最后,因为一旦 catch (Exception) 在前面,后面的具体分支就永远匹配不到,编译器会直接报错。
try {
work();
} catch (NullPointerException e) {
recoverFromMissingValue(e);
} catch (IndexOutOfBoundsException e) {
shrinkInput(e);
} catch (IOException | SQLException e) { // 多重捕获
log.error("operation failed", e);
}自定义异常按调用方能不能处理来选父类:
class CurrenciesDoNotMatchException extends RuntimeException {
CurrenciesDoNotMatchException(String message) { super(message); }
}继承 Exception 就是受检异常,调用方必须面对;继承 RuntimeException 则不必在签名上声明。选择标准是“调用方看到这种失败后有没有办法响应”。
提示:异常不处理会自动沿调用链向上传播,最终从线程里逃出去。处理到哪一层为止,取决于哪一层最有能力恢复——不一定是最初的抛出点。
二、异常要交代给谁
一段异常信息要同时服务三类人,口径各不相同:
| 面向 | 应该看到 |
|---|---|
| 最终用户 | 友好提示 + 下一步该做什么,不含堆栈 |
| 开发与支持团队 | 完整堆栈、业务上下文、原始原因 |
| 调用方 | 能判断能否恢复、重试或转换的异常类型 |
这解释了几条看似随意的规则:
不要吞异常。 空 catch 不会让错误消失,只会让它失去线索,然后在一个更远、更难查的位置以奇怪的症状冒出来。合法处理只有三种:真正恢复、记录完整信息后继续抛出、转换成更符合本层语义的异常并保留原因。
堆栈进日志,不进用户界面。 常见做法是生成一个唯一错误 ID:用户报这个 ID,支持团队直接在日志里定位到同一次失败。
异常不该拿来当正常分支。 反复用异常控制“输入是不是数字”这种正常校验,代价明显高于直接判断:
// 不推荐:创建、匹配、传播异常都有开销
while (true) {
try { int value = Integer.parseInt(input); break; }
catch (NumberFormatException e) { input = readAgain(); }
}
// 推荐:先判断再解析
while (true) {
if (isNumeric(input)) { int value = Integer.parseInt(input); break; }
input = readAgain();
}能恢复才捕获。 当前层能换备用数据源、能重试、能给出用户看得懂的结果,就地处理;只是想在消息里补点上下文,那就包装后继续抛。
设置全局边界。 命令行程序在 main 外层兜底,Web 应用交给框架的全局异常处理,统一负责记录完整信息并转成合适的错误响应,而不是让原始堆栈直接暴露出去。
三、资源管理与异常链
finally 存在的理由很实际:如果中途抛异常,手写的清理语句根本不会执行。
Scanner scanner = new Scanner(System.in);
int[] numbers = {1, 2, 3};
int value = numbers[5]; // 抛异常
scanner.close(); // 这一行永远到不了Scanner scanner = null;
try {
scanner = new Scanner(System.in);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (scanner != null) scanner.close(); // 连构造失败的情况也要照顾
}合法组合只有三种:try-catch、try-finally、try-catch-finally;光写 try 编译不过。finally 会在正常结束、抛异常、以及 try 中 return 时执行,但有两个例外:finally 内部自己抛异常会中断后续清理;System.exit() 直接终止进程,没有机会进入 finally。
警告:不要在
finally里写return,它会吞掉try的返回值和待传播的异常,是最隐蔽的 bug 来源之一。
这些样板代码可以由编译器接管——try-with-resources 要求资源实现 AutoCloseable,结束时自动 close(),逆序关闭,且不强制 catch:
try (var a = new Gate("A"); var b = new Gate("B")) { ... }
// 关闭顺序:B 然后 A主体与 close() 同时抛异常时,主体异常保留为主异常,关闭异常进入 suppressed 列表,排障时两者都要看。包装异常时要保留原始原因:
try {
validate(name);
} catch (NullPointerException cause) {
throw new IllegalArgumentException("用户名校验失败: " + name, cause);
}finalize() 已在 Java 9 弃用,不可靠地管理资源。
四、并发正确性基础
i++ 看起来是一个符号,底层却是三个动作:读取、加一、写回。两个线程各执行一次递增,交错之后可能都写回同一个值。
线程 A 读取 15
线程 B 也读取 15
线程 A 算出 16
线程 B 也算出 16
线程 A 写入 16
线程 B 写入 16 // 两次递增得到 16,应为 17竞态条件的定义是:多个线程同时访问共享数据,结果取决于执行的时序。这需要在三个条件同时成立时才会出现——同一个对象被多线程访问、对象状态会变化、更新不是原子的。
volatile 解决的是可见性,让一个线程的更新能被其他线程及时看到;它不提供互斥,也不保证复合操作原子,所以救不了 count++:
private volatile int count; // 仍然不是原子递增警告:用线程池不等于线程安全。线程池解决的是线程的创建、复用与生命周期,不会让任务之间共享的业务对象自动安全。另外测试里“结果正确”很可能只是竞态这次没触发。
五、锁、原子变量与并发容器
最简方案是让整个方法串行:
synchronized void increment() { value++; }代价是同步范围太大。一个对象上有两个互不相关的字段时,两个方法都会锁在同一个 this 上,线程 A 改 i 时线程 B 连改 j 都要排队。解决办法是给不同状态配不同的锁对象:
private final Object lockI = new Object();
private final Object lockJ = new Object();
void incrementI() { synchronized (lockI) { i++; } }
void incrementJ() { synchronized (lockJ) { j++; } }需要“尝试获取”“限时等待”“可中断”时换 ReentrantLock,它的释放必须写在 finally:
private final ReentrantLock lock = new ReentrantLock();
void increment() {
lock.lock();
try { value++; } // lock 之后紧跟 try 是铁律
finally { lock.unlock(); }
}方法只做简单递增时交给原子类,把这一步做成原子操作,不需要额外锁:
private final AtomicInteger value = new AtomicInteger();
value.incrementAndGet();原子类只能保证它自己那几个方法原子。检查余额、扣款、写审计、更新缓存这样的多步流程仍然需要锁。LongAdder 用分段累加化解高竞争计数,sum() 是聚合值、有可见性延迟,强一致读要用 AtomicLong。
Map 的“取值、判空、放入、递增”整体不是原子的,即便值对象换成了原子计数器也不行。ConcurrentHashMap 的 computeIfAbsent 把它收成一个原子步骤:
var occurrences = new ConcurrentHashMap<Character, LongAdder>();
occurrences.computeIfAbsent(ch, k -> new LongAdder()).increment();读远多于写时用 CopyOnWriteArrayList:修改性操作复制底层数组,读不加锁,遍历基于创建时的快照。写频繁时每次都复制整个数组,成本会完全反转。
提示:别捕获
ConcurrentModificationException继续跑。异常出现时数据结构可能已经处于不一致状态,应当改用removeIf、在快照上遍历,或换并发容器。
六、线程创建、状态与协作
线程的价值在于等待期间不占用 CPU。任务一在等网络响应时,任务二本可以推进;单线程顺序执行时 CPU 只能干等。顺序、并发、并行这三者的区别值得记清:
| 模式 | 含义 |
|---|---|
| 顺序 | 一次只做一件事 |
| 并发 | 多个任务交错推进,单核也能做到 |
| 并行 | 多个任务在同一时刻真正同时执行 |
创建线程的两种基本方式:继承 Thread,或实现 Runnable 后交给 new Thread(task)。选 Runnable 的实际理由很朴素——Java 类只能继承一个类,继承了 Thread 就没法再继承别的;实现接口不占用这个名额。
class Task implements Runnable {
@Override public void run() { doWork(); }
}
new Thread(new Task()).start(); // start 才创建线程警告:直接调用
run()只是普通方法调用,根本没有并发,但它照样有输出,极易误判。必须调start()。
需要返回结果的任务用 Callable<T>,它由 ExecutorService 提交后拿到 Future。线程有五种状态:新建、可运行、运行、阻塞/等待、终止。已终止的线程不能再次 start(),只能新建实例。
三个方法的对象与语义要分清:
| 方法 | 作用于 | 含义 |
|---|---|---|
t.join() |
某个指定线程 | 当前线程等它结束 |
Thread.sleep(ms) |
当前线程 | 暂停指定毫秒 |
Thread.yield() |
当前线程 | 提示调度器让位,可被忽略 |
线程优先级(1~10)同样只是给调度器的提示。业务正确性绝不能建立在“优先级高的先执行”之上。
三个协作工具解决不同问题:
| 工具 | 解决的问题 |
|---|---|
Semaphore |
限制同时访问某资源的线程数(最多 5 个线程调外部接口) |
CountDownLatch |
等待若干个“完成事件”发生 |
ThreadLocal |
每个线程一份变量副本,从根上消除共享 |
Semaphore permits = new Semaphore(2);
permits.acquire();
try { accessDatabase(); } finally { permits.release(); }
CountDownLatch latch = new CountDownLatch(2);
// 每个任务完成时 latch.countDown()
latch.await();wait / notify / notifyAll 是更老的做法,必须在同步上下文中调用,还容易丢失通知(通知发生在等待之前,线程会一直等),有 Lock 和上面这些工具后优先用后者。
死锁的成因是两个线程各自持有一把锁又等待对方的锁:
线程 1 持有 A,等 B
线程 2 持有 B,等 A破解方式有三种:所有线程按一致顺序获取多把锁、用带超时的 tryLock、减少嵌套锁。
七、ExecutorService 与任务编排
手动 new Thread(...).start() 处理一千个任务就意味着一千个线程,创建销毁昂贵,也无法统一等待和取结果。ExecutorService 把“创建线程”提升为“管理任务”:提交一百个任务,池里可能只有四个工作线程。
var executor = Executors.newFixedThreadPool(4);
for (int i = 1; i <= 100; i++) {
int taskNumber = i; // 必须 effectively final
executor.submit(() -> executeTask(taskNumber));
}
executor.shutdown();按需求选池子:
| 工厂方法 | 用途 |
|---|---|
newFixedThreadPool(n) |
固定 n 个工作线程,反复复用 |
newSingleThreadExecutor |
串行执行,保证顺序 |
newScheduledThreadPool(n) |
定时或周期任务 |
定时任务有两种语义,区别在于从什么时候开始算下一次:scheduleAtFixedRate 按固定时间点启动,若上次跑得慢会紧接着补;scheduleWithFixedDelay 则等上一次结束后再等指定间隔。
取结果的三种方式:
Future<String> one = executor.submit(() -> compute()); // 阻塞直到完成
List<Future<String>> all = executor.invokeAll(tasks); // 等全部完成
String fastest = executor.invokeAny(tasks); // 只等最先完成的那个警告:必须
shutdown()。否则非守护工作线程会让进程无法结束。要“立即停”用shutdownNow(),它会中断正在运行的任务但不给它们收尾机会。
分治型任务(快排、大规模求和这类可以递归拆分的)用 ForkJoinPool 比普通线程池更合适;多个相互独立的任务用 ExecutorService 就够。
八、虚拟线程与并行边界
平台线程与操作系统线程大致是一对一映射,内存成本高、创建销毁昂贵,线程等数据库或 API 时占住的 OS 线程也被浪费,数量上通常只能到几千。虚拟线程不再与 OS 线程一对一绑定,由 JVM 调度到少量承载线程上,创建成本和内存占用都很低。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String url : urls) {
executor.submit(() -> callService(url));
}
} // try-with-resources 自动 shutdown判断标准很清晰:
| 任务特征 | 更合适的选择 |
|---|---|
| 大量 I/O 等待(数据库、HTTP、文件) | 虚拟线程 |
| CPU 密集(图像处理、复杂运算) | 平台线程或受控线程池 |
虚拟线程让“海量并发等待”变得廉价,但不会让 CPU 密集计算变快——瓶颈在算力,不在调度。写法上应当保持简单的同步风格,线程池那套精细调参在虚拟线程场景通常不再必要。
九、反射
反射在运行时读取类的信息。三种拿 Class 的方式,同一个类只有一个 Class 实例:Class.forName("全限定名") 按名字查找、String.class 编译期已知、obj.getClass() 从实例反查。
| 意图 | 写法 |
|---|---|
| 读公有字段 | getFields |
| 读本类全部字段(含私有) | getDeclaredFields + setAccessible(true) |
| 调公有方法 | getMethod + invoke |
| 调私有方法 | getDeclaredMethod + setAccessible(true) + invoke |
| 创建实例 | getDeclaredConstructor + newInstance |
| 看继承 | getSuperclass、isAssignableFrom 配合 instanceof |
var method = Calc.class.getDeclaredMethod("hi", String.class);
method.setAccessible(true); // 私有方法必须放行
System.out.println(method.invoke(obj, "Lin")); // hi,Lin注意:私有成员调用前都要
setAccessible(true),否则抛IllegalAccessException;getMethod按声明的参数类型逐个匹配,类型没对上抛NoSuchMethodException;反射有额外开销,热点路径应缓存Field/Method或直接写普通代码。泛型擦除后List<String>.class也写不出来。
动态代理就藏在这套机制里:拿到接口的 Class,运行时生成实现并把调用转发到 invoke,框架是把“读方法 + 调方法”包了一层。常规业务不需要用反射绕过私有设计。
十、注解
注解是贴在代码上的标记,定义、标注、读取三步分开:元注解定义 → 标到类或方法上 → 运行时用反射统一读出。
| 元注解 | 管什么 | 常用取值 |
|---|---|---|
@Retention |
留到什么时候 | SOURCE、CLASS、RUNTIME |
@Target |
能贴在哪 | TYPE、METHOD、FIELD、PARAMETER 等 |
@Documented |
是否进 javadoc | 标注后出现在文档里 |
@Inherited |
子类是否继承 | 只对类上的注解生效 |
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test { String description() default "no description"; }
for (var method : suite.getClass().getDeclaredMethods()) {
if (method.isAnnotationPresent(Test.class)) {
Test t = method.getAnnotation(Test.class);
method.invoke(suite);
}
}常用内置注解:@Override 让编译器检查是否真的重写、@Deprecated 提示换新写法、@FunctionalInterface 保证单抽象方法、@SuppressWarnings 抑制指定警告。
警告:注解本身不干活,干活的是读它的代码。
Retention没写成RUNTIME,getAnnotation就只能拿到null;@Target不包含当前元素会直接编译报错。只贴不读等于没写。
十一、工具链、构建与测试
JShell 是 Java 9 提供的交互式环境,写一句立即看到结果,表达式结果存进 $1、$2 供后续引用:
jshell> 5 * 8
$1 ==> 40
jshell> 5 + 6
$2 ==> 11
jshell> /exit它适合试探不熟悉的 API、验证表达式、多行代码未写完时会继续提示输入(常用快捷键:Ctrl+A/Ctrl+E 行首行尾,Ctrl+R 搜历史)。它的定位是片段验证,与项目配置不一致时以构建结果为准。
Maven 坐标由三部分组成:groupId 取组织域名逆序,artifactId 取构件名,version 取版本(开发中未发布用 -SNAPSHOT,发布时去掉)。构建生命周期固定顺序:validate 校验 → test 运行测试(失败即停)→ package 打出 jar。
<maven.compiler.release>21</maven.compiler.release>依赖只声明直接需要的;传递依赖版本打架时先用 mvn dependency:tree 看清来源,再显式声明版本。
JUnit 5:源代码在 src/main/java,测试在 src/test/java;测试方法标 @Test,断言用 assertEquals 比较预期与实际,中文名用 @DisplayName,每个用例前的数据准备用 @BeforeEach,用例之间不共享可变状态。@BeforeAll 在全部用例前运行一次且需要静态方法。
@Test
@DisplayName("空数组求和为 0")
void calculateSum_zeroLengthArray() {
assertEquals(0, math.calculateSum(new int[] {}));
}警告:本地能跑 CI 失败,最常见的原因是 JDK 版本不一致。锁定
maven.compiler.release与 CI 镜像版本,差异就会暴露在构建期而不是上线后。
十二、数据访问:JDBC 到 JPA
JDBC 的执行固定三步:获取连接、预编译语句、读取结果集,三者的资源都要关闭。预编译 prepareStatement 用占位符绑定参数,同时防注入。
| 步骤 | 说明 |
|---|---|
| 获取连接 | DriverManager 或连接池 |
| 预编译 | prepareStatement 参数用占位符 |
| 读取结果 | ResultSet 按列读取后关闭 |
事务是一次业务一次提交:setAutoCommit(false),成功 commit、异常 rollback。事务边界放在服务层,仓储只执行 SQL、不决定提交时机。
try (var conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (var ps = conn.prepareStatement("UPDATE accounts SET balance = balance - ? WHERE id = ?")) {
ps.setBigDecimal(1, amount);
ps.setLong(2, from);
ps.executeUpdate();
}
conn.commit();
} catch (SQLException e) {
rollback(e);
throw e;
}JPA 把实体映射到表:实体对应表、主键字段对应主键列、普通字段对应同名列。EntityManager 提供 merge(插入或更新)、find(按主键查)、remove(删除),使用 JPA 执行写操作需要事务。
Spring Data JPA 再简化一层:声明一个扩展 JpaRepository<Course, Long> 的空接口,就获得 save、findById、findAll、count、deleteById、existsById;自定义查询按命名约定声明(findByAuthor、findByName);调试时开 spring.jpa.show-sql=true 看实际 SQL。
| 层次 | 职责 |
|---|---|
| 实体 | 字段与列的映射 |
| 仓储 | 查询声明与分页 |
| 服务 | 事务边界与业务规则 |
选择建议:CRUD 为主的业务表用 JPA 仓储;批量导入与复杂报表退回 JDBC 原生 SQL;混合场景由服务编排两者,事务统一放在服务层。
警告:加了
@Transactional却没生效,最常见原因是自调用——同一个类里方法 A 调方法 B,绕过了代理。事务方法要经由另一个 bean 调用。
十三、易错点总表
| 现象 | 原因 | 处理 |
|---|---|---|
| 异常被吞、上游误判成功 | catch 为空 |
至少打印堆栈,保留上下文 |
| 用户看到大段堆栈 | 缺少全局边界 | 应用边界统一转换与记录 |
| 高频正常分支性能差 | 拿异常当校验 | 先判断再解析 |
| 连接/文件句柄泄漏 | 资源未关闭 | 一律 try-with-resources |
finally 里的 return 吞掉结果 |
return 覆盖控制流 | finally 只放清理 |
| 锁让线程永久阻塞 | 异常路径漏 unlock |
lock 后紧跟 try-finally |
| 计数结果偏小 | 复合操作不原子 | 分区锁、原子类或原子 API |
volatile 加了还是错 |
只解决可见性 | 复合原子性靠锁或原子类 |
| 无输出但以为并发了 | 调了 run() |
必须 start() |
| 依赖线程执行顺序 | 优先级/yield 只是提示 |
用 join、Future.get、锁显式协调 |
| 进程退不出来 | 线程池没关 | shutdown() 或 try-with-resources |
| 依赖 HashMap 的遍历顺序 | 哈希顺序不承诺 | 用 LinkedHashMap 或 TreeMap |
getAnnotation 拿到 null |
Retention 不是 RUNTIME |
运行时读取一律 RUNTIME |
| 事务未生效 | 自调用绕过代理 | 经由另一个 bean 调用 |
| 本地能跑 CI 失败 | JDK 版本不一致 | 锁定 maven.compiler.release 与 CI 镜像 |
| 测试互相影响 | 用例间共享可变状态 | 每个用例独立准备数据 |
异常这条线是“别吞、留线索、交给能处理的人”,并发这条线是“共享可变状态 + 非原子更新 = 竞态”,工程这条线是“构建可重复、测试相互独立、事务边界在服务层”。三条线各自闭合,剩下的就是把反射注解这类框架手段用到该用的地方。
← 返回文章