JUC-并发容器

889 阅读16分钟

ConcurrentHashMap

在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会。

HashMap线程安全问题

  • 线程不安全的HashMap在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap

JDK 7

  • 采用的是头部插入的方式来存放链表的,也就是下一个冲突的键值对会放在上一个键值对的前面(同一位置上的新元素被放在链表的头部)。扩容的时候就有可能导致出现环形链表,造成死循环。

resize 方法

// newCapacity为新的容量
void resize(int newCapacity) {
    // 小数组,临时过度下
    Entry[] oldTable = table;
    // 扩容前的容量
    int oldCapacity = oldTable.length;
    // MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30
    if (oldCapacity == MAXIMUM_CAPACITY) {
        // 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 初始化一个新的数组(大容量)
    Entry[] newTable = new Entry[newCapacity];
    // 把小数组的元素转移到大数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 引用新的大数组
    table = newTable;
    // 重新计算阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer 方法

void transfer(Entry[] newTable, boolean rehash) {
    // 新的容量
    int newCapacity = newTable.length;
    // 遍历小数组
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 拉链法,相同 key 上的不同值
            Entry<K,V> next = e.next;
            // 是否需要重新计算 hash
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 根据大数组的容量,和键的 hash 计算元素在数组中的下标
            int i = indexFor(e.hash, newCapacity);

            // 同一位置上的新元素被放在链表的头部
            e.next = newTable[i];

            // 放在新的数组上
            newTable[i] = e;

            // 链表上的下一个元素
            e = next;
        }
    }
}
  • 注意 e.next = newTable[i]newTable[i] = e 这两行代码,就会将同一位置上的新元素被放在链表的头部。

  • 扩容前的样子假如是下面这样子。

  • 那么正常扩容后就是下面这样子。
  • 假设现在有两个线程同时进行扩容,线程 A 在执行到 newTable[i] = e; 被挂起,此时线程 A 中:e=3、next=7、e.next=null
  • 线程 B 开始执行,并且完成了数据转移。
  • 此时,7 的 next 为 3,3 的 next 为 null。

  • 随后线程A获得CPU时间片继续执行 newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:

  • 执行下一轮循环,此时 e=7,原本线程 A 中 7 的 next 为 5,但由于 table 是线程 A 和线程 B 共享的,而线程 B 顺利执行完后,7 的 next 变成了 3,那么此时线程 A 中,7 的 next 也为 3 了。

  • 采用头部插入的方式,变成了下面这样子:

  • 好像也没什么问题,此时 next = 3,e = 3。

  • 进行下一轮循环,但此时,由于线程 B 将 3 的 next 变为了 null,所以此轮循环应该是最后一轮了。

  • 接下来当执行完 e.next=newTable[i] 即 3.next=7 后,3 和 7 之间就相互链接了,执行完 newTable[i]=e 后,3 被头插法重新插入到链表中,执行结果如下图所示:

  • 无限循环

不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序。

JDK8

  • 正常情况下,当发生哈希冲突时,HashMap 是这样的:
  • 但多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。

put 的源码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // 步骤①:tab为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 步骤②:计算index,并对null做处理 
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;

        // 步骤③:节点key存在,直接覆盖value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;

        // 步骤④:判断该链为红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 步骤⑤:该链为链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);

                    //链表长度大于8转换为红黑树进行处理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }

                // key已经存在直接覆盖value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

        // 步骤⑥、直接覆盖
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;

    // 步骤⑦:超过最大容量 就扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

  • 问题发生在步骤 ② 这里:
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
复制代码
  • 两个线程都执行了 if 语句,假设线程 A 先执行了 tab[i] = newNode(hash, key, value, null),那 table 是这样的:
  • 接着,线程 B 执行了 tab[i] = newNode(hash, key, value, null),那 table 是这样的:
  • 3 被干掉了。

put 和 get 并发时会导致 get 到 null

  • 线程 A 执行put时,因为元素个数超出阈值而出现扩容,线程B 此时执行get,有可能导致这个问题。

