本文总结了Java集合容器的经典面试题,所有题目我都给出了自己思考,适合面试前复习扫盲使用。我不能保证里面包含了所有集合面试题,但只要认真深挖好每一道题,做到触类旁通,就能以不变应万变。
- 大纲:
- 概述型面试题
- List
- Map
- 小结
概述类面试题
1. 请说一下Java容器集合的分类,各自的继承结构
- Java中的容器集合分为两大阵营,一个是Collection,一个是Map
- Collection下分为Set,List,Queue
- Set的常用实现类有HashSet,TreeSet等
- List的常用实现类有ArrayList,LinkedList等
- Queue的常用实现类有LinkedList,ArrayBlockingQueue等
- Map下没有进一步分类,它的常用实现类有HashMap,ConcurrentHashMap等
能把上面的基本框架答出来基本就没问题了,对于各种类型我只列举了一些实际工作中常用的实现类。但其实在Set,List和Queue下还有更细的划分,如果想要在面试时表现一番,那得对着JDK好好背一背了>_<
2. 请谈一谈Java集合中的fail-fast和fail-safe机制
fail-fast是一种错误检测机制,Java在适合单线程使用的集合容器中很好地实现了fail-fast机制,举一个简单的例子:在多线程并发环境下,A线程在通过迭代器遍历一个ArrayList集合,B线程同时对该集合进行增删元素操作,这个时候线程A就会抛出并发修改异常,中断正常执行的逻辑。
而fail-safe机制更像是一种对fail-fast机制的补充,它被广泛地实现在各种并发容器集合中。回头看上面的例子,如果线程A遍历的不是一个ArrayList,而是一个CopyOnWriteArrayList,则符合fail-safe机制,线程B可以同时对该集合的元素进行增删操作,线程A不会抛出任何异常。
要理解这两种机制的表象,我们得了解这两种机制背后的实现原理:
我们同样用ArrayList解释fail-fast背后的原理:首先ArrayList自身会维护一个modCount变量,每当进行增删元素等操作时,modCount变量都会进行自增。当使用迭代器遍历ArrayList时,迭代器会新维护一个初始值等于modCount的expectedModCount变量,每次获取下一个元素的时候都会去检查expectModCount和modCount是否相等。在上面举的例子中,由于B线程增删元素会导致modCount自增,当A线程遍历元素时就会发现两个变量不等,从而抛出异常。
CopyOnWriteArrayList所实现的fail-safe在上述情况下没有抛出异常,它的原理是:当使用迭代器遍历集合时,会基于原数组拷贝出一个新的数组(ArrayList的底层是数组),后续的遍历行为在新数组上进行。所以线程B同时进行增删操作不会影响到线程A的遍历行为。
这种题目我觉得要先答出核心原理,如果你对多线程和单线程下容器的使用有自己的见解,可以考虑多聊点。
3. 如何一边遍历一边删除Collection中的元素?
使用集合迭代器自身的remove方法进行删除
Iterator it = list.iterator();
while(it.hasNext()){
*// do something*
it.remove();
}
可能笔试考的更多,算是Java的基本常识吧
List类面试题
4. 谈谈ArrayList和LinkedList的区别
本质的区别来源于两者的底层实现:ArrayList的底层是数组,LinkedList的底层是双向链表。
数组拥有O(1)的查询效率,可以通过下标直接定位元素;链表在查询元素的时候只能通过遍历的方式查询,效率比数组低。
数组增删元素的效率比较低,通常要伴随拷贝数组的操作;链表增删元素的效率很高,只需要调整对应位置的指针即可。
以上是数组和链表的通俗对比,在日常的使用中,两者都能很好地在自己的适用场景发挥作用。
比如说我们常常用ArrayList代替数组,因为封装了许多易用的api,而且它内部实现了自动扩容机制,由于它内部维护了一个当前容量的指针size,直接往ArrayList中添加元素的时间复杂度是O(1)的,使用非常方便。
而LinkedList常常被用作Queue队列的实现类,由于底层是双向链表,能够轻松地提供先入先出的操作。
我觉得可以分两部分答,一个是数组与链表底层实现的不同,另一个是答ArrayList和LinkedList的实现细节。
5. 谈谈ArrayList和Vector的区别
两者的底层实现相似,关键的不同在于Vector的对外提供操作的方法都是用synchronized修饰的,也就是说Vector在并发环境下是线程安全的,而ArrayList在并发环境下可能会出现线程安全问题。
由于Vector的方法都是同步方法,执行起来会在同步上消耗一定的性能,所以在单线程环境下,Vector的性能是不如ArrayList的
除了线程安全这点本质区别外,还有一个实现上的小细节区别:ArrayList每次扩容的大小为原来的1.5倍;Vector可以指定扩容的大小,默认是原来大小的两倍。
感觉可以顺带谈谈多线程环境下ArrayList的替代品,比如CopyOnWriteArrayList,但是要谈谈优缺点。
6. 为什么ArrayList的elementData数组要加上transient修饰
由于ArrayList有自动扩容机制,所以ArrayList的elementData数组大小往往比现有的元素数量大,如果不加transient直接序列化的话会把数组中空余的位置也序列化了,浪费不少的空间。
ArrayList中重写了序列化和反序列化对应的writeObject和readObject方法,在遍历数组元素时,以size作为结束标志,只序列化ArrayList中已经存在的元素。
细节题
Map类面试题
HashMap死亡连环Call即将来临,看爽了记得点个赞啊
7. 请介绍一下HashMap的实现原理
- 我们一般用HashMap存储key-value类型的数据,它的底层是一个数组,当我们调用put方法的时候,首先会对key进行计算得出一个hash值,然后根据hash值计算出存放在数组上的位置
- 这个时候我们会遇到两种情况:一是数组上该位置为空,可以直接放入数据;还有一种情况是该位置已经存放值了,这就发生了哈希冲突。
- 在现在使用较为普遍的JDK1.8中是这样处理哈希冲突的:先用链表把冲突的元素串起来,如果链表的长度达到了8,并且哈希表的长度大于64,则把链表转为红黑树。(在JDK1.7中没有转化为红黑树这一步,只用链表解决冲突)
先热身
8. HashMap是怎样确定key存放在数组的哪个位置的?
JDK1.8
首先计算key的hash值,计算过程是:先得到key的hashCode(int类型,4字节),然后把hashCode的高16位与低16位进行异或,得到key的hash值。
接下来用key的hash值与数组长度减一的值进行按位与操作,得到key在数组中对应的下标。
追问:为什么计算key的hash时要把hashCode的高16位与低16位进行异或?(变式:为什么不直接用key的hashCode)
计算key在数组中的下标时,是通过hash值与数组长度减一的值进行按位与操作的。由于数组的长度通常不会超过2^16,所以hash值的高16位通常参与不了这个按位与操作。
为了让hashCode的高16位能够参与到按位与操作中,所以把hashCode的高16位与低16位进行异或操作,使得高16位的影响能够均匀稀释到低16位中,使得计算key位置的操作能够充分散列均匀。
9. 为什么要把链表转为红黑树,阈值为什么是8?
在极端情况下,比如说key的hashCode()返回的值不合理,或者多个密钥共享一个hashCode,很有可能会在同一个数组位置产生严重的哈希冲突。
这种情况下,如果我们仍然使用使用链表把多个冲突的元素串起来,这些元素的查询效率就会从O(1)下降为O(N)。为了能够在这种极端情况下仍保证较为高效的查询效率,HashMap选择把链表转换为红黑树,红黑树是一种常用的平衡二叉搜索树,添加,删除,查找元素等操作的时间复杂度均为O(logN)
至于阈值为什么是8,这是HashMap的作者根据概率论的知识得到的。当key的哈希码分布均匀时,数组同一个位置上的元素数量是成泊松分布的,同一个位置上出现8个元素的概率已经接近千分之一了,这侧面说明如果链表的长度达到了8,key的hashCode()肯定是出了大问题,这个时候需要红黑树来保证性能,所以选择8作为阈值。
追问:为什么红黑树转换回链表的阈值不是7而是6呢?
如果是7的话,那么链表和红黑树之间的切换范围值就太小了。如果我的链表长度不停地在7和8之间切换,那岂不是得来回变换形态?所以选择6是一种折中的考虑。
10. 请说一下HashMap的扩容原理
- 首先得到新的容量值和新的扩容阈值,默认都是原来大小的两倍。
- 然后根据新容量创建新的数组
- 最后把元素从旧数组中迁移到新数组中
在JDK1.7中,迁移数据的时候所有元素都重新计算了hash,并根据新的hash重新计算数组中的位置。
在JDK1.8中,这个过程进行了优化:如果当前节点是单独节点(后面没有接着链表),则根据该节点的hash值与新容量减一的值按位与得到新的地址。
如果当前节点后面带有链表,则根据每个节点的hash值与旧数组容量进行按位与的结果进行划分。如果得到的值为0,这些元素会被分配回原来的位置;如果得到的结果不为0,则分配到新位置,新位置的下标为当前位置下标加上旧数组容量。
还有一种情况是当前节点是树节点,那么会调用一个专门的拆分方法进行拆分。
追问:为什么HashMap不支持动态缩容?
开放性题目?以下是个人见解:
如果要支持动态缩容,可能就要把缩容安排在remove方法里,这样可能会导致remove方法的时间复杂度从O(1)上升为O(N)。
还有一点可能和我们编写Java代码的习惯有关:由于Java有自动垃圾回收机制,让我们得以可劲地new对象,Java也默认了我们这种吃饭不收拾盘子的行为。既然对象会被回收,HashMap动态缩容在这样的大环境下似乎就显得没那么重要了,这可以说是一种空间换时间的策略吧。
11. 为什么HashMap中适合用Integer,String这样的基础类型作为key?
因为这些基础类内部已经重写了hashCode和equals方法,遵守了HashMap内部的规范。
追问:如果要用我们自己实现的类作为key,要注意什么?
一定要重写hashCode()和equals()方法,而且要遵从以下规则:
equals()是我们判断两个对象是否相同的依据,如果我们重写了equals方法,用自己的逻辑去判断两个对象是否相同,那么一定要保证:
两个equals()返回true的对象,一定要返回相同的hashCode。
这样,在HashMap的put方法中才能正确判断key是否相同。
不是经常有一个问题嘛,两个对象hashCode相同,equals一定返回true吗?答案肯定是否的,这和你的设计密切相关:如果在你的编程思路中这两个对象是不同的,那么就算恰巧两个对象的hashCode相同,equals也应该返回false。
12. 为什么HashMap数组的长度是2的幂次方?
因为这样能够提高根据key计算数组位置的效率。
HashMap根据key计算数组位置的算法是:用key的hash值与数组长度减1的值进行按位与操作。
在我们正常人的思维中,获取数组的某个位置最直接的方法是对数组的长度取余数。但是如果被除数是2的幂次方,那么这个对数组长度取余的方法就等价于对数组长度减一的值进行按位与操作。
在计算机中,位运算的效率远高于取模运算,所以为了提高效率,把数组的长度设为2的幂次方。
13. HashMap与HashTable有什么区别?
在JDK1.7之前,两者的实现极为相似,最大的区别在于HashTable的方法都用synchronized关键字修饰起来了,表明它是线程安全的。
但是由于直接在方法上加synchronized关键字的同步效率较低,在并发情况下,官方推荐我们使用ConcurrentHashMap。
所以我们看到在JDK1.8中,官方甚至没有对HashTable进行链表转树这样的优化,HashTable已经不被推荐使用了。
14. 请说一下ConcurrentHashMap的实现原理
在JDK1.7中ConcurrentHashMap采用了一种分段锁的机制,它的底层实现是一个segment数组,每个segment的底层结构和HashMap相似,也是数组加链表。
当对segment里面的元素进行操作之前,需要获得该segment独有的一把ReentrantLock。ConcurrentHashMap如果不进行手动设置的话,默认有16个segment,可以支持16个线程对16个不同的segment进行并发写操作。
在JDK1.8之后摒弃了segment这种臃肿的设计,新的实现和HashMap非常相似,底层用的也是数组加链表加红黑树。
在新实现中,在put方法里使用了CAS + synchronized进行同步。如果插入元素的位置为空,则使用CAS进行插入。如果插入的位置不为空,则对当前位置的对象进行加锁,也就链表或红黑树的头节点,加锁后再进行后续的插入操作。
这样设计的好处是:
- CAS是十分轻量的加锁操作,如果能够直接插入,用CAS能够大幅度节省加锁的开销。
- 如果发生冲突,只用锁住当前位置的头结点,理论上数组的长度有多大,并发操作的线程数就能有多少,比原来只能有16个线程效率更高。
这道题如果想深挖扩展可以开始往Java多线程并发方面扯:synchronized,CAS。Java多线程方面我也会出一份总结,有兴趣的不妨先点赞关注一波
小结
我感觉面试的时候对集合的考察会偏向实现原理多一些,所以一定要看一遍源码,相比于框架的源码,集合的源码简直太友好了。在笔试的时候可能还会考一些集合的使用,比如遍历,排序,比较等等,这些算是Java基础了,用得多也就熟了。
最后如果你觉得我的回答有问题,欢迎指正!
愿各位有所收获,共同进步,共勉!