天天看点

【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

现在属于一个查漏补缺的阶段,之前京东的面试中问到我,关于多线程操作集合时,集合不安全该如何解决?

当时就只想到了(可能因为紧张,我承认比较菜 )使用使用实现安全的集合类和使用Collections。synchronizedxxx的集合安全类来解决,现在回想起来自己当时的确回答的不好,这两种方式并不能保证“真正的线程安全”。查漏补缺,将掌握不熟练的知识一定要多练习,返回回顾知识,熟能生巧。

而且最近越来越感受到,每当看一些曾经掌握的知识的源码,就发现那些前辈真的很厉害,有一些设计很巧妙。

文章目录

  • List集合不安全
    • 方式1:直接使用多线程安全的集合类
      • 如何回答Vector是否是线程安全的集合类?
    • 方式2:使用Collections工具类修饰的集合类
    • 方式3:使用集合安全类
      • CopyOnWriteArrayList是如何保证线程安全的?
      • CopyOnWriteArrayList中可能出现的问题:
      • CopyOnWriteArrayList内部时如何实现的(梳理版)?
      • synchronizedArrayList的实现和CopyOnWriteArrayList有什么不同?
  • Set不安全
    • 使用Collections工具包下的synchronizedSet
    • 使用同步的Set集合
  • Map不安全
    • 使用Hashtable
    • 使用Collections.synchronizedMap
    • 使用同步Map

List集合不安全

当前企业的开发都会考虑到高并发的问题,博主我在今年秋招时也被多次问到集合类在多线程中使用的问题。这部分还是很重要的。

我们熟悉的ArrayList、HashMap等等集合类很多都不是线程安全的。也就是说在多线程情况下是可能造成多线程问题,因此

有需求也必须

让使用的集合类变安全。

通常能够想到的使用多线程安全的集合类的方式有三种:

  1. 直接使用本身就是线程安全的集合类,比如使用Vector,HashTable等等。
  2. 使用集合类工具类Collections类下的工具类对集合类进行修饰。
  3. 使用JUC包下的多线程安全集合类,如CopyOnWriteArrayList等。

接下来,我将三种不同的多线程安全的使用集合类做代码演示。

先演示一下普通的集合类会造成多线程安全问题

package jucTest2;

import java.util.ArrayList;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/4 17:02
 * 集合类在多线程下操作的不安全性
 */
public class UnFireCollectionDemo {

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

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                arrayList.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(arrayList);
            }
        },"A线程").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                arrayList.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(arrayList);
            }
        },"B线程").start();
    }
}
           
【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

出现了

java.util.ConcurrentModificationException

异常(同步修改异常),也就是说在多线程同时操作集合时出现了多线程操作的问题。

方式1:直接使用多线程安全的集合类

package jucTest2;

import java.util.UUID;
import java.util.Vector;

/**
 * @author 雷雨
 * @date 2020/12/4 16:55
 * 直接使用多线程安全的集合类
 */
public class FireCollectionDemo1 {

    public static void main(String[] args) {

        Vector<String> vector  = new Vector<>();

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                vector.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(vector);
            }
        },"线程A").start();

        new Thread(()->{
            for (int i = 1; i <= 10 ; i++) {
                vector.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(vector);
            }
        },"线程B").start();

    }
}
           
【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

有观察结果会发现:没有出现异常。也就是说在集合Vector进行多线程操作时没有发生多线程问题。

为什么Vector是线程安全的?

简单的讲,Vector是线程安全的,因为Vector中的每个方法都使用了synchronized修饰,从而保证访问 vector 的任何方法都必须获得对象的 intrinsic lock (或叫 monitor lock),也即,在vector内部,其所有方法不会被多线程所访问。

Vector一定不存在多线程安全问题吗?

if (!vector.contains(element)) 
vector.add(element); 
...
}
           

其实Vector也可能存在多线程安全安全问题,虽然在Vector的内部的方法都使用了synchronized修饰,保证了在Vector内部使用时,Vector是线程安全的,但是如果如上述代码所示,是在外部环境中使用的,仍然存在锁竞争,对应上述代码,虽然contains和add方法都是原子性的操作,但是在if条件判断为真之后,关于contains的锁释放了,在多线程的环境中,其他线程有可能与add线程竞争并获取了锁资源后修改了其状态,而add线程在当时正在等待,只有其他线程释放锁资源后,add线程拿到了锁,add线程才执行(而在add方法执行时,它已经是基于一个错误的假设了)。

