并发编程-线程安全策略之并发容器(J.U.C)中的集合类

目录

1、J.U.C总览

2、脑图

3、同步容器:

 4、并发容器

5、概述

6、并发容器特性

7、示例

7.1、ArrayList对应的线程安全的并发容器类CopyOnWriteArrayList (线程安全)

7.2、HashSet对应的线程安全的并发容器类CopyOnWriteArraySet (线程安全)

7.3、TreeSet对应的线程安全的并发容器类 ConcurrentSkipListSet (线程安全) 

7.4、TreeMap对应的线程安全的并发容器类ConcurrentSkipListMap (线程安全) 

7.5、HashMap对应的线程安全的并发容器类ConcurrentHashMap (线程安全) 

8、CopyOnWriteArrayList原理介绍

 8.1、简介

 8.2、要注意的问题

8.3、构造器方法

8.4、add方法(无参:默认添加到数组尾部)

8.4.1、添加过程的描述

8.4.2、添加过程的源码

8.4.3、总结

8.5、add方法(有参:添加到数组指定位置)

8.6、addAll方法(添加集合至指定下标)

8.7、get方法(获取指定位置元素)

8.8、set方法(修改指定位置元素)

8.9、remove方法(删除元素)

8.9.1、删除的过程

8.9.1、删除的源码

8.9.2、其他删除方法

8.10、使用场景和优缺点

8.10.1、优点

8.10.2、缺点


1、J.U.C总览

在这里插入图片描述

2、脑图

在这里插入图片描述

3、同步容器:

在这里插入图片描述

 4、并发容器

 在这里插入图片描述

5、概述

       JUC 是 Java 5.0 之后新增的一个包 java.util.concurrent ,该包提供了一套并发编程的工具类,包括原子操作、线程池、Lock、Condition 等类,方便进行多线程编程的操作。

        JUC 的出现是为了解决多线程共享资源,协作完成任务时常见的问题,如同时访问共享资源、线程死锁、饥饿、并行性不足等问题。使用 JUC 提供的工具类可以简化并发程序的编写,提高程序的效率和稳定性

       同步容器是通过synchronized来实现同步的,所以性能较差。而且同步容器也并不是绝对线程安全的,在一些特殊情况下也会出现线程不安全的行为。那么有没有更好的方式代替同步容器呢?----> 那就是并发容器,有了并发容器后同步容器的使用也越来越少的,大部分都会优先使用并发容器(J.U.C).

6、并发容器特性

  • CopyOnWriteArrayListCopyOnWriteArraySet因为需要copy数组,需要消耗内存,可能引发yonggc或者 fullgc,并且不能做到实时性,适合读多写少的情景

  • ConcurrentSkipListSet 支持自然排序,并且可以在构造的时候自己定义比较器,可以保证每一次的操作是原子性的,比如add()、remove等,但是对于批量操作,如addAll()等并不能保证原子性(需要自己手动做同步操作,如加锁等)

  • ConcurrentHashMap针对读操作做了大量的优化,这个类具有特别高的并发性,高并发场景下有特别好的表现

  • ConcurrentSkipListMapConcurrentHashMap相比的key是有序的,它支持更高的并发,它的存取时间和线程数是没有关系的,在一定的数据量下,并发的线程越多ConcurrentSkipListMap越能体现出它的优势来

7、示例

7.1、ArrayList对应的线程安全的并发容器类CopyOnWriteArrayList (线程安全)

在这里插入图片描述

运行结果:线程安全

在这里插入图片描述


7.2、HashSet对应的线程安全的并发容器类CopyOnWriteArraySet (线程安全)

在这里插入图片描述

运行结果:线程安全

在这里插入图片描述


7.3、TreeSet对应的线程安全的并发容器类 ConcurrentSkipListSet (线程安全) 

 在这里插入图片描述

运行结果:线程安全

在这里插入图片描述


7.4、TreeMap对应的线程安全的并发容器类ConcurrentSkipListMap (线程安全) 

在这里插入图片描述

运行结果:线程安全

在这里插入图片描述


7.5、HashMap对应的线程安全的并发容器类ConcurrentHashMap (线程安全) 

在这里插入图片描述

运行结果:线程安全

在这里插入图片描述


