Java并发容器学习之ConcurrentHashMap

一、写在前面

在这里插入图片描述

由于HashMap 是非线程安全的,因此在多线程环境下不能使用 HashMap,那想在多线程下安全的使用map,主要有三种方案:

使用Hashtable线程安全类;
使用Collections.synchronizedMap方法,对方法进行加同步锁;
使用并发包中的ConcurrentHashMap类;

Hashtable几乎所有的添加、删除、查询方法都加synchronized同步锁,相当于给整个哈希表加一把大锁,多线程访问时候,只要有线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,性能非常差,不推荐使用。

Collections.synchronizedMap是对 HashMap 做的方法做包装,里面使用对象锁来保证多线程场景下,操作安全,本质也是 HashMap进行全表锁。

因此并发包中的ConcurrentHashMap类就脱颖而出。

JDK1.8起,ConcurrentHashMap底层的数据结构就已经从Segment分段锁变为数组 + 链表 + 红黑树。本文就从JDK1.8的ConcurrentHashMap讲起。

二、红黑树数据结构

JDK1.8 HashMap的红黑树,准确说是TreeBin代理类容器,根据TreeNode链表初始化TreeBin类对象,TreeBin在实现上同样继承Node类,所以初始化完成的TreeBin类对象可以保持在Node数组中;

class TreeBin<K,V> extends Node<K,V> {
    TreeNode<K,V> root;
    volatile TreeNode<K,V> first;
    volatile Thread waiter;
    volatile int lockState;
    // values for lockState
    // set while holding write lock
    static final int WRITER = 1;
    // set when waiting for write lock
    static final int WAITER = 2; 
    // increment value for setting read lock
    static final int READER = 4; 
}

2.1 红黑树特性和定义:

  • 性质1:节点是红色或黑色。
  • 性质2:根节点是黑色。
  • 性质3:每个叶节点(NIL节点,空节点)是黑色的。
  • 性质4:每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
  • 性质5:从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

红黑树是一个近似平衡二叉查找树,其查找操作的平均时间复杂度是 O(log n)。

//数据结构
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	TreeNode<K,V> parent;  // red-black tree links
	TreeNode<K,V> left;
	TreeNode<K,V> right;
	TreeNode<K,V> prev;    // needed to unlink next upon deletion
	boolean red; //红黑节点标识
	TreeNode(int hash, K key, V val, Node<K,V> next) {
		super(hash, key, val, next);
	}
	....
}

在这里插入图片描述
新的节点加入时,有可能会破坏其中一些特性,需要通过左旋或右旋操作调整树结构(此处不赘述实现),重新着色,重新满足所有特性。

2.2 ConcurrentHashMap红黑树实现

当一个链表中的元素达到8个时,会调用treeifyBin()方法把链表结构转化成红黑树结构,实现如下:

/**
 * Replaces all linked nodes in bin at given index unless table is
 * too small, in which case resizes instead.
 */
private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,
                                              null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

可见并非一开始就创建红黑树结构,如果当前Node数组长度小于阈值MIN_TREEIFY_CAPACIT = 64,先通过扩大数组容量为原来的两倍以缓解单个链表元素过大的性能问题。

三、ConcurrentHashMap写操作

采用"CAS + synchronized"的方式来保证线程安全。其核心是使用Unsafe类提供的CAS操作来进行无锁的更新操作,同时涉及并发操作时通过synchronized来保证线程安全。

public V put(K key, V value) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
 
    // 第一次检查,如果tab为空或长度为0,则初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
 
    // 计算key的hash值,并进行二次散列
    if ((p = tab[i = (n - 1) & hash(key)]) == null)
        // 如果没有冲突,使用CAS尝试直接插入
        return (tab[i] = new Node<K,V>(key, value, null)).val;
 
    // 如果p的hash值为MOVED,则表示需要进行扩容,直接调用helpTransfer
    else if ((p.hash & NODE_HASH_MASK) == MOVED)
        tab = helpTransfer(tab, rehashStamp(p));
 
    // 如果在上面的情况都不符合,说明存在hash冲突
    else {
        Node<K,V> node = null;
        // 循环遍历链表或红黑树,尝试使用CAS插入
        synchronized (p) {
            if (tabAt(tab, i) == p) {
                if (p.next == null)
                    node = p.next = new Node<K,V>(key, value, null);
                else if (p instanceof TreeBin) {
                    node = ((TreeBin<K,V>)p).putTreeVal(this, tab, hash(key), key, value);
                } else {
                    for (Node<K,V> q = p; q != null; q = q.next) {
                        if (q.key.equals(key)) {
                            node = q;
                            break;
                        }
                    }
                    if (node == null) {
                        for (int binCount = 0; ; ++binCount) {
                            if ((node = p.next) == null) {
                                p.next = new Node<K,V>(key, value, null);
                                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                    treeifyBin(tab, i);
                                break;
                            }
                            if (node.key.equals(key))
                                break;
                            p = node;
                        }
                    }
                }
                if (node != null) {
                    node.val = value;
                }
            }
        }
        return node == null ? null : node.val;
    }
    addCount(1L, binCount);
    return null;
}

通过源码可以看到,put()方法的核心是putVal(),主要是通过synchronized去锁住每一个节点保证并发的安全性。两个核心点:
(1)判断put进去的这个元素,是处于链表还是处于红黑树上
(2)判断当前插入的key是否与链表或者红黑树上的某个元素一致

