🤔 面试官问:"你能通俗地讲一下 Java 中 == 和 equals() 的区别吗?"
- 第一步:关于 ==
Java 中的数据分为基本数据类型(如
int、double)和引用数据类型(如 String、自定义的对象)。对于基本数据类型,== 比较的就是它们具体的数值;而对于引用数据类型,就像你说的,比较的是内存地址。 - 第二步:关于 equals() 的"坑" 🕳️
equals 比较的是"实际值",这其实是面试官最爱挖的一个陷阱。
Java 中所有的类都默认继承自 Object 类。你知道吗?Object 类里面最原始的 equals() 方法,它的内部代码其实就是直接用 == 来比较的(也就是比较地址)!
追问1:
既然最原始的 equals() 也是比较地址,那为什么我们用 equals() 比较两个内容相同的 String 字符串时,它又能正确比较出"实际值"呢?你觉得 String 类在内部对 equals() 方法做了什么特别的处理?
String 类确实重写了 Object 类的 equals() 方法。它的内部逻辑很聪明:首先会用 == 检查一下是不是同一个内存地址,如果是,直接返回 true 以提高效率;如果地址不同,它会再去逐个比较字符串里包含的每一个字符是否完全相同。不仅是 String,Java 里的包装类(比如 Integer)也都重写了这个方法来比较实际的值。
追问2:
🤔 "既然我们在自定义类中重写了 equals() 方法,为什么官方规定通常也必须连同 hashCode() 方法一起重写呢?"
!NOTE
想象一下,Java 里的
HashSet或HashMap就像是一个有成百上千个抽屉的巨大文件柜 🗄️。
hashCode()的作用 :它就像是一个"快速向导",能根据对象的特征算出一个整数(哈希值)。系统用这个数字来决定把对象放进哪个编号的抽屉里。这个过程速度极快!
equals()的作用 :当向导带你走到指定的抽屉前,打开抽屉发现里面可能已经放了好几个文件时,就需要用equals()挨个仔细对比,找到内容完全一样的那份文件 🔍。
Java 官方有一条硬性规定:如果两个对象用 equals() 比较是相等的,那么它们的 hashCode() 算出来的值必须也一样。也就是说,内容相同的文件,必须被分到同一个抽屉里。
追问3:
如果你把名字叫"小明"的学生 A 存进了 HashSet,然后你又 new 了一个新的名字也叫"小明"的学生 B,去这个 HashSet 里查找是否存在"小明",你觉得系统能顺利找到他吗?为什么?
不能顺利找到,因为因为系统会发现Student 类实际上没有重写 hashCode()(默认还是按它们在内存里的物理地址来算抽屉号),系统根据它们不同的物理地址算出了不同的哈希值,把它们放进了不同的"抽屉"里。,自然找不到。
假设你的项目里维护了一个非常长的数据列表,而你的业务需求是需要频繁地在列表的头部或者中间位置插入和删除数据 。在这种情况下,你会选择使用 ArrayList 还是 LinkedList 呢?能说说你的理由吗?
1. ArrayList(底层是动态数组) 你可以把它想象成电影院里连在一起的一排座位 。它在内存中是连续存放的。如果你想在已经坐满人的中间强行加一个新座位(也就是中间插入数据),那么从这个位置往后的所有人,都必须辛辛苦苦地依次往后挪动一个位子,把空位腾出来。在程序里,这就意味着大规模的"数据复制和搬迁",数据列表越长,插入越慢,极其耗费性能。
2. LinkedList(底层是双向链表) 你可以把它想象成一群人手拉着手站成一排 。每个人(在 Java 里叫作"节点 Node")不仅保管着自己的数据,还记住了左手拉着谁(前一个节点),右手拉着谁(后一个节点)。 在这样的队伍里,如果你想在中间加一个人进去,根本不需要让后面所有人大挪移。只需要让原本相邻的两个人暂时松开手,和新来的人分别牵上手就可以了。这种仅仅改变"拉手关系"(指针指向)的操作,极其轻量和迅速。
所以,只要涉及到大量且频繁的插入和删除,LinkedList 是绝对的首选。
在真实的面试中,当你清晰地讲出"数组连续内存"和"链表节点指针"的区别时,就能很好地展现出你扎实的基本功。
追问1:
🤔 "如果现在的业务场景反过来了,我不需要频繁插入和删除,而是需要极高频率地'随机读取',比如我要瞬间拿到第 888 个位置上的数据,这时候该选 ArrayList 还是 LinkedList 呢?为什么?"
因为 ArrayList 底层是连续的内存空间 ,系统只要知道这排座位的"起始地址",配合每个座位固定的大小,就能通过一个简单的数学乘法,瞬间直接定位到第 888 个座位的绝对位置。这种能力叫作**"支持快速随机访问"**。
🤔 面试官问:"你能说说在 JDK 8 中,HashMap 的底层是由哪些数据结构组成的吗?"
它的完整底层结构是:数组 + 双向链表 + 红黑树 。
为什么 JDK 8 要在"数组+链表"的基础上,硬塞一个"红黑树"进去呢?
继续用咱们刚才"文件柜"的例子来想象: 假设系统的哈希算法不太好,导致大量不同的对象都算出了相同的抽屉号,全挤在了同一个抽屉里。如果这些文件全是用一条绳子串起来(链表),你要找排在最后的那份文件,就得顺着绳子从头撸到尾,原本 O(1) 的效率瞬间退化成了 O(n)。 为了解决这个问题,JDK 8 规定:当链表长度大于 8,且数组总容量大于等于 64 时,这条长长的链表就会"变身"成一棵红黑树。红黑树是一种自平衡的二叉树,它的查找效率极高(时间复杂度是 O(log n)),这样就算某个抽屉塞满了数据,也能保证查找速度依然飞快。
🤔 面试官问:"你前面说到 HashMap 很好用,那它是线程安全的吗?如果我在多线程环境下,同时对同一个 HashMap 进行写操作(put),会发生什么严重的问题?"
在不同的 JDK 版本中,它在多线程下暴露的"致命弱点"还不一样:
-
在老版本(JDK 7)中 :最臭名昭著的问题是,当多个线程同时触发
HashMap的"扩容"(也就是发现抽屉不够用了,要换一个更大的柜子并把文件重新摆放)时,由于内部链表节点的指针反转,可能会导致链表变成一个死循环(环形链表)。一旦发生,服务器的 CPU 会瞬间飙升到 100%,直接卡死。 -
在新版本(JDK 8)中 :虽然修复了死循环的问题,但如果在同一时刻,有两个线程恰好都要把数据放进同一个"空抽屉"里,就会发生数据覆盖------后放进去的数据会直接把前一个人的数据无情抹掉,导致数据丢失。
追问1:"如果在真实的业务代码里(比如我们要统计网站的实时在线人数),我们确实需要在一个多线程并发的环境下使用哈希表,你应该用哪个 Java 自带的类来完美替代 HashMap 呢?"
ConcurrentHashMap,HashTable太笨重
追问1:
🤔 "那你能说说,ConcurrentHashMap 是怎么做到既保证线程安全,又保证处理速度飞快的呢?它在 JDK 7 和 JDK 8 中的实现有什么区别?"
1. JDK 7 的时代:分段锁(Segment) 想象一下,以前有一种非常古老且笨重的容器叫 HashTable,它的做法是把整个文件柜(整个 Map)直接上锁。只要有一个人在往里面存文件,其他人不管存取哪个抽屉,都得在门外干等。效率极低! 而 JDK 7 的 ConcurrentHashMap 非常聪明,它采用了**"分段锁(Segment)"**的机制。它把一个大文件柜拆分成了 16 个独立的小文件柜(Segment),每个小柜子配一把独立的锁。这样一来,只要多个线程操作的不是同一个小柜子,大家就可以同时干活,互不干扰!
2. JDK 8 的时代:粒度更细(Node + CAS + synchronized) 到了 JDK 8,Java 的设计者觉得 16 把锁还是不够快,于是做了一次大手术: 它直接抛弃了"小文件柜"的概念,采用了和 JDK 8 HashMap 完全一样的底层结构(数组 + 链表 + 红黑树)。 那它是怎么上锁的呢?它把锁的粒度做到了极致------直接锁住每一个抽屉(数组的每一个头节点) !如果你往 1 号抽屉放东西,只会锁 1 号抽屉,别人去 2 号抽屉放东西完全不受影响。 同时,它在很多地方放弃了传统的重量级锁,大量使用了一种叫 CAS 的乐观锁技术,让性能又提升了一个台阶。
预告:
🤔 面试官问:"你前面把 HashMap 的底层原理说得很透彻,那你能顺便说一下 HashSet 的底层是怎么实现的吗?"
很多新手会顺手写出这样的代码(使用最常见的 foreach 循环):
Java
List<String> list = new ArrayList<>(Arrays.asList("张三", "李四", "王五"));
for (String name : list) {
if ("李四".equals(name)) {
list.remove(name); // 找到李四,执行删除
}
}
🤔 面试官问:"这段代码在运行的时候会发生什么?为什么?如果让你来写,正确的边遍历边删除的方法是什么?"