当前位置: 首页 > news >正文

计算机程序的思维逻辑 (73) - 并发容器 - 写时拷贝的List和Set

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》(马俊昌著),由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买:京东自营链接

本节以及接下来的几节,我们探讨Java并发包中的容器类。本节先介绍两个简单的类CopyOnWriteArrayList和CopyOnWriteArraySet,讨论它们的用法和实现原理。它们的用法比较简单,我们需要理解的是它们的实现机制,Copy-On-Write,即写时拷贝或写时复制,这是解决并发问题的一种重要思路。

CopyOnWriteArrayList

基本用法

CopyOnWriteArrayList实现了List接口,它的用法与其他List如ArrayList基本是一样的,它的区别是:

  • 它是线程安全的,可以被多个线程并发访问
  • 它的迭代器不支持修改操作,但也不会抛出ConcurrentModificationException
  • 它以原子方式支持一些复合操作

我们在66节提到过基于synchronized的同步容器的几个问题。迭代时,需要对整个列表对象加锁,否则会抛出ConcurrentModificationException,CopyOnWriteArrayList没有这个问题,迭代时不需要加锁。在66节,示例部分代码为:

public static void main(String[] args) {
    final List<String> list = Collections
            .synchronizedList(new ArrayList<String>());
    startIteratorThread(list);
    startModifyThread(list);
}
复制代码

将list替换为CopyOnWriteArrayList,就不会有异常,如:

public static void main(String[] args) {
    final List<String> list = new CopyOnWriteArrayList<>();
    startIteratorThread(list);
    startModifyThread(list);
}
复制代码

不过,需要说明的是,在Java 1.8之前的实现中,CopyOnWriteArrayList的迭代器不支持修改操作,也不支持一些依赖迭代器修改方法的操作,比如Collections的sort方法,看个例子:

public static void sort(){
    CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    list.add("c");
    list.add("a");
    list.add("b");
    Collections.sort(list);
}
复制代码

执行这段代码会抛出异常:

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.concurrent.CopyOnWriteArrayList$COWIterator.set(CopyOnWriteArrayList.java:1049)
    at java.util.Collections.sort(Collections.java:159)
复制代码

为什么呢?因为Collections.sort方法依赖迭代器的set方法,其代码为:

public static <T extends Comparable<? super T>> void sort(List<T> list) {
    Object[] a = list.toArray();
    Arrays.sort(a);
    ListIterator<T> i = list.listIterator();
    for (int j=0; j<a.length; j++) {
        i.next();
        i.set((T)a[j]);
    }
}
复制代码

基于synchronized的同步容器的另一个问题是复合操作,比如先检查再更新,也需要调用方加锁,而CopyOnWriteArrayList直接支持两个原子方法:

//不存在才添加,如果添加了,返回true,否则返回false
public boolean addIfAbsent(E e)
//批量添加c中的非重复元素,不存在才添加,返回实际添加的个数
public int addAllAbsent(Collection<? extends E> c)
复制代码

基本原理

CopyOnWriteArrayList的内部也是一个数组,但这个数组是以原子方式被整体更新的。每次修改操作,都会新建一个数组,复制原数组的内容到新数组,在新数组上进行需要的修改,然后以原子方式设置内部的数组引用,这就是写时拷贝。

所有的读操作,都是先拿到当前引用的数组,然后直接访问该数组,在读的过程中,可能内部的数组引用已经被修改了,但不会影响读操作,它依旧访问原数组内容。

换句话说,数组内容是只读的,写操作都是通过新建数组,然后原子性的修改数组引用来实现的。我们通过代码具体来看下。

内部数组声明为:

private volatile transient Object[] array;
复制代码

注意,它声明为了volatile,这是必需的,保证内存可见性,写操作更改了之后,读操作能看到。有两个方法用来访问/设置该数组:

final Object[] getArray() {
    return array;
}

final void setArray(Object[] a) {
    array = a;
}
复制代码

