文档目录

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 节关于稳态判定的讨论。