并发编程(十)J.U.C之 LinkedBlockingQueue 、ConcurrentLinkedQueue 、CopyOnWriteArrayList

1. LinkedBlockingQueue 原理

    public class LinkedBlockingQueue<E> extends AbstractQueue<E>
            implements BlockingQueue<E>, java.io.Serializable {
        
        // Node为LinkedBlockingQueue内部的节点
        static class Node<E> { E item;
            /**
             * 下列三种情况之一
             * - 真正的后继节点
             * - 自己, 发生在出队时
             * - null, 表示是没有后继节点, 是最后了
             */
            Node<E> next;
            Node(E x) { item = x; }
        }
    }

初始化链表 last = head = new Node(null); Dummy 节点用来占位,itemnull
1601294235031

1.1 基本入队出队

入队

当一个节点入队 last = last.next = node
1601294275879
再来一个节点入队 last = last.next = node;
1601294297494

出队

    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;

h = head
1601294364337
first = h.next
1601294494032
h.next = h
1601294509473
head = first
1601294533137

E x = first.item;
first.item = null;
return x;

1601294561953

1.2 加锁分析

高明之处在于用了两把锁和 dummy 节点

  1. 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
  2. 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
    1. 消费者与消费者线程仍然串行
    2. 生产者与生产者线程仍然串行
  3. 线程安全分析
    1. 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是head 节点的线程安全。两把锁保证了入队和出队没有竞争
    2. 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
    3. 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
// 用于 put(阻塞) offer(非阻塞)
private final ReentrantLock putLock = new ReentrantLock();
// 用户 take(阻塞) poll(非阻塞)
private final ReentrantLock takeLock = new ReentrantLock();

put 操作


    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        // count 用来维护元素计数
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            // 满了等待
            while (count.get() == capacity) {
                // 倒过来读就好: await notFull
                notFull.await();
            }
            // 有空位, 入队且计数加一
            enqueue(node);
            // 返回的是c加1之前的数值
            c = count.getAndIncrement();
            // 除了自己 put 以外, 如果队列还有空位, 由自己叫醒其他 put 线程
            // 这里与其它地方不同的是,put线程的唤醒是由其它put线程唤醒的;
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        // 如果队列中只有一个元素, 叫醒 take 线程
        if (c == 0)
            // 这里调用的是 notEmpty.signal() 而不是 notEmpty.signalAll() 是为了减少竞争
            signalNotEmpty();
    }

take 操作

    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)
                // 唤醒其它take线程
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        // 如果队列中只有一个空位时, 叫醒 put 线程
        // 如果有多个线程进行出队, 第一个线程满足 c == capacity, 但后续线程 c < capacity
        if (c == capacity)
            // 这里调用的是 notFull.signal() 而不是 notFull.signalAll() 是为了减少竞争
            signalNotFull();
        return x;
    }

1.3 与ArrayBlockingQueue的性能比较

主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较

  1. Linked 支持有界,Array 强制有界
  2. Linked 实现是链表,Array 实现是数组
  3. Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
  4. Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
  5. Linked 两把锁,Array 一把锁

2. ConcurrentLinkedQueue 原理

ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是

  1. 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
  2. dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
  3. 只是这【锁】使用了 cas 来实现

事实上,ConcurrentLinkedQueue 应用还是非常广泛的
例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用
1601297921132

3. CopyOnWriteArrayList

CopyOnWriteArraySet 是它的马甲

底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离。 以新增为例


    public boolean add(E e) {
        synchronized (lock) {
            // 获取旧的数组
            Object[] es = getArray();
            int len = es.length;
            // 拷贝新的数组(这里是比较耗时的操作,但不影响其它读线程)
            es = Arrays.copyOf(es, len + 1);
            // 添加新元素
            es[len] = e;
            // 替换旧的数组
            setArray(es);
            return true;
        }
    }

这里的源码版本是 Java 11,在 Java 1.8 中使用的是可重入锁而不是 synchronized

其它读操作并未加锁,例如:

public void forEach(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        for (Object x : getArray()) {
            @SuppressWarnings("unchecked") E e = (E) x;
            action.accept(e);
        }
    }

适合『读多写少』的应用场景

get 弱一致性

1601300830903

时间点操作
1Thread-0 getArray()
2Thread-1 getArray() 先得到数组再进行拷贝
3Thread-1 setArray(arrayCopy) 修改完数组再将数组设置回去
4Thread-0 array[index] 读到的还是旧数组的信息,就是说array[0]虽然被Thread-1删除了,但是还是能Thread-0可以读取到

不容易测试,但问题确实存在

迭代器弱一致性

        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        Iterator<Integer> iter = list.iterator();
        new Thread(() -> {
            list.remove(0);
            System.out.println(list);
        }).start();
        sleep1s();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }

不要觉得弱一致性就不好

  1. 数据库的 MVCC 都是弱一致性的表现
  2. 并发高和一致性是矛盾的,需要权衡
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值