深入研究Java的String常量池

一、StringTable

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象,就是说在生成字节码文件的时候并不会将常量写入串池中,而是执行到了才会写入(懒加载
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder + new String(xxx);
  • 对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。Javac 编译器会进行一个叫做 常量折叠的代码优化。常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一

  • 对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";

    并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

    • 基本数据类型( bytebooleanshortcharintfloatlongdouble)以及字符串常量。
    • final 修饰的基本数据类型和字符串变量
    • 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

分析一段代码

public static void main(String[] args) throws ExecutionException, InterruptedException {
    String s1 = "a";
    String s2 = "b";
    String s3 = "ab";
    String s4 = s1 + s2;
    String s5 = "a" + "b";

    System.out.println(s3 == s4); // false
    System.out.println(s3 == s5); // true
}

1、初始的时候,StringTable【串池】为空,他是一个HashTable结构,不能扩容。

2、常量池中的信息,都会被加载到运行时常量池,这时abab都是常量池中的符号,还没有变为java字符串对象

3、执行String s1 = "a",字节码ldc #2会把符号a变为"a"字符串对象,然后看StringTable中是否存在字符串"a",如果不存在,就将这个字符串放入串池,如果存在,就直接使用串池中的字符串对象

4、执行String s2 = "b",字节码ldc #3会把符号b变为"b"字符串对象,然后看StringTable中是否存在字符串"b",如果不存在,就将这个字符串放入串池,如果存在,就直接使用串池中的字符串对象

5、执行String s3 = "ab",字节码ldc #4 会把符号ab变为字符串"ab"对象,然后看StringTable中是否存在字符串"ab",如果不存在,就将这个字符串放入串池,如果存在,就直接使用串池中的字符串对象

6、String s4 = s1 + s2,底层字节码执行的就是new StringBuilder().append("a").append("b").toString(),这里toString()就是执行的new String("ab"),因此s4指向的是堆区地址,而s3指向的是串池中的地址,因此s3s4不相等

7、String s5 = "a" + "b",字符串常量拼接,编译器底层会进行优化,javac在编译期间,结果就被确定为ab,在常量池中找到"ab"存在,所以s5指向的也是串池中的地址



示例一

1、当执行到ldc #2时,会把符号 a 变为"a"字符串对象,并放入串池中(hashtable结构 不可扩容)

2、当执行到 ldc #3 时,会把符号 b 变为 "b"字符串对象,并放入串池中

3、当执行到 ldc #4 时,会把符号 ab 变为 "ab" 字符串对象,并放入串池中

最终StringTable ["a", "b", "ab"]

注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

public class StringTableStudy {
	public static void main(String[] args) {
		String a = "a"; 
		String b = "b";
		String ab = "ab";
	}
}

常量池中的信息,都会被加载到运行时常量池中,但这是abab 仅是常量池中的符号,还没有成为java字符串

0: ldc           #2                  // String a
2: astore_1
3: ldc           #3                  // String b
5: astore_2
6: ldc           #4                  // String ab
8: astore_3
9: return


示例二

1、通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()

2、最后的toString方法的返回值是一个新的字符串,但字符串的值和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中

public class HelloWorld {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2; //new StringBuilder().append("a").append("2").toString()  new String("ab")
        System.out.println(s3 == s4); //false
		// 结果为false,因为s3是存在于串池之中,s4是由StringBuffer的toString方法所返回的一个对象,存在于堆内存之中
    }
}
Code:
  stack=2, locals=5, args_size=1
   0: ldc           #2              // String a
   2: astore_1
   3: ldc           #3              // String b
   5: astore_2
   6: ldc           #4              // String ab
   8: astore_3
   9: new           #5              // class java/lang/StringBuilder
  12: dup
  13: invokespecial #6              // Method java/lang/StringBuilder."<init>":()V
  16: aload_1
  17: invokevirtual #7              // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  20: aload_2
  21: invokevirtual #7              // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  24: invokevirtual #8              // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  27: astore        4
  29: return


示例三

