让你吃透ConcurrentHashMap的细节

·  阅读 860

ConcurrentHashMap源码解析

我们在不需要保证线程安全的情况下,一般会使用HashMap来存储键值对。但是,HashMap在多线程情况下是线程不安全的,那么我们要使用线程安全的HashMap,就有这三种方法:

使用HashTable,HashTable是可以保证线程安全的。但是,HashTable的实现是在几乎每一个方法上都加了一个synchronized关键字,相当于是给真个Hash表加了一把锁,那么这种方式虽然实现了线程安全,但是并发效率十分低下。

使用Collections.synchronizedMap()将线程不安全的Map转化为线程的Map。实际上这种方式的最终实现也是在方法上加上synchronized关键字,最终的实现是和HashTable类似的,所以效率也比较低。

使用ConcurrentHashMap。ConcurrentHashMap是HashMap的线程安全版本,并且它的并发效率也要比HashTable要好的多。

  • 在JDK1.7的版本,ConcurrentHashMap的实现是基于分段锁+Unsafe+ReentrantLock实现的。
  • 而在JDK1.8版本中,是基于synchronized+Unsafe(其中包含CAS)来实现的。

JDK1.7中的ConcurrentHashMap

JDK1.7中的currentHashMap是通过分段锁+Unsafe类+RenterantLock实现的。在JDK1.7的ConcurrentHashMap中,存在一个Segment数组,而每一个Segment数组中又有一个HashEntry数组的引用。HashEntry数组是一个hash表,这个hash表中的每一个桶都可以保存一个链表。所以,每一个Segment对象其实就是一个JDK1.7中的HashMap.如图所示:

image.png

而Segment类又是继承于Lock接口的实现类ReenterantLock可重入锁。所以Segment数组中的每一个Segment对象都是一个可重入锁。但一个线程要访问这个Segment对象下面的HashEntry数组时,那么就需要拿到这Segment对象的锁,才能够对这个HashEntry数组进行修改。值得一提的是,如果一个线程第一时间没有获取到锁,那么这个线程也不会立即就进入到阻塞状态,而是会尝试自旋一定的次数,并且提前做一些工作,这个线程才会挂起,这个在后面会详细说明。 可以看到,在这样将数据分段加锁的情况下,访问不同段的线程获取的是不同的锁,他们之间是不存在竞争关系的。所以,这种方式相较于HashTable的加锁方式,锁的粒度是下降了,锁冲突的概率变小了,并发性能得到了提高。

ConcurrnetHashMap继承于AbstractMap抽象类,实现了ConcurrentMap接口。

一些属性

//所有段中的的HashEntry数组的总长度
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认的负载因子,是final修饰的,所以ConcurrentHashMap的负载因子是不可以指定的
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//并发级别,跟Segment数组的长度有关
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//HashEntry数组的最大总长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//每个Segment中HashEntry数组的最小长度
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//最大的segment数组的长度
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
//没有获取到锁时自旋的重试次数
static final int RETRIES_BEFORE_LOCK = 2;


复制代码

构造方法

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;
    //这个变量用于保存segment数组的容量
    int ssize = 1;
    //根据并发级别生成segment数组的容量
    while (ssize < concurrencyLevel) {
        ++sshift;
        //每次循环都左移一位,等价于乘以2,保证segment数组是2的n次方
        ssize <<= 1;
    }
    //这是为了散列分布更加均匀的偏移量
    this.segmentShift = 32 - sshift;
    //数组长度减1,就是length-1。i = hash & (length - 1)得到下标,是一个掩码
    this.segmentMask = ssize - 1;
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    //用总容量除以segment数组的个数,那么就得到每个segment里面的HashEntry数组的大小
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        //每次循环都左移,保证HashEntry数组的长度是2的n次方
        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数组                     
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    //使用Unsafe类的方法将segment数组的第一个元素赋值为s0
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}
复制代码

在这个构造方法中,我们可以了解到:

  1. concurrent_level并发级别决定segment数组的长度。segment数组的长度是大于并发级别的最小的2的n次方。
  2. HashEntry数组的长度也一定是2的n次方。
  3. 在构造方法中会初始化一个segment数组,并且这时只有数组的第一个元素是存在对象的,其余位置的值都是null,会在后面使用到那些位置的时候再初始化segment对象,这是一种延时初始化的思想。

并且,在这个方法中使用到了Unsafe类,为什么要叫做Unsafe呢?

因为在这个类中定义了硬件级别的许多直接操作内存的原子方法,在这个类中也有许多CAS的实现方法,程序员在编码的时候是不应该调用这个类中的方法的,这个类是在java的API中被调用的。上面地putOrderObject方法表示的就是在ss对象中偏移量为SBASE的地方写入s0。表示的就是将segment数组的第一个对象赋值为s0。

内部类HashEntry

