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
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ArrayListTest {

public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
// ArrayList 不支持并发写操作
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i)).start();
}
}

}

程序运行输出的结果:

1
2
3
4
5
6
7
8
java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1043)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:997)
at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:472)
at java.base/java.lang.String.valueOf(String.java:2951)
at java.base/java.io.PrintStream.println(PrintStream.java:897)
at com.java.interview.test.ArrayListTest.lambda$main$0(ArrayListTest.java:14)
at java.base/java.lang.Thread.run(Thread.java:834)

解决 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 的底层源码如下:

list-copy-on-write

写时复制的使用案例

这里简单介绍一下 CopyOnWriteArrayList 的使用,可以用于解决 ArrayList 的线程安全问题,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ArrayListTest {

public static void main(String[] args) throws InterruptedException {
List<String> list = new CopyOnWriteArrayList<>();
for (int i = 1; i <= 10; i++) {
// CopyOnWriteArrayList 支持并发写操作
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i)).start();
}
}

}

程序运行输出的结果:

1
2
3
4
5
6
7
8
9
10
[9d12f701]
[9d12f701, 77f4feca, eb280a00]
[9d12f701, 77f4feca]
[9d12f701, 77f4feca, eb280a00, 859da88c, 3204da31, 0b594f8c, ff2a1328]
[9d12f701, 77f4feca, eb280a00, 859da88c, 3204da31, 0b594f8c]
[9d12f701, 77f4feca, eb280a00, 859da88c, 3204da31]
[9d12f701, 77f4feca, eb280a00, 859da88c]
[9d12f701, 77f4feca, eb280a00, 859da88c, 3204da31, 0b594f8c, ff2a1328, 6515bf2e, 175fb2da, 97a3832a]
[9d12f701, 77f4feca, eb280a00, 859da88c, 3204da31, 0b594f8c, ff2a1328, 6515bf2e, 175fb2da]
[9d12f701, 77f4feca, eb280a00, 859da88c, 3204da31, 0b594f8c, ff2a1328, 6515bf2e]

写时复制的优缺点

  • 优点

    • 适用于读多写少的业务场景,可以提高并发性能。
  • 缺点

    • 存在内存占用问题、数据一致性问题。
    • 为了减少扩容带来开销,应该尽量使用批量添加(减少复制次数)。
    • CopyOnWrite 机制只能保证数据的最终一致性,不能保证实时数据的强一致性,因此如果希望写入的数据能马上能读到,那么就不能使用 CopyOnWrite 机制。

HashSet 的线程不安全

HashSet 的底层结构

HashSet 的底层是使用 HashMap 进行实现的。

为什么调用 HashSet 的 add() 方法时,只需要传递一个参数,而 HashMap 是则需要传递 key-value 键值对呢?首先查看 HashSet 的 add() 方法(如下图),可以发现在调用 add() 方法的时候,新增的值只是作为 key,而 value 存储的是一个 Object 类型的常量。也就是说 HashSet 在存储数据时,只关心 key,而不关心 value。

HashSet 的并发修改

为什么 HashSet 是线程不安全的呢?因为在进行执行写操作的时候,方法上为了保证并发性,是没有添加 synchronized 修饰的,所以在并发写的时候,就会出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HashSetTest {

public static void main(String[] args) throws InterruptedException {
Set<String> set = new HashSet<>();
for (int i = 1; i <= 10; i++) {
// HashSet 不支持并发写操作
new Thread(() -> {
set.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(set);
}, String.valueOf(i)).start();
}
}

}

程序运行输出的结果:

1
2
3
4
5
6
7
8
java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1493)
at java.base/java.util.HashMap$KeyIterator.next(HashMap.java:1516)
at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:472)
at java.base/java.lang.String.valueOf(String.java:2951)
at java.base/java.io.PrintStream.println(PrintStream.java:897)
at com.java.interview.test.HashSetTest.lambda$main$0(HashSetTest.java:15)
at java.base/java.lang.Thread.run(Thread.java:834)

解决 HashSet 并发写出现的异常:

  • 第一种方法:使用 CopyOnWriteArraySet(写时复制)类替代,其底层实现主要是一种读写分离的思想。
  • 第二种方法:使用 Collectons 类,即使用 Collections.synchronizedSet(new HashSet<>()) 创建一个线程安全的 HashSet。

HashSet 的写时复制

写时复制的实现原理

CopyOnWriteArraySet 的底层是使用 CopyOnWriteArrayList 进行实例化。

写时复制的使用案例

这里简单介绍一下 CopyOnWriteArraySet 的使用,可以用于解决 HashSet 的线程安全问题,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HashSetTest {

public static void main(String[] args) throws InterruptedException {
Set<String> set = new CopyOnWriteArraySet<>();
for (int i = 1; i <= 10; i++) {
// CopyOnWriteArraySet 支持并发修改
new Thread(() -> {
set.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(set);
}, String.valueOf(i)).start();
}
}

}

程序运行输出的结果:

1
2
3
4
5
6
7
8
9
10
[3c2e0351]
[3c2e0351, 8f987008, 9b0e5b7d]
[3c2e0351, 8f987008]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63, 7d4ca880, d68de36d]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63, 7d4ca880]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63, 7d4ca880, d68de36d, 1e8ce86f, 0c4f7d93, 524f3f78, 31ef5f4a]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63, 7d4ca880, d68de36d, 1e8ce86f, 0c4f7d93, 524f3f78]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63, 7d4ca880, d68de36d, 1e8ce86f, 0c4f7d93]
[3c2e0351, 8f987008, 9b0e5b7d, 06eddb63, 7d4ca880, d68de36d, 1e8ce86f]

HashMap 的线程不安全

HashMap 的并发修改

为什么 HashMap 是线程不安全的呢?因为在进行执行写操作的时候,方法上为了保证并发性,是没有添加 synchronized 修饰的,所以在并发写的时候,就会出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class HashMapTest {

public static void main(String[] args) throws InterruptedException {
Map<String, String> set = new HashMap<>();
for (int i = 1; i <= 10; i++) {
// HashMap 不支持并发写操作
new Thread(() -> {
String str = UUID.randomUUID().toString().substring(0, 8);
set.put(str, str);
System.out.println(set);
}, String.valueOf(i)).start();
}
}

}
1
2
3
4
5
6
7
8
9
java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1493)
at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1526)
at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1524)
at java.base/java.util.AbstractMap.toString(AbstractMap.java:551)
at java.base/java.lang.String.valueOf(String.java:2951)
at java.base/java.io.PrintStream.println(PrintStream.java:897)
at com.java.interview.test.HashMapTest.lambda$main$0(HashMapTest.java:16)
at java.base/java.lang.Thread.run(Thread.java:834)

解决 HashMap 并发写出现的异常:

  • 第一种方法:使用 ConcurrentHashMap 替代,即使用 Map<String, String> map = new ConcurrentHashMap<>()
  • 第二种方法:使用 Collectons 类,即使用 Collections.synchronizedMap(new HashMap<>()) 创建一个线程安全的 Map。