Skip to content

常见坑

开发中的常见陷阱与避坑指南。每个坑点都包含:问题现象、原因分析、错误示例和正确做法。


一、基础类型陷阱

1. Integer 缓存问题

现象:用 == 比较两个 Integer 时,有时为 true 有时为 false。

原因:Integer 在 -128127 范围内使用缓存(IntegerCache),范围内 == 比较是 true,超出范围则是 false。

java
// ❌ 错误
Integer a = 100;
Integer b = 100;
System.out.println(a == b);   // true -- 在缓存范围内

Integer c = 200;
Integer d = 200;
System.out.println(c == d);   // false -- 超出缓存范围,是两个不同对象

// ✅ 正确:始终用 equals 比较包装类型
System.out.println(a.equals(b));  // true
System.out.println(c.equals(d));  // true
// 或者拆箱后比较
System.out.println(c.intValue() == d.intValue());  // true

2. BigDecimal 使用 double 构造器

现象new BigDecimal(0.1) 的结果不是精确的 0.1。

原因:double 本身无法精确表示 0.1(二进制浮点数精度问题),BigDecimal(double) 构造函数传入的就是不精确的值。

java
// ❌ 错误
BigDecimal price = new BigDecimal(0.1);
System.out.println(price);  
// 输出: 0.1000000000000000055511151231257827021181583404541015625

// ❌ 同样错误
BigDecimal price2 = BigDecimal.valueOf(0.1 + 0.2);
System.out.println(price2);
// 输出: 0.30000000000000004

// ✅ 正确:使用字符串构造函数
BigDecimal price = new BigDecimal("0.1");
System.out.println(price);  // 0.1

// ✅ 正确:使用 valueOf(内部调用 toString)
BigDecimal price2 = BigDecimal.valueOf(0.1);  // JDK 把参数转为 String
System.out.println(price2);  // 0.1

// ✅ 计算正确
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
BigDecimal result = a.add(b);
System.out.println(result);  // 0.3

3. 自动装箱引发的 NPE

现象:看似正常的代码抛出 NullPointerException。

原因:自动拆箱时,如果包装类型为 null,intValue() 调用会抛出 NPE。

java
// ❌ 错误
public int getDiscount(Integer level) {
    // 如果 level 为 null,自动拆箱抛出 NPE
    return level > 3 ? 20 : 10;
}

// ❌ 错误
Map<String, Integer> map = new HashMap<>();
int count = map.get("key");  // get 返回 null,拆箱 NPE

// ✅ 正确:判空处理
public int getDiscount(Integer level) {
    if (level == null) {
        return 0;  // 或抛出有意义的异常
    }
    return level > 3 ? 20 : 10;
}

// ✅ 正确:使用 Optional
Map<String, Integer> map = new HashMap<>();
int count = Optional.ofNullable(map.get("key")).orElse(0);

4. switch 中 null 值

java
// ❌ 错误 -- NPE!
String type = null;
switch (type) {  // switch 会调用 hashCode(),触发 NPE
    case "A":
        break;
    default:
        break;
}

// ✅ 正确:先判空
if (type == null) {
    // 处理 null
} else {
    switch (type) {
        case "A":
            break;
    }
}

二、集合陷阱

5. ArrayList 遍历时删除(ConcurrentModificationException)

现象:在 for-each 循环中直接 list.remove() 抛出 ConcurrentModificationException

原因:for-each 本质是迭代器遍历,list.remove() 修改了 modCount,迭代器 next() 时检测到不一致。

java
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

// ❌ 错误 -- ConcurrentModificationException
for (String item : list) {
    if ("b".equals(item)) {
        list.remove(item);
    }
}

// ✅ 正确方式一:使用迭代器
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    if ("b".equals(iterator.next())) {
        iterator.remove();
    }
}

// ✅ 正确方式二:使用 removeIf(JDK 8+)
list.removeIf(item -> "b".equals(item));

// ✅ 正确方式三:倒序遍历(仅适用于 ArrayList)
for (int i = list.size() - 1; i >= 0; i--) {
    if ("b".equals(list.get(i))) {
        list.remove(i);
    }
}

6. Arrays.asList 的不可变性

现象Arrays.asList() 返回的列表不能增删元素。

原因Arrays.asList() 返回的是 Arrays 内部类 ArrayList,不是 java.util.ArrayList,它继承自 AbstractList,没有实现 add/remove 方法。

