【Java八股文之进阶篇(五)】多线程编程核心之并发容器

并发容器

注意:本版块的重点在于探究并发容器是如何利用锁机制和算法实现各种丰富功能的,我们会忽略一些常规功能的实现细节(比如链表如何插入元素删除元素),而更关注并发容器应对并发场景算法上的实现(比如在多线程环境下的插入操作是按照什么规则进行的)

单线程模式下,集合类提供的容器可以说是非常方便了,几乎我们每个项目中都能或多或少的用到它们,我们在JavaSE阶段,为各位讲解了各个集合类的实现原理,我们了解了链表、顺序表、哈希表等数据结构,那么,在多线程环境下,这些数据结构还能正常工作吗?

传统容器线程安全吗

我们来测试一下,100个线程同时向ArrayList中添加元素会怎么样:

public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        Runnable r = () -> {
            for (int i = 0; i < 100; i++)
                list.add("lbwnb");
        };
        for (int i = 0; i < 100; i++)
            new Thread(r).start();
      	TimeUnit.SECONDS.sleep(1);//保证100个线程执行完
        System.out.println(list.size());
    }
}

执行结果:

Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: 73
	at java.util.ArrayList.add(ArrayList.java:463)
	at javase.day15.ThreadTest01.lambda$main$0(ThreadTest01.java:20)
	at java.lang.Thread.run(Thread.java:748)
9900

Process finished with exit code 0

虽然运气好的时候会执行成功,输出10000
那么我们来看看报的什么错,从栈追踪信息可以看出,是add方法出现了问题:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;   //这一句出现了数组越界
    return true;
}

也就是说,同一时间其他线程也在疯狂向数组中添加元素,那么这个时候有可能在ensureCapacityInternal(确认容量足够)执行之后,elementData[size++] = e;执行之前,其他线程插入了元素,导致size的值超出了数组容量。这些在单线程的情况下不可能发生的问题,在多线程下就慢慢出现了。

我们再来看看比较常用的HashMap呢?

public static void main(String[] args) throws InterruptedException {
    Map<Integer, String> map = new HashMap<>();
    for (int i = 0; i < 100; i++) {
        int finalI = i;
        new Thread(() -> {
            for (int j = 0; j < 100; j++)
                map.put(finalI * 1000 + j, "lbwnb");
        }).start();
    }
    TimeUnit.SECONDS.sleep(2);
    System.out.println(map.size());
}

预期结构应该是:10000
运行结果:9884 9943 …

经过测试发现,虽然没有报错,但是最后的结果并不是我们期望的那样,实际上它还有可能导致Entry对象出现环状数据结构,引起死循环。

所以,在多线程环境下,要安全地使用集合类,我们得找找解决方案了。

并发容器介绍

怎么才能解决并发情况下的容器问题呢?我们首先想到的肯定是给方法前面加个synchronzed关键字,这样总不会抢了吧,在之前我们可以使用Vector或是Hashtable来解决,但是它们的效率实在是太低了,完全依靠锁来解决问题,因此现在已经很少再使它们了,这里也不会再去进行讲解。

JUC提供了专用于并发场景下的容器,比如我们刚刚使用的ArrayList,在多线程环境下是没办法使用的,我们可以将其替换为JUC提供的多线程专用集合类:

public static void main(String[] args) throws InterruptedException {
    List<String> list = new CopyOnWriteArrayList<>();  //这里使用CopyOnWriteArrayList来保证线程安全
    Runnable r = () -> {
        for (int i = 0; i < 100; i++)
            list.add("lbwnb");
    };
    for (int i = 0; i < 100; i++)
        new Thread(r).start();
    TimeUnit.SECONDS.sleep(1);
    System.out.println(list.size());
}

运行结果:
10000
我们发现,使用了CopyOnWriteArrayList之后,再没出现过上面的问题。

