xiaobenchi

导航

Java并发包中并发队列原理剖析

Java并发包中并发队列原理剖析

JDK中提供了一系列场景的并发安全队列。总的来说,按照实现方式的不同可分为阻塞队列和非阻塞队列,前者使用锁实现,而后者则使用CAS非阻塞算法实现。

1. ConcurrentLinkedQueue原理探究

ConcurrentLinkedQueue是线程安全的无界非阻塞队列,其底层数据结构使用单向链表实现,对于入队和出队操作使用CAS来实现线程安全。

public ConcurrentLinkedQueue(){
    head = tail = new Node<E>(null);
}

在Node节点内部则维护一个使用volatile修饰的变量item,用来存放节点的值;next用来存放链表的下一个节点,从而链接为一个单向无界链表。其内部则使用UNSafe工具类提供的CAS算法来保证出入队时操作链表的原子性。

  • 类图结构

image-20220808093054404

  • ConcurrentLinkedQueue原理介绍

    • offer操作

      在队尾添加一个元素,如果传递的参数是null,则抛出NPE异常。

      public boolean offer(E e){
          //(1)e为null则抛出空指针异常
          checkNotNull(e);
          
          //(2)构造Node节点
          final Node<E> newNode = new Node<E>(e);
          
          //(3)从尾节点进行插入
          for(Node<E> t = tail,p = t;;){
              Node<E> q = p.next;
              
              //(4)如果q为null,说明p是尾节点,则执行插入
              if(q == null){
                  
                  //(5)使用CAS设置p节点的next节点
                  if(p.casNext(null,newNode)){
                      //(6)CAS成功,说明新增节点已经被放入链表,然后设置当前尾节点
                      if(p != t){
                          casTail(L,newNode);
                      }
                      return true;
                  }
              }
              else if(p == q)
                  //(7)重新找新的head
                  p = (t != (t = tail)) ? t : head;
              else
                  //(8)寻找尾节点
                  p = (p != t && t != (t = tail)) ? t : q;
          }
          
      }
      

      offer操作中的关键步骤是代码(5),通过原子CAS操作来控制某时只有一个线程可以追加元素到队列末尾。进行CAS竞争失败的线程会通过循环一次次尝试进行CAS操作,直到CAS成功才会返回,也就是通过使用无限循环不断进行CAS尝试方式来替代阻塞算法挂起调用线程。相比阻塞算法,这是使用CPU资源换取阻塞所带来的开销。

    • add操作

      add操作是在链表末尾添加一个元素,其实在内部调用的还是offer操作。

    • poll操作

      poll操作是在队列头部获取并移除一个元素,如果队列为空则返回null

      public E poll{
          //(1) goto标记
          restartFromHead;
          
          //(2) 无限循环
          for(;;){
              for(Node<E> h = head, p = h,q;;){
                  //(3)保存当前节点值
                  E item = p.item;
                  
                  //(4)当前节点有值则CAS变为null
                  if(item != null && p.casItem(item,null)){
                      //(5)CAS成功则标记当前节点并从链表中移除
                      if(p != h){
                          updateHead(h,((q = p.next) != null) ? q :p);
                      }
                      return item;
                  }
                  //(6)当前队列为空则返回null
                  else if((q = p.next) == null){
                      updateHead(h,p);
                      return null;
                  }
                  //(7)如果当前节点被自引用了,则重新寻找新的队列头节点
                  else if(p == q)
                      continue restartFromHead;
                  else
                      p = q;
              }
          }
      }
      final void updateHead(Node<E> h,Node<E> p){
          if(h != p && casHead(h,p))
              h.lazySetHasNext(h);
      }
      

      poll方法在移除一个元素时,只是简单地使用CAS操作把当前节点的item值设置为null,然后通过重新设置头节点将该元素从队列里面移除,被移除的节点就成了孤立节点,这个节点会在垃圾回收时被回收掉。另外,如果在执行分支中发现头节点被修改了,要跳到外层循环重新获取新的头节点。

    • peek操作

      peek操作是获取队列头部一个元素(只获取不移除),队列为空则返回null。

      public E peek(){
          //(1)
          restartFormHead:
          for(;;){
              for(Node<E> h = head,p = h,q;;){
                  //(2)
                  E item = p.item;
                  //(3)
                  if(item != null || (q = p.next) == null){
                      undateHead(h,p);
                      return item;
                  }
                  //(4)
                  else if(p == q)
                      continue restartFormHead;
                  else
                      //(5)
                      p = q;
              }
          }
      }
      
    • size操作

      计算当前队列元素个数,在并发环境下不是很有用,因为CAS没有加锁,所以从调用size函数到返回结果期间有可能增删元素,导致统计的元素个数不精确。

    • remove操作

      public boolean remove(Object o){
          //为空,则直接返回false
          if(o == null) return false;
          Node<E> pred = null;
          for(Node<E> p = first(); p != null;p = succ(p)){
              E item = p.item;
              
              //相等则使用CAS设置为null,同时一个线程操作成功,失败的线程循环查找队列中是否有匹配的其他元素
              if(item != null && o.equals(item) && p.casItem(item,null)){
                  //获取next元素
                  Node<E> next = succ(p);
                  
                  //如果有前驱节点,并且next节点不为空则链接前驱节点到next节点
                  if(pred != null && next != null)
                      pred.casNext(p,next);
                  return true;
              }
              pred = p;
          }
          return fasle;   
      }
      
    • contains操作

      判断队列里面是否含有指定对象,由于是遍历整个队列,所以像size操作一样结果也不是那么精确,有可能调用该方法时元素还在队列里面,但是遍历过程中其他线程才把该元素删除了,那么就会返回false。

    • 小结

      ConcurrentLinkedQueue的底层使用单向链表数据结构来保存队列元素,每个元素被包装成一个Node节点。队列是靠头、尾节点来维护的,创建队列时头、尾节点指向一个item为null的哨兵节点。第一次执行peek或者first操作时会把head指向第一个真正的队列元素。由于使用非阻塞CAS算法,没有加锁,所以在计算size时有可能进行了offer、poll或者remove操作,导致计算的元素个数不精确,所以在并发情况下size函数不是很有用。

      offer操作是在tail后面添加元素,也就是调用tail.casNext方法,而这个方法使用的是CAS操作,只有一个线程会成功,然后失败的线程会循环,重新获取tail,再执行casNext方法。poll操作也通过类似CAS的算法保证出队时移除节点操作的原子性。

