Java沉思录之重识String
从String类型的发展历程到源码浅析,以及一些常用方法、问题介绍,最后通过在线文档使用帮助更进一步了解细节。
概述
字符串类型是编程语言中最常见的一种基本类型了,当然可能有一些语言本身并没有实现这种类型,比如C语言。但是如果需要的话,我们完全能够自己创造一个这样的类型。 为什么要重识String呢?因为我发现很多时候越是基础的东西反而越重要,越底层的东西往往越复杂。如果没有把这些基础的知识搞懂的话,在平时的开发中可能就会出现一些不容易被发现的问题。因此,我们需要溯本追源,重新再认识这些基本知识。
从char到String
最早接触编程语言,是从C语言开始。C语言有char类型,但是并没有string类型。这是因为C语言是静态弱类型语言,意味着类型(包括 size 等信息,你使用 sizeof 就是编译时候得到的)在编译的时候就能确定下来。string类型的大小不能在编译的时候确定下来,因为你将存储 string 的长度不是固定的,可大可小。而且C语言可以通过指针更加灵活的分配内存,也可以通过struct自己构建一个string类,因此它也就没有在标准库里面去实现string类。但是这其实还是有点不太方便,因此后面的语言包括C++,Java都在标准库里面实现了string类。如果查看源码的话,我们可以知道其实Java中的String类也是通过char类型来实现。字符串本身就是一个一个字符连接起来的,再通过一定的算法来分配大小空间。
String的不可变性(Immutability)
什么是不可变性
字符串类型的最基本的一个特点就是不可变性。通过final关键字来修饰。那究竟什么是不可变性呢?我们初始化一个字符串:
String s = "abcd";
如图,若堆中原本没有字符串"abcd"对象,则会重新分配一块空间用于保存字符串"abcd"。变量s只是保存一个string对象的引用即字符串"abcd"在堆中的内存地址,变量s本身是存储在栈中的。
若将变量s赋值给另外一个变量s2:
String s2 = s;
则变量s2指向同一个字符串"abcd"对象的引用,即和变量s一样保存"abcd"的内存地址。
当我们改变字符串的内容呢?比如追加"ef"到"abcd"上变成"abcdef"。
s = s.concat("ef");
如图,那么实际是重新new了一个字符串对象"abcdef",在堆中又分配了一块地址用于存储。而变量s的也指向了新的地址。
所以,一旦在堆中创建了一个字符串,那么它就是不可变的。所有的String方法,比如substring(),replace(),toUpper(),join()等等其实是创建了一个新的对象。如果我们需要一个可变的字符串对象,那么可以使用StringBuffer或者StringBuilder。
为什么String要不可变
那么问题来了,为什么String类型要被设计成不可变的呢?主要有以下几个因素。
-
提高性能:
当一个String对象被创建的时候,如果堆中不存在实例,则会创建一个实例。如果已经存在,则实例的引用就会被返回,这样在很多大量重复的字符串处理的时候,就不需要创建很多新的对象,浪费内存空间。 -
缓存哈希码: 字符串的哈希码经常在很多情况下被使用,比如HashMap和HashSet。作为不可变性能保证哈希码的一致性,每次使用哈希码的时候不需要再重新计算。在String类中,有一个字段用来保存哈希码。
private int hash;//this is used to cache hash code.
-
方便其他类使用: 如果String类型是可变的,那么很多其他类再使用它的时候,可能会出现矛盾的情况。
HashSet<String> set = new HashSet<String>(); set.add(new String("a")); set.add(new String("b")); set.add(new String("c")); for(String a: set) a.value = "a";
虽然String类里面没有一个value的属性,这里只是举个例子。如果String类型是可变的,那么就违反了HashSet的设计原则:HashSet里面的每个元素必须是不同的。
-
安全性:
String在很多Java类中作为参数使用,包括网络连接,文件操作等。假设String是可变的,连接或者文件就可能被篡改,就将导致很严重的安全隐患。 -
线程安全:
由于String是不可变的,那么自然的它就可以在多线程中共享。而不用去考虑同步的一些处理。
总之,由于这些因素String被设计成是不可变的。这些因素也是一个类通常应当优选考虑设计成不可变的的原因。
设置不可变类的一些原则:
- 将类声明为final,所以它不能被继承。
- 将所有的成员声明为私有的,这样就不允许直接访问这些成员。
- 对变量不要提供setter方法。
- 将所有可变的成员声明为final,这样只能对它们赋值一次。
- 通过构造器初始化所有成员,进行深拷贝(deep copy)。
- 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝。
源码浅析
String对象分别实现了序列化接口Serializable,比较接口Comparable
常用方法
通过Java在线接口文档查看常用方法说明。
charAt(int)
返回char指定索引处的值。
compareTo(String)
按照字典顺序比较两个字符串,比较基于字符串中每个字符的Unicode值。如果字符串相等,结果为零。两个字符串长度相等,逐一比较每个字符的值,直到某个值不相等,返回第一个字符串与第二个字符串第一个不相等字符的值差异。若大于0,则表示第一个字符串在字典中顺序大于第二个字符串。反之亦然。如果对两个超长的字符串进行比较还是非常费时间的。
indexOf(String)
返回指定子字符串第一次出现在字符串中的索引。
concat(String)
将指定的字符串连接到该字符串的末尾。
首先使用Arrays.copyOf方法创建一个新的字符数组,并将长度扩大为
原字符串长度加上要连接的字符串长度。然后通过getChars方法,内部实际是通过调用System.arraycopy方法来实现数组的复制,返回一个新的字符数组,最后通过String的内部构造方法,返回一个新的字符串。
contains(CharSequence)
当且仅当此字符串包含指定的char值序列时才返回true。
内部通过indexOf(String)方法实现。
matches(String)
若字符串满足指定的正则表达式,返回true。
通过调用Pattern.matches(regex, String)方法实现。
replaceAll(String regex, String replacement)
用给定的替换替换与给定的正则表达式匹配的此字符串的每个子字符串。
Pattern.compile(regex).matcher(this)返回正则匹配器,在通过Matcher.replaceAll(String)替换所有匹配项。
split(String)
将此字符串分割为给定的正则表达式的匹配。
这块代码内部实现比较麻烦,这里简单说明一下用法。
-
若需要分割的字符是正则表达式的元字符".$|()[{^?*+\",则需要加上转义符合进行分割。比如以'|'分割的,要使用"\|"进行分割。
String a = "hhh|sdf|eee"; String arr[] = a.split("\\|"); for (String s : arr){ System.out.println(s); }
-
多个分隔符,使用'|'进行连接,比如
String a = "aaa=? and bbb=? or ccc=? and ddd=?"; String arr[] = a.split("and|or"); for (String s : arr){ System.out.println(s); }
使用"and|or"同时分割and和or的字符串。
-
直接使用正则表达式进行分割。
String a = "ab+cd^efgh---234---[[.....["; //以一个或多个任意不是字母,数字,下划线,汉字的字符作为分隔符,即上面的+、^、---、.....、[、[[符号均能作为分隔符 String arr[] = a.split("\\W+"); //输出[ab, cd, efgh, 234] System.out.println(Arrays.toString(arr));
substring(int beginIndex, int endIndex)
返回一个字符串,该字符串是此字符串的子字符串。substring在jdk7实现有变化。在jdk7之前,substring返回的对象是原字符串。
//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){
//check boundary
return new String(offset + beginIndex, endIndex - beginIndex, value);
}
当substring被调用后,它创建了一个新的String对象,当时新的String的value仍然指向同一个堆中的char数组。新的String对象和原来的String对象只是count和offset的不同。也就是表面上是创建了一个新的String对象,实际还是指向原来那个String对象。
而在jdk7以后,substring方法被调用后是真正地在堆中创建了一个新的char数组。
//JDK 7
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);
}
trim()
返回一个字符串,其值为此字符串,并删除任何前导和尾随空格。
valueOf(int)
返回int类型参数的字符串形式。内部通过Integer.toString()方法实现。这里另外补充一下,Integer.parseInt(String)和Integer.valueOf(String)以及类似的parseDouble(),parseLong()方法和valueOf()方法的区别。不同点在于parseInt返回int基本类型。而valueOf返回Integer对象。还有valueOf内部其实还是使用parseInt方法来实现的。
hash()
只说明一下String的哈希值递归算法:
h = s[0] + (s[1] * 31 + s[0]) + ((s[1] * 31 + s[0]) * 31 + s[2]) + ... + (h[n-2] * 31) + s[n-1]
= s[0] * 31 ^ (n - 1) + s[0] * 31 ^ (n - 2) + ... + s[0] * 31 ^ 0 + s[1] * 31 ^ (n - 2) + s[1] * 31 ^ (n - 3) + ... + s[1] * 31 ^ 0 + ... + s[n-1] * 31 ^ 0
= s[0] * 31 ^ (n - 1) + s[1] * 31 ^ (n - 2) + ... + s[n-1]
equals()
- 首先通过==判断要比较的对象是否在内存中相同地址,即是否同一个对象。若是则直接返回true。
- 接着判断要比较的对象首先是字符串对象,然后再从第一位字符开始比较,若中间有某一位字符不同直接返回false。
- 最后比较所有字符都相同的话,返回true。
常见问题
使用""还是构造器创建String对象
Java中创建String对象有两种方式:
String x = "abc";
String y = new String("abc");
这两种方式的主要区别就是,使用双引号""创建的对象,如果已经在堆中存在的话,那么就返回这个对象,否则就创建一个新的对象。而使用new创建的对象就会直接创建一个新的对象。
String a = "abcd";
String b = "abcd";
String c = new String("abcd");
System.out.println(a == b); // True
System.out.println(a.equals(b)); // True
System.out.println(a == c); // False
System.out.println(a.equals(c)); // True
String,StringBuffer,StringBuilder区别
因为String对象是不可变的,当我们需要大量频繁地修改字符串时,为了避免产生太多的String对象,可以使用可变类型的StringBuilder或StringBuffer。
三者在执行速度方面的比较:StringBuilder > StringBuffer > String。
StringBuilder:线程非安全的。JDK1.5引入的。
StringBuffer:线程安全的。有加锁开销,效率略低。
- 如果要操作少量的数据用String
- 单线程操作字符串缓冲区下操作大量数据用StringBuilder
- 多线程操作字符串缓冲区下操作大量数据用StringBuffer
现在jdk1.5以后的String使用+拼接字符串都是在内部使StringBuilder进行替换StringBuffer的线程安全的场景基本没有。
所以,字符串使用+拼接和使用StringBuilder本质上是一样的。