写 Java 写了快十年,我一直觉得有个很有意思的现象:面试官对 String 的热爱近乎偏执,不少候选人却觉得它太简单没什么可聊,结果一追问就露馅。这些年带项目、看代码评审、自己招人,因为字符串处理翻过的车一个接一个。所以今天想把 String 相关的核心原理和易错点一次性说透——从字符串到底存在哪、为什么不可变,到日常开发里最容易出错的 API 行为,再到源码层的面试考点。文章不会像教科书那样把所有方法罗列一遍,但每个点都是我实际踩过坑或者动手验证过的,适合准备 Java 面试的朋友,也适合觉得自己 String 只会用、不懂底层的人。
1. 字符串常量池与堆的关系:一道经典面试题背后的完整逻辑
1.1"abc"和new String("abc")究竟差在哪
很多面试都是从这一句开始的:String str1 = "abc";和String str2 = new String("abc");有什么区别?我见过不少候选人只会回答"一个在常量池,一个在堆里",听起来没错,但细问下去就没了。实际上这背后是一条完整的链路:编译期和运行期各做了什么。
先说编译期。字面量"abc"会被写进 class 文件的常量池,是一个CONSTANT_String_info结构。JVM 加载这个类的时候,会把这个字符串字面量放进运行时常量池。到了真正执行这行代码时,JVM 会先去字符串常量池里找有没有内容相同的字符串:
- 如果有,直接把池中对象的引用交给
str1; - 如果没有,先在池中创建一个字符串对象,再把引用交给
str1。
而new String("abc")这一步,无论池里有没有"abc",都会额外在堆上创建一个新的 String 对象,它的 value 数组内容是"abc",但引用完全不同。所以这行代码在极端情况下会创建两个对象:一个在常量池(由前面的字面量解析逻辑保证存在),一个在堆里。
注意:很多人背答案是"
new String("abc")创建了两个对象",这其实不严谨。正确的说法是:如果常量池里还没有"abc",类加载/字面量解析阶段会创建一个池中对象,运行时再 new 一个堆对象,合计两个;如果池里已经有"abc",就只有堆上那一个。
平时写代码,String str2 = new String("abc")这种写法没有任何必要,除非你是故意想创建一个独立引用来做==比较的实验。生产代码里直接用字面量就好,省内存,行为也更可预期。
1.2 常量池的位置变迁:JDK 6 和 JDK 7 的 intern 行为不一样
intern()是另一个高频考点,它的作用是主动把字符串放入常量池并返回池中的引用。但 JDK 6 和 JDK 7 之后,这个方法的实际效果有明显差异。
JDK 6 及以前,字符串常量池放在方法区的永久代里。调用intern()时,如果池里没有这个字符串,JVM 会在永久代复制一份对象进去,也就是说 intern 返回的和原来的 String 是两个不同的对象。这时候如果有人在频繁拼接字符串后做 intern,很容易把永久代塞满,触发OutOfMemoryError: PermGen space。
JDK 7 开始,字符串常量池被挪到了堆中。调用intern()时,如果池里还没有相同内容的字符串,JVM 会把当前这个堆对象的引用登记到池里,而不是复制对象。于是会出现一个很经典的结果:
String s = new StringBuilder("计算").append("机软件").toString(); System.out.println(s.intern() == s); // JDK 7+ 输出 true String s2 = new StringBuilder("ja").append("va").toString(); System.out.println(s2.intern() == s2); // 大概率是 false为什么第一段s.intern() == s为 true?因为"计算机软件"这个字符串在此之前从未以字面量形式出现过,常量池里没有它,intern 时直接把堆里的 s 引用放进了池子,所以返回的就是 s 本身。第二段"java"就不同了,它早在 JVM 运行过程中就被很多基础类当成字面量用过了,常量池里已经有"java",因此 intern 返回的是池中那个老对象,而不是新造出来的 s2。这个例子我在面试中问过不少人,能一次性讲清 JDK 版本差异的,基本都算真正理解常量池了。
1.3 别滥用 intern,即使 JDK 7 放到了堆里
这里必须泼一盆冷水:intern()虽然能把 String 去重、省内存,但你得想清楚池本身也是内存。如果业务里存在大量动态生成的字符串,比如订单号、日志拼接出来的内容,你一股脑全 intern,池里的数据只会越来越多,最终拖垮堆内存,甚至让 GC 压力暴增。
我见过有团队为了省对象,把所有返回值都套一层 intern,结果上线后老年代增长特别快,排查了很久才发现是字符串池被撑大了。合理的使用场景应该是"有限且高度重复"的字符串,比如枚举名、固定枚举值、数据库状态字段这类东西。换句话说,真正想去重,先从业务设计的角度想想这份字符串是不是注定会重复出现,如果是,再考虑 intern;如果只是随手一调,多半是给自己埋雷。
2. 不可变性不是"天生的",是设计者刻意为之
2.1 String 内部到底怎么存数据
除了 JDK 版本差异,String 的对象结构一直是"不可变"这一说法的根基。JDK 8 及以前,String 内部就是一个private final char[] value;,数组本身存了 UTF-16 的字符。JDK 9 开始为了节省内存,改成了private final byte[] value;加一个private final byte coder;。coder 有两种值:LATIN1(0)表示字符串里每个字符都能用一个字节表示,UTF16(1)表示需要用两个字节。这样一来,纯 ASCII 字符串占用的内存能直接省一半。
很多人只记住"String 是 final 的、不可变",但是你要能说出来它不可变体现在哪几个层面:
- String 类本身是 final,方法不能被重写,行为不会被子类扭曲;
- value 数组是 final,引用不能重新指向别的数组;
- 所有公开方法都不直接改 value 的内容,稍微修改点的操作(比如 concat、replace、substring)都会生成新数组、返回新 String;
- 数组虽然是 private 的,但 String 没把内部数组暴露给外部。
这三层加起来,才构成"不可变"这个完整概念。只背一句"final 修饰"面试是过不了关的。
2.2 不可变给 JVM 和业务带来了哪些实际好处
第一是常量池共享的前提。你想,如果 String 可变,那么"hello"这个字面量在常量池里被多处引用,只要有一段代码偷偷改了它,其他所有引用"hello"的地方都会跟着变,程序会疯掉。正因为不可变,常量池才敢大胆做复用,String s1 = "hello"; String s2 = "hello"; s1 == s2才能成立。
第二是线程安全。String 对象没有内部状态会随时间变化,所以它可以肆无忌惮地作为一个共享变量在多个线程间传递,不需要加锁。Spring 的 Bean 名、配置项、参数名,大量这样的字符串都作为共享 Key 使用,你不需要担心并发修改。
第三是哈希缓存。看一下 hashCode 源码会发现,String 把计算出的 hash 缓存在一个private int hash字段里,懒加载,第一次计算后就用这个值。因为 String 不可变,算过一次之后永远有效,这也是 HashMap 里用 String 做 Key 性能好的原因之一——每次hashCode()都省去重新计算的成本。
第四是安全。包括类加载机制在内,JVM 很多地方会把类名、路径、权限信息都当作字符串来处理。如果这些字符串被篡改,整个访问控制系统就崩了。不可变让这些敏感信息一旦构建出来就能安全传递,不会在某个角落里悄悄被改成非法值。
2.3 反射能不能破坏不可变性?能,但别这么干
这个问题经常被用来挑战"不可变"的绝对性。通过反射拿到 String 内部的 value 数组,再修改数组里的元素,确实能让一个 String 的值"变":
Field field = String.class.getDeclaredField("value"); field.setAccessible(true); char[] value = (char[]) field.get(s); value[0] = 'X';我这里没有跑完整代码,但核心逻辑就是这样:绕过 private 限制,直接操作数组。在 JDK 8 上能跑通,JDK 9 之后加了模块化限制,反射访问内部字段会被警告甚至报错,JDK 17 开始默认强封装,这套动作已经很难做成功了。即便如此,这只代表"反射破坏了封装",不代表 String 不可变的设计失效了。正常代码路径下,String 仍然是不可变的,你总不能把反射攻击当成日常开发手段。
3. 业务代码里最容易踩的 String 易错点
3.1 equals 和 ==:为什么我用 == 判断总是翻车
这是 String 领域最出名的坑。==比较的是引用,equals比较的是内容,理论上新人都知道,但一写代码就容易忘:
String a = "abc"; String b = new String("abc"); System.out.println(a == b); // false System.out.println(a.equals(b)); // true真正的问题不是记不住结论,而是不知道什么时候该用哪种。如果你是在比较两个从不同途径进入程序的字符串,比如一个来自常量池字面量,一个来自 JSON 解析或者数据库返回值,那就绝对不要用==。反过来,如果你能确定两个引用都指向同一个字符串常量池对象,比如a和b都是直接常量赋值,a == b才会是 true。实际开发里没有必要这么赌,统一用equals或Objects.equals最稳。
还有一个相伴的坑是str.equals("abc")和"abc".equals(str)。如果str是 null,前者直接抛空指针,后者返回 false。所以到处有人建议"常量放前面"。但我个人更推荐用Objects.equals(str, "abc"),它内部处理了 null,代码读起来也更顺,没必要为了规避空指针而写出"abc".equals(str)这种反直觉的写法。
3.2 split 的两个隐形陷阱:正则语法和尾空串
split的参数是一个正则表达式,不是普通字符。很多人拿str.split(".")去分割带点号的字符串,结果得到一个空数组。原因就是正则里.表示"任意字符",它把你整个字符串每个字符都当成了分隔符,末尾空串又被自动去掉,剩不下任何东西。正确写法是split("\\.")。
第二个陷阱是尾随空字符串会被丢弃。例如:
String data = "a,b,"; String[] arr = data.split(","); System.out.println(arr.length); // 2,不是3"a,b,"按逗号切,直觉上是 ["a", "b", ""],但 split 默认会把末尾的空串全部干掉,结果就只有 ["a", "b"]。如果你真的需要保留末尾空串,给 split 传一个负数 limit 参数:data.split(",", -1),这样返回的数组就完整了。这个细节在做 CSV 解析、按行拆分文件内容时非常容易中招,我在真实项目里见过因为这个问题导致最后一列数据悄悄丢失的故障,排查起来还很费劲。
3.3 replace 和 replaceAll,差的不只是一个字母
replace有两个重载:replace(char, char)和replace(CharSequence, CharSequence),它们都是字面量替换,不解析正则。而replaceAll和replaceFirst的第一个参数是正则表达式,完全按正则语义执行。这个差异带来的运行结果可能和你预期相差十万八千里:
String s = "a.b.c"; System.out.println(s.replace(".", "/")); // a/b/c System.out.println(s.replaceAll(".", "/")); // /////对,"."在正则里是通配符,replaceAll把每个字符都换成了/。包括replaceAll("\\d")想替换数字但没写双反斜杠,也是常见翻车点。我的习惯是:能确定是普通字符串替换,一律用replace;只有明确要匹配正则的时候,才用replaceAll,并且写完后先用正则测试工具验证一下,别靠肉眼脑补。
3.4 空字符串判断:isEmpty、trim、isBlank 三者的适用场景
判断一个字符串是否为空,最基础的写法是str == null || str.isEmpty(),但这里有个隐藏语义:" "这种只含空格的字符串,isEmpty()返回 false,可很多业务场景里它和空字符串没什么区别,用户输入一堆空格提交上来,你总不能直接入库。
JDK 8 及以前,常见的做法是str.trim().isEmpty()。注意trim()只去掉小于等于 U+0020 的空白字符,包括空格、制表符、换行。JDK 11 引入了isBlank(),它用的是Character.isWhitespace判断,对全角空格、各种 Unicode 空白字符处理得更全面。如果你的项目已经跑到 JDK 11 以上,直接用str.isBlank()判断"空白字符串"最省心;还在 JDK 8,建议保留trim().isEmpty()的封装。
3.5 valueOf(null) 会抛空指针,很多人不知道
String.valueOf是个重载很密集的方法,valueOf(Object)传入 null 时返回字符串"null",这没问题。但如果你写的是裸调用:
String s = String.valueOf(null);编译器会选择最具体的重载版本,也就是valueOf(char[])。char[] 版本接收到 null 后,内部去取数组长度就直接抛NullPointerException。这个点特别适合用来考察对重载解析的理解。平时为了避免这种坑,我会显式写成String.valueOf((Object) null)或者干脆不依赖这个行为,直接让调用方自己判断 null,代码意图反而更清晰。
3.6 substring 的历史坑:JDK 6 的内存泄漏
老工程师应该对这个问题有印象。JDK 6 及以前,substring实现并不是复制出一段新字符数组,而是直接复用原 String 的 char[],通过偏移量加长度来标记子串范围。这样做的好处是截取很快,坏处也致命:你从一个很大的字符串里截取了一小段,比如从 10MB 的 JSON 里截出 100 字符,只要这一小段还被引用,那 10MB 的原始数组就一直被牵着不回收,等于变相内存泄漏。
JDK 7 修复了这个设计,substring会重新创建一个数组,复制需要的字符,截取的代价从 O(1) 变成了 O(n),但换来了正确性。现在的 JVM 版本已经不需要你为此专门绕路了,但了解这段历史有助于理解"程序里看起来理所当然的实现,其实都踩过血泪坑"。
4. 字符串拼接的性能账:+号的舒适区和 StringBuilder 的自律区
4.1 编译器对字符串相加做了哪些小动作
很多新人对"count: " + number这种写法有负罪感,总觉得用+拼字符串性能不行。这里要分情况讲:"a" + "b" + "c"这三个全是字面量的时候,编译器在编译期就会把它们折叠成一个常量"abc",根本不存在运行期拼接。所以你在代码里看到一堆常量加常量,完全不用担心性能,生成的字节码里就只有最终的字符串常量。
如果是字面量加变量,情况就不同了。JDK 8 的 javac 会把"a" + i翻译成类似new StringBuilder().append("a").append(i).toString()的字节码。也就是说,你写一个+,编译器背后替你 new 了一个 StringBuilder 再拼好。单次拼接没问题,问题出在循环里。
4.2 循环内拼接是大忌,因为每次循环都在重新建灶台
String result = ""; for (int i = 0; i < 1000; i++) { result += i + ","; }这段代码执行时,每次循环都会 new 一个 StringBuilder,把当前 result 整个复制进去,append 一个i,再 toString 生成新 String,然后把 result 指向新对象。循环 1000 次,前面累积的内容被反复复制多次,时间复杂度接近 O(n^2)。我实际测过,数据量到几万的时候,这种写法能让接口响应时间变得不可接受。
正确做法是手动创建 StringBuilder 放到循环外面:
StringBuilder sb = new StringBuilder(estimateSize); for (int i = 0; i < 1000; i++) { sb.append(i).append(','); } String result = sb.toString();如果业务上能估算出最终字符串的大致长度,就显式给 StringBuilder 一个初始容量,比如拼接 1000 个最多 10 字符的数字,可以给容量 12000。这样可以省掉中间多次扩容复制数组的成本。不用一开始就精确,给个粗估值就已经很有帮助了。
4.3 StringBuilder、StringBuffer、StringJoiner 到底怎么选
StringBuilder 非线程安全,适合单个线程内做复杂拼接;StringBuffer 在关键方法上加了 synchronized,线程安全,但正因为每个 append 都有锁开销,性能比 StringBuilder 差。坦白说,实际项目里多线程同时往同一个字符串缓冲里 append 本身就是个很可疑的设计,大多数情况下直接用 StringBuilder 就好,真需要线程安全,应该去考虑操作的原子性和状态同步,而不是寄希望于 StringBuffer 逐个方法的锁。
JDK 8 还提供了 StringJoiner,专门处理带分隔符的拼接,比如"a,b,c"这种。StringJoiner 可以设置前缀和后缀,内部其实就是用了 StringBuilder。如果你在处理 Stream,用Collectors.joining(", ")更顺手,它底层也是 StringJoiner。总结一下:普通拼接用 StringBuilder,集合且要分隔符用 StringJoiner 或 Collectors.joining,别一上来全上 StringBuffer。
4.4 String.format 的性能和使用场景
String.format内部会走 Formatter,要做格式解析和大量临时对象分配,性能明显比 StringBuilder 慢一个量级。但不是说不能用它。业务日志、给用户看的提示消息、SQL 模板这类"低频但可读性重要"的场景,用format或者直接+拼接完全没问题,代码更清晰。真正要避免的是在热点循环里用 format 拼 JSON、拼报文这种高频操作。性能优化讲究先测量再动手,别为了让 1000 次里快 1 微秒,写出读半天都看不懂的拼接代码。
顺带提一个很多人忽略的点:"a" + null的结果是"anull",不是空字符串也不是 NPE。因为底层 StringBuilder 对 null 调用了 appendNull,写入的正是"null"这四个字符。如果你在拼接用户输入或外部数据前没做好空值判断,页面上就会出现诡异的 "null" 字样,这也是生产事故的高发来源。
5. 源码级考点:equals、hashCode、compareTo 和 Unicode 编码
5.1 equals 的源码实现到底做了哪些判断
看 JDK 8 的 String.equals,逻辑很清晰:先this == anObject比引用,相同就直接 true;然后判断anObject instanceof String,不是 String 类型就直接 false;再比较两个字符串内部 value 数组的长度,长度不等直接 false;最后逐个对比 char 数组元素,全部相等才返回 true。JDK 9 之后多了 coder 的判断,如果一个是 Latin1、一个是 UTF16,还需要先做字符扩展再逐字节比较。
这个源码过程不是让你背,而是让你理解为什么"abc".equals(someObject)这么安全:它内部先做了类型判断和长度判断,不会因为内容类型不对就抛异常。对比一下StringBuilder,它没有重写 equals,new StringBuilder("a").equals(new StringBuilder("a"))是 false,因为对象基类的 equals 就是引用比较。想比较 StringBuilder 的内容,要先toString()。
5.2 hashCode 为什么选 31,而不是 32 或 37
String 的 hashCode 计算方式从 JDK 1.2 起就是s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1],也就是循环里h = 31 * h + value[i]。这里选 31 有讲究:
31 是一个不大不小的奇素数。如果是偶数乘出来结果高位信息容易丢失,因为相当于移位;如果是合数 33 之类的,哈希值的分布可能不均匀。31 则既保证了足够好的散列性,又有一个 JVM 层面的优化便利:31 * h等价于(h << 5) - h,一次移位加一次减法,CPU 算起来很快。当你把一个元素放进 HashMap,算完哈希再去分布时,31 组合出来的分布效果在通用场景下足够好,这是长年实践检验的选择。
还要注意一个细节:String 把 hash 缓存在实例字段里,第一次计算后存起来,之后hashCode()直接返回缓存。这也是为什么 String 特别适合做 HashMap Key 的原因之一——查同一个小集合里几千个字符串 Key,每个 Key 的哈希都只算过一遍。
5.3 compareTo 的字典序比较逻辑
compareTo也是面试常客。它的逻辑是:找两个字符串较短的那个长度,从头开始逐个字符比较,遇到第一个不相等的字符就返回value[i] - another.value[i](char 数值之差),如果较短部分全部相同,就返回长度差。例如"abc".compareTo("abd")结果是'c' - 'd' = -1,"abc".compareTo("abcde")结果是0 - 2 = -2,表示一个是另一个的前缀,前面这个更短。
别小看这个行为,它在排序里直接影响顺序。字符串排序并不是按字典里那种"先比长度、再比字符"的方式,纯粹的代码逻辑是先比内容、再比长度。所以"b".compareTo("aa")结果是正数,因为先比第一个字符b > a,而不是因为aa更短就排前面。实际业务里对中文排序更是另一套逻辑,要按拼音或笔画排序就必须用 Collator,不能用 compareTo 硬啃。
5.4 千万别再按 char 去遍历所有字符串
String 内部存的是 Unicode 代码单元的 UTF-16 表示。基本多语言平面之外的字符,比如 emoji、生僻汉字,在 Java 里会用两个 char 表示,也就是一个"代理对"。这就导致"ab😄".length()返回 4,而不是 3,因为 😄 占了两个 char。
如果你用for (int i = 0; i < s.length(); i++)然后s.charAt(i)去遍历,遇到 emoji 会得到两个"半截字符",很多场景下会显示乱码或者统计出错。正确做法是用s.codePointCount(0, s.length())数真正的字符个数,遍历时用s.codePointAt(i),如果当前 char 是高位代理,要自动跳过一个位置。Java 8 之后还有 Stream 相关的codePoints()方法,处理文本分析的时候按 codePoint 走才是安全的方式。
这个点不算冷门,但确实容易忽略,尤其是做敏感词过滤、字符串截断、字节长度限制校验时,按 char 截断很可能把 emoji 从中间切掉,生成无效字符,在存储和展示层面都会出现问题。
6. 实际项目里的几条使用纪律
6.1 字符串能承载文本,但不该承载秘密
有一类业务场景我是强烈建议别用 String 的:密码、令牌、密钥这类敏感信息。String 不可变,意味着它一旦被创建出来,在内存里就会一直以对象形式存在,你用完把它置 null 只是把引用断掉,底层 char[]/byte[] 在 GC 回收之前依然留在内存里,转储堆内存时这些明文就可能被看到。现代写法是密码用char[]接收、用完主动填充覆盖,或者使用更专门的 SecretKey 类。这个问题看着偏,但安全审计时会被揪出来。
6.2 日志输出别在方法调用前就拼好大字符串
日志框架,尤其是 log4j2、logback 这类,都支持延迟参数化,比如logger.debug("user: {}, order: {}", userId, orderId)。这种情况下字符串模板不会立即拼接,只有当日志级别满足输出条件时才会格式化,能省下大量无谓的 String 拼接开销。很多老代码里习惯先拼好String msg = "user: " + userId + ", order: " + orderId再传进去,这样一来不管日志打不打,拼接成本都支付了。在高 QPS 的线上环境,这是一个不大不小的浪费。我建议团队规范里直接写明:日志永远用参数化写法,不要手工拼字符串。
6.3 别重复造轮子,但用工具类前要看它的 null 策略
很多项目引入了 Apache Commons Lang 或 Spring 自带的 StringUtils,这些工具类提供了isEmpty、isBlank、capitalize、join等现成方法,用起来确实省事。但不同工具类的"空"定义不一样:commons-lang3 的StringUtils.isEmpty(null)和isEmpty("")都返回 true,但isEmpty(" ")返回 false;到了isBlank才把空白串也归为"空"。Spring 的 StringUtils 语义又有一点差异。你从网上抄代码时,如果不看导的是哪个包,很可能写出和你预期不一致的判空逻辑。我的习惯是:一个项目里统一用同一套工具类封装,不要混用 commons 和 spring 的判空方法,否则排查问题时要逐个翻包。
另外,像StringUtils.join这类方法处理 null 元素时通常直接跳过或转成"null",也要留意。工具类是好东西,但它只是帮你封装了常见逻辑,不是帮你消灭业务语义判断。
6.4 选择合适的数据结构,比疯狂优化 String 更重要
做字符串处理的时候,先停下来想一想数据本身的形态。如果一个字符串内容会频繁修改,比如在循环里不断追加字符,用 String 加+是下策,用 StringBuilder 是中策,上策可能是直接换成char[]或者分段存储,最后再一次性合并。如果是大量 Key-Value 映射,别自己用"k1=v1&k2=v2"这种字符串去硬拼,用 Map、对象、专门的序列化框架,代码可维护性和可读性会好很多。String 只是传输和展示的载体,不要把业务逻辑的全部状态都压进一个字符串里,那才是在给自己埋坑。
最后说点个人体会。String 是 Java 里第一个被我们学会的类,却也是最容易被低估的类。我带团队这几年,判断一个 Java 工程师的基础是否扎实,几乎不用问框架,聊三十分钟 String 的存储、不可变、API 边角行为就大致清楚了。今天写的这些内容,看起来分散,但串起来其实是同一条线:理解 JVM 怎么管理对象,理解 API 背后的正则和编码,然后在写每一行字符串相关代码时多想一层。建议你拿到这些易错点,自己在本地把代码跑一遍,把 JDK 源码翻一翻,这比死记结论要可靠得多。