2. LinkedBlockingQueue原理探究

前面介绍了使用CAS算法实现的非阻塞队列ConcurrentLinkedQueue,下面我们来介绍使用独占锁实现的阻塞队列LinkedBlockingQueue。

  • 类图结构

    LinkedBlockingQueue类图

    由类图可以看到,LinkedBlockingQueue也是使用单向链表实现的,其也有两个Node,分别用来存放首、尾节点,并且还有一个初始值为0的原子变量count,用来记录队列元素个数。另外还有两个ReentrantLock的实例,分别用来控制元素入队和出队的原子性,其中takeLock用来控制同时只有一个线程可以从队列头获取元素,其他线程必须等待,putLock控制同时只能有一个线程可以获取锁,在队列尾部添加元素,其他线程必须等待。另外,notEmpty和notFull是条件变量,它们内部都有一个条件队列用来存放进队和出队时被阻塞的线程,其实这是生产者—消费者模型。

    独占锁的创建代码

    image-20220808103341418

    默认队列容量为0x7fffffff,用户也可以自己指定容量,所以从一定程度上可以说LinkedBlockingQueue是有界阻塞队列。

  • LinkedBlockingQueue原理介绍

    • offer操作

      向队列尾部插入一个元素,如果队列中有空闲则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false。如果e元素为null则抛出NullPointerException异常。另外,该方法是非阻塞的。

      public boolean offer(E e){
          //(1)为空元素则抛出空指针异常
          if(e == null) throw new NullPointerException();
          
          //(2)如果当前队列满则丢弃将要放入的元素,然后返回false
          final AtomicInteger count = this.count;
          if(count.get() == capacity)
              return false;
          //(3)构造新结点,获取pubLock独占锁
          int c = -1;
          Node<E> node = new Node<E>(e);
          final ReentrantLock putLock = this.putLock;
          putLock.lock();
          try{
              //(4)如果队列不满则近队列,并递增元素计数
              if(count.get() < capacity){
                  enquene(node);
                  c = count.getAndIncrement();
                  //(5)
                  if(c + 1 < capacity)
                      notFull.signal();
              }
          }finally{
              //(6)释放锁
              putLock.unlock();
          }
          //(7)
          if(c == 0)
              signalNotEmpty();
          //(8)
          return c >= 0;
       }
      
      private void enqueue(Node<E> node){
          last = last.next = node;
      }
      

      offer方法通过使用putLock锁保证了在队尾新增元素操作的原子性。另外,调用条件变量的方法前一定要记得获取对应的锁,并且注意进队时只操作队列链表的尾节点。

    • put操作

      代码结构与offer方法类似,但是put操作在获取锁的时候是可以被中断的。

      使用while循环判断队列是否已满,避免虚假唤醒。

    • poll操作

      从队列头部获取并移除一个元素,如果队列为空则返回null,该方法是不阻塞的。

    • peek操作

      获取队列头部元素但是不从队列里面移除它,如果队列为空则返回null。该方法是不阻塞的。

      public E peek(){
          //(1)
          if(count.get() == 0)
              return null;
          //(2)
          final ReentrantLock takeLock = this.takeLock;
          takeLock.lock();
          try{
              Node<E> first = head.next;
              //(3)
              if(first == null)
                  return null;
              else
                  return first.item;
          }finally{
              takeLock.unLock();
          }
      }
      
    • take操作

      获取当前队列头部元素并从队列里面移除它。如果队列为空则阻塞当前线程直到队列不为空然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。

    • remove操作

      删除队列里面指定的元素,有则删除并返回true,没有则返回false。

      由于remove方法在删除指定元素前加了两把锁,所以在遍历队列查找指定元素的过程中是线程安全的,并且此时其他调用入队、出队操作的线程全部会被阻塞。另外,获取多个资源锁的顺序与释放的顺序是相反的。

    • size操作

      由于进行出队、入队操作时的count是加了锁的,所以结果相比ConcurrentLinkedQueue的size方法比较准确。

