JavaSE-Adventure(II) : String 字符串

JavaSE-Adventure (II) : String

构建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 的特性

  1. String 类被 final 关键字修饰,意味着String类不能被继承,并且它的成员方法都默认为final方法;字符串一旦创建就不能再修改。
  2. String类实现了SerializableCharSequenceComparable接口。
  3. String实例的值是通过字符数组实现字符串存储的。 private final char value[];
  4. 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 稍后介绍

上面代码引申出来的两个问题:

  1. a = a + “d”; 为什么不等于 String b = “abcd”;
  2. 明明修改了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[];
	...
}

从源码可以看出:

  1. String 类被final 修饰
  2. 字符串底层存储结构是由 private final 修饰的char 数组

那么为什么String 是不可变的呢?

  1. 字符串底层存储结构是 由private final修饰的char 数组。这里的final 仅表示value 无法指向新的数组,但不能代表数组内的元素不能修改,private 则表示无法从外部access 这个数组,并且String 没有暴露操作这个value 成员变量的方法。
  2. String 类又是由final 修饰,这里表示了无法通过继承的方式对重写相关操作底层数组的方法,进一步加强了字符串的安全性。

存储原理(字符串常量池)

由于String对象的不可变特性,在存储上也与普通的对象不一样。我们都知道对象创建在 堆 上,而String对象其实也一样,不一样的是,同时也存储在 常量池 中。处于堆区中的String对象,在GC时有极大可能被回收;而常量池中的String对象则不会轻易被回收,那么则可以重复利用常量池中的String对象。也就是说, 常量池是String对象得以重复利用的根本原因 。

往常量池中创建String对象的方式有两种:

  1. 显式使用双引号构造字符串对象
  2. 使用String对象的intern()方法 。

这两个方法不一定会在常量池中创建对象,如果常量池中已存在相同的对象,则会直接返回该对象的引用,重复利用String对象。其他创建String对象的方(new String(), str.subString())都是在堆区中创建String对象。

经典问题:

  1. 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
  1. 在堆中创建了 字符串"a" 对象,内存地址是0x01,并将引用赋给a
  2. 将a对象拷贝到常量池中(JDK6 的实现是在常量池中创建一个新的String 对象,JDK7 则是将实例a 的引用拷贝到常量池中也就是0x01)
  3. 实例化 b,在常量池中查找有无字符串 “a”,发现有(实际存储的是0x01,也就是堆中保存的字符串对象)
  4. 因此结果输出的必然是 true,因为他们都指向的是堆中的对象 (JDK 7+)

再看一个例子:

String str1 = new String("123") + new String("456");
str1.intern();
String str2 = "123456";
System.out.println(str1 == str2); //true

LINE 1:

  1. 构建StringBuilder 对象
  2. 构建"123"对象(存储在常量池)
  3. 构建 new String()对象(“123”,存储在堆中)
  4. 调用StringBuilder#append(“123”)
  5. 构建"456"对象(存储在常量池)
  6. 构建 new String()对象(“456”,存储在堆中)
  7. 调用StringBuilder#append(“456”)
  8. 调用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

继承结构

在这里插入图片描述

  1. 都实现了 CharSequence 接口

    代表方法:length(), charAt(int)。表示这三个类底层都是使用char[] 维护字符串。

  2. 3个类都使用final 修饰,无法继承
可变与不可变

AbstractStringBuilderStringBuilderStringBuffer 的公共父类。

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值