JDK字符串存储机制及String#intern方法深入研究

在jdk7或jdk8中执行如下代码(执行结果见对应的注释行):

public static void main(String[] args) {
        System.out.println("第一组对比:");
        System.out.println("======");

        String s1 = "1" + new String("2");
        String s2 = "12";
        String s3 = s1.intern();
        System.out.println(s1 == s3);//jdk6、jdk7、jdk8都为false
        System.out.println(s2 == s3);//true
        System.out.println("======");

        String s4 = "4" + "5";
        String s5 = "45";
        String s6 = s4.intern();
        System.out.println(s4 == s6);//true
        System.out.println(s5 == s6);//true
        System.out.println("======");
        System.out.println();

        System.out.println("第二组对比:");
        System.out.println("======");

        String s7 = new String("7") + new String("8");
        String s8 = "78";
        String s9 = s7.intern();
        System.out.println(s7 == s9);//false
        System.out.println(s8 == s9);//true
        System.out.println("======");

        String s10 = new String("9") + new String("0");
        String s11 = s10.intern();
        String s12 = "90";
        System.out.println(s10 == s12);//true
        System.out.println(s11 == s12);//true
        System.out.println("======");
}

上面的测试代码进行了两组对比,如果你完全能理解执行的结果,那么恭喜你,这篇博客你没必要看了;反之,这篇博客接下来的内容就是你的菜。不过,即使你能答对执行结果,也建议你阅读下本文对常量池及字符串创建的分析,因为关于这块解读的博客确实非常多,但可惜的是,大多说法都是错误的!!!

常量池

在分析这两组对比结果之前,我们需要先了解常量池。常量池大体可以分为:静态常量池,运行时常量池。

  • 静态常量池 存在于class文件中,比如执行“javap -verbose java类名”命令后,看到的最前面的部分内容就是静态常量池。

  • 运行时常量池 class文件被加载进了内存之后,静态常量池中的数据会保存在运行时常量池中,而运行时常量池保存在方法区中。通常说的常量池指的是运行时常量池。

运行时常量池

用于存放编译期生成的各种字面量和符号引用,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

  • JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代。

  • JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代

  • JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)。

字符串常量池

了解了字符串常量池,我们再来回答一个问题:字符串常量池里存的到底是对象还是引用?在 JDK6.0 及之前版本,字符串常量池是放在 Perm Gen 区(也就是方法区)中,此时字符串常量池中存储的是对象在 JDK7.0版本,字符串常量池被移到了堆中了,因而字符串常量池中存储的就只是引用了;在 JDK8.0 版本,永久代(方法区)被元空间取代了,字符串常量池仍然在堆中,因而字符串常量池中存储的也是引用。正是以上的差异,导致上述代码在jdk6中和jdk7和jdk8中的执行结果是不同的,下面具体分析。
 

字符串的三种生成方式

