StringPool详解

本文详细探讨了Java中String的不可变性,讲解了字符串常量池(StringPool)的结构和作用,以及不同情况下字符串拼接的效率差异。重点分析了String的intern方法的工作原理,包括在JDK6、7、8中的不同实现,并通过实例展示了其内存占用和性能优化。此外,还介绍了G1垃圾收集器如何进行String对象的去重操作,以减少内存浪费并提高效率。
摘要由CSDN通过智能技术生成

1. String的不可变性

在JDK7之后,String的内容是存放在堆中的字符串常量池(StringPool,也可叫StringTable)中的,字符串常量池中相同的字符串只会创建一份,所以定义相同的字符串变量时,多个字符串变量都是指向了同一份地址中的字符串。
示例一

public class StringDemo1 {
    public static void main(String[] args) {
        String str1 = "hello world";
        String str2 = "hello world";
        if (str1 == str2){
            System.out.println("str1 is equal to str2");
        }
    }
}

运行,输出结果s1 is equal to s2,说明尽管str1和str2是栈帧中局部变量中两个不同的变量,但这两个变量都指向了常量池中同一份字符串,因此str1==str2。如下图所示
在这里插入图片描述

示例二

public class StringDemo1 {
    public static void main(String[] args) {
        String str1 = new String("hello world");
        String str2 = new String("hello world");
        if (str1 != str2){
            System.out.println("str1 is not equal to str2");
        }
    }
}

运行,输出结果str1 is not equal to str2,str1和str2分别指向了堆上的不同String对象,尽管两个String对象中的字符串内容都与常量池中字符串内容相同,这个后面会进一步解释,所以str1 != str2。如下图所示
在这里插入图片描述

2. StringPool结构

StringPool为字符串常量池,存在于堆空间中,是堆空间中专门开辟出的一块空间用于存放字符串常量池。字符串是开发中经常用到的,堆空间垃圾回收频率高,字符串放堆空间可以加快无用字符串的回收效率。在JDK7之前字符串放在方法区中(方法区在JDK7的实现叫永久代,在JDK8的实现叫元空间),方法区垃圾回收的频率较低,不利于字符串的回收。
String Pool是一个固定大小的Hashtable结构,由数组+链表形式组成,如果放进字符串常量池String Pool中的字符串很多,就会造成Hash冲突严重,从而导致链表会很长,而链表长会影响String.intern的性能大幅下降。
使用-XX:StringTableSize可以设置StringTable的长度,JDK6中String Pool的长度是固定的,只有1009长度;在JDK7中String pool的长度默认为60013,可手动设置;在JDK8中默认设置也为60013,可手动设置,但1009是可设置的最小值。


由于常量池StringPool是HashTable格式的,在常量池中同一份字符串只会保留一份,如下代码所示

public class StringDemo2 {
    public static void main(String[] args) {
        String str = "hello world";
        changeStr(str);
        System.out.println(str);    //输出:hello world
        char[] arrays = "hello world".toCharArray();
        changeArrays(arrays);
        System.out.println(arrays); //输出:yello world
    }
    public static void changeStr(String str){
        str = "this is changeStr";
    }
    public static void changeArrays(char[] arrays){
        arrays[0] = 'y';
    }
}

上述代码,由于常量池中字符串只保留一份,在调用changeStr时,只会创建一份新的字符串,原来的hello world并不会改变;数组分配在栈上,调用changeArrays时,传入的是数组的引用,修改了引用地址中的内容。

3. 字符串拼接操作

<1> 字符串常量和常量拼接,编译期优化,结果存储在字符串常量池中
如下案例所示,str1和str2都是常量字符串的拼接,这种常量字符串的拼接动作在编译期就会自动优化,比如str1在编译期就会被优化成String str1="abcedf",同理str2也会被优化成String str2="abcedf"。str1和str2的变量存储在栈中的局部变量表中,而str1和str2中的字符串内容存储在字符串常量池中。

public class StringDemo3 {
    public static void main(String[] args) {
        String str1 = "abc" + "def";
        String str2 = "ab" + "cd" + "ef";
        if (str1 == str2){
            System.out.println("str1 is equal to str2");
        }
    }
}

<2> 字符串拼接中,有一个是字符串变量,拼接后的对象存储在堆中
不同于上述示例,本例中str4由str2和str3拼接而成,而str2和str3都是变量形式,导致了虽然str1和str4的内容都是abcdef,但str1 != str4

