Synchronized锁机制详解

前言:锁是一种怎样的存在

一个简单的日常的生活的例子:如果有人问你怎样保证自行车不被偷走,肯定回答上锁就行了呗。类比程序如果有人又问你多线程情况下怎样保证数据安全性,肯定又回答上锁就行了呗。日常生活中加锁解锁的是我们自己, 所以程序世界中加锁解锁的又是谁呢?

Synchronized

Synchronized的基本作用

Synchronized修饰静态方法的时候,锁的是当前类的class对象对象。
Synchronized修饰实例方法的时候,锁的是当前类实例的对象中的this引用对象。
Synchronized修饰同步代码块的时候,锁的是同步代码块括号里的对象实例

这里可以总结出 synchronized 的使用要依赖特定对象 与对象有一定关联关系

对象的组成

了解Java的同学应该都知道一句话’万物皆可对象’, 我现在打字的键盘,看文字的显示器,工作的电脑都是对象。所以对象是怎样组成的

对象组成

Java中的对象由三部分组成。分别是对象头、实例数据、对齐填充。
实例数据很好理解,就是我们在类中定义的那些字段数据所占用的空间。而对齐填充呢是因为Java特定的虚拟机要求对象的大小必须是8字节的整数倍,如果一个对象锁占用的存储空间最后会有一个不够8字节的碎片,那么要把他填充到8字节。看起来锁与这两个区域都不会有太大的关系,那么锁应该与对象头存在某种关系

长度	        内容	            		说明
32/64bit	Mark Word	       		存储对象的HashCode或锁信息
32/64bit	Class Metadata Address	存储到对象类型数据的指针
32/64bit	Array length			数组的长度(如果当前对象是数组)

这是对象头中的内容
我们以32位虚拟机为例(64位的类比即可),Mark Word只有四个字节,而且还要存放HashCode等信息,难道锁就完全存在于这四个字节之内就可以实现嘛?这句话在Jdk1.6之前是完全不对的,在Jdk1.6之后在一部分情况下是对的。

锁升级

之所以说前面那句话在部分情况下是正确的,是因为在Jdk1.6时,虚拟机团队对Synchronized进行了一系列的优化,具体我们就不讨论了,很多的并发编程书籍中都有详细的记录。而这里我们要说的就是其中的一项重要的优化——锁升级。
Java中Synchronized的锁升级过程如下:无锁——>偏向锁——>轻量级锁——>重量级互斥锁。
也就是说除非存在很严重的多线程之间的锁竞争,否则Synchronized不会使用Jdk1.6之前那么重的互斥锁了。
我们知道现实世界中是由我们人来负责进行上锁和开锁的,那么程序世界中其实是由线程来扮演人的角色来进行加锁解锁的。
偏向锁
刚开始的时候,处于无锁状态,我们可以理解为宝屋的门没锁着,这时第一个线程运行到了同步代码区域(第一个人走到了门前),加上了一个偏向锁,这个时候锁是一种什么形态呢?这个时候其实是类似一种人脸识别锁的形态,第一个进入同步代码块的线程自身作为钥匙,将能够唯一标识一个线程的线程ID保存到了Mark Word中。
这个时候的Mark Word中的内容如下:
在这里插入图片描述
这里的四个字节的23位用来存储第一个获取偏向锁的线程的线程ID,2位的Epoch代表偏向锁的有效性,4位对象分代年龄,1位是否是偏向锁(1为是),2位锁标志位(01是偏向锁)。
当第一个线程运行到同步代码块的时候,会去检查Synchronized锁使用的那个对象的对象头,如果上面所谈的Synchronized所使用的三种对象其中之一的对象头的线程ID这个地方为空的话,并且偏向锁是有效的,说明当前还是处于无锁的状态(也就是宝屋还没有上锁),那么这个时候第一个线程就会使用CAS的方式将自己的线程ID替换到对象头Mark Word的线程ID,如果替换成功说明该线程获取到了偏向锁,那么线程就可以安全的执行同步代码了,以后如果线程再次进入同步代码的时候,在此期间如果其他线程没有获取偏向锁,只需要简单的对比一下自己的线程ID与Mark Word中的线程ID是否一致,如果一致就可以直接进入同步代码区域,这样性能损耗就小多了。

轻量级锁

