Java synchronized深度探幽


造成线程安全问题的主要原因有两点:

  1. 存在共享数据(也称临界资源)。
  2. 存在多条线程,共同操作共享数据。

01. 应用概述

synchronized 是解决Java并发最常见的一种方法,也是最简单的一种方法。synchronized 保证同一时刻只有一个线程访问某个同步方法或代码块,同时保证线程间访问的可见性。注意:synchronized 是一个互斥重量级锁

  • synchronized的作用
    01) 确保线程互斥访问代码。
    02) 保证共享变量的修改能够及时可见(可见性)。
    03) 阻止JVM指令重排序。
  • synchronized实现同步的基础,Java中所有对象都可以作为锁,这是。synchronized主要有三种应用方式
    01) 普通同步方法,锁对象是当前实例的对象。
    02) 静态同步方法,锁对象是静态方法所在的类对象。
    03) 同步代码块,锁对象是括号里的对象。(可以是实例对象,也可以是类对象)。

02. 原理概要

Java虚拟机的synchronization都是基于Monitor对象实现进入和退出,无论是显示(同步代码块)还是隐式同步(同步方法)都是如此。

  • 同步代码块
    01) monitorenter指令插入到同步代码块的开始位置。monitorexit指令插入到同步代码块结束的位置,JVM内部保证monitorenter和monitorexit成对出现。
    02) 任何锁对象,都有一个monitor与之关联,当monitor被持有以后,它将处于锁定状态。线程执行到monitorenter指令时,会尝试获得monitor对象的所有权,即尝试获取锁。
    03) 虚拟机规范对monitorenter 和 monitorexit行为描述中,有两点需要注意。首先 synchronized 同步快对于同一条线程来说是可重入的,也就是说,不会出现把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

  • 同步方法
    JVM编译时,synchronized方法编译为普通方法调用和返回指令(如:invokevirtual、areturn)。在JVM字节码层面并没有任何特别的指令来实现synchronized修饰的方法,而是在.class文件的方法表中,将方法的access_flags字段的synchronized标志位置1,表示该方法是同步方法,在JVM内部使用调用该方法对象或该方法所属Class做为锁对象。

03. 原理详解

要理解底层实现,就需要理解两个重要的概念Mark WordMonitor

3.1 Java对象头

  • synchronized锁是存储在对象头中的(Java对象上锁的根本原因)。
  • HotSpot虚拟机中,对象头包括:Mark Word、 Klass Pointer(类型指针)和数组长度(只有对象数组用到)。

    Mark Work :存储对象自身的运行时数据。如:哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,在32和64位JVM中分别占用32和64位。
    类型指针:指向对象类元数据指针。
    数组长度:保存对象数组的长度。

下图是对象头运行时的变化状态:锁标志位是否偏向锁确定唯一的锁状态。其中,轻量锁偏向锁是JDK1.6之后新加的,用于对synchronized优化。
在这里插入图片描述
在这里插入图片描述

3.2 Monitor

Monitor是synchronized锁的实现关键。JDK1.6之前只有重量级锁,锁的标识位为10 ,非常消耗性能的。在JDK1.6以后,为了优化引入了偏向锁、轻量级锁。

  • Monitor是线程私有的。
  • 当线程试图获取对象锁时,自动创建。
  • 每个锁对象持有1个Monitor对象。
  • 线程持有Monitor之前,会封装该线程为ObjectWaiter对象。
  • 某个线程持有Monitor后,该Monitor处于锁定状态。
  • 锁定状态下,锁对象的MarkWord指向互斥量的指针,就是指向锁对象的Monitor起始地址。

Monitor是由ObjectMonitor实现的,C++定义的数据结构:

// 源码文件:位于HotSpot虚拟机ObjectMonitor.hpp源码
ObjectMonitor() {
    // C volatile线程安全
    volatile markOop   _header;
    volatile intptr_t  _count;        // 记录该线程获取锁的次数
    volatile intptr_t  _waiters,      // 处于wait的线程数
    volatile intptr_t  _recursions;   // 锁的重入次数
    void*     volatile _object;
    void *  volatile _owner;          // 指向持有ObjectMonitor的线程
    ObjectWaiter * volatile _WaitSet; // 处于wait状态的线程,会被加入到_WaitSet
    volatile int _WaitSetLock;        // 保护等待队列 - 简单的自旋锁
    Thread * volatile _Responsible;
    Thread * volatile _succ;
    ObjectWaiter * volatile _cxq;
    ObjectMonitor * FreeNext;
    ObjectWaiter * volatile _EntryList; // 处于等待锁block状态的线程,会被加入到该列表
    volatile int _SpinFreq;
    volatile int _SpinClock;
    int OwnerIsThread;                 // 判断当前ower是否是线程
  }

