HashSet
底层就是基于 HashMap
实现的
都继承自AbstractMap,但是TreeMap是基于红黑树,比HashMap多了根据键排序的能力以及对集合内元素的搜索能力(定向搜索、子集操作、逆序视图、边界操作)。TreeMap保证了搜索操作的时间复杂度为 O(log n)
数组+链表。先计算要插入元素的hashCode,然后经过扰动函数(优化哈希值分布减少碰撞)计算得出hash值,然后通过(n - 1) & hash得出索引,如果当前索引存在元素,则调用equals判断是否真的相等,如果相等,则覆盖,如果不相等,则加入链表。
数组+链表(红黑树),当链表长度大于8时会转化为红黑树(要先检查数组长度是否大于64,否则优先扩容数组)。链表查询效率为O(n),红黑树查询效率为O(logn)。
为什么优先扩容数组而不是直接转化为红黑树?
因为数组扩容能减少哈希冲突的几率(将元素重新散列到更大的数组),元素较少的情况下比转化为红黑树更有效,因为当链表较短时,O(n) 和 O(log n) 的性能差异不明显。红黑树需要保持自平衡,维护成本较高。过早引入红黑树会增加复杂度。
为什么选择阈值8和64?
hashCode算出来并不能直接用,还需要做取余运算,而普通的取余运算效率不高
HashMap发生扩容时,需要移动旧桶的链表中的元素到新位置,JDK1.7之前使用的是头插法,如果此时有多个线程同时进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成循环链表,导致查询元素的操作陷入死循环而结束。
为了解决这个问题,JDK1.8之后改为尾插法避免链表倒置导致死循环。但并发环境下还是推荐使用ConcurrentHashMap。
因为如果多个线程对HashMap做put操作,容易造成数据丢失/覆盖。
比如线程A和线程B同时进行put操作(假设hash值相同),线程A先获得CPU时间片,此时判断对应的哈希桶中没有元素,准备直接插入数组的对应索引位置,若此时线程A的时间片用完,线程B同样执行put操作并直接插入到了数组中。然后线程A重新获得时间片,由于之前已经判断过没有造成哈希冲突,会直接覆盖线程B插入的值,导致数据丢失。
1. 使用迭代器(Iterator)EntrySet 的方式进行遍历;
Iterator> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry entry = iterator.next();
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}
2. 使用迭代器(Iterator)KeySet 的方式进行遍历;
// 遍历
Iterator iterator = map.keySet().iterator();
while (iterator.hasNext()) {
Integer key = iterator.next();
System.out.println(key);
System.out.println(map.get(key));
}
3. 使用 For Each EntrySet 的方式进行遍历;
for (Map.Entry entry : map.entrySet()) {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}
4. 使用 For Each KeySet 的方式进行遍历;
for (Integer key : map.keySet()) {
System.out.println(key);
System.out.println(map.get(key));
}
5. 使用 Lambda 表达式的方式进行遍历;
// 遍历
map.forEach((key, value) -> {
System.out.println(key);
System.out.println(value);
});
6. 使用 Streams API 单线程的方式进行遍历;
map.entrySet().stream().forEach((entry) -> {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
});
7. 使用 Streams API 多线程的方式进行遍历。
// 遍历
map.entrySet().parallelStream().forEach((entry) -> {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
});
存在阻塞时 parallelStream 性能最高, 非阻塞时 parallelStream 性能最低 。
synchronized
和 CAS 机制,就是优化过且线程安全的HashMap。- JDK1.7之前:
分段数组+链表:每个Segment段(一个HashEntry
数组)配一把可重入锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。Segment默认有16个
- JDK1.8及之后:
Node数组+链表/红黑树
Node + CAS(
改 - 值变化) + synchronized(
增/删 - 结构性变化) 来保证并发安全。
JDK1.8之后锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突就不会产生并发,最大并发度就是 Node 数组的大小。
是为了防止并发环境下的二义性问题(值/键没有在集合中 or 值/键本身为NULL)
HashMap一般用在单线程场景下,可以先通过map.containsKey(key)来判断键值对是否存在,然后再调用get方法,这个过程不会和其他线程产生并发问题。
而ConcurrentHashMap,即使事先通过map.containsKey(key)来判断某个键值对是否存在,别的线程也有可能执行map.remove(key),这个时候调用get也会返回null,你无法确定是由于别的线程同时删掉了这个键值对导致的null,还是因为其本来就不存在,所以存在二义性问题。
多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。
From ConcurrentHashMap author Doug Lea:
‘The main reason that nulls aren't allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can't be accommodated. The main one is that if map.get(key) returns null, you can't detect whether the key explicitly maps to null vs the key isn't mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.’
如果分开调用put
、get
、remove
、containsKey,则
不能。例如先判断某个键是否存在containsKey(key)
,然后根据结果进行插入或更新put(key, value)
。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
那该如何保证ConcurrentHashMap的原子性?
使用putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
、merge