ConcurrentHashMap,CopyOnWriteArraySet「E」,BlockingQueue

37 篇文章 4 订阅
32 篇文章 1 订阅

1.ConcurrentHashMap:大家都知道HashMap是非线程安全的,Hashtable是线程安全的,但是由于Hashtable是采用synchronized进行同步,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。而ConcurrentHashMap避免了为整个容器上锁。JDK1.8以前他把整个容器分成了若干个段(Segment),而这些段数组的容量是固定的。每个段又相当于一个hashTable,相当于一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。并且ConcurrentHashMap的扩容是有效扩容,hashTable的有可能是无效扩容。jdk1.8以后,抛弃了原有的Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性,链表节点数超过指定阈值的话,也是会转换成红黑树的。至于如何实现,ConcurrentHashMap最常用的方法也就是put方法和get方法,那么下面主要看代码注释,便于理解。

这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述:

1、判断Node[]数组是否初始化,没有则进行初始化操作
2、通过hash定位数组的索引坐标,是否有Node节点,如果没有则使用CAS进行添加(链表的头节点),添加失败则进入下次循环。
3、检查到内部正在扩容,就帮助它一块扩容。
4、如果f!=null,则使用synchronized锁住f元素(链表/红黑树的头元素)。如果是Node(链表结构)则执行链表的添加操作;如果是TreeNode(树型结构)则执行树添加操作。
5、判断链表长度已经达到临界值8(默认值),当节点超过这个值就需要把链表转换为树结构
6、如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

1.spread(key,hashCode())  ,该方法主要是将key的hashCode的低16位于高16位进行异或运算,这样不仅能够使得hash值能够分散能够均匀减小hash冲突的概率,另外只用到了异或运算,在性能开销上也能兼顾。

2.initTable方法 ,主要作用将tab进行初始化

为了保证能够正确初始化,在第1步中会先通过if进行判断,若当前已经有一个线程正在初始化即sizeCtl值变为-1,这个时候其他线程在If判断为true从而调用Thread.yield()让出CPU时间片。正在进行初始化的线程会调用U.compareAndSwapInt方法将sizeCtl改为-1即正在初始化的状态。另外还需要注意的事情是,在第四步中会进一步计算数组中可用的大小即为数组实际大小n乘以加载因子0.75.可以看看这里乘以0.75是怎么算的,0.75为四分之三,这里n - (n >>> 2)是不是刚好是n-(1/4)n=(3/4)n,挺有意思的吧:)。如果选择是无参的构造器的话,这里在new Node数组的时候会使用默认大小为DEFAULT_CAPACITY(16),然后乘以加载因子0.75为12,也就是说数组的可用大小为12。

3.CAS关键操作

tabAt()该方法用来获取table数组中索引为i的Node元素
casTabAt()利用CAS操作设置table数组中索引为i的元素。
setTabAt()该方法用来设置table数组中索引为i的元素。

4.ConcurrentHashMap的扩容,通过判断该节点的hash值是不是等于-1(MOVED),代码为(fh = f.hash) == MOVED,说明 Map 正在扩容。那么就帮助 Map 进行扩容。以加快速度。如何帮助扩容呢?那要看看 helpTransfer 方法的实现。扩容过程有点复杂,可以查看上面注释。这里主要涉及到多线程并发扩容,ForwardingNode的作用就是支持扩容操作,将已处理的节点和空节点置为ForwardingNode,并发处理时多个线程经过ForwardingNode就表示已经遍历了,就往后遍历,下图是多线程合作扩容的过程。https://blog.csdn.net/ZOKEKAI/article/details/90051567

get方法

1.计算hash值,定位到该table索引位置,如果是首节点符合就返回。
2.如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回。
3.以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null

get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。1.8中ConcurrentHashMap的get操作全程不需要加锁,这也是它比其他并发集合安全效率高的原因之一,get操作全程不需要加锁是因为Node的成员val是用volatile修饰的,和数组用volatile修饰没有关系,数组用volatile修饰主要是保证在数组扩容的时候保证可见性

remove方法:

和put方法一样,多个remove线程请求不同的hash桶时,可以并发执行。

