英文原文链接: http://java-performance.info/string-intern-in-java-6-7-8/
本文将描述JDK6中String.intern()是如何实现的,以及在JDK7和JDK8中对字符串池化技术做了哪些改变。
String池化就是把一些值相同,但是标识符不同的字符串用一个共享的String对象来表示,而不是用多个String对象。举个栗子,如下代码所示,3个变量的值完全一样,那么通过String池化处理,3个变量a,b,c事实上指向同一个String对象:
String a = "afei"; String b = "afei"; String c = "afei";
要达到这个目的,你可以自己借助 Map<String, String>
实现,这就是传说中的重复造轮子。当然,最优雅的方式还是利用JDK提供的String.intern()方法。
在JDK6的时候,很多标准规范禁止使用String.intern(),因为如果池化控制不好的话,非常可能碰到OOM。因此,JDK7做了相当大的改变,更多细节可以参考下面给到的两个链接地址:
http://bugs.sun.com/view_bug.do?bug_id=6962931
http://bugs.sun.com/view_bug.do?bug_id=6962930
JDK6时代,所有通过String.intern()处理过的字符串都被保存在PermGen中,是Java堆的一个 固定大小 部分,主要用来存储已经加载的类信息和字符串常量池。除了显示调用String.intern()的字符串,PermGen的字符串常量池也包含了程序中所有 使用过 的字符串(如果一个类或者方法从来没有被加载,或者被调用,那就不是使用过的)。
JDK6中字符串常量池这样的实现最大的问题的就是它保存的地方--PermGen。PermGen的大小是 固定 的,不能在运行时扩容,用户可以通过JVM参数 -XX:MaxPermSize=N 设定PermGen的大小。这样的限制导致我们在使用String.intern()时需要非常小心,如果你想在JDK6中使用String.intern(),那么你最好不要使用在不能被控制的的地方(例如池化用户名,这绝对是灾难)。
Oracle工程师在JDK7中对字符串常量池逻辑做了非常重要的改变 -- 将字符串常量池重新移到了堆中,它不再是一块分离的固定大小的内存区域。所有字符串都被移到堆中,和其他大多数对象一样。如此一来,当你调优应用时只需要管理堆尺寸大小。如果是基于JDK7的应用,那么,这绝对是一个充分的理由让你重新考虑使用String.intern(),当然也有一些其他的理由。
是的,JVM字符串常量池中所有的字符串都能被垃圾回收掉,前提条件是那些不再被GC Roots引用的字符串 -- 这个结论适用于我们正在讨论的JDK6,7,8三个版本。这就意味着,如果你通过String.intern()池化的字符串超过了范围,并且不再被引用,那么它们是可以被GC掉的。
字符串常量池中的字符串能够被GC,也能保留在Java堆中。这样看来,对所有字符串来说,字符串常量池似乎是一个很合适的地方,理论上是这样的 -- 没有使用的字符串会在常量池中被GC掉,使用的字符串允许你保存在内存中,这样当然取等值的字符串时就能直接从常量池中取出来。听起来似乎是一个完美的保存策略,但是在下结论之前,你需要先了解字符串常量池实现的细节。
字符串常量池被实现为一个固定容量大小的HashTable,每个桶(bucket)包含所有hashcode值相同的字符串组成的List链表。默认池大小是1009,JDK6早期版本是固定常量池,JDK6u30~6u41是可以配置的,JDK7从7u02开始也是支持配置的,通过JVM参数 -XX:StringTableSize=N 即可指定。需要提醒的是,为了更好的性能和散列性,建议N为一个质数(prime number,例如13,17,1009这样的数)。
JDK6中StringTableSize这个参数帮助价值不大,因为还是受到PermGen固定大小的限制。所以,接下来的讨论将抛弃JDK6。
另外,需要注意的是,字符串常量池这个HashTable,当存放的数据足够多需要rehash时,其rehash的行为和Java中使用的HashMap有所不同。字符串常量池底层的HashTable在rehash时不会扩容,即rehash前后bucket数量是一样的。它的rehash过程只是通过一个新的seed重新计算一遍来 尝试 摊平每个bucket中LinkedList的长度(不保证rehash后的性能有很大的提升)。
需要说明的是,这个段落只表示JDK7u40之前的JDK7版本。
JDK7中,一方面你将受到更大堆内存的限制,意味着你能设置一个更大的字符串常量池(具体多大取决于你应用需求)。通常来说,当内存数据尺寸增长几百M后,我们才会开始关心内存消耗,在这种场景下,为常量池分配8~16M用来保存百万级个entry似乎是一个很合理的权衡(注意:不要设置-XX:StringTableSize的值为1000000,而应该设置为100003,因为1000000不是质数)。
如果你真的想要使用String.intern(),强烈建议将-XX:StringTableSize设置一个比默认值1009更大的值。否则,这个方法的性能很快就会退化到取决于双向链表长度。
以下是使用默认池大小的部分应用测试日志:在已经缓存了一些字符串时存入10000个字符串花费的时间,这1w个字符串通过Integer.toString(i)生成,其中i在[0, 1000000)之间:
0; time = 0.0 sec 50000; time = 0.030 sec 100000; time = 0.073 sec 150000; time = 0.130 sec 200000; time = 0.196 sec 250000; time = 0.279 sec 300000; time = 0.376 sec 350000; time = 0.471 sec 400000; time = 0.574 sec 450000; time = 0.666 sec 500000; time = 0.755 sec 550000; time = 0.854 sec 600000; time = 0.916 sec 650000; time = 1.006 sec 700000; time = 1.095 sec 750000; time = 1.273 sec 800000; time = 1.248 sec 850000; time = 1.446 sec 900000; time = 1.585 sec 950000; time = 1.635 sec 1000000; time = 1.913 sec
这个测试结果基于Core i5-3317U@1.7Ghz CPU。正如你看到的结果,耗时线性增长,当JVM常量池已经有1百万个字符串时,接下来每秒钟只能池化大概5000个字符串,这时候性能衰退还是很严重的。但是,当我们设置-XX:StringTableSize=100003后,再次进行测试,结果如下。正如你看到的,这种情况下,池化字符串的耗时比较稳定(每个bucket平均有10个字符串):
50000; time = 0.017 sec 100000; time = 0.009 sec 150000; time = 0.010 sec 200000; time = 0.009 sec 250000; time = 0.007 sec 300000; time = 0.008 sec 350000; time = 0.009 sec 400000; time = 0.009 sec 450000; time = 0.010 sec 500000; time = 0.013 sec 550000; time = 0.011 sec 600000; time = 0.012 sec 650000; time = 0.015 sec 700000; time = 0.015 sec 750000; time = 0.010 sec 800000; time = 0.010 sec 850000; time = 0.011 sec 900000; time = 0.011 sec 950000; time = 0.012 sec 1000000; time = 0.012 sec
接下来,我们依然设置-XX:StringTableSize=100003,但是准备池化1千万个字符串,这就意味着平均每个bucket中大概有100个字符串:
2000000; time = 0.024 sec 3000000; time = 0.028 sec 4000000; time = 0.053 sec 5000000; time = 0.051 sec 6000000; time = 0.034 sec 7000000; time = 0.041 sec 8000000; time = 0.089 sec 9000000; time = 0.111 sec 10000000; time = 0.123 sec
接下来,我们设置-XX:StringTableSize=10000003,并插入1千万个字符串继续进行测试。结果如下,正如你看到的,耗时是水平的而不是线性增长。由此可见,只要常量池大小足够大,即使是我这种个人电脑也能轻轻松松达到每秒百万级的插入量。
1000000; time = 0.005 sec 2000000; time = 0.005 sec 3000000; time = 0.005 sec 4000000; time = 0.004 sec 5000000; time = 0.004 sec 6000000; time = 0.009 sec 7000000; time = 0.010 sec 8000000; time = 0.009 sec 9000000; time = 0.009 sec 10000000; time = 0.009 sec
现在,我们比较一下JVM自带的 字符串常量池
和 WeakHashMap<String, WeakReference<String>>
(WeakHashMap可以用来模拟常量池),用WeakHashMap的代替实现如下:
private static final WeakHashMap<String, WeakReference<String>> s_manualCache = new WeakHashMap<String, WeakReference<String>>( 100000 ); private static String manualIntern(final String str){ final WeakReference<String> cached = s_manualCache.get(str); if (cached != null){ final String value = cached.get(); if (value != null){ return value; } } s_manualCache.put(str, new WeakReference<String>(str)); return str; }
利用WeakHashMap造出的轮子测试性能如下:
0; manual time = 0.001 sec 50000; manual time = 0.03 sec 100000; manual time = 0.034 sec 150000; manual time = 0.008 sec 200000; manual time = 0.019 sec 250000; manual time = 0.011 sec 300000; manual time = 0.011 sec 350000; manual time = 0.008 sec 400000; manual time = 0.027 sec 450000; manual time = 0.008 sec 500000; manual time = 0.009 sec 550000; manual time = 0.008 sec 600000; manual time = 0.008 sec 650000; manual time = 0.008 sec 700000; manual time = 0.008 sec 750000; manual time = 0.011 sec 800000; manual time = 0.007 sec 850000; manual time = 0.008 sec 900000; manual time = 0.008 sec 950000; manual time = 0.008 sec 1000000; manual time = 0.008 sec
当JVM内存充足时,利用WeakHashMap造的轮子和JVM内置常量池性能相当。但是,在我缓存短字符串进行字符串缓存测试时,Xmx1280M的设置只能缓存约2.5M的strings,但是JVM常量池在保证相同性能的情况下可以缓存约12.72M的strings(5倍多)。因此我认为,我们最好在程序中避免自己实现字符串常量池。
7u40以后的版本字符串常量池大小被增大到60013(60013也是质数)。默认值改为60013可以让你保存大概30000个不同的字符串到常量池中而不会发生任何Hash碰撞。通常来说,这个值完全足够你保存那些真正需要池化的字符串。这个默认值你可以通过如下命令进行查看,通过结果我们可知,JDK8默认值就是60013:
afei$ java -XX:+PrintFlagsFinal -version | grep StringTableSize uintx StringTableSize = 60013 {product} java version "1.8.0_201" Java(TM) SE Runtime Environment (build 1.8.0_201-b09) Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)
接下来我尝试在JDK8环境上运行相同的测试用例,Java8依旧支持-XX:StringTableSize参数且提供了与Java7同样的性能表现。只是唯一的不同在于默认池大小增长为60013了:
50000; time = 0.019 sec 100000; time = 0.009 sec 150000; time = 0.009 sec 200000; time = 0.009 sec 250000; time = 0.009 sec 300000; time = 0.009 sec 350000; time = 0.011 sec 400000; time = 0.012 sec 450000; time = 0.010 sec 500000; time = 0.013 sec 550000; time = 0.013 sec 600000; time = 0.014 sec 650000; time = 0.018 sec 700000; time = 0.015 sec 750000; time = 0.029 sec 800000; time = 0.018 sec 850000; time = 0.020 sec 900000; time = 0.017 sec 950000; time = 0.018 sec 1000000; time = 0.021 sec
这篇文章中使用的测试代码非常简单:在循环中不断生成和池化新的strings。我们同时计算了它池化当前10000个字符串的耗时。运行此程序时,强烈建议用 -verbose:gc
这个虚拟机参数,以便当发生GC时,能在控制台实时输出GC日志。你也可以使用-Xmx参数来指定最大堆空间。
这里有2个测试:testStringPoolGarbageCollection 测试将会证明JVM字符串常量池真的可以被垃圾回收 —— 查看垃圾回收日志并在随后查看缓存字符串的耗时。这个测试在Java6中默认的永久代区大小中会失败。因此要么更新大小,要么更新测试方法参数,要么使用Java7。
第二个测试将会向你展示内存中可以缓存多少字符串。请在Java6中通过两个不同的内存设定运行此测试。例如-Xmx128M和-Xmx1280M(后者是前者的10倍)。你会发现这并不会影响可在池中缓存字符串的数目,因为那块保存池化的字符串空间大小是固定的。而在Java7中,你却可以用你的字符串填满整个堆。
/** * Testing String.intern. * * Run this class at least with -verbose:gc JVM parameter. */ public class InternTest { public static void main( String[] args ) { testStringPoolGarbageCollection(); testLongLoop(); } /** * Use this method to see where interned strings are stored * and how many of them can you fit for the given heap size. */ private static void testLongLoop() { test( 1000 * 1000 * 1000 ); //uncomment the following line to see the hand-written cache performance //testManual( 1000 * 1000 * 1000 ); } /** * Use this method to check that not used interned strings are garbage collected. */ private static void testStringPoolGarbageCollection() { //first method call - use it as a reference test( 1000 * 1000 ); //we are going to clean the cache here. System.gc(); //check the memory consumption and how long does it take to intern strings //in the second method call. test( 1000 * 1000 ); } private static void test( final int cnt ) { final List<String> lst = new ArrayList<String>( 100 ); long start = System.currentTimeMillis(); for ( int i = 0; i < cnt; ++i ) { final String str = "Very long test string, which tells you about something " + "very-very important, definitely deserving to be interned #" + i; //uncomment the following line to test dependency from string length // final String str = Integer.toString( i ); lst.add( str.intern() ); if ( i % 10000 == 0 ) { System.out.println( i + "; time = " + ( System.currentTimeMillis() - start ) / 1000.0 + " sec" ); start = System.currentTimeMillis(); } } System.out.println( "Total length = " + lst.size() ); } private static final WeakHashMap<String, WeakReference<String>> s_manualCache = new WeakHashMap<String, WeakReference<String>>( 100000 ); private static String manualIntern( final String str ) { final WeakReference<String> cached = s_manualCache.get( str ); if ( cached != null ) { final String value = cached.get(); if ( value != null ) return value; } s_manualCache.put( str, new WeakReference<String>( str ) ); return str; } private static void testManual( final int cnt ) { final List<String> lst = new ArrayList<String>( 100 ); long start = System.currentTimeMillis(); for ( int i = 0; i < cnt; ++i ) { final String str = "Very long test string, which tells you about something " + "very-very important, definitely deserving to be interned #" + i; lst.add( manualIntern( str ) ); if ( i % 10000 == 0 ) { System.out.println( i + "; manual time = " + ( System.currentTimeMillis() - start ) / 1000.0 + " sec" ); start = System.currentTimeMillis(); } } System.out.println( "Total length = " + lst.size() ); } }
接下来对String.intern()进行一个总结,希望对你有用:
JDK6由于保存字符串常量池的是一块固定大小的内存区域(PermGem),所以不建议使用String.intern()。
JDk7和8实在堆内存中实现的字符串常量池,这就意味者字符串常量池的限制和整个Java应用的内存一样大。
JDK7和8中可以通过参数 -XX:StringTableSize 设定字符串常量池大小,估算一下你应用中需要池化的不同字符串的数量,建议将常量池的大小设置为这个数量的2倍的一个质数(避免可能的碰撞,假设我们需要池化5000个字符串,那么10000往后最近的质数是10007,即设定-XX:StringTableSize=10007)。这会让String.intern运行在一个常数时间内(性能很高),并且每个缓存字符串所需内存会很小(在同任务量下,显式使用Java WeakHashMap会产生4-5倍多的内存开销)。.
JDK6到JDK7u40之间 XX:StringTableSize 默认值是1009,7u40以后,包括JDK8,这个默认值被提升到60013。
如果你不太确定你应用需要多大的字符串常量池,可以通过JVM参数 -XX:+PrintStringTableStatistics ,在JVM退出时可以看到字符串常量池的使用情况,从而更合理的调整StringTableSize的值。
END