ConcurrentHashMap源码剖析

本文解析了ConcurrentHashMap的安全性概念,通过实例展示了如何避免并发时的值覆盖问题。讲解了核心putVal方法,包括散列算法、数据结构初始化和扩容策略,以及关键操作如putIfAbsent和treeifyBin。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在进行深入剖析源码前,我们要知道,ConcurrentHashMap的数据结构同HashMap一样也为:数组+链表+红黑树。

1、接下来我们来思考,如何正确理解ConcurrentHashMap安全性的概念。

假设有这样一段代码:

//实现功能 计数count
public class ConcurrentHashMapTest {
    public static ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
    public static void main(String[] args) {
        Integer count = map.get("count");
        if(count==null){
            //第一次添加键值对令值为1
            map.put("count",1);
        }
        else {
            //当键存在有值时 对值进行+1操作
            map.put("count",count+1);
        }
    }
}

若此时有两个线程A、B,两者都执行map.get方法,得到的均为null,则线程A、B均进入if中执行map.put("count",1)方法,即A执行一遍后count值为1,B根本不管A有没有执行,它也执行一遍count值为1,这样问题就来了,线程A、B明明都执行了方法,但是最后count的值却只加了一次。

原因:ConcurrentHashMap可以实现线程安全,但要理解,ConcurrentHashMap并不是像加了synchronized锁一样,ConcurrentHashMap只能保证map内的数据不被破坏,在并发线程中有读写操作时并不能保证安全,主要就是key一样时会有值覆盖问题。

解决方法(不加同步锁的前提):

//实现功能 计数count
public class ConcurrentHashMapTest {
    public static ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();
    public static void main(String[] args) {
        while(true){
            Integer count = map.get("count");
            if(count==null){
                //将数据存储到map中后,会返回null,表示存储成功
                //putIfAbsent方法表示若存在该key则不进行操作,若key不存在则进入操作
                if(map.putIfAbsent("count",1)==null){
                    break;
                }
            }else {
                //replace方法执行成功会返回true
                //replace(key,oldValue,newValue)
                if(map.replace("count",count,count+1)){
                    break;
                }
            }
        }
    }
}

此时,A、B两个线程的过程再来一遍:线程A、B进入while循环后都执行get方法,A先执行得到null,进入if语句,B也得到null也进入if语句。由于此时不存在“count”的这个key,因而这时线程A执行putIfAbsent方法,成功存储键值对后返回null,则break循环。线程B此时也想执行putIfAbsent方法,但因已经存在“count”这个key,因而不执行,由于死循环继续得到count(此时为1)不为null,线程B会进入replace方法,执行成功会返回true给if而后break循环。

2、Put方法

进入put方法可以发现其核心方法依然是putVal方法

 因而我们先来分段分析一下这个核心方法

