Synchronized 原理分析

660 阅读9分钟
  1. 在阅读源码时做了大量的注释,并且做了一些测试分析源码内的执行流程,由于博客篇幅有限,并且代码阅读起来没有 IDE 方便,所以在 github 上提供JDK1.8 的源码、详细的注释及测试用例。欢迎大家 star、fork !

  2. 由于个人水平有限,对源码的分析理解可能存在偏差或不透彻的地方还请大家在评论区指出,谢谢!

1. synchronized 介绍

   在并发程序中,这个关键字可能是出现频率最高的一个字段,他可以避免多线程中的安全问题,对代码进行同步。同步的方式其实就是隐式的加锁,加锁过程是有 jvm 帮我们完成的,再生成的字节码中会有体现,如果反编译带有不可消除的 synchronized 关键字的代码块的 class 文件我们会发现有两个特殊的指令 monitorentermonitorexit ,这两个就是进入管程和退出管程。为什么说不可消除的 synchronized ,这是由于在编译时期会进行锁优化,比如说在 StringBuffer 中是加了锁的,也就是锁对象就是他自己,然而我们编译以后会发现根本没有上面的两条指令就是因为,锁消除技术。

   Synchronized 使用的一般场景,在对象方法和类方法上使用,以及自定义同步代码块。但是在方法上使用 Synchronized 关键字和使用同步代码块是不一样的,方法上采用同步是采用的字节码中的标志位 ACC_SYNCHRONIZED 来进行同步的。而同步代码块则是采用了对象头中的锁指针指向一个监视器(锁),来完成同步。

   当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor ,获取成功之后才能执行方法体,方法执行完后再释放 monitor 。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

2. 对象头和锁

一个对象在内存中分为三部分:对象头、实例数据、对齐填充。

  1. 对象头中主要存放了 GC 分代年龄、偏向锁、偏向 id、锁类型、hash 值等。jvm 一般会用两个字来存放对象头,(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成。MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式

  2. 实例数据就包括对象字段的值,不仅有自己的值还有继承自父类的字段的值。一般字段的顺序是同类型的字段放在一起,空间比较大的字段放在前面。在满足上面的规则下父类的放在子类的前面。

  3. 对其填充并非必要的,整个对象需要是 8 字节的整数倍,当不足的时候会进行填充以达到 8 字节整数倍,主要还是为了方便存取。

这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(在 Synchronized 代码块中的监视器 )的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下。

ObjectMonitor() {
    _count        = 0; //记录个数
    _owner        = NULL; // 运行的线程
    //两个队列
    _WaitSet      = NULL; //调用 wait 方法会被加入到_WaitSet
   _EntryList    = NULL ; //锁竞争失败,会被加入到该列表
  }

   ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

3. Synchronized 代码块原理

反编译下面的代码得到的字节码如下:

public class SynchronizedTest {
    public static void main(String[] args) {
        synchronized (SynchronizedTest.class) {
            System.out.println("hello");
        }
    }

    public synchronized void test(){

    }
}

   当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor ,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。所以看到上面有两条 monitorexit !

4. Synchronized 方法原理

   先看一个反编译的实例方法的结果,确实比普通的方法多了一个标志字段。方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor , 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

5. 偏向锁

   偏向锁是 Java 为了提高程序的性能而设计的一个比较优雅的加锁方式。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做获取锁的过程。如果有其他线程竞争锁的时候就需要膨胀为轻量级锁。这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

   所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

   偏向锁获取的过程如下,当锁对象第一次被线程获取的时候,虚拟机把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中的偏向线程ID,并将是否偏向锁的状态位置置为1。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,直接检查ThreadId是否和自身线程Id一致, 如果一致,则认为当前线程已经获取了锁,虚拟机就可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。

   其实一般来说偏向锁很少又说去主动释放的,因为只有在其他线程需要获取锁的时候,也就是这个锁不仅仅被一个线程使用,可能有两个线程交替使用,根据对象是否被锁定来决定释放锁(恢复到未锁定状态)还是升级到轻量锁状态。

6.轻量级锁

   轻量级锁,一般指的是在有两个线程在交替使用锁的时候由于没有同时抢锁属于一种比较和谐的状态,就可以使用轻量级锁。他的基本思想是,当线程要获取锁时把锁对象的 Mark Word 复制一份到当前线程的栈顶,然后执行一个 CAS 操作把锁对象的 Mark Word 更新为指向栈顶的副本的指针,如果成功则当前线程拥有了锁。可以进行同步代码块的执行,而失败则有两种可能,要么是当前线程已经拥有了锁对象的指针,这时可以继续执行。要么是被其他线程抢占了锁对象,这时候说明了在同一时间有两个线程同时需要竞争锁,那么就打破了这种和谐的局面需要膨胀到重量级锁,锁对象的标志修改,获取线程的锁等待。    在轻量级锁释放的过程就采用 CAS 把栈上的赋值的 Mark Word 替换到锁对象上,如果失败说明有其他线程执抢占过锁,锁对象的 Mark Word 的标志被修改过,在释放的同时唤醒等待的线程。