static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;

    HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    
    final void setNext(HashEntry<K,V> n) {
        UNSAFE.putOrderedObject(this, nextOffset, n);
    }

    // Unsafe mechanics
    static final sun.misc.Unsafe UNSAFE;
    static final long nextOffset;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class k = HashEntry.class;
            nextOffset = UNSAFE.objectFieldOffset
                (k.getDeclaredField("next"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}
复制代码

根据HashEntry代码,我们可以了解到:

  1. key和hash都是final修饰的,是不应该改变的,如果改变,就可能导致数据存储错误
  2. value和next都是volatile修饰的,意味着value和next的值一旦发生改变,那么是对所有线程可见的。同样的是,Segment中的数组也是volatile修饰的。 需要注意的是:使用volatile修饰的变量不一定就是线程安全的。因为volatile只保证可见性和有序性,但是不保证原子性。所以,如果用volatile修饰的变量对它的操作都是原子性的,那么这个变量就是线程安全的。

ConcurrentHashMap的put方法

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    //通过一系列操作,得到一个hash值,目的是为了减少hash冲突    
    int hash = hash(key);
    //hash值右移,让高位参与运算,也是为了减少hash冲突
    int j = (hash >>> segmentShift) & segmentMask;
    //通过下标与偏移量直接查找segment数组上的对象
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        //如果不存在,创建一个segment对象 
        s = ensureSegment(j);
        //调用segment对象的put方法
    return s.put(key, hash, value, false);
}
复制代码

可以看到,如果value = null的话,直接就抛出空指针异常(NullPointException),说明在JDK1.7的ConcurrentHashMap中,value的值是不可以为null的

put方法的流程大致为:

①通过key计算出hash值,这是一个复杂的操作,但目的就是为了减少Hash冲突。

②通过得到的hash值与掩码segmentMask进行“&”运算,得到segment数组的下标,知道要访问的是哪一个segment。(segmentMask是在上面的构造方法中复制的,等于segment数组长度减1)

③判断对应的下标位置有没有segment对象。因为早构造方法中虽然创建了一个segment数组,但是只有下标为0的位置才有对象。如果没有对象,那么就调用ensureSegemnt方法在数组的对应位置创建一个segemnt对象。这个方法的底层是通过自旋CAS的方式来设置的,确保最后这个下标对应的位置是有对象的。

④最终元素是要插入到HashEntry数组上面的,所以就要调用segment对象的put方法。

Segment对象的put方法

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    //尝试获取锁
    HashEntry<K,V> node = tryLock() ? null : //获取成功,结点为null
        scanAndLockForPut(key, hash, value); //获取失败,调用这个方法
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        //定位数组下标
        int index = (tab.length - 1) & hash;
        //得到链表第一个结点
        HashEntry<K,V> first = entryAt(tab, index);
        //遍历链表
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                //如果找到一个重复的key,覆盖
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            //如果没有找到重复的key
            else {
                //如果node不是null,直接插入在链表的头部
                if (node != null)
                    node.setNext(first);
                //如果是null,创建一个node结点
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                //判断是否需要扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    //扩容
                    rehash(node);
                else
                    //头插,并将插入的结点放入桶中
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        //释放锁
        unlock();
    }
    return oldValue;
}
复制代码

对于Segment对象的put方法的流程:

①首先尝试获取锁,获取成功把node赋值为null,继续执行;如果获取锁失败,会调用scanAndLockForPut方法。也就是说,如果一个线程没有立即获取到锁,不会立即就阻塞,而是先要做一些操作,再进行阻塞,具体的实现后面会讲。

②在获取到锁后,通过hash值 & 数组长度减1,得到下标。

③得到下标后获取这个下标锁对应的桶的元素,遍历这个链表。

  1. 如果发现了一个重复的key,那么就覆盖它的value。
  2. 如果遍历到链表的尾部都没有发现到一个重复的key,那么就把node插入到链表的头部,然后判断是否需要扩容,需要扩容就调用rehash方法进行扩容。如果不需要,把新插入的结点放入桶中。 ④最后释放锁,返回旧的value。

在这个方法中我们可以看到,获取锁获取的就是每一个segment对象的锁,你要操作的数据在哪一个segment对象上,就获取那一把锁。操作不同的segment对象上的数据是不阻塞的。而且JDK1.7的ConcurrentHashMap使用的是头插法

ScanAndLockForPut