3. ArrayBlockQueue原理探究

使用游街数组方式实现阻塞队列。

构造函数必须传入队列大小参数。

public ArrayBlockingQueue(int capacity){
    this(capacity,false);
}
public ArrayBlockingQueue(int capacity,boolean fair){
    if(capacity <= 0)
        throw new IlleaglArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull = lock.newCondition();

}

由以上代码可知,在默认情况下使用ReentrantLock提供的非公平独占锁进行出、入队操作的同步。

  • ArrayBlockingQueue原理介绍

    • offer操作

      向队列尾部插入一个元素,如果队列有空闲空间则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false。如果e元素为null则抛出NullPointerException异常。另外,该方法是不阻塞的。

    • put操作

      向队列尾部插入一个元素,如果队列有空闲则插入后直接返回true,如果队列已满则阻塞当前线程直到队列有空闲并插入成功后返回true,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。另外,如果e元素为null则抛出NullPointerException异常。

    • poll操作

      从队列头部获取并移除一个元素,如果队列为空则返回null,该方法是不阻塞的。

      首先获取当前队头元素并将其保存到局部变量,然后重置队头元素为null,并重新设置队头下标,递减元素计数器,最后发送信号激活notFull的条件队列里面一个因为调用put方法而被阻塞的线程。

    • take操作

      获取当前队列头部元素并从队列里面移除它。如果队列为空则阻塞当前线程直到队列不为空然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。

    • peek操作

      获取队列头部元素但是不从队列里面移除它,如果队列为空则返回null,该方法是不阻塞的。

    • size操作

      size操作比较简单,获取锁后直接返回count,并在返回前释放锁。

  • 小结

    ArrayBlockingQueue通过使用全局独占锁实现了同时只能有一个线程进行入队或者出队操作,这个锁的粒度比较大,有点类似于在方法上添加synchronized的意思。

