JUC P7 线程安全集合 基础+代码
教程:https://www.bilibili.com/video/BV16J411h7Rd
13. 线程安全集合类
13.1 概述
13.1.1 老古董
HashTable
,Vector
13.1.2 使用 Collections 修饰的线程安全类(装饰器模式的思想)
13.1.3 JUC 安全集合类
13.1.3.1 CopyOnWrite
修改开销相对较重,基于 synchronized
,适用于读多写少的场景
13.1.3.2 Blocking
大部分实现基于锁(ReentrantLock
),并提供用来阻塞的方法
13.1.3.3 Concurrent
内部很多操作使用 CAS 优化,一般可以提供较高的吞吐量
- 弱一致性
- 遍历时弱一致性,例如,当利用迭代器遍历时,若容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
- 求容量大小弱一致性,size() 操作未必 100% 准确
- 读取弱一致性
Note:
对于非安全容器来讲,遍历过程中发生修改,使用了 fail-fast 的机制会让遍历立刻失败,抛出ConcurrentModificationException
,不再继续遍历。
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.add(5);
new Thread(() -> {
list.forEach(System.out::println);
}).start();
new Thread(() -> list.add(6)).start();
new Thread(() -> list.add(7)).start();
new Thread(() -> list.add(8)).start();
13.2 ConcurrentHashMap
13.2.1 练习:单词计数
随机生成 26 个字母,每个字母生成 200 次,打乱后装入 26 个文件中。
static final String ALPHA = "abcedfghijklmnopqrstuvwxyz";
public static void main(String[] args) {
int length = ALPHA.length();
int count = 200;
List<String> list = new ArrayList<>(length * count);
for (int i = 0; i < length; i++) {
char ch = ALPHA.charAt(i);
for (int j = 0; j < count; j++) {
list.add(String.valueOf(ch));
}
}
Collections.shuffle(list);
for (int i = 0; i < 26; i++) {
try (PrintWriter out = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream("G://tmp/" + (i + 1) + ".txt")))) {
String collect = String.join("\n", list.subList(i * count, (i + 1) * count));
out.print(collect);
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用 HashMap
统计,可以看到结果不正确,正确应该是每个字母出现次数都是 200:
public static void main(String[] args) {
demo(
// 创建 map 集合
// 创建 ConcurrentHashMap 对不对?
() -> new HashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter + 1;
map.put(word, newValue);
}
}
);
}
private static <V> void demo(Supplier<Map<String, V>> supplier,
BiConsumer<Map<String, V>, List<String>> consumer) {
Map<String, V> counterMap = supplier.get();
List<Thread> ts = new ArrayList<>();
for (int i = 1; i <= 26; i++) {
int idx = i;
Thread thread = new Thread(() -> {
List<String> words = readFromFile(idx);
consumer.accept(counterMap, words);
});
ts.add(thread);
}
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counterMap);
}
public static List<String> readFromFile(int i) {
ArrayList<String> words = new ArrayList<>();
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("G://tmp/"
+ i + ".txt")))) {
while (true) {
String word = in.readLine();
if (word == null) {
break;
}
words.add(word);
}
return words;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
ConcurrentHashMap 错误用法
假如把 HashMap
换成 ConcurrentHashMap
正确吗?
() -> new ConcurrentHashMap<String, Integer>()
可以看到结果也不正确!什么情况?
- 因为只能保证一个方法是原子的,当多个方法交错使用,那么这个整体并不保证是原子的
解决方案 1 (上 synchronized 锁)
() -> new ConcurrentHashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
synchronized (map) {
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter + 1;
map.put(word, newValue);
}
}
}
这样确实能解决问题,但是并发度不高。
解决方案 2(putIfAbsent 和 computeIfPresent)
() -> new ConcurrentHashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
map.putIfAbsent(word, 0);
map.computeIfPresent(word, (key, value) -> value + 1);
}
}
解决方案 3 (LongAdder 累加器)
() -> new ConcurrentHashMap<String, LongAdder>(),
// 进行计数
(map, words) -> {
for (String word : words) {
LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder());
value.increment();
}
}
对比
经过大量测试,大部分情况都是第三种方案更胜一筹,第一种方案次之,最差的就是第二种方案:
12.2.2 JDK 7 HashMap 并发死链
比较形象的例子:jdk1.7中 hashmap为什么会发生死链? - 磊哥的回答 - 知乎
12.2.3 JDK 8 HashMap 并发数据不一致
两个线程同时插入尾节点,出现顶替的情况,造成数据丢失。以及两个线程同时修改数据的情况。
12.2.4 为什么 ConcurrentHashMap 不允许插入的 key/value 为 null ?
第一,代码中不允许!
第二,这会带来并发场景下的歧义:
举个例子:
- 单线程下,比如使用
map.get(key)
方法后的结果返回null
,我们要判断 ①key
对应的值是null
,② 还是map
中没有这个key
,有这两种情况。这时候我们可以使用map.containsKey(key)
方法看返回的是true
还是false
。如果是true
,代表key
对应的值为null
,如果是false
,说明map
中没有这个key
。单线程情况下确实可以使用额外的方法判定消除歧义。 - 多线程下,判断是否含有
key
和获取key
对应的值两个方法因为是分开的操作,这样无法保证一个线程下执行两个方法过程中是否有另外一个线程进行捣乱。假如说初始状态下map
中key
对应"hello"
。
时刻 | 线程1 | 线程2 |
---|---|---|
1 | map.containsKey(key) 返回 true | |
2 | map.put(key, null) | |
3 | map.get(key) ,期待:"hello" ,实际:null |
13.3 CopyOnWriteArrayList
底层实现采用了写入时拷贝的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其他线程的并发读,读写分离。
读-读,读-写都可以并发执行,写-写是互斥的。
适用于读多写少的场景。写过程太多会一直创建新的数组,典型的以空间换时间。
弱一致性
@SneakyThrows
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iter = list.iterator();
new Thread(() -> {
list.remove(0);
System.out.println(list);
}).start();
TimeUnit.SECONDS.sleep(1);
while (iter.hasNext()) {
System.out.println(iter.next());
}
}
弱一致性:
- MySQL 的 MVCC
- 高并发和一致性是矛盾的,需要权衡
- 将读写分离