1.4 日常编码中最常用的类——String类

String类也是java. lang包下的一个类,属于日常编码中最常用的一个类,本节就来详细介绍String类。

1.4.1 字段属性

一个String字符串实际上是一个char数组。

1.4.2 构造方法

String类的构造方法很多。可以通过初始化一个字符串,或者字符数组,或者字节数组等来创建一个String对象,如图1-10所示。

●图1-10 String类的构造方法

1.4.3 equals(Object anObject)方法

String类重写了equals方法,比较的是组成字符串的每一个字符是否相同,如果都相同则返回true,否则返回false。详细代码已经在1. 1. 3节equals方法中列出过。

1.4.4 hashCode()方法

String类的hashCode方法并不是很复杂,但是源码中有一个奇怪的数字31。这个数字不是用常量声明的,所以没法从字面意思上推断这个数字的用途。下面来揭开数字31的用途之谜。

在详细说明StringhashCode方法选择数字31作为乘子的原因之前,先来看看String hashCode方法是怎样实现的,代码如下所示。

上面的代码就是String hashCode方法的实现,但hashCode方法核心的计算逻辑只有三行,也就是代码中的for循环。可以由上面的for循环推导出一个计算公式,hashCode方法注释中已经给出。如下:

说明一下,上面的s数组即源码中的val数组,是String内部维护的一个char类型数组。简单推导一下这个公式:

接下来关注的重点,即选择31的理由。主要原因有两个:

1)31是一个不大不小的质数,是作为hashCode乘子的优选质数之一。另外一些相近的质数,比如37、41、43等,也都是不错的选择。那么为什么选中了31呢?请看第二个原因。

2)31可以被JVM优化,31 ∗ i =(i<<5)- i。

上面说到,31是一个不大不小的质数,是优选乘子。那为什么同是质数的2和101(或者更大的质数)就不是优选乘子呢,分析如下。

这里先分析质数2。首先,假设n = 6,然后把质数2和n代入上面的计算公式。并仅计算公式中次数最高的那一项,结果是2^5 = 32,是很小的。所以这里可以断定,当字符串长度不是很长时,用质数2作为乘子算出的哈希值,数值不会很大。也就是说,哈希值会分布在一个较小的数值区间内,分布性不佳,最终可能会导致冲突率上升。

质数2作为乘子会导致哈希值分布在一个较小区间内,那么如果用一个较大的质数101会产生什么样的结果呢?根据上面的分析,猜想读者应该可以猜出结果了。就是不用再担心哈希值会分布在一个小的区间内了,因为101^5 = 10,510,100,501。但是要注意的是,这个计算结果太大了。如果用int类型表示哈希值,结果会溢出,最终导致数值信息丢失。尽管数值信息丢失并不一定会导致冲突率上升,但是暂且先认为质数101(或者更大的质数)也不是很好的选择。最后,再来看看质数31的计算结果:31^5 = 28629151,结果值大小适中。

上面用了比较简陋的数学手段证明了数字31是一个不大不小的质数,是作为hashCode乘子的优选质数之一。

1.4.5 charAt(int index)方法

一个字符串是由一个字符数组组成,这个方法是通过传入的索引(数组下标),返回指定索引的单个字符。

1.4.6 compareTo(String anotherString)和compareToIgnoreCase(String str)方法

先看看compareTo方法:

该方法源码很好理解,即按字母顺序比较两个字符串,是基于字符串中每个字符的Unicode值。当两个字符串某个位置的字符不同时,返回的是这一位置的字符Unicode值之差,当两个字符串都相同时,则返回两个字符串长度之差。

compareToIgnoreCase()方法是在compareTo方法的基础上忽略大小写,大写字母是比小写字母的Unicode值小32的,底层实现是先都转换成大写比较,然后都转换成小写进行比较。

1.4.7 concat(String str)方法

该方法是将指定的字符串连接到此字符串的末尾。

首先判断要拼接的字符串长度是否为0,如果为0,则直接返回原字符串。如果不为0,则通过Arrays工具类(后面会详细介绍这个工具类)的copyOf方法创建一个新的字符数组,长度为原字符串和要拼接的字符串之和,前面填充原字符串,后面为空。接着再通过getChars方法将要拼接的字符串放入新字符串后面为空的位置。

注意:

返回值是new String(buf,true),也就是重新通过new关键字创建了一个新的字符串,原字符串是不变的。这也是前面说的一旦一个String对象被创建,包含在这个对象中的字符序列是不可改变的。

1.4.8 indexOf(int ch)和indexOf(int ch,int fromIndex)方法

indexOf(int ch),参数ch其实是字符的Unicode值,这里也可以放单个字符(默认转成int),作用是返回指定字符第一次出现的此字符串中的索引。其内部是调用indexOf(int ch,int fromIndex),只不过这里的fromIndex=0,因为是从 0开始搜索;而indexOf(int ch,int fromIndex)作用也是返回首次出现的此字符串内的索引,但是从指定索引处开始搜索。

1.4.9 split(String regex)和split(String regex,int limit)方法

split(Stringregex)将该字符串拆分为给定正则表达式的匹配。split(String regex,int limit)也是一样,不过对于limit的取值有三种情况:

