Java 多线程并发编程(三) 并发容器类

分析容器类的时候,其实规律都是一样的,首先我们要找到容器类中真正存放数据的数据结构;接着通过容器类的添加元素和删除元素的方式来分析容器类的工作过程。接下来让我们开始今天的分析之路吧!

首先解释一下 CAS 操作:CAS 即 CompareAndSwap,也就是说先比较,再交换,那么比较什么?比较的是预期的变量值与实际的变量值是否一致,如果一致,那么就将变量的值与新值进行交换;如果不一致,则 CAS 失败。

 

1.List

(1)ArrayList

按照文章一开始的思路,我们先找到 ArrayList 里面用来保存数据的数据结构,以下是 ArrayList 里面的一些字段:

要找到存储数据的字段,首先可以排除字段类型是 int/long/double 等基本类型的字段,那么还剩下三个字段都是 Object[] 对象数组,看起来这是可以真正存放数据的地方,再看下面对这三个字段的注释可以看出来 ArrayList 真正用来存放数据的是 elementData 字段:

 知道了 ArrayList 存放数据的字段后,我们来看 ArrayList 的 add() 方法又是怎么实现的,我们可以看到首先要确保 elementData(即用来存数据的字段)的容量要足够,接着就将扩容之后的 elementData 的最后一个为空(注意是最后一个为空,而不一定是最后一个)的元素置为添加进来的元素并且将 elementData 的 size(elementData 的尺寸) 加 1:

接下来让我们看一下如何确保数组的容量足够大,如果数组是一个 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 即当前数组为空,那么就返回 DEFAULT_CAPACITY = 10 和 minCapacity(size+1) 的最大值:

 在确定了数组的最小容量之后,才开始真正的进入数组扩容阶段,这里做了一个判断,如果所需数组的最小容量 minCapacity 超过了 elementData 的数组长度才开始进行扩容:

 接下来是根据数组最小容量的扩容阶段,可以看到数组的扩容并不是仅仅扩容到 minCapacity,而是先做了一个计算,这里面 newCapacity 为 oldCapacity 的 1.5倍,也就是说如果原来数组长度为 10,那么现在数组的长度应该是 15;计算出来新的数组长度之后与传递进来的所需数组的最小容量做对比,如果新的数组长度比最小容量还小,那么就将新的数组长度设置为最小容量,即现在最小容量为 18,那么就将新的数组长度设置为 18(因为 18>15);如果新的数组长度的容量很大,甚至超过了最大的数组长度 MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8,那么就设置新的数组长度为 Integer.MAX_VALUE;在上面确定好新的数组长度之后,通过创建新数组、拷贝原数组数据将新的数组返回回来,作为之后存储数据的载体。

 

以上就是 ArrayList 添加元素调用 add() 方法的过程,总结起来就是:添加元素的时候,需要确保数组容量足够,如果数组容量不足就需要创建新的数组并且将原数组的数据拷贝过去,并且将新的数组的最后一个元素为空的位置的值设置成添加进来的元素。

 

我们再来看 ArrayList 的 remove(Object e) 方法,remove 方法的逻辑是通过遍历 elementData 对象数组来找到要删除元素的下标位置,然后通过元素的下标来移除元素:

 而 fastRemove 方法中主要是通过指定索引位置后面的所有元素向前移动一个位置来覆盖指定索引的元素来实现移除,最后注意将 elementData 的最后一个元素置空并且 size 减 1(后面还有一句话,clear to let GC do its works 其实设置 null 就是为了帮助 GC 进行垃圾回收的):

 

ArrayList 中还有一点要注意,我们平时都是直接使用 ArrayList 实例对象的 add/remove 方法来操作 ArrayList,但是 ArrayList 中也提供了一个实现了 Iterator 迭代器的私有类 Itr 用来迭代遍历 ArrayList 中的元素,我们可以看下这个 Itr 类中的 next() 和 remove() 方法的实现:从下面的图中可以看到 Itr 的 next 方法和 remove 方法在一开始的时候都调用了一个叫 checkForComodification() 的方法,这个方法的目的就是确保在 next 或者 remove 方法被执行的时候,数组并没有变动过;我们再注意下 remove 方法,其实可以看到 remove 方法就是调用了 ArrayList 本身提供的 remove 方法,但是 Itr 中的 remove 方法最后还有一步:expectedModCount=modCount;这是用来做什么的呢?