8、CopyOnWriteArrayList原理介绍

 8.1、简介

        CopyOnWriteArrayList是一个线程安全的ArrayList,对其进行的修改操作都是在底层的一个复制的数组(快照)上进行的,也就是使用了写时复制策略。通过锁 + 数组拷贝 + volatile 关键字保证了线程安全;每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去。

        每个CopyOnWriteArrayList对象里面有一个array数组对象用来存放具体元素,ReentrantLock独占锁对象用来保证同时只有一个线程对array进行修改

 8.2、要注意的问题

  • 何时初始化list,初始化的list元素个数是多少,list是有限大小吗?
  • 如何保证线程安全,比如多个线程进行读写时如何保证是线程安全的?
  • 如何保证使用迭代器遍历list时的数据一致性?

        从整体架构上来说,CopyOnWriteArrayList 数据结构和 ArrayList 是一致的,底层是个数组,只不过 CopyOnWriteArrayList 在对数组进行写操作的时候,基本会分四步走:

  • (1)加锁;
  • (2)从原数组中拷贝出新数组;
  • (3)在新数组上进行操作,并把新数组赋值给数组容器;所以CopyOnWriteArrayList是无界list
  • (4)解锁

        除了加锁之外,CopyOnWriteArrayList 的底层数组还被 volatile 关键字修饰,意思是一旦数组被修改,其它线程立马能够感知到,代码如下

private transient volatile Object[] array;

8.3、构造器方法

(1)无参构造函数会在内部创建一个大小为0的Object数组

    //创建ReentrantLock锁对象
    final transient ReentrantLock lock = new ReentrantLock();
    
    //volatile:保持可见性和有序性
    private transient volatile Object[] array;
    
    //获取当前数组
    final Object[] getArray() {
        return array;
    }
    
    //数组引用重新指向
    final void setArray(Object[] a) {
        array = a;
    }
 
    //构造方法,创建一个list,其内部元素是入参toCopyIn的副本
    public CopyOnWriteArrayList() {
    	//初始化数组容量为0;
        setArray(new Object[0]);
    }
 
 	//入参为集合,把集合里面的元素复制到本list
    public CopyOnWriteArrayList(Collection<? extends E> c) {
        Object[] elements;
        //判断集合的class对象是否为CopyOnWriteArrayList
        if (c.getClass() == CopyOnWriteArrayList.class)
			//向上转型
			//得到当前集合对象中的Object[] array数组
            elements = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
            elements = c.toArray();
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            //判断数组不是Object类型的数组
            if (elements.getClass() != Object[].class)
            	//将数组转换成Object类型数组
                elements = Arrays.copyOf(elements, elements.length, Object[].class);
        }
        //数组引用重新指向
        setArray(elements);
    }

8.4、add方法(无参:默认添加到数组尾部)

8.4.1、添加过程的描述

(1)获取独占锁ReentrantLock,如果多个线程都调用add方法则只有一个线程会获取到该锁,其他线程会被阻塞挂起直到锁被释放
(2)线程获取锁之后得到所有的原数组,然后复制到一个新数组,新数组的大小是原来数组大小增加1,所有CopyOnWriteArrayList是无界list,并且把新增的元素增加到新数组
(3)使用新数组替换原数组,并在返回前释放锁。由于加了锁,所以整个add过程是个原子性操作。在添加元素的时候,首先复制了一个快照数组,然后再快照上进行添加新数据,而不是直接在原来数组上进行

8.4.2、添加过程的源码

add(E e)方法:添加元素至集合尾部

// 添加元素到数组尾部
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        // 得到所有的原数组
        Object[] elements = getArray();
        int len = elements.length;
        // 拷贝到新数组里面,新数组的长度是 + 1 的,因为新增会多一个元素
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 在新数组中进行赋值,新元素直接放在数组的尾部
        newElements[len] = e;
        // 替换掉原来的数组
        setArray(newElements);
        return true;
    // finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁   
    } finally {
        lock.unlock();
    }
}

        除了加锁之外,还会从老数组中创建出一个新数组,然后把老数组的值拷贝到新数组上,这时候就有一个问题:都已经加锁了,为什么需要拷贝数组,而不是在原来数组上面进行操作呢,原因主要为:

(1)volatile 关键字修饰的是数组,如果我们简单的在原来数组上修改其中某几个元素的值,是无法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值才行。

(2)在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能访问到,降低了在赋值过程中,老数组数据变动的影响。

8.4.3、总结

从 add 系列方法可以看出,CopyOnWriteArrayList 通过加锁 + 数组拷贝+ volatile 来保证了线程安全,每一个要素都有着其独特的含义:

(1)加锁:保证同一时刻数组只能被一个线程操作;
(2)数组拷贝:保证数组的内存地址被修改,修改后触发 volatile 的可见性,其它线程可以立马知道数组已经被修改;
(3)volatile:值被修改后,其它线程能够立马感知最新值。

3 个要素缺一不可,比如说我们只使用 1 和 3 ,去掉 2,这样当我们修改数组中某个值时,并不会触发 volatile 的可见特性的,只有当数组内存地址被修改后,才能触发把最新值通知给其他线程的特性

8.5、add方法(有参:添加到数组指定位置)

add(int index, E element)方法:添加元素至指定下标位置

(1)获取独占锁ReentrantLock,如果多个线程都调用add方法则只有一个线程会获取到该锁,其他线程会被阻塞挂起直到锁被释放
(2)线程获取锁之后得到所有的原数组
(3)判断添加位置是否为尾部,如果是插在数组尾部,把原数组拷贝到一个新的长度+1的新数组中
(4)如果不是插在数组尾部,而是插在数组中间,就要分两次拷贝,第一次把index之前的数据拷贝到新的长度+1的新数组中,第二次把index之后的数据拷贝进去,把index位置空出来
(5)最后把要插入的值放入尾部或者中间的位置,完成数据的插入。原数组可以被GC了。
(6)finally里释放锁

    public void add(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            //判断下标越界
            if (index > len || index < 0)
                throw new IndexOutOfBoundsException("Index: "+index+
                                                    ", Size: "+len);
            Object[] newElements;
            
            int numMoved = len - index;
            //判断添加位置是否为尾部
            if (numMoved == 0)
            	//复制数组并容量+1
                newElements = Arrays.copyOf(elements, len + 1);
            else {
            	//不是尾部,创建新数组
                newElements = new Object[len + 1];
                //复制插入位置之前的元素到新数组
                System.arraycopy(elements, 0, newElements, 0, index);
              //复制插入位置之后的元素到新数组
                System.arraycopy(elements, index, newElements, index + 1,
                                 numMoved);
            }
            //修改值
            newElements[index] = element;
            setArray(newElements);
        } finally {
            lock.unlock();
        }
    }

8.6、addAll方法(添加集合至指定下标)

addAll(int index, Collection<? extends E> c)方法:添加集合至指定下标

    public boolean addAll(int index, Collection<? extends E> c) {
    	//装换成数组
        Object[] cs = c.toArray();
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
        	//获取当前成员数组
            Object[] elements = getArray();
            int len = elements.length;
            //下标越界
            if (index > len || index < 0)
                throw new IndexOutOfBoundsException("Index: "+index+
                                                    ", Size: "+len);
            //如果添加的数组长度为0,返回false
            if (cs.length == 0)
                return false;
            int numMoved = len - index;
            Object[] newElements;
            //判断是否插入集合尾部
            if (numMoved == 0)
            	//复制新数组长度=插入数组长度+原数组长度
                newElements = Arrays.copyOf(elements, len + cs.length);
            else {
            	//不是尾部
            	//创建新数组
                newElements = new Object[len + cs.length];
                //复制插入位置之前元素至新数组
                System.arraycopy(elements, 0, newElements, 0, index);
              //复制插入位置之后元素至新数组
                System.arraycopy(elements, index,
                                 newElements, index + cs.length,
                                 numMoved);
            }
            //复制插入数组元素至新数组
            System.arraycopy(cs, 0, newElements, index, cs.length);
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
}

8.7、get方法(获取指定位置元素)

(1)首先获取array数组
(2)然后通过下标访问指定位置额元素

8.8、set方法(修改指定位置元素)

使用E set(int index, E element)修改list中指定元素的值,如果指定位置的元素不存在就抛出IndexOutOfBoundsException异常:

(1)首先获取了独占锁,阻止其他线程对array数组进行修改
(2)然后获取当前数组,并且调用get方法获取指定位置的元素
(3)如果指定位置的元素值和新值不一致则创建新数组并复制元素,然后再新数组上修改指定位置的元素值并设置新数组到array
(4)如果指定位置的元素值和新值一样,则保证volatile语义,还是需要调用setArray方法重新设置array,虽然array的内容并没有改变