从这里我们可以看出,当锁开始时是偏向锁的时候是以一种怎样的形态存在,前面我们也说了偏向锁是在不存在多个线程竞争锁的情况下存在的,然而高并发环境下竞争锁是不可避免的,此时Synchronized便开启了他的晋升之路。
当存在多个线程竞争锁的时候,这时候简单的偏向锁就不是那么安全了,锁不住了,这时就要换锁,升级成一种更为安全的锁。此时的锁升级过程大概可以分为两步:(1)偏向锁的撤销(2)轻量级锁的升级。
首先偏向锁如何撤销呢,我们说偏向锁的锁其实就是Mark Work中的线程ID,这个时候只要更改Mark Word自然就相当于撤销了偏向锁,那么问题是偏向锁用线程ID表示,轻量级锁该用什么表示呢?答案是Lock Record(栈桢中的锁记录)。
这里我来解释一下:
我们知道JVM内存结构可以分为(1)堆(2)虚拟机栈(3)本地方法栈(4)程序计数器(5)方法区(6)直接内存。这其中程序计数器和虚拟机栈是线程私有的啊,每个线程都拥有自己独立的栈空间,看起来存放在栈中可以很好的区分开是哪个线程获取到了锁,事实上,JVM也确实是这么做的。
首先,JVM会在当前的栈中开辟一块内存,这块内存被称为Lock Record(锁记录),并把Mark Word中的内容复制到Lock Record中(也就是说Lock Record中存放的是之前的Mark Work中的内容,那为什么要存之前的内容呢?很简单,因为我们马上就要修改Mark Word的内容了,修改之前当然要保存一下,以便日后恢复啊),复制完了之后接下来就要开始修改Mark Word了,如何修改呢?当然是用CAS的方式替换Mark Word了!此时Mark Word将变成以下内容:

在这里插入图片描述

可以看到Mark Word中使用30位来记录我们刚刚在栈桢中创建的Lock Record,锁标志位为00表示轻量级锁,这样就很容易知道是哪个线程获取到了轻量级锁啦。

轻量级锁是基于这样的一个事实,当存在两个或以上的线程竞争锁的时候,绝大多数情况下,持有锁的线程是会很快释放锁的,也就是当锁存在少量竞争时,通常情况下锁被持有的时间很短,此时等待获取锁的线程可以不必进行用户态与内核态的切换从而阻塞自己,而只要空循环(这个叫自旋)一会儿,期望在自旋的这段时候持有锁的线程可以马上释放掉锁。
很明显轻量级锁适用于锁的竞争并不激烈并且锁被持有的时间很短的情况,相反如果锁竞争激烈或者线程获取到锁之后长时间不释放锁,那么线程会白白的自旋(死循环)而浪费掉cpu资源。

重量级互斥锁

当想要进入宝屋的人太多时,轻量级也不行了,这个时候只能使用杀手锏了——重量级互斥锁。这也是Synchronized在Jdk1.6之前的默认实现。
当锁处于轻量级锁的时候,线程需要自旋等待持有锁的线程释放锁,然后去申请锁,但是存在两个问题:

自旋的线程很多,也就是有很多线程都在等待当前持有锁的线程释放锁,由于锁只能同一时刻被一个线程获取(就Synchronized而言),这样就导致大量的线程获取锁失败,总不能一直的自旋下去吧?
持有锁的线程长时间不释放锁,导致在外面等待获取锁的线程长时间自旋仍然获取不到锁,总不能一直自旋下去吧?

上述两种情况下分别来看,等待获取锁的线程就很难受了,如果两种情况同时满足(锁竞争激烈同时持有锁的线程长时间不释放锁),那就更难受了。于是JVM设定了一个自旋次数的限制,如果线程自旋了一定的次数之后仍然没有获取到锁,那么可以视为锁竞争比较激烈的情况了,这个时候线程请求撤销轻量级锁,晋升为重量级的互斥锁。
在轻量级锁的时候,锁是以Lock Record的形式存在的,那么到了重量级锁的时候,该以什么形式存在呢?
重量级锁的复杂度是最高的,由于持有锁的线程在释放锁时候需要唤醒阻塞等待的线程,线程获取不到锁的时候需要进入某一个阻塞区域统一阻塞等待,同时我们知道还有wait,notify条件的等待与唤醒需要处理,所以重量级锁的实现需要一个额外的大杀器——Monitor。

