- 在操作系统里面,也会遇到什么信号量、互斥量,然后说利用互斥量、信号量可以实现锁的功能,而操作系统提供的原语有又mutex锁
- 在学习数据库的时候,什么表锁、行锁、读锁、写锁、排它锁、意向锁、meta锁等等,各种各样的锁的概念蜂拥而至
- 在学习java的时候,我们会学习到synchronize关键字、jdk里的Lock接口,然后有各种各样的Lock的实现;然后在聊synchronize的时候又会又各种锁的概念出来:自旋锁、偏向锁、轻量级锁、重量级锁。
- 在微服务架构下,大家又会遇到分布式锁,而分布式锁的实现有依赖于redis的、有依赖于zk的。然后又会聊到锁的锁的可重入、锁不能释放、错误释放等等等问题
- 还有其他很多概念,比如乐观锁、悲观锁、读写锁、可冲入锁、不可冲入锁等等
那么到底什么是锁?这些各种各各样的锁到底是什么意思?下面我们就来聊聊锁的本质,以及不同地方的各种各样的锁到底是什么含义?
首先,锁的本质,其实就是一个标记,然后约定好:所有线程在进入临界区的时候,先去检查锁的状态,然后再根据锁的状态来决定当前线程该怎么办?
所以上面所有的锁的概念,都无外乎是根据这两个概念来对锁进行了分类:
- 锁标志:锁标志到底放在什么地方。mysql中的行锁、表锁无非就是锁标志是放在行维度还是表维度、而java中,不管是synchronize还是Lock接口,锁标志都是放在对象上的,所以从这个角度来讲,java的锁就是对象锁。
- 当遇到锁冲突的时候该怎么办?
- 如果当前线程只要遇到锁被占用,就直接进入到阻塞状态。那这个锁就是排它锁
- 如果当前线程只要遇到锁被占用,要看当前操作是什么,来决定到底是挂起、还是进入临界区,那这就是共享锁。进一步,如果按照读写来明确这个操作,那就是读锁、写锁、读写锁。
- 如果当前线程只要遇到锁被占用,不是立马进入到阻塞状态,而是不断去轮询一下锁的状态。这就是自旋锁。
- 如果当前线程只要遇到锁被占用,然后再看下将这个锁改成占用状态的是不是自己。如果是自己,那么就继续进入临界区执行;如果不是,再根据操作等来决定下一步动作。这就是可重入/不可重入锁。
- 如果当前线程只要遇到锁被占用,且自己也已经占用了一个锁标志,那么正好占用了当前线程站在检查的那个锁的标志的那个线程在检查当前线程占用的锁,就会出现线程的循环等待,那就是死锁
其他的一些锁相关的概念,很多其实都是一些优化手段,只是可能叫顺口了,也叫成了xxx锁,包括自旋锁其实也是一个优化动作。所以这里就不用去过多的care这些概念了。
所以简单粗暴总结到依据,锁的本质就是:
- 在某个地方放一个标志。
- 所有线程/进程在进入临界区的时候都去检查下这个标志。
- 而进入临界区加锁就是修改这个标志
- 退出临界区解锁也是清除这个标志。
java的synchronize锁
同样的,还是以两个方面来看java的synchronize的实现。
锁标志存放位置
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位存放的内容是不一样的
而当标志位=00、01、10的时候,高30位存储的就都是锁标志相关的信息。具体如下表格:
我们会发现,synchronize的锁标记,分了好多中情况,并不是只有一个占用 or 释放那么简单。通过低位的标志位区分了:无锁、偏向锁、轻量级锁、重量级锁,这写到底意味这什么?
遇到锁冲突怎么办
不管怎么样,我们已经明确了synchronize使用的锁标志是放在对象头的,那么第一个问题就明确了:锁的标志存放在哪里的
所以要了解无锁、偏向锁、轻量级锁、重量级锁这些概念是用来干啥的,区别是啥? 那就从第二个方面入手来分析:当前线程进入临界区的时候,检查锁标志的时候,发现锁标志占用,该怎么办?
如下这种图,就是synchronize执行过程中,加锁过程的大概流程:
其实我们会发现,这几个锁的区别无外乎就在于遇到了锁占用,当前线程该怎么办:
- 如果发现锁被占用,但是标志低2位是01,第3位是1,说明已经有线程获得过锁。这个时候,当前线程就CAS去改变搞30位存放的线程id,尝试把它改成自己:
- 如果成功了,那就变成自己获得锁。那么就去执行临界区代码。
- 如果执行失败了,说明原本持有锁的线程,还没有退出临界区。当前线程就不能进去临界区。所以这个时候咋办?当前线程就将对象头的低两位变成00,然后并且高30位的保存好原本获得偏向锁的线程信息(即让原来获得偏向锁的线程依然保持获得锁)。然后当前线程开启自旋CAS去修改搞30位的信息,试图将这些信息改成自己(即自旋CAS获得轻量级锁)
- 如果在最大自旋次数前,CAS成功了,那么说明原本获得锁的那个线程离开临界区了(因为离开临界区会来修改锁的标志),那么当前线程就直接进入临界区执行。
- 如果已经自旋到了最大次数限制了,CAS还没有成功。那么当前线程就将低2位的标志表变成10,高30依然保持原本获得锁的线程信息(即让原来获得锁的线程依然保持获得锁)。让后将自己挂起。
- 如果发现锁被占用,但是低两位是00,那就开始自旋CAS修改锁标志
- 如果发现锁被占用,但是低两位是10,那就直接将自己挂起。
为啥会搞这么复杂,其实java的第一个版本没这么复杂,简单粗暴理解即使,锁标志只有一个占用 or 空闲,当前线程遇到空闲,那么就将锁标志变成占用,自己进入临界区;当前线程遇到锁占用,就将自己挂起,进入这个锁的阻塞队列,状态变成阻塞状态,坐等原本占有所的线程退出临界区修改了锁标志后,唤醒,然后自己被唤醒后,就去判断锁标志。
可以发现,只要是发现锁占用,就会挂起当前线程、然后只要是离开临界区,就唤醒阻塞在这个锁的阻塞队列上的线程。而这些操作成本是比较大的。线程状态线程状态
- 挂起线程,就会涉及用户态内核态的切换。我们知道cpu的这种航上下文切换是有成本的。
- 唤醒线程,又会涉及用户态和内核态的来回切换。
- 离开临界区唤醒所有线程,又会出现惊群问题。
那咋办呢,各位大佬经过一番研究统计后发现,在绝大部分使用锁的场景中,锁冲突并不严重,而且哪怕是遇到了锁冲突,占有锁的线程会很快释放。所以,那就想一些办法来避免挂起-唤醒的这一中操作,从而提高synchronize的效率。偏向锁、轻量级锁、自旋锁就应运而生了,
- 锁占用不是很快会释放么,那我遇到锁冲突了,我不是直接将自己挂起,我是空转等一会(具体实现其实就是不断CAS,因为cpu这段时间都在执行CAS,没有执行业务代码,所以认为是空转)。只要锁很快被释放,那当前线程就不用挂起自己啦,那么原本占有锁离开临界区的时候,也只需要修改锁标志,就不用唤醒所有线程了,那是不是自然就避免了挂起-唤醒那一堆成本呢?
到底什么情况才是自旋,而不是挂起呢?那就在标志上给个特殊的标记,只不过对外表达的时候,我们将锁标记=00的这种情况,称之为轻量级锁而已。
- 另外就是锁冲突概率不大么,那我就假设来竞争这个锁的线程是同一个线程,每次都优先将锁分配给同一个线程。那就出现了偏向锁。同样的,到底什么情况是执行偏向,而不是自旋、也不是挂起自己呢?所以也要给个特殊标记。
ps:我个人觉得偏向锁有点极端了,优化效果在实际生产中会不太理想,更不可接受的是,撤销偏向锁会STW,那就意味着业务耗时抖动。从jvm参数上,也可以看出,java发明这个功能的人也是不自信的,jvm参数中,是有不使用偏向锁的参数的,但是没有不是使用轻量级锁的参数吧。
所以简单粗暴点的理解,就是遇到偏向锁、轻量级锁、自旋锁冲突的时候,不是直接调用OS的系统调用,让当前线程进入阻塞的状态。所以其实就是synchronize实现时候的几个if分支罢了。
ps:实际实现的时候,锁的标志信息不一定完全都放在对象头,上面的表格也会发现,有些情况其实对象头里放的其实是个指针,这个指针指向的位置放了跟过的的关于锁的信息(比如可重入信息等),这个指针指向的位置,其实就是运行时栈。
jdk中的Lock
以比较常用的两类锁:可重入锁ReentrantLock和ReentrantReadWriteLock为例来说明:
同样的,我们还是从两方面来来说明会锁的实现:
- 锁标志存哪儿。在jdk中有一个锁实现的共有抽象父类,那就是AbstractQueuedSynchronizer,jdk中所有的锁实现都会依赖于这个抽象父类,这就是很多博客中锁说大名鼎鼎的AQS。这个类里就是用来存储锁标志的。比如AQS#state字段,就是来标记锁是否别占用的。其父类AbstractOwnableSynchronizer#exclusiveOwnerThread就是用来标记当前锁被哪个线程占用的,从而实现可重入。
- 当线线程在进入临界区时,遇到锁冲突怎么办? 使用Lock接口的时候,lock#lock()和lock#unlock()包围起来的就是临界区。所以任何一个线程要进入临界区都先要执行lock#lock()也是在这个方法里去检查锁标志的。
当锁被占用(AQS#state不为0),那么当前线程就会首先使用CAS去修改state值(即自旋获得锁),这个过程是和synchronize的轻量级锁的过程是一模一样的。无非就是synchronize的CAS是编辑器植入的,而Lock#lock()的CAS是用的UnSafe包装的CAS指令。
当CAS达到一定次数的时候(spinForTimeoutThreshold),就是使用LockSupport#park()将当前线程挂起。其实就是升级到重量级锁了。
这里也可以看到,和synchronize相比,jdk的实现,其实已经没有偏向锁了。只有轻量级锁和重量级锁
所以不管是synchronize还是Lock,其实锁标志 都是存放在对象上的,所以我们可以认为就是对象锁。所以来看下,如下这些情况锁标志都是放在哪儿的
class TestLock{
public synchronized void doSomeThing(){
// 业务逻辑
}
}
class TestLock{
Object lock = new Object();
public synchronized void doSomeThing(){
synchronized(lock){
// 业务逻辑
}
}
}
class TestLock{
public void doSomeThing(){
synchronized(TestLock.class){
// 业务逻辑
}
}
class TestLock {
ReentrantLock lock = new ReentrantLock();
public void doSomeThing() {
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
}
}
答案依次是:第一个是在this对象的对象头上;第二个是在Object lock这个指定对象的对象头上;第三个其实就是TestLock这个类加载到内存后为其创建的Class对象的对象头上;第四个当然就是ASQ上。
所以不管使用哪种方式,一定注意,锁防护的临界区,一定要用同一把锁(即锁标志是同一个对象上的)
ps:经典面试题,synchronize和Lock的区别。
其实以前老有人喜欢这么问,其本质还是在于synchronize以前(好像是jdk1.5以前)只有重量级锁,效率不高,所以拿这个作为一个点来问。
但是到了jdk1.8,其实synchronize已经做了很多优化了,其实效率是很高的了,所以这个时候又有人说了,区别在于灵活性,Lock接口更领过。但是synchronize也不是只能修饰方法,还是可以修饰代码块,且这个时候锁标志是放在指定对象的对象头上,其实这也比较灵活了,多个对象的方法使用同一把锁防护共享资源的访问,synchronize也是可以实现的。反而Lock,少补流程就有可能出错的,可能导致锁没有被释放的,所以使用Lock的时候一定要吧unlock放到finally块中,而synchronize却是编译器完成了这个事情,用户不用关系锁释放的事情的。
所以,synchronize唯一做不到的是:在A处加锁、在B处释放,而A、B两处是不同的代码块,但想想真实世界中,真有这么奇葩的使用方式么?
实际上,在绝大部分场景中,synchronize和lock已经没有那么大的区别的。
- synchronize默认开启了偏向锁,偏向锁在有所的竞争的时候,其实会影响性能;而lock是没有偏向锁的。
- 对于条件变量的使用上,Lock其实是更灵活,可以支持多个条件变量。而条变量的使用是可以减少惊群效应的。
- synchronize存在所谓的锁升级问题,一旦锁升级后,就不能降级了。但是Lock是不存在的这种升级的。但并不是说lock就更牛逼,个人觉得只是设计理念不一样而已,面对不同的场景就各有优劣。
- 面对突发的严重冲突,然后又立即缓解。因为synchronize可能已经升级成重量级锁了,那后续锁都是重量级锁了。但lock任何时候获取锁的过程都一样,所以后面还是有CAS自旋的过程的。
- 如果持续冲突,那么这个时候重量级锁挂起线程反而是最优的,而Lock就会存在大量的毫无意义的CAS自旋。
曾经也是硬总计了一些区别,参考:lock和synchronize对比
mysql中的锁
mysql中的锁其实非常复杂,但是我们依然按照这两方面来认识mysql中各种锁。
首先还是看锁标志存放在哪儿?
- 如果锁的标志是存在库级别的,那就是库锁。
- 如果锁的标志是存在表级别的,那就是表锁。
- 如果锁的标志是存在行级别的,那就是行锁。
- 如果锁的标志是在行与行的间隙,那就是间隙锁(实际上msyql中不会有间隙这个数据结构的,但可以这么去理解)
- 如果锁的标志是存在shema级别的,那就是meta锁。
然后从遇到锁冲突改怎么办?
- 只要遇到锁冲突,那就挂起当前线程,这就是排它锁(或者叫写锁)。
- 遇到锁冲突,还有根据当前的操作来判断,是否挂起当前线程。就是共享锁、或者叫读锁
然后两者一结合,解释mysql中的各种锁,比如meta读锁、meta写锁、行上的读锁、行上年的写锁的能等,之所以有这么多所,无非就是mysql为了在不同场景去提交读写效率而已。
比如meta锁,每个写操作都会给表加meta读锁、但是meta读锁不会排斥写,但是修改表语句会给表加上meta写锁,那meta写锁就会阻塞所有的写操作了,这就是online ddl一直致力解决的问题,让修改表语句不要阻塞太多dml语句。
随着后续的管理控制粒度更细,可能还会冒出来更多的锁,但是它的实现,逃不开这两方面的。
总结:
所以,万变不离其宗,不管是哪个系统对锁的实现,我们都从这两方面去学习,会发现,都逃不过这两个方面,其实说白了,锁的本质就是一个标志,然后进入临界区的时候都去检查这标志,然后进去/退出临界区的时候修改标志,达到临界区同一时刻只会有一个线程来执行。