一、String 类的基本概念
String类位于java.lang包下,在使用时无需显式导入,是 Java 语言中的一个不可变类。这意味着一旦一个String对象被创建,它的值就不能被修改。当我们对字符串进行拼接、截取等操作时,实际上是创建了一个新的String对象,而原来的对象保持不变。
例如,执行以下代码:
String str = "hello";
str = str + " world";
这里,最初创建的"hello"对象并没有被修改,而是创建了一个新的"hello world"对象,并将str引用指向了这个新对象。
二、String 类的特性
1. 不可变性
String类的不可变性是其最核心的特性,这种特性带来了以下重要优势:
-
线程安全:由于String对象一旦创建就不可修改,多个线程可以安全地并发访问同一个String对象,不需要进行额外的同步处理(如synchronized关键字或Lock机制)。这在多线程环境下极大地简化了字符串操作的复杂性。
-
缓存优化:
- String类的常量池机制完全依赖于其不可变性
- JVM可以安全地缓存字符串字面量,因为知道它们永远不会被修改
- 这种机制能够显著减少内存占用,提高访问效率
- 例如,在Web应用中处理大量重复的HTTP请求参数时,这种优化效果尤为明显
-
哈希码缓存:
- String对象在第一次调用hashCode()方法时计算并缓存其哈希码
- 由于字符串内容不可变,哈希码也永远不会改变
- 后续的哈希码获取操作直接返回缓存值,使哈希表操作更加高效
- 这对于使用String作为HashMap键的场景特别重要,能显著提升查找性能
2. 常量池机制
Java为了提高字符串的复用性和节省内存,设计了字符串常量池这一重要机制:
-
字面量创建:
- 当使用双引号创建字符串时,JVM会执行常量池检查
- 检查步骤:
- 查找常量池中是否存在完全相同的字符串
- 如果存在,直接返回该字符串的引用
- 如果不存在,在常量池中创建新字符串并返回引用
- 这种机制保证了相同字面量的字符串在内存中只有一份拷贝
示例代码:
String str1 = "hello"; // 首次创建,加入常量池 String str2 = "hello"; // 复用常量池中的"hello" System.out.println(str1 == str2); // 输出true,因为引用相同
-
new关键字创建:
- 使用new String()会强制在堆内存中创建新对象
- 即使常量池中存在相同内容的字符串,也会创建新的独立对象
- 这种创建方式会消耗更多内存,通常应避免使用
示例代码:
String str3 = new String("hello"); // 堆中新对象 String str4 = "hello"; // 常量池中的对象 System.out.println(str3 == str4); // 输出false,因为引用不同
-
intern()方法:
- 可以将堆中的字符串对象"入驻"到常量池
- 如果常量池已存在相同字符串,返回池中的引用
- 如果不存在,将字符串添加到池中并返回引用
3. 值传递特性
在Java的方法参数传递机制中,String类型遵循值传递规则:
-
传递机制:
- 传递的是字符串对象的引用值(即内存地址的拷贝)
- 方法内部获得的参数是原始引用的副本
- 对参数赋新值只会改变副本的指向,不影响原始引用
-
示例分析:
public static void changeString(String str) { // 这里的str是main方法中str引用的副本 str = "world"; // 只改变了副本的指向 } public static void main(String[] args) { String str = "hello"; // 原始引用指向"hello" changeString(str); // 传递引用副本 System.out.println(str); // 仍然输出"hello" }
-
与可变对象的区别:
- 对于StringBuilder等可变对象,虽然也是值传递
- 但通过引用副本修改对象内容会影响原始对象
- 而String的不可变性确保了内容永远不会被修改
这种特性使得String作为方法参数时行为完全可预测,不会产生意外的副作用,这在大型项目维护中尤为重要。
三、String 类的常用方法
1. 字符串长度
length()
方法是 String 类最基础的方法之一,用于返回字符串中包含的字符数量(包括空格、标点等所有可见和不可见字符)。该方法返回的是 int 类型的值,表示字符串的实际长度。
基础用法示例:
String str = "hello world";
System.out.println(str.length()); // 输出11,包括中间的空格
重要特性与注意事项:
- 字符串长度是从1开始计数的,而字符串索引是从0开始的
- 对于空字符串
""
,length()
方法会返回0 - 字符串中包含的Unicode代理对会被视为两个字符
- 字符串中的转义字符(如
\n
、\t
等)会被视为单个字符 - 字符串中的Unicode补充字符(如emoji)可能占用2个char空间
特殊场景示例:
String emptyStr = "";
System.out.println(emptyStr.length()); // 输出0
String emoji = "😊";
System.out.println(emoji.length()); // 输出2,因为emoji是代理对
String escapeStr = "hello\nworld";
System.out.println(escapeStr.length()); // 输出11(\n算作1个字符)
2. 字符串比较
Java 提供了多种字符串比较方法,适用于不同场景:
常用比较方法:
equals(Object anObject)
:严格比较两个字符串的内容是否完全相同,区分大小写equalsIgnoreCase(String anotherString)
:比较字符串内容,忽略大小写差异compareTo(String anotherString)
:基于Unicode值进行字典序比较contentEquals(CharSequence cs)
:与任何实现了CharSequence接口的对象比较内容
String str1 = "hello";
String str2 = "Hello";
System.out.println(str1.equals(str2)); // 输出false
System.out.println(str1.equalsIgnoreCase(str2)); // 输出true
System.out.println(str1.compareTo(str2)); // 输出32('h'和'H'的ASCII码差)
应用场景分析
用户登录验证:
String inputUsername = "Admin";
String storedUsername = "admin";
if(inputUsername.equalsIgnoreCase(storedUsername)) {
// 验证通过
System.out.println("用户名匹配");
}
排序算法实现:
String[] names = {"Alice", "bob", "Charlie"};
// 使用compareToIgnoreCase进行不区分大小写的排序
Arrays.sort(names, String::compareToIgnoreCase);
System.out.println(Arrays.toString(names)); // 输出[Alice, bob, Charlie]
敏感数据校验:
String inputPassword = "secret123";
String correctPassword = "Secret123";
if(inputPassword.equals(correctPassword)) {
System.out.println("密码正确");
} else {
System.out.println("密码错误");
}
3. 字符串查找
字符串查找方法常用于文本处理和数据分析:
核心查找方法:
charAt(int index)
:获取特定位置的字符indexOf(String str)
:正向查找子串,返回首次出现的位置lastIndexOf(String str)
:反向查找子串,返回最后一次出现的位置contains(CharSequence s)
:判断是否包含指定字符序列
String str = "hello world";
System.out.println(str.charAt(1)); // 输出'e'(索引为1的字符)
System.out.println(str.indexOf("l")); // 输出2(第一个'l'的位置)
System.out.println(str.lastIndexOf("l")); // 输出9(最后一个'l'的位置)
System.out.println(str.contains("world")); // 输出true
实际应用场景
解析URL路径:
String url = "https://example.com/products?id=123";
int queryStart = url.indexOf("?");
if(queryStart != -1) {
String query = url.substring(queryStart + 1);
System.out.println("查询参数: " + query); // 输出"id=123"
}
提取特定格式的数据:
String data = "Name:John,Age:30,City:New York";
int ageStart = data.indexOf("Age:") + 4;
int ageEnd = data.indexOf(",", ageStart);
String age = data.substring(ageStart, ageEnd);
System.out.println("年龄: " + age); // 输出"30"
验证字符串格式:
String email = "user@example.com";
if(email.indexOf("@") != -1 && email.indexOf(".") > email.indexOf("@")) {
System.out.println("邮箱格式基本有效");
} else {
System.out.println("邮箱格式无效");
}
4. 字符串截取
substring()
方法用于提取字符串的特定部分:
基本用法:
String str = "hello world";
System.out.println(str.substring(6)); // 输出"world"(从索引6到末尾)
System.out.println(str.substring(0, 5)); // 输出"hello"(索引0到4)
关键注意事项:
- 开始索引包含在结果中
- 结束索引不包含在结果中
- 如果索引越界会抛出
StringIndexOutOfBoundsException
- Java 7之前,
substring
会共享原字符串的char数组,可能导致内存泄漏
示例说明:
// 提取文件扩展名
String filename = "document.pdf";
int dotIndex = filename.lastIndexOf(".");
if(dotIndex != -1) {
String extension = filename.substring(dotIndex + 1);
System.out.println("文件扩展名: " + extension); // 输出"pdf"
}
// 错误处理示例
try {
String part = str.substring(0, 20); // 可能抛出异常
} catch (StringIndexOutOfBoundsException e) {
System.out.println("索引越界: " + e.getMessage());
}
// 获取最后N个字符
String lastThree = str.substring(str.length() - 3);
System.out.println("最后三个字符: " + lastThree); // 输出"rld"
5. 字符串转换
字符串转换方法用于改变字符串的表现形式:
常用转换方法:
toLowerCase()
:转换为小写toUpperCase()
:转换为大写valueOf(Object obj)
:将各种类型转为字符串toCharArray()
:将字符串转换为字符数组
String str = "Hello World";
System.out.println(str.toLowerCase()); // 输出"hello world"
System.out.println(str.toUpperCase()); // 输出"HELLO WORLD"
System.out.println(String.valueOf(123)); // 输出"123"
System.out.println(String.valueOf(true)); // 输出"true"
System.out.println(Arrays.toString(str.toCharArray())); // 输出字符数组
转换细节:
- 大小写转换会考虑当前Locale设置
valueOf()
可以转换基本类型、对象和数组- 对于null对象,
valueOf()
返回"null"字符串而非抛出异常
实际应用:
// 格式化输出
int count = 5;
String message = "找到" + String.valueOf(count) + "条记录";
System.out.println(message); // 输出"找到5条记录"
// 统一格式处理
String userInput = " MixedCASE ";
String normalized = userInput.trim().toLowerCase();
System.out.println(normalized); // 输出"mixedcase"
// 处理null值
Object obj = null;
System.out.println(String.valueOf(obj)); // 输出"null"
6. 字符串替换
replace()
方法用于字符串内容替换:
基本用法:
String str = "hello world";
System.out.println(str.replace("l", "x")); // 输出"hexxo worxd"
System.out.println(str.replace("hello", "hi")); // 输出"hi world"
方法特点:
- 支持字符和字符串替换
- 会替换所有匹配项,而不是仅替换第一个
replaceAll()
支持正则表达式替换replaceFirst()
只替换第一个匹配项- 原始字符串不会被修改,总是返回新字符串
示例应用
// 替换多个空格为单个空格
String text = "hello world";
String cleaned = text.replaceAll("\\s+", " ");
System.out.println(cleaned); // 输出"hello world"
// 敏感信息脱敏
String phone = "138-1234-5678";
String masked = phone.replaceAll("\\d{4}$", "****");
System.out.println(masked); // 输出"138-1234-****"
// 模板内容替换
String template = "尊敬的{name},您的订单{orderId}已发货";
String personalized = template.replace("{name}", "张三")
.replace("{orderId}", "ORD123456");
System.out.println(personalized);
7. 字符串分割
split()
方法基于正则表达式分割字符串:
基础用法:
String str = "hello,world,java";
String[] arr = str.split(",");
for (String s : arr) {
System.out.println(s);
}
// 输出:
// hello
// world
// java
高级用法详解:
1.基本分割
// 按逗号分割
String csv = "a,b,c,d";
String[] parts = csv.split(",");
System.out.println(Arrays.toString(parts)); // 输出[a, b, c, d]
2.正则表达式分割
// 按多个空格分割
String text = "hello world java";
String[] words = text.split("\\s+");
System.out.println(Arrays.toString(words)); // 输出[hello, world, java]
// 按多种分隔符分割
String complex = "apple,orange;banana|grape";
String[] fruits = complex.split("[,;|]");
System.out.println(Arrays.toString(fruits)); // 输出[apple, orange, banana, grape]
3.限制分割次数
// 最多分成2部分
String path = "usr/local/bin";
String[] dirs = path.split("/", 2);
// dirs[0] = "usr", dirs[1] = "local/bin"
System.out.println("主目录: " + dirs[0]);
System.out.println("子路径: " + dirs[1]);
4.特殊字符处理
// 按点号分割(需要转义)
String ip = "192.168.1.1";
String[] octets = ip.split("\\.");
System.out.println(Arrays.toString(octets)); // 输出[192, 168, 1, 1]
// 处理空结果
String data = ",a,b,,c,";
String[] items = data.split(",", -1); // 保留空字符串
System.out.println(Arrays.toString(items)); // 输出[, a, b, , c, ]
四、String 类的内存机制
1. 字符串常量池存储机制
字符串常量池在JDK 7及以后版本被移到了Java堆内存中。这个改变是为了:
- 提高字符串常量池的存储容量
- 避免永久代内存溢出的问题
- 允许更灵活的内存管理
当使用双引号字面量方式创建字符串时,JVM会执行以下步骤:
- 首先在字符串常量池中查找是否存在相同内容的字符串
- 如果存在,则直接返回常量池中该字符串的引用
- 如果不存在,则在常量池中创建该字符串对象
例如:
String a = "java"; // 第一次创建,放入常量池
String b = "java"; // 直接引用常量池中的对象
System.out.println(a == b); // 输出true
2. 堆内存字符串对象存储
使用new关键字创建字符串对象时,无论常量池中是否存在相同内容,都会在堆中创建一个新的对象:
String c = new String("java"); // 在堆中创建新对象
String d = new String("java"); // 再创建一个新对象
System.out.println(c == d); // 输出false,因为是不同对象
这种创建方式会:
- 首先检查常量池中是否有"java"字符串
- 如果没有,先在常量池创建
- 然后在堆内存中创建一个新的String对象
3. intern()方法详解
intern()方法用于将字符串对象主动添加到常量池中:
String str1 = new String("hello"); // 堆中创建新对象
String str2 = str1.intern(); // 将"hello"放入常量池(如果不存在)
String str3 = "hello"; // 直接引用常量池中的对象
System.out.println(str2 == str3); // 输出true,因为引用的是同一个常量池对象
intern()方法的工作流程:
- 检查常量池中是否存在当前字符串内容
- 如果存在,直接返回常量池中的引用
- 如果不存在,则将当前字符串添加到常量池后返回引用
应用场景:
- 当需要大量重复字符串时,使用intern()可以减少内存占用
- 需要频繁进行字符串比较时,使用intern()后的字符串可以用==代替equals()
注意:
- JDK 6及之前版本,intern()方法会将字符串实例复制到永久代
- JDK 7+版本,intern()方法只是在常量池中记录首次出现的实例引用
五、String 与 StringBuffer、StringBuilder 的区别
1. 可变性
String的不可变性
- String对象一旦创建,其内容就不可改变(immutable)
- 任何修改操作(如concat、replace、substring等)都会创建新的String对象
- 示例:
String str = "Hello"; str = str.concat(" World"); // 创建新对象"Hello World"
StringBuffer和StringBuilder的可变性
- 两者都是可变字符序列(mutable)
- 修改操作(如append、insert、delete等)直接在原有对象上进行
- 示例:
StringBuilder sb = new StringBuilder("Hello"); sb.append(" World"); // 直接修改原对象
2. 线程安全性
StringBuffer的线程安全
- 所有公共方法都使用synchronized关键字修饰
- 适合多线程环境下的字符串操作
- 示例:
StringBuffer buffer = new StringBuffer(); // 多线程可以安全调用buffer的方法
StringBuilder的非线程安全
- 不提供同步保证
- 单线程环境下性能优于StringBuffer约10-15%
- 示例:
StringBuilder builder = new StringBuilder(); // 仅适合单线程环境使用
String的线程安全
- 由于不可变性,String对象天然线程安全
- 多个线程可以安全地共享String对象
3. 性能比较
性能测试场景
- 在10万次字符串拼接操作下的性能表现:
- String:每次拼接都创建新对象,性能最差
- StringBuffer:同步操作带来一定开销
- StringBuilder:无同步开销,性能最佳
实际性能差异
操作类型 | 相对性能 |
---|---|
String拼接 | 1x (基准) |
StringBuffer拼接 | 约5-10倍于String |
StringBuilder拼接 | 约6-12倍于String |
4. 使用场景建议
String适用场景
- 字符串内容不变的场合
- 作为方法参数或返回值
- 字符串常量池的应用
- 示例:
String config = "server.properties";
StringBuffer适用场景
- 多线程环境下的字符串拼接
- 需要线程安全的字符串构建
- 示例:
// 多线程日志记录 StringBuffer logBuffer = new StringBuffer();
StringBuilder适用场景
- 单线程环境下的字符串操作
- 性能敏感的字符串处理
- 循环体内的字符串构建
- 示例:
// SQL语句构建 StringBuilder sql = new StringBuilder("SELECT * FROM users"); sql.append(" WHERE age > 18");
5. 最佳实践建议
- 优先考虑StringBuilder,除非明确需要线程安全
- 预估初始容量可以避免频繁扩容(默认容量16)
- 复杂字符串操作使用链式调用:
StringBuilder sb = new StringBuilder() .append("Name: ").append(name) .append(", Age: ").append(age);
- 字符串拼接操作超过3次时,应避免使用String的"+"运算符
六、注意事项
1. 避免频繁拼接字符串
由于String类的不可变性(immutable),每次字符串拼接操作都会创建一个新的String对象。在循环中进行大量拼接时,会产生大量临时对象,严重影响性能并增加GC压力。
示例分析:
String str = "";
for (int i = 0; i < 10000; i++) {
str += i; // 每次循环都会创建新的StringBuilder和String对象
}
这段代码实际执行时相当于:
String str = "";
for (int i = 0; i < 10000; i++) {
str = new StringBuilder().append(str).append(i).toString();
}
优化方案:
StringBuilder sb = new StringBuilder(); // 单线程环境下推荐
// 或 StringBuffer sb = new StringBuffer(); // 多线程环境下使用
for (int i = 0; i < 10000; i++) {
sb.append(i); // 只操作内部字符数组,不创建新对象
}
String str = sb.toString();
2. 正确使用==和equals()
字符串比较的常见误区:
==
比较的是对象的内存地址equals()
比较的是字符串的实际内容
重要场景示例:
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
System.out.println(s1 == s2); // true,都指向字符串常量池中的同一对象
System.out.println(s1 == s3); // false,s3是堆中新创建的对象
System.out.println(s1.equals(s3)); // true,内容相同
最佳实践:
- 比较字符串内容时总是使用
equals()
- 特别要注意常量字符串与变量字符串的比较
- 对于可能为null的字符串,使用
Objects.equals()
更安全
3. 注意字符串的空值判断
空指针异常是字符串操作中的常见问题,特别是在处理外部输入或数据库查询结果时。
危险代码示例:
String input = getUserInput(); // 可能返回null
if (input.equals("yes")) { // 当input为null时会抛出NullPointerException
// ...
}
安全写法:
String input = getUserInput();
if ("yes".equals(input)) { // 常量在前,避免NPE
// ...
}
// 或者使用Java 7+的Objects.equals()
if (Objects.equals(input, "yes")) {
// ...
}
4. 合理使用字符串常量池
JVM字符串常量池(String Pool)机制:
- 字面量创建的字符串会自动加入常量池
intern()
方法可以手动将字符串加入常量池- 常量池可以避免重复创建相同内容的字符串
使用建议:
String s1 = "hello"; // 使用常量池
String s2 = new String("hello"); // 新建对象
String s3 = s2.intern(); // 将s2加入常量池
System.out.println(s1 == s3); // true
注意事项:
- 不要过度使用
intern()
,可能导致常量池过大 - 大量动态生成的字符串不适合放入常量池
- Java 7+已将字符串常量池移出永久代(PermGen),改为在堆中管理
5. 注意substring()方法的内存泄漏问题(旧版本JDK)
历史问题说明: 在JDK 6及之前版本中,substring()
会共享原字符串的char[]数组,仅修改偏移量和计数。这可能导致大字符串被截取少量字符后,仍保留整个原始数组的引用,造成内存泄漏。
JDK 6示例:
String bigString = new String(new byte[1000000]); // 1MB的字符串
String smallString = bigString.substring(0,1); // 实际仍持有1MB的char[]引用
JDK 7+改进:
String bigString = new String(new byte[1000000]);
String smallString = bigString.substring(0,1); // 创建新的char[]数组,只包含所需字符
迁移建议:
- 仍在使用JDK 6的项目应升级到新版本
- 如果必须使用JDK 6,替代方案:
String smallString = new String(bigString.substring(0,1));