1. 从一道题开始,聊聊String到底怎么学
不要小看“String类基础题目”这组关键词。我在带新人、帮朋友做面试复盘时发现一个很反常的现象:绝大多数人看到字符串题目都觉得自己会,真正落笔写结论时,能全对的不到三分之一。不是语法不会,而是String在Java里太特殊了——它表面是普通类,底层却牵扯到常量池、不可变性、引用传递、编译期优化一堆东西。任何一个环节理解偏了,题目一做就露馅。
这篇文章就是干这个用的:把String类相关的底层原理和常考题目揉碎了讲,从最经典的“==和equals”到“intern到底在干嘛”、“拼接字符串为什么有人用加号有人用StringBuilder”,一层层拆开来看。无论你是准备笔试、应付期末考试,还是单纯想把基础补牢,都可以直接拿这篇文章当复习主线。
我假设你已经有基本的Java语法基础,知道怎么定义变量、调用方法。文章里所有代码我都用最简短的写法,并且标注了输出结果和原因,方便你直接跑一遍验证。有些结论比较反直觉,我会把JVM层面的原理补上,不讲玄学,只讲代码背后真实发生的动作。
2. 题目的底层考点:池、不可变性、引用传递
2.1 字符串常量池:面试题里的“第一道分水岭”
先记住这句话:所有String字面量都会在类加载阶段进入字符串常量池。这个池子可以理解为JVM在堆内存里划出的一块专门的缓存区域,Java有意识地把它设计成一份特殊的哈希表,用来存储不重复的字符串实例。
用生活里的场景来类比:它就像公司仓库里专门存放“标准零件”的柜子。仓库管理员规定,同一个尺寸的螺丝只放一颗,谁要用就直接拿这个唯一的样品去用,绝不重新造一颗。字符串常量池也是这样,当你在代码里写:
String a = "abc"; String b = "abc";a和b拿到的是不是同一个对象?
答案是:是。编译期确定的内容相同的字符串字面量,在常量池里只会存在一份。所以a == b 的结果为true。这个结果是绝大多数人都能答对的送分题,但它后续衍生的题目就没这么友好了:
String c = new String("abc"); System.out.println(a == c); // false为什么这里就是false?因为new关键字强制在堆内存中重新开辟了一块空间,创建了一个全新的对象。你想象一下:仓库里明明已经有标准零件了,但你偏要自己掏钱再定制一个一模一样的、属于自己名下的零件。虽然尺寸等于“abc”,但身份完全不同。== 在Java里比的是“引用地址”,不是内容,所以结果必然false。
这里有个经常被忽略的细节:new String("abc")其实会创建两个对象。编译期,字符串字面量“abc”已经存在于常量池;运行期,new又在堆里创建了一个副本。如果你再看它的构造方法源码,里面实际上是调用了original.value数组来做复制,这个细节在后面的题里还会用到。
2.2 不可变性:String为何被设计成“不让改”
String类被final修饰,内部的char数组也被final修饰且对外不可访问,这意味着一旦一个String对象被创建,它的内容就永远固定了。这个设计一开始很多人不理解:字符串明明经常要拼接、替换、截取,不让改得多麻烦?
恰恰相反,不可变性是String能成为常量池成员的先决条件。如果字符串可以随意修改,那么常量池里那份唯一实例被修改了,所有引用它的变量都会受到影响,程序里到处都可能出现莫名其妙的串值问题。正因为内容不可变,大家都引用同一份实例才安全。
更重要的是,不可变性天然具有线程安全性。多个线程同时读同一个字符串对象,没人能改它,那就不存在竞争条件,不用加锁。Java核心类库里有大量以String做key的场景,比如HashMap、ClassLoader的缓存,它们能保持稳定高效,很大程度上依赖这个特性。
做题时这个知识点怎么考?最典型的题目是问:下面这段代码输出了什么?
String s = "hello"; s.toUpperCase(); System.out.println(s);答案是“hello”,不是“HELLO”。因为toUpperCase方法会返回一个新字符串,但调用结果你没有接收——旧字符串s依然指向原来的不可变对象。这题考察的就是“修改String的操作本质上都是在创建新对象,旧对象纹丝不动”。很多人第一次做错,不是不知道不可变性,而是思维惯性上把s.toUpperCase()误以为像数组的sort一样原地修改。
2.3 “==”与equals:一个比引用,一个比内容
这是String类基础题目里最核心的一对概念。拿刚才的例子继续延伸:
String a = "abc"; String b = new String("abc"); System.out.println(a == b); // false System.out.println(a.equals(b)); // true“==”在比较引用类型时,比较的是“两个变量是否指向同一个对象”,也就是地址;而equals方法,String类已经重写过了,它先比较引用地址(如果引用了同一个对象,直接返回true),再比较是否为String类型,最后逐字符比较内容数组。所以equals用来判断两个字符串“长得一样不一样”,==用来判断“是不是同一个”。
实际操作中我有一个很深的体会:为了美观或性能习惯,很多人写业务代码用“abc”.equals(str)来规避空指针,这是好习惯。但要真正理解为什么推荐这么写,你就得懂equals里的逻辑——如果str是null,str.equals(...)直接空指针,而“abc”.equals(str)只会返回false。这不算高级技巧,但在基础题目里经常作为干扰项出现在代码阅读题中。
3. 从池子到内存:字符串拼接的全过程拆解
3.1 编译期确定的神奇常量折叠
如果一个字符串拼接的结果,在编译期就能被确定,编译器不会让它运行时再去计算。例如:
String a = "a" + "b" + "c"; String b = "abc"; System.out.println(a == b); // true运行这段代码,a和b指向的其实是同一个对象。原因很简单:Java编译器在javac阶段就把"a" + "b" + "c"直接优化成了"abc"这个字面量。这个行为专门有个术语叫“编译期常量折叠”。它不是运行时的技术,而是编译器的智力劳动。
但是,一旦拼接表达式中出现变量,编译期就无法确定结果了,情况立刻变化:
String s1 = "a"; String s2 = s1 + "b" + "c"; System.out.println(s2 == "abc"); // falses1是变量,虽然你肉眼能看出它的值是"a",但编译器不确定s1会不会在其他地方变化(毕竟它不是final),所以s2只能留到运行时计算。结果s2在堆上创建了一个新的String对象,和常量池里的"abc"不是同一个引用。
如果给s1加上final修饰符呢?答案会再次反转:
final String s1 = "a"; String s2 = s1 + "b" + "c"; System.out.println(s2 == "abc"); // truefinal让s1成为了真正的编译期常量,所以编译器又能原样折叠了。这套“加不加final结论完全不同”的题目,是我见到的题库里最经典的陷阱之一,也是区分一个人是真懂常量池还是背题型的试金石。
3.2 运行时拼接背后的StringBuilder
有个老生常谈的问题:日常写代码时字符串拼接用“+”到底好不好?从写法上,它确实简洁优雅;从性能上,它在循环等高频场景里是有问题的。因为运行时字符串拼接会创建StringBuilder然后调用toString转成String,每个循环次数多了就会大量产生临时对象。
比如这段代码:
String result = ""; for (int i = 0; i < 1000; i++) { result = result + i; }每次循环都执行一次“result + i”。javac编译后,循环体内部实际上是:
result = new StringBuilder(result).append(i).toString();这意味着创建了1000个StringBuilder临时对象,外加1000个String中间结果,这些对象都等待垃圾回收。如果数据量继续增大,GC压力和堆内存消耗会明显上升。所以后台开发标准建议是:当拼接语句在循环或者高频路径中时,手动声明一个StringBuilder,循环里只用append方法。
很多基础题目会直接拿这个当考点,问你哪种拼接方式性能更高。标准回答是:少量固定数量的拼接,“+”因为编译期折叠或者单次new StringBuilder,性能完全可以接受;而循环拼接,必须用StringBuilder。另外还有一道辨识题:StringBuffer和StringBuilder有什么区别。区别在于StringBuffer的方法加了synchronized,线程安全但有同步开销;StringBuilder线程不安全但更快。单线程环境下没有理由用StringBuffer。回答这类题时,能讲出“单线程场景谁快谁慢,多线程场景谁安全谁不安全”就是满分。
3.3 一个改良版的拼接写法:显式Builder
给出一个可以直接抄的规范写法:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 1000; i++) { sb.append(i); } String result = sb.toString();如果你对性能比较敏感,可以在new StringBuilder时传入一个预估容量,比如确定最终拼接结果为1万字符,可以写new StringBuilder(10000),预先分配底层char数组空间,避免多次扩容。这一点在源码题里也偶尔出现:StringBuilder的底层和String一样是char数组,但它不像String那么死板,可以在空间不够时自动扩容,扩容策略相当于原容量乘2再加2,并把老数组内容拷贝过去。
4. 高频题目实战:从易到难逐题拆解
4.1 题目一:String对象创建了几个
看这段代码:
String str = new String("hello");面试官最爱的经典题:这个语句创建了几个对象?正确答案是“两个,或者一个”。如果常量池里已存在“hello”字面量,则只创建一个堆对象;如果常量池里还没有,则会先创建一个常量池对象,再创建一个堆对象,共两个。注意,String类内部构造函数用Arrays.copyOf去复制底层数组,堆对象和常量池对象是两个完全独立的char数组,内容相同但身份不同。
这道题升级版是这样:
String str1 = new String("hello"); String str2 = new String("hello");两句一共创建了几个新对象?第一句已创建两个,第二句创建了一个(因为常量池已有,无需重复创建)。所以合计三个新的对象。常数很明显的点在于:常量池的对象全局唯一,堆对象每new一次必多一个。
4.2 题目二:字符串参数传递,方法内修改会不会影响外部
题目经常这样写:
public class Test { public static void main(String[] args) { String s = "hello"; change(s); System.out.println(s); } public static void change(String str) { str = "world"; } }输出结果是什么?答案是“hello”。
这题考察的是Java参数传递的本质。Java传参都是值传递,如果参数是引用类型,传递的是引用的副本——你可以理解为“地址的复印件”。change方法里,str这个局部变量被重新赋值为指向"world"的新对象,但这个赋值只改变局部变量自己的指向,方法外的s根本不受影响。这有点像你抄了一份门牌号给朋友,朋友后来把抄件上的地址改成了别处,你的原门牌号一点也不会变。
但如果不重新赋值,而是调用方法修改对象内部内容,情况就不一样。比如传入StringBuilder然后append,方法内外指向的是同一个对象,对象内容改变,外部能看到。String之所以“怎么改都影响不到外部”,核心还是因为不可变性——所有看似修改的操作都是生成新对象,不是改动原对象。
4.3 题目三:intern方法到底发生了什么
intern是String类里存在感最低、但一考就是压轴题的方法。用法是:
String s1 = new String("hello") + new String("world"); String s2 = s1.intern();要理解intern,记住一句话:它试图把当前字符串的内容存入常量池,并返回常量池中对应字符串的引用。具体规则在不同JDK版本中略有差异。
JDK6及以前,常量池位于方法区(永久代),如果常量池里不存在相同内容的字符串,intern会复制一份副本放进常量池,并返回这个副本的引用。那么s1指向堆对象、s2指向常量池对象,二者不同。
JDK7及以后,常量池挪到了堆,intern的优化逻辑也变了:如果常量池里没有内容相同的字符串,它不再复制,而是直接把当前堆对象的引用记录下来,也就是说常量池里保存的是一个指向堆对象的引用,返回的也是这个引用。此时s1和s2指向同一个堆对象。
所以同样是这段代码,两个版本的JDK运行结果会有差异,这也是网上讨论intern时吵得不可开交的原因。实际操作建议是:千万别把业务代码写成依赖intern版本特性的形式,可读性差而且换JDK就可能出问题。平时复习时理解原理就够了。
4.4 题目四:字符串截取、替换、分割后还相等吗
我整理几个高频小陷阱题,直接给结论:
String s = "abcabc"; System.out.println(s.replace('a', 'x')); // xbcxbc,但s不变 System.out.println(s); // abcabc String sub = s.substring(2); System.out.println(sub == "cabc"); // false,substring返回新对象 String[] arr = "a,b,c".split(","); System.out.println(arr.length); // 3replace和substring都会产生新字符串对象,原字符串不受影响。split是按正则切分,分隔符注意转义,比如“.”必须写成"\."。这些结论单独看都简单,但组合到代码阅读题里,特别是逐行问“哪些行会产生新对象”,很多人就会漏判。
有一个JDK版本相关的细节可说可不说,但说出来会显得你更懂:JDK7之前,substring方法内部会共享原字符串的char数组,只通过offset和count来表示截取的范围,好处是快,坏处是大字符串即使只用了一小段,整个大数组也无法被回收,容易内存泄漏;JDK7及以后改成用Arrays.copyOfRange真正复制出新数组,牺牲一点性能换来安全。这个知识点不进基础考纲,但用来做闲暇拓展非常合适。
4.5 题目五:几种初始化和比较的综合题
把上面所有知识点揉在一起,出一道综合题:
String s1 = "java"; String s2 = "java"; String s3 = new String("java"); String s4 = "ja" + "va"; String s5 = new StringBuilder("ja").append("va").toString(); System.out.println(s1 == s2); // true System.out.println(s1 == s3); // false System.out.println(s1 == s4); // true System.out.println(s1 == s5); // false System.out.println(s1.equals(s5)); // true输出结果我直接标注在代码里了。s1和s2是因为常量池复用;s3是new出来的一定不等于常量池引用;s4是编译期常量折叠,直接折叠成“java”,所以同s1;s5由StringBuilder运行时拼接而来,底层是一个新char数组,必然不等于常量池的引用。只有equals才会逐字符比较,所以最后一行是true。
这题如果都能一眼看对,String的基础关就过了。
5. 常见错误盲区与考点速查
5.1 先new后intern,到底怎么走
很多人intern的规则记住了,但一遇到先new后intern的混搭题还是慌。我给出最稳妥的记忆方式:intern做的事情只有两件事,一是查询常量池中有没有内容相同的字符串,有就直接返回它的引用;二是没有就把当前字符串内容对应的引用放入常量池(JDK7+是直接放当前对象的引用),然后返回这个引用。
再看一个常见变体:
String s = new String("a") + new String("b"); System.out.println(s.intern() == s); // JDK7+ 是 true这个结果很多初次接触的人会懵。按照JDK7+的规则就很好理解:“ab”这个内容没有进过常量池,s.intern()就把s这个堆对象的引用放进了常量池,同时返回这个引用,所以和s自己是同一个引用。而另一道变体:
String s = new String("ab"); System.out.println(s.intern() == s); // falsenew String("ab")创建对象时,字面量“ab”已经把常量池占了,intern发现自己内容相同的信息已经存在,直接返回常量池那个引用,和堆里的s不是同一个。
5.2 equals重写的完整逻辑
String类的equals,虽然结论常用“比较内容”来概括,但源码顺序值得了解:
- 如果obj == this,直接返回true(同一对象当然相等,这是性能捷径)。
- 如果obj instanceof String不成立,返回false。
- 比较两个字符串的底层char数组长度,长度不同直接false。
- 逐个字符比较,全部相同才true。
有一个简单但常错的点:String重写了equals同时也重写了hashCode。两个字符串equals相等,hashCode必然相同。这也是它作为HashMap key的基础保障。
5.3 常见错误对照表
| 错误认知 | 正确结论 |
|---|---|
| String可以用==直接比较内容 | 必须用equals,==比的是引用地址 |
| new String("a")创建了一个对象 | 可能会创建两个(常量池+堆),取决于池里是否已有 |
| 字符串拼接总是效率低 | 编译期常量折叠不费运行时开销,只有循环拼接才建议改用StringBuilder |
| StringBuffer一定比StringBuilder好 | 单线程下StringBuilder更快,StringBuffer的同步是开销 |
| String变量不可变,方法内修改会影响外部 | 不可变性反而保证外部一定不受影响 |
这个表我建议你存一下,考前扫一眼就够。
5.4 字符串初始化的默认值问题
还有一个零基础容易踩的坑,也偶尔作为选择题出现:String是引用类型,所以类的成员变量声明但不初始化时,默认值是null,不是空字符串。空字符串是"",也是String对象;null表示没有引用任何对象。调用null对象的任何方法都会抛NullPointerException。所以判断一个字符串是否为空,要分两步:先判断是不是null,再判断是不是空串,常见写法是str == null || str.isEmpty()。如果用Apache或JDK11以上的isBlank()还能顺带把全是空格的情况也处理掉。
6. 一套可复制的基础能力训练组合
6.1 背熟三条核心法则
想不被String基础题虐,我建议从这三条法则入手,它们能覆盖九成题目:
法则一:常量池是全局唯一的,内容相同的字符串字面量只保留一份。
法则二:new关键词永远在堆中产生新的独立对象。
法则三:所有看起来“修改”字符串内容的方法,实际都是生成新对象。
这三句话连在一起,遇到题目时逐条往代码上套,答案基本就出来了。
6.2 配套自测题:五道硬核小题
以下是我自己带人时常出的五道小题,和上面的例题不重复,建议自己先写答案再对照解析:
小题一:
String a = "ab"; String b = "a" + "b"; System.out.println(a == b);小题二:
String a = "ab"; String b = new String("ab"); System.out.println(a.equals(b));小题三:
String a = "ab"; String b = "a"; String c = b + "b"; System.out.println(a == c);小题四:
final String b = "a"; String c = b + "b"; String a = "ab"; System.out.println(a == c);小题五:
String s = "abc"; String t = s.substring(0, 2); System.out.println(t == "ab");解析:小题一,两个都是编译期字面量,常量折叠后同一份常量池对象,true。小题二,equals比较内容,true。小题三,b是变量,c运行时拼接,c是新对象,false。小题四,final让b成了编译期常量,常量折叠发生,c和a指向同一常量池对象,true。小题五,substring返回新对象,和“ab”字面量不是同一个引用,false。
这五道题全对,基础关真的过了。
6.3 学习顺序建议
从零复习String,我推荐的顺序是:先吃透不可变性和常量池这两个核心概念,再搞清==和equals的差别,接着练习拼接类题目,最后研究intern。前面几个没弄清楚前,不要碰intern,因为它是建立在前几个概念之上的进阶玩法。顺序反了会越看越乱。
7. 进阶思考:String之外的相似题目模型
String学透之后,你会发现其他包装类、数组的参数传递题全部是同一套逻辑。比如Integer也是不可变类,方法内对它做加减运算,外部同样不受影响;但如果你传入的是int[]数组,方法内修改arr[0] = 99,外部能看到变化,因为对象本身没有重新赋值。底层逻辑一句话:重新赋值改变的是引用的指向,修改对象内容改变的是内在状态。
应对这类题有个万能公式:先看方法体里变量的引用到底有没有被重新赋值,如果重新赋值了,外部引用不变;如果没重新赋值,而是调用了对象内部方法改内容,就要看这个对象本身是否可变。String和Integer对外表现为“重新赋值无效”,本质原因是不可变性——它们所有操作都强制你重新赋值。
再把视角放大一点,会发现在Spring的配置类、MyBatis的映射文件里,到处都是字符串比较的场景。虽然框架封装好了,但底层判断逻辑仍然是equals和常量池那套。把基础夯实,遇到框架里的字符串性能问题、缓存问题时,才有能力往下排查。
8. 写在最后的个人体会
我教过不少人备考,发现最影响成绩的不是智力,而是“以为自己会了”的错觉。String这个知识点特别容易给人这种错觉,因为代码写起来太简单了,好像没什么好学的,但一到辨析题就错。我的经验是:不要光看答案解析,亲手把例题敲一遍,在IDE里用debug模式看每个变量引用的地址,能直观感受“引用”是什么。地址一样就是同一个对象,地址不一样就是两个对象,这个直观印象建立起来之后,所有的题目都只是组合游戏。
最后分享一个我总结的小技巧:做题时把“String不可变”这句话当成公理来用,任何和修改相关的题目先用公理推导一遍,不要凭感觉判断。严格按照“有没有重新赋值、是不是同一个引用、池里有没有同内容”这个顺序去分析,正确率能稳定在百分之九十以上。等你把这些基础题吃透了,再遇到String in HashMap、常量池溢出这类进阶话题,你会发现它们不过是同一个知识体系的延伸,那时候再深入也不迟。