public class StringDemo3 {
    public static void main(String[] args) {
        String str1 = "abc" + "def";
        String str2 = "abc";
        String str3 = "def";
        String str4 = str2 + str3;
        if (str1 != str4){
            System.out.println("str1 is not equal to str2");
        }
    }
}

为什么拼接字符串中含有变量的形式会导致字符串str1 != str4呢,java源代码编译成jvm指令如下所示,执行String str4 = str2 + str3;相当于JVM执行标记蓝色的命令。如命令所示,在做含有变量字符串拼接操作时,首先new 了一个StringBuilder对象,然后执行了StringBuilder对象的初始化方法,然后通过StringBuilder的append方法拼接字符串,拼接字符串结束后又调用了StringBuilder对象的toString方法。
在这里插入图片描述
StringBuilder的toString方法如下,调用该方法后,实质就是在堆中创建了一个String对象,对象中的字符串从常量池中加载,如同String的不可变性章节中的案例二,因此str1指向了常量池中字符串abcdef,而str4指向了堆中的String对象,所以两者地址不相等。

@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

4. 拼接操作与append操作效率对比

public class StringDemo4 {
    public static void main(String[] args) {
        test1();
        test2();
        test3();
    }

    public static void test1(){
        long startTime = System.currentTimeMillis();
        String str = "";
        for (int i=0; i<100000; i++){
            str = str + "a";
        }
        long endTime = System.currentTimeMillis();
        System.out.println("拼接字符串花费时间:" + (endTime - startTime));   //拼接字符串花费时间:4861
    }

    public static void test2(){
        long startTime = System.currentTimeMillis();
        StringBuilder str = new StringBuilder();
        for (int i=0; i<100000; i++){
            str.append("a");
        }
        long endTime = System.currentTimeMillis();
        System.out.println("追加字符串花费时间:" + (endTime - startTime));   //追加字符串花费时间:9
    }

    public static void test3(){
        long startTime = System.currentTimeMillis();
        StringBuilder str = new StringBuilder(100000);
        for (int i=0; i<100000; i++){
            str.append("a");
        }
        long endTime = System.currentTimeMillis();
        System.out.println("指定容量追加字符串花费时间:" + (endTime - startTime));   //指定容量追加字符串花费时间:8
    }
}

从上述案例可以看出,拼接字符串的效率要远远低于追加字符串的效率,当然在追加字符串过程中,如果预知最终字符串长度,指定StringBuilder的容量的情况下,效率会更高。


为什么会出现追加字符串效率高于拼接字符串的效率呢?
如上述案例,对于追加字符串形式,从最开始创建了一个StringBuilder对象,然后循环100000次,调用StringBuilder对象append方法100000次。
而对于拼接字符串形式,每次拼接字符串需要创建一个StringBuilder对象和String对象,如上一章节字符串拼接操作所示,因此循环100000次就需要创建100000个StringBuilder对象和100000个String对象,因此String拼接字符串效率远远低于append字符串的效率。

5. intern详解

5.1 intern基本知识

String调用intern方法是为了确保字符串在内存中只有一份copy,这样可以节约空间,加快字符串操作任务的执行速度。当一个字符串不在常量池中,该字符串调用intern方法后,会把字符串放在常量池中一份,如果常量池中含有该字符串,就返回对该字符串的应用。如下所示:

public static void test5(){
    String str2 = "hello";
    String str1 = new String("hello").intern();
    System.out.println(str1 == str2); //true
}

本先str2指向字符串常量池中"hello"的地址,而str1指向堆上String地址,但是str1调用intern方法后,也指向了常量池中’hello"地址,所以str1 == str2。原理图如下:str2首先在常量池中创建了"hello"对象,str1调用了intern方法,也同时指向了常量池中"hello"的地址。
在这里插入图片描述

5.2 new String创建了几个对象

1. new String创建了几个字符串
String str = new String("hello world");代码为例,执行该代码后,内存中创建了几个对象呢,编译成字节码指令如下所示,首先第一行new了一个String对象,然后第3行ldc创建了一个hello world字符串的对象,并且把该字符串放到了字符串常量池中。因此String str = new String("hello world");在内存中创建了2个对象。

 0 new #4 <java/lang/String>
 3 dup
 4 ldc #12 <hello world>
 6 invokespecial #5 <java/lang/String.<init>>