单个方法的synchronized了并不代表组合方法调用的原子性。

如何回答Vector是否是线程安全的集合类?

Vector 和 ArrayList 实现了同一接口 List, 但所有的 Vector 的方法都具有 synchronized 关键修饰。但对于复合操作,Vector 仍然需要进行同步处理。

方式2:使用Collections工具类修饰的集合类

package jucTest2;

import java.util.*;

/**
 * @author 雷雨
 * @date 2020/12/4 17:28
 * 直接使用多线程安全的集合类
 */
public class FireCollectionDemo2 {

    public static void main(String[] args) {

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

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

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


    }
}
           

结果正常输出,没有发生多线程安全问题。

【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

关于Collections.synchronizedList和Vector的区别:

  1. 在源码中Vector中线程安全的实现是使用了synchronized锁住了整个方法(也就是使用了同步方法的方式),而在Collections.synchronizedList中是使用synchronized锁了当前的mutex对象,而mutex对象指向的是当前的实例。
  2. 那么Vector锁的对象是调用者,而Collections.synchronizedList锁的是synchronizedList本身的实例对象。

方式3:使用集合安全类

比如使用CopyOnWriteArrayList

package jucTest2;


import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * @author 雷雨
 * @date 2020年12月4日20:45:23
 * 直接使用多线程安全的集合类CopyOnWriteArratList
 */
public class FireCollectionDemo3 {

    public static void main(String[] args) {

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

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


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




    }
}
           

多次运行,发现没有出现异常,该集合类是一个多线程安全的类。

【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

CopyOnWriteArrayList是如何保证线程安全的?

CopyOnWriteArrayList直接翻译就是写的时候复制,也就是说在写操作的时候是创建一个新的容器进行写操作,写完之后,再将原容器的引用指向新容器,整个过程加锁,保证了写的线程安全。

整个过程都使用Lock加锁,是线程安全的

//添加元素操作
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;
        //将array引用指向新数组
        setArray(newElements);
        return true;
    } finally {
        //解锁
        lock.unlock();
    }
}
//删除操作
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 {
            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();
    }
}
           

而因为读操作的时候不会对当前容器做任何处理,所以我们可以对容器进行并发的读,而不需要加锁,也就是读写分离。

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

CopyOnWriteArrayList中可能出现的问题:

CopyOnWriteArrayList虽然实现了读写分离,提高了效率,并且在需要写操作的地方使用了ReentrantLock保证了线程的同步,但是仍然是存在问题的:

  1. 由于写操作是通过复制原数组,会消耗内存,如果原数组的数据量较大,可能会导致频繁的minor GC。
  2. 不能用于

    实时性

    的场景,因为是读写分离的,而且在写操作中采用的方式是通过复制写的操作,那么就会有耗时,可能会在写入数据的过程中,有读取的操作,那么可能导致读取的数据还是旧的数据。

    CopyOnWriteArrayList

    能保证

    最终一致性

    ,但是却不能满足实时性的要求。

从第二点也就说明了CopyOnWriteArrayList其实比较适用于

读多写少

的场景,但是还是慎用,不能保证每次写入的操作的数据量,可能会导致读取到旧的数据的可能性。

小结:

CopyOnWriteArrayList的思想:

1、读写分离,读和写分开

2、最终一致性

3、使用另外开辟空间的思路,来解决并发冲突

CopyOnWriteArrayList内部时如何实现的(梳理版)?

  • 写操作的实现

再梳理一遍源码(以添加操作为例)

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;
        //将array引用指向新数组
        setArray(newElements);
        return true;
    } finally {
        //解锁
        lock.unlock();
    }
}
           

首先CopyOnWriteArrayList的写操作的实现:

  1. 首先在写操作的内部创建了一个ReentrantLock同步锁。
  2. 另外在写操作,在加锁的情况下还使用了复制写的思想。复制一个新的数据,添加元素之后,将引用指向新数组。

为什么要使用ReentrantLock?

是为了保证多线程的同步操作。对于多线程同步,采用加锁,能够解决多线程的问题。

为什么要使用复制写的操作?

因为关于集合的操作不仅有写操作还有读操作,如何不采用复制写的思想,那么就要对读操作也要加锁,不然就可能会造成线程安全问题(锁竞争机制)。但是CopyOnWriteArrayList为了保证读写分离(保证读的操作的效率),因此没有对读操作加锁。

