bigywater.tech
Java 内部原理 · 11

StringBuilder 扩容:真实反射测出容量是 16→34→70→142 这样长的

ArrayList 的扩容公式已经在系列里真实验证过一次。StringBuilder 底层同样是个会扩容的数组,但公式不一样——反射进 StringBuilder 继承来的底层 byte[],连续 append,把每一次真实扩容后的容量都记下来。

16→34→70→142…
真实反射记录的连续扩容容量序列,完全对应 oldCapacity*2+2 这个公式
2000 次 → 0 次扩容
预先 new StringBuilder(2000) 给够容量,同样 append 2000 次,真实测出扩容次数归零

上一篇:HashMap 的 key 字段变了:真实测出对象没丢,只是永远找不到了

底层是紧凑字符串的 byte[],不是 char[]

JDK 9 之后 String/StringBuilder 底层默认用紧凑字符串(Compact Strings),纯 ASCII 内容按 1 字节/字符存,不再是老印象里的 char[]。

StringBuilder 的可变字符缓冲区字段定义在它的父类 AbstractStringBuilder 里,反射拿到这个字段,在每次 append 之后检查数组长度是否变化,就能捕捉到每一次真实的扩容:

StringBuilderGrowth.java(核心反射逻辑) Field valueField = Class.forName("java.lang.AbstractStringBuilder").getDeclaredField("value"); valueField.setAccessible(true); for (int i = 0; i < 2000; i++) { sb.append('x'); byte[] value = (byte[]) valueField.get(sb); int cap = value.length; // Latin1 编码下 1 字节/字符,cap 就是真实容量 // ... 记录 cap 变化 }

真实验证:连续扩容的容量序列

默认 new StringBuilder() 初始容量是 16,之后每次不够用就扩容一次。

真实实测 · 默认容量开始,连续 append 2000 次
length=1 触发扩容 -> 新容量=16
length=17 触发扩容 -> 新容量=34
length=35 触发扩容 -> 新容量=70
length=71 触发扩容 -> 新容量=142
length=143 触发扩容 -> 新容量=286
length=287 触发扩容 -> 新容量=574
length=575 触发扩容 -> 新容量=1150
length=1151 触发扩容 -> 新容量=2302

第一行(length=1,容量 16)是初始状态,不是真实的扩容事件——new StringBuilder() 默认就分配了 16 个字符的容量,append 第 1 个字符时用不着扩容,只是这里的记录逻辑把"第一次读到的容量"也打印了出来。真正的第一次扩容发生在 length 到达 17(容量 16 已经不够用)的时候,新容量变成 34。逐个验证这串数字:16*2+2=34,34*2+2=70,70*2+2=142……一直到 1150*2+2=2302,每一步都真实对应 JDK 源码里 newCapacity = (oldCapacity << 1) + 2 这个公式,没有例外。

和 ArrayList 的扩容公式不一样系列里验证过 ArrayList 的扩容用的是 oldCapacity + (oldCapacity >> 1),大约 1.5 倍;这里 StringBuilder 用的是 oldCapacity*2+2,接近 2 倍——两者都是"翻倍类"扩容策略,但具体倍数和加的常数并不相同,不能把其中一个的公式套到另一个身上。

分步演示:oldCapacity*2+2 逐次代入

把上面表格里的 8 次容量变化串成动画,每一步都是真实反射读出的 byte[] 长度,代入公式逐次验证。

连续 append 2000 次,8 次真实扩容的容量序列(反射读出 AbstractStringBuilder.value 的长度)
这一步刚记录到的新容量

    真实验证:预先给够容量,扩容次数真实归零

    如果提前知道大概要拼多长的字符串,用带初始容量的构造函数能真实省掉全部扩容开销。

    真实实测 · new StringBuilder(2000),append 2000 次
    预设容量 2000 的初始底层数组长度 = 2000
    append 2000 次(预设容量场景)真实扩容次数 = 0
    和默认容量场景需要真实扩容 7 次相比,预先给够容量的场景全程 0 次扩容——每次扩容都意味着分配一个新数组、把旧内容整体拷贝过去,拼接量大且能预估长度的场景下,这个成本是完全可以真实省掉的

    参考与说明

    • 本文全部容量数字均在本机真实运行 Java 17(反射需要 --add-opens java.base/java.lang=ALL-UNNAMED)得到。
    • 和系列里 ArrayList 扩容那篇是同一类问题(可变长度容器的扩容策略),建议对照读。
    • 没有涉及:非 Latin1(含中文等非 ASCII 字符)时底层退化成 UTF-16、每字符 2 字节的存储细节——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号