CopyOnWriteArrayList你都不知道,怎么拿offer?

前言

只有光头才能变强

前一阵子写过一篇COW(Copy On Write)文章,结果阅读量很低啊...COW奶牛!Copy On Write机制了解一下

可能大家对这个技术比较陌生吧,但这项技术是挺多应用场景的。除了上文所说的Linux、文件系统外,其实在Java也有其身影。

大家对线程安全容器可能最熟悉的就是ConcurrentHashMap了,因为这个容器经常会在面试的时候考查。

比如说,一个常见的面试场景:

  • 面试官问:“HashMap是线程安全的吗?如果HashMap线程不安全的话,那有没有安全的Map容器”
  • 3y:“线程安全的Map有两个,一个是Hashtable,一个是ConcurrentHashMap”
  • 面试官继续问:“那Hashtable和ConcurrentHashMap有什么区别啊?”
  • 3y:“balabalabalabalabalabala"
  • 面试官:”ok,ok,ok,看你Java基础挺不错的呀“

那如果有这样的面试呢?

  • 面试官问:“ArrayList是线程安全的吗?如果ArrayList线程不安全的话,那有没有安全的类似ArrayList的容器”
  • 3y:“线程安全的ArrayList我们可以使用Vector,或者说我们可以使用Collections下的方法来包装一下”
  • 面试官继续问:“嗯,我相信你也知道Vector是一个比较老的容器了,还有没有其他的呢?”
  • 3y:“Emmmm,这个...“
  • 面试官提示:“就比如JUC中有ConcurrentHashMap,那JUC中有类似"ArrayList"的线程安全容器类吗?“
  • 3y:“Emmmm,这个...“
  • 面试官:”ok,ok,ok,今天的面试时间也差不多了,你回去等通知吧。“

今天主要讲解的是CopyOnWriteArrayList~

本文力求简单讲清每个知识点,希望大家看完能有所收获

一、Vector和SynchronizedList

1.1回顾线程安全的Vector和SynchronizedList

我们知道ArrayList是用于替代Vector的,Vector是线程安全的容器。因为它几乎在每个方法声明处都加了synchronized关键字来使容器安全。

如果使用Collections.synchronizedList(new ArrayList())来使ArrayList变成是线程安全的话,也是几乎都是每个方法都加上synchronized关键字的,只不过它不是加在方法的声明处,而是方法的内部

1.2Vector和SynchronizedList可能会出现的问题

在讲解CopyOnWrite容器之前,我们还是先来看一下线程安全容器的一些可能没有注意到的地方~

下面我们直接来看一下这段代码:


    // 得到Vector最后一个元素
    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    // 删除Vector最后一个元素
    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }

以我们第一反应来分析一下上面两个方法:在多线程环境下,是否有问题

  • 我们可以知道的是Vector的size()和get()以及remove()都被synchronized修饰的。

答案:从调用者的角度是有问题

我们可以写段代码测试一下:


import java.util.Vector;

public class UnsafeVectorHelpers {

    public static void main(String[] args) {

        // 初始化Vector
        Vector<String> vector = new Vector();
        vector.add("关注公众号");
        vector.add("Java3y");
        vector.add("买Linux可到我下面的链接,享受最低价");
        vector.add("给3y加鸡腿");

        new Thread(() -> getLast(vector)).start();
        new Thread(() -> deleteLast(vector)).start();
        new Thread(() -> getLast(vector)).start();
        new Thread(() -> deleteLast(vector)).start();
    }

    // 得到Vector最后一个元素
    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    // 删除Vector最后一个元素
    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}

可以发现的是,有可能会抛出异常的:

原因也很简单,我们照着流程走一下就好了:

  • 线程A执行getLast()方法,线程B执行deleteLast()方法
  • 线程A执行int lastIndex = list.size() - 1;得到lastIndex的值是3。同时,线程B执行int lastIndex = list.size() - 1;得到的lastIndex的值是3
  • 此时线程B先得到CPU执行权,执行list.remove(lastIndex)将下标为3的元素删除了
  • 接着线程A得到CPU执行权,执行list.get(lastIndex);,发现已经没有下标为3的元素,抛出异常了.

