一、概述
java的String类可以说是日常实用的最多的类,但是大多数时候都只是简单的拼接或者调用API,今天决定深入点了解一下String类。
要第一时间了解一个类,没有什么比官方的javaDoc文档更直观的了:
根据文档,对于String类,我们关注三个问题:
- String对象的不可变性(为什么是不可变的,这么设计的必要性)
- String对象的创建方式(两种创建方式,字符串常量池)
- String对象的拼接(StringBuffer,StringBuilder,加号拼接的本质)
一、String对象的不可变性
1.String为什么是不可变的
文档中提到:
对于这段话我们结合源码来看;
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
}
我们可以看到,String类字符其实就是char数组对象的二次封装,存储变量value[]
是被final修饰的,所以一个String对象创建以后是无法被改变值的,这点跟包装类是一样的。
我们常见的写法:
String s = "AAA";
s = "BBB";
实际上创建了两个String对象,我们使用 = 只是把s指从AAA的内存地址指向了BBB的内存地址。
我们再看看熟悉的substring()
方法:
public String substring(int beginIndex, int endIndex) {
... ...
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
可以看出,在最后也是返回了一个新的String对象,同理,toLowerCase()
,trim()
等返回字符串的方法也都是在最后返回了一个新对象。
2.String不可变的必要性
String之所以被设计为不可变的,目的是为了效率和安全性:
- 效率:
- String不可变是字符串常量池实现的必要条件,通过常量池可以避免了过多的创建String对象,节省堆空间。
- String的包含了自身的HashCode,不可变保证了对象HashCode的唯一性,避免了反复计算。
- 安全性:
- String被许多Java类用来当参数,如果字符串可变,那么会引起各种严重错误和安全漏洞。
- 再者String作为核心类,很多的内部方法的实现都是本地调用的,即调用操作系统本地API,其和操作系统交流频繁,假如这个类被继承重写的话,难免会是操作系统造成巨大的隐患。
- 最后字符串的不可变性使得同一字符串实例被多个线程共享,所以保障了多线程的安全性。而且类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载。
二、字符串常量池
1.作用
文档中有提到:
字符串常量池是一块用于记录字符串常量的特殊区域(具体可以参考我在关于jvm内存结构的文章),JDK8之前字符串常量池在方法区的运行时常量池中,JDK8之后分离到了堆中。“共享”操作就依赖于字符串常量池。
我们知道String是一个对象,而value[]
是一个不可变值,所以当我们日常中使用String的时候就会频繁的创建新的String对象。JVM为了提高性能减少内存开销,在通过类似String S = “aaa”
这样的操作的时候,JVM会先检查常量池是否是存在相同的字符串,如果已存在就直接返回字符串实例地址,否则就会先实例一个String对象放到池中,再返回地址。
举个例子:
String s1 = "aaa";
String s2 = "aaa";
System.out.print(s1 == s2); // true
我们知道“==”比较对象的时候比较的是内存地址是否相等,当s1创建的时候,一个“aaa”String对象被创建并放入池中,s1指向的是该对象地址;当第二个s2赋值的时候,JVM从常量池中找到了值为“aaa”的字符串对象,于是跳过了创建过程,直接将s1指向的对象地址也赋给了s2.
2.入池方法intern()
这里要提一下String对象的手动入池方法 intern()
。
这个方法的注释是这样的:
举个例子说明作用:
String s1 = "aabb";
String s2 = new String("aabb");
System.out.println(s1 == s2); //false
System.out.println(s1 == s2.intern()); //true
最开始s1创建了“aabb”对象A,并且加入了字符串常量池,接着s2创建了新的"aabb"对象B,这个对象在堆中并且独立于常量池,此时s1指向常量池中的A,s2指向常量池外的B,所以==返回是false。
我们使用intern()
方法手动入池,字符串常量池中已经有了值等于“aabb”的对象A,于是直接返回了对象A的地址,此时s1和s2指向的都是内存中的对象A,所以==返回了true。
三、String对象的创建方式
从上文我们知道String对象的创建和字符串常量池是密切相关的,而创建一个新String对象有两种方式:
- 使用字面值形式创建。类似
String s = "aaa"
- 使用new关键字创建。类似
String s = new String("aaa")
1.使用字面值形式创建
当使用字面值创建String对象的时候,会根据该字符串是否已存在于字符串常量池里来决定是否创建新的String对象。
当我们使用类似String s = "a"
这样的代码创建字符串常量的时候,JVM会先检查“a”这个字符串是否在常量池中:
如果存在,就直接将此String对象地址赋给引用s(引用s是个成员变量,它在虚拟机栈中);
如果不存在,就会先在堆中创建一个String对象,然后将对象移入字符串常量池,最后将地址赋给s。
2.使用new关键字创建
当使用String关键字创建String对象的时候,无论字符串常量池中是否有同值对象,都会创建一个新实例。
看看new调用的的构造函数的注释:
当我们使用new关键字创建String对象时,和字面值形式创建一样,JVM会检查字符串常量池是否存在同值对象:
- 如果存在,则就在堆中创建一个对象,然后返回该堆中对象的地址;
- 否则就先在字符串常量池中创建一个String对象,然后再在堆中创建一个一模一样的对象,然后返回堆中对象的地址。
也就是说,使用字面值创建后产生的对象只会有一个,但是用new创建对象后产生的对象可能会有两个(只有堆中一个,或者堆中一个和常量池中一个)。
我们举个例子:
String s1 = "aabb";
String s2 = new String("aabb");
String s3 = "aa" + new String("bb");
String s4 = new String("aa") + new String("bb");
System.out.println(s1 == s2); //false
System.out.println(s1 == s3); //false
System.out.println(s1 == s4); //false
System.out.println(s2 == s3); //false
System.out.println(s2 == s4); //false
System.out.println(s3 == s4); //false
我们可以看到,四个String对象是都是相互独立的。
实际上,执行完以后对象在内存中的情况是这样的:
3.小结
- 使用new或者字面值形式创建String时都会根据常量池是否存在同值对象而决定是否在常量池中创建对象
- 使用字面值创建的String,引用直接指向常量池中的对象
- 使用new创建的String,还会在堆中常量池外再创建一个对象,引用指向常量池外的对象
四、String的拼接
我们知道,String经常会用拼接操作,而这依赖于StringBuilder类。实际上,字符串类不止有String,还有StringBuilder和StringBuffer。
简单的来说,StringBuilder和StringBuffer与String的主要区别在于后两者是可变的字符序列,每次改变都是针对对象本身,而不是像String那样直接创建新的对象,然后再改变引用。
1.StringBuilder
我们先看看它的javaDoc是怎么介绍的:
我们知道这个类的主要作用在于能够动态的扩展(append()
)和改变字符串对象(insert()
)的值。
我们对比一下String和StringBuilder:
//String
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence{}
//StringBuilder
public final class StringBuilder extends AbstractStringBuilder
implements java.io.Serializable, CharSequence{}
不难看出,两者的区别在于String实现了Comparable接口而StringBulier继承了抽象类AbstractStringBuilder。后者的扩展性就来自于AbstractStringBuilder。
AbstractStringBuilder中和String一样采用一个char数组来保存字符串值,但是这个char数组是未经final修饰,是可变的。
char数组有一个初始大小,跟集合容器类似,当append的字符串长度超过当前char数组容量时,则对char数组进行动态扩展,即重新申请一段更大的内存空间,然后将当前char数组拷贝到新的位置;反之就会适当缩容。
一般是新数组长度默认为:(旧数组长度+新增字符长度) * 2 + 2
。(不太准确,想要了解更多的同学可以参考AbstractStringBuilder类源码中的newCapacity()
方法)
2.加号拼接与append方法拼接
我们平时一般都直接对String使用加号拼接,实际上这仍然还是依赖于StringBuilder的append()
方法。
举个例子:
String s = "";
for(int i = 0; i < 10; i++) {
s += "a";
}
这写法实际上编译以后会变成类似这样:
String s = "";
for (int i = 0; i < 10; i++) {
s = (new StringBuilder(String.valueOf(s))).append("a").toString();
}
我们可以看见每一次循环都会生成一个新的StringBuilder对象,这样无疑是很低效的,也是为什么网上很多文章会说循环中拼接字符串不要使用String而是StringBuilder的原因。因为如果我们自己写就可以写成这样:
StringBuilder s = new StringBuilder();
for (int i = 0; i < 10; i++) {
s.append("a");
}
明显比编译器转换后的写法要高效。
理解了加号拼接的原理,我们也就知道了为什么字符串对象使用加号凭借==返回的是false:
String s1 = "abcd";
String s2 = "ab";
String s3 = "cd";
String s4 = s1 + s2;
String s5 = "ab" + s3;
System.out.println(s1 == s4); //false
System.out.println(s1 == s5); //false
分析一下上面的过程,无论 s1 + s2
还是 "ab" + s3
实际上都调用了StringBuilder在字符串常量池外创建了一个新的对象,所以==判断返回了false。
值得一提的是,如果我们遇到了“常量+字面值”的组合,是可以看成单纯的字面值:
String s1 = "abcd";
final String s3 = "cd";
String s5 = "ab" + s3;
System.out.println(s1 == s5); //true
总结一下就是:
- 对于“常量+字面值”的组合,可以等价于纯字面值创建对象
- 对于包含字符串对象引用的写法,由于会调用StringBuilder类的toString方法生成新对象,所以等价于new的方式创建对象
3.StringBuffer
同样看看它的javaDoc,与StringBuilder基本相同的内容我们跳过:
可以知道,StringBuilder是与JDK5之后添加的StringBuffer是“等效类”,两个类功能基本一致,唯一的区别在于StringBuffer是线程安全的。
我们查看源码,可以看到StringBuffer实现线程安全的方式是为成员方法添加synchronized
关键字进行修饰,比如append()
:
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
事实上,StringBuffer几乎所有的方法都加了synchronized
。这也就不难理解为什么一般情况下StringBuffer效率不如StringBuilder了,因为StringBuffer的所有方法都加了锁。