ObjectMonitor有两个队列:_EntryList_WaitSet,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner 指向持有ObjectMonitor的线程。

根据虚拟机规范的要求,在执行monitorenter指令时,会尝试获取对象的锁。如果对象没有被锁定(获取锁),获取对象已经被该线程锁定或锁重入。则把_count加1(_count加1)。相应的,在执行monitorexit指令时,会将计数器减一(_count减1)。当计数器为0时,_owner指向Null,锁就被释放。

  • 当多个线程同时访问一个同步代码时,首先会进入_EntryList 集合,当线程获取到对象的Monitor后,会进入_owner区域,然后把Monitor中的_owner变量修改为当前线程,同时Monitor中的计数器_count会加1。
  • 若线程调用wait()方法,将释放当前持有的Monitor,owner变量恢复为null,count自减1,同时该线程进入_WaitSet集合中等待被唤醒。
  • 若当前线程执行完毕,释放并复位Monitor,以便其他线程获取锁。
    在这里插入图片描述

04. 底层实现

4.1 synchronized 代码块底层原理

从javac编译成的字节码可以看出,同步代码块使用的是monitorenter和monitorexit指令,其中monitorenter指向同步代码块的开始位置,monitorexit指向同步代码块的结束位置。

  • 在线程执行到monitorenter指令时,当前线程将尝试获取锁,即尝试获取锁对象对应的monitor的持有权。当monitor的count计数器为0,或者monitor的owner已经是该线程,则获取锁,count计数器+1。
  • 如果其他线程已经持有该对象的锁,则该线程被阻塞,直到其他线程执行完毕释放锁。
    线程执行完毕时,count归零,owner指向Null,锁释放。
  • 注意:编译器会确保,无论通过何种方法完成,方法中的每一条monitorenter指令,最终都会有monitorexit指令对应,不论这个方法正常结束还是异常结束,最终都会配对执行。
  • 编译器会自动产生一个异常处理器,这个处理器声明可以处理所有的异常,它的目的就是为了确保monitorexit指令最终执行。

4.2 synchronized 方法底层原理

方法的同步是隐式,即无需通过字节码来控制的,它实现在方法调用和返回操作中。

  • 在Class方法常量池方法表结构(method_info Structure)中,用ACC_SYNCHRONIZED标志方法是否为同步方法。在方法被调用时,会检查方法的 ACC_SYNCHRONIZED标记是否被设置。如果被设置了,则线程将持有该方法对应对象的Monitor(调用方法的实例对象or静态方法的类对象),然后再执行该方法。
  • 最后在方法执行完成时,释放monitor。
  • 在方法执行期间,执行线程持有了Monitor,其他任何线程都无法再获得同一个Monitor。
    public class SyncMethod {
       public int i;
       public synchronized void syncTask(){
          i++;
       }
    }
    
    javap反编译的字节码:
    Classfile SyncMethod.class
      Last modified 2017-6-2; size 308 bytes
      MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
      Compiled from "SyncMethod.java"
    public class SyncMethod
      minor version: 0
      major version: 52
      flags: ACC_PUBLIC, ACC_SUPER
    Constant pool;
       //省略没必要的字节码
      //==================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
    }
    SourceFile: "SyncMethod.java"
    
    从字节码可以看出,synchronized修饰的方法并没有monitorenter和monitorexit指令。而是用ACC_SYNCHRONIZED的flag标记该方法是否是同步方法,从而执行相应的同步调用。

05. 锁的状态和优化

5.1 低版本锁问题

在早期jdk版本中,synchronized属于重量级锁。线程监视器锁(Monitor)依赖于底层操作系统的Mutex Lock实现,操作系统实现线程切换时,需要从用户态切换到核心态,这是一个非常重的操作,时间成本也很高,导致效率低下。

  • JDK1.6之后JVM官方对锁做了较大优化,引入了:
    锁粗化(Lock Coarsening)
    锁消除(Lock Elimination)
    适应性自旋(Adaptive Spinning)
  • 同时增加了两种锁的状态:
    偏向锁(Biased Locking)
    轻量锁(Lightweight Locking)

5.2 锁状态

锁的状态共有四种:无锁,偏向锁,轻量锁,重量锁。随着锁的竞争,锁会从偏向锁升级到轻量锁,然后升级为重量锁。锁的升级是单向的,JDK1.6中默认开启偏向锁和轻量锁。