如图所示:删除的node节点的next依然指着下一个元素。此时若有一个遍历线程正在遍历这个已经删除的节点,这个遍历线程依然可以通过next属性访问下一个元素。从遍历线程的角度看,他并没有感知到此节点已经删除了,这说明了ConcurrentHashMap提供了弱一致性的迭代器

HashMap、Hashtable、ConccurentHashMap三者的区别

HashMap线程不安全,数组+链表+红黑树
Hashtable线程安全,锁住整个对象,数组+链表
ConccurentHashMap线程安全,CAS+同步锁,数组+链表+红黑树
HashMap的key,value均可为null,其他两个不行。

在JDK1.7和JDK1.8中的区别

在JDK1.8主要设计上的改进有以下几点:

1、不采用segment而采用node,锁住node来实现减小锁粒度
2、设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。
3、使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。
4、sizeCtl的不同值来代表不同含义,起到了控制的作用。采用synchronized而不是ReentrantLock

CopyOnWriteArraySet:

1)它最适合于具有以下特征的应用程序:set 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。  
 *      2)它是线程安全的, 底层的实现是CopyOnWriteArrayList;   
 *      3)因为通常需要复制整个基础数组,所以可变操作(add、set 和 remove 等等)的开销很大。  
 *      4)迭代器不支持可变 remove 操作,因为CopyOnWriteArrayList的迭代器的remove操作不受支持。  
 *      5)使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。  

CopyOnWriteArrayList是ArrayList 的一个线程安全的变体,其中所有可变操作(add、set等等)都是通过对底层数组进行一次新的复制来实现的。这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更 有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。“快照”风格的迭代器方法在创建迭代器时使用了对数组状态的引用。此数组在迭代器的生存期内不会更改,因此不可能发生冲突,并且迭代器保证不会抛出ConcurrentModificationException。创建迭代器以后,迭代器就不会反映列表的添加、移除或者更改。在迭代器上进行的元素更改操作remove不受支持。这些方法将抛出UnsupportedOperationException。允许使用所有元素,包括null。底层是一Reetrantlock 和 volatile 的 Object数组。

Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略。从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWrite容器非常有用,可以在非常多的并发场景中使用到。

什么是CopyOnWrite容器

CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

LinkedBlockingQueue

LinkedBlockingQueue,先进先出(FIFO)顺序。而ArrayLinkedQueue是自然有界的,LinkedBlockingQueue可选的边界。下面这是一个完整的生产者消费者代码例子。阻塞队列实现生产者消费者模式超级简单,它提供开箱即用支持阻塞的方法put()和take(),两个方法为接口BlockingQueue中的方法,底层为一个链表。

package oher;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class PublicBoxQueue {
	public static void main(String[] args) {
		 BlockingQueue publicBoxQueue= new LinkedBlockingQueue(5);   //定义了一个大小为5的盒子   
         Thread pro= new Thread(new ProducerQueue(publicBoxQueue));
         Thread con= new Thread(new ConsumerQueue(publicBoxQueue));        
         pro.start();
         con.start();
	}

}
//生产者
class ProducerQueue implements Runnable
{
	private final BlockingQueue<Integer> proQueue;
	
    public ProducerQueue(BlockingQueue<Integer> proQueue)
    {
    	this.proQueue = proQueue;
    }
	@Override
	public void run() {
		try {
			
			for (int j = 0; j < 10; j++) {
				System.out.println("生产者生产的苹果编号为 : " + j);
				proQueue.put(j);
				//在该队列的尾部插入指定的元素,如果需要,等待尾部变为可用
				 Thread.sleep(3000);
			}
		} catch (InterruptedException e) {
			// TODO 自动生成的 catch 块
			e.printStackTrace();
		}	
	}	
}
//消费者
class ConsumerQueue implements Runnable {
	 private final BlockingQueue<Integer> conQueue;
      
