JEP 192 String Deduplication in G1

原文:http://openjdk.java.net/jeps/192
概述
通过增强G1垃圾收集器,减少java堆存活数据集合,它会自动持续不断的对重复的String实例进行去重。
 
目标不是
它的目标只是在G1垃圾收集器中实现这个特性
 
动机
许多大规模的java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半String对象是重复的,重复的意思是说:string1.equals(string2)=true。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存,减少内存footprint。

描述
String去重

String类有两个字段
private final char[] value; 
private int hash;
value字段跟具体的实现有关,在String这个类外部是不可见的。String类不会修改char[]的内容,也不会在数组对象上做同步。这就是说,同一个String的多个实例可以同时被安全透明的使用。

String对象去重理论上只是对value字段重新赋值,像这样:aString.value = anotherString.value.实际上的重新赋值是由VM来完成的,这就是说,虽然valaue字段是final的,但这并不是问题。

实际上,我们不是去重String对象,而是对String对象里面的char数组去重。不可能安全的做到对String对象去重,因为java应用程序是能观察到这样的变化(重新赋值)的,这可能会引起问题,比如:应用程序正在使用这个对象来做同步,或者是一些其他的依赖对象ID的方式。

String去重不需要对jdk的类库和已经存在的java代码做任何的改动

预期的收益

对许多java应用(有大的也有小的)做的测试得出以下结果:

堆存活数据集合里面String对象占了25%
堆存活数据集合里面重复的String对象有13.%
String对象的平均长度是45

因为我们只是对char数组进行去重,我们仍然要承受String对象的开销(对象头、字段、填充)。这个开销大概在24-32和字节,取决于平台和配置。但是,对于一个长度是45的字符串(90个字节+数组的头部),去重仍然是很可观的。

考虑上面的例子,期望的好处是能减少10%的堆。注意,这个数字是在很多应用的基础上计算出来的一个平均数。对某一个特定的应用,这个数字可能会上下波动很大。
实现
概述
当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。

候选集合

候选集合是在young/mixed和full收集的时候完成的,这是性能敏感的操作,因为它会应用到所有访问过的对象上。如果下面所有的条件都成立,这个对象就被认为是一个候选去重对象

这个对象是String对象

这个对象正在被从young区移除

对象正在被移进young/survivor区,并且对象的年龄达到了去重的阈值。或者是对象正在被移进old区并且它的年龄小于去重的阈值。

一旦一个对象晋升到old区,或者是它的年龄大于去重的阈值,它就再也不会变成候选对象了。这种方式避免了把相同的对象多次变成候选对象。

对string做intern()有一点特殊,他们在被插入到String常量表的时候会做明确的去重(下面解释了细节)。他们以后也可能变为去重的候选对象,若果他们达到了去重的年龄阈值,或者是被移到了old区。第二次尝试去重这些String是没有用的,但是我们没有更快的方式来过滤掉他们。但是这并不是一个问题,因为通常来说,interned的字符串的数量远远小于普通的字符串。

去重年龄阈值

一般认为,String对象要么存活时间很长,要么很短。对那些存活时间短的对象进行去重不过是浪费CPU和内存罢了。为了避免太早的对string进行去重,去重的年龄阈值表明了一个String对象在被认为是去重的候选对象之前必须要到达的年龄。这个阈值有一个合理的默认值,同时也是可配置的VM选项。

去重队列

去重队列实际上有好几个队列组成,每一个GC的工作线程都有一个。GC的工作线程可以做无锁和缓存友好的入队操作,这非常重要,因为入队是在stop-the-world阶段执行的。

去重Hashtable

去重Hashtable用来记录堆上发现的所有的不重复的char数组(他们是被String对象引用的)当处理一个候选的对象的时候,会查找这个hashtable,看是否已经存在一个相同的char数组。如果找到了,这个候选String对象的value字段就会被更新,然后指向hashtable里面存在的char数组,这就允许垃圾收集器最终会把原来的char数组回收掉。如果查找失败了,这个char数组会被添加到hashtable里面,这样以后的时候就可以共享这个数组了。如果char数组被垃圾回收掉,就会把它从hashtable中删掉,比如当所有引用这个char数组的String对象都被回收掉以后。

hashtable的大小会根据table中entry的数量动态变化,table有多个hash桶,每一个用链表解决hash冲突。如果链表的长度超过或者是少于给定的阈值,hashtable就会相应的收缩或者增长。

如果hashtable变得严重不平衡,它会自动重新计算hash值的(用一个新的种子),比如:一个hash的链表严重的超长,这根String常量表处理不平衡的hashtable是相似的。

对于那种产生大量不重复string的任务来说,几乎没有去重的机会
比起那些不使用去重的算法,hastable就会占用更多的内存,在这种情况下就不应该启用String去重。
去重的统计信息会打印在GC的log里面,这有助于指导决定是否开启去重。

去重线程
去重线程是一个VM的内部线程,它是和java应用并发的执行的。这个线程是实际做去重工作的。它会等待去重队列中String对象引用的出现,然后一个一个的把他们出队。对每一个出队的String对象,它会计算String的hashcode(如果需要的话),在去重hashtable中查找然后可能的话就去重。去重线程维持去重的统计信息(候选对象的个数,去重的个数),然后可以把这些信息打印到GC的日志里面。

加入到常量池的String

