String 是 Java 语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是 Immutable 类的典型实现,原生的保证了基础线程安全。也由于它的不可变性,类似拼接、裁剪字符串等动作都会产生新的 String 对象。由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响。

  1. public final class String
  2. implements java.io.Serializable, Comparable<String>, CharSequence {
  3. private final char value[];
  4. private int hash;
  5. ......
  6. }

从源码中可以看到,每个 String 对象维护着一个 char 类型的数组,它是 String 的底层数组,用于存储字符串的内容。另外在 String 内部还维护了一个 hash 值,因为 String 类不可变,所以一旦对象被创建,该 hash 值也无法改变。由于字符串 hashcode 使用频繁,所以缓存 hash 值更加高效。

字符编码

在早期计算机系统中,为了给字符编码,美国国家标准学会制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从 0 到 127,最高位始终为 0,称为 ASCII 编码。例如,字符 ‘A’ 的编码是 0x41,字符 ‘1’ 的编码是 0x31。

但 128 个字符显然是不够用的,于是 ISO 组织在 ASCII 码基础上又制定了 ISO-8859-1,其涵盖了大多数西欧语言字符,但 ISO-8859-1 仍然是单字节编码,它总共能表示 256 个字符。

如果要把汉字也纳入计算机编码,显然一个字节是不够的。GB2312 编码 使用两个字节表示一个汉字,其中第一个字节的最高位始终为 1,以便和 ASCII 编码区分开。例如,汉字 ‘中’ 的 GB2312 编码是 0xd6d0。后面又出现的 GBK 编码 是为了扩展 GB2312,并加入更多的汉字,它的编码和 GB2312 是兼容的。

在全球除了汉字的 GB2312 编码外,类似的,日文有 Shift_JIS 编码,韩文有 EUC-KR 编码,这些编码因为标准不统一,如果同时使用就会产生冲突。后面为了统一全球所有语言的编码发布了 Unicode 编码 把世界上的主要语言都纳入到了同一个编码中。而 UTF-16 则具体定义了 Unicode 字符在计算机中的存取方法,UTF-16 使用固定的两个字节来表示 Unicode 的转化格式,表示字符非常方便,也大大简化了字符串操作。

UTF-16 统一采用两个字节来表示一个字符,虽然使用简单,但有很大一部分字符用一个字节就可以表示,如果使用 UTF-16 的话就会很浪费空间,所以出现了 UTF-8 编码,它是一种变长编码,用来把固定长度的 Unicode 编码变成 1~4 字节的变长编码。UTF-8 编码的另一个好处是容错能力强,传输过程中某些字符出错不会影响后续字符,因为 UTF-8 编码依靠高位字节来确定一个字符使用几个字节,因此它经常用来作为传输编码。

在 Java 中,char 类型实际上就是两个字节的 Unicode 编码。如果我们要手动把字符串转换成其他编码,可以这样做,牢记 Java 中的 String 和 char 在内存中总是以 Unicode 编码来表示的。

  1. byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换,不推荐
  2. byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换
  3. byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换
  4. byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换

使用

1. 构造函数

  1. public String()
  2. public String(String original)
  3. public String(char value[])
  4. /**
  5. * offset为字节数组初始索引,count为需要复制的字节数量
  6. */
  7. public String(char value[], int offset, int count)
  8. /**
  9. * 通过使用指定的字符集解码指定的字节数组,offset为字节数组初始索引,count为需要解码复制的字节数量
  10. */
  11. public String(byte bytes[], int offset, int length, String charsetName)
  12. public String(byte bytes[], int offset, int length, Charset charset)
  13. public String(byte bytes[], String charsetName)
  14. public String(byte bytes[], Charset charset)
  15. /**
  16. * 通过使用平台默认的字符集解码指定的字节数组
  17. */
  18. public String(byte bytes[], int offset, int length)
  19. public String(byte bytes[])