     public ConsumerQueue(BlockingQueue<Integer> conQueue)
     {
    	 this.conQueue = conQueue;
     }
	@Override
	public void run() {
		 try {
			 for (int i = 0; i < 10; i++)
			 {              
				 System.out.println("消费者消费的苹果编号为 :" + conQueue.take());
				 //检索并删除此队列的头,如有必要,等待元素可用
				 Thread.sleep(3000);                                 
			 }
		 } catch (InterruptedException e) {           
            e.printStackTrace();
		 }
	}
}
生产者生产的苹果编号为 : 0
消费者消费的苹果编号为 :0
生产者生产的苹果编号为 : 1
消费者消费的苹果编号为 :1
生产者生产的苹果编号为 : 2
消费者消费的苹果编号为 :2
生产者生产的苹果编号为 : 3
消费者消费的苹果编号为 :3
生产者生产的苹果编号为 : 4
消费者消费的苹果编号为 :4
生产者生产的苹果编号为 : 5
消费者消费的苹果编号为 :5
生产者生产的苹果编号为 : 6
消费者消费的苹果编号为 :6
生产者生产的苹果编号为 : 7
消费者消费的苹果编号为 :7
生产者生产的苹果编号为 : 8
消费者消费的苹果编号为 :8
生产者生产的苹果编号为 : 9
消费者消费的苹果编号为 :9

LinkedBlockingQueue内部由单链表实现,只能从head取元素,从tail添加元素。添加元素和获取元素都有独立的锁,也就是说LinkedBlockingQueue是读写分离的,读写操作可以并行执行。LinkedBlockingQueue采用可重入锁(ReentrantLock)来保证在并发情况下的线程安全。

构造器

LinkedBlockingQueue一共有三个构造器,分别是无参构造器、可以指定容量的构造器、可以穿入一个容器的构造器。如果在创建实例的时候调用的是无参构造器,LinkedBlockingQueue的默认容量是Integer.MAX_VALUE,这样做很可能会导致队列还没有满,但是内存却已经满了的情况(内存溢出),

public LinkedBlockingQueue();   //设置容量为Integer.MAX
 
public LinkedBlockingQueue(int capacity);  //设置指定容量

public LinkedBlockingQueue(Collection<? extends E> c);  //穿入一个容器,如果调用该构造器,容量默认也是Integer.MAX_VALUE

 private final int capacity;

    /** Current number of elements */
    private final AtomicInteger count = new AtomicInteger();

    /**
     * Head of linked list.
     * Invariant: head.item == null
     */
    transient Node<E> head;

    /**
     * Tail of linked list.
     * Invariant: last.next == null
     */
    private transient Node<E> last;

    /** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();


take():首选。当队列为空时阻塞

poll():弹出队顶元素,队列为空时,返回空

peek():和poll类似,返回队队顶元素,但顶元素不弹出。队列为空时返回null

remove(Object o):移除某个元素,队列为空时抛出异常。成功移除返回true

contains(Object o) 如果此队列包含指定的元素,则返回 true 

size()方法会遍历整个队列,时间复杂度为O(n),所以最好选用isEmtpy 判断队列是否为空

offer(E e) 如果可以在不超过队列的容量的情况下立即将其指定的元素插入到队列的尾部,如果队列已满,则返回 true和 false. 
 
boolean offer(E e, long timeout, TimeUnit unit)  在该队列的尾部插入指定的元素,放弃之前等待多久。 

put元素原理

基本过程:

1.判断元素是否为null,为null抛出异常

2.加锁(可中断锁)

3.判断队列长度是否到达容量,如果到达一直等待

4.如果没有队满,enqueue()在队尾加入元素

5.队列长度加1,此时如果队列还没有满,调用signal唤醒其他堵塞队列

    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        // Note: convention in all put/take/etc is to preset local var
        // holding count negative to indicate failure unless set.
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }

take元素原理

 基本过程:

1.加锁(依旧是ReentrantLock),注意这里的锁和写入是不同的两把锁

2.判断队列是否为空,如果为空就一直等待

3.通过dequeue方法取得数据

3.取走元素后队列是否为空,如果不为空唤醒其他等待中的队列

public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            while (count.get() == 0) {
                notEmpty.await();
            }
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }

LinkedBlockingDeque

它是一个由双向链表结构组成的双向阻塞队列,即可以从队列的两端插入和移除元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。它实现了BlockingDeque,而BlockingDeque又继承了BlockingQueue,所以BlockingQueue里的put和take方法LinkedBlockingDeque也有。而且还多了对应的putfirst接takefirst等方法。

相比于其他阻塞队列,LinkedBlockingDeque多了putFirst、putLast、pollFirst、pollLast等方法,以first结尾的方法,表示插入、获取获移除双端队列的第一个元素。以last结尾的方法,表示插入、获取获移除双端队列的最后一个元素。addFist和addlast的时候如果没有空间会抛出IllegalStateException异常。

LinkedBlockingDeque是可选容量的,在初始化时可以设置容量防止其过度膨胀,如果不设置,默认容量大小为Integer.MAX_VALUE,有可能内存溢出。

public LinkedBlockingDeque()
public LinkedBlockingDeque(int capacity)
public LinkedBlockingDeque(Collection<? extends E> c)

BlockingDeque继承自BlockingQueue和Deque接口,BlockingDeque接口定义了在双端队列中常用的方法。

LinkedBlockingDeque类中的数据都被封装成了Node对象:双向链表

static final class Node<E> {
    E item;
    Node<E> prev;
    Node<E> next;
 
    Node(E x) {
        item = x;
    }
}

   */
    transient Node<E> first;

    /**
     * Pointer to last node.
     * Invariant: (first == null && last == null) ||
     *            (last.next == null && last.item != null)
     */
    transient Node<E> last;

    /** Number of items in the deque */
    private transient int count;

    /** Maximum number of items in the deque */
    private final int capacity;

    /** Main lock guarding all access */
    final ReentrantLock lock = new ReentrantLock();

    /** Condition for waiting takes */
    private final Condition notEmpty = lock.newCondition();

    /** Condition for waiting puts */
    private final Condition notFull = lock.newCondition();

