Java锁
锁分类
- 根据处理
- 乐观锁:认为一定不会被其他线程修改,如果修改了再去做相应措施
- 版本号机制或CAS
- 悲观锁:认为一定会被其他线程修改,所以早早上锁:
- Synchronized
- reentrantlock等
- 乐观锁:认为一定不会被其他线程修改,如果修改了再去做相应措施
- 根据底层:
- 基于AQS:Reentrantlock,Semaphore,CountDownLatck,CyclicBarrieer
- 基于C++的ObjectMonitor-基于操作系统的MutexLock:Synchronized
详细谈谈Synchronized?
先从ObjectMonitor谈起
这是由C++实现的,每个对象都存在一个Monitor与之关联,所以又叫对象锁
先来讲讲这个对象的构造布局是怎样的?
一个Java对象由:对象头,实例数据,对齐填充构成
对象头:
MarkWord(很重要)
标志位 01 :对象哈希码,对象分代年龄,
标志位 00:指向锁记录的指针,
标志位10:指向重量级锁的指针
标志位 11:空:
标志位01 :偏向线程ID
就是标志位为几就记录几的信息
类元信息:通过这个指针来确定这个对象是哪个类的实例
实例数据
存放类的属性数据信息,还有父类的属性信息
对齐填充 保证为8个字节的倍数,为啥?因为当前计算机都是64位处理器,因此一次能处理64位的指令也就是8个字节的数据,因此可以处理更快
Synchronized如何依赖这个ObjectMonitor
锁升级是怎样的?
无锁-偏向锁-轻量级锁-重量级锁
无锁:一个对象被实例化后,如果还没被任何线程竞争就会是无锁状态,此时锁标志位为01
偏向锁:当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问便会自动获得锁 这样可以避免频繁的线程切换
偏向锁的持有过程:在锁第一次被拥有的时候,记录下偏向线程ID;
这样就可以直接检查锁的MarkWord里放的是不是自己的线程ID
相等:偏向锁偏向于当前线程,直接进入同步
不相等,发生了竞争,此时会尝试使用CAS自旋来替换MarkWord里的线程ID为新线程ID偏向锁只有碰到竞争时,才会释放锁,不然不会主动释放锁
竞争成功:MarkWord的线程ID为新线程ID;仍然为偏向锁
竞争失败:升级为轻量级锁,保证线程间公平竞争
>开启偏向锁默认会有4s延时,如果程序在激活前结束,就进入轻量级锁,
>关于偏向锁的撤销 不是说等竞争出现才会释放锁吗,但是还有一个条件:需要等待全局安全点 (即 该时间点上没有字节码正在执行)
>一旦有竞争就会升级为轻量级锁
>如果持有锁的线程已经执行完,就会释放偏向锁,让其他锁获得偏向锁
>Java 15以后取消并逐步废除偏向锁
轻量级锁: 其本质就是自旋锁CAS,特点不存在锁竞争太过激烈的情况,没有线程阻塞
如何获取?为每个线程在当前栈帧中创建用于存储锁记录的空间(DisplacedMarkWord)
如果一个线程获得锁时发现是轻量级锁,会把锁的MarkWord复制到自己的DisplacedMarkWord里面,
具体如何操作?尝试用CAS将锁的MarkWord替换为指向锁记录的指针(DisplacedMarkWord)
成功,线程获得锁
失败,MarkWord被替换成其他线程的锁记录(说明与其他线程竞争锁,当前线程就会用CAS自旋来获取锁)
锁如何释放?使用CAS操作将DisplacedMarkWord内容复制回锁的MarkWord里
如果没有发生竞争,那么这个复制操作就会成功
如果有其他线程因为自旋多次,就会导致轻量级锁升级成重量级锁,同时CAS操作失败,会释放锁并唤醒被阻塞的线程
那么多少次自旋会升级为重量级锁呢?
在Java6之后,采取的是自适应自旋锁,大致原理为:线程如果自旋成功了,那下次自旋的最大次数就会增加,
因为你既然这次上锁成功了,那么这次我加大自旋的次数肯定也会成功的,(所以是根据同一个锁上次自旋的时间和根据拥有线程的状态来决定的)
轻量级锁与偏向锁的区别是啥?
争夺轻量级锁失败,会自旋抢占锁,每次退出同步块都需要释放锁;
偏向锁是在竞争发生时才释放锁
重量级锁: 重量级锁是基于进入和退出Monitor对象实现的
主要就是基于OBjectMonitor实现的
关于ObjectMonitor(也叫管程)
进程可以通过调用冠城来实现进程级别的并发控制
每个对象都有一个ObjectMonitor,每个被锁住的对象都和Monitor关联
它包含有:
owner: 当前持有这个ObjectMonitor对象的线程
WaitSet:存放处于Wait状态的线程队列
EntrySet:存放处于等待锁Block状态的线程队列
recursions: 锁的重入次数
count: 用来记录这个线程获取锁的次数
ObjectMonitor的加锁过程
比如说是加载代码块上哈:
MonitorEnter加锁-判断count是否为0
-为0:说明当前锁对象没有被占用-执行加锁操作
-为1说明当前锁对象被占用
-再判断当前持有者是不是自己
是,当前锁对象是自己,则加锁
不是,则说明当前锁对象不是自己,则当前线程进入阻塞队列:EntrySet
加锁操作:
count+1;
owner指向当前线程;
recursion+1
MonitorExit:解锁
count-1;
recursion-1
判断recursion==0
是说明从多层Synchronized 中全部退出了,则此时owner置空代表这个线程完全释放了锁
否,说明还没从多层Synchronized中完全退出,则此时Owner保持不变
那如果是针对方法修饰的话,则用一个ACC_SYNCHRONIZED 标识,这个标识指明了该方法是一个同步方法
整体升级过程:先自旋,不行再阻塞
锁升级和哈希Code的关系
他们都是在一个Markword里面的,不同的值对应不同的锁状态,当为00时就是哈希码了,那么当你在无锁状态下调用hashcode方法就会将其保存在markword中
偏向锁:一个对象的哈希码已经被调用过一次,就不能被设置为偏向锁了
为啥??? 如果可以设置,就意味着这个哈希码会被当前偏向线程Id覆盖,那我再次调用hashcode方法就会得到一个对象前后不同的哈希码
获取偏向锁时,会用ThreadID 和epoch值覆盖 这个哈希码所在位置
注意:获取锁之前,计算了哈希值,会变成轻量级锁;占有锁时调用哈希码会膨胀为重量级锁
轻量级锁:会创建一个锁记录空间用于存储锁对象的MarkWord拷贝,这个拷贝可以包含哈希码, 因此可以和哈希码并存,
释放后会将所有信息都写回到对象头
重量级锁:代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的MarkWord
释放后会将所有信息都写到对象头
锁的优缺点比较?
偏向锁:加锁和解锁不需要额外消耗;但如果存在竞争会带来额外的锁撤销消耗,适用于只有一个线程访问同步块场景
轻量级锁:竞争的线程不会阻塞,但一直得不到锁也会消耗CPU,适用于同步块执行速度非常快的场景
重量级锁:线程竞争不使用自旋,不会消耗CPU,但线程阻塞响应时间缓慢,同步块执行速度较快
为啥Synchronized是重量级锁呢?
因为它是基于MutexLock实现的,Java的线程是映射到操作系统的原生线程之上的,
Java基础
如果要挂起或唤醒一个线程,都需要操作系统帮忙完成
那你加锁,就涉及到了线程之间切换,那怎么实现线程切换?
就需要从用户态转换到内核态,然后再实现线程切换,时间成本相对比较高,这就是为什么它“重”
谈谈用户态和内核态是如何切换的?
先说说什么时候会切换吧:
系统调用、产生异常、外设发生中断等事件时,会发生用户态和内核态之间的切换
那怎么切换?
将用户栈顶保存在当前CPU的一个PER_CPU变量里,完成用户栈的保存,
将内核栈的地址存放到当前指针寄存器中, 此时栈顶寄存器指向就是内核栈的栈顶
完成切换
如何使用Synchronized?
修饰实例方法:进入同步代码块前要获得当前对象实例的锁
修饰代码块:synchronized(this)
表示进入同步代码块前要先获得给定对象的锁;Synchronized(类.class)
表名要获取给定Class的锁才能进入代码块
修饰静态方法:进入同步代码块前要获得当前类的锁
提问:构造方法可以用Synchronized修饰吗?
构造方法不能用Synchronized关键字修饰,因为构造方法本身就是线程安全的
谈谈这个AQS
什么是AQS?
核心思想就是
如果被请求的共享资源空闲,就将当前请求资源的线程设置有有效线程,锁定共享资源
如果被占用,就需要一个阻塞等待唤醒机制来保证锁分配
这个机制就是CLH队列的变体实现的,将暂时获取不到锁的线程加入队列中进行park
主要就是将等待的线程封装成一个Node节点,通过CAS抢锁,LockSupport.park阻塞,来进行一个等待,(这里就相当于实现了一个EntrySet)
通过维护一个State变量状态来判断这个锁的持有者(这里就相当于一个count/owner)
整体来说,就是一个仿照Synchronized的锁,有entrySet,有owner,整个锁住的实现通过park,唤醒通过unpark方法来实现
什么是CLH队列?
CLH就是一个单向链表,
AQS的队列就是一个虚拟双向队列,同时还是先进先出的
每一个被阻塞的队列都会被封装成一个Node类
详细谈谈AQS的一个实现
基于Reentrantlock
- 一个volatile修饰的int类型的成员变量state
- 0 可以获取锁
- 1 要等
- Node类:
- 有一个waitstatus 表明当前节点再队列中的状态(volatile修饰)
- thread 这个节点的线程
- prev 前驱节点
- predecessor 返回前驱系欸但
- nextWaiter 指向下一个处于等待状态的节点
- next 后继节点
加锁实现:
非公平的lock:
通过CompareAndSetState (CAS) 来加锁,然后将当前持有线程设置为自己;
(与公平的区别,
公平锁多了个hasQueuedPredecessors方法,
这个方法如果返回true,代表有别的线程排在当前这个线程前面,就不会往下走,不让当前线程去抢占,
如果返回false,才会用CAS去抢占锁)
抢占失败 进入等待队列,走addWaiter
来创建一个当前线程的节点,模式为Exclusive
模式,创建完节点,其中还有一个哨兵节点用来占位的,这个哨兵节点在最前面,说明真正等待的节点是从第二个节点开始的,
创建完就入队,入队前不死心还会再抢占一次锁(针对你是第一个等待的线程的情况),最后还是失败了就会完全死心,调用LockSupport.park()方法,此时线程就会在队列中稳稳当当地自旋,(这个方法之后是返回Thread.interrupted清除中断位)
那如果不是第一个等待的线程,你就直接自旋等吧!
解锁操作:
当前线程释放锁,会调用release方法, 此时就会将独占线程设为null,将状态设置为0-没被占用
然后判断头节点是不是空,如果不是空,判断头节点下一个节点是不是空,不空unpark唤醒下一个节点的线程,此时那么parkandcheckInterrupt返回false(之前都没有返回值而是阻塞在那)
醒来之后,因为你是非公平锁所以要抢占锁,仍然是CAS抢占,抢占成功,就把独占线程设为自己,并设置状态State为1;
队列里相应要做这些操作:
将头节点设置为当前获得锁的那个节点
并且这个节点线程设为null,它成为了哨兵节点
然后(因为释放锁的时候是从哨兵节点开始的,各种判断也是需要从哨兵节点来判断第一个等待的线程) 之前的节点直接废除
取消操作:
将取消的节点设为null,并将这个节点的waitstaute设为canceled
然后在当前节点往前找:不取消的节点
并将当前节点的前置节点设置为不取消的节点,
因为不是队尾节点,就会把当前节点刚找的前面的节点 它的下一个节点 改为这个节点的下一个节点,然后把当前取消节点 直接断开所有连接,
如果是队尾节点取消,就把它的前置节点设为尾节点,将尾节点的前置节点的下一个节点设为空,断开与尾节点的连接
聊聊这个LockSupport吧
你看AQS里的阻塞和唤醒都用了LockSupport,这里唤醒的是“entryset”里的线程
具体怎么做的?
park的底层实现?
https://juejin.cn/post/7036199683080847397
https://xie.infoq.cn/article/610a1a56ad372645e1c5036dc
底层调用了UNSAFE类的park方法,这是个native方法
这里有几个判断:
刚开始:
利用CAS设置_counter变量为0,且返回旧值;之前大于0就说明允许访问,不阻塞,返回;
创建ThreadBlockInVM对象,底层调用了pthread_mutex_trylock
获取线程互斥锁,如果没有获取到锁,就返回(就是!=0)
如果获取不到锁,就创建一个等待对象
调用pthread_cond_wait
/pthread_cond_timedwait
进行等待,
判断_counter变量是否>0,是就置为0,说明没有线程在等待,然后释放锁,:
释放锁:pthread_mutex_unlock
进行释放_mutex锁
unpark的底层实现?
直接设置_counter为1,再 pthread_mutex_unlock(返回
如果_counter之前的值是0,则还要调用pthread_cond_signal唤醒在park中等待的线程
以上过程也体现了,一个unlock一次,只会有一个permit(counter=1),这里的counter就是permit,一开始如果说permit(之前的counter)>0就直接不阻塞,也就不设置counter为0,如果要阻塞就将这个counter置为0,
那为什么调用两次unpark,两次park最后还是park?
调用两次park即使你调用两次unpark也只会让counter为1,然后一次park调用不阻塞,因为之前counter=1>0,但此时CAS设置counter已经为0,下一次park就会阻塞, 因为之前的counter为0,所以还是阻塞
用LockSupport来实现了object类的notify和wait操作
如果
继续谈谈AQS的其他锁
https://zhuanlan.zhihu.com/p/504177575
CountDownLatch
让某个/某几个线程阻塞,等其他线程执行完再执行
两个核心方法:
CountDown 计数器的数字执行减1操作(同前面的释放锁操作)
只有state为1时,才会唤醒头节点的线程,还是调用unpark来唤醒
await方法表示让当前线程进入等待状态,直到count的值为0才继续执行(同前面的加锁操作)
AQS的state为count
Semaphore
实现最大可以多少个线程同时执行
维护一个关键变量permits
两个核心方法:
acquire CAS获取许可,每获取一个许可,许可(state)的数量就减1,为0时后面的线程进入阻塞状态
release(): 释放一个许可,每次释放一个许可,许可(state)就加1,唤醒一个等待的线程。
公平信号量和非公平信号量的区别就在于公平回去判断当前线程在不在队列的头部,不在则排队等候;非公平是不管在不在都直接CAS(抢)
谈谈CyclicBarrier
https://www.cnblogs.com/crazymakercircle/p/13906379.html#autoid-h2-4-1-0
实现让多个线程都停在一起,然后再开闸放
底层就是:
CyclicBarrier类的内部有一个计数器,每个线程在到达屏障点的时候都会调用await方法将自己阻塞,此时计数器会减1,当计数器减为0的时候所有因调用await方法而被阻塞的线程将被唤醒。
和CountDownLatch最大的不同就在于:
被记录的线程和被阻塞的线程是不是同一个,Countdown是被记录的线程不会阻塞,其他的线程阻塞;
CyclicBarrier是被记录的线程被阻塞
核心方法:
基于Reentrantlock+Condition
dowait 实现等待,此处用到了Reentrantlock
执行前会先lock.lock;
每等待一次就会将count-1,
然后判断是否为0,如果为0就会先去执行之前制定好的任务,执行完就将所有线程唤醒,重新开始下一轮的栅栏,
如果不为0,就需要等到,调用condion的awai方法来等待;
到最后还会用lock.unlock,
和Countdownlatch什么区别?
从dowait方法也可以看出,它是可以循环利用的
还有就是前面提到的线程是不是一致的
既然讲到了Condition,讲下Condition的机制吧?
Condtion 其实也是基于AQS的
他能实现针对性的唤醒,是作为一个多线程通信的工具类;
有两个最重要的方法:
await: 把当前线程阻塞挂起
signal:唤醒阻塞的线程
就类似于wait/notify
调用await方法,会使当前线程进入等待队列(Condition队列)(此处也要把线程封装成Node,放进队列中,这个队列和Reentrantlock不同,使单向链表)并释放锁,同时线程状态变为等待状态,
进入一个while循环:
然后调用LockSupport.park方法来阻塞线程;
然后就会停在这,
一直到线程醒来,判断:
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
如果通过signal唤醒的,就会结束循环,如果通过中断唤醒的就会继续阻塞
被唤醒之后就会尝试拿锁(acquireQyeyed
之后再清理Condition队列上的节点
如果线程被中断了,就会抛出异常/或什么都不做
释放锁FullRelease,这个方法能够实现释放一次,将所有的重入次数都归零
signal方法:就是将线程从Condition队列移到AQS队列中,Condition队列是等待,AQS是可以开始抢锁了;调用了LockSupport.unpark来进行唤醒
注意,线程被唤醒,不一定是调用了Unpark方法,还有可能调用了interrupt方法,这个方法会更新一个中断表示,并唤醒处于阻塞状态下的线程
//使用 cas 修改节点状态,如果还能修改成功,说明线程被中断时,signal 还没有被调用。
if (compareAndSetWaitStatus(node, Node.CONDITION, 0)) { enq(node); //如果 cas 成功,则把 node 添加到 AQS 队列 return true; }
这里就是在中断下的
如果在AQS队列(同步队列了,意味着它需要去竞争同步锁来获得执行程序执行权限
参考:
https://juejin.cn/post/6871976482726477838
既然前面讲到了中断,谈谈中断吧?
中断机制:仅仅只是将线程对象的中断表示设为true,(阻塞+interrupt方法能使线程停止,然后停止的方式是抛出异常)
它是用于中断因线程挂起的等待,调用interrupt方法后,线程会被唤醒,待下次cpu调度就会继续执行中断后的代码
具体是怎样的?
当一个线程调用interrupt时,会将该线程的中断标志设为true,被设中断标志的线程会继续正常运行,不受影响,
然后在这个基础上再进入阻塞状态:sleep,wait,join,就会抛出一个Interrupted异常,且中断标志被清除,重新设置为false,
之后就需要我们对中断进行处理,如果不捕获这个中断异常,线程就会异常终止(因为异常而终止);如果捕获了异常就可以继续往下走
线程阻塞地调用wait/join/sleep方法,线程的中断标志就会被清除,并且会捕获interruptedException,因此catch中需要再设置interrupt设置多一次中断标志位
https://www.51cto.com/article/694777.html
如何中断运行中的线程?
通过volatile变量实现
通过AtomicBoolean实现
通过interrupt实现
中断状态的设置
interrupted和isinterrupted方法的区别?
interrupt是用来设置中断标识为true
interrupted,静态方法,判断线程是否被中断并清除当前中断状态,中断状态会清0
isinterrupted 非静态方法,查询其他线程的中断状态且不会改变中断状态标识
任何抛出InterruptedException异常的方法都会将中断状态清0,
并且,一个线程的中断状态有可能会被其他线程调用中断来改变
谈谈乐观锁?
CAS是什么?
CAS操作包含三个操作数:
- 需要读写的内存位置(V)
- 进行比较的预期值(A)
- 拟写入的新值(B)
执行时,将内存位置的值与预期比较,匹配就将该位置的值更新为新值;不匹配就不做操作,一直自旋,多个线程操作只会有一个成功
它是JDK提供的非阻塞原子性的操作,是一条CPU的原子指令,不会造成数据不一致,且排他时间更短
像AtomicInteger 自增操作底层是调用了 UNSAFE类的compareAndswapInt来实现的CompareAndSet
这方法:
可以操作特定内存的值,所有方法都是native,就是可以直接调用操作系统底层资源执行相应任务 valueoffset = 内存中的偏移地址
再往底层走,它的保证原子性,就是因为用了一个原语级别的指令,不允许中断,也就是cmpxhg(x,addr,e)==e 来比较并交换,成功则返回期望值(true);如果是false,则返回内存值
但CAS会出现ABA问题
ABA是什么?
就是中间数据被修改了,虽然最后又被改回来了,但是存在一段时间数据是不一致的
因此可以用版本号/戳记流水来实现
(JAVA里可以用AtomicStampedReference (带有版本号的类来实现)
版本号机制来实现乐观锁,可行吗?
可行
在数据中增加一个字段version,表示该数据的版本号,
每当数据被修改,版本号加1。
当某个线程查询数据时,将该数据的版本号一起查出来;
当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。
参考:
https://www.cnblogs.com/kismetv/p/10787228.html
操作系统锁
互斥锁的实现?
禁止中断
禁止中断指令:禁止中断出现或禁止响应中断的指令
进入临界区前禁止中断(禁止其他线程中断我),离开前恢复中断,这样任何中断都不会发生,CPU不会被切换到其他线程
缺点:
效率低,屏蔽中断的指令执行起来比其他指令要慢
关中断,可能会导致某些中断信号丢失,比如读盘
只适用于单 CPU 的场景,其他 CPU 上运行的线程仍然可以访问临界资源,因为不同 CPU 有自己的时钟中断器
TSL指令
锁住内存总线,使得当一个进程使用内存时,另一个进程不能访问内存,即使是另一个 CPU 也不能访问
解决了多CPU情况下的禁止中断
TSL指令:就是将一个存储器字(存放在一个存储单元中的二进制代码组合)读到一个寄存器,然后在这个内存地址上存一个非0值,读数和写数是不可分割的,即该指令结束之前其他处理机都不能访问这个存储器字,执行这个指令的CPU将锁住内存总线来禁止其他CPU在本指令结束在之前访问内存
http://blog.chinaunix.net/uid-1878792-id-1976672.html
SpinLock自旋锁
用一个flag标志标识锁是否被占用,当flag = 0标识锁空闲,当一个线程成功将flag从0 变到1表示该线程获得锁,
线程将在 while
循环中尝试通过 TAS(Test And Set)等硬件原子指令获取锁。
还有CompareAndSwap也可以实现自旋锁
Mutex互斥锁
互斥锁需要操作系统的帮助。当一个线程访问其他线程持有的锁时,会被 OS 调度为阻塞状态(休眠),直到锁被释放后,再唤醒一个休眠的线程。
但会消耗CPU资源,每次线程切换都需要户态到内核态的切换(系统调用)、重新调度(移到阻塞队列)和上下文切换(线程切换),时间成本很高
自适应互斥锁
先执行 spinlock 操作,不断持续尝试获取锁;如果尝试多次还是获取不到,就执行 mutex 操作,让线程进入睡眠。
还有的叫法是两阶段锁(Two-Phase Lock)、混合锁(Hybrid Mutex)
学习自:
https://imageslr.com/2020/locks.html
总结得很好!
数据库锁
全局锁
锁整个数据库:
flush tables with read lock
执行后,整个数据库就处于只读状态了
- 对数据的增删改操作,比如 insert、delete、update等语句;
- 对表结构的更改操作,比如 alter table、drop table 等语句
如果要释放全局锁,就要执行
unlock tables
一般用于全局备份,
表级锁
表锁
表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作
释放表锁:
unlock tables
会话退出释放表锁
元数据锁
元数据锁(MDL)
我们不需要显式的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL
- 对一张表进行 CRUD 操作时,加的是 MDL 读锁;
- 对一张表做结构变更操作的时候,加的是 MDL 写锁
MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。
MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的
是因为申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作
意向锁
作用:用来快速判断某个表里是否有记录被加锁
为什么要有意向锁
加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。
就是需要遍历了
而有了意向锁,由于先加了表级意向独占锁,则在加独占表锁时,只需要
直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。
意向共享锁
意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些加共享锁(S 锁), 加共享锁前必须先取得该表的 IS 锁。
意向排他锁
意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。
意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)
意向锁之间是互相兼容的。
并且表锁和行锁都满足
读读共享
读写互斥
写写互斥
AUTO-INC锁
AUTO-INC 锁
表里的主键通常都会设置成自增的,这是通过对主键字段声明 AUTO_INCREMENT 属性实现的。
在插入数据时,可以不指定主键的值,数据库会自动给主键赋值递增的值,这主要是通过 AUTO-INC 锁实现的。
是特殊的表锁机制
在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉
行级锁
MySQL 中锁定粒度最小的一种锁,是针对索引字段加的锁,只针对当前操作的行记录进行加锁
记录锁(Record Lock) :也被称为记录锁,属于单个行记录上的锁
- 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容);
- 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)
间隙锁(Gap Lock) :锁定一个范围,不包括记录本身
只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读现象
间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的。
表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生
临键锁(Next-key Lock) :Record Lock+Gap Lock,锁定一个范围,包含记录本身。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。
next-key lock 是包含间隙锁+记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的。
能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成退化成记录锁或间隙锁
插入意向锁:它是一种特殊的间隙锁,属于行级别锁
一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)
如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。
插入意向锁是一种在真正执行插入操作之前设置的一种gap lock ,当多个不同的事务,同时往同一个索引的同一个间隙中插入数据的时候,它们互相之间无需等待
MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁)
插入意向锁的死锁问题:两边同时对同一范围进行插入操作,原本事务A对这个范围就有一把锁,事务A等待事务B释放插入意向锁,事务B等待事务A释放插入意向锁这样就形成了死锁
关键在于插入意向锁与gaplock/next-key lock都冲突,一个事务想要获取插入意向锁,如果有其他事务已经加了gap lock或 Next-key lock,则会阻塞。
如何解决上面的情况?
不用事务,(因为事务的隔离级别就是通过锁机制和多版本并发控制MVCC实现的)
事务隔离级别降低到MVCC实现的即读提交(其是通过MVCC来实现的)
「插入意向锁」锁住的就是一个点
参考:https://xiaolincoding.com/mysql/lock/mysql_lock.html#%E8%A1%A8%E9%94%81
https://blog.51cto.com/u_14286115/3906999
分布式锁
分布式锁就是控制分布式系统不同进程共同访问共享资源的一种锁
- 基于数据库实现分布式锁
- 基于Redis实现分布式锁
基于数据库实现的分布式锁
悲观锁
就是利用forupdate锁来实现的(forupdate 行级锁,也就是排他锁,不允许其他线程对这个记录进行更新操作)
先决条件:在事务的基础上,利用for update来加锁,然后这条记录被加上了排他锁,那么获得这个排他锁的线程就获得了分布式锁
resourceLock = 'select * from t_resource_lock where key_resource ='#{keySource}' for update';
释放锁就通过提交事务来实现:connection.commit()
乐观锁
版本号!加个version字段,每次更新都连带将版本号查出来,要求版本号是预期的版本号才能执行更新,如果不是预期的号,就是被别人并发修改过,需要继续重试
参考:
https://segmentfault.com/a/1190000023045815
基于Redis实现的分布式锁
主要是SetNX 指定的key不存在为key设置指定的值,命令设置成功返回1,设置失败返回0
一般就是需要设置过期时间 名字
set key value(唯一值) ex(过期时间) nx(不存在才执行操作)
判断锁和释放锁 一般用LUA脚本来实现保证原子性
加锁就是setnx,释放锁就是del(key)
Redisson 实现分布式锁
其底层是就是调用Rlock 的lock unlock来实现加锁解锁,
调用了一个Lua脚本来创建一个Rlock,如果要加的锁那个锁key不存在,就加锁,通过hincrby命令设置一个hash结构(用于为哈希表中的字段值加上指定增量值,如果哈希表的key不存在,就会先创建再执行;结构为 锁的name,锁持有者ID,value(加锁次数)),
当其他客户端进程想获取锁时,如果获取失败会使用进程Id通过redis的channel订阅锁释放时间,如果等待过程一直没等到锁释放,超过最大等待时间就会获取锁失败,
如果等到了锁释放事件通知就会开始进入一个不断重试获取锁的循环
每次都会尝试获取锁以及得到锁的剩余存活时间,如果重试中拿到了锁,则直接返回,
如果没抢到,就会等待,这里利用了Semaphore来阻塞这个线程,当锁被释放并发布释放锁的消息就会调用信号量的release来通知被阻塞的线程获取锁
获取到锁会返回锁的剩余存活时间
Redisson有一个看门狗机制
底层就是利用一个守护线程,
将持有所的线程放到一个
RedissonLock.EXPIRATION_RENEWAL_MAP
里面,然后每隔 10 秒(internalLockLeaseTime / 3)
检查一下,如果客户端 1 还持有锁 key
(判断客户端是否还持有 key,其实就是遍历EXPIRATION_RENEWAL_MAP
里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。
每隔10秒延长到30s~
自定义时间,看门狗机制就会失效~
那redisson如何实现可重入锁呢?
还是Lua脚本直接对对应的锁+1,
释放锁就将对应的锁的value值递减为0然后删除,并向channel里发送锁释放的信息,
取消掉看门狗机制,也就是将**RedissonLock.EXPIRATION_RENEWAL_MAP
里面的线程 id 删除**,
并且 cancel 掉 Netty 的那个定时任务线程
这个的缺点就是
可能会存在单Master模式下,Master宕机,所有客户端都获取不到锁,主从同步会是异步进行的,因此就会出现线程1设置完锁,Master没了,slave上位,因为异步复制,slave现在还没有线程1的锁,可能还没同步过去,然后线程2刚好过来加锁,加相同的锁加成功了,此时一把锁就两个线程都有了
所以可以采用红锁
也就是客户端挨个和多个节点申请加锁,超过半数以上的节点加锁成功才算加锁成功
但同时,还需要判断加锁的时间小于这个锁的存活时间
参考:
https://www.51cto.com/article/705985.html
https://zhuanlan.zhihu.com/p/135864820
https://www.cnblogs.com/mushishi/p/14959933.html
https://segmentfault.com/a/1190000038988087
还有很多知识点还没学习到,会持续更新…