Java 服务代码首先是 Java 程序,然后才是 Spring 应用。类型设计、对象共享、异常与资源边界没有想清楚,增加注解通常只会让问题更难发现。本页以 Java 21 为语法基线,不依赖预览特性。

对象有效性放在构造边界

金额由数值与币种共同决定,不应在各处散落两个不相关参数。record 适合表达小型不可变数据载体,但只提供浅层不可变:若组件引用可变 List,列表内容仍然能改变。

record Money(long minor, String currency) {
    Money {
        if (minor < 0 || currency == null || currency.isBlank()) {
            throw new IllegalArgumentException("invalid money");
        }
    }
    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(Math.addExact(minor, other.minor), currency);
    }
}

Math.addExact 让溢出成为异常,而不是悄悄绕回负数。引用相等用 == 判断是否同一对象;业务值比较使用合适的 equals。泛型帮助在编译时表达容器类型,但类型擦除等运行时边界仍要理解,不能依靠强制转换掩盖错误。

资源关闭不由垃圾回收替代

文件、Socket、数据库连接常持有堆外或系统资源。使用 try-with-resources 在正常与异常退出时关闭 AutoCloseable;多个资源按反向顺序关闭。连接池的 Connection.close 通常是归还连接,而不是销毁整个物理连接。

捕获异常应决定恢复、转成业务错误或继续传播。包装时保留 cause:throw new IllegalStateException("load reservation", original);。吞掉异常再返回空列表,会把数据库故障伪装成“没有数据”。checked exception 的声明也不等于调用者已经正确恢复。

共享对象与虚拟线程

Spring 单例服务默认会被多个请求同时调用,不能把“当前用户”“本次订单”保存在实例字段中。每次调用的局部值、不可变配置与有明确同步规则的共享状态应区分开。

Java 21 的虚拟线程可通过 Executors.newVirtualThreadPerTaskExecutor() 创建。下面实验提交 1000 次计数,等待每个 Future 后再检查结果,避免主线程先退出。原子计数保证共享写入不丢失;虚拟线程并不自动让普通 int 安全。

完整代码可下载 MoneyAndCounter.java,在该文件目录运行:

java MoneyAndCounter.java

程序检查金额相加、不同币种被拒绝、并发结果为 1000,失败时抛异常;成功打印 money=3990 CNY; counter=1000。这是本机功能实验,不测吞吐。

JVM 诊断从现象选工具

堆内存容纳对象,线程栈保存每个线程的调用状态;堆外内存、代码缓存和本地库还会占用其他进程内存。堆不足、线程过多和容器内存上限触发退出,需要不同证据。

线程转储帮助看锁等待与阻塞位置,JFR 帮助观察运行时事件,堆转储帮助分析对象保留。高分配率可能增加 GC 压力;“有 GC”不代表存在泄漏。先观察暂停、分配和存活对象,再调堆大小,避免仅靠一套传说中的 JVM 参数。

练习与面试自查

把 AtomicInteger 换成普通 int,并故意在读取和写入之间协调两个线程,构造一次确定丢失更新。再解释为什么只加 volatile 仍不够。给 Money 增加减法,明确负余额是否允许,再编写边界测试。

  • record 中包含可变集合时,怎样在构造边界防止外部修改?
  • try-with-resources 遇到业务异常和关闭异常时,如何保留证据?
  • 虚拟线程适合什么负载?为什么数据库连接池仍然必须限量?

参考资料