LinkedBlockingDeque类的底层实现和LinkedBlockingQueue类很相似,都有一个全局独占锁,和两个Condition对象,用来阻塞和唤醒线程。

LinkedBlockingDeque类对元素的操作方法比较多,我们下面以putFirst、putLast、pollFirst、pollLast方法来对元素的入队、出队操作进行分析。

入队

putFirst(E e)方法是将指定的元素插入双端队列的开头,源码如下:


public void putFirst(E e) throws InterruptedException {
    // 若插入元素为null,则直接抛出NullPointerException异常
    if (e == null) throw new NullPointerException();
    // 将插入节点包装为Node节点
    Node<E> node = new Node<E>(e);
    // 获取全局独占锁
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        while (!linkFirst(node))
            notFull.await();
    } finally {
        // 释放全局独占锁
        lock.unlock();
    }

入队操作是通过linkFirst(E e)方法来完成的,如下所示

private boolean linkFirst(Node<E> node) {
    // assert lock.isHeldByCurrentThread();
    // 元素个数超出容量。直接返回false
    if (count >= capacity)
        return false;
    // 获取双向链表的首节点
    Node<E> f = first;
    // 将node设置为首节点
    node.next = f;
    first = node;
    // 若last为null,设置尾节点为node节点
    if (last == null)
        last = node;
    else
        // 更新原首节点的前驱节点
        f.prev = node;
    ++count;
    // 唤醒阻塞在notEmpty上的线程
    notEmpty.signal();
    return true;
}

若入队成功,则linkFirst(E e)方法返回true,否则,返回false。若该方法返回false,则当前线程会阻塞在notFull条件上。

putLast(E e)方法是将指定的元素插入到双端队列的末尾,该方法和putFirst(E e)方法几乎一样,不同点在于,putLast(E e)方法通过调用linkLast(E e)方法来插入节点,若入队成功,则linkLast(E e)方法返回true,否则,返回false。若该方法返回false,则当前线程会阻塞在notFull条件上。

pollFirst()方法是获取并移除此双端队列的首节点,若不存在,则返回null。

pollLast()方法是获取并移除此双端队列的尾节点,若不存在,则返回null。

