字符串常量池的基本特征

String的基本特性

  • 在了解字符串常量池之前,先看一下String的一些基本特性

  • String:字符串,使用一对""引起来表示。

        // 字面量的定义方式
        string sl = "hello";
        // new的方式
        String s2 = new string ("hello");
    
  • string声明为final的,不可被继承

  • string实现了Serializable接口: 表示字符串是支持序列化的,支持在网络中传输。

  • 实现了Comparable接口: 表示string可以比较大小

  • string在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9改为byte[] value,节约了一些空间,同时StringBuffer和StringBuilder也同样发生的改变

    • 具Oracle统计String大多数存储的为英文、拉丁文等只占用一个字节的字符,更改为byte以一个字节为存储单位,而char占用两个字节
  • 从JDK7开始,字符串常量池中从方法区移动到了堆区

String的不可变性

  • String:代表不可变的字符序列。简称:不可变性。

  • 当对字符串进行重新赋值、字符串进行连接操作、调用string的replace ()方法修改指定字符或字符串时,都需要重新在字符串常量池中进行创建,不能使用原有的value进行赋值

  • 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中,字符串常量池中是不会存储相同内容的字符串的

  • 另一个理解角度:底层是使用数组去实现的,数组的长度一旦确定则不可再修改

/**
 * 证明String的不可变性
 *
 * @author wcong
 * @version 1.0
 * @date 2021-01-09 11:42
 */
public class StringTests {

    /**
     * 测试赋值操作
     */
    public static void test1(){
        String s1 = "aaa";
        String s2 = s2;
        s1 = "hello";

        System.out.println("s1: " + s1); // hello
        System.out.println("s2: " + s2); // aaa

        System.out.println(s1 == s2); // 判断地址: false
    }

    /**
     * 测试拼接操作
     */
    public static void test2(){
        String s1 = "aaa";
        String s2 = s1;
        s1 += "hello";

        System.out.println("s1: " + s1); // aaahello
        System.out.println("s2: " + s2); // aaa
    }

    /**
     * 测试替换操作
     */
    public static void test3(){
        String s1 = "aaa";
        String s2 = s1.replace("a","b");

        System.out.println("s1: " + s1); // aaa
        System.out.println("s2: " + s2); // bbb
    }

}

可能上面的例子看起来比较简单,通过下面这个例子相信你会有很深的印象

public class StringTest2 {

    public static void main(String[] args) {
        String str = new String("aaa");
        char[] chars = {'t', 'e', 's', 't'};
        
        // 调用下面的测试方法
        test1(str,chars);

        System.out.println("str: " + str); // aaa
        System.out.println("chars: " + new String(chars)); // best
    }

    public static void test1(String str,char[] chars){
        str = "hello word";
        chars[0] = 'b';
    }
    
}

字符串常量池的理解

《Java语言规范》里要求完全相同的字符串字面量(区别与new),应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个在字符串常量池中的String类实例

在字符串常量池中是不会存储相同内容的字符串的,那么字符串常量池是怎么实现的呢?是否存在大小呢?怎么设置大小?如果超出了最大的容量会怎样?

  • String Pool是一个固定大小的HashTable,在jdk1.7以前默认值长度是1009,从jdk1.7开始调整为了60013。

  • 如果放进String Pool的string非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响当调用string.intern()时性能会大幅下降。

  • 使用-XX:StringTableSize可设置StringTable(StringPool)的长度

    • 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。
    • 在jdk7中,StringTable的长度默认值是60013
    • Jdk8开始,StringTable的长度默认值是60013,设置StringTable的长度时1009是可设置的最小值,若设置的值小于1009,则会抛出异常
        Error: Could not create the Java Virtual Machine.
        Error: A fatal exception has occurred. Program will exit.
        StringTable size of 1000 is invalid; must be between 1009 and 2305843009213693951
    
  • 如果超出了StringTable的最大容量会进行垃圾回收

字符串拼接操作

  1. 字面量的拼接:字面量与字面量的拼接在编译期成class文件后就完成了,拼接结果直接放入常量池,其中字符串常量也可以看成是字面量
  2. 变量的拼接: 只要其中有一个是变量,创建的对象只在堆中。变量拼接的原理是StringBuilder.toString()

验证

  • 验证字符串的拼接,在字节码中只有将该字符串加入局部变量表中的指令,并没有创建StringBuilder的过程(其中字符串常量也可以看成是字面量)

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

  • 验证变量的拼接

在这里插入图片描述

