Java中集合存在的线程安全问题
Java中,ArrayList、Hashset、HasMap等都是线程不安全的,主要原因就是其中许多操作(例如ArrayList中的add操作)是非原子的操作,这就导致多线程在操作这些集合类的时候会产生线程的安全性问题。
例如:
List<Integer> list = new ArrayList<>();
for(i = 1;i <= 30; i++){
new Thread(() -> {
list.add(i);
System.out.println(list);
});
}
上面这段代码中,很明显可以看出每个线程都会调用 list 的 add() 方法,这相当于写操作;每个线程又会输出 list ,这相当于读操作。
根据ArrayList的源码:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e; //此处可以分为两步: elementData[size] = e; size ++;
return true;
}
可以很明显看出,由于 list 的 add() 方法并不是原子操作,以及线程调度的随机性,就会导致线程的安全性问题,程序会报出java.util.concurrentModificationException异常,即线程并发修改异常。
举个例子就是假如有线程 A、B 同时操作 list ,若 A 线程对 elementData[size] 先行赋值,然而还没来得及进行 size++ 的时候,线程 B 抢先对 elementData[size] 进行了赋值,这样就会把线程A 赋给 elementData[size] 的值给覆盖掉,这样就会造成赋值缺失的错误;线程 B 赋值后执行了 size++ ,线程 A 这时也执行了 size++ ,那么 elementData[size+1] 就无值可赋,这样就会造成此处存入 null。
解决方法
整体思路在于将 add() 方法变成原子操作,即加锁。不过并不需要我们自己去手动加锁,Java中已经给了我们集中解决方案。
解决方法1:使用Vector类
List<Integer> list = new Vector<>();
原因是Vector类的源码中已经对 add() 方法进行了 synchronized 加锁。具体源码如下:
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
这样一来,就使得 add() 方法称为了线程安全的方法。不过一般不推荐使用Vector类,因为不能完全保证完全的线程安全,比如遍历时。
解决方法2:使用Collections.synchronizedList()
//使用集合工具类创建一个线程安全的ArrayList
List<Integer> list = new Collections.synchronizedList(new ArrayList<>());
具体源码不再做展示,可以自行查看。这种方法也是对 list 进行了 synchronized 加锁。不过也不推荐使用该方法,原因同解决方案1。
解决方法3:使用JUC中的copyOnWriteArrayList<E>()类
List<String> list = new CopyOnWriteArrayList<>();
通过查看 CopyOnWriteArrayList 类的 add() 方法的源码:
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
//使用了lock锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
//调用Arrays数组工具类中的copyOf()方法将原数组拷贝到一个新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//在新数组中的末位进行赋值
newElements[len] = e;
//将原引用指向当前的新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
上述过程是在lock锁的情况下执行的,每次进行写操作之前都要拷贝一个备份出来进行写操作,当执行完写操作时,会将原数组的引用指向当前新数组的引用,保证读操作区域和写操作区域的分离,这样就能解决 list.add() 带来的线程安全问题以及其他所有操作带来的线程安全问题。
当然该方法比较适用于读操作使用频率远远大于写操作使用频率的场景,因为拷贝数组会占用系统的内存。
HashSet和HashMap的线程安全问题可以类比ArrayList进行解决。
上述三种解决方案中,最为推荐的是使用 JUC 中的 copyOnWriteArraylist 类。