我们接着来介绍字符串的生成方式。很多博客说Java字符串生成有两种方式,但本博客总结为三种:

  1. 以双引号的方式:
    String str1 = "test1";

    该方式在jdk6、jdk7和jdk8中都是在类加载阶段(包括加载、验证、准备、解析和初始化)生成一个值为“test1”的对象,但不同的是,在jdk6中这个对象位于字符串常量池中,str1指向该对象;在jdk7和jdk8中这个对象都位于堆中,str1指向该对象,且该对象同时会被常量池所引用。因而在代码执行阶段,以双引号的方式创建会直接返回字符串常量池中引用的对象。

  2. 以new String()的方式:
    String str2 = new String("test2");

    该方式在jdk6、jdk7和jdk8中最终都会生成两个相互独立、值都为“test1”的对象,且都是一个在类加载阶段创建,另一个在上述代码片段的执行阶段创建。但不同的是,在jdk6中这两个对象分别位于堆中和常量池中,其中str1指向堆中的对象;在jdk7和jdk8中这两个对象都位于堆中,str1指向其中一个,另一个被常量池引用。

  3. 以运算符(+)创建:
    String str3 = "te3" + "st3";//方式一
    String str4 = new String("te4") + new String("st4");//方式二
    String str5 = "te5" + new String("st5");//方式三
    String str6 = "te6";
    String str7 = "st7";
    String str8 = str6 + str7;//方式四

    这种方式可以认为是前面两种方式和运算符“+”的组合方式。经过反编译后,上述代码变成了如下等价代码:

    String str3 = "te3st3";//方式一
    String str4 = (new StringBuilder()).append("te4").append("st4").toString();//方式二
    String str5 = (new StringBuilder()).append("te5").append("st5").toString();//方式三
    String str6 = "te6";
    String str7 = "st7";
    String str8 = (new StringBuilder()).append(str6).append(str7").toString();;//方式四

    由此可知,方式一等价于前面的双引号方式,即只会在类加载阶段创建一个值为“te3st3”的对象,这里需要注意的是,在方式一中字符串常量池中不会包含“te3”或“st3”对象或对象引用,其实由编译完成之后的代码来看这是显然的;而方式二、三、四实际上都是通过StringBuilder来实现的。以方式二为例,在类加载阶段会创建值为“te4”和“st4”的对象各一个,这两个对象在jdk6中是位于常量池中,而在jdk7和jdk8中则是位于堆中(常量池中保存了其引用)。然后在代码执行阶段会在堆中创建一个值为“te4st4”的对象,并将其引用赋值给str4,即字符串常量池中不会有值为“te4st4”的引用。

String#intern()方法

由前面的介绍可知,在有些场景下,字符串在编译结束后其值是无法确定的, 因而无法在类加载阶段将这样的字符串添加到字符串常量池中。intern方法就是为了解决这样的问题而诞生的,即该方法可以在运行过程中手动将字符串添加进字符串常量池。

在该方法的源码中有这样一段注释:

When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.

实际上,这段话对jdk6来说是合适的,但到了jdk7和jdk8就容易引起误会了。具体来说就是,在jdk6中,如果常量池中不包含值相等的字符串常量,则在常量池中重新创建一个该值的对象,该对象和堆中的对象没有任何关联;对于jdk7和jdk8时,如果常量池中不包含值相等的字符串常量时,是直接将堆中该对象的引用直接添加到常量池中,因而此时从常量池中获取到的对象就是堆中的对象。

测试代码分析   分别分析jdk6和jdk7,8

String s1 = "1" + new String("2");
String s2 = "12";
String s3 = s1.intern();
System.out.println(s1 == s3);//false
System.out.println(s2 == s3);//true

由前面的介绍可知,s1在代码执行阶段指向的是堆中值为“12”的对象,而此时字符串常量池中是没有该对象引用的;第二行代码在类加载阶段会在堆中创建一个值为“12”的对象,并将其引用添加到字符串常量池中,而s2指向的就是类加载阶段创建的对象;第三行代码执行intern()方法时,发现常量池中已经有值为“12”的对象引用了,于是返回的是字符串常量池中对象的引用,即s2和s3指向的是同一个对象,因而有s2==s3为true,而s1指向的是堆中另一个对象,因而s1==s3为false。

String s4 = "4" + "5";
String s5 = "45";
String s6 = s4.intern();
System.out.println(s4 == s6);//true
System.out.println(s5 == s6);//true

类似地,s4指向的对象是在类加载阶段创建的值为“45”的对象,且此时字符串常量池中也引用了该对象;第二句,s5获取到的是字符串常量池中引用的对象,即s4和s5指向了同一个对象。第三句执行intern方法时发现常量池已经有该对象了,返回的还是常量池的对象,因而才会有s4==s5==s6都为true。

String s7 = new String("7") + new String("8");
String s8 = "78";
String s9 = s7.intern();
System.out.println(s7 == s9);//false
System.out.println(s8 == s9);//true

类似地,s7指向的是位于堆中值为“78”的对象,此时常量池中还没有值为“78”的对象;执行第二句时,s8获取到的是字符串常量池中引用的对象;执行intern方法时,返回了常量池中的对象引用,因而s7==s9为false,而s8==s9为true。

String s10 = new String("9") + new String("0");
String s11 = s10.intern();
String s12 = "90";
System.out.println(s10 == s12);//true
System.out.println(s11 == s12);//true

类似地,s10指向的是位于堆中值为“90”的对象,此时常量池中还没有值为“90”的对象;此时执行intern方法,发现常量池中还没有值为“90”的对象,于是将对中值为“90”的对象引用添加(add)到字符串常量池中,并返回该对象的引用(即s11和s10指向的都是堆中的对象);执行第三句时,发现常量池中已经有该对象的引用了,则直接返回该引用,因而才会有s10==s11==s12都为true。

String#intern()方法使用分析

美团技术团队给了一个正确使用intern方法的示例:

static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
    Integer[] DB_DATA = new Integer[10];
    Random random = new Random(10 * 10000);
    for (int i = 0; i < DB_DATA.length; i++) {
        DB_DATA[i] = random.nextInt();
    }
	long t = System.currentTimeMillis();
    for (int i = 0; i < MAX; i++) {
        //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));//不使用intern方法
         arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();//使用intern方法
    }

	System.out.println((System.currentTimeMillis() - t) + "ms");
    System.gc();
}