4. PriorityBlockingQueue原理探究

PriorityBlockingQueue是带优先级的无界阻塞队列,每次出队都返回优先级最高或者最低的元素。其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默认使用对象的compareTo方法提供比较规则,如果你需要自定义比较规则则可以自定义comparators。

由于这是一个优先级队列,所以有一个比较器comparator用来比较元素大小。lock独占锁对象用来控制同时只能有一个线程可以进行入队、出队操作。notEmpty条件变量用来实现take方法阻塞模式。这里没有notFull条件变量是因为这里的put操作是非阻塞的,为啥要设计为非阻塞的,是因为这是无界队列。

  • 原理介绍

    • offer操作

      offer操作的作用是在队列中插入一个元素,由于是无界队列,所以一直返回true。

    • poll操作

      poll操作的作用是获取队列内部堆树的根节点元素,如果队列为空,则返回null。

    • put操作

      put操作内部调用的是offer操作,由于是无界队列,所以不需要阻塞。

    • take操作

      take操作的作用是获取队列内部堆树的根节点元素,如果队列为空则阻塞。

      首先通过lock.lockInterruptibly()获取独占锁,以这个方式获取的锁会对中断进行响应。然后调用dequeue方法返回堆树根节点元素,如果队列为空,则返回false。然后当前线程调用notEmpty.await()阻塞挂起自己,直到有线程调用了offer()方法(在offer方法内添加元素成功后会调用notEmpty.signal方法,这会激活一个阻塞在notEmpty的条件队列里面的一个线程)。另外,这里使用while循环而不是if语句是为了避免虚假唤醒。

    • size操作数

  • 案例介绍

    在这个案例中,会把具有优先级的任务放入队列,然后从队列里面逐个获取优先级最高的任务来执行。

    /*
    *如上代码首先创建了一个Task类,该类继承了Comparable方法并重写了compareTo方法,自定义了元素优先级比较规则。然后在main函数里面创建了一个优先级队列,并使用随机数生成器生成10个随机的有优先级的任务,并将它们添加到优先级队列。最后从优先级队列里面逐个获取任务并执行。
    **/
    public class TestProrityBlockingQueue{
        static class Task implements Comparable<Task>{
            public int getPriority(){
                return priority;
            }
            public void setProirity(int priority){
                this.priority = priority;
            }
            public int getTaskName(){
                return taskName;
            }
            public void setTaskName(int priority){
                this.TaskName = TaskName;
            }
            
            private int priority = 0;
            
            private String taskName;
            
            @override
            public int compareTo(Task o){
                if(this.priority >= o.getPriority()){
                    return 1;
                }else{
                    return -1;
                }
            }
            public void doSomeThing(){
                System.out.println(taskName + ":" + priority);
            }
        }
        
        public static void main(String[] args){
            //创建任务,并添加到队列
            PriorityBlockingQueue<Task> proorityQueue = new PriorityBlockingQueue<Task>();
            Random random = new Random();
            for(int i = 0; i < 10; ++i){
                Task task = new Task();
                task.setPriority(random.nextInt(10));
                task.setTaskName("taskName" + i);
                priorityQueue.offer(task);
                
                //取出任务执行
                while(!priorityQueue.isEmpty()){
                    Task task = priorityQueue.poll();
                    if(null != task){
                        task.doSomeThing();
                    }
                }
            }
        }
    }
    
  • 小结

    PriorityBlockingQueue队列在内部使用二叉树堆维护元素优先级,使用数组作为元素存储的数据结构,这个数组是可扩容的。当当前元素个数>=最大容量时会通过CAS算法扩容,出队时始终保证出队的元素是堆树的根节点,而不是在队列里面停留时间最长的元素。使用元素的compareTo方法提供默认的元素优先级比较规则,用户可以自定义优先级的比较规则。

5. DelayQueue原理探究

