做Java开发的人,大概没有谁不用String。但别看它基础,String在javaSE里算得上“看似简单、实际坑最深”的类之一。我面试过不少候选人,简历上写着熟悉Java,结果一道“new String("abc")创建了几个对象”就卡住了;线上我也处置过不少诡异问题,字符串乱码、拼接OOM、intern滥用撑爆内存,最后排查下来根子都在String的使用细节上。
这篇博文不是给你罗列API文档,而是把我这几年在javaSE里和String缠斗的经验做一次系统梳理:底层设计为什么这么写、常用API里有哪些坑、三兄弟到底怎么选、字符串遇到编码/转换/异常怎么定位。适合三类人看:刚学Java的在校生,想系统补基础;准备面试的求职者,想把这些高频考点一网打尽;已经写了几年代码、但总觉得字符串问题“玄学”的开发者。
1. 深入String源码:不可变性、存储结构与常量池真相
1.1 存储结构:从char[]到byte[]的演进,JDK9为什么要改
在JDK8及以前,String内部就是一个private final char value[],一个字符占两个字节。如果是纯英文字符串,比如"hello",每个字符用UTF-16编码存储,内存里实际只用到一个字节,另一半白白空着。对于内存敏感的服务器应用,大量字符串对象的浪费是肉眼可见的。
所以JDK9之后,官方把实现改成了:
java复制private final byte[] value;
private final byte coder;
value从char数组变成了byte数组,coder字段用来标记当前字符串是LATIN1(单字节)还是UTF16(双字节)。当一个字符串的所有字符都能用Latin-1表示时,就用单字节存;一旦出现中文、emoji这类超出Latin-1范围的字符,coder切到UTF16。这一改动在减少内存占用上效果非常明显,官方数据说堆内存占用下降了不少。
这个演进过程也解释了为什么Java的String总给人一种“有点重”的感觉:它不是一个单纯的字符序列,而是一个自带编码标记的字节数组包装。你平时写s.length(),其实返回的是char数量,而不是byte数量,这个细节在涉及中英文混合、以及做字节截断的时候特别容易出错。有C++背景的同学可以对比一下std::string,它内部有SSO小字符串优化和堆分配策略,Java的String则统一由JVM管理对象和常量池,设计思路差异很大。
1.2 不可变性:String为什么必须是final,反射修改的真相
String类本身被final修饰,内部的value数组也是final的,注释里写得很明白:String对象是不可变的。很多人知道结论但不知道原因。我自己总结下来,不可变的设计至少有四个好处。
第一是安全。字符串在类加载、网络协议、文件路径、数据库驱动这些场景里大量充当“关键参数”,如果可变,一个方法拿着字符串引用悄悄改一下内容,整个系统的行为都不可控。String不可变意味着你传入的参数在方法执行期间不可能被偷偷篡改。
第二是线程安全。既然内容不会变,多线程环境下读同一个字符串就没有数据竞争,不用加锁。这是String能广泛用作HashMap/HashSet key的底气之一。
第三是hashCode可以被缓存。String的hashCode是惰性计算的,第一次调用时算好存到private int hash字段里,之后直接复用。如果字符串可变,hash缓存就会失效,放到HashMap里也会出大乱子。
第四是字符串常量池能够复用。正因为内容不可变,JVM才敢把相同内容的字面量指向同一个对象,否则池子里一个字符串被改了,所有引用它的地方全跟着变,那场面不敢想。
是不是真的一点改不了?也不绝对。网上流传的“反射修改String”确实可以做到,因为反射可以绕开final限制:
java复制String s = "hello";
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
char[] arr = (char[]) value.get(s);
arr[0] = 'H';
但这类操作纯属给自己挖坑。字符串内容一旦被改,字符串池里共享的对象、以及缓存好的hashCode都会对不上,线上生产环境这么玩,大概率会炸出各种匪夷所思的bug。我见过一个同学在单元测试里这么干,结果一个测试跑完,其他测试全挂。所以结论是:不可变是设计红线,知道反射能改是一回事,项目里千万别这么写。
1.3 字符串常量池与intern():==比较到底能不能信
字符串常量池在JDK7之后被放到了堆内存里。它的核心作用是:对于编译期能确定的字符串字面量,在类加载时就把它们放进池中;后续代码里再出现相同内容的字面量,直接复用池中的引用。
java复制String s1 = "java";
String s2 = "java";
System.out.println(s1 == s2); // true
这个例子能跑出true,就是因为s1和s2都指向常量池中同一个对象。但如果你改成:
java复制String s3 = new String("java");
System.out.println(s1 == s3); // false
new String("java")会先在常量池里放一个"java"(如果还没有),然后在堆上再new一个内容相同的String对象,s3指向的是堆上新对象,和s1不是同一个引用。
“new String("abc")创建了几个对象”这个经典面试题,答案就是这么来的:如果常量池里还没有"abc",会创建两个对象,一个在常量池,一个在堆上;如果池里已经有"abc",那只在堆上创建了一个对象。
再说intern()方法。调用s.intern()时,如果常量池里已有相同内容的字符串,就返回池中的引用;如果没有,JDK7之后是把当前字符串对象的引用加入池中,然后返回这个引用。所以:
java复制String s = new String("java").intern();
String s1 = "java";
System.out.println(s == s1); // true
看起来挺好用的,但intern()要谨慎用。它是native方法,底层要遍历字符串池做比较,高频调用时性能开销不小;更危险的是,如果把大量动态生成的内容都intern进去,字符串池会无限膨胀,最终撑爆内存。我接手过一个服务,某位前辈为了“省内存”把所有查询参数都intern了,结果跑了一个多月,JVM内存直线上升,最后只能通过dump堆内存才发现罪魁祸首是上百万个动态字符串全被塞进了池里。这个教训很深刻:默认不要用intern,只有明确知道字符串集合很小、且需要频繁比较时,才值得考虑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频API与转换操作:substring、split、replace的隐藏陷阱
2.1 创建、判空与相等性判断:这些基础细节别栽跟头
创建String的方式有好几种,平常最常用的就是字面量、new、或者从char[]转换:
java复制String a = "hello";
String b = new String("hello");
String c = new String(new char[]{'h', 'e', 'l', 'l', 'o'});
从业务角度看,推荐字面量方式,能用池省内存;new String("...")这种写法在代码里基本没有出现价值,唯一例外是明确想创建一个和池中内容不同引用的对象,但99%的业务场景用不到。
判空是个高频需求,但很多人分不清“空串”和“null”:
java复制String s = "";
String t = null;
空串是一个长度为0的String对象,而null代表没有引用。判空时先判断null再判断length:
java复制if (s == null || s.isEmpty()) {
// 空
}
如果项目用JDK11及以上,可以用s == null || s.isBlank(),isBlank比isEmpty更严格,会把只有空格、制表符、换行的字符串也判定为“空白”。具体差别对用户输入校验很有帮助。
再一个高频争论是equals和==。我见过太多线上bug是因为对象属性用==比较字符串导致的。记住一句话:判断字符串内容是否相同,一律用equals;判断两个引用是否指向同一个对象,才用==。如果要忽略大小写,用equalsIgnoreCase;要比较大小,用compareTo。
一个小技巧:执行"abc".equals(str)而不是str.equals("abc"),这样即使str是null也不会抛NPE,返回false而已。这个习惯在解析外部参数的时候特别管用。
2.2 截取、分割、替换:substring、split、replace的坑一次说清
substring方法是String里最有故事的一个。在JDK6及以前,substring底层不会复制字符数组,而是新建一个String对象,共享原来的value数组,只是偏移量不同。听起来省内存,实际上如果从一个很大的字符串截取了很小的一段,这个大字符串的整个char数组都会被小对象引用着,导致内存无法被回收,这就是当年的经典内存泄漏问题。JDK7之后官方改成了复制数组,空间换了安全,这也是为什么现在substring返回的新字符串与原始字符串不共享底层数组。
substring接参数时要注意索引越界问题,substring(0, s.length())合法,substring(s.length())返回空串,但substring(s.length() + 1)直接抛StringIndexOutOfBoundsException。业务里用substring前最好先做长度检查。
split和replace的坑更多是正则造成的。split的参数是正则表达式,而不是普通字符。很多人想按句号分割写str.split("."),结果数组长度是0。原因很简单:点在正则里是“匹配任意字符”的通配符,字符串每个位置都算匹配,分割后剩下的全是空串,而split默认会丢弃尾部的空字符串,所以最后得到一个长度为0的数组。正确写法是str.split("\\.")或者str.split(Pattern.quote("."))。
replace和replaceAll也常常被混用。replace(CharSequence, CharSequence)的入参是字符串字面量,不做正则处理;replaceAll(String, String)的入参是正则表达式。如果你想把字符串里的"$"替换成其他内容,用replace最安全,用replaceAll就得把$转义成\\$,否则会报异常或者替换结果完全不对。因为$在正则替换串里有特殊含义,表示分组引用。
2.3 字符串与数值、数组的互转:C#玩家尤其注意
数值转字符串,常见的三种写法:
java复制String s1 = String.valueOf(123);
String s2 = Integer.toString(123);
String s3 = "" + 123;
String.valueOf底层就是Integer.toString,所以两者其实是一样的;"" + 123在JDK8里会被编译成new StringBuilder().append("").append(123).toString(),会有对象创建开销,但在JDK9之后编译器换成了invokedynamic拼接,性能差距变小了。从代码语义上说,推荐String.valueOf,因为它的意图最清晰。
反过来字符串转数值,用Integer.parseInt、Double.parseDouble、Long.parseLong:
java复制int i = Integer.parseInt("123");
double d = Double.parseDouble("3.14");
这些方法在字符串不是合法数字时会抛NumberFormatException,比如字符串带空格、带中文、带千分位逗号,都会挂。解析外部输入前,要么先trim,要么用正则校验,要么catch异常统一处理。
我注意到热词里有“C# double转string”这种说法,顺带提一句:Java里没有String类的toDouble()方法,和C#的习惯不一样,Java是反向的,数值类型有自己的toString,解析则是parseDouble。跨语言过来的朋友很容易在这个地方犯迷糊。
char[]和String互转也很常用:
java复制char[] chars = str.toCharArray();
String s = new String(chars, 0, 5);
byte[]和String互转一定要指定字符集:
java复制byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
String s = new String(bytes, StandardCharsets.UTF_8);
这里不指定编码会导致乱码,JDK18之后默认UTF-8,但在老版本的项目里,Windows默认GBK、Linux默认UTF-8,同一份代码在两个环境跑出来的结果可能完全不一样。这个我们在第4节专门展开。
3. String、StringBuffer、StringBuilder三兄弟选型全解析
3.1 源码层面的区别:不可变、synchronized与扩容策略
String、StringBuffer、StringBuilder是面试必考、业务必用的一组类。它们的核心关系可以这样理解:String是不可变的,内容一旦确定就不能变;StringBuffer和StringBuilder都继承自AbstractStringBuilder,底层是可变的字符数组,可以在原对象上追加、插入、删除内容。
那StringBuffer和StringBuilder的区别呢?看源码就很清楚:StringBuffer的public方法几乎都加了synchronized关键字,比如append、insert、delete、toString,线程安全但有同步开销;StringBuilder的方法没有synchronized,线程不安全但性能更好。
| 特性 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 安全(synchronized) | 不安全 |
| 性能 | 不变场景最好 | 一般 | 最高 |
| 适用场景 | 字符串常量、key、参数 | 多线程共享追加 | 单线程拼接 |
很多人一听“线程安全”就觉得StringBuffer更高级,其实在业务开发中,绝大多数字符串拼接都是在局部变量、方法栈内完成的,根本不存在多线程共享同一个StringBuilder的场景。只有字符串对象被多个线程共同追加、并且能确保需要同步语义时,才值得用StringBuffer。我自己的默认选择是:
- 字符串内容不变,直接用String;
- 单线程循环拼接、动态构建SQL、生成日志,用StringBuilder;
- 确实有多线程共享可变字符串的需求,再考虑StringBuffer,但这类场景出现概率极低,多线程设计本身往往应该把字符串当作不可变对象来传递。
两兄弟还有一个共同的构造函数参数:初始容量。默认容量是16,如果追加的内容超过当前容量,会触发扩容。AbstractStringBuilder的扩容逻辑是newCapacity = (oldCapacity << 1) + 2,也就是旧容量翻倍再加2,然后通过Arrays.copyOf把原内容拷贝到新数组。如果提前能预估字符串最终长度,建议直接new StringBuilder(capacity),减少扩容次数。扩容不是免费的,一次大字符串拼接如果反复扩容,数组拷贝的代价很可观。
3.2 拼接性能实测:+号什么时候该换成StringBuilder
先看最经典的场景:
java复制String result = "";
for (int i = 0; i < 10000; i++) {
result = result + i;
}
