0%

JAVA String详解

一. String概念

String 是java中对字符串的一种表达方式,这是一个示例对象,并不属于常见的8中基本类型,和char[]也有一定的区别。

1.String特性

String有三个特性。

  • 不可变性:从JDK文档中我们可以看到,String是常量的,这就意味着当我们对String重新赋值的时候,需要重写指定内存区域进行赋值,不能对原有的内存地址中的value进行修改。当对现有的字符串进行拼接的时候,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。当我们调用String的replace()方法进行修改的时候,同样的也不能直接修改

  • final 修饰:这意味这String是不可以被继承的,这也增加了String的安全性。实现Serializable接口:表示字符串支持序列化,实现了ComparaBle接口:表示String可以比较大小;

  • 在JVM中维护了一个字符串常量池,用于存放字符串常量,这个对于我们深入理解String是非常重要的,至于字符串常量池的版本变化,我在之前的方法区中有提到过,大家可以去看一下。通过字面量的方式(区别与new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。当然根据字符串常量池的特性,常量池中是不会存放相同的值,当我们创建两个具有相同字面量的字符串时,比如

1
2
String a = "abc";
String b = "abc";

因为字符串a和字符串b被同一个字面量创建,当a被创建的时候,会先检查字符串常量池中是否有“abc”,如果没有则开辟一个空间并把“abc”存放到这个地址上,而当b被创建的时候,检查常量池上已经有“abc”了,所以直接把指针指向该地址。实际上a和b指向的是同一个地址。

2.String内存分配

在Java语言中,有8种基本类型,和一种比较特殊的类型String,这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。
常量池就是类似一个Java系统级别的提供的缓存。8中基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它主要使用的方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。

String操作

String操作有:字符串的创建、拼接、比较等几个比较常用的方法,这些方法的一些用法因为String的特殊性,经常被当作笔试的题目,我们接下来就重点讲解这几个操作。

1.String创建

String的创建有两种方法,一种是使用字面量赋值,比如

1
2
String a = "abc";
String b = "abc";

这种方式创建字符串,会直接在常量池中创建对象,栈对象a和b分别用指针指向该字符串常量池中的常量。

另一种方式是使用常用的new关键字创建,如下所示:

1
String c = new String("abc");

这种方式创建的字符串会先在堆中开辟一个空间,并创建字符串对象c,这个对象的value为“abc”,同时会检查常量池是否含有“abc”字符串,如果没有的话会重新创建一个字符串常量“abc”。

总结来说第一种创建方法指针会直接指向字符串常量池相当于a->”abc”,b->”abc”,第二种方式首先会在堆中创建一个c的String对象,它的value是“abc”。 同时如果这个字符串在常量池中不存在,会在常量池中创建这个String对象“abc”;

我们用下图表示,两者的区别:

upload successful

所以我们可以看到这样的面试题:

1
2
3
4
5
6
7
8
9
public class stringTest {
public static void main(String[] args) {
String a = "abc";
String b = "abc";
String c = new String("abc");
System.out.println(a == b);
System.out.println(a == c);
}
}

返回的结果是

1
2
true
false

2.String拼接

常量池的拼接遵守以下规则:

  • 1.常量和常量的拼接结果在常量池中,原理是编译期优化。
  • 2.常量池中不会存在相同内容的常量。
    1. 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
  • 4.如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放在常量池中,并返回此对象地址。

下面我们根据一些笔试的题目看一下这些规则:
面试题一:

1
2
3
4
5
6
@Test
public void Test1(){
String a = "a" + "b" + "c";
String b = "abc";
System.out.println(a ==b);
}

这里a是由三个字符串拼接的结果,我们根据规则一可以得到拼接结果“abc”会放在常量池中,所以结果阿返回的是true,并且我们根据编译的class文件可以看到,String a = “a” + “b” + “c”;直接会被优化成String a = “abc”;所以最后执行的代码是String a = “abc”;这样我们可以和我们之间的知识对应。

面试题二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void Test2(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a"+"b";
String s5 = s1+"b";
String s6 = "a"+s2;
String s7 = s1+s2;

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

}

由上面的规则得到,只要其中有一个是变量,得到的结果就存放在堆中,而不是在常量池中,所以除了当一个返回的是true,其他的返回的都是false。

当我们使用变量进行拼接的时候,我们使用的底层是StringBilder,比如上面的String s7 = s1+s2;就相当于

1
2
3
4
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
sb.toString();

StringBuilder的toString操作实际上是一个new String的操作。

当然不是所有的变量拼接操作都是使用StringBuilder操作的,当我们对变量声明为final的时候,变量的拼接就会编程常量的拼接,这样底层就不会用到SringBuilder,而是由编译器优化直接使用常量池进行赋值。

这里同时也变相说明了String的拼接操作会比StringBuilder的拼接操作效率低,因为String在进行拼接操作的过程中会new StringBuilder对象,然后再进行拼接操作,而StringBuilder直接进行拼接,节省了空间和时间从而提高效率。

讲完上面两个String的创建和拼接,我们来看一个综合的笔试面试题目。

1
2
3
4
@Test
public void Test3(){
String a = new String("a")+ new String("b");
}

我们来计算一下这里到底创建了多少个对象,我们先说答案,再说为什么,这里一共生成了6个对象。
首先是会生成一个StringBuilder对象用于拼接,接着对于new String(a)会在堆中以及常量分别生成String对象,以及对于new String(“b”)同样的会生成两个对象,最后调用StringBuilder的toString方法,会生成一个String对象。这里要注意,toString方法并不会在常量池中生成对象,所以常量池中并没有“ab”的字符串对象。

3.intern()

String字符串还有一个比较特殊的API:intern(),当一个字符串s调用这个函数的时候,会从字符串常量池中寻找是否有与s值相等的字符串,如果找到了,就返回常量池中的字符串。否则,将该字符串加入到常量池中,并且返回对该常量池中这个字符串的引用。

比如说:

1
String info = new String("1111").intern();

也就是说,如果在任意字符串上调用String.intern方法,那么返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同,因此,下列表达式的值必定是true

1
{"a"+"b"+"c"}.intern()== “abc”;

通俗来讲,Intern就是确保相同值的字符串在内存中只用一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意这个值会被存放在字符串内部池。

这里我们仍然用一个面试题来看一下intern()方法使用:

1
2
3
4
5
6
7
@Test
public void Test4(){
String a = new String("a") + new String("b");
a.intern();
String b = "ab";
System.out.println(a == b);
}

这题的答案其实根据不同的jdk版本是不一样的,在jdk1.6以前,因为之前说过String a = new String(“a”) + new String(“b”);并没有在常量池中创建“ab”的字符串,且a指向的是堆空间地址,所以返回的是false,但是在jdk7及以后,intern同样会在常量池中寻找“ab”对象,但是因为在堆中已经创建了“ab”的字符串对象,所以当b创建对象的时候,常量池不需要在常量池中重新创建“ab”对象了,可以直接存储堆中的引用,这个引用指向s3引用的对象,也就是说引用地址相同。所以结果最终返回的是true。

当我们将a.intern();和String b = “ab”;调换一下顺序之后,结果又会发生变化,如下:

1
2
3
4
5
6
7
@Test
public void Test4(){
String a = new String("a") + new String("b");
String b = "ab";
a.intern();
System.out.println(a == b);
}

因为String b = “ab”;并不会像a.intern()一样智能会选择直接引用堆中相同的对象,所以结果返回的就是false;

4.String 比较

String的比较主要有两种,一种是==一种是equals。

  • 使用==号:用于比较对象引用的内存地址是否相同。
  • 使用equals方法:在Object类中和==号相同,但在自定义类中,建议覆盖equals方法去实现比较自己内容的细节;由于String类覆盖已经覆盖了equals方法,所以其比较的是字符内容。

三. StringBuffer 和 StringBuilder

StringBuffer和StringBuilder常常用于解决字符串拼接的问题,他们都比String直接凭借效率高很多。

先来分别使用String/StringBuilder/StringBuffer来拼接30000次字符串,对比各自损耗的时间,经过测试发现:

String做字符串拼接的时候,耗时最高,性能极低,原因是String内容是不可变的,每次内容改变都会在内存中创建新的对象。

性能最好的是StringBuilder,其次是StringBuffer,最后是String。StringBuilder和StringBuffer区别并不是很大,也有可能是测试次数还不够吧。感兴趣的小伙伴可以增加拼接次数来看看。代码很简单,就不展示出来了。

所以在开发中拼接字符串时,优先使用StringBuffer/StringBuilder,不到万不得已,不要轻易使用String。

StringBuilder以及StringBuffer的区别

StringBuffer和StringBuilder都表示可变的字符串,两种’的功能方法都是相同的。但唯一的区别:

  • StringBuffer:StringBuffer中的方法都使用了synchronized修饰符,表示同步操作,在多线程并发的时候可以保证线程安全,但在保证线程安全的时候,对其性能有一定影响,会降低其性能。
  • StringBuilder:StringBuilder中的方法都没有使用了synchronized修饰符,线程不安全,正因为如此,其性能较高。

对并发安全没有很高要求的情况下,建议使用StringBuilder,因为其性能很高。像这样的情况会较多些。使用StringBuilder无参数的构造器,在底层创建了一个长度为16的char数组:

upload successful

此时该数组只能存储16个字符,如果超过了16个字符,会自动扩容(创建长度更大的数组,再把之前的数组拷贝到新数组),此时性能极低;如果事先知道大概需要存储多少字符,可以通过构造器来设置字符的初始值:

upload successful

四. 参考资料

尚硅谷2020最新版宋红康JVM教程持续更新中(java虚拟机详解,jvm从入门到精通)

「JAVA」细述合理创建字符串,分析字符串的底层存储,你不该错过

字符串常量池深入解析