我们已经知道了,在发生锁冲突的时候,一个线程没有获取到锁时不会立即进入阻塞状态,而是要预先执行一些操作,这些操作在这个方法中:

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    //定位要操作的数据在hash表的那个桶中
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    //重试次数
    int retries = -1; // negative while locating node
    //尝试自旋的获取锁
    while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
        //在这个if语句中,遍历链表
        if (retries < 0) {
            //如果遍历到链表尾部都没有找到一个重复的key,那么就new一个node
            if (e == null) {
                if (node == null) // speculatively create node
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            //如果找到一个重复的key,那么就把retries = 0,这是retries表示的就是重试次数
            else if (key.equals(e.key))
                retries = 0;
            else
                e = e.next;
        }
        //运行到这里,如果需要预先创建结点,那么就已经创建好了,
        //如果自旋获取锁的次数达到了上限
        else if (++retries > MAX_SCAN_RETRIES) {
            //调用lock方法,如果不能获取锁,就加入到AQS同步队列中
            lock();
            //跳出循环
            break;
        }
        //每自增两次,检查头结点是否改变
        else if ((retries & 1) == 0 &&
                 (f = entryForHash(this, hash)) != first) {
            //如果头结点改变,把retries赋值为-1,表示需要遍历结点判断是否需要创建结点
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    //返回创建的结点
    return node;
}
复制代码

这个方法的大致流程是:

①先定位到要要访问或者是操作的桶。定义了一个redtries变量,这个变量一开始为-1,为-1时表示还在判断是否需要预先创建结点。

②自旋的获取锁。如果获取锁失败,并且retries = -1,那么就会遍历链表,判断是插入操作还是覆盖操作。如果是插入操作,那么就预先创建一个结点。把retries变量赋值为0,此时这个变量表示的就是重试次数。

③在遍历链表之后,每重试两次就会判断头结点是否改变,如果改变,那么就重新遍历结点,判断这时是否需要预先创建结点,因为重复的key的结点可能会被删除。

④重复这个过程,直到重试次数达到上限。

可以知道,ScanAndlocKForPut方法就是让没有获取到锁的线程自旋的尝试获取锁,在自旋的过程中会给需要创建结点的线程预先创建结点,那么等到获取锁的时候就不需要创建结点了。这种预先创建的方式也在一定程度上提高了并发效率

rehash(扩容)

JDK1.7的ConcurrentHashMap的扩容是只针对HashEntry数组的,Segment数组在初始化之后就无法扩容

private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    //扩容为原来的两倍
    int newCapacity = oldCapacity << 1;
    threshold = (int)(newCapacity * loadFactor);
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    int sizeMask = newCapacity - 1;
    //遍历HashEntry数组
    for (int i = 0; i < oldCapacity ; i++) {
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            int idx = e.hash & sizeMask;
            if (next == null)   //  Single node on list
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                /*
                这部分逻辑是遍历链表,找到最后的一组重新定位在同一个数组下标的元素
                
                **/
                HashEntry<K,V> lastRun = e;
                int lastIdx = idx;
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                //把这段元素的第一个元素放到新位置,那么后面的元素也等于放到了新位置
                newTable[lastIdx] = lastRun;
                // Clone remaining nodes
                //然后从头开始遍历,把每个元素一个一个的插入到新位置的链表头部
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    //在新的数组加上新插入的结点
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}
复制代码

大致流程是:

①先创建一个容量为原容量两倍的数组,把扩容阈值也变成原来的两倍。

②遍历HashEntry数组

  1. 如果桶中没有元素,跳过
  2. 如果桶中只有一个元素,把这个元素重新定位,hash & (length - 1).
  3. 如果有一个链表,那么找到最后一段重新定位在同一个桶中的第一个结点,把这个结点加入到新数组的对应桶中

未命名文件.png

  1. 最后将剩下来的元素遍历,一个一个的放入到新数组中。上面就是将k=1的结点放入到新数组中。 这个方法是在put方法中调用的,也是加了锁的。

size方法

public int size() {
    // Try a few times to get accurate count. On failure due to
    // continuous async changes in table, resort to locking.
    final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    //每个HashEntry数组修改次数的和
    long sum;         // sum of modCounts
    //上一次修改次数的和
    long last = 0L;   // previous sum
    //第一次迭代,不是重试
    int retries = -1; // first iteration isn't retry
    try {
        //自旋重试
        for (;;) {
            //表示达到重试次数,就上锁
            if (retries++ == RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    //求segment的修改次数的总和
                    sum += seg.modCount;
                    //得到每个segment的个数
                    int c = seg.count;
                    
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            //判断这一次的修改次数和是不是等于这一次的修改次数和,就是判断有没有被修改
            if (sum == last)
                break;
            //把这一次的和记录下来
            last = sum;
        }
    } finally {
        //释放锁
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}
复制代码

size方法使用了乐观锁的思想,得到总的修改次数,把这个值记录下来。然后再次遍历,又得到一个修改次数,判断这两个值是否相等。相等,就说明没有线程修改,得到的值是准确的。如果不相等,那么值不准确,重试。重试到一次次数就会给每个segment都加锁,再统计。

get方法

public V get(Object key) {
    Segment<K,V> s; 
    HashEntry<K,V>[] tab;
    //得到hash值
    int h = hash(key);
    //得到这个对象在数组上的偏移量
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    //通过Unsafe方法得到segment对象
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        //同理得到hashEntry表上桶中的对象
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            //判断是否存在要获取的对象
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}
复制代码

get方法流程:

①获取hash值,定位到对应的segment对象

②通过Unsafe类的getObjectVolatile方法并发安全的获取segment对象。如果获取的值为null,那么直接返回null。

③通过hash值定位到hash表中的桶,同样通过getObjectVolatile方法获取桶中的结点,如果为null,直接返回。如果不为null,遍历链表,如果存在返回对应的value。不存在,返回null。

这个方法是不加锁的,主要Usafe类中的方法来保证线程安全地获取数据。而且HashEntry数组、结点中的value,next都是volatile修饰的,所以只要其他线程一修改,那么对于所有线程都是可见的,保证获取的是最新的值。

clear方法

ConcurrentHashMap的clear方法

public void clear() {
    final Segment<K,V>[] segments = this.segments;
    for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> s = segmentAt(segments, j);
        if (s != null)
            s.clear();
    }
}
复制代码

Segment的clear方法

final void clear() {
    lock();
    try {
        HashEntry<K,V>[] tab = table;
        for (int i = 0; i < tab.length ; i++)
            setEntryAt(tab, i, null);
        ++modCount;
        count = 0;
    } finally {
        unlock();
    }
}
复制代码

可以看到,我们调用clear方法,它的实现是一个一个地对segment加锁,再清除。那么这时就会产生一个问题:如果刚刚清除了一个segment上面的所有数据,此时释放这个segment的锁。但是立马又有另一个线程在这个segment里面添加了一个数据,那么我们以为没有数据了,但实际上还是存在数据的。这种情况下就体现了ConcurretnHashMap的弱一致性。其实不止clear方法,在迭代遍历ConcurrentHashMap的时候也是一个Segment一个Segment的遍历的,也会提现出弱一致性的问题。

总结

JDK1.7的ConcurrentHashMap是通过分段锁+Usafe类+ReenterantLock实现的,每一段数据由一把锁锁住,操作不同数据段的线程不存在锁冲突,提高了并发的效率。

每当想要在这个容器中添加或者删除数据时都需要先获取到对应的segment锁,进行数据操作之后,再将锁释放掉。如果一个线程没有获取到锁,也不会立马就挂起。而是先自旋尝试获取锁,如果是put操作,还会尝试预先创建结点,那么在获取锁之后就不用创建结点了,这也是为了提高并发效率而做出的一点小优化。在自旋达到一定的次数时候,就会停止自旋,将线程挂起。

get方法是没有上锁的,通过volatile的内存语义以及Unsafe类的方法来并发安全的获取到数据。

size方法采用的是类似于乐观锁的方式,先不加锁,再最后判断修改次数有没有改变,如果发现有线程修改,那么就重新统计。如果重试了一定次数还没有成功,那么就加锁。

clear是遍历segment数组,对每个segment轮流加锁。迭代遍历也是类似,他们会体现弱一致性。

JDK1.8中的ConcurrentHashMap

在JDK1.8中,ConcurrentHashMap发生了比较大的变化。首先是数据结构的改变,JDK1.8中的ConcurrentHashMap不再存在Segment数组和HashEntry数组两级Hash表,只存在一个Hash表,和JDK1.8HashMap的数据结构一致。不仅数据结构发生了变化,实现线程安全的方式也发生了变化。在JDK1.8中,不再使用分段锁+Unsafe类+ReentrantLock的方式。而是通过synchronized+Unsafe的原子操作(CAS)的方式加锁,并且加锁不在是一个HashEntry数组共用一把锁,而是给Hash表中的每个桶都加一把锁,这把锁就是在这个桶中的元素的对象锁。其实这种加锁方式也可以看做是分段锁。

这样一来,JDK1.8ConcurrentHashMap的实现比JDK1.7的实现并发效率又有提高。

  1. 首先,锁的粒度进一步变小了,锁冲突的概率小,并发性能得到提高。
  2. 其次,synchronized关键字的加锁模式又分为偏向锁、轻量级锁和重量级锁。JDK1.7中的加锁模式类似于重量级锁。也就是说,当加锁模式在偏向锁或者轻量级锁的时候,JDK1.8中的实现要比JDK1.7中的实现快很多。

重要的属性

//底层数组,是volatile修饰的
transient volatile Node<K,V>[] table;

//扩容时的新数组,也是volatile修饰的
private transient volatile Node<K,V>[] nextTable;

//用来统计结点个数
private transient volatile long baseCount;

/*
    ConcurrentHashMap中比较重要的属性,主要用于并发控制和状态标记
        1.在指定初始化容量时,会保存初始化容量
        2.如果 sizeCtl = 0,表示正要初始化数组,还没有线程初始化
        3.sizeCtl = -1,表示有线程正在初始化数组
        4.表示扩容阈值
        5.sizeCtl < -1,表示当前线程正在扩容。
*/
private transient volatile int sizeCtl;

//这个属性在扩容时数据转移时使用,表示的是这个索引前面的数组元素还没有分配线程进行转移。
private transient volatile int transferIndex;

//是counterCells的标记位,类似于一把锁,表示counterCells正在被访问。
private transient volatile int cellsBusy;

//用于计数,每个线程都会分配到一个CounterCell,里面保存了线程对hash表的添加数据的个数
private transient volatile CounterCell[] counterCells;

//初始化容量
private static final int DEFAULT_CAPACITY = 16;

//最大长度
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

//默认并发级别
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

//负载因子,是final修饰的,已经指定为0.75,不可以再指定。
private static final float LOAD_FACTOR = 0.75f;

//树化阈值
static final int TREEIFY_THRESHOLD = 8;

//解除树化阈值
static final int UNTREEIFY_THRESHOLD = 6;

//最小树化容量
static final int MIN_TREEIFY_CAPACITY = 64;

//最小传输步长,后面会讲到
private static final int MIN_TRANSFER_STRIDE = 16;

//结点的hash值,如果Hash值是-1,表示这个结点是forwarding结点,表示这个数组正在扩容。
static final int MOVED     = -1; // hash for forwarding nodes

//表示一颗红黑树,
static final int TREEBIN   = -2; // hash for roots of trees
复制代码

以上就是一些比较重要的属性,具体在使用到的时候回再次说明。

构造方法

无参的构造方法方法体是空的,只是创建了一个对象,没有初始化数组,数组也是等到使用到的时候再初始化,也是一种延时初始化的思想。

有参的构造方法。

public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (initialCapacity < concurrencyLevel)   // Use at least as many bins
        initialCapacity = concurrencyLevel;   // as estimated threads
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        MAXIMUM_CAPACITY : tableSizeFor((int)size);
    this.sizeCtl = cap;
}
复制代码

构造方法比较简单,就是得到初始化容量,这个初始化容量一定是2的n次方,原理和HashMap保证初始化容量的原理是一样的。并且将初始化容量保存在sizeCtl属性中。sizeCtl属性和HashMap的threshold变量类似,不过它的功能更加强大。

put方法

final V putVal(K key, V value, boolean onlyIfAbsent) {
    //key和value都不能为null
    if (key == null || value == null) throw new NullPointerException();
    //计算出hash值
    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();
        //如果定位到的桶中没有数据,那么直接使用CAS的方式将结点插入
        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
        }
        /*
            如果定位到的桶中的结点hash值是MOVED=-1,表示正在扩容,并且已经将这个桶中的数据
            转移到新数组了
        */
        else if ((fh = f.hash) == MOVED)
            //当前线程帮助扩容
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            //使用synchronized加锁,锁对象就是桶中的第一个结点对象
            synchronized (f) {
                //判断在加锁的过程中桶中第一个结点有没有改变,相当于双重检测机制
                if (tabAt(tab, i) == f) {
                    //结点的hash值大于0,说明这是一个链表结点
                    if (fh >= 0) {
                        binCount = 1;
                        //遍历链表,binCount最终会变成遍历过的结点个数
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            //如果找到重复的key,覆盖
                            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;
            }
        }
    }
    //类似于size++操作,然后还要判断是否需要扩容
    addCount(1L, binCount);
    return null;
}
复制代码

