☰
Java面试必问:==与equals的区别及字符串常量池原理
2026/10/14 4:58:35 网站建设 项目流程

1. 先从一道送命题开始:这段代码的输出是什么

做Java面试官这几年,我几乎每次面人都要扔出这么一段代码:

String s1 = "abc"; String s2 = "abc"; String s3 = new String("abc"); System.out.println(s1 == s2); System.out.println(s1 == s3); System.out.println(s1.equals(s3));

结果相当有意思。工作经验超过三年的候选人有不少能答对,但问一句"为什么"就开始含糊。刚毕业的候选人更是重灾区,十个里面有六七个直接懵掉。

先公布答案:true、false、true。

如果你能一口气解释清楚这三个输出背后的原理,那这篇内容对你来说就是复习。如果答不上来,或者只知道背结论"==比较地址,equals比较值"——那你需要把下面这几千字看完。我自己当年在这上面吃的亏,以及后来帮人排查过的线上事故,都和这点破事有关。

需要注意一点:网上很多说法把"equals比较值"当成铁律,这是不准确的。equals的行为取决于哪个类、怎么重写。不搞清楚这个前提,你后面写代码会踩大坑。

2. == 运算符到底在比什么

2.1 两类数据,两种语义

Java里的数据类型分两大类:基本类型和引用类型。== 运算符的行为,在这两种类型上是完全不同的。

对于基本类型(byte、short、int、long、float、double、char、boolean),== 比较的是数值大小。这个没什么好争论的,1 == 1 就是 true,2.0 == 2.0 也是 true。

int a = 100; int b = 100; System.out.println(a == b); // true,比较的是数值

对于引用类型,== 比较的是栈中存放的引用地址,也就是"这两个变量是否指向堆内存中的同一个对象"。它根本不关心这两个对象的内容是否相同。

这里先记住一个核心结论:== 从来不去看对象内部长什么样,它只看栈内存地址一不一样。

用生活化的例子类比:两个人分别拿着一张纸条,纸条上写着一个房间的门牌号。== 做的事情就是对比这两张纸条上的门牌号是不是同一个——如果门牌号相同,说明俩人进的是同一间房;如果门牌号不同,哪怕两个房间装修得一模一样,它也会返回 false。

2.2 关键细节:Integer 缓存坑了你多少次

基本类型用 == 没争议,但基本类型的"包装类"是引用类型,问题就来了。

Integer a = 100; Integer b = 100; System.out.println(a == b); // true,但很多人不知道为什么 Integer c = 200; Integer d = 200; System.out.println(c == d); // false,炸了

同一个写法,100 的时候返回 true,200 的时候就变成 false。为什么?因为 Integer 内部有个缓存机制:值在 -128 到 127 之间的 Integer 对象会被缓存复用。

你写Integer a = 100时,实际上走的是Integer.valueOf(100)。valueOf 方法会先检查这个值是否在缓存范围内,如果在,直接返回缓存数组里那个现成的对象,不会 new 新的。所以 a 和 b 拿到的是同一个对象,== 自然返回 true。

但 200 不在缓存范围内,每次 valueOf 都会 new 一个对象出来,c 和 d 指向的是堆里两个不同的对象,门牌号不一样,== 就返回 false。

这不是什么冷门知识点,而是每天都在发生的隐性坑。我见过有人在分页参数判断里这么写:

if (pageSize == 200) { // 某个特殊逻辑 }

如果 pageSize 是 Integer 类型,这个判断在某些情况下会诡异失效。你排查半天以为是并发问题,其实底层就是 Integer 缓存边界没搞懂。

3. equals 方法的前世今生

3.1 默认的 equals:脱下伪装,它其实就是 ==

equals 是哪里来的?所有类的老祖宗 Object 类里定义的。那么 Object 里的 equals 是怎么实现的?看源码:

public boolean equals(Object obj) { return (this == obj); }

没看错,Object 默认的 equals 就是用 == 实现的,比的是引用地址。

这意味着什么?如果你自己定义了一个类,没有重写 equals 方法,那调用 equals 和调用 == 完全没有区别。很多初学者以为 equals 天生就是"比较内容",这是个天大的误会。equals 之所以能比较内容,是因为 String、Integer 这些类重写了它。

来看 Integer 的 equals 源码:

public boolean equals(Object obj) { if (obj instanceof Integer) { return value == ((Integer)obj).intValue(); } return false; }

先把 obj 拆箱成 int,再用 == 比较数值。这才是"equals 比较值"这个说法的真正来源——它只是针对某些重写过 equals 的包装类成立,不是一个放之四海皆准的规律。

3.2 String 的 equals 是如何逐字符比较的

String 是我们平时打交道最多的类,它的 equals 实现值得仔细看:

public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String) anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }

这个实现有几个很实用的细节:

第一,进来先判断 this == anObject。如果引用都一样,说明是同一个对象,那还比什么,直接 true。这是一种短路优化,避免后面逐字符遍历的开销。

第二,先判断类型。用 instanceof 检查传入对象是不是 String,不是就直接 false。这样能防止你用字符串去 equals 一个 Integer 之类的对象,不会抛异常,只是安全地返回 false。

第三,先比较长度。长度都不一样,内容肯定不一样,直接返回 false,省去逐字符比较。

第四,逐字符比较。真正的内容判断是靠循环里逐个比较 char 值完成的。

我把这些源码细节摆出来,是想让你明白一个道理:String 的 equals 是一个"优化过的、逐字符比较"的算法。它不是魔法,不是 Java 虚拟机里特别开的后门,就是普普通通的 Java 代码,用了一点常见的性能优化手段而已。

3.3 == 和 equals 选哪个:一句话决策法

说了这么多,其实开发中怎么选特别简单:

判断两个"基本类型"的数值,用 ==。判断两个"对象内容"是否相等,用 equals——前提是这个类重写了 equals,而且是按你想的语义重写的。

如果你的业务类没有重写 equals,用 == 和用 equals 效果一样,都是比较地址。所以别指望什么默认魔法,要用 equals 比较内容,必须确认目标类重写过它。

4. 字符串常量池:理解 String 比较的核心钥匙

4.1 "abc" 和 new String("abc") 到底差在哪

回到开头那个题。String s1 = "abc"和String s3 = new String("abc"),同样是"abc",为什么 == 返回 false?

关键在于字符串的创建方式不同,走了两条完全不同的内存路径。

当你写String s1 = "abc"时,JVM 会先去方法区里的字符串常量池查找有没有"abc"这个字面量。第一次没有,就创建一个放进常量池;第二次再遇到s2 = "abc",发现常量池里已经有了,直接把池里那个对象的地址赋给 s2。所以 s1 和 s2 指向同一个对象,== 为 true。

而new String("abc")是在堆内存中强制 new 了一个新对象。注意,new 操作是无论如何都会创建一个新的堆对象,不管常量池里有没有"abc"。所以 s3 和 s1 是两个不同的对象,它们的地址必然不同,== 为 false。

再强调一个容易失分的细节:new String("abc")创建的对象,常量池里可能已经有"abc"了——因为构造参数里的"abc"本身就是一个字符串字面量,这个字面量在类加载阶段就会被放进常量池。所以这个语句实际可能产生两个对象:常量池里那个,和一个堆里 new 出来的。只不过 s3 引用的是堆里的那个。

4.2 intern() 方法:手动把对象"塞进"常量池

String 类有个 intern() 方法,作用是把当前字符串对象的内容登记到常量池里,返回常量池中的对象引用。

String s1 = "abc"; String s3 = new String("abc"); String s4 = s3.intern(); System.out.println(s1 == s3); // false System.out.println(s1 == s4); // true

调了 intern() 之后,如果常量池里已经有相同内容的字符串,它直接返回池里那个对象的引用;如果没有,就把当前字符串的引用加入池中并返回。这样 s4 和 s1 指向的就是同一个东西了。

关于 intern 在 JDK 不同版本中的行为差异,我多说一句:JDK 7 之前,字符串常量池在方法区(永久代),intern 在池里没有对应字符串时,会拷贝一份副本存入池中;JDK 7 以后,字符串常量池挪到了堆里,intern 在有需要时不会再拷贝副本,而是直接把堆中对象的引用放入池中。这导致 JDK 7 前后的 intern 返回值在极端场景下会有细微差别。不过日常开发中用到 intern 的频率不高,面试中能说清这个变化是加分项。

4.3 开发中用 == 判断字符串的场景:什么时候可以

既然 == 在字符串比较上这么多坑,是不是完全不建议用 == 判断字符串?也不绝对。有几个特定场景下用 == 不但没问题,反而性能更好。

最常见的场景是用字符串做状态机的时候。如果你定义了一组字符串常量,并且保证这些常量在类加载时就已经存在于常量池,那么这些变量指向的都是池里的同一个对象,用 == 判断是安全的:

public static final String STATUS_SUCCESS = "SUCCESS"; public static final String STATUS_FAIL = "FAIL"; if (status == STATUS_SUCCESS) { // 这里用 == 是安全的,因为两者都是常量池里的同一个对象 }

注意,这里有个前提:status 这个变量必须是通过常量赋值或者 intern 过的方式得到的,不能是外部传入的 new String。如果 status 是从数据库读出来的、或者由字符串拼接得到的新对象,== 就会失效。

我自己在实际项目中,对这种"固定状态码判断"会使用 ==,因为常量池里的对象复用保证了两者必然同址。但任何来自外部输入、配置文件、网络传输的字符串,一律用 equals 或 Objects.equals,这是底线。

5. 重写 equals 必须同时重写 hashCode

5.1 hashCode 契约:为什么是"必须"而不是"建议"

很多教材都会说"重写 equals 时要重写 hashCode",但没说为什么。这个"为什么"才是关键。核心原因是 Java 的散列集合(HashMap、HashSet、Hashtable)依赖 hashCode 来定位存储位置。

先看一个约定的三条规则:

  • 两个对象用 equals 比较相等,那么它们的 hashCode 必须相等。
  • 两个对象用 equals 比较不相等,它们的 hashCode可以相等(但会造成哈希冲突,影响性能)。
  • 同一个对象在未被修改状态的前提下,多次调用 hashCode 必须返回同一个值。

这条规则不是随便定的。HashMap 的 put 和 get 流程是:先计算 key 的 hashCode,确定落在哪个桶,再在桶里用 equals 逐个比对。

回到现实事故。我帮同事排查过一个诡异 bug:某系统用自定义对象做 HashSet 的元素,往集合里 add 进去了一个对象,然后修改了这个对象的某个字段,再用一个内容完全相同的新对象去 contains,结果返回 false。查了整整一下午,最后发现就是因为这个自定义类只重写了 equals,没重写 hashCode。

我说一下这个事故的完整原理。HashSet 的 add 流程是:先调 hash 方法获取对象的哈希值,定位到某个哈希桶,然后在桶里用 equals 比对。这个自定义类没重写 hashCode,所以它用的是 Object 默认的、基于内存地址生成的哈希值。两个内容一样的对象,地址不同,哈希值几乎不可能相同,算出来的桶都不一样,equals 连被调用的机会都没有。

在哈希集合里,hashCode 决定"去哪个抽屉找",equals 决定"抽屉里的东西是不是你要的"。两个抽屉都不是同一个,equals 再有本事也找不到。

5.2 手写一个正确的 equals 和 hashCode 组合

正确的做法长什么样?看下面这个示例:

public class User { private String name; private int age; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return age == user.age && Objects.equals(name, user.name); } @Override public int hashCode() { return Objects.hash(name, age); } }

注意几个细节:

  • 先用 this == o 做短路判断,效率高。
  • 用 getClass() != o.getClass() 而非 instanceof,这样可以避免父子类之间 equals 不对称的问题。举个例子,两个对象一个是父类实例、一个是子类实例,用 instanceof 判断时可能互相认为相等,交换调用顺序就会得到不同结果,破坏 equals 的对称性。getClass() 判断能保证类型严格一致。
  • 参与 equals 比较的每个字段,必须同时参与 hashCode 计算。上面这个类里,equals 用了 name 和 age,hashCode 也必须这两个字段都参与。如果 equals 里用了三个字段,hashCode 只算两个,那么第三个字段不同时哈希值相同,对象会被放错桶,导致查找逻辑混乱。
  • null 处理:name 可能为 null,所以用 Objects.equals 来安全比较,它内部做了 null 判断。别自己写name.equals(user.name),万一 name 是 null 就直接空指针了。

5.3 只重写 equals 不重写 hashCode 的翻车现场

我再补一个更具体的翻车案例。某个业务里,一个订单去重功能:

Set<Order> orders = new HashSet<>(); orders.add(order1); Order duplicate = new Order(order1.getId(), order1.getAmount()); System.out.println(orders.contains(duplicate)); // false,明明内容一样却找不到

订单类只重写了 equals,没重写 hashCode。add 的时候 order1 根据默认 hashCode 存进了一个桶,contains 的时候 duplicate 因为对象地址不同,默认 hashCode 也不同,被定位到另一个桶。哪怕它的 equals 返回 true,也没机会被调用。

