【JUC源码】CopyOnWriteArrayList源码分析

1.结构

CopyOnWriteArrayList 继承关系,核心成员变量及主要构造函数如下:

public class CopyOnWriteArrayList<E>
	implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    
    

	final transient ReentrantLock lock = new ReentrantLock();

    // volatile 关键字修饰,可见的
    // array 只开放出 get set
    private transient volatile Object[] array;
    
    //--------------------------------构造函数---------------------------------------
    public CopyOnWriteArrayList() {
    
    
        setArray(new Object[0]);
    }

    // 如果是普通的 list ,都会重新拷贝一份,不会影响原来的 list
    public CopyOnWriteArrayList(Collection<? extends E> c) {
    
    
        Object[] elements;
        if (c.getClass() == CopyOnWriteArrayList.class)
            elements = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
    
    
            elements = c.toArray();
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elements.getClass() != Object[].class)
                elements = Arrays.copyOf(elements, elements.length, Object[].class);
        }
        setArray(elements);
    }

    public CopyOnWriteArrayList(E[] toCopyIn) {
    
    
        setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
    }
}

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

  1. 加锁
  2. 从原数组中拷贝出新数组
  3. 在新数组上进行操作,并把新数组赋值给数组容器
  4. 解锁

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

2.方法解析&api

整体上来说,CopyOnWriteArrayList 就是利用 锁+拷贝+volatile 关键字保证了 List 的线程安全

  • 加锁:保证同一时刻数组只能被一个线程操作;
  • 数组拷贝:新建数组完成函数功能,不影响原数组
  • volatile:保证数组的内存地址被修改后,其它线程在读取数组时会从内存中读取,而不是从线程的缓存中读

这时候就有一个问题:都已经加锁了,为什么需要拷贝数组,而不是在原来数组上面进行操作呢,原因主要为:

  • volatile 关键字修饰的是数组,如果我们简单的在原来数组上修改其中某几个元素的值,是无法触发可见性的,我们必须通过修改数组的内存地址才行(setArray),也就说要对数组进行重新赋值才行。
  • 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能访问到,降低了在赋值过程中,老数组数据变动的影响。

2.1 增加

尾插:add(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;
        // 替换掉原来的数组,触发volatile可见性(即其他线程在读取elements时会从内存中读取而不是从自己线程栈的缓存中读)
        setArray(newElements);
        return true;
    // finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁   
    } finally {
    
    
        lock.unlock();
    }
}

从源码中,我们发现整个 add 过程都是在持有锁的状态下进行的,通过加锁,来保证同一时刻只能有一个线程能够对同一个数组进行 add 操作。

指定位置插入:add(int,E)

  1. 索引正确性判断
  2. 根据插入位置分成两种情况
    1. 插入在数组最后:直接通过Arrays.copyOf创建新数组
    2. 插入在数组中间:先创建新数组,然后分成两部分拷贝
  3. 将新元素在 index 位置插入
public void add(int index, E element) {
    
    
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
    
    
        	// 获取到原数组
            Object[] elements = getArray();
            int len = elements.length;
            // 判断索引是否正确,即是否超过数组大小或者小于0
            if (index > len || index < 0)
                throw new IndexOutOfBoundsException("Index: "+index+
                                                    ", Size: "+len);
            Object[] newElements;
            // 当插入一个元素,相当于原数组被分成了三部分。这里的numMoved计算的就是第三部分(第二个数组)的大小
            int numMoved = len - index;
            // 如果要插入的位置正好等于数组的末尾,直接拷贝数组即可
            if (numMoved == 0)
            	// 直接通过Arrays.copyOf创建len+1大小的数组(注:此时newElements[len]=null)
                newElements = Arrays.copyOf(elements, len + 1);
            // 如果要插入的位置在数组的中间,就需要拷贝 2 次
            else {
    
    
            	// 首先创建一个 len+1 大小的数组
                newElements = new Object[len + 1];
                // 第一次从 0 拷贝到 index,拷贝index个元素
                System.arraycopy(elements, 0, newElements, 0, index);
                // 第二次从 index+1 拷贝到末尾,拷贝numMoved个元素
                System.arraycopy(elements, index, newElements, index + 1, numMoved);
            }
            // index 索引位置的值是空的,直接赋值即可。
            newElements[index] = element;
            setArray(newElements);
        } finally {
    
    
            lock.unlock();
        }
    }

2.2 删除

删除指定位置元素:remove(int)

这个方法的过程类似上面的指定位置插入,即也分两种情况删除

  1. 要删除的元素在数组最后:直接通过Array.copyOf创建一个小一位的数组
  2. 要删除的元素在数组中间:先创建一个小一位的新数组,然后分成两部分拷贝
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);
        // 同上面add的numMoved,numMoved计算的是第三部分(第二个数组)的大小
        // 注:这里还有个-1,代表少拷贝一个元素(待删除元素)
        int numMoved = len - index - 1;
        // 如果要删除的数据正好是数组的尾部,直接删除
        if (numMoved == 0)
        	// 直接通过Array.copyOf方法产生一个小一位的数组
            setArray(Arrays.copyOf(elements, len - 1));
        // 如果删除的数据在数组的中间,分三步走
        else {
    
    
            // 1. 设置新数组的长度减一,因为是减少一个元素
            Object[] newElements = new Object[len - 1];
            // 2. 从 0 拷贝到数组新位置
            System.arraycopy(elements, 0, newElements, 0, index);
            // 3. 从新位置拷贝到数组尾部(在拷贝中执行删除的)
            System.arraycopy(elements, index + 1, newElements, index, numMoved);
            setArray(newElements);
        }
        return oldValue;
    } finally {
    
    
        lock.unlock();
    }
}

