一、Java基础篇

3 阅读12分钟

一、Java基础篇

1. 自动拆装箱空指针 与 ==/equals 混用

问题现象

Integer a = null;
int b = a; // NullPointerException
Integer x = 128, y = 128;
System.out.println(x == y); // false,超出缓存范围

原因分析

  • 自动拆箱调用 a.intValue()a为null时直接NPE。
  • Integer缓存池默认范围是 -128~127IntegerCache),超出范围==比较的是对象地址而非值。

修正方案

  • 包装类型判空后再拆箱,或使用 Optional
  • 对象比较一律用 equals,基本类型才用 ==
Integer a = getValue();
int b = (a != null) ? a : 0;

Integer x = 128, y = 128;
System.out.println(x.equals(y)); // true

2. 字符串处理陷阱

问题现象

  • 循环内用 + 拼接字符串导致大量临时对象、性能急剧下降。
  • 多线程共享 StringBuilder 导致数据错乱(StringBuilder非线程安全)。
  • 读取文件/网络流未指定编码导致乱码。

原因分析 String不可变,+拼接在循环中会不断创建新对象;StringBuilder无同步锁,StringBuffer才是线程安全版本;平台默认编码依赖操作系统。

修正方案

// 循环拼接用StringBuilder
StringBuilder sb = new StringBuilder();
for (String s : list) sb.append(s);

// 多线程场景用StringBuffer或加锁,或每个线程独立StringBuilder再合并
// 编码显式指定
String content = new String(bytes, StandardCharsets.UTF_8);

3. 集合类常见坑

问题现象

List<String> list = new ArrayList<>(List.of("a","b","c"));
for (String s : list) {
    if (s.equals("b")) list.remove(s); // ConcurrentModificationException
}

List<Integer> fixed = Arrays.asList(1,2,3);
fixed.add(4); // UnsupportedOperationException

原因分析

  • 增强for基于Iterator,遍历中直接修改集合会触发modCount校验失败。
  • Arrays.asList返回的是数组的固定长度视图,不支持增删。
  • HashMap在JDK7及以前多线程扩容时可能出现死循环(链表成环)。

修正方案

// 用迭代器安全删除
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) it.remove();
}
// 或用removeIf
list.removeIf(s -> s.equals("b"));

// 需要可变List用new ArrayList<>(Arrays.asList(...))
List<Integer> mutable = new ArrayList<>(Arrays.asList(1,2,3));

// 多线程场景使用ConcurrentHashMap而非HashMap
Map<String,Object> map = new ConcurrentHashMap<>();

4. 异常处理反模式

问题现象

try {
    doSomething();
} catch (Exception e) {
    // 吞掉异常,什么都不做
}

try {
    return 1;
} finally {
    return 2; // 覆盖了try中的return,且会吞掉try中抛出的异常
}

原因分析 空catch块掩盖问题,导致故障难以排查;finally中的return/throw会覆盖try/catch中的返回值和异常,是非常隐蔽的bug来源。

修正方案

  • 捕获异常至少记录日志,不确定处理方式则重新抛出(保留异常链)。
  • finally中避免return/throw
try {
    doSomething();
} catch (BizException e) {
    log.error("业务处理失败, param={}", param, e);
    throw e;
} catch (Exception e) {
    log.error("未知异常", e);
    throw new SystemException("系统异常", e); // 保留异常链
}

5. 泛型与类型擦除问题

问题现象

List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass()); // true

// 泛型数组创建报错
// T[] arr = new T[10]; // 编译错误

原因分析 Java泛型是编译期擦除实现的,运行时List<String>List<Integer>是同一个Class对象;由于擦除,无法直接创建泛型数组,也无法在运行时通过反射区分泛型实参。

修正方案

// 需要保留类型信息时用Class<T>传参或TypeToken(如Gson)
public <T> T convert(Object obj, Class<T> clazz) { ... }

// 泛型数组用反射创建
@SuppressWarnings("unchecked")
T[] arr = (T[]) Array.newInstance(clazz, size);

6. equals/hashCode/compareTo契约违反

