面试连环炮:HashMap线程不安全 怎么解决?你来说说ConcurrentHashMap怎么就安全(万字长文)

面试官:HashMap线程不安全 怎么解决?你来说说ConcurrentHashMap (JDK7)

从构造方法探寻ConcurrentHashMap的数据结构

我们先大概看一张图 让大家先有一个认识 在去通过构造方法深入的分析

在这里插入图片描述

这里 我们要去了解到 ConcurrentHashMap 由一个个的Segment组成 而一个个的Segment由一个个HashEntry组成 每个HashEntry里存放了Key-Value键值对 我们先大概清楚这个结构 然后再去进入源码分析究竟是怎么回事

这个是 ConcurrentHashMap的构造方法 传入了3个参数

 public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;
        // Find power-of-two sizes best matching arguments
        int sshift = 0;
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // create segments and segments[0]
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

这个是ConcurrentHashMap的1.7下的构造方法 我们先了解默认情况下构造方法的三个参数的默认赋值是怎样的

    static final int DEFAULT_INITIAL_CAPACITY = 16;

    static final float DEFAULT_LOAD_FACTOR = 0.75f;
   
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;

我们选取代码块一步步的分析

  	int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }

初始情况下 ssize=1 concurrencyLevel=16

第一次循环 ssize=1<16 进入循环 ssize左移两位变成2;

第二次循环 ssize=2<16 进入循环 ssize左移两位变成4;

第三次循环 ssize=4<16 进入循环 ssize左移两位变成8;

第四次循环 ssize=8<16 进入循环 ssize左移变成16;

此时 ssize变成了16 不小于16 跳出循环

这块代码的目的是去找到一个最小的大于等于concurrencyLevel的2的幂次方数

大于等于16的最小2的幂次方数就是16呀 所以我们找到ssize就是16

同时 心细一点的同学跟着刚才的循环去计算 得到的sshift是4 也就是找到了2^4等于16的这个4


我们往下看下一部分代码

我们先注释掉一部分不去管它

 if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;
       /* if (c * ssize < initialCapacity)
            ++c;*/

        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;

这块代码就要引出ConcurrentHashMap的底层结构了 要认真理解这块

//initialCapacity的默认值是16
//ssize上面我们算出来是16
//我们可以得到下面这个写的不太规范的式子
int c=16/16=1

这里的c是什么呢 ? 这里的c先把他理解成就是Segment数组的大小(其实c的含义是不对的 但我们先这么理解) 我们是根据 initialCapacity和ssize计算出来得

别着急 下面还有完善

 int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;

这个MIN_SEGMENT_TABLE_CAPACITY的**默认值是2(也就是说设计者设计这个Segment数组最小长度就是2) **就是说现在cap=2;c=1;

2不小于1 所以不会进入这个while循环 接着往下

我们这里算的c=1 比设计者设计的Segment数组最小长度还要小 所以我们按cap=2去初始化

在这里插入图片描述

如上图所示 cap指定了每一个Segment可以放几个HashEntry

ssize指定了一个ConcurrentHashMap可以放多少个Segment

现在 我们就可以重新绘制一下这个数据结构图了

这其实就是默认情况下 ConcurrentHashMap的数据结构了

在这里插入图片描述


这里 我们把刚刚注释掉的代码打开 再去分析一波

 int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;

假设 我这里指定了initialCapacity是33

呢我们这个时候33/16 计算出来得c就是2 这个时候就会进入if这里 c就会加1 此时c等于了3

 while (cap < c)
            cap <<= 1;

当代码走到这里 cap=2 c=3 2<3

就会进入while当中 cap左移变成了4 这里实质性的改变就是对应的每一个Segment就会有4个HashEntry

这里说明一下 为什么cap使用了左移 从2变成了4 这是因为设计者要Segment的大小不论是几

都应该是2的幂次方数

此时的数据结构是这样

在这里插入图片描述


ConcurrentHashMap的数据插入原理(put方法)—怎么去保证线程安全

public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
    	//(1)  j是算出来的Segment数组的下标
        int j = (hash >>> segmentShift) & segmentMask;
    	//(2)  通过Unsafe类去取segments数组第j个位置的元素看是不是null
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            //(3) 如果是null 去生成一个Segment对象
            s = ensureSegment(j);
    		//(4) 去调用生成的Segment对象的put方法
        return s.put(key, hash, value, false);
    }

我们把(3)处的ensureSegment方法展开说明一下 去理解他是怎么生成一个Segment对象并保证线程安全的

 private Segment<K,V> ensureSegment(int k) {
        final Segment<K,V>[] ss = this.segments;
        long u = (k << SSHIFT) + SBASE; // raw offset
        Segment<K,V> seg;
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            //这部分代码就是将一开始构造方法生成的ss[0]作为一个原型(雏形)
            //利用ss[0]去初始化我们此时的Segment对象
            //但是真正初始化在下面一个if之后 这里可以理解是做了一个准备工作
            //准备了一些需要的属性 如负载因子啊 cap长度啊
            Segment<K,V> proto = ss[0]; // use segment 0 as prototype
            int cap = proto.table.length;
            float lf = proto.loadFactor;
            int threshold = (int)(cap * lf);
            HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
            
            if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                == null) { // recheck
                //这时候又再次判断该位置有没有其他线程进行了初始化
                //没有其他线程的话   这时候真正去创建一个Segment对象
                //但是这里还没有把Segment对象放到数组对应的位置
                Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    //这里的这个CAS操作真正对第u个位置进行赋值
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;
    }

