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 慢一个数量级以上。
四、三个关键点(正文对应)
-
微基准(场景 A)测出的「82 ns」在真实并发下没有代表性。 8 个线程争一把锁,同一个函数变成 1840 ns——放大 20 倍。这就是正文里「微基准结论不能推测线上表现」的实证。
-
锁竞争的成本不是「加锁本身」,而是「等待」。 无竞争的
synchronized只要十几纳秒,但有竞争时线程要排队、要上下文切换。这也是第 5 节「利用率 90% 很危险」的另一个侧面:竞争和排队是一回事。 -
分配压力(场景 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 章)。
- 这里的数字不能用于任何工程决策。