目录
2.2 CAS (Compare And Swap -- 比较并交换)
2.5 synchronized 和 Lock 解决并发编程可见性 的异同点
经典案例:双检锁(Double-Checked Locking)单例模式
问题1:三大特性的出现背景是什么?它出现是为了解决什么问题。
1、三大特性都是为了解决多线程环境下,数据不一致和线程安全问题。换句话说,就是为了保证程序符合预期的运行。
2、所以出现的背景就是多线程不安全隐患,需要其解决问题
问题2:问题1中数据不一致具体指什么呢?线程安全问题是指什么呢?
1、数据不一致问题:
数据不一致问题是指多个线程同时修改共享数据时,由于缺乏同步机制,导致数据状态出现错误。常见表现包括:
- 脏读(Dirty Read):一个线程读取了另一个线程未提交的数据。
- 不可重复读(Non-repeatable Read):同一查询在不同时间返回不同结果。
- 幻读(Phantom Read):一个事务读取到其他事务新增或删除的数据
举例说明:
单线程操作一份数据 : 读取数据 -> 修改数据 -> 回写数据。
多线程(2个及以上)操作同一份数据:
线程A(功能:数据+1):读取数据 -> 修改数据 -> 回写数据
线程B(功能:数据+1):读取数据 -> 修改数据 -> 回写数据
假设取到的数据为100,当线程A、B 同时对数据进行+1 操作,我们预期的结果是经过线程A的“+1”操作后,再由线程B的“+1”操作,最终数据为102。但是若A、B取到的数据都是100(B在回写数据前就读取到了数据),此时的结果就是101了。
2、线程安全问题:
线程安全问题指的是在多线程环境下,多个线程同时访问共享资源时,可能导致程序行为异常或数据错误。常见问题包括:
- 竞态条件(Race Condition):多个线程同时操作共享资源,执行顺序不确定,导致结果不可预测。
- 数据竞争(Data Race):多个线程同时读写共享数据,且未正确同步,导致数据不一致。
- 死锁(Deadlock):多个线程互相等待对方释放资源,导致程序无法继续执行。
- 活锁(Livelock):线程不断重试某个操作,但始终无法取得进展。
- 资源饥饿(Resource Starvation):某些线程因资源被长期占用而无法执行。
上述提到的问题就可以用这次要讲到的三大特性进行解决处理。
并发的三大特性: 原子性、可见性、有序性。
一、原子性
1、什么是并发编程原子性
一个操作不可分割,不可中断,一个线程执行过程中,另一个线程无法对其造成影响。
代码体现(两个线程相互影响 导致结果和预期不符):
2、保证并发编程的原子性
先说结论:synchronized可以让避免多线程同时操作临界资源,同一时间点,只会有一个线程正在操作临界资源
2.1 synchronized
加上synchronized后。
2.2 CAS (Compare And Swap -- 比较并交换)
先说结论: CAS 本身就是原子性操作。
2.2.1 什么是CAS
CAS 就是比较和交换, 他是一条CPU的并发原语。
CAS只是比较和交换,在获取原值的这个操作上,需要自己实现
它在替换内存中某个位置的数据时,会先比较内存中的数据和预期的数据是否一致:
若不一致就认为该数据已经被修改,就不再做修改。
若一致就认为该数据是需要被修改的,就会将内存数据修改。
重点:这个操作是原子性操作。
2.2.2 CAS的工作方式
CAS的工作方式可以通俗理解为一种“乐观锁”机制,他的核心思想:“先检查了了再动手,冲突了就重试”,就像我们平时生活中修改一份共享文档一样。
举个例子 🌰:
假设你和同事同时在线编辑一份文档,文档里有一个数字
当前值是100
。现在你想把它改成 200,而你的同事可能也在修改它。CAS的工作方式是这样的:
你记住原值:你先看一眼文档,记住当前的值是 100。
动手修改前再检查:当你点击保存时,系统会偷偷再检查一次文档的值:
如果发现值还是100(没人改过),立刻改成 200,操作成功!
如果发现值变成150(比如同事抢先改了),你的修改就会失败,系统提示你:“值变了,重试吧!”
失败就重试:你重新看一眼文档现在的值(比如150),再尝试改成 200,直到成功为止。
2.2.3 CAS的特点
-
无锁:不需要像“锁门”那样阻塞其他人操作,大家都能随时尝试修改。
-
原子性:检查值+修改值是一瞬间完成的,不会被中途打断。
-
可能循环:如果多人频繁修改,可能需要多次重试(类似不断刷新页面直到能改成功)。
2.2.4 CAS的优缺点
-
✅ 优点:避免线程阻塞,性能高(尤其在低竞争场景)。
-
❌ 缺点:
1、高竞争时可能“反复重试”(CPU空转)-- 自旋时间过长;- 可以在CAS一次失败后,将这个操作暂存起来,后面需要获取结果时,将暂存的操作全部执行,再返回最后的结果。
- 可以指定CAS一共循环多少次,如果超过这个次数,直接失败/或者挂起线程。(自旋锁、自适应自旋锁)
2、需注意ABA问题(比如值从100→150→100,看起来没变,但中间其实被改过,可以用版本号解决)。AtomicStampedReference在CAS时,不但会判断原值,还会比较版本信息。
2.3 Lock锁
Lock锁是JDK1.5时研发的。它比JDK1.5时期的synchronized性能要好上很多,但是在JDK1.6时对于synchronized优化后,性能就相差不大了,如果涉及并发比较多时,推荐使用ReentrantLock锁,性能会更好。
ReentrantLock功能上比synchronized跟丰富
ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作。
2.4 ThreadLocal
ThreadLocal是一种线程隔离机制。提供了多线程环境下,对共享变量访问的安全性
- 设计思路借鉴
- ThreadLocal就是空间换时间
- 线性探测解决hash冲突
- 数据预清理机制
- 弱引用KEY的涉及尽可能避免内存泄漏
ThreadLocal 实现原理:
- 每个Thread中都存储这一个成员变量 : ThreadLocalMap -通过这个容器存储共享变量的副本,每个线程只对自己的变量副本做更新操作,这样即解决了线程安全问题,又避免了多线程竞争锁的一个开销
- ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap
- ThreadLocalMap本身就是基于Entry[]实现的。因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式来实现。
- ThreadLocalMap -- key:ThreadLocal本身 ,对value进行存取
- ThreadLocalMap的key 是一个弱引用。
- 弱引用:即便有弱引用,在GC时,也必须被回收,这里时为了在ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收。
ThreadLocal内存泄漏问题
- 如果ThreadLocal引用丢失,key因为弱引用会被GC回收掉,如果同时线程还没有被回收,就会导致内存泄漏,内存的value无法被回收,同时也无法被获取到。
- 只需要在使用完毕ThreadLocal对象后,及时的调用remove方法,移除Entry即可。
二、可见性
1、什么是可见性呢?
CPU三级缓存
可见性的问题是基于CPU三级缓存出现的。
这个问题要基于CPU的三级缓存(不了解的可以点链接看看做一下初步了解)来考虑。现在CPU的都是多核,每个线程的工作内存(CPU三级缓存)都是独立的。每个线程做修改时,只会该自己的工作内存(CPU三级缓存),并没有及时同步回主内存,导致数据不一致。
JMM: java内存模型 (不理解的可以点链接去看看,做一个简单的了解)。
JMM 的核心功能是定义 线程与主内存的交互规则。
可见性的代码体现
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
2、如何解决可见性问题
2.1 volatile
先说结论:volatile就是让CPU每次操作数据时,必须立即同步到主内存,以及从主内存中读取数据。
volatile是一个关键字,修饰成员变量。
volatile修饰的变量。是不允许通过CPU缓存读写数据的,必须去主内存读写。
volatile的内存语义
- 读数据:当读一个volatile变量,JMM会将当前线程对应的CPU缓存中的内存设置为无效,必须取主内存中重新读取共享变量
- 写数据:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中。
2.2 synchronized
在java中 synchronized 关键字不仅能保证代码块的 互斥执行(原子性),还能确保共享变量的可见性。
synchronized 是通过内存屏障以及Happens-Before规则解决可见性问题的。这点和Lock锁有相似之处。
1、Monitor锁的内存屏障
Monitor锁的获取(进入同步代码块)和释放(退出同步代码块)会隐式插入 内存屏障,强制线程与主内存之间的数据同步:
(1) 获取锁时(monitorenter 指令)
JVM 会插入 读屏障(Load Barrier),强制当前线程:
从主内存重新加载所有共享变量的最新值,丢弃本地缓存中的旧值。
确保后续操作基于最新数据执行。
(2) 释放锁时(monitorexit 指令)
JVM 会插入 写屏障(Store Barrier),强制当前线程:
将所有修改过的共享变量刷新到主内存,确保其他线程能立即看到这些修改。
禁止将临界区内的写操作重排序到释放锁之后。
2. Happens-Before 规则
Java 内存模型(JMM)规定,
synchronized
的锁释放和获取满足 Happens-Before 关系:
锁的释放 Happens-Before 锁的获取:
如果线程 A 释放锁,线程 B 随后获取同一把锁,那么线程 A 在释放锁之前的所有操作(包括对共享变量的修改)对线程 B 是可见的。
3. 禁止指令重排序
JVM
和CPU
会对代码进行指令重排序优化以提高性能,但synchronized
会严格限制这种优化:
临界区内的代码不会被重排序到加锁或解锁操作之外。
synchronized和lock 相同点和差异点会在可见性的最后有讲到。
2.3 Lock锁
Lock锁是通过内存屏障以及Happens-Before规则解决可见性问题的,确保一个线程对变量的修改是对其他线程可见的。
-
内存屏障(Memory Barrier)
先说结论: 释放锁之前,确保所有修改对其他线程可见;获取锁之后,强制从主内存中读取最新数据。
读屏障(Load Barrier)
当一个线程获取锁(lock)时,Lock会插入一个读屏障(依赖volatile变量和CAS操作),强制该线程从主内存中获取共享变量的最新值,而不是从CPU缓存中取旧数据。
写屏障(Store Barrier)
当一个线程释放锁时,Lock的实现(如ReentrantLock)会插入一个写屏障(修改volatile状态变量,如AQS中的state),强制该线程在临界区(加锁代码块)中的所有写操作刷新到主内存中,而不是停留在CPU缓存中。
底层实现依赖
Lock
的实现(如ReentrantLock
)基于 AQS(AbstractQueuedSynchronizer),其内部使用volatile
变量(如state
)和 CAS(Compare-And-Swap) 操作。
volatile
变量的读写天然包含内存屏障,而 CAS 操作通过 CPU 指令(如LOCK CMPXCHG
)隐式触发屏障。
-
Happens-Before 规则
先说结论:释放锁前的操作对后续获取锁的线程可见。
锁的释放-Happens-Before-锁的获取
如果线程A释放了锁,而线程B随后获取了同一把锁,那么线程A在是释放前的所有修改,对线程B一定时可见的。
有序性保证
临界区的操作不会被重排序到加锁或解锁操作之外,避免了编译器和CPU的指令重排导致的可见性问题。
2.4 final
因为final修饰的变量是不可修改的,所以就不存在修改数据导致可见性问题了。
2.5 synchronized 和 Lock 解决并发编程可见性 的异同点
1、共同机制
二者均通过 内存屏障 和 Happens-Before 规则实现可见性的。
2、实现差异
- synchronized 依赖 JVM 的隐式管理(Monitor锁)
- Lock 以来显示的 volatile 变量和CAS操作 (如AQS的实现)
synchronized
Lock
(如ReentrantLock
)实现方式 JVM 隐式管理( monitorenter
/exit
)显式编码(基于 AQS + volatile
+ CAS)内存屏障 JVM 自动插入屏障 通过 volatile
变量和 CAS 隐式触发屏障灵活性 不可中断、不可超时、仅非公平锁 可中断、可超时、支持公平/非公平锁 性能 优化后与 Lock
接近(如锁粗化、偏向锁)在高度竞争场景下更灵活
3、选择建议
- 简单场景: synchronized(代码简洁,无需手动释放锁)
- 复杂需求(如超时、公平性):Lock
三、有序性
聊有序性问题之前,我们要知道,JAVA中的程序是乱序执行的。
1、有序性是什么?
程序的执行顺序可能不会按照代码的顺序执行,因为编译器和处理器可能会对指令进行重排序,目的是优化性能,但是在多线程环境下,这种重排序可能导致意想不到的结果。
例如,一个线程写入变量的顺序被改变,另一个线程看到的顺序可能不一致,从而引发错误。
2、有序性具体解决什么问题呢?
有序性确保多线程环境下,操作执行的顺序符合预期,避免因重排序引发的可见性问题或逻辑错误。
🌰举例:
双检锁单例模式中,如果没有正确的同步,可能会拿到未完全初始化的实例。这是因为指令重排序导致对象初始化步骤被重排,其他线程看到的是一个部分初始化的对象。
经典案例:双检锁(Double-Checked Locking)单例模式
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题在此!
}
}
}
return instance;
}
}
-
问题:
new Singleton()
的步骤可能被重排序为:-
分配内存空间。
-
将引用指向内存(此时
instance
不为null
)。 -
初始化对象。
-
-
若线程 A 执行到步骤 2 后,线程 B 调用
getInstance()
,将拿到未初始化的instance
,导致程序崩溃。
3、如何保证有序性
3.1 内存屏障(如:volatile)
内存屏障是CPU或编译器提供的一种指令,用于 禁止特定类型的重排序,确保屏障前后的操作按顺序执行。
屏障类型 作用 LoadLoad 禁止屏障前的读操作与屏障后的读操作重排序。 StoreStore 禁止屏障前的写操作与屏障后的写操作重排序。 LoadStore 禁止屏障前的读操作与屏障后的写操作重排序。 StoreLoad 禁止屏障前的写操作与屏障后的读操作重排序(全能屏障,开销最大)。
volatile 变量的写操作会插入 StoreStore + StoreLoad 屏障。
volatile 变量的读操作会插入 LoadLoad + LoadStore 屏障。
3.2 HappensBefore
具体规则:
-
单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
-
锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
-
volatile的happen-before原则: 对一个volatile变量的写操作happen-before对此变量的任意操作。
-
happen-before的传递性原则: 如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
-
线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
-
线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
-
线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
-
对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。 JMM只有在不出现上述8中情况时,才不会触发指令重排效果。
不需要过分的关注happens-before原则,只需要可以写出线程安全的代码就可以了。