Java 多线程:线程安全、原子性、可见性与有序性
一、线程安全与三大特性
1. 什么是线程安全?
线程安全是指:多个线程并发访问共享数据时,程序仍然能够得到符合预期的正确结果。
例如,商品库存为 100,两个线程分别扣减一次库存:
stock--;正常情况下,最终库存应该是 98。
但在多线程环境中,可能发生:
初始库存:100
线程 A:读取库存 100
线程 B:读取库存 100
线程 A:计算 100 - 1 = 99
线程 B:计算 100 - 1 = 99
线程 A:写回 99
线程 B:写回 99
最终库存:99(错误)这就是典型的丢失更新(Lost Update)。
原因是 stock-- 并不是一个原子操作,它包含读取、计算和写回三个步骤。
多个线程可能在这些步骤之间交叉执行。
Java 并发编程主要需要解决三个问题:
原子性(Atomicity)
可见性(Visibility)
有序性(Ordering)
2. 原子性
原子性是指一个操作或一组操作作为不可分割的整体执行,不会被其他线程交叉干扰。
例如:
count++;不是原子操作,因为它实际上包含:
第一步:读取 count
第二步:计算 count + 1
第三步:将结果写回 count假设两个线程同时执行:
count++;可能发生丢失更新。
可以使用 AtomicInteger 实现原子递增:
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();AtomicInteger 是 Java 8 自带的 JUC 原子类,主要通过 CAS 实现原子操作。
也可以使用 synchronized:
private static int count = 0;
public static synchronized void increment() {
count++;
}当所有线程都通过该同步方法修改 count 时,可以保证递增操作的互斥性。
3. 可见性
可见性是指一个线程修改共享变量后,其他线程能够按照 Java 内存模型规定的同步关系观察到修改。
例如:
public class VisibilityDemo {
private static boolean running = true;
public static void main(String[] args)
throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) {
// 持续检查状态
}
System.out.println("线程退出");
});
worker.start();
Thread.sleep(1000);
running = false;
}
}预期:
主线程修改 running = false 后,子线程退出。
但实际运行时,子线程可能一直循环。
原因是多个线程之间没有建立必要的同步关系。JIT 编译器或处理器可能进行优化,导致子线程没有按预期观察到变量的更新。
解决方法:
private static volatile boolean running = true;volatile 可以提供跨线程的可见性保证。
注意:
volatile 并不保证线程立即获得 CPU 执行。
volatile 不会自动终止正在进行的阻塞 IO。
volatile 不能保证复合操作的原子性。
4. 有序性
有序性是指程序中的操作必须满足 Java 内存模型规定的执行顺序约束。
Java 为了提高性能,允许编译器和处理器进行指令重排序。
例如:
int a = 10;
int b = 20;
int c = a + b;在不改变单线程执行结果的前提下,一些没有数据依赖关系的操作可能调整执行顺序。
但在多线程环境下,如果没有正确同步,其他线程可能观察到不符合预期的数据状态。
例如:
public class OrderingDemo {
private static int data = 0;
private static boolean ready = false;
public static void main(String[] args) {
Thread writer = new Thread(() -> {
data = 100;
ready = true;
});
Thread reader = new Thread(() -> {
if (ready) {
System.out.println(data);
}
});
writer.start();
reader.start();
}
}在没有同步关系的情况下,即使 reader 观察到 ready == true,也不能保证它读取到的 data 一定是 100。
这是因为普通共享变量的访问缺少必要的可见性和有序性保证。
解决方法:
private static volatile boolean ready = false;写线程:
data = 100;
ready = true;读线程:
if (ready) {
System.out.println(data);
}当读线程读取到写线程通过 volatile 写入的 true 时,可以保证观察到之前完成的 data = 100。
这是 Java 内存模型中 happens-before 规则提供的保证。
二、Java 内存模型 JMM
1. 什么是 JMM?
JMM 全称 Java Memory Model,即 Java 内存模型。
JMM 定义了 Java 程序在多线程环境下共享变量的访问规则,以及原子性、可见性和有序性相关的内存语义。
JMM 可以简单理解为:
JMM 主内存(抽象概念)
|
共享变量 count
|
┌─────────┴─────────┐
| |
v v
线程 A 的工作内存 线程 B 的工作内存
| |
v v
线程 A 线程 B这里的工作内存是 JMM 的抽象概念,用于描述线程对共享变量的使用方式。
不能直接将它理解为 CPU 缓存或者 Java 虚拟机栈。
2. JMM 与 JVM 内存结构的区别
这两个概念经常被混淆。
例如:
JVM 内存结构中的堆、虚拟机栈、方法区,描述的是 Java 程序运行期间的数据存储区域。
而 JMM 主要描述多个线程在访问共享数据时,需要遵守哪些规则。
重点:JMM 是内存访问的抽象规范,不是具体的内存区域划分。
3. 什么是 happens-before?
happens-before 是 Java 内存模型中非常重要的概念。
它用于描述两个操作之间的内存可见性与顺序保证。
如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,并且 A 在内存语义上先于 B。
但它并不意味着所有存在 happens-before 关系的操作,都必须在物理执行时间上严格先后执行。
常见规则:
例如:
private static int data = 0;
private static volatile boolean ready = false;线程 A:
data = 100;
ready = true;线程 B:
if (ready) {
System.out.println(data);
}当线程 B 读到由线程 A 写入的 true 时:
线程 A:data = 100
|
v
线程 A:ready = true
|
| volatile 写-读建立 happens-before
v
线程 B:读取 ready == true
|
v
线程 B:读取 data通过 happens-before 的传递性,可以保证线程 B 观察到 data = 100。
这里要注意:volatile 提供的是同步关系,不是把所有代码都变成原子操作。
三、volatile、synchronized 与 AtomicInteger
1. 三者的核心区别
2. volatile 为什么不能保证 count++ 的原子性?
例如:
private static volatile int count = 0;多个线程同时执行:
count++;仍然可能出现错误。
因为 count++ 包含:
读取 count
↓
计算 count + 1
↓
写回 countvolatile 可以保证变量访问的可见性和相应的顺序约束,但不能保证这三个步骤整体不可分割。
因此:
volatile int count;不能让 count++ 变成线程安全的操作。
3. 使用 AtomicInteger 保证原子递增
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
private static final AtomicInteger COUNT =
new AtomicInteger(0);
public static void main(String[] args) {
COUNT.incrementAndGet();
System.out.println(COUNT.get());
}
}incrementAndGet() 是原子操作。
其底层主要通过 CAS 实现。
CAS 的核心思想是:
读取当前值
↓
检查当前值是否仍然符合预期
↓
如果符合,则更新
↓
如果不符合,则重新尝试例如:
线程 A 读取 count = 10
线程 B 读取 count = 10
线程 A CAS(10, 11) 成功
线程 B CAS(10, 11) 失败
线程 B 重新读取 count = 11
线程 B CAS(11, 12) 成功
最终 count = 12因此不会出现普通 count++ 的丢失更新问题。
4. 使用 synchronized 保证互斥
public class SynchronizedDemo {
private static int count = 0;
public static synchronized void increment() {
count++;
}
}因为这是静态同步方法,所以锁对象是:
SynchronizedDemo.class所有调用该方法的线程都会竞争同一把类对象监视器锁。
在所有相关访问都正确使用同一把锁的前提下,能够保证同步方法中的递增操作线程安全。
四、并发计数器完整测试
下面通过实际代码验证普通变量和 AtomicInteger 的区别。
使用 Java 8 自带的 ExecutorService、CountDownLatch 和 AtomicInteger。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class CounterDemo {
private static int count = 0;
private static final AtomicInteger ATOMIC_COUNT =
new AtomicInteger(0);
public static void main(String[] args)
throws InterruptedException {
int threadCount = 8;
int loopCount = 100000;
ExecutorService executor =
Executors.newFixedThreadPool(threadCount);
// 控制线程统一开始
CountDownLatch start = new CountDownLatch(1);
// 等待所有任务完成
CountDownLatch done =
new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.execute(() -> {
try {
start.await();
for (int j = 0; j < loopCount; j++) {
// 普通变量递增
count++;
// 原子变量递增
ATOMIC_COUNT.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
try {
start.countDown();
done.await();
System.out.println(
"预期结果:" + threadCount * loopCount
);
System.out.println(
"普通变量:" + count
);
System.out.println(
"原子变量:" + ATOMIC_COUNT.get()
);
} finally {
executor.shutdown();
}
}
}一次可能的执行结果:
预期结果:800000
普通变量:532846
原子变量:800000原因:
普通变量
count++不是原子操作,可能发生丢失更新。AtomicInteger.incrementAndGet()保证原子递增。CountDownLatch 用于控制多线程开始执行以及等待任务完成。
注意:普通变量的最终结果不固定,也可能碰巧等于预期值,不代表其具有线程安全保证。
五、生产环境:Spring Boot 库存扣减
1. 错误示例
假设商品库存为 100。
Integer stock = productMapper.selectStock(productId);
if (stock > 0) {
productMapper.updateStock(productId, stock - 1);
}在高并发情况下,可能发生:
初始库存:100
请求 A:查询库存 100
请求 B:查询库存 100
请求 A:更新库存 99
请求 B:更新库存 99
最终库存:99两次扣减只减少了一件商品,发生了丢失更新。
2. 为什么不能只使用 synchronized?
例如:
public synchronized void decreaseStock(Long productId) {
// 查询库存
// 更新库存
}如果系统只有一个 JVM 实例,并且所有相关库存操作都使用同一把锁,可以实现当前 JVM 内的互斥。
但如果 Spring Boot 通过 Docker 部署了三个实例:
Spring Boot 实例 A
|
Spring Boot 实例 B
|
Spring Boot 实例 C
|
v
MySQL每个实例都有自己的 JVM 以及锁对象。
不同实例之间的 synchronized 不会自动互斥。
因此,单机锁不能直接解决分布式并发问题。
3. 推荐方式:数据库条件更新
对于简单库存扣减,可以通过单条 SQL 保证检查和修改作为一个数据库原子更新执行。
UPDATE product
SET stock = stock - 1
WHERE id = #{productId}
AND stock > 0;MyBatis 示例:
@Mapper
public interface ProductMapper {
@Update("UPDATE product " +
"SET stock = stock - 1 " +
"WHERE id = #{productId} AND stock > 0")
int decreaseStock(@Param("productId") Long productId);
}Service:
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Transactional
public boolean decreaseStock(Long productId) {
int rows = productMapper.decreaseStock(productId);
return rows == 1;
}
}执行结果:
返回 1:库存扣减成功。
返回 0:库存不足或者商品不存在。
在 MySQL InnoDB 中,单条条件 UPDATE 可以利用数据库的并发控制机制安全地执行库存扣减。
这种方式适用于单库中简单的库存扣减操作。
如果涉及订单创建、支付和重复请求,还需要结合事务、幂等性等机制保证整体业务正确性。
重点:Java 线程安全和数据库并发安全是两个不同层面的问题。
六、重点总结
三大核心特性
需要记住:
count++包含读取、计算、写回,不是原子操作。volatile 保证可见性和特定有序性,但不能保证复合操作的原子性。
synchronized 可以通过互斥和内存同步规则保护共享数据。
AtomicInteger 主要通过 CAS 实现原子操作。
JMM 是 Java 内存访问的抽象规范,不等同于 JVM 的堆、栈等运行时数据区域。
happens-before 用于描述跨线程操作之间的可见性和顺序保证。
多个 JVM 实例之间不能直接通过 synchronized 实现互斥。
数据库条件更新可以解决简单库存扣减的并发安全问题。
七、常见问题与答案
1. 为什么 count++ 在多线程环境下不安全?
因为它包含读取、计算和写回三个步骤,不是原子操作,可能发生丢失更新。
2. volatile 能保证线程安全吗?
需要看具体操作。
对于简单的状态标记,volatile 通常可以满足需求。
但对于 count++ 等复合操作,仅使用 volatile 不能保证线程安全。
3. synchronized 能保证原子性、可见性和有序性吗?
在所有相关线程都通过同一把锁正确同步共享状态的前提下,可以提供所需的互斥、可见性和有序性保证。
4. JMM 和 JVM 内存结构有什么区别?
JMM 规定多线程之间的内存访问语义;JVM 内存结构描述堆、方法区、虚拟机栈等运行时数据区域。
5. 什么是 happens-before?
happens-before 是 JMM 定义的内存顺序关系。
如果 A happens-before B,那么 A 的结果对 B 可见,并且 A 在内存语义上先于 B。
6. AtomicInteger 为什么能够保证原子递增?
因为其递增操作通过 CAS 等原子操作实现,多个线程发生竞争时,失败的更新会重试,避免普通递增操作中的丢失更新。
7. volatile 和 synchronized 有什么区别?
volatile 不提供互斥,适合状态标记等场景;synchronized 提供互斥和同步语义,可以保护包含多个操作的临界区。
8. 为什么多实例部署不能只依赖 synchronized?
因为 synchronized 的锁只在当前 JVM 内有效,不同 JVM 实例拥有各自独立的锁对象,无法直接实现跨进程互斥。
