一文了解JAVA锁机制

乐观锁和悲观锁

乐观和悲观的定义是对于数据冲突的态度,乐观锁乐观地认为并发不会造成数据冲突,悲观锁悲观的认为并发一定会造成数据冲突。


乐观锁

乐观地认为并发访问不会造成数据冲突,只在更新时检查是否有冲突。乐观锁和CAS的关系可以用“乐观锁是一种思想,CAS是一种具体的实现”来理解。

当使用CAS操作修改数据时,如果版本号不匹配或者其他线程已经修改了要操作的数据,CAS会返回失败。这时候,程序可以再次尝试CAS操作,也就是进行自旋重试,直到CAS操作成功。

因此,CAS操作已经内置了自旋重试的机制,避免了使用额外的自旋锁。

适用场景:适用于并发较低(高并发场景每次修改了去对比,还不如让加锁阻塞排队执行)、读多写少的场景,相信数据多数情况下不会发生冲突,只在更新时进行检查,以减少对共享资源的争用。

java中常见悲观锁实现:可以使用java.util.concurrent.atomic包中的原子类,比如AtomicInteger、AtomicLong等,来实现CAS操作。

mysql实现乐观锁:版本号、时间戳


悲观锁

悲观地认为并发访问会造成数据冲突,因此在访问共享资源之前就会进行加锁,确保同一时刻只有一个线程能够访问。

适用场景:适用于高并发、写多的场景,通过加锁保护共享资源,确保并发访问时不会造成数据不一致性。

java中常见悲观锁实现:synchronized 关键字、ReentrantLock(可重入锁)

mysql中实现悲观锁:SELECT … FOR UPDATE、 SELECT … LOCK IN SHARE MODE


乐观锁ABA问题

假设我们有一个银行账户的并发操作场景,其中账户余额初始为100元。现在有两个并发线程A和B同时尝试从该账户中取出50元。

1.线程A的操作:

线程A首先读取账户余额,得到100元(此时V=100,A=100)。
线程A计划执行CAS操作,将余额从100元减至50元(B=50)。
但在线程A等待执行CAS操作的过程中,线程B介入了。

2.线程B的操作:

线程B也读取账户余额,得到100元(此时V、A仍为100,但A对于线程B来说是新的读取值)。
线程B成功执行CAS操作,将余额从100元减至50元(B=50,操作成功,此时V=50)。
假设此时有另一个操作(如存款)将账户余额重新加至100元。

3.线程A继续执行:

当线程A继续执行其CAS操作时,它发现账户余额仍然是100元(V=100,与 A相等),因此CAS操作成功执行。
结果是账户余额从100元再次减至50元,但实际上应该只减少一次。

ABA问题的本质

在上述示例中,虽然账户余额的值(100元)在线程A的CAS操作前后保持不变,但实际上它已经被线程B修改过(从100元减至50元,然后又加回100元)。这就是所谓的ABA问题:值A被修改为B,然后又改回A,但CAS操作无法检测到这种中间状态的变化。

解决ABA问题

解决ABA问题的一种常见方法是使用带有版本号的原子引用类(如Java中的AtomicStampedReference)。这类原子引用类在存储值时,还会附带一个版本号。每次值被修改时,版本号也会递增。这样,在CAS操作时,不仅需要比较值是否相等,还需要比较版本号是否一致。如果版本号不一致,则说明该值在中间已经被其他线程修改过,此时CAS操作应失败。

为了解决ABA问题,一种常见的方法是使用版本号(Version Numbering)或者时间戳(Timestamp)来标记共享变量的变化次数,每次修改共享变量时都更新版本号或时间戳,这样就能够避免因为共享变量的数值相同而导致的误判。另外,Java中的AtomicStampedReference类可以用于解决ABA问题,它通过引入一个标记来区分不同的修改次数,从而避免了传统CAS操作可能出现的ABA问题。


悲观锁和乐观锁比较

相对而言,悲观锁适用于高并发,乐观锁适用于低并发
为什么乐观锁适用于并发量低:因为并发量高的时候,cas一直失败自旋没有任何意义,损耗性能,不如让cpu干其他的或者等待


锁升级

在 Java 中,锁升级是指在同步代码块中锁的状态发生改变的过程。这个过程包括偏向锁、轻量级锁和重量级锁三种状态的切换。
锁升级是JVM自动进行管理的。当JVM检测到多个线程对同步代码块的竞争时,会根据实际情况自动进行锁的升级。这种锁升级的机制是为了在多线程竞争情况下保证程序的安全和效率,以及在不同线程竞争程度下选择合适的锁状态,从而最大限度地提高并发性能。

