String实例创建及池化分析

"本文详细介绍了Java字符串的不可变性,字符串常量池的概念和实现,以及不同创建字符串的方式对内存的影响。字符串常量池在JDK的不同版本中位置有所变化,通过intern()方法可以实现字符串的池化,减少内存占用。此外,分析了使用"+"拼接字符串的底层实现,以及如何通过池化优化性能。"
摘要由CSDN通过智能技术生成

​在设计层面,java的字符串要求其值一旦定义,不可再发生变化。在实现层面,通过使用final修饰String类的成员变量value实现其不可变性。在代码实践中,字符串具有极高的使用频率,那么同样存在大量使用重复字符串的场景。出于节省jvm内存空间和提升读取效率的考虑,结合java字符串的不可变性,很自然的考虑在内存中设计一个共享区,存放一份可能重复使用的字符串,使所有使用该字符串的程序指令直接从共享区读取,而不是每次使用时重新创建。

​在java体系中,这个共享区被称为字符串常量池,把字符串放入字符串常量池的过程被称为字符串池化。本文接下来将介绍字符串池化过程中的相关内容。

String类结构

在String类有一个名为value的成员变量,它在JDK1.8中是一个char类型数组,在JDK1.9中是一个byte类型数组,它是存储String类实例值的最底层数据结构。同时,该变量使用final关键字修饰,实现了java字符串不可变的特性,进而保证了线程安全。因为数组在jvm内存中是连续存储的,那么value可被看作是一个指向jvm内存中某个数组的首地址的指针。

特别注意区分,Stirng类实例String s = "abc”的引用是指向jvm内存中的实例地址,即所谓的String类对象地址。而在String类实例内,维护了一个数组地址vlaue,它指向了另外一块存储char字符数组(jdk1.9 byte字符数组)的内存地址。即一个String类对象有两个引用:1)有一个实例引用,2)实例内部有一个数组引用。

图一 String类实例内存地址

四种常量池

