Java8中ConcurrentHashMap的源码分析

Java架构师交流群:793825326

java版本:jdk1.8

IDE:idea 18

ConcurrentHashMap是java并发库里面的一个集合类,是一个线程安全的HashMap,它继承ConcurrentMap<K,V>接口。它的基本操作和HashMap几乎一样,差别就在于它实现了线程安全,它实现线程安全在Java8中使用的策略是CAS+volatile+synchronized。下面具体分下它的源码。由于源码和HashMap差不多,所以这里面我们着重关注它线程安全的部门,其他的部门可以参考我之前的HashMap源码分析文章。

1.写一段测试代码:

ConcurrentHashMap<String,String> map=new ConcurrentHashMap<>();
for (int i=0;i<20000000;i++)
{
    map.put(Integer.toString(i),Integer.toString(i));
}

2.跟踪进put方法内部:

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

这里我们注意到了它和HashMap的第一个区别,就是它不允许key和value为空,而HashMap在这方面没有限制。为什么要做这个限制,后面我会讲到。

3.紧接着首先进入的方法是initTable()方法,看下这段代码怎么实现的:

    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)  //sizeCtl默认为0,当小于0表示有线程正在进行初始化或者扩容。
                                    //那么就调用Thread.yield()让出当前线程,也就起到了阻塞的目的。
                Thread.yield(); // lost initialization race; just spin
                    //利用compareAndSwapInt设置sizeCtl为-1,让map进入初始化状态
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        //使用默认容量16进行初始化
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    //初始化结束后,
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

这段代码就是CAS算法,这里面是一个while循环,这里面有个控制变量sizeCtl,如果它小于0,则表示正在初始化或者扩容。那么其他的线程检测不通过后就会阻塞,另外sizeCtl 在不进行初始化或者扩容的时候则代表当前map的阈值,我们可以看到它的值是n减去n向右移两位,即n-n/4=0.75n,恰好等于默认的加载因子。

4.紧接着存入第一个元素:

//使用cas判断当前key所在的索引位置是否有数据,如果没有,则尝试在该位置插入一个新的节点数据
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }

//tabAt
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }

//casTabAt的源码
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }

该方法完成后结束了,其他线程如果恰好也正在存入数据,那么将会不成功,继续循环

5.在链上新增数据,采用的策略是使用synchronized锁住第一个节点:

V oldVal = null;
synchronized (f) {
    if (tabAt(tab, i) == f) {
        if (fh >= 0) {
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
                K ek;
                if (e.hash == hash &&
                    ((ek = e.key) == key ||
                     (ek != null && key.equals(ek)))) {
                    oldVal = e.val;
                    if (!onlyIfAbsent)
                        e.val = value;
                    break;
                }
                Node<K,V> pred = e;
                if ((e = e.next) == null) {
                    pred.next = new Node<K,V>(hash, key,
                                              value, null);
                    break;
                }
            }
        }
        else if (f instanceof TreeBin) {
            Node<K,V> p;
            binCount = 2;
            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                           value)) != null) {
                oldVal = p.val;
                if (!onlyIfAbsent)
                    p.val = value;
            }
        }
    }
}

6.在连上新增数据后,检查是否需要树形化:

if (binCount != 0) {
    if (binCount >= TREEIFY_THRESHOLD)
        treeifyBin(tab, i);
    if (oldVal != null)
        return oldVal;
    break;
}

我们看到树形化的阈值和HashMap一样,都是8。treeifyBin方法的源码如下:

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));
                }
            }
        }
    }
}

其保证线程安全的策略仍然是synchronized +cas方法。其他的操作则基本和HashMap一样。同样的,首先不是进行树形化操作,而是检查是否大于等于最小树形化阈值,这个阈值和HashMap一样,都是64。

7.数据存入结束后,调用addCount()方法增加map的数据总数,同时检查是否需要扩容,扩容的条件判断是

