Synchronized原理

1.Syncronized应用

synchronized是java中加锁的关键字,能够用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程能够执行这段代码。另外一个线程必须等待当前线程执行完这个代码块之后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另外一个线程仍能够访问该object中的非加锁代码块。java

1.1 三种应用方式

synchronized关键字最主要有如下3种应用方式,下面分别介绍程序员

修饰实例方法:做用于当前实例加锁,进入同步代码前要得到当前实例的锁数组

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
复制代码

上述代码中,咱们开启两个线程操做同一个共享资源即变量i,因为i++;操做并不具有原子性,该操做是先读取值,而后写回一个新值,至关于原来的值加上1,分两步完成,若是第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一块儿看到同一个值,并执行相同值的加1操做,这也就形成了线程安全失败,所以对于increase方法必须使用synchronized修饰,以便保证线程安全。此时咱们应该注意到synchronized修饰的是实例方法increase,在这样的状况下,当前线程的锁即是实例对象instance,注意Java中的线程同步锁能够是任意对象。从代码执行结果来看确实是正确的,假若咱们没有使用synchronized关键字,其最终输出结果就极可能小于2000000,这即是synchronized关键字的做用。安全

修饰静态方法:做用于当前类对象(Class对象,每一个类都有一个Class对象),进入同步代码前要得到当前类对象(Class对象)的锁bash

public class AccountingSyncClass implements Runnable{
    static int i=0;

    /**
     * 做用于静态方法,锁是当前class对象,也就是
     * AccountingSyncClass类对应的class对象
     */
    public static synchronized void increase(){
        i++;
    }

    /**
     * 非静态,访问时锁不同不会发生互斥
     */
    public synchronized void increase4Obj(){
        i++;
    }

    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新实例
        Thread t1=new Thread(new AccountingSyncClass());
        //new新实例
        Thread t2=new Thread(new AccountingSyncClass());
        //启动线程
        t1.start();t2.start();

        t1.join();t2.join();
        System.out.println(i);
    }
}
复制代码

因为synchronized关键字修饰的是静态increase方法,与修饰实例方法不一样的是,其锁对象是当前类的class对象。注意代码中的increase4Obj方法是实例方法,其对象锁是当前实例对象,若是别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不一样,但咱们应该意识到这种状况下可能会发现线程安全问题(操做了共享静态变量i)。markdown

修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要得到给定对象的锁。数据结构

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    @Override
    public void run() {
        //省略其余耗时操做....
        //使用同步代码块对变量i进行同步操做,锁对象为instance
        synchronized(instance){
            for(int j=0;j<1000000;j++){
                    i++;
              }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}
复制代码

从代码看出,将synchronized做用于一个给定的括号里的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,若是当前有其余线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操做。固然除了instance做为对象外,咱们还可使用this对象(表明当前实例)或者当前类的class对象做为锁,以下代码:多线程

//this,当前实例对象锁
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

//class对象锁
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}
复制代码

以上就是java中synchronized关键字的用法,很简单,接下来咱们先介绍一些基础知识,而后一步一步说明synchronize关键字的低层实现原理。app

2.Java对象头

HotSpot虚拟机中,对象在内存中存储的布局能够分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
普通对象的对象头包括两部分:Mark Word 和 Class Metadata Address (类型指针),若是是数组对象还包括一个额外的Array length数组长度部分。ide

Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。

Class Metadata Address:类型指针指向对象的类元数据,虚拟机经过这个指针肯定该对象是哪一个类的实例。

Array length:数组长度

若是对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,若是对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。

长度 内容 说明
32/64bit Mark Word 存储对象hashCode或锁信息等运行时数据。
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(若是当前对象是数组)

对象须要存储的运行时数据不少,其实已经超出了3二、64位Bitmap结构所能记录的限度,可是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用本身的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,MarkWord的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其余状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容以下表所示。

此处可能存在疑问,无锁状态时,Mark Word中会存储hashCode等信息,在有锁状态时,位置被锁指针占用,那hashCode等信息要存到哪里?是没有了吗?这个问题在后面monitor先关的小节会解答。

2.Monitor对象

什么是Monitor?咱们能够把它理解为一个同步工具,也能够描述为一种同步机制,它一般被描述为一个对象。与一切皆对象同样,全部的Java对象是天生的Monitor,每个Java对象都有成为Monitor的潜质,由于在Java的设计中,每个Java对象自打娘胎里出来就带了一把看不见的锁,它叫作内部锁或者Monitor锁。

每一个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor能够与对象一块儿建立销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构以下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)。

ObjectMonitor() {
   _header       = NULL;
   _count        = 0; //记录个数
   _waiters      = 0,
   _recursions   = 0;
   _object       = NULL;
   _owner        = NULL;
   _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
   _WaitSetLock  = 0 ;
   _Responsible  = NULL ;
   _succ         = NULL ;
   _cxq          = NULL ;
   FreeNext      = NULL ;
   _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
   _SpinFreq     = 0 ;
   _SpinClock    = 0 ;
   OwnerIsThread = 0 ;
 }