在该示例中,实际上只使用了10个值不同的字符串,但通过分别注释掉不使用intern方法和使用intern方法这两句代码发现,不使用 intern 的代码生成了1000w 个字符串,占用了大约640m 空间,而使用了 intern 的代码生成了1345个字符串,占用总空间 133k 左右。

看到这里不知道你会不会和博主有同样的困惑:怎么可能呢?虽然使用了intern方法,可是不还是通过new String()的方式创建的对象么?new String().intern()方法肯定是先在堆中创建个对象,然后再尝试将该字符串引用添加到常量池中呀,那为什么会和不使用intern方法有这么大差异呢?难道美团技术团队写错了?

对于这个困惑,博主也是百思不得其解(甚至开始怀疑人生)了好几天,后来突然灵光乍现:是GC,肯定是GC!!什么意思呢?没错,上面的示例中,不论是否使用intern方法,两者都是先使用new String()的方式来创建的字符串对象,因而如果没有GC的化,两种方式创建字符串对象肯定是一样多的。而有了GC之后,两者的情形就大不一样了。对于不使用intern方法时,new String()返回的对象是被arr[i]所引用的,因而在GC时是不会被回收的;相反,使用intern方法后,由于上述代码实际上只使用了10个值不同的字符串,因而在绝大多数情况下,attr[i]中保存的都是同一个对象的引用,而此时new String().intern()语句产生的对象本身成了匿名对象,在GC过程中就被当成垃圾给回收了。因而才会出现这么大的差距。

其实由这个示例可知,intern方法适合在字符串变化不频繁的场景下使用;对于大量值不同的字符串,如果使用intern方法则会导致常量池hashTable中存储过多字符串引用,从而导致YGC变长等问题,具体参见参考博客

 

总结

本文深入分析了jdk8字符串的存储机制及String#intern方法的功能,其目的是为了梳理虚拟机对字符串的处理机制,为我们在使用字符串时具体选择哪种方式来产生字符串提供依据。此外,对于intern方法的适用场景可以总结为:适合于变化不频繁的字符串。

参考博客:

1、https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html  深入解析String#intern

2、https://blog.csdn.net/goldenfish1919/article/details/81216560 JVM的方法区和永久带是什么关系

3、https://blog.csdn.net/liupeifeng3514/article/details/81067150  源码学习 | String 之 new String()和 intern()方法深入分析

4、https://blog.csdn.net/Herishwater/article/details/100924191  Java 中方法区与常量池

5、https://zhuanlan.zhihu.com/p/107781993  Java 基础:String——常量池与 intern

6、https://cloud.tencent.com/developer/article/1450501 彻底弄懂java中的常量池

7、http://lovestblog.cn/blog/2016/11/06/string-intern/ JVM源码分析之String.intern()导致的YGC不断变长

8、https://blog.csdn.net/zm13007310400/article/details/77534349 Java中的常量池(字符串常量池、class常量池和运行时常量池)

9、https://www.jianshu.com/p/e74fe532e35e  JVM知识整理

10、https://blog.csdn.net/xiaojin21cen/article/details/105300521?utm_medium=distribute.pc_aggpage_search_result.none-task-blog-2~all~first_rank_v2~rank_v25-3-105300521.nonecase  class常量池、字符串常量池和运行时常量池的区别

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值