乐观锁
每次读写数据都认为不会发生冲突,不会阻塞,一半来说只有在数据更新时才会检查是否发生冲突,若没有冲突直接更新,有冲突再解决冲突。
当线程冲突不严重,可以采用乐观锁策略来避免多次加锁和解锁操作 color{#FF0000}当线程冲突不严重,可以采用乐观锁策略来避免多次加锁和解锁操作 当线程冲突不严重,可以采用乐观锁策略来避免多次加锁和解锁操作
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决. 假设我们需要多线程修改 “用户账户余额”.
设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 “提交版本必须大于记录 当前版本才能执行更新余额”
- 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1,
balance=100 ).
- 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 );
- 线程 A 完成修改工作,将数据版本号加1(version=2 ),连同帐户扣除后余额(balance=50 ),写回到内存中;
- 线程 B 完成了操作,也将版本号加1(version=2 )试图向内存中提交数据(balance=80
),但此时比对版本发现,操作员B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足
“提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.
悲观锁
总是假设最坏的情况,每次去拿数据都认为别人会修改,所以每次在拿数据都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。
当线程冲突严重时就需要加锁,来避免线程频繁访问共享数据失败带来的
C
P
U
空转问题
color{#FF0000}当线程冲突严重时就需要加锁,来避免线程频繁访问共享数据失败带来的CPU空转问题
当线程冲突严重时就需要加锁,来避免线程频繁访问共享数据失败带来的CPU空转问题
2.读写锁举个栗子: 同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师 你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后,才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为"老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学 B 直接就来找老师.(没 加锁, 直接访问资源)如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁,但是能识别出数据访问冲突). 这个是乐观锁.
多线程在访问数据时,并发读数据不会冲突,只有在写数据时才有可能发生冲突,所以就有了读写锁的概念,JDK内置的读写锁是ReentrantReadWriteLock。
适用条件
特别适用于线程基本都在读数据,很少有写数据的情况。
特性
1.多个线程并发读数据,都可以访问数据,并发执行不互斥。
2.多个线程写数据,只有一个线程能访问到写锁,其他线程阻塞。
3.一个线程读数据一个线程写数据也会互斥,当写数据的线程写操作结束后才能执行读数据的线程。
重量级锁
需要操作系统和硬件支持(重度依赖os提供的mutex),线程获取重量级锁失败进入阻塞状态(os->用户态切换到内核态,开销非常大)
比如:
去银行办理业务:
用户态:在窗口外自己处理
内核态:窗口内,工作人员协助
来回切换相当于人不断和工作人员交流还要重新排队->开销非常大。
轻量级锁
尽量在用户态执行操作,线程不会阻塞,不会进行状态切换。
轻量级锁的实现
轻量级锁的常用实现是自旋锁:
按之前的方式,线程在枪锁失败后进入阻塞状态,放弃CPU,需要过很久才能再次被调度,但实际上大部分情况下,虽然锁抢夺失败,但过不了多久所就会被释放,没必要放弃CPU,这个时候就可以采取自旋锁来解决这个问题。
伪代码:
while (抢锁(lock) == 失败) {}
如果抢锁失败立即再尝试获取锁,无限循环直到获取到锁位置,第一次获取锁失败,第二次的尝试会在极短时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁
理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意, 这个很长的时间间隔里,女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的轻量级锁的实现方式
1.优点:没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁
2.缺点:如果锁被其他线程占有的时间较长,那么会持续消耗CPU资源(而挂起等待锁不消耗CPU资源)
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待. 当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注:
color{#FF0000}注:
注:
操作系统内部的线程调度就可以视为是随机的
.
如果不做任何额外的限制
,
锁就是非公平锁
.
如果要想实现公平锁
,
就需要依赖额外的数据结构
,
来记录线程们的先后顺序
.
公平锁和非公平锁没有好坏之分
,
关键还是看适用场景
.
s
y
n
c
h
r
o
n
i
z
e
d
是非公平锁
.
color{#FF0000}操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序. 公平锁和非公平锁没有好坏之分, 关键还是看适用场景. synchronized 是非公平锁.
操作系统内部的线程调度就可以视为是随机的.如果不做任何额外的限制,锁就是非公平锁.如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序.公平锁和非公平锁没有好坏之分,关键还是看适用场景.synchronized是非公平锁.
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁.
CAS理解 “把自己锁死” :
一个线程没有释放锁, 然后又尝试再次加锁.
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁,这样的锁称为不可重入锁。
注: s y n c h r o n i z e d 是可重入锁 color{#FF0000}注:synchronized 是可重入锁 注:synchronized是可重入锁
什么是CAS
CAS全名:Compare and Swap : 比较交换,是乐观锁的实现方式之一,这种实现不会阻塞线程,而是不断尝试更新。
一个CAS涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
CAS是怎么实现的
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
java 的 CAS 利用的的是 unsafe 这个类提供的
CAS 操作;
unsafe 的 CAS 依赖的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子 性。
简而言之,是因为硬件予以了支持,软件层面才能做到。
CAS的应用
假设两个线程同时调用 getAndIncrement
-
两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
主内存: Value: 0
线程1:oldValue:B == 0
线程2:oldValue:B == 0 -
线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.
V == 0,A == 0,B ==1
CAS(V == A) -> B写回主内存 -
线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
V == 1,A == 0,B == 1
此时 V != A,说明了主内存在线程2读取变量value之后有了修改,本次写回操作失败,将最新的Value加载到当前的工作内存中再次进行操作之后尝试写回,如此循环实现了原子性
CAS引发的ABA问题
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成
Z, 那么就需要
1.先读取 num 的值, 记录到 oldNum 变量中.
2.使用 CAS 判定当前 num 的值是否为 A, 如果为 A,就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这 个时候 t1究竟是否要更新 num 的值为 Z 呢?
synchronized锁升级流程解决方案:
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:
- 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
2.如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).