通过查看 Itr 的定义我们可以得知,expectedModCount 就是 Itr 定义的当前预期的数组修改次数,而 modCount 则表示数组真正的修改次数(modCount 在 ArrayList 的 add/remove 方法中都会变化,属于 ArrayList 的字段);而 checkForComodification 方法就是比较 expectedModCount 和 modCount 的值是否一致,如果不一致就抛出异常。

接下来我们以一段代码来演示上面的效果:下面的程序执行结果是第一次可以打印出 a,但是第二次就会抛出一个异常ConcurrentModificationException,原因就是因为 迭代器的 iterator 方法要检查 expectedModCount 的值,但是 ArrayList 的 remove 方法只会更新 modCount 的值,但是不会更新 expectedModCount 的值,所以导致第二次在调用 next 方法的时候执行 checkForComodification 方法抛出 ConcurrentModificationException 异常

public class ArrayListDemo {
    public static void main(String[] args) {
        ArrayList<String> arrayList = new ArrayList<>();
        arrayList.add("a");
        arrayList.add("b");
        arrayList.add("c");

        Iterator<String> iterator = arrayList.iterator();
        while (iterator.hasNext()) {
        	String s=iterator.next();
        	System.out.println(s); //执行的结果是:第一次可以打印出 a,但是第二次就会抛出一个异常:ConcurrentModificationException
                arrayList.remove(s); 
        }
    }
}

但是如果通过迭代器的 remove 方法就不会出现 ConcurrentModificationException 异常,道理也是一样的,因为虽然调用的是 ArrayList 的 remove 方法,但是也同步更新了 expectedModCount:

 

(2)CopyOnWriteArrayList

为了解决 ArrayList 中最后提到的问题,其实就是迭代器遍历数组的时候不允许删除数组中的元素,JDK 提供了同样实现了 List  接口的 CopyOnWriteArrayList 类:从它的字段上来看比较简单,一个是用来存放数据的对象数组 array,另外还有一个 ReentrantLock 的实例

而它的 add 方法的实现就比较简单,首先加锁,然后将数组的长度拓展 1 个位置,然后将老数组拷贝过去,最后将添加进来的元素放在数组的最后一个位置上,最后释放锁

 remove 方法也大概是同样的逻辑,首先加锁,然后找到要删除元素的索引位置,然后创建一个新数组(数组长度是原数组长度 -1 ),接着将原数组的元素分两段拷贝到新数组里面(略过要删除元素的索引)并将新数组返回回来,最后释放锁

 

 

2.Map

(1)HashMap

首先我们来看 HashMap 中用来存储键值对的数据结构是什么:从下面的源代码图片中我们可以看到,存放键值对数据是通过定义了一个 Node<K,V> 的类来表示,而存储多个键值对的数据结构其实是一个 Node<K,V>[] 数组类型的字段 table

 

接下来我们看 HashMap 提供的增删键值对元素的逻辑: 可以看到 put 方法用来存放数据之前,先是通过 hash(key) 方法计算了当前键值对的键的 HashCode,然后调用 putVal 方法真正的将键值对保存下来

 putVal 方法的代码如下所示:注意划线部分是主要逻辑。首先 628 行在判断了当前用来存放 Node<K,V> 的数组是否为空,如果是的话,就调用 resize 方法,我们暂时先不管 resize 方法具体是如何工作的,我们知道它是用来初始化并创建以及扩容 table 数组的即可。接下来是根据 key 的 HashCode 来计算当前应该定位到 table 数组中的位置,如果这个位置为空的话,那么就根据当前的 key,value 来创建一个节点;如果这个位置已经有元素了,那么就判断当前元素的 key 是否跟需要添加的键值对的 key 一致,如果一致的话,就用新的 value 代替原先的值;如果不一致就判断当前数组上的节点类型是不是 TreeNode(为什么要判断是不是 TreeNode 呢?后面在具体添加元素的时候就能知道),如果是的话,就直接将需要添加的元素添加到树里面,否则就以链表的方式将需要添加的元素添加进来。这里面还有一个判断在 642 行,if(binCount>=TREEIFY_THRESHOLD-1),在 JDK 1.7 里面,元素是直接添加到链表中的,JDK 1.8 中里面不是简单的加入到链表中,而是先获得链表上的元素个数,如果超过了一定的值,就将整个链表重构成一个红黑树,也就是 643 行的 treeifyBin(tab,hash) 的功能,现在可以解释为什么上面要判断数组上的节点是不是 TreeNode 类型了。当新增的节点添加完成后,第 661 行判断 table 的元素个数与阈值的大小,如果超过了阈值,那么就进行 resize 扩容,后面会说到阈值的概念。

