java集合:线程安全的实现方式与分析

目录

1.ArrayList、HashSet和HashMap分析线程不安全的原因

1.1 ArrayList

1.2 HashMap

1.3 HashSet

2. List线程安全的实现方式

3.Set线程安全的实现方式

4.Map线程安全的实现方式


前言我们常用的ArrayList、HashSet以及HashMap都是线程不安全的。

1.ArrayList、HashSet和HashMap分析线程不安全的原因

1.1 ArrayList

public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
  • 这里不是一个原子操作,是分两步执行,赋值和运算

  • elementData[size++] = e;

(1)多线程并发的时候,假如A、B两个线程,A挂起的时候,B线程拿到的size值和A是一样的,就会覆盖线程A的值,导致A的值为空。

(2)因为size不能保证原子性,ArrayList 有默认数组大小,就会抛出数组下标越界的异常  
 

这说明add一个元素需要两个步骤:1. 将元素添加到对应位置;2. 将size+1

在多线程的情况下会出现什么问题呢?

假设有两个线程A和B都执行了add方法,并假设此时size为3,A添加完元素后(即执行完 elementData[s] = e;)被挂起了,B也添加元素,注意,此时size还是3,这会导致两个问题:

    1.A刚刚写入的值被覆盖了
    2.当A和B向下执行时,都会执行size = s + 1,那么size最后就变成了5,导致4位置本应该是B写入的值,现在却变成了null。

理论部分完毕,现在看一下代码:

public static void main(String[] args) throws InterruptedException {
       ArrayList<Integer> list1 = new ArrayList<>();
       for (int i = 0; i < 30; i++) {
           new Thread(() -> {
               try {
                   TimeUnit.MILLISECONDS.sleep(10);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               list1.add(3);
           }).start();
       }
       TimeUnit.SECONDS.sleep(2);
       System.out.println(list1.size());
       System.out.println(list1);
}

运行结果(为了得出这个结果试了好多次 > _ <):

11
[3, 3, 3, null, 3, 3, 3, 3, 3, 3, 3]

如看到的这样,确实出现了null。但似乎size的值有问题啊,我们是启动了30个线程add,那么最后size应该是30啊,为啥是11呢(其实每次运行size的值都会变的)?


size大小不是预期的值

如上所述,size的值并不是30,原因在于,size = s + 1并不是一个原子操作。

举个例子,假设现在s = 4,A、B两个线程都去进行s + 1操作,在Java中每个线程是有自己的工作内存的,因此A和B各自都有一份s = 4,然后分别执行s + 1,最后再写会主存。理想情况是A计算s + 1 = 5之后写回主存,然后B再读取,将6写回主存。但事实却是A和B可能同时执行s + 1 = 5然后写会主存,这就导致了size不是我们预期的结果。
数组下标越界

这种现象主要发生在准备扩容时,我们再来看一下add方法:

private void add(E e, Object[] elementData, int s) {
    if (s== elementData.length)
        elementData = grow();
        // A线程到这里挂起
    elementData[s] = e;
    size = s + 1;
}

还是假设A、B两个线程,并设此时数组长度为10,而s为9,那么A和B同时进到if判断都不会扩容,现在A在上面代码标注位置挂起了,而B接着执行添加操作,所以size = 10,此时就出现了数组下标越界错误。

1.2 HashMap

主要有这么几个问题:

    数据覆盖
    环形链表
    size的非原子问题

数据覆盖

A、B两个线程put时,假设计算出的hash值并对数组取余后是同一个位置,那么就会出现这种情况:A发现这个位置可以插入,但在插入前被挂起了,而B也发现可以插入,并且成功了,那么A继续执行后,由于刚刚已经判断过了是可以插入的,因此会直接把B的值给覆盖掉。
环形链表

这个主要是由于1.7版本中头插法导致的,网上已经有很多帖子说明了,这里贴几个比较清楚的:

    https://blog.csdn.net/zzu_seu/article/details/106669757
    https://blog.csdn.net/swpu_ocean/article/details/88917958

size原子问题

put时会判断++size是否超过了阈值,如果超过就扩容,但++size是非原子的,因此会有ArrayList中提到的问题。


1.3 HashSet

和HashMap是一回事,就不多说了。

2. List线程安全的实现方式

方案一:

java.util.Vector 所有的操作方法都是 synchronized 修饰, 确保线程安全。jdk1.1就有了,比较老了

方案二:
java.util.Collections.synchronizedList(list) 同样利用 synchronized 代码块, 包装原 list 的操作, 实现线程安全

方案三:
java.util.concurrent.CopyOnWriteArrayList 读写分离的思想, 写上锁, 读无锁. 写入时, 加锁 (利用了 java.util.concurrent.locks.ReentrantLock 上锁), 复制原数组 (并且数组长度 + 1, 赋值数组末尾元素为要新增的元素), 再更新数组的引用, 解锁

3.Set线程安全的实现方式

方案一:

和list一样,使用Colletcions这个工具类syn方法类创建个线程安全的set.

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

方案二:

使用JUC包里面的CopyOnWriteArraySet

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

4.Map线程安全的实现方式

1. HashTable :HashTable是对整张Hash表进行加锁,

2.ConcurrentHashMap:ConcurrentHashMap将Hash表分为16桶(segment),每次只对需要的桶进行加锁。

3. Collections 类提供了synchronizedMap()方法,可以将指定的集合包装成线程同步的集合。

上一篇:typora试用及部分Markdown格式


下一篇:LinkedHashSet