main方法对应的字节码如下,可以看到每次变量的拼接的过程中都创建了一个StringBuilder对象,并调用了append方法

 0 ldc #2 <a>
 2 astore_1
 3 new #3 <java/lang/StringBuilder>
 6 dup
 7 invokespecial #4 <java/lang/StringBuilder.<init>>
10 aload_1
11 invokevirtual #5 <java/lang/StringBuilder.append>
14 ldc #6 <b>
16 invokevirtual #5 <java/lang/StringBuilder.append>
19 invokevirtual #7 <java/lang/StringBuilder.toString>
22 astore_2
23 new #3 <java/lang/StringBuilder>
26 dup
27 invokespecial #4 <java/lang/StringBuilder.<init>>
30 aload_2
31 invokevirtual #5 <java/lang/StringBuilder.append>
34 ldc #8 <c>
36 invokevirtual #5 <java/lang/StringBuilder.append>
39 invokevirtual #7 <java/lang/StringBuilder.toString>
42 astore_3
43 return

关于StringBuilder的拼接操作

StringBuilder.toString()方法返回的字符串存对象地址存储在常量池中吗

  • 关于intern()方法,intern()是String中的静态方法,调用该方法会先从字符串常量池中判断该字符串是否存在,若存在则直接返回字符串常量池中的地址,若不存在则在字符串常量池中创建这个字符串对象,然后再返回。

  • 从jdk7开始,字符串常量池从方法区移动到堆中

  • jdk6及以前:调用intern方法符合上面的概述。

  • jdk7及以后:在调用intern方法时,若在字符串常量池中不存在这个字符串对象,但是在堆中存在这个字符串对象,这个时候并不会在字符串常量池中创建这个对象,而是直接引用这堆中这个字符串对象的地址,以达到节省空间的效果。

    public static void main(String[] args) {
        // 以字面量的方式定义字符串,会在字符串常量池中创建对象
        String abc = new StringBuilder("abc").toString();
        // StringBuilder.toString(): 不会在字符串常量池中创建"def"字符串对象,但在堆中创建了这个"def"字符串对象
        // 在字符串常量池中"de"和"f"对象是存在的
        String def = new StringBuilder("de").append("f").toString();

        // jdk7及以后: 若在堆中存在这个字符串对象,但在字符串常量池中不存在
        //              这个时候会直接引用堆中字符串对象的地址
        abc.intern();
        def.intern();
        System.out.println(abc == "abc"); // false
        System.out.println(def == "def"); // true
    }
  • 通过上面的例子可以证明StringBuilder中的toString方法并没有将字符串创建在字符串常量池中

  • 查看StringBuilder中的toString方法字节码,可以看到并没有ldc指令

 0 new #80 <java/lang/String>
 3 dup
 4 aload_0
 5 getfield #234 <java/lang/StringBuilder.value>
 8 iconst_0
 9 aload_0
10 getfield #233 <java/lang/StringBuilder.count>
13 invokespecial #291 <java/lang/String.<init>>
16 areturn

StringBuilder的效率问题

  1. 通过StringBuilder的append()的方法拼接字符串的效率要远远高于直接使用String的字符串拼接方式
  2. StringBuilder的append()方法从头到尾只创建了一个StringBuilder对象
  3. 使用String字符串的拼接方式创建了多个StringBuilder和String对象
  4. 使用String字符串的拼接方式在内存中由于创建了较多的StringBuilder和String对象,内存占用更大,如果进行GC,需要花费较长的时间。
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        // stringConn(100000); // ===20057ms
        stringAppend(100000); // ===9ms

        long end = System.currentTimeMillis();
        System.out.println("===" + (end - start) + "ms");
    }

    public static void stringConn(int count){
        String str = "a";
        for (int i = 0; i < count; i++) {
            str += i;
        }
    }

    public static void stringAppend(int count){
        StringBuilder str = new StringBuilder();
        for (int i = 0; i < count; i++) {
            str.append(i);
        }
    }
  • StringBuilder拼接字符串的提升空间: 在实际开发中,如果基本确定要添加的字符串长度不会高于某个限定值的情况下,可以在构造器中传入内存创建char数组的长度(**默认长度为new char[16]),可以避免扩容带来的性能消耗。

