JVM G1源码分析——字符串去重

字符串去重是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的回收在安全点中进行,如何调整参数也在安全点一章中介绍。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值