java
// ❌ 错误 -- UnsupportedOperationException
List<String> list = Arrays.asList("a", "b", "c");
list.add("d");   // 异常!
list.remove(0);  // 异常!

// 更隐蔽的问题:修改原数组会影响列表
String[] arr = {"a", "b", "c"};
List<String> list = Arrays.asList(arr);
arr[0] = "x";
System.out.println(list);  // [x, b, c] -- 列表也被修改了!

// ✅ 正确:包装为真正的 ArrayList
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
list.add("d");  // 正常

7. List.subList 的陷阱

现象:修改 subList 或原列表,另一个也被修改。

原因subList() 返回的是原列表的视图,不是独立拷贝。

java
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d", "e"));

// ❌ 错误:修改 subList 影响原列表
List<String> sub = list.subList(0, 3);
sub.clear();
System.out.println(list);  // [d, e] -- 前三个元素也被删了

// ❌ 错误:修改原列表后 subList 不可用
List<String> sub2 = list.subList(0, 2);
list.add("f");  // 原列表结构修改
sub2.get(0);    // ConcurrentModificationException

// ✅ 正确:创建独立副本
List<String> sub = new ArrayList<>(list.subList(0, 3));

8. HashMap 在 JDK 7 中的死循环

现象:多线程并发 resize 时,HashMap 可能出现死循环(CPU 100%)。

原因:JDK 7 HashMap 扩容时采用头插法,并发环境下链表可能形成环。

java
// ❌ 多线程下使用 HashMap 可能导致死循环(JDK 7)
// 或数据丢失(JDK 8)
Map<String, String> map = new HashMap<>();

// ✅ 正确:使用 ConcurrentHashMap
Map<String, String> map = new ConcurrentHashMap<>();

// 或者使用 Collections.synchronizedMap
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());

三、异常处理陷阱

9. finally 块中的 return

现象:finally 中有 return 语句,try 中的 return 被覆盖。

原因:finally 块在 return 语句执行前运行,finally 中的 return 会覆盖 try 中的返回值。

java
// ❌ 错误:finally 中的 return 覆盖了 try 中的返回值
public int getValue() {
    try {
        return 1;
    } finally {
        return 2;  // 实际返回 2!
    }
}

// ❌ 错误:finally 中 return 吞掉了异常
public int getValue() {
    try {
        throw new RuntimeException("error");
    } finally {
        return 0;  // 异常被吞掉,返回 0
    }
}

// ✅ 正确:finally 中只做清理,不 return
public int getValue() {
    try {
        return computeValue();
    } finally {
        cleanup();
    }
}

10. 异常被吞掉

java
// ❌ 错误:空 catch 或只打印
try {
    doSomething();
} catch (Exception e) {
    // 什么都不做 -- 异常被吞掉
}

try {
    doSomething();
} catch (Exception e) {
    e.printStackTrace();  // 只打印不处理,生产环境找不到
}

// ✅ 正确:记录日志并传播
try {
    doSomething();
} catch (Exception e) {
    log.error("操作失败,userId={}", userId, e);
    throw new ServiceException("操作失败", e);
}

四、线程安全陷阱

11. SimpleDateFormat 线程不安全

现象:多线程共享 SimpleDateFormat 导致解析错误或异常。

原因:SimpleDateFormat 内部维护了 Calendar 对象作为共享状态,并发修改导致数据错乱。

java
// ❌ 错误:多线程共享 SimpleDateFormat
public static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");

// 多线程调用 SDF.parse() 或 SDF.format() 可能出错

// ✅ 正确方式一:使用 ThreadLocal
private static final ThreadLocal<DateFormat> DATE_FORMAT = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

public static String format(Date date) {
    return DATE_FORMAT.get().format(date);
}

// ✅ 正确方式二:使用 DateTimeFormatter(JDK 8+,线程安全)
private static final DateTimeFormatter FORMATTER = 
    DateTimeFormatter.ofPattern("yyyy-MM-dd");

public static String format(LocalDate date) {
    return date.format(FORMATTER);
}

// ✅ 正确方式三:每次新建实例(不推荐高频场景)
public static String format(Date date) {
    return new SimpleDateFormat("yyyy-MM-dd").format(date);
}

12. 双重检查锁定的指令重排