凡是遇到"对象内容明明相等,集合却判断不存在"这种问题,第一反应就应该是检查 hashCode 契约。我用 500 行代码换来的教训:在 Java 的散列集合世界里,equals 是最后一道关卡,hashCode 是第一道也是决定性的一道关卡。

6. 实际开发里的黄金法则与典型问题

6.1 Objects.equals 能解决什么

既然 NullPointerException 是 Java 程序员的老朋友,那么如果你要判断两个字符串是否相等,上策是用 java.util.Objects 工具类:

Objects.equals("abc", null); // false,安全 Objects.equals(null, null); // true,两个都是 null 视为相等

看它的源码,其实就是做了一次空判断再调用 equals:

public static boolean equals(Object a, Object b) { return (a == b) || (a != null && a.equals(b)); }

这个工具类的价值在于双端空安全。你写a.equals(b)时要小心 a 为 null 的情况,而 Objects.equals 推荐方法的顺序是把可能为 null 的一方放在第二个参数。原因看源码就懂,a 如果为 null,直接短路返回 false;只有两边都 null 才返回 true。如果你把可能为 null 的对象放第一个参数,单看源码逻辑也能工作,但阅读性不好,而且容易让人误以为第一个参数不能为 null。保持一个固定习惯:非 null 的放前面,可能为 null 的放后面。

6.2 一个速查表理清所有场景

我把日常写代码会遇到的比较场景整理成一张表,直接照着用就行:

场景推荐方式说明
两个 int/long 等基本类型==比较数值,无任何歧义
Integer 等包装类比数值equals或intValue() ==直接 == 会踩缓存边界坑
String 内容比较equals或Objects.equals除非是常量池内的字面量
枚举类型判断==枚举实例全局唯一,== 安全且高效
自定义业务对象equals,同时重写hashCode否则 HashSet/HashMap 无法正常工作
两个 null 的情况Objects.equals(a, b)安全处理,不会空指针
比较地址/身份==确定两个变量指向同一个对象

6.3 一个真实线上问题的排查实录

最后分享一个我带新人时遇到的经典问题。有个系统接到反馈,某个配置项始终不生效。这属于典型的"代码看起来没问题,但行为诡异"的 bug。

配置加载逻辑大概是:

String target = getConfigFromDb(); // 从数据库读出来的 if (target == DEFAULT_TARGET) { // 应用默认配置 }

数据库里存的字符串内容确实和 DEFAULT_TARGET 常量一模一样,但 == 判断始终为 false,导致默认配置永远不生效。

原因是 getConfigFromDb() 返回的字符串是从数据库读取后 new 出来的对象,它在堆中是一块新的内存,和常量池里 DEFAULT_TARGET 的地址必然不同。== 比的是地址,当然返回 false。就算内容一模一样也没用。

排查过程:新人一开始很自信,说"== 比较值啊,内容一样应该 true"。我让他打印 identityHashCode,他才发现问题。这就是典型的把"equals 比较内容"和"== 比较地址"弄混,再加上对 String 对象创建方式不敏感导致的问题。

这个问题的正确修法很简单,把 == 改成 Objects.equals(target, DEFAULT_TARGET) 就完了。但理解背后的原理比改这一行代码重要得多。

6.4 面试题变体:这些说法哪些对哪些错

面试和实际开发中,经常会有下面这些说法,你能快速判断对不对吗?

说法一:"两个字符串内容相同,== 一定返回 true。"错,如果是 new String 出来的两个对象,内容相同但地址不同。

说法二:"equals 一定比较内容。"错,要看有没有重写 equals。Object 默认的 equals 和 == 没区别。

说法三:"String 用 == 一定不安全。"错,如果两个引用都指向常量池里的同一个对象,比如两个字面量相同时,== 是安全的。

说法四:"Integer 超过 127 后 == 就一定返回 false。"错,如果两个对象是通过同一个变量赋值或者同一个对象引用传递,地址相同,== 还是 true。缓存边界只是说自动装箱时的 valueOf 行为。

我在实际操作中的经验是,面试时能把"== 是地址比较、equals 是方法调用、String 有常量池复用机制、Integer 有缓存边界、重写 equals 要配 hashCode"这五条说清楚,这个知识点的掌握程度基本就够了。这五条就是 Java 值比较的完整地图,剩下的都是它们在不同场景下的组合变化。

如果你看到这里还是不确定自己有没有真懂,回头再跑一下开头那段代码,试着把每一行的内存引用画出来。什么时候你能闭着眼睛画出字符串常量池、堆、栈之间的引用关系,这个知识点就算彻底过关了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询