put方法大致流程:

①先判断插入的key或者value是否为null,如果有一个为null,那么就会抛出NullPointException。也就是说JDK1.8的ConcurrentHashMap key和value都不能为null。这是和JDK1.7不同的点,JDK1.7是value值不能为null

②使用自旋的方式进行插入,保证一定会插入成功(for()死循环。)。判断底层数组是否为null,如果为null,那么就调用initTable方法初始化数组。这个方法后面会讲到。

③通过hash&(length-1)找到对应的桶位,如果这个桶中没有任何数据,那么就使用CAS的方式将结点插入到这个位置。如果发生了线程竞争的话,使用CAS操作只会有一个线程成功

④如果定位到的桶中的结点是一个forwarding结点,也就是这个结点的hash值为-1.那么就说明这个hash表正在扩容转移数据,当前线程就会调用helpTransfer方法帮组扩容。也就是说,在JDK1.8中的ConcurrentHashMap是支持多线程对同一个数组扩容的。实际上也可以说JDK1.7中的ConcurrentHashMap支持多线程扩容,但是线程是对不同HashEntry数组扩容。

③如果定位到的桶位有数据,那么使用synchronized关键字上锁。锁对象就是桶中的第一个结点对象。然后就会通过结点的hash值判断结点是什么结点。

  1. 如果hash值大于0,表示是一个链表结点。那么就遍历链表,同时使用binCount记录遍历结点的个数,如果发现存在重复的key,那么就覆盖这个结点。否则,就把结点插入到链表的尾部。JDK1.8中ConcurrentHashMap使用的是尾插法
  2. 反之,判断是不是TreeBin结点。如果是的话,将值添加到红黑树结点中(存在就覆盖,不存在就添加)。值得注意的是:在JDK1.8中,有两种树结点。一种是TreeBin结点,一种是TreeNode结点。一般来说,如果一个桶中是一颗红黑树,那么这个桶中的第一个结点就是TreeBin结点,其余结点就是TreeNode结点。TreeBin结点中并不存放数据,它指向红黑树的根节点,也就是说指向一颗红黑树
  3. 那么为什么我们要多一个TreeBin结点呢?如果没有TreeBin结点,红黑树的根节点就是第一个结点。那么我们在往红黑树中插入结点时,红黑树会通过旋转、变色等操作调整平衡,根节点就可能因为旋转操作而发生改变。代码中又是获取第一个结点的锁对象来保证这个桶上的树的线程安全。如果第一个结点发生改变,那么有其他线程获取到这个结点的锁,线程就会不安全。 ④如果是链表,那么就要判断遍历的结点个数是不是大于树化阈值,如果大于,那么就调用树化的方法。(但要先判断数组容量是不是大于64,如果小于,会先扩容)

