Java并发编程-集合类的线程安全问题

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异常。

分析:

  1. 故障现象:
    java.util.ConcurrentModificationException异常(重要)

  2. 导致原因:
    ArrayList线程不安全,因为add方法没有加锁。现在30个线程同时操作,又来读,又来写,此时崩盘了。

  3. 解决方案
    见下一小节

  4. 优化建议(同样的错误,不会出现第二次)

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的初始化容量设置的大一点,尽量避免扩容。

猜你喜欢

转载自blog.csdn.net/qq_39736597/article/details/112670327