resize 源码:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超过最大值就不再扩充了,就只好随你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 没超过最大值,就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的resize上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
}

  • 线程 A 执行完 table = newTab 之后,线程 B 中的 table 此时也发生了变化,此时去 get 的时候当然会 get 到 null 了,因为元素还没有转移。

HashTable效率低下

  • HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable 的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同 步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。

ConcurrentHashMap的锁分段技术可有效提升并发访问率

  • HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的 线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并 发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。 首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数 据也能被其他线程访问。

ConcurrentHashMap结构

  • ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重 入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数 据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种 数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元 素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时, 必须首先获得与它对应的Segment锁。
  • qq_pic_merged_1659880753999.jpg

ConcurrentHashMap初始化

  • ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel等几个 参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的 HashEntry数组来实现的。
if (concurrencyLevel > MAX_SEGMENTS) 
    concurrencyLevel = MAX_SEGMENTS;
    int sshift = 0;
    int ssize = 1;
 while (ssize < concurrencyLevel) { 
         ++sshift; 
         ssize <<= 1; 
       }
 segmentShift = 32 - sshift;
 segmentMask = ssize - 1;
 this.segments = Segment.newArray(ssize); 
  • 为了能 通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方 (power-of-two size),所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值 来作为segments数组的长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里 锁的个数也是16。

定位Segment

再散列,减少冲突

private static int hash(int h) {
    h += (h << 15) ^ 0xffffcd7d;
    h ^= (h >>> 10);
    h += (h << 3);
    h ^= (h >>> 6);
    h += (h << 2) + (h << 14);
    return h ^ (h >>> 16);
 }

定位到Segment

final Segment<K,V> segmentFor(int hash) { 
    return segments[(hash >>> segmentShift) & segmentMask];
    }

ConcurrentHashMap操作

get