出现这个问题的原因也很简单:

  • getLast()deleteLast()这两个方法并不是原子性的,即使他们内部的每一步操作是原子性的(被Synchronize修饰就可以实现原子性),但是内部之间还是可以交替执行。

    • 这里的意思就是:size()和get()以及remove()都是原子性的,但是如果并发执行getLast()deleteLast(),方法里面的size()和get()以及remove()是可以交替执行的。

要解决上面这种情况也很简单,因为我们都是对Vector进行操作的,只要操作Vector前把它锁住就没毛病了

所以我们可以改成这样子:


    // 得到Vector最后一个元素
    public static Object getLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            return list.get(lastIndex);
        }
    }
    // 删除Vector最后一个元素
    public static void deleteLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            list.remove(lastIndex);
        }
    }

ps:如果有人去测试一下,发现会抛出异常java.lang.ArrayIndexOutOfBoundsException: -1,这是没有检查角标的异常,不是并发导致的问题。

经过上面的例子我们可以看看下面的代码:


    public static void main(String[] args) {

        // 初始化Vector
        Vector<String> vector = new Vector();
        vector.add("关注公众号");
        vector.add("Java3y");
        vector.add("买Linux可到我下面的链接,享受最低价");
        vector.add("给3y加鸡腿");

        // 遍历Vector
        for (int i = 0; i < vector.size(); i++) {

            // 比如在这执行vector.clear();
            //new Thread(() -> vector.clear()).start();

            System.out.println(vector.get(i));
        }
    }

同样地:如果在遍历Vector的时候,有别的线程修改了Vector的长度,那还是会有问题

  • 线程A遍历Vector,执行vector.size()时,发现Vector的长度为5
  • 此时很有可能存在线程B对Vector进行clear()操作
  • 随后线程A执行vector.get(i)时,抛出异常

在JDK5以后,Java推荐使用for-each(迭代器)来遍历我们的集合,好处就是简洁、数组索引的边界值只计算一次

如果使用for-each(迭代器)来做上面的操作,会抛出ConcurrentModificationException异常

SynchronizedList在使用迭代器遍历的时候同样会有问题的,源码已经提醒我们要手动加锁了。

如果想要完美解决上面所讲的问题,我们可以在遍历前加锁


		// 遍历Vector
 		synchronized (vector) {
            for (int i = 0; i < vector.size(); i++) {
                vector.get(i);
            }
        }

有经验的同学就可以知道:哇,遍历一下容器都要我加上锁,这这这不是要慢死了吗.的确是挺慢的..

所以我们的CopyOnWriteArrayList就登场了!

二、CopyOnWriteArrayList(Set)介绍

一般来说,我们会认为:CopyOnWriteArrayList是同步List的替代品,CopyOnWriteArraySet是同步Set的替代品。

无论是Hashtable-->ConcurrentHashMap,还是说Vector-->CopyOnWriteArrayList。JUC下支持并发的容器与老一代的线程安全类相比,总结起来就是加锁粒度的问题

  • Hashtable、Vector加锁的粒度大(直接在方法声明处使用synchronized)
  • ConcurrentHashMap、CopyOnWriteArrayList加锁粒度小(用各种的方式来实现线程安全,比如我们知道的ConcurrentHashMap用了cas锁、volatile等方式来实现线程安全..)
  • JUC下的线程安全容器在遍历的时候不会抛出ConcurrentModificationException异常

所以一般来说,我们都会使用JUC包下给我们提供的线程安全容器,而不是使用老一代的线程安全容器。

下面我们来看看CopyOnWriteArrayList是怎么实现的,为什么使用迭代器遍历的时候就不用额外加锁,也不会抛出ConcurrentModificationException异常。

2.1CopyOnWriteArrayList实现原理

我们还是先来回顾一下COW:

如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源

参考自维基百科:https://zh.wikipedia.org/wiki/%E5%AF%AB%E5%85%A5%E6%99%82%E8%A4%87%E8%A3%BD