//while里面的条件为扩容条件,里面的控制因子是sizeCtl,当进行扩容时,它会为-1,其他的线程会帮忙扩容
//这里正常的扩容条件和加载因子的关系,请看后续总结
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
       (n = tab.length) < MAXIMUM_CAPACITY) {
    int rs = resizeStamp(n);
    if (sc < 0) {
        if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
            sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
            transferIndex <= 0)
            break;
        if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
            transfer(tab, nt);
    }
    else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                 (rs << RESIZE_STAMP_SHIFT) + 2))
        transfer(tab, null);
    s = sumCount();
}

总结ConcurrentHashMap的源码如下:

1.构造函数的区别,当传入初始大小为10,加载因子为0.5时,得到的初始大小并不是16,而是32,区别于HashMap

2.key和value都不能为空,如果你比较细心,你会发现同样支持并发操作的HashTable也不允许key和value为null。究其原因,我在网上看到一段据说是作者的原话:

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

翻译过来的意思是:

在ConcurrentMaps(ConcurrentHashMaps, ConcurrentSkipListMaps)null值不被允许的原因是只在非并发环境中允许的歧义,在并发环境中不允许。最主要的一点是,如果map.get(key)返回null,你无法明确地区分是因为字典根据获取到的值是null还是这个字典压根不包含这个key。在一个非并发字典中,你可以通过map.contains(key)来检查,但在并发环境中,字典可能在几个操作之间被改变了。

这句话是什么意思呢,看如下的代码:

HashMap<String,String> hashMap=new HashMap<>();
if (hashMap.containsKey("ceshi"))
{
    String str=hashMap.get("ceshi");
}
ConcurrentHashMap<String,String> concurrentHashMap=new ConcurrentHashMap<>(5,0.00001f);
if (concurrentHashMap.containsKey("ceshi")) 
{
    String str = concurrentHashMap.get("ceshi");
}

通过containsKey判断是否存在“ceshi”这个键,如果存在就从map里面取出它的值。这在HashMap里没有任何问题,因为它是在非并发环境下使用的,但在ConcurrentHashMap中就有问题了,因为当你的判断结束,正要通过get取数据的时候,这期间可能另外一个线程改变了这个map,把“ceshi”这个key删除了,这个时候你当前的线程获取到的是null值,那么你就分辨不了是这个key对应的value是null,还是其他的线程把这个key从map中移除了。这就解释了为何value不能为null了。

那么为何key也不能为null呢?暂时不知道,我个人猜测是存key为null的值本身就没什么意义,所以作者在不允许存value为null的值的同时,索性也不允许存key为null的值进来。

3.保证取数据的线程安全采用的是volatile,保证存数据的线程安全以及树形化操作等采用的是cas+synchronized,保证扩容和初始化的线程安全才采用的是sizeCtl(控制变量)+cas。cas属于Unsafe类。

4.扩容的时候其他的修改数据的线程无法进行修改,但是会参与到扩容当中,帮助扩容线程快速完成扩容。这个时候使用sizeCtl来控制的,当为-2时,表示有一个线程正在扩容,当为-3时,表示有两个线程正在扩容,即当为-n时,表示n-1个线程正在扩容。

扩容的时候不影响取数据,因为扩容采用的方式是新建一个数组,替换掉原来的数组。因此不会影响到正在进行的查询。

5.扩容条件和加载因子的关系不同于HashMap,HashMap是将容量和加载因子相乘,得出阈值。而ConcurrentHashMap的首次阈值计算,如果不给记载因子,则默认是容量*0.75,如果给了,则默认是:

long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
    MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;

比如如果我们传进去的初始容量是5,加载因子是20f,则计算出来的扩容阈值是2。后续的扩容计算则按照容量*0.75来扩容。

6.线程安全策略存在死锁的隐患,具体参考我另外一篇博文https://blog.csdn.net/dap769815768/article/details/96481502

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值