本文根据牛客、leetcode等平台上的部分面经,针对Java集合相关的考点进行了总结,篇幅4000字,可能需要花费15-18min。
1、Java集合框架包含哪些基础接口?
Collection接口是Java集合框架的顶层抽象,List、Set、Queue等接口都是它的子类。Java集合框架的结构图:
List:是一个有序集合,元素可以重复,可以通过索引来访问元素。它的典型实现类有ArrayList、LinkedList、Vector等;
Set: 是一个无序集合、元素不可以重复。它的典型实现类有HashSet、TreeSet、SortedSet等;
Map: 是一种将键-key映射到值-value的数据结构,其中键是不可以重复的。它的典型实现类有HashMap、TreeMap、LinkedHashMap等。
2、ArrayList和LinkedList有什么区别?
数据结构:ArrayList底层基于数组实现,内存地址连续、数组长度可变,支持动态扩容;LinkedList底层基于线性链表实现,内存地址非连续、是一个双向链表;
随机访问效率:ArrayList支持通过索引直接访问、而LinkedList是一个线性存储结构,需要通过遍历的方式依次从前往后查找,所以ArrayList随机访问效率更高;
增加和删除元素:非尾部增删元素LinkedList的效率高于ArrayList、因为ArrayList插入或删除元素会影响其它元素的下标,导致元素移动;
总结来说,对于查询操作频繁的场景,推荐使用ArrayList、增删操作频繁的场景,推荐使用LinkedList。时间复杂度对比:
随机访问 | 随机添加 | 尾部添加 | 随机删除 | |
ArrayList | O(1) | O(n) | O(1) | O(n) |
LinkedList | O(n) | O(n) | O(1) | O(1) |
3、ArrayList和Vector有什么区别?
二者的底层都是基于数组实现,特性基本相似。但是,ArrayList比Vector访问性能更快,因为Vector的所有方法被sychronized关键字修饰,是同步的。另外,Vector有一个重要的实现类Stack,是一种“先进后出”的栈结构。
4、什么是快速失败和安全失败?
快速失败:在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出 ConcurrentModificationException。java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改。
原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount == expectedmodCount 值,相等的话就返回遍历;否则抛出异常,终止遍历。
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
安全失败:采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 ConcurrentModificationException。
5、简单聊一下HashMap的实现原理?
HashMap的底层是基于散列表+链表/红黑二叉树来实现。我们可以通过put(key,vaule)存储元素,get(key)获取元素。执行put操作时,通过key.hashCode()计算相应key的hash值,根据hash值确定value在散列表中的位置(桶位),如果计算出的桶位已经存在元素,说明发生了 Hash冲突 ,HashMap通过 链地址法 来解决Hash冲突,当Hash冲突较少时,HashMap通过链表来存储value, 当链表长度大于8时,链表会树化成红黑二叉树。
HashMap是面试时的高频考点,主要包括扩容问题、Hash冲突问题、以及线程安全问题。
针对扩容,我们主要了解一下负载因子和容量大小问题。负载因子是和扩容机制有关的,意思是如果当前容器的容量,达到了我们设定的最大值,就要开始执行扩容操作。举个例子:
比如说容器容量是16,负载因子是0.75, 那么扩容阈值为16*0.75=12,也就是说,当容量达到了12的时候就会进行扩容操作。
那为什么HashMap的负载因子默认为0.75呢?HashMap的设计者主要权衡了时间和空间利用率的问题。我们看下两种极端情况,当负载因子是1.0的时候,也就意味着,只有当散列表(上图表示了5个)全部填充了,才会发生扩容。这会导致hash冲突发生的概率变大,底层的红黑树变得异常复杂,对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。因此,如果负载因子过大,那么HashMap的空间利用率提高了,但是时间效率降低了。当负载因子是0.5时,那么当数组中的元素达到了一半就开始扩容,填充的元素变少发生hash冲突概率也随之变小,那么底层的链表长度或者是红黑树的高度就会降低,查询效率就会提高。但是,这时候空间利用率就会大大的降低。权衡了时间和空间问题,HashMpa设计者将负载因子定为了0.75。
关于HashMap的容量问题,为什么HashMap的大小始终是2的n次幂? HashMap根据key计算采用(n-1)& hash (这里n表示容量) 这个公式来计算桶位。而当n为2的整数次幂时,(n - 1)& hash等价于hash%(n-1),即是hash算法中常用的取余法。对于计算机而言,&运算的执行速度要优于%运算,基于这一点HashMap的容量设置始终保持为2的n次幂。
针对hash冲突,我们主要关注 链地址法和链表树化成红黑二叉树阈值 这两个问题。上图已经很清晰的描述了链地址法,即当不同的key计算得到的hash值相同时,HashMap认为发生了hash冲突,此时它会在相应桶位维护一个链表结构,来保存相应的value值。JDK1.8之前,它只采用链表结构,这样就会存在一个问题,如果频繁发生hash冲突,链表的长度就会变得很长,可能导致查找的时间复杂度退化到O(n),针对这个问题,JDK1.8增加了红黑二叉树数据结构,即当链表的长度超过阈值8时,链表会树化成红黑二叉树,这样即使在最坏的情况下,查找的时间复杂度也只会退化到O(logn)。
那为什么链表树化红黑二叉树的阈值为8呢?HashMap中的注释很好的解释了这一点:如果hashCode的分布离散良好的话,那么红黑树是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,当长度为8的时候,概率概率仅为0.00000006,这么小的概率,HashMap的红黑树转换几乎不会发生。
HashMap是非线程安全的类,在高并发场景下,可能会出现值丢失问题和死环问题。这个流程比较复杂,感兴趣的朋友推荐阅读这篇文章:
https://blog.csdn.net/u010416101/article/details/88727204
提到HashMap的线程安全问题,一般会引申出这样的两个问题:1)你可以让HashMap变成线程安全吗?2)既然HashMap是线程不安全的,那在多线程环境怎么处理?
针对问题1),一种方式是通过继承的方式,重写HashMap的方法,用sychronized关键字或Lock来保证线程安全;另一种方式是使用Collections工具类,包装Map实例,使它变成线程安全的类,伪代码:
/**
* @Author Java发烧友
* @Date 2021/5/19 13:51
* @Description 方法1继承HashMap结构,使用synchronized关键字来保证线程安全性
*/
public class SafeHashMap<K,V> extends HashMap<K,V> {
@Override
public synchronized V put(K key, V value) {
return super.put(key, value);
}
}
//方法2使用工具类将Map结构包装为线程安全的类
Map map = Collections.synchronizedMap(new HashMap<>());
针对问题2)、JDK实际上提供了Hashtable和ConcurrentHashMap这两个线程安全的Hash结构来适用高并发场景。Hashtable属于历史遗留下来的一个hash数据结构,不推荐使用,它的实现原理与HashMap类似。那么Hashtable和HashMap二者之间主要有什么区别呢?二者的区别主要体现在以下几点:1)Hashtable的key-value不支持null,而HashMap支持;2)Hashtable线程安全,适用于高并发场景,而HashMap适用于单线程场景;3)Hashtable默认容量为11,采用2倍扩容,而HashMap采用1.5倍扩容,且大小始终保持为2的n次幂;4)JDK1.7之前,二者都是通过链表结构来解决hash冲突,JDK1.8后HashMap新引入了红黑二叉树结构。
线程安全的ConcurrentHashMap又是如何实现的呢?
在DK1.7中,ConcurrentHashMap采用的 分段锁 的技术来保证线程安全。如下图,ConcurrentHashMap由 Segment数组+HashEntry数据+链表 组成,其中,HashEntry数据+链表部分和JDK1.7的HashMap原理一样。区别于HashMap,ConcurrentHashMap新增了Segment数组这个结构,将散列表(这里是HashEntry数组)进行了拆分,每Segment[i]管理自己维护的散列表,而Segment继承自ReextranLock,这样在高并发的场景下,每一段Segment能保证对自己维护的HashEntry数组的所有操作是线程安全的,而各个Segment之间又相互独立,互不影响,通过这样分段锁的方式,保证了ConcurrentHashMap的线程安全性。ConcurrentHashMap进行put或get操作时,需要进行两次定位:1)计算在Segment中的位置;2)计算在HashEntry中的位置;
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表/红黑树的数据结构来实现,如下图,并发控制使用synchronized+CAS来操作,整个结构可以看作是一个优化过且线程安全的HashMap。sychronized实际上细化了锁的粒度,Segment锁控制了多个HashEntry,而sychronized细化到每一个Node[i]里,提升了并发性能。
6、LinkedHashMap了解吗?
我们知道HashMap存储的数据是无法按照插入顺序去访问的,而LinkedHashMap为我们提供了按顺访问的能力。它在HashMap的基础上进行了改造,为每个节点增加了前后指针,将所有put进去的元素构成一个双向链表,伪代码如下:
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>{
//增加前后指针
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
//双向链表头指针
transient LinkedHashMap.Entry<K,V> head;
//双向链表尾指针
transient LinkedHashMap.Entry<K,V> tail;
}
7、Set的实现原理?
常用的set集合主要有HashSet、TreeSet,LinkedHashSet。在Java中,Set都是基于Map来实现,HashSet是基于HashMap实现,TreeSet是基于TreeMap来实现,LinkedHashSet是基于LinkedHashMap实现,它们相当于value为空的Map。HashSet和TreeSet的区别在于后者存储的元素是有顺序的,只要使用者指定排序规则,元素就会按照规则存放。另外,HashSet的底层为散列表,而TreeSet底层数据结构为红黑二叉树。
感谢你花时间阅读完本文,如果觉得写得不错,辛苦帮忙分享给其他的朋友哦!也可以关注我的公众号!如果有写得不正确的地方,也辛苦指正呀!我会持续更新面试相关的内容,希望可以帮助各位找工作的朋友。
不管脚步有多慢都不要紧
只要你在走
总会看到进步