集合类的线程安全问题
1.List集合的线程安全
1.1.ArrayList线程安全问题。
ArrayList是线程安全的吗,我们不妨运行以下的程序
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=new ArrayList<>();
for(int i=1;i<=3;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的。写操作
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //默认复写了toString方法。读操作
},String.valueOf(i)).start();;
}
}
运行第一次
运行第二次
运行第三次
可见,几乎每次运行的结果都不一样。也没有报错。这显然是线程不安全的。
他们谁先写,谁先读是不知道的,因为太快了,纳秒级别的。有时候它还没写进去,别的线程就抢着读。读出来是个null。从个数上来说应该是3个。从值上面来说应该是8位的字符串。但是这里每次执行效果不一样。
如果改成30个线程,此时程序报错
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
直接报了异常。java.util.ConcurrentModificationException异常。
分析:
-
故障现象:
java.util.ConcurrentModificationException异常(重要) -
导致原因:
ArrayList线程不安全,因为add方法没有加锁。现在30个线程同时操作,又来读,又来写,此时崩盘了。 -
解决方案
见下一小节 -
优化建议(同样的错误,不会出现第二次)
1.2.ArrayList线程安全解决方案。
1.2.1.使用Vector
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=new Vector<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
}
没有报错
我们可以看看Vector的源码
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
可见,Vector的add方法中添加了synchronized关键字。可以确保线程安全。
我们再看看ArrayList的源码
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
没有加synchronizad关键字,所以线程不安全
线程安全能够保证数据一致性,但是读取效率会下降。Vector同一时间段只能有一个人操作,并不友好。数据一致性能够保证,但是性能下降。
1.2.2.Collections.synchronizedList(集合参数)
我们可以把线程不安全的ArrayList转换为一个线程安全的。在小数据量的时候,用这种方法完全可以。
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=Collections.synchronizedList(new ArrayList<>());
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
}
1.2.3.CopyOnWriteArrayList类(JUC中的类)
public static void main(String[] args) {
// TODO Auto-generated method stub
List<String> list=new CopyOnWriteArrayList();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
}
如何解释这个原理呢?
举个例子:
比如上课时要求的签到,花名册就是一个资源类。
先解决ArrayList为什么会出错,ArrayList没有加synchronized。多个线程允许来抢。
假设情况是ArrayList。此时桌子上只有一份名单(资源类)。张三在签到的时候,刚把张字写完,准备写三,李四此时过来一扯,画了长长的一道。相当于导致了并发修改异常。
而CopyOnWriteArrayList能够控制住多人的争抢,是加了锁相关的东西的。
用vector加锁了,同一时间段内只允许一个人来写一个人来读。用ArrayList读的人越来越多了,写的人会争坏。能不能解决一种问题能够同时满足写和读呢?
即要保证写的时候不出错,也要保证高并发的时候多个人来读。
此时我们产生了第三种思想,俗称写时复制,也称为读写分离的思想的一种。
写时复制:
CopyOnWrite容器即写时复制的容器。往一个容器中添加元素的时候,不直接往当前容器Object[]添加,而是先将当前容器Object[]进行copy。复制出一个新的容器Object[] newElements,然后新的容器Object[] newElements里添加元素,添加完元素之后,再将原容器的引用指向新的容器setArray(newElements);这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
我们来看看源代码
首先,CopyOnWriteArrayList类有一个Object类型的数组和相应的get和set方法
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
final void setArray(Object[] a) {
array = a;
}
找到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();
}
}
现在就可以解释上述问题了,**对于ConcurrentModificationException异常。通常是对容器进行并发的读和写的时候会出现该异常。**比如foreach遍历List的时候往其中添加add元素。
了解到ConcurrentModificationException异常后,我们就可以结合COW进行思考,如果写操作的时候不复制一个容器,仍然是之前的容器,那么此时并发的读操作就是对之前容器进行的操作,一个容器在被读的时候,又被另外一个线程进行了写操作,会报出上述错误。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器,不会发生ConcurrentModificationException异常
CopyOnWrite容器的优缺点:
优点:可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。最大的优势就是CopyOnWrite容器在被写的时候,仍然是可以读的。而Concurrent容器在写的时候,不能读。
不足1:CopyOnWrite容器在写入的时候会进行内部容器的复制,所以内部实现上多了一份核心数据的拷贝赛所需的资源,可以理解为:拿空间换时间
不足2:CopyOnWrite容器仅仅保证了数据的最终一致性,Concurrent容器保证了数据随时的一致性。
适用场景
对数据在操作过程中的一致性要求不高
根据上述不足1进行分析可以得出:更适用于读大于写的场景。换言之CopyOnWrite容器中保存的数据应该是尽可能不变化的。
2.Set集合的线程安全
2.1.HashSet集合的线程安全问题
public static void main(String[] args) {
Set<String> set=new HashSet<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
set.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(set); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
}
同样也报错了。一样的异常。
2.2.HashSet集合的线程安全解决方案
2.2.1.Collections.synchronizedSet(集合参数)
Set<String> set=Collections.synchronizedSet(new HashSet<>());
2.2.2.CopyOnWriteArraySet类;
Set<String> set=new CopyOnWriteArraySet<>();
都没问题。
2.3.HashSet的底层原理分析
HashSet底层数据结构是HashMap。如果是HashMap,往里面添加元素,需要添加两个,即键值对。而HashSet只添加了一个。怎么回事呢?
我们看看HashSet的源码
public HashSet() {
map = new HashMap<>();
}
底层是HashMap实锤了。
为什么一个添加键值对,一个就添加一个元素呢?
因为HashSet底层add方法调用的就是HashMap的put方法,HashSet添加进去的一个元素就是HashMap的key,value永远是Object的一个常量,固定写死。
private static final Object PRESENT = new Object(); //固定常量
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
3.Map集合的线程安全
3.1.HashMap集合的线程安全问题
HashMap是线程不安全的。
public static void main(String[] args) {
Map<String,String> map=new HashMap<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,8));
System.out.println(map); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
}
3.2.HashMap集合的线程安全问题解决方案
3.2.1.Collections.synchronizedMap(集合参数)
Map<String,String> map=Collections.synchronizedMap(new HashMap<>());
按照上面的思路此时应该有一个CopyOnWriteMap类,但是不是的。JUC提供了一个名叫ConcurrentHashMap类。
3.2.2.ConcurrentHashMap类
Map<String,String> map=new ConcurrentHashMap<>();
for(int i=1;i<=30;i++){
new Thread(()->{
//生成一个8位的随机不重复字符串,UUID版的
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,8));
System.out.println(map); //默认复写了toString方法。
},String.valueOf(i)).start();;
}
3.3.HashMap的底层原理分析
HashMap底层是一个Node类型数组+Node类型的链表+红黑树。即数组+链表+红黑树。HashMap是Node类型的结点,HashMap里面存的是Node,Node里面存的是键值对。
final int hash;
final K key;
V value;
Node<K,V> next;
HashMap默认初始化容量为16,负载因子为0.75
比如我们写的new HashMap()等价于new HashMap(16,0.75);
当我们创建一个空的HashMap(),数组的初始容量是16,到了16*0.75=12就会扩容。我们可以根据我们项目的要求一次性给一个容量,避免多次扩容。
ArrayList扩容时,扩容为原来的一半。
HashMap扩容为原来的一倍。开始时是16,经过扩容,扩容到32.之后每次扩容的容量都是2的n次幂。所以优化HashMap是根据我们项目的需求,尽量把HashMap的初始化容量设置的大一点,尽量避免扩容。