复制代码

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

3.Synchronized原理

3.1 同步代码块

public class SyncCodeBlock {
    public int i;

    public void syncTask(){
        synchronized (this){
            i++;
        }
    }
}
复制代码

编译上述代码并使用javap反编译后获得字节码以下(这里咱们省略一部分没有必要的信息):

public class com.fufu.concurrent.SyncCodeBlock {
  public int i;

  public com.fufu.concurrent.SyncCodeBlock();
    Code:
       0: aload_0
       1: invokespecial #1 // Method java/lang/Object."<init>":()V
       4: return

  public void syncTask();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter                   //注意此处,进入同步方法
       4: aload_0
       5: dup
       6: getfield      #2 // Field i:I
       9: iconst_1
      10: iadd
      11: putfield      #2 // Field i:I
      14: aload_1
      15: monitorexit                    //注意此处,退出同步方法
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit                    //注意此处,退出同步方法
      22: aload_2
      23: athrow
      24: return
    Exception table:
       from    to  target type
           4    16    19   any
          19    22    19   any
}

复制代码

从字节码中可知同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor的进入计数器为 0,那线程能够成功取得monitor,并将计数器值设置为1,取锁成功。

若是当前线程已经拥有 objectref 的 monitor 的持有权,那它能够重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。假若其余线程已经拥有 objectref 的 monitor的全部权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其余线程将有机会持有 monitor 。

值得注意的是编译器将会确保不管方法经过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而不管这个方法是正常结束仍是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然能够正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理全部的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也能够看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

3.2 同步方法

public class SyncMethod {

   public int i;

   public synchronized void syncTask(){
           i++;
   }
}
复制代码

方法级的同步是隐式,即无需经过字节码指令来控制的,它实如今方法调用和返回操做之中。JVM能够从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED访问标志是否被设置,若是设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),而后再执行方法,最后再方法完成(不管是正常完成仍是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其余任何线程都没法再得到同一个monitor。若是一个同步方法执行期间抛出了异常,而且在方法内部没法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法以外时自动释放。下面咱们看看字节码层面如何实现:

//省略不必的字节码
  //==================syncTask方法======================
  public synchronized void syncTask();
    descriptor: ()V
    //方法标识ACC_PUBLIC表明public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2 // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2 // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
复制代码

从字节码中能够看出,synchronized修饰的方法并无monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM经过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这即是synchronized锁在同步代码块和同步方法上实现的基本原理。

4. 锁优化

上一节看出,Synchronized的实现依赖于与某个对象向关联的monitor(监视器)实现,而monitor是基于底层操做系统的Mutex Lock实现的,而基于Mutex Lock实现的同步必须经历从用户态到核心态的转换,这个开销特别大,成本很是高。因此频繁的经过Synchronized实现同步会严重影响到程序效率,而这种依赖于Mutex Lock实现的锁机制也被称为“重量级锁”,为了减小重量级锁带来的性能开销,JDK对Synchronized进行了种种优化。

Java SE1.6为了减小得到锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,因此在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争状况逐渐升级。锁能够升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

在看下面内容以前,若是不熟悉CAS是什么的话,强烈建议看一下这篇关于CAS机制的博客,java的锁优化基本上就是基于CAS,对于理解下面内容有很大帮助。《深刻浅出CAS》

4.1 偏向锁

Hotspot的做者通过以往的研究发现大多数状况下锁不只不存在多线程竞争,并且老是由同一线程屡次得到,为了让线程得到锁的代价更低而引入了偏向锁。

获取锁

  1. 检测Mark Word是否为可偏向状态,便是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,若是是,则执行步骤(5),不然执行步骤(3);
  3. 若是线程ID不为当前线程ID,则经过CAS操做竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,不然执行线程(4);
  4. 经过CAS竞争锁失败,证实当前存在多线程竞争状况,当到达全局安全点,得到偏向锁的线程被挂起,偏向锁升级为轻量级锁,而后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块

释放锁: 偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,须要等待其余线程来竞争。偏向锁的撤销须要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤以下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;

此时,解答一下前面的小节中提出的问题,在有锁状态时,位置被锁指针占用,那hashCode等信息要存到哪里?是没有了吗?通过在网上苦苦搜寻,终于找到了大神关于次问题的恢复,下面先看偏向锁的状况,偏向锁时,mark word中记录了线程id,没有足够的额外空间存储hashcode,因此,答案是:

  1. 当一个对象已经计算过identity hash code,它就没法进入偏向锁状态;
  2. 当一个对象当前正处于偏向锁状态,而且须要计算其identity hash code的话,则它的偏向锁会被撤销,而且锁会膨胀为重量锁;
  3. 重量锁的实现中,ObjectMonitor类里有字段能够记录非加锁状态下的mark word,其中能够存储identity hash code的值。或者简单说就是重量锁能够存下identity hash code。

请必定要注意,这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。

由于mark word里没地方同时放bias信息和identity hash code。 HotSpot VM是假定“实际上只有不多对象会计算identity hash code”来作优化的;换句话说若是实际上有不少对象都计算了identity hash code的话,HotSpot VM会被迫使用比较不优化的模式。

做者:RednaxelaFX 连接:www.zhihu.com/question/52… 来源:知乎

4.2 轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减小传统的重量级锁使用操做系统互斥量产生的性能消耗。 当关闭偏向锁功能或者多个线程竞争偏向锁致使偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤以下:

获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),如果,则JVM首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);不然执行步骤(3);
  2. JVM利用CAS操做尝试将对象的Mark Word更新为指向Lock Record的指正,若是成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操做;若是失败则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,若是是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;不然只能说明该锁对象已经被其余线程抢占了,这时轻量级锁须要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁: 轻量级锁的释放也是经过CAS操做来进行的,主要步骤以下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操做将取出的数据替换当前对象的Mark Word中,若是成功,则说明释放锁成功,不然执行(3);
  3. 若是CAS操做替换失败,说明有其余线程尝试获取该锁,则须要在释放锁的同时须要唤醒被挂起的线程。