这个时候问题的关键来了 他在创建Segment对象的过程中是怎么确保线程安全的呢

我们选取这部分代码

 if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            //...省略
          
            if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                == null) { // recheck
               
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    //这里利用cas原子操作真正对数组第u个位置赋值
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;

首先 他运用了一个双重检验判断 这个判断类似单例模式的双重检验判断

public class Singleton {
    private static Singleton instance=null;

    private Singleton(){
    }

    public static Singleton getInstance() {
        if(instance==null){//检查判断1
            synchronized (Singleton.class){
                if(instance==null){//检查判断2
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

(1)当线程A、B同时调用getInstance()方法,他们同时发现 instance == null成立(检查判断 1),同时去获取的Singleton.class锁
(2)其中线程A获取到锁,线程B 处于等待状态;线程A会创建一个SingleTon实例,之后释放锁
(3)线程A释放锁后,线程B 被唤醒,线程B获取到锁,然后线程B检查
instance == null 不成立(检查判断2),不会再创建Singleton实例对象

第二 也是更重要的是 他在马上要赋值的时候 利用了CAS这个原子操作

CAS这个原子操作是不能被中断的 我们这里简单谈一下CAS干了什么

CAS就是先获取主物理内存中的值作为期望值 然后我们再去获得此时主物理内存的真实值 如果期望值与真实值一致 我们就进行修改 否则 就一直取值比较 直到成功

所以每到一个关键节点 他就会做一次关于线程安全的判断 利用了双重检查机制 也利用了CAS思想

总结一下 这个方法干了什么事情 也就是在new 一个Segment对象的时候保证了线程安全

在这里插入图片描述


我们把(4)处的Segment对象的put方法展开说明一下

	//(4) 去调用生成的Segment对象的put方法
        return s.put(key, hash, value, false);

我们去继续学习这个Segment对象的put方法

这里我们先去抽象出一个数据结构 也就是说Segment内部维护的一个个HashEntry整合起来 就好像一个小的HashMap一样 也是数组+链表的形式(Segment内部就像一个小的HashMap)

在这里插入图片描述

我们先不去看加锁的逻辑 我们先把中间怎么put数据的流程大致理解清楚

 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                //取tab数组 下表为index的值作为first
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    //链表的头结点不为空的情况
                    if (e != null) {
                        K k;
                        //遍历当前位置的链表
                        //判断传入的key 和当前遍历的 key 是否相等,相等则覆盖旧的 value
                        //这里根HashMap的逻辑很像
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    //为空的情况
                    //情况1 头结点为空 把key-value放在头结点
                    //情况2 遍历完整个链表 然后头插法插入
                    else {
                        if (node != null)
                            node.setNext(first);
                        else
                            //生成了一个HashEntry对象 记为node
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        //如果超过阈值 就rehash
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            //没有超过阈值 就把刚刚生成的node通过setEntryAt这个方法放进去
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

我们上面初步分析了一下 put添加数据的过程

下面我们重点分析一下加锁的过程

HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);

首先 我们去理解 scanAndLockForPut这个方法干了什么事情

我们这里要做一个小的铺垫

trylock()
lock()

trylock()这个方法 如果能够获取到锁 就会立马返回一个true

trylock()这个方法 如果获取不到锁 就会立马返回一个false

trylock()不会阻塞

lock()这个方法如果获取不到锁 就会一直阻塞在这里

这个scanAndLockForPut方法大概干了什么事情 我给大家解释一下

当trylock()获取不到锁的时候 通过刚刚我们的铺垫我们知道trylock()是不会阻塞的

那我们不能傻傻的等在这里 我们既然不会阻塞 我们在这个过程中可以准备一些什么事情呀?

这个过程就好比做饭 你在烧水等水开的过程中 可以去准备个凉菜 算是合理安排 提高效率

我们这里的合理安排就是根据key-value去new一个HashEntry 我们把这个HashEntry记成node
在这里插入图片描述

这里我们就把scanAndLockForPut这个方法做的事情给大家大致说明白了

scanAndLockForPut这个方法本身就设计的非常精妙 由于篇幅的原因就不在展开描述

之后会有更加详细的说明解释

 tryLock() ? null : scanAndLockForPut(key, hash, value);

这里不是有个三目运算符吗 trylock()获取不到锁的时候 就会走scanAndLockForPut这个方法准备一个node对象出来

第二 我们理解一下这里的保证线程安全 为什么用了lock

在这里插入图片描述

如上图所示 我们根本的目的是把key-value放进去

我们要在链表当中去插入元素 注意是插入元素 这个时候CAS就没有更好的办法了 因为CAS对某一个具体的位置赋 值还是可以的 但是让CAS去插入是不能实现的 所以这个插入时候我们为了保证线程安全 就要去加锁

这里保证线程安全的方法很实在 就是加了一把锁 让同一时间只有一个线程去put数据


我们总结一下jdk7 下 ConcurrentHashMap是怎么保证并发安全的

第一 在进行一些链表的插入数据时 用了ReentrantLock去加了一把锁

第二 用了UNSAFE的各种方法 这其中包括了我们最熟悉的CAS 还有UNSAFE类的一些其他方法呀

​ 比如 UNSAFE.putOrderedObject等等



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章