[翻译]Java 6,7,8中的String.intern

前言

最近一直在关注“故障排查”的相关知识,首先着手的是OOM的异常。OOM异常通常会有Perm区的OOM(java7及以前)和HeapSpace的OOM,这两种各有不同的排查手段,但是在review上述两种案例的过程中,String.intern()是出现很多的一个方法,遂研究之。在网上找到了一篇写得不错的文章,就翻译下来给自己留点印象。原文地址:http://java-performance.info/string-intern-in-java-6-7-8/

——————————————————迷之分割线————————————

Java 6,7,8中的String.intern

这篇文章主要讲述了在java6中String.intern是怎么实现的以及java7和java8对它作了哪些改变。

字符串池化

字符串池化(通常也叫作“字符串常量化”),就是把一些标识符(可以理解为变量名)不同但是值相同的String对象用一个共享的String对象替代。你可以通过维护一个Map(根据需求可能需要使用soft/weak引用)或者使用JDK提供的String.intern()方法来实现“字符串常量池”。
在Java6的时代,很多标准禁止使用String.intern(),因为如果不加控制地使用String.intern(),很可能导致OOM。Java 7对“字符串常量池”的实现作了非常大的改变,详见http://bugs.sun.com/view_bug.do?bug_id=6962931 和 http://bugs.sun.com/view_bug.do?bug_id=6962930。

Java6中的String.intern()

在之前,所有的interned strings都存储在PermGen(永久代)中——堆中一个固定大小的区域,主要用来存储加载了的类和字符串常量池。除了被显示intern的strings,永久代的字符串常量池还被用来存储程序中使用过的所有String字面量(要注意的是“使用过的”,如果一个类/方法从来没有被加载/调用,定义其中的任何常量都不会被加载)
java6中这样构建的字符串常量池最大的问题在于它所处的地址——PermGen(永久代)。PermGen大小固定在运行时不能改变。你可以通过-XX:MaxPermSize=N选项设置PermSize的大小。据我所知,根据平台的不同,PermGen的大小从32M到96M不等。你可以增加它的大小,但是它的大小在运行时一直固定。基于这些限制,你在使用String.intern的时候必须特别小心——最好不要使用Sting.intern缓存任何不受控制的用户输入。这就是为啥在Java6的时候,大部分字符串常量池都是通过手动维护的Maps来实现的原因。

Java7中的String.intern()

在Java7中,Oracle的工程师对“字符串池化”的逻辑作了重大的改变——将字符串常量池移动到了堆中。这意味着你不会再被一块固定大小的内存区域所限制。像大多数常规对象一样,所有的Strings现在会处于堆中。当你需要微调优化你的应用时,你只需要关系堆大小就可以了。从技术上说,仅仅因为这个原因,你可以重新考虑在java7程序中使用String.intern()。但是还有其他原因。

字符串常量池中的值可以被GC回收

是的,JVM字符串常量池中的所有对象在没有被GC roots引用的情况下都可以被回收,这个结论适用于我们讨论的所有Java版本。这意味着如果你缓存的string逃离了作用域并且失去了引用——它将被移出JVM字符串常量池,并且被gc回收。

能够被gc会后并且处于堆中,JVM字符串常量池看起来可以用来存放所有的strings,不是吗?理论上来说是的,无用的strings应该被回收,使用过的strings允许驻留在内存中,方便下次取用。看起来是一个完美的节省内存的策略?基本上差不多。但是在你决定这么干之前你必须知道字符串常量池是怎么实现的。

java6、7和8中JVM 字符串常量池的实现

字符串常量池本质上是一个固定容量的hash map,每个bucket中包含着有着相同hashcode的string list。一些具体的实现细节可以从Java bug报告中略窥一二:http://bugs.sun.com/view_bug.do?bug_id=6962930。

字符串常量池的默认大小是1009。在java6的早期版本中,常量池大小是个常量,在Java6u30 和 Java6u41版本之间变得可配置。Java7版本从一开始就是可以配置的。你需要通过-XX:StringTableSize=N指定,其中N是字符串常量池map的大小。基于性能考虑,N最好是质数。

在Java6中这个参数帮助可能不大,因为你还是受限于PermGen的大小。以下的讨论不在java6范围内。

java7(到java7u40)

在Java7中,一方面。你受限的是内存空间更大的堆区域。意味着在一开始你可以把字符串常量池设置得更大(根据你应用需求而定)。通常,在内存数据增长了至少几百兆的时候,我们才开始关心内存消耗。基于此,为字符串常量池分配8-16M空间,可容纳百万级entry看起来是一个合理的权衡。

你可能希望桶中的缓存strings会均匀分布——请读这篇文章,看看实验结果。
如果你真的想要使用String.intern(),请把-XX:StringTableSize设置到更大的值(相比初始值1009),否则这个方法的性能将很快退化成链表的性能。

我注意到,字符串长度与缓存100个字符以下的字符串的时间两者是相关的(我发现在真实使用中缓存50字符长的数据都不太可能,所以100字符长的数据对我来说应该是一个很好得测试长度)。
以下是使用默认池大小的部分应用测试日志:在已经缓存了一些strings时存入10000个strings花费的时间;Integer.toString(i)其中i在0到999999之间:

0; time = 0.0 sec
50000; time = 0.03 sec
100000; time = 0.073 sec
150000; time = 0.13 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 [email protected] CPU。你可以看到,时间线性增长,当JVM字符串常量池大小包含了百万strings时,每秒钟只能缓存5000个strings。这对于大多数需要在内存中处理大量数据的应用来说都是不可接受的。

下面是当设置了 -XX:StringTableSize=100003的测试结果:

50000; time = 0.017 sec
100000; time = 0.009 sec
150000; time = 0.01 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.01 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.01 sec
800000; time = 0.01 sec
850000; time = 0.011 sec
900000; time = 0.011 sec
950000; time = 0.012 sec
1000000; time = 0.012 sec

可以看出,在这种场景下,往池中添加strings几乎是常数时间。以下是当往池中添加千万个strings的统计结果(平均每个桶中包含100个strings)。

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

当我们把池的大小增加到一百万个桶的时候:

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.01 sec
8000000; time = 0.009 sec
9000000; time = 0.009 sec
10000000; time = 0.009 sec

可以看出,时间几乎是固定,而且几乎与“一百万0秒”的十倍小的池没什么两样。只要池大小足够高,甚至我的这个很慢的笔记本都可以每秒向JVM字符串常量池新加入一百万的字符串。

我们还需要手动编写字符串常量池吗

现在我们把JVM自带的字符串常量池与WeakHashMap>进行比较,后者可以用来模拟JVM字符串常量池。以下方法用来替代String.intern。

private static final WeakHashMap> s_manualCache =
    new WeakHashMap>( 100000 );
 
private static String manualIntern( final String str )
{
    final WeakReference cached = s_manualCache.get( str );
    if ( cached != null )
    {
        final String value = cached.get();
        if ( value != null )
            return value;
    }
    s_manualCache.put( str, new WeakReference( str ) );
    return str;
}

以下是使用上述“池”的测试结果。

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有充足的内存时,使用自己实现的常量池与JVM常量池性能相当。但是,在我缓存短strings进行测试缓存测试时,Xmx1280M的设置只能缓存约2.5Mstrings,但是JVM常量池在保证相同性能的情况下可以缓存约12.72Mstrings(5倍多)。因此我认为,我们最好在程序中避免自己实现字符串常量池。

java 7u40+ 和 java8中的String.intern()

在java7u40版本中,字符串常量池大小增加到了60013。你可以在其中缓存约30000不同的strings而不发生碰撞。一般来说,这已经足够你用来缓存有效数据了。使用+PrintFlagsFinalJVM参数可以获得这个值。

我试着在java8原版中进行同样的测试。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.01 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.02 sec
900000; time = 0.017 sec
950000; time = 0.018 sec
1000000; time = 0.021 sec

测试代码

这篇文章中使用的测试代码非常简单:在循环中不断生成和缓存新的strings。我们同时计算了它缓存当前10,000个字符串的耗时。运行此程序时,十分提倡使用 -verbose:gc 这个虚拟机参数,以便查看GC何时何地发生。你也可能想通过使用 -Xmx参数来指定最大堆空间。
这里有2个测试:testStringPoolGarbageCollection 测试将会证明JVM字符串常量池真的可以被垃圾回收 —— 查看垃圾回收日志并在随后查看缓存字符串的耗时。这个测试在Java6中默认的永久代区大小中会失败。因此要么更新大小,要么更新测试方法参数,要么使用Java7。
第二个测试将会向你展示内存中可缓存多少字符串。请在Java6中通过两个不同的内存设定运行此测试。例如 -Xmx128M 和 -Xmx1280M(十倍)。你很可能会发现这并不会影响可在池中缓存字符串的数目。换言之,在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 lst = new ArrayList( 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> s_manualCache =
        new WeakHashMap>( 100000 );

    private static String manualIntern( final String str )
    {
        final WeakReference cached = s_manualCache.get( str );
        if ( cached != null )
        {
            final String value = cached.get();
            if ( value != null )
                return value;
        }
        s_manualCache.put( str, new WeakReference( str ) );
        return str;
    }

    private static void testManual( final int cnt )
    {
        final List lst = new ArrayList( 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() );
    }
}

总结

  • 在java6中避免使用String.intern(),因为JVM字符串常量值使用了固定大小的内存区域(PermGen)
  • java7和8字符串常量池在堆内存中。这意味着字符串常量的缓存之受限于整个应用的内存。
  • 在Java7/8中使用-XX:StringTableSizeJVM参数设置常量池的map大小。这个值是固定的,因为它是一个由桶组成的hash表。估算一下你应用中不同strings的数量,将常量池的大小设置为接近strings数量2倍的一个质数(避免可能的碰撞)。这会让String.intern运行在一个常数时间内,并且每个缓存字符串所需内存会很小(在同任务量下,显式使用Java WeakHashMap会产生4-5倍多的内存开销)。
  • 在Java6以及Java 7 直到 Java7u40前,-XX:StringTableSize 参数默认值是1009。在Java7u40中它增长为60013(在Java8中也是同样的值)。
  • 如果你不确定字符串常量池的使用情况,尝试使用 -XX:+PrintStringTableStatics 虚拟机参数。它将会在你程序结束时打印出你的字符串常量池的使用情况。

你可能感兴趣的:([翻译]Java 6,7,8中的String.intern)