final V putVal(K key, V value, boolean onlyIfAbsent) {
        //key、value不允许为空
        if (key == null || value == null) throw new NullPointerException();
        //基于key计算出Hash值  一会细讲
        int hash = spread(key.hashCode());
        //声明了一个标识
        int binCount = 0;
        //死循环,并声明了很多tab,table赋值给了tab,为当前ConcurrentHashMap的数组
        for (Node<K,V>[] tab = table;;) {
            //声明了一堆变量 开发者写的
            //tab--数组  n--数组长度  i--数据要存储的索引位置  f--当前索引位置的数据
            Node<K,V> f; int n, i, fh; K fk; V fv;
            //判断若数组为null或者数组的长度为0
            if (tab == null || (n = tab.length) == 0)
            //则初始化数组
                tab = initTable();


            //到else if说明数组已经初始化完成 需要将数据插入到map中
            //下面的tabAt方法表示:获取数组中某一个索引位置的数据即得到tab[i]
//casTabAt—>以CAS的方式,将数组tab中i位置的数据从null修改为new Node<K,V>(hash, key, value)
            

            //将数组长度-1 & key的hash值  作为索引i 配合tabAt方法获取这个索引位置的值
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //若当前索引位置的值为null,则需要将数据插入到这个位置,采用CAS方法
            //插入成功返回true,否则返回false再从for循环再来
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                    //成功则break循环
                    break;                   
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent && fh == hash &&  // check first node
                     ((fk = f.key) == key || fk != null && key.equals(fk)) &&
                     (fv = f.val) != null)
                return fv;
            //哈希冲突存储
            else {
                //说明出现了hash冲突,需要将数据挂到链表上或者添加到红黑树中
                V oldVal = null;
                //锁住当前桶的位置
                synchronized (f) {
                //拿到i索引位置的数据,判断跟锁的数据是不是同一个
                //避免并发操作时还未追加数据就被另一个线程修改了数据
                    if (tabAt(tab, i) == f) {
                        //fh就是当前桶位置的哈希值
                        //哈希值大于等于0时说明当前桶下是链表存储或者桶是空
                        //以下操作把数据挂到链表上 不是挂末尾就是替换
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                          //此处表明哈希值一样同时key也一样 是同一个对象 因而是修改操作
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    //获取当前位置的value值
                                    oldVal = e.val;
                             //onlyIfAbsent若为false,则表示key值相同时同意修改原值
                             //否则直接break
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                             //追加操作 没有找到一样的 就找末尾
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                //next指向null就直接插入数据
                                    pred.next = new Node<K,V>(hash, key, value);
                                    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;
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                if (binCount != 0) {
                    //如果binCount >= 8就需要将链表转为红黑树(前提数组长度>64)
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

 jdk1.7中是segment锁,是一个位置可能会锁住多个桶,jdk1.8如图,只锁住当前桶的位置。

在源码中,我们注意到其hash值得计算是用的spread(key.hashCode())方法,接下来来深入了解此方法。

 3、spread方法(散列算法、尽量避免哈希冲突)

进入spread源码

//h即key.hashCode() 直接用哈希值冲突比较大 
static final int spread(int h) {
     return (h ^ (h >>> 16)) & HASH_BITS;
}

//进入HASH_BITS可以看到
static final int HASH_BITS = 0x7fffffff;

//其在二进制下表示  01111111 11111111 11111111 11111111

由putVal源码中得知:索引下标是由(n-1)&hash计算出的

1、为什么n要减1?

( n - 1 ) & hash(此hash为经过spread方法计算出的hash)

假设存在一个int类型的hash,32位如下(随意写)

00000110  00011000  00111010  00001110  ---hash(假设该hash没有经过spread方法)

假设n的长度为16(数组长度 随意假设)

00000000  00000000  00000000 00010000  ---n=16

若此时n&hash可以发现大多数哈希不为0的地方&之后都为0、都没有用,仅与n(数组长度)有关,则发生哈希冲突很严重,因而采取n-1令&计算得到的结果尽可能不同

00000000  00000000  00000000  00001111  ---n-1=15

2、为什么hash要进行一系列右移、异或、与运算(即spread方法)?

此时可以发现若要让hash的高位起作用,则n-1必须非常大才可以。因而我们对hash先进行了无符号右移16位,高位补0,即

00000110  00011000  00111010  00001110 ---原hash

00000000  00000000  00000110  00011000 --- 进行无符号右移16位

再对二者进行异或运算,异或后结果与(n-1)进行与运算  可以发现原来参与不到运算的高位,现在可以参与进来,尽可能的避免哈希冲突

3、为什么(h ^ (h >>> 16)) & HASH_BITS最后要&上这个值HASH_BITS?

可以保证key的hashcode值一定是一个正数!

因为负数有特殊含义(如图),因而必须得是正数。

小结:将key的hashCode值的高16位与低16位进行异或运算,将结果与数组的长度-1进行&运算,得到当前的数据即为要添加数据的索引值。这样做的目的是尽可能打散数据,避免哈希冲突。

4、initTable方法

为了更好的理解这个方法,先对方法中的参数、属性做了解

sizeCtl:
-1:代表map数组正在初始化
小于-1:代表正在扩容
0:表示还没有初始化
正数:若没有初始化,代表要初始化的长度;若已经初始化了,代表扩容的阈值 即临界值



//初始化数组
private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        //判断数组是否已经初始化
        while ((tab = table) == null || tab.length == 0) {
            //sizeCtl赋值给sc,并判断是否小于0
            if ((sc = sizeCtl) < 0)
                //提出释放CPU时间片的请求
                Thread.yield(); 
            //若sizeCtl大于等于0,则以CAS的方式将sizeCtl设置为-1 而-1代表正在初始化
            else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
                try {
                    //要开始初始化了 再次判断(单例模式懒汉的DCL)
                    if ((tab = table) == null || tab.length == 0) {
     //获取数组初始化的长度,如果sc>0,以sc作为长度;如果sc为0,就以默认长度16 
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        //nt就是new出来的数组
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        //table初始化完毕
                        table = tab = nt;
                        //临界值即得到下次扩容的阈值并赋值给sc
                        sc = n - (n >>> 2);
                    }
                } finally {
                    //将sc赋值给sizeCtl
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

 假设多线程A、B进入,线程A刚进入

还没有初始化数组,但此时sizeCtl已经是-1了,因而此时线程B可以进入到while循环并进入if语句, B会提出释放CPU时间片,因而线程A会继续正常初始化,而后线程B会回到while循环条件再一次判断,便直接退出了,因为此时数组已经初始化完毕,可以防止多线程重复初始化操作。

为什么要再次判断数组是否为空或者数组长度是否为0?

假设线程A执行到,并且最后finally中会将sc赋值给sizeCtl,此时sc大于0,若有另一个线程B正好执行到

那线程B也会进入else if语句,再次对数组初始化,这明显是不行的,所以要再次判断数组是否为空即双重锁定检查。 

5、TreeifyBin方法中tryPresize方法

首先,为什么要有红黑树?主要是为了提升查询效率。

//转红黑树                       传的参数是数组table 以及索引i
    private final void treeifyBin(Node<K,V>[] tab, int index) {
        Node<K,V> b; int n;
        if (tab != null) {
        //获取数组长度 并判断是否小于64
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
                //小于64则尝试扩容
                //n左移一位 相当于乘2
                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));
                    }
                }
            }
        }
    }
//这个方法是有可能并发操作的 从别的地方进来可能会先扩容 
//则再进入判断扩容时会发现扩容长度已经小于扩容阈值    
private final void tryPresize(int size) {
        //对扩容数组长度做判断并赋值给c 若大于最大值 则c就取最大值MAXIMUM_CAPACITY
        int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        //否则c就为这个->size + (size >>> 1) + 1,并传入tableSizeFor方法
        //该方法就是将数c转为2的n次幂(若传入的不是2的n次幂)保证数组长度是2的n次幂
            tableSizeFor(size + (size >>> 1) + 1);
        //以上主要就是得到要扩容的数组长度是多少
    
        int sc;
        //拿到sizeCtl,是否大于0 
        while ((sc = sizeCtl) >= 0) {
 //则有两种可能 1、没初始化数组(可能从concurrentHashMap的putAll中直接进入这个方法) 
 // 2、数组已经初始化
            Node<K,V>[] tab = table; int n;
            if (tab == null || (n = tab.length) == 0) {
                n = (sc > c) ? sc : c;
                if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
                    try {
                        if (table == tab) {
                            @SuppressWarnings("unchecked")
                            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                            table = nt;
                            sc = n - (n >>> 2);
                        }
                    } finally {
                        sizeCtl = sc;
                    }
                }
            }
        //若要扩容的长度已经小于扩容阈值 代表扩容已经完成了
        //或者数组长度已经大于等于最大长度  那么不用再管 跳出循环
            else if (c <= sc || n >= MAXIMUM_CAPACITY)
                break;
            else if (tab == table) {
        //得到一个扩容戳(扩容戳是长度32位的数值,其高16位做扩容标识,低16位做扩容线程数!)
        //高16位与数组长度有关系 低16位默认就是2 且为2的时候 代表有一个线程正在扩容
                int rs = resizeStamp(n);
        //代表没有线程扩容 先设置sizeCtl标记 开始扩容
                if (U.compareAndSetInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
            }
        }
    }

通过以上对putVal方法源码的解读,相信大家一定有了更深刻的认识。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

九一973

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

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

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

打赏作者

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

抵扣说明:

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

余额充值