字符串就是一串字符序列。在说字符串前,首先我们要了解一下字符。

由于计算机只能处理数字,如果处理文本,就必须将文本转换为数字才行。所以人们将世界上的所有的字符进行编码,形成编码表,这样每个字符都有唯一对应的代码值,这个代码值或者编码叫做码点。所有的码点组成的集合叫做编码空间。根据编码的方式,有美国的 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 中。

映射转换的公式如下:

  1. int highSurrogate = (c-0x10000) / 0x400 + 0xD800; // 高位的计算方式
  2. 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 都会去先检查一下字符串常量池,如果字符串常量池里面已经有了该字符串,那么就直接返回常量池中的实例引用。如果字符串常量池里没有该字符串,这时候才会将该字符串实例化并放入字符串常量池中。这样就在很大程度上降低了对象的创建、分配的次数,提升了性能。

看如下的代码:

  1. String s0 = "string";
  2. String s1 = "string";
  3. 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 class String, and is derived from a CONSTANT_String_info structure (§4.4.3) in the binary representation of a class or interface. The CONSTANT_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 关键字创建。那么它们的区别呢?

  1. String s0 = "hello,world";
  2. String s1 = "hello,world";
  3. String s2 = new String("hello,world");
  4. System.out.println(s0 == s1); // true
  5. System.out.println(s0 == s2); // false

通过在上面字符串常量池的描述,我们可以很容判断出下面的第一个输出的结果。我们使用双引号创建的字符串,会把实例引用存放在字符串常量池里,如果字符串常量池存在同样的字符串,那么就不会再重新创建,直接复用。所以 s0s1 其实引用的是同一个对象,返回 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() 方法就可以达到类似的目的。

  1. String s0 = "hello,world";
  2. String s2 = new String("hello,world");
  3. 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++ 实现的 StringTableintern() 的方法实现。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中一般会遇到下面几种方式:

  1. 字面值直接拼接
  2. 字符串变量和字面值拼接
  3. 字符串变量之间拼接
  4. 字符串变量和字面值混合拼接
  5. 使用 concat(String) 函数拼接

针对上面的拼接方式,Java有不同的实现。

字面值直接拼接

所谓字符串字面值就是指字符串本身,即使用双引号括起来的的部分。

  1. String ab = "a1" + "b1";

上面的代码就是字面值直接拼接。在Java中使用字面值拼接时会有一定的优化;会将上面的 a1b1 看成一个字符串。也就是说在Java编译之后,实际上的代码应该是这样的

  1. String ab = "a1b1";

