只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

8、字符串

内容来自王争 Java 编程之美

除了 int、long 等基本类型,及其 Integer、Long 等包装类之外,在项目开发中,字符串也是应用得非常多的数据类型
Java 提供了 String 类,封装了字符数组(char[]),并提供了大量操作字符串的方法,比如 toUpperCase()、split()、substring() 等,除此之外,Java String 也是面试中常考的知识点
比如,Java String 为什么设计成 final 不可变类?早期 JDK 中的 substring() 函数为什么会出现内存泄露?intern() 方法的作用和底层实现原理是什么?等等
本节我们就来详细聊聊 String 类型

1、String 的实现原理

在 JDK 8 中,String 底层依赖 char 数组实现,类的定义如下所示

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
/**
* The value is used for character storage.
*/
private final char value[];
/**
* Cache the hash code for the string
*/
private int hash; // Default to 0
// ... 省略很多方法 ...
}

String 类实现了 Serializable、Comparable、CharSequence 这三个接口

  • Serializable 接口用来表示该类的对象可以序列化和反序列化,关于这个接口,我们在 IO / 文件章节中详细讲解
  • Comparable 接口只有一个 compareTo() 方法,用来比较两个对象的大小,在 Comparable 接口的实现类中,可以自行定义比较方式
    关于这个接口,我们在容器章节中的详细讲解
  • CharSequence 接口定义了一组操作字符串的方法,比如 length()、charAt()、toString() 等,StringBuilder、StringBuffer 都实现了这个接口

String 类中定义的属性非常少,核心的属性只有 value 数组和 hash

  • value 数组用来存储字符串,在 JDK 8 中为 char 类型
    JDK 9 对其进行了优化,将其改为 byte 类型,关于这点,我们在本节的「String 的压缩技术」小节中讲解
    接下来,不特殊说明的情况下,我们都是按照 JDK 8 来讲解
  • hash 属性用来缓存的 hashcode,关于 hash 属性的作用,我们在本节的「String 的不可变性」小节中讲解

尽管 String 类包含的属性很少,但包含的方法却很多,接下来,我挑了一些需要特殊注意的方法来讲解

1.1、构造方法

String 对象的构造方法有很多,主要有以下几种,关于这几种构造方法的不同,我们在「String 的常量池技术」小节中讲解

String s1 = "abc"; // 字面常量赋值
String s2 = new String("abc");
String s3 = new String(new char[]{'a', 'b', 'c'});
String s4 = new String(s3);

1.2、运算符

熟悉 C++ 语言的同学应该知道,C++ 语言支持运算符重载,我们可以在自定义类中,重载运算符,如下代码所示,两个 Point 对象可以执行加法操作

#include <iostream>
using namespace std;
class Point {
public:
int x, y;
Point() {};
Point(int x, int y) : x(x), y(y) {};
Point operator+(const Point &a) { // 运算符重载
Point ret;
ret.x = this->x + a.x;
ret.y = this->y + a.y;
return ret;
}
};
int main() {
Point a(2, 4); // 不用 new 也可以创建对象
Point b(5, 3);
Point c = a + b; // 两个对象相加
cout << "x :" << c.x << endl;
cout << "y :" << c.y << endl;
}

Java 语言并不支持运算符重载,一来,运算符重载的设计思想来自函数式编程,并不是纯粹的面向对象设计思想
二来,Java 语言设计的主旨之一就是简单,所以,摒弃了 C++ 中的很多复杂语法,比如指针和这里的运算符重载

尽管程序员自己编写的类无法重载运算符,但 Java 自己提供的 String 类却实现了加法操作
如下代码所示,两个 String 对象可以相加,Java 这样做,不是自己打自己脸吗?

String sa = new String("abc");
String sb = new String("def");
String sc = sa + sb;
System.out.println(sc); // 打印 abcdef

实际上,这也是一种权衡之后的结果
String 类型作为最常用的类型之一,延续了基本类型及其包装类的设计,也支持加法操作,这样使用起来就比较方便和统一(跟基本类型和包装类的操作统一)

1.3、length()

我们先来看下面这段代码,你觉得它的打印结果是什么?

String sd = "a我b你c";
int len = sd.length();
System.out.println(len);

