两百万条记录,一百种城市名,占了一百多兆
一个把订单明细加载到内存里做聚合的任务,堆转储里排第一的是
byte[],第二是String。点开看,是同样的几十个城市名、状态码、币种,每个重复了上万次。
从数据库、文件、消息里读出来的每个字符串字段,都是一个新的 String 对象,哪怕它的内容与前一条记录完全相同。Java 不会自动把内容相同的字符串合并成一个。对取值种类很少的字段,这意味着同样的内容在堆里存了成千上万份。
本文量一下这部分开销有多大,以及几种去重办法各自省掉了什么。实验见文末。
一、一个字符串占多少
实验模拟读入 200 万条记录,每条有一个约 20 个字符的城市字段,取值只有 100 种,每次解析都从字节新建一个 String:
| 做法 | 每个字段占用 |
|---|---|
每条记录各建一个 String | 64.5 字节 |
64.5 字节由两部分组成:String 对象本身 24 字节(对象头、指向数组的引用、缓存的哈希值等),加上存放内容的 byte[] 约 40 字节(数组头 16 字节加 20 个字符,对齐到 8 的倍数)。内容只有 20 字节,开销是它的三倍多。
200 万条合计约 129 MB,而真正不同的内容只有 100 个字符串,几 KB。
一个汉字的代价
| 做法 | 每个字段占用 |
|---|---|
| 各建各的,字段末尾多一个汉字 | 87.7 字节 |
JDK 9 之后,字符串内部按内容选择存储方式:所有字符都在 Latin-1 范围内时每个字符 1 字节,否则整个字符串每个字符 2 字节。只要出现一个汉字,前面那 20 个英文字符也都变成 2 字节。中文环境里的字符串普遍是后一种,同样长度占的内存接近两倍。
二、三种去重
| 做法 | 每个字段占用 | 构建 200 万个字段 |
|---|---|---|
| 各建各的 | 64.5 字节 | 167 ms |
解析后调用 intern() | 0.4 字节 | 249 ms |
用自己的 HashMap 归一 | 0.4 字节 | 182 ms |
各建各的,加 -XX:+UseStringDeduplication | 24.4 字节 | 183 ms |
归一成同一个对象
intern() 和自己维护一张表,做的是同一件事:内容相同的字符串只留一个对象,所有记录都指向它。新建的那个临时字符串随后被回收。200 万个字段最后只对应 100 个对象,每个字段的开销只剩下那个引用。
Map<String, String> canon = new HashMap<>();
String c = canon.putIfAbsent(s, s);
if (c != null) s = c;两者的差别在于那张表归谁管:
intern()用的是 JVM 全局的字符串表。放进去的字符串在没有任何引用之后可以被回收,但表是全进程共享的,谁往里放了多少无从知道;对取值不受控的字段(用户输入、订单号)调用它,表会变得很大。把intern()的结果当锁对象还会带来别的问题,见加了 synchronized,计数还是少了。- 自己的表范围清楚:一次加载任务用一张,任务结束整张表丢掉。也可以限制大小,或者只对指定的几个字段使用。
多数情况下自己的表更合适。实验里它也更快一些,不过这个耗时是单次运行的结果,只能看量级。
归一之后要注意一点:这些字符串成了共享的对象。String 不可变,共享本身是安全的;但不要拿它们当锁。
G1 的字符串去重
-XX:+UseStringDeduplication 不需要改代码。垃圾回收器在后台找出内容相同的字符串,让它们共享同一个底层数组:
每个字段从 64.5 字节降到 24.4 字节。省掉的是 byte[] 那 40 字节,剩下的 24 字节是 String 对象本身:200 万个 String 对象仍然在,只是都指向了同一批数组。
它的特点:
- 不改代码,对无法修改的第三方库产生的字符串也有效。
- 只省数组,不省对象。
- 在后台进行,对象要存活过几轮回收才会被处理,短命的字符串不受影响,也不需要。
- 占用一些回收时的 CPU。
代码可以改的地方用归一,改不了的地方用它兜底,两者可以同时使用。
三、哪些字段值得处理
归一的收益取决于「取值种类」与「记录数」的比例:
- 种类远少于记录数:状态、类型、币种、国家与城市、租户标识、渠道。这些字段几乎都能省掉。
- 几乎不重复:订单号、用户名、时间戳、自由文本。归一没有任何收益,还要多维护一张与记录数一样大的表,内存反而增加。
所以不要对所有字符串字段一律处理。先看堆转储或者数据本身的分布,挑出那几个低基数的字段。
更进一步的做法是不用字符串:取值固定的字段转成枚举,或者映射成整数编码,记录里只存 4 个字节甚至 1 个字节。这对长期驻留在内存里的大数据集(本地缓存、内存索引)效果最明显。
也要先确认这件事值得做。只有当数据会在内存里停留、并且量足够大时,重复的字符串才是问题。一个请求里读几十条记录、处理完就丢弃,这些对象很快被回收,不需要任何处理。对象在年轻代里的分配与回收成本,见 JIT 与逃逸分析。
四、怎么发现
- 堆转储按类统计,
byte[]与String排在前面,而且String的数量远大于业务对象的种类。 - 分析工具(例如 Eclipse MAT)有专门的重复字符串报告,直接列出重复次数最多的内容。
- G1 下可以用
-Xlog:stringdedup*=debug看去重的统计,从中也能看出重复的比例。
排查堆问题的一般步骤,见 JVM 调优与线上排查。
配套实验
- codesphere-labs/java/string-duplication:JDK 25.0.4.1、G1:200 万条记录、100 种取值的字段,各建各的、
intern()、自己的HashMap归一、-XX:+UseStringDeduplication、多一个汉字五种情况下每个字段平均占用的堆;脚本只断言范围(验证记录)
参考资料