队列的线程安全

在Java多线程应用中,队列的使用率很高,多数生产消费模型的首选数据结构就是队列(先进先出)。Java提供的线程安全的Queue可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。

注:什么叫线程安全?这个首先要明确。线程安全就是说多线程访问同一代码,不会产生不确定的结果。

并行和并发区别

1、并行是指两者同时执行一件事,比如赛跑,两个人都在不停的往前跑;
2、并发是指资源有限的情况下,两者交替轮流使用资源,比如一段路(单核CPU资源)同时只能过一个人,A走一段后,让给B,B用完继续给A ,交替使用,目的是提高效率

设计一个线程安全的队列有以下方法:
第一种:使用synchronized同步队列,就像Vector或者Collections.synchronizedList/Collection那样。
 显然这不是一个好的并发队列,这会导致吞吐量急剧下降。
第二种:使用Lock。一种好的实现方式是使用ReentrantReadWriteLock来代替ReentrantLock提高读取的吞吐量。
 但是显然 ReentrantReadWriteLock的实现更为复杂,而且更容易导致出现问题,
 另外也不是一种通用的实现方式,因为 ReentrantReadWriteLock适合哪种读取量远远大于写入量的场合。
 当然了ReentrantLock是一种很好的实现,结合 Condition能够很方便的实现阻塞功能。
第三种:使用CAS操作。尽管Lock的实现也用到了CAS操作,但是毕竟是间接操作,而且会导致线程挂起。
 一个好的并发队列就是采用某种非阻塞算法来取得最大的吞吐量。
 ConcurrentLinkedQueue采用的就是第三种策略。
要使用非阻塞算法来完成队列操作,那么就需要一种“循环尝试”的动作,就是循环操作队列,直到成功为止,失败就会再次尝试。

LinkedBlockingQueue
由于LinkedBlockingQueue实现是线程安全的,实现了先进先出等特性,是作为生产者消费者的首选,LinkedBlockingQueue 可以指定容量,也可以不指定,不指定的话,默认最大是Integer.MAX_VALUE,其中主要用到put和take方法,put方法在队列满的时候会阻塞直到有队列成员被消费,take方法在队列空的时候会阻塞,直到有队列成员被放进来。

ConcurrentLinkedQueue是Queue的一个线程安全实现。
它是一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。队列的头部 是队列中时间最长的元素。
队列的尾部 是队列中时间最短的元素。新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。

ConcurrentLinkedQueue的数据结构
ConcurrentLinkedQueue只有头结点、尾节点两个元素,而对于一个节点Node而言除了保存队列元素item外,还有一个指向下一个节点的引用next。
默认情况下head节点存储的元素为空,tair节点等于head节点。
  private transient volatile Node<e> tail = head;。
但是也有几点是需要说明:
   1. 所有结构(head/tail/item/next)都是volatile类型。 这是因为ConcurrentLinkedQueue是非阻塞的,
   所以只有volatile才能使变量的写操作对后续读操作是可见的(这个是有 happens-before法则保证的)。同样也不会导致指令的重排序。
   2. 所有结构的操作都带有原子操作,这是由AtomicReferenceFieldUpdater保证的,
   这在原子操作中介绍过。它能保证需要的时候对变量的修改操作是原子的。
   3. 由于队列中任何一个节点(Node)只有下一个节点的引用,所以这个队列是单向的,根据FIFO特性,也就是说出队列在头部(head),入队列在尾部(tail)。
   头部保存有进入队列最长时间的元素,尾部是最近进入的元素。
   4. 没有对队列长度进行计数,所以队列的长度是无限的,同时获取队列的长度的时间不是固定的,这需要遍历整个队列,并且这个计数也可能是不精确的。
   5. 初始情况下队列头和队列尾都指向一个空节点,但是非null,这是为了方便操作,不需要每次去判断head/tail是否为空。但是head却不作为存取元素的节点,
   tail在不等于head情况下保存一个节点元素。也就是说head.item这个应该一直是空,但是tail.item却不一定是空(如果 head!=tail,那么tail.item!=null)。
