详解String、StringBuffer和StringBuilder
本博客来源于B站视频: 你真的了解String StringBuilder StringBuffer吗link…
本博客对于String底层有一定的见解,但是还不够底层。没有给出如何做到线程安全的解释、也没有给出StringBuffer效率高的原因,等后面了解更多会继续更新
1. String类
1.1 String对象内容不会发生改变
知识点1:String类被final修饰(String类如下图所示)表明不能被继承,并且一个类如果被final修饰,那么他所有的方法相当于也被final修饰,这些方法都不能被重写。
知识点2:在早期的jdk版本中,String类默认会加一些final方法,这些加了final的方法会转为内嵌调用,用来提高代码的效率,现在jdk已经不需要用这种方法来提高性能了(现在String所有的方法都不会被重写,因为String类加了final)
知识点3:底层是一个char数组(如下图所示),可以看到这个char数组也是被final修饰了,说明他是不能被改变的:
知识点4:String类的方法,是不会影响原对象的。
我们来看String类中的几个方法:substring、concat、replace方法,源码如下
(1)substring方法(截取部分字符串):代码如下所示,一共两种重载形式,注意两段代码最后一行,返回的都是new了一个新的string。
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
(2)concat方法(字符串拼接,jdk1.8->String类2026行):
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
(3)replace方法(字符串替换方法,jdk1.8->String类2066行):
public String replace(char oldChar, char newChar) {
if (oldChar != newChar) {
int len = value.length;
int i = -1;
char[] val = value; /* avoid getfield opcode */
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
if (i < len) {
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ? newChar : c;
i++;
}
return new String(buf, true);
}
}
return this;
}
举例以上三种方法,是为了得出下面这样一个结论:
String类的方法,是不会影响原对象的,也就是说我现在有一个string对象str1,我现在调用方法substring、concat、replace等等其他方法,是不会影响str1对象的,调用这些方法都会生成新的String类对象。这就说明,无论如何,String对象是不会被改变的
结论:
无论如何,String对象是不会被改变的
1.2 String对象的比较(即字符串的比较)
背景知识:在运行过程中,需要把 .java文件编译成 .class文件,而 .class文件中的一部分是用来存储编译期间产生的字面常量和符号引用,称之为class文件的常量池,对应的是运行时常量池。而运行时常量池在jdk1.7之后被移到堆当中去存储了。
先看下面的代码:利用不同的构造来判别字符串是否相等
public class StringTest {
public static void main(String[] args) {
String s1 = "hello world";
String s2 = new String("hello world");
String s3 = "hello world";
String s4 = new String("hello world");
System.out.println(s1 == s2); // false
System.out.println(s1 == s3); // true
System.out.println(s2 == s4); // false
}
}
现在解析上面这段代码:
- 对于s1和s3,为什么相等呢?因为在编译期间,生成了字面常量和符号引用,s1和s3会被存储在对应的运行时常量池中。由于s1和s3的内容一样(都是hello world),在运行时常量池中只存储一次。
- 对于1)中最后一句话(在运行时常量池中只存储一次),具体是怎么存储的呢?
先去运行时常量池中找有没有 hello world 这个字符串存在。如果存在,就令变量直接指向该字符串的位置;如果不存在,常量池会创建一个内存空间来存储这个字符串,再令变量指向这个字符串。
(1)对于我们上述的s1和s3:当我们执行到上述代码第三行:
此时常量池中没有hello world 这个字符串,这个时候会开辟一个内存空间来存储hello world,然后令s1指向字符串 hello world的内存空间!(注意是指向内存空间)String s1 = "hello world";
(2)当执行到上述代码第五行:
这个时候常量池中有 hello world ,不会创建新的内存空间,会直接令s3指向字符串hello world的内存空间。String s3 = "hello world";
(3)所以s1和s3指向了同一个内存空间,也正是因此在上述代码第9行结果为false。System.out.println(s1 == s3); // true
- 对于s1和s2、s2和s4为什么返回false呢?
(1)众所周知,通过new关键词创建出来的对象,一定是一个新的对象,即在堆当中创建了一个新的对象。即使堆当中已经有相同内容的字符串,new出来的对象也会新开辟一个内存空间存储当前字符串。
(2)对于上述s2、s4,都是通过new关键字创建的对象,即使堆中已经有了 hello world字符串,此时也会创建新的内存空间存储字符串hello world,然后令s2和s4分别指向这两个内存空间;既然是新开辟的内存空间,那么内存空间的位置必然不一样,所以在比较s1 == s2和s2 == s4时都会返回false。
注意:上述3)中我提到了堆而没有说常量池,是因为我不太确定new出来的新对象是否在常量池中,或者是在堆的其他空间?(常量池是在堆中,堆的定义范围更大)。不过我可以确定new出来的新对象是在堆中。这方面知识涉及jvm的内存空间分配,我还不了解。我使用堆这个更大的范围应该是没有问题的。非常欢迎各位共同讨论~
结论:
1. 编译期间,会生成了字面常量和符号引用
2. 运行时相同字符串常量池中只存储一次。
3. new关键词创建出来的对象,一定是一个新的对象
2. String和StringBuilder的比较
既然已经有了String,为什么还要提出StringBuilder?
1.先来一个小栗子~
public class Test {
public static void main(String[] args) {
String s1 = "";
for (int i = 0; i < 1000; i++) {
s1 += "a";
}
System.out.println(s1); // aaaaaa......1000个a
}
}
1)上述代码会会拼接字符串成1000个a,然后令s1指向它(实际是内存空间)。
2)通过查看jvm指令(如下图),可以看到每一次循环,jvm都创建了一个StringBuilder对象;也就说原代码中的第四行:s1 += “a”; jvm都会自动优化成创建一个StringBuilder对象,然后调用append方法去完成。那么循环1000次,就创建了1000个StringBuilder对象,调用了1000次append方法。
3)既然创建了1000个StringBuilder对象,并且这些对象都是在堆当中。这对内存资源是一种极大的消耗,
2.可以对上述代码进行优化如下:
public class Test {
public static void main(String[] args) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 1000; i++) {
builder.append("a");
}
System.out.println(builder); // aaaaaa......1000个a
}
}
显然可以看出这里我们最开始创建了一个StringBuilder对象,然后在1000次循环中,每次都利用append追加字符串,避免了代码1循环时每次都要新建StringBuilder对象,节约了内存。
结论:
利用StringBuilder拼接字符串效率更高
3. String、StringBuilder和StringBuffer三者比较
3.1 StringBuffer与StringBuilder的区别:线程安全
查看StringBuffer源码(如下图):可以发现StringBuffer每个方法中都有一个synchronized标识,这个是用来保证多线程安全访问,所以StringBuffer是线程安全的,StringBuilder是是非线程安全的。
注:这里为什么能够起到线程安全与否的作用,我暂时还不理解,后面学到了会及时更新,也欢迎朋友们互相探讨~
3.2 实例对比区别
1.String、StringBuilder和StringBuffer三个对象拼接十万次字符串 ”a“。
public class Test {
public static int time = 100000;
public static void main(String[] args) {
testString(); // String拼接用时:5570
testStringBuffer(); // StringBuffer拼接用时:4
testStringBuilder(); // StringBuilder拼接用时:5
}
public static void testString(){
String s1 = "";
long start = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
s1 += "a";
}
long end = System.currentTimeMillis();
System.out.println("String拼接用时:"+(end-start));
}
public static void testStringBuffer(){
StringBuffer buffer = new StringBuffer();
long start = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
buffer.append("a");
}
long end = System.currentTimeMillis();
System.out.println("StringBuffer拼接用时:"+(end-start));
}
public static void testStringBuilder(){
StringBuilder builder = new StringBuilder();
long start = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
builder.append("a");
}
long end = System.currentTimeMillis();
System.out.println("StringBuilder拼接用时:"+(end-start));
}
}
1)执行结果如下(每次执行结果不会完全相同):
String拼接用时:5570
StringBuffer拼接用时:4
StringBuilder拼接用时:5
2)可以看到String拼接时间非常大,大约5秒钟,其他两个为4和5毫秒;所以以后尽量不要用String拼接字符串。
结论:
1.String拼接字符串效率低、时间久;StringBuffer和StringBuilder效率高
2.StringBuffer是线程安全的,StringBuilder是是非线程安全的
注意:这里为什么StringBuffer和StringBuilder效率高(似乎和底层定义的数组有关)和线程安全我还不了解,后续深入学习后再更新!
3.3 String直接和间接拼接的区别
public class Test {
public static int time = 100000;
public static void main(String[] args) {
testString1(); // String直接拼接用时:2
testString2(); // String间接拼接用时:41
}
// String直接拼接
public static void testString1(){
String s1 = "";
long start = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
s1 = "a" + "b" + "c";
}
long end = System.currentTimeMillis();
System.out.println("String直接拼接用时:"+(end-start));
}
// String间接拼接
public static void testString2(){
String a = "a";
String b = "b";
String c = "c";
long start = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
String s1 = a + b + c;
}
long end = System.currentTimeMillis();
System.out.println("String间接拼接用时:"+(end-start));
}
}
1)执行结果如下:20倍的差距
String直接拼接用时:2
String间接拼接用时:41
2)直接拼接和间接拼接的区别:
- 直接拼接:编译期间就可以确定要拼接的值:将”a“ + “b” + “c"三个字符串优化成 ”abc”,然后再拼接。
- 间接拼接:不会进行拼接,所以效率低。
4. 常见问题
例1:字符串直接拼接
String s1 = "helloa";
String s2 = "hello" + "a";
System.out.println(s1 == s2); // true
解释:对于s2,编译期中编译器就会将“hello" + "a"自动优化为”helloa“(即上述提到的直接拼接),那么程序运行到第二行 String s2 = “hello” + “a”;时已经线程池已经有了“helloa”这个字符串,会直接将该地址赋值给s2(即我们上述提到的常量池原理),也因此判断二者相等时,返回true。
例2:符号引用的拼接
String s1 = "helloa";
String s2 = "hello";
String s3 = s2 + "a";
Syste.out.println(s1 == s3); // flase
解释:由于上述第三行即String s3 = s1 + “a”;的等号右边是s1 + ”a”;并非两个字符串之间拼接,属于符号引用,在编译期间并不会被优化。这个时候会生成一个新的对象保存至堆内存中,因此s1和s3并非同一个对象。
例3:final关键字修饰的符号引用(在代码2的基础上给s2加了final修饰)
String s1 = "helloa";
final String s2 = "hello";
String s3 = s2 + "a";
Syste.out.println(s1 == s3); // true
解释:由于s2被final修饰,在编译期间会被代替为真正的值,即第三行在编译期间将s2代替为"hello",然后进行直接拼接。那么因此会拼接后变成"helloa",当执行到第三行时,由于常量池中已经有了”helloa“(s1已经存在),所以将s3指向了该字符串内存空间。自然而然,s1 == s3返回true。
例4:编译期和运行期间创建对象的区别
String str = new String("abc");
问题:以上代码在执行期间创建了几个对象?
答案:1个。
解释:上述完整过程会创建两个对象:第一个是字符串"abc",会被创建在常量池中。第二个是new String()对象,即在堆中new出来一个对象。但是,第一个字符串对象“abc”是编译期间在常量池中创建的,而不是执行期间!所以执行期间创建的对象只有一个,就是new String()创建出来的对象。
例5:利用方法赋值后拼接字符串
public class Test {
public static void main(String[] args) {
String s1 = "helloa";
final String s2 = hello();
String s3 = s2 + "a";
System.out.println(s1 == s3); // false
}
public static String hello(){
return "hello";
}
}
解释:上述例子3中提到被final修饰,在编译过程中会被替代为真实值。但是我们这里第4行中,调用了方法hello(),即使这里s2被final修饰了,但是s2的值是方法hello() 的返回值。那么在编译期间方法hello()的返回值是不能确定的,只能在运行期确定,自然而然s2的值也只能等到方法执行后确定。所以返回false。
例6:方法intern()
public class Test {
public static void main(String[] args) {
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s1 == s2.intern()); // true
}
}
解释:方法intern()在jdk1.7后发生了改变。
1)jdk1.7之前,s2.intern();会先判断在字符串常量池是否有字符串s2的值,如果没有就把s2的值复制一份到常量池,即在常量池中开辟空间写入s2的真实值,然后方法intern()返回的就是常量池中开辟中的内存空间的地址。注意:b没有变,b的真实值和b所指向的空间位置都没有改变,只是方法intern()返回的是新开辟的空间的地址。
2)jdk1.7之后,s2.intern();会先判断在字符串常量池是否有字符串s2的值,如常量池中没有,并不会把s2的值复制一份到常量池,而是在常量池中会产生一个引用,该引用指向了s2的具体的值,方法intern()会返回这个引用。
String str = s.intern();
1.8:将字符串的值尝试放入StringTable;如果StringTable中已经存在,则不会放入;如果没有则放入StringTable,并返回StringTable中的字符串对象。
1.6:将字符串的值尝试放入StringTable;如果StringTable中已经存在,则不会放入;如果没有会把此对象复制一份,放入串池,会把串池中的对象返回。
https://www.cnblogs.com/feizhai/p/10196955.html intern方法在1.6和1.8可以参考这篇文章。
注意:jdk1.8中intern方法,比如 s.intern(); 如果StringTable中没有s的值,不仅会把s的字符串内容复制一份到StringTable,还会更改s的引用,令s指向StringTable中的这个值。
**总结:** ==1.直接拼接发生在编译期,效率更高 2.符号引用不能进行直接拼接 3.被final修饰的变量符号,在编译期间会被代替为真正的值,从而进行符号拼接。 4.编译期在字符串常量池新建对象,运行期在堆中new出对象。 5.方法在运行期执行,其结果也在方法执行后返回 6.方法intern()在jdk1.7之前为调用者在常量池复制一份,jdk1.7之后为复制引用。==