面试-Java集合

图片

本文根据牛客、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底层数据结构为红黑二叉树。

 


 感谢你花时间阅读完本文,如果觉得写得不错,辛苦帮忙分享给其他的朋友哦!也可以关注我的公众号!如果有写得不正确的地方,也辛苦指正呀!我会持续更新面试相关的内容,希望可以帮助各位找工作的朋友。

 

不管脚步有多慢都不要紧

 只要你在走

总会看到进步


图片

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值