在《Java并发编程的艺术》一书中有着这样的描述:
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有
详细说明。但是,方法的同步同样可以使用这两个指令来实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

我们以HotSpot虚拟机为例,其是用C++实现的,C++也是一门面向对象的语言,因此,虚拟机设计团队这一次选择以对象的形态表示锁,同时C++也支持多态,这里的Monitor其实是一种抽象,虚拟机中对于Monitor的实现使用ObjectMonitor实现,关于Monitor与ObjectMonitor的关系可以类比Java中Map与HashMap的关系。
我们看一下ObjectMonitor的真容:

ObjectMonitor()
{
_header = NULL;
_count = 0;//用来记录该线程获取锁的次数
_waiters = 0,
_recursions = 0;//锁的重入次数
_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 ;
}
首先ObjectMonitor中需要有一个指针指向当前获取锁的线程,就是上面的owner,当某一个线程获取锁的时候,将调用ObjectMonitor.enter()方法进入同步代码块,获取到锁之后,就将owner设置为指向当前线程,当其他的线程尝试获取锁的时候,就找到ObjectMonitor中的owner看看是否是自己,如果是的话,recursions和count自增1,代表该线程再次的获取到了锁(Synchronized是可重入锁,持有锁的线程可以再次的获取锁),否则的话就应该阻塞起来,那么这些阻塞的线程放在哪里呢?统一的放在EntryList中即可。当持有锁的线程调用wait方法时(我们知道wait方法会使得线程放弃cpu,并释放自己持有的锁,然后阻塞挂起自己,直到其他的线程调用了notify或者notifyAll方法为止),那么线程应该释放掉锁,把owner置为空,并唤醒EntryList中阻塞等待获取锁的线程,然后将自己挂起并进入waitSet集合中等待,当其他持有锁的线程调用了notify或者或者notifyAll方法时,会将WaitSet中的某一个线程(notify)或者全部线程(notifyAll)从WaitSet中移动到EntryList中等待竞争锁,当线程要释放锁的时候,就会调用ObjectMonitor.exit()方法退出同步代码块。结合《Java并发编程的艺术》中的描述,一切都很清晰了。

锁升级为重量级锁同样需要两个步骤:(1)轻量级锁的撤销(2)重量级锁升级。
要撤销轻量级锁,当然要把保存在栈桢中的Lock Record中存储的内容再写回Mark Work中,然后将栈桢中的Lock Record清理掉。此后需要创建一个ObjectMonitor对象,并且将Mark Word中的内容保存到ObjectMonitor中(便于撤销锁的时候恢复Mark Word,这里是保存在了ObjectMonitor中)。那么如何寻找到这个ObjectMonitor对象呢?哈哈没错就是在Mark Word中记录指向ObjectMonitor对象的指针即可。如何修改替换Mark Word中的内容呢?当然会CAS啦!
锁在重量级互斥锁的形态下Mark Word中的内容如下:

在这里插入图片描述

锁形态的变迁
现在我们可以回答文章开头“ Java中的锁长什么样子?”这个问题了,在不同的锁状态下,锁表现出了不同的形态。
当锁以偏向锁存在的时候,锁就是Mark Word中的Thread ID,此时线程本身就是打开锁的钥匙,Mark Word中存了哪个线程的"身份证",哪个线程就获得了锁。
当锁以轻量级锁存在的时候,锁就是Mark Word中所指向栈桢中锁记录的Lock Record,此时的钥匙就是地盘,是虚拟机栈,谁的栈中有Lock Record,谁就获得了锁。
当锁以重量级锁存在的时候,锁就是C++中对于Monitor的实现ObjectMonitor,此时的钥匙就是ObjectMonitor中的owner。owner指向谁,谁就获得了锁。
之前的问题中,我们说32位的虚拟机Mark Word只有四个字节,难道锁就完全存在于这四个字节之内就可以实现嘛?这句话在Jdk1.6之前是完全不对的,在Jdk1.6之后在一部分情况下是对的。现在你是否对这句话有了更深刻的理解呢?
而现实世界中上锁开锁的是我们人类,通过前面的了解,程序世界中上锁开锁的又是谁呢?是的就是线程了。
现在再回头看文章开头的那些问题,就很容易给出答案了,原来一切真的就是从Synchronized使用的那个锁对象开始的!

注:本文摘抄自 https://juejin.cn/post/6955318854889242638 感谢老哥允许转载

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值