乐观锁和CAS
一、乐观锁
1.1按照线程进入临界区是否锁住同步资源:悲观锁和乐观锁
悲观锁
悲观锁:悲观的思想,每次进入临界区操作数据时,都会认为有别的线程会修改,所以线程每次读写数据时都会上锁,锁住同步资源,这样其他的线程需要读写这个数据时就会被阻塞,一直到拿到锁
Java的synchronize锁(升级为重量级锁,Monitor锁)也是悲观锁,
Innodb的行锁也属于悲观锁,行锁机制跟Innodb事务有关,事务执行完毕必须提交或回滚,否则导致其他事务一直阻塞。
乐观锁
乐观锁:乐观的思想,每次拿数据时都会认为别的线程不会修改,所以不会上锁,**但在更新的时候会判断一下此期间别的线程有没有去更新这个数据。**采取在写时先读取当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样就更新),如果失败则重复读-比较-写的操作(自旋)。总之运用于读大于写的场景
@GetMapping("/optimisticLockUser")
public String optimisticLock(UserEntity userEntity) {
Long userId = userEntity.getUserId();
// 标记该线程是否修改成功
Integer resultCount = 0;
//失败重试,通过while循环
while (resultCount <= 0) {
// 1.根据userid 查找到对应的VERION版本号码 获取当前数据的版本号码
UserEntity dbUserEntity = userMapper.selectById(userId);
if (dbUserEntity == null) {
return "未查询到该用户";
}
// 2.做update操作的时候,放入该版本号码 乐观锁
userEntity.setVersion(dbUserEntity.getVersion());
//UPDATE meite_user SET user_name=?, version=? WHERE user_id=? AND version=? AND deleted=0 所进行的操作
resultCount = userMapper.updateById(userEntity);
}
return resultCount > 0 ? "success" : "fail";
}
Java中的乐观锁普遍通过CAS实现。
二、乐观锁底层JUC.CAS
什么是CAS
JDK5 所增加的 JUC(java.util.concurrent)并发包,对操作系统的底层 CAS 原子操作进行了封装,为上层 Java 程序提供了 CAS 操作的 API。
CAS 是在用户态完成,且CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少
CAS: Compare and Swap, 比较并交换。 底层会执行CAS(V,E,N)
CAS的三个操作数,修改目标的真实V,旧的预期值E,要修改的新值N,
当且仅当预期值E和目标的真实值V相等时,才能实现修改操作,即将V修改为N,否则什么也不做
2.1 Synchronize锁的缺点
在JDK1.5之前 的synchronized锁,线程没有获取synchronized锁,会直接阻塞,即synchronized锁会直接升级为重量级锁,后期唤醒的成本非常高
Jdk1.5及之后引入了synchronized锁的锁升级:偏向锁–>轻量级锁–>重量级锁
轻量级锁为(共享锁)乐观锁,被阻塞的线程也只处于用户态
但一直处于用户态(会一直占用CPU,消耗CPU资源)
而重量级锁属于(互斥锁)悲观锁,线程获取锁的过程,会进行用户态和内核态的切换
2.2 内核角度分析 轻量级/重量级锁的区别
用户态和内核态的区别
- 内核态(Kernel Mode):运行操作系统程序,操作硬件 (内核空间)
- 用户态:运行用户程序(用户空间)
为了安全,应用程序无法直接调用 硬件的功能。当应用程序需要硬件功能时 (如文件读写),就需要系统调用(当进程进行系统调用后,就会从用户态转换为内核态)
- 何为零拷贝:零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。
2.3 Linux内核互斥锁
Linux内核互斥锁mutex lock:
1、atomic_t count; //指示互斥锁的状态:
1:没有上锁,可以获得;
0:被锁定,不能获得。
负数:被锁定,且可能在该锁上有等待进程 ,初始化为没有上锁。
2、spinlock_t wait_lock; //等待获取互斥锁中使用的自旋锁。在获取互斥锁的过程中,操作会在自旋锁的保护中进行。初始化为为锁定。
3、struct list_head wait_list; //等待互斥锁的进程队列。
2.4Cpu上下文切换的原因
何为CPU上下文切换
Cpu通过分配时间片来执行任务,一个CPU在同一时刻只能运行一个线程,当一个任务的时间片用完(时间片耗尽或出现阻塞等情况),CPU会转去执行另一个线程,切换另一个任务,切换之前会保存上一个任务的状态,当下次在重新切换到该任务,就会继续切换之前的状态运行。任务从保存到再加载的过程就是一次上下文切换
上下文切换只发生在内核态中。内核态是CPU的一种特权模式,这种模式下,内核运行并且可以访问所有内存和其他系统资源。其他的程序,如应用程序,在最开始都是运行在用户态,但是他们能通过系统调用(在Java中可以通过navtive进行系统调用)来运行部分内核的代码
1.通过调用下列方法会导致自发性上下文切换:
Thread.sleep()
Object.wait()
Thread.yeild()
Thread.join()
LockSupport.park()
2.发生下列情况可能导致非自发性上下文切换:
- 切出的线程时间片用完
- 有优先级更高的线程被允许
- JVM的垃圾回收(GC作为守护线程)
2.5如何避免上下文切换
- 无锁并发编程。
- CAS算法,JUC的Atomic包使用CAS算法来更新数据,而不需要加锁,可能会消耗cpu资源。
- 使用最少的线程,避免创建不必要的线程
- 协程:在单线程里实现多任务的调度,并在多线程里维护多个任务间的切换
关于协程:比线程更轻量级的存在,协程之于线程,就像线程之于进程
重要的是:协程不被操作系统内核所管理,而完全有应用程序所控制(只在用户态中运行,不会发生CPU上下文切换),从而减少了像线程切换那样消耗资源
2.6 重量级锁效率为什么低
轻量级都是在用户态完成;重量级需要用户态与内核态切换;
原因:重量级锁需要通过操作系统自身的互斥量(mutex lock,即互斥锁)来实现,然而这种实现方式需要通过用户态和核心态的切换来实现,但这个切换的过程会带来很大的性能开销
申请锁时,会从用户态进入内核态,申请到后从内核态返回到用户态(两次切换);没有申请到时阻塞睡眠在内核态。使用完资源到释放锁,从用户态进入内核态,唤醒阻塞等待锁的进程,然后再返回到用户态(又两次切换);被唤醒进程在内核态申请到锁,返回用户态(可能其他申请锁的进程又要阻塞)。所以,使用一次锁,包括申请,持有到释放,当前进程要进行四次用户态与内核态的切换。同时,其他竞争锁的进程在这个过程中也要进行一次切换
2.7 CAS底层实现原理
Unsafe类中的CAS方法
Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全的底层操作,
如直接访问系统内存资源、自主管理内存资源等,Unsafe 大量的方法都是 native 方法,基于 C++
语言实现,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的
作用。
- 此外操作系统层面的CAS是一条CPU的原子指令,所以使用CAS操作数据是不会造成数据的不一致问题。
完成 Java 应用层的 CAS 操作,主要涉及到的 Unsafe 方法调用,具体如下:
(1)获取 Unsafe 实例。
(2)调用 Unsafe 提供的 CAS 方法,这些方法主要封装了底层 CPU 的 CAS 原子操作。
(3) 调用 Unsafe 提供的字段偏移量方法,这些方法用于获取对象中的字段(属性)偏移量,此偏移量值需要作为参数提供给 CAS 操作。
CPU飙高的问题->CAS进行失败重试进入循环
var1对象的地址+var2(属性偏移量) ==对象当前属性的值
var5预期值,与上面的值进行判断,相等则替换原有的值,否则进行失败重试
private volatile int value; 值//volatile关键字修饰 具有可见性
//value 属性值的地址偏移量
private static final long valueOffset; (该偏移量再 加载原子类的时候通过static代码块获取)
static {
try {
//计算 value 属性值的地址偏移量
valueOffset = unsafe.objectFieldOffset(
AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
- volatile的可见性,使其呈现出轻量级 同步机制的特性,其修饰的的变量在JVM的主存的值,具有可见性。即在多线程环境下,多个线程对这个变量进行修改,线程A在自己内存中将值修改后同步到主存,此时线程B会察觉到主存中值的改变,线程B会强制重新读取主存中的值
- 但volatile不能保证原子性,但保证了线程安全性问题
其实a++操作分为3步;
从主内存中读取值 (全局共享变量V,位于主存之中,将其读取到线程私有内存中)
进行值运算(加一操作)(对V进行修改变为N)
将栈中副本中的新值同步到共享内存中 (再将其重新放回到主存中)
- 原子性 是由Unsafe解决,从硬件层面保证原子性
2.8 CAS解决ABA问题
- 何为ABA问题
ABA问题不仅存在于多线程,而且也来自于Mysql使用乐观锁
eg:说一个线程 A 从内存位置 M 中取出 V1,另一个线程 B 也取出 V1。现在假设线程 B 进行了一些操作之后将 M 位置的数据 V1 变成了 V2,然后又在一些操作之后将 V2 变成 V1。之后,线程 A 进行 CAS 操作,但是线程 A 发现 M 位置的数据仍然是 V1,然后线程 A 操作成功。
尽管线程 A 的 CAS 操作成功,但是不代表这个过程是没有问题的,线程 A 操作的数据 V1 可能已经不是之前的 V1,而是被线程 B 替换过的 V1,这就是ABA 问题。
ABA问题解决的方案, 尽量结合业务
- 添加版本号
很多乐观锁的实现,都是使用版本号(version)方式解决ABA问题。乐观锁每次执行数据的修改操作,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作 同时版本号加1,否则就执行失败。
- 此外,原子类的AtomicStampReference 在CAS的基础上增加 了一个 印戳的标记了,用于觉察数据是否发生变化
public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp)
//这不是c++ map的元素吗
}
private volatile Pair<V> pair; //通过可见性保证线程安全
//AtomicStampReference几个常用的方法
//获取被封装的数据
public V getRerference();
//获取被封装的数据的版本印戳
public int getStamp();
AtomicStampedReference 的 CAS 操作的定义如下:
public boolean compareAndSet(
V expectedReference,//预期引用值
V newReference, //更新后的引用值
int expectedStamp, //预期印戳(Stamp)标志值
int newStamp) //更新后的印戳(Stamp)标志值
- 使用 AtomicMarkableReference 解决 ABA 问题
- 可以使用构造方法更新一个布尔类型的标记位和引用类型。就是AtomicStampReference的简化版
2.9 JUC原子类
**1.原子更新基本类型类 **
AtomicBoolean: 原子更新布尔类型。
AtomicInteger: 原子更新整型。
AtomicLong: 原子更新长整型。
2.原子更新数组
AtomicIntegerArray: 原子更新整型数组里的元素。
AtomicLongArray: 原子更新长整型数组里的元素。
AtomicReferenceArray: 原子更新引用类型数组里的元素。
3.原子更新引用类型
AtomicReference: 原子更新引用类型。
AtomicReferenceFieldUpdater: 原子更新引用类型的字段。
AtomicStampReference: 带Stamp印戳或标记),用于觉察数据是否发生变化,给数据带上一种时效性的检验
AtomicMarkableReferce: 原子更新带有标记位的引用类型,可以使用构造方法更新一个布尔类型的标记位和引用类型。
4.原子更新字段类
AtomicIntegerFieldUpdater: 原子更新整型的字段的更新器。
AtomicLongFieldUpdater: 原子更新长整型字段的更新器。
AtomicStampedFieldUpdater: 原子更新带有版本号的引用类型。
JDK8新增原子类
DoubleAccumulator
LongAccumulator
DoubleAdder
LongAdder(空间换时间,热点分离,将value分离成为一个数组,当多线程访问时,通过hash算法将线程映射到数组的一个元素进行CAS操作,和ConcurrentHashMap有异曲同工之妙)
LongAdder通过热点分离的思想,通过增加元素的个数,降低value的热点,使AtomicLong中的恶性CAS空自旋得以解决
2.10 CAS操作的弊端
主要有以下三点:
1、ABA问题 (Markable Stamped解决)
2、只能保证一个共享变量之间的原子性操作,当然AtomicReference类可以解决
3、开销问题
CAS恶性自旋:解决就是空间换时间
1、使用分散热点:使用LongAdder替代AtomicLong,线程映射到数组的一个元素进行CAS操作,和ConcurrentHashMap有异曲同工之妙
LongAdder通过热点分离的思想,通过增加元素的个数,降低value的热点,使AtomicLong中的恶性CAS空自旋得以解决