1、使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了"ab",所以s5直接从串池中获取值,和s3相等

2、使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建

public class HelloWorld {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2; //new StringBuilder().append("a").append("2").toString()  new String("ab")
        String s5 = "a" + "b";
        System.out.println(s5 == s3); //true
    }
}
Code:
stack=2, locals=6, args_size=1
   0: ldc           #2              // String a
   2: astore_1
   3: ldc           #3              // String b
   5: astore_2
   6: ldc           #4              // String ab
   8: astore_3
   9: new           #5              // class java/lang/StringBuilder
  12: dup
  13: invokespecial #6              // Method java/lang/StringBuilder."<init>":()V
  16: aload_1
  17: invokevirtual #7              // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  20: aload_2
  21: invokevirtual #7              // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  24: invokevirtual #8              // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  27: astore        4				 
  29: ldc           #4              // String ab  
  31: astore        5				// s5的ab初始化时直接从串池中获取字符串
  33: return


二、 intern

JDK1.8

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,就在常量池中创建一个指向该字符串对象的引用
  • 如果有该字符串对象,即字符串常量池中保存了对应的字符串对象的引用,则放入失败

无论放入是否成功,都会返回串池中的字符串对象


示例

1、String x = "ab",创建字符串"ab",将该字符串放入串池中,此时串池内容为["ab"]

2、String s = new String("a") + new String("b"),堆区创建字符串new String("a")new String("b")new String("ab"),将“a”"b"放入串池,此时串池内容["ab", "a", "b"]

3、String s2 = s.intern(),尝试将字符串s放入串池,但是现在串池已经有ab这个字符串了,所以放入失败,s指向堆区,但是返回的s2是串池的字符串"ab"

4、对于new String("a")这个语句创建了两个对象,第一个对象是"a"字符串存储在常量池,第二个对象是在Java堆中的String对象

public static void main(String[] args) {
    String x = "ab";
    String s = new String("a") + new String("b");

    String s2 = s.intern(); // 将这个字符串对象尝试放入串池,现在串池有,放入失败
    System.out.println(s2 == x); // true
    System.out.println(s == x); // false
}
public static void main(String[] args) {
    String s = new String("a") + new String("b");

    String s2 = s.intern(); // 将这个字符串对象尝试放入串池,现在串池没有,放入成功
    System.out.println(s2 == "ab"); // true
    System.out.println(s == "ab"); // true
}


JDK1.6

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中【因为JDK1.6中,字符串常量池在方法区】,该对象仍然指向堆区地址
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象



JDK1.8测试

public static void main(String[] args) {
    String s1 = "a";
    String s2 = "b";
    String s3 = "a" + "b";
    String s4 = s1 + s2;
    String s5 = "ab";
    String s6 = s4.intern();
    System.out.println(s3 == s4); // false
    System.out.println(s3 == s5); // true
    System.out.println(s3 == s6); // true

    String x2 = new String("c") + new String("d");
    String x1 = "cd";
    x2.intern();
    System.out.println(x1 == x2); // false

    String x4 = new String("e") + new String("f");
    x4.intern();
    String x3 = "ef";
    System.out.println(x3 == x4); // true
}


1、StringTable位置

JDK1.6 时,StringTable是属于常量池的一部分,在方法区中,但是方法区只有在fullgc时垃圾才会被回收,回收效率太低。因此从JDK1.7往后,StringTable放在中,MinorGC就会触发垃圾回收,减轻无用字符串对内存的占用。StringTable在内存紧张时,会发生垃圾回收



2、StringTable 性能调优

  • StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间【避免了Hash冲突】

    -XX:StringTableSize=桶个数(最少设置为 1009 以上)

  • 考虑是否需要将字符串对象入池,可以通过 intern 方法减少重复入池,保证相同字符串在StringTable只保存一份


3、intern深入分析

以下内容参考自美团技术博客:https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

3.1 思考

String s = new String("abc")这个语句创建了几个对象?

答案:上述的语句中是创建了2个对象,第一个对象是”abc”字符串存储在常量池中,第二个对象在JAVA Heap中的 String 对象。

