关于Java中的String - Alias的博客

关于Java中的String

字符串的不可变性

在java中,字符串是不可变的。那什么是不可变对象呢?不可变对象是在完全创建之后其内部状态保持不变的对象,这意味着一旦对象被复制给变量我们既不能更新引用也不能通过任何方式改变内部状态

这时可能有人疑惑,通过一下代码我们不就改变字符串了吗?

1
2
String s = "abc";
s = s.concat("def");

这里虽然字符串内容改变了,但实际上我们所得到的已经是一个新的字符串了(在堆中重新创建了一个”abcdef”的字符串)

所以,一旦一个String对象在堆中被创建出来就无法被修改,所有通过String类的方法都没办法改变字符串本身的值,都是返回了一个新的对象。

如果我们想要一个可修改的字符串,可以使用StringBuffer或者StringBuilder

为什么字符串要设计成不可变?

缓存

因为字符串是使用最广泛的数据结构,大量的字符串创建非常消耗资源,所以Java提供了对字符串的缓存功能,以此可以大大节省堆空间。

在JVM中,专门开辟了一个字符串池来存储Java字符串

通过字符串池,两个相同内容的字符串可以从池中指向同一个字符串对象,从而节省了内存资源

1
2
String s = "abcd";
String s2 = s;

示例中,s和s2内容相同,所以他们指向字符串池中同一个字符串对象,之所以可以这样,是因为字符串的不可变性,若可变,一旦修改s的内容,那s2的内容也会相应的改变

安全性

字符串在Java中广泛用于存储敏感信息,JVM类加载器在加载类的时候也广泛使用它,因此对于String类的保护至关重要,我们在传递字符串时,如果字符串内容是不可变的,那么我们就可以相信这个字符串中的内容,若可变,那字符串中的内容就随时随地都能修改,内容就完全不可信,系统就毫无安全性可言。

线程安全

不可变会自动让字符串成为线程安全的,因为当多个线程同时访问他们时,他们不会被更改。因此,一般来说不可变对象可以在同时运行的多个线程中共享,如果线程更改了值,那么就将在字符串池中创建一个新的字符串而不是修改相同的值。所以字符串对于多线程来说是安全的。

hashcode缓存

字符串对象被广泛的用于哈希实现,比如HashMap、HashTable等,在对这些散列实现进行操作时,经常调用hashCode()方法。不可变性保证了字符串的值不会改变,因此hashCode()方法在String类被重写,以方便缓存,这样在第一次hashCode()调用期间计算和缓存散列,并从那时起返回相同的值。

性能

前面所提到的字符串池,缓存等都是对性能的提升

substring方法在JDK6和7中的原理区别

substring(int beginIndex, int endIndex)方法截取字符串并返回其[beginIndex,endIndex-1]范围内的内容。

在JDK6中

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

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

在java源码中是这样表示的

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

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

导致的问题

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

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

在jdk7中

在jdk7中使用substring方法会在堆内存中创建一个新的数组

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

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

String对”+”的重载

有人把Java中使用+拼接字符串的功能理解为运算符重载。其实并不是,Java是不支持运算符重载的。这其实只是Java提供的一个语法糖。

运算符重载:在计算机程序设计中,运算符重载(英语:operator overloading)是多态的一种。运算符重载,就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。

语法糖:语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·兰丁发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。语法糖让程序更加简洁,有更高的可读性。

1
2
3
String wechat = "Alias";
String introduce = "good";
String hollis = wechat + "," + introduce;

反编译后

1
2
3
String wechat = "Alias";
String introduce = "\u6BCF\u65E5\u66F4\u65B0Java\u76F8\u5173\u6280\u672F\u6587\u7AE0";//good
String hollis = (new StringBuilder()).append(wechat).append(",").append(introduce).toString();

我们可以发现字符串常量在拼接的过程中是将String转换成了StringBuilder后使用其append方法进行处理的

那如果是两个固定的字面量拼接

1
String s = "a" + "b"

编译器会进行常量折叠(因为两个都是编译期常量,编译期可知),直接变成 String s = “ab”。

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

concat拼接

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

此时我们得到的已经是一个新的字符串了

源码:

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,这也就呼应到前面我们说的字符串的不变性问题上了。

“+”拼接

见上文

StringBuffer和StringBuilder

接下来我们看看StringBuffer和StringBuilder的实现原理。

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

1
char[] value;

String不同的是,它并不是final的,所以他是可以修改的。另外,与String不同,字符数组中不一定所有位置都已经被使用,它有一个实例变量,表示数组中已经使用的字符个数,定义如下:

1
int count;

其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会直接拷贝字符到内部的字符数组中,如果字符数组长度不够,会进行扩展。

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

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

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

评论