1. 概述
String
类代表字符串。Java
程序中的所有字符串字面值(如 "abc"
)都作为此类的实例(对象)实现
字符串是常量;它们的值在创建之后不能更改。因为 String
对象是不可变的,所以可以共享。字符串缓冲区支持可变的字符串
2. String
的实现以及不可变设计
String
是不可变类,所谓的不可变类是指这个类的实例一旦创建完成后,就不能改变其成员变量值;
James Gosling 说迫使 String
类设计成不可变的一个原因是安全,当你在调用其他方法时,比如调用一些系统级操作指令之前,可能会有一系列校验,如果是可变类的话,可能在你校验过后,它的内部的值又被改变了,这样有可能会引起严重的系统崩溃问题
同时使用 final
能够缓存结果,当你在传参时不需要考虑谁会修改它的值;如果是可变类的话,则有可能需要重新拷贝出来一个新值进行传参,这样在性能上就会有一定的损失
不可变类在设计的时候一般有以下特点,并且他们在String
类的设计中都有体现
① 类添加final
修饰符,保证类不被继承
如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变
对于这一点我们看String
的类的签名,的确如此
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
}
② 保证所有成员变量必须私有,并且加上final
修饰
通过这种方式保证成员变量不可改变,String
内部实际存储结构为 char
数组,而这个char数组就是final
修饰的,也就是说这个变量只可读,不能修改,初始化在最初或构造方法完成(但是这里final
修饰的是数组,只是数组的引用不可改变,数组的内容可能改变,这个问题的解决下面再说)
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
// 用于存储字符串的值
private final char value[];
// 缓存字符串的 hash code
private int hash; // Default to 0
// ......其他内容
}
③ 通过构造器初始化所有成员,进行深拷贝(deep copy)
在第②点中,如果是对象成员变量有可能在外部改变其值,因为他和外部指向的是同一块内存地址;为了保证内部的值不被修改,可以采用深度copy
来创建一个新内存保存传入的值
String
提供了多种构造方法
String() //初始化一个新创建的 String 对象,使其表示一个空字符序列
String(byte[] bytes) //通过使用平台的默认字符集解码指定的 byte 数组,构造一个新的 String。
String(byte[] bytes, Charset charset) // 通过使用指定的 charset 解码指定的 byte 数组,构造一个新的 String。
String(byte[] bytes, int offset, int length) //通过使用平台的默认字符集解码指定的 byte 子数组,构造一个新的 String。
String(byte[] bytes, int offset, int length, Charset charset) // 通过使用指定的 charset 解码指定的 byte 子数组,构造一个新的 String。
String(byte[] bytes, int offset, int length, String charsetName) //通过使用指定的字符集解码指定的 byte 子数组,构造一个新的 String。
String(byte[] bytes, String charsetName) // 通过使用指定的 charset 解码指定的 byte 数组,构造一个新的 String。
String(char[] value) //分配一个新的 String,使其表示字符数组参数中当前包含的字符序列。
String(char[] value, int offset, int count) //分配一个新的 String,它包含取自字符数组参数一个子数组的字符。
String(int[] codePoints, int offset, int count) //分配一个新的 String,它包含 Unicode 代码点数组参数一个子数组的字符。
String(String original) //初始化一个新创建的 String 对象,使其表示一个与参数相同的字符序列;换句话说,新创建的字符串是该参数字符串的副本。
String(StringBuffer buffer) //分配一个新的字符串,它包含字符串缓冲区参数中当前包含的字符序列。
String(StringBuilder builder) //配一个新的字符串,它包含字符串生成器参数中当前包含的字符序列。
这里可以看到,如果构造方法传递的是一个char
数组那么为了保证他的不可变性,实际上是创建了新的char
数组,这样外界的char
数组改变就不会影响内部的成员变量的char
数组,这个也叫做保护性拷贝的设计模式
④ 不提供改变成员变量的方法,包括setter
避免通过其他接口改变成员变量的值,破坏不可变特性。
⑤ 在getter
方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝
为了说明这一点,我看和他性质类似的subString()
方法
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = length() - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
if (beginIndex == 0) {
return this;
}
return new String(value,beginIndex,subLen);
}
并没有改变原始的字符串,而是创建了新的字符串,创建新字符串对象也使用了保护性拷贝
String
对象的不可变性的优点
① 字符串常量池的需要(下面说)
字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。
但如果字符串是可变的,此时相同内容的String
还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。
常量池是一个固定大小的HashTable
实现的
② 线程安全考虑
同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
③ 类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载
④ 支持hash
映射和缓存
因为字符串是不可变的,所以在它创建的时候hashcode
就被缓存了,不需要重新计算。这就使得字符串很适合作为Map
中的键,字符串的处理速度要快过其它的键对象。这就是HashMap
中的键往往都使用字符串
补充说明
上面的代码对于Jdk1.8来说的,Jdk1.9以后就变了,内部不再使用一个char[]
数组,而是使用一个byte[]
数组来存储String
这是因为一个char
占两个字节,大部分的String
对象包含的都是拉丁字符,一个字节就存的下,这样再使用char
就会浪费一半的空间,所以对于ISO-8859-1
或Latin-1
编码的就用一个字节去存;
除此之外,还有汉字,汉字需要使用两个字节去存储,所以除了byte
数组以外还补充了一个字符编码集的标识,如果是UTF-16
之类的需要两个字节存储的就用两个byte
一组存储
3. 享元模式
因为String
太过常用,JAVA
类库的设计者在实现时做了个小小的变化,即采用了享元模式;
每当生成一个新内容的字符串时,他们都被添加到一个共享池(字符串常量池)中,当第二次再次生成同样内容的字符串实例时,就共享此对象,而不是创建一个新对象,但是这样的做法仅仅适合于通过=
符号进行的初始化;
包装类Boolean
,Short
,Integer
,Long
等也使用了享元模式
String
常见的创建方式有两种,new String()
的方式和直接赋值的方式;
直接赋值的方式会先去字符串常量池中查找是否已经有此值,如果有则把引用地址直接指向此值,否则会先在常量池中创建,然后再把引用指向此值;
而 new String()
的方式一定会先在堆上创建一个字符串对象,然后再去常量池中查询此字符串的值是否已经存在,如果不存在会先在常量池中创建此字符串,然后把引用的值指向此字符串,如下代码所示:
String s1 = new String("Java");
String s2 = s1.intern();
String s3 = "Java";
System.out.println(s1 == s2); // false
System.out.println(s2 == s3); // true
在上面说过迫使 String
类设计成不可变的一个原因是安全,当你在调用其他方法时,比如调用一些系统级操作指令之前,可能会有一系列校验,如果是可变类的话,可能在你校验过后,它的内部的值又被改变了,这样有可能会引起严重的系统崩溃问题
只有字符串是不可变时,我们才能实现字符串常量池,字符串常量池可以为我们缓存字符串,提高程序的运行效率,如下图所示:
试想一下如果 String
是可变的,那当 s1
的值修改之后,s2
的值也跟着改变了,这样就和我们预期的结果不相符了,因此也就没有办法实现字符串常量池的功能了
一个面试题
public class Apple {
public static void main(String[] args) {
String a = "abc";
String b = "abc";
String c = new String("abc");
System.out.println(a==b); //true
System.out.println(a.equals(b)); //true
System.out.println(a==c); //false
System.out.println(a.equals(c)); //true
}
}
4. String
对"+"的重载
Java
是不支持重载运算符,String
的 “+” 是 java
中唯一的一个重载运算符
public static void main(String[] args) {
String string = "hello";
String string2 = string + "world";
}
实际执行如下
public static void main(String args[]){
String string = "hello";
String string2 = (new StringBuilder(String.valueOf(string))).append("world").toString();
}
再看下一个
public class TestClass3 {
public static void main(String[] args) {
String str = new String("a")+new String("b");
}
}
实际执行如下
public static void main(String args[]){
String string = "hello";
String str = (new StringBuilder()).append("hello").append("world").toString();
}
反编译字节码如下
Code:
stack=4, locals=2, args_size=1
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: new #4 // class java/lang/String
10: dup
11: ldc #5 // String a
13: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: new #4 // class java/lang/String
22: dup
23: ldc #8 // String b
25: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: astore_1
35: return
可以看到
首先为StringBuilder
对象开辟空间,并调用初始化方法(0-4)
然后创建String("a")
,这里和上面的分析一样,在常量池中和堆空间中分别有一个对象(7-13)
调用append
方法(16)
再初始化String("b")
,也是堆里一个常量池里一个,并调用StringBuilder
的append
方法(19-28)
最后调用StringBuilder
的toString()
方法,toString
方法也会返回一个对象,看一下他的源码
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
但是这里有一个问题,那就是toStirng("ab")
的时候并不会在常量池中创建"ab"
,这一点和new String("ab")
有所不同
所以这里一个有6个对象
可以看到变量和常量的拼接是在堆里面的,但是编译器还会对 String
字符串做一些优化,常量与常量的拼接是在常量池中的
String s1 = "Ja" + "va";
String s2 = "Java";
System.out.println(s1 == s2);//true
虽然 s1
拼接了多个字符串,但对比的结果却是 true
,我们使用反编译工具,看到的结果如下
Compiled from "StringExample.java"
public class com.lagou.interview.StringExample {
public com.lagou.interview.StringExample();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String Java
2: astore_1
3: ldc #2 // String Java
5: astore_2
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_1
10: aload_2
11: if_acmpne 18
14: iconst_1
15: goto 19
18: iconst_0
19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
22: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 22
}
从编译代码 #2
可以看出,代码 "Ja"+"va"
被直接编译成了 "Java"
,因此 s1==s2
的结果才是 true
,这就是编译器对字符串优化的结果
总结一下
- 常量与常量的拼接的结果是在常量池中的,原理是编译期的优化
- 只要其中有一个变量,结果就在堆中(非常量池),变量拼接的结果是
StringBuilder
5. String
的 intern()
方法有什么含义?
当调用 intern
方法时,如果池已经包含一个等于此 String
对象的字符串(该对象由 equals(Object)
方法确定),则返回池中的字符串,否则,将此 String
对象添加到池中,并且返回此 String
对象的引用
再jdk1.6和1.7以后不太一样,总结起来如下
jdk1.6中,将这个字符串对象尝试放入串池
- 如果串池中有,则不会放入,返回已有的串池中的对象地址
- 如果没有,会把对象赋值一份,放入串池,返回串池中的对象的地址
jdk1.7起,将这个字符串对象尝试放入串池
- 如果串池中有,则不会放入,返回已有的串池中的对象地址
- 串池中没有,而会把这个对象的引用地址复制一份,放入串池,并返回串池中的引用地址
出现这个变化的原因主要就是字符串常量池在jdk1.7后从方法区移到了堆里,为了节省堆里的内存,直接重用了堆里的对象
String str1 = "a";
String str2 = "b";
String str3 = "ab";
String str4 = str1 + str2;
String str5 = new String("ab");
System.out.println(str5.equals(str3));//true
System.out.println(str5 == str3);//false
System.out.println(str5.intern() == str3);//true
System.out.println(str5.intern() == str4);//false
intern
确保了字符串在内存中只有一份拷贝,可以节省内存空间,加快字符串操作任务的执行速度
** new String(“abc”)
究竟会创建几个对象**
Code:
stack=3, locals=2, args_size=1
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String ab
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: return
首先使用new
就会在堆空间开辟一块空间,ldc
就是把常量池中的字符串"ab"拿出来,之后再调用String
的构造器初始化堆中的String
对象;
对于intern
方法对于不同jdk
版本的体现看下面代码
public class TestClass3 {
public static void main(String[] args) {
String s3 = new String("1") + new String("2");//s3的地址为堆中的地址
//执行完上述的代码,字符串常量池有1 2 但是没有12
s3.intern();//在字符串常量池生成12
String s4 = "12";//地址是常量池中的地址
System.out.println(s3 == s4);
//jdk1.6:false 这很好理解,一个指向堆中,一个指向常量池,地址不一样
//jdk1.7:true 在jdk1.7之后,字符串常量池从方法区放到了堆里,而堆中为了节省内存,使用intern方法时发现堆中已经有对象12了,会把这个对象的引用地址复制一份,放入串池,并返回串池中的引用地址
}
}
6. equals()
比较两个字符串是否相等
在对字符串比较的时候,对比的是内存地址,而equals
比较的是字符串内容,在开发的过程中,equals()
通过接受参数,可以避免空指向
String str = null;
if(str.equals("hello")){//此时会出现空指向异常
...
}
if("hello".equals(str)){//此时equals会处理null值,可以避免空指向异常
...
}
源码如下
public boolean equals(Object anObject) {
// 对象引用相同直接返回 true
if (this == anObject) {
return true;
}
// 判断需要对比的值是否为 String 类型,如果不是则直接返回 false
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
// 把两个字符串都转换为 char 数组对比
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
// 循环比对两个字符串的每一个字符
while (n-- != 0) {
// 如果其中有一个字符不相等就 true false,否则继续对比
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
7. String
和 StringBuilder
、StringBuffer
的区别
因为 String
类型是不可变的,所以在字符串拼接的时候如果使用 String
的话性能会很低,因此我们就需要使用另一个数据类型 StringBuffer
,它提供了 append
和 insert
方法可用于字符串的拼接,它使用 synchronized
来保证线程安全,如下源码所示:
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
因为它使用了 synchronized
来保证线程安全,所以性能不是很高,于是在 JDK 1.5
就有了 StringBuilder
,它同样提供了 append
和 insert
的拼接方法,但它没有使用 synchronized
来修饰,因此在性能上要优于 StringBuffer
,所以在非并发操作的环境下可使用 StringBuilder
来进行字符串拼接
8. String
对象的是否真的不可变
虽然String
对象将value
设置为final
,并且还通过各种机制保证其成员变量不可改变。但是还是可以通过反射机制的手段改变其值。例如:
//创建字符串"Hello World", 并赋给引用s
String s = "Hello World";
System.out.println("s = " + s); //Hello World
//获取String类中的value字段
Field valueFieldOfString = String.class.getDeclaredField("value");
//改变value属性的访问权限
valueFieldOfString.setAccessible(true);
//获取s对象上的value属性的值
char[] value = (char[]) valueFieldOfString.get(s);
//改变value所引用的数组中的第5个字符
value[5] = '_';
System.out.println("s = " + s); //Hello_World
//s = Hello World
//s = Hello_World
9. 空串与Null
串
空串 ""
是长度为 0 的字符串。可以调用以下代码检查一个字符串是否为空:
if (str.length() == 0)
//或者
if (str.equals(""))
空串是一个 Java 对象, 有自己的串长度(0 ) 和内容(空)
不过,String 变量还可以存 放一个特殊的值, 名为 null, 这表示目前没有任何对象与该变量关联,要检查一个字符串是否为 null
, 要使用以下条件:
if (str == null)
有时要检查一个字符串既不是 null 也不为空串,这种情况下就需要使用以下条件:
if (str != null && str.length() != 0)
首先要检查str 不为 null
。在第 4 章会看到, 如果在一个 null
值上调用方法, 会出现 错误