⑤调用addCount方法,将size++。并且在这个方法还会判断是否要扩容。

initTable方法

initTable方法是初始化底层数组的方法。

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    //判断数组是不是为null,是null就循环,直到不为null。
    while ((tab = table) == null || tab.length == 0) {
        //此时sizeCtl<0,让出cpu时间片
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        //否则,使用CAS操作将sizeCtl修改为-1    
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    //初始化数组
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    //sc = 3/4 * n,表示扩容阈值
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}
复制代码

方法流程:

①判断链表是不是为null。如果为null,那么就循环地创建,直到不为null。

②如果sizeCtl<0,表示有线程已经通过CAS操作将sizeCtl修改为-1,这个线程将会初始化数组。那么其他线程将会让出他们的cpu时间片,尽可能的让初始化数组的线程抢到更多的cpu时间片,提高初始化数组的效率。

③如果sizeCtl不小于0.说明此时还没有线程初始化数组,那么就会通过CAS操作将sizeCtl修改为-1,修改成功的线程初始化数组。最后将扩容阈值保存在sizeCtl中。其他线程继续循环。

但是,在线程过多的情况下,也可能会导致cpu占用过高的情况。因为很多线程都在这里循环,不阻塞。

addCount

这个方法是让size+1的方法,加1后也会判断是否需要扩容。因为是在并发环境下,会有很多线程添加数据,就会有很多线程需要size+1.为了并发的线程安全,所有都必须要排队修改这个size,效率就会很低,所以在ConcurrentHashMap中对这种情况做了一些优化:

还是定义一个共享变量baseCount,如果线程插入了一条数据,那么可以通过修改这个变量来达到加一的操作,也可以不修改这个这个变量。java还给我们提供了一个CounterCell(计数格)数组,这个数组就是一个hash表,每一个线程都可以定位到一个数组中的一个CounterCell对象,这个对象里面仅有一个value属性。当线程插入数据需要将结点数加1时,可以定位到对应的CounterCell对象,将这个对象中的value+1。当需要统计ConcurrentHashMap中有多少结点时,那么就可以遍历CounterCell数组,将其中的value全部相加,最后再加上baseCount的值,就是Map中的结点个数。

使用这种方法,我们在让结点数+1时就不用全部访问baseCount了,也可以访问CounterCells数组,不同的线程访问不同的CounterCell对象,减少了锁的冲突,提高了并发的效率。

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    //判断counterCells数组是不是为null,如果为null,修改baseCount的值
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        //如果as不为null或者修改baseCount失败
        CounterCell a; long v; int m;
        boolean uncontended = true;
        //如果as为null或者修改CounterCell失败
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            //执行这个方法,就是完成结点+1的操作
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        //统计结点的个数    
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        //判断结点是不是需要扩容,需要扩容,进入循环。
        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);
            }
            //把sizeCtl改成rs左移16位加上2.得到的是一个负数。
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}
复制代码

总结一下addCount方法的流程:

①先是要让结点的个数加1,那么就有两种加1的方式。如果CounterCell数组存在,那么就用CAS的操作加到这个数组中。如果不存在,那么就用CAS的操作更新baseCount(先尝试在数组中加,因为冲突更少)。如果更新失败,那么就调用fullAddCount方法,这个方法会保证这个加1操作执行成功。

②在加一操作之后,统计map中的结点个数,如果发现需要扩容,那么就进入到扩容的逻辑。为此次扩容生成一个扩容标记,这个扩容标记仅仅跟当前的容量有关,主要用来标识是那一次扩容。多个线程来进行扩容,首先使用CAS操作将sizeCtl修改为(rs << RESIZE_STAMP_SHIFT + 2),这是一个负数。修改成功的线程先去初始化扩容时的新数组,并开始扩容。

③如果其他线程没有CAS成功,或者sizeCtl为负数,判断是否扩容完成,扩容完成就退出循环。没有扩容完成就加入扩容,那么就尝试将sizeCtl+1,调用transfer方法进行扩容。

④在完成transfer方法的调用以后,统计map中的结点个数,因为是在多线程环境下,可能刚刚完成扩容,map中的元素个数又达到了扩容阈值,又需要进行扩容,所以这里是一个while循环判断是否需要扩容。