2. new String(“hello”) + new String(“world”)创建了几个对象
以代码 String str = new String("hello") + new String("world");
为例,把该代码编译成字节码指令如下所示,首先创建了一个StringBuilder对象,然后创建了一个String对象,然后又在常量池中创建了字符串为hello的对象(ldc #3),创建的hello字符串用来初始化刚创建的String对象;随后又创建了另一个String对象,然后又在常量池中创建了字符串为world的对象(ldc #13),用来初始化另一个String对象,通过调用StringBuilder的append方法把两个字符串加起来,最后调用了StringBuilder的toString方法,本节前面例子已经说明,toString方法内部其实又创建了一个String对象,因此 new String("hello") + new String("world")总共创建了6个对象。

在这里插入图片描述

5.3 案例

1. 案例一

    public static void test1(){
        /*
        * 前面已经介绍过,new String创建了2个对象,一个在堆中str1对象,一个在常量池中的helloworld字符串对象
        * 执行该执行后,在堆中创建了一个str1对象,对象中属性内容为helloworld,同时也会在常量池中创建一份helloworld字符串对象,两个对象地址不同,一个是堆上,一个是常量池中
        * */
        String str1 = new String("helloworld");
        /*
        * 由于常量池中已经有helloworld对象了,执行str1.intern方法不会再向常量池中创建一份helloworld对象,该方法没有任何影响
        * */
        str1.intern();
        /*
        * 由于常量池中已经有helloworld对象了,str2直接指向常量池中的helloworld对象
        * */
        String str2 = "helloworld";
        /*
        * 前面已介绍,str1是堆中的内存地址,str2是常量池中内存地址,两者地址不是一个,因此不相等
        * */
        System.out.println(str1 == str2);
    }

str1和str2在内存中图示如下,可以看出两者地址不同。
在这里插入图片描述
2. 案例二

 public static void test4(){
    /*
    * 前面已介绍,两个new String字符串的拼接是通过StringBuilder的append方法进行拼接的
    * hello和world两个字符串都已放在字符串常量池中,唯独helloworld字符串没有放在常量池
    * */
    String str1 = new String("hello") + new String("world");
    /*
    * 由于常量池中还没有helloworld字符串,调用intern方法后,在常量池中创建了一份helloworld字符串对象
    * str1指向了堆上的String对象,堆上字符串对象存放的是常量池中helloworld对象地址
    * */
    str1.intern();
    /*
    * 常量池中已经有了helloworld字符串对象,str2直接指向了常量池中helloworld字符串对象
    * */
    String str2 = "helloworld";
    /*
    * str1间接指向常量池中helloworld对象,str2直接指向常量池中helloworld对象,因此str1和str2指向的地址相同
    * */
    System.out.println(str1 == str2); //true
}

str1和str2在内存中图示如下
在这里插入图片描述
如不特殊说明,示例均采用JDK8演示,本示例,如果改成JDK6的话,返回结果是false,因为在new String("hello") + new String("world")执行后,再执行intern方法时,并不是堆中字符串地址指向常量池中地址,而是再堆上创建了字符串后,在常量池copy同样的一份字符串,因此堆上字符串地址与常量池中字符串地址不是同一个。


String的intern方法在jdk6与jdk7/8中原理是不同的,区别如下:

JDK6中,字符串调用intern方法时

  • 如果字符串常量池中有该字符串,则不会把该字符串放入常量池;
  • 如果字符串常量池中没有,会把该字符串对象复制一份,放入常量池中,并返回常量池中字符串对象的地址。

JDK7/8中,字符串调用intern方法时

  • 如果字符串常量池中有该字符串,则不会把该字符串放入常量池;
  • 如果字符串常量池中没有,则会把该字符串对象的引用地址复制一份,放入常量池中,并返回常量池中该字符串的引用地址。

3. 案例三
案例二,调换第二条和第三条指令顺序,如下所示,结果出现翻转

    public static void test5(){
        String str1 = new String("hello") + new String("world");
        String str2 = "helloworld";
        /*
        * 首先str1现在堆里面创建了“helloworld”字符串对象,并没有在字符串常量池中创建
        * 然后str2在字符串常量池中创建了“helloworld”字符串对象
        * 当str1执行intern方法时,由于常量池中已经存在了helloworld字符串对象,str1直接返回,不会把str1堆中字符串地址放到常量池中
        * str1和str2指向的地址不同,一个是堆中的,一个是字符串常量池中的,因此两者不相等。
        * */
        str1.intern();
        System.out.println(str1 == str2); //false
    }

str1和str2在内存中示意图如下,str1.intern()方法虽然执行了,但并没有什么影响,与不执行结果无异。
在这里插入图片描述
但如果改成如下形式,比较str2与str3则是相等

    String str1 = new String("hello") + new String("world");
    String str2 = "helloworld";
    String str3 = str1.intern();
    System.out.println(str1 == str2);	//false
    System.out.println(str2 == str3);	//true
}

