- Java修炼指南:高频源码解析
- 开课吧组编 曹子方 杨富杰 刘常凯等编著
- 3818字
- 2021-04-22 17:10:50
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一定要用心理解。