偏向锁:


用于处理只有一个线程访问同步块的情况,减少不必要的竞争。
偏向锁需要在对象头中记录持有偏向锁的线程ID,避免重复的CAS操作。
适用于只有一个线程执行同步块的情况,从而减少不必要的同步操作。不用去加锁释放锁。

轻量级锁:


适用于短时间内只有少量线程竞争同步块的情况。
使用CAS操作尝试获取锁,避免了传统锁(互斥锁)的性能开销。
在少量线程竞争情况下,避免了传统锁的重量级化,提高了性能。

重量级锁:


当锁存在大量的线程竞争时会升级为重量级锁,采用传统的互斥锁实现。
保证了线程间数据同步和互斥访问,但性能开销相对较大。

偏向锁升级为轻量级锁:
当多个线程访问同步块时,偏向锁会升级为轻量级锁。这时,会使用CAS(Compare And Swap)操作来尝试获取锁,如果成功获取锁,则表示处于轻量级锁状态。

轻量级锁升级为重量级锁:
如果轻量级锁获取失败,就会升级为重量级锁。这时,会使用传统的互斥锁机制来确保线程间的互斥访问。

公平锁


公平锁则按照请求锁的顺序来获取锁,不允许插队,即等待时间最长的线程会优先获得锁。

非公平锁


非公平锁允许抢占,即允许在等待队列中的线程随机获取锁,synchronized 关键字是非公平锁

在 Java 中,ReentrantLock 是可重入锁的一种实现,通过使用 ReentrantLock 类,可以根据构造函数的不同参数选择是公平锁还是非公平锁。

ReentrantLock fairLock = new ReentrantLock(true); // 创建一个公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 创建一个非公平锁
 


可重入锁

可重入锁是一种允许同一个线程多次获取同一把锁的锁。当线程第一次获取锁后,再次尝试获取该锁时,也会成功获取而不会被阻塞,这样可以避免死锁情况的发生。Java 中的 ReentrantLock 、synchronized 就是一种可重入锁的实现。

image

不可重入锁

不可重入锁是一种不允许同一个线程多次获取同一把锁的锁。如果一个线程已经获取了该锁,再次尝试获取时会被阻塞,即使是同一个线程也不能再次获取这把锁,这可能会导致死锁情况。

image

单机锁

单机锁是指在单个计算机或单个进程中控制对共享资源的访问的锁。常见的单机锁包括 synchronized 关键字、ReentrantLock 等。单机锁通常只在单个 JVM 内有效,不能跨越多个计算机或进程。 synchronized 关键字或 ReentrantLock 都是单机锁

分布式锁

分布式锁是用于在分布式系统中控制对共享资源的访问的锁,可以跨越多个计算机或进程。分布式锁可以通过基于数据库、基于缓存(如 Redis)、基于 ZooKeeper 等方式来实现。


关于锁的本质

首先,锁的本质,其实就是一个标记,然后约定好:所有线程在进入临界区的时候,先去检查锁的状态,然后再根据锁的状态来决定当前线程该怎么办?

所以上面所有的锁的概念,都无外乎是根据这两个概念来对锁进行了分类:

锁标志:锁标志到底放在什么地方。mysql中的行锁、表锁无非就是锁标志是放在行维度还是表维度、而java中,不管是synchronize还是Lock接口,锁标志都是放在对象上的,所以从这个角度来讲,java的锁就是对象锁。

当遇到锁冲突的时候该怎么办?

  • 如果当前线程只要遇到锁被占用,就直接进入到阻塞状态。那这个锁就是排它锁
  • 如果当前线程只要遇到锁被占用,要看当前操作是什么,来决定到底是挂起、还是进入临界区,那这就是共享锁。进一步,如果按照读写来明确这个操作,那就是读锁、写锁、读写锁。
  • 如果当前线程只要遇到锁被占用,不是立马进入到阻塞状态,而是不断去轮询一下锁的状态。这就是自旋锁。
  • 如果当前线程只要遇到锁被占用,然后再看下将这个锁改成占用状态的是不是自己。如果是自己,那么就继续进入临界区执行;如果不是,再根据操作等来决定下一步动作。这就是可重入/不可重入锁。
  • 如果当前线程只要遇到锁被占用,且自己也已经占用了一个锁标志,那么正好占用了当前线程站在检查的那个锁的标志的那个线程在检查当前线程占用的锁,就会出现线程的循环等待,那就是死锁