在这个方法中,第一个扩容的线程将sizeCtl复制为扩容标志rs左移16位再加2。其他线程在进入扩容时都是将sizeCtl+1。那么这个sizeCtl高16位保存的就是扩容的标志,低16位就是扩容的线程数+1(因为第一个线程进入扩容时加2)。

transfer方法

transfer方法是ConcurrentHashMap扩容和转移数据的方法。因为ConcurrentHashMap容器一般使用在多线程的环境下,所以如果有一个线程在扩容的时候,是不可以操作数据的。但是其他线程等待并发效率比较低,那么就会让正在操作这个容器中数据的线程参与扩容。从后往前每个线程分配Hash表中的一段数据,让它来进行这一段数据的转移,这一段数据的长度一般就是一个步长,最小是16。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    //得到一个步长
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range‘’
    //如果新数组还没有初始化,初始化新数组,一般是由第一个线程来做的    
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            //扩容为原来的两倍
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        //这个元素表示的是从后往前分配,分配到了那个下标了,从n开始
        transferIndex = n;
    }
    int nextn = nextTab.length;
    //创建一个forwarding结点
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    //advance,表示是否向前遍历
    boolean advance = true;
    //数组中的数据是否全部分配完毕
    boolean finishing = false; // to ensure sweep before committing nextTab
    //自旋
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            //i在这里改变,如果i大于这个步长中的边界值,表示这个步长还没有处理完
            if (--i >= bound || finishing)
                advance = false;
            //判断是不是所有的数据都分配完了    
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            //使用CAS操作修改transferIndex的值,那个线程修改成功,那个线程就负责转移这一个步长的数据
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                //这一段步长的边界                   
                bound = nextBound;
                //开始转移的下标
                i = nextIndex - 1;
                //先跳出循环,转移数据
                advance = false;
            }
        }
        //i<0不一定就全部转移成功,而是表示已经全部分配完成
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            //判断是否全部转移成功
            if (finishing) {
                nextTable = null;
                //将引用指向新数组
                table = nextTab;
                //sizeCtl保存扩容阈值
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            //转移成功的线程把sizeCtl-1,表示正在扩容的线程少1
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                //如果 sizeCtl = rs << RESIZE_STAMP_SHIFT + 2,表示只剩下最后一个线程,
                //这个线程也扩容完成
                
                //把finishing设置为true
                finishing = advance = true;
                //重新检查,看还有没有没有处理的,顺便把空节点都放置一个fwd结点
                i = n; // recheck before commit
            }
        }
        //如果遍历到的桶为null,那么就在这里放一个forwarding结点
        else if ((f = tabAt(tab, i)) == null)
            //如果成功advance为true,进入循环,转移前面一个结点
            advance = casTabAt(tab, i, null, fwd);
        //如果已经被转移,那么也转移前面一个结点    
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        //如果有结点    
        else {
            //上锁
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    //如果是链表结点
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        //这个循环找到定位在同一个桶中的最长链表后缀
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        //要么放在原来的位置
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        //放在 原来位置+老数组长度 的位置
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        //遍历剩下的结点
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                //头插
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        //把它放在对应的位置
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    //如果是树
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        //遍历所有结点
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            //放在原来的位置    
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            //放在高位
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        //判断是否要树化
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        //放置到对应的位置    
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}
复制代码

扩容方法的大致流程:

①第一个扩容的线程创建新数组,为原容量的2倍。

②每个线程来扩容的时候,都会被分配Hash表中的一段数据,让这个线程把这一段数据从老数组转移到新数组中。

③如果遍历到了空桶,那么通过CAS操作在这个桶中放一个forwarding结点。其他操作map的线程看到一个forwarding结点就会来帮组扩容

④如果遍历到了forwarding结点,跳过这个结点,处理前一个结点。

⑤如果这个桶中存在需要转移的结点,使用synchronized关键字拿到第一个结点的对象锁。判断是链表还是红黑树。如果是链表,先找到定位到同一个桶中的最长链表后缀。再遍历剩下的结点,使用头插法将结点插入到和它定位在同一个桶中的链表上。最后将链表放在桶中。

⑥如果是红黑树,遍历所有结点,把结点放在和它定位在同一个桶中的链表上。遍历完成后,判断要不要转化成红黑树。最后将数据放入桶中。

⑦如果一个线程把自己要处理的步长转移完了,那么就会去重新尝试获取步长。如果还有数据没有转移,即transferIndex > 0,那么就重复上面的过程。如果 transferIndex <= 0,即hash表分配完了。那么这个线程就会退出扩容,将sizeCtl减1。通过transferIndex这个共享变量来保证给每个线程分配到不同的步长

⑧如果sizeCtl等于 rs << 16 + 2(扩容标志左移16位加2)。那么就说明只剩下最后一个线程了。如果这个线程扩容成功,就会将引用指向新数组,sizeCtl赋值为扩容阈值,完成扩容。