问题现象 只重写equals未重写hashCode,导致对象放入HashMap/HashSet后无法正确查找;compareToequals不一致导致TreeSet/TreeMap去重逻辑异常。

原因分析 hashCode契约要求:equals相等的两个对象hashCode必须相等。若只重写equals,默认hashCode基于内存地址,导致逻辑相等的对象被认为是不同的key。

修正方案

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof User)) return false;
    User user = (User) o;
    return Objects.equals(id, user.id);
}

@Override
public int hashCode() {
    return Objects.hash(id);
}

使用IDE自动生成或@EqualsAndHashCode(Lombok),并保证参与比较的字段一致。


7. 浮点数精度问题

问题现象

System.out.println(0.1 + 0.2); // 0.30000000000000004
double price = 19.9;
int total = (int)(price * 100); // 可能是1989而非1990

原因分析 float/double基于IEEE754二进制浮点表示,无法精确表示大多数十进制小数,涉及金额计算时误差会累积放大。

修正方案 金额类计算统一使用BigDecimal,且构造时用字符串而非double

BigDecimal price = new BigDecimal("19.9");
BigDecimal qty = new BigDecimal("3");
BigDecimal total = price.multiply(qty).setScale(2, RoundingMode.HALF_UP);

数据库层面金额字段建议用DECIMAL类型而非FLOAT/DOUBLE


8. 日期时间处理陷阱

问题现象

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// 多线程共享同一个sdf实例,parse/format结果错乱

原因分析 SimpleDateFormat内部用可变的Calendar字段存储中间状态,非线程安全;Date本身也不携带时区信息,跨时区场景容易出错。

修正方案 优先使用JDK8+的java.time包(LocalDateTime/ZonedDateTime/DateTimeFormatter),这些类都是不可变、线程安全的。

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate date = LocalDate.parse("2026-07-25", formatter);

// 涉及跨时区,显式使用ZonedDateTime
ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));

如必须用SimpleDateFormat,用ThreadLocal包装或换成FastDateFormat(Apache Commons)。


9. 反射与动态代理常见错误

问题现象

  • Class.forName加载类未考虑ClassLoader隔离,导致ClassNotFoundException或类型转换异常。
  • 反射调用私有方法/字段未setAccessible(true)IllegalAccessException
  • JDK动态代理只能代理接口,代理类实现类方法直接调用不生效(丢失AOP增强)。

原因分析 JDK动态代理基于接口生成代理类,若目标对象没有实现接口或调用方持有的是目标类引用(非代理对象),则AOP不会生效——这是Spring AOP自调用失效的根本原因之一。

修正方案

// 反射访问私有成员
Field field = clazz.getDeclaredField("name");
field.setAccessible(true);

// 无接口场景使用CGLIB代理,或Spring配置proxyTargetClass=true强制类代理
@EnableAspectJAutoProxy(proxyTargetClass = true)

10. 资源泄漏(流未关闭)

问题现象

FileInputStream fis = new FileInputStream(file);
// 处理逻辑抛异常,fis.close()未被执行,文件句柄泄漏

原因分析 异常发生时若未在finally中关闭资源,会导致文件句柄、数据库连接、网络Socket等系统资源持续占用,最终耗尽(如"too many open files")。

修正方案 使用try-with-resources(要求资源类实现AutoCloseable)。

try (FileInputStream fis = new FileInputStream(file);
     BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) {
    // 使用reader
} catch (IOException e) {
    log.error("读取文件失败", e);
}
// 无需手动close,且多个资源按声明逆序自动关闭

数据库连接、MQ连接同理,务必确保连接池归还(finallyclose或使用连接池自动管理机制)。


11. static关键字陷阱

问题现象

public class Counter {
    static int count = 0; // 所有实例共享,多用户/多请求场景数据串了
    public void increment() { count++; }
}

class Parent {
    static void hello() { System.out.println("parent"); }
}
class Child extends Parent {
    static void hello() { System.out.println("child"); } // 不是重写,是隐藏(hiding)
}
Parent p = new Child();
p.hello(); // 输出parent,而非期望的child,因为静态方法不参与多态

