Java 基础进阶系列之一【字符串和数组】

一. Java 字符串的不变性

(1) 首先我们声明一个 String 变量 test1

1
String test1 = "test";

test1 储存着 String 对象的引用,下图的箭头应该解释为”内存的引用”

Java-String-1

(2) 将 test1 的值赋给另一个 String 变量 test2

1
String test2 = test1;

tset2 将会使用同一个内存区域存储的值

Java-String-2

(3) 字符串连接

1
test1 = test1.contact("aurthur");

test1 之后存储的是重新被创造出来的新的对象引用

Java-String-3

(4) Java 不变性小结

一旦 String 对象在内存(heap区)中被创建了,它就不能被改变;

如果我们需要创建可变的 String 对象, 我们可以使用 StringBuffer 或者 StringBuilder ,否则很多时间可能被浪费在垃圾回收上,因为每一次都会有一个新的对象产生。

二. 为什么Java 中 String 是不可变对象?

(1) 不可变对象

不可变对象,顾名思义就是创建后的对象不可以改变,典型的例子有 Java 中的 String 类型
且看 JDK 源码,java.lang.String 类构造函数:

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

可以看到 String 是一个 char 数组 ,并且设计成被 final 修饰,这样设计的 String 有什么优势?下文将逐一讲解:

(2) 字符串常量池(String Pool)的需要

字符串常量池(字符串字面量池)是 JVM 为了减少字符串对象的重复创建而维护的一个特殊的内存。当创建一个字符串,如果池中已经存在相同的字符串,将返回现有的字符串的引用,而不是创建一个新的对象。

以上说明必须基于 String 对象的创建方式为 字面量形式 ,如 String str = “aurthur”;(还有一种是使用new这种标准的构造对象的方法的形式,如String str = new String(“aurthur”);)

以下代码在同一个堆中只会创建一个字符串

1
2
String test1 = "aurthur";
String test2 = "aurthur";

如下图

Java-String-4

如果一个字符串设计陈可变的,那么改变一个引用的字符串将导致其他引用的值出现错误

(3) 缓存 Hashcode

一个 String 的 Hashcode 在 Java 中被使用的非常频繁,比如在 HashMap 中。String 的不变性可以保证它的 Hashcode 也是不变的,这意味着使用 String 类型的实例是不要每次都去计算它的 Hashcode ,很多操作的效率会极大的提高。其实在 String 类设计中,已经考虑过 Hashcode 的问题了,看看它的构造方法中有这么一行:

1
2
//this is used to cache hash code.
private int hash; // Default to 0

(4) 安全

String 被广泛用于网络连接、文件 IO 等多种 Java 基础类的参数中,如果 String 内容可变的话,将潜在地带来多种严重安全隐患,例如链接地址被暗中更改等,出于同样的原因,在 Java 反射机制中可变 String 参数也会导致潜在的安全威胁

以下是一个实例

1
2
3
4
5
6
7
8
boolean connect(string url){
// 验证url地址是否安全,不安全的网络访问将被异常抛出阻止
if (!isSecure(url)) {
throw new SecurityException();
}
// 上一步url已通过安全检验,但如果url在这里能够被(其他线程)其他引用修改,将触发严重的安全威胁
mayCauseProblemWhileOpen(url);
}

(5) 线程安全

不可变对象对于多线程是安全的,因为在多线程同时进行的情况下,一个可变对象的值很可能被其他线程改变,这样会造成不可预期的结果,那么使用不可变对象就可以避免这种情况出现

将 String 设计成不可变对象小结

Java 将 String 设成不可变最大的原因是 效率 安全

三. substring() 方法如何工作?

(1) substring() 能做什么?

substring(int beginIndex, int endIndex) 函数返回一个从beginIndex到endIndex-1的字符串
例如:

1
2
3
String test = "aurthur";
test = test.substring(1,4);
System.out.println(test);

以上代码会输出:

1
"urt"

(2) 当 substring() 函数被调用的时候发生了什么?

因为字符串 test 是不可变的,当 test 被赋值为test.substring()的时候,test 指向了一个完全新创建的字符串,暂时可以简单的理解为以下图片所示

Java-substring-1

