CopyOnWriteArrayList

1. 读写分离

写操作在一个复制的数组上进行,读操作还是在原数组中进行,读写分离,互不影响。

写操作需要加锁,防止并发写入时导致写入数据丢失。

写操作结束之后需要把原数组指向新的复制数组。

  1. //写操作:
  2. //通过过创建底层数组的新副本来实现的。
  3. //当 List 需要被修改的时候,并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。
  4. //写完之后,把原数组指向新的复制数组。
  5. //这样可以保证写操作实在一个复制的数组上进行,而读操作还是在原数组中进行,不会影响读操作。
  6. public boolean add(E e) {
  7. //加锁
  8. final ReentrantLock lock = this.lock;
  9. lock.lock();
  10. try {
  11. Object[] elements = getArray();
  12. int len = elements.length;
  13. // newElements 是一个复制的数组
  14. Object[] newElements = Arrays.copyOf(elements, len + 1);
  15. newElements[len] = e;
  16. // 写操作在一个复制的数组上进行
  17. setArray(newElements);
  18. return true;
  19. } finally {
  20. lock.unlock();
  21. }
  22. }
  23. final void setArray(Object[] a) {
  24. array = a;
  25. }
  1. //读操作
  2. //读操作没有任何同步控制和锁操作,
  3. //因为内部数组 array 不会被修改。
  4. private transient volatile Object[] array;
  5. public E get(int index) {
  6. return get(getArray(), index);
  7. }
  8. @SuppressWarnings("unchecked")
  9. private E get(Object[] a, int index) {
  10. return (E) a[index];
  11. }
  12. final Object[] getArray() {
  13. return array;
  14. }

2. 适用场景

CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,很适合读多写少的应用场景。

CopyOnWriteArrayList 有其缺陷:

  • 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
  • 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。

所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。

ConcurrentHashMap

1. 存储结构

  1. static final class HashEntry<K,V> {
  2. final int hash;
  3. final K key;
  4. volatile V value;
  5. volatile HashEntry<K,V> next;
  6. }

ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶, 从而使其并发度更高(并发度就是 Segment 的个数)。

  1. //Segment 继承自 ReentrantLock。
  2. static final class Segment<K,V> extends ReentrantLock implements Serializable {
  3. private static final long serialVersionUID = 2249069246763182397L;
  4. static final int MAX_SCAN_RETRIES =
  5. Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
  6. transient volatile HashEntry<K,V>[] table;
  7. transient int count;
  8. transient int modCount;
  9. transient int threshold;
  10. final float loadFactor;
  11. }
  1. final Segment<K,V>[] segments;
  2. //默认的并发级别为 16,也就是说默认创建 16 个 Segment。
  3. static final int DEFAULT_CONCURRENCY_LEVEL = 16;

image.png

2. size 操作

每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。

  1. /**
  2. * The number of elements. Accessed only either within locks
  3. * or among other volatile reads that maintain visibility.
  4. */
  5. transient int count;Copy to clipboardErrorCopied

在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。

ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的

尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。