length() 方法的返回值是 char 类型 value 数组的长度,不管是英文还是中文,均占用一个 char 的存储空间,所以,上面这段代码的打印结果为 5

对于 length() 函数,我们再来看这样一个问题:对于一个长度为 n 的字符串,length() 方法的时间复杂度是多少呢?我们来看一下它的源码

public int length() {
return value.length;
}

String 类的 length() 方法直接调用了 value 数组的 length 属性,length 是 JVM 在内存中为数组维护的信息,所以,获取字符串长度的 length() 方法的时间复杂度为 O(1)

不过,数组的 length 属性记录的是数组的大小,而非元素个数,如果数组大小为 10,但只存储了 5 个字符,数组的 length 属性值为 10,而非 5
String 的 length() 方法(获取 value 元素个数),之所以可以直接使用数组的 length 属性值(value 数组大小)
是因为 value 数组不存在空余空间,数组大小就等于元素个数
毕竟,String 类是不可变类,在创建 String 对象时,存储什么样的字符串就已经明确了,不再会更改
因此,对于 value 数组来说,并没有扩展性需求,在创建之初申请的数组大小,跟需要存储的字符串长度相同

1.4、valueOf()

Java 重载了一组 valueOf() 方法,可以将 int、char、long、float、double、boolean 等基本类型数据转化成 String 类型,如下示例代码所示

public static String valueOf(boolean b) {
return b ? "true" : "false";
}
public static String valueOf(char c) {
char data[] = {c};
return new String(data, true);
}
public static String valueOf(int i) {
return Integer.toString(i);
}
public static String valueOf(long l) {
return Long.toString(l);
}
public static String valueOf(float f) {
return Float.toString(f);
}
public static String valueOf(double d) {
return Double.toString(d);
}

除了使用 valueOf() 函数,Java 还支持如下方式将基本类型数据转换为 String 类型

String sf = "" + 53;
System.out.println(sf); // 打印 53

前面我们讲到,String 重载了 + 运算符,可以实现两个 String 对象相加
实际上,String 对象还可以跟其他任意类型的对象相加,最终的结果为 String 对象与其他对象的 toString() 函数的返回值相加,如下代码所示

String s1 = "abc";
Integer i = 4;
String s2 = s1 + i;
System.out.println(s2); // 打印: abc4
Student stu = new Student(1, 1);
String s3 = s1 + stu;
// Student 没有重写 toString() 函数, 使用 Object 的 toString() 函数
// 打印: abcdemo.Student@7852e922
System.out.println(s3);

1.5、compareTo()

上一节,我们讲到,字符可以比较大小
字符比较大小,是将字符对应的 UTF-16 二进制编码,重新解读为 16 位的无符号数,再进行比较的
字符串比较大小,是从下标 0 开始,两个字符串中的相同下标位置的字符一一比较,当遇到第一组不相等的字符时,根据这组字符的大小关系,决定两个字符串的大小关系
当然,如果短字符串跟长字符串的前缀完全相同,那么规定短字符串小于长字符串

compareTo() 方法的具体的代码实现如下所示

  • 如果 a 字符串小于 b 字符串,那么 a.compareTo(b) 返回负数
  • 如果 a 字符串等于 b 字符串,那么 a.compareTo(b) 返回 0
  • 如果 a 字符串大于 b 字符串,那么 a.compareTo(b) 返回正数
