Java并发-Synchronized的实现
Monitor基础
为什么要学习Monitor
在JDK1.6之前,Synchronized为重量级锁,其实现只依赖于Monitor,效率较低。学习Monitor,是为学习Synchronized打下基础。
什么是Monitor
Monitor是一种同步机制。它并不单单属于Java,而是一种系统级别的设计理念。
开发并发应用时,经常需要设计这样的对象,该对象的方法会在多线程的环境下被调用,而这些方法的执行都会改变该对象本身的状态。为了防止竞争条件 (race condition) 的出现,对于这类对象的设计,需要考虑解决以下问题:
在任一时间内,只有唯一的公共的成员方法,被唯一的线程所执行。
对于对象的调用者来说,如果总是需要在调用方法之前进行拿锁,而在调用方法之后进行放锁,这将会使并发应用编程变得更加困难。合理的设计是,该对象本身确保任何针对它的方法请求的同步被透明的进行,而不需要调用者的介入。
如果一个对象的方法执行过程中,由于某些条件不能满足而阻塞,应该允许其它的客户端线程的方法调用可以访问该对象。
Monitor Object 设计模式就是为了解决这类问题:
将被客户线程并发访问的对象定义为一个 monitor 对象。客户线程仅仅通过 monitor 对象的同步方法才能使用 monitor 对象定义的服务。为了防止陷入竞争条件,在任一时刻只能有一个同步方法被执行。
每一个 monitor 对象包含一个 monitor 锁,被同步方法用于串行访问对象的行为和状态。此外,同步方法可以根据一个或多个与 monitor 对象相关的 monitor conditions 来决定在何种情况下挂起或恢复他们的执行。
Monitor结构
Monitor机制中,共有四种角色:
监视者对象 Monitor Object
负责公共的接口方法,这些公共的接口方法会在多线程的环境下被调用执行。
同步方法:
这些方法是监视者对象所定义。为了防止竞争条件,无论是否同时有多个线程并发调用同步方法,还是监视者对象含有多个同步方法,在任一时间内只有监视者对象的一个同步方法能够被执行。
监视锁 (Monitor Lock):
每一个监视者对象都会拥有一把监视锁。
监视条件 (Monitor Condition):
同步方法使用监视锁和监视条件来决定方法是否需要阻塞或重新执行。如果拥有监视锁,则可以调用方法,而当满足某个监视条件,则会记录工作进度,交还监视锁。一直在监视条件上挂起(WAITING / TIMED_WAITING)。
Monitor序列图
在监视者对象模式中,在参与者之间将发生如下的协作过程:
同步方法的调用和串行化
当客户线程调用监视者对象的同步方法时,必须首先获取它的监视锁。只要该监视者对象有其他同步方法正在被执行,获取操作便不会成功。在这种情况下,客户线程将被阻塞直到它获取监视锁。当客户线程成功获取监视锁后,进入临界区,执行方法实现的服务。一旦同步方法完成执行,监视锁会被自动释放,目的是使其他客户线程有机会调用执行该监视者对象的同步方法。
同步方法线程挂起
如果调用同步方法的客户线程必须被阻塞或是有其他原因不能立刻进行,它能够在一个监视条件上等待,这将导致该客户线程暂时释放监视锁,并被挂起在监视条件上。
监视条件通知
一个客户线程能够通知一个监视条件,目的是为了让一个前期使自己挂起在一个监视条件上的同步方法线程恢复运行。
同步方法线程恢复
一旦一个早先被挂起在监视条件上的同步方法线程获取通知,它将继续在最初的等待监视条件的点上执行。在被通知线程被允许恢复执行同步方法之前,监视锁将自动被获取。图 1 描述了
监视者
对象的动态特性。图 1. Monitor Object Sequence Diagram.
Java Monitor Object
Java Monitor 从两个方面来支持线程之间的同步,即:互斥执行与协作。
互斥执行
对象内(对应到锁对象代码块内)的所有方法都互斥的执行。好比一个 Monitor 只有一个运行许可,任一个线程进入任何一个方法都需要获得这个许可,离开时把许可归还。
协作
通常提供signal(等待/通知)机制:允许正持有许可的线程暂时放弃许可,等待某个监视条件成真,条件成立后,当前线程可以通知正在等待这个条件的线程,让它可以重新获得运行许可。
- 线程进入同步方法中:
- 为了继续执行临界区代码,线程必须获取 Monitor 锁。如果获取锁成功,将成为该监视者对象的拥有者。任一时刻内,监视者对象只属于一个活动线程(The Owner)
- 拥有监视者对象的线程可以调用 wait() 进入等待集合(Wait Set),同时释放监视锁,进入等待状态。
- 其他线程调用 notify() / notifyAll() 接口唤醒等待集合中的线程,这些等待的线程需要重新获取监视锁后才能执行 wait() 之后的代码。
- 同步方法执行完毕了,线程退出临界区,并释放监视锁。
实质上,Java 的 Object 类本身就是监视者对象,Java 语言对于这样一个典型并发设计模式做了内建的支持。
Java 使用对象锁 ( 使用 synchronized 获得对象锁 ) 保证工作在共享的数据集上的线程互斥执行 , 使用 notify/notifyAll/wait 方法来协同不同线程之间的工作。这些方法在 Object 类上被定义,会被所有的 Java 对象自动继承。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)。
图 2. Java Monitor
ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象。
_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
Synchronized实现原理
Synchonized可用于普通同步方法、静态同步方法,以及局部代码块。
1 | // 普通方法 |
普通同步方法
当加在普通方法时,使用的锁为当前实例对象,及this对象
静态同步方法
当加在静态同步方法,锁是当前类的Class对象
局部代码块
当在局部代码块使用
synchronized
关键字时,使用的锁是括号中的对象
当我们使用synchronized
修饰方法名时,编译后会在方法名上生成一个ACC_SYNCHRONIZED标识来实现同步。
代码块同步使用monitorenter
与monitorexit
指令字节码来实现。每个monitorenter
必有与之对应的monitorexit
。monitorenter
会再编译后插入到同步代码块的开始位置,monitorexit
会插入到同步代码块结束以及异常的位置。当线程执行到monitorenter
时,会申请该代码块的锁。
测试:
1 | public class TestSync{ |
编译后javap -v TestSync.class
1 | PS C:\Users\skywater\Desktop> javap -v .\TestSync.class |
在JVM里,对象锁就是实现monitor机制的一种方式。entermonitor就是获得某个对象的lock(owner是当前线程),leavemonitor就是释放某个对象的锁。
对象头
synchronized
使用的锁是存在于对象头中。
如果锁是数组,则使用三个字宽来表示对象头,如果是非数组,则用两个字宽来表示。
1字宽=4字节=32bit
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark word | 存储对象的hashcode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象数据类型的指针 |
32/64bit | Array length | 如果是数组,则表示数组的长度* |
Mark Word
Java对象头Mark word中默认存储以下内容:
- HashCode
- 分代年龄
- 锁标记位
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的改变而改变,可能有以下四种情况
32位虚拟机
64位虚拟机
一些名词解释:
identity hash code是指不经重写过由jvm计算的hashcode*
unused:未使用的
hashcode:上文提到的identity hash code,本文出现的hashcode都是指identity hash code
thread: 偏向锁记录的线程标识
epoch: 验证偏向锁有效性的时间戳
age:分代年龄
biased_lock 偏向锁标志
lock 锁标志
pointer_to_lock_record 轻量锁lock record指针
pointer_to_heavyweight_monitor 重量锁monitor指针
Lock Record
当线程访问到同步代码块,如果锁对象的锁标志位为01(无锁状态),则会在线程栈中创建Lock Record(锁记录)空间,用于存储Mark Word的拷贝。官方称之为Displaced Mark Word。
Lock Record是线程私有的数据结构,每个线程都拥有一个可用的Lock Record列表,同时还有个全局的可用列表。当该线程拥有,会将锁对象的对象头中的Mark Word拷贝一份到其中一个Lock Record中。
每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word
),表示该锁被这个线程占用。
如下图所示为Lock Record的内部结构:
Lock Record | 描述 |
---|---|
Owner | 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL |
EntryQ | 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程 |
RcThis | 表示blocked或waiting在该monitor record上的所有线程的个数 |
Nest | 用来实现 重入锁的计数 |
HashCode | 保存从对象头拷贝过来的HashCode值(可能还包含GC age) |
Candidate | 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。 Candidate只有两种可能的值,0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。 |
锁类型与锁的升级
Java中锁共分为四种状态,级别从低到高分别为:无锁状态、偏向锁、轻量级锁、重量级锁
Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
锁可以被升级,但不能被降级。当偏向锁升级为轻量级锁后,无法降级为偏向锁。目的是为了提高获得锁和释放锁的效率。
偏向锁
并发访问一个同步代码块,在很多情况下,往往是同一个线程获取到这个代码块的执行权。为了降低进入同步代码块时获取锁造成的损耗,于是JDK1.6引入了偏向锁。
轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。适用于同一个线程多次访问同一个同步代码块。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态 (标志位为“01”) ,若处于活动状态,则膨胀为轻量级锁(标志位为“00”)。最后唤醒暂停的线程 。
此时的轻量级锁由原持有偏向锁的线程所有,并继续执行其同步代码,而正在竞争的线程会自旋等待该轻量级锁。(对于一个已经竞争到同步锁的线程,在还没有走出同步块的时候,时间片结束不会导致释放锁。)
流程可见下图:
关闭偏向锁
Java6与Java7中默认启用偏向锁,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
。
如果确定应用程序所有的锁基本都出在竞争状态下,则可以选择关闭偏向锁:-XX:-UseBiasedLocking=false
,此时,当申请锁时都会进入轻量级锁状态。
轻量级锁
当偏向锁发生多线程竞争时,会膨胀成轻量级锁。
在代码进入同步块时,如果此同步对象未被锁定,锁标志位“01”,虚拟机将在当前线程的栈帧中建立一个名为锁记录Lock Record的空间用于存储锁对象对象头中Mark Word的拷贝
在线程栈帧中创建Lock Record空间,并在其中创建所对象头Mark Word的拷贝,修改拷贝的Mark Word中的Owner内容为指向锁对象的指针。
修改锁标志位为00,将指向线程ID的指针记录到锁对象头中。
重量级锁
重量级锁依赖于Monitor实现,而Java中的ObjectMonitor基于系统底层的Mutex Lock(互斥锁)来实现。
Mutex Lock
保护代码临界区在同一时刻只有一个线程可以访问。如果线程访问时已上锁,则新线程进入阻塞(BLOCKED)。代码块执行结束后,需要对Mutex Lock
进行解锁。
Java线程进入阻塞或重新唤醒,需要操作系统从用户态转换为核心态,而频繁切换较为耗费系统资源,所以在JDK1.6之前,synchronized仅依赖于Monitor,频繁的状态切换导致执行效率较低,所以在1.6之后,在虚拟机层面引入了偏向锁以及轻量级锁,加入了直接判断线程ID以及通过一定时间的自旋,来避免使用重量级锁。
三种锁的对比
优势 | 劣势 | 适用场景 | |
---|---|---|---|
偏向锁 | 仅通过一次CAS操作markword填充当前线程的id,是则获得锁,不是则撤销并升级为轻量级锁。 | 如果程序运行存在过多的锁竞争,则会带来锁撤销的性能开销。 | 代码块偶尔发生竞争。 |
轻量级锁 | 如果线程未能获取到锁,则会进入高速的CAS自旋,而不进入阻塞,节省了线程之间上下文切换的开销。 | 自旋如果长时间竞争不到锁会消耗cpu。 | 代码块占用锁的时间较短。 |
重量级锁 | 不使用自旋,节约cpu。 | 依赖于Monitor,monitor依赖于系统底层Mutex Lock,用户态与核心态的切换降低了代码执行效率。 | 系统中竞争多,且锁占用时间较长。 |
附:全流程
参考
<<Java并发编程的艺术>>