自己对锁的理解
synchronized 的底层实现原理
同步锁。可以修饰实例方法 (当前实例加锁) 修饰静态方法 (当前类加锁),修饰代码块 (指定加锁对象)
使用 synchronized 编译后的字节码文件中会有 monitorenter 和 monitorexit 指令。分别对应着获取锁和释放锁。
而每一个同步对象都有一个自己的 Monitor 监视器锁。加锁的时候,会先尝试获取 monitor 的所有权,
- 如果 monitor 的进入数为 0,则将进入数设置为 1 并进入 monitor。该线程即为 monitor 的所有者
- 如果线程已经占有该 monitor,那么就重新进入,将进入数加 1
- 如果其他线程占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0。重新获取 monitor 的所有权
如果 monitorexit 出现了两次,第一次为同步正常释放锁,第二次为发生异常退出锁
Synchronized 和 Lock 的区别
Synchronized 是 java 的关键字,而 Lock 是一个接口
synchronized 不会产生死锁,而 lock 必须在 finally 中释放锁,不然容易造成死锁
synchonized 不可以判断锁的状态,lock (通过 trylock) 可以判断
synchonized 可重入,不可中断,是一个非公平锁 lock 则是可以自行调整
Thread.sleep () 和 Object.wait () 的区别
Thread.sleep 不会释放锁、Object.wait 会释放锁
Thread.sleep 到了时间自动唤醒、wait 如果没有指定时间,则必须手动使用 notify 唤醒
如果 wait 设置了时间并到了时间被唤醒,如果获得了锁,则继续执行,没获得锁则进入锁池等待
锁升级
Java 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和 “轻量级锁”:锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
偏向锁:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中记录存储锁偏向的线程 ID,以后该线程在进入同步块时先判断对象头的 Mark Word 里是否存储着指向当前线程的偏向锁,如果存在就直接获取锁。
轻量级锁:当其他线程尝试竞争偏向锁时,锁升级为轻量级锁。线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 MarkWord 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,标识其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
重量级锁:锁在原地循环等待的时候,是会消耗 CPU 资源的。所以自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么等待锁的线程会不断的循环反而会消耗 CPU 资源。默认情况下锁自旋的次数是 10 次,可以使用 - XX:PreBlockSpin 参数来设置自旋锁等待的次数。10 次后如果还没获取锁,则升级为重量级锁。
发生死锁怎么办
一般发生死锁的原因:系统资源竞争 进程推进顺序非法
产生死锁的四个必要条件:
互斥条件:一个资源每次只能被一个线程使用
请求与保持条件:一个线程因请求资源而堵塞时,对自己获得的资源保持不放
不剥夺条件 进程已经获得的资源,在未使用完之前,不能被其他线程强行剥夺
循环等待条件 若干个线程形成一种头尾相接的循环等待资源关系
那么如何避免死锁呢?我们可以针对加锁顺序,让程序按照我们指定好顺序执行。还有加锁时限,也就是线程获取锁的时候加上一定的时限,超过这个时限,放弃请求并释放自己的锁。
CAS(Compare And Swap)
CAS,比较并替换。有三个操作数,内存地址 V 旧值 A 新值 B
CAS 执行操作的时候,只有当内存地址 V 的值和旧值 A 的值相等时,才将 V 修改为 B,否则什么都不做,是一个原子性操作
为什么是原子性的呢?
- 因为 CAS 是一种系统原语。系统原语属于操作系统用于范畴,由若干个指令组成。由于原语的执行必须是连续的,不允许被打断,所以具有原子性操作
synchronized 和 CAS 区别
- synchronized 加锁,同一时间只能有一个线程访问,一致性得到了保障,但是降低了并发性。而 CAS 用的是 do whilte,没有加锁,反复通过 CAS 比较,直到成功。既保证了一致性又提高了并发性。
CAS 三大缺点
循环时间长:
CAS 一般和死循环配合使用,如果 CAS 失败,会一直尝试,如果一直不成功,会给 CPU 带来很大开销
只能保证一个变量的原子操作:
对一个变量进行操作的时候,我们可以通过循环 CAS 来保证原子操作。
但是多个不可以保证操作的原子性。可以通过使用互斥锁来保证原子性或者使用 AtomicReference
ABA 问题:
假设我们现在要修改内存值 2 为 4,第一步我们查出来 A 的值,然后发现和旧值 A 是相等的。准备修改值为 4,就在这时候,来了一个操作,将 2 改为 4 又改为 2.CAS 会判断值没有变,修改完成。但其实这个值已经被变了
AtomicStampedReference 与 AtomicMarkableRederence 类
为了解决 ABA 问题。java 提供了以上 2 个类。
AtomicStampedReference 是利用版本戳的形式记录了每次改变以后的版本号
举个通俗点的例子,你倒了一杯水放桌子上,干了点别的事,然后同事把你水喝了又给你重新倒了一杯水,你回来看水还在,拿起来就喝,如果你不管水中间被人喝过,只关心水还在,这就是 ABA 问题。如果你是一个讲卫生讲文明的小伙子,不但关心水在不在,还要在你离开的时候水被人动过没有,因为你是程序员,所以就想起了放了张纸在旁边,写上初始值 0,别人喝水前麻烦先做个累加才能喝水。这就是 AtomicStampedReference 的解决方案。
AQS
AbstractQueuedSynchronizer (AQS) 是 JDK 实现并发编程的核心,ReentrantLock 就是基于 AQS 实现的。AQS 是一个多线程同步器,是 JUC 包多个组件的底层实现。有三个特点。
第一点,AQS 提供了 2 种锁机制 (共享锁 / 排他锁)。排他锁就是重量级锁。共享锁也是读锁。表示资源可以同时被多个线程锁读取。
第二点,AQS 提供了可重入锁。也就是获取到锁资源的线程可以再次获取锁。
第三点,锁竞争的公平性和非公平性。也就是公平锁和非公平锁。AQS 内部有一个核心变量 state (volatle 修饰)。表示锁的状态。 初始状态下是 0。
如果现在有一个线程调用了 lock 方法进行加锁。 State 会被设置为 1,同时 AQS 内部还有一个关键变量,用来记录加锁的是哪个线程。默认值 null。加锁的过程是将 state 的值从 0 变为 1。同时将加锁线程设置为自己。如果没有人加过锁的话,到这里就加锁成功了。
reentrantLock 是一个可重入锁,也就是对同一个锁加多次。每一次重入加锁,会先判断当前线程是不是自己,是自己的话进行重入加锁,将 state 的值累加 1.
如果说线程 A 加锁成功。线程 B 进来加锁会发生什么?首先线程 B 会查看锁的状态 state,发现是 1,接着去看加锁线程,发现也不是自己。那么加锁就会失败。但是并没有结束。线程 B 会将自己放入队列中等待,使用的是 CAS 机制。等待线程 A 释放锁,自己就可以重新尝试加锁了。这个地方叫锁池。 如果线程 A 执行完了自己的业务逻辑,就会释放锁。首先将 state 的值 - 1. 如果不是 0 的话,说明自己还持有锁。继续执行逻辑。直到减到 0。这个时候真正释放锁。同时加锁线程设置为 null。这个时候线程 B 就尝试进行加锁。如果没有其他线程进行竞争。线程 B 当然可以加锁成功。将 state 变为 1 线程变量设置为自己。将自己从锁池中移除。加锁成功
分布式锁
数据库分布式锁:也就是锁表,我们可以创建一个专门用来记录锁的状态一张表。获取锁插入一条数据。逻辑执行完成删除这条数据。通过这条数据来获取锁
悲观锁的实现,需要先把 MySQL 的自动提交给关闭了。set autocommit=0 就可以。然后查询语句可以后面跟 for update 来锁定。被锁定的数据必须等待本次事务执行提交之后才可以被操作。保证了数据不被其他事务修改。但是我们必须要基于索引来实现。也就是说必须走索引。如果不走索引的话,会把查询时扫描到的其他行全部上锁,极大的影响了效率。
乐观锁的实现,我们可以给数据初始化的时候指定一个版本号,每次对数据操作都对版本号 + 1 并判断当前数据是不是该数据的最新版本号。比如基于乐观锁的 SQL 语句就是:
redis的分布式锁
redisson 其实就是控制分布式系统不同进程共同访问共享资源的一种锁的实现 保证一致性,一般使用场景有秒杀下单,抢红包这种高并发的场景。
分布式锁具有的特征:
互斥性:任意时刻,只有一个客户端可以持有锁
锁超时释放:设置超时时间,可以防止不必要的资源浪费,同时防止死锁
可重入性 一个线程如果获取了锁之后,可以再次对其请求加锁
高性能和高可用:加锁和解锁开销尽可能低,同时保证高可用 避免分布式锁失效
安全性:锁只能被持有的客户端删除,不能被其他客户端删除
通过 setnx 实现。如果加锁成功返回 1
redis红锁
假设我们有 N 个 redis 主节点,这些节点是完全独立的。我们不使用复制或任何其他隐式卸掉系统。为了取到锁。客户端要做一些操作。
- 获取当前时间,以毫秒为单位
- 依次尝试从 5 个实例,使用相同 key 和随机值获取锁。获取锁的尝试时间要远远小于锁的超时时间。访问某个 masterDown 了。
- 只要大多数节点获取了锁,而且总获取时间小于锁的超时时间的情况下,认为锁获取成功
- 如果锁获取成功 锁的超时时间就是最初的锁超时时间减去获取锁的总耗时时间
- 如果锁获取失败 不管是因为获取成功的数目没有过半,还是因为获取锁的耗时时间超过了锁的释放时间,都会将已经设置了 key 的 master 上的 key 删除