Skip to content

两百万条记录,一百种城市名,占了一百多兆 ​

一个把订单明细加载到内存里做聚合的任务,堆转储里排第一的是 byte[],第二是 String。点开看,是同样的几十个城市名、状态码、币种,每个重复了上万次。

从数据库、文件、消息里读出来的每个字符串字段,都是一个新的 String 对象,哪怕它的内容与前一条记录完全相同。Java 不会自动把内容相同的字符串合并成一个。对取值种类很少的字段,这意味着同样的内容在堆里存了成千上万份。

本文量一下这部分开销有多大,以及几种去重办法各自省掉了什么。实验见文末。

每条各建一个 String64.5 字节,合计约 129 MB加 -XX:+UseStringDeduplication24.4 字节,约 49 MB调用 intern()0.4 字节用自己的 Map 归一0.4 字节各建各的,字段里多一个汉字87.7 字节字符串里只要有一个字符超出 Latin-1,整个字符串改用每字符 2 字节存储归一只对「取值种类远少于记录数」的字段有意义:状态、类型、城市、币种、租户订单号、用户名这类几乎不重复的字段,归一没有收益,还多维护一张表
图 1 · 实测(JDK 25.0.4.1):200 万条记录,每条有一个约 20 个字符的字段,取值只有 100 种。每条各建一个字符串时,每个字段占 64.5 字节:24 字节的 String 对象加 40 字节的字节数组,合计约 129 MB。归一成同一个对象之后只剩 100 个字符串。G1 的字符串去重只共享底层的数组,String 对象本身还在。

一、一个字符串占多少 ​

实验模拟读入 200 万条记录,每条有一个约 20 个字符的城市字段,取值只有 100 种,每次解析都从字节新建一个 String:

做法每个字段占用
每条记录各建一个 String64.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:+UseStringDeduplication24.4 字节183 ms

归一成同一个对象 ​

intern() 和自己维护一张表,做的是同一件事:内容相同的字符串只留一个对象,所有记录都指向它。新建的那个临时字符串随后被回收。200 万个字段最后只对应 100 个对象,每个字段的开销只剩下那个引用。

java
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、多一个汉字五种情况下每个字段平均占用的堆;脚本只断言范围(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权