文档目录

0.1 配套代码:同一个函数,为什么测出三个不同的数字

对应小节:0.1 三类问题必须分开处理 要验证的结论:「这段代码要 2 μs」是一个不完整的陈述——真实性能取决于它在什么环境下运行。

一、这段代码想说明什么

正文里有一句话:

「我测过 JSON 序列化只用 2 μs,一个请求 10 ms,所以序列化不是瓶颈。」——这句话的结论是错的,因为在真实并发下,同一个操作可能被放大几十倍。

这段代码就是把这句话变成可观察的数字:同一个函数,在三种环境下运行——

场景 环境 预期现象
A 单线程,无竞争 最快的数字(这就是「微基准」会给你的)
B 8 个线程同时调用,有共享锁 单次耗时大幅上升
C 单线程,但有持续的对象分配 耗时上升,且 GC 开始介入

二、代码

// src/main/kotlin/lesson00/ThreeKindsOfProblems.kt
package lesson00

import java.util.concurrent.CountDownLatch
import kotlin.concurrent.thread
import kotlin.system.measureNanoTime

/** 共享的「热点数据」,用于制造锁竞争 */
private val lock = Any()
private var sharedCounter = 0L

/**
 * 被测函数:模拟一次「有点计算量 + 需要访问共享资源」的操作。
 * 注意它是完全相同的代码,三种场景下不做任何修改。
 */
fun handleRequest(seed: Int): Long {
    // ① 一点本地计算(模拟业务逻辑)
    var local = seed.toLong()
    repeat(200) { local = local * 31 + it }

    // ② 访问共享资源(模拟写指标、更新缓存、计数器)
    synchronized(lock) {
        sharedCounter += local and 0xFF
        local += sharedCounter and 0x0F
    }

    // ③ 一点分配(模拟对象创建、序列化缓冲)
    val payload = ByteArray(256)
    payload[0] = local.toByte()
    return local + payload.size
}

/** 场景 A:单线程、无竞争 —— 这就是典型微基准会报告的"性能" */
fun scenarioA(iterations: Int): Long {
    waitForJit(iterations)
    return measureNanoTime {
        var sink = 0L
        repeat(iterations) { sink += handleRequest(it) }
        check(sink != Long.MIN_VALUE)
    } / iterations
}

/** 场景 B:8 线程争用同一把锁 */
fun scenarioB(iterations: Int, threads: Int = 8): Long {
    waitForJit(iterations)
    val perThread = iterations / threads
    val start = CountDownLatch(1)
    val done = CountDownLatch(threads)
    var totalElapsed = 0L

    repeat(threads) {
        thread {
            start.await()
            val elapsed = measureNanoTime {
                var sink = 0L
                repeat(perThread) { sink += handleRequest(it) }
                check(sink != Long.MIN_VALUE)
            }
            synchronized(lock) { totalElapsed = maxOf(totalElapsed, elapsed) }
            done.countDown()
        }
    }
    start.countDown()
    done.await()
    // 每个线程各自跑了 perThread 次,用时约等于最慢那个线程的耗时
    return totalElapsed / perThread
}

/** 场景 C:单线程,但持续分配大对象,制造 GC 压力 */
fun scenarioC(iterations: Int): Long {
    waitForJit(iterations)
    val garbage = ArrayList<ByteArray>(4096)
    return measureNanoTime {
        var sink = 0L
        repeat(iterations) { i ->
            sink += handleRequest(i)
            garbage.add(ByteArray(4096))          // 每秒产生大量垃圾
            if (garbage.size > 4096) garbage.clear()
        }
        check(sink != Long.MIN_VALUE)
    } / iterations
}

/** 预热:让 JIT 完成编译,否则测到的是解释执行阶段 */
private fun waitForJit(iterations: Int) {
    var sink = 0L
    repeat(iterations / 2) { sink += handleRequest(it) }
    check(sink != Long.MIN_VALUE)
}

fun main() {
    val n = 200_000
    println("同一个 handleRequest(),三种环境下运行:\n")
    println("A 单线程无竞争      : %6d ns/op".format(scenarioA(n)))
    println("B 8 线程争用同一把锁 : %6d ns/op".format(scenarioB(n)))
    println("C 单线程 + 分配压力  : %6d ns/op".format(scenarioC(n)))
    println("\n注意:三行是同一段代码。差别只来自运行环境。")
}

三、预期输出(形态)

同一个 handleRequest(),三种环境下运行:

A 单线程无竞争      :     82 ns/op
B 8 线程争用同一把锁 :   1840 ns/op
C 单线程 + 分配压力  :    260 ns/op

注意:三行是同一段代码。差别只来自运行环境。

你的数字会不同,但趋势应该一致:B 明显比 A 慢一个数量级以上。

四、三个关键点(正文对应)

  1. 微基准(场景 A)测出的「82 ns」在真实并发下没有代表性。 8 个线程争一把锁,同一个函数变成 1840 ns——放大 20 倍。这就是正文里「微基准结论不能推测线上表现」的实证。

  2. 锁竞争的成本不是「加锁本身」,而是「等待」。 无竞争的 synchronized 只要十几纳秒,但有竞争时线程要排队、要上下文切换。这也是第 5 节「利用率 90% 很危险」的另一个侧面:竞争和排队是一回事。

  3. 分配压力(场景 C)的影响是间接的。 分配本身(TLAB 指针碰撞)非常快,变慢的原因是 GC 开始工作、以及缓存被垃圾数据冲刷。所以 JVM 上的优化目标不是「零分配」,而是降低分配速率(第 2 章)。

五、动手改造(重要)

跑通之后,改参数再跑一遍,观察敏感度:

改动 预期观察
把场景 B 的线程数从 8 改成 2、4、16 找到「竞争开始显著」和「继续加线程反而更慢」的两个点
把 synchronized 换成 java.util.concurrent.atomic.AtomicLong 竞争成本大幅下降(但仍有缓存行争用)
在场景 B 里把临界区的内容减少一半 竞争成本不成比例地下降——临界区越短,竞争越少
把场景 C 的 ByteArray(4096) 改成 ByteArray(64) 分配速率的影响变小

每一次改动前先猜结果,猜错的地方才是你真正学到东西的地方。

六、这段代码的局限

  • 运行在你的开发机上,有后台进程干扰,数字仅供量级对比。
  • 没有真正预热到稳态就测量(waitForJit 只做了粗略预热)。第 2 章会教你用 JMH 做标准化处理。
  • 场景 B 的计时方式比较粗糙(用最慢线程的耗时除以每线程迭代数)。真实压测要用开环模型(第 4 章)。
  • 这里的数字不能用于任何工程决策。