聊聊并发:(十六)concurrent包并发容器之ConcurrentHashMap分析

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/wtopps/article/details/88181268

前言

在上一篇文章,我们介绍了concurrent包中的并发集合:CopyOnWriteArrayList,本篇,我们继续了解学习一下另外一个非常常用也是非常重要的一个并发容器:ConcurrentHashMap。

ConcurrentHashMap介绍

大家可能都知道,HashMap是一个线程不安全的实现,在多线程的环境下,有可能出现死循环的情况。而使用线程安全的HashTable,性能又非常的糟糕,因此,在多线程的场景下,我们应该考虑使用ConcurrnetHashMap。

ConcurrnetHashMap是HashMap线程安全版本的实现,阅读本文的朋友,我先假定您对HashMap的使用已经非常的了解了,如果不太熟悉HashMap实现的读者,可以先对其进行一下了解,这样可以帮助您更好的理解ConcurrentHashMap。

ConcurrentHashMap是在Java 5版本中加入的,在此之前,由于HashMap的实现是线程不安全的,因此使用的时候,需要的额外的同步工作,Java 5以后,在多线程操作的场景下,就可以直接使用ConcurrentHashMap来满足线程安全的要求。

ConcurrentHashMap在1.8的版本之前的实现与1.8中实现差异较大,在1.8版本后进行了较大的重构,由于在JDK 1.8版本后的设计非常复杂,因此本篇,我们依旧基于JDK 1.7的版本进行分析。

首先,我们先看一下ConcurrentHashMap的结构图,对其结构有一个大体的了解:
在这里插入图片描述
ConcurrentHashMap的数据存储结构与HashMap很相似,底层都采用了一个Table数组来存储数据,关于拉链表的存储模式,这里不再赘述, 如果不太清楚的读者,可以去了解一下HashMap的实现,ConcurrentHashMap中引入了Segment的段级锁的概念,利用段级锁来实现细粒度的并发控制,这块我们后面会详细来说。

ConcurrentHashMap 结构

ConcurrentHashMap 继承了AbstractMap,实现了ConcurrentMap,同时间接实现了Map接口,首先我们来看一下其比较主要的属性:

主要属性:

// 默认数据初始化大小,可以通过构造函数指定
static final int DEFAULT_INITIAL_CAPACITY = 16;

//默认加载因子,可以通过构造函数指定
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//默认并发等级,可以通过构造函数指定
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

//最大容量,2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

//segments段级锁数组
final Segment<K,V>[] segments;

//key集合
transient Set<K> keySet;

//元素集合
transient Set<Map.Entry<K,V>> entrySet;

//value集合
transient Collection<V> values;

上面是concurrentHashMap中定义的比较主要的属性,其含义可以参见注释,大部分参数与HashMap类似,我们再看一下重要的内部类:

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

    /**
    * Sets next field with volatile write semantics.  
    * (See above about use of putOrderedObject.)
    */
    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是ConcurrentHashMap的一个内部类,其结构与HashMap的Entry基本一致,不同的是setNext()方法,这个方法可以保证写入后,可以立即对其他线程可见,通过native方法实现了voliate的效果。

Segment:

static final class Segment<K,V> extends ReentrantLock implements Serializable {

        private static final long serialVersionUID = 2249069246763182397L;

        static final int MAX_SCAN_RETRIES =
            Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

        transient volatile HashEntry<K,V>[] table;

        transient int count;

        transient int modCount;

        transient int threshold;

        final float loadFactor;

        Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
            this.loadFactor = lf;
            this.threshold = threshold;
            this.table = tab;
        }
        //......
        //省略具体方法实现
        //......
    }

上面是Segment的部分实现,我想用简单的片面的一句话说明一下这个类,Segment实现了ReentrantLock锁,拥有ReentrantLock锁的能力,它将HashMap的一个大table进行了切分,切分成若干个table,进行数据的存储,可以支持更小粒度的并发控制。我们一会分析put()与get()等主要方法时,会详细分析它的实现。

ConcurrentHashMap构造初始化

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
            //最大大小为65535
        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;
    }

上面是构造函数初始化的代码,构造函数中支持传入三个参数,分别是初始化的大小(默认16)、加载因子(默认0.75)、并发等级(默认16),默认无参的构造函数,会传入三个默认值。

在构造函数中,会初始化几个重要的变量,分别是segments[]数组、段级锁偏移量segmentShift、段级锁掩码segmentMask,以及segment[]数组中的第一个变量, HashEntry数组。

我们来看一下初始化的过程,首先检查concurrencyLevel是否超过了最大限制长度65535,接下来会计算segments数组的大小,ssize的大小通过右移计算得到,所以最后计算的值则一定是2的N次幂,即段级锁的数目一定是2的N次幂个,初始化大小为16。

segmentShift由sshift计算而来,初始化默认大小为28,segmentMask初始化大小为15。

HashEntry的初始化大小根据initialCapacity计算得来,默认值为2。

ConcurrentHashMap put()实现

接下来我们看一下核心的方法之一,put方法的实现:

put():

public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