看一段代码:

public static void main(String[] args) {
    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);
    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);
}

打印结果

  • jdk6 下false false
  • jdk7 下false true

然后将s3.intern();语句下调一行,放到String s4 = "11";后面。将s.intern(); 放到String s2 = "1";后面

public static void main(String[] args) {
    String s = new String("1");
    String s2 = "1";
    s.intern();
    System.out.println(s == s2);
    String s3 = new String("1") + new String("1");
    String s4 = "11";
    s3.intern();
    System.out.println(s3 == s4);
}

打印结果

  • jdk6 下false false
  • jdk7 下false false

3.2 JDK6中的解释

  1. jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA 堆 区域是完全分开的。
  2. 上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用String.intern方法也是没有任何关系的。

3.3 JDK7中的解释

  1. 在 Jdk6 以及以前的版本中,字符串的常量池放在 Perm 区(JDK7及之前方法区的实现),Perm 区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用 intern 是会直接产生java.lang.OutOfMemoryError: PermGen space错误的。
  2. 所以在 jdk7 的版本中,字符串常量池已经从 Perm 区移到 Java 堆 区域。因为Perm 区域太小,同时在Perm区的字符串需要等到FullGC才能被回收。在JDK8中,方法区的实现采用了元空间matespace,放在了本地内存。

3.4 详细分析

  • 在第一段代码中,先看 s3和s4字符串。String s3 = new String("1") + new String("1");,这句代码中现在生成了2个对象,是字符串常量池中的“1” 和 堆中 中的 s3引用指向的对象。中间还有2个匿名的new String(“1”) 我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。执行s3.intern()将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,在jdk6中,会在常量池中生成一个 “11” 的对象,但是在 jdk7 中常量池不在 Perm 区域了,常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。 最后String s4 = "11"; 这句代码中”11”是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较 s3 == s4 是 true。

  • 再看 s 和 s2 对象。 String s = new String("1"); 第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern(); 这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。接下来String s2 = "1"; 这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同

  • 第二段代码中的 s 和 s2 代码中,s.intern();,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1");的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。

  • 再看s3和s4,执行String s4 = "11";声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern();时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。


3.5 intern正确使用的例子

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])); 
            arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
        }
        System.out.println((System.currentTimeMillis() - t) + "ms");
        System.gc();
    }

分析结果

在这里插入图片描述

在这里插入图片描述

  • 通过上述结果,我们发现不使用 intern 的代码生成了1000w 个字符串,占用了大约640m 空间。 使用了 intern 的代码生成了1345个字符串,占用总空间 133k 左右。
  • arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern(); 在堆区创建一个字符串后,然后将字符串放入常量池,不管是否放进去,最后返回的都是常量池的对象,因此新创建的堆区对象是没有引用的,所以在youngGc的时候就会被回收。但如果不使用intren,那么每个在堆区创建的字符串对象都被引用的,在youngGC时也就不会被回收掉

3.6 intern使用不当的例子

在使用 fastjson 进行接口读取的时候,我们发现在读取了近70w条数据后,我们的日志打印变的非常缓慢,每打印一次日志用时30ms左右,如果在一个请求中打印2到3条日志以上会发现请求有一倍以上的耗时。在重新启动 jvm 后问题消失。继续读取接口后,问题又重现。

这是因为 fastjson 中对所有的 json 的 key 使用了 intern 方法,缓存到了字符串常量池中,这样每次读取的时候就会非常快,大大减少时间和空间。而且 json 的 key 通常都是不变的。这个地方没有考虑到大量的 json key 如果是变化的,那就会给字符串常量池带来很大的负担。因为字符串常量池大小是固定的为1009,如果放入的字符串过多,会造成hash冲突,导致之后的查询速度缓慢。
这个问题 fastjson 在1.1.24版本中已经将这个漏洞修复了。程序加入了一个最大的缓存大小,超过这个大小后就不会再往字符串常量池中放了。

  • 21
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值