DelayQueue并发队列是一个无界阻塞延迟队列,队列中的每个元素都有个过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最快要过期的元素。

DelayQueue内部使用PriotiryQueue存放数据,使用ReentrantLock实现线程同步。另外,队列里的元素要实现Delayed接口,每个元素都有一个过期时间。

public interface Delayed extends Comparable<Delayed>{
    
    long getDelay(TimeUnit unit);
}
  • 主要函数原理讲解

    • offer操作

      插入元素到队列,如果插入元素为null则抛出NullPointerException异常,否则由于是无界队列,所以一直返回true。插入元素要实现Delayed接口。

    • take操作

      获取并移除队列里面延迟时间过期的元素,如果队列里面没有过期元素则等待。

      public E take() throws InterrupttedException{
          final ReentrantLock lock = this.lock;
          lock.lockInterruptibly();
          try{
              for(;;){
                  //获取但不移除队首元素
                  E first = q.peek();
                  if(first == null)
                      savailable.await(); //(2)
                  else{
                      
                  }
              }
          }
      }
      
    • poll操作

      获取并移除队头过期元素,如果没有过期元素则返回null。

      public E poll(){
          final ReentrantLock lock = this.lock;
          lock.lock();
          try{
              E first = q.peek();
              if(first == null || first.getDelay(TimeUnit.UANOSECONDS) > 0)
                  return null;
              else
                  return q.poll();
          }finally{
              lock.unlock();
          }
      }
      
    • size()操作

      计算队列元素个数,包含过期的和没有过期的。

      首先获取独占锁,然后调用优先级队列的size方法。

  • 案例介绍

    public class TestDelay{
        
        static class DelayedEle implements Delayed{
            
            private final long delayTime; //延迟时间
            private final long expire; //到期时间
            private String taskName; //任务名称
            
            public DelayedEle(long delay,String taskName){
                delayTime = delay;
                this.taskName = taskName;
                expire = System.currentTimeMills() + delay;
            }
            
            /**
            * 剩余时间 = 到期时间 - 当前时间
            */
            @override
            public long getDelay(TimeUnit unit){
                return unit.convert(this.expire - System.currentTimeMills(),TimeUnit.MILLISCONDS);
            }
            
            /**
            * 优先级队列里面的优先级规则
            */
            @override
            public int compareTo(Delayed o){
                return (int)(this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
            }
            
            @override
            public String toString(){
                final StringBuilder sb = new StringBuilder("DelayedELe{");
                sb.append("delay=").append(delayTime);
                sb.append(", expire=").append(exprie);
                sb.append(", taskName='").append(taskName).append('\'');
                sb.append('}');
                return sb.toString();
            }
        }
        
        public static void main(String[] args){
            
            //(1) 创建delay队列
            DelayQueue<DelayedEle> delayQueue = new DelayQueue<DelayedEle>();
            
            //(2)创建延迟任务
            Random random = new Random();
            for(int i = 0; i < 10; ++i){
                DelayedEle element = new DelayedEle(random.nextInt(500),"task:" + i);
                delayQueue.offer(elememt);
            }
            
            //(3)依次取出任务并打印
            DelayedEle ele = null;
            try{
                //(3.1)循环,如果想避免虚假唤醒,则不能吧全部元素都打印出来
                for(;;){
                    //(3.2)获取过期任务并打印
                    while((ele = delayQueue.take()) != null){
                        System.out.println(ele.toString());
                    }
                }
            }catch(InterruptedException e){
                e.printStackTrace();
            }
        }
    }
    

    出队的顺序和delay时间有关,而与创建任务的顺序无关。

  • 小结

    本节讲解了DelayQueue队列,其内部使用PriorityQueue存放数据,使用ReentrantLock实现线程同步。另外队列里面的元素要实现Delayed接口,其中一个是获取当前元素到过期时间剩余时间的接口,在出队时判断元素是否过期了,一个是元素之间比较的接口,因为这是一个有优先级的队列。

posted on 2022-08-12 10:32  小迟在努力  阅读(193)  评论(0)    收藏  举报