在分析 resize 方法之前,我们先了解几个名词:threshold 阈值,即一个封顶的值,达到这个值之后就会做一些事情;loadFactor 加载因子,这个是用来计算阈值的,通过与容量相乘得到对应的阈值。

resize 方法我们分成两个部分来看,首先看如何初始化,创建或者扩容一个 Node<K,V> 类型的数组 table:假设现在 table 为空,那么程序会跑到 693 行,这里设置了新的 table 的容量 newCap 为一个默认值 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 也就是 newCap=16;设置了新的 table 的阈值,这个阈值是由两部分相乘得到的结果,DEFAULT_INITIAL_CAPACITY 也就是上面定义的 16,而DEFAULT_LOAD_FACTOR  在程序里面定义为:static final float DEFAULT_LOAD_FACTOR = 0.75f,也就是说 table 的新的阈值为 16*0.75=12,计算完之后就是创建 Node<K,V> 类型的数组 703行,704 行将新创建的数组赋值给 table;如果 table 一开始的时候不是空的,那么可以看到 table 扩容的时候,table 的新的容量 newCap 是原来的两倍 686 行,table 的新的阈值也是两倍的增长 688 行。

 我们总结一下创建及扩容的过程:如果 table 数组刚开始为空,那么就创建一个长度为 DEFAULT_INITIAL_CAPACITY 即 16 的数组,并且设置阈值为 16*0.75=12;如果 table 数组不为空,那么就创建一个长度为原数组长度两倍,阈值也是原数组阈值两倍的新数组并且赋值给 table,也就是说第一次扩容的话,table 的长度为 32,阈值为 24,第二次扩容长度是 64,阈值为 48,以此类推...

接下来我们简单看一下 resize 后面一段代码做了什么事:JDK 1.8 之后代码的可读性也变差了很多,大概可以看到这段代码里面,由于之前数组的长度有了变化,因此原数组中的元素都要重新计算所在位置,例如708-711 行就是重新计算并赋值的过程(将原来位置置空),如果原数组中元素的类型为 TreeNode 就要通过 split 方法将红黑树里面的节点重新分配;如果不是 TreeNode 类型的节点,就将这个链表拆成不同的链重新添加到 table 数组中。

最后我们总结下 put() 方法的实现逻辑:首先根据 key 算出对应的 HashCode 值,然后根据这个值算出当前新增的键值对应该存放到 table 数组中的哪个位置,如果那个位置为空,那么就直接新建一个 Node<K,V> 对象并占据这个位置;如果这个位置有元素,那么就比对一下已有元素的 key 与当前新添加元素的 key 是否一致,如果一致的话,就用新的 value 来更新已有元素的 value;如果已有元素的 key 与当前新增加元素的 key 不一致,那么就根据 key,value 创建一个 Node 对象,并且保存到已有元素后面的 Node 链尾,当链尾的元素达到一致的阈值时,就将整条链重新调整为一个红黑树的结构,并且已有元素上的节点类型也变成了 TreeNode。当上面的步骤完成后,判断一下当前 table 数组中的 Node 节点总个数,如果超过了所设定的阈值,那么就需要进行扩容的步骤,扩容是以双倍容量扩容的,当然阈值也会更新,扩容之后重新调整一下所有 Node 的位置。