如果一个String被加入到常量池(调用了String.intern()),在插入到常量池String表之前,首先会做去重操作。这保证了一旦一个string对象被加入到常量池,它就永远不会被去重。把string加入到常量池以后再去重是非常糟糕的主意,因为它会阻碍编译器对String字符串常量的优化。
一些优化措施假定(一般都是正确的)String.value字段是永远不会改变的,是不可能指向另一个char数组的。基于这个认识,编译器可能把带有char数组地址的代码发布成直接值。这种优化允许比如:String.equals()做简单的更快的指针比较。如果char数组被GC移动了,在这样的代码块中,地址就会被改变。但是,如果String.value是在GC的外面,优化会失效,并回退到普通(但更慢)的字符串比较的方式。

对GC停顿时间的影响
下面的选项可能会影响GC的停顿时间:
候选集合是在hot path中完成标记(full堆),计算(young/mixed区),从GC的观点看,去重队列和去重hashtable都以弱引用的方式存储了oops(???),这意味着,垃圾收集器需要遍历这两个数据结构来调整或者是删除对已经删掉或者是回收掉的对象的引用。
遍历队列和hash表是这个算法性能最关键的地方。遍历允许所有的工作线程并行的工作。

足够高的去重成功率会平衡掉大多数或者是所有的这种影响,因为去重可以减少GC停段时间里面别的阶段要做的工作(比如减少计算的对象的数量)也会减少GC的频率(因为减少了堆的压力)

命令行选项

新添加下面的命令行选项:

UseStringDeduplication (bool):开启String去重

PrintStringDeduplicationStatistics (bool) :打印详细的去重统计信息

StringDeduplicationAgeThreshold (uintx):达到这个年龄的String对象被认为是去重的候选对象

其他的
有许多其他的对String对象去重的方式
在String创建的时候去重
这种方式存在的问题是,许多或者是大多数String对象很年轻就死掉了,计算hash值,查找已经存在的相等的char数组带来的开销是无法忽略的。

在代码中明确的使用String.intern()
 
有些情况下,这是最好的避免重复String对象的方式,但是这种方式也有一些问题:

一个问题是:String.intern()对所有equal的String对象都返回同一个String对象。除非是非常的小心,否则就可能带来功能上的不便,比如:当String对象用来同步的时候。

另一个问题是,许多情况下,开发者不知道在他们的代码里面什么时候应该使用String.intern()。

最后,目前String.intern()的实现并不是很好,因此这样的操作代价可能会很高。

测量和注入等价的String.intern()调用

你可以测试已经存在的应用,找到重复的String对象存在的地方,使用诸如java.lang.instrument这样的框架,在合适的地方注入String.intern()调用。好处就是在实际的workload中,只需要动态的改动字节码而不需要修改源代码。这么做的一个问题是,要看到字段被更新的频率不是那么直接,因此,如果intern()的调用被注入到hot path中,它将会极大地影响性能。所以,如果改了源代码,测试就需要重做,代价可能会非常大,有时候可能要靠手工。

在String.equals()和String.compareTo()的时候去重

当两个String进行比较的时候,结果表明了他们是否是一样的,在返回结果之前,这些方法可以调整让一个String的char数组引用另一个String的char数组,按这种方式实现的一个原型工作的相对比较好,主要的优点是没有内存的开销,因为不需要保持一个去重的hashtable。

但是,这种方式也有几个很明显的局限,首先,为了去重,两个String必须要进行比较。这意味着,很大一部分去重的候选对象都被错过了,因为并不是所有的对象有进行比较过。进一步,VM对这些方法有编译器自省机制,这回让实现起来变复杂,因为这不仅仅是调整String类本身。还有其他的技术上的问题,他们都让这种方式变得不那么吸引人了。

一次性去重

可以是一次性的操作,而不是持续不断的进行去重操作。简言之,可以这样实现,首先定位堆上所有的String对象,然后构建一个去重的hashtable,按需去重String对象,然后释放掉hashtable和其他的临时的数据结构。这比持续不断的去重要非常简单且容易实现,并且当不做去重的时候还不会增加内存的footprint。可以更进一步设想,如果需要的话,这种一次性的去重操作可以被周期性的执行,这就变成了半持续性操作。

这种方式的一个原型被发明出来,它使用JVMTI来扫描堆上的String对象。但是存在几个问题,首先,从一大批javaee的应用的观察结果说明,持续的进行去重比间或的去重更有好处。如果经常执行去重操作,每次都要扫描整个堆,重建hashtable带来的开销也是非常巨大的。进一步说,对于JVMTI来说,做这种类型的工作有点太不方便了,尤其是当它选择哪个一对象在什么时候要被去重的时候。

测试

将会用jtreg测试来确保去重按预期进行工作,也需要做系统测试和性能测试,评估java堆存活数据集合的减少,还要测试性能是提升还是下降。

风险和假设

我们假设引入“启用去重”,在hot path里面检查垃圾收集器的标记/复制逻辑不会引起严重的开销。这一点需要验证。

一般的,String对象和它对应的char数组是放在内存相邻的位置上,缓存很方便。去重以后,String对象和它的char数组离得非常远,可能会导致轻微的性能开销。最初的测试表明,这并不是个问题。实际上,去重以后,访问的内存的数量会减少,这会更有可能导致改善缓存的命中率。

影响
性能/扩展性:这可能会影响GC的停顿时间和缓存命中率,当访问String的char数组的时候。
我们需要做一些测试来评估对性能的影响。
阅读更多
换一批

没有更多推荐了,返回首页