public E set(int index, E element) {
	//获取锁
    final ReentrantLock lock = this.lock;
    //加锁
    lock.lock();
    try {
    	//获取当前类CopyOnWriteArrayList对象中数组(新引用指向它)
        Object[] elements = getArray();
        //获取指定下标值
        E oldValue = get(elements, index);
        
        //判断数组中的值是否等于要修改的值
        if (oldValue != element) {
            int len = elements.length;
            //复制到新数组
            Object[] newElements = Arrays.copyOf(elements, len);
            //修改值
            newElements[index] = element;
            //数组引用重新指向
            setArray(newElements);
        } else {
            // Not quite a no-op; ensures volatile write semantics
            setArray(elements);
        }
        return oldValue;
    } finally {
    	//释放锁
        lock.unlock();
    }
}

8.9、remove方法(删除元素)

8.9.1、删除的过程

(1)加锁
(2)复制原来的数组
(3)如果要删除的数据正好是数组的尾部,就直接删除;如果删除的数据再数组的中间,分三步走:
        1-设置新数组的长度减一,因为是减少一个元素
        2-从 0 拷贝到数组新位置
        3-从新位置拷贝到数组尾部
(4)把新数组复制给旧数组,释放锁

8.9.1、删除的源码

// 删除某个索引位置的数据
public E remove(int index) {
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        // 先得到老值
        E oldValue = get(elements, index);
        int numMoved = len - index - 1;
        // 如果要删除的数据正好是数组的尾部,直接删除
        if (numMoved == 0)
            setArray(Arrays.copyOf(elements, len - 1));
        else {
            // 如果删除的数据在数组的中间,分三步走
            // 1. 设置新数组的长度减一,因为是减少一个元素
            // 2. 从 0 拷贝到数组新位置
            // 3. 从新位置拷贝到数组尾部
            Object[] newElements = new Object[len - 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index + 1, newElements, index,
                             numMoved);
            setArray(newElements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

8.9.2、其他删除方法

1)retainAll(Collection<?> c)方法:只在当前集合保留传入集合中的元素,其余的删除

// 批量删除包含在 c 中的元素
public boolean removeAll(Collection<?> c) {
    if (c == null) throw new NullPointerException();
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        // 说明数组有值,数组无值直接返回 false
        if (len != 0) {
            // newlen 表示新数组的索引位置,新数组中存在不包含在 c 中的元素
            int newlen = 0;
            Object[] temp = new Object[len];
            // 循环,把不包含在 c 里面的元素,放到新数组中
            for (int i = 0;
 i < len;
 ++i) {
                Object element = elements[i];
                // 不包含在 c 中的元素,从 0 开始放到新数组中
                if (!c.contains(element))
                    temp[newlen++] = element;
            }
            // 拷贝新数组,变相的删除了不包含在 c 中的元素
            if (newlen != len) {
                setArray(Arrays.copyOf(temp, newlen));
                return true;
            }
        }
        return false;
    } finally {
        lock.unlock();
    }
}

        从源码中,我们可以看到,我们并不会直接对数组中的元素进行挨个删除,而是先对数组中的值进行循环判断,把我们不需要删除的数据放到临时数组中,最后临时数组中的数据就是我们不需要删除的数据。

        不知道大家有木有似曾相识的感觉,ArrayList 的批量删除的思想也是和这个类似的,所以我们在需要删除多个元素的时候,最好都使用这种批量删除的思想,而不是采用在 for 循环中使用单个删除的方法,单个删除的话,在每次删除的时候都会进行一次数组拷贝(删除最后一个元素时不会拷贝),很消耗性能,也耗时,会导致加锁时间太长,并发大的情况下,会造成大量请求在等待锁,这也会占用一定的内存。

8.10、使用场景和优缺点

8.10.1、优点

(1)保证增删改操作线程安全
CopyOnWriteArrayList的增删改都是在拷贝出来的新数组上进行的,增删改操作都需要先获取ReentrantLock锁,所以可以保证增删改的线程安全。
(2)保证读读线程之间不阻塞
而get查方法不需要获取锁,可以保证读读线程之间并发,提高读的效率。
(3)保证读写线程之间不阻塞
写线程在操作list的时候,不是在原数组上操作的,而是复制一个新的数组进行操作,这个时候如果有读线程进来读的就是原数组,所以读写之间不冲突。并且数组加了volital,一旦写线程操作完毕并且调用setArray方法,新的数组就会马上被其他线程感知

8.10.2、缺点

(1)内存占用问题
        内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。

        针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。

(2)数据一致性问题
        CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。【当执行add或remove操作没完成时,get获取的仍然是旧数组的元素】

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值