addFirst()插入此双端队列的前面,如果它是立即可行且不会违反容量限制.如果当前没有空间可用,抛出一个指定的元素IllegalStateException。 当使用容量限制的deque时,通常最好使offerFirst,它月putFist相比是非阻塞的。

addLast() 插入此双端队列的最后,如果它是立即可行且不会违反容量限制.如果当前没有空间可用,抛出一个指定的元素IllegalStateException。推荐使用offerLast,它为非阻塞。

peekFirst()  检索但不删除此deque的第一个元素,如果此deque为空,则返回 null 

peekLast() 检索但不删除此deque的最后一个元素,如果此deque为空,则返回 null 

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

ArrayBlockingQueue 是有界队列,且初始化时必须指定队列的大小,有界也就意味着,它不能够存储无限多数量的对象。所以在创建 ArrayBlockingQueue 时,必须要给它指定一个队列的大小。数据存储为使用一个Object数组来存储元素、

我们先来熟悉一下 ArrayBlockingQueue 中的几个重要的方法。

add(E e):把 e 加到 BlockingQueue 里,即如果 BlockingQueue 可以容纳,则返回 true,否则报异常 

offer(E e):表示如果可能的话,将 e 加到 BlockingQueue 里,即如果 BlockingQueue 可以容纳,则返回 true,否则返回 false 

put(E e):把 e 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程被阻断直到 BlockingQueue 里面有空间再继续

poll(time):取走 BlockingQueue 里排在首位的对象,若不能立即取出,则可以等 time 参数规定的时间,取不到时返回 null 

take():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,阻断进入等待状态直到 Blocking 有新的对象被加入为止 

remainingCapacity():剩余可用的大小。等于初始容量减去当前的 size

我们再来看一下 ArrayBlockingQueue 使用场景。

先进先出队列(队列头的是最先进队的元素;队列尾的是最后进队的元素)

有界队列(即初始化时指定的容量,就是队列最大的容量,不会出现扩容,容量满,则阻塞进队操作;容量空,则阻塞出队操作)

队列不支持空元素.

/** The queued items */
    final Object[] items;

    /** items index for next take, poll, peek or remove */
    int takeIndex;

    /** items index for next put, offer, or add */
    int putIndex;

    /** Number of elements in the queue */
    int count;

    /*
     * Concurrency control uses the classic two-condition algorithm
     * found in any textbook.
     */

    /** Main lock guarding all access */
    final ReentrantLock lock;

    /** Condition for waiting takes */
    private final Condition notEmpty;

    /** Condition for waiting puts */
    private final Condition notFull;

ArrayBlockingQueue 进队操作采用了加锁的方式保证并发安全。源代码里面有一个 while() 判断

public void put(E e) throws InterruptedException {
    checkNotNull(e); // 非空判断
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly(); // 获取锁
    try {
        while (count == items.length) {
            // 一直阻塞,知道队列非满时,被唤醒
            notFull.await();
        }
        enqueue(e); // 进队
    } finally {
        lock.unlock();
    }
}
public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {
    checkNotNull(e);
    long nanos = unit.toNanos(timeout);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length) {
        // 阻塞,知道队列不满
        // 或者超时时间已过,返回false
            if (nanos <= 0)
                return false;
            nanos = notFull.awaitNanos(nanos);
        }
        enqueue(e);
        return true;
    } finally {
        lock.unlock();
    }
}

   private void enqueue(E e) {
        // assert lock.isHeldByCurrentThread();
        // assert lock.getHoldCount() == 1;
        // assert items[putIndex] == null;
        final Object[] items = this.items;
        items[putIndex] = e;
        if (++putIndex == items.length) putIndex = 0;
        count++;
        notEmpty.signal();
    }
 private E dequeue() {
        // assert lock.isHeldByCurrentThread();
        // assert lock.getHoldCount() == 1;
        // assert items[takeIndex] != null;
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        E e = (E) items[takeIndex];
        items[takeIndex] = null;
        if (++takeIndex == items.length) takeIndex = 0;
        count--;
        if (itrs != null)
            itrs.elementDequeued();
        notFull.signal();
        return e;
    }

