JavaSE-Adventure (II) : String
CONTENTS
构建String 的方法
String str = "abc";
str = new String("abc");
str = new String(new char[] {'a', 'b', 'c'});
str = new String(new char[] {'a', 'b', 'c'}, 1, 2);
str = new String(new byte[] {97, 98, 99});
str = new String(new byte[] {97, 98, 99}, 0, 2);
str = new String(new StringBuffer("abc"));
str = new String(new StringBuilder("abc"));
String 的特性
- String 类被
final
关键字修饰,意味着String类不能被继承,并且它的成员方法都默认为final方法;字符串一旦创建就不能再修改。 - String类实现了
Serializable
、CharSequence
、Comparable
接口。 - String实例的值是通过字符数组实现字符串存储的。
private final char value[];
- String类中的”+” / “+=” 是Java中仅有的两个重载过的操作符
运算符重载 +/+=
String abc = "abc";
String efg = "efg";
System.out.println(abc + efg);
反编译结果显示这段代码等同于:
StringBuilder builder = new StringBuilder();
builder.append(abc);
builder.append(efg);
System.out.println(builder.toString());
如果在循环中使用对字符串使用 + 运算符,则每次循环都会新创建一个StringBuilder 对象,可通过在循环外构建StringBuilder实例,在循环内部调用append方法进行字符串的拼接。
另一种情况是:当"+"两端均为编译期确定的字符串常量时,编译器会进行相应的优化,直接将两个字符串常量拼接好
// 这段代码没有创建StringBuilder 对象。(字符串的编译期优化)
System.out.println("abc" + "efg");
字符串拼接也是支持直接拼接一个普通的对象,这个时候会调用该对象的toString方法返回一个字符串来进行拼接。toString方法是Object类的方法,若子类没有重写,则会调用Object类的toString方法,该方法默认输出类名+引用地址。
String string = str + object;
这看起来没有什么问题,但是有一个大坑:切记不要在toString方法中直接使用+拼接自身,直接拼接this会调用this的toString方法,从而造成了无限递归。
public String toString() {
return this + "";
}
String 的编码问题 (代码点/代码单元)
在Java中,一般情况下,一个char对象可以存储一个字符,一个char的大小是16位。但随着计算机的发展,字符集也在不断地发展,16位的存储大小已经不够用了,因此拓展了使用两个char,也就是32位来存储一些特殊的字符,如emoij。
一个16位(char) 称为一个 代码单元 ,一个字符称为 代码点 ,一个代码点可能占用一个代码单元,也可能是两个。
在一个字符串中,当我们调用String.length() 方法时,返回的是代码单元的数目, String.charAt() 返回也是对应下标的代码单元。
String cnStr = "你好😊";
System.out.println(cnStr.length()); // 4
这在正常情况下并没有什么问题。而如果允许输入特殊字符时,这就有大问题了。要获得真正的代码点数目,可以调用 String .codePointCount 方法;要获得对应的代码点,可调用 String.codePointAt 方法。以此来兼容拓展的字符集。
System.out.println(cnStr.codePointCount(0, cnStr.length())); // 3
一个字符为一个代码点,一个char称为一个代码单元。一个代码点可能占据一个或两个代码单元。若允许输入特殊字符,则必须使用代码点为单位来操作字符串。
扩展:codepoint
unicode的范围从000000 - 10FFFF,char的范围只能是在\u0000到\uffff,也就是标准的 2 字节形式,通常称作 UCS-2,在Java中,char类型用UTF-16编码描述一个代码单元,但unicode大于0x10000的部分如何用char表示呢,比如一些emoji:😀
String stringWithEmoji = "abc😀";
int length = stringWithEmoji.length();
System.out.println(length); // 5
int codePointCount = stringWithEmoji.codePointCount(0, stringWithEmoji.length());
System.out.println(codePointCount); // 4
// 遍历代码点
for (int index = 0; index < codePointCount; index++) {
int codepoint = stringWithEmoji.codePointAt(index);
char[] chars = Character.toChars(codepoint);
// | a | b | c | 😀
System.out.printf("| %s ", new String(chars));
}
String 的不可变性
概念(什么是不可变)
String对象值是不可变的,一切操作都不会改变String的值,而是通过构造新的字符串来实现字符串操作。
String string = "abcd";
String string1 = string.replace("a", "b");
System.out.println(string); // abcd
System.out.println(string1); // bbcd
string.replace(“a”,“b”)这个方法把"abcd"中的a换成了b。通过输出可以发现,原字符串string并没有发生任何改变,replace方法构造了一个新的字符串(重新创建的String 对象)"bbcd"并赋值给了string1变量。这就是String的不可变性。
设计思想
基于重复使用String的情况比更改String的场景更多的前提下,Java把String设计为不可变,保持数据一致性,使得同个字面量的字符串可以引用同个String对象,重复利用已存在的String对象。
String s1 = "hello";
String s2 = "hello";
s1与s2引用的是同个String对象。如果String可变,那么就无法实现这个设计了。因此,我们可以重复利用我们创建过的String对象,而无需重新创建他。
《Java编程思想》一书中还提到另一个观点:
我们都知道Java 方法参数是通过值传递, 当参数是对象时,传递的是引用地址,下面有这样一个方法:
public String showAllCase(String src) {
return src.toUpperCase();
}
showAllCase
方法把传入的String对象全部变成大写并返回修改后的字符串。而此时,调用者的期望是传入的String对象仅仅作为提供信息的作用,而不希望被修改,那么String不可变的特性则非常符合这一点。有的时候修改原字符串可能不是程序员的本意。所以String不可变的安全性就体现在这里。
限定程序员的操作, 是数据封装, 限定调用方行为很重要的设计思想
另一个例子:
HashSet 的key可以重复?
StringBuilder b1 = new StringBuilder("abc");
StringBuilder b2 = new StringBuilder("abcdef");
HashSet<StringBuilder> set = new HashSet<>();
set.add(b1);
set.add(b2);
StringBuilder b3 = b1;
b3.append("def");
System.out.println(set); // [abcdef, abcdef]
这里的个人的观点是:String 重复使用String的情况(输出或比较)比更改String的场景更多, 因此JVM 会专门维护一个String 常量池(后续介绍),所有相同的字面量String 字符串都会指向在常量池中的同一个对象,因此相同的字面量String 字符串调用equals 总是为true,调用hashCode 也会是相等。正如上个例子,假如我们本意是想要在Set 中存储字符串,没有人会希望使用散列表时有两个相同的元素重复出现,String不可变的特性正是实现这一切的关键,JVM 得以维护一个所有String 对象都能共享的字符串常量池。
再举个例子:用HashSet 存储字符串
HashSet<String> strings = new HashSet<>();
String a = "abc";
String b = "abcd";
strings.add(a);
strings.add(b);
a = a + "d";
System.out.println(strings); // [abc, abcd]
System.out.println(a); // abcd
System.out.println(a == b); // false
String c = "abcd";
System.out.println(b == c); // true
a = a.intern();
System.out.println(a == b); // true 稍后介绍
上面代码引申出来的两个问题:
- a = a + “d”; 为什么不等于 String b = “abcd”;
- 明明修改了a的引用,指向了新的字符串,set 为什么还是[abc, abcd]
第一个问题:
根据先前的介绍,字符串拼接会调用StringBuilder#append,
翻看StringBuilder 的源码,可以发现toString 返回是通过调用String的构造参数,因此返回的是Heap 中新构建的对象,而不是常量池中的"abcd"对象。
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
第二个问题:
String a = "abc";
String b = "abcd";
这两句代码在常量池中创建对象:
假如:
a = 0x01
b = 0x02
把他们加入到set 中:
[0x01, 0x02]
然后下面这句代码将a拼接一个字符串d
a = a + "d";
返回一个新的字符串对象给 实例a, 之前添加到set 中的还是指向常量池中的"abc" 的引用,所以实例a 的引用改变不会影响到set 中的引用。
String 为什么不可变
JDK 源码:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
...
}
从源码可以看出:
- String 类被final 修饰
- 字符串底层存储结构是由 private final 修饰的char 数组
那么为什么String 是不可变的呢?
- 字符串底层存储结构是 由private final修饰的char 数组。这里的final 仅表示value 无法指向新的数组,但不能代表数组内的元素不能修改,private 则表示无法从外部access 这个数组,并且String 没有暴露操作这个value 成员变量的方法。
- String 类又是由final 修饰,这里表示了无法通过继承的方式对重写相关操作底层数组的方法,进一步加强了字符串的安全性。
存储原理(字符串常量池)
由于String对象的不可变特性,在存储上也与普通的对象不一样。我们都知道对象创建在 堆 上,而String对象其实也一样,不一样的是,同时也存储在 常量池 中。处于堆区中的String对象,在GC时有极大可能被回收;而常量池中的String对象则不会轻易被回收,那么则可以重复利用常量池中的String对象。也就是说, 常量池是String对象得以重复利用的根本原因 。
往常量池中创建String对象的方式有两种:
- 显式使用双引号构造字符串对象
- 使用String对象的intern()方法 。
这两个方法不一定会在常量池中创建对象,如果常量池中已存在相同的对象,则会直接返回该对象的引用,重复利用String对象。其他创建String对象的方(new String(), str.subString())都是在堆区中创建String对象。
经典问题:
String s = new String("abc")
,这句代码创建了几个对象?
先说结论:2个
查看String 的构造函数
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
String构造函数需要一个String 对象,"abc"作为参数传递到String 构造函数时,需要先实例化为String 对象,"abc"在常量池中构造了一个对象,new String()方法在堆区中又创建了一个对象,所以一共是两个。
并且有s == "abc" // false
回看先前的例子:
String a = "abc";
String b = "abcd";
a = a + "d";
System.out.println(a); // abcd
// 这里之前解释过是因为StringBuilder 通过new 了一个新的String 对象,因此这时a和b 并不是同一个对象
System.out.println(a == b); // false
a = a.intern();
System.out.println(a == b); // true
a.intern()
表示在常量池中构建"abcd" 的String 对象。
在常量池创建String对象前都会检查是否存在相同的String对象,如果是则会直接返回该对象的引用,而不会重新创建一个对象,因为常量池中已经有"abcd" 的对象,这时a.intern() 会直接返回已经存在的对象,这时和 b 引用的是同一个String 对象。因此 a == b //true
。
底层实现
在HotSpot VM中字符串常量池是通过一个StringTable
类实现的,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例中只有一份,被所有的类共享;字符串常量由一个一个字符组成,放在了StringTable上。要注意的是,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降(因为要一个一个找)。
在JDK 6及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中的,StringTable的长度是固定的1009;在JDK 7版本中,字符串常量池被移到了堆中,StringTable的长度可以通过-XX:StringTableSize=1234
参数指定。至于JDK 7为什么把常量池移动到堆上实现,原因可能是由于方法区的内存空间太小且不方便扩展,而堆的内存空间比较大且扩展方便。
“JDK 6及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中, 在JDK 7时将常量池迁移出了方法区,改在堆区中实现,JDK 8以后取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中"。
JDK 7以后常量池的实现使得在常量池中创建对象可以进行浅拷贝,也就是不需要把整个对象复制过去,而只需要复制对象的引用即可,避免重复创建对象”
JDK 6往常量池中创建对象时,就需要进行深拷贝,也就是把一个对象完整地复制一遍并创建新的对象。
JDK 7之后使用的是浅拷贝,在常量池中创建对象可以进行浅拷贝,得以重复利用堆区中的String对象。这个概念怎么理解呢? 可以看以下例子:
String a = new String(new char[] {'a'});
a.intern();
String b = "a";
System.out.println(a == b); // true
- 在堆中创建了 字符串"a" 对象,内存地址是0x01,并将引用赋给a
- 将a对象拷贝到常量池中(JDK6 的实现是在常量池中创建一个新的String 对象,JDK7 则是将实例a 的引用拷贝到常量池中也就是0x01)
- 实例化 b,在常量池中查找有无字符串 “a”,发现有(实际存储的是0x01,也就是堆中保存的字符串对象)
- 因此结果输出的必然是 true,因为他们都指向的是堆中的对象 (JDK 7+)
再看一个例子:
String str1 = new String("123") + new String("456");
str1.intern();
String str2 = "123456";
System.out.println(str1 == str2); //true
LINE 1:
- 构建StringBuilder 对象
- 构建"123"对象(存储在常量池)
- 构建 new String()对象(“123”,存储在堆中)
- 调用StringBuilder#append(“123”)
- 构建"456"对象(存储在常量池)
- 构建 new String()对象(“456”,存储在堆中)
- 调用StringBuilder#append(“456”)
- 调用StringBuilder#toString(实际是调用new String(char[], int, int))
因此:第一句代码执行后:
在堆中创建了:“123”,“456”,“123456”
在池中创建了:“123”,“456”
LINE 2: 将堆中的 “123456” 对象拷贝到池中,这时拷贝的是堆中对象的引用
LINE 3: 在池中搜索有无 “123456” 的对象,发现有,直接返回,这时返回的还是堆中对象的引用
LINE 4: 因此结果输出的必然是 true,因为他们都指向的是堆中的对象
最后一个例子:
String a = new String(new char[] {'a'});
String a1 = new String("a");
a.intern();
String b = "a";
System.out.println(a == b); // false
有了之前的知识,这里就很好理解了
LINE 1: 在堆中创建字符串 “a” 的实例
LINE 2: 第一次将 “a” 实例化到常量池, 并且堆中创建了一个 “a” 实例
LINE 3: 调用a.intern() 时常量池已经有相同字符串 "a"了,这里返回的是常量池中的引用 (如果 a = a.intern() 结果将为true)
LINE 4: 返回常量池中的 “a”
LINE 5: 这时 实例a 指向的是堆中对象,b 指向的是常量池中的对象
String 编译期优化
编译器在编译期会针对字符串常量叠加得到固定值,字符串常量包括"hello"或用fianl修饰的变量,编译器认为这些常量是不可变的。
String str = "hello" + "java" + 1;
// 编译期编译器会直接编译为"hellojava1"
#2 = String #21 // hellojava1
#21 = Utf8 hellojava1
public static final String A = "ab"; // 常量A
public static final String B = "cd"; // 常量B
String s = A + B; // 将两个常量用+连接对s进行初始化
String t = "abcd";
System.out.println(s == t); // true
A和B都是常量,值是固定的,因此s的值也是固定的,它在类被编译时就已经确定了。
public static final String A; // 常量A
public static final String B; // 常量B
static {
A = "ab";
B = "cd";
}
String s = A + B;
String t = "abcd";
System.out.println(s == t); // false
A和B虽然被定义为常量,但是它们都没有马上被赋值(静态代码块在类被加载时执行)。在运算出s的值之前,他们何时被赋值,以及被赋予什么样的值,都是个变数。因此A和B在被赋值之前,性质类似于一个变量。那么s就不能在编译期被确定,无法在编译期进行优化,只能在运行时被创建了。
这段代码可以看做是:
StringBuilder builder = new StringBuilder();
builder.append(A);
builder.append(B);
System.out.println(builder.toString())
StringBuilder 的toString() 方法实际调用new String(char[], int, int), 不会再常量池中构建
String、StringBuilder、StringBuffer
继承结构
- 都实现了 CharSequence 接口
代表方法:length(), charAt(int)。表示这三个类底层都是使用char[] 维护字符串。
- 3个类都使用final 修饰,无法继承
可变与不可变
AbstractStringBuilder
是 StringBuilder
与 StringBuffer
的公共父类。
AbstractStringBuilder
定义了一些字符串的基本操作,这个抽象类内部维护了一个 char[],外部可以直接修改(非private),因此这两个类的字符串是可变的,这个抽象类也提供方法可以修改字符串,append, replace, delete, insert。
线程安全性
String 中的对象是不可变的,也就可以理解为常量,线程安全。
StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
性能
StringBuilder > StringBuffer > String
toString() 方法
String
public String toString() {
return this;
}
StringBuffer
private transient char[] toStringCache;
@Override
public synchronized String toString() {
if (toStringCache == null) {
toStringCache = Arrays.copyOfRange(value, 0, count);
}
return new String(toStringCache, true);
}
StringBuilder
char[] value;
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
参考:
Java中的代码点和代码单元: https://blog.csdn.net/guoqingshuang/article/details/79079816
String 的编译期优化: https://www.kancloud.cn/luoyoub/java-note/1890310
如何理解 String 类型值的不可变: https://www.zhihu.com/question/20618891/answer/114125846
字符串常量池深入解析: https://blog.csdn.net/weixin_40304387/article/details/81071816
一次性搞清楚unicode、codepoint、代码点、UTF: https://blog.csdn.net/qlql489/article/details/82780716
Unicode、UTF-8、UTF-16、MUTF-8、Java编码扫盲: https://segmentfault.com/a/1190000023345905
Java 深究字符串String类(1)之运算符"+"重载: https://blog.csdn.net/Dextrad_ihacker/article/details/53055709
老是遇到乱码问题:它是如何产生的,又如何解决呢?:https://www.cnblogs.com/jay-huaxiao/p/12148622.html