同步器
多线程编程中,可能出现多个线程同时访问同一个共享,可变资源的情况,这个资源我们称之为临界资源。
共享:资源可以由多个线程同时访问
可变:资源可以在其生命周期内被修改
由此,我们可以引出一个问题:
由于线程执行的过程是不可控的,所以需要采用同步机制来协同对象可变状态的访问。
常见的解决线程并发安全问题的方式是序列化访问临界资源
即在同一时刻只允许一个线程访问临界资源,也称作为同步互斥访问。
Java中提供了两种方式来实现同步互斥访问:synchronized
和 Lock
同步器的本质就是加锁,为了序列化访问临界资源
synchronized原理详解
synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
加锁的方式:
1、同步实例方法,锁是当前实例对象
2、同步类方法,锁是当前类对象
3、同步代码块,锁是括号里面的对象
Monitor监视器锁
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。
Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
synchronized关键词的JVM指令
使用synchronized
修饰代码块时,字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置:
1 | // class version 51.0 (51) |
这两条指令对于不同的锁会有不同的操作。
monitorenter:
每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
monitorexit:
执行monitorexit的线程必须是object所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异常退出释放锁;
通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理
Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
而使用synchronized
修饰方法时,JVM指令如下:
对象上的锁
Synchronized一般有三种用法:
- 锁类中的普通方法,锁的是当前实例对象(this),多个实例对象对应多把锁
- 锁类中的静态方法,锁的是当前类的Class对象,只有唯一一把锁
- 锁类中普通方法的代码块,锁是
synchronized()
括号里面的对象,如果是静态对象只有唯一一把锁
无论是哪种用法,锁的都是对象,那么到底怎么给一个Java对象加锁呢?
首先我们都知道synchronized
是依靠Monitor进行加解锁的,而Monitor对象是存在于每个Java对象的对象头Mark Word
中(存储的指针的指向)。
Synchronized
锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait
等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?
答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面我们一起认识一下对象的内存布局
对象头结构
之前我们也介绍过,HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域,对象头(Header)、实例数据(Instance Data)和对齐填充(Padding):
HotSpot虚拟机的对象头包括两部分信息
其中第一部分是Mark Word,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。
这部分数据的长度在32位和64位的虚拟机中分别为32个和64个Bits
我们单以32位举例:
32位虚拟机的Mark Word
Mark word确定了该对象的状态,我们可以通过上图看到,锁标志位为重量级锁的时候,才指向Monitor指针。
而在无锁 偏向锁 轻量级锁时,无锁状态时分代年龄等都记录在Markword
偏向锁的时候,就是线程ID和分代年龄等等。
那么锁升级的时候,那些信息大概是保存在其他地方了。
锁升级
在Mark Word字段表中,分别有无锁,偏向锁,轻量级锁和重量级锁,下面我们分别介绍。
无锁
无锁状态对应Mark Word表中:
无锁非常简单,一个对象在刚刚创建的时候它就是无锁状态:
1 | public class Juc_PrintMarkWord { |
对象头中的Mark Word内容如下:
1 | // 无锁 |
锁标志为对应的是001,表示没有锁。【小端模式
偏向锁(单线程)
偏向锁,顾名思义就是偏向第一个获取到锁对象的线程,并且在运行过程中,只有一个线程会访问同步代码块,不存在多线程的场景,这种情况下加的就是偏向锁。偏向锁状态对应Mark Word表中:
可以看到当对象持有偏向锁时,对象头中记录了持有该偏向锁的线程ID。我们来演示一下:
1 | public class Juc_PrintMarkWord { |
我们预期的结果应该是第一次打印001,第二次打印101,可是结果却如下:
1 | // 第一次打印:无锁 |
不对啊,为什么直接变成轻量级锁了?难道不应该是偏向锁吗?
这是由于java对于偏向锁的启动是在启动几秒之后才激活导致的,因为JVM启动的过程中会有大量的同步块,且这些同步块都有竞争,如果一启动就启动偏向锁,会出现很多没有必要的锁升级和撤销,因此JVM选择直接将其设置为轻量级锁。
我们可以通过参数-XX:BiasedLockingStartupDelay=0
参数,将偏向锁启动延迟设置为0,我们再启动一次看看:
1 | // 第一次打印:匿名偏向锁 |
可以看到,JVM一开始就将对象状态设置为偏向锁,这是因为它检测到了我们后面可能会以该对象为锁产生线程竞争。不过我们可以发现一开始,其对象头中并没有保存线程ID,因为还没有线程获取锁,我们将其称为匿名偏向锁。
之后又一个线程对获取了该锁,于是该对象头中就会记录该线程的ID。另外,通过参数-XX:-UseBiasedLocking
可以关闭偏向锁,默认开启。
经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(一些CAS操作)的代价而引入偏向锁。
偏向锁的核心思想是:
如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时, 无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。
但是对于不止一个线程访问锁的场合(无论有没有竞争),偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,而是升级为轻量级锁,如下图所示:
当然,撤销偏向锁也是一个过程:需要在安全点暂停当前持有该偏向锁的线程,如果这时该线程还没有执行完同步代码块,则将锁升级为轻量级锁。
轻量级锁(多个线程交替执行)
倘若出现了多个线程,虚拟机并不会立即将锁升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时 Mark Word 的结构也变为轻量级锁的结构:
我们来演示一下:
1 | public class Juc_PrintMarkWord { |
输出结果如下:
1 | // 第一次输出:匿名偏向锁 |
可以看到,当线程2申请锁后,由于当前偏向锁中的线程ID不是自己的ID,因此升级为了轻量级锁。轻量级锁对应的Mark Word中除了锁标志位,还有一个**指向线程栈中锁记录的指针(pointer to Lock Record)**,那么这个锁记录(Lock Record)是什么?
当升级为轻量级锁后,Mark Word的结构会发生改变,原来Mark Word的内容会被拷贝至当前持有该锁的线程栈帧中的**锁记录(Lock Record)**区域,而Mark Word会变为指向这块区域的一个指针。如图所示:
Lock Record是线程帧栈中的一个内存区域,其中有两个重要的字段:
- _displaced_header:存放原来锁对象Mark Word的拷贝,用于CAS操作
- owner:指向锁对象,便于找到哪个对象被锁住了
简单来说,Mark Word中的指针用于找到哪个线程正持有该锁。如果有另一个线程要获取该轻量级锁,流程如下:
可以看到,轻量级锁竞争的过程,其实主要就是锁对象Mark Word中锁记录指针修改的过程,如果修改失败,那么说明该锁对象正在被其他线程持有。自旋一会,依旧不能获得锁就会升级为重量级锁。这样看,轻量级锁的情况下一旦有竞争,就会升级为重量级锁,那轻量级锁的存在有什么意义呢?
轻量级锁能够提升程序性能的依据是对绝大部分的锁,在整个同步周期内都不存在竞争,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,即产生了锁的竞争,就会导致轻量级锁膨胀为重量级锁。 膨胀过程大致如下:
- 线程2申请轻量级锁,发现锁记录指针指向线程1的栈帧,锁被线程1持有,发生了锁竞争,锁进行膨胀
- 线程2初始化Monitor对象,将锁记录指针修改为指向Monitor对象的指针
- 此时,线程1还在执行,因此Monitor的拥有者为线程1,而线程2进入等待队列
- 线程1执行完毕后,发现锁以及升级为重量级锁了,通过锁记录中的owner找到Monitor对象,进行重量级锁的释放流程
重量级锁(多线程存在竞争)
在轻量级锁的情况下,如果存在了多线程竞争锁,那锁就会升级为重量级锁,对象的Mark Word也会发生变化:
我们来演示一下重量级锁:
1 | public class Juc_PrintMarkWord { |
结果如下:
1 | // 第一次输出:匿名偏向锁 |
可以看到,线程1、2同时启动后,会产生锁竞争,于是我们看到出现了重量级锁。之前我们能也介绍了,Mark Word中的指向锁记录的指针,会变成指向Monitor对象的指针。那这个Monitor对象是什么?它有什么用?
我们先来看看C++源码中对Monitor的定义:
1 | // Monitor的结构体 |
可以把Monitor理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,它就是实现重量级锁的关键。
所有的状态为重量级锁的对象,其对象头中的指针都指向一个Monitor,那么所有线程针对这把锁的竞争、释放都是基于这个Monitor实现的。
我们来看看在重量级锁的级别下线程对锁的竞争:
我们看到了一个叫做自旋的操作,什么是自旋?
锁申请失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟在我们之前介绍过的内核线程模型下,操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程循环重新申请锁。
在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起。我们可以通过下面的伪代码理解:
1 | // 设定自旋次数为10 |
当然,虽然通过自旋的方式可以在一定程度上减少用户态和内核态的切换,减少对操作系统中与线程相关的库函数调用,但是自旋的过程需要一直占用CPU,因此过度的自旋可能适得其反。
最后注意,在Java代码中,如果线程获得锁后调用object.wait()
方法,则会将线程加入到Monitor对象的WaitSet中,当被object.notify()
唤醒后,会将线程从WaitSet移动到 _cxq 或 _EntryList中去。
需要注意的是,当调用wait
或notify
方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。也就是说,wait和notify
等方法依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用这些方法,否则会抛出java.lang.IllegalMonitorStateException
的异常。
- 本文标题:synchronized详解
- 创建时间:2021-04-26 22:07:11
- 本文链接:2021/04/26/2021/synchronized详解/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!