通过源码分析,我们可以发现下面的规律:

阻塞调用方式 put(e)或 offer(e, timeout, unit)

阻塞调用时,唤醒条件为超时或者队列非满(因此,要求在出队时,要发起一个唤醒操作)

进队成功之后,执行notEmpty.signal()唤起被阻塞的出队线程

ArrayBlockingQueue 队列我们可以在创建线程池时进行使用。

new ThreadPoolExecutor(1, 1,
  0L, TimeUnit.MILLISECONDS,
  new ArrayBlockingQueue<Runnable>(2));

new ThreadPoolExecutor(1, 1,
  0L, TimeUnit.MILLISECONDS,
  new LinkedBlockingQueue<Runnable>(2));  

Itr与Itrs介绍

if (itrs != null)
    itrs.elementDequeued();

ItrsArrayBlockingQueue中的一个内部类,itrs则为其一个成员变量。初始化时为null,transient Itrs itrs = null;

源码中关于Itrs的描述截取如下

Shared data between iterators and their queue, allowing queue modifications to update iterators when elements are removed.

该对象在迭代器即阻塞队列之间共享了数据,在队列删除元素时会更新迭代器。

构造迭代器的方法如下:

public Iterator<E> iterator() {
    //这里是Itr不是Itrs
    return new Itr();
}

然后进入Itr的构造函数中看下:

Itr() {
    lastRet = NONE;
    final ReentrantLock lock = ArrayBlockingQueue.this.lock;
    lock.lock();
    try {
        //队列中没有元素
        if (count == 0) {
            cursor = NONE;
            nextIndex = NONE;
            prevTakeIndex = DETACHED;
        } else {
            final int takeIndex = ArrayBlockingQueue.this.takeIndex;
            prevTakeIndex = takeIndex;
            nextItem = itemAt(nextIndex = takeIndex);
            cursor = incCursor(takeIndex);
            if (itrs == null) {
        //在这里会构造一个Itrs对象,并赋值给ArrayBlockingQueue中的itrs对象
        //而Itrs内部类似个链表,用于迭代   
                itrs = new Itrs(this);
            } else {
                itrs.register(this); // in this order
                itrs.doSomeSweeping(false);
            }
            prevCycles = itrs.cycles;
        }
    } finally {
        lock.unlock();
    }
}

在 ArrayBlockingQueue 中使用 Itrs 维护这一个 Itr 链表,用于在一个队列下的多个 Itr 迭代器中共享队列元素,保证多个迭代器中的元素数据的一致性。

虽然 Itrs 这个设计增加了维护上的复杂性,但是为了保证迭代器在删除元素时,各个迭代器中能够保持一致,这个 Itrs 的设计时有必要的。Itrs 通过

跟踪 takeIndex 循环到 0 的次数。提供 takeIndexWrapped 方法,当 takeIndex 循环到 0 时,清除过期迭代。
提供 removedAt,通知所有的迭代器执行 removedAt 来保证所有的 Itr 迭代器数据保持一致。
以上的两项操作应当能够保证 Itr 迭代器间的一致性,但是增加了许多其他的操作来维护这些 Itr 迭代器。Itrs 通过一个链表和弱引用来维护 Itr 迭代器,并通过一下三种方式清空 Itr 迭代器:

当创建 Itr 迭代器时,检查链表中的 Itr 迭代器是否过期。
当 takeIndex 循环到 0 时,检查超过一次循环,但是从未被使用的迭代器。
如果队列被清空,那么所有的 Itr 迭代器都会被通知数据作废。
所以为了保证正确性,removedAt、shutdown 和 takeIndexWrapped 方法都做检查 Itr 迭代器是否过期的操作。如果元素都过期,迭代器作废或者迭代器通知自己过期,那么这些过期的元素会被清除。这个操作不需要做额外的其他操作就可完成。
https://blog.csdn.net/qq_43327091/article/details/104401103

ArrayBlockingQueue和LinkedBlockingQueue的比较

相同点

1、LinkedBlockingQueue和ArrayBlockingQueue都实现了BlockingQueue接口;