5.2.1 偏向锁

  • 引入偏向锁的目的是:为了解决无线程竞争的情况下,尽量减少不必要的轻量锁执行路径。

    01) 因为经过研究发现,在大部分情况下,锁并不存在多线程竞争,而且总是由一个线程多次获得锁。因此为了减少同一线程获取锁(会涉及到一些耗时的CAS操作)的代价而引入。
    02) 如果一个线程获取到了锁,那么该锁就进入偏向锁模式,当这个线程再次请求锁时无需做任何同步操作,直接获取到锁。这样就省去了大量有关锁申请的操作,提升了程序性能。

  • 获取偏向锁:
    01) 检查Mark Word 是否为可偏向状态,即是否为偏向锁,锁标志位=01。
    02) 若为可偏向状态,则检查线程ID是否为当前对象头中的线程ID,如果是,则获取锁,执行同步代码块。如果不是,进入第3步。
    03) 如果线程ID不是当前线程ID,则通过CAS操作竞争锁,如果竞争成功。则将Mark Word中的线程ID替换为当前线程ID,获取锁,执行同步代码块。如果没成功,进入第4步。
    04) 通过CAS竞争失败,则说明当前存在锁竞争。当执行到达全局安全点时,获得偏向锁的进程会被挂起,偏向锁膨胀为轻量级锁(重要),被阻塞在安全点的线程继续往下执行同步代码块。

  • 释放偏向锁:
    偏向锁的释放,采取了一种只有竞争才会释放锁的机制,线程不会主动去释放锁,需要等待其他线程来竞争。偏向锁的撤销需要等到全局安全点,步骤如下:
    01) 暂停拥有偏向锁的线程,判断对象是否还处于被锁定的状态。
    02) 撤销偏向锁。恢复到无锁状态(01)或者膨胀为轻量级锁。
    在这里插入图片描述

5.2.2 轻量级锁

  • 为何需要轻量级锁?
    轻量锁能够提升性能的依据,是基于如下假设:在真实情况下,同步代码很多时候都处于一种无锁竞争的状态(即单线程环境),避免操作系统层面调用(重量级锁)实现线程的切换,通过设置合理的CAS时间实现线程切换更节省资源以及时间。

当关闭偏向锁功能或者多个线程竞争偏向锁导致升级为轻量锁,则会尝试获取轻量锁。

  • 获取轻量锁
    01) 判断当前对象是否处于无锁状态(偏向锁标记=00,无锁状态=01),如果是,则JVM会将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储当前对象的Mark Word拷贝(官方称为Displaced Mark Word)。接下来执行第2步。如果对象处于有锁状态,则执行第3步
    02) JVM利用CAS操作,尝试将对象的Mark Word更新为指向Lock Record的指针。如果成功,则表示竞争到锁。将锁标志位变为00(表示此对象处于轻量级锁的状态),执行同步代码块。如果CAS操作失败,则执行第3步。
    03) 判断当前对象的Mark Word 是否指向当前线程的栈帧,如果是,则表示当前线程已经持有当前对象的锁,直接执行同步代码块。否则,说明该锁对象已经被其他对象抢占,此后为了不让线程阻塞,还会进入一个自旋锁的状态,如在一定的自旋周期内尝试重新获取锁,如果自旋失败,则轻量锁需要膨胀为重量锁(重点),锁标志位变为10,后面等待的线程将会进入阻塞状态。
  • 释放轻量锁
    轻量级锁的释放操作,也是通过CAS操作来执行的,步骤如下:
    01) 取出在获取轻量级锁时,存储在栈帧中的 Displaced Mard Word 数据。
    02) CAS操作,取出的数据替换到对象的Mark Word中,成功,说明释放锁成功;失败,执行第3步。
    03) 如果CAS操作失败,说明有其他线程在尝试获取该锁,则要在释放锁的同时唤醒被挂起的线程。
    在这里插入图片描述

5.2.3 重量级锁

  • 重量级锁通过对象内部的监视器(Monitor)来实现,而其中monitor本质上是依赖于低层操作系统的 Mutex Lock实现。
  • 操作系统实现线程切换,需要从用户态切换到内核态,切换成本非常高。

5.2.4 适应性自旋锁

  • 为何需要自适应自旋锁?
    假设:大多数情况下,线程持有锁的时间不会太长,将线程挂起在操作系统层面成本较高。而“适应性”表示,自学的周期更加聪明。自旋的周期是不固定的,它是由上一次在同一个锁上的自旋时间以及锁拥有者的状态共同决定。
    具体方式:如果自旋成功了,那么下次的自旋最大次数会更多,因为JVM认为既然上次成功了,那么这一次也有很大概率会成功,那么允许等待的最大自旋时间也相应增加。反之,如果对于某一个锁,很少有自旋成功的,那么就会相应的减少下次自旋时间,或者干脆放弃自旋,直接升级为重量锁,以免浪费系统资源。
  • 聪明的虚拟机
    有了适应性自旋,随着程序的运行信息不断完善,JVM会对锁的状态预测更加精准,虚拟机会变得越来越聪明。

