ArrayList 的扩容公式已经在系列里真实验证过一次。StringBuilder 底层同样是个会扩容的数组,但公式不一样——反射进 StringBuilder 继承来的底层 byte[],连续 append,把每一次真实扩容后的容量都记下来。
上一篇:HashMap 的 key 字段变了:真实测出对象没丢,只是永远找不到了
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,之后每次不够用就扩容一次。
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 这个公式,没有例外。
把上面表格里的 8 次容量变化串成动画,每一步都是真实反射读出的 byte[] 长度,代入公式逐次验证。
如果提前知道大概要拼多长的字符串,用带初始容量的构造函数能真实省掉全部扩容开销。
预设容量 2000 的初始底层数组长度 = 2000 append 2000 次(预设容量场景)真实扩容次数 = 0和默认容量场景需要真实扩容 7 次相比,预先给够容量的场景全程 0 次扩容——每次扩容都意味着分配一个新数组、把旧内容整体拷贝过去,拼接量大且能预估长度的场景下,这个成本是完全可以真实省掉的