那么它是如何实现的呢,我们先来看看它是如何进行add()操作的:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();   //直接加锁,保证同一时间只有一个线程进行添加操作
    try {
        Object[] elements = getArray();  //获取当前存储元素的数组
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);   //直接复制一份数组
        newElements[len] = e;   //修改复制出来的数组
        setArray(newElements);   //将元素数组设定为复制出来的数组
        return true;
    } finally {
        lock.unlock();
    }
}

可以看到添加操作是直接上锁,并且会先拷贝一份当前存放元素的数组,然后对数组进行修改,再将此数组替换(CopyOnWrite)接着我们来看读操作:

public E get(int index) {
    return get(getArray(), index);
}

因此,CopyOnWriteArrayList对于读操作不加锁,而对于写操作是加锁的,类似于我们前面讲解的读写锁机制,这样就可以保证不丢失读性能的情况下,写操作不会出现问题。

接着我们来看对于HashMap的并发容器ConcurrentHashMap

public static void main(String[] args) throws InterruptedException {
    Map<Integer, String> map = new ConcurrentHashMap<>();
    for (int i = 0; i < 100; i++) {
        int finalI = i;
        new Thread(() -> {
            for (int j = 0; j < 100; j++)
                map.put(finalI * 100 + j, "lbwnb");
        }).start();
    }
    TimeUnit.SECONDS.sleep(1);
    System.out.println(map.size());
}

运行结果:10000

可以看到这里的ConcurrentHashMap就没有出现之前HashMap的问题了。因为线程之间会争抢同一把锁,我们之前在讲解LongAdder的时候学习到了一种压力分散思想,既然每个线程都想抢锁,那我就干脆多搞几把锁,让你们每个人都能拿到,这样就不会存在等待的问题了,而JDK7之前,ConcurrentHashMap的原理也比较类似,它将所有数据分为一段一段地存储,先分很多段出来,每一段都给一把锁,当一个线程占锁访问时,只会占用其中一把锁,也就是仅仅锁了一小段数据,而其他段的数据依然可以被其他线程正常访问。在一定程度上提高了效率。
image-20220308165304048
这里我们重点讲解JDK8之后它是怎么实现的,它采用了CAS算法配合锁机制实现,我们先来回顾一下JDK8下的HashMap是什么样的结构:

img

HashMap就是利用了哈希表哈希表的本质其实就是一个用于存放后续节点的头结点的数组,数组里面的每一个元素都是一个头结点(也可以说就是一个链表),当要新插入一个数据时,会先计算该数据的哈希值,找到数组下标,然后创建一个新的节点,添加到对应的链表后面。当链表的长度达到8时,会自动将链表转换为红黑树,这样能使得原有的查询效率大幅度降低!当使用红黑树之后,我们就可以利用二分搜索的思想,快速地去寻找我们想要的结果,而不是像链表一样挨个去看。

又是基础不牢地动山摇环节,由于ConcurrentHashMap的源码比较复杂,所以我们先从最简单的构造方法开始下手:
image-20220308214006830
我们发现,它的构造方法和HashMap的构造方法有很大的出入,但是大体的结构和HashMap是差不多的,也是维护了一个哈希表,并且哈希表中存放的是链表或是红黑树,所以我们直接来看put()操作是如何实现的,只要看明白这个,基本上就懂了:

public V put(K key, V value) {
    return putVal(key, value, false);
}