public V get(Object key) { 
    int hash = hash(key.hashCode()); 
    return segmentFor(hash).get(key, hash); 
    }
  • ConcurrentHashMap的get操作是不加锁的,原因是它的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前 Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线 程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写 (有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写 共享变量count和value,所以可以不用加锁。
transient volatile int count;
volatile V value;

put

  • 1.是否扩容
  • 在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈 值,则对数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容 之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
  • 2.如何扩容
  • 在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进 行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只 对某个segment进行扩容。

size操作

  • 如果要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小 后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢? 不是的,虽然相加时可以获取每个Segment的count的最新值,但是可能累加前使用的count发生了变化,那么统计结果就不准了。所以,最安全的做法是在统计size的时候把所有Segment的put、remove和clean方法 全部锁住,但是这种做法显然非常低效。 因为在累加count操作过程中,之前累加过的count发生变化的几率非常小。
  • 所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。 那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢? 使用modCount 变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size 前后比较modCount是否发生变化,从而得知容器的大小是否发生变化

ConcurrentLinkedQueue

  • ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和 指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一 张链表结构的队列。默认情况下head节点存储的元素为空,tail节点等于head节点。

入列

  • 入队主要做两件事情:
  • 第一是将入队节点设置成当前队列尾节点的下一个节点;
  • 第二是更新tail节点,如果tail节点的next节 点不为空,则将入队节点设置成tail节点,如果tail节点的next节点为空,则将入队节点设置成 tail的next节点,所以tail节点不总是尾节点

在多线程下使用CAS算法来入队

public boolean offer(E e) {
    if (e == null) 
        throw new NullPointerException(); 
        // 入队前,创建一个入队节点 
        Node<E> n = new Node<E>(e);
        retry:
        // 死循环,入队不成功反复入队。 
        for (;;) { 
        // 创建一个指向tail节点的引用 
            Node<E> t = tail;
            // p用来表示队列的尾节点,默认情况下等于tail节点。
            Node<E> p = t;
            for (int hops = 0; ; hops++) { 
                // 获得p节点的下一个节点。 
                Node<E> next = succ(p);
                // next节点不为空,说明p不是尾节点,需要更新p后在将它指向next节点
                if (next != null) { 
                // 循环了两次及其以上,并且当前节点还是不等于尾节点 
                if (hops > HOPS && t != tail)
                continue retry; 
                p = next;
                }
                // 如果p是尾节点,则设置p节点的next节点为入队节点。 
                else if (p.casNext(null, n)) {
                /*如果tail节点有大于等于1个next节点,则将入队节点设置成tail节点, 更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点*/ 
                if (hops >= HOPS) 
                casTail(t, n); 
                // 更新tail节点,允许失败 
                return true; 
                } // p有next节点,表示p的next节点是尾节点,则重新设置p节点 
                else {
                p = succ(p);
                }
           } 
        }
    }
  • 第一是定位出尾节点
  • 第二是使用CAS算法将入队节点设置成尾节点的next节点,如不成功则重试。

定位尾结点

  • tail节点并不总是尾节点,所以每次入队都必须先通过tail节点来找到尾节点。尾节点可能 是tail节点,也可能是tail节点的next节点。代码中循环体中的第一个if就是判断tail是否有next节 点,有则表示next节点可能是尾节点。获取tail节点的next节点需要注意的是p节点等于p的next 节点的情况,只有一种可能就是p节点和p的next节点都等于空,表示这个队列刚初始化,正准备添加节点,所以需要返回head节点

hops用途

  • 使用hops变量来控制并减少tail节点的更新频率,并不 是每次节点入队后都将tail节点更新成尾节点,而是当tail节点和尾节点的距离大于等于常量 HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长,使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为 循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来 看它通过增加对volatile变量的读操作来减少对volatile变量的写操作,而对volatile变量的写操 作开销要远远大于读操作,所以入队效率会有所提升。

出列

public E poll() { 
    Node<E> h = head; 
    // p表示头节点,需要出队的节点
    Node<E> p = h;
    for (int hops = 0;; hops++) {
        // 获取p节点的元素 
        E item = p.getItem(); 
        // 如果p节点的元素不为空,使用CAS设置p节点引用的元素为null
        // 如果成功则返回p节点的元素。 
        if (item != null && p.casItem(item, null)) {
            if (hops >= HOPS) {
            // 将p节点下一个节点设置成head节点 
            Node<E> q = p.getNext();
            updateHead(h, (q != null) q : p); }
            return item; 
            }
            // 如果头节点的元素为空或头节点发生了变化,这说明头节点已经被另外 
            // 一个线程修改了。那么获取p节点的下一个节点 
            Node<E> next = succ(p); 
            // 如果p的下一个节点也为空,说明这个队列已经空了 
            if (next == null) {
            // 更新头节点。 
            updateHead(h, p);
            break;
            }
            // 如果下一个元素不为空,则将头节点的下一个节点设置成头节点
            p = next; 
            }
        return null;
      }
  • 首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已 经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引 用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经 进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。

Java阻塞队列

ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。

  • ArrayBlockingQueue是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原 则对元素进行排序。 默认情况下不保证线程公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照 阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平 的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问 队列。为了保证公平性,通常会降低吞吐量。我们可以使用以下代码创建一个公平的阻塞队列。

LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。

  • LinkedBlockingQueue是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。

PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。

  • PriorityBlockingQueue是一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序 升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化 PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证 同优先级元素的顺序。

DelayQueue:一个使用优先级队列实现的无界阻塞队列。

  • DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。队 列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。 只有在延迟期满时才能从队列中提取元素。

SynchronousQueue:一个不存储元素的阻塞队列。

  • SynchronousQueue是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作, 否则不能继续添加元素。

LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

  • LinkedTransferQueue是一个由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻 塞队列,LinkedTransferQueue多了tryTransfer和transfer方法。

LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

  • LinkedBlockingDeque是一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以 从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队 时,也就减少了一半的竞争。相比其他的阻塞队列,LinkedBlockingDeque多了addFirst、 addLast、offerFirst、offerLast、peekFirst和peekLast等方法,以First单词结尾的方法,表示插入、 获取(peek)或移除双端队列的第一个元素。以Last单词结尾的方法,表示插入、获取或移除双 端队列的最后一个元素

实现原理

  • 如果队列是空的,消费者会一直等待,当生产者添加元素时,消费者是如何知道当前队列 有元素的呢? 如果让你来设计阻塞队列你会如何设计,如何让生产者和消费者进行高效率的通信呢?
  • 使用通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生 产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用