概述
java 的 String 类可以说是日常中使用的最多的类,但是大多数时候都只是简单的拼接或者调用 API,只知其然不知其所以然。为了改变这个情况,我决定结合网上的资料,参考源码,深入一点去了解 String 这个熟悉的陌生人。
要第一时间了解一个类,没有什么比官方的文档更直观的了:
String类表示字符串。Java程序中的所有字符串文本(如“abc”)都作为此类的实例实现。
字符串是常量;它们的值在创建后不能更改。字符串缓冲区支持可变字符串。因为字符串对象是不可变的,所以可以共享它们。
Java语言提供了对字符串连接运算符(+)以及将其他对象转换为字符串的特殊支持。字符串连接是通过
StringBuilder
(或StringBuffer
)类及其append
方法实现的。字符串转换是通过toString
方法实现的… …
根据文档,对于String类,我们关注三个问题:
- String对象的不可变性(为什么是不可变的,这么设计的必要性)
- String对象的创建方式(两种创建方式,字符串常量池)
- String对象的拼接(StringBuffer,StringBuilder,加号拼接的本质)
一、String对象的不可变性
1.String为什么是不可变的
文档中提到:
字符串是常量;它们的值在创建后不能更改。
对于这段话我们结合源码来看;
1 | public final class String |
我们可以看到,String类字符其实就是char数组对象的二次封装,存储变量value[]
是被final修饰的,所以一个String对象创建以后是无法被改变值的,这点跟包装类是一样的。
我们常见的写法:
1 | String s = "AAA"; |
实际上创建了两个String对象,我们使用 = 只是把s指从AAA的内存地址指向了BBB的内存地址。
我们再看看熟悉的substring()
方法:
1 | public String substring(int beginIndex, int endIndex) { |
可以看出,在最后也是返回了一个新的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对象放到池中,再返回地址。
举个例子:
1 | String s1 = "aaa"; |
我们知道“==”比较对象的时候比较的是内存地址是否相等,当s1创建的时候,一个“aaa”String对象被创建并放入池中,s1指向的是该对象地址;当第二个s2赋值的时候,JVM从常量池中找到了值为“aaa”的字符串对象,于是跳过了创建过程,直接将s1指向的对象地址也赋给了s2.
2.入池方法intern()
这里要提一下String对象的手动入池方法 intern()
。
这个方法的注释是这样的:
最初为空的字符串池由String类私有维护。
调用intern方法时,如果池已经包含等于
equal()
方法确定的此String对象的字符串,则返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。
举个例子说明作用:
1 | String s1 = "aabb"; |
最开始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调用的的构造函数的注释:
初始化新创建的字符串对象,使其表示与参数相同的字符序列;换句话说,新创建的字符串是参数字符串的副本。除非需要original的显式副本,否则没有必要使用此构造函数,因为字符串是不可变的。
当我们使用new关键字创建String对象时,和字面值形式创建一样,JVM会检查字符串常量池是否存在同值对象:
- 如果存在,则就在堆中创建一个对象,然后返回该堆中对象的地址;
- 否则就先在字符串常量池中创建一个String对象,然后再在堆中创建一个一模一样的对象,然后返回堆中对象的地址。
也就是说,使用字面值创建后产生的对象只会有一个,但是用new创建对象后产生的对象可能会有两个(只有堆中一个,或者堆中一个和常量池中一个)。
我们举个例子:
1 | String s1 = "aabb"; |
我们可以看到,四个String对象是都是相互独立的。
实际上,执行完以后对象在内存中的情况是这样的:
3.小结
- 使用new或者字面值形式创建String时都会根据常量池是否存在同值对象而决定是否在常量池中创建对象
- 使用字面值创建的String,引用直接指向常量池中的对象
- 使用new创建的String,还会在堆中常量池外再创建一个对象,引用指向常量池外的对象
四、String的拼接
我们知道,String经常会用拼接操作,而这依赖于StringBuilder类。实际上,字符串类不止有String,还有StringBuilder和StringBuffer。
简单的来说,StringBuilder和StringBuffer与String的主要区别在于后两者是可变的字符序列,每次改变都是针对对象本身,而不是像String那样直接创建新的对象,然后再改变引用。
1.StringBuilder
我们先看看它的注释是怎么介绍的:
可变的字符序列。
此类提供与StringBuffer兼容的API,但不保证同步。
此类设计为在单线程正在使用StringBuilder的地方来代替StringBuffer。在可能的情况下,建议优先使用此类而不是StringBuffer,因为在大多数实现中它会更快。
StringBuilder上的主要操作是
append()
和insert()
方法,它们会被重载以接受任何类型的数据。每个有效地将给定的基准转换为字符串,然后将该字符串的字符追加或插入到字符串生成器中。 append方法始终将这些字符添加到生成器的末尾。 insert方法在指定点添加字符。例如:
如果z指向当前内容为“ start”的字符串生成器对象,则方法调用z.append(“ le”)会使字符串生成器包含“ startle”,而z.insert(4,“ le”)将更改字符串生成器以包含“ starlet”。
通常,如果sb引用StringBuilder的实例,则sb.append(x)与sb.insert(sb.length(),x)具有相同的效果。每个字符串生成器都有能力。只要字符串构建器中包含的字符序列的长度不超过容量,就不必分配新的内部缓冲区。如果内部缓冲区溢出,则会自动变大。
StringBuilder实例不能安全地用于多个线程。如果需要这样的同步,则建议使用StringBuffer。除非另有说明,否则将null参数传递给此类中的构造函数或方法将导致引发NullPointerException。
我们知道这个类的主要作用在于能够动态的扩展(append()
)和改变字符串对象(insert()
)的值。
我们对比一下String和StringBuilder:
1 | //String |
不难看出,两者的区别在于String实现了Comparable接口而StringBulier继承了抽象类AbstractStringBuilder。后者的扩展性就来自于AbstractStringBuilder。
AbstractStringBuilder中和String一样采用一个char数组来保存字符串值,但是这个char数组是未经final修饰,是可变的。
char数组有一个初始大小,跟集合容器类似,当append的字符串长度超过当前char数组容量时,则对char数组进行动态扩展,即重新申请一段更大的内存空间,然后将当前char数组拷贝到新的位置;反之就会适当缩容。
一般是新数组长度默认为:(旧数组长度+新增字符长度) * 2 + 2
。(不太准确,想要了解更多的同学可以参考AbstractStringBuilder类源码中的newCapacity()
方法)
2.加号拼接与append方法拼接
我们平时一般都直接对String使用加号拼接,实际上这仍然还是依赖于StringBuilder的append()
方法。
举个例子:
1 | String s = ""; |
这写法实际上编译以后会变成类似这样:
1 | String s = ""; |
我们可以看见每一次循环都会生成一个新的StringBuilder对象,这样无疑是很低效的,也是为什么网上很多文章会说循环中拼接字符串不要使用String而是StringBuilder的原因。因为如果我们自己写就可以写成这样:
1 | StringBuilder s = new StringBuilder(); |
明显比编译器转换后的写法要高效。
理解了加号拼接的原理,我们也就知道了为什么字符串对象使用加号凭借==返回的是false:
1 | String s1 = "abcd"; |
分析一下上面的过程,无论 s1 + s2
还是 "ab" + s3
实际上都调用了StringBuilder在字符串常量池外创建了一个新的对象,所以==判断返回了false。
值得一提的是,如果我们遇到了“常量+字面值”的组合,是可以看成单纯的字面值:
1 | String s1 = "abcd"; |
总结一下就是:
- 对于“常量+字面值”的组合,可以等价于纯字面值创建对象
- 对于包含字符串对象引用的写法,由于会调用StringBuilder类的toString方法生成新对象,所以等价于new的方式创建对象
3.StringBuffer
同样看看它的javaDoc,与StringBuilder基本相同的内容我们跳过:
线程安全的可变字符序列。StringBuffer类似于字符串,但是可以修改。
对于**。字符串缓冲区可安全用于多个线程。这些方法在必要时进行同步,以使任何特定实例上的所有操作都表现为好像以某种串行顺序发生,该顺序与所涉及的每个单独线程进行的方法调用的顺序一致。
… …
请注意,虽然StringBuffer被设计为可以安全地从多个线程中并发使用,但是如果将构造函数或append或insert操作传递给在线程之间共享的源序列,则调用代码必须确保该操作具有一致且不变的视图操作期间源序列的长度。这可以通过调用方在操作调用期间保持锁定,使用不可变的源序列或不跨线程共享源序列来满足。
… …
**从JDK 5版本开始,该类已经添加了一个等效类StringBuilder,该类旨在供单线程使用。**通常应优先使用StringBuilder类,因为它支持所有相同的操作,但它更快,因为它不执行同步,因此它比所有此类都优先使用。
可以知道,StringBuilder是与JDK5之后添加的StringBuffer是“等效类”,两个类功能基本一致,唯一的区别在于StringBuffer是线程安全的。
我们查看源码,可以看到StringBuffer实现线程安全的方式是为成员方法添加synchronized
关键字进行修饰,比如append()
:
1 | public synchronized StringBuffer append(Object obj) { |
事实上,StringBuffer几乎所有的方法都加了synchronized
。这也就不难理解为什么一般情况下StringBuffer效率不如StringBuilder了,因为StringBuffer的所有方法都加了锁。