2. 常用方法

  1. public int length()
  2. public boolean isEmpty()
  3. /**
  4. * 返回指定索引处的char值。索引范围是0到length-1
  5. */
  6. public char charAt(int index)
  7. /**
  8. * 将字符串中从srcBegin到srcEnd的字符复制到目标字符数组中,注意待复制的数组要有足够容量
  9. */
  10. public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin)
  11. /**
  12. * 使用指定的字符集将此String编码为字节序列,并将结果存储到新的字节数组中
  13. */
  14. public byte[] getBytes(String charsetName)
  15. public byte[] getBytes(Charset charset)
  16. public byte[] getBytes()
  17. /**
  18. * 忽略大小写进行比较
  19. */
  20. public boolean equalsIgnoreCase(String anotherString)
  21. public int compareToIgnoreCase(String str)
  22. /**
  23. * 将此String对象的子字符串与参数other的子字符串进行比较,如果这些子字符串表示相同的字符序列则返回true
  24. * 要比较的String对象的子字符串始于索引toffset,长度为len
  25. * 要比较的other子字符串从索引ooffset开始,长度为len
  26. */
  27. public boolean regionMatches(int toffset, String other, int ooffset, int len)
  28. public boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len)
  29. public boolean startsWith(String prefix, int toffset)
  30. public boolean startsWith(String prefix)
  31. public boolean endsWith(String suffix)
  32. /**
  33. * 返回指定字符(int类型则为其Unicode值)首次出现时在此字符串中的索引,如果未出现该字符则为-1
  34. * fromIndex表示从指定位置开始搜索的索引
  35. */
  36. public int indexOf(int ch)
  37. public int indexOf(String str)
  38. public int indexOf(int ch, int fromIndex)
  39. public int indexOf(String str, int fromIndex)
  40. /**
  41. * 返回指定字符(Unicode值)最后一次出现在此字符串中的索引,如果未出现该字符则为-1
  42. * fromIndex表示从指定位置开始搜索的索引
  43. */
  44. public int lastIndexOf(int ch)
  45. public int lastIndexOf(String str)
  46. public int lastIndexOf(int ch, int fromIndex)
  47. public int lastIndexOf(String str, int fromIndex)
  48. /**
  49. * 返回该字符串的子字符串,子字符串以beginIndex索引处的字符开头,并且扩展到endIndex索引处截止
  50. */
  51. public String substring(int beginIndex)
  52. public String substring(int beginIndex, int endIndex)
  53. /**
  54. * 返回一个字符串,该字符串是通过用newChar替换该字符串中所有出现的oldChar产生的
  55. * char表示单个字符,CharSequence表示字符序列
  56. */
  57. public String replace(char oldChar, char newChar)
  58. public String replace(CharSequence target, CharSequence replacement)
  59. /**
  60. * 用给定的字符串替换与正则表达式相匹配的该字符串的第一个子字符串,replaceAll表示替换所有
  61. */
  62. public String replaceFirst(String regex, String replacement)
  63. public String replaceAll(String regex, String replacement)
  64. public boolean matches(String regex)
  65. public boolean contains(CharSequence s)
  66. public String trim()
  67. public String toUpperCase()
  68. public String toLowerCase()
  69. public char[] toCharArray()
  70. /**
  71. * 在给定的正则表达式的匹配项周围拆分此字符串,此方法返回的数组包含此字符串的每个子字符串
  72. * limit参数用来控制正则匹配的次数,这会影响所得数组的长度
  73. */
  74. public String[] split(String regex, int limit)
  75. public String[] split(String regex)
  76. public static String join(CharSequence delimiter, CharSequence... elements)
  77. public static String join(CharSequence delimiter, Iterable<? extends CharSequence> elements)
  78. /**
  79. * 格式化
  80. *
  81. * %s:字符串类型
  82. * %c:字符类型
  83. * %b:布尔类型
  84. * %d:整数类型
  85. * %f:浮点类型
  86. * %n:换行符
  87. * ......
  88. */
  89. public static String format(String format, Object... args)

字符串常量池

下面我们通过一个例子来查看 String 的使用:

  1. public class Test {
  2. public static void main(String[] args) {
  3. String a = "abc";
  4. String b = "abc";
  5. String c = new String("abc");
  6. System.out.println(a==b); // true
  7. System.out.println(a.equals(b)); // true
  8. System.out.println(a==c); // false
  9. System.out.println(a.equals(c)); // true
  10. }
  11. }

Java 语法设计的时候针对 String 提供了两种创建方式和一种特殊的存储机制(String intern Pool):

  • 字面值的方式赋值,该字符串会直接存储在常量池中
  • new 关键字新建一个字符串对象,在堆中及常量池中都会存储一份

String intern Pool:该区域用来保存所有的 String 对象数据,当通过字面量赋值的方法构造一个新字符串时,JVM 会优先在该区域里查找是否已经存在能满足需要的 String 对象,如果有就直接返回该对象的地址引用,没有就正常的构造一个新对象,然后丢进去存起来,这样下次再使用同一个 String 时,就可以直接从池中获取而不需要再次创建对象,也就避免了很多不必要的开销。

