2.1 配套代码:台阶式提升与 PrintCompilation 解读
对应小节:2.1 四种执行形态 要验证的结论:同一份代码在不同调用次数下的性能是台阶式变化的,不是渐变的。
一、实验:在不同调用次数下测量同一个方法
// src/main/kotlin/lesson02/ExecutionTiers.kt
package lesson02
import kotlin.random.Random
private var sink = 0L // 防止死代码消除
fun work(data: IntArray): Long {
var s = 0L
for (v in data) s += v
return s
}
/**
* 用「累计调用次数」作为横轴,观察性能台阶。
* 每一档:先跑 total 次让 JIT 有机会编译,再测 measure 次的平均耗时。
*/
fun measureAt(totalInvocations: Int, measure: Int, data: IntArray): Double {
repeat(totalInvocations) { sink += work(data) } // 累积热度
val t0 = System.nanoTime()
repeat(measure) { sink += work(data) }
val t1 = System.nanoTime()
return (t1 - t0).toDouble() / measure
}
fun main() {
val rnd = Random(42)
val data = IntArray(4096) { rnd.nextInt() }
println("累计调用次数 单次耗时(ns/op)")
println("-".repeat(40))
// 每一档都从一个"新进程的视角"重新累积是不现实的,
// 所以这里用【单调递增】的累积次数:观察曲线是否台阶式下降
for (total in listOf(10, 100, 500, 1_000, 5_000, 20_000, 100_000, 500_000)) {
val ns = measureAt(total, 2_000, data)
println("%,10d %8.1f".format(total, ns))
}
println()
println("sink = $sink (确保计算没被优化掉)")
println()
println("预期形态:")
println(" 10 ~ 500 : 上千 ns(解释执行 / C1)")
println(" 1e3 ~ 2e4 : 快速下降(C2 介入)")
println(" 1e5 以后 : 平台(稳态)")
println()
println("注意:平台【不是突然到达】的,是渐近的 —— 这正是第 4 节要讲的稳态判定问题。")
}
二、预期输出形态
累计调用次数 单次耗时(ns/op)
----------------------------------------
10 1312.4
100 604.8
500 391.2
1,000 312.7
5,000 261.4
20,000 240.8
100,000 231.2
500,000 230.4
三、观察编译事件
java -XX:+PrintCompilation \
-cp build/classes/kotlin/main lesson02.ExecutionTiersKt
输出形如:
85 42 3 lesson02.ExecutionTiersKt::work (24 bytes)
112 51 % 4 lesson02.ExecutionTiersKt::work @ 2 (24 bytes)
340 78 4 lesson02.ExecutionTiersKt::work (24 bytes)
逐列解读:
| 输出 | 含义 |
|---|---|
85 |
从 JVM 启动起的毫秒数 |
42 |
编译任务 ID(递增) |
% |
OSR 编译——循环体被热点编译(不是整个方法) |
3 |
C1 带 profiling(为 C2 收集类型/分支信息) |
4 |
C2 编译 |
lesson02...::work |
被编译的方法 |
@ 2 |
OSR 的入口字节码偏移 |
(24 bytes) |
字节码大小 |
要重点找的三类行:
# ① 看 C2 什么时候介入(第一次出现 " 4 " 的行)
java -XX:+PrintCompilation ... | grep -E "^\s*[0-9]+\s+[0-9]+\s+4" | head -5
# ② 看有没有去优化
java -XX:+PrintCompilation ... | grep "not entrant"
# ③ 看有没有 OSR
java -XX:+PrintCompilation ... | grep "%"
四、为「内联导致火焰图丢帧」做验证
# 正常跑,观察 work 是否出现在编译列表里
java -XX:+PrintCompilation -cp build/classes/kotlin/main lesson02.ExecutionTiersKt | grep "work"
# 禁止内联 work,看性能变化
java -XX:CompileCommand=dontinline,lesson02.ExecutionTiersKt::work \
-cp build/classes/kotlin/main lesson02.ExecutionTiersKt
怎么读这个对比:
- 如果禁用内联后性能明显下降,说明
work的快部分来自被内联(消除了调用开销,并让上层能做更多优化)。 - 这也解释了为什么火焰图上可能看不到
work这一帧——它被合并进了调用方。
实践意义:当你在火焰图里找不到某个「确定被调用」的方法时,第一反应应该是「它被内联了」,而不是「埋点没生效」。
五、动手改造
| 改动 | 观察什么 |
|---|---|
把 data 改成 IntArray(4096) { it }(规律数据) |
数字会不会变得更低?为什么(提示:常量折叠 + 向量化)? |
把 data 大小从 4096 改成 40960 |
台阶位置会变吗?(提示:每次调用的工作量变了,达到阈值所需的调用次数也变了) |
加 -XX:TieredStopAtLevel=1 |
只有 C1,没有 C2——曲线会停在中间那个台阶上 |
把 measure 从 2000 改成 100 |
数字会变得更抖——为什么?(提示:测量窗口太短,混入了噪声与过渡态) |
去掉 sink 的累加 |
数字会变成荒谬的小值(这就是第 3 节的死代码消除) |
六、这段代码的局限
- 它不是严格的实验:因为「累计调用次数」是单调递增的,后面的档位已经受益于前面累积的编译热度,所以曲线是「同一条时间线上的不同时刻」,不是「独立实验的对比」。要严格对比,需要每个档位跑在独立进程里。
- 没有预热与测量的分离:真实基准(JMH)会把两者严格分开。
- 开发机噪声:这台机器上可能有其他进程影响结果。
它的价值:让你看见「台阶」和「渐近平台」这两个形态。这足以支撑第 4 节关于稳态判定的讨论。