在Java中,最简单粗暴的同步手段就是synchronized关键字,其同步的三种用法:
①.同步实例方法,锁是当前实例对象
②.同步类方法,锁是当前类对象
③.同步代码块,锁是括号里面的对象
示例:
public class SynchronizedTest { /** * 同步实例方法,锁实例对象 */ public synchronized void test() { } /** * 同步类方法,锁类对象 */ public synchronized static void test1() { } /** * 同步代码块 */ public void test2() { // 锁类对象 synchronized (SynchronizedTest.class) { // 锁实例对象 synchronized (this) { } } } } 复制代码
javap -verbose查看上述示例:
从图中我们可以看出:
同步方法:方法级同步没有通过字节码指令来控制,它实现在方法调用和返回操作之中。当方法调用时,调用指令会检查方法ACC_SYNCHRONIZED访问标志是否被设置,若设置了则执行线程需要持有管程(Monitor)才能运行方法,当方法完成(无论是否出现异常)时释放管程。
同步代码块:synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,每条monitorenter指令都必须执行其对应的monitorexit指令,为了保证方法异常完成时这两条指令依然能正确执行,编译器会自动产生一个异常处理器,其目的就是用来执行monitorexit指令(图中14-18、24-30为异常流程)。
具体看下monitorexit指令做了什么,在Hotspot源码中全文搜索monitorenter,在ciTypeFlow.cpp中找到如下:
case Bytecodes::_monitorenter: { pop_object(); set_monitor_count(monitor_count() + 1); break; } case Bytecodes::_monitorexit: { pop_object(); assert(monitor_count() > 0, "must be a monitor to exit from"); set_monitor_count(monitor_count() - 1); break; } void pop_object() { assert(is_reference(type_at_tos()), "must be reference type"); pop(); } void pop() { debug_only(set_type_at_tos(bottom_type())); _stack_size--; } int monitor_count() const { return _monitor_count; } void set_monitor_count(int mc) { _monitor_count = mc; } 复制代码
从源码中我们发现 当线程获得该对象锁后,计数器就会加一,释放锁就会将计数器减一 。
每个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态,如图:
Monitor是线程私有的数据结构,每个线程都有一个可用monitor record列表,同时 还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:
Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程
RcThis:表示blocked或waiting在该monitor record上的所有线程的个数
Nest:用来实现重入锁的计数
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁
jdk1.6中synchronized的实现进行了各种优化,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁,主要解决三种场景:
①.只有一个线程进入临界区,偏向锁
②.多线程交替进入临界区,轻量级锁
③.多线程同时进入临界区,重量级锁
偏向锁→轻量级锁→重量级锁过程, 锁可以升级但不能降级 ,这种策略是为了提高获得锁和释放锁的效率,源码解析可以看 占小狼——synchronized实现
引入偏向锁的目的是:在没有多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。相对于轻量级锁,偏向锁只依赖一次CAS原子指令置换ThreadID,不过一旦出现多个线程竞争时必须撤销偏向锁,主要校验是否为偏向锁、锁标识位以及ThreadID。
①.获取对象的对象头里的Mark Word
②.检测Mark Word是否为可偏向状态,即mark的偏向锁标志位为1,锁标识位为01
③.若为可偏向状态,判断Mark Word中的线程ID是否为当前线程ID,如果指向当前线程执行⑥,否则执行④
④.通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行⑤
⑤.通过CAS竞争锁失败,证明当前存在多线程竞争,当到达safepoint全局安全点( 这个时间点是上没有正在执行的代码 ),获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级,升级完成后被阻塞在安全点的线程继续执行同步代码块
⑥.执行同步代码块
线程是不会主动去释放偏向锁,只有当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,释放锁需要等待全局安全点。步骤如下:
①.暂停拥有偏向锁的线程,判断锁对象石是否处于被锁定状态
②.撤销偏向锁,恢复到无锁状态(01)或者轻量级锁(00)的状态
引入轻量级锁的主要目的是在多线程没有竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁, 在有多线程竞争的情况下,轻量级锁比重量级锁更慢 。
①.获取对象的对象头里的Mark Word
②.判断当前对象是否处于无锁状态,即mark的偏向锁标志位为0,锁标志位为 01
③.若是,JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),然后执行④;若不是执行⑤
④.JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行⑤
⑤.判断当前对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经持有这个对象的锁,则直接执行同步代码块;否则说明该锁对象已经被其他线程抢占了,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
①.如果对象的Mark Word仍然指向着线程的锁记录,执行②
②.用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果成功,则说明释放锁成功,否则执行③
③.如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。