给Java基础做复盘和总结,最值得花时间深挖的知识点里,String绝对排得上号。我见过太多写了三四年业务代码的Java工程师,遇到new String("abc")创建了几个对象、split(".")为什么解析不出IP、字符串拼接到底要不要自己写StringBuilder这类问题,照样会卡住。String的不可变性、常量池、intern机制、拼接优化和API陷阱,每一块单独拆开都能写成一篇文章。这篇就把这些核心知识点串起来,配合例题把题目长什么样、答案怎么推导、代码怎么写讲清楚,做开发、备战面试、复习JDK源码,都能用得上。
1. 先搞清楚String不可变这件事值不值得记
1.1 String类到底做了什么
看JDK源码,String类用final修饰,意味着它不能被继承。类里的核心字段在JDK9之前是这样定义的:
java复制private final char value[];
JDK9之后因为压缩字符串(Compact Strings)的改进,改成了byte[],同时增加了coder字段用来标记编码是LATIN1还是UTF16:
java复制private final byte[] value;
private final byte coder;
关键不是char还是byte,而是private final。final保证这个值字段一旦初始化就不能再指向其他数组,配合不对外暴露修改入口的设计,String实例的内容在创建之后就固定了。哪怕你有String的引用,也没有任何公开方法能改掉它内部那串字符。
这是整个String知识体系的起点。后面讲的常量池、线程安全、哈希缓存,全都建立在不可变这个根基上。
1.2 不可变带来的四笔"隐形收益"
第一笔收益是线程安全。不可变对象天然可以在多线程环境下自由共享,不需要加锁同步。比如一个配置项用String保存,A线程读、B线程读,谁也不可能把内容写坏。这也是为什么String能放心当HashMap的key,连累别的类当key还要考虑hashCode字段是否会被修改。
第二笔收益是支持字符串常量池复用。JVM能放心地把同一个字面量"abc"共享给多个引用,正是因为"abc"不会变。如果String可变,两个引用指向同一个常量池对象,一个改了内容另一个全被牵连,缓存共享就根本没法做。
第三笔收益是安全性。文件路径、网络地址、数据库URL、反射的类名这些关键参数经常以String形式传递。不可变保证了中间环节没人能篡改,不会出现"A方法传入路径,B方法偷偷把路径改了"这种吓人的问题。
第四笔收益是hashCode可以被缓存。String重写了hashCode(),按字符内容计算。源码里有一个私有字段hash,第一次调用后把结果存下来,后面直接返回。这之所以安全,是因为内容不可变,缓存永远不会失效。String作为HashMap/HashSet的key能保持高性能定位,这是重要原因。
1.3 不可变带来的代价与误解
不可变不是没有代价。最直观的就是"修改"成本高,任何看似修改的操作,比如concat、substring、replace,实际都是生成新对象。所以用String做大量循环拼接,性能会很难看,具体在第三章展开。
这里还要破除一个常见误解:String s = "a"; s = "b";并不是把String从"a"改成了"b",而是把s这个引用从指向常量池中的"a"对象,改为指向"b"对象。原来的"a"对象还在常量池里躺着,等GC回收。引用变量换个指向,不代表对象本身可变。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常量池、intern和那组经典的"==比较"题目
2.1 常量池的位置和字面量规则
字符串常量池(String Table)在JDK7之前位于方法区(永久代),JDK7之后移到了Java堆中。它本质上是一个哈希表,用来存放字符串字面量的引用或者对象。
String s1 = "abc";这种写法叫字面量创建。JVM在处理这个语句时,会先去常量池里找有没有内容等于"abc"的字符串。有就复用,没有就在池里新建一个,然后把引用给s1。所以两个内容相同的字面量字符串,在常量池里是同一个对象。
String s2 = new String("abc");则不然。它在堆上创建了一个全新的String对象,内容也是"abc"。问题是,new的时候JVM会先确保常量池里有"abc"(没有就先创建池中对象),然后再在堆上new一个。结果就是:常量池一个"abc",堆上一个"abc",两个不同对象。
2.2 intern()在JDK7之后的行为变化
intern()是一个native方法,方法签名是public native String intern();。它的语义是:如果字符串常量池中已经有内容相同的字符串,就返回池中那个对象的引用;如果没有,就把当前字符串放入池中并返回。
很多老书讲intern说的是"复制一份到方法区",这是JDK6及之前的逻辑。JDK7之后常量池进堆,intern的行为变成了在池中记录对象的引用(如果池里没有)。这个差异会导致某些老题目的答案在JDK6和JDK7+不一致,所以遇到相关题目,先问清楚JDK版本,或者默认按JDK8+回答。
2.3 一组等价判断(重点)
看这段代码:
java复制String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1.equals(s3)); // true
System.out.println(s1 == s3.intern()); // true
逐条解释:
s1 == s2为true,因为字面量"abc"在常量池只有一份,两个引用都指向它。s1 == s3为false,因为s3是堆上新new出来的对象,不是常量池那个对象。s1.equals(s3)为true,String重写了equals,按字符内容逐个比较。s3.intern()会把s3的内容在常量池中登记,又因为常量池里已经有"abc",它直接返回池中"abc"对象的引用,也就是s1,所以和s1比较是true。
==比较引用,equals比较内容,这句话背得再熟,也得在代码面前能准确说出每一行谁指向谁,才算真会。
2.4 常量折叠与final变量的配合
再来看拼接场景下的一个经典区别。"a" + "b"这种字面量拼接,在编译期就会直接折叠成"ab",运行期不会做任何拼接动作。但如果是变量拼接,编译结果就变成new StringBuilder().append(a).append(b).toString(),运行期才产生新对象。
如果变量被final修饰,情况又不一样:
java复制final String a = "a";
final String b = "b";
String c = a + b;
String d = "ab";
System.out.println(c == d); // true
a和b都是编译期常量,编译器能算出来结果就是"ab",所以c在字节码里直接被替换成了"ab",于是c == d成立。
如果去掉final:
java复制String a = "a";
String b = "b";
String c = a + b;
String d = "ab";
System.out.println(c == d); // false
这里的c是运行期通过StringBuilder拼出来的堆上新对象,和常量池里的"ab"不是同一个,所以false。这两组代码是面试题里的常客。
| 场景 | c的生成方式 | c == "ab" |
|---|---|---|
| 两个字面量拼接 | 编译期常量折叠 | true |
| 两个final变量拼接 | 编译期常量折叠 | true |
| 两个普通变量拼接 | 运行期StringBuilder拼接 | false |
3. 字符串拼接的性能真相:从+到StringBuilder
3.1 "+"号编译后到底变成什么
Java编译器对String的+拼接优化策略分两种情况:
第一种,所有操作数都是编译期常量(字面量或final常量),直接在编译期折叠成一个字符串,运行期零开销。"Hello" + " " + "World"编译后就是"Hello World"。
第二种,只要有一个操作数是变量,编译后就会转换成类似这样的字节码逻辑:new StringBuilder().append(第一个操作数).append(后续操作数)...toString()。
所以你直接用a + b,其实编译器已经在帮你用StringBuilder了。这就带来一个很反直觉的结论:单个拼接表达式的场景下,自己手动写StringBuilder反而多此一举,直接+即可,代码还更可读。
真正的分水岭是循环拼接。如果写成:
java复制String result = "";
for (int i = 0; i < 10000; i++) {
result += i;
}
每次循环都会创建新的StringBuilder,拼完再toString成新String,再丢掉。这等于在循环体里反复创建对象、反复拷贝字符数组,GC压力巨大。虽然编译器"优化"了每次+=,但这种优化在循环里帮倒忙。
3.2 StringBuilder与StringBuffer怎么选
StringBuilder和StringBuffer的API几乎一样,区别在于StringBuffer的方法加了synchronized,是线程安全的,代价是并发场景下会有锁竞争开销。
实际开发中的建议很直白:方法局部变量做拼接,哪怕有循环,都用StringBuilder。因为局部变量不逃逸出方法,根本不存在多线程访问问题,加了同步纯属浪费。类成员变量如果会被多个线程追加内容,才考虑StringBuffer,或者用StringBuilder配合外部加锁。
另外一个小细节:StringBuilder没有重写equals(),所以两个StringBuilder不能直接按内容比较,必须先toString()再比。
3.3 concat方法的定位
String.concat(String str)的源码逻辑是复制旧数组和新字符串到新数组里,生成一个新String对象。它比+号少了一层"编译器帮你包StringBuilder"的逻辑,性能上通常比单个+更差,也没有任何优化空间。日常代码基本用不到concat,除非是在极简环境下不想依赖编译器行为。
3.4 本地实测与循环陷阱
我在本地做过一个很朴素的对比:循环10万次,一组用String +=拼整数,一组用StringBuilder.append拼整数,再一组用预分配容量的StringBuilder。
结论是:String +=和StringBuilder.append的耗时差了两个数量级,而且String +=那组GC明显更频繁。预分配容量和默认容量在10万次这个量级差别不算特别夸张,但次数越多差距越明显,因为底层数组扩容翻倍意味着多次数组拷贝。
归宿很明确:
java复制// 推荐写法:预分配容量减少扩容
StringBuilder sb = new StringBuilder(1024 * 1024);
for (int i = 0; i < 10000; i++) {
sb.append(i).append("\n");
}
String result = sb.toString();
容量预分配就是最开始猜一个可能用到的字符量,能少几次底层Arrays.copyOf就少几次,尤其一次拼接几十万行文本的场景,这个差异非常可观。
4. 常用API不翻车清单:split、replace、substring这些坑
4.1 split的两个隐藏规则
split(String regex)接收的是正则表达式,不是普通字符串。这是坑的第一层。另一个隐藏规则是默认limit为0时,会丢弃字符串末尾的所有空串。两个规则叠加,让split成为了事故高发区。
先看转义问题:
java复制"192.168.1.1".split("."); // 返回长度为0的数组
"192.168.1.1".split("\\."); // 正确
第一条代码里.在正则里是"匹配任意字符"的通配符,整个字符串每个位置都能匹配上,再加上丢弃空串,最后返回的是空数组。处理这种场景建议直接写清楚:
java复制String[] parts = ip.split("\\.");
再看末尾空串问题:
java复制"a,b,,c,".split(","); // ["a", "b", "", "c"]
"a,b,,c,".split(",", -1); // ["a", "b", "", "c", ""]
limit传-1才能真正保留末尾空元素。如果业务上需要知道最后一项是空的,一定记得用-1。这里容易出那种"数据对不上行数"的隐蔽bug。
4.2 replace、replaceAll、replaceFirst的区别
这三个方法很容易混。replace(CharSequence target, CharSequence replacement)的参数是普通字符串,按字面量替换,JDK5之后源码也是基于正则Pattern做过优化处理,但语义上是字面量。replaceAll(String regex, String replacement)的参数是正则。replaceFirst(String regex, String replacement)替换第一个匹配项。
常见事故:
java复制"123abc456".replaceAll("\\d", "#"); // "#abc#"
"192.168.1.1".replace(".", "-"); // 结果是 "192-168-1-1"
"192.168.1.1".replaceAll(".", "-"); // 结果是 "----------",因为 . 匹配所有字符
需要把路径里的反斜杠替换成正斜杠时,很多人懵掉:replaceAll("\\", "/")会直接抛PatternSyntaxException,因为正则里单独的\是非法转义符。正确写法要么用replace("\\", "/")做字面量替换,要么用replaceAll("\\\\", "/")让正则里出现一个真的反斜杠。
4.3 substring在"老版本"的内存隐患
这是一个只有老项目才会遇到、但必须知道的坑。JDK6及更早版本中,String.substring()的实现是:新String对象复用原字符串内部的char[] value数组,只是把offset和count改成新范围。听起来省内存,实际却导致了一件可怕的事:
如果从一个100MB的大字符串里substring(0, 10)取出一个只有10个字符的小串,这个小串仍然引用着那个100MB的字符数组。原大字符串引用就算置null,只要小串还活着,那100MB就永远无法回收。线上表现为一段操作后内存像漏了一样只升不降。
JDK7之后修复了这个问题,substring会重新复制数组,新对象不持有原数组引用。老版本上的临时处理办法是new String(original.substring(0, 10)),强制复制一份,让原大数组可以被GC。新项目一般不用担心,但如果公司还有跑在JDK6/7上的祖宗级服务,这个知识是能救命的那种。
4.4 判空API:isEmpty、isBlank、equals的组合
字符串判空是日常最频繁的操作,也最容易踩坑。isEmpty()判断length == 0,只能判断纯空串。isBlank()是JDK11加入的,会忽略空格、\t、\n等空白字符," ".isBlank()为true," ".isEmpty()为false。团队如果JDK版本够新,推荐用isBlank()。
更常见的是NPE问题:
java复制// 错误写法:param为null时直接NPE
if (!param.equals("")) {
doSomething();
}
正确写法:
java复制if (param != null && !param.isEmpty()) {
doSomething();
}
也有一些人写成"".equals(param)避免NPE,但equals里把需要判空的对象放在外侧,可读性很差,我一般建议显式先判null再判空。
还有一种必须先处理的场景:把对象转成字符串时用String.valueOf(obj),如果obj为null,得到的是字符串"null",不是我方期待的null。这个特性和直接用obj.toString()不同——后者会直接抛NPE。两者没有谁绝对好,但心里得有数:valueOf把null"抹平"成了字符串,如果你后面拿这个值做判断、拼SQL、写日志,都可能出现语义问题。
5. 经典例题精讲:创建对象数量和拼接输出
5.1 例题一:new String("abc")创建了几个对象
题目:
java复制String s = new String("abc");
先把对象分两类:常量池里的"abc"对象,以及堆上new出来的String对象。如果常量池里本来没有"abc",这段代码会先创建一个常量池对象,再在堆上new一个对象,一共创建2个对象。如果之前已经有别的代码让"abc"进了常量池,这里只创建1个堆对象。
注意还有一个引用s在栈上,它不是对象,是对象引用。很多回答把引用算成对象,是概念上的错误。
5.2 例题二:各种拼接后的==与equals组合题
题目:
java复制String s1 = "hello";
String s2 = "he" + "llo";
String s3 = new String("hello");
String s4 = s3.intern();
System.out.println(s1 == s2); // ①
System.out.println(s1 == s3); // ②
System.out.println(s1 == s4); // ③
System.out.println(s3 == s4); // ④
答案:①true,②false,③true,④false。
"he" + "llo"在编译期被折叠成"hello",所以s2和s1指向常量池同一个对象。s3是堆上新对象,与常量池s1不是同一个引用。s4调用了intern(),发现常量池已有"hello",返回池中对象s1,所以s4 == s1。s4和s3自然不相等,一个是池中对象,一个是堆对象。
如果把题目末尾改成:
java复制String s5 = s3.intern();
s3 = new String("hello");
System.out.println(s5 == s1); // true
结论不变,因为intern返回的永远是池里已有对象。这一串题目跑通,说明对常量池的理解基本到位了。
5.3 例题三:手写字符统计与回文判断
字符统计:
java复制public static Map<Character, Integer> countChars(String input) {
Map<Character, Integer> map = new HashMap<>();
for (char c : input.toCharArray()) {
map.put(c, map.getOrDefault(c, 0) + 1);
}
return map;
}
这里用到toCharArray()拿到字符数组,getOrDefault避免每次判断map里有没有这个字符。如果要统计的是单词而不是字符,思路类似,只是先split("\\s+")再计数。
回文判断用双指针:
java复制public static boolean isPalindrome(String s) {
int left = 0;
int right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
注意如果字符串带标点、空格,比如"abc, cba",需要先过滤掉非字母数字再统一转小写,那就是另一个级别的问题——LeetCode的经典题会要求这一步,手写时注意Character.isLetterOrDigit(c)和Character.toLowerCase(c)的配合。
5.4 例题四:字符串反转与空格替换
字符串反转最简单的方式:
java复制String reversed = new StringBuilder("abcdef").reverse().toString();
如果是面试让手写,可以写双指针配合char数组:
java复制public static String reverse(String s) {
char[] arr = s.toCharArray();
int left = 0;
int right = arr.length - 1;
while (left < right) {
char tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
left++;
right--;
}
return new String(arr);
}
这里有个提升点:用new String(arr)而不是arr.toString(),因为数组的toString()返回的是地址哈希字符串,不是内容。初学者这行写错率很高。
空格替换经典题:把字符串中的空格替换成"%20"。简单实现:
java复制public static String replaceSpace(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (c == ' ') {
sb.append("%20");
} else {
sb.append(c);
}
}
return sb.toString();
}
引申一个容易被问倒的知识点:如果直接用new StringBuilder(s).reverse().toString()处理包含emoji的字符串,可能把代理对拆散,导致字符错乱。进阶解法要用codePoint相关API按Unicode码点处理,普通场景用StringBuilder.reverse没问题,但如果明确提到Unicode、表情符号,要能意识到这个边界。
6. 真实项目里的String事故复盘
6.1 事故一:循环拼接导致接口卡死/OOM
场景:某后台系统导出报表,把每一行数据拼成一个超长字符串,循环里用的写法是result += row,几十万行数据一跑,接口直接超时,随后报OOM。
排查链路:先看GC日志,发现StringBuilder和char[]相关的年轻代对象大量快速创建,GC频率高到离谱;再看堆转储,发现几乎全是String和char数组垃圾;定位代码后发现那个循环拼接。这里编译器虽然把+=优化成new StringBuilder().append(),但循环体每次迭代都新建StringBuilder和String,对象数量等于行数的倍数,内存自然顶不住。
修复方案很简单,循环外建一个StringBuilder,预分配一个足够大的容量,循环里只append。修复后接口响应时间从几十秒降到一两秒,内存GC频率也正常了。
6.2 事故二:split(".")把IP解析成全空数组
场景:日志解析任务需要从一行文本里提取IP字段,然后按"."拆成四段做统计,某天突然发现统计结果全空,日志里却明明是正常的IPv4地址。
排查链路:看输出数组,发现长度是0;再验证"192.168.1.1".split("."),结果确实是空数组。原因就是.在正则里是通配符,字符串每个位置都被匹配到,默认丢弃空串后什么都不剩。
修复:split("\\.")即可。类似的情况还有按|拆需要split("\\|"),按$拆需要split("\\$")。如果写正则总是担心转义,可以用Pattern.quote("."),它会自动把任意字符串转成字面量正则,一劳永逸。
6.3 事故三:replaceAll没有转义导致PatternSyntaxException
场景:一段文件处理逻辑要把Windows路径里的反斜杠替换成正斜杠,代码写成path.replaceAll("\\", "/"),运行后抛PatternSyntaxException: Unexpected internal error near index 1。
排查链路:异常堆栈清晰指向Pattern.compile,原因就是replaceAll的第一个参数是正则,单个\在正则里是非法转义符。这种报错还算友好,更危险的情况是没抛异常但结果完全不对。
修复:如果只是字面量替换,用replace("\\", "/");如果坚持用replaceAll,则写replaceAll("\\\\", "/"),四个反斜杠表示正则里的一个反斜杠。这个转换规则很绕,我的习惯是能不用正则就坚决不用。
6.4 事故四:substring让老服务内存暴涨
场景:老版本JDK的某个接口从MQ拿到大报文,截取前几KB字段做后续处理,处理完大报文就置null了。但内存监控显示每次调用后堆占用都涨几MB,最后稳定在高位不回落。
排查链路:堆转储发现大量String对象的value数组长度异常大,远大于字符串显示长度。这才意识到是substring共享大数组导致的。老JDK里substring不会复制数组,小字符串引用大数组,大数组无法回收。修复方式就是new String(original.substring(...))强制复制,这个服务在新启动时顺手升级了JDK,问题根除。
新JDK用户不用过度紧张,但遇到"明明是大字符串来源,substring后内存却恐怖增长"的现象,还是先确认JDK版本再定位。
6.5 事故五:判空写错顺序引发NPE
场景:一个校验方法里写了if (!param.equals("")),用于判断参数不为空。上线后发现部分请求频繁报空指针,调用栈指向这一行。
排查链路:先确认是指针异常,再看调用方传入的param是null。问题核心是param.equals("")里,param才是被调用方,null调用任何实例方法都会NPE。很多人习惯把参数放在前面,却没想到真正的争议点是null的调用方身份。
修复:
java复制if (param != null && !param.isEmpty()) {
// 业务逻辑
}
如果团队代码规范喜欢Guava风格,可以引入Strings.isNullOrEmpty(param),语义更清晰。
写到这里想到我刚入行那会儿,也是被String的常量池题目连续教训过几次,后来把每一道题在本地IDE里跑一遍、把引用关系画在纸上,才慢慢建立起"引用、对象、常量池、堆"四者关系的清晰图景。String的知识点其实密度极高,内容上从栈到堆、从编译期到运行期、从API用法到内存模型都有涉及。建议你把文里的例题自己在JShell或IDE里敲一遍,intern()相关的题改几个变量再看输出,比背十遍结论都管用。日常写代码时也养成习惯:能用StringBuilder预分配的别用+=循环,需要正则的API先想想参数是不是正则,判空前先处理null。这些小习惯到位了,String相关的问题基本就翻不出什么浪花了。