public int compareTo(String anotherString) {
int len1 = value.length;
int len2 = anotherString.value.length;
int lim = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value;
int k = 0;
while (k < lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}

1.6、substring()

1.6.1、JDK 7

substring(int beginIndex, int endIndex) 方法截取并返回下标在 [beginIndex, endIndex) 范围内的子串
在 JDK 7 及其以上版本中,substring() 方法会生成新的 String 对象来存储子串
但是,如果传入的 beginIndex 为 0,endIndex 为字符串的长度,那么 substring() 会返回字符串本身,不会创建新的 String 对象

String s = "abcde";
String substr1 = s.substring(1, 4); // substr1 为 "bcd"
String substr2 = s.substring(0, 5); // substr2 为 "abcde"
System.out.println("" + (s == substr2)); // 打印 true

上述示例中的 s、substr1、substr2 之间关系如下图所示
image

1.6.2、JDK 6

在 JDK 6 及其以前版本中,String 类的 substring() 方法的实现方式会导致内存泄露
尽管这个问题已经在 JDK 7 中修复,现在也很少有项目在用 JDK 6
但是,为了面试和开阔视野,我们还是介绍一下,JDK 6 中 substring() 的实现方法以及产生内存泄露的原因

在 JDK 6 中,String 类的属性要比 JDK 7 中的多,除了 char 类型的 value 数组之外,还包含两个 int 类型的属性:offset 和 count
通过 substring() 方法获取到的子串会共享 char 数组,使用 offset 来标志子串的起点,使用 count 来标志子串的长度,示例如下所示

// value = {'a', 'b', 'c', 'd', 'e'}, offset = 0, count = 5
String s = "abcde";
// value = {'a', 'b', 'c', 'd', 'e'}, offset = 1, count = 3
String substr = s.substring(1, 4); // substr 为 "bcd"

上述示例中的 s 和 substr 的关系如下图所示,你可以拿它跟上图(JDK 7 中的实现方式)做对比
image
JDK 6 中 substring() 的实现方法,比起 JDK 7 的实现方法,不需要拷贝 value 数组,只需要记录 offset 和 count,更加节省空间
但也正因为如此,存在原始字符串无法被 JVM 垃圾回收的问题

如上代码示例,如果 s 所引用到 String 对象中存储的是比较长的字符串
当此长字符串 String 对象不再被使用时,为了节省内存,尽快让 JVM 将其垃圾回收,很多程序员会主动将 s 设置为 null(s = null),这样变量 s 便不再引用这个长字符串 String 对象,这个长字符串 String 对象没有变量引用之后,便可以被 JVM 垃圾回收掉
但事与愿违,即便将 s 设置为 null,但长字符串 String 对象中的 value 数组仍然被 substr 中的 value 属性所引用,所以,仍然无法被 JVM 垃圾回收,这跟程序员的预期不符,所以称为内存泄露

当然,在 JDK 6 中,针对 substring() 方法导致的内存泄露问题,也有相应的解决对策,如下代码所示
其中,对于 intern() 方法,我们在「String 的常量池技术」小节中讲解

String s = "abcde";
// 方法一
String substr = new String(s.substring(1, 4));
// 方法二
String substr = s.substring(1, 4) + "";
// 方法三
String substr = s.substring(1, 4).intern();

2、String 的压缩技术

String 类作为在开发中最常用的类型之一,在性能和使用方便程度上,都理应做到极致
前面提到,String 类提供了大量操作字符串的方法,就是为了提高使用的方便程度
接下来,我们再来看一下,在 String 类的性能优化方面,Java 主要做了哪些努力,这里,我们侧重存储效率

前面讲到,在 JDK 8 以及之前的版本中,String 类底层依赖 char 类型的数组来存储字符串
而上一节讲到,char 类型存储的是字符的 UTF-16 编码,一个字符占 2 个字节长度,所以,存储英文等 ASCII 码会比较浪费空间
因此,在 JDK 9 中,Java 对 String 类进行了优化,将存储字符串的 value 数组的类型,由 char 改为了 byte,JDK 9 中 String 类的定义如下所示

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
/**
* Cache the hash code for the string
*/
private int hash; // Default to 0
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
// 是否使用压缩存储方式, 可以通过 JVM 参数设置为 false
static final boolean COMPACT_STRINGS;
static {
COMPACT_STRINGS = true;
}
// ... 省略大量方法 ...
}

其中,coder 属性的值有两个
一个值是 LATIN1,表示 String 中存储是 Latin-1 字符,Unicode 编号小于等于 127,一个字符用一个字节存储
另一个值是 UTF16,表示 String 中存储的字符并非全为 Latin-1 字符,所以,字符采用 UTF-16 的两字节编码,1 个字符占 2 个字节

coder 属性的值是通过分析字符串来得到
如果在所存储的字符串中,每个字符对应的 UTF-16 编码值(2 字节编码)都小于等于 127,那说明字符串中只包含英文字符
我们就对字符串进行压缩存储,使用 1 个字节存储 1 个字符