若当前插入key与链表当中所有元素的key都不一致时,那当前的插入操作就追加到链表的末尾(尾插法),否则就替换掉key对应的value。
在这里插入图片描述

四、扩容原理

什么情况导致扩容嘞?
需要知道的两个重要字段:

MIN_TREEIFY_CAPACITY :数组初始长度,默认为64
TREEIFY_THRESHOLD :树化阈值,指定桶位链表长度达到8的话,就可能发生树化操作

线程往桶里面新增每一个元素,都会对链表的长度进行判断,只有元素个数大于阈值MIN_TREEIFY_CAPACITY并且链表长度大于8,才会调用treeifyBin()把链表转化为红黑树,否则就会进行扩容操作。
这里的扩容,指的就是扩大数组的桶个数,从而装下更多的元素。
除此之外,扩容还维护另一重要的字段sizeCtl:
在这里插入图片描述
可以知道这个字段有三种状态:

sizeCtl < 0:若为-1则起标记作用,告知其它线程此时正在初始化;若为其它的值表示当前table正在扩容
sizeCtl = 0:表示创建table数组时还未进行扩容,没有指定的初始容量
sizeCtl > 0:表示当table初始化后下次扩容的触发条件

字段的值可以转化为32位的二进制数值,它的高16位表示扩容标识戳,用来标识扩容的范围,如从长度16扩容到32;低16位表示当前参与扩容的线程数量。
在这里插入图片描述
在这里插入图片描述

注:尾插法指的就是后面插入的元素都处于前一个元素的后面

在这里插入图片描述
这里简单普通的扩容是没什么问题的,大多数场景都和HashMap的扩容是一样的。
问题就在于当前是处于并发环境的,而扩容也需要时间。

五、正在扩容且有多个线程正在竞争

5.1 扩容期间的读操作:

若扩容期间,有线程进行元素的读取,是可以读到的,但前提是这个节点已经迁移结束,若是一个正在扩容迁移的节点,那就访问不到。
具体的操作,就是去调用find()。
当一个桶位要进行数据迁移,就会往这个桶位上放置一个ForwardingNode节点。除此之外还需要去标识这个节点是正在迁移还是已经迁移结束的;

当其中某一个节点迁移完成后,就会在老节点上添加一个fwd引用,它指向新节点的地址。

所以当某个线程访问这个节点,若存在fwd引用,就说明当前table正在扩容,那就会根据这个引用上的newtable字段去新数组的对应桶位上找到数据然后返回。
在这里插入图片描述

5.2 扩容期间的写操作:

写操作相较于读操作会更加复杂,因为读操作只需获取对应数据返回即可,而写操作还要修改数据,所以当一个写线程来修改数据刚好碰到容器处于扩容期间,那么它还要协助容器进行扩容

具体的扩容操作依然还要分情况,假如访问的桶位数据还没有被迁移走的话,那就直接竞争锁,然后在老节点上进行操作就行。

但假如线程修改的节点正好是一个fwd节点,说明当前节点正处于扩容操作,那么为节约线程数并且快速完成任务,当前线程就会进行协助扩容。如果有多个线程进行同时写,那这些线程都会调用helpTransfer()进行协助扩容。

这里协助扩容就是拿到一个扩容标识戳,这个标识戳的作用就是用来标识扩大的容量大小。因为每个线程都是独立,互不通信,但需要做相同任务将桶位扩大相同的值,所以就必须拿到相同标识戳才会进行扩容。

例如:

一个容器从16个桶位扩容到32个桶位,有线程AB两个线程。
若A触发扩容的机制,那么线程A就会进行扩容,此时线程B也来进行写操作,发现正在扩容就会进入到协助扩容的步骤中去。所以线程A和线程B共同负责桶位的扩容。

sizeCtl转化为32位后,它的低16位是表示当前参与扩容的线程数量。所以当A线程触发扩容之后,就会将sizeCtl低16位的最后一位值+1,表示扩容线程多一位,当它退出扩容时又会将最后一位的值-1,表示扩容线程少一位,就这样各个线程共同维护这个字段。
在这里插入图片描述
而最后一个退出扩容的线程,完成任务后去判断sizeCtl的值得时候,若发现它的低16位最后一位只剩下1,再减为0,那就代表它是最后一个退出扩容的线程。那此线程还需去轮询检查一遍老的table数组是否还留有fwd节点,判断是否还有遗漏未迁移。若没有代表迁移完成,若有还要继续迁移到新桶位。

由于源码很长,就通过流程图理解扩容期间操作:
在这里插入图片描述

六、总结

由于JDK1.7 和 JDK1.8 对 ConcurrentHashMap 的实现有很大的不同,本文只简单叙述JDK1.8的部分知识。
我在《Java高并发JUC之ConcurrentHashMap温故》 文章中加入部分补充。

七、ConcurrentHashMap常见面试题

1ConcurrentHashMap使用什么技术来保证线程安全?
2HashMapConcurrentHashMap 的区别,Hashmap 原理,以及不同情况下的复杂度?
3ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?
4ConcurrentHashMap默认初始容量是多少?
5ConCurrentHashmap 的key,value是否可以为null?为什么?
6ConCurrentHashmap 每次扩容是原来容量的几倍
7ConCurrentHashmap的数据结构是怎么样的?
8ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?
ConcurrentHashMap面试题参考链接
https://blog.csdn.net/QGhurt/article/details/107323702
https://blog.csdn.net/xt199711/article/details/114339022
https://blog.csdn.net/afreon/article/details/120397899
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

容若只如初见

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值