//有点小乱,如果看着太乱,可以在IDEA中折叠一下代码块,不然有点难受
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException(); //键值不能为空,基操
    int hash = spread(key.hashCode());    //计算键的hash值,用于确定在哈希表中的位置
    int binCount = 0;   //一会用来记录链表长度的,忽略
    for (Node<K,V>[] tab = table;;) {    //无限循环,而且还是并发包中的类,盲猜一波CAS自旋锁
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();    //如果数组(哈希表)为空肯定是要进行初始化的,然后再重新进下一轮循环
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {   //如果哈希表该位置为null,直接CAS插入结点作为头结即可(注意这里会将f设置当前哈希表位置上的头结点)
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))  
                break;                   // 如果CAS成功,直接break结束put方法,失败那就继续下一轮循环
        } else if ((fh = f.hash) == MOVED)   //头结点哈希值为-1,这里只需要知道是因为正在扩容即可
            tab = helpTransfer(tab, f);   //帮助进行迁移,完事之后再来下一次循环
        else {     //特殊情况都完了,这里就该是正常情况了,
            V oldVal = null;
            synchronized (f) {   //在前面的循环中f肯定是被设定为了哈希表某个位置上的头结点,这里直接把它作为锁加锁了,防止同一时间其他线程也在操作哈希表中这个位置上的链表或是红黑树
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {    //头结点的哈希值大于等于0说明是链表,下面就是针对链表的一些列操作
                        ...实现细节略
                    } else if (f instanceof TreeBin) {   //肯定不大于0,肯定也不是-1,还判断是不是TreeBin,所以不用猜了,肯定是红黑树,下面就是针对红黑树的情况进行操作
                      	//在ConcurrentHashMap并不是直接存储的TreeNode,而是TreeBin
                        ...实现细节略
                    }
                }
            }
          	//根据链表长度决定是否要进化为红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);   //注意这里只是可能会进化为红黑树,如果当前哈希表的长度小于64,它会优先考虑对哈希表进行扩容
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

很复杂,之后有时间再研究。【先完成后完美
先贴一张图:
image-20220308230825627
我们接着来看看get()操作:

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());   //计算哈希值
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
      	// 如果头结点就是我们要找的,那直接返回值就行了
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
      	//要么是正在扩容,要么就是红黑树,负数只有这两种情况
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
      	//确认无误,肯定在列表里,开找
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
  	//没找到只能null了
    return null;
}

综上,ConcurrentHashMap的put操作,实际上是对哈希表上的所有头结点元素分别加锁,理论上来说哈希表的长度很大程度上决定了ConcurrentHashMap在同一时间能够处理的线程数量,这也是为什么treeifyBin()会优先考虑为哈希表进行扩容的原因。显然,这种加锁方式比JDK7的分段锁机制性能更好。

其实这里也只是简单地介绍了一下它的运行机制,ConcurrentHashMap真正的难点在于扩容和迁移操作,我们主要了解的是他的并发执行机制,有关它的其他实现细节,这里暂时不进行讲解。

并发本质上就是多个线程操作同一个资源。

阻塞队列

除了我们常用的容器类之外,JUC还提供了各种各样的阻塞队列,用于不同的工作场景

阻塞队列本身也是队列,但是它是适用于多线程环境下的,基于ReentrantLock实现的,它的接口定义如下:

public interface BlockingQueue<E> extends Queue<E> {
   	boolean add(E e);

    //入队,如果队列已满,返回false否则返回true(非阻塞)
    boolean offer(E e);

    //入队,如果队列已满,阻塞线程直到能入队为止
    void put(E e) throws InterruptedException;

    //入队,如果队列已满,阻塞线程直到能入队或超时、中断为止,入队成功返回true否则false
    boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException;

    //出队,如果队列为空,阻塞线程直到能出队为止
    E take() throws InterruptedException;

    //出队,如果队列为空,阻塞线程直到能出队超时、中断为止,出队成功正常返回,否则返回null
    E poll(long timeout, TimeUnit unit)
        throws InterruptedException;

    //返回此队列理想情况下(在没有内存或资源限制的情况下)可以不阻塞地入队的数量,如果没有限制,则返回 Integer.MAX_VALUE
    int remainingCapacity();//剩余容量

    boolean remove(Object o);

    public boolean contains(Object o);

  	//一次性从BlockingQueue中获取所有可用的数据对象(还可以指定获取数据的个数)
    int drainTo(Collection<? super E> c);

