很多原来的数据结构, 在并发编程中无法直接使用, 因为没有特别的针对多线程进行过优化, 或者因为没有锁, 直接就不适合在多线程中使用.

可以通过加一把大锁来让所有对于原来的数据结构的操作变成支持并发, java就提供了这样一种方法, 现在就来看一看并发容器

  1. 并发容器概览
  2. 将非线程安全的容器转换成线程安全
  3. 阻塞队列与跳表

并发容器概览

容器基本上都在java.util.concurrent包中, 说明都是并发工具

涉及的主要工具有:

  1. ConcurrentHashMap<K, V>, 线程安全的HashMap
  2. CopyOnWriteArrayList<V>, 并发安全的List, 比Vector的性能要好很多. 还有一个亲戚CopyOnWriteArraySet<E>
  3. ConcurrentLinkedQueue<V>, 单向队列, 还有一个亲戚双端队列ConcurrentLinkedDeque.
  4. BlockingQueue<E>, 这是一个接口, 表示阻塞队列, 有一个亲戚BlockingDeque<E>, 阻塞(也有不阻塞方法), 实现类有ArrayBlockingQueue, DelayQueue, LinkedBlockingDeque, LinkedBlockingQueue, LinkedTransferQueue, PriorityBlockingQueue, SynchronousQueue等一堆.
  5. ConcurrentSkipListMap<K,V>, 这是一个特别的数据结构叫做跳表, 本质是一个多层链表, 实现的功能类似于Map, 实际中性能还不错.

另外java.util.vector是线程安全的, 不过现在基本上也没人用了.

将非线程安全的容器转换成线程安全

除了并发容器之外, java提供了将原来非线程安全的容器转换成线程安全容器的方法, 就是Collection对应的工具类Collections中提供的一系列方法:

  1. synchronizedList
  2. synchronizedMap
  3. synchronizedSet
  4. synchronizedCollection

这些方法都接受对应的接口类型, 然后返回这个接口类型的一个对象, 其本质是用一个大锁对所有操作加锁, 自然就线程安全了, 但是效率也变得低下. 写个简单的例子:

import java.util.*;

public class ConcurrentTools {

    public static Map<String, Integer> unsafeMap = new HashMap<>();

    //线程类, 从unsafeMap中读一个键, 如果没有读到就设置为0, 读到就将其增加1
    public static class Worker implements Runnable {
        @Override
        public void run() {
            if (unsafeMap.containsKey("saner")) {
                unsafeMap.put("saner", unsafeMap.get("saner") + 1);
            } else {
                unsafeMap.put("saner", 0);
            }
        }
    }


    public static void main(String[] args) throws InterruptedException {

        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < 20000; i++) {
            Thread thread = new Thread(new Worker());
            threads.add(thread);
        }

        for (Thread t : threads) {
            t.start();
        }

        for (Thread t : threads) {
            t.join();
        }

        System.out.println(unsafeMap.get("saner"));
    }

}

这里如果单线程执行, 结果应该是19999. 但是多线程情况下很难得到19999, 大部分时候都会出现问题. 现在改用Collections方法:

public static Map<String, Integer> unsafeMap = Collections.synchronizedMap(new HashMap<>());

这个时候惊奇的发现, 依然还是有问题(换成ConcurrentHashMap一样有问题), 这是因为并发容器的每个方法虽然都加了锁, 但依然无法让两个线程能够每次都读取到变化后的结果.

所以实际上, 还是得加锁, 要修改成:

public void run() {
    synchronized (unsafeMap) {
        if (unsafeMap.containsKey("saner")) {
            unsafeMap.put("saner", unsafeMap.get("saner") + 1);
        } else {
            unsafeMap.put("saner", 0);
        }
    }
}

所以相比加大锁的方式, 还是使用并发包中提供的比较好, 并发数据容器都进行了特殊的优化, 还是直接使用比较好.

阻塞队列与跳表

并发的其他容器没有什么太多好说的, 只是可以避免结构被多线程破坏, 但是使用的时候依然要注意更新的时候加锁.

这里特别要提到阻塞对象BlockingQueue, 这个实现有很多种, 但不管各种实现的具体形态, 这个接口有几个方法要知道:

  1. offer(),非阻塞的添加元素, 如果满了会立刻返回false
  2. put(),添加元素, 如果队列满了会阻塞, 知道生产者消费者模型中的中间队列数据结构, 就会知道这其中使用了两个条件变量, put()就是让线程在其中一个上进行等待, 被拿取线程唤醒.
  3. poll(),非阻塞的获取元素, 如果队列为空直接会返回null.
  4. take(), 阻塞的获取元素, 如果队列为空, 会一直等待, 直到被放入线程放入之后唤醒.

生产者消费者果然是一大模型, 这个阻塞队列其中就是生产消费的原理, 也就是使用这个阻塞队列, 就可以模拟生产者消费者模型了, 而且同时提供非阻塞和阻塞的方法, 也非常方便.

还一个值的一看的就是跳表, 这个跳表我也是第一次听说. 原来就是一个合理组织过的多层链表. 同时不像普通的HashMap, 这玩意的遍历竟然还是有序的.

跳表相比树, 最大的好处就是可以使用局部锁而不是全局锁来进行操作, 但是会维护额外的多层链表, 因此是一种以时间换空间的做法.