原因分析 static变量属于类而非实例,所有对象共享同一份内存,在Web应用中如果误把请求级/用户级数据存成static,会导致并发请求间数据互相污染;static方法不能被重写,子类同名静态方法只是"隐藏"了父类方法,调用哪个方法由编译期的引用类型决定,而非运行时的实际对象类型(这与实例方法的多态行为完全相反)。

修正方案

// 请求级/用户级数据不要用static,改用实例变量或ThreadLocal
private int count = 0; // 或用AtomicInteger配合真正需要全局计数的场景

// 需要多态行为,用实例方法而非静态方法
class Parent {
    void hello() { System.out.println("parent"); } // 去掉static
}

只有真正"属于类本身、与实例无关、所有对象共享是预期行为"的数据(如全局配置、常量、单例引用)才适合用static,涉及用户请求上下文的数据一律不能用static


12. 内部类/匿名内部类导致的内存泄漏

问题现象

public class MainActivity {
    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) { /* 处理逻辑 */ }
    }; // 匿名内部类隐式持有外部类MainActivity的引用
}
// MainActivity已经销毁,但因为Handler中还有未处理完的延迟消息持有它的引用,导致无法被GC回收

原因分析static的内部类、匿名内部类、Lambda表达式(部分场景)都会隐式持有外部类实例的引用,如果这个内部类对象的生命周期比外部类更长(比如被注册到消息队列、被其他单例持有),就会导致外部类对象无法被垃圾回收,形成内存泄漏。这在Android开发中是经典问题,但在长生命周期的后端监听器、回调注册场景同样会出现。

修正方案

// 方案1:改用静态内部类+弱引用持有外部类
private static class MyHandler extends Handler {
    private final WeakReference<MainActivity> activityRef;
    MyHandler(MainActivity activity) { this.activityRef = new WeakReference<>(activity); }
    @Override
    public void handleMessage(Message msg) {
        MainActivity activity = activityRef.get();
        if (activity != null) { /* 处理逻辑 */ }
    }
}

// 方案2:外部类销毁时主动清理内部类持有的引用/注销监听
@PreDestroy
public void cleanup() {
    eventBus.unregister(this);
}

后端场景中,向某个长生命周期的单例(如事件总线、缓存管理器)注册回调/监听器时,务必确保在使用方销毁时主动注销,否则等同于持有了一条无法断开的强引用链。


13. 序列化陷阱

问题现象

public class User implements Serializable {
    private String name;
    private transient String password; // 反序列化后password变为null
    // 未显式声明serialVersionUID
}
// 修改了User类结构后,反序列化旧数据报InvalidClassException: local class incompatible

public enum Singleton implements Serializable {
    INSTANCE;
} 
// 用普通类实现的单例,即使私有构造函数,反序列化会创建新实例,破坏单例

原因分析 transient修饰的字段不参与序列化,反序列化后会是默认值(对象为null,基本类型为0/false),常被误用导致密码等字段"丢失"却未被发现;未显式声明serialVersionUID时,JVM会根据类的结构自动生成一个,类结构发生任何变化(增删字段、方法)都会导致该值变化,历史序列化数据无法被新版本类反序列化;普通类实现的单例模式,反序列化时会调用readObject重新创建对象实例,绕过了私有构造函数的限制,破坏单例保证。

修正方案

public class User implements Serializable {
    private static final long serialVersionUID = 1L; // 显式声明,避免类结构变化导致反序列化失败
    private String name;
    // 不希望持久化的敏感字段用transient,但要清楚知道它反序列化后会丢失
    private transient String password;
}

// 单例模式如果要支持序列化,用enum实现(JVM保证enum不会被反序列化破坏),
// 或在类中显式实现readResolve()方法返回既有实例
protected Object readResolve() { return INSTANCE; }

14. switch穿透与枚举比较陷阱

问题现象

switch (status) {
    case 1:
        doSomething(); // 忘记break
    case 2:
        doOther(); // status=1时会意外执行到这里
        break;
}

