介绍String之前,先来介绍常量池(本文基于JDK1.8)
主要有三种常量池
1、Class文件常量池
在class文件中保存了一份常量池(Constant Pool),主要存储编译时确定的数据,包括代码中的字面量和符号引用。
2、运行时常量池
位于方法区中,全局共享,class文件常量池中的内容会在类加载后存放到方法区的运行时常量池中,除此之外在运行期间可以将新的变量放入运行时常量池中,具备动态性。
3、字符串常量池(String Pool)
位于堆中,全局共享,JVM为了提升性能和减少内存开销。避免字符串的重复创建,维护了一块特殊的内存空间,由String类独自维护。在String Pool池中,字符串内容不重复。JDK1.7之前是存放方法区,用来存储编译器生成的字符串引用。在JDK1.7之后,常量池存放在堆中。这个String Pool是一个固定大小的Hash Table。默认值大小是1009,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表很长,就会导致调用String.intern()性能下降,在JDK6中是固定的,在JDK7可以通过参数- XX:String TableSize=999991指定大小。
三者之间的关系如图所示:
详细说下字符串常量池的结构,在Hotspot JVM中,字符串常量池StringTable的本质是一张HashTable,采用的是数组加链表的结构,链表中的节点是一个个的HashTableEntry,而Hash TableEntry中的value则存储了堆上String对象的引用。
如图:
以String s = “123”的过程为例,我们都知道是将这个字符串放到字符串常量池中了,实际放进去的是字符串的对象本身还是对象的引用呢??
首先在堆上创建String对象,value执向char[]数组对象“123”,然后创建HashTableEntry,使它的value指向堆上的String实例,并把HashTableEntry放入字符串常量池,而不是直接把String对象放入字符串常量池中。意思就是常量池中存放的是String对象的引用。
一、String的几个特性:
我们先看下String源码:
1、String类被final修饰,不能被继承。
2、value值被final和private修饰,我们知道final修饰的成员变量,引用地址不能改变,且因为被private修饰符修饰,外部也无法改变value值。
3、通过 String str = "abc"字面量赋值的方式是存储于字符串常量池中的,一个字符串被创建,就不能被修改了, replace、subString等方式是创建了一个新的字符串,并非修改原来的字符串。
4、JDK1.8及之前value值用char[]数组存储,JDK1.9之后用byte[]数组存储。原因:1.8及之前,用两个字节表示一个字符。实际运用中,大多数字符只使用了拉丁字符,只需要一个字节就能存放,所以改变了内部存储结构,从UTF-16到byte[]数组,并加上一个encoding- flag域,用于判断字符串该用哪种方式存储。
5、字符串常量池不会存储相同内容的字符串,String Pool是一个固定大小的Hashtable,JDK6默认长度是1009,JDK7以后默认60013,如果放进池的String非常多,就会冲突严重而使链表变长,性能大幅下降。长度是固定的,可以提前用 —XX:String TableSize设置大小,在JDK6可以任意设置,JDK8以后最小设置为1009。
6、常量+常量(“hello” + “world”)的拼接结果在常量池中,原理是编译期优化(在生成字节码文件的时候就完成了拼接)。只要其中有一个变量,结果就在堆中,原因是采用的String Builder对象的append进行拼接的,如:String s = “hello” s = s + “world” ;有个特殊的情况,如果变量被声明为final,即此变量中途不会再改变其引用对象,那么就会被视作为常量,不会使用StringBuilder进行拼接。
7、如果一个字符串在堆中,在常量池中不存在,可以调用intern()方法,主动将该字符串放入常量池中,并返回该字符串在堆中的引用地址。
二、创建几个对象
1、我们来看看字面量声明String的方式:
public class StringTest {
public static void main(String[] args) {
String str = "hello";
System.out.println(str); //true
}
}
这种方式创建,只在堆中创建一个String对象,一个char数组,在字符串常量池中存储了它的引用。
2、构造方法创建的字符串方式
String str = new String("xxxx");
通过这种方式创建了两个String对象,一个char数组,都是在堆上创建的,两个String中的value都指向了同一个char数组对象,且通过字面量的方式创建的String对象的引用被存储到了字符串常量池中,可通过IDEA中的debug模式的Membory中看到String对象的创建个数。
三、String的几种创建方式的区别
String在我们平时开发过程中,使用的是最多的,创建String也有几种不同的方式:
1、通过字面量直接赋值
String s1 = "HelloWorld";
2、通过new关键字调用构造方法创建对象
String s2 = new String("HelloWorld");
3、通过 “+ ”运算赋创建对象
String s3 = "Hello" + "World";
String ss = "Hello";
String s4 = ss + "World";
那么这几种方式有什么区别呢?让我们看下代码
public class StrTest {
public static void main(String[] args) throws InterruptedException {
//1、通过字面量直接赋值
String s1 = "HelloWorld";
//2、通过new关键字调用构造方法创建对象
String s2 = new String("HelloWorld");
//3、通过 “+ ”运算赋创建对象
String s3 = "Hello" + "World";
String ss = "Hello";
String s4 = ss + "World";
System.out.println(s1 == s2); //false
System.out.println(s1 == s3); //true
System.out.println(s1 == s4); //false
System.out.println(s3 == s4); //false
}
}
解释下执行结果:
代码1中 String s1 = "HelloWorld"; 首先在常量池中进行查找是否存在 "HelloWorld",如果不存在,则在常量池中创建 "HelloWorld"对象,并把常量池的引用地址赋值给s1。
代码2中 String s2 = new String("HelloWorld"); 创建的字符串内容"HelloWorld"在常量池中已经存在了,所以不会进行创建。而通过构造方法则会在堆中创建一个新对象,变量s2引用的是这个新对象的地址,所以s1==s2为false;
代码3中 s3 中是两个常量用“+”连接,编译器在编译时可以确定是常量,编译器会进行优化,会直接拼接成一个常量,然后在常量池中进行查找"HelloWorld",查找到了就把字符串的引用地址赋值给s3,因此s1==s3为true
而s4中因为ss是以变量的形式参与+连接的,在编译期不能确定是常量,JVM在字符串遇到“+”的时候,会创建StringBuilder对象进行append操作,然后再调用to String()方法创建一个新的String对象,把这个新的对象赋值给s4,所以s1 == s4 为false,s3== s4 为false,过程就是:new StringBuilder().append(ss).append("World").toString();
四、字符串 “ + ”操作的底层原理
先看段代码:
//代码1
String sa = "ab";
String sb = "cd";
String sab = sa+sb;
String s = "abcd";
System.out.println(sab==s); // false
//代码2
String sc="ab"+"cd";
String sd="abcd";
System.out.println(sc==sd); //true
代码1中“ab” 和“cd”都是字符串常量,存在堆中的字符串常量池中,同时把“ab”和“cd”所对应的字符串常量池的引用地址分别赋值给sa和sb,当执行sa+sb时,在编译时不能确定是常量,所以JVM首先会在堆中创建一个String Builder类,同时用“ab”完成对象的初始化,接着调用append("cd")执行合并操作,最后调用toString()方法在堆中创建一个新的String对象,将新生成的对象的引用地址赋值给sab。局部变量s存储的是“abcd”常量在常量池中中对应的引用地址,所以sab的引用地址不等于s的引用地址,== 比较的是引用地址。
代码2中,“ab” + “cd” 会在编译完成后就合并成常量“abcd”,原因是在编译期间,应用了编译器优化中一种被称为常量折叠的技术,因此相同字面值常量对应的是同一个引用地址,所以 sc == sd。
编译期优化:
常量折叠会将编译期常量的加减乘除的运算过程在编译过程中折叠。编译器通过语法分析,会将常量表达式计算求值,并用求出的值来替换表达式,而不必等到运行期间再进行运算处理,从而在运行期间节省处理器资源。
编译期常量有哪些:
- 被声明为final
- 基本类型或者字符串类型
- 声明时就已经初始化
- 使用常量表达式进行初始化
五、创建几个字符串对象??
String s1 = new String("xyz");
String s2 = new String("xyz");
System.out.println(s3==s4);// false
对于String s1 = new String("xyz");由上一个问题可知,创建了2个String对象和一个char[]数组,此时字符串 常量池中已经有“xyz”的地址引用了。当执行String s2 = new String("xyz")时,会调用构造方法重新创建一个String对象。
所以在堆中,存在三个String对象和一个char[]数组,三个String对象的value都指向这个char[]数组。
六、String.intern()方法
String类的intern()方法:当调用 intern方法时,如果字符串常量池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串对象的引用。
否则,将此String对象添加到字符串常量池中,并返回此String对象的引用。
先来看段代码:
public static void main(String[] args) {
String s1 = new String("Hydra");
String s2 = s1.intern();
System.out.println(s1 == s2);
System.out.println(s1 == "Hydra");
System.out.println(s2 == "Hydra");
}
执行结果:
false
false
true
用图来描述它们的关系:
- 在创建s1的时候,其实堆里已经创建了两个字符串对象StringObject1和StringObject2,并且在字符串常量池中驻留了StringObject2
- 当执行s1.intern()方法时,字符串常量池中已经存在内容等于"Hydra"的字符串StringObject2,直接返回这个引用并赋值给s2
- s1和s2指向的是两个不同的String对象,因此返回 fasle
- s2指向的就是驻留在字符串常量池的StringObject2,因此s2=="Hydra"为 true,而s1指向的不是常量池中的对象引用所以返回false
再来看另外一段代码:
public static void main(String[] args) {
String s1 = new String("Hy") + new String("dra");
s1.intern();
String s2 = "Hydra";
System.out.println(s1 == s2);
}
执行结果:
true
简单分析一下这个过程
第一步会在堆上创建"Hy"和"dra"的字符串对象,并驻留到字符串常量池中。
接下来,完成字符串的拼接操作,前面我们说过,实际上jvm会把拼接优化成StringBuilder的append方法,并最终调用toString方法返回一个String对象。在完成字符串的拼接后,字符串常量池中并没有驻留一个内容等于"Hydra"的字符串。
所以,执行s1.intern()时,会在字符串常量池创建一个引用,指向前面StringBuilder创建的那个字符串,也就是变量s1所指向的字符串对象。
最后,当执行String s2 = "Hydra"时,发现字符串常量池中已经驻留这个字符串,直接返回对象的引用,因此s1和s2指向的是相同的对象。
六、String类为什么要设计成不可变的 ??
我们都知道String类是final修饰的,且String底层是char[]value,value被final修饰。
1、保证线程安全,在并发环境中多个线程同时读写资源时,会引起线程不安全,由于String是不可变的,不会引起线程问题。
2、HashCode,当String被创建的时候,Hash Code的值也会被缓存,Hash Code的值与value有关,如果String改变,那么Hash Code也会改变。针对Map、Set等容器的键值需要保证唯一性和一致性,String不可变保证了这个特性。
3、安全问题,一些参数都是通过字符串的形式进行传递的,如果可以改变的话,可能会被遭到攻击,搞成安全漏洞。
本文部分观点参考引用于 5道面试题,拿捏String底层原理! - 知乎,因为对String常量池存储内容的争论各式各样,因为官方没有给出明确的解释,我只是赞同本博文的观点。另外本文基于JDK1.8,不同版本争论较大。本文只是学习整理,以备以后复习之用。不喜勿喷。