06. 锁的优化

6.1 锁粗化

  • 在使用锁的时候,需要让同步的作用范围尽可能的小——仅在共享数据的操作中才进行。这样做的目的,是为了让同步操作的数量尽可能小,如果存在锁竞争,那么也能尽快的拿到锁。
  • 在大多数的情况下,上面的原则是正确的。但是如果存在一系列连续的 lock unlock 操作,也会导致性能的不必要消耗。
  • 粗化锁就是将连续的同步操作连在一起,粗化为一个范围更大的锁。

例如,对Vector的循环add操作,每次add都需要加锁,那么JVM会检测到这一系列操作,然后将锁移到循环外。

6.2 锁消除

锁消除是JVM进行的另外一项锁优化,该优化更彻底。

  • JVM在进行JIT编译时,通过对上下文的扫描,JVM检测到不可能存在共享数据的竞争,如果这些资源有锁,那么会消除这些资源的锁。这样可以节省毫无意义的锁请求时间。
  • 虽然大部分程序员可以判断哪些操作是单线程的不必要加锁,但我们在使用Java的内置 API时,部分操作会隐性的包含锁操作。
  • 锁消除的依据,是逃逸分析的数据支持。

例如StringBuffer的操作,HashTable的操作。

07. ObjectMonitor锁源码

在这里插入图片描述

  • 获取锁源码剖析
    void ATTR ObjectMonitor::enter(TRAPS) {
      Thread * const Self = THREAD ;
      void * cur ;
      // 通过 CAS 尝试把monitor的_owner字段设置为当前线程
      cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
      // 获取锁失败
      if (cur == NULL) {
         assert (_recursions == 0   , "invariant") ;
         assert (_owner      == Self, "invariant") ;
         // CONSIDER: set or assert OwnerIsThread == 1
         return ;
      }
      // 如果旧值和当前线程一样,说明当前线程已经持有锁,此次为重入,_recursions自增,并获得锁。
      if (cur == Self) { 
         // TODO-FIXME: check for integer overflow!  BUGID 6557169.
         _recursions ++ ;
         return ;
      }
    
      // 如果当前线程是第一次进入该monitor,设置_recursions为1,_owner为当前线程
      if (Self->is_lock_owned ((address)cur)) { 
        assert (_recursions == 0, "internal state error");
        _recursions = 1 ;
        // Commute owner from a thread-specific on-stack BasicLockObject address to
        // a full-fledged "Thread *".
        _owner = Self ;
        OwnerIsThread = 1 ;
        return ;
      }
      // 省略部分代码。
      
      // 通过自旋执行ObjectMonitor::EnterI方法等待锁的释放
      for (;;) {
      	 jt->set_suspend_equivalent();
      	 EnterI (THREAD) ;
      	 if (!ExitSuspendEquivalent(jt)) break ;
         _recursions = 0 ;
      	 _succ = NULL;
    	 exit (Self);
      	 jt->java_suspend_self();
      }
    }
    

在这里插入图片描述

  • 释放锁源码剖析
    void ATTR ObjectMonitor::exit(TRAPS) {
       Thread * Self = THREAD ;
       // 如果当前线程不是 Monitor 的所有者
       if (THREAD != _owner) { 
         if (THREAD->is_lock_owned((address) _owner)) { // 
           // Transmute _owner from a BasicLock pointer to a Thread address.
           // We don't need to hold _mutex for this transition.
           // Non-null to Non-null is safe as long as all readers can
           // tolerate either flavor.
           assert (_recursions == 0, "invariant") ;
           _owner = THREAD ;
           _recursions = 0 ;
           OwnerIsThread = 1 ;
         } else {
           // NOTE: we need to handle unbalanced monitor enter/exit
           // in native code by throwing an exception.
           // TODO: Throw an IllegalMonitorStateException ?
           TEVENT (Exit - Throw IMSX) ;
           assert(false, "Non-balanced monitor enter/exit!");
           if (false) {
              THROW(vmSymbols::java_lang_IllegalMonitorStateException());
           }
           return;
         }
       }
        // 如果_recursions次数不为0,自减
       if (_recursions != 0) {
         _recursions--;        // this is simple recursive enter
         TEVENT (Inflated exit - recursive) ;
         return ;
       }
    
       // 省略部分代码
       
       // 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,
       // 通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
    

在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值