问题
A线程不安全的:
| 序号 | 面试题 |
|---|---|
| 0 | 集合接口List,Set,Map的区别、特点 |
| 1 | ArrayList\LinkedList的优点、缺点,使用场景 |
| 2 | Iterator迭代器解决什么问题,如何使用,原理是什么 |
| 3 | HashSet和HashMap的联系 |
| 4 | HashSet如何保证元素不重复 |
| 5 | 各种集合类的初始化容量、扩容时机 |
B特别点出HashMap
| 序号 | 面试题 |
|---|---|
| 1 | HashMap的扩容怎么发生的,有哪些注意的 |
| 2 | HashMap的实现原理,JDK1.7和1.8 |
| 3 | HashMap的容量为什么总是2的幂?手动指定容量 会不会破坏这个设计? |
| 4 | put()方法的流程 |
| 5 | 负载因子是什么?为什么是0.75 |
| 6 | HashMap怎么解决Hash冲突的 |
| 7 | HashMap的死循环 |
| 8 | 为什么要在1.8引入红黑树 |
| 9 | HashMap中的元素为什么要同时覆写equals()和hashCode() |
C线程安全的
| 序号 | 面试题 |
|---|---|
| 1 | HashTable、Vector为什么弃用了 |
| 2 | ConcurrentHashMap在JDK1.7和1.8的实现区别 |
| 3 | 1.8中为什么放弃了分段锁Segment |
| 4 | ConcurrentHashMap在扩容的时候,怎么保证线程安全 |
回答
A0、集合接口List,Set,Map的区别、特点
| 集合 | 特点 | 使用场景 |
|---|---|---|
| List | 有序的、可重复的 | 注重顺序 |
| Set | 无序的、不可重复的 | 简便去重 |
| Map | 无序的,key是不可重复的,value是可重复的 | O(1)查询 |
A1、ArrayList\LinkedList的优点、缺点,使用场景
时间复杂度
| 操作 | Arraylist(数组) | LinkedList(链表) |
|---|---|---|
| 随机访问 | O(1) | O(N) |
| 头部插入 | O(N) | O(1) |
| 头部删除 | O(N) | O(1) |
| 尾部插入 | O(1) | O(1) |
| 尾部删除 | O(1) | O(1) |
如果应用程序对数据有较多的随机访问,ArrayList对象要优于LinkedList对象;
如果应用程序有更多的插入或者删除操作,较少的随机访问,LinkedList对象要优于ArrayList对象;
不过ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快。
A2、Iterator迭代器解决什么问题,如何使用,原理是什么
凡是实现 Collection 接口的集合类都必须实现 iterator 方法 ,返回一个迭代器对象
Iterator<E> iterator();
迭代器也是一种设计模式,将集合的底层实现屏蔽,抽象出迭代器这样一个工具,用来遍历并选择序列中的对象。
此外,迭代器通常被称为轻量级对象:创建它的代价小。因此,经常可以见到对迭代器有些奇怪的限制;例如,Java的Iterator只能单向移动
for-each循环(增强for循环)默认使用的就是集合的迭代器进行遍历
List<Integer> temp = new ArrayList<>();
for(Integer each: temp){
//doSomething;
}
- 迭代器Iterator的内部方法
//判断end boolean hasNext(); //遍历元素 E next(); //删除元素,一般实现都需要再override的 default void remove() { throw new UnsupportedOperationException("remove"); } - 快速失败vs安全失败
- java.util包下的集合类都是快速失败;在使用迭代器遍历一个集合对象时,如果遍历过程中对集合进行修改(增删改),则会抛出 ConcurrentModificationException 异常
- java.util.concurrent包下的集合类,都采用安全失败机制(fail—safe).在遍历集合时不是直接访问原有集合,而是先将原有集合的内容复制一份,然后在拷贝的集合上进行遍历。由于是对拷贝的集合进行遍历,所以在遍历过程中对原集合的修改并不会被迭代器检测到,所以不会抛出 ConcurrentModificationException 异常。
- 使用场景
- 遍历过程中可能会删除元素
- 建议使用java8 stream或普通for循环倒序删除,不建议使用迭代器
- 参考文章浅谈为什么倒序遍历List删除元素没有问题
- 遍历过程中可能会删除元素
A3、HashSet和HashMap的联系
HashSet内部使用HashMap来实现,value是一个内部的静态Object,只是占位而已。本质就是用一下HashMap的key
A4、HashSet如何保证元素不重复
HashMap的put()方法中做了处理,详细解释参考HashMap部分。 大致流程是:
- 假设put()一个重复的的对象(用的是HashMap的Key)
- 先根据hashCode()算出hash值一样,数组的index就是一样的
- 现在冲突了,就再看equals()是否相同,
A5、各种集合类的初始化容量、扩容时机
| 集合类 | 初始容量 | 扩容时机 | 扩容策略 |
|---|---|---|---|
| ArrayList | 10 | add()的时候触发ensureCapacity(),满了就扩容 | 扩容为1.5倍, 再把老数组的元素存储到新数组里面 |
| HashMap | 16 | 容量= size * 负载因子的时候,初始是16 * 0.75 = 12 | 扩容为2倍, 将数据rehash, 然后复制过去(扩容时非常影响性能) |
| HashTable | 11 | 扩容为2倍 + 1 |
LinkedList、TreeSet基于链表,不需要扩容
HashSet 同HashMap
| 1 | HashMap的扩容怎么发生的,有哪些注意的| | 4 | put()方法的流程| | 5 | 负载因子是什么?为什么是0.75 | | 7 | HashMap的死循环 |
B2、HashMap的实现原理,JDK1.7和1.8
B3、HashMap的容量为什么总是2的幂?手动指定容量 会不会破坏这个设计?
Hash 值的范围值 2^32 (-2147483648到2147483647),前后加起来⼤概40亿的映射空间,只要 哈希函数映射得⽐均匀松散,⼀般应⽤是很难出现碰撞的。但问题是⼀个40亿⻓度的数组,内 存是放不下的。所以这个散列值是不能直接拿来⽤的。⽤之前还要先做对数组的⻓度取模运算,即hash % length 而重点是,如果满足下面的条件:
- length 是2的幂
就可以有这样的等式hash%length==hash&(length-1) (数学证明略)
即这种情况下,求余计算可以简化成位运算&, 位运算大家都知道吧?CPU一个时钟周期就完成啦!
手动指定初始容量,也不会破坏2次幂的设计,因为HashMap的源码里通过tableSizeFor()方法进行优化
static final int tableSizeFor(int cap) {
// cap-1后,n的二进制最右一位肯定和cap的最右一位不同,即一个为0,一个为1,例如cap=17(00010001),n=cap-1=16(00010000)
int n = cap - 1;
// n = (00010000 | 00001000) = 00011000
n |= n >>> 1;
// n = (00011000 | 00000110) = 00011110
n |= n >>> 2;
// n = (00011110 | 00000001) = 00011111
n |= n >>> 4;
// n = (00011111 | 00000000) = 00011111
n |= n >>> 8;
// n = (00011111 | 00000000) = 00011111
n |= n >>> 16;
// n = 00011111 = 31
// n = 31 + 1 = 32, 即最终的cap = 32 = 2 的 (n=5)次方
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
B5、负载因子是什么?为什么是0.75
扩容发生在 size = capacity * 负载因子时。
负载因子值的选取, 其实是空间利用率Vs查询的时间 的一个取舍
(和大多数数据结构一样,要么空间换时间,要么时间换空间)
HashMap源码的注释
* Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
B6、HashMap怎么解决Hash冲突的
- 如果真的冲突了,就使用【链式地址法】, 将 冲突的节点储存在链表后端(直至转化成红黑树)
- 使用扰动函数,对hashCode()方法的结果进行扰动,尽量避免不均匀的hash函数的影响
B8、为什么要在1.8引入红黑树
- 链表转换红黑树的时机
- 如果length > 64 , 且最长链表长度>8, 就会转换成红黑树。Node节点也会转换成TreeNode
- 如果length < 64, 就算链表长度>8, 也会先尝试扩容,然后rehash。 (因为长度较短的时候,冲突可能不是hash算法的问题,而是hash%size,其中size较小导致的)
- 如果hash方法设计得好,hash冲突少,其实链表是不会转换成红黑树的
- 所以红黑树是为了解决:
极端情况下,hashMap转成单链表的时候,查询效率低下的问题。get()时间复杂度为O(n),
红黑树是”近似平衡“的, 牺牲了一些查找性能 但其本身并不是完全平衡的二叉树。因此插入删除操作效率略高于AVL树。
B9、为什么要同时重写equals()和hashCode()方法
很多参考文章都说,如果没有重写hashCode(), 那么equals相同的对象,就会有不同的hash值,落在数组的不同地方,这种情况下,同一个HashMap就会存在2个这样的对象。
但注意,此时说的对象,是HashMap的key对象,而不是Value。
如果是value对象,hashCode不同
如果是key对象,就会存在两个equals相同的key,同时存在hashMap中(因为如果key的hashCode没冲突,就不会调用equals进行对比,更不会覆盖;这样你在get()的时候就会出现歧义(很多时候会引发BUG)),例如下面的code
HashMap<A, Integer> temp = new HashMap<>();
A a = new A(1);
A b = new A(2);
temp.put(a, 1);
temp.put(b, 2);
System.out.println(temp.get(a));// 结果2
System.out.println(temp.get(new A(3)));// 结果null
C1、ConcurrentHashMap在JDK1.7和1.8的实现区别
C2、1.8中为什么放弃了分段锁Segment
C3、HashTable、Vector为什么弃用了
给几乎所有的public方法都加上了synchronized关键字,导致性能欠佳,已被弃用。
C4、ConcurrentHashMap在扩容时怎么保证线程安全?
等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点。
好在Doug lea教授对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞。
但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类来实现多线程协作扩容.
参考文章<<理解Java7和8里面HashMap+ConcurrentHashMap的扩容策略>>
详细知识点
1.ArrayList
- 一句话原理
ArrayList底层的数据结构是数组,数组元素类型是Object,即内部是用Object[]实现的
2.LinkedList
- 一句话原理 LinkedList 底层使⽤的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。)
3.HashSet
-
一句话原理
内部使用HashMap来实现,value是一个内部的静态Object,只是占位而已。本质就是用一下HashMap的keypublic class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { static final long serialVersionUID = -5024744406713321676L; private transient HashMap<E,Object> map; private static final Object PRESENT = new Object(); } ··· public boolean add(E e) { return map.put(e, PRESENT)==null; }
4.TreeSet
- 一句话原理
和HashSet类似,本质通过new了一个TreeMap实现功能, 使用了它的KeySet,value用了内部的静态Object对象
在TreeSet中,元素按照其自然序升序排列和存储,内部使用了红黑树。
其中每个节点都额外保有一个比特,用来指示当前的节点颜色是红色或者黑色。这些“颜色”比特在后续的插入或者删除中,有助于确保树结构保持平衡
5.HashMap
- 一句话原理
- 原理 参考(zhuanlan.zhihu.com/p/69035659)
6.TreeMap
线程安全的
7.ConcurrentHashMap
详细内容请参考ConcurrentHashMap 原理浅析
- 原理:
- 在 JDK1.7 中 ConcurrentHashMap 采用了
数组 + Segment + 分段锁的方式实现。 - JDK8 中 ConcurrentHashMap 参考了 JDK8 HashMap 的实现,采用了
数组 + 链表 + 红黑树的实现方式来设计,内部大量采用 CAS 操作
- 在 JDK1.7 中 ConcurrentHashMap 采用了
8.CopyOnWriteArraySet、CopyOnWriteArrayList
9.ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue、ConcurrentLinkedDeque等
至于为什么没有ConcurrentArrayList,原因是无法设计一个通用的而且可以规避ArrayList的并发瓶颈的线程安全的集合类,只能锁住整个list,
可以使用List synArrayList = Collections.synchronizedList(new ArrayList());
10.HashTable:
- 一句话原理 :
HashTable和HashMap的用法类似, 它给几乎所有public方法都加上了synchronized关键字 - tips: HashTable的K,V都不能为null
多线程的K-V集合基本都不允许value为null, 因为如果允许就会存在二义性:null是有这个value, 还是没get到返回null
单线程的集合允许为null, 因为当前线程自己知道自己的逻辑,到底是没get到还是存了null。
11.Vector:
- Vector和ArrayList类似,是长度可变的数组。
- 一句话原理 :
给几乎所有的public方法都加上了synchronized关键字,导致性能欠佳,已被弃用。