1. 一道题的真实杀伤力:候选人当场破防
1.1 题面
先来看一道我这两年面试Java候选人时几乎必问的题,也是网上各种Java面试题合集里反复出现的经典。代码不长,就四行:
java复制String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1.equals(s3));
请在心里先给出三个输出结果,然后继续往下看。
答案是:true、false、true。
1.2 现场反应与答案分布
这道题我大概面过几十个候选人,从刚毕业的校招生到号称五年经验的社招都遇到过。统计下来,能一次性完全答对的大概只有四成左右。剩下六成里,有直接说"输出false、false、true"的——这类是背过一点概念,但没理解;也有说"全是true"的——这类是纯粹的初学者思维,根本没区分==和equals;还有更离谱的,思考半天后告诉我"第一个可能是true也可能是false,取决于字符串池策略"。
最让我印象深刻的是一个小伙子,他明显背过八股文,脱口而出"==比较的是引用,equals比较的是内容",然后自信满满地给出了"false、false、true"的答案。我追问了一句:"那为什么第一个是false?你前面不是说==比较引用吗?"他愣住,然后开始支支吾吾。
这就是问题所在。很多人背下来的是结论,却没理解结论背后的机制。一旦面试官换个角度追问,或者代码场景稍作变化,立刻露馅。这篇文章我想把这套东西从头到尾掰开揉碎讲清楚——不仅告诉你是什么,还要告诉你为什么,以及面试官为什么要问这道题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. "=="比较的究竟是什么——从栈、堆和方法区说起
2.1 基本类型:比较的是数值本身
Java里有两类数据类型:基本类型(int、char、double、boolean等)和引用类型(所有new出来的对象)。
基本类型的变量,栈上直接存的就是值。比如:
java复制int a = 10;
int b = 10;
System.out.println(a == b); // true
这时候==比较的就是这两个变量栈帧里的数值,10 == 10,结果自然是true。这没什么好争论的。
但注意,即使是基本类型,也有个容易忽略的细节——float和double的==比较。浮点数因为精度问题,0.1 + 0.2 == 0.3是false,但这是另一个话题了。==本身在这里没做错什么,它只是忠实地比较了两个二进制表示的数值是否完全相同。这个分类一定要在脑子里装清楚:==用于基本类型时,比较的是值,这层意思没有任何歧义。
2.2 引用类型:比较的是"门牌号"
真正让无数人翻车的是引用类型。当变量是对象类型时,栈上存的不是对象本身,而是对象在堆内存中的地址——你可以把它想象成一个门牌号。==比较引用类型时,比较的是这个门牌号是否一样,也就是两个变量是否指向同一个内存地址,而不是门牌号对应的那套房子的装修是否一样。
举个例子:
java复制String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // false
s1和s2分别new出了两个对象,它们在堆上占两块不同的内存,所以门牌号不同,==返回false。尽管两个对象内部的char[]内容都是"hello",但==根本不管这些,它只看门牌号。
这就是为什么网上经常有人说"==比较的是引用"——严格来说是"引用类型的变量,==比较的是引用指向的地址是否相同"。这个表述不完整,因为它没有涵盖基本数据类型的情况,但对于理解引用类型是够用的。
2.3 等号比较的本质:Java里没有"对象变量"这回事
很多初学者会踩一个概念上的坑:以为String s1 = "hello"中的s1就是对象本身。其实不是。Java里所有对象类型的变量,本质上都是引用(或者说指针,虽然Java不让你像C/C++那样操作指针,但机制是相通的)。
我在培训新人时常打一个比方:对象是停在停车场里的一辆车,变量是手上一把车钥匙,钥匙上刻着车位编号。==比较的不是车,而是钥匙上的车位编号是否一样。
两把钥匙如果刻着同一个车位号,那它们指向的是同一辆车——这就是true。两把钥匙刻着不同的车位号,哪怕停着两辆一模一样、连车牌都相同的车,==也是false。
当你写下s1 == s2时,Java虚拟机做的事情就是:取出s1这个变量里存的门牌号,取出s2里存的门牌号,然后比对两个号码是否相等。仅此而已。
理解了这一层,你就明白了为什么说"==比较地址"——因为引用类型变量本身存的就是地址。
3. Object.equals的默认实现:为什么说它"约等于没写"
3.1 Object源码里藏着答案
equals并不是什么高深的方法,它定义在java.lang.Object里,Java里所有类都继承自Object,所以所有对象都能调用equals。问题是,Object里的equals默认实现是什么样的?
直接看JDK源码:
java复制public boolean equals(Object obj) {
return (this == obj);
}
就一行:return (this == obj);
也就是说,如果你没有在自己的类里重写equals,那么调用equals的效果和直接用==一模一样。这太关键了——很多人以为"equals天生就是比较内容的",这是天大的误会。
equals天生什么都不比较,它只是给了你一个可以重写的方法入口。你重写了,它才有"比较内容"的能力;不重写,它就是==的复制品。
3.2 String类第一次改写了equals
既然Object.equals默认比较地址,那为什么s1.equals(s3)是true?因为String类重写了equals。
打开String的源码,其equals方法做的事情大致是:
- 先看是不是同一个对象(
this == anObject),是就直接返回true,快速判断。 - 判断对方是不是
String类型,不是则返回false。 - 对比两个字符串的长度,如果长度不同,直接返回
false——这是最廉价的剪枝操作。 - 一个字符一个字符地比较底层
char[]数组里的值,只要有一个字符不同就返回false,全部相同才返回true。
核心逻辑就是这样。String把"地址比较"改成了"内容比较",所以s1.equals(s3)结果是true——尽管两个变量指向不同的对象,但只要字符序列相同,就认为相等。
这里要专门提醒一下:String重写的equals是大小写敏感的。"hello".equals("HELLO")结果是false。如果你需要忽略大小写比较,要用equalsIgnoreCase。这也是面试中容易顺手考到的点。
3.3 重写equals必须遵守的五条铁律
明白了"重写才有内容比较能力"之后,一个很自然的问题来了:既然String可以重写,那我自己的类重写equals时需要注意什么?
Java官方文档(Object.equals的契约)规定了五条规则,Java基础面试题经常考,也是你在实际开发中必须遵守的底线:
- 自反性:
x.equals(x)必须返回true。自己都不等于自己,那整个逻辑直接崩了。 - 对称性:
x.equals(y)为true时,y.equals(x)也必须为true。这条最容易违反,常见错误是子类重写equals时用了getClass()判断,导致父类对象和子类对象互相比较时不对称。 - 传递性:
x.equals(y)为true,y.equals(z)为true,则x.equals(z)也必须是true。 - 一致性:在对象没有被修改的前提下,多次调用
equals结果必须一致。 - 非空性:
x.equals(null)必须返回false,而不是抛出NullPointerException。
在实际编码中,一条最实用的建议是:用IDE自动生成equals和hashCode,不要手写。IDEA、Eclipse生成的模板都符合规范,手写翻车的概率极高。后面第5章还会详细讲hashCode为什么必须配套重写。
4. 字符串常量池与intern():==在String上"翻车"的真正源头
4.1 一份字面量代码,JVM做了什么
回到开头的代码:
java复制String s1 = "hello";
String s2 = "hello";
注意,这里没有new,就是直接的字面量赋值。很多新手不理解为什么s1 == s2会是true。用"==比较引用"这个结论来说,两个变量指向同一个对象,说明s1和s2的门牌号一样。为什么会一样?
答案是:字符串常量池。
JVM在运行Java程序时,有一块区域专门用来存放字符串字面量。当你第一次执行String s1 = "hello"时,JVM会去常量池里找有没有值为"hello"的字符串对象。没有,于是创建一个放入池中,然后把引用交给s1。当执行String s2 = "hello"时,JVM又去常量池里找,发现已经有了,就不再创建新对象,直接把池中那个现成对象的地址交给s2。
于是s1和s2指向同一个字符串对象,==自然为true。
这个机制和Integer缓存(第6章会讲)在思想上很像——都是为了减少重复创建对象的开销,提高性能。但它的另一面是让==在字符串字面量比较时产生"看起来在比较内容"的假象,特别容易误导初学者。
4.2 new String("hello")到底创建了几个对象
再看第三行:String s3 = new String("hello")。
new是啥意思?强制在堆上创建一个新对象,不管常量池里有没有。所以s3拿到的是一个独立的内存地址——即使内部字符数组内容和池中的"hello"一模一样,但对象本身不是同一个。
所以s1 == s3是false,因为一个指向常量池里的对象,一个指向堆上的新对象,门牌号不同。
这里有个经典追问:new String("hello")到底创建了几个对象?
答案是:一个或两个。如果常量池中已有"hello",只创建一个堆对象;如果常量池中没有,则会创建两个——一个池中对象(或等到后续某个时机被放入池中)加一个堆对象。这个细节在不同JDK版本里有细微差别,但面试时说出"最多两个、至少一个"这个层面的理解,已经足够说明你对机制是清楚的。
4.3 intern()的机制
如果你想把堆上的字符串对象"塞回"常量池复用,Java提供了一个方法:intern()。
java复制String s4 = s3.intern();
intern()的行为是:如果常量池中已经有内容相同的字符串,返回池中对象的引用;如果没有,就把当前字符串的引用加入常量池(JDK 7及以后是存入引用)并返回。
所以s4拿到的,实际上是常量池中那个"hello"对象的引用,和s1是同一个,s1 == s4为true。
不过在日常业务代码里,intern()的使用场景并不多,它更多出现在面试题和某些特定性能优化场景中。滥用intern()会造成常量池膨胀,反而拖垮性能。这个点只要记住结论和原理就好,不用过度编码。
4.4 一张表总结:什么情况下String的==是true
为了方便记忆,我把常见场景整理成一个表:
| 比较表达式 | 结果 | 原因 |
|---|---|---|
"hello" == "hello" |
true |
两个字面量,常量池同一个对象 |
new String("hello") == new String("hello") |
false |
两个不同的堆对象 |
"hello" == new String("hello") |
false |
池中对象 vs 堆对象 |
"hello" == new String("hello").intern() |
true |
intern()返回池中对象引用 |
s1.equals(s3) |
true |
String.equals比较字符内容 |
这个表值得你收藏——面试时或者和同事讨论时拿出来,一看就清晰。
5. hashCode:和equals配套的第二重契约
5.1 Java官方约定
如果说前面几章是"道"的层面理解,那hashCode就是实际开发中最直接的"术"——因为它直接关系到HashMap的性能和正确性。
Java官方文档对hashCode和equals的关系有两个硬性约定:
- 如果两个对象
equals相等,那么它们的hashCode必须相等。 - 如果两个对象
hashCode相等,equals不一定相等(这就是哈希冲突)。
第一条是强制约束,第二条恰恰是哈希表能高效工作的前提——哈希码相同可能是同一个桶,但桶里可能有多个元素,最终还是要靠equals来精确定位。
很多开发者的错误做法是:只重写equals,不重写hashCode。这在绝大多数场景下不会立刻报错,但一旦对象被放进HashMap、HashSet、HashTable这类基于哈希的集合时,就会出现诡异的bug。
5.2 一个活生生的HashMap事故现场
假设有这样一个类:
java复制public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
// 注意:没有重写 hashCode
}
然后你写了这样一段业务代码:
java复制Map<Person, String> map = new HashMap<>();
Person p1 = new Person("张三", 25);
map.put(p1, "工程师");
Person p2 = new Person("张三", 25);
System.out.println(map.get(p2)); // null
直觉上你会认为p2和p1是等价的,应该能取到"工程师"。但实际输出是null。
原因很简单:p1和p2虽然equals相等,但hashCode不同(默认的Object.hashCode是跟对象内存地址相关的)。HashMap在put时,根据p1的hashCode计算出桶位置,把键值对存放在那个桶里;get时,根据p2的hashCode计算出另一个桶位置,直接跑错了桶,连equals都没机会被调用。
你可能会觉得"两个对象equals相等但hashCode不同"是极端情况——不,这是所有没重写hashCode的类都在踩的坑。你的equals越是"按内容比较",这个坑就越大,因为内容相同的对象要散落到不同的桶里。
5.3 正确的重写姿势
正确做法是永远保持"equals相等的对象,hashCode一定相等"。最省事的方式是让IDE生成,或者用Objects.hash:
java复制@Override
public int hashCode() {
return Objects.hash(name, age);
}
Objects.hash会按照你传入字段的组合来生成哈希码,这样name和age都相同的两个对象,hashCode一定相同。这条铁律要记住:重写equals就必须重写hashCode,没有例外。
这里补充一个实用技巧:参与hashCode计算的字段,应该和参与equals比较的字段保持一致。如果equals比较了name和age,hashCode只算name,有可能出现hashCode相同但equals不同的情况——这在哈希表里是允许的(哈希冲突),但会降低性能;反过来,如果hashCode算了equals没有的字段,就会违反"equals相等则hashCode相等"的约束,直接产生bug。
6. 包装类缓存:=="莫名其妙"正确时的另一种陷阱
6.1 现象:Integer的诡异行为
字符串常量池不是唯一会让=="意外正确"的机制。Integer等包装类也有类似设计,而且坑得更隐蔽。
看代码:
java复制Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b); // true
System.out.println(c == d); // false
同样是字面量赋值,为什么127比较为true,128就变成false了?
6.2 原因:valueOf与缓存上限
这背后是Integer的缓存机制。当你写Integer a = 127时,编译器会自动装箱,等价于Integer.valueOf(127)。而valueOf的源码逻辑很明确:
java复制public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
IntegerCache默认缓存了-128到127之间的所有Integer对象。在这个范围内,valueOf返回的是缓存数组里现成的对象,所以a和b拿到的是同一个引用,==为true。超出这个范围,每次都new一个新对象,c和d指向不同对象,==为false。
有面试官会追问:为什么缓存上限是127,而不是128或256?这个设计主要受两个因素影响:
-128到127是byte的取值范围,也是JVM规范中明确要求必须缓存的范围。- 这个范围的整数在业务代码中最常用,缓存命中率高,而且驻留对象数量可控,不会造成明显的内存浪费。
顺带说一句,IntegerCache.high这个上限在启动时可以通过JVM参数-XX:AutoBoxCacheMax调大,但一般没人这么干,缓存太大反而占用内存。理解默认机制就够了。
6.3 与字符串常量池的异同
包装类缓存和字符串常量池的相似之处在于:都是一种"对象驻留"机制,用空间换时间,避免高频创建重复对象。
但有一个显著区别:字符串常量池中的对象是可共享且不可变的(String不可变是硬性约束),而Integer缓存也是基于对象不可变的假设——Integer类本身是final的,内部value字段也是final的,所以缓存安全。
这意味着什么?在你自己的业务类中,不要试图模仿这种缓存机制,除非你的类是真正不可变的。否则缓存对象被修改,所有引用方都会受影响,这是个巨大隐患。
6.4 实际代码里该怎么选
我见到不少人在代码里用Integer的==比较值,这在值恰好落在-128到127范围内时能正常工作,但一旦超出范围就静默出错。这是最危险的情况——不是必现bug,而是概率性bug。
务实的建议是:
- 对于基本类型的包装类(
Integer、Long、Short、Byte、Character、Boolean),永远不要用==比较数值,一律用equals或intValue()后的基本类型比较。 - 这个建议不仅适用于
Integer,Long也有同样的缓存机制(-128到127),Character缓存范围是0到127。 - 如果你的代码规范比较严格,甚至可以直接用
Objects.equals(),它在内部做了空指针防护,比较两个Integer时最稳妥。
7. 面试实战:怎么把这个题答成加分项
7.1 回答框架
如果面试官问你"Java中==和equals的区别",你不需要在十秒内一口气倒完所有知识点。好的回答是有层次的。我建议按下面这个框架来讲:
第一步,先把概念分层:基本类型和引用类型,==的比较含义不同——基本类型比较值,引用类型比较地址。
第二步,再说equals的默认行为:Object.equals默认就是==,String等类重写了equals,才让它从"地址比较"变成"内容比较"。
第三步,用字符串举例,展示==和equals在字符串上的对比结果,自然引出常量池和intern()。
第四步,主动提到重写equals必须重写hashCode,并用HashMap的误用场景作为案例。
按这个顺序答下来,你已经把"是什么、为什么、常见坑、关联知识"都覆盖了。这套回答至少值一个"对Java有深入理解"的评价。
7.2 高频追问与应对
面试官通常不会只满足于一个答案,他会顺着你的思路往下展开追问。我总结几个高频追问,并且给出应对思路:
-
追问一:
new String("hello")创建了几个对象?
回答思路:一个或两个。常量池中没有则先创建一个池中对象,再new一个堆对象;常量池已有则只new一个堆对象。JDK版本不同,池对象创建时机略有差异,但在面试中答出"最多两个"就已经合格。 -
追问二:
s.intern()是做什么的?
回答思路:把字符串引用放入常量池,如果池中已有相同内容的字符串则直接返回池中引用。调用intern()之后,堆对象和池对象可能建立关联,具体行为在JDK 7前后有变化。 -
追问三:重写
equals时为什么必须重写hashCode?
回答思路:基于哈希表的集合依赖hashCode定位桶、equals确定桶内元素。如果equals相等而hashCode不同,对象会散落在不同的桶,导致get失败。官方契约明确要求"equals相等则hashCode相等"。 -
追问四:
Integer a = 128和Integer b = 128,a == b的结果是?
回答思路:false,超过缓存范围后valueOf会创建新的Integer对象。顺带说明-128到127是默认缓存范围,也是byte的取值范围。 -
追问五:为什么说String适合作为HashMap的key?
回答思路:因为String是不可变的,同时重写了equals和hashCode,缓存了hashCode值,作为key既安全又高效。注意这里又反过来用到了equals和hashCode的知识,整个知识体系就串起来了。
7.3 真正拉开差距的地方
最后说点面试之外的实话。
==和equals这个问题,本质上是Java语言设计的一个缩影。它看似简单,但牵扯到内存模型、对象生命周期、标准库设计哲学、哈希算法等多个维度。面试官拷打这个知识点,其实是在试探你对Java底层机制的掌握程度。
如果你只是背一句"==比较地址,equals比较内容",那么面试官稍微换一个场景,比如把String换成StringBuilder、把==换成Objects.equals、把两个变量换成从不同方法返回的对象,你就会露馅。而如果你真理解了对象引用、常量池、缓存机制这些底层逻辑,那么无论场景怎么换,你都能一眼看穿答案。
我个人的建议是:不要为了面试去背这个知识点,而是真的动手敲一遍代码,亲手运行一次,观察结果,再对照源码验证。我自己带过的很多新人,都是从这道题开始对Java内存模型产生真正兴趣的。理解了它,后面再看JVM调优、HashMap源码、并发编程,都会轻松很多,因为这些底层概念是相通的。