经典面试题

    public static void main(String[] args) {
        String s1 = "javaEE";
        String s2 = "hadoop";
        String s3 = "javaEE" + "hadoop";
        String s4 = "javaEEhadoop";
        String s5 = "javaEE" + s2;
        String s6 = s1 + "hadoop";
        String s7 = s1 + s2;

        System.out.println(s3 == s4);
        System.out.println(s3 == s5);
        System.out.println(s3 == s6);
        System.out.println(s3 == s7);
        System.out.println(s5 == s6);
        System.out.println(s6 == s7);

        String s8 = s6.intern();
        System.out.println(s3 == s8);
    }

答案:true、false、false、false、false、false、true

  • 第一个为true的原因:字面量的拼接操作在编译成class文件就已经完成了。
  • 第二到六位false的原因:如果拼接字符中出现了变量,则相当于在堆空间中new String(),所以地址不相等。
  • 最后一个为true的原因:intern(): 判断字符串常量池中是否存在javaEEhadoop值,如果存在则返回常量池中的地址,如果不存在则在常量池中加载一份该字符串,并返回加载后的地址。

关于intern()的效率问题

  • 关于intern方法的作用及在不同jdk版本中的变化上面已经做过讲解,这里不再进行复述,下面主要对intern方法进行简单的效率测试

场景一:大的网站平台,需要内存中存储大量的字符串。比如社交网站存储的: 北京市、海淀区等信息。这时候如果字符串都调用intern()方法,就会明显降低内存的大小。

1. 从时间的角度
/**
 * 测试intern:之时间角度
 *
 *   只有到字符串很多的时候,才会有比较明显的效果,平常效率还不如valueOf()
 *
 * @author wcong
 * @version 1.0
 * @date 2021-01-11 20:13
 */
public class Intern效率测试1 {

    static final int COUNT = 1000 * 10000;

    static final String[] ARR = new String[COUNT];

    public static void main(String[] args) throws InterruptedException {

        Integer[] data = new Integer[]{1,2,3,4,5,6,8,9,0};
        int len = data.length;
        long start = System.currentTimeMillis();

        for (int i = 0; i < COUNT; i++) {
            /**
             * 100 * 10000: 耗时: 39ms
             * 1000 * 10000: 耗时: 3933ms
             */
            // ARR[i] = String.valueOf(data[i % len]);
            /**
             * 100 * 10000: 耗时: 120ms
             * 1000 * 10000: 耗时: 1900ms
             */
            ARR[i] = String.valueOf(data[i % len]).intern();
        }

        long end = System.currentTimeMillis();
        System.out.println("耗时: " + (end - start) + "ms");

        Thread.sleep(1000000);

    }

}
2. 从空间的角度
/**
 * 测试intern
 *
 *      时间角度:只有到字符串很多的时候,才会有比较明显的效果,平常效率还不如valueOf()
 *      空间角度:当程序中存在很多重复的字符串时,使用intern方法可以很大程度上的节省内存空间
 * @author wcong
 * @version 1.0
 * @date 2021-01-11 20:13
 */
public class Intern效率测试1 {

    static final int COUNT = 1000 * 10000;

    static final String[] ARR = new String[COUNT];

    public static void main(String[] args) throws InterruptedException {

        Integer[] data = new Integer[]{1,2,3,4,5,6,8,9,0};
        int len = data.length;
        long start = System.currentTimeMillis();

        for (int i = 0; i < COUNT; i++) {
            // ARR[i] = String.valueOf(data[i % len]);
            ARR[i] = String.valueOf(data[i % len]).intern();
        }

        long end = System.currentTimeMillis();
        System.out.println("耗时: " + (end - start) + "ms");

        Thread.sleep(1000000);

    }

}
  • 不使用intern方法

在这里插入图片描述

  • 使用intern方法

在这里插入图片描述

G1垃圾收集器对String的去重操作

背景: 对许多Java应用(有大的也有小的)做的测试得出以下结果:

  • 堆存活数据集合里面string对象占了25%
  • 堆存活数据集合里面重复的string对象有13.5%
  • String对象的平均长度是45

许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是string对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说: stringl.equals(string2) =t rue堆上存在重复的string对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的string对象进行去重,这样就能避免浪费内存。

大致实现过程

  • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是需要去重的string对象
  • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,用来处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。
  • 使用一个hashtable来记录所有的被string对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
  • 如果存在,string对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
  • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。

相关JVM参数

  • UseStringDeduplication (bool): 开启string去重,默认是不开启的,需要手动开启
  • PrintStringDeduplicationStatistics (bool): 打印详细的去重统计信息
  • StringDeduplicationAgeThreshold (uintx): 达到这个年龄的string对象被认为是去重的候选对象
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值