之前写博客的时候,如果是要看源码,一般会翻译一下源码的注释并用图贴在文章上的。Emmm,发现阅读体验并不是很好,所以我这里就直接概括一下源码注释说了什么吧。另外,如果使用IDEA的话,可以下一个插件Translation(免费好用).



概括一下CopyOnWriteArrayList源码注释介绍了什么:

  • CopyOnWriteArrayList是线程安全容器(相对于ArrayList),底层通过复制数组的方式来实现。
  • CopyOnWriteArrayList在遍历的使用不会抛出ConcurrentModificationException异常,并且遍历的时候就不用额外加锁
  • 元素可以为null

2.1.1看一下CopyOnWriteArrayList基本的结构


    /** 可重入锁对象 */
    final transient ReentrantLock lock = new ReentrantLock();

    /** CopyOnWriteArrayList底层由数组实现,volatile修饰 */
    private transient volatile Object[] array;

    /**
     * 得到数组
     */
    final Object[] getArray() {
        return array;
    }

    /**
     * 设置数组
     */
    final void setArray(Object[] a) {
        array = a;
    }

    /**
     * 初始化CopyOnWriteArrayList相当于初始化数组
     */
    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }

看起来挺简单的,CopyOnWriteArrayList底层就是数组,加锁就交由ReentrantLock来完成。

2.1.2常见方法的实现

根据上面的分析我们知道如果遍历Vector/SynchronizedList是需要自己手动加锁的。

CopyOnWriteArrayList使用迭代器遍历时不需要显示加锁,看看add()、clear()、remove()get()方法的实现可能就有点眉目了。

首先我们可以看看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;

			// 将volatile Object[] array 的指向替换成新数组
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

通过代码我们可以知道:在添加的时候就上锁,并复制一个新数组,增加操作在新数组上完成,将array指向到新数组中,最后解锁。

再来看看size()方法:


	public int size() {

		// 直接得到array数组的长度
        return getArray().length;
    }

再来看看get()方法:



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

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

那再来看看set()方法


public E set(int index, E element) {
	final ReentrantLock lock = this.lock;
	lock.lock();
	try {

		// 得到原数组的旧值
		Object[] elements = getArray();
		E oldValue = get(elements, index);

		// 判断新值和旧值是否相等
		if (oldValue != element) {

			// 复制新数组,新值在新数组中完成
			int len = elements.length;
			Object[] newElements = Arrays.copyOf(elements, len);
			newElements[index] = element;

			// 将array引用指向新数组
			setArray(newElements);
		} else {
			// Not quite a no-op; enssures volatile write semantics
			setArray(elements);
		}
		return oldValue;
	} finally {
		lock.unlock();
	}
}

对于remove()、clear()set()和add()是类似的,这里我就不再贴出代码了。

总结:

  • 在修改时,复制出一个新数组,修改的操作在新数组中完成,最后将新数组交由array变量指向
  • 写加锁,读不加锁

2.1.3剖析为什么遍历时不用调用者显式加锁

常用的方法实现我们已经基本了解了,但还是不知道为啥能够在容器遍历的时候对其进行修改而不抛出异常。所以,来看一下他的迭代器吧:



	// 1. 返回的迭代器是COWIterator
	public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }

	// 2. 迭代器的成员属性
    private final Object[] snapshot;
    private int cursor;

	// 3. 迭代器的构造方法
	private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

	// 4. 迭代器的方法...
	public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

	//.... 可以发现的是,迭代器所有的操作都基于snapshot数组,而snapshot是传递进来的array数组

到这里,我们应该就可以想明白了!CopyOnWriteArrayList在使用迭代器遍历的时候,操作的都是原数组

2.1.4CopyOnWriteArrayList缺点