顾名思义、池一般指某个容器或范围内放置若干数量具有相同属性的东西,而常量指内容不发生变化的量。那么,常量池是指在jvm的内存放置若干的常量的区域。在java体系中,可分为Class文件常量池、运行时常量池、部分基本数据类型包装类常量池、全局字符串常量池,他们的作用都是为了复用常量,提高读取性能。

  • Class文件常量池
    java程序编译之后,会在磁盘上生成.class后缀的文件,其中用于存储字面量和符号引用的部分称为常量池。这个常量池在编译之后就固定在磁盘上,因此可称为静态常量池。

  • 运行时常量池
    在jvm完成类加载之后,Class文件常量池(静态常量池)会被加载进jvm的内存,成为运行时常量池。它位于方法区中,1.6 在永久代,1.7 在元空间中,永久代跟元空间都是对方法区的实现

  • 部分基本数据类型包装类常量池

    在四类八种基本数据类型,除Float和Double之外,其他六种基本数据类型的包装类都实现了常量池技术。其实质指当创建的数值范围在[-128,128]内时,jvm会将其放入常量池中,而不是创新新的对象。当数值超出范围后,则需要重新创建对象。如下代码

    Integer a = 12; 
    Integer b = 12System.out.println(a == b)// true. 12在范围内,将被置入常量池中,变量a,b指向同一位置。
    Integer c = 129; 
    Integer b = 129System.out.println(a == b)//false. 数字129超出范围,创建两个对象。
    
  • 全局字符串常量池
    英文称为String table,又称为String pool。在JDK1.7之前,该常量池位于永久代中,之后为位于堆中。它的本质是一个Hashtable(底层采用数组和链表实现,Hashtable无法扩容,这一点不同于HashMap),其中存储的是字符串实例对象的引用,而不是字符串实例对象自身,类似C语言中的指针,指向字符串值在jvm内存中的位置。这个表被整个jvm共享且唯一存在,所有创建字符串的指令执行之前,都会先查询这个哈希表。如果有目标字符串,则返回目标字符串的引用,避免重复创建。
    如下图所示,字符串常量池中存储了指向存储字符串实例的地址,而字符串实例中的成员变量value指向了存储字符的数组地址,且多个值相等(依据String#equal方法)的String实例的成员变量value可以指向同一个字符数组。需要注意的是,在JDK1.9中,String类的char[] 数组被改为 byte[]数组,以节约内存和减少GC次数。
    当调用String#intern方法时,会扩充字符串常量池。当发生gc时,会缩减字符串常量池。

    图二 字符串常量池内存结构

String#intern()

这个方法是一个 native 的方法,其方法注释为:如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回。从注释来看,该方法的作用一方面是获取字符串常量池中用于全局共享的某个String类实例的引用,又因为它会向字符串常量池中添加新的引用,所以另一方面是可以起到扩充字符串常量池的作用,即所谓字符串池化。

在JDK6中, 当一个字符串S调用intern()方法时,如果字符串常量池中不存在指向同S值相同的String类实例的引用, 则根据S复制一份新的字符串实例S1,并S1的引用添加到字符串常量池中;如果字符串常量池中存在指向同S值相同的String类实例的引用, 直接返回字符串常量池中的引用地址。

在JDK7中, 当一个字符串S调用intern()方法时,如果字符串常量池中不存在指向同S值相同的String类实例的引用, 则直接将字符串S的引用添加到字符串常量池中;如果字符串常量池中存在指向同S值相同的String类实例的引用, 直接返回字符串常量池中的引用地址。

String的”+“拼接

使用”+“拼接字符串,编译器会在编译阶段把代码优化成使用StringBuilder(jdk1.5后引入,之前是StringBuffer)类,并调用StringBuilder#append方法进行字符串拼接,最后调用StringBuilder#toString方法。 可以看到在StringBuilder#toString中使用new新建了一个String实例,并返回该实例。注意此处的new字符串实例的方式与其他位置的new字符串实例不同,下文会介绍。

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

其基本过程为:在运行时, 两个字符串str1, str2的拼接首先会调用 String.valueOf(obj),这个Obj为str1,而String.valueOf(Obj)中的实现是return obj == null ? “null” : obj.toString(), 然后产生StringBuilder, 调用的StringBuilder(str1)构造方法, 把StringBuilder初始化,长度为str1.length()+16,并且调用append(str1)接下来调用StringBuilder.append(str2), 把第二个字符串拼接进去, 然后调用StringBuilder.toString返回结果。

	String s=null;
	s=s+"abc";
	System.out.println(s); // "nullabc"

String的创建方式及内存状态

1、String s = “xyz”

这种创建方式在编译器已经完成,编译器会检查字符串常量池中是否存在指向值为“xyz”的String类实例。若存在,则返回实例的引用赋值给变量s。若不存在,则创建一个值为“xyz”的String类实例,将该实例的引用放入字符串常量池用于共享,并将该实例的引用赋值给变量s。
同这种方式类似,String s = "x" + "yz"也会在编译器直接优化为String s = "xyz",然后执行上面的步骤。

其底层执行以下步骤

  1. 查询字符串常量池,查看JVM内存中是否已存在”xyz“实例。此处使用equal(判断值)而不是“==”(判断地址)
  2. 若不存在,则创建一个“xyz”实例,将这个实例的引用放入字符串常量池,并将“xyz”实例的引用返回给栈区中的变量s。
    图三 创建字符串”xyz“ 之前内存结构

    对比图三、图四,可以看到,创建字符串”xyz“之后,该字符串的引用被添加进了字符串常量池中。
    图四 创建字符串”xyz“ 之后内存结构
  3. 若存在,则将“xyz”的实例引用赋值给变量s。
    图五 字符串常量池已有字符串

    可以看到,这种方式声明字符串,堆中最后只有一个String实例,其值为“xyz”;有两个引用指向该实例,分别是字符串常量池中的一个引用和栈区的一个引用。 该结论可使用如下代码说明
     //栈区中的变量s是“xyz”的引用
    String s = "xyz";
    //intern方法返回字符串常量池中指向值为“xyz"的String类实例的引用
    String stringPool = s.intern();
    //true。“==”比较地址,
    // 说明这个两个引用指向的地址相同,即只有一个值为“xyz”的String类实例。
    System.out.println(s == stringPool);
    

2、String s = new String(“xyz”)

这种创建方式主要完成两部分工作。

  1. 查询字符串常量池中是否存在指向值为“xyz”的String类实例,若有,则无操作。若没有,则创建一个值为“xyz”的String类实例,并将该实例的引用放入字符串常量池中。这一步的操作是为了实现字符串共享
  2. 因为有new关键字,因此触发创建实例动作。此处会创建一个值为“xyz”的String类实例,并将该实例的引用赋值给变量s.

需要注意的是,此处字符串常量池创建的值为“xyz”的String类实例不同于new关键字创建的值为“xyz”的String类实例。但由于两者的值都为“xyz”,按上文介绍,若多个String类实例的成员变量value指向的字符数组值相同,则该数组可以共享。此处两个实例的成员变量value都指向同一个字符数组(jdk1.8)。其最终内存状态如下。

图六 new方式创建字符串
可以看到图中,1和2两个引用指向不同的String类实例,但这两个实例的value属性指向同一个字符数组(JDK1.8)。以下代码说明1和2指向不同实例
//1、池化。创建一个将值为“xyz”的String类实例,并将这个实例的引用放入字符串常量池中。
//2、new触发创建一个新的String实例,并将这个实例的引用赋值给s0
String s0 = new String("xyz");
//获取字符串常量池中值为“xyz”的String类实例的引用
String s1 = s0.intern();
//false。“==”比较地址,
//说明s0和s1是不同类实例。
System.out.println(s0 == s1);

下图说明,不同实例共享同一个字符数组(JDK1.8)

图七 共享字符数组
![在这里插入图片描述]()

3、String s = 表达式1 + 表达式2;

使用“+“拼接方式创建字符串,可有以下方式

		String a = "x";
        String b = a + "yz";  //拼接方式1
        String c = new String("x") + "yz";  //拼接方式2
        String d = new String("x") + new String("yz"); 拼接方式3

上文已经介绍,字符串”+“拼接底层使用StringBuild完成拼接过程,在完成的最后一步重写toString方法中,返回使用new关键字新建的String类实例。与上一小节介绍的 String s = new String("xyz")不同,StringBuild#toString()中字符串创建完成后,不会将新建的字符串添加进字符串常量池,不管常量池中有没有对应的字符串实例。

假设我们使用String d = new String("x") + new String("yz");拼接字符串,其内存状态如下图。从图中可以看到,新生成的”xyz“实例未添加进字符串常量池,即字符串常量池中没有存储指向”xyz“实例的引用。

图八 拼接方式创建字符串
可调用String#intern方法将新生成的”xyz“实例添加进字符串常量池,即所谓池化过程。根据上文介绍,池化之后,可以看到字符串常量池中指向了新建的”xyz“对象地址,即把”xyz“对象的引用直接添加进字符串常量池,变量s和字符串常量池指向了相同的地址。而在jdk1.7之前把复制的、新的实例对象的引用添加进字符串常量池。
图九 字符串池化
以上结论可以使用以下代码验证
		//拼接字符串,将最终生成的字符串实例引用赋值给s1
        String s1 = new StringBuilder("计算机").append("软件").toString();
        //池化。获取字符串常量池中的引用
        String s11 = s1.intern();
        //true.说明两者指向同一个地址。
        System.out.println(s1 == s11);

        String s2 = new StringBuilder("ja").append("va").toString();
        String s22 = s2.intern();
        //false.原因是”java“字符串在虚拟机启动时已经添加进字符串常量池。
        System.out.println(s2 == s22);

实践意义

如果程序中存在大量重复字符串,使用intern()会节省大量空间(用一个共享字符串代替若干重复字符串),与JDK版本无关。

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值