总结一下,锁的本质:

  • 在某个地方放一个标志。
  • 所有线程/进程在进入临界区的时候都去检查下这个标志。
  • 而进入临界区加锁就是修改这个标志
  • 退出临界区解锁也是清除这个标志。

java中的Synchornize锁

锁标志存放位置

synchronize使用的锁标志是存放在对象头里的。在java中,实例化一个独享后,除了对象里存放的数据外,编译器还会额外的给每个对象分配一块内存,存储一些对象数据,这块内存就是所谓的对象头。

java中,对象的对象头由三部分构成(如果是数组还有一部分就是记录数组长度的):

类型指针:类型指针指向的就是当前对象的类的元数据,虚拟机通过这个指针来确定该对象是哪个类的实例。

mark word:这部部分主要用来记录该对象自身的运行时数据,如hashcode,gc分代年龄等。mark word占用一个jvm的字大小,即32位的jvm,mark word为32位;64位的jvm,mark word占64位。synchronize使用的锁标志,就是记录在mark word里面的。

以32位的jvm为例来说明mark word的构成

住院号就是两部分构成:

高30位部分:mark word中存放信息的部分

低2位部分:这部分就是标志位,低2位取值不同,代表了高30位存放的内容是不一样的




image

遇到冲突怎么办

image


jdk中的lock

以比较常用的两类锁:可重入锁ReentrantLock和ReentrantReadWriteLock为例来说明:

image

锁标志存哪儿。在jdk中有一个锁实现的共有抽象父类,那就是AbstractQueuedSynchronizer,jdk中所有的锁实现都会依赖于这个抽象父类,这就是很多博客中锁说大名鼎鼎的AQS。这个类里就是用来存储锁标志的。比如AQS#state字段,就是来标记锁是否别占用的。其父类AbstractOwnableSynchronizer#exclusiveOwnerThread就是用来标记当前锁被哪个线程占用的,从而实现可重入。

当线线程在进入临界区时,遇到锁冲突怎么办?

使用Lock接口的时候,lock#lock()和lock#unlock()包围起来的就是临界区。所以任何一个线程要进入临界区都先要执行lock#lock()也是在这个方法里去检查锁标志的。

  1. 当锁被占用(AQS#state不为0),那么当前线程就会首先使用CAS去修改state值(即自旋获得锁),这个过程是和synchronize的轻量级锁的过程是一模一样的。无非就是synchronize的CAS是编辑器植入的,而Lock#lock()的CAS是用的UnSafe包装的CAS指令。
  2. 当CAS达到一定次数的时候(spinForTimeoutThreshold),就是使用LockSupport#park()将当前线程挂起。其实就是升级到重量级锁了。

 

这里也可以看到,和synchronize相比,jdk的实现,其实已经没有偏向锁了。只有轻量级锁和重量级锁

所以不管是synchronize还是Lock,其实锁标志 都是存放在对象上的,所以我们可以认为就是对象锁。


经典面试题:synchornize和lock的区别

synchronize唯一做不到的是:在A处加锁、在B处释放,而A、B两处是不同的代码块,但想想真实世界中,真有这么奇葩的使用方式么?

实际上,在绝大部分场景中,synchronize和lock已经没有那么大的区别的。

  1. synchronize默认开启了偏向锁,偏向锁在有所的竞争的时候,其实会影响性能;而lock是没有偏向锁的。
  2. 对于条件变量的使用上,Lock其实是更灵活,可以支持多个条件变量。而条变量的使用是可以减少惊群效应的。
  3. synchronize存在所谓的锁升级问题,一旦锁升级后,就不能降级了。但是Lock是不存在的这种升级的。但并不是说lock就更牛逼,个人觉得只是设计理念不一样而已,面对不同的场景就各有优劣。
  4. 面对突发的严重冲突,然后又立即缓解。因为synchronize可能已经升级成重量级锁了,那后续锁都是重量级锁了。但lock任何时候获取锁的过程都一样,所以后面还是有CAS自旋的过程的。

如果持续冲突,那么这个时候重量级锁挂起线程反而是最优的,而Lock就会存在大量的毫无意义的CAS自旋。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