领域:Java 规则
基于昨天输入优化的路径,在今天输入到来时会失去优化 [R:a-speculated-path-deoptimises-when-the-input-changes]
源自 Aleksey Shipilev 的“JVM 解剖笔记”,关于性能分析污染、内联和反优化;HotSpot 的
uncommon-trap机制。
JIT 不会编译你的代码。它编译的是一个关于你代码的赌注,而当这个赌注不再成立时,结算就开始了。
HotSpot 基于性能分析进行编译:这个调用点只见过一种接收者类型,因此内联它并添加守卫;这个分支从未被执行过,因此不要生成它;这个字段从未为 null,因此跳过检查;这个类从未被加载过,因此假设不存在子类。每个假设都有一个守卫,编译后的路径之所以快,正是因为守卫开销小,而缺少的代码体根本不存在。当某个守卫失败时,栈帧会被去优化:触发一个罕见陷阱,从编译后的栈帧重建解释器栈帧,执行在解释器中继续,并等待使用新的性能分析数据重新编译。
这个时机使它成为一条关于延迟的规则,而非吞吐量。当输入发生变化——出现异常的顺序类型、当天的第一个异常、新场所的消息格式、运行第六小时时加载的第二个接口实现——假设就会被打破。因此,这条路径将产生的最大延迟总是由最不寻常的事件触发的,而这通常也是最重要的事件。使用代表平均流量的负载测试无法复现这种情况。
默认情况下,它也是静默的。没有任何日志记录,没有任何指标变化,唯一的线索是一个无法与 GC 暂停或调度延迟区分的延迟异常点。-XX:+PrintCompilation 和 -XX:+TraceDeoptimization 可以告诉你发生了什么;JFR 能以更小的开销承载这些事件,是值得一直启用的。
因此,同样使用不寻常的用例来预热路径,而不仅仅是常见的用例——这就是它与 [R:a-measurement-matches-the-regime-it-reports] 相遇的地方:仅由典型流量构建的预热会产生一个典型流量会确认而异常流量会破坏的性能分析数据。保持热调用点有意的单态性;热点路径上的多态接口调用是一个永久的内联屏障,而不是一次性陷阱。在路径生效之前,加载路径所需的所有类。并将 lambda 或动态调用点添加到热点路径视为需要重新赢得的新性能分析数据。
故障模式检查:JIT 关于这条路径做了哪些假设,当这些假设第一次不成立时会发生什么? 如果诚实的答案是没人知道假设了什么,那本身就是一个发现——假设是可读的,而读取它们就是工作。
集合视图是他人数据的窗口 [R:a-view-is-not-a-copy]
要知道你持有的是数据还是数据的窗口。静态类型 List<T> 两者皆可,而且永远不会告诉你。
库返回视图的频率远远高于大多数代码的预期,并且每个都有其独特的出乎意料之处:
Arrays.asList(a)是一个基于数组的固定大小包装器。set会写入到数组;add和remove会抛出UnsupportedOperationException。List.of(...)是不可变且独立的,通常才是你想要的。list.subList(from, to)是一个实时窗口。写入会透传,并且对父列表的任何结构性修改都会在子列表下次使用时抛出ConcurrentModificationException——包括获取子列表然后清空父列表这种常见用法。map.keySet()、values()、entrySet()都是视图。keySet().remove(k)会从映射中移除条目。这偶尔是你想要的,但总是值得慎重考虑。Collections.unmodifiableList(l)包装列表而不复制:该包装器拒绝写入,而原始列表仍然可以修改,因此它只能防御调用者,而不能防御你。它不是防御性副本,这就是为什么[R:no-reference-to-internals-escapes]使用List.copyOf替代。Stream.toList()是不可修改的;collect(Collectors.toList())不提供此类保证,可能可变也可能不可变。不要依赖它们中的任何一个可变。
因此,在创建时就要明确你想要哪种。当接收者必须独立时使用 List.copyOf;当共享是意图且生命周期很短时使用视图。存储在一个字段中的视图是最需要仔细审视的形态:它的生命周期超过了创建它的表达式,并且其背后的集合可能在之后的任何时候发生变化。
同样的区别也适用于迭代。在单线程环境下,迭代时修改集合也会抛出 ConcurrentModificationException,因为迭代器本身也是一个视图——请使用 removeIf,或者迭代一个副本,或者收集要删除的元素然后统一应用。
故障模式检查,针对任何接收或返回的集合:如果有人写入此集合,其他什么会发生变化? 如果你不打开工厂方法的 Javadoc 就无法回答这个问题,那么复制它。
包装类型是有开销的,直到逃逸分析将其消除 [R:a-wrapper-type-is-not-free-here]
在这里也要给领域概念赋予自己的类型——一个无法传递到本应使用 AccountId 的地方的 OrderId,在 Java 中与在其他地方一样有价值。但是,不要将开销论点和设计论点混为一谈,因为只有其中之一可以被迁移。
在 Rust 中,newtype 是一个编译时构造:struct OrderId(u64) 在运行时就是一个 u64,编译器可以证明这一点。在 Java 中,record OrderId(long value) 是一个对象——包含头部、字段、需要追踪的引用,以及一个与指向它的任何东西分开的缓存行。JIT 可以消除它:逃逸分析和标量替换会展平一个可证明不逃逸其编译单元的包装器,这涵盖了大量的普通代码。但它不涵盖你关心的场景——存储在一个字段中、放入集合中、传递给 JIT 决定不内联的方法、或者放入数组中,其中 OrderId[] 是一个指针数组,而 long[] 是一个数字数组。
因此,规范根据值所在的位置来划分:
- 在 API 边界处,以及任何正确性是关键问题的地方,使用包装器。它可以防止参数混乱的错误,即使分配一个 JIT 通常会消除的开销也是值得的。
- 在热循环内部、大数组中或每个消息的结构中,在假设包装器已被消除之前必须进行测量。这就是
[R:verify-the-abstraction-compiled-away]在一个优化器决策是动态的语言中的体现:-XX:+PrintInlining、JFR 分配分析或逃逸分析转储可以告诉你实际发生了什么,并且答案在同一二进制文件的不同运行之间也可能不同。 - 当它没有被消除且预算有限时,保留基本类型,并将安全性放在其他不产生开销的地方——具名参数、构建器、或者签名不可能被错误调用的静态工厂。
这个规则的未来版本值得了解而不是等待:Project Valhalla 的值类旨在通过规范而不是优化器的判断来完全展平此类包装器。在代码能在支持此功能的 JVM 上运行之前,其开销是动态的,必须测量而非假设。
故障模式检查,在包装一个有预算路径上的基本类型之前:在 JIT 完成之后,这个对象是否仍然存在,以及是什么告诉我的? “它只是一个包装器”是 Rust 的答案,而这个答案并不能移植过来。
资源由打开它的构造块关闭,在每条路径上 [R:close-what-you-open]
只在 try-with-resources 的头部打开资源,其他地方不行。该构造块在每一条退出路径上、以相反的顺序关闭资源,并且——手工编写的 finally 做不到的部分——当 close 也失败时,它保留原始的异常。
try (var conn = pool.take(); var stmt = conn.prepare(sql)) {
return stmt.execute();
}
finally 块引入的失败值得直白地指出,因为它看起来是正确的。如果方法体抛出异常并且 close() 也抛出异常,finally 的异常会替换方法体的异常:程序报告一个关闭连接失败,而对首先失败的查询却只字不提。相反,try-with-resources 会抑制关闭失败,并将其附加到原始异常上,因此两者都得以保留,原因正是你想要的。泄漏一个句柄会花费你一个资源;丢失一个原因会花费你的调查时间。
三种构造块不适用但规范仍然适用的场景:
- 生命周期是字段而非块的资源——例如连接池、客户端、执行器。拥有该资源的对象实现
AutoCloseable,规范提升了一个层次;需要拒绝的是那种被打开但从未被某个特定对象关闭的字段。 ExecutorService仅靠shutdown()是无法关闭的。shutdown()然后awaitTermination()然后shutdownNow()才是正确的序列,跳过它会导致非守护线程阻止 JVM 退出。- 覆盖文件的
Stream(Files.lines、Files.walk)持有一个句柄,必须关闭。它是唯一会泄漏的流,而且看起来和那些不会泄漏的流一模一样。
不要将终结器或 Cleaner 作为主要机制。它们的运行时间不确定或永远不会运行,它们的存在是为了捕捉规范已经失效的情况。
故障模式检查,针对每个资源:如果下一行抛出异常,哪个构造块会关闭这个资源? 如果答案是方法更靠后的 close() 调用,那么没有人会关闭它。
类要么是 final 的,要么其继承是经过设计和文档化的 [R:design-for-inheritance-or-forbid-it]
除非你为子类设计了接口,否则将类设为 final。如果你设计了,那么要确切说明子类可以覆盖什么、不能覆盖什么。
继承并不是看起来那样的重用机制。子类依赖于父类的哪些方法调用了哪些其他方法——addAll 调用 add 是典型的例子——而这是一个父类有权在补丁版本中更改的实现细节。因此,一个能正常工作的子类会在父类被重构时失效,而双方作者都没有做错任何事。组合没有这个属性:包装器依赖于公共契约,而没有任何别的东西。
构造函数的情况比脆弱更糟,它从一开始就是坏的。调用可覆盖方法的构造函数会在子类自己的字段被赋值之前运行子类的覆盖版本,因此覆盖的方法看到的是它声明为 final 并已初始化的字段中的 null 和 0。没有任何警告。这与 [R:no-reference-to-internals-escapes] 提到的 this 逃逸是相同的发布风险,只是通过看起来像普通方法调用的途径达到——而 [R:publish-safely-or-not-at-all] 解释了为什么即使是在单线程环境中,这也是一个问题。
因此:
- 默认使用
final,应用于类,以及任何子类不应替换的方法。密封([R:seal-the-alternatives])是其更强的形式,其中子类型集合是封闭且已知的。 - 永远不要在构造函数、初始化器、
clone()或readObject()中调用可覆盖的方法,这些方法只是披着其他外衣的构造函数。 - 如果一个类是为继承设计的,要记录其自用情况:哪些方法调用了哪些方法,哪些是安全可以覆盖的,子类必须调用什么。这些文档是契约的一部分,其成本是大多数类应该改为
final的真正原因。 - 优先使用组合。 持有实例并进行转发的包装器编写起来更长,但不受父类内部实现的影响。当动机是重用而非可替代性时,应该使用组合。
故障模式检查,针对每个非 final 的类:我的哪些方法相互调用,如果一个子类覆盖了其中一个,在我重新排序这些方法后,它仍然正确吗? 如果这个问题无法回答,那么这个类就是意外地可扩展的。
equals 和 hashCode 是一个决策,可变键会破坏两者 [R:equality-is-one-contract]
一次性决定等式语义,在同一个变更中实现这两个方法,并从对象在集合中可达期间不会改变的字段派生它们。
契约是机械性的:相等的对象必须具有相等的哈希码。违反这个契约会导致 HashMap 在错误的桶中查找,因此 get 会为它包含的键返回 null,remove 找不到要移除的条目——这是一个大小随流量增长而增长的泄漏。没有任何东西能检测到这一点。集合的行为完全符合规范;只是规范被交给了撒谎的对象。
可变键的部分是能通过 Review 而幸存下来的,因为类在编写时是正确的,只有当有人添加了 setter 时才变得错误。其 hashCode 依赖于某个后来被修改的字段的对象,已经在不通知映射的情况下更改了桶,并且现在通过它当初被归档的键本身是无法到达的。所以,用作键的类型是不可变的,或者它就不应该被用作键。 一个 record 从组件和 final 字段中生成这两个方法及其实现,这就是为什么它是值类型的默认形态,以及为什么手写这两个方法应该感觉像一个决定,而不是模板代码。
还有两个遵循相同契约的规则值得指出,因为每个都有自己的出错方式:
compareTo必须与equals一致,只要该类型会用于TreeMap或有序集合,这些集合使用顺序比较而根本不调用equals。如果一个比较器说两个对象相等,而equals却说它们不同,那么会产生一个默默持有其中一个对象的集合。- 继承和等式不可组合。 使用
instanceof编写的equals会接受一个子类,然后变得不对称;使用getClass()编写的equals是对称的,但拒绝所有子类。没有第三种选择,这是[R:design-for-inheritance-or-forbid-it]偏好final的原因之一。
故障模式检查,在将类型用作键或放入集合之前:它的等式依赖哪些字段?
...
[为简洁起见省略]