问题的历史
在Java 8更新20之前,开发者寻求减少来自重复String实例的堆消耗时,必须完全依赖于String.intern()。该方法将字符串放入永久代(后来的Metaspace),需要显式的API调用,并可能导致内部池的内存压力。随着JEP 192的引入,G1垃圾收集器引入了自动String去重,这是一种透明的优化,针对企业应用中普遍存在的冗余字符数组问题。
问题
在数据密集型的Java应用程序中,比如解析XML、JSON或者数据库结果集,String对象通常占活跃堆的25-50%。这些字符串中有相当一部分是字符逐一相同的,但位于不同的char[](或在Java 9之后的**byte[]**紧凑字符串)背后数组中。如果不加干预,这些重复的数组会浪费内存并增加GC频率。挑战在于消除这种冗余,而不引入额外的停顿时间或需要修改代码。
解决方案
G1在现有的撤离暂停期间机会性地进行去重(当线程已经停止时)。当通过**-XX:+UseStringDeduplication启用时,收集器会扫描年轻代中的对象。对于每个经历至少-XX:StringDeduplicationAgeThreshold次垃圾收集(默认3次)的String**,G1会计算其背后数组的哈希值。然后,它会咨询一个去重表。如果存在相同的数组,G1使用比较和交换(CAS)操作将String的value字段重定向到现有数组,从而允许在下一个周期回收重复项。这利用现有的暂停,仅增加极小的CPU开销。
// 不需要代码更改;JVM标志启用优化: // -XX:+UseG1GC -XX:+UseStringDeduplication -XX:StringDeduplicationAgeThreshold=3 public class DeduplicationExample { public static void main(String[] args) { // 这两个字符串在去重后共享相同的背后数组 String a = new String("FinancialInstrument".toCharArray()); String b = new String("FinancialInstrument".toCharArray()); // 在足够的GC周期和撤离暂停之后, // a.value == b.value(内部数组引用相等) } }
一个处理FIX协议消息的高频交易平台经历了超过200毫秒的严重G1暂停时间。分析显示,64GB堆中有30%被字符串对象占用,这些对象表示标准标签(例如,"55","150","EUR/USD")和从传入字节流中解析的类似枚举值。每条消息实例化通过new String(byte[], Charset)创建新的String实例,这导致每分钟产生数百万个重复的背后数组。
评估了几种解决方案。String.intern()被拒绝,因为它需要对50多个消息类型进行侵入性更改,并且有可能因永久引用而使Metaspace饱和,这些引用将永远不会被垃圾回收。原型设计了一个基于WeakHashMap的缓存,但引入了复杂的并发开销和过期条目的清理逻辑,反而因额外的WeakReference处理而增加了GC压力。
最终,团队启用了默认年龄阈值为3的G1 String去重。这种透明的方法需要零代码更改,并在现有的撤离暂停期间运行,避免了任何新的停顿时间。
结果是堆耗用减少了22%,第95百分位的暂停时间降到50毫秒以下。在高峰市场时间,CPU开销大约为1.5%,这是一个可接受的权衡,考虑到内存节省和延迟改善。
String去重如何与Java 9的紧凑字符串相互作用,后者将Latin-1文本存储为byte[]而不是char[]?
答案。 String去重已更新为在启用紧凑字符串时对byte[]数组进行操作(自Java 9以来的默认)。去重逻辑检查coder字段(LATIN1或UTF16),并相应地对byte[]或char[]背后数组进行哈希。去重表按哈希和数组类型存储条目,确保Latin-1字符串只与其他Latin-1字符串去重,完整宽度的UTF-16字符串则与其对等去重。候选人常常误认为这一特性在紧凑字符串中被弃用,但它仍然完全兼容。
为何JVM施加年龄阈值(默认3次GC),使得一个String变得有资格进行去重?
答案。 年龄阈值防止系统浪费CPU周期去重短暂的、易逝的字符串,这些字符串很可能在下一次年轻代收集中就会消亡。通过要求String经历几次G1撤离周期(从Eden区域提升到Survivor区域,最终到达Tenured),启发式确保只有"成熟的"字符串——那些具有长期存活高概率的字符串——被处理。这将哈希计算和表查找的成本摊薄到对象的预期生命周期。
String去重是否影响String实例的不可变性或hashCode的稳定性?
答案。 不。去重过程严格是一种value字段引用突变的实现细节。由于替换数组包含相同的字节或字符,String的逻辑状态和hashCode保持不变。hashCode缓存在String对象本身的一个瞬态字段中,由于内容相同,缓存的值仍然有效。equals合约得以保留,因为内容相等意味着背后存储的引用相等与API合约无关。该操作从应用程序的角度是原子的,保持了String的不可变保证。