如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。

  1. /**
  2. * Number of unsynchronized retries in size and containsValue
  3. * methods before resorting to locking. This is used to avoid
  4. * unbounded retries if tables undergo continuous modification
  5. * which would make it impossible to obtain an accurate result.
  6. */
  7. static final int RETRIES_BEFORE_LOCK = 2;
  8. public int size() {
  9. // Try a few times to get accurate count. On failure due to
  10. // continuous async changes in table, resort to locking.
  11. final Segment<K,V>[] segments = this.segments;
  12. int size;
  13. boolean overflow; // true if size overflows 32 bits
  14. long sum; // sum of modCounts
  15. long last = 0L; // previous sum
  16. int retries = -1; // first iteration isn't retry
  17. try {
  18. for (;;) {
  19. // 超过尝试次数,则对每个 Segment 加锁
  20. if (retries++ == RETRIES_BEFORE_LOCK) {
  21. for (int j = 0; j < segments.length; ++j)
  22. ensureSegment(j).lock(); // force creation
  23. }
  24. sum = 0L;
  25. size = 0;
  26. overflow = false;
  27. for (int j = 0; j < segments.length; ++j) {
  28. Segment<K,V> seg = segmentAt(segments, j);
  29. if (seg != null) {
  30. sum += seg.modCount;
  31. int c = seg.count;
  32. if (c < 0 || (size += c) < 0)
  33. overflow = true;
  34. }
  35. }
  36. // 连续两次得到的结果一致,则认为这个结果是正确的
  37. if (sum == last)
  38. break;
  39. last = sum;
  40. }
  41. } finally {
  42. if (retries > RETRIES_BEFORE_LOCK) {
  43. for (int j = 0; j < segments.length; ++j)
  44. segmentAt(segments, j).unlock();
  45. }
  46. }
  47. return overflow ? Integer.MAX_VALUE : size;
  48. }Copy to clipboardErrorCopied

3. JDK 1.8 的改动

ConcurrentHashMap 取消了 Segment 分段锁。

JDK 1.8 使用 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized

数据结构与HashMap 1.8 的结构类似,数组+链表 / 红黑二叉树(链表长度 > 8 时,转换为红黑树 )。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 Hash 值不冲突,就不会产生并发。

4. JDK 1.8 中的 put 方法

(1)hash 算法

  1. static final int spread(int h) {
  2. return (h ^ (h >>> 16)) & HASH_BITS;
  3. }

(2)定位索引位置

  1. i = (n - 1) & hash

(3)获取 table 中对应索引的元素 f

  1. f = tabAt(tab, i = (n - 1) & hash
  1. // Unsafe.getObjectVolatile 获取 f
  2. // 因为可以直接指定内存中的数据,保证了每次拿到的数据都是新的
  3. static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
  4. return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
  5. }

(4)如果 f 是 null,说明 table 中是第一次插入数据,利用

  • 如果 CAS 成功,说明 Node 节点插入成功
  • 如果 CAS 失败,说明有其他线程提前插入了节点,自旋重新尝试在该位置插入 Node

(5)其余情况把新的 Node 节点按链表或红黑树的方式插入到合适位置,这个过程采用内置锁实现并发。

5. 和 Hashtable 的区别

底层数据结构:

  • JDK1.7 的ConcurrentHashMap底层采用分段的数组+链表实现, JDK1.8 的ConcurrentHashMap底层采用的数据结构与JDK1.8 的HashMap的结构一样,数组+链表/红黑二叉树
  • Hashtable和JDK1.8 之前的HashMap的底层数据结构类似都是采用数组+链表的形式, 数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。

实现线程安全的方式

  • JDK1.7的ConcurrentHashMap(分段锁)对整个桶数组进行了分割分段(Segment), 每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问度。 JDK 1.8 采用数组+链表/红黑二叉树的数据结构来实现,并发控制使用synchronized和CAS来操作。
  • Hashtable:使用 synchronized 来保证线程安全,效率非常低下。 当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态, 如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈。

Hashtable 全表锁

image.png

ConcurrentHashMap 分段锁

image.png

补充

红黑树性质

  1. 每个节点或者是红色的,或者是黑色的
  2. 根节点是黑色的
  3. 每一个叶子节点(最后的空节点)是黑色的

    这其实是一个定义

  4. 如果一个节点是红色的,那么它们的孩子节点都是黑色的

    因为红节点和其父亲节点表示一个3节点,黑色节点的右孩子一定是黑色节点。

  5. 从任意一个节点到叶子节点经过的黑色节点都是一样的

    因为2-3树是绝对平衡的

image.png

相较于 AVL 树,红黑树上的查找的操作的效率的低于 AVL 树的。但对于增删操作,红黑树的性能是更优的。