并发编错三大特性
原子性
一个或多个操作,在CPU执行的过程中,看起来就像一个单一的、不可中断的操作。要么所有操作都执行成功,要么都不执行,不存在执行了一半的中间状态被其他线程看到。
但是在多线程情况下,线程切换过程中会强制挂起当前线程,并切换到另一个线程去执行
经典的i++操作
public class CounterDemo {
private static int count;
@SneakyThrows
public static void increment() {
TimeUnit.MILLISECONDS.sleep(100);
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
最后输出结果可能不等于200
在Java端保证原子性一般有三种方式
CAS、synchronized、ReentrantLock
CAS
public class CounterDemo {
private static AtomicInteger count = new AtomicInteger(0);
@SneakyThrows
public static void increment() {
TimeUnit.MILLISECONDS.sleep(100);
count.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
它内部使用了CAS (Compare-And-Swap) 这种更轻量级的、基于CPU原子指令的机制来保证原子性。
Synchronized
public class CounterDemo {
private static int count ;
@SneakyThrows
public static synchronized void increment() {
TimeUnit.MILLISECONDS.sleep(100);
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
通过monitorenter和monitorexit指令,确保同一时间只有一个线程能执行同步代码块,从而保证了原子性。
ReentrantLock
public class CounterDemo {
private static int count ;
private static ReentrantLock lock = new ReentrantLock();
@SneakyThrows
public static void increment() {
TimeUnit.MILLISECONDS.sleep(100);
lock.lock();
count++;
lock.unlock();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
可见性
当一个线程修改了一个共享变量的值,其他线程能够立即看到这个修改
现代CPU为了弥补CPU与主内存(RAM)之间巨大的速度差异,引入了多级高速缓存(L1, L2, L3 Cache)。
- 线程在执行时,会先把主内存中的数据拷贝一份到自己的工作内存(即CPU缓存)中。
- 所有的计算和修改都是在工作内存中进行的。
- 修改完成后,在某个不确定的时机,才会将工作内存的数据**写回(刷新)**到主内存。
这就导致了缓存不一致性问题:一个线程在自己的缓存里修改了变量,但还没来得及写回主内存,另一个线程从主内存读到的就是旧的、过期的“脏数据”。
public class StoppableTaskDemo {
public static boolean stop = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (stop){
}
System.out.println("线程结束");
}).start();
Thread.sleep(100);
stop = false;
System.out.println("线程开始");
}
}
通过volatile解决
public class StoppableTaskDemo {
public static volatile boolean stop = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (stop){
}
System.out.println("线程结束");
}).start();
Thread.sleep(100);
stop = false;
System.out.println("线程开始");
}
}
通过synchronized解决
public class StoppableTaskDemo {
public static boolean stop = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (stop){
//内部有一个synchronized
System.out.println("线程运行中");
}
System.out.println("线程结束");
}).start();
Thread.sleep(100);
stop = false;
System.out.println("线程开始");
}
}
通过Lock解决
public class StoppableTaskDemo {
public static boolean stop = true;
public static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (stop){
lock.lock();
lock.unlock();
}
System.out.println("线程结束");
}).start();
Thread.sleep(100);
stop = false;
System.out.println("线程开始");
}
}
Java中的解决方案
volatile关键字:这是保证可见性的主要手段。当一个变量被声明为volatile:- 写操作:会强制将当前线程工作内存中的值刷新到主内存。
- 读操作:会强制让当前线程的工作内存失效,重新从主内存中读取。
synchronized和 ****Lock:它们也能保证可见性。在解锁(unlock)前,会强制把修改过的变量刷新到主内存;在加锁(lock)后,会清空工作内存,强制从主内存加载。final关键字:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用泄露出去,那么在其他线程中就能保证可见。
有序性
程序执行的顺序与代码中定义的顺序一致。
为了提升性能,编译器和处理器通常会对输入的指令进行乱序执行优化。它们会保证在单线程环境下,重排序后的结果与代码顺序执行的结果是一致的。但在多线程环境下,这种优化可能会导致意想不到的后果。
经典DCL单例模式
class Singleton {
// 如果没有volatile,可能因指令重排序而出错
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
instance = new Singleton() 这行代码不是原子的,它大致包含三个步骤:
memory = allocate();// 1. 分配对象的内存空间ctorInstance(memory);// 2. 初始化对象instance = memory;// 3. 设置instance指向刚分配的内存地址
编译器或CPU可能会将步骤2和3重排序,变成 1 -> 3 -> 2。
如果发生重排序:
- 线程A执行到
instance = new Singleton(),按 1 -> 3 -> 2 的顺序执行。 - 当执行完第3步
instance = memory时,instance已经不为null了,但对象还没初始化。 - 此时发生线程切换,线程B进入
getInstance()方法。 - 线程B执行第一个
if (instance == null),发现instance不为null,直接返回instance。 - 但这个
instance是一个半初始化的对象,使用它可能会导致程序崩溃。
Java中的解决方案:
volatile关键字:它包含禁止指令重排序的语义。通过插入内存屏障来阻止编译器和处理器的重排序优化。synchronized和 ****Lock:同样能保证有序性。一个锁的解锁操作happens-before于后续对这个锁的加锁操作。- Java内存模型 (JMM) 的 Happens-Before 原则:这是Java语言层面定义的有序性规则,比如“程序次序规则”、“监视器锁规则”、“volatile变量规则”等,它们天然地保证了一些操作的有序性。
Java中的锁
锁分类
悲观锁
共享资源每次只给一个线程使用,其他线程阻塞,只有用完后其他线程才能获取
- 思想:总是假设最坏的情况,认为数据在操作期间一定会被其他线程修改。因此,在每次操作数据之前,都会先加锁,确保在自己操作的整个过程中,数据不会被外界修改。
- 实现方式:Java中所有传统的锁,如
synchronized和ReentrantLock,都属于悲观锁。 - 应用场景:写多读少,并发冲突激烈的场景。它能有效防止数据冲突,但加锁和释放锁的开销较大,在冲突不激烈时会影响性能。
乐观锁
乐观锁总是设想最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停的执行,无需加锁和等待,只有提交修改的时候会验证数据是否被其他资源修改(CAS)
- 思想:总是假设最好的情况,认为数据在操作期间不会被其他线程修改。因此,它不会上锁,而是在更新数据时去判断,在此期间数据有没有被其他线程修改过。
- 实现方式:通常通过 CAS (Compare-And-Swap) 机制实现。CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。当且仅当 V 符合预期值 A 时,处理器才会用 B 更新 V 的值,否则不执行任何操作。整个过程是原子的。
- 典型代表:
java.util.concurrent.atomic包下的所有原子类,如AtomicInteger。 - 应用场景:读多写少,并发冲突不激烈的场景。它避免了加锁和解锁的开销,性能更高。但如果冲突频繁,会导致CAS操作不断失败和重试,反而会消耗更多CPU资源。
可重入锁
一个线程在持有某个锁的情况下,可以再次、多次地请求同一个锁,并且每次都能成功获取。
为了支持重入,锁的内部必须维护两个关键信息:
- 当前持有锁的线程 (owner):记录是谁拿了锁。
- 重入计数器 (holdCount):记录这个线程拿了多少次锁。
- 绝对的主流和默认选择。Java中的
synchronized和ReentrantLock都是可重入的,这使得我们可以在一个同步方法中安全地调用另一个同步方法,或者在子类重写父类的同步方法时,通过super调用父方法,而不会产生死锁。 - 递归调用:如果一个递归函数需要加锁,那么它必须是可重入的。
- 绝大多数并发场景:只要你需要加锁,99.9%的情况下你需要的都是可重入锁。
不可重入锁
一个线程在持有某个锁的情况下,不能再次请求同一个锁。如果尝试再次获取,线程会被阻塞,导致死锁。
- 极为罕见。它的使用场景非常有限,通常是作为一个理论上的概念,用于对比和理解可重入锁的重要性。
- 在某些非常特定的场景下,你可能想强制避免某个线程重复进入一段代码(防止意外的递归调用),可能会考虑使用它。但这通常被认为是一种糟糕的设计,更好的方式是通过代码逻辑本身来控制,而不是依赖锁的特性。
公平锁
每个线程获取锁的顺序是按照线程访问锁的先后顺序获取的,最前面的线程总是最先获取到锁。
- 缺点:整体效率可能较低。因为每次都必须严格按顺序唤醒等待队列中的第一个线程,这涉及到大量的线程挂起和唤醒操作,开销较大。
- 当公平性是业务的硬性要求时。
- 适用场景:
- 任务调度系统:需要确保长时间等待的任务最终能被执行,防止重要但不紧急的任务被“饿死”。
- 资源队列:比如一个打印机任务队列,你希望严格按照提交的先后顺序进行打印。
- 任何要求“先来后到”业务逻辑的场景。
非公平锁
每个线程获取锁的顺序是随机的,并不会遵循先来先得的规则,所有线程会竞争获取锁。synchronized,ReentrantLock()默认 都是非公平锁
- 缺点:可能导致饥饿 (Starvation)。某些线程可能运气一直不好,总是抢不到锁,从而长时间无法执行。
- 绝大多数场景。在对性能要求极高,且能容忍偶尔的线程饥饿情况下,非公平锁是最佳选择。
- 它的高吞吐量特性,使得它成为
ReentrantLock和synchronized的默认实现。 - 适用场景:追求最高性能的后台服务、Web服务器等。
排他锁
同一时间点,只能有一个线程持有当前的锁资源
如果一个线程获取了排他锁,那么在它释放该锁之前,其他任何线程(无论想读还是想写)都无法获取该锁,只能进入等待状态。synchronized,ReentrantLock
适用场景:适用于任何需要保证数据一致性的写操作,或者读-写-改这类复合操作。在这些场景下,资源状态的修改过程不希望被任何其他线程(即使是读线程)观察到。
共享锁
同一时间点,可以有多个线程同时持有当前的锁资源
如果一个线程获取了共享锁,其他线程也可以成功获取共享锁并访问资源。但是,如果有线程想要获取排他锁,它必须等待所有共享锁都被释放。
适用场景:读多写少 (Read-Mostly) 的场景。当一个共享资源的读取频率远高于写入频率时,使用读写锁 (ReentrantReadWriteLock) 可以极大地提升并发性能。
CAS
CAS的全称是 Compare-And-Swap (比较并交换)。
它是一种原子操作,通常由CPU指令直接支持(例如x86架构的 CMPXCHG指令),这意味着它的执行过程不会被任何其他线程中断。
CAS操作涉及三个操作数:
- V (Memory Location):要操作的内存地址(即变量)。
- A (Expected Value):线程预期的、该地址当前应该存放的值。
- B (New Value):准备要写入的新值。
执行逻辑: 当且仅当内存地址 V处的值与预期值 A相等时,处理器才会原子地将该地址的值更新为新值 B。否则,它什么也不做。无论成功与否,它都会返回 V处操作前的真实值。
优点
- 高性能 (非阻塞):相比于
synchronized这种悲观锁,CAS是非阻塞的。它对于JVM不会导致线程被挂起和恢复,没有线程上下文切换的开销。在并发冲突不激烈的情况下,性能远超悲观锁。 - 乐观锁实现:它体现了乐观锁的思想,即“假设没有冲突,先尝试操作再说”。
缺点
- ABA问题
- 问题描述:CAS只检查“当前值”和“预期值”是否相等,但无法感知这个值是否被“动过手脚”。一个值可能从A变为B,然后又变回了A。CAS检查时会误认为它从未变过。
- 例子:线程1读取值为A。线程2将值从A改为B,然后又改回A。线程1进行CAS时,发现值仍然是A,于是操作成功。但在某些业务场景下(例如链表的节点操作),这可能会导致严重问题。
- 解决方案:Java提供了
AtomicStampedReference,它在CAS的基础上增加了一个“版本号”(stamp)。每次修改,版本号都会加1。这样,即使值变回A,版本号也不同了,CAS会失败。
- 自旋开销大
- 如果并发冲突非常激烈,会导致大量线程反复地、长时间地自旋(循环重试)。这会持续消耗CPU资源,性能反而可能不如让线程进入等待状态的悲觀锁。
- 只能保证一个共享变量的原子操作
- CAS一次只能对一个内存地址进行原子操作。如果需要同时保证多个变量的原子性,就需要使用
synchronized或Lock,或者将多个变量封装成一个对象,然后使用AtomicReference来对这个对象的引用进行CAS操作。
- CAS一次只能对一个内存地址进行原子操作。如果需要同时保证多个变量的原子性,就需要使用
| 特性 | CAS (乐观锁) | synchronized (悲观锁) |
|---|---|---|
| 思想 | 假设不会冲突,直接尝试,失败再重试 | 假设总会冲突,先加锁再操作 |
| 实现 | CPU原子指令,通常伴随自旋 | 操作系统互斥量 (Mutex),涉及线程上下文切换 |
| 性能 | 低/中度冲突时性能高 | 高冲突时性能稳定,避免CPU空转 |
| 问题 | ABA问题、CPU空转开销 | 线程阻塞、上下文切换开销 |
synchronized
synchronized 的核心作用是提供一种**互斥(Mutual Exclusion)**的机制,它确保在同一时刻,只有一个线程能够执行被它修饰的代码块或方法。这块被保护的代码区域被称为“临界区”。
- 修饰实例方法
- 锁对象:当前类的实例对象 (
this)。 - 作用范围:整个方法体。
- 示例:
- codeJava
public class BankAccount {
private int balance;
// 锁是 this,即 BankAccount 的实例对象
public synchronized void deposit(int amount) {
balance += amount;
}
}
- 当一个线程调用
deposit方法时,它会尝试获取this对象的锁。如果成功,其他任何线程都无法同时调用该实例的任何synchronized方法(比如withdraw方法),直到该线程退出deposit方法并释放锁。
- 修饰静态方法
- 锁对象:当前类的
Class对象 (例如,BankAccount.class)。 - 作用范围:整个方法体。
- 示例:
- codeJava
public class Bank {
private static int totalAssets;
// 锁是 Bank.class 对象
public static synchronized void addAssets(int amount) {
totalAssets += amount;
}
}
- 静态方法的锁是类级别的。一个线程进入
addAssets方法后,其他线程无法进入任何该类的synchronized静态方法,但不影响它们调用非静态的synchronized方法(因为锁的对象不同)。
- 修饰代码块
- 锁对象:手动指定的任何对象(包括
this、Xxx.class或任何其他对象实例)。 - 作用范围:
{}包围的代码块。 - 示例:
- codeJava
public class Worker {
private final Object lock = new Object();
// 通常使用一个专用的锁对象
public void doWork() {
// ... 一些不需要同步的代码 ...
// 只对关键部分加锁,减小锁的粒度
synchronized (lock) {
// ... 临界区代码 ...
}
// ... 另一些不需要同步的代码 ...
}
}
- 这是最灵活、也是最推荐的使用方式,因为它可以精确地控制锁的粒度,只锁定必要的代码,从而提高程序的并发性能。
synchronized 的底层原理:Monitor
synchronized 关键字的实现是基于 JVM 层面的 **Monitor(监视器锁)**机制。每个 Java 对象都可以看作是一个 Monitor。
当一个线程试图获取一个对象的锁时,它实际上是在尝试获取该对象关联的 Monitor 的所有权。
Monitor 内部主要包含几个关键部分:
_owner: 一个指针,指向当前持有该 Monitor 的线程。_entryList: 一个等待队列,存放所有尝试获取锁但失败、进入**阻塞 (BLOCKED)**状态的线程。_waitSet: 另一个等待队列,存放调用了该对象wait()方法、进入**等待 (WAITING)**状态的线程。_recursions: 一个计数器,用于支持锁的可重入。
执行流程(简化版):
monitorenter: 线程执行到synchronized代码块的入口时,JVM 会插入一条monitorenter字节码指令。线程尝试获取 Monitor 的所有权。- 如果 Monitor 的
_owner为null,线程成功获取锁,将_owner指向自己,计数器_recursions设为1。 - 如果
_owner是当前线程自己,说明是重入,_recursions计数加1。 - 如果
_owner是其他线程,线程获取锁失败,被放入_entryList队列中阻塞等待。
- 如果 Monitor 的
monitorexit: 线程执行完同步代码块后(无论是正常退出还是异常退出),JVM 会插入一条monitorexit字节码指令。- 线程将
_recursions计数减1。 - 如果计数变为0,线程释放 Monitor 的所有权(将
_owner设为null),并唤醒_entryList中的一个等待线程来竞争锁。
- 线程将
锁消除
锁消除是一种非常激进的优化。JIT 编译器在运行时,通过对代码进行逃逸分析,判断一个锁对象是否只被一个线程访问,从未发布到其他线程。
如果编译器能证明一个锁对象不会“逃逸”出当前线程的作用域,也就是说,不可能有其他线程来竞争这个锁,那么对这个对象的加锁操作就是完全多余的。这时,编译器就会直接消除这些加锁和解锁的指令,就好像它们从未存在过一样。
逃逸分析:简单来说,就是分析一个对象的动态作用域。如果一个对象在方法中被定义后,可能被外部方法所引用(比如作为返回值、或者赋值给类变量),则认为该对象“逃逸”了。反之,则认为它“未逃逸”。
- 经典例子
一个最典型的例子就是 StringBuffer 或 Vector 这类线程安全的类在方法内部作为局部变量使用。
public class LockElisionDemo {
// 这个方法看起来有锁,但JIT可能会把它消除掉public String createAndAppendString(String s1, String s2, String s3) {
// sb 是一个局部变量,它的引用不会“逃逸”出这个方法// 其他线程根本不可能访问到这个 sb 对象
StringBuffer sb = new StringBuffer();
// StringBuffer的append方法是synchronized的// 理论上每次调用都会加锁
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
}
分析:
StringBuffer的append方法是synchronized方法,锁对象是sb实例。- 在
createAndAppendString方法中,sb是一个方法内的局部变量。 - JIT 编译器通过逃逸分析发现,
sb这个对象的引用从未离开过createAndAppendString方法的作用域。它没有被赋值给类的成员变量,也没有作为返回值返回(返回的是sb.toString()创建的新字符串)。 - 结论:这个
sb对象是线程私有的,永远不可能被其他线程访问到。因此,对sb的所有synchronized加锁操作都是不必要的。 - 优化:JIT 编译器会大胆地将
append方法内部的monitorenter和monitorexit指令全部移除,这个过程就叫锁消除。
优化后的代码,在运行时就等同于使用非线程安全的 StringBuilder,性能得到了极大的提升
锁粗化
如果 JIT 编译器发现有一系列连续的操作都在反复地、频繁地对同一个对象进行加锁和解锁,即使这些操作之间没有竞争,频繁的加锁/解锁本身也会带来性能开销。
在这种情况下,编译器会认为这种“细粒度”的锁反而成了累赘。于是,它会智能地将这些连续的加锁/解锁操作合并成一个更大范围的锁,只在整个操作序列的开始加一次锁,在结束时解一次锁。这个过程就叫锁粗化。
public class LockCoarseningDemo {
private final Object lock = new Object();
private int count = 0;
// 未优化前的代码逻辑
public void processInLoop_Before() {
for (int i = 0; i < 10000; i++) {
// 在循环内部,每次都加锁和解锁
synchronized (lock) {
count++;
}
}
}
// JIT编译器可能将其粗化为下面的样子
public void processInLoop_After() {
// 将锁的范围扩大到整个循环之外
synchronized (lock) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
}
}
分析:
- 在
processInLoop_Before方法中,synchronized块在循环体内部。 - 这意味着程序将执行 10000次加锁 和 10000次解锁 操作。
- JIT 编译器检测到这种模式后,会判断将锁的范围扩大到整个循环之外,并不会改变程序的同步语义。
- 优化:编译器会将
synchronized (lock)这行代码“移动”到for循环的外面,形成processInLoop_After的效果。 - 结果:加锁和解锁操作从 10000 次骤降到 1 次,性能显著提升。
这似乎与我们常说的“尽量减小锁的粒度”相违背。实际上,这两者并不矛盾:
- 减小锁粒度:是程序员在编写代码时应该遵循的原则,目的是为了减少线程不必要的等待时间,提高并发度。
- 锁粗化:是 JIT 编译器在运行时进行的一种优化,它针对的是“没有并发竞争,但加解锁开销过大”的特定场景,目的是为了减少加解锁操作本身的性能损耗。
锁升级:从“轻”到“重”的性能优化
早期的 synchronized 被诟病性能差,因为它直接依赖操作系统的互斥量(Mutex),每次加锁都会涉及用户态到内核态的切换,开销巨大。
从 JDK 1.6 开始,JVM 引入了锁升级机制,这是一种自适应的优化,目的是根据锁的竞争情况,动态地选择合适的锁状态,以最小的代价实现同步。
锁的状态记录在 Java 对象头的 Mark Word 中,升级路径是单向的,只能升级不能降级。
- 无锁状态 (Unlocked)
- 对象刚创建,没有任何线程竞争。
- 偏向锁 (Biased Locking)
- 适用场景:绝大多数情况下,锁不仅没有竞争,而且总是由同一个线程反复获取。
- 机制:当第一个线程获取锁时,JVM 会把线程 ID 记录在 Mark Word 中。之后,该线程再次进入同步块时,无需任何同步操作,只需简单检查一下线程 ID 是否匹配。这是最高效的状态。
- 升级:当有另一个线程尝试获取该锁时,偏向锁模式被撤销,锁升级为轻量级锁。
- 轻量级锁 (Lightweight Locking)
- 适用场景:存在锁竞争,但竞争不激烈,线程持有锁的时间很短。
- 机制:线程通过 CAS (Compare-And-Swap) 操作尝试获取锁。失败的线程不会立即阻塞,而是会进行自旋 (Spinning),即执行一个空循环,期待锁能很快被释放。自旋避免了线程上下文切换的开销。
- 升级:如果自旋一定次数后(或竞争加剧),锁仍未被释放,锁就会膨胀为重量级锁。
- 重量级锁 (Heavyweight Locking)
- 适用场景:锁竞争激烈,线程等待时间长。
- 机制:这就是传统的 Monitor 锁。获取不到锁的线程会被放入等待队列并阻塞,交由操作系统调度。虽然开销最大,但它不会让线程空转消耗 CPU。
评论区