字符串去重是G1中引入的新特性,从OpenJDK的官方文档来看,该特性的引入平均节约内存13%左右。本章主要介绍:G1中字符串去重的实现、日志分析,另外字符串去重和JDK中String类的intern方法有一些类似的功能,所以本章还介绍了intern的实现以及字符串去重和intern的区别。
字符串去重概述
字符串是我们日常开发使用最多的类型。字符串去重目的是优化字符串对象的内存使用,因为从统计数据上看,应用程序中的String对象会消耗大量的内存。这里面有一部分是冗余的,即同样的字符串会存在多个不同的实例(a!=b,但a.equals(b))。最初JDK提供了一个String.intern()方法来解决字符串冗余的问题。这个方法的缺点在于你必须去找出哪些字符串需要进行驻留(interned)。如果使用得当的话,字符串驻留会是一个非常有效地节省内存的工具,它让你可以重用整个字符串对象。
从Java 7 update 6开始,每个String对象都有一个自己专属的私有字符数组char[],这个字符数组在JVM内部使用typeArrayOOp表示。这样JVM可以自动进行优化,既然底层的char[]没有暴露给外部客户端的话,那么JVM就能去判断两个字符串的内容是否一致,进而将一个字符串底层的char[]共享成另一个字符串的底层char[]数组。字符串去重是为了共享以减低内存使用,在Java 8 update 20中被引入。这个特性发生在G1回收器的YGC阶段中或者是在Full GC的标记阶段。
字符串去重的过程可以分为三步。
第一步,找到需要去重的对象。这一步发生在Young GC的对象复制阶段或者是在Full GC的标记阶段。首先会检查字符串是否可以去重,如果可以则把对象放入到队列中。字符串是否可以参与去重的条件如下:
- 对象是字符串对象,且位于新生代。
- 如果是在GC的对象复制阶段。
- 如果是复制到S区,并且对象的年纪是StringDeduplicationAgeThreshold。这是为了让那些小对象能够经过几次GC后才处理,这样大多数生命周期短的对象不会被处理。
- 如果不是复制到S区,即晋升到Old,并且对象的年纪小于StringDeduplicationAgeThreshold。对于小对象,如果已经处理过,不用再处理了。如果是大对象,则不会发生去重。
- 如果是在Full GC的标记阶段,只需要考虑第二个条件。发生Full GC之后,会把所有的分区标记为Old。
代码如下所示:
hotspot/src/share/vm/gc_implementation/g1/g1StringDedup.cpp
bool G1StringDedup::is_candidate_from_evacuation(bool from_young, bool to_young, oop obj) {
if (from_young && java_lang_String::is_instance(obj)) {
if (to_young && obj->age() == StringDeduplicationAgeThreshold)
return true;
if (!to_young && obj->age() < StringDeduplicationAgeThreshold)
return true;
}
return false;
}
bool G1StringDedup::is_candidate_from_mark(oop obj) {
if (java_lang_String::is_instance(obj)) {
bool from_young = G1CollectedHeap::heap()->heap_region_containing_raw(obj)->is_young();
if (from_young && obj->age() < StringDeduplicationAgeThreshold)
return true;
}
return false;
}
void G1StringDedup::enqueue_from_evacuation(bool from_young, bool to_young, uint worker_id, oop java_string) {
if (is_candidate_from_evacuation(from_young, to_young, java_string)) {
G1StringDedupQueue::push(worker_id, java_string);
}
}
第二步,去重。这一步主要由单独的去重线程完成,它会处理去重队列以尝试去重。去重中有个HashTable能够跟踪所有字符串中使用的不同的字符数组。开始去重的时候,会先查找字符数组是否已经存在,如果存在则调整对象指针,共享字符串数组,释放字符串对象的字符数组;如果失败则把字符数组加入到hashTable中。队列最多有ParallelGCThreads个,每个队列最大的长度是1 000000,超过长度则不参与去重。
去重线程在G1CollectHeap初始化中启动。HashTable的最大长度为1<<24(16777 216个),最小为1<<10(1024个)。hashTable存储的是String对象的值(即上面的char[],类型为typearrayOOp)。在字符串被回收的时候可以通过closure直接遍历,查找。遍历增加了额外的一层访问,但是对象的空间可能节约很多。据JEP的测试报告,字符串长度平均为45个字符,对象占了堆空间的25%,去重后大约占比13.5%。具体可以参考http://openjdk.java.net/jeps/192获得更的信息。
字符串的intern操作是有点特殊,因为这个操作在插入到StringTable之前去重,这是为了在C2编译优化下不能去重。但是这样做的后果就是使得这里的去重技术完全没用了,目前还没有合适的解决方案来过滤那些经过intern的字符串。这通常不是问题,intern操作占的比例不大。
第三步,回收。当发生GC的时候,会尝试对去重后的字符串对象进行回收。发生的时机主要有:YGC发生中会回收,在并发标记的过程中处理引用的时候也会进行字符串去重的回收,在FGC中标记活跃对象时也会发生回收。
假设定义两个字符串对象,如下所示:
String Str1 = new String(“abc”);
String Str2 = new String(“abc”);
下图是字符串去重前后的对象存储示意图。
日志解读
下面通过一个例子分析一下字符串去重的影响:
import java.util.LinkedList;
public class StringDepTest {
private static final LinkedList<String> strings = new LinkedList<>();
public static void main(String[] args) throws Exception {
int iteration = 0;
while (true) {
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 10; j++) {
strings.add(new String("String " + j));
}
}
iteration++;
System.out.println("Survived Iteration: " + iteration);
Thread.sleep(100);
}
}
}
如果不使用字符串去重,大概在3386次会发生OOM,运行的参数为:
-Xmx256M -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
如果使用字符串去重,大概在5431次后发生OOM。运行的参数为
-Xmx256M -XX:+UseG1GC -XX:+UseStringDeduplication
-XX:+PrintStringDeduplicationStatistics -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
这说明字符串去重在大量重复的情况下,还是能优化不少。下面看一下具体的日志分析:
121.874: [GC concurrent-string-deduplication, 12.9M->0.0B(12.9M), avg
99.9%, 0.0354704 secs]
[Last Exec: 0.0354704 secs, Idle: 43.2857942 secs, Blocked: 0/0.0000000
secs] // 当前发生GC或者并发标记时进行去重的统计信息
[Inspected: 423955]
[Skipped: 0( 0.0%)] // 当字符串对应的值为NULL,则跳过
[Hashed: 423955(100.0%)] // 字符串对应的哈希值不为0
[Known: 0( 0.0%)] // 字符串可以共享的次数
[New: 423955(100.0%) 12.9M] // 字符串新加入到hash table
// 中的次数
[Deduplicated: 423955(100.0%) 12.9M(100.0%)]
[Young: 10( 0.0%) 320.0B( 0.0%)]// 字符数组在新生代的个数
[Old: 423945(100.0%) 12.9M(100.0%)]// 字符数组在老生代的个数
[Total Exec: 5/0.0917765 secs, Idle: 5/121.5590417 secs, Blocked:
0/0.0000000 secs] // 发生GC或者并发标记时进行去重总的统计信息,这里表明到现在为
// 止已经发生了5次去重回收
[Inspected: 1154437]
[Skipped: 0( 0.0%)]
[Hashed: 1152384( 99.8%)]
[Known: 1809( 0.2%)]
[New: 1152628( 99.8%) 35.2M]
[Deduplicated: 1152179(100.0%) 35.2M( 99.9%)]
[Young: 165( 0.0%) 5280.0B( 0.0%)]
[Old: 1152014(100.0%) 35.2M(100.0%)]
[Table]
[Memory Usage: 68.9K] // HashTable所占用的空间
[Size: 2048, Min: 1024, Max: 16777216] // 大小信息
[Entries: 2258, Load: 110.3%, Cached: 0, Added: 2258, Removed: 0]
// entry信息
[Resize Count: 1, Shrink Threshold: 1365(66.7%), Grow Threshold:
4096(200.0%)] // hashTable变化信息
[Rehash Count: 0, Rehash Threshold: 120, Hash Seed: 0x0] // Rehash信息
[Age Threshold: 3] // 字符串去重的阈值信息
[Queue]
[Dropped: 0] // 字符串超过队列长度丢弃的次数
参数介绍和调优
字符串去重涉及的参数有:
- 参数UseStringDeduplication,默认值为false,打开参数表示允许字符串去重。
- 参数StringDeduplicationAgeThreshold,默认值为3,控制字符串是否参与去重的阈值。虽然字符串去重能明显减少内存的使用,但是正如我们在对代码的分析中提到,这会增加GC处理的时间,所以在实际使用中,建议先打开字符串去重进行验证,如果发现能得到比较好的效果再使用。
字符串去重和String.intern的区别
intern方法是Java类库中String类提供的方法,用来返回常量池中的某字符串,简单地说该方法的功能是:利用Hotspot里面的一个StringTable(使用HashTable实现)存储字符串对象,如果StringTable中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在StringTable中加入该对象,然后返回引用。从功能上看,似乎和字符串去重非常类似,但实际上它们的机制并不相同。还继续使用9.1节的两个字符串对象并执行intern方法,如下所示:
String Str1 = new String(“abc”);
String Str2 = new String(“abc”);
Str1.intern();
Str2.intern();
Str1.intern()执行后,在StringTable内存有一个Hash Table存储这个String对象。由于Str1对应的字符数组对象并不在StringTable中,所以它会被加入到StringTable中,如图9-2所示,图中用Oop表示对象,这里我们忽略外部的引用根即栈信息。
当执行Str2.intern()时,首先计算Str2的哈希码,然后用哈希码和Str2的字符数组对象在StringTable中查找是否已经存储了String对象,并且这个存储的String对象哈希玛以及字符串数组是否相同,如果相同则不需要再次把字符串放入StringTable中,同时返回Str1这个对象。
另外在G1中因为使用SATB的并发标记算法,当Str1已经死亡时,这时Str2.intern()并不会插入StringTable中,所以为了不丢失对象,需要把Str1重新激活(通过前面提到的写屏障)。
最后做一个简单的总结:
- intern缓存的是字符串对象,字符串去重缓存的是字符串对象里面的字符数组。其实这里还可以再思考一下,为什么它们的实现机制不同?为什么G1中的字符串去重不采用和intern中一样的实现?简单地说是为了并发标记的处理。
- intern必须显式调用,才能达到去重的目的;字符串去重是JVM自动进行的。
String.intern中的实现
JVM在内部使用了StringTable来存储字符串intern的结果。实际上JVM中使用的符号表SymbolTable和StringTable的结构是一样的,如下图所示。
StringTable是用户在执行intern时添加的,SymbolTable是对Java类解析时添加的。他们的回收是在GC操作中的安全点进行的,详情可见下一章。这里介绍一下如何观察符号表和字符串的信息,再介绍一个参数PrintStringTableStatistics,由该参数可以得到日志信息,结合上图进行分析,如下所示:
SymbolTable statistics:
符号表一共有bucket 20011个,且每个bucket占用8个字节(说明:如果是32位系统,那么每个bucket占用4个字节),如下所示:
Number of buckets : 20011 = 160088 bytes, avg 8.000
实际使用了14109个entry,占用的空间为330KB,每个entry固定占用24字节,如下所示:
Number of entries : 14109 = 338616 bytes, avg 24.000
这14109个entry,存储的字面变量占用的空间为587KB,平均字符串占用42.6字节,如下所示:
Number of literals : 14109 = 601200 bytes, avg 42.611
总的空间占用情况,包括bucket、entry和字面变量,如下所示:
Total footprint : = 1099904 bytes
下面是bucket中LinkedList的平均大小,这个值越大,说明HashTable碰撞越严重:
Average bucket size : 0.705
Variance of bucket size : 0.707
Std. dev. of bucket size: 0.841
Maximum bucket size : 6
StringTable statistics:和符号表一样,省略。
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1769 = 42456 bytes, avg 24.000
Number of literals : 1769 = 158040 bytes, avg 89.339
Total footprint : = 680600 bytes
Average bucket size : 0.029
Variance of bucket size : 0.030
Std. dev. of bucket size: 0.172
Maximum bucket size : 2
因为StringTable和SymbolTable的回收在安全点中进行,如何调整参数也在安全点一章中介绍。