根据以上概念,我们再来看示例代码,当 JVM 执行到 String a = “abc” 的时候,会先看常量池里有没有字符串刚好是 abc 的这个对象,如果没有,则在常量池里创建初始化该对象,并把引用指向它,如下图:
image.png
当执行到 String b = “abc” 时,发现常量池已经有了 abc 这个值了,于是不再在常量池中创建这个对象,而是直接把引用指向了该对象,如下图:
image.png
执行 String c = new String(“abc”) 时我们加了一个 new 关键字,这个关键字就是告诉 JVM,你直接在堆内存里给我开辟一块新的内存,如下图所示:
image.png
我们知道 == 比较的是地址,equals 比较的是内容(String 内部重写了 toString 方法),由于 a、b、c 三个变量的内容完全一样,因此 equals 的结果都是 true,而 a、b 又是一个同一个对象,因此地址也一样,a、c 很显然不是同一个对象,那么此时为 false 也是很好理解的。

intern

String 类在 Java 6 以后提供了 intern() 方法,目的是提示 JVM 把相应字符串缓存起来以备重复使用。在创建字符串对象并调用 intern() 方法时,如果已经有缓存的字符串就会返回缓存里的实例,否则在创建字符串对象后也将其缓存起来。

  1. String a =new String("abc").intern();
  2. String b = new String("abc").intern();
  3. if(a==b) {
  4. System.out.print("a==b");
  5. }
  6. // 输出 a==b

当调用 intern 方法时,会去查看字符串常量池中是否有等于该对象的字符串的引用,如果没有,在 JDK1.6 版本中会复制堆中的字符串到常量池中,并返回该字符串引用,堆内存中原有的字符串由于没有引用指向它,将会通过垃圾回收器回收。在 JDK1.7 版本以后,由于常量池已经合并到了堆中,所以不会再复制具体字符串了,只是会把首次遇到的字符串的引用添加到常量池中;如果有,就返回常量池中的字符串引用。

如果在运行时,创建字符串对象,将会直接在堆内存中创建,不会在常量池中创建。所以动态创建的字符串对象调用 intern 方法,在 JDK 1.6 版本中会去常量池中创建运行时常量以及返回字符串引用,在 JDK 1.7 版本之后会将堆中的字符串常量的引用放入到常量池中,当其它堆中的字符串对象通过 intern 方法获取字符串对象引用时,则会去常量池中判断是否有相同值的字符串的引用,此时有,则返回该常量池中字符串引用,跟之前的字符串指向同一地址的字符串对象。
image.png
注意,如果使用 Java 6 的版本,并不推荐大量使用 intern 机制,因为这些被缓存的字符串是存在 PermGen 里面的,这个空间非常有限并且基本不会被 FullGC 之外的垃圾收集照顾到。如果使用不当 OOM 就会光顾。因此在后续版本中,字符串常量池被放置在了堆中,这样就极大避免了永久代占满的问题,而永久代也在 JDK 8 中被 MetaSpace(元数据区)替代了。

Intern 是一种显式地排重机制,但是它也有一定的副作用,因为需要开发者写代码时明确调用,一是不方便,每一个都显式调用是非常麻烦的;另外就是我们很难保证效率,应用开发阶段很难清楚地预计字符串的重复情况,有人认为这是一种污染代码的实践。

幸好在 Oracle JDK 8u20 之后,推出了一个新的特性,也就是 G1 GC 下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的,是 JVM 底层的改变,并不需要 Java 类库做什么修改。注意这个功能目前是默认关闭的,需要使用 -XX:+UseStringDeduplication 参数开启,并且指定使用 G1 GC。

参考资料:https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html

StringBuffer、StringBuilder

StringBuffer 是为解决字符串拼接产生太多中间对象的问题而提供的一个类,我们可以用 append 或 add 方法把字符串添加到已有序列的末尾或指定位置。StringBuffer 本质是一个线程安全的可修改字符序列,在保证了线程安全的同时也随之带来了额外的性能开销,除非有线程安全的需要不然还是推荐使用 StringBuilder。

StringBuilder 是 Java 1.5 中新增的,在能力上和 StringBuffer 没有本质区别,但它不保证线程安全,因此有效减小了性能开销,是绝大部分情况下进行字符串拼接的首选。

StringBuffer 的线程安全是通过把各种修改数据的方法都加上 synchronized 关键字实现的。为了实现修改字符序列的目的,StringBuffer 和 StringBuilder 底层都是利用可修改的 char 数组,JDK 9 以后是 byte 数组,二者都继承了 AbstractStringBuilder,里面包含了基本操作,区别仅在于最终的方法是否加了 synchronized。

这个内部数组的长度为:构建时初始字符串长度加 16,即如果没有构建对象时输入最初的字符串,那么初始值就是 16。如果我们确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免很多次扩容的开销。扩容会产生多重开销,因为要抛弃原有数组,创建新的数组,还要进行 arraycopy。

  1. public final class StringBuffer
  2. extends AbstractStringBuilder implements java.io.Serializable, CharSequence {
  3. private transient char[] toStringCache;
  4. ......
  5. }
  6. public final class StringBuilder
  7. extends AbstractStringBuilder implements java.io.Serializable, CharSequence {
  8. ......
  9. }
  10. abstract class AbstractStringBuilder implements Appendable, CharSequence {
  11. char[] value; // 它并不是final的,所以他是可以修改的
  12. int count; // 记录使用的字节数
  13. ......
  14. }