通过上面对 put() 方法的介绍,remove 方法的逻辑也应该差不多可以理清楚了:简单说一下 remove 的逻辑,先是根据需要移除的 key 算出 hashCode,然后根据 HashCode 定位到 Node 的位置,如果该 Node 是TreeNode 类型,那么就调用 removeTreeNode 方法将其从树上移除;如果不是 TreeNode 类型,但该 Node 却在数组上(即在 Node 链的链头),那么就将链头的下一个元素放到数组上,作为新的链头;如果该 Node 不在数组上(即 Node 链中的普通节点),那么就将其从链中移除(前一节点的 next 等于当前节点的 next 即完成了移除的过程)

 

(2)ConcurrentHashMap

同样的分析过程,先看存储数据的数据结构是什么:从下图中可以看到依旧是 Node<K,V> 数组类型的字段 table

接下来看元素添加的方法 put():其实 ConcurrentHashMap 和 HashMap 的 put() 方法大致逻辑是一致的,不过是将很多线程不安全的操作替换成了线程安全的操作,例如 1019 行的 CAS 更新数组上指定索引位置的元素;1027 行使用同步关键字 synchronized 来同步多线程的访问。注意图中划线部分的关键语句,第一个是初始化 table 数组的,第二个是 CAS 更新数组上指定索引处的元素,第三个是 hash 相同,key 也相同,替换 value 的,第四个是将 key,value 构造成 Node 加入到 Node 链表尾部的,第五个是如果数组上元素类型是 TreeBin,就把 key,value 加入到红黑树结构中,第六个是调整链表长度,当长度超过一定阈值时将链表结构调整为红黑树结构,第七个就是添加完元素之后的扩容并重新分配元素位置的过程。

remove 方法逻辑也和 HashMap 差不多,也是添加了保障线程安全的机制 synchronized 关键字:如果 key 对应的 hash 不存在,就直接跳出;否则就开始找对应 key 的节点位置,如果节点在链表中,代码 1133 行,将当前节点的下一节点赋值为前一个节点的下一节点(原先前一个节点的下一个节点是当前节点),如果节点在链表头部,代码 1135 行,将当前节点的下一节点设置为链表的头部并且数组中对应位置的元素为当前节点的下一节点,代码 1156 行,进入了红黑树的判断,首先移除相应的树节点,然后根据是否移除结果判断是否要重新规划红黑树,并且将重新规划好的树挂到数组的制定位置,最后使用 addCount 方法来使得 table 数组的元素个数减 1.

 

总结:ConcurrentHashMap 和 HashMap 的区别就是使用了线程安全的机制,例如 CAS、同步关键字等,实现逻辑大致相似。

3.Set

(1)HashSet

 从以下 HashSet 的字段属性中可以看到,HashSet 是使用 HashMap 定义的一个字段 map 来实现数据的存储的。

因此 HashSet 里面的 add() 添加元素、remove() 删除元素的方法都是基于 HashMap 的 put(),remove() 来实现的,只不过 HashSet 利用的是 HashMap 里面的 key 来存储数据,因此 HashSet 里面的元素是不会重复的(因为 HashMap 中的元素 key 也不会重复)

 

(2)CopyOnWriteArraySet

同样的方式,找存储数据的容器:类型是 CopyOnWriteArrayList 的字段 al 来存储数据。当然元素的添加和删除也是调用的 CopyOnWriteArrayList 里面的方法来实现的。

但是 ArrayList 的数据存储容器是数组,数组是允许重复的,那如何保证元素唯一性呢?我们来看一下 add 方法就一目了然了。可以看到划线部分 addIfAbsent,意思就是当这个元素不存在的时候才添加进去,否则不添加。

 

 

4.Queue

(1)ArrayBlockingQueue

找存储数据的数据结构:应该是 Object[] items 字段,即使用的是对象数组这种结构。

 

