JAVA并发编程工具
文章目录
- 并发工具
- ConcurrentHashMap
- 使用注意事项
- 复合操作非原子性问题
- 弱一致性问题
- 不支持 Null 键值
- 使用示例
- BlockingDeque
- LinkedBlockingDeque
- ArrayBlockingDeque
- ConcurrentLinkedQueue
- CopyOnWriteArrayList / CopyOnWriteArraySet
并发工具
Concurrent
类型的容器
- 内部很多操作使用cas优化,一般可以提供较高吞吐量
- 弱一致性
- 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容时旧的
- 求大小弱一致性,size操作未必时100%准确
- 读取弱一致性
遍历时如果发生了修改,对于非安全容器来讲,使用fail-fast
机制也就是让遍历立刻失败,抛出ConcurrentModificationException
,不再继续遍历
ConcurrentHashMap
- 为什么要重写hashCode()和equals()
1.重复存储相同对象
若两个对象逻辑相等(根据业务规则,如字段值相同),但未重写 hashCode()
,其默认哈希值(基于内存地址)不同。哈希集合(如 HashSet
)会将其视为不同对象,导致重复存储.
Set<Person> set = new HashSet<>();
set.add(new Person("Alice", 30)); // 对象1
set.add(new Person("Alice", 30)); // 对象2(逻辑相同)
System.out.println(set.size()); // 输出 2(预期应为 1)
2.无法正确检索数据
在 HashMap
中,若用未重写 hashCode()
的对象作为键:
存储时:根据键的哈希值决定存储位置(桶索引)。
检索时:新键的哈希值不同,无法定位到原桶,返回 null
Map<Person, String> map = new HashMap<>();
map.put(new Person("Bob", 25), "Engineer");
String job = map.get(new Person("Bob", 25)); // 返回 null
3.性能下降/对象哈希值可能集中分布
Java
的Object.hashCode()
默认实现基于对象的内存地址生成哈希值,哈希值分布依赖内存地址的分配模式,而非对象内容JVM
分配内存时,若连续创建对象obj1
、obj2
、obj3
,其地址可能为0x1000
、0x1008
、0x1010
,哈希值对应为1000
、1008
、1010
(假设简化转换),默认哈希值的低位可能高度相似(如上述哈希值的低4位均为000
),导致计算出的桶索引集中在少数位置
示例
对象 | 内存地址 | 哈希值 | 桶索引 (15 & hash) |
---|---|---|---|
objA | 0x1000 | 4096 | 0 (因 4096 & 15 = 0) |
objB | 0x2000 | 8192 | 0 (因 8192 & 15 = 0) |
objC | 0x3000 | 12288 | 0 (因 12288 & 15 = 0) |
结果:所有对象被分配到索引0
的桶中,冲突率100%
。
- 集中分布的后果:多个对象被分配到同一桶中,
HashMap
退化为链表(或红黑树),查询效率从O(1)
降至O(n)
或O(log n)
。
使用注意事项
复合操作非原子性问题
问题描述:
单个操作(如 get
、put
)是线程安全的,但多个操作的组合(如“检查再更新”)不保证原子性。例如:
// 线程不安全的库存扣减
public boolean processOrder(String productId, int quantity) {Integer stock = map.get(productId);if (stock >= quantity) {map.put(productId, stock - quantity); // 可能覆盖其他线程的修改return true;}return false;
}
风险:多个线程同时读取相同库存值并各自扣减,导致超卖(如实际库存 10,线程 A 和 B 均读到 10 并扣减 3 和 2,最终库存变为 7 而非 5)。
解决方案
使用原子方法:computeIfPresent()
或 compute()
确保检查与更新原子化
map.computeIfPresent(productId, (k, v) -> v >= quantity ? v - quantity : v);
或配合 synchronized
或 ReentrantLock
手动加锁(牺牲部分性能)。
弱一致性问题
问题描述:
- 迭代器弱一致性:迭代过程中若其他线程修改数据,迭代器可能反映部分修改或完全不反映,但不会抛
ConcurrentModificationException
size()/isEmpty()
不精确:返回的是近似值(无锁统计可能遗漏部分更新)。
::: tip
风险:
实时统计场景(如实时监控大盘)可能读到过期数据
:::
- 需强一致性的场景改用
Collections.synchronizedMap()
或Hashtable
(性能更低)。 或通过额外锁机制同步
不支持 Null 键值
问题描述:
键或值为 null
时会抛 NullPointerException
(而 HashMap
允许)。
原因:
多线程下无法区分“键不存在”和“键对应值为 null
”,可能引发歧义。
解决方案:
- 改用
Optional
包装值(如map.put(key, Optional.ofNullable(value))
)。 - 或确保业务逻辑中无需
null
。
使用示例
- 示例一
// 使用 putIfAbsent:无论 "a" 是否存在,都会先创建 LongAdder 对象
conMap.putIfAbsent("a", new LongAdder()); // 可能浪费资源// 使用 computeIfAbsent:仅当 "a" 不存在时才执行 new LongAdder()
LongAdder longAdder = counter.computeIfAbsent("key", k -> new LongAdder()); // 按需创建
longAdder.increment(); // +1
BlockingDeque
LinkedBlockingDeque
- 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
- 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
- 消费者与消费者线程仍然串行
- 生产者与生产者线程仍然串行
线程安全分析
- 当节点总数大于
2
时(包括dummy
节点),putLock
保证的是last
节点的线程安全,takeLock
保证的是
head
节点的线程安全。两把锁保证了入队和出队没有竞争 - 当节点总数等于
2
时(即一个dummy
节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞
争 - 当节点总数等于
1
时(就一个dummy
节点)这时take
线程会被notEmpty
条件阻塞,有竞争,会阻塞
ArrayBlockingDeque
Linked
支持有界,Array
强制有界Linked
实现是链表,Array
实现是数组Linked
是懒情的,而Array
需要提前初始化Node
数组Linked
每次入队会生成新Node
,而Array
的Node
是提前创建好的Linked
两把锁,Array
一把锁
ConcurrentLinkedQueue
- 两把【锁】,同一时刻,可以充许两个线程同时(一个生产者与一个消费者)执行
dummy
节点的引入让两把【锁】将来锁住的是不同对象,避免竞争- 只是这【锁】使用了
cas
来实现
CopyOnWriteArrayList / CopyOnWriteArraySet
- 适合读多写少的场景,用空间换取线程安全
CopyOnlriteArraySet
是CopyOnWriteArrayList
的马甲
底层实现采用了写入时贝的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离。
get
弱一致性- 迭代器弱一致性
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();Thread.sleep(1000);while (iter.hasNext()) {System.out.println(iter.next());}
不要觉得弱一致性就不好
- 数据库的MVCC都是弱一致性的表现(mysql的可重复读)
- 并发高和一致性是矛盾的,需要权衡