各种方式拼接字符串的效率比较:

  1. public static void main(String[] args) {
  2. long start = System.currentTimeMillis();
  3. String string = "";
  4. for (int i = 0; i < 50000; i++) {
  5. string = string + i;
  6. }
  7. System.out.println("+ cost:" + (System.currentTimeMillis() - start));
  8. start = System.currentTimeMillis();
  9. StringBuilder stringBuilder = new StringBuilder();
  10. for (int i = 0; i < 50000; i++) {
  11. stringBuilder.append(i);
  12. }
  13. System.out.println("stringBuilder cost:" + (System.currentTimeMillis() - start));
  14. start = System.currentTimeMillis();
  15. StringBuffer stringBuffer = new StringBuffer();
  16. for (int i = 0; i < 50000; i++) {
  17. stringBuffer.append(i);
  18. }
  19. System.out.println("stringBuffer cost:" + (System.currentTimeMillis() - start));
  20. }

输出如下:
String - 图5
由于 StringBuffer 在 StringBuilder 的基础上做了同步处理,所以在耗时上会相对多一些,这个很好理解。那为什么使用的 + 拼接字符串,其底层也是使用的 StringBuilder,但与直接使用 StringBuilder 会相差这么多呢?

这主要是因为在 for 循环中通过 + 拼接字符串,每次都是 new 出一个新的 StringBuilder 来进行 append() 操作,而频繁的新建对象当然要耗费很多时间了,并且还会造成内存资源的浪费。而 StringBuilder 和 StringBuffer 的内部实现,预先分配了一定的内存。字符串操作时,只有当预分配内存不足时才会扩展内存,这就大幅度减少了内存分配、拷贝和释放的频率。

所以在循环体内拼接字符串的最优方式,是使用 StringBuilder 的 append 方法进行拼接,切记不要使用 +。

但如果我们拼接的是常量字符串,则可以放心使用 + 号拼接,因为 Java 编译器和 JVM 会对常量字符串的拼接进行优化。比如 “ Hello, “ + “ world! “ 这样的表达式,不会真正执行字符串连接。编译器会把它处理成一个连接好的常量字符串 “Hello, world!”。这样也就不存在反复的对象创建和销毁了。如果字符串的连接里,出现了变量,编译器和 JVM 就没有办法进行优化了。这时 StringBuilder 的效率优势才能体现出来。

String 的演化

image.png
在 Java 6 以及之前的版本中,String 对象是对 char 数组进行了封装实现的对象,通过 offset 和 count 两个属性来定位 char[] 获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。

因为在 Java 6 提供的 substring 方法在构造字符串时会复用原来的 char 数组,如果原本字符串已经没有引用了,但由于通过 substring 构造的字符串里的 char 数组仍指向原字符串,因此原字符串也无法回收,从而导致内存泄露。

从 Java7 版本开始到 Java8 版本,Java 对 String 类做了一些改变。String 类中不再有 offset 和 count 两个变量了。这样的好处是 String 对象占用的内存稍微少了些,同时 String.substring 方法也不再共享 char[] 从而解决了使用该方法可能导致的内存泄漏问题。

从 Java9 版本开始,工程师将 char[] 字段改为了 byte[] 字段,又维护了一个新的属性 coder,它是一个编码格式的标识,并且将相关字符串操作类都进行了修改。另外,所有相关的 Intrinsic 之类也都进行了重写,以保证没有任何性能损失。虽然底层实现发生了这么大的改变,但是 Java 字符串的行为并没有任何大的变化,所以这个特性对于绝大部分应用来说是透明的,绝大部分情况不需要修改已有代码。

工程师为什么这样修改呢?我们知道一个 char 字符占 16 位,是两个 bytes 大小。拉丁语系语言的字符根本就不需要太宽的 char,这样无区别的实现就造成了一定的浪费。密度是编程语言平台永恒的话题,因为归根结底绝大部分任务是要来操作数据的。

因此 JDK 1.9 的 String 类为了节约内存空间,于是使用了 byte[] 来存放字符串。而新属性 coder 的作用是计算字符串长度或者使用 indexOf() 函数时,我们需要根据这个字段判断如何计算字符串长度。coder 属性默认有 0 和 1 两个值,0 代表 Latin-1(单字节编码),1 代表 UTF-16。如果 String 判断字符串只包含了 Latin-1,则 coder 属性值为 0,反之则为 1。