在HashMap中,是将元素存放在底层的数组中进行存储,而在ConcurrentHashMap中,存储位置改为了Segment,因此,存放元素前,需要计算出存放在哪个Segment中。

put()中主要分为几步操作:

1、判断值是否为null

2、计算hash值

3、定位要存储到哪个Segment,如果不存在,则进行创建

4、调用Segment的put()方法存储元素

在定位Segment上,ConcurrentHashMap对hash算法进行了优化,首先计算一遍hash值,再进行移位操作和与操作,这样做的目的是为了减少hash冲突的概率,使元素可以均匀的分布在每一个Segment上,避免大量元素都落到一个Segment上进行存储,降低并发的效率。

接下来看一下Sgement的put()方法:

Segment 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;
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            else {
                if (node != null)
                    node.setNext(first);
                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()方法的实现,上面我们提到过,Segment继承了ReentrantLock,因此它具备可重入锁的能力,如果您对ReentrantLock不熟悉的话,可以看一下往期文章对ReentrantLock的分析。Segment中有HashEntry[]数组,采用拉链表的 模式存储元素,机制基本与HashMap一致。我们对其put()方法的主要步骤进行一下分析:

1、尝试获取锁

2、定位HashEntry[]数组中元素要存储的位置

3、遍历链表,查找key是否已经存在,如果存在,判断onlyIfAbsent的值,决定是否要对旧值进行覆盖,默认情况下会进行覆盖,并返回旧值

4、如果key不存在,将元素放入数组指定位置的拉链表的首节点

5、释放锁

这里需要注意的是,与HashMap不同,Segment在存储元素之前,会检查数组的容量是否达到阈值,如果达到,会先进行扩容,再进行存储,而HashMap则是先进行存储,然后判断是否需要扩容,但是扩容后可能会没有新的元素插入,导致了空间的浪费,因此,这里Segment进行了更加了良好的优化。

在扩容时,ConcurrentHashMap只会对指定的Segment进行扩容,不会对全部的进行扩容,因为扩容是比较耗时的一个操作, 这样的方式会更加的高效。

ConcurrentHashMap get()实现

上面我们看完了put()的实现,接下来我们再看一下get()的实现:

get()

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        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()的操作相对比较简单,主要分为下面几个步骤:

1、计算key的hash值

2、定位到存储的Segment,再定位到存储数组中的具体位置

3、遍历拉链表,查找key值

4、找到对应的key,返回value,否则返回null

get()方法的实现中,我们可以发现一点,没有一处使用到锁的地方,为什么get()可以支持并发操作下不使用锁呢?

原因是它的 get 方法里将要使用的共享变量都定义成 volatile,如用于统计当前 Segement 大小的 count 字段和用于存储值的 HashEntry 的 value。

定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在 get 操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。

之所以不会读到过期的值,是根据 java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景。

正是基于这种设计,可以让get()操作在并发场景下可以保证正确结果的前提下,又非常的高效。

ConcurrentHashMap size()实现

上面我们介绍了两个最重要的方法,get()与put(),了解了ConcurrentHashMap的工作机制,我们也知道了,ConcurrentHashMap是基于Segment的段级锁进行存储的机制,那么当需要统计在整个ConcurrentHashMap的元素个数时,是如何做到的呢,我们来看一下size()的实现。

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
    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) {
                    sum += seg.modCount;
                    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里的元素大小之和。但是直接进行累加,在并发场景下,很难保证是数值的准确性,但是直接对所有的Segment进行加锁,再进行累加操作,效率又非常的低下。

ConcurrentHashMap的做法比较精妙,首先会尝试三次不加锁计算,如果过程中ConcurrentHashMap的元素没有被修改,那么说明count值是准确的,可以直接返回;如果三次都失败,计算过程中元素数都发生了修改,那么再进行加锁操作,重新统计。这样可以保证效率的最大化,因为修改操作与size()操作同时发生的概率并不是很大。

总结

本篇,我们介绍了ConcurrentHashMap的实现机制,了解了为什么它可以支持在并发操作下的线程安全保证,下面我们对其进行一下简单的总结:

1、ConcurrentHashMap是线程安全版本的HashMap,采用Segment段级锁的机制进行实现,Segment继承于ReentrantLock。

2、ConcurrentHashMap中的key和value值都不能为null,HashMap中key可以为null,HashTable中key不能为null。

3、ConcurrentHashMap是线程安全的类并不能保证使用了ConcurrentHashMap的操作都是线程安全的。

ConcurrentHashMap的实现依赖于ReentrantLock,如果对ReentrantLock不了解的读者,可以查看之前的文章对于ReentrantLock的介绍。

聊聊并发:(九)concurrent包之ReentrantLock分析

本篇我们就介绍到这里,下一篇我们将对几种常用的阻塞队列进行分析,敬请期待!

感谢您的关注与支持~~

更多Java干货文章请关注我的个人微信公众号:老宣与你聊Java
更多Java干货文章请关注我的个人微信公众号:老宣与你聊Java

展开阅读全文

没有更多推荐了,返回首页