1)limit>0,则pattern(模式)应用n-1次。

2)limit = 0,则pattern(模式)应用无限次并且省略末尾的空字串。

3)limit<0,则pattern(模式)应用无限次。

下面看看底层的源码实现。对于split(String regex),其内部调用split(regex,0)方法。

重点看split(Stringregex,int limit)的方法实现。

1.4.10 replace(char oldChar,char newChar)和String replaceAll(String regex,String replacement)方法

1)replace(char oldChar,char newChar):将原字符串中所有的oldChar字符都替换成newChar字符,返回一个新的字符串。

2)String replaceAll(String regex,String replacement):将匹配正则表达式regex的匹配项都替换成replacement字符串,返回一个新的字符串。

1.4.11 substring(int beginIndex)和substring(int beginIndex,intendIndex)方法

1)substring(int beginIndex):返回一个从索引beginIndex开始一直到结尾的子字符串。

2)substring(intbeginIndex,int endIndex):返回一个从索引beginIndex开始,到endIndex结尾的子字符串。

1.4.12 常量池

在前面讲解构造函数的时候,明确了最常见的两种声明一个字符串对象的形式:

1)通过“字面量”的形式直接赋值。

2)通过new关键字调用构造函数创建对象。

那么这两种声明方式有什么区别呢?在讲解之前,先介绍JDK 1. 7(不包括1. 7)以前的JVM的内存分布,如图1-11所示。

●图1-11 JDK 1. 6及以前JVM内存划分

1)程序计数器:也称为PC寄存器,保存的是程序当前执行的指令的地址(即保存下一条指令所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。线程私有。

2)虚拟机栈:基本数据类型、对象的引用都存放在这。线程私有。

3)本地方法栈:虚拟机栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构做强制规定,虚拟机可以自由实现它。如在HotSopt虚拟机中直接把本地方法栈和虚拟机栈合二为一。

4)方法区:存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。注意:在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

5)堆:用来存储对象本身以及数组(数组引用是存放在Java栈中的)。在JDK 1. 7以后,方法区的常量池被移除放到堆中了,如图1-12所示。

注意:

图1-11中红色的箭头,通过new关键字创建的字符串对象,如果常量池中存在了,会将堆中创建的对象指向常量池的引用。可以通过本节末尾介绍的intern()方法来验证。

使用包含变量表达式创建对象:

●图1-12 JDK 1. 7及以后常量池在JVM堆中

str3由于含有变量str1,编译器不能确定是常量,会在堆区中创建一个String对象。而str4是两个常量相加,直接引用常量池中的对象即可。

1.4.13 intern()方法

这是一个本地方法,定义如下:

当调用intern方法时,如果池中已经包含一个与该String确定的字符串相同equals(Object)的字符串,则返回该字符串。否则,将此String对象添加到池中,并返回此对象的引用。

这句话表明了调用一个String对象的intern()方法,如果常量池中有该对象了,直接返回该字符串的引用(存在堆中就返回堆中,存在池中就返回池中),如果没有,则将该对象添加到池中,并返回池中的引用。如图1-13所示。

●图1-13 JDK String intren方法

1.4.14 String真的不可变吗

前面介绍了String类是用final关键字修饰的,所以可以认为其是不可变对象。但是真的不可变吗?

每个字符串都是由许多单个字符组成的,其源码是由char[] value字符数组构成。

value被final修饰,只能保证引用不被改变,但是value所指向的堆中的数组,才是真实的数据,只要能够操作堆中的数组,依旧能改变数据。而且value是基本类型构成,那么一定是可变的,即使被声明为private,也可以通过反射来改变。

通过前后两次打印的结果,可以看到String被改变了,但是在代码里,几乎不会使用反射的机制去操作String字符串,所以,会认为String类型是不可变的。

那么,String类为什么要这样设计成不可变呢?可以从性能以及安全方面来考虑:

1)引发安全问题。譬如数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,或者在Socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客可以改变字符串指向的对象的值,造成安全漏洞。

2)保证线程安全。在并发场景下,多个线程同时读写资源时,会引竞态条件,由于String是不可变的,不会引发线程的问题而保证了线程。

hashCode,当String被创建出来的时候,hashCode也会随之被缓存,hashCode的计算与value有关,若String可变,那么hashCode也会随之变化,针对Map、Set等容器,它们的键值需要保证唯一性和一致性,因此,String的不可变性使其比其他对象更适合当容器的键值。

当字符串是不可变时,字符串常量池才有意义。字符串常量池的出现,可以减少创建相同字面量的字符串,让不同的引用指向池中同一个字符串,为运行时节约很多的堆内存。若字符串可变,则字符串常量池失去意义,基于常量池的String. intern()方法也失效,每次创建新的String将在堆内开辟出新的空间,占据更多的内存。

关于Java基础类:8种基本数据类型的包装类,本书以Integer类为例。8种基本数据类型的包装类除了Character和Boolean没有继承该类外,剩下的都继承了Number类,方法也很类似就不在此一一介绍。而Object贯穿了整个Java,学习Java一定要用心理解。