sizeCtl表达的几种意义

  1. ConcurrentHashMap指定了初始化容量的时候,sizeCtl会表示初始化的容量。
  2. 当需要初始化底层数组时,sizeCtl = -1,表示有一个线程正在初始化数组。
  3. 当初始化数组时,sizeCtl >= 0,表示没有线程在初始化数组,应该有一个线程要去初始化数组。
  4. 当在扩容时,sizeCtl的高16位表示的是扩容的标志。低16位表示的是 扩容线程数+1.
  5. 在初始化数组或者扩容结束后,sizeCtl会保存数组的扩容阈值。

get方法

和JDK1.7一样,JDK1.8的get方法也是不加锁的。底层数组是volatile修饰的。所以,如果发生在数组中的修改(引用的修改,数组元素的插入删除)。对于其他线程都是可见的。又每个结点的value以及next结点是voltile,他们的修改也是所有线程课件的。又是通过Unsafe类的getObject方法获取的桶中第一个结点,这一点也保证了线程安全。

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        //tabAt方法:底层调用了getObjectVolatile方法
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}
复制代码

get方法的流程:

①先算出hash值,使用hash值通过getObjectVolatile方法得到对应桶中的第一个结点。

②如果桶为null,返回null

③看第一个结点是不是要找的结点,是就返回

④如果不是,那么看是链表结点还是红黑树结点。遍历链表或者红黑树,返回值。

总结

JDK1.8中的ConcurrentHashMap是使用synchronized+CAS(Unsafe)类来保证线程安全的。

底层数组使用的是一种延时加载的机制,等到使用这个数组的时候,才会去初始化。

在初始化时,只有一个线程能够初始化数组,其他线程都在循环,如果线程数多的话,可能会导致cpu占用率过高的问题。

在使用put方法时,key和value是都不可以为null的,否则就会抛出NullPointException异常。并且put方法也不是一定要上锁,如果插入的桶中没有数据时,使用CAS操作将数据插入。否则,就需要使用synchronized拿到桶中第一个对象的锁。需要注意的是,在红黑树中还是用了一个TreeBin结点来作为桶中的第一个结点,防止在红黑树调整平衡时导致第一个结点改变。

线程在执行put方法后,会将map中的结点数加1。为了提高并发的效率,JDK1.8中的ConcurrentHashMap对计数方面进行了一些优化。在这个类中,存在一个CounterCell数组,数组中的每个CounterCell对象中都有一个value属性用来计数。而每个线程又会定位到一个CounterCell对象上。 线程在计数是不会立即去修改共享变量baseCount,而是会先去它对应的CounterCell对象修改属性value,如果修改不成功,再去修改baseCount。最终要统计总数的时候只需要将数组中的value值全部相加,再加上baseCount即可。

线程在put或者remove时,如果定位到了forwarding结点,那么就会帮助扩容。每个线程都会获得一个步长,线程会转移这些步长之上的数据。如果一个线程转移完毕还存在没有分配的步长,那么就会继续获取,直到扩容结束。

get方法是不加锁的,通过volatile对关键变量的修饰已经Unsafe类的方法来保证获取的数据时可靠的。

ConcurrentHashMapJDK1.7和1.8的不同

  1. 底层数据结构不同。1.8数组+链表_红黑树、1.7数组+链表
  2. 加锁的方式不同。JDK1.7使用的是分段锁+ReentrantLock,而JDK1.8使用的是synchronized+CAS的方式。JDK1.8加锁的方式效率更高。因为synchronized还有偏向锁和轻量级锁两种加锁模式。
  3. 加锁的粒度不同。JDK1.7是对对Hash桶批量地解锁,而JDK1.8是对每一个桶进行上锁。JDK1.8锁的粒度更低,效率更高。
  4. 初始化数组的时机不同。JDK1.7和JDK1.8虽然都使用到了临时初始化的思想。但是JDK1.7中的segment数组在构造方法中初始化。但是JDK1.8的底层数组在第一次put的时候初始化。
  5. JDK1.8中不存在预先创建结点优化。因为加锁方式的改变,我们不能向JDK1.7中那么灵活的控制线程阻塞的时机,所以预先创建结点的优化在JDK1.8中就不存在了。
  6. JDK1.8支持多线程扩容。JDK1.8是支持多个线程对同一个数组进行扩容的。JDK1.7从这个map来看,也是支持多线程扩容的,但是不支持同一个数组的多线程扩容。
  7. 扩容转移数组的方式不同。JDK1.7是遍历链表,一个结点一个结点的转移。JDK1.8是遍历结点,插入到同一个链表中,一次性转移。
  8. 对结点计数的方法不同。JDK1.7在每个segment中都有一个count属性,记录HashEntry数组的结点个数。在计数时,先使用乐观锁的方式统计结点个数,如果乐观锁不行,再使用悲观锁的方式统计。而在JDK1.8中是通过一个CounterCell数组和baseCount变量完成计数的。

参考: juejin.cn/post/695646… juejin.cn/post/706406…

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改