现象:DCL 单例模式中,instance 可能被分配了内存但未初始化就被使用。

原因new Singleton() 不是原子操作(分配内存 -> 执行构造 -> 赋值引用),指令重排可能导致引用先赋值后初始化。

java
// ❌ 错误:缺少 volatile
public class Singleton {
    private static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();  // 可能指令重排
                }
            }
        }
        return instance;
    }
}

// ✅ 正确:加上 volatile
public class Singleton {
    private static volatile Singleton instance;  // volatile 禁止指令重排
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

五、Stream 与函数式编程陷阱

13. Stream 不可重复使用

现象:Stream 第二次使用抛出 IllegalStateException

原因:Stream 是流式、一次性的,操作后就被关闭了。

java
// ❌ 错误
Stream<String> stream = list.stream();
long count = stream.count();        // 第一次消费
stream.forEach(System.out::println); // 异常:stream has already been operated upon or closed

// ✅ 正确:重新创建 Stream
long count = list.stream().count();
list.stream().forEach(System.out::println);

// ✅ 正确:使用 Supplier 包装
Supplier<Stream<String>> streamSupplier = list::stream;
long count = streamSupplier.get().count();
streamSupplier.get().forEach(System.out::println);

14. Stream 中的变量必须是 effectively final

java
// ❌ 错误:Lambda 中修改外部变量
int sum = 0;
list.forEach(item -> sum += item);  // 编译错误

// ✅ 正确:使用 reduce 或收集器
int sum = list.stream().mapToInt(Integer::intValue).sum();

// ✅ 正确:使用原子类(不推荐,违反函数式编程原则)
AtomicInteger sum = new AtomicInteger(0);
list.forEach(item -> sum.addAndGet(item));

15. parallelStream 的线程池问题

现象:所有 parallelStream 共享同一个 ForkJoinPool(默认线程数为 CPU 核心数 - 1),可能导致阻塞。

java
// ❌ 隐患:parallelStream 中调用阻塞操作,可能耗尽线程池
list.parallelStream().forEach(item -> {
    // 这是一个阻塞的网络调用
    httpClient.get("http://api.example.com/" + item);
});

// ✅ 正确:自定义线程池
ForkJoinPool customPool = new ForkJoinPool(10);
customPool.submit(() -> 
    list.parallelStream().forEach(item -> {
        httpClient.get("http://api.example.com/" + item);
    })
).get();

// ✅ 或在非阻塞场景下使用 parallelStream(CPU 密集型)
list.parallelStream()
    .map(this::computeIntensive)  // 纯计算操作
    .collect(Collectors.toList());

六、资源与连接陷阱

16. 资源没有关闭(连接泄漏)

java
// ❌ 错误:资源未关闭,可能耗尽连接池
public void readFile(String path) {
    FileInputStream fis = new FileInputStream(path);
    // 使用 fis
    // 忘记关闭
}

// ❌ 错误:虽然加了 finally 但也很繁琐且容易出错
FileInputStream fis = null;
try {
    fis = new FileInputStream(path);
    // 使用 fis
} finally {
    if (fis != null) {
        try {
            fis.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

// ✅ 正确:try-with-resources
try (FileInputStream fis = new FileInputStream(path);
     BufferedInputStream bis = new BufferedInputStream(fis)) {
    // 使用 bis
} catch (IOException e) {
    log.error("读取文件失败: {}", path, e);
}
// JDBC、HTTP 连接等也同理

17. 数据库连接未归还

java
// ❌ 错误:连接未关闭
public List<User> queryUsers() {
    Connection conn = dataSource.getConnection();
    PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
    ResultSet rs = stmt.executeQuery();
    // 处理结果
    return users;
    // 连接、语句、结果集全都没关闭!
}

// ✅ 正确:try-with-resources
public List<User> queryUsers() throws SQLException {
    String sql = "SELECT * FROM users";
    try (Connection conn = dataSource.getConnection();
         PreparedStatement stmt = conn.prepareStatement(sql);
         ResultSet rs = stmt.executeQuery()) {
        List<User> users = new ArrayList<>();
        while (rs.next()) {
            users.add(mapRow(rs));
        }
        return users;
    }
}

七、其他常见陷阱

18. BigDecimal 的 equals vs compareTo

java
// ❌ 错误:BigDecimal 的 equals 比较精度
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b));  // false -- 精度不同!

// ✅ 正确:用 compareTo
System.out.println(a.compareTo(b) == 0);  // true -- 数值相等

19. 字符串拼接性能问题

java
// ❌ 错误:循环中 String 拼接
String result = "";
for (int i = 0; i < 10000; i++) {
    result += "item" + i;  // 每次循环创建新 String 对象
}

// ✅ 正确:StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append("item").append(i);
}
String result = sb.toString();

20. 浮点数比较

java
// ❌ 错误:直接用 == 比较浮点数
double a = 0.1 + 0.2;
System.out.println(a == 0.3);  // false!

// ✅ 正确:用 BigDecimal 或误差范围
double epsilon = 0.0000001;
System.out.println(Math.abs(a - 0.3) < epsilon);  // true

// ✅ 正确:BigDecimal
BigDecimal result = new BigDecimal("0.1").add(new BigDecimal("0.2"));
System.out.println(result.compareTo(new BigDecimal("0.3")) == 0);  // true

避坑速查表

序号陷阱关键点解决方案
1Integer 缓存-128~127 内 == 为 true用 equals()
2BigDecimal double 构造精度丢失用 String 构造器
3自动拆箱 NPEnull 拆箱抛异常判空或 Optional
4switch null调用 hashCode() NPE先判空
5遍历时删除ConcurrentModificationException迭代器或 removeIf
6Arrays.asList不可增删,与原数组关联new ArrayList<>() 包装
7subList视图而非拷贝new ArrayList<>(list.subList)
8HashMap 并发JDK 7 死循环,JDK 8 数据丢失ConcurrentHashMap
9finally return覆盖 try 返回值、吞异常finally 只做清理
10吞异常空 catch 或只 printStackTrace记录日志并传播
11SimpleDateFormat线程不安全ThreadLocal 或 DateTimeFormatter
12DCL 指令重排instance 未初始化volatile 修饰
13Stream 重用一次性消费重新创建 Stream
14Lambda 变量必须 effectively final用 reduce/collect
15parallelStream 线程池共享 ForkJoinPool自定义线程池
16资源泄漏IO/连接未关闭try-with-resources
17连接泄漏数据库连接未归还try-with-resources
18BigDecimal equals比较精度用 compareTo
19循环字符串拼接大量临时对象StringBuilder
20浮点数比较精度误差BigDecimal 或误差范围

面试常见问题

Q1: 为什么 SimpleDateFormat 不是线程安全的?

A: SimpleDateFormat 继承自 DateFormat,内部维护了 Calendar 对象作为成员变量。在 format/parse 过程中,都会修改这个共享的 Calendar 状态。多线程并发调用时,线程 A 的设置可能被线程 B 覆盖,导致解析结果错乱或抛出异常。JDK 8 的 DateTimeFormatter 是不可变对象,天生线程安全。

Q2: JDK 7 HashMap 为什么会形成死循环?

A: JDK 7 HashMap 扩容时采用头插法(将旧链表元素逐个插入新桶的头部),在并发环境下,两个线程同时扩容时,可能导致链表中的节点互相引用形成环。当调用 get() 查找不存在的 key 时,会陷入死循环造成 CPU 100%。JDK 8 改为尾插法,避免了死循环,但并发下仍可能出现数据丢失。

Q3: BigDecimal 的 equals 和 compareTo 有什么区别?

A: equals 同时比较数值和精度(scale),如 1.01.00 的 precision 不同,equals 返回 false。compareTo 只比较数值大小,忽略精度差异,1.01.00 比较返回 0。业务场景中的金额比较应使用 compareTo

Q4: 为什么循环中不应该用 + 拼接字符串?

A: String 是不可变对象,每次 + 拼接都会创建新的 String 对象和 StringBuilder 对象。循环 10000 次意味着创建 10000 个临时对象,导致大量 GC 和性能下降。应使用 StringBuilder(非线程安全)或 StringBuffer(线程安全)在循环外声明,循环内 append。

Q5: Stream 为什么不能重复消费?

A: Stream 设计为流式操作,类似于迭代器,只能遍历一次。Stream 的终端操作(如 count、collect、forEach)会遍历数据源并关闭流,再次使用会抛出 IllegalStateException。这是 Stream 的惰性求值和短路机制决定的,如果需要多次使用,应重新创建 Stream。