《实战java高并发程序设计》JDK的并发容器

《实战java高并发程序设计》JDK的并发容器


仅作为笔记


前言

仅作为笔记


0、容器与同步

0.1 关于容器

Java 中的容器主要可以分为四个大类,分别是 List、Map、Set 和 Queue,如下图:

在这里插入图片描述
但并不是所有的 Java 容器都是线程安全的。例如,我们常用的 ArrayList、HashMap 就不是线程安全的。在介绍线程安全的容器之前,我们先思考这样一个问题:如何将非线程安全的容器变成线程安全的容器?实现思路其实很简单,只要把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了。下面我们就以 ArrayList 为例,看看如何将它变成线程安全的。在下面的代码中,SafeArrayList 内部持有一个 ArrayList 的实例 c,所有访问 c 的方法我们都增加了synchronized 关键字,需要注意的是我们还增加了一个 addIfNotExist() 方法,这个方法也是用 synchronized 来保证原子性的。


SafeArrayList<T>{
  //封装ArrayList
  List<T> c = new ArrayList<>();
  //控制访问路径
  synchronized
  T get(int idx){
    return c.get(idx);
  }

  synchronized
  void add(int idx, T t) {
    c.add(idx, t);
  }

  synchronized
  boolean addIfNotExist(T t){
    if(!c.contains(t)) {
      c.add(t);
      return true;
    }
    return false;
  }
}

这一点不止我们想到了,Java SDK 的开发人员是这么想的,所以他们在 Collections 这个类中还提供了一套完备的包装类,比如下面的示例代码中,分别把 ArrayList、HashSet 和 HashMap 包装成了线程安全的 List、Set 和 Map。


List list = Collections.
  synchronizedList(new ArrayList());
Set set = Collections.
  synchronizedSet(new HashSet());
Map map = Collections.
  synchronizedMap(new HashMap());

上面提到的这些经过包装后线程安全容器,都是基于 synchronized 这个同步关键字实现的,所以也被称为同步容器。Java 提供的同步容器还有 Vector、Stack 和 Hashtable,这三个容器不是基于包装类实现的,但同样是基于 synchronized 实现的,对这三个容器的遍历,同样要加锁保证互斥不过同步容器有个最大的问题,那就是性能差,所有方法都用 synchronized 来保证互斥,串行度太高了

一、容器和并发:并发集合简介

Java 在 1.5 及之后版本提供了性能更高的容器,我们一般称为并发容器。并发容器虽然数量非常多,但依然是前面我们提到的四大类:List、Map、Set 和 Queue,下面的并发容器关系图,基本上把我们经常用的容器都覆盖到了。
在这里插入图片描述

1.1 List

List 里面只有一个实现类就是 CopyOnWriteArrayList。CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁。那CopyOnWriteArrayList 的实现原理是怎样的呢?下面我们就来简单介绍一下

  • 读操作:CopyOnWriteArrayList 内部维护了一个数组,成员变量 array 就指向这个内部数组,所有的读操作都是基于 array进行的,如下图所示,迭代器 Iterator 遍历的就是 array 数组。
    在这里插入图片描述
  • 写操作:CopyOnWriteArrayList 会将 array 复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将 array 指向这个新的数组。通过下图你可以看到,读写是可以并行的,遍历操作一直都是基于原 array 执行,而写操作则是基于新 array 进行
    在这里插入图片描述
  • 注意事项
    一是应用场景,CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致
    二是CopyOnWriteArrayList 迭代器是只读的,不支持增删改

1.2 Map

Map 接口的两个实现是 ConcurrentHashMapConcurrentSkipListMap,它们从应用的角度来看,主要区别在于 ConcurrentHashMap 的 key 是无序的,而ConcurrentSkipListMap 的 key 是有序的。所以如果你需要保证 key 的顺序,就只能使用 ConcurrentSkipListMap。下表是Map相关的实现类对于key和value的要求:
在这里插入图片描述

  • ConcurrentSkipListMap 里面的 SkipList本身就是一种数据结构,中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(logn),理论上和并发线程数没有关系,所以在并发度非常高的情况下,若你对 ConcurrentHashMap 的性能还不满意,可以尝试一下ConcurrentSkipListMap。 原因是如果key冲突比较大hashmap还是要靠链表或者tree来解决冲突的,所以O(1)是理想值。同时增删改操作很多也影响hashmap性能。这个也是要看冲突情况。也就是说hashmap的稳定性差,如果很不幸正好偶遇它的稳定性问题,同时又接受不了,就可以尝试skiplistmap,它能保证稳定性,无论你的并发量是多大,也没有key冲突的问题
  • 关于ConcurrentHashMap的详情可以看这里详细解析

