字符串就是一串字符序列。在说字符串前,首先我们要了解一下字符。
由于计算机只能处理数字,如果处理文本,就必须将文本转换为数字才行。所以人们将世界上的所有的字符进行编码,形成编码表,这样每个字符都有唯一对应的代码值,这个代码值或者编码叫做码点。所有的码点组成的集合叫做编码空间。根据编码的方式,有美国的 ASCII、 西欧语言中的ISO 8859-1,俄罗斯的 KOI-8 ,中国的 GB-18030 和 BIG-5 等 。但是由于方式太多,同一个编码在不同的编码表中,可能代表不同的字符,所有后来就有了 Unicode 编码。
Unicode 是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案。 起初使用16 位来表示 Unicode 的字符的编码。这样可以表示 65536 个字符(0x0000 ~ 0xFFFF),这就是 UTF-16。随着时间的增长,字符也会增长,过了一段时间,65536 的编码就不够用了。在 Java 中就是使用 Unicode 编码的方式来表示字符的。16位的编码不够了,Java是怎么解决的呢?解释这个问题之前,我们需要了解一下 Unicode 相关的几个概念。
码点的表示
在Unicode中,码点就是在 Unicode 编码表中的每个字符的编码值。在Unicode标准中,码点采用 16 进制表示,并加上 U+
的前缀。比如大写字符 ‘A’ ,在Unicode编码表中的编码值为 65 ,转换成16 进制为 41 , 那么它的码点就是 U+0041
。
由于后来65536个编码值不够用了,Unicode 为了扩大字符的表示范围,将码点分为了17个代码平面,也可以叫做代码等级。最初的 U+0000 ~ U+FFFF 这部分码点被分到了第一平面,这个平面叫做 基本的多语言平面(Basic Multilingual Plane,BMP),简称基本平面。其余的16个平面叫做辅助平面,码点从 U+10000 ~ U+10FFFF ,里面包括一些辅助字符。这样 UTF-16 的编码长度要么是 2 个字节 16 位,落到 U+0000 ~ U+FFFF 之间,要么 4 个字节 32 位 落到 U+10000 ~ U+10FFFF 之间。
在基本平面中每个字符用 16 位表示,通常称为代码单元,而在辅助平面内,辅助字符采用两个连续的代码单元进行编码。
在基本平面内从 U+D800 ~ U+DFFF 是一个空段,这些码点不对应任何字符。这个空段可以用来映射辅助平面的字符。辅助平面的字符有 2^20
个,可以表示20个二进制位,UTF-16将这个20个二进制位分成两半,前10位映射在 U+D800 ~ U+DBFF 之间,称为高位(H)。后10位 映射在 U+DC00 ~ U+DFFF 之间,称为低位(L) 。这样就把一个辅助平面的字符,拆成两个基本平面的字符表示。
当解读字符时,遇到两个字节落到 U+D800 ~ U+DBFF 之间说明这个字符是辅助平面中映射而来,说明这个这个字符是4个字节,还需要解读接下来的两个字节,接下来的两个字节一定落在 U+DC00 ~ U+DFFF 中。这四个字节一起解读,就可以得到这个字符。
例如:😁 这个笑脸表情,也是在 Unicode 编码表中。它的码点为 0x1f601 ,它的码点范围已经超出了 U+0000 ~ U+FFFF 的范围。因此它需要通过辅助平面来表示,需要用到4个字节。
首先用 0x1f601 - 0x10000 = 0xf601,转换为 2 进制 ,0000111101 1000000001
, 不满20位的地方补零。前10位映射到 U+D800 ~ U+DBFF 中 ,后10位映射到 U+DC00 ~ U+DBFF 中。
映射转换的公式如下:
int highSurrogate = (c-0x10000) / 0x400 + 0xD800; // 高位的计算方式
int lowSurrogate = (c - 0x10000) % 0x400 + 0xDC00;// 低位的计算方式
依据上面的公式,我们可以计算得到高低位编码:
高位:H = ( 0xf601 - 0x10000 ) / 0x400 + 0xD800 = 0xd83d
地位:L = ( 0xf601 - 0x10000 ) % 0x400 + 0xDC00 = 0xde01
这样 😁 这个符号就使用
0xd83d
0xde01
表示。
小结
字符在Java使用Unicode编码,初期每个字符使用两个字节,每个字符的编码值叫做码点。
所有的码点的集合叫做编码空间。
随着时代的发展,16位不够表示所有的字符,为更好的扩展,Unicode 将码点分为了17个代码平面。最初的码点叫做基本平面;其它平面叫做辅助平面。
基本平面中的每个字符使用16位表示,这16通常称为代码单元。
在基本平面内会有一个空段,用来映射辅助平面的字符。
char 和 Character
char
在 Java 中,char 是基本数据类型之一, 用来表示单个字符,并且使用单引号‘’
包括起来。
char 类型是值使用16位无符号整数表示的,指向基本平面的Unicode 码点,以 UTF-16 编码,默认值为 Unicode 的 null 码点,即 \u0000
。
char 占用2个字节,但由于现在Unicode编码表的扩大,有些字符无法使用 char 来表示了。当我们在 IDEA 中使用 char 来声明上面的😁 时,就会报错,因为它不再是占用两个字节,而是占用四个字节了,所以这个时候,就需要使用 String 来表示了。在开发过程中,除非特别明确的之后字符能用两个字节表示,否则不建议使用 char 类型来表示字符。
char 类型的值可以表示为16进制值。 其范围 \u0000 ~ \uFFFF ,正好都在Unicode基本平面中。其中的 \
是用来转义的。后面 跟上 u
就表明当前表示的 Unicode 编码。比如: \u0041
就表示的是大写字母 A
。
除了 \u 转义序列外,其它的特殊转义序列如下:
转义序列 | 名称 | Unicode值 |
---|---|---|
\b | 退格 | \u0008 |
\t | 制表符 | \u0009 |
\n | 换行 | \u000a |
\r | 回车 | \u000d |
\“ | 双引号 | \u0022 |
\‘ | 单引号 | \u0027 |
\\ | 反斜杠 | \u005c |
Character
char 属于Java的基本数据类型,仅仅能表示一个字符,并不能对其进行转换成其它的数据类型,也不能查看它的码点等等。所以 Java 提供了char 的包装类 Character。
Character 提供了很多静态方法,可以对字符做各种操作。还有对一些字符的缓存。
结合上面的 Unicode 相关的内容,它提供了一些关于码点的相关操作:
Character.codePointAt() : 获取码点。
Character.charCount() : 根据码点获取当前的字符需要几个代码单元表示。返回2表示需要辅助字符;返回1表示不需要。
Character.codePointCount() : 当前的字符数组/串内有多少码点。
Character.isSupplementaryCodePoint() :根据当前的码点判断,字符是否需要辅助字符表示。
Character.isHighSurrogate() : 当前码点是否在高位。
Character.isLowSurrogate() : 当前码点是否在低位。
其它的操作:
Character.digit(char,int) : 将char根据进制转换成 int 。
Character.isDigit(char) : 当前char 是否是数字。
Character.isDefined(char) : 判断Unicode内是否包含当前的 char。
Character.isLetter(char) : 当前 char 是否是字母
Character.isLetterOrDigit(char) 当前 char 是否是字母或者数字。
还有其他的大小写相关,类型相关,空格等等的操作。
小结
char 表示一个字符,使用单引号包括,char 使用一个无符号的整形表示,指向 Unicode 的编码单元,每个字符指向一个码点。
char 占两个字节,当已有字符的码点超出了两个字节的范围,会使用辅助字符表示。在Java中超出两个字节的字符使用单引号会报错。
在开发过程中,除非特别明确的之后字符能用两个字节表示,否则不建议使用 char 类型来表示字符。
Character 是对 char 封装,是其包装类,其中包含了一些字符相关的属性和操作。
String
在 Java 中没有提供内置的字符串类型,而是由 Java 类库提供了一个预定义的 String 类。
String 表示的是一系列的 Unicode 字符。在Java中所有的字符串都是String类的实例。
String 提供了字符串比较,查找,截取,替换,大小写转换,获取码点值,获取长度等一些列的方法。
String 是一个 final 的类,这就意味着它不能被继承。String 一旦定义就不能改变。
在 Java 8 之前它使用 char 数组实现字符串的存储,从 Java 9 开始将 char[] 数组改为了 byte[] 数组。但是大家现在普遍都在用 Java8 ,所以不管本文还是以 Java 8 为准。
字符串常量池
字符串的在内存中分配跟其它的对象是一样的,都要付出时间和空间上的开销。由于字符串使用频繁,会消耗高昂的时间和空间。JVM 为了提供性能和减少内存开销,使用了 字符串常量池 对字符串进行了优化。每当创建字符串时,JVM 都会去先检查一下字符串常量池,如果字符串常量池里面已经有了该字符串,那么就直接返回常量池中的实例引用。如果字符串常量池里没有该字符串,这时候才会将该字符串实例化并放入字符串常量池中。这样就在很大程度上降低了对象的创建、分配的次数,提升了性能。
看如下的代码:
String s0 = "string";
String s1 = "string";
System.out.println(s0 == s1); // 返回 true
第一次定义 “string” 时,字符创常量池里没有该实例的引用,所以会将其放入常量池中;第二次定义时,会首先去字符串常量池里查找 “string”的引用 ,显然已经有了,所以直接将其返回该实例,所以s0 和 s1 其实指向的是同意一个对象的实例。
另一方面,Java 在定义String串时就将其定义成了不可变的,所以在常量池中一定不存在两个相同的字符串。
一个问题
字符串常量池里到底存的是什么?是String对象的实例还是引用?
根据 Java 虚拟机规范 5.1 The Run-Time Constant Pool 中的描述:
A string literal is a
reference
to an instance of classString
, and is derived from aCONSTANT_String_info
structure (§4.4.3) in the binary representation of a class or interface. TheCONSTANT_String_info
structure gives the sequence of Unicode code points constituting the string literal.
即我们使用双引号括起来的字符串是 String 类实例的一个引用。在二进制的表示里,使用 CONSTANT_String_info
结构来体现。CONSTANT_String_info
给出了字符串字面量的 Unicode 码点序列。
另外根据 Java 虚拟机规范:2.5.3 Heap 中对堆的描述:
The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.
JVM 的堆是在所有 JVM 的线程之间共享的。堆是在运行时为所有类的实例和数组分配内存空间的数据区域。
这样我们可以确定 Java 中的对象实例都是在堆中分配的内存空间的。结合上面的 字符串字面量都是String类的一个实例
来看。类的实例存在堆中,为了从常量池中检索已有的字符串,那么常量池中必然存的是字符串的引用。
另外在 Hotspot 虚拟机中,实现字符串常量池功能的是一个叫做 StringTable
的 C++ 类。它是一个哈希表,里面保存的就是字符串字面量的引用,具体的实例对象是在堆中存放的。
字符串两种创建方式的区别
创建字符串,不仅可以使用双引号,还可以通过 new 关键字创建。那么它们的区别呢?
String s0 = "hello,world";
String s1 = "hello,world";
String s2 = new String("hello,world");
System.out.println(s0 == s1); // true
System.out.println(s0 == s2); // false
通过在上面字符串常量池的描述,我们可以很容判断出下面的第一个输出的结果。我们使用双引号创建的字符串,会把实例引用存放在字符串常量池里,如果字符串常量池存在同样的字符串,那么就不会再重新创建,直接复用。所以 s0
和 s1
其实引用的是同一个对象,返回 true 。
然后我们通过 new 关键字创建的字符串就不一样了。首先在Java中不管什么时候执行 new 的时候,一定会生成一个对象的实例。这里的 s2
是 String 的类一个实例的引用。然后其中的 “hello,world” 是一个字符串字面量,它也会生成一个String实例,这个实例的引用会存在字符串常量池中。这样通过 new 关键字创建的字符串会声称两个String类的实例:一个是通过字面量 “hello,world” 生成的实例,一个是通过new 生成的内容与 “hello,world” 相同的String的实例。上面我们比较的 s0 == s2
比较的 “hello,world” 字面量的实例 和 新生成的 String 类的实例,它们两个不是同一个,所以会返回false。
String.intern()
上面的代码中,如何才能使 s0 == s2
返回 true 呢?在比较的时候使用 intern()
方法就可以达到类似的目的。
String s0 = "hello,world";
String s2 = new String("hello,world");
System.out.println(s0 == s2.intern()); // true
intern()
方法是一个native方法。它的注释是这样的
Returns a canonical representation for the string object.
A pool of strings, initially empty, is maintained privately by the class String.
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.
All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.
简单来说,intern()
方法可以把String字符串的对象实例的引用放入到字符串常量池,并返回其引用。如果字符串的引用已经在字符串常量池中,就直接返回其引用。
追踪 openjdk8 中的源码可以发现,Hotspot 使用 jni 调用 C++ 实现的 StringTable
的 intern()
的方法实现。StringTable
是一个 HashTable
,在64位的机器中默认大小为 60013 ,如果放入到其中的 String
非常多,会造成 Hash碰撞严重,导致链表过长,从而导致调用 intern()
性能下降。所以在使用时要慎重。
小结
String 表示的是一系列的 Unicode 字符。在Java中所有的字符串都是String类的实例。
为避免重复创建字符串,有效降低内存消耗和创建对象带来的开销,JVM 提供了字符串常量池,来缓存字符串。
每当创建字符串时,JVM 都会去先检查一下字符串常量池,如果字符串常量池里面已经有了该字符串,那么就直接返回常量池中的实例引用。
在字符串常量池中,保存的是字符串字面量的引用,其实例在对中创建。
String s = new String("string")
创建字符串的方式通常会创建两个实例,一个是字符串字面量 "string"
的实例,一个是通过 new 创建的内容为 "string"
的实例。
intern()
方法可以把 String 字符串的对象实例的引用放入到字符串常量池,并返回其引用。一般可以在 new 创建字符串是使用。不过要注意,字符串常量池的大小,如果超出,会影响性能。
字符串拼接
字符串拼接在Java中一般会遇到下面几种方式:
- 字面值直接拼接
- 字符串变量和字面值拼接
- 字符串变量之间拼接
- 字符串变量和字面值混合拼接
- 使用 concat(String) 函数拼接
针对上面的拼接方式,Java有不同的实现。
字面值直接拼接
所谓字符串字面值就是指字符串本身,即使用双引号括起来的的部分。
String ab = "a1" + "b1";
上面的代码就是字面值直接拼接。在Java中使用字面值拼接时会有一定的优化;会将上面的 a1
和 b1
看成一个字符串。也就是说在Java编译之后,实际上的代码应该是这样的
String ab = "a1b1";
为了证实这个观点,我们可以通过 javap 查看一下:
、、、省略一部分、、、
Constant pool:
#1 = Methodref #4.#20 // java/lang/Object."<init>":()V
#2 = String #21 // a1b1
#3 = Class #22 // vip/liteng/baiscs/StringSamples
#4 = Class #23 // java/lang/Object
、、、省略一部分、、、
{
、、、省略一部分、、、
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: ldc #2 // String a1b1
2: astore_1
3: return
、、、省略一部分、、、
}
SourceFile: "StringSamples.java"
在常量池中,我们可以看到 "a1" + "b1"
直接编译成了 a1b1
。
String ab = "a1" + "b1";
String ab1 = "a1b1";
System.out.println(ab == ab1); // true
那么我可以很容易发现上面的代码是返回 true 的。在编译后 ,"a1" + "b1"
和 a1b1
是没有区别的。
字符串变量和字面值拼接
要弄明白字符串变量和字面值是如何拼接的,最好的办法是查看其反编译结果来分析。
String a = "a1";
String ab = a +"b1";
System.out.println(ab);
上面的代码 javac 之后,然后使用 javap -c 对字节码进行反编译
Compiled from "StringSamples.java"
public class vip.liteng.baiscs.StringSamples {
public vip.liteng.baiscs.StringSamples();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String a1
2: astore_1
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: aload_1
11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: ldc #6 // String b1
16: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
22: astore_2
23: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
26: aload_2
27: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: return
}
我们看上面的反编译结果,在执行 a + "b1"
时,使用了 StringBuilder
进行的拼接。将上面反编译后的结果转变成Java代码应该是这样的
String a = "a1";
String ab = (new StringBuilder()).append(a).append("b1").toString();
System.out.println(ab);
字符串变量之间拼接
字符串变脸之间拼接,将字节码反编译,看指令会比较直白。我们将下面的代码编译后在反编译
String a = "a1";
String b = "b1";
String ab = a + b;
System.out.println(ab);
反编译的结果如下:
Compiled from "StringSamples.java"
public class vip.liteng.baiscs.StringSamples {
public vip.liteng.baiscs.StringSamples();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String a1
2: astore_1
3: ldc #3 // String b1
5: astore_2
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: aload_1
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: aload_2
18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: astore_3
25: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
28: aload_3
29: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
32: return
}
同样的,依然使用的 StringBuilder
进行拼接的。
字符串变量和字面值混合拼接
这里的混合拼接是指变量和字面值交替拼接,比如下面的代码:
String a = "a1";
String ab = a + "b1" + "c1" + a + a;
System.out.println(ab);
我们直接上反编译后的结果:
Compiled from "StringSamples.java"
public class vip.liteng.baiscs.StringSamples {
public vip.liteng.baiscs.StringSamples();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String a1
2: astore_1
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: aload_1
11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: ldc #6 // String b1c1
16: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: aload_1
20: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: aload_1
24: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
27: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
30: astore_2
31: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
34: aload_2
35: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
38: return
}
在 #6 中我们可以看到 b1 + c1
编译之后会变成 "b1c1"
,而其它的拼接都是使用 StringBuilder
。
使用 concat(String) 函数拼接
concat 是 Java 提供的一个函数,它直接操作了 char 数组 。它的源码:
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
//Arrays.copyOf(char[],int)
public static char[] copyOf(char[] original, int newLength) {
char[] copy = new char[newLength];
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
// str.getChars(char[],int)
void getChars(char dst[], int dstBegin) {
System.arraycopy(value, 0, dst, dstBegin, value.length);
}7
可以看到 concat 直接通过 System.arraycopy()
对 char[] 进行操作,通过新建相当于两个字符串长度的char[],然后在将其复制进去新的数组。
小总
字符串拼接时 jvm 会进行一些优化。
如果是字面值直接拼接,会将其编译成一个字符串
如果是字符串变量和字面值拼接,会使用 StringBuilder
进行拼接,StringBuilder
创建一次,然后一直调用append() 进行拼接。
如果是字符串变量之间拼接,也会使用 StringBuilder
进行拼接。
如果是字符串变量和字面值混合拼接,当字面量相邻时,会直接编译成一个字符串,其它时候会使用 StringBuilder
拼接。
如果使用 concat(String) 函数拼接,则是直接操作 char[] ,通过创建相当于两个字符串长度的char[],然后在将其复制进去新的数组。
总结
在 Java 中 String 是不可变的。其被声明成 final 类,且所有的属性都是 final 的。其拼接、裁剪等操作都会产生了新的String 对象。
在Java中使用Unicode编码,并提供了字符串常量池对字符进行优化。字符串拼接时会根据不同的情况进项分别优化。
String 被设计成不可变的,我们无法对它的内部数据进行修改,原生保证了基础的线程安全,在进行拷贝时更不需要进行额外复制数据。只有字符串不可变的情况下,我们的才能实现字符串常量池。