CAS + volatile
乐观锁
- 介绍:只有在数据更新的时候,才会进行冲突检测(加版本号比较两个当前引用、当前引用的标志),查看是否产生了并发冲突
- 常见:CAS(Atomic类里面的unsafe类实现CAS)
-
场景:资源竞争轻,线程冲突少。这样的话,产生自旋锁的概率少,减少因为一直循环产生的CPU的执行消耗。
CAS(原子性) + volatile(有序性(禁止指令重排)、可见性)替换锁(原子性、有序性、可见性)实现线程安全。
CAS
- 介绍:compareAndSwap*(比较并交换)、通过硬件保证了比较--更新操作的原子性。
- 概念:Object object(对象的内存位置)、valueOffset(对象中变量的偏移量)、expect(变量预期值)、update(新值)。如果对象中内存偏移量valueOffset等于预期值expect的时候,会使用新值update替换旧值expect。否则就会执行自旋锁直到valueOffset的值等于expect,就会产生大量的CPU的执行消耗。
// 不断自旋 public final long getAndSetLong(Object obj, long offset, long update){ long l; do{ l = getLongvolatile(obj, offset); }while(!compareAndSwapLong(obj, offset, l, update)); return l; }
- JUC下面的原子类,通过unsafe类操作内存,里面有compareAndSwap()方法也就是CAS。
compareAndSwap*具体看实现那个类:
- AtomicLong:compareAndSwapLong;
- AtomicInteger:compareAndSwapInt;
多线程如何实现i++:
AtomicInteger atomicInteger = new AtomicInteger(); atomicInteger.incrementAndGet();
incrementAndGet()的源码:通过CAS不断的自旋实现i++
// 调用unsafe里面的getAndAddInt()方法
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// 实现操作内存实现加一
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
CAS存在问题
- ABA问题;
- 循环时间长,开销大;(如果自旋CAS长时间不成功,会给CPU带来非常大的执行开销)
- 只能保证一个共享变量的原子操作;
只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以循环CAS的方式来保证原子操作;当多个共享变量操作时,就无法保证原子操作;
解决:
- 使用AtomicReference类保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
存在ABA问题
简单来说:就是有一个线程将期望值改动又改回去,另一个线程才将期望值改成新值。
import java.util.concurrent.atomic.AtomicInteger; /** * @author SHshuo * @data 2021/8/24--11:03 */ public class ABAQuestion { public static void main(String[] args) { // 保证原子性 AtomicInteger atomicInteger = new AtomicInteger(2021); // 捣乱的线程 // 期望数值是2021,如果是将其改为2022 System.out.println(atomicInteger.compareAndSet(2021, 2022)); System.out.println(atomicInteger.get()); // 期望数值是2022,如果是将其改为2021 System.out.println(atomicInteger.compareAndSet(2022, 2021)); System.out.println(atomicInteger.get()); // 期望的线程 System.out.println(atomicInteger.compareAndSet(2021, 9999)); System.out.println(atomicInteger.get()); } }
解决方案:预期值 + 版本号。
- JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前的标志是否等于预期标志,如果全部相等,则以原子方式将该应用和该标志的值设置为给定的更新值。
源码分析:
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair<V> current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); }
注意AtomicStampedReference. compareAndSet()方法比较的是内存地址,用的==。
补充:
- Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。也就是如果超过-128~127,==判断后一直是false了。但是AtomicStampedReference里面一般引用的是一个对象而不是包装类。
- 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。
避免ABA问题的代码示例
import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicStampedReference; /** * @author SHshuo * @data 2021/8/24--15:33 * AtomicStampedReference里面多出了版本实现了防止ABA问题的CAS * * Integer范围是-128~127不能超过这个范围,一旦超过这个范围就会new对象。 * 源码比较的是内存地址,用的==所以一直会false * * * 注意引用的包装类,版本号stamp是地址比较。实际开发中引用的是一个对象 */ public class CASAvoidABAQuestion { public static void main(String[] args) { // 20为期望值,1为版本号 AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(20, 1); // 捣乱线程 new Thread(() -> { // 获取版本号 System.out.println(stampedReference.getStamp()); stampedReference.compareAndSet(20, 22, stampedReference.getStamp(), stampedReference.getStamp() + 1); stampedReference.compareAndSet(22, 20, stampedReference.getStamp(), stampedReference.getStamp() + 1); // 获取版本号 System.out.println("A线程修改完的版本号 =>" + stampedReference.getStamp()); }, "A").start(); // B线程睡眠2秒保证A线程先执行 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(() -> { // 进行CAS操作、比较并修改 stampedReference.compareAndSet(20, 22, stampedReference.getStamp(), stampedReference.getStamp() + 1); // 获取版本号 System.out.println("B线程修改完的版本号 =>" + stampedReference.getStamp()); }, "B").start(); } }
自旋锁(spinLock):
- 原子操作 + 自循环。
- 自旋锁是一种非阻塞锁,也就是说,如果某线程需要获取自旋锁,但该锁已经被其他线程占用时,该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取自旋锁。
import java.util.concurrent.atomic.AtomicReference; /** * @author SHshuo * @data 2021/8/24--19:40 * 自旋锁:原子操作 + 自循环 * * 自旋锁是一种非阻塞锁,也就是说,如果某线程需要获取自旋锁,但该锁已经被其他线程占用时, * 该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取自旋锁。 * * 执行一次循环就退出走了 */ public class spinLock { // 引用对象 AtomicReference<Thread> atomicReference = new AtomicReference<>(); public void lock(){ // 获取当前线程 Thread thread = Thread.currentThread(); System.out.println(Thread.currentThread().getName() + "正在加锁"); // 自旋锁,等待获取线程 while (!atomicReference.compareAndSet(null, thread)){ } } public void unLock(){ // 获取当前线程 Thread thread = Thread.currentThread(); System.out.println(Thread.currentThread().getName() + "正在解锁"); atomicReference.compareAndSet(thread,null); } }测试类:
// 测试自旋锁 spinLock spinLock = new spinLock(); new Thread(() -> { try{ spinLock.lock(); System.out.println(Thread.currentThread().getName() + "正在执行任务"); }catch (Exception e){ e.printStackTrace(); }finally { spinLock.unLock(); } },"A").start(); // 由于没有加锁,保证A线程先执行 TimeUnit.SECONDS.sleep(2); new Thread(() -> { try{ spinLock.lock(); System.out.println(Thread.currentThread().getName() + "正在执行任务"); }catch (Exception e){ e.printStackTrace(); }finally { spinLock.unLock(); } },"B").start(); }
思维导图:
Volatile
介绍:
- JVM提供轻量化的同步机制;
- 可见性、有序性(禁止指令重排)、不满足原子性(确保动作要么全部完成、要么完全不起作用);
- 主要修饰变量,synchronized主要修饰类或者方法。
不具有原子性:
例如:
volatile int i = 0; i++;变量的自增操作i++,分为三个步骤:
- 从内存中读取出变量 i 的值;
- 将i的值加一;
- 将加 1 后的值写回内存。
可见性:
介绍:
- 当一个对象被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新会主内存。
- 当其他线程读取该共享变量的时候,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的数值。
JMM:
介绍:
java的内存模型、是概念、约定、不存在的;
同步的约定:
- 线程解锁前,必须把共享变量立刻刷回主存;
- 线程加锁前,必须读取主存中的最新值到工作内存中;
- 加锁、解锁是同一把锁;
线程的工作原理:
指令重排:
介绍:
- java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序;
- 会经历如下的过程:源代码-->编译器优化-->指令并行-->内存系统-->执行;
- 通过内存屏障实现禁止指令重排;
内存屏障:
- JVM层面实现:
- LoadLoadBarrier:读屏障,读取屏障后的数据前,要保证屏障前的数据被读取完毕;
- StoreStoreBarrier:写屏障,屏障后写入操作执行前,要保证屏障前的写入操作对其他处理器可见;
- LoadStoreBarrier:读写屏障,屏障后写入操作被刷出前,要保证屏障前的读取数据被读取完毕;
- StoreLoadBarrier:写读屏障,屏障后读取数据执行之前,要保证屏障前写入对所有处理器可见。
- x86 硬件层面实现:
CPU提供了三个汇编指令串行化运行读写指令达到实现保证读写有序性的目的:
- sfence:在该指令之前写操作必须在该指令后的写操作前完成;
- lfence:在该指令前读操作必须在该指令后的读操作前完成;
- mfence:在该指令前读写操作必须在该指令后的读写操作前完成;
hshuo的面试之路 文章被收录于专栏
作者目标是找到一份Java后端方向的工作 此专栏用来记录从Bilibili、书本、其他优质博客上面学习的内容 用于巩固、总结内容 主要包含Docker、Dubbo、Java基础、JUC、Maven、MySQL、Redis、SpringBoot、SpringCloud、数据结构、杂文、算法、计算机网络、操作系统、设计模式等相关内容