一、List类相关面试题
- Java中List集合的核心定义是什么?它和Collection、Set的关系是什么?常见的List实现类有哪些?
• 核心回答:List是java.util.Collection接口的核心子接口,存储的元素有序且可重复------"有序"指元素存入顺序与取出顺序完全一致,支持通过索引(类似数组下标)直接访问指定位置的元素;与Set是Collection接口下的并列子接口,Set的核心特性是"无序且不可重复",不支持索引访问;常见的List实现类包括ArrayList(基于动态数组实现)、LinkedList(基于双向链表实现)、Vector(早期线程安全的List,属于历史遗留类)。
• 思考+例子:生活中最贴合List特性的场景是"学生的每日作息表"。比如你早上按"7:00起床、8:00上课、12:00吃饭、14:00上课"的顺序记录,晚上回顾时,取出的顺序和记录顺序完全一样(有序),即使"上课"这个内容出现两次(可重复),也会完整保留;如果想找"第三件事",直接看作息表的第三个位置(索引2),就能快速找到"12:00吃饭"(支持索引访问)。
Collection就像"所有待办事项的总称",而List是"按顺序记录的待办",Set则是"不重复的待办"------比如"家里需要采购的物品",如果用Set记录,"牛奶"只会出现一次(不可重复),不用管记录顺序;但用List记录,可能会按"牛奶、面包、牛奶"的顺序记(可重复+有序),方便后续按顺序采购。
常见实现类的差异也能通过生活场景区分:ArrayList像"学校的固定课桌",每个课桌有固定编号(索引),找第5排3号直接按编号走;LinkedList像"串在绳子上的钥匙扣",要找第3个钥匙扣,必须从第一个开始挨个捋;Vector则像"带锁的课桌",每次只有一个人能使用,其他人需要排队(线程安全但效率低)。
- ArrayList 和 LinkedList 的底层数据结构有什么本质区别?这种区别会带来哪些实际使用场景的差异?
• 核心回答:两者底层数据结构完全不同------ArrayList基于动态数组,元素存储在连续的内存空间中,每个元素通过索引与内存地址直接关联;LinkedList基于双向链表,每个元素(称为"节点")包含三部分:元素值、前驱节点引用(指向前一个节点)、后继节点引用(指向后一个节点),节点间通过引用连接,内存空间不连续。
这种差异直接导致场景适配不同:ArrayList适合"读多写少、按索引访问"的场景,因为按索引查找元素效率极高(时间复杂度O(1));LinkedList适合"写多(中间位置增删)读少"的场景,因为中间位置增删只需修改节点引用,无需移动大量元素(时间复杂度O(1),但查找需遍历,整体增删操作时间复杂度仍为O(n),仅平均步长更优)。
• 思考+例子:先从数据结构的直观感受入手------ArrayList就像你把书整齐地摆放在书架的固定格子里,每个格子有编号(比如第1格、第2格),想找第3格的书,直接伸手就能拿到(按索引访问快);而LinkedList像把书用绳子串起来挂在墙上,每个书只和前一本、后一本有连接,想找第3本书,必须从第一本开始,顺着绳子挨个捋到第三本(按索引访问慢)。
实际场景差异更明显:比如"查字典"------如果你要找《新华字典》的第50页(按索引访问),直接翻到第50页(ArrayList),不用从头翻;但如果字典是用绳子串起来的(LinkedList),要找第50页,得从第1页开始一页页捋,效率天差地别。
再看"中间位置增删":比如你要在书架的第3格和第4格之间插入一本新书(ArrayList),必须先把第4格、第5格......所有后面的书都往后挪一格,才能腾出位置;但如果是串在绳子上的书(LinkedList),要在第3本和第4本之间插一本,只需要把连接第3本和第4本的绳子解开,分别系在新书的两端,不用动其他任何书,操作效率远高于ArrayList。
需要特别注意:如果是"按元素内容查找"(比如找一本封面是蓝色的书),不管是ArrayList还是LinkedList,都得从头开始挨个看(时间复杂度O(1))------就像在一堆零食里找薯片,不管是按排摆(ArrayList)还是串着挂(LinkedList),都得一个个检查包装,效率没有差异;如果是"在末尾增删"(比如在书架最后加一本书),ArrayList直接把书放在最后一格,不用挪其他书,这时两者效率也差不多。
- ArrayList 的扩容机制具体是怎样的?为什么必须扩容?扩容时会有什么性能影响?
• 核心回答:ArrayList基于数组实现,而数组一旦创建,长度就固定不变,因此当元素数量超过数组容量时,必须通过"扩容"来容纳新元素。扩容的触发时机是:调用add()方法添加元素前,先检查"当前元素个数(size)+1"是否超过数组容量(capacity),如果超过则触发扩容。
JDK1.8及以后,ArrayList默认初始容量为10,扩容时新容量=原容量×1.5(通过oldCapacity + (oldCapacity >> 1)计算,右移1位等价于除以2,避免浮点数运算导致的精度问题)。扩容的具体步骤是:先创建一个长度为新容量的数组,再通过Arrays.copyOf()方法将原数组的所有元素拷贝到新数组中,最后将存储元素的elementData数组引用指向新数组。
扩容会带来两方面性能影响:一是"数组拷贝"会消耗CPU资源,元素数量越多,拷贝耗时越长;二是扩容后的新数组可能存在空闲空间(比如原容量10,扩容后15,仅存11个元素),造成内存浪费。
• 思考+例子:用"装米的米缸"来理解扩容机制最贴切。你买了一个能装10斤米的米缸(ArrayList初始容量10),当米缸里的米已经装满10斤时,再倒1斤米(add元素),米就会洒出来(数组溢出),这时候必须换一个更大的米缸(扩容)。新米缸的容量是15斤(10×1.5),你需要先把原来的10斤米倒进新米缸(数组拷贝),再把新的1斤米倒进去------这个"倒米"的过程就是耗时的拷贝操作,如果原米缸装了100斤米(原容量100),扩容后150斤,倒米的时间会更长(拷贝100个元素)。
内存浪费的问题也很明显:新米缸装了11斤米后,还剩4斤的空位置(15-11=4),这些空位置就是被浪费的内存;如果频繁扩容,比如从10→15→22(15×1.5=22.5,取整22)→33→49→73,每次扩容后都会有不同程度的空闲空间,累积起来会浪费不少内存。
实际开发中的优化技巧:如果你提前知道ArrayList需要存储的元素数量,比如要存50个学生的成绩,创建ArrayList时直接指定容量new ArrayList(50),就能避免默认的多次扩容(从10到15到22到33到49到73,共5次扩容),既减少了CPU拷贝的开销,又避免了内存浪费------就像你知道要装50斤米,直接买50斤容量的米缸,不用先买10斤的,不够了再一次次换大的。
- ArrayList 为什么用 transient 修饰存储元素的 elementData 数组?它是如何实现自定义序列化的?
• 核心回答:transient是Java中的关键字,作用是"阻止被修饰的变量参与默认序列化过程"。ArrayList用transient修饰elementData数组,核心原因是elementData的长度(数组容量)可能大于实际存储的元素个数(size),默认序列化会将数组中的空位置(未存储元素的索引)也一并序列化,导致IO资源浪费(序列化数据量变大)和内存占用增加(反序列化后仍会保留空位置)。
为了解决这个问题,ArrayList通过重写writeObject()和readObject()方法实现自定义序列化,只序列化size个实际存储的元素,不序列化空位置。具体来说,writeObject()方法会先将size(实际元素个数)写入输出流,再循环将elementData[0]到elementData[size-1]的元素依次写入;readObject()方法则先从输入流中读取size,再创建一个长度为size的新数组,然后将输入流中的size个元素依次存入新数组,最后将elementData引用指向新数组。
• 思考+例子:想象你有一个"带10个格子的文具盒"(elementData数组,容量10),但你只装了6支笔(实际元素个数size=6),剩下4个格子是空的。如果要把这个文具盒"打包快递"(序列化),默认的打包方式会把10个格子都包进去,包括4个空格子------这就像你寄快递时,把空格子也塞满泡沫(浪费包装材料),快递重量增加(序列化数据量变大),运费也更高(IO开销增加)。
而ArrayList的自定义序列化就像"智能打包":你先数清楚"有6支笔"(记录size=6),然后只把6支笔拿出来,按顺序放进小盒子里(序列化实际元素),快递单上注明"共6支笔"(写入size);收件人收到快递后(反序列化),先看快递单"6支笔",找一个能装6支笔的小盒子(创建容量为6的数组),再把6支笔按原来的顺序放进盒子里------这样既节省了包装材料(减少序列化数据量),又加快了打包和拆包速度(节省IO时间),还不会有多余的空格子(避免内存浪费)。
这种设计的核心优势是"按需序列化",只处理有用的数据,避免无效数据占用资源。比如一个ArrayList容量为1000,但实际只存了10个元素,默认序列化要处理1000个位置,而自定义序列化只需处理10个元素,效率提升非常明显。
- 什么是快速失败(fail-fast)和安全失败(fail-safe)?ArrayList 属于哪种?两者的核心原理和适用场景有什么区别?
• 核心回答:快速失败(fail-fast)和安全失败(fail-safe)是Java集合应对"多线程并发修改"的两种不同机制,ArrayList属于快速失败机制。
• 原理区别:①快速失败:迭代器创建时会记录集合的"修改次数"(modCount),并将这个次数存入迭代器的expectedModCount变量中。在迭代过程中,每次调用next()或hasNext()方法前,都会检查集合当前的modCount是否与expectedModCount一致------如果不一致,说明其他线程在迭代期间修改了集合(增、删、改元素),会立即抛出ConcurrentModificationException异常,终止迭代。②安全失败:迭代器创建时会先拷贝集合的一份"快照"(通常是复制集合的底层数组),迭代过程中只操作这份快照,不直接访问原集合。即使其他线程修改了原集合,快照也不会变化,因此迭代过程中不会抛出异常,但迭代器访问的始终是迭代开始时的旧数据,无法获取原集合的实时修改。
• 场景区别:快速失败适合"单线程环境或非并发修改的多线程环境",比如用户个人的待办清单(只有用户自己修改和查看),核心优势是"及时发现并发修改",避免迭代过程中使用被篡改的数据;对应的集合类主要在java.util包下,如ArrayList、HashMap。安全失败适合"多线程并发修改且允许读取旧数据"的场景,比如多用户访问的商品库存列表(用户频繁查看库存,后台线程偶尔更新库存),核心优势是"迭代不被打断",保证读操作的连续性;对应的集合类主要在java.util.concurrent包下,如CopyOnWriteArrayList、ConcurrentHashMap。
• 思考+例子:用"看纸质笔记"的场景来理解两种机制的差异:①快速失败:你正在看自己的笔记(迭代ArrayList),笔记上写着"1.学习Java集合 2.整理面试题"(modCount=2,expectedModCount=2)。这时候同桌突然拿过你的笔记,涂掉了"整理面试题",加了"看电影"(modCount变成3)。你翻到下一页时,发现笔记内容不对(检查modCount != expectedModCount),立刻停下说"你别改我的笔记!"(抛ConcurrentModificationException)------这种机制能及时发现修改,但迭代会被迫终止。②安全失败:你先把笔记复印了一份(迭代CopyOnWriteArrayList前拷贝快照),拿着复印件看,内容是"1.学习Java集合 2.整理面试题"。这时候同桌改了你的原版笔记(modCount变化),但你的复印件不受影响,依然能继续看,但你看到的始终是修改前的内容(快照),不知道同桌加了"看电影"------这种机制能保证迭代不被打断,但无法获取实时数据。
需要注意一个误区:快速失败不是"线程安全"的保证,它只是"检测并发修改的工具"。比如你和同桌同时修改笔记(多线程修改ArrayList),快速失败会抛异常,但不能阻止你们同时修改,可能导致笔记内容混乱;而安全失败的集合(如CopyOnWriteArrayList)通过"写操作拷贝新集合"实现线程安全,读和写操作不会冲突。
- 如何保证 ArrayList 的线程安全?有哪些方案?各方案的优缺点和适用场景是什么?
• 核心回答:ArrayList本身是线程不安全的------多线程同时修改(增、删、改)时,可能出现元素丢失、数组越界、迭代抛出ConcurrentModificationException等问题。保证其线程安全主要有4种核心方案:①使用Vector替代ArrayList;②使用Collections.synchronizedList()方法包装ArrayList;③使用CopyOnWriteArrayList替代ArrayList;④手动通过同步锁(如synchronized代码块)控制并发访问。
• 思考+例子:用"大家共用一个笔记本记东西"(多线程操作ArrayList)的场景,理解4种方案的差异:
-
使用Vector:Vector是早期的线程安全List,它的所有方法(如add()、remove()、get())都加了synchronized锁,相当于给笔记本装了一把"内置锁"------谁要使用笔记本(不管是写还是读),都必须先拿到锁,用完再把锁还回去,其他人只能排队等。优点是实现简单,直接替换类名即可;缺点是锁的粒度太粗,读操作和写操作会互相阻塞------比如A只是想看看笔记本(读操作),也要等B写完(写操作)才能看,10个人同时看笔记就要排队,效率极低。现在Vector已基本被淘汰,仅在维护老系统时可能遇到,适合"并发量极低、不追求性能"的场景。
-
使用Collections.synchronizedList():这个方法会返回一个包装后的同步List,相当于给普通笔记本加了一把"外置锁"------所有对List的操作(读、写)都会先通过这把锁,锁的逻辑和Vector类似,也是"一刀切"的加锁。优点是灵活,能给任何List(包括ArrayList、LinkedList)添加同步功能;缺点和Vector一样,读操作会被写操作阻塞,比如A在写笔记时,B、C、D想读都要等。适合"并发量小、读写频率相近"的场景,比如小团队的共享待办清单,每天只有几个人修改,几十个人查看。
-
使用CopyOnWriteArrayList:这是Java并发包提供的线程安全List,核心原理是"写时复制"------大家平时看笔记(读操作)直接拿原版看,不用等;谁要改笔记(写操作),先把原版笔记复印一份(拷贝新数组),在复印件上修改,改完后把原版换成复印件。优点是读操作无锁,效率极高,100个人同时看笔记也不用排队;缺点是写操作要拷贝数组,会浪费内存(拷贝过程中存在两个数组),且读操作可能看到旧数据(修改后的复印件未替换原版前,读的还是旧的)。适合"读多写少"的场景,比如系统的配置列表( thousands of users check the configuration every day, but only a few administrators modify it once a month )、用户的历史订单列表(用户频繁查订单,很少修改订单)。
-
手动加synchronized锁:自己通过synchronized代码块控制锁的粒度,比如规定"只有写操作加锁,读操作不加锁",或者"特定时间段内允许修改"。比如你可以写一段逻辑:"当调用add()或remove()(写操作)时,用synchronized锁住List;调用get()(读操作)时不锁"。优点是锁的粒度可自定义,能根据实际场景优化性能------比如读多写少的场景,读操作不阻塞,写操作加锁排队;缺点是需要自己写同步逻辑,容易出错(比如忘了解锁、锁的对象不对),适合"对并发逻辑有特殊要求"的场景,比如需要将List修改与数据库操作同步时,可在同步块里同时处理List和数据库。
-
CopyOnWriteArrayList 的核心原理是什么?它和 ArrayList 相比,线程安全是如何保证的?为什么它适合读多写少的场景?
• 核心回答:CopyOnWriteArrayList的核心原理是"写时复制(Copy-On-Write)",即"读操作直接访问原集合,写操作在拷贝的新集合上执行"。具体来说,读操作(如get())直接访问底层数组array,无需加锁;写操作(如add()、remove())会先获取锁(避免多个写操作同时拷贝数组),然后拷贝一份当前的array数组(新数组长度=原数组长度+1,用于添加元素),在新数组上执行写操作,操作完成后将array引用指向新数组,最后释放锁。
它的线程安全通过"读写分离"保证:读操作访问原数组,写操作修改新数组,两者不会同时操作同一个数组,因此不会出现并发修改问题;而ArrayList线程不安全,是因为多线程会同时操作同一个数组,可能出现"写操作覆盖"(A和B同时加元素,A的元素被B覆盖)、"数组越界"(A在扩容时,B同时添加元素)等问题。
CopyOnWriteArrayList适合读多写少的场景,是因为读操作无锁,效率远高于加锁的List(如Vector、synchronizedList),而写操作的拷贝开销在"写少"的情况下可忽略------如果写操作频繁,每次写都要拷贝数组,会导致内存占用过高、写操作延迟增加。
• 思考+例子:用"公司的公告栏"场景理解CopyOnWriteArrayList的原理:公告栏上贴的是公司制度(CopyOnWriteArrayList的底层数组array),员工平时看制度(读操作)直接围在公告栏前看,100个人同时看也不用排队(读无锁,效率高);如果行政要修改制度(写操作),比如把"9:00上班"改成"9:30上班",不会直接在公告栏上涂画(不修改原数组),而是先把原制度复印一份(拷贝新数组),在复印件上修改"上班时间",改完后把公告栏上的旧制度换成新的(array引用指向新数组)。在行政修改的过程中,员工看的还是旧制度(读原数组),完全不受影响,等换完新制度后,员工再看就是修改后的内容------这就实现了读写分离,保证线程安全。
为什么适合读多写少?如果公司每天改10次制度(写多),行政每天要复印10次公告,既浪费纸(内存,拷贝过程中存在两个数组),又费时间(拷贝大数组耗时),员工还可能频繁看到旧制度(数据不一致);但如果公司一个月才改1次制度(写少),复印1次的开销几乎可以忽略,而员工每天几千人看制度(读多),不用排队,效率极高。
还要注意两个细节:一是CopyOnWriteArrayList的迭代器是"不可修改的",迭代过程中调用remove()会抛UnsupportedOperationException------就像你拿着公告栏的复印件看,不能在复印件上改,要改只能找行政改原版;二是写操作加了锁,避免多个行政同时修改------比如A行政和B行政同时想改制度,A先拿到锁,拷贝数组修改,B只能等A改完释放锁,再拷贝修改,避免出现"两个复印件互相覆盖"的问题。
二、Set类相关面试题
- Java中Set集合的核心定义是什么?它和Collection、List的关系是什么?Set集合为什么能保证元素不重复?
• 核心回答:Set是java.util.Collection接口的子接口,存储的元素无序且不可重复------"无序"指元素的存入顺序与取出顺序不一定一致,且不支持索引访问;"不可重复"指集合中不会存在两个"相等"的元素(通过equals()方法判断)。与List是Collection下的并列子接口,List的核心是"有序且可重复",支持索引访问。
Set保证元素不重复的核心逻辑是"先比哈希值,再比内容":添加元素时,先调用元素的hashCode()方法获取哈希值,根据哈希值找到元素在底层数组中的"桶位置";如果桶位置为空,直接添加元素;如果桶位置不为空,遍历桶中的元素,用equals()方法逐一比较------如果有元素的equals()返回true,说明元素重复,不添加;如果所有元素的equals()都返回false(哈希冲突),则将元素添加到桶中。
• 思考+例子:生活中最典型的Set场景是"小区的门禁卡号"。每个业主有一个唯一的门禁卡,卡号(元素)不能重复------如果A的卡号是12345,B的卡号也不能是12345(不可重复);业主刷门禁时,不管是A先刷还是B先刷,只要卡号对就能开门,不用关心卡号的排列顺序(无序),也不能说"找第3个卡号"(不支持索引)。
Set保证不重复的逻辑,就像小区物业登记门禁卡:登记新卡号时,先看卡号的"开头数字"(哈希值)------比如12345的开头是1,分配到1号登记册(桶位置);如果1号登记册是空的,直接登记;如果1号登记册里已有卡号12346(开头也是1,哈希冲突),再对比完整卡号(equals())------12345和12346不一样,就登记进去;如果1号登记册里已有12345,就说"这个卡号已经登记过了"(不添加)。
和List的区别很明显:比如"学生的学号"适合存在Set里,每个学号唯一,不用管顺序;而"学生的成绩单"适合存在List里,按分数从高到低排(有序),可能有两个学生同分(可重复),还能通过索引找"第5名的成绩"。
- HashSet 的底层实现原理是什么?它和 HashMap 有什么关系?HashSet 是如何利用 HashMap 保证元素不重复的?
• 核心回答:HashSet的底层完全依赖HashMap实现,它本身没有复杂的逻辑,大部分方法都是直接调用HashMap的对应方法。两者的关系可以理解为"HashSet是HashMap的'外壳'"------HashSet存储的元素会作为HashMap的key,而HashMap的value是一个固定的静态常量PRESENT(类型为Object),所有HashSet对象共用这一个PRESENT,避免创建大量重复的空对象,节省内存。
HashSet保证元素不重复,本质是利用HashMap的key不可重复特性:当调用HashSet的add(E e)方法时,会直接调用HashMap的put(K key, V value)方法,将元素e作为key,PRESENT作为value;HashMap的put()方法会判断key是否已存在------如果key已存在,返回旧的value(即PRESENT);如果key不存在,返回null。HashSet根据put()的返回值判断是否添加成功:如果返回null,说明元素是新的,添加成功;如果返回PRESENT,说明元素已存在,不添加。
• 思考+例子:用"学校的学生证登记系统"理解两者的关系:学校的登记系统(HashMap)需要记录"学生证号"(key)和"登记状态"(value),而"登记状态"只有一种------"已注册"(对应HashSet的PRESENT),所有学生证共用这个状态,不用每个学生证都新建一个"已注册"标记。
当新生张三来注册(调用HashSet的add()),他的学生证号是2024001(元素e),系统会调用HashMap的put(2024001, "已注册"):此时HashMap中没有2024001这个key,返回null,HashSet判断返回值为null,就把张三的学生证号添加进去,注册成功。
如果张三不小心重复注册(再次调用add(2024001)),系统再次调用put(2024001, "已注册"):此时HashMap中已有2024001这个key,返回旧的value"已注册",HashSet判断返回值不为null,就知道学生证号已存在,不添加,避免重复登记。
从源码设计来看,HashSet的构造方法就是在创建HashMap:比如public HashSet() { map = new HashMap<>(); };size()方法是return map.size();(返回HashMap的key数量);contains(Object o)方法是return map.containsKey(o);(判断HashMap是否包含该key)------几乎所有功能都在"复用"HashMap,既减少了代码冗余,又保证了逻辑一致性。
- HashSet、LinkedHashSet、TreeSet 的核心区别是什么?它们的底层实现、有序性、性能有什么差异?适用场景分别是什么?
• 核心回答:三者都是Set的实现类,核心区别体现在"底层实现、有序性、性能"三个维度:
◦ ①HashSet:底层基于HashMap实现,无序(存入顺序与取出顺序不一致),查找、增删元素的时间复杂度为O(1)(基于哈希表的快速访问),不支持排序;
◦ ②LinkedHashSet:底层基于LinkedHashMap实现(LinkedHashMap是HashMap的子类,在HashMap基础上维护了一个双向链表,记录元素的插入顺序),有序(按元素插入顺序排列),查找、增删性能略低于HashSet(维护双向链表会增加少量开销),不支持自定义排序;
◦ ③TreeSet:底层基于TreeMap实现(TreeMap基于红黑树,一种自平衡的二叉搜索树),有序(按元素的自然排序或自定义排序排列),查找、增删元素的时间复杂度为O(logn)(红黑树的平衡操作),支持自定义排序。
适用场景:HashSet适合"仅需去重,不关心顺序"的场景;LinkedHashSet适合"去重+保留插入顺序"的场景;TreeSet适合"去重+按规则排序"的场景。
• 思考+例子:用"超市购物结账"的场景,理解三者的差异(假设你买了苹果、牛奶、面包三种商品,需去重,避免重复扫码):
-
HashSet:就像超市的"商品堆"------你把苹果、牛奶、面包随便堆在收银台上,收银员扫码时,可能按"面包→苹果→牛奶"的顺序扫(无序),不管你放的顺序,只要不重复扫就行。优点是扫码快(性能O(1)),缺点是不知道你先拿的哪个商品。适合场景:统计"今天超市卖了哪些商品",只需要知道有什么,不用管卖的顺序。
-
LinkedHashSet:就像超市的"购物篮"------你按"苹果→牛奶→面包"的顺序把商品放进购物篮,收银员扫码时也按这个顺序扫(保留插入顺序),不会乱。优点是能记住购买顺序,方便后续核对"买了什么、先买的什么";缺点是比直接堆商品慢一点(要维护购物篮里商品的顺序,就像LinkedHashSet维护双向链表)。适合场景:统计"顾客购买商品的顺序",比如分析"顾客通常先买牛奶,再买面包",既要去重(同一个顾客买两次牛奶只记一次),又要保留顺序。
-
TreeSet:就像超市的"商品货架"------货架上的商品按"价格从低到高"排列(自然排序),苹果5元、牛奶3元、面包4元,所以货架上的顺序是"牛奶→面包→苹果"。收银员从货架上拿商品扫码,就按这个排序后的顺序扫(有序)。优点是能按规则排序,方便看"低价商品卖了哪些";缺点是拿商品比购物篮慢(红黑树排序有开销,性能O(logn))。适合场景:统计"今天卖的商品按价格排序",比如按价格从低到高列出卖了哪些商品,方便做促销活动(比如低价商品打折)。
如果需要自定义排序,比如按"商品名称长度从短到长"排(牛奶2个字、苹果2个字、面包2个字,若有"矿泉水"3个字则排最后),TreeSet可以通过传入Comparator实现------就像给货架定一个新规则,按名称长度排,而不是默认的价格排;而HashSet和LinkedHashSet做不到自定义排序,前者无序,后者只按插入顺序排。
- HashSet 中的元素可以为 null 吗?为什么?如果HashSet中存了null元素,多次添加null会怎样?
• 核心回答:HashSet中的元素可以为null,因为它的底层HashMap允许key为null,且HashMap对nullkey有特殊处理:null的hashCode()值被默认定义为0,因此会将nullkey存入哈希表的第0个桶(数组索引0),无需计算复杂的哈希值,避免抛出NullPointerException。
HashSet多次添加null元素,只会保留一个null------第一次添加null时,HashMap的put(null, PRESENT)方法会判断第0个桶为空,添加成功,返回null,HashSet认为是新元素,添加成功;第二次及以后添加null时,HashMap的put(null, PRESENT)方法会发现第0个桶中已有nullkey,返回旧的value(PRESENT),HashSet判断返回值不为null,认为元素已存在,不添加,因此最终HashSet中只有一个null。
• 思考+例子:用"学校的特殊学生登记"场景理解:学校有一个"特殊学生名单"(HashSet),用于登记"还没分配学号的转学生"(对应null元素)------这类学生没有学号,只能用"无学号"(null)标记。
第一次登记转学生李四(add(null)):名单的底层HashMap会把"无学号"作为key,存入第0个桶(null的hashCode为0),此时桶为空,添加成功,返回null,名单中多了"无学号"的记录。
如果李四不小心被重复登记(再次add(null)):HashMap会检查第0个桶,发现已有"无学号"的key,返回PRESENT,名单判断返回值不为null,就知道"这个无学号的学生已经登记过了",不添加,避免重复。
需要特别注意:TreeSet中的元素不能为null,因为TreeSet底层基于红黑树,需要对元素进行排序,而null无法与其他元素比较(调用compareTo()方法时会抛出NullPointerException)------就像货架要按价格排序,"没有价格的商品"(null)无法排序,不能放在货架上;而HashSet不用排序,所以能存null。
- 为什么重写 equals() 方法时必须重写 hashCode() 方法?如果不重写,会对HashSet的去重功能产生什么影响?
• 核心回答:Java的哈希约定明确规定:①如果两个对象通过equals()方法比较返回true,那么它们的hashCode()方法必须返回相同的值;②如果两个对象的hashCode()方法返回不同的值,那么它们的equals()方法比较必须返回false。重写equals()不重写hashCode(),会违反这个约定,导致两个"逻辑相等"的对象(equals()返回true)拥有不同的哈希值。
对HashSet的影响是"无法实现去重":HashSet添加元素时,先根据hashCode()找桶位置------如果两个对象equals()返回true但hashCode()不同,会被分配到不同的桶位置;HashSet会认为它们是不同的元素,都添加到集合中,最终导致集合中存在重复元素,破坏Set的"不可重复"特性。
• 思考+例子:用"学生对象"的场景理解:假设你定义了一个Student类,用于存储学生的学号,希望"学号相同的学生就是同一个学生"(重写equals()方法,判断学号是否相同),但没重写hashCode()方法(使用Object类的默认hashCode(),默认hashCode()基于对象的内存地址生成,不同对象的hashCode()不同)。
你创建了两个学号相同的学生对象:Student s1 = new Student("2024001"); Student s2 = new Student("2024001");。此时s1.equals(s2)返回true(学号相同),但s1.hashCode()和s2.hashCode()不同(默认hashCode()基于内存地址,s1和s2是不同对象,内存地址不同)。
当你把s1和s2添加到HashSet时:①添加s1:HashSet调用HashMap的put(s1, PRESENT),s1的hashCode()对应桶A,桶A为空,添加成功;②添加s2:s2的hashCode()对应桶B(和桶A不同),桶B为空,HashMap认为s2是新key,添加成功。最终HashSet中同时存在s1和s2,但按equals()它们是相等的,这就破坏了Set的去重功能------就像学校登记学生,两个学号相同的学生(应该是同一个人),因为"登记册不同"(桶不同),被当成两个人登记,导致重复。
如果重写hashCode()方法,让学号相同的学生返回相同的哈希值(比如用学号的哈希值作为hashCode()):@Override public int hashCode() { return Objects.hash(id); }。此时s1.hashCode()和s2.hashCode()相同,添加s2时,HashMap会找到和s1相同的桶(桶A),然后用equals()判断,发现s1.equals(s2)为true,认为是重复key,不添加s2,HashSet中只有s1,保证了去重。
简单总结:hashCode()决定"元素该进哪个桶",equals()决定"桶里是不是同一个元素"。不重写hashCode(),相等的元素会进不同的桶,equals()没机会判断,自然无法去重。