在多线程编程中,线程之间共享资源是常态。然而,当多个线程同时访问同一块内存区域时,如果没有正确的同步机制,就会引发一系列令人头疼的问题。数据覆盖、原子性破坏、临界资源竞争,这些概念看似抽象,却每天都在并发系统中上演。本文将深入剖析这三种常见的并发风险,通过实例代码揭示其本质,并提供应对策略。
一、共享资源为何成为风险
在单线程环境下,代码执行顺序是确定的,每次运行结果都是一致的。但在多线程环境中,操作系统通过时间片轮转让多个线程“同时”运行,线程的执行顺序变得不可预测。当多个线程同时读写共享变量时,就会出现交错执行,导致数据不一致。
所谓共享资源,就是指多个线程都能访问的变量或对象。例如:
实例变量(堆内存)
静态变量(方法区)
外部资源(文件、数据库连接)
共享资源本身并不危险,危险的是缺乏协调的并发访问。
二、数据覆盖问题:丢失的更新
2.1 问题描述
数据覆盖,也称为“丢失更新”(Lost Update),是指两个线程同时对同一变量进行修改,后一个线程的写入覆盖了前一个线程的结果,导致部分更新丢失。
2.2 典型案例:计数器
假设我们有一个计数器,两个线程各执行100万次自增操作,理论上最终结果应为200万。但实际运行往往小于200万。
public class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } }启动两个线程,每个线程调用increment()100万次:
Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) { counter.increment(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) { counter.increment(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.getCount()); // 通常输出小于2_000_0002.3 原因分析
count++并不是一个原子操作,它在底层分解为三个步骤:
从内存读取
count的值到寄存器。在寄存器中执行加1操作。
将新值写回内存。
当两个线程并发执行时,可能出现如下交错:
线程1: 读取count (0) 线程2: 读取count (0) 线程1: 加1 (得到1) 线程2: 加1 (得到1) 线程1: 写回 (count=1) 线程2: 写回 (count=1)最终结果只有1,丢失了一次更新。这就是数据覆盖问题。
三、原子性破坏:非原子操作的风险
3.1 什么是原子性
原子性是指一个操作要么全部执行且不受干扰,要么完全不执行。在Java中,对基本类型变量的赋值和读取通常是原子的(long和double例外),但像i++、i += 1这样的复合操作不是原子的。
3.2 原子性被破坏的演示
除了i++,任何包含“读-改-写”模式的操作都是非原子的。例如,a = b + c同样涉及读取、计算、赋值三步。当这些步骤之间被其他线程插入时,数据就会出错。
3.3 字节码层面看原子性
使用javap -c Counter查看increment方法的字节码:
public void increment(); Code: 0: aload_0 1: dup 2: getfield #2 // 读取count 5: iconst_1 6: iadd // 加1 7: putfield #2 // 写回count 10: return可以看到getfield(读)、iadd(加)、putfield(写)三条指令,它们之间没有任何锁保护,因此线程可能在任意时刻被调度出去,导致原子性被破坏。
四、临界资源竞争:超卖与负数票
4.1 问题描述
临界资源(Critical Resource)是指一次只能被一个线程访问的资源。当多个线程同时竞争临界资源且未加保护时,就会出现资源被超额使用或状态异常的情况。最经典的例子就是售票系统。
4.2 售票系统演示
假设有100张票,多个窗口(线程)同时卖票:
public class TicketSeller { private int tickets = 100; public void sell() { if (tickets > 0) { // 模拟售票耗时 try { Thread.sleep(10); } catch (InterruptedException e) {} tickets--; System.out.println(Thread.currentThread().getName() + "售出一张,剩余:" + tickets); } else { System.out.println(Thread.currentThread().getName() + "票已售罄"); } } }启动多个线程同时卖票:
TicketSeller seller = new TicketSeller(); for (int i = 0; i < 20; i++) { new Thread(() -> { for (int j = 0; j < 10; j++) { seller.sell(); } }, "窗口" + i).start(); }运行结果可能出现:
同一张票被多个线程售出(超卖)
剩余票数出现负数
最终票数不等于初始值
4.3 原因分析
if (tickets > 0)和tickets--之间不是原子的。当多个线程同时进入判断时,它们都看到tickets大于0,然后各自执行减操作,导致票数被重复减少,甚至出现负数。
这种竞争导致了临界资源的不安全访问。
五、应对策略:让共享资源安全起来
针对上述问题,Java提供了多种同步机制:
5.1 synchronized 关键字
同步方法:锁住当前对象(实例方法)或Class对象(静态方法)。
同步代码块:指定任意对象作为锁,粒度更细。
public synchronized void increment() { // 实例方法锁 count++; } public void sell() { synchronized (this) { if (tickets > 0) { tickets--; } } }5.2 Lock 接口
提供更灵活的锁操作,如尝试锁、可中断锁、公平锁等。
Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }5.3 原子类
对于简单的计数器,可以使用AtomicInteger等原子类,内部通过CAS(Compare-And-Swap)实现无锁并发。
private AtomicInteger count = new AtomicInteger(0); count.incrementAndGet(); // 原子自增5.4 volatile 与可见性
volatile保证变量的可见性,但不保证原子性。适用于单次读写的标志位。
六、总结
并发编程中的共享资源风险,源于操作系统的时间片轮转和线程交错执行。数据覆盖、原子性破坏、临界资源竞争是三种最常见的并发问题,它们本质都是对共享资源的多线程访问缺乏协调。
要避免这些风险,必须为临界区提供互斥保护,确保同一时刻只有一个线程能执行关键操作。从synchronized到Lock,再到Atomic类,Java提供了丰富的工具,开发者应根据场景选择合适的同步机制,既保证正确性,又兼顾性能。