常见线程安全问题之复合操作

创作内容丰富的干货文章很费心力,感谢点过此文章的读者,点一个关注鼓励一下作者,激励他分享更多的精彩好文,谢谢大家!


复合操作的问题本质上和 TOCTOU 是一样的,如果有多个操作(如同一变量的读写)就有可能出现线程安全问题。

不过在本节我们要强调的是,即使每个操作本身是原子的,复合操作也不是原子的,这种情形有时候比较难一眼就认出来。

示例

这里以《Java 并发编程实战》第二章的“因式分解”代码为例:

 
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
  private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
  private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

  public void service(ServletRequest req, ServletResponse res) {
    BigInteger i = extractFromRequest(req);
    if (i.equals(lastNumber.get())) {               // ①
      encodeIntoResponse(resp, lastFactors.get());  // ②
    } else {
      BigInteger[] factors = factorOf(i);
      lastNumber.set(i);                            // ③
      lastFactors.set(factors);                     // ④
      encodeIntoResponse(resp, factors);
    }
  }
}

这个例子中 lastNumber 用来记录上一次做过“因式分解”的数,lastFactors 存放上次因式分解的结果。service 中先判断 lastNumber 是否与请求的数相等,如果相等则使用存储的 lastFactors;反之不相等则需要重新计算因式分解,并把结果存入 lastNumber 与 lastFactors 中。

这里 lastNumber 与 lastFactors 都用了 AtomicReference,它们是 JUC 中的类,可以理解为已经达到了原子性、可见性与顺序性。所以代码中的 ①②③④ 处的 get set 都是原子的,只不过复合操作的问题是,即使每个操作都是原子的,操作整体也不是原子的。

这个示例比较精妙的地方在于它很符合我们的编码习惯,如果不仔细思考甚至都发现不了它存在线程安全问题。

问题时序

考虑线程 A 与线程 B 同时进入 else 语句,且分别需要求得 2 和 3 的因式分解,考虑下面的时序:

----------- Thread A ---------------+--------- Thread B -----------------
lastNumber.set(i);         (=2)     |
                                    | lastNumber.set(i);         (=3)
                                    | lastFactors.set(factors);  (=[1,3])
lastFactors.set(factors);  (=[1,2]) |

则最终结束后 lastNumber = 3lastFactors = [1,2],则下次请求如果是分解 3 ,则会使用 lastFactors 的值,得到结果 [1,2],是错误的结果。

另一方面,也有可能是这样的时序:

----------- Thread A ---------------+--------- Thread B -----------------
lastNumber.set(i);         (=2)     |
                                    | if (i.equals(lastNumber.get()))  (= 2)
                                    |   encodeIntoResponse(resp, lastFactors.get());
lastFactors.set(factors);  (=[1,2]) |

这个时序里,一个线程计算了 2 的结果,正在写回缓存,过程中另一个线程请求因式分解 2,此时 lastNumber = 2,因此返回了 lastFactors 的内容,但线程 A 关于 2 的结果还未写回 lastFactors,线程 B 返回了一个错误的结果。

当然,也有可能是这样的时序:

----------- Thread A ---------------+--------- Thread B -----------------
Initial Value of lastNumber: 2      |
                                    | if (i.equals(lastNumber.get()))  (= 2)
lastNumber.set(i);         (=3)     |
lastFactors.set(factors);  (=[1,3]) |
                                    |   encodeIntoResponse(resp, lastFactors.get()); (=[1,3])

不成熟的解法:同步方法

从 TOCTOU 一节中我们知道,要解决这种竞争问题,需要把对状态的检查与使用都变成原子的,最简单的方式就是在方法上用 synchronized

 
@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
  // .. 省略代码

  public synchronized void service(ServletRequest req, ServletResponse res) {
    // .. 省略代码
  }
}

但是这个方法太极端了,所有的请求线程调用 service 方法都需要同步,同一时间只能有一个线程执行该方法,完全失去了多线程的优势。

解法:减小粒度

