当前位置: 首页 > 科技观察

锁是一种怎样的存在?

时间:2023-03-22 17:31:49 科技观察

锁是一种怎样的存在?随着业务的发展和用户数量的增加,高并发问题往往成为程序员不得不面对和处理的一个非常棘手的问题,而并发编程是编程领域中比较高深和晦涩的知识。如果你想学习并发相关的,写出好的并发程序可不是那么容易的。对于写Java的程序员来说,在这一点上可能会比较高兴,因为Java中有大量封装的同步原语和高手编写的同步工具类,降低了编写正确高效的并发程序的门槛。许多。这种高度的封装和抽象虽然简化了程序的编写,但是却阻碍了我们对其内部实现机制的理解。下面让我们从现实世界中的锁的角度来打个比方,看看程序世界中的锁是什么。是一种怎样的存在?编程世界中的锁如果有人问你:“你如何保护你的房子免受陌生人的伤害”?我想你可能很容易想到:“就锁上吧!”。而如果有人问你:“如何处理多线程并发”?我想你可能会脱口而出:“加一把锁就行了!”。类似的场景在现实世界中很容易理解,但在程序世界中,这句话却充满了疑惑。我们在现实世界中见过各种各样的锁,那么Java中的锁长什么样子呢?我们在现实世界中通常需要一把钥匙才能开锁进入屋内,那么在程序世界中开锁的钥匙是什么呢?实际上,锁通常位于门、柜子或其他位置。程序世界中哪里存在锁?在现实世界中,我们通常是加锁和解锁的人,那么在程序世界中加锁和解锁的人是谁呢?带着这些问题,我们想更深入地了解Java中到底存在什么样的锁?从哪里开始理解呢,我觉得锁首先是在程序中用到的,那我们就从锁的使用开始考察吧!锁的使用是指Java中的锁,通常可以分为两类,一类是JVM层面提供的并发同步原语Synchronized,一类是JavaAPI层面Lock接口的实现类。Reentrantlock、ReentrantReadWriteLock等JavaAPI级锁都有非常详细的源码。你可以去看看它们是如何实现的。您或许可以在上面找到答案。这里我们看一下Synchronized。先看下面的代码:publicclassLockTest{Objectobj=newObject();publicstaticsynchronizedvoidtestMethod1(){//同步代码。}publicsynchronizedvoidtestMethod2(){//同步代码}publicvoidtestMethod3(){synchronized(obj){//同步代码}}}很多并发编程书籍对Synchronized的用法总结如下:当Synchronized修改一个静态方法(对应testMethod1)时,锁是当前类的类对象,对应LockTest.class_object_。当Synchronized修改实例方法(对应testMethod2)时,当前类实例的对象被锁定,对应LocKTest中的this引用_object_。Synchronized修改同步代码块(对应testMethod3)时,锁定的是同步代码块中括号内的对象实例,对应这里的obj_object_。从这里我们可以看出Synchronized的使用依赖于具体的对象,从这里我们可以发现锁和对象之间存在着一定的关系。那么接下来我们就来看看对象中的锁有哪些蛛丝马迹。对象的组成Java中的一切都是对象,就像你的对象有长头发、大眼睛(也许一切都只是想象)……Java中的一个对象由三部分组成。它们是对象头、实例数据和对齐填充。实例数据很好理解,就是我们在类中定义的字段数据占用的空间。对齐填充是因为Java专用虚拟机要求对象的大小必须是8字节的整数倍。如果一个对象锁占用的存储空间以小于8字节的片段结束,那么它必须被填充到8字节。字节。看起来锁跟这两个区域没有太大的关系,那么锁应该和对象头有一些关系,如下图所示:ObjectComposition.png我们来看看对象头的内容:以计算机为例(64位类比就够了),MarkWord只有四个字节,还存储了HashCode等信息,是否可以实现锁完全存在于这四个字节中?这句话在Jdk1.6之前是完全错误的,Jdk1.6之后在某些情况下是正确的。你为什么这么说?这是因为Java中的线程与本地操作系统线程一一对应,而操作系统为了保护系统内部安全,防止随机调用一些内部指令等,将系统空间划分为用户态。,并保证内核的安全。与内核态不同的是,我们平时运行的线程只运行在用户态。当我们需要调用操作系统服务(这里称为系统调用)时,如read、writer等,在用户态是没有办法直接发起调用的,这时候就需要在用户态和内核态之间进行切换。Synchronized早期之所以被称为重量级锁,是因为使用Synchronized进行加锁和解锁需要在用户态和内核态之间切换,所以早期的Synchronized是重量级锁,需要实现线程阻塞和唤醒。阻塞队列和条件队列的dequeue和enqueue等等,这些我们后面再说,显然不可能存储在这四个字节之内。但是Jdk1.6对Synchronized做了一系列的优化,包括锁的升级,使得这句话部分正确。之所以前面那句话在锁升级过程中在某些情况下是正确的,是因为在Jdk1.6中,虚拟机团队对Synchronized进行了一系列的优化,具体的我们就不讨论了,很多并发编程里面都有详细介绍书中记载。而我们这里要说的就是重要的优化之一——锁升级。Java中Synchronized的锁升级过程是:无锁-->偏向锁-->轻量级锁-->重量级互斥锁。也就是说,除非多线程之间存在严重的锁竞争,否则Synchronized是不会使用Jdk1.6之前那么重的mutex的。我们知道在现实世界中,负责加锁和解锁的是我们,所以在程序世界中,线程实际上扮演着人类加锁和解锁的角色。偏向锁开始时,处于解锁状态。我们可以理解为宝库的门没有锁。这时第一个线程跑到同步代码区(第一个人走到门口),加了偏向锁。锁,此时的锁是一种什么样的形式?这时候其实就类似于人脸识别锁的一种形式。第一个进入同步代码块的线程本身作为key,在MarkWord中保存可以唯一标识一个线程的线程ID。此时MarkWord的内容如下:biasedlock.jpg这里4个字节的23位用来存放第一个获得偏向锁的线程的线程ID,2位的Epoch代表有效性偏向锁的编号,以及4位对象的分代年龄,1位是否是偏向锁(1表示是),2位锁标志(01是偏向锁)。当第一个线程运行到同步代码块时,会检查Synchronized锁使用的对象的对象头。如果上面提到的Synchronized使用的三个对象其中一个的对象头的线程ID为空,偏向锁有效,则说明还处于无锁状态(即宝househasnotbeenlocked),那么第一个线程将使用CAS将自己的线程ID替换为对象头。标记Word的线程ID,如果替换成功,说明该线程已经获得了偏向锁,那么该线程就可以安全的执行同步代码了。如果以后线程再次进入同步代码,如果其他线程在此期间没有获取到偏向锁,只需要简单的比较一下自己的线程ID和MarkWord中的线程ID是否一致。如果一致,就可以直接进入同步代码区,这样性能损失会小很多。偏向锁是基于HotSpot的研发团队曾经研究表明,通常情况下锁是不竞争的,锁总是被同一个线程多次获取。在这种情况下,引入偏向锁可以说是大有裨益!反之,如果这种情况不是很常见,也就是说锁竞争很严重,或者通常锁是多个线程轮流获取的,那么偏爱锁是没有用的。轻量级锁从这里我们可以看出锁在一开始是偏向锁的时候是一种什么样的形式存在。我们也说过,当没有多个线程竞争锁时,就存在偏向锁,但是在高并发环境下,竞争锁是不可避免的。就在此时,同步开始了他的晋升之路。当有多个线程在竞争锁时,此时简单的偏向锁就没有那么安全了,锁是锁不上的。这时候就必须换锁,升级一把更安全的锁。此时的锁升级过程大致可以分为两步:(1)偏向锁的撤销(2)轻量级锁的升级。首先,如何取消偏向锁?我们说偏向锁的锁其实就是MarkWork中的线程ID。这个时候只要改变MarkWord自然就相当于取消了偏向锁。问题是偏向锁是用线程ID来表示的,轻量级应该用什么来表示锁呢?答案是LockRecord(栈帧中的锁记录)。这里解释一下:我们知道JVM内存结构可以分为(1)堆(2)虚拟机栈(3)本地方法栈(4)程序计数器(5)方法区(6)直接内存。其中,程序计数器和虚拟机栈是线程私有的。每个线程都有自己独立的栈空间。貌似存入栈就可以区分是哪个线程获取了锁。事实上,JVM是这样做的。首先,JVM会在当前栈中开辟一块内存,这块内存叫做LockRecord(锁记录),将MarkWord中的内容复制到LockRecord中(也就是存储在LockRecord中的内容)LockRecord就是之前MarkWork中的内容,那为什么要保存之前的内容呢,很简单,因为我们马上就要修改MarkWord中的内容了,当然修改前一定要先保存,这样才能恢复以后。)复制之后,下一步就是我开始修改MarkWord了,怎么修改呢?当然是把MarkWord换成CAS!此时MarkWord会变成如下内容:lightweightlock.jpg可以看到MarkWord用30位来记录我们刚刚在栈帧中创建的LockRecord,锁标志位为00表示是轻量级的锁。很容易知道哪个线程获得了轻量级锁。轻量级锁是基于当有两个或多个线程在竞争一个锁时,大多数情况下,持有锁的线程会快速释放锁,也就是当有少量的锁竞争时通常,锁定时间很短。这时候等待获取锁的线程不需要在用户态和内核态之间切换来阻塞自己,而是只要空循环(这个叫自旋)一会,预计在这段时间自旋,持有锁的线程可以立即释放锁。显然,轻量级锁适用于锁竞争不激烈,锁持有时间较短的情况。反之,如果锁竞争激烈或者线程获取到锁后长时间不释放锁,线程就会白旋(Infiniteloop),浪费cpu资源。重量级互斥体当想进入宝库的人太多时,轻量级是不够的。这时候,我们就只能使用杀手锏——重量级的mutex了。这也是Jdk1.6之前Synchronized的默认实现。当锁是轻量级锁时,线程需要自旋等待持有锁的线程释放锁,然后再申请锁,但是有两个问题:1、自旋线程很多,即,许多线程正在等待当前持有锁的线程释放锁。由于锁只能同时被一个线程获取(就Synchronized而言),这会导致大量线程获取锁失败。他们不能继续旋转吗?2.持有锁的线程长时间不释放锁,导致外面等待获取锁的线程自旋了很久,仍然获取不到锁。不能一直转吗?分别来看上面两种情况,等待获取锁的线程很不爽。如果两种情况同时满足(锁竞争激烈,持有锁的线程长时间不释放锁),那就更难受了。所以JVM对自旋次数设置了限制。如果线程自旋一定次数后仍然没有获取到锁,就可以认为是锁竞争激烈的情况。这时,线程请求撤销轻量级锁,并提升一个重量级互斥量。说到轻量级锁,锁是以LockRecord的形式存在,那么说到重量级锁,应该以什么形式存在呢?重量级锁的复杂度是最高的。因为持有锁的线程在释放锁时需要唤醒被阻塞的等待线程,当线程无法获取到锁时,需要进入某个阻塞区域统一阻塞等待。同时我们知道还有一个等待,需要处理notify条件的等待和唤醒,所以重量级锁的实现需要多一个大杀器——Monitor。在《Java并发编程的艺术》一书中,有这样的描述:JVM根据进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现细节是不同的。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另一种方法实现的,JVM规范中没有详细说明。但是,也可以使用这两个指令来实现方法同步。monitorenter指令被插入在编译后的同步代码块的开头,monitorexit被插入在方法的末尾和异常处。JVM必须确保每个monitorenter必须有一个相应的monitorexit与之配对。任何对象都有一个与之关联的监视器,当一个监视器被持有时,它就会被锁定。当线程执行monitorenter指令时,会尝试获取对象对应的monitor的所有权,即尝试获取对象的锁。我们以HotSpot虚拟机为例。它是用C++实现的。C++也是一种面向对象的语言。因此,虚拟机设计团队这次选择了用对象的形式来表示锁。同时,C++也支持多态。这里,Monitor其实就是一个抽象。虚拟机中Monitor的实现是使用ObjectMonitor实现的。Monitor和ObjectMonitor的关系可以类比Java中Map和HashMap的关系。我们来看看ObjectMonitor的真实内容:ObjectMonitor(){_header=NULL;_count=0;//用来记录线程获取锁的次数_waiters=0,_recursions=0;//锁的数量reentries_object=NULL;_owner=NULL;//指向持有ObjectMonitor的线程_WaitSet=NULL;//存放Wait状态的线程集合_WaitSetLock=0;_Responsible=NULL;_succ=NULL;_cxq=NULL;FreeNext=NULL;_EntryList=NULL;//等待获取锁时阻塞的线程集合_SpinFreq=0;_SpinClock=0;OwnerIsThread=0;}这里强烈推荐大家看一下基于AQS的ReentrantLock(AbstractQueueSynchronizer))实现源码,因为ReentrantLock里面synchronizer的实现思路基本上就是Synchronized实现中Monitor的缩影。首先,ObjectMonitor中需要有一个指向当前获取锁的线程的指针,也就是上面的owner。当线程获取到锁后,会调用ObjectMonitor.enter()方法进入同步代码块。获取到锁后,设置owner指向当前线程,当其他线程尝试获取锁时,在ObjectMonitor中查找owner,看是不是你。如果是的话,recursions和count都会加1,说明线程又获得了锁(Synchronized是可以Reentrantlock,持有锁的线程可以重新获得锁),否则应该是阻塞的,那么whereare这些阻塞线程?统一放在EntryList中即可。当持有锁的线程调用wait方法时(我们知道wait方法会导致线程放弃cpu,释放持有的锁,然后自己阻塞挂起,直到其他线程调用notify或notifyAll方法),那么线程应该释放锁,owner置空,EntryList中被阻塞等待获取锁的线程应该被唤醒,然后自己挂起,进入waitSet集合等待。当其他持有锁的线程调用notify或notifyAll方法时,WaitSet中的某个线程(notify)或所有线程(notifyAll)会从WaitSet移到EntryList中等待竞争锁。当线程要释放锁时,会调用ObjectMonitor.exit()方法退出同步代码块。结合《Java并发编程的艺术》中的描述,一切就很清楚了。将锁升级为重量级锁也需要两个步骤:(1)撤销轻量级锁(2)升级重量级锁。要取消轻量级锁,当然要把栈帧中存储的LockRecord中的内容写回MarkWork,然后清空栈帧中的LockRecord。之后需要创建一个ObjectMonitor对象,将MarkWord中的内容保存到ObjectMonitor中(取消锁时方便恢复MarkWord,这里保存在ObjectMonitor中)。那么如何找到这个ObjectMonitor对象呢?哈哈,没错,就是在MarkWord中记录下ObjectMonitor对象的指针。如何修改替换MarkWord中的内容?当然是CAS!当锁是重量级互斥量形式时,MarkWord的内容如下:重量级lock.jpg可以看到MarkWord用30位来存放指向ObjectMonitor的指针,锁标志位为10,指重量级锁。重量级锁是基于当锁竞争严重,或者长时间持有锁时,等待获取锁的线程应该自己阻塞挂起,等待线程唤醒当锁被释放时,以免白白浪费cpu资源。锁形式的变化现在我们可以回答“Java中的锁是什么样的?”这个问题了。在文章的开头。在不同的锁状态下,锁呈现出不同的形态。当锁作为偏向锁存在时,锁就是MarkWord中的ThreadID。这时候线程本身就是开锁的钥匙。MarkWord中存入了哪个线程的“身份证”,该线程就会获得锁。当锁作为轻量级锁存在时,锁就是栈帧中锁记录中MarkWord指向的LockRecord。这个时候关键是site,也就是虚拟机栈。谁在堆栈中拥有锁定记录,谁就会得到它。锁定。当锁作为重量级锁存在时,锁就是ObjectMonitor,Monitor在C++中的实现,此时的key就是ObjectMonitor中的owner。谁指向所有者,谁就获得锁。在上一个问题中,我们说过32位虚拟机MarkWord只有四个字节。是不是完全存在于这四个字节之内就可以实现锁呢?这句话在Jdk1.6之前是完全错误的,Jdk1.6之后在某些情况下是正确的。现在你是不是对这句话有了更深的理解呢?在现实世界中,锁定和解锁的是我们人类。通过前面的了解,在程序的世界里谁加锁谁开锁呢?是的,就是线程。现在回过头来看文章开头的问题,很容易给出答案。原来一切真的是从Synchronized使用的锁对象开始的!关于CAS,虽然Synchronized经过了一系列的优化,性能比原来的性能要好很多,但是业务越来越追求低延迟和高响应,以乐观并发控制为代表的CAS并发控制方式越来越多受欢迎的。可见CAS在非阻塞原子替换方面确实有很好的应用效果。有意思的是,通过前面的了解,在Synchronized的升级过程中大量使用了CAS,用于MarkWord的非阻塞修改和替换。在很多方面都值得学习。