看添加数据的方法:本来应该要看 add 方法的,但是 add 方法其实调用的就是 offer 方法来实现的,只不过在 offer 返回 false 的时候报出异常IllegalStateException("Queue full"),所以直接看 offer 方法的实现即可。offer 方法的逻辑是首先检查要加入的元素是否为空;然后获取锁对象并给代码段加锁,判断当前数组中元素个数 count 是否与数组长度一致,如果一致说明队列已经满了,因此 offer 方法应该直接返回 false,当然此时 add 方法也因为 offer 返回false 而报出异常。如果 count 与 数组长度不一致,就调用 enqueue(e) 将元素加入队列。

enqueue 方法也很简单,逻辑就是将数组的 putIndex 索引位置的元素设置成需要添加的元素,同时更新 putIndex(即当 putIndex 的值和数组的长度相等时,就重置为 0),并且将数组元素个数 count 加 1,同时唤醒所有因为队列空而等待的线程。 

我们再来看一下 put 方法,同样是添加元素:但是在添加元素的时候,如果队列满了,就阻塞等待在那里,直到有队列未满的信号唤醒它。

 

从上面的添加方法中可以总结出:add() 方法用来添加元素的时候,如果队列满了,就会抛出异常;offer 方法添加元素的时候,如果队列满了,就会返回 false;put 方法添加元素的时候,如果队列满了,就将当前线程阻塞住,直到有队列未满的信号发过来。

 

再看下与 offer() 方法相对的 poll():从队列中取出一个元素出来。同样是加锁,当数组中元素个数为 0 时直接返回null,否则调用 dequeue 方法取出元素。

dequeue 方法使用的是 takeIndex 这个索引,与 enqueue 很类似,只不过是取出元素的过程是将指定位置的元素置空。同时也会维护数组的元素个数,并且通知所有等待往队列中添加元素的线程。

take 方法也是用来取数据的,但是它跟 put() 方法是相对的,如果它在取数据的时候,队列中没有元素,即元素个数为 0,那么就等待,直到收到队列非空的信号。 

以上可以知道队列的 poll() 方法在队列为空的时候返回 null,而 take 方法在队列为空的时候阻塞直到收到队列非空的信号。

 

(2)LinkedBlockingQueue

找到存储数据的数据结构:通过查看 LinkedBlockingQueue 源码中划线部分我们可以找到,数据在 LinkedBlockingQueue 中是以自定义的 Node 对象来存储的,而 Node 类中定义的 next 形成的链表是存储多个数据的关键。

 

查看添加数据的方法:put() 方法中定义,如果链表中 Node 对象的数量等于 capacity(代表的是即将创建的 LinkedBlockingQueue 的容量),那么就阻塞住 put() 方法的执行直到收到队列未满的信号;否则就将 Node 对象添加到链表的尾部。

 

那么 capacity 又是什么时候定义的呢?从下面的图片中可以看到,如果在构建 LinkedBlockingQueue 对象的时候没有指定队列的容量,那么默认就是 Integer.MAX_VALUE。

 

而另外一个添加元素的方法:offer 方法中判断如果当前链表中的节点数量没有到指定的容量之前,都是一个可以添加元素的。offer 方法不会阻塞元素的添加,如果遇到元素个数已经达到容量的时候,直接返回 false 停止添加过程。

 

以上总结得到,和 ArrayBlockingQueue 类似,put 方法是阻塞加入,而 offer 方法是立即返回。 

而 LinkedBlockingQueue 的移除元素的方法 take 和 poll 跟 ArrayBlockingQueue 的也差不多,当队列中没有元素的时候,take 是阻塞等待取元素,而 poll 是立即返回 null,注意一下 take 和 poll 方法中都调用的 dequeue 方法,说的是从队列的头部移除一个节点,第二个划线的地方 214 行有一句注释:help GC;其实返回的是原队列中 head 之后的元素的值,然后把 head 之后的元素变成  head,并且让原队列的 head 指向自己,也就是从这个链表中脱离开。注意 head 和 last 的 item 属性都是 null,因此在 217 行 first.item=null

附上一段 Demo:

// 它是基于链表的队列,此队列按 FIFO(先进先出)排序元素。
// 如果有阻塞需求,用这个。类似生产者消费者场景
public class LinkedBlockingQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        // 构造时可以指定容量,默认Integer.MAX_VALUE
        LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<String>(3);
        // 1秒消费数据一个
        new Thread(() -> {
            while (true) {
                try {
                    System.out.println("取到数据:" + queue.poll()); // poll非阻塞
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                }
            }
        }).start();

        Thread.sleep(3000L); // 让前面的线程跑起来

        // 三个线程塞数据
        for (int i = 0; i < 6; i++) {
            new Thread(() -> {
                try {
                    // queue.put(Thread.currentThread().getName()); // put阻塞
                    queue.offer(Thread.currentThread().getName()); // offer非阻塞,满了返回false
                    System.out.println(Thread.currentThread() + "塞入完成");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

 

(3)ConcurrentLinkedQueue 

从 ConcurrentLinkedQueue 的字段中我们可以看到,ConcurrentLinkedQueue 和 LinkedBlockingQueue 的存储结构类似,都是使用 Node 对象来存储数据。

 

我们再来看数据添加的方法:offer 方法(因为 add 方法也是单纯的调用 offer 方法来实现的,本质上并没有什么区别) 。作为一个并发编程容器类,offer 方法一定要能应对多线程添加数据的情况,那么多线程添加元素的时候,offer 方法又是怎么实现的呢?答案是 CAS 机制。代码的 334 行,当多个线程试图把新创建的节点放到队列的末尾的时候,只有一个线程可以成功并返回 true,其他线程需要再次经过循环,然后同样的调用 CAS 机制把新创建的元素放到队列末尾。例如现在有两个线程 A,B,两个线程都进入到 for 循环中,都读取到 tail 的 next 属性为空(代码 332 行),此时线程 A 先执行了第 334 行并且成功执行,返回 true(但并没有把 tail 对象更新成新创建的 Node 对象),而线程 B 也试图执行 CAS 的赋值操作,但是此时由于线程 A 的动作导致线程 B 的 CAS 操作失败,因此线程 B 需要进入下一轮 for 循环,当再次执行到 332 行的时候,此时 tail 的 next 属性已经被线程 A 创建的 Node 对象占据,因此线程 B 进入到 344 行进行判断,此时 p 依旧代表队尾 tail,而 q 是线程 A 创建的 Node,因此 344 行的判断不成立,进入 352 行操作,此时 p 依旧是队尾 tail,而 t 一直指向的都是队尾,所以第 352 行代码会将 q 赋值给 p;接着再次进入 for 循环,此时再次尝试读取 p.next (代码 331 行),已经是线程 A 新创建 Node 对象的 next 属性了, 而新创建的 Node 对象的 next 对象为空,所以线程 B 会进入到代码 334 行进入 CAS 操作,到这里线程 A 和 B 的执行都已经结束。

 而 poll 方法的逻辑基本和 offer 方法逻辑类似,例如当前有两个线程 A,B,现在都进入到代码的 362 行,这次线程 B 先执行了 CAS 操作,将 head 的 item 属性设置为 null ,并且将之前保存的 item 值返回给调用者,线程 A 也尝试更新 head 的 item 属性,但是此时 item 已经被线程 B 置为空,因此线程 A 的 CAS 操作失败,进入下一次 for 循环,代码执行到369行,将 q 赋值为 p 的next,也就是当前队列 head 后面的 Node 节点,后面判断 q 是否为空,那么表示线程 B 在操作之前队列中只有 head 一个元素,此时直接返回 null,告诉线程 A 我已经给不了你元素了;如果 q 不为空,那么进入到 376 行将 q 赋值为 p;线程 A 再次进入 for 循环,此时 p 已经是原队列中的 head 后面的一个节点了,因此 p 的 item 属性确实不为空,因此进入 362 行 CAS 操作,并且由于 p 已经移动到下一节点,因此 366 行判断成功,因此将队列的 head 节点更新成 p,至此 线程 A,B 的操作都已经完成。

 

附上一段 Demo:

// 优势:无锁。
// 注意:批量操作不提供原子保证  addAll, removeAll, retainAll, containsAll, equals, and toArray
// 坑: size()方法每次都是遍历整个链表,最好不要频繁调用
// 如果没有阻塞要求,用这个挺好的(堆积数据)
public class ConcurrentLinkedQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        // 不需要指定容量,addAll
        ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<String>();
        // 1秒消费数据一个
        new Thread(() -> {
            while (true) {
                try {
                    System.out.println("取到数据:" + queue.poll()); // poll非阻塞
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                }
            }
        }).start();

        Thread.sleep(3000L); // 让前面的线程跑起来

        // 三个线程塞数据
        for (int i = 0; i < 6; i++) {
            new Thread(() -> {
                try {
                    queue.offer(Thread.currentThread().getName()); // offer非阻塞,满了返回false
                    System.out.println(Thread.currentThread() + "塞入完成");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

总结:通过对 LinkedBlockingQueue 已经 ConcurrentLinkQueue 的梳理,可以看到其实 LinkedBlockingQueue 多了一个阻塞的功能,而 ConcurrentLinkedQueue 没有阻塞功能;而且 LinkedBlockingQueue 是使用 ReentrantLock 来实现线程安全的,而 ConcurrentLinkedQueue 是使用 CAS 机制来保证线程安全,因此 ConcurrentLinkedQueue 在多线程情况下效率会更高点。

 

(4)PriorityQueue 优先级队列

参考自以下链接

优先级队列中用来存储数据的是一个对象数组 queue 字段(逻辑结构其实是一个大顶堆或者是一个小顶堆,所谓的大顶堆其实就是树形结构中,父亲节点以及左右孩子节点中,父亲节点的值一定是最大的;小顶堆就是相反的逻辑):

我们来看添加元素的方法:首先如果队列容量不足就开始扩容,如果队列为空,那么在扩容之后,就将添加进来的元素放到数组下标为 0 的位置,否则就调用 siftUp 方法将新元素添加进来并维护堆的逻辑结构。

首先看扩容的过程:可以看到如果旧的队列容量小于 64,就将容量翻倍增加,如果超过 64,就将队列的容量以一半的增量进行增长。

 

再看一下,当队列中已有元素,新元素添加进来经历了什么:如果 comparator 比较器不为空,即在创建优先级队列的时候,指定了比较器的话,就根据比较器的比较方法进行元素的搬移。

 看一下具体搬移的方法:其实搬移的过程就是维护大顶堆或者小顶堆的过程。

 下面以示例的方式呈现:现在来看往最小堆 pq = {3, 5, 6, 7, 8, 9} 中添加元素 1的过程

  • 首先,把要添加的元素 1 放到pq[size],然后调用siftUp(k, e)来维护堆,调整结束后 size++;
  • 向上调整(k, e)时,先找到结点pq[k]的父结点,满足规律 parent = (k - 1) >>> 1,例子中,k = 6, parent = 2;
  • 比较pq[k]与pq[parent],将较小者放到高处,较大者移到低处,例子中,交换pq[6](1)与pq[2](6)的位置;
  • 此次交换结束后,令 k = parent,继续以同样的方法操作,直到 k <= 0 时(到达根结点)结束;

 

 

再来看下元素的删除过程,还是通过在一个示例 pq = {0, 1, 7, 2, 3, 8, 9, 4, 5, 6} 中进行一系列删除操作,来理解算法的运作过程。

  • 第1步,remove(6),indexOf(6) = 9,removeAt(9)(用r(9)表示,后面同理),由于i = 9为队列末端,删除后不会破坏堆性质,所以可以直接删除;
  • 第2步,remove(1),即r(1),根据图(5.b)可以看出,算法是拿队列尾部pq[8]去替换pq[1],替换后破坏了最小堆的性质,需要向下调整进行维护;
  • 第3步,remove(8),即r(5),使用队列尾部元素pq[7]替换pq[5],替换后破坏了最小堆的性质,需要向上调整进行维护;

 

 

(5) DelayedQueue 延时队列

首先看数据的存储结构:采用的是优先级队列来实现的。

那么队列的具体添加、删除操作就不解释了 ,使用的就是优先级队列中相应的方法。

那如何做到所谓的延时的呢?这个实现其实跟优先级队列中的元素类型关系很大,延时队列要求存储在队列中的元素是实现了 Delayed 接口的类型,而实现了 Delayed 接口就必须实现里面的 getDelay 方法,表示还需要延迟多久。下面红框里面,首先从优先级队列中取出一个元素,然后判断这个元素的 getDelay 方法是不是返回正数,如果是表示还没有到指定的之间,此时 poll 方法就返回为 null,如果到了延时时间,就从优先级队列的堆顶弹出一个元素并返回。

下面是一个 DelayedQueue 的 Demo :

// (基于PriorityQueue来实现的)是一个存放Delayed 元素的无界阻塞队列,
// 只有在延迟期满时才能从中提取元素。该队列的头部是延迟期满后保存时间最长的 Delayed 元素。
// 如果延迟都还没有期满,则队列没有头部,并且poll将返回null。
// 当一个元素的 getDelay(TimeUnit.NANOSECONDS) 方法返回一个小于或等于零的值时,
// 则出现期满,poll就以移除这个元素了。此队列不允许使用 null 元素。
public class DelayQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<Message> delayQueue = new DelayQueue<Message>();
        // 这条消息5秒后发送
        Message message = new Message("message - 00001", new Date(System.currentTimeMillis() + 5000L));
        delayQueue.add(message);

        while (true) {
            System.out.println(delayQueue.poll());
            Thread.sleep(1000L);
        }
        // 线程池中的定时调度就是这样实现的
    }
}

// 实现Delayed接口的元素才能存到DelayQueue
class Message implements Delayed {

    // 判断当前这个元素,是不是已经到了需要被拿出来的时间
    @Override
    public long getDelay(TimeUnit unit) {
        // 默认纳秒
        long duration = sendTime.getTime() - System.currentTimeMillis();
        return TimeUnit.NANOSECONDS.convert(duration, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        return o.getDelay(TimeUnit.NANOSECONDS) > this.getDelay(TimeUnit.NANOSECONDS) ? 1 : -1;
    }

    String content;
    Date sendTime;

    /**
     * @param content  消息内容
     * @param sendTime 定时发送
     */
    public Message(String content, Date sendTime) {
        this.content = content;
        this.sendTime = sendTime;
    }

    @Override
    public String toString() {
        return "Message{" +
                "content='" + content + '\'' +
                ", sendTime=" + sendTime +
                '}';
    }
}

 

(6)SynchronousQueue

具体可以参考如下链接:https://www.jianshu.com/p/376d368cb44f

SynchronousQueue是无界的,是一种无缓冲的等待队列,但是由于该Queue本身的特性,在某次添加元素后必须等待其他线程取走后才能继续添加;可以认为SynchronousQueue是一个缓存值为1的阻塞队列,但是 isEmpty()方法永远返回是true,remainingCapacity() 方法永远返回是0,remove()和removeAll() 方法永远返回是false,iterator()方法永远返回空,peek()方法永远返回null。

声明一个SynchronousQueue有两种不同的方式,它们之间有着不太一样的行为。

公平模式和非公平模式的区别:如果采用公平模式:SynchronousQueue会采用公平锁,并配合一个FIFO队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;

但如果是非公平模式(SynchronousQueue默认):SynchronousQueue采用非公平锁,同时配合一个LIFO队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。

SynchronousQueue是这样 一种阻塞队列,其中每个 put 必须等待一个 take,反之亦然。同步队列没有任何内部容量,甚至连一个队列的容量都没有。
 不能在同步队列上进行 peek,因为仅在试图要取得元素时,该元素才存在;
 除非另一个线程试图移除某个元素,否则也不能(使用任何方法)添加元素;
 也不能迭代队列,因为其中没有元素可用于迭代。队列的头是尝试添加到队列中的首个已排队线程元素; 如果没有已排队线程,则不添加元素并且头为 null。

 

以上如有不足和理解错误之处,还请各位大神指教,谢谢。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值