1. 什么是String类
String类是Java标准类库中提供的一个预定义类,它用来表示Unicode字符序列。将Unicode字符序列使用双引号括起来,称为字符串,每一个字符串都是String类的一个对象实例。
2. Unicode、码元、码点、编码方式
2.1 为什么要有Unicode
前面解释String类是用来表示Unicode字符序列的,那么什么是编码?什么又是Unicode呢?
我们都知道,计算机存储数据是使用0101这样的二进制序列。而将这样的二进制序列转换为人眼可读的字符,就是所谓编码。
最早的计算机字符是采用ASCII编码的,全称American Standard Code for Information Interchange,即美国信息交换标准代码。它使用一个字节(总共有8位)的后7位来表示一个字符,第一位则统一为0,这样可以表示2的7次方,总共128个字符。针对英文国家,128个字符是足够的。
当计算机传播到一些西方其他国家,如法国,德国,128个字符是不够用的。
所以就出现了ISO-8859-1,它从ASCII码扩展而来,使用一个字节的全部8位来表示一个字符,总共可以表示256个字符。
当计算机传播到使用象形文字国家,如日本,中国等,ISO-8859-1的256个字符又不够了。由此又出现了诸如gb2312,GBK等编码。
这么多编码,那么不同地区的人们使用起来就没有一个统一的标准,为了解决这些编码间互不兼容的问题,Unicode就应运而生了。
2.2 码点与码元
码点
每一种编码方式都有一个编码表,类似一个字典,包含了这种编码方式下,每一个字符对应的代码值。
码点指的就是每一个字符对应的这个代码值。在Unicode中,码点采用十六进制,并且需要加上前缀U+,例如U+0041就是拉丁字母A的码点。
码元
2个字节称为一个代码单元(Code Unit),也就是码元。在Java中,char类型表示一个UTF-16编码的“一个码元”。
一个码点可以包含两个码元,即两个char。使用length方法得到的并不是码点的个数,而是码元的个数,所以使用length方法得到字符串长度(字符个数),可能是有误的。
可以使用codePointCount方法可以得到真正的码点个数。
charAt(n)方法可以得到某个位置的码元。
char a = str.charAt(0);//得到第一个位置的字符
codePointAt返回某个位置的码元。
int index = str.offsetByCodePoints(0,i);
int cp = str.codePointAt(index);
可以使用codePoints方法得到一个int值的码点流,再将它转换为一个数组。
int[] codePoints = str.codePoints().toArray();
可以使用一个码点数组来构造一个String对象。
String str = new String(codePoints,0,codePoints.length);
2.3 UTF-8与UTF-16编码
Unicode只是一个字符集,在这个字符集中,有一些字符是用1个字节就能表示的,有一些字符是需要2个字节,3个字节,最长的甚至需要4个字节,如何对这些字符进行存储就衍生出了不同的基于Unicode的编码方式。这里主要介绍一下UTF-8和UTF-16。
UTF-8
UTF-8 是目前互联网上使用最广泛的一种 Unicode 编码方式,它的最大特点就是可变长,根据字符的不同变换长度。编码规则如下:
- 对于单个字节的字符,第一位设为 0,后面的 7 位对应这个字符的 Unicode 码点(兼容ASCII码)。
- 对于需要使用 N 个字节来表示的字符(N > 1),第一个字节的前 N 位都设为 1,第 N + 1 位设为0,剩余的 N - 1 个字节的前两位都设为 10,剩下的二进制位则使用这个字符的 Unicode 码点来填充。
UTF-16
Unicode的码点并不是一次性定义的,而是分区定义的,每个区可以存放 65536 个(2的16次方)字符,每个区称为一个代码级别(英文为Plane)。目前(Unicode 3.0)已定义的代码级别总共有17个。第一个级别,码点从U+0000到U+FFFF,称为基本多语言级别(Basic Multilingual Plane,即BMP),这个级别包含了经典的常用的Unicode字符。剩余的16个级别称为辅助级别(Supplementary Multilingual Plane,SMP ),码点范围从 U+010000 到 U+10FFFF。
UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方法的特点。编码规则:基本多语言级别的字符占用 2 个字节,辅助级别的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000 到 U+FFFF),要么是 4 个字节(U+010000 到 U+10FFFF)。2个字节称为一个代码单元,也就是码元,可以看到,基本多语言级别的字符使用一个码元就可以表示,而辅助级别的字符则需要两个码元来表示。那么问题来了,当我们遇到一个码元时,到底是把这个码元当作一个基本多语言级别字符还是与后面的码元连起来当作一个辅助级别字符呢?
这里有一个很巧妙的设计,在基本多语言级别中从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符,而是用来映射辅助级别中的字符。其中U+D800至U+DBFF(共10位)用于映射第一个代码单元,U+DC00至U+DFFF(共10位)用于映射第二个代码单位,总共可以表示 个字符。用于表示辅助级别中的字符已经足够了。
在Java中,char类型表示一个UTF-16编码的“一个码元”。可见一个char无法表示一个辅助级别字符。
3.构造String对象
3.1 字符串常量池
最简单的构造一个String对象,就是直接使用字符串常量(或称“字面量”)。如下:
String name = "Jacky"; //此处将字面量Jacky赋于String对象name。
为了优化程序执行效率和节省存储空间,减少在JVM中创建的字符串的数量,在JVM运行时区域的方法区中,有一块区域称为运行时常量池,主要用来存储编译期生成的各种字面量。这些字面量在JVM内是共享的,相同字面量值的字符串在这个常量池中只存储一份。
3.2 在堆中创建字符串
使用new方法也可以构造String对象
String name = new String("Jacky");
它会在堆中新建一个String对象,并且copy字符串常量池中的“Jacky”字面量的值。
事实上,这种copy是一种浪费,我们完全可以直接使用字符串常量的方式,直接指向常量池中已存在的对象。
再来看一个小例子:
String name = "Jacky";
String sameName = new String("Jacky");
System.out.println(name == sameName);
上例程序片断的输出结果是
false
因为变量name指向的是JVM方法区常量池中的字符串对象,而sameName指向的是JVM堆内存中的字符串对象,虽然二者字面值是相同的,但是却是不同的两个对象。
image.png
注意:
常量池中的字符串对象是在编译期就确定好了的,在类被加载的时候创建的,如果类加载时,该字面量在常量池中已经有了,那这一步就省略了。
堆中的对象是在运行期才确定的,在代码执行到new的时候创建的。
4. String的不可变性及其带来的好处
String是不可变,它一旦被实例化就无法被修改。
为什么要设计成不可变的?归根结底,有以下好处:
4.1 共享字符串常量
前面我们提到JVM为了提高性能和节省空间,提供了字符串常量池统一存储字符串常量,它可以让多个线程共享JVM中同一字面值的字符串。这是String不可变性带来的好处之一。试想一下,如果String是可变的,那么当一个线程修改一个字符串时,其他共享此字符串的线程都会受到影响。
4.2 线程安全
不可变的对象是天生线程安全的,它们可以自由地在多个线程之间共享。不需要任何同步处理。
4.3 安全性
String被广泛的使用在其他Java类中充当参数。比如网络连接、打开文件等操作。如果字符串可变,那么类似操作可能导致安全问题。因为某个方法在调用连接操作的时候,他认为会连接到某台机器,但是实际上并没有(其他引用同一String对象的值修改会导致该连接中的字符串内容被修改)。可变的字符串也可能导致反射的安全问题,因为他的参数也是字符串。
5. String的intern方法
常量池中的字符串对象是在编译期就确定好的了,而使用intern方法,可以在运行时向常量池中添加字符串对象。
看下面的代码片断:
String name = "Jacky";
String name1 = new String("Jacky");
String name2 = new String("Jacky").intern();
System.out.println(name1 == name);
System.out.println(name2 == name);
输出结果是:
false
true
name指向常量池中的对象,而name1指向的是堆中的对象,所以二者不相等。
name2是使用intern方法返回的,它也是指向常量池中的对象的,所以二者相等。
具体来说,当调用字符串的intern方法时,如果常量池中存在这个字符串,则返回常量池中这个字符串的地址。如果常量池中不存在这个字符串,则将这个字符串放到池中再返回这个对象的地址。
6. 比较字符串
不要使用==运算符来比较两个字符串是否相等。这个运算符是用来比较对象的内存地址的,它只能用来确定两个字符串是否存放在同一个内存位置上。你会说,同一个内存位置上的字符串不就是相同的字符串吗?是的,但是也有可能,多个相同的字符串会分别存放在不同的内存地址上。事实上,只有字符串常量是共享的,而+或者substring等操作产生的结果并不是共享的。
比如,
String greeting = "Hello";
if(greeting.substring(0,3)=="Hel"){
//很有可能就是不相等的
}
所以要使用equals方法来对字符串进行比较。
7. 截取字符串
String提供了substring(int beginIndex, int endIndex)方法用来截取一定区间内的字符串。
比如:
String x = "abcdef";
x = x.substring(1,3);
System.out.println(x);
输出:
bc
你可能知道,因为x是不可变的,当使用x.substring(1,3)对x赋值的时候,它会指向一个全新的字符串:
string-immutability1-650x303.jpeg
然而,这个图不是完全正确的表示堆中发生的事情。因为在jdk6 和 jdk7中调用substring时发生的事情并不一样。
JDK 6中的substring
String是通过字符数组实现的。在jdk 6 中,String类包含三个成员变量:char value[], int offset,int count。他们分别用来存储真正的字符数组,数组的第一个位置索引以及字符串中包含的字符个数。
当调用substring方法的时候,会创建一个新的string对象,但是这个string的值仍然指向堆中的同一个字符数组。这两个对象中只有count和offset 的值是不同的。
x = x.substring(x, y) + ""
下面是证明上说观点的Java源码中的关键代码:
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中,一般用以下方式来解决该问题,原理其实就是生成一个新的字符串并引用他。
x = x.substring(x, y) + ""
JDK 7 中的substring
在jdk 7 中,substring方法会在堆内存中创建一个新的数组。
string-substring-jdk71-650x389.jpeg
Java源码中关于这部分的主要代码如下:
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);
}
以上是JDK 7中的subString方法,其使用new String创建了一个新字符串,避免对老字符串的引用。从而解决了内存泄露问题。
8. 连接字符串
8.1 +号连接字符串
String类对操作符+进行了重载,使它能够完成两个字符串的拼接。
比如:
String greeting = "Hello"+"Jacky";
当+号后都是字符串常量时,拼接后的也是一个字符串常量,即它会从常量池中寻找此字符串。
当+号后是对象时,它会调用此对象的toString方法得到一个字符串对象,再进行拼接,这样生成的字符串对象则是存放在堆里的。可见下例片断:
String greeting = "Hello"+"Jacky";
String name = "Jacky";
String greeting2 = "Hello"+name;
System.out.println(greeting==greeting2);
输出:
false
8.2 StringBuilder和StringBuffer连接字符串
使用+号连接字符串,会产生大量的中间字符串对象,这些中间结果对象没有意义且占用内存空间。
特别是在循环体中拼接多个字符串时,性能极其低下。
如下例:
String string = "";
for(int i=0;i<10000;i++){
string += "hello";
}
反编译其字节码文件:
091056535456212.jpg
可以看出:string+="hello"的操作事实上会自动被JVM优化成:
StringBuilder str = new StringBuilder(string);
str.append("hello");
str.toString();
但是也可以看出,每一次循环都会创建一个StringBuilder,也就是说会创建10000个对象,造成大量内存浪费。
更好的做法是,当遇到大量字符串拼接时,应该使用StringBuilder批量添加,再一次性转换为String对象,避免创建大量中间结果对象和多余的对象拷贝。上例可修改成:
StringBuilder stringBuilder = new StringBuilder();
for(int i=0;i<10000;i++){
stringBuilder.append("hello");
}
String result = stringBuilder.toString();
StringBuffer与StringBuilder功能是相同的,区别只是StringBuffer中所有的修改对象的方法都是synchronized修饰的,也就是线程安全的。
如果不考虑线程安全,StringBuilder性能要比StringBuffer高。
参考资料:
网友评论