enum Status { ACTIVE, INACTIVE }
Status s = getStatus();
if (s == Status.ACTIVE) { ... } // 正常,枚举用==比较是安全的(枚举实例全局唯一)
if ("ACTIVE".equals(s)) { ... } // 错误写法,类型都不匹配

原因分析 传统switch语句case分支不写break会"穿透"继续执行下一个分支,是非常经典且高频的疏忽性bug;枚举类型比较用==本身是安全的(枚举实例是单例,JVM保证同一个枚举常量全局只有一个对象),但容易被误解为"引用类型必须用equals"从而写出不必要甚至错误的比较代码。

修正方案

// 每个case后加break,或使用JDK14+的switch表达式(自动无穿透)
switch (status) {
    case 1 -> doSomething();
    case 2 -> doOther();
    default -> doDefault();
}

// 枚举比较统一用==(更安全,能在编译期做null检查提示,equals反而可能因为NPE风险更差)
if (s == Status.ACTIVE) { ... }

使用IDE的静态检查工具(如IDEA自带的"missing break"提示)或Checkstyle/SonarLint规则强制检查switch穿透问题,代码审查中重点关注多caseswitch语句。


15. 深拷贝与浅拷贝混淆

问题现象

class Order implements Cloneable {
    private List<Item> items;
    @Override
    public Order clone() throws CloneNotSupportedException {
        return (Order) super.clone(); // 浅拷贝,items引用被复制,两个Order共享同一个List
    }
}
Order copy = original.clone();
copy.getItems().add(newItem); // 修改copy的items,original的items也被意外修改

原因分析 Object.clone()默认是浅拷贝,只复制对象本身的字段值,对于引用类型字段(如List/Map/自定义对象),复制的是引用地址而非新建一份数据,导致拷贝后的对象与原对象共享同一份引用数据,修改一方会影响另一方,这是非常隐蔽的bug源头。

修正方案

// 方案1:手动深拷贝每个引用字段
@Override
public Order clone() throws CloneNotSupportedException {
    Order cloned = (Order) super.clone();
    cloned.items = new ArrayList<>(this.items); // 至少做到集合本身不共享
    cloned.items.replaceAll(item -> item.clone()); // 集合内的对象也需要深拷贝(如果Item可变)
    return cloned;
}

// 方案2(更推荐):用JSON序列化/反序列化实现通用深拷贝,避免手写每个字段
Order copy = JSON.parseObject(JSON.toJSONString(original), Order.class);

// 方案3:用成熟工具库如Apache Commons Lang的SerializationUtils.clone(需实现Serializable)

业务上凡是涉及"复制一份对象用于修改,且不希望影响原对象"的场景,都要明确评估字段是否需要深拷贝,尤其是包含集合、日期、自定义对象的字段。


16. 数组协变与整数溢出

问题现象

Object[] objects = new String[3]; // 数组协变,编译期允许
objects[0] = 100; // 运行时抛出ArrayStoreException

int a = Integer.MAX_VALUE;
int b = a + 1; // 溢出,结果变成Integer.MIN_VALUE(负数),没有任何异常提示
long total = list.size() * itemPrice; // int运算溢出后才转为long,为时已晚

原因分析 Java数组是协变的(String[]Object[]的子类型),编译期类型检查会放行,但JVM在运行时给数组元素赋值时会做实际类型检查,类型不匹配直接抛异常;int运算溢出不会抛异常,而是静默按位回绕,在金额计算、数量统计等场景极易埋下难以察觉的逻辑错误,尤其是"先做int运算再转long"这种顺序错误。

修正方案

// 数组协变问题:尽量使用泛型集合(List<T>)替代数组,泛型有更严格的编译期检查
List<String> list = new ArrayList<>();

// 整数溢出:提前使用更大范围的类型,或做溢出检测
long total = (long) list.size() * itemPrice; // 先转型再运算,避免int中间结果溢出
int safeAdd = Math.addExact(a, b); // JDK8+,溢出时主动抛出ArithmeticException而非静默出错

涉及金额、库存数量等业务关键计算,优先使用Math.addExact/Math.multiplyExact等带溢出检测的方法,或直接使用BigDecimal/long规避int范围限制。