在CopyOnWriteArrayList中,读不需要锁,可以并行,读和写也可以并行,但多个线程不能同时写,每个写操作都需要先获取锁,CopyOnWriteArrayList内部使用ReentrantLock,成员声明为:

transient final ReentrantLock lock = new ReentrantLock();
复制代码

默认构造方法为:

public CopyOnWriteArrayList() {
    setArray(new Object[0]);
} 
复制代码

就是设置了一个空数组。

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();
    }
}
复制代码

代码也容易理解,add方法是修改操作,整个过程需要被锁保护,先拿到当前数组elements,然后复制了个长度加1的新数组newElements,在新数组中添加元素,最后调用setArray原子性的修改内部数组引用。

查找元素indexOf的代码为:

public int indexOf(Object o) {
    Object[] elements = getArray();
    return indexOf(o, elements, 0, elements.length);
}
复制代码

也是先拿到当前数组elements,然后调用另一个indexOf进行查找,其代码为:

private static int indexOf(Object o, Object[] elements,
                           int index, int fence) {
    if (o == null) {
        for (int i = index; i < fence; i++)
            if (elements[i] == null)
                return i;
    } else {
        for (int i = index; i < fence; i++)
            if (o.equals(elements[i]))
                return i;
    }
    return -1;
}
复制代码

这个indexOf方法访问的所有数据都是通过参数传递进来的,数组内容也不会被修改,不存在并发问题。

迭代器方法为:

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}
复制代码

COWIterator是内部类,传递给它的是不变的数组,它也只是读该数组,不支持修改。

其他方法的实现思路是类似的,我们就不赘述了。

小结

每次修改都创建一个新数组,然后复制所有内容,这听上去是一个难以令人接受的方案,如果数组比较大,修改操作又比较频繁,可以想象,CopyOnWriteArrayList的性能是很低的。事实确实如此,CopyOnWriteArrayList不适用于数组很大,且修改频繁的场景。它是以优化读操作为目标的,读不需要同步,性能很高,但在优化读的同时就牺牲了写的性能。

之前我们介绍了保证线程安全的两种思路,一种是锁,使用synchronized或ReentrantLock,另外一种是循环CAS,写时拷贝体现了保证线程安全的另一种思路。对于绝大部分访问都是读,且有大量并发线程要求读,只有个别线程进行写,且只是偶尔写的场合,这种写时拷贝就是一种很好的解决方案。

写时拷贝是一种重要的思维,用于各种计算机程序中,比如经常用于操作系统内部的进程管理和内存管理。在进程管理中,子进程经常共享父进程的资源,只有在写时在复制。在内存管理中,当多个程序同时访问同一个文件时,操作系统在内存中可能只会加载一份,只有程序要写时才会拷贝,分配自己的内存,拷贝可能也不会全部拷贝,而只会拷贝写的位置所在的页,页是操作系统管理内存的一个单位,具体大小与系统有关,典型大小为4KB。

CopyOnWriteArraySet

CopyOnWriteArraySet实现了Set接口,不包含重复元素,使用比较简单,我们就不赘述了。内部,它是通过CopyOnWriteArrayList实现的,其成员声明为:

private final CopyOnWriteArrayList<E> al;
复制代码

在构造方法中被初始化,如:

public CopyOnWriteArraySet() {
    al = new CopyOnWriteArrayList<E>();
}
复制代码

其add方法代码为:

public boolean add(E e) {
    return al.addIfAbsent(e);
}
复制代码

就是调用了CopyOnWriteArrayList的addIfAbsent方法。

contains方法代码为:

public boolean contains(Object o) {
    return al.contains(o);
}
复制代码

由于CopyOnWriteArraySet是基于CopyOnWriteArrayList实现的,所以与之前介绍过的Set的实现类如HashSet/TreeSet相比,它的性能比较低,不适用于元素个数特别多的集合。如果元素个数比较多,可以考虑ConcurrentHashMap或ConcurrentSkipListSet,这两个类,我们后续章节介绍。

