Java 开发最佳实践:从代码规范到性能优化的全方位指南
一套经过生产环境验证的 Java 最佳实践,涵盖代码风格、异常处理、集合操作、并发编程、性能优化、测试规范与日志管理七大维度,帮助初中级开发者写出更健壮、更高效的企业级代码。
目录
一、代码风格与命名规范
代码风格是团队协作的基石。一致的命名规范和格式化规则能显著降低 Code Review 的心智负担,让代码读起来像散文一样流畅。无论是一个人维护的 Side Project,还是上百人协作的企业系统,统一的风格都能让新成员快速上手、让维护者准确定位问题。
1.1 包名:全小写,用点分层
包名应该采用反向域名前缀,全部小写,用点号分隔层级。避免使用下划线、驼峰或大写字母。正确的分层能让项目在 50 个类时和 500 个类时一样清晰。一个典型的项目结构如下:
com.company.project
├── controller // REST 接口层
├── service // 业务逻辑层
│ └── impl // 接口实现(可选)
├── repository // 数据访问层
├── model // 领域模型
│ ├── entity // JPA 实体
│ ├── dto // 数据传输对象
│ └── vo // 视图对象
├── config // 配置类
├── interceptor // 拦截器
├── exception // 自定义异常
└── util // 工具类
常见反模式:将 Controller、Service、Repository 全部塞在同一级包下。当项目膨胀到 50+ 类时,浏览文件列表就会成为负担。另一个常见错误是包名混用单复数和缩写——要么统一用 util,要么统一用 utils,不要两者并存。
1.2 类名与方法名:说人话
类名用大驼峰命名(PascalCase),通常是名词或名词短语。接口名不要加 I 前缀——这是 C# 的习惯,不是 Java 的:
// ✅ 推荐
public class OrderService {}
public class EmailNotificationSender {}
public class UserProfileDTO {}
public interface PaymentGateway {} // 不是 IPaymentGateway
// ❌ 避免
public class order_service {} // 这是 C 风格
public class DoSomething {} // 动词短语当类名
public class Data {} // 太过泛化,Data 是什么数据?
public interface IPaymentGateway {} // 不 Java
方法名用小驼峰命名(camelCase),通常是动词或动词短语。布尔值返回方法建议用 is / has / can / should 前缀,让调用代码读起来像自然语言:
// ✅ 推荐
public void processPayment() {}
public boolean hasPermission(String role) {}
public boolean canCancel(Order order) {}
public List<User> findActiveUsers() {}
// ❌ 避免
public void payment() {} // 缺动词,不明确是查询还是处理
public boolean permission() {} // 读起来别扭
public List<User> active() {} // 太模糊
1.3 常量:大写+下划线,集中管理
魔法数字和魔法字符串是 Bug 的温床。常量应定义为 static final,全大写加下划线分隔。对于在整个系统中多处使用的常量,建议抽取为独立的常量类或枚举:
// ✅ 推荐:常量类按领域分组
public final class CacheConstants {
private CacheConstants() {} // 防止实例化
public static final String USER_CACHE = "user:cache";
public static final long TTL_5MIN = 300L;
public static final long TTL_1HOUR = 3600L;
}
// ❌ 避免:魔法数字散落各处
if (status == 7) { ... } // 7 是什么?
if (type.equals("VIP_PREMIUM_ELITE")) { ... } // 容易拼错
对于一组相关常量,优先使用 enum 而不是一堆 static final int。枚举提供编译期类型检查,杜绝传入非法值的可能:
// ✅ 推荐:类型安全 + 自解释
public enum OrderStatus {
PENDING("待支付", false),
PAID("已支付", false),
SHIPPING("配送中", false),
COMPLETED("已完成", true),
CANCELLED("已取消", true);
private final String displayName;
private final boolean terminal; // 是否终态
OrderStatus(String displayName, boolean terminal) {
this.displayName = displayName;
this.terminal = terminal;
}
public boolean canTransitionTo(OrderStatus target) {
// 终态不能再流转
if (this.terminal) return false;
// 允许状态机定义的所有合法转换
return switch (this) {
case PENDING -> target == PAID || target == CANCELLED;
case PAID -> target == SHIPPING || target == CANCELLED;
case SHIPPING -> target == COMPLETED;
default -> false;
};
}
}
1.4 代码格式化:告别审美争论
在 .editorconfig 或 IDE 配置中统一以下规则,用 Spotless 或 Checkstyle 自动执行。笔者见过太多 Code Review 的时间被浪费在讨论"这里该不该换行"上——工具能解决的问题就不要让人来争论:
| 规则 | 推荐值 | 原因 |
|---|---|---|
| 缩进 | 4 个空格(不用 Tab) | 跨平台显示一致 |
| 行宽 | 120 字符 | 适合分屏 Code Review |
| 大括号 | 行尾开、独立闭(K&R) | Java 社区主流 |
| import | 不使用通配符 * |
明确依赖来源,便于 IDE 分析和重构 |
| 空行 | 逻辑块之间一行 | 增强可读性 |
| 文件末尾 | 一个空行 | POSIX 标准,避免 git diff 噪声 |
1.5 注释:解释"为什么"而非"做什么"
好的代码自解释,注释应聚焦于业务背景和设计权衡。代码告诉你"怎么做",注释告诉你"为什么这么做"。尤其要避免的三种低质量注释:废话注释、僵尸代码注释和情绪化注释:
// ✅ 推荐:解释为什么这样做
// 使用 CopyOnWriteArrayList 而非同步块加锁,
// 因为读操作(99%)远多于写操作(1%),
// COW 的写入开销在此场景下可接受。
// 参考:https://issues.apache.org/jira/browse/XXX-1234
private final List<EventListener> listeners = new CopyOnWriteArrayList<>();
// ❌ 废话:重复代码已经表达的信息
// 遍历用户列表
for (User user : users) { ... }
// ❌ 僵尸代码:注释掉的代码应立即删除(Git 会记住历史)
// public void oldMethod() {
// // 这个老方法先保留,说不定以后有用
// }
二、异常处理的艺术
异常处理不当是生产事故的头号元凶之一。一个未捕获的 NullPointerException 就能让整个请求链路崩溃,而一个被吞掉的异常则会让 Bug 潜伏数周后才在数据不一致中暴露。掌握以下原则,让你的异常处理从"救火"升级为"防火"。
2.1 优先使用标准异常
JDK 内置的异常类型覆盖了大部分场景,不需要为每个错误都自定义异常类。自定义异常的价值在于表达业务语义(如 InsufficientBalanceException),而非重复标准异常已经表达的含义:
| 场景 | 推荐异常 | 示例 |
|---|---|---|
| 参数为 null | NullPointerException |
Objects.requireNonNull(param) |
| 参数值非法 | IllegalArgumentException |
金额不能为负数 |
| 状态不正确 | IllegalStateException |
订单已取消时不能支付 |
| 不支持的操作 | UnsupportedOperationException |
不可变集合的 add |
| 资源未找到 | NoSuchElementException |
Optional.orElseThrow() |
| 并发错误 | ConcurrentModificationException |
迭代中修改集合 |
| 索引越界 | IndexOutOfBoundsException |
list.get(999) |
| IO 错误 | IOException |
文件读写失败 |
// ✅ 推荐:用标准异常表达意图
public void validateAmount(BigDecimal amount) {
Objects.requireNonNull(amount, "金额不能为 null");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("金额必须大于零: " + amount);
}
}
// ✅ 自定义异常:表达业务语义
public class InsufficientBalanceException extends RuntimeException {
private final BigDecimal available;
private final BigDecimal required;
public InsufficientBalanceException(BigDecimal available, BigDecimal required) {
super("余额不足: 可用=" + available + ", 需要=" + required);
this.available = available;
this.required = required;
}
}
2.2 异常链:永远不要吞掉根因
捕获异常后重新抛出时,必须保留原始异常作为 cause,否则日志中只能看到无意义的包装异常,就像医生只告诉你"身体不舒服"而不说具体病因:
// ✅ 推荐:保留异常链
try {
userRepository.save(user);
} catch (DataAccessException e) {
throw new ServiceException("保存用户失败, userId=" + user.getId(), e); // ← root cause
}
// ❌ 灾难:吞掉根因
try {
userRepository.save(user);
} catch (DataAccessException e) {
throw new ServiceException("保存用户失败");
// 排查时日志只有这个,完全不知道是主键冲突、连接超时还是字段过长
}
2.3 受检异常 vs 非受检异常:当代共识
Java 的受检异常(Checked Exception)设计初衷是好的——强制调用方处理可恢复的错误。但实践中,它导致了大量的 try-catch 噪音和"吞异常"反模式。现代 Java 社区的共识是:
- 业务异常:继承
RuntimeException(非受检),在全局异常处理器统一处理 - 可恢复的异常:少数场景下使用受检异常(如文件读取失败时使用默认配置)
- 不可恢复的异常:放手让它向上传播,由框架的兜底机制处理,记录日志并返回 500
// ✅ 推荐:Spring Boot 全局异常处理器
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
return ResponseEntity.badRequest()
.body(ErrorResponse.of(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest()
.body(ErrorResponse.of("VALIDATION_ERROR", msg));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
log.error("未预期的异常", e);
return ResponseEntity.status(500)
.body(ErrorResponse.of("INTERNAL_ERROR", "服务器内部错误"));
}
}
2.4 try-with-resources:告别 finally 地狱
任何实现了 AutoCloseable 的资源(流、连接、锁等)都应该用 try-with-resources 管理。Java 7 引入的这一特性从根本上消灭了资源泄漏和 finally 块中的二次异常覆盖问题。它的底层机制是编译器自动生成 finally 块调用 close(),而且在 close() 抛出的异常会被压制(suppressed)而不是覆盖原始异常:
// ✅ 推荐:多个资源同时管理,自动反向关闭
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
processRow(rs);
}
} // rs → stmt → conn 按声明逆序自动关闭
// ✅ JDBC 的现代替代:JdbcTemplate 已内置资源管理
List<User> users = jdbcTemplate.query(
"SELECT * FROM users WHERE status = ?",
(rs, rowNum) -> mapUser(rs),
"ACTIVE"
);
2.5 Optional:告别 NPE 恐惧症
Optional 是 Java 8 引入的"可能为空"容器,其设计初衷是让 API 的返回值语义清晰——"这个方法可能不返回结果"。但 Optional 有其适用的边界。核心原则只有一条:Optional 只做返回值类型,不作为字段、方法参数或集合元素:
// ✅ 推荐:链式处理可能为空的值
public String getCityName(User user) {
return Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.map(City::getName)
.orElse("未知城市");
}
// ✅ 抛出具体异常而非返回 null
public User findByEmail(String email) {
return userRepo.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException(email));
}
// ❌ Optional 的常见误用
public class User {
private Optional<String> nickname; // 不要!序列化有问题
}
public void process(Optional<String> param) {} // 不要!调用方被迫包装
三、集合与流的正确打开方式
Java 集合框架是日常开发中使用最频繁的 API 之一。用得对,代码简洁高效;用错了,性能问题可能会在数据量上来后悄然爆发——从毫秒级变为秒级,从秒级变为分钟级。
3.1 选择合适的集合类型
不同集合类型在时间复杂度上的差异可以相差数个数量级。面对常见场景时,对照下表选择。关键在于理解每种集合的底层数据结构——ArrayList 是数组所以随机访问 O(1)、LinkedList 是双向链表所以头尾操作 O(1) 但随机访问 O(n):
| 需求场景 | 推荐集合 | 底层结构 | 核心操作复杂度 |
|---|---|---|---|
| 频繁随机访问 | ArrayList |
动态数组 | get O(1), add O(1)* |
| 频繁头尾增删 | ArrayDeque |
循环数组 | addFirst/Last O(1) |
| 需要去重 | HashSet |
哈希表 | add/contains O(1) |
| 需要排序+去重 | TreeSet |
红黑树 | add/contains O(log n) |
| 需要计数/分组 | HashMap |
哈希表+链表/红黑树 | put/get O(1) |
| 需要 LRU 缓存 | LinkedHashMap |
哈希表+双向链表 | 保持插入/访问顺序 |
| 线程安全列表 | CopyOnWriteArrayList |
写时复制数组 | 读无锁,写 O(n) |
| 线程安全 Map | ConcurrentHashMap |
分段哈希表 | 高并发读写 |
// ✅ 推荐:预估容量,避免扩容开销
List<String> items = new ArrayList<>(expectedSize);
Map<String, User> userCache = new HashMap<>(expectedSize, 0.75f);
// ❌ 避免:默认容量 10,频繁扩容涉及 O(n) 数组复制
List<String> items = new ArrayList<>(); // 插入 10000 个元素时扩容约 14 次
3.2 Stream API:声明式数据处理
Stream 让集合操作从"怎么做"变为"要什么",代码可读性大幅提升。但 Stream 不是银弹——它有额外的对象分配开销,不适合极致性能敏感的循环。以下场景建议避开 Stream:
- 简单的 for-each 循环(少量操作,Stream 对象开销不值得)
- 需要访问非 effectively final 变量的操作
- 需要
break/continue/return提前退出的循环 - 需要修改外部集合或状态的场景
- 嵌套循环(此时 Stream 的 flatMap 会导致可读性下降)
// ✅ Stream 最适合的场景:多步流水线操作
List<String> topPaidUsers = users.stream()
.filter(u -> u.getOrders().size() > 10)
.sorted(Comparator.comparing(User::getTotalPay).reversed())
.limit(20)
.map(u -> u.getName() + " (¥" + u.getTotalPay() + ")")
.collect(Collectors.toList());
// ✅ 复杂分组:按部门和职级双层分组统计薪资
Map<Department, Map<Level, DoubleSummaryStatistics>> stats = users.stream()
.collect(Collectors.groupingBy(
User::getDepartment,
Collectors.groupingBy(
User::getLevel,
Collectors.summarizingDouble(User::getSalary)
)
));
// ✅ partitioningBy:二分类(比 filter 两次更优雅)
Map<Boolean, List<User>> partitioned = users.stream()
.collect(Collectors.partitioningBy(u -> u.getAge() >= 18));
List<User> adults = partitioned.get(true);
List<User> minors = partitioned.get(false);
3.3 不可变集合:防御性编程的第一道墙
JDK 9+ 的 List.of() / Set.of() / Map.of() 让不可变集合的创建变得极其简单,无需再依赖 Guava 或 Collections.unmodifiableList()。不可变集合天然线程安全,且杜绝了意外修改的 Bug:
// ✅ JDK 9+: 工厂方法
List<String> COLORS = List.of("RED", "GREEN", "BLUE");
Set<Integer> PRIMES = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> SCORES = Map.of("Alice", 95, "Bob", 87);
// ✅ 防御性拷贝:不信任外部传入的集合
public void processOrders(List<Order> orders) {
List<Order> safeCopy = List.copyOf(orders); // 不可变拷贝
// 即使调用方后续修改原始 orders,此处不受影响
}
// ✅ 返回不可变集合,让调用方清楚地知道不应修改
public List<String> getAdminRoles() {
return List.of("SUPER_ADMIN", "USER_MANAGER", "AUDITOR");
}
3.4 谨慎使用 parallelStream
并行流并非免费的午餐。线程调度、任务拆分、结果合并都有不可忽视的开销。只有在以下条件全部满足时才考虑使用:
- 数据量足够大(通常 > 10,000 条,少于这个量并行开销超过收益)
- 每个元素的计算开销足够重(毫秒级,非微秒或纳秒级)
- 使用的公共 ForkJoinPool 没有被其他任务占用(注意:所有 parallelStream 默认共享同一个池)
- 操作的集合是可拆分的(ArrayList 拆分效率高,LinkedList 差)
- 没有共享可变状态(包括外部变量和集合)
// ✅ 适合 parallelStream:CPU 密集型独立计算
List<Image> thumbnails = images.parallelStream()
.map(img -> resize(img, 200, 200)) // 每张图耗时 ~20ms
.collect(Collectors.toList());
// ❌ 不适合:轻量操作 + I/O 密集型
names.parallelStream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// ❌ I/O 操作不适合在公共 ForkJoinPool 中跑
urls.parallelStream()
.map(url -> httpClient.send(request)) // 阻塞 I/O,占满线程池
.toList();
四、并发编程安全守则
并发编程是 Java 开发者进阶路上的必过门槛。以下守则来自生产环境的事故复盘——每一条背后都是一个凌晨三点被报警叫醒的真实案例。核心思想只有一句话:尽量减少共享可变状态的表面积。
4.1 共享可变状态:最小化原则
并发 Bug 的根源是共享的可变状态。首要策略不是加锁,而是从设计层面消除共享。三种策略按优先级排列:
// ✅ 策略 1(首选):不可变对象 — 天然线程安全
@Value // Lombok:全 final 字段 + 无 setter + equals/hashCode
public class ImmutableConfig {
String host;
int port;
Duration timeout;
}
// ✅ 策略 2:线程封闭 — 每个线程持有自己的实例
public void process() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// 每次都新建,避免共享
}
// ✅ 策略 3:线程安全类 — 内部已处理好同步
private final ConcurrentHashMap<String, UserSession> sessions = new ConcurrentHashMap<>();
private final AtomicLong requestCounter = new AtomicLong(0);
// ❌ 策略 4(最后手段):手动 synchronized
private final Map<String, UserSession> sessions = new HashMap<>();
public synchronized void addSession(String k, UserSession v) {
sessions.put(k, v);
}
4.2 synchronized 的粒度:锁住该锁的
锁的粒度是性能与安全的平衡术。锁太大影响吞吐,锁太小无法保证原子性。一个常见的衡量标准:锁内不要做 I/O 操作——无论是数据库查询、网络请求还是文件读写,这些操作的耗时比内存操作高 4-6 个数量级:
// ❌ 锁太大:整个方法被锁,包括不共享的局部操作和 I/O
public synchronized void processOrder(Order order) {
String logMsg = buildLogMessage(order); // 纯局部
validateOrder(order); // 纯局部
accountBalance.debit(order.getAmount()); // 只有这里需要同步
sendNotification(order); // 外部 I/O,绝不应该在锁里!
}
// ✅ 锁粒度恰当:最小化同步块
public void processOrder(Order order) {
String logMsg = buildLogMessage(order);
validateOrder(order);
synchronized (accountBalance) {
accountBalance.debit(order.getAmount());
}
sendNotification(order);
}
4.3 volatile 的正确理解
volatile 只保证可见性,不保证原子性。它适用的状态模式极其有限——基本上只有"一个线程写、其他线程读"的简单标志位。任何涉及"先读后写"的操作(如 count++)都不能仅靠 volatile:
// ✅ volatile 适用场景:单写多读的标志位
volatile boolean running = true;
public void shutdown() { running = false; } // 线程 A
public void doWork() {
while (running) { // 线程 B — 总能读到最新值
processNext();
}
}
// ❌ volatile 不适用:复合操作
volatile int counter = 0;
counter++; // 三步:读→加→写,非原子!两个线程可能都读到 5,都写入 6
// 应该用 AtomicInteger 代替
4.4 CompletableFuture:异步编程的现代范式
Java 8 的 CompletableFuture 提供了声明式的异步编程模型。它解决了 Future 的两个核心痛点:无法手动完成、无法链式组合。配合 Java 21 的虚拟线程,异步代码甚至可以写得像同步代码一样直观:
// ✅ 声明式异步流水线:多个异步任务编排
public OrderSummary processOrderAsync(long orderId) {
return CompletableFuture
.supplyAsync(() -> orderRepo.findById(orderId))
.thenApply(opt -> opt.orElseThrow(() -> new NotFoundException("订单不存在")))
.thenCompose(order ->
CompletableFuture.allOf(
CompletableFuture.supplyAsync(() -> userRepo.findById(order.getUserId())),
CompletableFuture.supplyAsync(() -> inventoryRepo.check(order.getItems()))
).thenApply(v -> order)
)
.thenApply(order -> pricingService.calculate(order))
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> {
log.error("订单处理失败: {}", orderId, ex);
return OrderSummary.failed(orderId, ex.getMessage());
})
.join();
}
4.5 线程池:不要用 Executors 便捷工厂
Executors.newCachedThreadPool() 和 Executors.newFixedThreadPool() 虽然方便,但它们的无界队列或无限线程数在生产环境中是定时炸弹。newCachedThreadPool 在突发流量下会创建无数线程直到 OOM;newFixedThreadPool 的无界队列会让任务堆积到内存耗尽。始终用 ThreadPoolExecutor 显式构造:
// ✅ 推荐:显式参数,有界队列 + 明确的拒绝策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // 核心线程数
8, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(200), // 有界队列,防止 OOM
new ThreadFactoryBuilder().setNameFormat("order-worker-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:回退给调用线程
);
// ✅ 监控线程池状态
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
log.info("线程池状态: active={}, queue={}, completed={}",
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount());
}, 10, 30, TimeUnit.SECONDS);
五、性能优化实战手册
性能优化不是玄学——它是对程序行为的测量和改进。这句经典名言值得刻在桌面上:"Premature optimization is the root of all evil." 在动手优化之前,先用 profiler(如 JProfiler、Async Profiler)找到真正的瓶颈。凭直觉优化往往适得其反。
5.1 字符串处理:理解 JVM 的优化
JVM 对字符串做了大量优化(常量池、StringBuilder 自动转换、Compact Strings 等),但你需要配合才能发挥最大效果:
// ✅ 少量拼接(3 个片段以内):直接用 +,JVM 自动优化为 StringBuilder
String message = "用户 " + userName + " 登录成功";
// ✅ 循环拼接或复杂拼接:显式用 StringBuilder,预估容量
StringBuilder sb = new StringBuilder(estimateSize());
for (OrderItem item : order.getItems()) {
sb.append(item.getName())
.append(" ×")
.append(item.getQuantity())
.append("\n");
}
// ✅ JDK 9+: formatted() 比 String.format 更简洁
String msg = "订单 %s 已%s,金额 ¥%.2f".formatted(orderId, status, amount);
// ❌ 循环中用 +:每次迭代创建新的 String 和 StringBuilder
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 创建了 10000 个中间对象
}
5.2 对象创建:热路径上的隐形杀手
在高频调用的代码路径上,不必要的对象分配会导致 GC 压力剧增。以日期格式化和数值包装为例:
// ❌ 每次调用 new,SimpleDateFormat 非线程安全且创建成本高
public String formatDate(Date date) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}
// ✅ 使用 DateTimeFormatter(不可变+线程安全)或 ThreadLocal
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public String formatDate(LocalDateTime dt) {
return FMT.format(dt);
}
// ✅ 使用 valueOf / 缓存值代替 new
Integer count = Integer.valueOf(i); // -128~127 走缓存
Boolean flag = Boolean.TRUE; // 直接用常量
5.3 数据库查询:N+1 是排名第一的性能杀手
N+1 问题在 ORM 框架中极其常见——遍历 N 条记录,每条触发一次额外查询。解决策略按复杂度排列:
// ❌ N+1 问题:查 100 个订单 = 1 + 100 = 101 次 SQL
List<Order> orders = orderRepo.findAll();
for (Order order : orders) {
List<OrderItem> items = orderItemRepo.findByOrderId(order.getId());
order.setItems(items);
// 每循环一次,多一次 SQL
}
// ✅ 方案 1(最优):一条 JOIN FETCH 搞定
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.items " +
"LEFT JOIN FETCH o.user " +
"WHERE o.status = :status")
List<Order> findOrdersWithDetails(@Param("status") String status);
// ✅ 方案 2(灵活):批量 in 查询 + 内存组装
List<Order> orders = orderRepo.findAll();
List<Long> orderIds = orders.stream().map(Order::getId).toList();
List<OrderItem> allItems = orderItemRepo.findByOrderIdIn(orderIds);
Map<Long, List<OrderItem>> itemMap = allItems.stream()
.collect(Collectors.groupingBy(OrderItem::getOrderId));
orders.forEach(o -> o.setItems(itemMap.getOrDefault(o.getId(), List.of())));
// 总共 2 次 SQL,无论多少条订单
5.4 缓存策略:多级金字塔
从快到慢的缓存层次以及各自的适用边界:
| 层级 | 技术选型 | 访问延迟 | 容量 | 适用场景 |
|---|---|---|---|---|
| 本地缓存 | Caffeine / Guava Cache | 纳秒级 | MB~GB | 热点配置、字典数据 |
| 分布式缓存 | Redis / Memcached | 毫秒级 | GB~TB | 会话、计数器、分布式锁 |
| 数据库 | MySQL / PostgreSQL | 毫秒~秒级 | TB+ | 持久化存储 |
| 外部 API | 第三方服务 | 百毫秒~秒级 | 无限制 | 支付、短信、地图 |
// ✅ Caffeine:高性能本地缓存(比 Guava Cache 更快)
Cache<Long, User> userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
User user = userCache.get(userId, id -> userRepo.findById((Long) id).orElse(null));
// ✅ 多级缓存串行回退
public User getUser(long id) {
return localCache.get(id, key ->
redisCache.get(key, k ->
userRepo.findById((Long) k).orElse(null)));
}
5.5 批量处理:游标分页代替 offset
当数据量达到数十万级别时,传统的 LIMIT offset, size 分页性能急剧下降(MySQL 需要扫描并丢弃前 offset 行)。游标分页利用索引精确定位,无论翻到第几页性能恒定:
// ✅ 游标分页:利用主键索引,性能恒定
Long lastId = 0L;
int pageSize = 1000;
List<User> batch;
do {
batch = userRepo.findByIdGreaterThan(lastId, PageRequest.of(0, pageSize));
for (User user : batch) {
process(user);
}
if (!batch.isEmpty()) {
lastId = batch.get(batch.size() - 1).getId();
}
} while (batch.size() == pageSize);
六、测试驱动的最佳实践
测试不是写完代码后的额外工作,而是开发过程的一部分。好的测试用例既是可执行的文档,也是重构时保护行为不变的安全网。没有测试的代码是"遗留代码"——因为你不敢改。
6.1 测试金字塔:投入产出比最优
经典的测试金字塔建议三层比例为 70% 单元测试 → 20% 集成测试 → 10% 端到端测试。倒金字塔(E2E 太多、单元测试太少)是慢速 CI 和脆弱测试的主要元凶。E2E 测试每多一个,CI 时间增加 30-60 秒,而单元测试只需毫秒。
6.2 单元测试的 FIRST 原则
- Fast(快速):一个测试应在毫秒级完成,不能依赖网络或数据库
- Isolated(隔离):测试之间独立运行,不共享可变状态
- Repeatable(可重复):每次运行结果一致,不能依赖系统时间或随机数
- Self-validating(自验证):用断言自动验证,不用肉眼判断
- Timely(及时):最佳时机是在写生产代码之前(TDD)或紧接之后
// ✅ 优秀的单元测试:Given-When-Then 结构清晰
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepo;
@Mock PaymentGateway paymentGateway;
@InjectMocks OrderService orderService;
@Test
void shouldThrowWhenOrderNotFound() {
when(orderRepo.findById(999L)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.pay(999L, BigDecimal.TEN));
}
@Test
void shouldDeductAndMarkPaid() {
Order order = new Order();
order.setTotal(new BigDecimal("200.00"));
when(orderRepo.findById(1L)).thenReturn(Optional.of(order));
orderService.pay(1L, new BigDecimal("200.00"));
verify(paymentGateway).charge(new BigDecimal("200.00"));
assertEquals(OrderStatus.PAID, order.getStatus());
}
}
6.3 参数化测试:覆盖边界条件
JUnit 5 的 @ParameterizedTest 让你用一张数据表覆盖多种场景,远比复制粘贴测试方法高效:
@ParameterizedTest
@CsvSource({
"100, VIP, 90",
"100, REGULAR, 100",
"0, VIP, 0",
})
void testDiscount(double amount, String level, double expected) {
User user = new User(DiscountLevel.valueOf(level));
assertEquals(expected, discountService.calculate(amount, user), 0.01);
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "invalid@", "@example.com"})
void shouldRejectInvalidEmail(String email) {
assertThrows(ValidationException.class,
() => userService.register(email, "password123"));
}
6.4 Mock 的边界:不 Mock 不属于你的类
Mockito 的核心原则:只 mock 你自己的依赖,不要 mock 你不拥有的类。Mock String、ArrayList 或第三方库的内部类是反模式——你没有所有权,它们的实现可能在任何版本中变化。如果某个外部依赖难以测试,创建一个薄封装层(Wrapper)并 mock 这个封装层。
七、日志与监控体系建设
日志是生产环境排障的眼睛。没有好的日志体系,当用户抱怨"订单支付成功但状态没变"时,你只能靠猜。一套完整的可观测性体系包括三大支柱:日志(Logs)、指标(Metrics)和链路追踪(Traces)。
7.1 日志级别使用规范
每个级别的日志应该有明确的"读者"和"行动预期":
| 级别 | 目标读者 | 行动预期 | 示例 |
|---|---|---|---|
| ERROR | 值班工程师 | 立刻排查 | 支付回调验签失败、数据库连接断开 |
| WARN | 开发团队 | 当天关注 | 缓存未命中降级、重试耗尽、配额接近上限 |
| INFO | 产品/运营 | 了解业务状态 | 订单创建、用户注册、定时任务完成 |
| DEBUG | 开发者 | 开放问题时启用 | 方法入参出参、中间计算步骤 |
| TRACE | 开发者 | 极少使用 | 循环中的逐次迭代详情 |
// ✅ 结构化日志:用占位符 + key=value 格式
log.info("订单创建成功 orderId={} userId={} amount={} channel={} elapsed={}ms",
order.getId(), order.getUserId(), order.getAmount(),
order.getChannel(), System.currentTimeMillis() - start);
// ✅ 异常日志:始终传 exception 对象作为最后一个参数
try {
paymentGateway.charge(amount);
} catch (PaymentException e) {
log.error("支付失败 orderId={} amount={}", orderId, amount, e); // e 必须在最后
}
7.2 MDC:给每行日志打上请求烙印
在微服务架构中,一个用户请求可能跨越 3-5 个服务。MDC(Mapped Diagnostic Context)在每个线程的上下文中存储键值对,让同一请求的所有日志都带上 traceId:
@Component
public class TraceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
String traceId = req.getHeader("X-Trace-Id");
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
MDC.put("userId", req.getHeader("X-User-Id"));
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object handler, Exception ex) {
MDC.clear(); // 🔴 必须清理,否则线程复用时污染下一个请求
}
}
然后在 logback-spring.xml 中配置 pattern 引用 MDC 变量:
<pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
7.3 关键业务指标监控
日志告诉你"发生了什么",指标告诉你"发生的有多频繁、有多慢"。以下是每个服务都应该监控的核心指标(RED 方法:Rate、Errors、Duration):
@RestController
public class OrderController {
private final MeterRegistry meterRegistry;
@PostMapping("/orders")
public Order createOrder(@RequestBody OrderRequest req) {
Timer.Sample sample = Timer.start(meterRegistry);
try {
Order order = orderService.create(req);
// Rate: 请求速率
meterRegistry.counter("orders.created.total").increment();
return order;
} catch (Exception e) {
// Errors: 错误速率
meterRegistry.counter("orders.created.errors",
"error", e.getClass().getSimpleName()).increment();
throw e;
} finally {
// Duration: 处理耗时
sample.stop(Timer.builder("orders.create.duration")
.description("订单创建耗时")
.publishPercentiles(0.5, 0.95, 0.99) // P50/P95/P99
.register(meterRegistry));
}
}
}
八、常见问题 FAQ
Q1: Lombok 到底该不该用?
推荐用,但有节制。@Data 对 JPA 实体过于激进——它会生成包含所有字段(包括懒加载代理)的 equals/hashCode/toString,可能在调试时触发 LazyInitializationException。建议:
- 实体类:只用
@Getter、@Setter、@NoArgsConstructor - DTO/VO:放心用
@Data或@Value(不可变版本) @Builder:非常适合构造复杂对象,但记得加@AllArgsConstructor
Q2: Java 版本怎么选?
新项目直接上 Java 21 LTS(下一个 LTS 是 Java 25,预计 2025 年 9 月发布)。虚拟线程、记录模式、模式匹配、密封类对生产力和性能的提升是实打实的。老项目至少升级到 Java 17 LTS(支持到 2029 年),Java 11 和 8 的免费支持均已终止。
Q3: Spring Boot vs Quarkus 怎么选?
- Spring Boot:生态最大、资料最多、招聘最容易。适合大多数企业项目
- Quarkus:启动快(毫秒级)、内存小、支持 GraalVM 原生编译。适合容器化和 Serverless
- 决策公式:如果团队已熟悉 Spring → 用 Spring Boot。如果追求极致启动速度和低内存 → 评估 Quarkus。不要因为"新技术很酷"而引入团队不熟悉的框架
Q4: 什么时候该上微服务?
先单体后拆分。微服务解决的是组织扩展问题,而非技术问题。满足以下 3 项以上再考虑拆分:
- 团队 > 20 人,单仓库协作冲突频繁
- 不同模块有独立的发布节奏和 SLA 要求
- 某模块需要独立扩容(如秒杀模块)
- 需要用不同技术栈或数据存储
如果不满足,单体 + 模块化(如 Java Module System 或 Gradle 多模块)是最优解。
Q5: == vs equals() 如何选择?
- 基本类型(int、long、boolean 等):只能用
== - 引用类型比较值:用
equals() - 引用类型比较身份(同一对象):用
== - Integer 的 -128~127 范围内
==也成立是缓存实现细节,不要依赖
Integer a = 100, b = 100;
a == b; // true(在缓存范围 -128~127)
Integer c = 200, d = 200;
c == d; // false(超出缓存范围)
c.equals(d); // true
Q6: JPA vs MyBatis 选哪个?
- 简单 CRUD 为主、实体关系复杂 → JPA(自动管理关联、缓存)
- SQL 复杂、报表、遗留数据库 → MyBatis(直接控制 SQL)
- 不确定 → JPA 为主,遇到复杂查询时在 Repository 中用
@Query(nativeQuery=true)补充原生 SQL
Q7: 如何处理大文件上传和下载?
不要将整个文件读入内存。使用流式处理:
// 上传:流式写入
@RequestMapping("/upload")
public String upload(MultipartFile file) throws IOException {
try (InputStream in = file.getInputStream();
OutputStream out = Files.newOutputStream(Path.of("/data/" + file.getOriginalFilename()))) {
in.transferTo(out);
}
return "ok";
}
// 下载:流式返回
@GetMapping("/download/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
FileInfo info = fileService.getFileInfo(id);
Path path = Path.of(info.getStoragePath());
Resource resource = new InputStreamResource(Files.newInputStream(path));
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("Content-Disposition", "attachment; filename=\"" + info.getOriginalName() + "\"")
.body(resource);
}
Q8: 全局异常处理器中怎么区分业务异常和系统异常?
通过异常类型体系区分,而非通过异常消息字符串匹配。定义清晰的异常继承层次:
// 基类
public abstract class AppException extends RuntimeException {
private final String code;
protected AppException(String code, String message) { super(message); this.code = code; }
public String getCode() { return code; }
}
// 业务异常:返回 400,消息可展示给用户
public class BusinessException extends AppException {
public BusinessException(String code, String message) { super(code, message); }
}
// 系统异常:返回 500,消息仅供内部排查
public class SystemException extends AppException {
public SystemException(String message, Throwable cause) { super("SYS_ERROR", message); }
}
Q9: Maven 和 Gradle 该选哪个?
两者都是优秀的构建工具,选择取决于项目特点:
- Maven:约定优于配置,XML 声明式,生态成熟稳定。适合大多数标准项目,新人上手快
- Gradle:基于 Groovy/Kotlin DSL,构建脚本更灵活,增量构建和缓存更智能。适合大型多模块项目、自定义构建逻辑复杂的场景
实际建议:如果你在问这个问题,就用 Maven。全世界的 CI/CD 系统、IDE 和插件对 Maven 的支持最完善。Gradle 的上限更高但学习曲线更陡。
Q10: 数据库事务传播行为怎么选?
Spring 的 @Transactional 默认传播行为是 REQUIRED(如果已有事务则加入,没有则新建),它在 90% 的场景下是正确的。关键是理解另外两种:
// REQUIRES_NEW:暂停当前事务,始终开启新事务
// 适用:日志记录、审计——即使业务回滚,日志也不能丢
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String action) { ... }
// NESTED:嵌套事务,子事务可独立回滚
// 适用:批量处理中的单项失败不影响整体
@Transactional(propagation = Propagation.NESTED)
public void processItem(Item item) { ... }
Q11: 为什么推荐用构造器注入而不是字段注入?
// ✅ 构造器注入(推荐)
@RestController
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
}
// ❌ 字段注入(不推荐,尽管代码更短)
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
}
三个关键理由:一是不可变性——final 字段确保依赖不会在运行时被替换;二是可测试性——无需 Spring 容器即可在单元测试中手动注入 mock;三是编译期检查——构造器参数缺失是编译错误,而 @Autowired 字段缺失只有在运行时才会暴露。
九、总结与行动清单
Java 最佳实践不是一个静态的清单,而是在实践中不断迭代的活文档。这些实践的背后不是教条,而是真实的生产环境经验——遵循它们可以让你少踩很多坑。
可立即行动的改进计划:
| 时间 | 行动项 | 预期收益 |
|---|---|---|
| 本周 | 引入 .editorconfig + Spotless 统一代码风格 |
Code Review 效率提升 50% |
| 本周 | 检查所有异常处理,确保异常链完整 | 排障效率提升 |
| 本月 | 审查线程池使用,替换无界队列为有界 | 防止 OOM 风险 |
| 本月 | 为核心业务逻辑补充单元测试 | 重构信心增强 |
| 本季度 | 引入 Caffeine 本地缓存 | 热点查询延迟降低 90% |
| 本季度 | 完善日志规范 + MDC 追踪 | 链路追踪可用 |
| 长期 | 跟踪 Java 新版本,评估升级到 21 LTS | 获取虚拟线程等新特性 |
编程是一门手艺,最佳实践是前人经验的结晶。遵循它们可以让你少走弯路,但也不要成为教条的奴隶——理解背后的原理,在合适的场景中灵活运用,才是真正的"最佳实践"。
本文由 MarkShareX AI 自动创作,分类:Java,方向:最佳实践