Java 业务开发:高频陷阱与 Code Review 清单
本文系统梳理 Java 业务开发中的高频陷阱,每类问题均配有"错误示例 vs 正确做法"代码对比,帮助在 Code Review 和日常开发中快速识别并规避风险。
相关笔记:Java 并发编程 · Java 虚拟机 · AQS 原理深度解析 · Java 性能调优
目录
| 章节 | 说明 |
|---|---|
| 线程安全陷阱 | ThreadLocal 泄漏、ConcurrentHashMap 误用、CopyOnWriteArrayList 滥用 |
| 加锁陷阱 | 锁粒度、锁层级、死锁 |
| Spring 事务陷阱 | 事务不生效、不回滚、传播配置错误 |
| 集合使用陷阱 | Arrays.asList、subList、LinkedList 误用 |
| 数值精度陷阱 | double 精度丢失、BigDecimal 误用、数值溢出 |
| OOM 陷阱 | 对象副本膨胀、WeakHashMap 失效、参数配置不当 |
| 异常处理陷阱 | 吞异常、捕获过宽、受检异常不回滚 |
线程安全陷阱
ThreadLocal 线程重用导致数据串
Web 服务器(如 Tomcat)使用线程池,线程会被复用。若在 ThreadLocal 中存储用户信息却不清理,下一个请求可能读到上一个用户的数据。
// 错误:用完不清理
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
@GetMapping("/wrong")
public Map<String, Object> wrong(@RequestParam Integer userId) {
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(userId);
String after = Thread.currentThread().getName() + ":" + currentUser.get();
// 线程被复用时,before 可能是上一个用户的 userId!
return Map.of("before", before, "after", after);
}
// 正确:在 finally 中显式清除
@GetMapping("/right")
public Map<String, Object> right(@RequestParam Integer userId) {
currentUser.set(userId);
try {
return Map.of("userId", currentUser.get());
} finally {
currentUser.remove(); // 必须清除,防止数据串
}
}
原则:ThreadLocal 使用完毕后,必须在
finally块中调用remove()。
ConcurrentHashMap 的复合操作不是原子的
ConcurrentHashMap 只保证单个方法的原子性,多步操作之间仍然存在竞态条件。
// 错误:size() + putAll() 不是原子操作,多线程下结果不可预期
ConcurrentHashMap<String, Long> map = getData(900);
IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
int gap = 1000 - map.size(); // 此时 size 可能已被其他线程改变
map.putAll(getData(gap));
});
// 最终 map.size() 可能是 1536,而不是预期的 1000
// 正确方案一:对复合逻辑加锁
IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
synchronized (map) {
int gap = 1000 - map.size();
map.putAll(getData(gap));
}
});
// 正确方案二:充分利用 ConcurrentHashMap 的原子方法
// 统计 Key 出现次数,使用 computeIfAbsent + LongAdder,无需加锁
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
// 比手动 synchronized + put 性能高约 10 倍
| 场景 | 推荐方式 |
|---|---|
| 单步读写 | 直接使用 ConcurrentHashMap 方法 |
| 多步复合逻辑 | synchronized (map) { ... } |
| 高性能计数 | computeIfAbsent + LongAdder |
CopyOnWriteArrayList 写多场景性能灾难
CopyOnWriteArrayList 每次写操作都会复制整个数组,写多读少时性能极差。
// 错误:写多场景使用 CopyOnWriteArrayList
List<Integer> list = new CopyOnWriteArrayList<>();
IntStream.rangeClosed(1, 100_000).parallel().forEach(i ->
list.add(ThreadLocalRandom.current().nextInt(100_000))
);
// 耗时:约 6000ms(写 10 万次时比加锁 ArrayList 慢 ~100 倍)
// 正确:写多读少时用加锁的 ArrayList
List<Integer> list = Collections.synchronizedList(new ArrayList<>());
// 写多场景耗时:约 60ms
// CopyOnWriteArrayList 适合:读多写少、无锁读的场景(如配置列表)
| 场景 | 推荐 |
|---|---|
| 读多写少(配置、白名单) | CopyOnWriteArrayList |
| 读写均衡或写多 | Collections.synchronizedList(new ArrayList<>()) |
| 高并发 | ConcurrentHashMap 或 CopyOnWriteArrayList(只读多) |
加锁陷阱
静态字段必须用类级别锁保护
非静态 synchronized 方法的锁是实例级别的,无法保护静态字段。
// 错误:用实例锁保护静态字段
class Counter {
private static int count = 0;
public synchronized void increment() { // 锁的是 this(实例),而 count 是类级别
count++;
}
}
// 多个实例并发调用 increment(),count 仍然线程不安全
// 正确:用静态锁对象保护静态字段
class Counter {
private static int count = 0;
private static final Object LOCK = new Object();
public void increment() {
synchronized (LOCK) { // 类级别锁
count++;
}
}
}
锁粒度过大导致性能下降
private final List<Integer> data = new ArrayList<>();
// 错误:把耗时操作也纳入锁范围
public void wrong() {
synchronized (this) {
slowOperation(); // 10ms 的耗时操作,不涉及共享资源
data.add(1);
}
}
// 正确:只锁需要保护的资源
public void right() {
slowOperation(); // 在锁外执行
synchronized (data) {
data.add(1);
}
}
// 性能差距:1000 次操作,wrong 耗时 11s,right 耗时 1.4s
多锁顺序不一致导致死锁
// 错误:不同线程以不同顺序获取锁,可能死锁
// 线程 A:先锁 item1,再锁 item2
// 线程 B:先锁 item2,再锁 item1
// → 互相等待,死锁
// 正确:所有线程按相同顺序获取锁(对资源排序)
List<Item> cart = createCart().stream()
.sorted(Comparator.comparing(Item::getName)) // 统一排序
.collect(Collectors.toList());
// 所有线程都先锁 itemA 再锁 itemB,不会死锁
死锁四要素:互斥、占有并等待、不可剥夺、循环等待。破坏任一条件即可解决。最常用的方式是对资源排序,统一加锁顺序。
Spring 事务陷阱
陷阱一:事务不生效
// 错误一:private 方法上的 @Transactional 不生效
// Spring AOP 基于动态代理,private 方法无法被代理
@Transactional
private void createUser(UserEntity entity) { // 不生效!
repository.save(entity);
}
// 错误二:通过 this 调用,绕过了代理
@Transactional
public void createUserWrong() {
this.doCreate(); // this 不是代理对象,@Transactional 不生效
}
@Transactional
public void doCreate() { ... }
// 正确:通过 Spring 注入的 Bean 调用,且方法必须是 public
@Service
public class UserService {
@Autowired
private UserService self; // 注入自身代理
public void createUser() {
self.doCreate(); // 通过代理调用,事务生效
}
@Transactional
public void doCreate() {
repository.save(entity);
}
}
// 更好的做法:让 Controller 直接调用 doCreate(),避免自注入
事务生效的两个前提:
- 方法必须是
public - 必须通过 Spring 代理对象调用(不能
this.xxx())
陷阱二:事务不回滚
// 错误一:捕获了异常,异常无法传播出去,事务不回滚
@Transactional
public void createUserWrong1(String name) {
try {
repository.save(new UserEntity(name));
throw new RuntimeException("error");
} catch (Exception ex) {
log.error("failed", ex); // 吞掉了异常,事务不会回滚!
}
}
// 错误二:受检异常默认不触发回滚
@Transactional
public void createUserWrong2(String name) throws IOException {
repository.save(new UserEntity(name));
readFile(); // 抛出 IOException(受检异常),默认不回滚!
}
// 正确一:捕获后手动标记回滚
@Transactional
public void createUserRight1(String name) {
try {
repository.save(new UserEntity(name));
throw new RuntimeException("error");
} catch (Exception ex) {
log.error("failed", ex);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 手动回滚
}
}
// 正确二:声明对受检异常也回滚
@Transactional(rollbackFor = Exception.class)
public void createUserRight2(String name) throws IOException {
repository.save(new UserEntity(name));
readFile();
}
Spring 事务回滚规则:
- 默认只对
RuntimeException和Error回滚 - 受检异常(
IOException等)默认不回滚 - 使用
rollbackFor = Exception.class可扩大回滚范围
陷阱三:事务传播配置错误
// 错误:子方法与主方法共用同一个事务
// 子方法抛出异常后,整个事务被标记为 rollback-only
// 即使主方法捕获了子方法的异常,最终提交时也会静默回滚
@Transactional
public void createUserWrong(UserEntity entity) {
createMainUser(entity);
try {
subUserService.createSubUser(entity); // 子方法抛 RuntimeException
} catch (Exception ex) {
log.error("sub user failed: {}", ex.getMessage());
// 虽然捕获了,但当前事务已被标记 rollback-only,提交时抛 UnexpectedRollbackException
}
}
// 正确:子方法开启独立事务,互不影响
@Service
public class SubUserService {
@Transactional(propagation = Propagation.REQUIRES_NEW) // 新事务
public void createSubUser(UserEntity entity) {
repository.save(entity);
throw new RuntimeException("invalid status");
}
}
@Transactional
public void createUserRight(UserEntity entity) {
createMainUser(entity);
try {
subUserService.createSubUser(entity); // 子事务独立回滚,不影响主事务
} catch (Exception ex) {
log.error("sub user failed: {}", ex.getMessage());
}
// 主事务正常提交
}
| 传播行为 | 说明 |
|---|---|
REQUIRED(默认) | 加入当前事务,无则新建 |
REQUIRES_NEW | 新建事务,挂起当前事务 |
NESTED | 嵌套事务(savepoint),外层回滚影响内层 |
SUPPORTS | 有事务则加入,无则非事务执行 |
集合使用陷阱
Arrays.asList 的三个坑
// 坑一:int[] 不会被拆箱为 Integer[],整个数组作为一个元素
int[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
System.out.println(list.size()); // 1,不是 3!
// 正确:使用 Arrays.stream 或 Integer[]
List<Integer> list1 = Arrays.stream(arr).boxed().collect(Collectors.toList());
Integer[] arr2 = {1, 2, 3};
List<Integer> list2 = Arrays.asList(arr2); // 正确,size=3
// 坑二:Arrays.asList 返回的 List 不支持 add/remove
String[] arr3 = {"a", "b", "c"};
List<String> list3 = Arrays.asList(arr3);
list3.add("d"); // 抛出 UnsupportedOperationException!
// 坑三:修改原数组会影响 List(共享底层数组)
arr3[0] = "x";
System.out.println(list3.get(0)); // "x",而不是 "a"
// 正确:包一层 new ArrayList<>() 解耦
List<String> mutableList = new ArrayList<>(Arrays.asList(arr3));
subList 持有原 List 强引用导致 OOM
// 错误:subList 持有原 List 的强引用,导致 100000 个元素的 List 无法被 GC
private static List<List<Integer>> data = new ArrayList<>();
private static void oom() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100_000)
.boxed().collect(Collectors.toList());
data.add(rawList.subList(0, 1)); // rawList 被 SubList 强引用,无法回收!
}
// OOM:1000 个 100000 元素的 List 全部驻留内存
}
// 正确方案一:new ArrayList<> 包装,断开强引用
data.add(new ArrayList<>(rawList.subList(0, 1)));
// 正确方案二:Stream skip + limit
data.add(rawList.stream().skip(0).limit(1).collect(Collectors.toList()));
⚠️ subList 的两个副作用:
- 删除 subList 中的元素会影响原 List
- 修改原 List 结构后遍历 subList 会抛
ConcurrentModificationException
LinkedList 随机插入并不比 ArrayList 快
// 误区:认为 LinkedList 插入是 O(1),比 ArrayList 快
// 实际:LinkedList 随机插入需要先 O(n) 遍历找到节点,再 O(1) 插入
// 10 万元素、10 万次随机插入:LinkedList 耗时 9.3s,ArrayList 耗时 1.5s
// 结论:除非在头尾操作,否则 ArrayList 通常优于 LinkedList
| 操作 | ArrayList | LinkedList |
|---|---|---|
随机访问 get(i) | O(1) | O(n) |
随机插入 add(i, e) | O(n)(移位) | O(n)(先遍历) |
| 头尾插入/删除 | O(n) | O(1) |
| 内存占用 | 紧凑 | 每节点额外两个指针 |
数值精度陷阱
double 浮点数不精确
// 错误:直接用 double 做金融计算
System.out.println(0.1 + 0.2); // 0.30000000000000004,不是 0.3!
System.out.println(1.0 - 0.8); // 0.19999999999999996
System.out.println(4.015 * 100); // 401.49999999999994
double amount1 = 2.15;
double amount2 = 1.10;
if (amount1 - amount2 == 1.05) { // false!永远不要用 == 比较浮点数
System.out.println("OK");
}
// 正确:使用 BigDecimal,且必须用 String 构造
// 错误初始化方式(仍然不精确):
new BigDecimal(0.1) // 0.1000000000000000055511151231257827...
// 正确初始化方式:
new BigDecimal("0.1").add(new BigDecimal("0.2")) // 0.3 ✓
new BigDecimal("1.0").subtract(new BigDecimal("0.8")) // 0.2 ✓
new BigDecimal("4.015").multiply(new BigDecimal("100")) // 401.500 ✓
// 比较 BigDecimal 用 compareTo,不用 equals(equals 还比较 scale)
new BigDecimal("1.0").compareTo(new BigDecimal("1")) == 0 // true ✓
new BigDecimal("1.0").equals(new BigDecimal("1")) // false!
BigDecimal 使用规范:
| 场景 | 做法 |
|---|---|
| 初始化 | new BigDecimal("0.1") 或 BigDecimal.valueOf(0.1) |
| 比较大小 | compareTo() |
| 放入 HashSet/HashMap | 先 stripTrailingZeros() 或改用 TreeSet |
| 格式化输出 | setScale(2, RoundingMode.HALF_UP) |
数值溢出静默发生
// 错误:long 溢出不抛异常,静默返回错误结果
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808,变成了负数!
// 正确方案一:使用 Math.xxxExact 方法,溢出时抛 ArithmeticException
Math.addExact(Long.MAX_VALUE, 1); // 抛出 ArithmeticException: long overflow
// 正确方案二:使用 BigInteger
BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
i.add(BigInteger.ONE); // 9223372036854775808,无溢出
OOM 陷阱
缓存中存储了多份相同对象
// 错误:每个索引 Key 都 new 了一个新的 UserDTO,导致同一用户存了多份
Map<String, List<UserDTO>> autoCompleteIndex = new ConcurrentHashMap<>();
users.forEach(user -> {
for (int i = 0; i < user.getName().length(); i++) {
String key = user.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
.add(new UserDTO(user.getName())); // 每次 new,1万用户 * 6个前缀 = 6万个对象!
}
});
// 正确:先去重,多个 Key 共享同一个 UserDTO 对象
Set<UserDTO> cache = users.stream()
.map(u -> new UserDTO(u.getName()))
.collect(Collectors.toCollection(HashSet::new)); // 去重,只有 1 万个对象
cache.forEach(userDTO -> {
for (int i = 0; i < userDTO.getName().length(); i++) {
String key = userDTO.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
.add(userDTO); // 共享同一个对象引用,内存从 1.2GB 降至 200MB
}
});
WeakHashMap 的 Value 持有 Key 强引用导致无法 GC
// 错误:Value(UserProfile)持有 Key(User)的强引用
// WeakHashMap 的 Key 虽是弱引用,但 Value→Key 的强引用链阻止了 GC
Map<User, UserProfile> cache = new WeakHashMap<>();
cache.put(user, new UserProfile(user, "location")); // UserProfile 持有 user 引用
// 结果:cache 永远不会被 GC,最终 OOM
// 正确方案一:Value 也用弱引用包装
Map<User, WeakReference<UserProfile>> cache = new WeakHashMap<>();
cache.put(user, new WeakReference<>(new UserProfile(user, "location")));
// 正确方案二:Value 不引用 Key(new 一个新的 User)
cache.put(user, new UserProfile(new User(user.getName()), "location"));
异常处理陷阱
捕获通用异常 / 吞掉异常
// 错误一:捕获 Exception 太宽泛,隐藏了真实意图
try {
Thread.sleep(1000);
} catch (Exception e) { // 应该捕获 InterruptedException
// ignore
}
// 错误二:生吞异常,出问题时无法定位
try {
doSomething();
} catch (IOException e) {
e.printStackTrace(); // 生产环境不应该用 System.err,应该用日志框架
}
// 正确:捕获具体异常,使用日志记录
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断状态
log.warn("Thread interrupted", e);
}
try {
doSomething();
} catch (IOException e) {
log.error("IO operation failed: {}", e.getMessage(), e);
throw new ServiceException("操作失败,请重试", e); // 包装后重新抛出
}
异常处理原则:
- Throw early:在发现问题的第一时间抛出(如参数校验失败立即抛)
- Catch late:在有能力处理的层次捕获(如 Controller 层统一处理)
- 不要生吞:捕获后要么记录日志,要么重新抛出
- 不要捕获 Throwable/Error:除非你知道自己在做什么
参考资料
评论 (0)