Java并发编程入门学习之集合不安全

hresh 657 0

Java并发编程入门学习之集合不安全

概念

Java集合类框架的基本接口有哪些?

总共有两大接口:Collection 和 Map ,一个元素集合,一个是键值对集合; 其中 List 和 Set 接口继承了 Collection 接口,一个是有序元素集合,一个是无序元素集合; 而 ArrayList 和 LinkedList 实现了 List 接口,HashSet 实现了 Set 接口,这几个都比较常用; HashMap 和 HashTable 实现了 Map 接口,并且 HashTable 是线程安全的,但是 HashMap 性能更好;

Java并发编程入门学习之集合不安全

Collection 和 Collections 有什么区别?

  • java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection 接口在 Java 类库中有很多具体的实现。Collection 接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有 List 与 Set。

  • Collections 则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。

List、Set、Map 之间的区别是什么?

Java并发编程入门学习之集合不安全

快速失败(fail-fast)和安全失败(fail-safe)的区别是什么?

快速失败:当你在迭代一个集合的时候,如果有另一个线程正在修改你正在访问的那个集合时,就会抛出一个 ConcurrentModification 异常。
在 java.util 包下的都是快速失败,不能在多线程下发生并发修改(迭代过程中被修改)。

安全失败:你在迭代的时候会去底层集合做一个拷贝,所以你在修改上层集合的时候是不会受影响的,不会抛出 ConcurrentModification 异常。
在 java.util.concurrent 包下的全是安全失败的。可以在多线程下并发使用,并发修改。

List

并发不安全

首先我们查看如下案例:

public class ListTest {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();

        for(int i=0;i<10;i++){
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            },String.valueOf(i)).start();
        }
    }
}

执行上述代码,会抛出 java.util.ConcurrentModificationException 并发异常。

解决方案

1、Vector

public class ListTest {

    public static void main(String[] args) {
        List<String> list = new Vector<>();

        for(int i=0;i<10;i++){
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(list);
            },String.valueOf(i)).start();
        }
    }
}

Vector 类是在 JDK1.0 出现的,比 ArrayList 还早,那为什么不推荐该方法呢?查看源码得知:

    public synchronized boolean add(E var1) {
        ++this.modCount;
        this.ensureCapacityHelper(this.elementCount + 1);
        this.elementData[this.elementCount++] = var1;
        return true;
    }

使用 Synchronized 关键字来实现同步操作,效率较低。原因:在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

2、使用 Collections 工具类

List<String> list = Collections.synchronizedList(new ArrayList<>());

同样我们来查看源码,Collections.synchronizedList() 的定义如下:

    public static <T> List<T> synchronizedList(List<T> var0) {
        return (List)(var0 instanceof RandomAccess ? new Collections.SynchronizedRandomAccessList(var0) : new Collections.SynchronizedList(var0));
    }
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable

一路跳转后,最终定位到 SynchronizedCollection 静态内部类,List 对象也就变为了 SynchronizedCollection 类型,查看其 add 方法定义:

        public boolean add(E var1) {
            synchronized(this.mutex) {
                return this.c.add(var1);
            }
        }

同 Vector 一样,还是采用的 Synchronized 关键字来做同步操作,只是封装在 Collections 工具类中。

3、使用CopyOnWriteArrayList

我们来通过看源码的方式来理解 CopyOnWriteArrayList,实际上 CopyOnWriteArrayList 内部维护的也是一个数组

private transient volatile Object[] array;

只是该数组是被 volatile 修饰,注意这里仅仅修饰的是数组引用,与被 volatile 修饰的普通变量有所区别,关于这点我在之前的文章中有分析,对 volatile 还不是太了解的朋友也可以去看一下。对 list 来说,我们自然而然最关心的就是读写的时候,分别为 get 和 add 方法的实现。

以下方式利用 CopyOnWriteArrayList 来保证线程安全。

List<String> list = new CopyOnWriteArrayList<>();

CopyOnWriteArrayList 比 Vector 更高级,因为加锁的方式不一样,前者使用 Lock 锁。查看其 add 方法定义:

    public boolean add(E var1) {
        ReentrantLock var2 = this.lock;
        var2.lock();

        boolean var6;
        try {
            Object[] var3 = this.getArray();
            int var4 = var3.length;
            Object[] var5 = Arrays.copyOf(var3, var4 + 1);
            var5[var4] = var1;
            this.setArray(var5);
            var6 = true;
        } finally {
            var2.unlock();
        }

        return var6;
    }

除了通过 Lock 来加锁处理,每次写数据前,都会另外通过 Arrays.copyOf 方法复制一份数据,在写入的时候避免覆盖,造成数据问题。

我们需要注意 CopyOnWriteArrayList 中的这两个字段属性以及相关方法。

    final transient ReentrantLock lock = new ReentrantLock();
    private transient volatile Object[] array;

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

    final void setArray(Object[] var1) {
        this.array = var1;
    }

注意事项:

  1. 采用 ReentrantLock 来加锁,保证同一时刻只有一个线程在对数据进行读写操作;
  2. 数组引用是 volatile 修饰的,因此将旧的数组引用指向新的数组,根据 volatile 的 happens-before 规则,写线程对数组引用的修改对读线程是可见的的,但是数组中的元素并不可见。
  3. 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写是在两个不同的数据容器中进行操作。

CopyOnWriteArrayList 的缺点:

  1. 内存占用问题:因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 minor GC 和 major GC。
  2. 数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。

CopyOnWriteArrayList 的优点:

  1. 线程安全,能够保证数据一致性。
  2. 与 Vector、ArrayList 相比,CopyOnWriteArrayList 在多线程遍历迭代过程中不会报错。