批量删除:removeAll

批量删除,类似于 ArrayList 的 removeAll 方法,即都是通过双指针加上一次遍历实现多个元素删除。感兴趣的同志可以参考【Java容器源码】ArrayList源码分析

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;
            if (len != 0) {
    
    
                // 新数组的长度,不是直接通过element.length - c.length,而是在删除过程中++的
                int newlen = 0;
                // 创建一个一样的新数组(长度为len)
                Object[] temp = new Object[len];
                // 通过双指针完成删除,一个指针是i(负责遍历),一个指针是newLen(负责赋值)
                for (int i = 0; i < len; ++i) {
    
    
                    Object element = elements[i];
                    if (!c.contains(element))
                        temp[newlen++] = element;
                }
                // 如果newLen != len,即在原数组中存在要删除元素
                if (newlen != len) {
    
    
                	// 通过Array.copyOf创建一个newLen长的新数组
                    setArray(Arrays.copyOf(temp, newlen));
                    return true;
                }
            }
            // 如果元素组长度len=0,或者没有要删除的元素(newLen=len),返回false
            return false;
        } finally {
    
    
            lock.unlock();
        }
    }

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

2.3 根据索引获取元素:indexOf

indexOf 方法的主要用处是查找元素在数组中的下标位置,如果元素存在就返回元素的下标位置,元素不存在的话返回 -1,不但支持 null 值的搜索,还支持正向和反向的查找,我们以正向查找为例,通过源码来说明一下其底层的实现方式

// o:我们需要搜索的元素
// elements:我们搜索的目标数组
// index:搜索的开始位置
// fence:搜索的结束位置
private static int indexOf(Object o, Object[] elements, int index, int fence) {
    
    
    // 支持对 null 的搜索
    if (o == null) {
    
    
        for (int i = index; i < fence; i++)
            // 找到第一个 null 值,返回下标索引的位置
            if (elements[i] == null)
                return i;
    } else {
    
    
        // 通过 equals 方法来判断元素是否相等
        // 如果相等,返回元素的下标位置
        for (int i = index; i < fence; i++)
            if (o.equals(elements[i]))
                return i;
    }
    return -1;
}

indexOf 方法在 CopyOnWriteArrayList 内部使用也比较广泛,比如在判断元素是否存在时(contains),在删除元素方法中校验元素是否存在时,都会使用到 indexOf 方法,indexOf 方法通过一次 for 循环来查找元素,我们在调用此方法时,需要注意如果找不到元素时,返回的是 -1,所以有可能我们会对这个特殊值进行判断。

2.4 迭代器

关于迭代器其实没什么多说的,迭代思路与ArrayList差不多。这里值得探究的一点是,在 CopyOnWriteArrayList 类注释中,明确说明了,在其迭代过程中,即使数组的原值被改变,也不会抛出 ConcurrentModificationException 异常,其根源在于在开始迭代时持有的是老数组的引用,而数组的每次变动,都会生成新的数组,不会影响老数组,这样的话,迭代过程中,根本就不会发生迭代数组的变动,我们截几个图说明一下:

  1. 迭代是直接持有原有数组的引用,也就是说迭代过程中,一旦原有数组的值内存地址发生变化,必然会影响到迭代过程,下图源码演示的是 CopyOnWriteArrayList 的迭代方法,我们可以看到迭代器是直接持有原数组的引用:

  2. 我们写了一个 demo,在 CopyOnWriteArrayList 迭代之后,往 CopyOnWriteArrayList 里面新增值,从下图中可以看到在 CopyOnWriteArrayList 迭代之前,数组的内存地址是 962,请记住这个数字:

  3. CopyOnWriteArrayList 迭代之后,我们使用 add(“50”) 代码给数组新增一个数据后,数组内存地址发生了变化,内存地址从原来的 962 变成了 968,这是因为 CopyOnWriteArrayList 的 add 操作,会生成新的数组,所以数组的内存地址发生了变化:

  4. 迭代继续进行时,我们发现迭代器中的地址仍然是迭代之前引用的地址,是 962,而不是新的数组的内存地址

从上面 4 张截图,我们可以得到迭代过程中,即使 CopyOnWriteArrayList 的结构发生变动了,也不会抛出 ConcurrentModificationException 异常的原因:CopyOnWriteArrayList 迭代持有的是老数组的引用,而 CopyOnWriteArrayList 每次的数据变动,都会产生新的数组,对老数组的值不会产生影响,所以迭代也可以正常进行。

猜你喜欢

转载自blog.csdn.net/weixin_43935927/article/details/108785676