然而,在 JDK 源码的底层,在堆内存中,所发生的事情并不是上图所示那么简单(甚至可以说上图是错误的),并且 JDK 6 和 JDK 7 之间存在不同

(3) 在 JDK 6 中的substring() 函数

String 是由一个 char 数组来保存的,在 JDK 6 中,String 类有三个字段:char value[] , int offset, int count

当 substring() 方法被调用的时候,创建了一个新的字符串,但是新字符串的值,仍然指向对内存中相同的字符串数组

Java-substring-2

以下代码简单的解释了这个问题

1
2
3
4
5
6
7
8
9
10
11
//JDK 6
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}

public String substring(int beginIndex, int endIndex) {
//检查边界
return new String(offset + beginIndex, endIndex - beginIndex, value);
}

JDK 6 中的 substring() 方法可能会引发的问题:

如果有一个很长的字符串,并且每次调用 substring() 方法取得只是很小的一部分字符串

就会出现性能的问题,因为每次取得只是一小部分,如何来解决这个问题?可以调用完 substring() 方法之后把其变为字符串:

1
x = x.substring(x, y) + "";

(4) 在 JDK 7 中的 substring() 函数

JDK 7 对 JDK 6 的做法进行了改进,每次调用 substring() 方法,在对内存中创建了一个新的数组

Java-substring-3

1
2
3
4
5
6
7
8
9
10
11
//JDK 7
public String(char value[], int offset, int count) {
  //检查边界
  this.value = Arrays.copyOfRange(value, offset, offset + count);
}

public String substring(int beginIndex, int endIndex) {
  //检查边界
  int subLen = endIndex - beginIndex;
  return new String(value, beginIndex, subLen);
}

四. 用””还是构造函数(new String())创建 String 对象?

Java 中字符串对象创建有两种形式,一种为字面量形式,如

1
String str = "aurthur";

另一种就是使用new这种标准的构造对象的方法,如

1
String str = new String("aurthur");

这两种方式我们在代码编写时都经常使用,尤其是字面量的方式,那么他们的区别是什么呢?

(1) 字面量形式

1
String a = "aurthur";

JVM 检测这个字面量,这里我们认为没有内容为 aurthur 的对象存在。JVM 通过字符串常量池查找不到内容为 aurthur 的字符串对象存在,那么会创建这个字符串对象,然后将刚创建的对象的引用放入到字符串常量池中,并且将引用返回给变量 a;

1
String b = "aurthur";

同样 JVM 还是要检测这个字面量,JVM 通过查找字符串常量池,发现内容为 ”aurthur” 字符串对象存在,于是将已经存在的字符串对象的引用返回给变量 b,这里不会重新创建新的字符串对象;

验证是否为 a 和 b 是否指向同一对象,我们可以通过这段代码

1
2
System.out.println(a == b);  // True
System.out.println(a.equals(b)); // True

a==b 返回 true 是因为 a 和 b 引用的是同一个内存空间的同一个字符串

(2) 使用 new 创建

1
String c = new String("aurthur");

当我们使用了 new 来构造字符串对象的时候,不管字符串常量池中有没有相同内容的对象的引用,新的字符串对象都会创建,我们测试一下

1
2
3
String d = new String("aurthur");
System.out.println(c == d); // False
System.out.println(c.equals(d)); // True

(3) intern 的使用

对于上面使用 new 创建的字符串对象,如果想将这个对象的引用加入到字符串常量池,可以使用 intern 方法。

调用 intern 后,首先检查字符串常量池中是否有该对象的引用,如果存在,则将这个引用返回给变量,否则将引用加入并返回给变量

1
2
String e = c.intern();
System.out.println(a == c); //True

(4) 小结

这两种方式我们在代码编写时都经常使用,然而这两种方式的实现其实存在着一些性能和内存占用的差别;这一切都是源于 JVM 为了减少字符串对象的重复创建,其维护着一个特殊的内存,这段内存被成为字符串常量池或者字符串字面量池。
字符串常量池的好处就是减少相同内容字符串的创建,节省内存空间;缺点的话就是牺牲了 CPU 计算时间来换空间,CPU 计算时间主要用于在字符串常量池中查找是否有内容相同对象的引用,不过其内部实现为HashTable,所以计算成本较低