对于第5点,可以从ConcurrentLinkedQueue的初始化中看到。这种头结点也叫“伪节点”,也就是说它不是真正的节点,只是一标识,就像c中的字符数组后面的\0以后,只是用来标识结束,并不是真正字符数组的一部分。
    private transient volatile Node<E> head = new Node<E>(null, null);
    private transient volatile Node<E> tail = head;

入队列

入队列就是将入队节点添加到队列的尾部。



    第一步添加元素1。队列更新head节点的next节点为元素1节点。又因为tail节点默认情况下等于head节点,所以它们的next节点都指向元素1节点。
    第二步添加元素2。队列首先设置元素1节点的next节点为元素2节点,然后更新tail节点指向元素2节点。
    第三步添加元素3,设置tail节点的next节点为元素3节点。
    第四步添加元素4,设置元素3的next节点为元素4节点,然后将tail节点指向元素4节点。

通过debug入队过程并观察head节点和tail节点的变化,发现入队主要做两件事情,第一是将入队节点设置成当前队列尾节点的下一个节点。第二是更新tail节点,如果tail节点的next节点不为空,则将入队节点设置成tail节点,如果tail节点的next节点为空,则将入队节点设置成tail的next节点,所以tail节点不总是尾节点,理解这一点对于我们研究源码会非常有帮助。

上面的分析让我们从单线程入队的角度来理解入队过程,但是多个线程同时进行入队情况就变得更加复杂,因为可能会出现其他线程插队的情况。如果有一个线程正在入队,那么它必须先获取尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队操作,然后重新获取尾节点。让我们再通过源码来详细分析下它是如何使用CAS算法来入队的。
01  public boolean offer(E e) {
02   
03          if (e == null) throw new NullPointerException();
04   
05          //入队前,创建一个入队节点
06   
07          Node</e><e> n = new Node</e><e>(e);
08   
09          retry:
10   
11          //死循环,入队不成功反复入队。
12   
13          for (;;) {
14   
15              //创建一个指向tail节点的引用
16   
17              Node</e><e> t = tail;
18   
19              //p用来表示队列的尾节点,默认情况下等于tail节点。
20   
21              Node</e><e> p = t;
22   
23              for (int hops = 0; ; hops++) {
24   
25              //获得p节点的下一个节点。
26   
27                  Node</e><e> next = succ(p);
28   
29       //next节点不为空,说明p不是尾节点,需要更新p后在将它指向next节点
30   
31                  if (next != null) {
32   
33                     //循环了两次及其以上,并且当前节点还是不等于尾节点
34   
35                      if (hops > HOPS && t != tail)
36   
37                          continue retry;
38   
39                      p = next;
40   
41                  }
42   
43                  //如果p是尾节点,则设置p节点的next节点为入队节点。
44   
45                  else if (p.casNext(null, n)) {
46   
47                    //如果tail节点有大于等于1个next节点,则将入队节点设置成tair节点,更新失败了也没关系,因为失败了表示有其他线程成功更新了tair节点。
48   
49  if (hops >= HOPS)
50   
51                          casTail(t, n); // 更新tail节点,允许失败
52   
53                      return true;
54   
55                  }
56   
57                 // p有next节点,表示p的next节点是尾节点,则重新设置p节点
58   
59                  else {
60   
61                      p = succ(p);
62   
63                  }
64   
65              }
66   
67          }
68   
69      }

从源代码角度来看整个入队过程主要做二件事情。第一是定位出尾节点,第二是使用CAS算法能将入队节点设置成尾节点的next节点,如不成功则重试。