ConcurrentHashMap与HashMap类似,适用于不要求排序的场景,ConcurrentSkipListSet与TreeSet类似,适用于要求排序的场景。Java并发包中没有与HashSet对应的并发容器,但可以很容易的基于ConcurrentHashMap构建一个,利用Collections.newSetFromMap方法即可。

小结

本节介绍了CopyOnWriteArrayList和CopyOnWriteArraySet,包括其用法和原理,它们适用于读远多于写、集合不太大的场合,它们采用了写时拷贝,这是计算机程序中一种重要的思维和技术。

下一节,我们讨论一种重要的并发容器 - ConcurrentHashMap。

(与其他章节一样,本节所有代码位于 github.com/swiftma/pro…)


未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

相关文章:

  • 关于在线编辑器的一个奇怪的问题
  • 设计模式_策略模式
  • 源码分析一(Iterator、Collection以及List接口)
  • 【Asp.Net】GridView中的各种事件
  • Beans
  • atlas 实现弹出窗口
  • 结对编程,第二周作业
  • nodejs爬虫
  • Tracking_表结构(1)
  • HDU 2438 Turn the corner(三分查找)
  • (转)用.Net的File控件上传文件的解决方案
  • 了解基于客户端的网页应用程序,AJAX和ASP.NET 'Atlas'[翻译]
  • [转]Spring mvc interceptor配置拦截器,没有登录跳到登录页
  • ASP站点无法访问怎么办
  • 任何一个创业者都要注意的22个经典提示
  • 时间复杂度分析经典问题——最大子序列和
  • [ 一起学React系列 -- 8 ] React中的文件上传
  • [js高手之路]搞清楚面向对象,必须要理解对象在创建过程中的内存表示
  • 【刷算法】求1+2+3+...+n
  • classpath对获取配置文件的影响
  • Django 博客开发教程 16 - 统计文章阅读量
  • happypack两次报错的问题
  • Java反射-动态类加载和重新加载
  • JS进阶 - JS 、JS-Web-API与DOM、BOM
  • Mac 鼠须管 Rime 输入法 安装五笔输入法 教程
  • passportjs 源码分析
  • session共享问题解决方案
  • TCP拥塞控制
  • win10下安装mysql5.7
  • 记一次和乔布斯合作最难忘的经历
  • 我与Jetbrains的这些年
  • 优秀架构师必须掌握的架构思维
  • 运行时添加log4j2的appender
  • 转载:[译] 内容加速黑科技趣谈
  • !!【OpenCV学习】计算两幅图像的重叠区域
  • # Java NIO(一)FileChannel
  • # 日期待t_最值得等的SUV奥迪Q9:空间比MPV还大,或搭4.0T,香
  • ###C语言程序设计-----C语言学习(6)#
  • #Linux(帮助手册)
  • (02)vite环境变量配置
  • (22)C#传智:复习,多态虚方法抽象类接口,静态类,String与StringBuilder,集合泛型List与Dictionary,文件类,结构与类的区别
  • (AngularJS)Angular 控制器之间通信初探
  • (附源码)springboot 智能停车场系统 毕业设计065415
  • (附源码)计算机毕业设计SSM疫情社区管理系统
  • (考研湖科大教书匠计算机网络)第一章概述-第五节1:计算机网络体系结构之分层思想和举例
  • (企业 / 公司项目)前端使用pingyin-pro将汉字转成拼音
  • (续)使用Django搭建一个完整的项目(Centos7+Nginx)
  • (一)搭建springboot+vue前后端分离项目--前端vue搭建
  • (一)基于IDEA的JAVA基础1
  • (转)关于多人操作数据的处理策略
  • (转贴)用VML开发工作流设计器 UCML.NET工作流管理系统
  • (轉貼) 資訊相關科系畢業的學生,未來會是什麼樣子?(Misc)
  • .Net程序帮助文档制作
  • .NET高级面试指南专题十一【 设计模式介绍,为什么要用设计模式】
  • .NET命名规范和开发约定