String 类中的很多操作,比如计算字符串长度的 length(),以及根据下标返回字符的 charAt(int index) 等,都依赖 coder 属性的值
length() 函数和 charAt() 函数的代码实现如下所示

public int length() {
// coder = 1 时, 使用 UTF-16 2 字节编码, 字符串长度 = value 数组大小 / 2
return value.length >> coder();
}
byte coder() { // COMPACT_STRINGS 默认为 true, 可以通过 JVM 参数修改
return COMPACT_STRINGS ? coder : UTF16;
}
// coder = 0, 1 个字节代表 1 个字符
// coder = 1, 2 个字节代表 1 个字符
public char charAt(int index) {
if (isLatin1()) {
return StringLatin1.charAt(value, index);
} else {
return StringUTF16.charAt(value, index);
}
}

总结一下,String 类从 JDK 6 开始,经历了多次优化和重构,如下图所示

JDK 版本 JDK 6 JDK 7 / 8 JDK 9
String 类中属性 char[] value; int offset; int count; int hash; char[] value; int hash; byte[] value; int coder; int hash;

3、String 的常量池技术

在前面章节中,我们讲到,Integer、Long 等基本类型的包装类,使用常量池技术,缓存常用的数值对象,起到节省内存的作用
String 作为常用的数据类型,也效仿了基本类型的做法,设计了常量池,缓存用过的字符串

不过,String 类型跟 Integer 等包装类类似,使用 new 方式创建对象,并不会触发常量池技术,只有在使用字符串常量赋值时,才会触发常量池技术
如果字符串常量在常量池中已经创建过,则直接使用已经创建的对象
如果没有创建过,则在常量池中创建,以供复用

3.1、示例

如下示例代码所示,因为 s1 和 s2 引用常量池中相同的 String 对象,所以第一个打印语句返回 true;s3 引用在堆上新申请的 String 对象,所以第二个打印语句返回 false

String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc"); // 不适用常量池技术
System.out.println(s1 == s2); // 打印 true
System.out.println(s1 == s3); // 打印 false

上述 String 对象对应的内存存储结构如下所示,在 JDK 6 及其以前版本中,字符串常量池存储在 PermGen 永久代,在 JDK 7 中,字符串常量池被移动到了堆中
之所以这样做,是因为永久代空间有限,如果常量池中存储的字符串较多,将会产生 PermGen OOM 错误,关于这一点,我们在 JVM 模块中详细讲解
image

3.2、intern()

除了使用字符串常量赋值之外,我们还可以使用 intern() 方法,将分配在堆上的 String 对象,原模原样在常量池中复制一份,如下示例代码所示

String s1 = "abc";
String s2 = new String("abc");
String s3 = s2.intern(); // 关押
System.out.println(s1 == s2); // 打印 false
System.out.println(s1 == s3); // 打印 true
System.out.println(s2 == s3); // 打印 false

在上述代码中,s2.intern() 语句返回的是字符串 "abc" 在常量池中的 String 对象
所以,s1 和 s3 引用相同的 String 对象,上述 String 对象对应的内存存储结构如下图所示
image
在平时的开发中,什么时候使用 intern() 方法呢?

当我们无法通过字符串常量来给 String 变量赋值(比如使用现成的 API 从文件或数据库中读取字符串)
但又存在大量重复字符串(比如数据库中有一个 "公司" 字段,有大量重复值)时,我们就可以将读取到的 String 对象,调用 intern() 方法,复制到常量池中
代码中使用常量池中的 String 对象,原 String 对象就被 JVM 垃圾回收掉,示例代码如下所示

String companyName = dao.query(...).intern();

3.3、总结

既然从 JDK 7 开始,String 常量池存储在堆中,通过 new 创建的 String 对象也存储在堆中
那么,为什么不把通过 new 创建的 String 对象,也放到常量池中呢?这样,复用率岂不是更高,更加节省存储空间?

之所以分开存储是因为,在 String 常量池中创建 String 对象前,需要先查询此 String 对象是否已经存在
尽管 String 常量池组织成类似哈希表一样的数据结构,但查询总是要耗时的,特别当 String 常量池中的数据太多时,耗时就会增多
不仅如此,创建的过程要将对象放入一定的数据结构中,也要比直接在堆中创建要耗时多
对于没有太多重复的字符串,我们没必要将它放入常量池中,所以,Java 提供了灵活的 String 对象创建方式,由程序员自己决定是否将 String 对象放入常量池

