【多线程并发编程】十二 CopyOnWriteArrayList源码分析

程序猿学社的GitHub,欢迎Star
https://github.com/ITfqyd/cxyxs
本文已记录到github,形成对应专题。

前言:

在学习多线程的过程中,我们经常会听到ArrayList线程不安全。有个别社友就在说,我们在项目中用的好好的,也没有什么报错,实际上,大部分的人,在项目开发过程中,确实很少接触到这块。来我们一探究竟呗。看看为什么说ArrayList线程不安全。

1.浅谈ArrayList

一文了解ArrayList源码及扩容
在多线程环境下,操作同一资源,会有各种各样的问题出现,例如,在多线程环境下,操作ArrayList进行添加操作,就会出现java.util.ConcurrentModificationException的异常。我们常说某某是不是线程安全,实际上,是有一个前提的,是在多线程环境里,因为单线程过程中不会存在线程不安全的问题。

package com.cxyxs.thread.twelve;

import java.lang.reflect.Array;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * Description:转发请注明来源  程序猿学社 - https://ithub.blog.csdn.net/
 * Author: 程序猿学社
 * Date:  2020/2/20 20:00
 * Modified By:
 */
public class Demo1 {
    public static void main(String[] args){
        List<Integer> lists = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                for (int j = 0; j < 50; j++) {
                    lists.add(j);
                    try {
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"A").start();
        }

       while (true){
           if (Thread.activeCount() == 2){
               System.out.println(lists.size());
               return;
           }
       }
        //Thread.currentThread().getThreadGroup().list();
    }
}

  • TimeUnit.MILLISECONDS.sleep(1) 线程休眠一毫秒,不要使用 Thread.sleep(1),这两句代码都是一样的意思,我们应该尽量使用juc下的内容。
  • 50个线程,打印50次,每次休眠1毫秒,鬼知道什么时候能打完,看到社长之前的文章的社友,应该都知道,启动main方法后,会有两个线程一直处于运行状态,所以,我们只需要判断正在运行的线程数是不是为2,就可以知道子线程的业务逻辑是否已经跑完,因为run执行完后,线程会进入死亡状态。

读过小学的,都知道50*50,是2500,为什么打印的结果是2463?

  • 这就是为什么说ArrayList是线程不安全的原因。

话不多说,上ArrayList的add源码

private int size;
public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

size++就是线程不安全的,需要大家了解JMM的可见性,不了解可以大致看看我之前的文章。
【多线程并发编程】六 什么是线程安全?

  • 假设size为10,主内存会存size=10,其他的线程,会copy一份,放到各自的工作内存里面,线程A,发现size为10,所以+1后,size就会11了,注意,这时候线程B,发现size也是10,也执行+1操作,size变成11,线程A和线程B会把操作的结果通知给主内存,所以主内存的值变成11,实际上,应该为12才对。

2.解决线程不安全问题

使用Vetoct

package com.cxyxs.thread.twelve;

import java.lang.reflect.Array;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * Description:转发请注明来源  程序猿学社 - https://ithub.blog.csdn.net/
 * Author: 程序猿学社
 * Date:  2020/2/20 20:00
 * Modified By:
 */
public class Demo1 {
    public static void main(String[] args){
        //线程不安全
        //List<Integer> lists = new ArrayList<>();
        // 优化方案1
        List<Integer> lists = new Vector<>();
        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                for (int j = 0; j < 50; j++) {
                    lists.add(j);
                    try {
                        Thread.sleep(1);
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"A").start();
        }

       while (true){
           if (Thread.activeCount() == 2){
               System.out.println(lists.size());
               return;
           }
       }
        //Thread.currentThread().getThreadGroup().list();
    }
}

  • 社长运行了几次,每次的结果都是2500,说明线程不安全的问题,已经解决。
    我们来看一看Vector源码,来看看他是怎么解决线程不安全问题的。
    使用ctrl+F12(idea),输入add快速定位到这个方法
public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }
  • synchronized 关键字,上锁,意思同一时刻只能有一个线程,能操作add方法。类似于,有一个茅坑,里面有几个坑,有一个怪癖,每次上茅坑,都把大门都关起来。造成资源的浪费,其他的人想上茅坑,只能等隔壁老王上完才能使用。所以这种方式,不怎么建议使用,根据场景,自己合理选择。
  • modCount看起来很陌生,他表示结构被修改的次数。新增,修改等modCount都会增加1

多线程开发经常遇到的报错ConcurrentModificationException

之前在开发过程中,经常发现有ConcurrentModificationException这个报错。
看名字其义,意思就是多线程修改异常,也就说多个线程对同一份资源进行写操作会有这种异常报错。