看了上面的实现源码,我们应该也大概能分析出CopyOnWriteArrayList的缺点了。

  • 内存占用:如果CopyOnWriteArrayList经常要增删改里面的数据,经常要执行add()、set()、remove()的话,那是比较耗费内存的。

    • 因为我们知道每次add()、set()、remove()这些增删改操作都要复制一个数组出来。
  • 数据一致性:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性
    • 从上面的例子也可以看出来,比如线程A在迭代CopyOnWriteArrayList容器的数据。线程B在线程A迭代的间隙中将CopyOnWriteArrayList部分的数据修改了(已经调用setArray()了)。但是线程A迭代出来的是原有的数据。

2.1.5CopyOnWriteSet

CopyOnWriteArraySet的原理就是CopyOnWriteArrayList。


    private final CopyOnWriteArrayList<E> al;

    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();
    }

三、最后

现在临近双十一买阿里云服务器就特别省钱!之前我买学生机也要9.8块钱一个月,现在最低价只需要8.3一个月!

如果有要买服务器的同学可通过我的链接直接享受最低价https://m.aliyun.com/act/team1111/#/share?params=N.FF7yxCciiM.pfn5xpli



阅读这篇文章可能需要对Java容器和多线程有一定的了解。如果对这些知识还不太了解的同学们可看我之前写过的文章哦~

如果大家有更好的理解方式或者文章有错误的地方还请大家不吝在评论区留言,大家互相学习交流~~~

参考资料:

扩展阅读:

一个坚持原创的Java技术公众号:Java3y,欢迎大家关注

3y所有的原创文章:

原文地址:https://www.cnblogs.com/Java3y/p/9920784.html

时间: 2024-10-09 16:55:20

CopyOnWriteArrayList你都不知道,怎么拿offer?的相关文章

不锈钢餐具的选购标准和使用禁忌,99%的人都不知道

不锈钢餐具以器皿的美观耐用的特征,被大量地用来制造餐具用品,我们每个家庭几乎都会用到不锈钢餐具.锅具.厨具. 消费者在选购时往往会发现,不锈钢餐具的价格是千差万别,一只汤匙最便宜的一两元钱便可买到,而最贵的高端产品则要几十元左右,相差达几十倍! 其实,很多人都不知道,不锈钢餐具隐藏了很多不为人知的标准,消费者往往花了冤枉钱买了不合格产品,这些不合格的产品又含铅.铝.汞和镉等重金属元素,长期使用危害人体健康. 第一部分:不锈钢餐具选购标准 1.选购不锈钢产品时应认真查看外包装上是否标注所用的材质和

这个季节吃茄子能治疗7种疾病,很多人都不知道!

这个季节吃茄子能治疗7种疾病,很多人都不知道! 2016-04-26 都市报道扩大版 茄子的功效有什么?茄子含有维生素E,有防止出血和抗衰老功能,常吃茄子,可使血液中胆固醇水平不致增高,对延缓人体衰老具有积极的意义.下面盘点一下茄子的7大功效,或许你会对茄子刮目相看. 1.抗衰老 茄子含有维生素E,有防止出血和抗衰老功能,常吃茄子,可使血液中胆固醇水平不致增高,对延缓人体衰老具有积极的意义. 2.清热解毒 用于热毒痈疮.皮肤溃疡.口舌生疮.痔疮下血.便血.衄血等. 中医学认为,茄子属于寒凉性质的

【源码解析】自动配置的这些细节都不知道,别说你会 springboot

spring-boot 相对于 spring,很重要的一个特点就是自动配置,使约定大于配置思想成功落地.xxx-spring-boot-starter 一系列引导器能够开箱即用,或者只需要很少的配置(对于初学人员)就是因为已做了默认的自动配置. 自动配置在一开始就初始化了一些配置,同时提供修改配置的入口. 整体结构spring-boot-autoconfigure 包是 spring-boot-starter 中一个非常重要的包,其中提供了自动配置功能,还对常用依赖,设置了默认配置. 依赖其依赖

面试官:你连RESTful都不知道我怎么敢要你? 文章解析

面试官:你连RESTful都不知道我怎么敢要你?文章目录01 前言02 RESTful的来源03 RESTful6大原则1. C-S架构2. 无状态3.统一的接口4.一致的数据格式4.系统分层5.可缓存6.按需编码.可定制代码(可选)03 RESTful的7个最佳实践1. 版本2.参数命名规范3.url命名规范4. 统一返回数据格式5. http状态码6. 合理使用query parameter7. 多表.多参数连接查询如何设计URL 原文地址:https://www.cnblogs.com/f