关于优缺点中各自的第二条,通过以下案例向大家说明:

public class CowTest {

    public static void main(String[] args) {
        // 初始化一个list,放入5个元素
        final List<Integer> list = new CopyOnWriteArrayList<>();
//        final List<Integer> list = new Vector<>();
//        final List<Integer> list = Collections.synchronizedList(new ArrayList<>());
        for(int i = 0; i < 5; i++) {
            list.add(i);
        }

        // 线程一:通过Iterator遍历List
        Thread t1 = new Thread(()-> {
            for(int item : list) {
                System.out.println("遍历元素:" + item);
                // 由于程序跑的太快,这里sleep了1秒来调慢程序的运行速度
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 线程二:add一个元素
        Thread t2 = new Thread(() ->{
            // 由于程序跑的太快,这里sleep了1秒来调慢程序的运行速度
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            list.add(5);
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
            System.out.println(list);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

执行结果为:

遍历元素:0
遍历元素:1
遍历元素:2
遍历元素:3
遍历元素:4
[0, 1, 2, 3, 4, 5]

当线程1在遍历集合,线程2往集合中新增一个数据,如果使用前两种线程安全的替换方案,有可能产生 ConcurrentModificationException 异常。为什么 CopyOnWriteArrayList 就没问题呢?原因在于它保证线程安全采用的是写时复制并加锁,所以线程1在遍历时拿到的集合是旧的,这也就是结果中为啥没有输出5的原因,虽然拿到的是旧集合,但是至少不会报错。

CopyOnWriteArrayList 只能保证数据最终一致性,这点从结果中可以看出来,当在线程2中给集合新增了元素,但是无法立即通知到线程1,所以无法保证数据实时性。但是当其他线程再次读取集合时,才能读取到完整的新数据。

最后总结一下,CopyOnWriteArrayList 适合在读多写少的场景使用,实时性要求不高,不然读取到的可能是旧数据。添加数据可以采用批量添加 addAll 方法,减少内存占用。

Set

并发不安全

首先我们查看如下案例:

public class SetTest {

    public static void main(String[] args) {
        Set<String> set= new HashSet<>();

        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            },String.valueOf(i)).start();
        }
    }
}

执行上述代码,会抛出 java.util.ConcurrentModificationException 并发异常。

解决方案

1、使用 Collections 工具类

Set<String> set = Collections.synchronizedSet(new HashSet<>());

同 List 使用 Collections.synchronizedList()一样,通过 synchronized 来实现线程安全。

2、使用CopyOnWriteArraySet

Set<String> set = new CopyOnWriteArraySet<>();

我们点击查看 CopyOnWriteArraySet 类,发现其背后通过 CopyOnWriteArrayList 来存储数据。

    private final CopyOnWriteArrayList<E> al;

    public CopyOnWriteArraySet() {
        this.al = new CopyOnWriteArrayList();
    }

我们知道 Set 集合中的元素是不可重复,那么这是怎么做到的呢?首先来查看 add 方法。

    public boolean add(E var1) {
        return this.al.addIfAbsent(var1);
    }

实际上是执行 CopyOnWriteArrayList 中的 addIfAbsent 方法。

public boolean addIfAbsent(E var1) {
    Object[] var2 = this.getArray();    //获取当前数组信息
    //indexOf方法用于比对新增值在原数组中是否存在,若存在,则返回值不小于0,否则返回-1,即要执行addIfAbsent方法
    return indexOf(var1, var2, 0, var2.length) >= 0 ? false : this.addIfAbsent(var1, var2);
}

private boolean addIfAbsent(E var1, Object[] var2) {
    ReentrantLock var3 = this.lock;
    var3.lock();

    try {
        Object[] var4 = this.getArray();
        int var5 = var4.length;
        boolean var13;
        if (var2 != var4) {
            int var6 = Math.min(var2.length, var5);

            for(int var7 = 0; var7 < var6; ++var7) {
                if (var4[var7] != var2[var7] && eq(var1, var4[var7])) {
                    boolean var8 = false;
                    return var8;
                }
            }

            if (indexOf(var1, var4, var6, var5) >= 0) {
                var13 = false;
                return var13;
            }
        }

        Object[] var12 = Arrays.copyOf(var4, var5 + 1);
        var12[var5] = var1;
        this.setArray(var12);
        var13 = true;
        return var13;
    } finally {
        var3.unlock();
    }
}

同 CopyOnWriteArrayList 中的 add 方法类似,addIfAbsent 方法也是先加锁,然后写前复制来保证线程安全。

Map

并发不安全

首先我们查看如下案例:

public class MapTest {

    public static void main(String[] args) {

//        Map<String,Object> map = new HashMap<>();
        //加载因子和初始容量
        //等价于:new HashMap(16,0.75)

        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                map.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0,5));
                System.out.println(map);
            },String.valueOf(i)).start();

        }
    }
}

执行上述代码,会抛出 java.util.ConcurrentModificationException 并发异常。

解决方案

1、使用 Collections 工具类

Map<String,Object> map = Collections.synchronizedMap(new HashMap<>());

2、使用Hashtable

Map<String,Object> map = new Hashtable<>();

该类通过对读写进行加锁(synchronized)操作,一个线程在读写元素,其余线程必须等待,性能较低。

3、使用 ConcurrentHashMap

Map<String,Object> map = new ConcurrentHashMap<>();

关于 ConcurrentHashMap 的详细学习,可以参考这篇文章

参考文献

并发容器之CopyOnWriteArrayList

先简单说一说Java中的CopyOnWriteArrayList

如何线程安全地遍历List:Vector、CopyOnWriteArrayList

发表评论 取消回复
表情 图片 链接 代码

分享