给整个方法加锁十分简单,但是由于锁的粒度很粗,并发性差。而我们的真实需求其实有两个:

  1. 对 lastNumber 和 lastFactors 的赋值操作需要是原子的
  2. 对 lastNumber 和 lastFactors 的读取也需要是原子的(至少读取过程中不允许赋值)

因此我们可以用 synchronized 代码块,实现如下:

 
public class UnsafeCachingFactorizer implements Servlet {
  private BigInteger lastNumber;
  private BigInteger[] lastFactors;

  public void service(ServletRequest req, ServletResponse res) {
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = null;

    synchronized (this) {                   // ①
      if (i.equals(lastNumber.get())) {
        factors = lastFactors.clone();
      }
    }

    if (factors == null) {
      factors = factor(i);
      synchronized (this) {                 // ②
        lastNumber = i;
        lastFactors = factors;
      }
    }

    encodeIntoResponse(resp, factors);
  }
}

在 ① 中把读操作用 synchronized 代码块保证原子性,在 ② 中用同样方法保证赋值的原子性。另一个关键点是,两个代码块需要加同一个锁,此处直接用了 this,是最稳妥的选择,当然也可以锁其它的 object,只要两个块加同一个锁即可。

另外此处因为使用了 synchronized,对 lastNumber 和 lastFactors 不再需要使用原子类。通常原子类(如 AtomicReference) 对单个操作的原子性保证很方便,但复合操作本身需要加锁,这里再使用原子类就显得没必要了。

小结

复合操作即使操作本身是原子的,复合操作作为一个整体本身也不具备原子性。所以和 TOCTOU 问题一样,解决方法是需要加锁来保证复合操作整体的原子性。

还有一点比较特殊,是我们看到“读操作”和“写操作”一样,都是必须要加锁的。

示例中我们也看到,并发编程是在简单性与并发性中的权衡。锁的粒度粗了,使用起来简单,但是并发性低,也许就满足不了性能要求;反之锁的粒度细了,并发性提高了,但是复杂度也随之增加,稍有不慎就容易有线程安全问题。

