Map 接口
ConcurrentHashMap 的实现原理
JDK 1.7
JDK1.7中的ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即ConcurrentHashMap 把哈希桶切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。
其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色;HashEntry 用于存储键值对数据。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:
Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。Segment 默认为 16,也就是并发度为 16。
存放元素的 HashEntry,也是一个静态内部类,主要的组成如下:
其中,用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性!
JDK1.8
在数据结构上, JDK1.8 中的 ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加低粒度的锁。
将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度。
ConcurrentHashMap JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?
-
在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
-
减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
-
线程安全实现方式 : JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。
-
Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
-
并发度 : JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
-
锁的粒度: 原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
-
查询时间复杂度: 从原来的遍历链表O(n),变成遍历红黑树O(logN)。
ConcurrentHashMap 的 put 方法执行逻辑是什么?
JDK1.7
先定位到相应的 Segment ,然后再进行 put 操作。
源代码如下:
首先会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
- 尝试自旋获取锁。
- 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功
大致可以分为以下步骤:
首先,会尝试获取锁,如果获取失败,利用自旋获取锁;如果自旋重试的次数超过 64 次,则改为阻塞获取锁。
获取到锁后:
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
- 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
- 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
- 释放 Segment 的锁。
JDK1.8
大致可以分为以下步骤:
-
根据 key 计算出 hash值。
-
判断是否需要进行初始化。
-
定位到 Node,拿到首节点 f,判断首节点 f:
- 如果为 null ,则通过cas的方式尝试添加。
- 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容。
- 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入。
-
当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树。
源代码如下:
ConcurrentHashMap 的 get 方法执行逻辑是什么?
JDK1.7
首先,根据 key 计算出 hash 值定位到具体的 Segment ,再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。
由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。
源代码如下:
JDK1.8
致可以分为以下步骤:
-
根据 key 计算出 hash 值,判断数组是否为空;
-
如果是首节点,就直接返回;
-
如果是红黑树结构,就从红黑树里面查询;
-
如果是链表结构,循环遍历判断。
源代码如下:
ConcurrentHashMap 的 get 方法是否要加锁,为什么?
get 方法不需要加锁。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。
这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 安全效率高的原因之一。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//可以看到这些都用了volatile修饰
volatile V val;
volatile Node<K,V> next;
}
get方法不需要加锁与volatile修饰的哈希桶有关吗?
没有关系。哈希桶table用volatile修饰主要是保证在数组扩容的时候保证可见性。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 存放数据的桶
transient volatile HashEntry<K,V>[] table;
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
我们先来说value 为什么不能为 null ,因为ConcurrentHashMap 是用于多线程的 ,如果map.get(key)得到了 null ,无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,这就有了二义性。
而用于单线程状态的HashMap却可以用containsKey(key) 去判断到底是否包含了这个 null 。
我们用反证法来推理:
假设ConcurrentHashMap 允许存放值为 null 的value,这时有A、B两个线程,线程A调用ConcurrentHashMap .get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的key。那么,我们可以用ConcurrentHashMap .containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回false。
但是在我们调用ConcurrentHashMap .get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap .put(key, null )的操作。那么我们调用containsKey方法返回的就是true了,这就与我们的假设的真实情况不符合了,这就有了二义性。
答案:
`ConcurrentHashMap`是Java中一个线程安全的哈希表实现。它支持高并发的读和写操作,因为它使用了分段锁技术。然而,`ConcurrentHashMap`不支持键(key)或值(value)为null的原因主要有以下几点:
1. **并发性能考虑**:
- 如果允许null键或值,那么在计算键的哈希值时会出现问题。因为任何对象的哈希值为0,所以所有的键都会映射到同一个槽位上。这意味着所有的写操作都将在这个槽位上竞争,从而导致严重的性能瓶颈。
- 同样地,如果允许null值,那么在查找值时也会出现类似的问题。
2. **线程安全考虑**:
- `ConcurrentHashMap`使用分段锁来确保线程安全。每个分段都有自己的锁。如果允许null键或值,那么在计算分段索引时也会出现问题,因为null的哈希值为0。这会导致所有的键都映射到同一个分段上,从而引发严重的锁竞争。
3. **异常处理问题**:
- 如果允许null键或值,那么在使用这些键或值进行操作时可能会抛出`NullPointerException`。例如,当你尝试获取一个不存在的键的值时,你可能会得到一个null值,这可能会导致未预期的`NullPointerException`。
如果遇到需要存储null键或值的场景,有以下几种替代方案或建议的集合类型:
1. **使用其他线程安全的集合**:例如`Collections.synchronizedMap(new HashMap<>())`或`Hashtable`。但请注意,这些集合在高并发场景下的性能可能不如`ConcurrentHashMap`。
2. **使用外部同步**:如果你知道某个特定的键或值总是null,并且这个键或值不会在多个线程之间共享,你可以使用外部同步来保护对这个键或值的访问。
3. **使用其他数据结构**:根据具体的需求,可能有更适合的数据结构来存储null键或值。例如,如果键是唯一的,但值可能为null,可以考虑使用`TreeMap`。
总之,虽然`ConcurrentHashMap`不支持null键或值,但这是为了确保其线程安全性和高性能。在实际应用中,应该避免使用null键或值,或者选择其他更适合的数据结构来满足需求。
ConcurrentHashMap中的value不能为null的原因就是为了避免上述的二义性问题。
在ConcurrentHashMap中,null值表示找不到对应的键,如果一个value也是null,那么就会让使用者无法判断具体是哪种情况。举个例子,考虑下面的代码片段:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("key", null);
String value = map.get("key");
如果ConcurrentHashMap允许value为null,那么当我们从map中取出"key"对应的value时,得到的结果就有两种可能,一种是value确实是null,另一种是"key"并不存在于map中。这就会给代码的正确性和可读性带来影响。
因此,在ConcurrentHashMap中,为了避免这种二义性,value不能为null。如果需要表示键值对中的value不存在的情况,可以使用一些特殊的值,比如空字符串或空数组等。
ConcurrentHashMap 的并发度是多少?
在JDK1.7中,并发度默认是16,这个值可以在构造函数中设置。如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
ConcurrentHashMap 迭代器是强一致性还是弱一致性?
与HashMap迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。
JDK1.7与JDK1.8 中ConcurrentHashMap 的区别?
-
数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
-
保证线程安全机制:JDK1.7采用Segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8 采用CAS+Synchronized保证线程安全。
-
锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
-
链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
-
查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
ConcurrentHashMap 和Hashtable的效率哪个更高?为什么?
ConcurrentHashMap 的效率要高于Hashtable,因为Hashtable给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在JDK1.7中采用分段锁实现线程安全,在JDK1.8 中采用CAS+Synchronized实现线程安全。
说一下Hashtable的锁机制 ?
Hashtable是使用Synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
多线程下安全的操作 map还有其他方法吗?
还可以使用Collections.synchronizedMap方法,对方法进行加同步锁。
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNon null (m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
// 省略部分代码
}
如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!