扒一扒String

String不可变性

什么是不可变对象

       众所周知,String 对象是不可变的。那么什么是不可变对象呢?在 Java教程 中定义:如果一个对象在构造后状态无法改变,则该对象被视为不可变。

不能改变状态的意思是,不能改变对象内的成员变量,包括基本数据类型的值不能改变,引用类型的变量不能指向其他的对象,引用类型指向的对象的状态也不能改变。

不可变对象的遵守策略(规则)

  • 类中所有字段都被 final 修饰,并且是私有的,也就是被 private 修饰。
  • 不能提供修改字段或字段引用对象的 setter 方法。
  • 不允许子类重写方法
  • 如果实例字段包含对可变对象的引用,则不允许更改这些对象:
    • 不要提供修改可变对象的方法。
    • 不要直接返回可变对象的引用。如有必要,可创建内部可变对象的副本,并返回可变对象的副本。

String的不可变性

定义一个字符串 s

1
2
3
4
5
6
7
public static void main(String[] args) {
String s = "abcd";
System.out.println(s);

s = "abcdef";
System.out.println(s);
}

输出结果为:

1
2
abcd
abcdef

首先创建一个对象 s ,并赋值为abcd,然后再赋值为abcdef。从输出结果可以看出s的值确实改变了,那么为什么还说String对象是不可变的呢?

其实 s 变量只是保存了对象的引用,该引用指向了堆中具体的对象。如下图:

然后又创建了一个新的对象abcdef, 而引用s重新指向了这个新的对象,原来的对象abcd还在内存中存在,并没有改变。如下图:

所以上面的代码仅仅改变了s的引用地址

为什么String对象是不可变的?

要理解String的不可变性,先看String类的源码:

1
2
3
4
5
6
7
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类内部是用一个字符数组来维护值的,并且被申明为 private final ,也就是value字符数组在对象被构造后就不允许重新赋值了。并且类内部也没有提供可以修改value数组值的setter方法,
所以可以认为String对象是不可变的。

可能会发现String类还提供了很多方法可以修改字符串值的方法:substring , concat , replace , replaceAll 等等。

比如 substring 方法:

1
2
3
4
5
public static void main(String[] args) {
String s = "abcd";
System.out.println(s);
System.out.println(s.substring(1));
}

执行后输出结果:

1
2
abcd
bcd

可以看到s的值确实是变了。这解释起来也很容易,看 substring 方法实现就明白了:

1
2
3
4
5
6
7
8
9
10
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

源码并不是去修改的 value 数组里的值,而是重新创建一个新的对象,并把新的对象引用赋值给s。其它修改字符串值的方法也都一样,都是创建新的对象返回。

String的值真的不可变吗?

value变量是 private final 的,也就是初始化后不可修改。它是引用变量,虽然不能重新指向其它堆内存地址,但可以通过反射去修改堆里的值。

1
2
3
4
5
6
7
8
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String s = "abcd";
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] chars = (char[]) field.get(s);
chars[1] = '%';
System.out.println(s);
}

输出结果为:

1
a%cd

这个反射的实例还可以说明一个问题:如果一个对象,他组合的其他对象的状态是可以改变的,那么这个对象很可能不是不可变对象。例如一个Car对象,它组合了一个Wheel对象,虽然这个Wheel对象声明成了private final 的,但是这个Wheel对象内部的状态可以改变, 那么就不能很好的保证Car对象不可变。

JDK6与JDK7中String类实现的区别

JDK 6

String是通过字符数组实现的。在jdk 6 中,String类包含三个成员变量:char value[], int offset,int count。他们分别用来存储真正的字符数组,数组的第一个位置索引以及字符串中包含的字符个数。

下面是jdk1.6中的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence
{
/** The value is used for character storage. */
private final char value[];

/** The offset is the first index of the storage that is used. */
private final int offset;

/** The count is the number of characters in the String. */
private final int count;

/** Cache the hash code for the string */
private int hash; // Default to 0


// JDK 6
// Package private constructor which shares value array for speed.
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}

// substring
public String substring(int beginIndex, int endIndex) {
//check boundary
return new String(offset + beginIndex, endIndex - beginIndex, value);
}

// concat
public String concat(String str) {
return new String(0, count + otherLen, buf);
}

// 其它修改字符串的方法
}

当调用substring,concat等方法的时候,会创建一个新的string对象,但是这个string的值仍然指向堆中的同一个字符数组。这两个对象中只有count和offset 的值是不同的

JDK 6中的substring导致的问题

如果你有一个很长很长的字符串,但是当你使用substring进行切割的时候你只需要很短的一段。这可能导致性能问题,因为你需要的只是一小段字符序列,但是你却引用了整个字符串(因为这个非常长的字符数组一直在被引用,所以无法被回收,就可能导致内存泄露)。在JDK 6中,一般用以下方式来解决该问题,原理其实就是生成一个新的字符串并引用他。

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

内存泄露:在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。 内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。

concat也同样有该问题,如果对象a只需要很小一段字符序列,对象b去拼接了很长的字符串,这就导致如果a对象没被回收,这个很长很长的字符数组就一直不会被释放。

JDK 7

上面提到的问题,在jdk 7中得到解决。在jdk 7 中,substring,concat等方法会在堆内存中创建一个新的数组。

Java源码中关于这部分的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
public String substring(int beginIndex, int endIndex) {
// check boundary
int subLen = endIndex - beginIndex;
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}