2、LinkedBlockingQueue和ArrayBlockingQueue都是可阻塞的队列(内部都是使用ReentrantLock和Condition来保证生产和消费的同步;当队列为空,消费者线程被阻塞;当队列装满,生产者线程被阻塞.)

不同点

1、队列中的同步锁机制不同

ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁; 使用一个ReentrantLock来保证线程安全:入列和出列前都需要获取该锁。

LinkedBlockingQueue中的锁是分离的,使用两个ReentrantLock来保证线程安全:入列前需要获取到入列锁(putLock),出列前需要获取到出列锁(takeLock),实现了入列锁和出列锁的分离。

2、底层实现机制不同

ArrayBlockingQueue      使用一个Object数组来存储元素。

LinkedBlockingQueue    使用链表来存储元素。

3.队列的大小不同:

ArrayBlockingQueue      是有界队列,且初始化时必须指定队列的大小。

LinkedBlockingQueue    是无界队列,在初始化的时候可以指定队列的大小从而变成有界队列。默认是Integer.MAX_VALUE,当入列速度大于出列速度时可能会造成内存溢出。(也可以做手工指定大小,从而成为有界的)。

4.在生产或消费时操作不同

ArrayBlockingQueue基于数组,在生产和消费的时候,是直接将枚举对象插入或移除的,不会产生或销毁任何额外的对象实例;

LinkedBlockingQueue基于链表,在生产和消费的时候,需要把枚举对象转换为Node<E>进行插入或移除,会生成一个额外的Node对象,这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC的影响还是存在一定的区别。

5、并发性能

ArrayBlockingQueue中生产和消费用的是同一个锁; 入列和出列前都需要获取该锁。

LinkedBlockingQueue中使用入列锁和出列锁的分离,故LinkedBlockingQueue的并发执行效率要高一些。

 6.内存方面

ArrayBlockingQueue 用于存储队列元素的存储空间是预先分配的,使用过程中内存开销较小(无须动态申请存储空间)

LinkedBlockingQueue 用于存储队列元素的存储空间是在其使用过程中动态分配的,因此它可能会增加JVM垃圾回收的负担。

7.吞吐量

LinkedBlockingQueue在大多数并发的场景下吞吐量比ArrayBlockingQueue高,但是性能不稳定。

Linked queues typically have higher throughput than array-based queues but less predictable performance in most concurrent applications.

这个主要针对LinkedBlockingQueue是无界的场景来说,由于无界,所以offer以及poll的吞吐量通常比ArrayBlockingQueue高。

ConcurrentLinkedQueue

非阻塞的实现方式则可以使用循环CAS的方式来实现,ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点的引用(next)组成,节点与节点之间就是通过这个next关联起来,从而组成一张链表结构的队列。

public boolean add(E e) {
    return offer(e);
}
 