<think>首先,用户的问题是:“我想了解HashMap在多线程环境下的安全问题请问javahashmap线程安全问题及解决方案”。根据系统级指令,我必须使用中文回答,遵循LaTeX格式:行内数学表达式用$...$,独立公式用$$...$$并单独成段。LaTeX语法必须正确。另外,要生成相关问题。参考站内引用:我需要查看提供的引用:-引用[1]:HashMap线程安全问题及解决方案。提到了Hashtable、ConcurrentHashMap、SynchronizedMap。-引用[2]:HashMap线程不安全的原因包括数据不一致、程序崩溃、死循环。-引用[3]:线程安全体现在可见性和TestAndSet操作的非原子性上,解决方案如synchronized或HashTable。-引用[4]:比较了HashMap、SynchronizedMap、ConcurrentHashMap的线程安全和性能。-引用[5]:JDK7和JDK8的HashMap问题,如死循环在JDK7中。用户提供的引用已经编号为[^1]到[^5],所以在回答中引用时,我应该使用这些标识符。我的回答结构:系统指令要求回答结构清晰,逐步解决问题。保证真实可靠。主要回答HashMap的线程安全问题及解决方案:1.**线程安全问题**:解释为什么HashMap在并发时线程不安全。2.**解决方案**:讨论Hashtable、SynchronizedMap、ConcurrentHashMap。3.**额外信息**:比较性能、使用方式,提到JDK版本差异。从引用中提取关键点:-HashMap线程不安全原因:数据不一致(如put和get冲突)、死循环(在JDK7扩容时)、程序崩溃。-解决方案:-Hashtable:使用synchronized方法,整个map锁住。-SynchronizedMap:通过Collections.synchronizedMap包装HashMap。-ConcurrentHashMap:使用锁分段技术,提高并发性能。-性能:HashMap最好但非安全,SynchronizedMap较差,ConcurrentHashMap折中。-JDK版本:JDK7有死循环风险,JDK8改进但不安全。回答大纲:1.**引言**:简要介绍HashMap在多线程环境下的问题。2.**问题详细解释**:用中文阐述线程不安全的原因,结合引用。3.**解决方案**:列出并解释每个解决方案。4.**比较**:讨论性能和适用场景。5.**生成相关问题**:结尾生成相关问题。LaTeX使用:在提到数学概念时用LaTeX,例如时间复杂度可以用$O(1)$等。但HashMap本身不是数学,所以可能用不到LaTeX太多。确保如果提到时间复杂度等,用正确格式。引用标识:在回答中引用的段落末尾添加引用标识,如[^1]。参考用户的引用列表。步骤结构:-**步骤1:理解线程不安全的原因**-从引用[2]、[5]:数据不一致、死循环、程序崩溃。-**步骤2:具体问题场景**-如扩容时的死循环(JDK7),普通操作的数据竞争。-**步骤3:解决方案**-Hashtable:简单但低效[^1]-SynchronizedMap:包装HashMap[^1][^4]-ConcurrentHashMap:高效锁分段[^4]-**步骤4:性能比较**-从引用[4]-**结论和推荐**生成相关问题:结尾添加“相关问题”部分,类似示例中快速排序的相关问题。完整回答结构:-**标题**:或许不需要标题,直接开始。-**正文**:-解释HashMap的线程安全问题。-详细解决方案。-比较分析。-**代码块**:用户没有要求代码,但如果有相关代码可以放,但HashMap的实现不需代码。参考示例有代码,但用户问题不涉及代码。可能不需要代码。-**相关问题**:生成至少3个相关的问题。确保语言正式、清晰、中文。最后,生成相关问题示例:1.ConcurrentHashMap的锁分段技术是如何工作的?2.在哪些场景下使用HashMap可能导致并发问题?3.JDK8与JDK7中HashMap线程安全的区别有哪些?现在,编写回答。</think> ### Java HashMap 在多线程环境下的安全问题及解决方案 HashMap 是 Java 中最常用的哈希表实现,以其平均 $O(1)$ 的时间复杂度(查找和插入操作)高效而著称[^2]。然而,它在多线程环境下存在严重的线程安全问题,可能导致数据不一致、程序崩溃甚至死循环[^2][^5]。以下我将结构化地解释这些安全问题,并详细介绍解决方案。我会逐步引导你理解问题根源、具体表现和推荐措施。 #### 步骤1: HashMap 线程不安全的原因 HashMap 的设计初衷并非用于并发环境,因此在多线程操作中,主要问题源于以下两点: - **数据不一致性**:当多个线程同时进行 put 或 get 操作时,HashMap 的内部结构(如桶数组和链表)可能出现竞争。举例来说,一个线程在扩容时,另一个线程可能正在访问相同位置的数据,导致读取到旧值或丢失更新[^2][^4]。这是由于可见性和原子性问题(TestAndSet 操作的非原子性),这意味着变量的修改可能不被其他线程立即看到[^3]。 - **死循环问题(在特定 JDK 版本)**:在 JDK7 版本中,HashMap 在扩容时使用头插法(head insertion)。如果两个线程同时扩容,第一个线程被阻塞后,第二个线程完成扩容并形成新的链表结构;当第一个线程恢复时,可能创建环形链表,从而导致死循环(查询操作陷入 infinite loop)[^5]。JDK8 虽然通过尾插法(tail insertion)避免了死循环,但 HashMap 仍非线程安全[^5]。根本原因包括: - **可见性问题**:一个线程的修改对另一个线程不可见。 - **原子性问题**:复合操作(如 putIfAbsent)非原子,可能被其他线程打断。 这些问题的后果包括程序抛出 ConcurrentModificationException、数据丢失或系统资源耗尽。本质上,HashMap 缺乏内置锁机制,导致并发操作不可靠[^3][^4]。 #### 步骤2: 线程安全的解决方案 针对这些问题Java 提供了三种主要解决方案:Hashtable、SynchronizedMap 和 ConcurrentHashMap。每个方案都有其优缺点,我会从使用方式、性能和适用场景进行对比。 1. **Hashtable** - **描述**:这是 Java 早期提供的线程安全哈希表类。它通过在方法上加 synchronized 锁(例如 put 和 get 方法),确保整个映射在任何操作时都被锁住[^1][^3]。 - **优点**:实现简单,线程安全有保障。 - **缺点**:性能较差,因为每次操作都需要获取对象级锁。在高并发场景下,锁竞争导致时间复杂化度增到 $O(n)$($n$ 是线程数),吞吐量大幅下降[^4]。 - **使用示例**: ```java Hashtable<Integer, String> safeMap = new Hashtable<>(); safeMap.put(1, "a"); // 线程安全,但低效 ``` 2. **SynchronizedMap (通过 Collections.synchronizedMap)** - **描述**:这是一种包装器模式,使用 Collections.synchronizedMap() 将普通 HashMap 包装为线程安全映射。它同样依赖于 synchronized 关键字来锁住整个映射[^1][^4]。 - **优点**:使用灵活,只需一行代码转换 HashMap;适用于轻量级并发。 - **缺点**:性能与 Hashtable 类似,每个操作都需全局锁,开销大。在读取操作频繁的系统中,可能成为瓶颈[^4]。 - **使用示例**: ```java Map<Integer, String> map = new HashMap<>(); Map<Integer, String> synchronizedMap = Collections.synchronizedMap(map); // 线程安全包装 ``` 3. **ConcurrentHashMap** - **描述**:这是 Java 5 引入的并发类,专门针对高并发场景设计。它采用锁分段技术(lock striping),将映射划分为多个段(segments),每个段有自己的锁[^4]。这样,多个线程可以同时操作不同段,减少竞争。在 JDK8 后,它进一步优化为使用 CAS 操作(Compare-And-Swap)和并发桶管理[^4]。 - **优点**:高并发性能优,平均时间复杂度接近 $O(1)$;读操作通常无需锁,写操作只锁受影响段。适合大规模并发应用(如 Web 服务器缓存)。 - **缺点**:某些方法(如 size() 或 isEmpty()) 在并发环境下可能不准确,因为它只提供近似的视图[^4]。 - **使用示例**: ```java ConcurrentHashMap<Integer, String> concurrentMap = new ConcurrentHashMap<>(); concurrentMap.put(1, "a"); // 高效线程安全 ``` #### 步骤3: 解决方案性能对比和推荐 以下是关键指标比较表,基于引用中的总结: | 解决方案 | 线程安全机制 | 时间复杂度 | 适用场景 | 注意事项 | |----------|--------------|--------------|-------------|-------------| | HashMap | 无 | $O(1)$ (但并发下不可靠) | 单线程应用 | 避免多线程使用[^2] | | Hashtable | synchronized 全局锁 | $O(n)$ (高并发时) | 简单并发需求 | 已被 ConcurrentHashMap 替代[^1][^4] | | SynchronizedMap | synchronized 全局锁 | $O(n)$ (高并发时) | 小型并发系统 | 易用但性能差[^4] | | ConcurrentHashMap | 锁分段/CAS | $O(1)$ (平均) | 高并发系统 | size() 方法非精确[^4] | **推荐建议**: - **高并发场景**:优先使用 ConcurrentHashMap,因其性能和可靠性最佳[^4]。 - **遗留系统或简单需求**:考虑 Hashtable 或 SynchronizedMap,但需注意性能陷阱。 - **版本注意**:在 JDK7 中,HashMap 的死循环问题尤为突出,升级到 JDK8+ 是基础,但仍需结合上述方案[^5]。 在实际应用中,测试并发行为是关键——使用工具如 JUnit 或压力测试框架验证线程安全[^1]。 #### 相关问题 为了帮助你深入理解,以下是几个相关问题: 1. ConcurrentHashMap 的锁分段技术具体如何实现?它与 CAS 操作有何关系? 2. 在哪些实际应用场景中,HashMap 的线程安全问题常见? 3. JDK8 与 JDK7 中 HashMap 的线程安全机制有何具体区别?[^5] 通过这些问题,你可以进一步探索并发编程的最佳实践。如果有具体代码案例或版本疑问,欢迎提供更多细节!
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏日彩虹

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值