public String(char value[], int offset, int count) {
// check boundary
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

以上是JDK 7中的subString方法,其使用new String创建了一个新字符串,避免对老字符串的引用。从而解决了内存泄露问题。

所以,如果你的生产环境中使用的JDK版本小于1.7,当你使用String的subString方法时一定要注意,避免内存泄露。

编译器对String字符串拼接的优化

  • String s = “a” + “b”,编译器会进行常量折叠(因为两个都是编译期常量,编译期可知),即变成 String s = “ab”

markdown

  • 对于能够进行优化的(String s = “a” + var 等)用 StringBuilder 的 append() 方法替代,最后调用 toString() 方法 (底层就是一个 new String())

markdown

字符串拼接的几种方式和区别

字符串不变性与字符串拼接

其实,所有的所谓字符串拼接,都是重新生成了一个新的字符串。下面一段字符串拼接代码:

1
2
String s = "abcd";
s = s.concat("ef");

其实最后我们得到的s已经是一个新的字符串了。如下图

markdown

s中保存的是一个重新创建出来的String对象的引用。

1.使用 + 拼接字符串

在Java中,拼接字符串最简单的方式就是直接使用符号 + 来拼接。如:

1
2
3
String a = "a";
String b = "b";
String c = a + "," + b;

其实使用 + 拼接字符串,只是Java提供的一个语法糖, 那么,我们就来解一解这个语法糖,看看他的内部原理到底是如何实现的。

上面代码经过反编译后如下:

1
2
3
String a = "a";
String b = "b";
String c = new StringBuilder().append((String)a).append((String)",").append((String)b).toString();

通过查看反编译以后的代码,我们可以发现,原来字符串常量在拼接过程中,是将String转成了StringBuilder后,使用其append方法进行处理的。

那么也就是说,Java中的 + 对字符串的拼接,其实现原理是使用 StringBuilder.append

2.concat

还可以使用String类中的方法concat方法来拼接字符串。如:

1
2
3
String a = "a";
String b = "b";
String c = a.concat(",").concat(b);

我们再来看一下concat方法的源代码,看一下这个方法又是如何实现的。

1
2
3
4
5
6
7
8
9
10
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);
}

这段代码首先创建了一个字符数组,长度是已有字符串和待拼接字符串的长度之和,再把两个字符串的值复制到新的字符数组中,并使用这个字符数组创建一个新的String对象并返回。

通过源码我们也可以看到,经过concat方法,其实是new了一个新的String,这也就呼应到前面我们说的字符串的不变性问题上了。

3.StringBuffer和StringBuilder

关于字符串,Java中除了定义了一个可以用来定义 字符串常量String 类以外,还提供了可以用来定义 字符串变量StringBufferStringBuilder 类,它的对象是可以扩充和修改的。

使用 StringBufferStringBuilder 可以方便的对字符串进行拼接。如:

1
2
3
4
5
6
7
StringBuffer buffer = new StringBuffer("a");
String b = "b";
String c = buffer.append(",").append(b).toString();

StringBuilder builder = new StringBuilder("a");
String b = "b";
String c = builder.append(",").append(b).toString();

接下来我们看看 StringBufferStringBuilder 的实现原理。

String 类类似, StringBufferStringBuilder 类也封装了一个字符数组,定义如下:

1
char[] value;

其append源码如下:

1
2
3
4
public StringBuilder append(String str) {
super.append(str);
return this;
}

该类继承了AbstractStringBuilder类,看下其append方法:

1
2
3
4
5
6
7
8
9
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}

append会直接拷贝字符到内部的字符数组中,如果字符数组长度不够,会进行扩展。

StringBufferStringBuilder 类似,最大的区别就是 StringBuffer 是线程安全的,看一下 StringBuffer 的append方法。

1
2
3
4
5
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

该方法使用 synchronized 进行声明,说明是一个线程安全的方法。而 StringBuilder 则不是线程安全的。

常用的字符串拼接方式有五种,分别是使用+、使用concat、使用StringBuilder、使用StringBuffer以及使用StringUtils.join。

由于字符串拼接过程中会创建新的对象,所以如果要在一个循环体中进行字符串拼接,就要考虑内存问题和效率问题。

因此,经过对比,我们发现,直接使用StringBuilder的方式是效率最高的。因为StringBuilder天生就是设计来定义可变字符串和字符串的变化操作的。

但是,还要强调的是:

1、如果不是在循环体中进行字符串拼接的话,直接使用+就好了。

2、如果在并发场景中进行字符串拼接的话,要使用StringBuffer来代替StringBuilder。

switch对String的支持

Java 7中,switch的参数可以是String类型了。到目前为止switch支持这样几种数据类型:byte short int char String

还是先上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
String str = "world";
switch (str) {
case "hello":
System.out.println("hello");
break;
case "world":
System.out.println("world");
break;
default:
break;
}
}

对代码进行反编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String args[])
{
String str = "world";
String s;
switch((s = str).hashCode())
{
default:
break;
case 99162322:
if(s.equals("hello"))
System.out.println("hello");
break;
case 113318802:
if(s.equals("world"))
System.out.println("world");
break;
}
}

看到这个代码,你知道原来字符串的switch是通过equals()和hashCode()方法来实现的。记住,switch中只能使用整型,比如byte。short,char(ackii码是整型)以及int。还好hashCode()方法返回的是int,而不是long。通过这个很容易记住hashCode返回的是int这个事实。仔细看下可以发现,进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行switch或者使用纯整数常量,但这也不是很差。因为Java编译器只增加了一个equals方法,如果你比较的是字符串字面量的话会非常快,比如”abc” ==”abc”。如果你把hashCode()方法的调用也考虑进来了,那么还会再多一次的调用开销,因为字符串一旦创建了,它就会把哈希值缓存起来。因此如果这个switch语句是用在一个循环里的,比如逐项处理某个值,或者游戏引擎循环地渲染屏幕,这里hashCode()方法的调用开销其实不会很大。

参考资料

定义不可变对象的策略

为什么String在java中是不可变的?

评论