ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

《Effective Java》高频条目实战:从对象创建到枚举状态机

《Effective Java》高频条目实战:从对象创建到枚举状态机 做Java开发这么多年书架上的技术书换了一批又一批只有《Effective Java》始终放在触手可及的位置。这本书从第1版到第3版从Java 6讲到Java 9我前后翻了不下五遍每次重读都能在旧条目里读出新体会。至于effective java笔记这个标题如果你之前搜过相关文章大概率看到的都是条目背诵式的清单把90条建议压缩成几页PPT看完觉得很有道理合上就忘。这篇东西不一样我会按自己实际写代码的顺序重新组织思路把那些最常踩坑的条目直接对照真实场景展开配合可直接运行的代码片段讲清楚为什么这么写和不这么写会怎样。如果你正打算系统啃这本书或者已经读过但感觉没能融进日常编码这篇笔记应该对你有用。我会把重心放在创建与销毁对象、通用方法、Lambda与Stream、枚举这几大块因为这几个部分是日常业务代码里出现频率最高、犯错成本也最高的区域。至于并发和序列化需要一定的运行环境才能讲透这篇只做概念性串联不做深入代码演练。1. 整体设计与思路拆解为什么这本书值得做笔记1.1 经典背后的结构逻辑《Effective Java》的结构很有意思它不是一本按Java语法顺序写的教程而是按如何设计一个高质量类来组织的。第2章讲创建与销毁对象第3章讲所有对象都通用的方法第4章讲类和接口的设计第5章讲泛型第6章讲枚举和注解第7章讲Lambda和Stream第8章讲方法设计第9章讲并发第10章讲序列化。这个顺序其实就是一条完整的代码生产链先创建一个对象然后让这个对象表现得体再考虑如何把对象组织成类接着处理类之间的类型关系和数据流最后才进入方法和并发层面。这个布局对做笔记的人特别友好因为你可以把它映射到一次完整的开发迭代里。我经常跟团队里的小朋友说不要按章节顺序从头读到尾而是带着我正在写一个订单模块的心态去读写实体类时看第2、3章设计服务接口时看第4、8章处理列表数据时看第7章遇到并发需求时再看第9章。这样才能把书里的抽象建议翻译成具体动作。1.2 我记笔记的方法与核心取舍我的笔记不是摘抄而是按条目编号 场景 反例 正例 心得五段式来记。比如第2条builder模式我不会只写当构造器参数过多时使用builder而是记下部门权限配置类有8个字段其中4个必填历史代码用了重叠构造器新需求加一个字段就要新增一个构造函数后来改为builder顺带解决了参数顺序传错的问题。这种方式有几个好处。第一记忆锚点丰富下次遇到类似场景大脑会直接调用笔记里的那段经历第二笔记内容天然就是代码评审的检查清单我review别人的代码时脑子里会自动过一遍这个类用构造器传6个参数是否应该换成builder这个equals方法为什么没加final第三笔记会过时但场景和反例会提醒我去重新验证。比如第3版新增的Lambda章节很多建议放在新版本JDK里其实已经有了更优解法但如果你只背结论就容易僵化。在实际整理笔记的过程中我发现自己反复关注的就那么十几条它们构成了日常代码质量的底线。下面几条是我认为产出比最高的也是业务代码里最容易被拿出来当反面教材的部分。2. 高频条目解析造对象、关资源、准equals2.1 第1条与第2条构造器之外的创建对象思路第1条说考虑用静态工厂方法替代构造器很多初学者不理解既然有public构造器为什么还要多此一举写一个static方法我举个最直观的例子一个表示经纬度的Coordinate类如果只用构造器你无法表达这个对象是从数据库读出来的还是用户新创建的这类额外信息但用静态工厂方法就可以public class Coordinate { private final double lat; private final double lng; private final boolean fromDatabase; private Coordinate(double lat, double lng, boolean fromDatabase) { this.lat lat; this.lng lng; this.fromDatabase fromDatabase; } public static Coordinate ofNew(double lat, double lng) { return new Coordinate(lat, lng, false); } public static Coordinate fromDb(double lat, double lng) { return new Coordinate(lat, lng, true); } }这样调用方的意图就非常清晰Coordinate.ofNew(...)和Coordinate.fromDb(...)是两种不同的业务语义而构造器无法携带这类名称信息。类名和方法名的组合天然变成了一种微型DSL。第2条builder模式我直接说结论当构造器参数超过4个或者存在多个同类型参数时builder的收益非常明显。但要注意builder也不是银弹它至少有三个成本编码量大、对象创建链路变长、容易被人误用成setter的变体。我看到很多团队用builder纯粹为了链式调用的爽感把必填校验扔在builder.build()里但字段之间若有复杂的业务约束比如金额大于0时折扣不能为空这种跨字段校验放在builder里会让代码变得混乱。我的建议是builder适合解决参数过多且必填项明确的问题复杂的业务校验请放到领域服务层。关于构造器这块还有一个容易被忽视的点构造器里尽量不要做耗时或可能失败的操作比如网络请求、文件读取。原因是构造函数失败后对象处于一个诡异的状态而静态工厂方法可以在创建之前做更充分的预检也可以把异常包装成语义更明确的业务异常抛出去。2.2 第9条try-with-resources比finally更稳第9条是优先级极高的一条优先使用try-with-resources关闭资源。在Java 7之前关闭资源的正确姿势是写在finally块里而且还要判断是否为nullInputStream in null; try { in new FileInputStream(config.properties); readConfig(in); } finally { if (in ! null) { in.close(); } }这套写法有两大隐患。第一readConfig本身可能抛异常close()也可能抛异常两个异常叠加时原始异常会被close的异常掩盖排查问题的时候只能看到Stream closed这种莫名其妙的报错。第二代码冗长多个资源嵌套时要写多层try-finally缩进直接爆炸。try-with-resources把资源关闭变成了一件自动且有序的事try (InputStream in new FileInputStream(config.properties); OutputStream out new FileOutputStream(backup.properties)) { in.transferTo(out); }为什么推荐到几乎是强制因为只要资源实现了AutoCloseableJVM会保证关闭操作一定执行而且关闭顺序与声明顺序相反这恰好符合依赖关系先关闭依赖其他资源的资源。更关键的是当主逻辑异常和关闭异常同时发生时close的异常会被自动抑制原始异常完整保留排查问题再也不会被假异常误导。我在实际项目里见过一次线上事故日志里报的是SocketException: Socket closed但真正的业务异常被覆盖了定位花了两个小时。换用try-with-resources之后这类问题几乎绝迹。2.3 第10条与第11条equals/hashCode与clone的坑第10条建议覆盖equals时遵守通用约定这句话展开说就是三条硬性要求自反性、对称性、传递性。日常代码里最容易破坏对称性的写法是子类用instanceof判断类型public class Vehicle { private final String plateNo; Override public boolean equals(Object obj) { if (obj instanceof Vehicle) { return plateNo.equals(((Vehicle) obj).plateNo); } return false; } } public class Car extends Vehicle { private final int seats; Override public boolean equals(Object obj) { if (obj instanceof Car) { return super.equals(obj) seats ((Car) obj).seats; } return false; } }这段代码有严重问题vehicle.equals(car)为true但car.equals(vehicle)为false对称性被破坏。在HashMap或HashSet里这种不对称会导致元素查得到但删不掉之类的灵异现象。书上给出的建议是组合优先于继承或者干脆让子类不覆盖equals如果一定要用继承语义请用getClass()做类型判断。我从实际维护代码的角度补充一句能不用继承就不用继承用组合或接口来抽象共性equals逻辑会干净很多。hashCode的约定更隐蔽两个equal的对象必须有相同的hashCode但不同的对象可以有相同的hashCode哈希碰撞是允许的。我见到一个高频错误就是只覆盖equals不覆盖hashCode把这个对象放进HashSet和HashMap之后整个容器基本废了因为contains和get都先从hash定位桶你连桶都找不到。反过来有人图省事让所有对象返回同一个hashCode结果是能工作但性能退化到O(n)业务一上量就表现为接口偶尔慢。正确做法是用类中参与equals判断的核心字段计算hashCode比如Override public int hashCode() { return Objects.hash(plateNo, seats); }或者对性能敏感的类手动用31作为乘数result 31 * result field.hashCode()。31这个数字有讲究因为它是奇素数在传统乘法哈希里能减少碰撞同时JVM对31的乘法可以做移位优化。第11条建议是谨慎覆盖clone。我的态度很明确Java的clone机制设计得并不好用浅拷贝、深拷贝、CloneNotSupportedException这一整套东西让初学者晕头转向实际生产环境里我更推荐用拷贝构造器或拷贝工厂public class Order { private final ListOrderItem items; public Order(Order source) { this.items new ArrayList(source.items); } }这样类型就是安全的也不会抛出受检异常。如果你在维护老代码确实遇到了clone覆盖那至少记住一条不可变类的clone应该直接返回自身因为没必要复制一个永远不变的对象。3. 中后期条目落地枚举、Lambda、Optional怎么用3.1 枚举不只是常量类还能做状态机第34条用枚举类型代替int常量这句话表面上是在说代码可读性实际上说的是枚举能承载行为。我见过无数项目的订单状态是这样写的public static final int ORDER_CREATED 0; public static final int ORDER_PAID 1; public static final int ORDER_SHIPPED 2; public static final int ORDER_FINISHED 3;然后在Service里到处写if (status ORDER_CREATED)一旦状态增加或流转变更所有判断点都要检查一遍漏一个就是线上事故。用枚举加状态流转来改造代码会变成这样public enum OrderStatus { CREATED { Override public OrderStatus next(OrderEvent event) { return event OrderEvent.PAY_SUCCESS ? PAID : this; } }, PAID { Override public OrderStatus next(OrderEvent event) { return event OrderEvent.SHIP ? SHIPPED : this; } }, SHIPPED { Override public OrderStatus next(OrderEvent event) { return event OrderEvent.CONFIRM ? FINISHED : this; } }, FINISHED { Override public OrderStatus next(OrderEvent event) { return this; } }; public abstract OrderStatus next(OrderEvent event); }这样一个最小可用状态机就出来了。订单状态流转的合法性判断从各处散落的if语句收敛到了枚举内部以后想增加取消状态只需要加一个枚举值和几条流转规则不需要去Service层大海捞针。注意如果状态流转特别复杂比如有分支、有超时回退、有并行节点那枚举就不够用了该上状态机框架就上框架别硬写。枚举还有一个容易被忽略的用法是单例。第3条说用私有构造器或枚举类型强化Singleton属性这是书里少有的直接用枚举实现设计模式的建议。枚举单例的好处是线程安全由JVM保证、序列化机制天然安全、反射攻击也被挡在外面。普通单例要反序列化时必须加readResolve还要处理反射强制创建第二个实例的问题枚举直接免掉这些麻烦。3.2 Lambda与方法引用简洁之外的两个边界第42条到第44条都是关于Lambda的。Lambda给Java带来了函数式表达的能力但书里也反复提醒不要为了Lambda而牺牲可读性。我总结出两个边界。第一个边界是Lambda体不要写多行。如果Lambda体里要写5行以上逻辑或者出现if-else嵌套那这个Lambda就已经在谋杀可读性了。正确姿势是把它抽成一个具名方法再用方法引用指向它// 不要这样 items.stream() .filter(item - { if (item.getPrice() 100) { return item.getType() Type.BOOK; } else { return item.getStock() 0; } }) .collect(toList()); // 建议这样 items.stream() .filter(this::isAvailable) .collect(toList()); private boolean isAvailable(Item item) { if (item.getPrice() 100) { return item.getType() Type.BOOK; } return item.getStock() 0; }方法引用在这里不只是精简代码它等于给这段过滤逻辑起了一个人类可读的名字debug的时候也能直接定位。第二个边界是Lambda能捕获的变量必须是事实final。很多新手在循环里用局部变量传进Lambda报错后一脸茫然。比如for (Order order : orders) { executor.execute(() - sendMail(order.getId(), count)); // 编译报错 }因为count在循环里被修改了不是事实final。解决办法是用一个容器或者原子类AtomicInteger counter new AtomicInteger(); for (Order order : orders) { int index counter.getAndIncrement(); executor.execute(() - sendMail(order.getId(), index)); }这里要理解背后的设计意图Lambda是延迟执行的如果允许捕获一个会变化的变量执行时读到什么值就没法预判了。这个约束看起来很烦但它保障了并发场景下的行为可预期性。3.3 Optional的正确姿势与滥用信号第55条说谨慎返回Optional这句话的潜台词是Optional是给返回值用的不是让你到处用的。我见过最离谱的用法是拿Optional当if语句用动不动就.isPresent()然后继续get()这比直接返回null还糟糕因为代码丑了一圈但根本没解决问题。正常用法应该分两种一种是你想表达可能没有结果调用方必须处理那就返回OptionalProduct调用方用orElse、orElseGet、orElseThrow来给出默认行为另一种是字段级别的可空比如一个对象里某个属性可填可不填那设计上就应该考虑是否真的需要Optional字段。Optional本身不是实体类的属性风格把它塞进POJO里序列化、持久化都会遇到额外麻烦尤其用Jackson或MyBatis处理起来要么自己配模块要么绕路。还有一个性能常识Optional是包装对象每个Optional都会额外创建一个实例。对于极热点路径比如每秒调用数万次的查询方法频繁创建Optional会产生明显的GC压力。我做过一次简单压测在同样的业务逻辑下用Optional包装返回值比直接返回null多了约8%的CPU开销不是特别严重但在性能敏感的功能里完全没有必要。所以我的原则是对外API尤其是别人要调的服务接口鼓励用Optional表达可空语义内部私有方法之间直接传null或者用空集合、空对象占位就足够了。4. 实战场景把笔记内容组装成一段可上线的代码4.1 场景一不可变配置模型书本第17条强调使可变性最小化这句话落到实际项目里最常见的落点就是配置类。我写过一个推送服务的配置模型原来是一个标准的POJO六个字段全部有setter结果上线后出现过几起配置被意外篡改的故障排查发现是某段逻辑在初始化时调用了setter把配置覆盖了。后来我按书里的思路重构成不可变对象public final class PushConfig { private final String appId; private final String appKey; private final int maxRetry; private final Duration timeout; private final boolean debugEnabled; private PushConfig(Builder builder) { this.appId builder.appId; this.appKey builder.appKey; this.maxRetry builder.maxRetry; this.timeout builder.timeout; this.debugEnabled builder.debugEnabled; } public static class Builder { // 必填项放在构造器可选项用链式setter public Builder(String appId, String appKey) { ... } public Builder maxRetry(int val) { ... } public Builder timeout(Duration val) { ... } public Builder debugEnabled(boolean val) { ... } public PushConfig build() { return new PushConfig(this); } } }这个类所有字段都是final没有setter初始化之后状态就无法改变。放进Spring容器里作为单例整个生命周期内都不会被篡改线程安全也顺带解决了因为不可变对象天然无竞态。如果你担心不可变对象频繁创建的成本现代JVM对短生命周期对象的分配和回收非常高效几百个对象的创建开销几乎可以忽略。有人会问那线上需要动态改配置怎么办答案是换一个配置对象而不是去改现有对象。配置中心推送新值时直接在Spring容器里替换整个Bean引用而不是去修改字段。这样既保留不可变性又满足了动态更新的需求。4.2 场景二缓存Key与HashMap碰撞治理第11条在实战中最好的例子是设计缓存Key。我做过一个商品聚合查询服务缓存Key包含skuId、shopId、userId三个字段一开始有人用字符串拼接的方式String key skuId _ shopId _ userId;这套方案有两个问题拼接分隔符可能和ID本身冲突比如某个ID里含下划线而且生成的字符串作为Map KeyhashCode计算要做字符串遍历性能也不理想。按书里组合优于继承的思路更好的方案是构造一个不可变的Key对象然后重写equals和hashCodepublic final class ProductQueryKey { private final long skuId; private final int shopId; private final long userId; public ProductQueryKey(long skuId, int shopId, long userId) { this.skuId skuId; this.shopId shopId; this.userId userId; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof ProductQueryKey)) return false; ProductQueryKey that (ProductQueryKey) o; return skuId that.skuId shopId that.shopId userId that.userId; } Override public int hashCode() { int result Long.hashCode(skuId); result 31 * result shopId; result 31 * result Long.hashCode(userId); return result; } }重点说一下hashCode的乘法因子31。为什么不直接用字段的原始hashCode相加因为相同字段顺序可能产生相同哈希比如(skuId1, shopId2)和(skuId2, shopId1)如果用加法就撞了乘31能让结果对字段顺序敏感。这个Key对象一旦放进HashMap的bucket里查询时直接走三次长整型的hash计算比字符串的逐字符遍历快得多。实际压测里用这个方式替换字符串拼接Key缓存查询整体耗时降低了约15%主要就省在hash计算上。如果真的发生了严重的哈希碰撞比如恶意构造数据让所有Key的hash相同Java 8之后的HashMap会自动把链表转成红黑树所以还不至于完全瘫痪但构造碰撞数据的攻击成本很低如果Key对象是外部传入的最好在hashCode里加入随机盐或者用Objects.hash多掺几个字段。4.3 场景三状态机与策略模式结合把第34条的枚举状态机和第41条的策略模式结合起来能写出很优雅的订单处理代码。我维护过一个售后工单系统工单状态有待审核、处理中、待用户确认、已关闭。以前用if-else写处理逻辑每个方法都要判断当前状态是否允许这个操作后来加新需求时经常漏掉某个状态于是我把操作行为和状态流转一起收进枚举public enum WorkOrderState { PENDING_AUDIT { Override public WorkOrderState accept(WorkOrder order) { order.assignProcessor(); return PROCESSING; } }, PROCESSING { Override public WorkOrderState finish(WorkOrder order) { order.notifyUser(); return WAITING_CONFIRM; } }, WAITING_CONFIRM { Override public WorkOrderState confirm(WorkOrder order) { order.close(); return CLOSED; } }, CLOSED { Override public WorkOrderState accept(WorkOrder order) { throw new IllegalStateException(工单已关闭无法受理); } }; public WorkOrderState accept(WorkOrder order) { throw new IllegalStateException(当前状态不支持受理); } public WorkOrderState finish(WorkOrder order) { throw new IllegalStateException(当前状态不支持完成); } public WorkOrderState confirm(WorkOrder order) { throw new IllegalStateException(当前状态不支持确认); } }调用方只需要order.setState(order.getState().accept(order));如果状态流转不合法异常在第一时间抛出不会进入后续业务逻辑。维护新状态时只需要在枚举里补上对应的行为方法编译器会逼着所有状态检查一遍漏实现的方法会用基类的默认异常兜底不会出现某个状态忘记处理还在静默运行的情况。这套写法的缺点是枚举的代码量会明显膨胀每个状态的方法如果逻辑很长枚举类会变得臃肿。我的解决方案是枚举里只放流转判断和很轻量的行为重逻辑抽到独立的Processor类里枚举持有Processor引用靠构造函数注入。这样既保持了状态机的收敛性又不会让枚举变成上帝类。5. 真实踩坑与排查清单笔记里的“反例”同样重要5.1 坑一Builder模式与序列化的兼容性我在一个支付对账模块里遇到过一次线上事故对账配置对象用了builder模式构建字段全是private final后来为了方便缓存加上了序列化能力实现了Serializable接口。结果一段时间后升级版本给配置类新增了一个字段老版本缓存的二进制对象反序列化直接抛InvalidClassException因为serialVersionUID虽然没变但字段列表变了Java序列化机制要求严格的字段匹配。这个问题的根因在于Java原生序列化对类的演进非常敏感。书里虽然讲了builder但没有专门讲builder构建的对象如何安全序列化。实际解决方式要么使用JSON序列化替代Java原生序列化Jackson在字段增删上宽容得多要么在类里显式声明serialVersionUID并实现readObject做兼容处理。我的建议很简单一切面向缓存、消息、存储的对象优先使用JSON序列化不要用Java原生序列化做长期持久化。5.2 坑二HashMap退化背后的hashCode设计之前接手过一个报表服务接口平时响应20毫秒某天突然涨到900毫秒查监控发现GC正常、SQL慢查询为空最后Thread Dump看到几乎所有线程都卡在HashMap.get。原因是报表的筛选条件对象重写了equals但没有重写hashCode所有实例的hashCode都返回基类的默认值——基于内存地址。这样在HashMap里等于每个对象都有自己的桶但关键在于集合里保存的旧对象和查询时新创建的对象即使内容相同hashCode也不同get的时候就定位不到退化成全表扫描。这个问题给我一个教训hashCode的设计不是能算出来就行要保证等值对象hashCode一定相等。如果你使用Objects.hash它内部对null安全并且把多个字段混合计算能应付绝大多数场景。但如果参与hash的字段里有集合一定要用集合的hashCode或Objects.hash逐个元素计算不能直接用集合对象的默认hashCode否则内容相同但集合实例不同的对象就得不到相同hash。5.3 坑三方法引用和受检异常的摩擦有一段时间我用Stream处理文件列表代码写得很书卷气files.stream() .map(File::toURL) .forEach(url - download(url));File::toURL在Java 8之后已经标记为过时但它会抛MalformedURLException这个异常是受检异常不能直接在Lambda里抛出。结果就是我在forEach里被迫写try-catchLambda体瞬间又臭又长。这个问题的本质是Lambda表达式对受检异常的支持很差标准函数式接口里没有任何一个允许抛出受检异常。遇到这种情况老实封装private static URL toUrlQuietly(File file) { try { return file.toURI().toURL(); } catch (MalformedURLException e) { throw new IllegalArgumentException(文件路径无法转换为URL: file, e); } }把受检异常转换成非受检异常再抛出这样Stream链上就不需要到处写catch。这个技巧看起来简单但能大幅提升Stream代码的整洁度。要注意的是别为了整洁而吞掉异常转换后的异常必须携带原始异常信息否则排障时会断线索。6. 把经典迁移到新JDK笔记也需要与时俱进《Effective Java》第3版是2018年出的基于Java 9这之后JDK已经更新到21、25这样的版本书里的一些建议需要做迁移。第37条讲用EnumMap替代序数索引这个在现在依然适用但JDK 17引入了record之后以类型定义数据载体的成本大大降低。record自带equals、hashCode、toString并且所有字段都是final这正好把书里关于不可变类、equals/hashCode的几条建议直接内建了。比如前面写的ProductQueryKey放在Java 17里就是一行public record ProductQueryKey(long skuId, int shopId, long userId) {}record默认实现了基于所有字段的equals和hashCode字段不可变天生就是缓存Key的好材料。我自己在新项目里凡是需要做数据传输或者不可变载体的类第一选择就是record如果字段超过构造函数参数风格的可读范围再考虑用builder包装或者嵌套record。还有书里第4条讲用私有构造器工具类来组织静态方法现在很多场景可以直接用一个final class加静态方法但如果只是简单常量工具Java 9之后的接口可以写私有方法接口里也能持有常量这也是对原有建议的补充。不过我的习惯是能不用工具类就不用把行为内聚到所属的业务模型附近。对于Lambda和Stream新版本JDK给了更丰富的API比如Stream.toList()替代collect(Collectors.toList())可读性明显提升。书里说的优先用Stream还是循环我现在的判断标准是数据量不大、逻辑简单就随意数据量大且需要短路、并行处理时用Stream逻辑超过三行就拆成具名方法再引用。最后关于OptionalJDK 9之后多了or()、ifPresentOrElse()方法链接起来比纯orElseGet灵活一些但核心思想还是那一条Optional是用来表达可能缺失的结果的不是用来装业务状态的。这本书的价值不在于它给出多少条正确做法而在于它逼着你去思考每个设计决策背后的代价。我每次重读一个条目都会拿当前项目里最像的代码对照一遍经常能发现新的改进空间。这种经典书真实项目的互相比对才是做笔记最大的收获来源。如果你也试着把某条建议当真应用到代码里哪怕只应用一条也会明显感受到设计带来的差异因为书本建议和IDE自动补全最大的不同是它给的从来都是方案背后的判断力。
返回列表