Java 多线程编程之六集合类的线程安全问题
大纲
- Java 多线程编程之一 Java 内存模型浅析
- Java 多线程编程之二 synchronize 锁对象竞争
- Java 多线程编程之三 volatile 与 JMM 内存模型
- Java 多线程编程之四 CAS、ABA 问题、锁
- Java 多线程编程之五 AQS 底层源码深度剖析
- Java 多线程编程之六集合类的线程安全问题
- Java 多线程编程之七队列、线程池、线程通信
集合类的简单介绍
常见的集合类有哪些
Map 接口和 Collection 接口是所有集合框架的父接口,其中 Collection 接口的子接口包括 Set 接口和 List 接口:
- Set 接口的实现类主要有:HashSet、TreeSet、LinkedHashSet 等
- List 接口的实现类主要有:ArrayList、LinkedList、Vector、Stack 等
- Map 接口的实现类主要有:HashMap、TreeMap、HashTable、ConcurrentHashMap 等
HashMap 和 HashTable 的区别
HashMap 和 HashTable 都是 Java 中用于存储键值对的数据结构,它们之间有以下几点区别:
线程安全性
:HashTable 是线程安全的,而 HashMap 是非线程安全的。null 键值
:在 HashMap 中,可以存储一个键或值为 null 的元素,如果尝试将相同的键多次放入 HashMap 中,后面的值会覆盖前面的值。相反的,在 HashTable 中,不允许键或值为 null。继承关系
:HashMap 继承自 AbstractMap 类,而 HashTable 继承自 Dictionary 类,两者都实现了 Map 接口。性能方面
:HashMap 的性能比 HashTable 更高,因为 HashTable 在每次访问时都需要进行同步处理,而 HashMap 不需要同步,因此在单线程环境下 HashMap 的性能更高。
总结说明
- 如果不需要线程安全,并且有可能会存储 null 键或值,推荐使用 HashMap。
- 如果需要线程安全,建议使用 ConcurrentHashMap,而避免使用 HashTable,因为它已经被官方标记为不推荐使用。
- ConcurrentHashMap 通过把整个 Map 分为 N 个 Segment(类似 HashTable),可以提供相同的线程安全,但是性能提升 N 倍,性能默认提升 16 倍。
ArrayList 的线程不安全
ArrayList 的扩容机制
当执行 new ArrayList<Integer>()
操作时,底层创建了一个空的数组,且数组的初始长度为 10。当向 ArrayList 添加元素时,如果当前元素个数达到了数组的容量,就会触发扩容操作。ArrayList 的扩容机制是通过调用 grow()
方法来实现的,而 grow()
方法则是调用 Arrays.copyOf(elementData, netCapacity)
方法进行扩容。具体的扩容过程如下:
- (1) 当创建 ArrayList 对象时,会初始化一个默认容量(一般为 10)的数组作为底层存储结构。
- (2) 当添加新元素时,会首先判断是否需要扩容,即判断当前元素个数是否已经达到数组容量。
- (3) 如果需要进行扩容,ArrayList 会计算新的容量大小,一般是当前容量的 1.5 倍(即旧容量 * 1.5)。
- (4) 然后会创建一个新的数组,并将原数组中的元素复制到新数组中。
- (5) 最后,ArrayList 会将新数组设置为内部存储数组,并丢弃旧数组。
ArrayList 的并发修改
为什么 ArrayList 是线程不安全的呢?因为在进行执行写操作的时候,方法上为了保证并发性,是没有添加 synchronized
修饰的,所以在并发写的时候,就会出现问题。
1 | public class ArrayListTest { |
程序运行输出的结果:
1 | java.util.ConcurrentModificationException |
解决 ArrayList 并发写出现的异常:
- 第一种方法:使用 JUC 里面的 CopyOnWriteArrayList (写时复制)类替代,其底层实现主要是一种读写分离的思想。
- 第二种方法:使用 Collectons 类,即使用
Collections.synchronizedList(new ArrayList<>())
创建一个线程安全的 List。 - 第三种方法:使用 Vector 替代,Vector 底层是使用数组实现的,在每个方法上都加了锁,即使用
synchronized
修饰方法,这导致了其运行效率特别低。
ArrayList 的写时复制
写时复制的实现原理
CopyOnWrite 容器即写时复制的容器。往一个容器添加元素的时候,不直接往当前容器的 Object[]
添加,而是先将当前 Object[]
进行 Copy,复制出一个新的容器 Object[] newElements
,然后新的容器 Object[] newElements
里添加元素,添加完元素之后,再将原容器的引用指向新的容器(setArray(newElements)
)。这样做的好处是可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,所以 CopyOnWrite 容器也是一种读写分离的思想,读和写针对的是不同的容器。JDK 8 的 CopyOnWriteArrayList 的底层源码如下:
写时复制的使用案例
这里简单介绍一下 CopyOnWriteArrayList 的使用,可以用于解决 ArrayList 的线程安全问题,示例代码如下:
1 | public class ArrayListTest { |
程序运行输出的结果:
1 | [9d12f701] |
写时复制的优缺点
优点
- 适用于读多写少的业务场景,可以提高并发性能。
缺点
- 存在内存占用问题、数据一致性问题。
- 为了减少扩容带来开销,应该尽量使用批量添加(减少复制次数)。
- CopyOnWrite 机制只能保证数据的最终一致性,不能保证实时数据的强一致性,因此如果希望写入的数据能马上能读到,那么就不能使用 CopyOnWrite 机制。
HashSet 的线程不安全
HashSet 的底层结构
HashSet 的底层是使用 HashMap 进行实现的。
为什么调用 HashSet 的 add()
方法时,只需要传递一个参数,而 HashMap 是则需要传递 key-value 键值对呢?首先查看 HashSet 的 add()
方法(如下图),可以发现在调用 add()
方法的时候,新增的值只是作为 key,而 value 存储的是一个 Object 类型的常量。也就是说 HashSet 在存储数据时,只关心 key,而不关心 value。
HashSet 的并发修改
为什么 HashSet 是线程不安全的呢?因为在进行执行写操作的时候,方法上为了保证并发性,是没有添加 synchronized
修饰的,所以在并发写的时候,就会出现问题。
1 | public class HashSetTest { |
程序运行输出的结果:
1 | java.util.ConcurrentModificationException |
解决 HashSet 并发写出现的异常:
- 第一种方法:使用 CopyOnWriteArraySet(写时复制)类替代,其底层实现主要是一种读写分离的思想。
- 第二种方法:使用 Collectons 类,即使用
Collections.synchronizedSet(new HashSet<>())
创建一个线程安全的 HashSet。
HashSet 的写时复制
写时复制的实现原理
CopyOnWriteArraySet 的底层是使用 CopyOnWriteArrayList 进行实例化。
写时复制的使用案例
这里简单介绍一下 CopyOnWriteArraySet 的使用,可以用于解决 HashSet 的线程安全问题,示例代码如下:
1 | public class HashSetTest { |
程序运行输出的结果:
1 | [3c2e0351] |
HashMap 的线程不安全
HashMap 的并发修改
为什么 HashMap 是线程不安全的呢?因为在进行执行写操作的时候,方法上为了保证并发性,是没有添加 synchronized
修饰的,所以在并发写的时候,就会出现问题。
1 | public class HashMapTest { |
1 | java.util.ConcurrentModificationException |
解决 HashMap 并发写出现的异常:
- 第一种方法:使用 ConcurrentHashMap 替代,即使用
Map<String, String> map = new ConcurrentHashMap<>()
。 - 第二种方法:使用 Collectons 类,即使用
Collections.synchronizedMap(new HashMap<>())
创建一个线程安全的 Map。