第一步定位尾节点。tail节点并不总是尾节点,所以每次入队都必须先通过tail节点来找到尾节点,尾节点可能就是tail节点,也可能是tail节点的next节点。代码中循环体中的第一个if就是判断tail是否有next节点,有则表示next节点可能是尾节点。获取tail节点的next节点需要注意的是p节点等于p的next节点的情况,只有一种可能就是p节点和p的next节点都等于空,表示这个队列刚初始化,正准备添加第一次节点,所以需要返回head节点。获取p节点的next节点代码如下
1 final Node</e><e> succ(Node</e><e> p) {
2  
3          Node</e><e> next = p.getNext();
4  
5          return (p == next) ? head : next;
6  
7      }

第二步设置入队节点为尾节点。p.casNext(null, n)方法用于将入队节点设置为当前队列尾节点的next节点,p如果是null表示p是当前队列的尾节点,如果不为null表示有其他线程更新了尾节点,则需要重新获取当前队列的尾节点。

hops的设计意图。上面分析过对于先进先出的队列入队所要做的事情就是将入队节点设置成尾节点,doug lea写的代码和逻辑还是稍微有点复杂。那么我用以下方式来实现行不行?
01  public boolean offer(E e) {
02   
03         if (e == null)
04   
05           throw new NullPointerException();
06   
07        Node</e><e> n = new Node</e><e>(e);
08   
09        for (;;) {
10   
11           Node</e><e> t = tail;
12   
13           if (t.casNext(null, n) && casTail(t, n)) {
14   
15              return true;
16   
17           }
18   
19        }
20   
21      }

让tail节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑非常清楚和易懂。但是这么做有个缺点就是每次都需要使用循环CAS更新tail节点。如果能减少CAS更新tail节点的次数,就能提高入队的效率,所以doug lea使用hops变量来控制并减少tail节点的更新频率,并不是每次节点入队后都将 tail节点更新成尾节点,而是当 tail节点和尾节点的距离大于等于常量HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来看它通过增加对volatile变量的读操作来减少了对volatile变量的写操作,而对volatile变量的写操作开销要远远大于读操作,所以入队效率会有所提升。
1 private static final int HOPS = 1;

还有一点需要注意的是入队方法永远返回true,所以不要通过返回值判断入队是否成功。

出队列

    出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用。



    从上图可知,并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。这种做法也是通过hops变量来减少使用CAS更新head节点的消耗,从而提高出队效率。让我们再通过源码来深入分析下出队过程。

01  public E poll() {
02   
03             Node</e><e> h = head;
04   
05         // p表示头节点,需要出队的节点
06   
07             Node</e><e> p = h;
08   
09             for (int hops = 0;; hops++) {
10   
11                  // 获取p节点的元素
12   
13                  E item = p.getItem();
14   
15                  // 如果p节点的元素不为空,使用CAS设置p节点引用的元素为null,如果成功则返回p节点的元素。
16   
17                  if (item != null && p.casItem(item, null)) {
18   
19                       if (hops >= HOPS) {
20   
21                            //将p节点下一个节点设置成head节点
22   
23                            Node</e><e> q = p.getNext();
24   
25                            updateHead(h, (q != null) ? q : p);
26   
27                       }
28   
29                       return item;
30   
31                  }
32   
33                  // 如果头节点的元素为空或头节点发生了变化,这说明头节点已经被另外一个线程修改了。那么获取p节点的下一个节点
34   
35                  Node</e><e> next = succ(p);
36   
37                  // 如果p的下一个节点也为空,说明这个队列已经空了
38   
39                  if (next == null) {
40   
41                // 更新头节点。
42   
43                       updateHead(h, p);
44   
45                       break;
46   
47                  }
48   
49                  // 如果下一个元素不为空,则将头节点的下一个节点设置成头节点
50   
51                  p = next;
52   
53             }
54   
55             return null;
56   
57       }

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

 ConcurrentHashMap,它是一个以Concurrent开头的并发集合类,其原理是通过增加锁和细化锁的粒度来提高并发度。而ConcurrentLinkedQueue这个类采用了另一种提高并发度的方式:非阻塞算法(Non-blocking),第一次实现了无锁的并发。
  谈到这里,先要介绍一下非阻塞算法。其实非阻塞算法并不是什么神秘高深的东西,它需要有一套硬件和指令的配合(似乎目前大多数pc都能支持),
  主要解决的问题是:在许多时候,一个线程A持有其他线程B,C,D所需要的资源,但线程A遭遇网络阻塞,或数据库连接阻塞,或页面阻塞等。
  这时B,C,D就必须等待 A执行结束才能继续向前推进。这种情况在队列、堆栈等数据结构中也会经常出现,典型的如将一个队列中的数据取出来需要锁整个队列,
  也就是说,在对队列的操作中,各个线程实际上是串行的,中间还需要加上线程上下文切换的开销。如何在取队列中元素时进一步提高并发度(就像ConcurrentHashMap一样只锁部分)。
  ConcurrentHashMap是固定的16个段,并且每个段的操作是独立的,所以每个段使用了一把锁,关于这点也是考虑到一些开销和安全的问题,
  而队列中元素则是可以动态增长的,因为要涉及到队列指针的问题,不是锁单独一个元素就能够保证其原子性的。这时传说中的非阻塞算法就是比较好的选择了。