public boolean offer(E e) {
    // 如果e为null,则直接抛出NullPointerException异常
    checkNotNull(e);
    // 创建入队节点
    final Node<E> newNode = new Node<E>(e);
 
    // 循环CAS直到入队成功
    // 1、根据tail节点定位出尾节点(last node);2、将新节点置为尾节点的下一个节点;3、casTail更新尾节点
    for (Node<E> t = tail, p = t;;) {
        // p用来表示队列的尾节点,初始情况下等于tail节点
        // q是p的next节点
        Node<E> q = p.next;
        // 判断p是不是尾节点,tail节点不一定是尾节点,判断是不是尾节点的依据是该节点的next是不是null
        // 如果p是尾节点
        if (q == null) {
            // p is last node
            // 设置p节点的下一个节点为新节点,设置成功则casNext返回true;否则返回false,说明有其他线程更新过尾节点
            if (p.casNext(null, newNode)) {
                // Successful CAS is the linearization point
                // for e to become an element of this queue,
                // and for newNode to become "live".
                // 如果p != t,则将入队节点设置成tail节点,更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点
                if (p != t) // hop two nodes at a time
                    casTail(t, newNode);  // Failure is OK.
                return true;
            }
            // Lost CAS race to another thread; re-read next
        }
        // 多线程操作时候,由于poll时候会把旧的head变为自引用,然后将head的next设置为新的head
        // 所以这里需要重新找新的head,因为新的head后面的节点才是激活的节点
        else if (p == q)
            // We have fallen off list.  If tail is unchanged, it
            // will also be off-list, in which case we need to
            // jump to head, from which all live nodes are always
            // reachable.  Else the new tail is a better bet.
            p = (t != (t = tail)) ? t : head;
        // 寻找尾节点
        else
            // Check for tail updates after two hops.
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

从源代码角度来看整个入队过程主要做两件事情:

  • 第一是定位出尾节点
  • 第二是使用CAS算法能将入队节点设置成尾节点的next节点,如不成功则重试。
public E poll() {
    restartFromHead:
    for (;;) {
        // p节点表示首节点,即需要出队的节点
        for (Node<E> h = head, p = h, q;;) {
            E item = p.item;
 
            // 如果p节点的元素不为null,则通过CAS来设置p节点引用的元素为null,如果成功则返回p节点的元素
            if (item != null && p.casItem(item, null)) {
                // Successful CAS is the linearization point
                // for item to be removed from this queue.
                // 如果p != h,则更新head
                if (p != h) // hop two nodes at a time
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            // 如果头节点的元素为空或头节点发生了变化,这说明头节点已经被另外一个线程修改了。
            // 那么获取p节点的下一个节点,如果p节点的下一节点为null,则表明队列已经空了
            else if ((q = p.next) == null) {
                // 更新头结点
                updateHead(h, p);
                return null;
            }
            // p == q,则使用新的head重新开始
            else if (p == q)
                continue restartFromHead;
            // 如果下一个元素不为空,则将头节点的下一个节点设置成头节点
            else
                p = q;
        }
    }
}

该方法的主要逻辑就是首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。
size()方法:

public int size() {
    int count = 0;
    // first()获取第一个具有非空元素的节点,若不存在,返回null
    // succ(p)方法获取p的后继节点,若p == p的后继节点,则返回head
    for (Node<E> p = first(); p != null; p = succ(p))
        if (p.item != null)
            // Collection.size() spec says to max out
            // 最大返回Integer.MAX_VALUE
            if (++count == Integer.MAX_VALUE)
                break;
    return count;
}

size()方法用来获取当前队列的元素个数,但在并发环境中,其结果可能不精确,因为整个过程都没有加锁,所以从调用size方法到返回结果期间有可能增删元素,导致统计的元素个数不精确。

concurrentLinkedQueue 使用三个不变式 ( 基本不变式,head 的不变式和 tail 的不变式 ),来约束队列中方法的执行。通过这三个不变式来维护非阻塞算法的正确性。

基本不变式

在执行方法之前和之后,head 必须保持的不变式:

  • 当入队插入新节点之后,队列中有一个 next 域为 null 的(最后)节点。
  • 从 head 开始遍历队列,可以访问所有 item 域不为 null 的节点。

head 的不变式

  • 所有”活着”的节点(指未删除节点),都能从 head 通过调用 succ() 方法遍历可达。
  • head 不能为 null。
  • head 节点的 next 域不能引用到自身。

tail 的不变式

  • 通过 tail 调用 succ() 方法,最后节点总是可达的。
  • tail 不能为 null。

ConcurrentLinkedDeque

ConcurrentLinkedDeque 是双向链表结构的无界并发队列。从JDK 7开始加入到J.U.C的行列中。使用CAS实现并发安全,与 ConcurrentLinkedQueue 的区别是该阻塞队列同时支持FIFO和FILO两种操作方式,即可以从队列的头和尾同时操作(插入/删除)。适合“多生产,多消费”的场景.size方法不是一个准确的操作.基于链接节点的无界并发deque 。 并发插入,删除和访问操作可以跨多个线程安全执行。 一个 ConcurrentLinkedDeque是许多线程将共享对公共集合的访问的适当选择。 像大多数其他并发集合实现一样,此类不允许使用null元素.

ConcurrentLinkedDeque使用了自旋+CAS的非阻塞算法来保证线程并发访问时的数据一致性.

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值