面试八股文之——Java集合
众所周知,程序员的技术能力考核大部分来源于面试和笔试,少数人可以靠着开源项目或者是证书、个人作品(书籍)等提升求职竞争力而直接获取offer。绝大多数程序员依旧是靠面试来获取offer。因此对待面试题,很多时候,应聘者需要做很多的准备,本文将对Java集合高频面试题目进行分享。
一、ArrayList篇
1.ArrayList是如何实现自动扩容的?是线程安全的吗?如何实现线程安全?
ArrayList默认size是10,当然,也可以在初始化指定长度大小,当数组空间不足时,则会创建一个新数组,长度为原数组的1.5倍,采用Arrays.CopyOf方法将原数组数据复制到新数组中。扩容后,再将待插入的数据放入新数组中。
ArrayList不是线程安全的,在多线程情况下,会发生线程不安全的问题,比如在扩容过程中,A/B两个线程同时插入1条数据,由于扩容过程没有采取同步,容易导致扩容过程中,某个线程插入记录丢失,发生异常。
实现线程安全有很多种方式:
1.最简单的措施,采用线程安全的List——CopyOnWriteArrayList【推荐,简单高效】
2.基于ArrayList改造,在进行扩容中,加入锁设计,比如显示地使用Lock锁,或者使用synchronized关键字。
3.显示地在ArrayList的插入、删除方法中引入锁。
2.ArrayList、Vector、LinkedList的存储性能及特性
1.
ArrayList/Vector
底层采用数组实现,具备插入慢、读取快
的特点,插入慢的原因是,每次插入后,插入位置后的所有数组元素都需要往后移动一位,扩容过程也会降低插入效率,读取快的原因是,只需要知道数组下标即可快速计算出对象的存储地址。
2.Vector是线程安全
,其他不是
,但是由于采用了synchronized关键字来保证同步,性能较差
。是早起版本的容器,官方已不在推荐使用。
3.LinkedList
基于双向链表实现,具备插入快、读取慢
的特点。读取慢是由于每次访问数据,需要通过遍历来获取指定位置的元素,插入快是因为插入过程中,只需要记录元素的前后项,并采用指针指向即可,受影响的元素只有2个。
二、HashMap篇
1.单线程下HashMap工作原理?
1.基本参数:size 元素个数,threshold 扩容阈值,loadFactor 负载因子,modCount 记录hashMap内部结构修改次数,DEFAULT_INITIAL_CAPACITY 初始容量大小,扩容阈值等于负载因此*容量大小
2.数据结构:数组桶+链表(JDK1.8优化后,当链表长度大于等于8则转为红黑树)
3.特征:存储KV数据,数据访问速度快,允许存null值和null键,线程不安全
4.put方法执行步骤:
(1) 计算元素Key的hash(采用Key的hashCode和高16位hashCode经过异或计算得到),将hash与容量长度减一进行按位与操作,等价于与容量长度size进行取模,得到数组的下标。按位与效率更高。
(2) 如当前下标无数据,直接插入即可,如发生hash碰撞,则采用头插法插入,JDK8之后改用尾插法,构建链表结构
(3)当链表长度大于等于8且map容量大于等于64,改为红黑树进行存储【旨在解决链表过长导致查询时间增加的问题】
,当红黑树节点数小于等于6,改为链表存储。
(4) 当插入元素达到扩容阈值threshold,则会发生扩容,采用2倍扩容。
2.HashMap如何解决Hash冲突的?
常见解决hash冲突的方法有四种:
1.开放定址法
,也称为线性探测法
,从发生冲突的位置开始,按照一定的次序,从Hash表中找到一个空闲的位置,然后将发生冲突的元素插入到这个空闲位置。ThreadLocal
采用这种方法来解决Hash冲突。
2.链式寻址法
,将发生冲突的Key,用单向链表来存储,HashMap采用这种方式解决Hash冲突。
3.再Hash法
,当通过某个Hash函数计算的Key存在冲突时,再另外使用一个Hash方法对Key进行计算,一直运算至不再发生冲突,计算时间增加,性能影响较大。
4.建立公共溢出区
,将Hash表分为基本表和溢出表,凡是存在冲突的元素,一律放入溢出表中。
3.谈谈对HashMap扩容机制的理解?
1.当hashMap中的元素数目size到达扩容阈值,也就threshold ,则会动态进行
2倍扩容
,其中threshold 是负载因子loadFactor 和容量Capacity的乘积,默认负载因子是0.75,容量是16
。由于动态扩容的存在,实际开发中,最好初始化集合的大小,避免频繁扩容带来性能上的消耗
;
负载因子设计在0~1之间,这个越大,空间利用率越高,同时hash冲突的概率也越大,反之,这个越小,空间利用率越低,hash冲突概率越低。0.75值的选取,链表长度达到8的概率几乎为0,综合考虑了hashMap空间利用率和hash冲突的影响,做到了空间和时间成本的平衡。
4.HashMap为什么会发生死循环?
1.这个
只会发生JDK1.7中
,JDK1.8,官方彻底解决了这个问题。
2.JDK1.7采用头插法插入元素,扩容后的新Map采用尾插法插入元素,当多个线程并发发生扩容
时候,会导致新Map上的链表节点倒序排列,此时其他线程节点引用关系仍然是顺序排列,此时链表起始节点和下一个节点就会发生互相引用,因此发生死循环。在JDK1.8中,HashMap插入节点也改为尾插入
,彻底解决这个问题。
3.避免HashMap发生死循环的方法如下:
(1) 使用ConcurrentHashMap替代HashMap ,推荐,简单高效
(2) 使用线程安全的HashTable替代,性能低,不推荐
(3) 在执行插入前手动加入锁机制,比如synchronized/lock锁
5.HashMap和TreeMap的区别?
1.数据结构:HashMap是基于数据+链表实现,TreeMap是基于红黑树实现
2.效率:HashMap效率更高,O(1),TreeMap效率O(logN)
3.线程安全:都是线程不安全的,如要保持线程安全和保证顺序,可以使用Collections.synchronizedMap()方法将其转为线程安全的Map。
4.HashMap无序,TreeMap基于Key排序或者自定义排序。
6.为什么ConcurrentHashMap的Key不为null?
直接原因:JDK ConcurrentHashMap源码设计中设定Key和Value不能为null,否则抛出空指针异常。
本质原因
:ConcurrentHashMap大部分应用于多线程场景下,Key或者是Value为null,无法判断是Key本身不存在还是Key的值就是null,
容易发生歧义,增加心智负担。
7.ConcurrentHashMap的底层原理及如何保证线程安全的?
1.ConcurrentHashMap
本质就是线程安全版的HashMap
,底层基于数组+链表(jdk1.8采用红黑树优化查询效率),ConcurrentHashMap在JDK1.8之后对锁进行了优化,抛弃了JDK1.7分段锁Segment的设计
,进一步缩小锁粒度,只对桶的头结点进行加锁
,提升并发场景下对象操作性能,JDK1.7采用ReentrantLock锁来保证线程安全,JDK1.8采用CAS+volatile+synchronized。相比之下,性能得到进一步提升。
2.除此之外,ConcurrentHashMap引入了多线程并发扩容机制
,即每个线程负责一个分片的数据迁移,从而提升扩容中数据迁移的效率。
3.对获取总的元素个数的size()方法进行了优化,如果竞争不激烈,直接采用CAS进行原子递增,否则将拆分为一个数组,如果需要增加元素个数则直接从数组中随机选择一个,再通过CAS进行原子递增,核心思想引入一个数组来降低CAS竞争,提升并发更新的速度
。