1.3 Set

Set 接口的两个实现是 CopyOnWriteArraySetConcurrentSkipListSet,使用场景可以参考前面讲述的 CopyOnWriteArrayList 和 ConcurrentSkipListMap,它们的原理都是一样的。

1.4、Queue

Java 并发包里面 Queue 这类并发容器是最复杂的,你可以从以下两个维度来分类。一个维度是阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞当队列已空时,出队操作阻塞。另一个维度是单端与双端单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。Java 并发包里阻塞队列都用 Blocking 关键字标识单端队列使用 Queue 标识双端队列使用 Deque 标识

  • 单端阻塞队列:其实现有
    ArrayBlockingQueue(数组)
    LinkedBlockingQueue(队列)
    SynchronousQueue(不持有队列,生产者线程的入队操作必须等待消费者线程的出队操作)
    LinkedTransferQueue(融合了LinkedBlockingQueue & SynchronousQueue)
    PriorityBlockingQueue(按照优先级出队)
    DelayQueue(支持延时出队)。

    内部一般会持有一个队列,这个队列可以是数组(其实现是 ArrayBlockingQueue)也可以是链表(其实现是 LinkedBlockingQueue);甚至还可以不持有队列(其实现是SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作而 LinkedTransferQueue 融合 LinkedBlockingQueue 和 SynchronousQueue 的功能性能比 LinkedBlockingQueue 更好;PriorityBlockingQueue 支持按照优先级出队;DelayQueue 支持延时出队

下图是单端阻塞队列示意图:
在这里插入图片描述

  • 双端阻塞队列:其实现是 LinkedBlockingDeque。如下图:
    在这里插入图片描述
  • 单端非阻塞队列:其实现是 ConcurrentLinkedQueue。
  • 双端非阻塞队列:其实现是 ConcurrentLinkedDeque。
  • 使用队列的注意事项:需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致OOM。上面我们提到的这些 Queue 中,只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的(这里的有界,是指生成队列的时候,在构造函数中指定队列的大小;如果不指定,则认为是无界的,实际上,即使调用了无界的构造函数,其队列的大小就是Integer.MAX_VALUE),所以在使用其他无界队列时,一定要充分考虑是否存在导致 OOM 的隐患。
  • 基于数组实现的ArrayBlockingQueue,适合作为有界队列。ArrayBlockingQueue将内容存放在一个对象数组中意味着他在删除和添加内容时并不会产生和销毁任何的额外对象实例。这个队列使用的锁是没有分离的重入锁,也就是其添加和移除操作使用的是同一把锁。
  • 基于node节点链表技术实现的LinkedBlockingQueue,本质上是一个链表这就意味着是可以扩张的,所以这也是任务队列的无界队列的实现根本。由于是采用的链表,所以在添加和移除操作时会产生一个额外的node对象,这会影响GC回收机制。这个实现队列中的锁采用的是分离锁,取出和添加操作使用的不是用一把锁,这样有利于极大的提高吞吐量有利于整个队列的性能提升

二、随机数据结构:跳表(SkipList)

  • 是一种随机数据结构,实质就是一种可以进行二分查找的有序链表(按理说链表是不可以直接进行二分查找的)
  • 跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。随机是体现在提取索引的方法是随机的,之所以采用随机是因为希望提取索引是趋于均匀的。
  • 那为什么是二分查找呢?因为提取索引是在前一层数量的一半。空间复杂度为O(n),时间复杂度是O(logn),是使用空间换取时间来提升性能的操作。可以提取多层索引(都必须是前一层的一半),直至只有两个索引节点为止。详细可参考这位大佬的解释知乎大佬解释的跳表的链接

三、快失败(fail-fast)和安全失败(fail-safe)

  • 快失败(fail-fast): 直接在容器上进行遍历,在遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出ConcurrentModificationException异常导致遍历失败。java.util包下的集合类都是快速失败机制的, 常见的的使用fail-fast方式遍历的容器有HashMap和ArrayList等

  • 安全失败(fail-safe):采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。常见的的使用fail-safe方式遍历的容器有ConcerrentHashMap和CopyOnWriteArrayList等
    缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

  • 快失败和安全失败的区别fail-safe允许在遍历的过程中对容器中的数据进行修改,而fail-fast则不允许。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值