    int drainTo(Collection<? super E> c, int maxElements);

比如现在有一个容量为3的阻塞队列,这个时候一个线程put向其添加了三个元素,第二个线程接着put向其添加三个元素,那么这个时候由于容量已满,会直接被阻塞,而这时第三个线程从队列中取走2个元素,线程二停止阻塞,先丢两个进去,还有一个还是进不去,所以说继续阻塞。(三个线程同事操作一个队列,然后这个队列底层是基于锁实现的,所以是线程安全的)
image-20220309165644403
利用阻塞队列,我们可以轻松地实现消费者和生产者模式。

所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架有货物的时候,消费者可以从货架上拿走商品,生产者此时等待货架出现空位,进而补货,这样不断的循环。

通过多线程编程,来模拟一个餐厅的2个厨师(两个生产者)和3个顾客(三个消费者),假设厨师炒出一个菜的时间为3秒,顾客吃掉菜品的时间为4秒,窗口上只能放一个菜。

我们来看看,使用阻塞队列如何实现,这里我们就使用ArrayBlockingQueue实现类:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<Object> queue = new ArrayBlockingQueue<>(1);//五个线程共同操作的资源
        Runnable supplier = () -> {
            while (true){//循环的出餐
                try {
                    String name = Thread.currentThread().getName();
                    System.out.println(time()+"生产者 "+name+" 正在准备餐品...");
                    TimeUnit.SECONDS.sleep(3);
                    System.out.println(time()+"生产者 "+name+" 已出餐!");
                    queue.put(new Object());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        };
        Runnable consumer = () -> {
            while (true){//循环的取餐
                try {
                    String name = Thread.currentThread().getName();
                    System.out.println(time()+"消费者 "+name+" 正在等待出餐...");
                    queue.take();//出队,如果为空,该线程就阻塞,直到出队成功。
                    System.out.println(time()+"消费者 "+name+" 取到了餐品。");
                    TimeUnit.SECONDS.sleep(4);
                    System.out.println(time()+"消费者 "+name+" 已经将饭菜吃完了!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        };
        for (int i = 0; i < 2; i++) new Thread(supplier, "Supplier-"+i).start();
        for (int i = 0; i < 3; i++) new Thread(consumer, "Consumer-"+i).start();
    }

    private static String time(){
        SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss");
        return "["+format.format(new Date()) + "] ";
    }
}

运行结果:

[19:17:39] 生产者 Supplier-0 正在准备餐品...
[19:17:39] 生产者 Supplier-1 正在准备餐品...
[19:17:39] 消费者 Consumer-2 正在等待出餐...
[19:17:39] 消费者 Consumer-1 正在等待出餐...
[19:17:39] 消费者 Consumer-0 正在等待出餐...
[19:17:42] 生产者 Supplier-0 已出餐!
[19:17:42] 生产者 Supplier-1 已出餐!
[19:17:42] 生产者 Supplier-0 正在准备餐品...//从这句话看出,明显是线程Supplier-0出餐成功(抢到put方法),然后又开始准备餐品。Supplier-1阻塞在put方法。
[19:17:42] 消费者 Consumer-2 取到了餐品。//Consumer-2抢到了锁,其它两个线程手速慢,被阻塞在take方法。
[19:17:42] 生产者 Supplier-1 正在准备餐品...//刚刚被阻塞的Supplier-1立即执行put方法,因为Supplier-0在等待3秒
[19:17:42] 消费者 Consumer-1 取到了餐品。//Consumer-1抢到了锁,其它两个线程一个在等待4秒(吃饭),另外一个手速慢,被阻塞在take方法。
[19:17:45] 生产者 Supplier-1 已出餐!//
[19:17:45] 生产者 Supplier-0 已出餐!//此时Supplier-0和1在争抢put方法。注意:此时队列是空的,当然一个线程(Supplier-1)直接使用put方法。
[19:17:45] 生产者 Supplier-1 正在准备餐品...//直接使用put方法。
[19:17:45] 生产者 Supplier-0 正在准备餐品...//这句话是对应下面这句的,Consumer-0一取到餐,Supplier-0就放好了下一餐(使用put)
[19:17:45] 消费者 Consumer-0 取到了餐品。//抢到餐。其他两个还在吃。
[19:17:46] 消费者 Consumer-1 已经将饭菜吃完了!
[19:17:46] 消费者 Consumer-2 已经将饭菜吃完了!
[19:17:46] 消费者 Consumer-1 正在等待出餐...
[19:17:46] 消费者 Consumer-2 正在等待出餐...
[19:17:46] 消费者 Consumer-1 取到了餐品。//取的是Supplier-0的餐。
[19:17:48] 生产者 Supplier-0 已出餐!
[19:17:48] 生产者 Supplier-1 已出餐!

Process finished with exit code 130

整个流程需要好好捋,还是很清晰的。

可以看到,阻塞队列在多线程环境下的作用是非常明显的,算上ArrayBlockingQueue,一共有三种常用的阻塞队列:

  • ArrayBlockingQueue:有界带缓冲阻塞队列(就是队列是有容量限制的,装满了肯定是不能再装的,只能阻塞,数组实现
  • SynchronousQueue:无缓冲阻塞队列(相当于没有容量的ArrayBlockingQueue,因此只有阻塞的情况
  • LinkedBlockingQueue:无界带缓冲阻塞队列(没有容量限制,也可以限制容量也会阻塞,链表实现
    现在只要知道阻塞队列底层是通过可重入锁实现的就行。至于源码,之后有时间再看。【时间有限,先完成再完美

为了博客的完整性,我先把博客写完。
这里我们以ArrayBlockingQueue为例进行源码解读,我们先来看看构造方法:

final ReentrantLock lock;

private final Condition notEmpty;

private final Condition notFull;

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);   //底层采用锁机制保证线程安全性,这里我们可以选择使用公平锁或是非公平锁
    notEmpty = lock.newCondition();   //这里创建了两个Condition(都属于lock)一会用于入队和出队的线程阻塞控制
    notFull =  lock.newCondition();
}

接着我们来看putoffer方法是如何实现的:

public boolean offer(E e) {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;    //可以看到这里也是使用了类里面的ReentrantLock进行加锁操作
    lock.lock();    //保证同一时间只有一个线程进入
    try {
        if (count == items.length)   //直接看看队列是否已满,如果没满则直接入队,如果已满则返回false
            return false;
        else {
            enqueue(e);
            return true;
        }
    } finally {
        lock.unlock();
    }
}

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();
    }
}
private E dequeue() {
    // assert lock.getHoldCount() == 1;
    // assert items[takeIndex] != null;
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal();    //出队操作会调用notFull的signal方法唤醒被挂起处于等待状态的线程
    return x;
}

接着我们来看出队操作:

public E poll() {
    final ReentrantLock lock = this.lock;
    lock.lock();    //出队同样进行加锁操作,保证同一时间只能有一个线程执行
    try {
        return (count == 0) ? null : dequeue();   //如果队列不为空则出队,否则返回null
    } finally {
        lock.unlock();
    }
}

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();    //可以响应中断进行加锁
    try {
        while (count == 0)
            notEmpty.await();    //和入队相反,也是一直等直到队列中有元素之后才可以出队,在入队时会唤醒此线程
        return dequeue();
    } finally {
        lock.unlock();
    }
}
private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    notEmpty.signal();    //对notEmpty的signal唤醒操作
}

可见,对锁的使用非常熟悉的话,那么在阅读这些源码的时候,就会非常轻松了。
同时,阅读源码也有助于夯实基础。千万不能出现基础不牢,地洞山摇的情况。

这里再提一嘴,对于阻塞队列主要是学会他的引用场景【参考狂神的笔记】,然后底层源码【参考青空の霞光的笔记】了解能说个大概就行,掌握程度根据时间把握,目前阶段先完成所有的最小必要知识的记录。之后再不断扩充【知识的广度和深度

到此,有关并发容器的讲解就到这里。

下篇继续线程池以及并发工具类。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kplusone

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值