在 Java 开发中,String 类是日常使用频率极高的类之一。从简单的字符串拼接到复杂的文本处理,都离不开它的身影。但很多时候,我们只是停留在'会用'的层面,对其底层源码的设计逻辑和特性了解甚少。
一、String 类的核心特性:不可变性
打开 JDK 源码,我们会发现 String 类被 final 关键字修饰,这意味着 String 类不能被继承。同时,其内部存储字符串的成员变量 value(在 JDK 1.8 及之前为 char[] 类型,JDK 1.9 之后改为 byte[] 类型)也被 private final 修饰。这两个关键设计,共同决定了 String 类的核心特性——不可变性。
// JDK 1.8 String 类核心成员变量
private final char value[];
// JDK 1.9 String 类核心成员变量
private final byte[] value;
private final byte coder; // 用于标识编码格式,0 表示 Latin-1,1 表示 UTF-16
不可变性意味着一旦 String 对象被创建,其内部的字符序列就无法被修改。比如执行 String str = "abc"; str += "d"; 时,看似是修改了 str 的值,实则是创建了一个新的 String 对象 "abcd",并将 str 的引用指向了这个新对象,原对象 "abc" 依然存在于内存中(后续会被垃圾回收机制处理)。
这种设计带来了诸多好处:
- 线程安全:由于对象不可修改,多线程环境下无需担心线程安全问题,无需额外加锁。
- 可缓存哈希值:String 类的 hashCode() 方法会根据 value 计算哈希值,由于 value 不可变,哈希值只需计算一次并缓存起来,后续调用 hashCode() 可直接返回,提升效率(这也是 HashMap 等集合中常用 String 作为键的重要原因)。
- 常量池复用:Java 中的字符串常量池(String Pool)可以复用相同内容的 String 对象,减少内存占用。比如
String a = "abc"; String b = "abc";,a 和 b 会指向常量池中同一个对象。
二、底层存储的演变:从 char[] 到 byte[]
在 JDK 1.8 及之前,String 类使用 char[] 存储字符,每个 char 占 2 个字节(UTF-16 编码)。但在实际开发中,大部分字符串是由 Latin-1 字符(如英文字母、数字、常见符号)组成,这类字符只需 1 个字节就能存储。使用 char[] 存储会造成大量内存浪费。
为了解决这个问题,JDK 1.9 对 String 的底层存储进行了优化,将 char[] 改为 byte[],并新增了 coder 成员变量来标识编码格式:
- 当 coder = 0 时,使用 Latin-1 编码,每个字符占 1 个字节,适用于存储英文字符等。
- 当 coder = 1 时,使用 UTF-16 编码,每个字符占 2 个字节,适用于存储中文、特殊符号等。
这种自适应编码的设计,在保证功能不受影响的前提下,大幅减少了内存占用,尤其在处理大量英文字符串的场景下,优化效果显著。
三、常用方法的源码解析
1. equals() 方法:判断字符串内容是否相等
String 类重写了 Object 类的 equals() 方法,其核心逻辑是逐字符比较两个字符串的内容是否一致,具体步骤如下:
- 先判断两个对象的引用是否相同,若相同直接返回 true。
- 判断传入的对象是否为 String 类型,若不是直接返回 false。
- 比较两个字符串的长度和编码格式(JDK 1.9 及之后),若不一致返回 false。
- 逐字符比较两个字符串的 value 数组,若所有字符都相同则返回 true,否则返回 false。
// JDK 1.9 String 类 equals() 方法核心代码
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String) anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value) : StringUTF16.equals(value, aString.value);
}
}
return false;
}
2. hashCode() 方法:计算字符串的哈希值
String 类的 hashCode() 方法根据 value 数组的内容计算哈希值,计算公式为:hash = 31 * hash + val[i](其中 val[i] 是字符串第 i 个字符的 ASCII 值)。选择 31 作为乘数,是因为 31 是一个质数,且 31 * x 可以被优化为 (x << 5) - x(移位运算比乘法运算效率更高),同时 31 能有效减少哈希冲突。
// JDK 1.8 String 类 hashCode() 方法
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; // 返回哈希码
}
3. substring() 方法:截取字符串(JDK 1.7 前后差异)
在 JDK 1.7 之前,substring() 方法通过创建新的 String 对象,并共享原对象的 value 数组,只修改新对象的 offset(偏移量)和 count(长度)来实现截取。这种设计虽然高效,但会导致原对象的 value 数组无法被垃圾回收(即使原对象不再使用,只要截取后的对象存在,value 数组就会被引用),容易造成内存泄漏。
JDK 1.7 及之后,substring() 方法进行了优化,不再共享原对象的 value 数组,而是通过复制原数组的指定部分来创建新的 value 数组,从根本上解决了内存泄漏问题。
// JDK 1.8 String 类 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);
}
4. indexOf() 方法:查找子串在原字符串内的索引
indexOf 方法用于查找一个子字符串在原字符串中第一次出现的位置,并返回该位置的索引。来看该方法的源码:
/*
* 查找字符数组 target 在字符数组 source 中第一次出现的位置。
* sourceOffset 和 sourceCount 参数指定 source 数组中要搜索的范围,
* targetOffset 和 targetCount 参数指定 target 数组中要搜索的范围,
* fromIndex 参数指定开始搜索的位置。
* 如果找到了 target 数组,则返回它在 source 数组中的位置索引(从 0 开始),
* 否则返回 -1。
*/
static int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex) {
// 如果开始搜索的位置已经超出 source 数组的范围,则直接返回 -1(如果 target 数组为空,则返回 sourceCount)
if (fromIndex >= sourceCount) {
return (targetCount == 0 ? sourceCount : -1);
}
// 如果开始搜索的位置小于 0,则从 0 开始搜索
if (fromIndex < 0) { fromIndex = 0; }
// 如果 target 数组为空,则直接返回开始搜索的位置
if (targetCount == 0) { return fromIndex; }
// 查找 target 数组的第一个字符在 source 数组中的位置
char first = target[targetOffset];
int max = sourceOffset + (sourceCount - targetCount);
// 循环查找 target 数组在 source 数组中的位置
for (int i = sourceOffset + fromIndex; i <= max; i++) {
/* Look for first character.
*/
// 如果 source 数组中当前位置的字符不是 target 数组的第一个字符,则在 source 数组中继续查找 target 数组的第一个字符
if (source[i] != first) {
while (++i <= max && source[i] != first);
}
/* Found first character, now look at the rest of v2
*/
// 如果在 source 数组中找到了 target 数组的第一个字符,则继续查找 target 数组的剩余部分是否匹配
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++);
// 如果 target 数组全部匹配,则返回在 source 数组中的位置索引
if (j == end) {
/* Found whole string.
*/
return i - sourceOffset;
}
}
}
// 没有找到 target 数组,则返回 -1
return -1;
}
四、开发中的注意事项
- 避免频繁字符串拼接:由于 String 的不可变性,使用 + 进行频繁字符串拼接会创建大量临时对象,导致内存浪费和性能下降。此时应使用 StringBuilder(非线程安全,效率高)或 StringBuffer(线程安全,效率较低)。
- 合理使用 intern() 方法:intern() 方法会将字符串对象加入常量池(若常量池中不存在该对象),并返回常量池中的引用。合理使用 intern() 可以减少重复对象的创建,但过度使用会增加常量池的负担,需根据实际场景权衡。
- 区分空字符串与 null:"" 是一个长度为 0 的 String 对象,其 value 数组长度为 0;而 null 表示没有引用任何对象。在使用 String 对象前,需注意判断是否为 null,避免空指针异常。