4. 案例四

public static void test6(){
    String str1 = new String("hello") + new String("world");
    /*
    * str1已经在堆上创建了helloworld对象
    * 调用str1.intern方法后,会把堆上helloworld字符串的引用放到字符串常量池中,
    * str2指向常量池中helloworld字符串对象,由于常量池中存放的是堆中helloworld字符串的引用地址,因此str2也指向了堆中helloworld对象地址
    * */
    String str2 = str1.intern();
    System.out.println(str1 == "helloworld");	//true
    System.out.println(str2 == "helloworld");	//true
}

str1和str2在内存中示意图如下所示,str1与str2指向的是同一个字符串地址,因此两者相等。
在这里插入图片描述
但是如果对于JDK6的话,str2 == "helloworld"返回的是true,str1 == "helloworld"返回的是false。因为在执行str1.intern时,是在常量池中放的是str1中字符串对象的copy,因此str1与str2不同。

5. 案例五

public static void test7(){
    /*
    * 在堆中存放了helloworld的对象,同时也会在常量池中放一份helloworld字符串对象
    * */
    String str1 = new String("helloworld");
    /*
    * 由于常量池中已经有了helloworld对象,执行str1.intern方法没有任何影响
    * */
    str1.intern();
    /*
    * str2指向了常量池中的helloworld字符串对象
    * */
    String str2 = "helloworld";
    /*
    * str1指向的是堆中的字符串对象,str2指向的是常量池中字符串对象,因此两个地址不相容
    * */
    System.out.println(str1 == str2); //false
}

str1与str2在内存中示意图如下所示
在这里插入图片描述

5.4 结论

对于程序中存在大量字符串,尤其是很多重复字符串时,使用intern方法可以节省内存空间大小,例如程序中经常用到人民币、美元等字符串,如果调用intern方法,就会明显减低内存的使用大小。另外内存中对象变少,也会较少垃圾回收的次数,提供效率。

6. G1垃圾收集器下的String去重操作

G1垃圾收集器实现自动持续对重复的String对象进行去重操作,这样可以避免内存的浪费。
字符串常量池中只会存在一份字符串对象,这里说的String去重操作,指的是堆上的String对象,当重复执行new String操作时,会在堆上创建多个String对象,因此去重操作是对这一部分堆上字符串对象的去重。
步骤如下:

当垃圾收集器工作时,会访问堆上的存活对象,对每一个访问的对象都会检查是否候选的要去去重的String对象;
如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列中删除处理的对象元素,然后去尝试去重它引用的String对象;
String底层是用的一个Char型数组(JDK8),使用一个HashTable来记录所有被String对象使用不重复的Char数组。当去重的时候,会查询这个Hashtable,看判断堆上是否已经存在相同的Char数组;
如果存在,String会被调整引用Hashtable中的Char数组,释放对原来数组的引用,被释放的数组最终会被垃圾回收器进行回收;
如果不存在,Char数组就会被插入Hashtable中,方便后续共享数组。

G1下String对象去重操作归结为一句话就是:用一个Hashtable结构存储已经创建的字符串对象的Char型数组,后续创建的相同字符串的对象,在G1垃圾回收器工作时,后续创建的字符串对象都指向Hashtable中存储的Char数组,后续创建的字符串对象都会被回收,最终堆中只保留一份字符串对象。


G1垃圾回收器默认是不开启去重String对象的,如若开启,需手动设置 `-XX:+UseStringDeduplication` 。

设置-XX:+PrintStringDeduplicationStatistics可以打印字符串去重的信息。
-XX:StringDeduplicationAgeThreshold=x用来设置垃圾回收时达到指定年龄的String对象才会被去重,比如-XX:StringDeduplicationAgeThreshold=3表示3次垃圾回收之后还存在的String对象重复的才会被去重。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值