如果没有复制,写时加锁,读取不加锁,那么就会造成并发读写问题,产生不可预期的错误,造成ConcurrentModificationException问题。(是因为为了保证并发读写的安全性,在集合中维护了一个ModConcurrent用来计数集合修改次数)如果在写时,进行了读取操作,ModConcurrent变化了,就会抛出ConcurrentModificationException。

  • 可能会问,为什么CopyOnWriteArrayList中采用写操作,读不加锁?

如果写操作加锁,读操作也使用ReentrantLock加锁,那么就退化为synchronized,读性能大大减弱。

synchronizedArrayList的实现和CopyOnWriteArrayList有什么不同?

synchronizedArrayList的实现是使用了synchronized关键字在方法的内部对操作进行加锁(同步代码块)的方式实现线层同步,CopyOnWriteArrayList的内部使用的是ReentrantLock(同步锁),CopyOnWriteArrayList底层保存元素的数组使用了volatile保证而来线程间的可见性。

Set不安全

package jucTest2;

import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/4 21:53
 * Set 集合不安全
 */
public class UnFireCollectionSet {

    public static void main(String[] args) {

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

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"A线程").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"B线程").start();


    }
}
           
【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

结果可以看到发生了CurrentModifcationException异常(同步修改异常)。

使用Collections工具包下的synchronizedSet

package jucTest2;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/5 8:47
 * 使用集合安全的类  set
 * Collections工具包下的集合安全类
 */
public class FireCollectionSet2 {
    public static void main(String[] args) {
        Set<String> set = Collections.synchronizedSet(new HashSet<String>());

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"线程A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"线程B").start();
    }
}
           
【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

使用同步的Set集合

使用CopyOnWriteSet

package jucTest2;

import java.util.UUID;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CopyOnWriteArraySet;

/**
 * @author 雷雨
 * @date 2020/12/5 8:53
 * 使用同步Set
 */
public class FireCollectionSet3 {
    public static void main(String[] args) {
        CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<String>();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"线程A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                set.add(UUID.randomUUID().toString().substring(0,5));
                System.out.println(set);
            }
        },"线程B").start();
    }
}
           

CopyOnWriteSet的底层使用CopyOnWriteList来实现的,因此也能保证线程安全。

Map不安全

HashMap在多线程操作下不安全

package jucTest2;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
 * @author 雷雨
 * @date 2020/12/5 9:03
 * Map在多线程下操作不安全
 *
 */
public class UnFireCollectionMap {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线层A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线程B").start();
    }
}
           

使用Hashtable

使用HashTable

package jucTest2;

import java.util.Hashtable;
import java.util.UUID;

/**
 * @author 雷雨
 * @date 2020/12/5 9:10
 * 使用本身就是多线程安全的类
 */
public class FireCollectionMap1 {
    public static void main(String[] args) {
        Hashtable<String,Integer> map  = new Hashtable<>();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线层A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线程B").start();
    }
}
           

使用本身是集合安全的Hashtable能够保证多线程操作的安全。

为什么Hashtable是线程安全的?

看源码分析一下

//写操作上加锁
public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}
//读操作加锁
public synchronized V get(Object key) {
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            return (V)e.value;
        }
    }
    return null;
}
           

小结:Hashtable就是将读写方法都进行了加锁,保证了读写的多线程安全性。

但是还是仍然存在多线程问题的,因为存在锁资源竞争。

使用Collections.synchronizedMap

package jucTest2;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
 * @author 雷雨
 * @date 2020/12/5 9:17
 * 使用Collections
 */
public class FireCollectionMap2 {
    public static void main(String[] args) {
        Map<Object, Object> map = Collections.synchronizedMap(new HashMap<>());
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线层A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线程B").start();
    }
}
           

Collections.synchronizedMap是线程安全的类。

【JUC】集合类多线程操作不安全的三种解决方案List集合不安全Set不安全Map不安全

使用同步Map

使用ConcurrentHashMap

package jucTest2;

import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 * @author 雷雨
 * @date 2020/12/5 9:25
 * 使用同步Map
 */
public class FireCollectionMap3 {
    public static void main(String[] args) {
        ConcurrentMap<String,Integer> map = new ConcurrentHashMap<>();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线层A").start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                int temp = i;
                map.put(UUID.randomUUID().toString().substring(0,5),temp);
                System.out.println(map);
            }
        },"线程B").start();
    }
}
           

ConcurrentHashMap源码分析放在之后的博客,因为细节比较多。