非阻塞算法
  在《Concurrency in practice》中对两个概念nonblocking和lock-free进行了解释。nonblocking定义为:任何线程失败或挂起不影响其他线程的失败或挂起;
  而lock-free定义为:在执行的的每一步,都有线程能够向前推进。而一个基于CAS(compareAndSet)且构造正确的算法一定是nonblocking和lock-free的。
  对于java中的非阻塞算法,核心原理是采用硬件级的指令来保证CAS的原子性,不同于lock这样的悲观锁定,非阻塞算法是乐观的,
  它基于某些算法步骤是不安全的,在每次进行CAS时可能成功,也可能失败,失败则再取新值重新CAS,
  这样不用每次使用lock以保证得到锁的线程必须成功。
  一个比较好的例子是Java 理论与实践: 非阻塞算法简介中的Nonblocking stack,这里采用的是Treiber 的非阻塞算法。
  这个例子比较容易,之后有一个对ConcurrentLinkedQueue的put方法介绍的例子,这里又是采用的Michael-Scott算法。
  开发非阻塞算法是一项非常有挑战的任务,对一个算法中的每一步都需要证明不会产生冲突和死锁。
  当然,也遵循一些规律,首先无论是否在多线程的多步执行中必须使得数据结构总是在一致的状态。
  即一个线程不能打断另一个线程的原子操作。其次,假设一个线程执行更新,另一个线程等待更新,
  如果前一个线程更新失败,则后一个线程会浪费等待时间,并且在等待中没有任何向前推进。
  解决的办法是细化原子操作的粒度,并且后一个线程使用快照。
  更多非阻塞算法请参考http://www.ibm.com/developerworks/cn/java/j-lo-concurrent/


ConcurrentLinkedQueue 本身是一个基于链接节点的无界线程安全队列,你自己调用就不用考虑线程安全了吗?

结论是:原子性操作当然是线程安全的,非原子性那就不安全了

原子性操作:

queue.add(obj);

   or

queue.poll();

非原子性操作(这里涉及到两个操作,在大并发的时候,在两个方法之间,在中间这个时刻,即当调用完了queue.isEmpty()的时候,可能有人操作了queue.poll(),在当你调用queue.poll()的时候,队列已经为空了,所以就出现异常了)

if(!queue.isEmpty()) {
   queue.poll();
}

应该为下面:

synchronized(queue) {
    if(!queue.isEmpty()) {
       queue.poll();
    }
}

参考文章:http://blog.csdn.net/hudashi/article/details/6936473
http://ifeve.com/concurrentlinkedqueue/
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值