为了证实这个观点,我们可以通过 javap 查看一下:

  1. 、、、省略一部分、、、
  2. Constant pool:
  3. #1 = Methodref #4.#20 // java/lang/Object."<init>":()V
  4. #2 = String #21 // a1b1
  5. #3 = Class #22 // vip/liteng/baiscs/StringSamples
  6. #4 = Class #23 // java/lang/Object
  7. 、、、省略一部分、、、
  8. {
  9. 、、、省略一部分、、、
  10. public static void main(java.lang.String[]);
  11. descriptor: ([Ljava/lang/String;)V
  12. flags: ACC_PUBLIC, ACC_STATIC
  13. Code:
  14. stack=1, locals=2, args_size=1
  15. 0: ldc #2 // String a1b1
  16. 2: astore_1
  17. 3: return
  18. 、、、省略一部分、、、
  19. }
  20. SourceFile: "StringSamples.java"

在常量池中,我们可以看到 "a1" + "b1" 直接编译成了 a1b1

  1. String ab = "a1" + "b1";
  2. String ab1 = "a1b1";
  3. System.out.println(ab == ab1); // true

那么我可以很容易发现上面的代码是返回 true 的。在编译后 ,"a1" + "b1"a1b1 是没有区别的。

字符串变量和字面值拼接

要弄明白字符串变量和字面值是如何拼接的,最好的办法是查看其反编译结果来分析。

  1. String a = "a1";
  2. String ab = a +"b1";
  3. System.out.println(ab);

上面的代码 javac 之后,然后使用 javap -c 对字节码进行反编译

  1. Compiled from "StringSamples.java"
  2. public class vip.liteng.baiscs.StringSamples {
  3. public vip.liteng.baiscs.StringSamples();
  4. Code:
  5. 0: aload_0
  6. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  7. 4: return
  8. public static void main(java.lang.String[]);
  9. Code:
  10. 0: ldc #2 // String a1
  11. 2: astore_1
  12. 3: new #3 // class java/lang/StringBuilder
  13. 6: dup
  14. 7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
  15. 10: aload_1
  16. 11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  17. 14: ldc #6 // String b1
  18. 16: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  19. 19: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  20. 22: astore_2
  21. 23: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
  22. 26: aload_2
  23. 27: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  24. 30: return
  25. }

我们看上面的反编译结果,在执行 a + "b1" 时,使用了 StringBuilder 进行的拼接。将上面反编译后的结果转变成Java代码应该是这样的

  1. String a = "a1";
  2. String ab = (new StringBuilder()).append(a).append("b1").toString();
  3. System.out.println(ab);

字符串变量之间拼接

字符串变脸之间拼接,将字节码反编译,看指令会比较直白。我们将下面的代码编译后在反编译

  1. String a = "a1";
  2. String b = "b1";
  3. String ab = a + b;
  4. System.out.println(ab);

反编译的结果如下:

  1. Compiled from "StringSamples.java"
  2. public class vip.liteng.baiscs.StringSamples {
  3. public vip.liteng.baiscs.StringSamples();
  4. Code:
  5. 0: aload_0
  6. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  7. 4: return
  8. public static void main(java.lang.String[]);
  9. Code:
  10. 0: ldc #2 // String a1
  11. 2: astore_1
  12. 3: ldc #3 // String b1
  13. 5: astore_2
  14. 6: new #4 // class java/lang/StringBuilder
  15. 9: dup
  16. 10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
  17. 13: aload_1
  18. 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  19. 17: aload_2
  20. 18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  21. 21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  22. 24: astore_3
  23. 25: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
  24. 28: aload_3
  25. 29: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  26. 32: return
  27. }

同样的,依然使用的 StringBuilder 进行拼接的。

字符串变量和字面值混合拼接

这里的混合拼接是指变量和字面值交替拼接,比如下面的代码:

  1. String a = "a1";
  2. String ab = a + "b1" + "c1" + a + a;
  3. System.out.println(ab);

我们直接上反编译后的结果:

  1. Compiled from "StringSamples.java"
  2. public class vip.liteng.baiscs.StringSamples {
  3. public vip.liteng.baiscs.StringSamples();
  4. Code:
  5. 0: aload_0
  6. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  7. 4: return
  8. public static void main(java.lang.String[]);
  9. Code:
  10. 0: ldc #2 // String a1
  11. 2: astore_1
  12. 3: new #3 // class java/lang/StringBuilder
  13. 6: dup
  14. 7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
  15. 10: aload_1
  16. 11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  17. 14: ldc #6 // String b1c1
  18. 16: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  19. 19: aload_1
  20. 20: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  21. 23: aload_1
  22. 24: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  23. 27: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  24. 30: astore_2
  25. 31: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
  26. 34: aload_2
  27. 35: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  28. 38: return
  29. }

在 #6 中我们可以看到 b1 + c1 编译之后会变成 "b1c1" ,而其它的拼接都是使用 StringBuilder

使用 concat(String) 函数拼接

concat 是 Java 提供的一个函数,它直接操作了 char 数组 。它的源码:

  1. public String concat(String str) {
  2. int otherLen = str.length();
  3. if (otherLen == 0) {
  4. return this;
  5. }
  6. int len = value.length;
  7. char buf[] = Arrays.copyOf(value, len + otherLen);
  8. str.getChars(buf, len);
  9. return new String(buf, true);
  10. }
  11. //Arrays.copyOf(char[],int)
  12. public static char[] copyOf(char[] original, int newLength) {
  13. char[] copy = new char[newLength];
  14. System.arraycopy(original, 0, copy, 0,
  15. Math.min(original.length, newLength));
  16. return copy;
  17. }
  18. // str.getChars(char[],int)
  19. void getChars(char dst[], int dstBegin) {
  20. System.arraycopy(value, 0, dst, dstBegin, value.length);
  21. }7

可以看到 concat 直接通过 System.arraycopy() 对 char[] 进行操作,通过新建相当于两个字符串长度的char[],然后在将其复制进去新的数组。

小总

字符串拼接时 jvm 会进行一些优化。

如果是字面值直接拼接,会将其编译成一个字符串

如果是字符串变量和字面值拼接,会使用 StringBuilder 进行拼接,StringBuilder 创建一次,然后一直调用append() 进行拼接。

如果是字符串变量之间拼接,也会使用 StringBuilder 进行拼接。

如果是字符串变量和字面值混合拼接,当字面量相邻时,会直接编译成一个字符串,其它时候会使用 StringBuilder 拼接。

如果使用 concat(String) 函数拼接,则是直接操作 char[] ,通过创建相当于两个字符串长度的char[],然后在将其复制进去新的数组。

总结

在 Java 中 String 是不可变的。其被声明成 final 类,且所有的属性都是 final 的。其拼接、裁剪等操作都会产生了新的String 对象。

在Java中使用Unicode编码,并提供了字符串常量池对字符进行优化。字符串拼接时会根据不同的情况进项分别优化。

String 被设计成不可变的,我们无法对它的内部数据进行修改,原生保证了基础的线程安全,在进行拷贝时更不需要进行额外复制数据。只有字符串不可变的情况下,我们的才能实现字符串常量池。