只学一点点:我的技术学习策略(可以参考一下:寻找遁去的一,不用管别人怎么想;有学习的时间,不如自己写、自己实践,否则学完了都不知道是什么东西)

李敖有首诗叫<只爱一点点> : 不爱那么多, 只爱一点点: 别人的爱情像海深, 我的爱情浅. 不爱那么多, 只爱一点点: 别人的爱情像天长, 我的爱情短. 不爱那么多, 只爱一点点: 别人眉来又眼去, 我只偷看你一眼. 一点足够.在黄易的大唐双龙传中有个说法叫<遁去的一>,也就是说任何事情在纷杂万象之中都有一个消失的一,把这个消失的一找到,就可以事半功倍. 在学技术中,很多人纠结于掌握与精通.掌握是能够熟练的使用该技术实现自己的目标,而精通,则是对该技术的常用及半常用的场景都熟悉,

开源存在陷阱吗?摩斯拉实验室的关闭了,大家居然都不知道

根据伊恩-比克林的一篇博客,摩斯拉实验室于数月之前就关闭了,但是我们从来都没有听过关于这方面的新闻,这样很不利于互联网的发展. 很多人抗议当时谷歌实验室的关闭.我就不明白了,拥有那么多项目的一个组织,怎么可能就轻易的关掉了呢?谷歌实验室关闭之前,最起码应该告诉我一下吧.摩斯拉实验室悄悄的关闭了,官方没有给出任何说明,可是摩斯拉实验室的网站依然是可以被访问的. 该网站虽然是可以被访问的,但是当你搜索该网站时,你会发现该网站上最后一篇博客写于2013年12月份,倒数第二篇写于2013年9月. 如果你

揭秘! 99%的人都不知道卡背签名的重要性!

导读:今日[卡神论坛]跟卡友们谈谈信用卡卡背 签名有多重要,想知道往下读吧....... 不少人对背签持怀疑态度,部分人认为,刷卡消费既然已经设置交易密码,为什么还要背签呢,这安全吗?持卡人认为信用卡背签多此一举,那么是否多此一举,往下看吧! 王思雨是第一次申请信用卡,对于信用卡的使用注意事项还有些不清楚的地方. 当按照说明书的步骤激活信用卡时,王思雨读到的第一句话便是“您的卡片尚未激活,请即刻在卡片背面签名,并选择以下渠道进行激活”.让王思雨不明白的是,卡的正面已经有了持卡人姓名的汉语拼音,而

震惊!你连无线Wi-Fi都不知道?

1.简介 Wi-Fi是一种允许电子设备连接到一个无线局域网(WLAN)的技术,通常使用2.4G UHF或5G SHF ISM 射频频段.连接到无线局域网通常是有密码保护的:但也可是开放的,这样就允许任何在WLAN范围内的设备可以连接上. 现在WIFI网络主要频率分为2.4GHz和5GHz,其中速率分为A B G N AC: 2.2.4G和5G 1).频率2.4GHz和5GHz 2.4G 优点是频段室内环境中抗衰减能力强,穿墙能力不错. 劣势是许多设备用的都是 2.4GHz,如蓝牙,zigbee无

90%的用户都不知道手机内部功能

iPhone手机隐藏的小功能有很多,每个数字及字母都能透露出很多信息.今天小编就教大家如果利用拨号键盘查询到更多的信息. 查询手机信号 输入*3001#12345#*,就可以运行手机内置的FieldTest.隐藏程序,可以查看基站信息.信道.信号强弱,固件版本号等内容.输入#302#.#303#.#304#.#305#.#306#,就可以建立一个虚拟的通信回路,回拨自己的手机.输入*#06#,就可以查询手机的IMEI码. 所有来电类型的呼叫转移: 如果在设置呼叫转移时遇到错误,或者你想取消所有呼