final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
}

我们看一看哪里调用了这个方法

 public E next() {
            synchronized (Vector.this) {
                checkForComodification();
                int i = cursor;
                if (i >= elementCount)
                    throw new NoSuchElementException();
                cursor = i + 1;
                return elementData(lastRet = i);
            }
        }
  • 发现迭代器next方法会检查修改的次数。调用Itr时,会把modCount赋值给expectedModCount。因为modCount涉及到修改删除等操作时,就会变动。
public synchronized Iterator<E> iterator() {
        return new Itr();
}
  • list的循环输出iterator方法,就是上就是new Itr()

疑问

本人在调用add时,发现modCount每次调用add就会+1,但是expectedModCount一直没有变,就有点困惑。个人感觉应该跟这些类的add方法应该有关系。对这方面了解很深的大佬,可以在下方留言

使用Collections集合类

  //优化方案二:
 List<Integer> lists =Collections.synchronizedList(new ArrayList<>());
  • 经过多次测试,结果都是2500.
  • 有了synchronized,为什么还用弄出一个Collections,就是为了方便我们开发,使我们更关注于业务逻辑的开发。

CopyOnWriteArrayList

package com.cxyxs.thread.twelve;

import java.lang.reflect.Array;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;

/**
 * Description:转发请注明来源  程序猿学社 - https://ithub.blog.csdn.net/
 * Author: 程序猿学社
 * Date:  2020/2/20 20:00
 * Modified By:
 */
public class Demo1 {
    public static void main(String[] args){
        //线程不安全
        //List<Integer> lists = new ArrayList<>();
        // 优化方案1
        //List<Integer> lists = new Vector<>();
        //优化方案二:
        //List<Integer> lists =Collections.synchronizedList(new ArrayList<>());
        //优化方案三
        List<Integer> lists = new CopyOnWriteArrayList();

        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                for (int j = 0; j < 50; j++) {
                    lists.add(j);
                    try {
                        Thread.sleep(1);
                        TimeUnit.MILLISECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            },"A").start();
        }

       while (true){
           if (Thread.activeCount() == 2){
               System.out.println(lists.size());
               return;
           }
       }
        //Thread.currentThread().getThreadGroup().list();
    }
}

通过多次测试,发现结果为2500.

  • CopyOnWriteArrayList是一个一个线程安全ArrayList,其中所有可变操作( add , set ,等等)通过对底层数组的最新副本实现
    了解CopyOnWriteArrayList,我们首先需要先了解copyonwrite机制.

写入时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的一种优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源。
我们通过源码来看一看,jdk大佬是如何实现CopyOnWriteArrayList的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();
        }
    }
  • 看到我们熟悉的人"lock",为了避免死锁,jdk官方的代码就是跟try finally配套使用的,也是为了try里面的代码报错,锁没有释放。
  • 这简单很简洁,上锁,获取原来数组内容,再在原来数组长度上增加1,产生一个新的数组,并给最后一个元素复制。最后,再set进去。
  • 看了这个代码后,如果多个线程在操作过程中,我们读,会读到旧数据,这也是CopyOnWrite,写入时复制,实际上换一个说法,就是读写分离。
    我们看了CopyOnWriteArrayList的add方法,我们来看一看,他的iterator方法
 public ListIterator<E> listIterator() {
        return new COWIterator<E>(getArray(), 0);
    }
   static final class COWIterator<E> implements ListIterator<E> {
        /** Snapshot of the array */
        private final Object[] snapshot;
        /** Index of element to be returned by subsequent call to next.  */
        private int cursor;

        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }  

     @SuppressWarnings("unchecked")
        public E next() {
            if (! hasNext())
                throw new NoSuchElementException();
            return (E) snapshot[cursor++];
        } 
}
  • 不知道大家注意到没有,我们可以发现调用iterator,实际上都用的是另外一个类,实际上就是把外面的object,复制给里面这个类的数组。所以,个人认为,读到的也是旧的数据(多线程环境下)。
  • 看了ArrayList,Vetoct,CopyOnWriteArrayList等等一些源码,发现他们都是没有直接迭代输出的方法,都是借助一个类实现ListIterator接口,大致都是这种套路。
    总结:CopyOnWrite适用于读多写少的场景。因为我们知道每次调用写操作,都会重新开辟一个数组。对频繁的写操作,性能十分的底下。
发布了286 篇原创文章 · 获赞 557 · 访问量 22万+

猜你喜欢

转载自blog.csdn.net/qq_16855077/article/details/104598379