写在开头
String字符串作为一种引用类型,在Java中的地位举足轻重,也是代码中出现频率最高的一种数据结构,因此,我们需要像分析Object一样,将String作为一个topic,单独拿出来总结,这里面涉及到字符串的不可变性,字符串拼接、存储、比较、截取以及StringBuffer,StringBuilder区别等。
String类的源码
源码解读
想要真切的去了解Java中被定义好的一个类,读源码是最直接的方式,以经典的Java8为例(Java9之后,内部的实现数组类型从char改为了byte,目的用来节省内存空间),我们来看看Java中对于String是如何设计的。
public final class String implements java.io.Serializable,
Comparable<String>, CharSequence {
private final char value[];
//...
}
我们从源码中可以总结出如下几点内容:
1. String类被final关键字修饰,意味着它不可被继承;;
2. String的底层采用了final修饰的char数组,意味着它的不可变性;
3. 实现了Serializable接口意味着它支持序列化;
4. 实现了Comparable接口,意味着字符串的比较可以采用compareTo()方法,而不是==号,并且Sring类内部也重写了Object的equals()方法用来比较字符串相等。
String如何实现不可变得性?
从过源码我们可以看到类和char[]数组均被final关键字修饰,且数组的访问修饰符为private,访问权限仅限本类中。
final关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。但光用final修饰只能保证不被子类继承,不存在子类的破坏,char数组中的字符串仍然是可以改变的。
但,当底层实现的这个char[]被private修饰后,代表着它的私有化,且String没有对外提供修改这个字符串数组的方法,这才导致了它的不可变!
String如为什么要不可变?
那么问题来了,String为什么要设计成不可变的呢?我们都知道,不可变意味着,每次赋值其实就是创建一个新的字符串对象进行存储,这无疑带来了诸多不便。但相比于以下2点,那些不便似乎无关紧要了
1、String 类是最常用的类之一,为了效率,禁止被继承和重写
2、为了安全。String 类中有很多调用底层的本地方法,调用了操作系统的API,
如果方法可以重写,可能被植入恶意代码,破坏程序。其实Java 的安全性在这里就有一定的体现啦。
String类的方法
因为使用频率非常高,所以String内部提供很多操作字符串的方法,常用的如下:
equals:字符串是否相同
equalsIgnoreCase:忽略大小写后字符串是否相同
compareTo:根据字符串中每个字符的Unicode编码进行比较
compareToIgnoreCase:根据字符串中每个字符的Unicode编码进行忽略大小写比较
indexOf:目标字符或字符串在源字符串中位置下标
lastIndexOf:目标字符或字符串在源字符串中最后一次出现的位置下标
valueOf:其他类型转字符串
charAt:获取指定下标位置的字符
codePointAt:指定下标的字符的Unicode编码
concat:追加字符串到当前字符串
isEmpty:字符串长度是否为0
contains:是否包含目标字符串
startsWith:是否以目标字符串开头
endsWith:是否以目标字符串结束
format:格式化字符串
getBytes:获取字符串的字节数组
getChars:获取字符串的指定长度字符数组
toCharArray:获取字符串的字符数组
join:以某字符串,连接某字符串数组
length:字符串字符数
matches:字符串是否匹配正则表达式
replace:字符串替换
replaceAll:带正则字符串替换
replaceFirst:替换第一个出现的目标字符串
split:以某正则表达式分割字符串
substring:截取字符串
toLowerCase:字符串转小写
toUpperCase:字符串转大写
trim:去字符串首尾空格
方法有很多,无法一一讲解,我们挑选几个聊一聊哈
方法1、hashCode
由于Object中有hashCode()方法,所以所有的类中都有对应的方法,在String中做了如下的实现:
private int hash; // 缓存字符串的哈希码
public int hashCode() {
int h = hash; // 从缓存中获取哈希码
// 如果哈希码未被计算过(即为 0)且字符串不为空,则计算哈希码
if (h == 0 && value.length > 0) {
char val[] = value; // 获取字符串的字符数组
// 遍历字符串的每个字符来计算哈希码
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i]; // 使用 31 作为乘法因子
}
hash = h; // 缓存计算后的哈希码
}
return h; // 返回哈希码
}
先检核是否已计算哈希,若已计算则直接返回,否则根据31倍哈希法进行计算并缓存计算后的哈希值。String中重写后的hashCode方法,计算效率高,随机性强,哈希碰撞概率小,所以常被用作HashMap中的Key。
方法2、equals
我们在之前的文章中曾提到过重写equals方法往往也需要重写hashCode方法,这一点String就做到了,我们来看看String中equals()方法的实现:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
这是Java8中的实现,逻辑清晰易懂,首先,通过==判断是否是同一个对象,如果是则直接返回true,否则进入下一轮判断逻辑:判断对象是否为String类型,再判断两个字符串长度是否相等,再比较每个字符是否相等,全部为true最后返回true,其中有任何一个为flase则返回false。
方法3、substring
该方法在日常开发中时常被用到,主要用来截取字符串,源码:
public String substring(int beginIndex) {
// 检查起始索引是否小于 0,如果是,则抛出 StringIndexOutOfBoundsException 异常
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
// 计算子字符串的长度
int subLen = value.length - beginIndex;
// 检查子字符串长度是否为负数,如果是,则抛出 StringIndexOutOfBoundsException 异常
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 如果起始索引为 0,则返回原字符串;否则,创建并返回新的字符串
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
使用案例:
注意源码中注释提到的:如果 beginIndex 为 0,说明子串与原字符串相同,直接返回原字符串。否则,使用 value 数组(原字符串的字符数组)的一部分 new 一个新的 String 对象并返回。
String str = "Hello, world!";
String pre = str.substring(0);
System.out.println(pre);
String prefix = str.substring(0, 5);
System.out.println(prefix);
String suffix = str.substring(7);
System.out.println(suffix);
输出:
Hello, world!
Hello
world!
方法4、indexOf
indexOf的主要作用是获取目标字符或字符串在源字符串中位置下标,看源码:
/**
* 由 String 和 StringBuffer 共享的用于执行搜索的代码。这
* source 是正在搜索的字符数组,目标
* 是要搜索的字符串。
*
* @param正在搜索的字符的来源。
* @param源字符串的 sourceOffset 偏移量。
* @param源字符串的 sourceCount 计数。
* @param定位正在搜索的字符。
* @param目标字符串的 targetOffset 偏移量。
* @param目标字符串的 targetCount 计数。
* @param fromIndex 要开始搜索的索引。
*/
static int indexOf(char[] source, int sourceOffset, int sourceCount,
char[] target, int targetOffset, int targetCount,
int fromIndex) {
if (fromIndex >= sourceCount) {
return (targetCount == 0 ? sourceCount : -1);
}
if (fromIndex < 0) {
fromIndex = 0;
}
if (targetCount == 0) {
return fromIndex;
}
char first = target[targetOffset];
int max = sourceOffset + (sourceCount - targetCount);
for (int i = sourceOffset + fromIndex; i <= max; i++) {
/* Look for first character. */
if (source[i] != first) {
while (++i <= max && source[i] != first);
}
/* Found first character, now look at the rest of v2 */
if (i <= max) {
int j = i + 1;
int end = j + targetCount - 1;
for (int k = targetOffset + 1; j < end && source[j]
== target[k]; j++, k++);
if (j == end) {
/* Found whole string. */
return i - sourceOffset;
}
}
}
return -1;
}
使用案例一
String str = "Hello, world!";
int index = str.indexOf("wor"); // 查找 "world" 子字符串在 str 中第一次出现的位置
System.out.println(index); // 输出 7,字符串下标从0开始,空格也算一位
使用案例二
String str = "Hello, world!";
int index1 = str.indexOf("o"); // 查找 "o" 子字符串在 str 中第一次出现的位置
int index2 = str.indexOf("o", 5); // 从索引为5的位置开始查找 "o" 子字符串在 str 中第一次出现的位置
System.out.println(index1); // 输出 4
System.out.println(index2); // 输出 8
方法五、replace与replaceAll
话不多说,直接看码
///replace是字符和字符串的替换操作,基于字符匹配
public String replace(char oldChar, char newChar)
public String replace(CharSequence target, CharSequence replacement)
//replaceAll是基于正则表达式的字符串匹配与替换
public String replaceAll(String regex, String replacement)
使用案例:
String str = "Hello Java. Java is a language.";
//查找原字符串中所有Java子串,并用c进行替换
System.out.println(str.replace("Java", "c"));
//根据正则表达式匹配规则,.代表是任意字符 可以匹配任何单个字符
//所以经过正则匹配后,找出原字符串中所有“Java”+”任意一个字符”的子串,用c进行替换!
System.out.println(str.replaceAll("Java.", "c"));
输出:
Hello c. c is a language.
Hello c cis a language.
String类的使用
学以致用,学习的最终目的就是使用!
字符串常量池
搞清楚字符串常量池之前,我们先看如下这条语句,考你们一下,这行代码创建了几个对象?
String s1 = new String("abc");
这个答案并不是唯一的
第一种情况:若字符串常量池中不存在“abd”对象的引用,则语句会在堆中闯将2个对象,其中一个对象的引用保存到字符串常量池中。
这种情况下的字节码(JDK1.8)
ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。
第二种情况: 如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");
看到这里我们大致可以明白什么时字符串常量池,以及它的作用了:
字符串常量池是JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
字符串引用的存储
在上面的内容中,我们了解了字符串常量池,那么Java中是怎么将字符串的引用保存到常量池中的呢,这里我们需要提到String的intern()方法。
String.intern() 是一个native(本地)方法
其作用是将指定的字符串对象的引用保存在字符串常量池中。
若字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用;
若字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
我们看下面一段代码:
String s1 = new String("Hello") + new String("World");
String s2 = s1.intern();
System.out.println(s1 == s2);
你们觉得返回的是false还是true?如果还不明白,那么请看一下美团团队发布的一篇文章
美团技术团队深入解析 String.intern()
字符串的拼接
你是不是曾用过“+”进行字符串的拼接操作,比如说String res = "str" + "ing"; 。
,最终输出的就是string
出现这样的效果的原因是Java编译器的优化功能-常量折叠!
对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。
优化前:String res = "str" + "ing";
优化后:String res = "string";
但像对象引用这种情况,无法在编译其进行优化,我们看下面这段
String str1 = "str";
String str2 = "ing";
System.out.println(str1+str2);
字节码(JDK1.8)
通过字节码我们可以分析出,通过+号将几个对象引用进行拼接,实际上是调用StringBuilder().append(str1).append(str2).toString();
来实现的。
但有几个对象引用拼接,就会创建几个StringBuilder对象,浪费资源,因此,在做字符串拼接时直接采用StringBuilder实现!
String、StringBuffer,StringBuilder区别
相同点:
1、都可以储存和操作字符串
2、都使用 final 修饰,不能被继承
3、提供的 API 相似
异同点:
1、String 是只读字符串,String 对象内容是不能被改变的
2、StringBuffer 和 StringBuilder 的字符串对象可以对字符串内容进行修改,在修改后的内存地址不会发生改变
3、StringBuilder 线程不安全;StringBuffer 线程安全