Java 互斥锁 synchronized

  • 互斥锁, 就是能达到互斥访问目的的锁
  • 当存在多个线程操作共享数据时, 需保证同一时刻有且仅有一个线程在操作共享数据, 其他线程必须等到当前线程处理完数据后再进行
  • synchronized 保证在同一个时刻只有一个线程可以执行某个方法或者某个代码块
  • synchronized 保证一个线程的变化(主要是共享数据的变化)被其他线程所看到

在 JVM 中,Java 对象头在内存中分为如下区域


image.png
  • 对象头
  • 实例变量 存放类的属性数据信息, 包括父类的属性信息, 如果是数组的实例部分还包括数组的长度, 这部分内存按 4 字节对齐
  • 填充数据 由于虚拟机要求对象起始地址必须是 8 字节的整数倍. 填充数据不是必须存在的, 仅仅是为了节对齐

一般而言, synchronized 使用的锁就存储在对象头里, JVM 采用 2 个字节来存储对象头(如果对象是数组则会分配3个字节, 多出来的 1 个字节记录的是数组长度), 其主要结构是由 Mark Word 和 Class Metadata Address 组成, 如下图


image.png

Mark Word 在默认情况下存储对象的 HashCode、分代年龄、锁标记位等. 以下是 32 位 JVM的 Mark Word 默认存储结构:


image.png

可以注意到当锁标志位为 10 的时候,对象头保存了一个指针


image.png

其中轻量级锁和偏向锁是 Java 对 synchronized 锁进行优化后新增加的。重量级锁也就是通常说的 synchronized,锁标识位为10,其中指针指向的是 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,其 _owner 指向持有 ObjectMonitor 对象的线程。

多个线程同时访问一段同步代码时,线程先进 _EntryList 。当某一线程获取到对象的 monitor 后把 monitor 中的 owner 变量设置为当前线程,同时计数器 count 加1。

若线程调用 wait() ,则释放当前 monitor,owner 变量复为null,count 自减1,同时该线程进入_WaitSet 中等待。若当前线程执行完毕将释放 monitor (锁)并复位,以便其他线程获取 monitor (锁)。


image.png

monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁就是通过这种方式获取。这也是为什么 Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因
synchronized 有多种使用方式,比如直接加在代码块上,

定义一个synchronized修饰的同步代码块,在代码块中操作共享变量i :

public class SyncCodeBlock {
   public int i;
   public void syncTask(){
       //同步
       synchronized (this){
           i++;
       }
   }
}

下面是编译出来的字节码

public void syncTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         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:

同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorente r指令指向同步代码块的开始位置,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 的指令

synchronized 还可以加在一个函数上。

public class SyncMethod {
   public int i;     
   public synchronized void syncTask(){           
      i++;    
   }
 }

原理类似,JVM 可从方法常量池中的方法表结构( method_info Structure ) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。

在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。

看看字节码就一目了然了。

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

同时,必须注意到的是在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。

庆幸的是 Java 官方在从 JVM 层面上对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁锁的状态有四种,

锁状态
偏向锁
轻量级锁
重量级锁
随着锁竞争,锁可从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

下面介绍偏向锁和轻量级锁以及 JVM 的其他优化手段。

偏向锁 ——单线程

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

这是因为经过研究发现,大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些 CAS 操作)的代价而引入偏向锁。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失。

需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

轻量级锁——多线程无竞争

倘若偏向锁失败,虚拟机会尝试使用一种称为轻量级锁的优化手段( 1.6 之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是,

“绝大部分的锁在整个同步周期内都不存在竞争”。

注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

使用轻量级锁时,不需要申请互斥量,仅仅将 Mark Word 中的部分字节 CAS 更新指向线程栈中的 Lock Record,如果更新成功,则轻量级锁成功获取,记录锁状态为轻量级锁;否则说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

自旋锁——多线程有竞争短期持有

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是 50 个循环或 100 循环,在经过若干次循环后,如果得到锁就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除

这种优化更彻底。

Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer 不可能存在共享资源竞争的情景,JVM 会自动将其锁消除。

锁的重入性也需要考虑。

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。

synchronized 是基于原子性的内部锁机制,是可重入的。

因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁是允许的,这就是synchronized 的可重入性。

注意,由于 synchronized 是基于 monitor 实现的,因此每次重入,monitor 中的计数器仍会加1。

在轻量级锁的时候,提出了CAS(compare and swap)。在理解完这个十分重的、可能导致线程切换的 synchronized 之后,还将引入许多类似于 CAS 这种比较轻的工具。

事实上,回到最开头,synchronized 直接锁住一整个 HashMap 是最差的解决办法,比如多个线程同步读的情况也串行就不太合适了。武器太重了

你可能感兴趣的:(Java 互斥锁 synchronized)