轻量级锁状态时,位置被锁指针占用,那hashCode等信息要存到哪里?这里的问题就比较简单了,由于有拷贝的mark word,因此Displaced Mark Word中存在所须要的信息。

4.3 重量级锁

重量级锁经过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操做系统的Mutex Lock实现,操做系统实现线程之间的切换须要从用户态到内核态的切换,切换成本很是高。

4.4 自旋锁

轻量级锁失败后,虚拟机为了不线程真实地在操做系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数状况下,线程持有锁的时间都不会太长,若是直接挂起操做系统层面的线程可能会得不偿失,毕竟操做系统实现线程之间的切换时须要从用户态转换到核心态,这个状态之间的转换须要相对比较长的时间,时间成本相对较高,所以自旋锁会假设在不久未来,当前的线程能够得到锁,所以虚拟机会让当前想要获取锁的线程作几个空循环(这也是称为自旋的缘由),通常不会过久,多是50个循环或100循环,在通过若干次循环后,若是获得锁,就顺利进入临界区。若是还不能得到锁,那就会将线程在操做系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是能够提高效率的。最后没办法也就只能升级为重量级锁了。自旋是把双刃剑,若是旋的时间过长会影响总体性能,时间太短又达不到延迟阻塞的目的。显然,自旋的周期选择显得很是重要,但这与操做系统、硬件体系、系统的负载等诸多场景相关,很难选择,若是选择不当,不但性能得不到提升,可能还会降低。

4.5 适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数再也不是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么作呢?线程若是自旋成功了,那么下次自旋的次数会更加多,由于虚拟机认为既然上次成功了,那么这次自旋也颇有可能会再次成功,那么它就会容许自旋等待持续的次数更多。反之,若是对于某个锁,不多有自旋可以成功的,那么在之后要或者这个锁的时候自旋的次数会减小甚至省略掉自旋过程,以避免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的情况预测会愈来愈准确,虚拟机会变得愈来愈聪明。

4.6 锁消除

为了保证数据的完整性,咱们在进行操做时须要对这部分操做进行同步控制,可是在有些状况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 若是不存在竞争,为何还须要加锁呢?因此锁消除能够节省毫无心义的请求锁的时间。变量是否逃逸,对于虚拟机来讲须要使用数据流分析来肯定,可是对于咱们程序员来讲这还不清楚么?咱们会在明明知道不存在数据竞争的代码块前加上同步吗?可是有时候程序并非咱们所想的那样?咱们虽然没有显示使用锁,可是咱们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操做。好比StringBuffer的append()方法,Vector的add()方法。

4.7 锁的膨胀流程

在前面偏向锁和轻量级锁的小节中已经大概了解的锁的膨胀流程:

偏向锁->轻量级锁->重量级锁

偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。

一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它如今认为只可能有一个线程来访问它,因此当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。

偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操做,并将对象头中的ThreadID改为本身的ID,以后再次访问这个对象时,只须要对比ID,不须要再使用CAS在进行操做。

一旦有第二个线程访问这个对象,由于偏向锁不会主动释放,因此第二个线程能够看到对象是偏向状态,这时代表在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,若是挂了,则能够将对象变为无锁状态,而后从新偏向新的线程,若是原来的线程依然存活,则立刻执行那个线程的操做栈,检查该对象的使用状况,若是仍然须要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。若是不存在使用了,则能够将对象回复成无锁状态,而后从新偏向。

轻量级锁认为竞争存在,可是竞争的程度很轻,通常两个线程对于同一个锁的操做都会错开,或者说稍微等待一下(自旋),另外一个线程就会释放锁。 可是当自旋超过必定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程之外的线程都阻塞,防止CPU空转。

下面这张图,很好的说明了锁的膨胀流程。

相关文章
相关标签/搜索