4、String 的不可变性

String 类是不可变类,不可变的意思是:其对象在创建完成之后,所有的属性都不可以再被修改,包括引用类型变量所引用的对象的属性
那么,为什么 Java 将 String 设置成不可变类呢?主要有以下几点原因

4.1、原因一

因为 String 类使用了常量池技术,有可能很多变量会引用同一个 String 对象
如果 String 对象允许修改,某段代码对 String 对象进行了修改,其他变量因为引用同一个 String 对象,获取到的数据值也紧跟着修改,这样显然不符合大部分的业务开发需求

4.2、原因二

字符串和整型数经常用来作为 HashMap 的键(key)
在平常的开发中,我们经常将对象的某个 String 类型或整型类型的属性作为 key,对象本身作为 value,存储在 HashMap 中
如果之后属性值又改变了,那么,此对象在 HashMap 中的存储位置,需要作相应的调整,否则就会导致此对象在 HashMap 中无法查询,这显然增加了编码的复杂度

讲到这里,我们再说一下 String 类中的 hash 属性,String 类定义了自己的 hashcode() 函数
等到讲到容器的时候,我们会讲到,当将对象存储在 HashMap(哈希表)中时,HashMap 会调用对象的 hashcode() 函数来计算哈希值
对于 String 不可变对象来说,hash 值在计算得到之后是不会再改变的,所以,使用一个属性 hash 来缓存这个值,避免重复计算

public int hashCode() {
int h = hash; // h 不为 0, 表示已经计算过
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

4.3、原因三

我们前面反复提到,String 的设计思想非常贴近基本类型,比如支持 + 运算符等
所以,基本类型及其包装类都是不可变的,所以,String 也延续了它们的设计思路,也设计为不可变的

5、StringBuilder

因为 String 是不可变类,当我们在拼接多个字符串时,效率(空间和时间)会比较低,如下代码所示
在 for 循环中,每次都会创建一个新的 String 对象,再赋值给 s,而并不能直接修改 s 对应的 String 对象
所以,下述代码创建了大量的 String 对象,空间和时间效率都很低

String s = "a";
for (int i = 0; i < 100000; i++) {
s += "x"; // s = s + "x"; 创建一个新的对象拼接 s 和 "x", 赋值给 s
}

于是,为了解决这个问题,Java 设计了一个新的类 StringBuilder,支持修改和动态扩容
使用 StringBuilder 类将上述代码重构,如下所示,这样就避免了 for 循环创建大量的 String 对象

String s = "a";
StringBuilder sb = new StringBuilder();
sb.append(s);
for (int i = 0; i < 100000; i++) {
sb.append("x");
}
s = sb.toString();

在平时的开发中,我们经常使用加号(+)连接多个字符串,实际上,底层就是采用 StringBuilder 来实现的,如下所示

String s1 = "abc";
String s2 = "def";
String s3 = "hij";
String s4 = s1 + s2 + s3;
// 底层实现逻辑 String s4 = (new StringBuilder()).append(s1).append(s2).append(s3).toString();

实际上,我们可以把 StringBuilder 看做是 char 类型的 ArrayList(ArrayList<Character>),关于 StringBuilder 的扩容方式,我们在容器部分讲解

6、课后思考题

在 JDK 8 和 JDK 9 下,这段代码分别打印什么结果?

String s1 = "abc";
System.out.println(s1.getBytes().length); // 3
String s2 = "王abc争";
System.out.println(s2.getBytes().length); // 9
有些同学学了本节的内容之后,可能认为上述代码的打印结果在 JDK 8 和 JDK 9 下分别是:6,10 和 3,10,实际上是不对的
提示:研究一下 getBytes() 获取到是什么?
getBytes() 获取的是字符串的外编码,在作者的 MacOS 下,默认编码方式是 UTF-8,因此
s1 的 UTF-8 编码占 3 字节大小
s2 的 UTF-8 编码占 9 字节大小
因此,打印结果是 3 和 9
posted @   lidongdongdong~  阅读(88)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开