Java中Synchornize关键字原理

在实际开发中,往往需要考虑数据并发安全问题,比如秒杀业务场景、买票业务场景,都需要考虑并发,Java提供了Synchornize关键字来为我们解决了并发性问题.

本文讲解Synchornize关键字的工作原理

一、Java对象头和Monitor

JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。


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

Java头对象是实现Synchornize关键字的基础,一般来说,Synchornize锁对象是存储在JAVA对象头里的,JVM采用2个字节来存储对象头(如果对象是一个数组,会分配3个字节,剩下的一个字节用来记录数组长度),主要结构由Mark Word和Class MetaData Address组成

meta.png

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


basicMeta.png

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构

superMeta.png

其中轻量级锁和偏向锁是Java 6对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态

Monitor其实是个同步工具,也可以说成是一种同步机制。他的主要特点为:

  • 对象的所有方法都被“互斥”的执行。好比一个Monitor只有一个运行“许可”,任一个线程进入任何一个方法都需要获得这个“许可”,离开时把许可归还
  • 通常提供singal机制:允许正持有“许可”的线程暂时放弃“许可”,等待某个谓词成真(条件变量),而条件成立后,当前进程可以“通知”正在等待这个条件变量的线程,让他可以重新去获得运行许可

二、监视器的实现

在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下

 ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有几个关键属性:

  • _owner:指向持有ObjectMonitor对象的线程
  • _WaitSet:存放处于wait状态的线程队列
  • _EntryList:存放处于等待锁block状态的线程队列
  • _recursions:锁的重入次数
  • _count:用来记录该线程获取锁的次数

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁
若持有monitor的线程调用wait()方法,将释放当前持有的monitor_owner变量恢复为null_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

lock.png

monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAllwait方法依赖于monitor对象

三、Synchornize关键字工作原理

JVM通过进入和退出monitor监视器来实现方法、同步块同步的,具体实在编译后在同步方法调用前加入monitor.entry指令,在退出和异常的地方加一个monitor.exit指令,本质上就是对一个对象的监视器(monitor)进行获取,而这个获取具有排他性,从而达到了只能一个线程访问的目的。对于没有获得锁的那些线程会阻塞到方法入口处,直到获取锁的那个线程执行了monitor.exit指令后,才尝试再次获取锁

getlock.jpg

通过代码演示:

/**
 * @author Gjing
 **/
public class Test {
    public void test() {
        synchronized (this) {
            System.out.println("hello");
        }
    }
}

通过javap -c Test.class指令查看汇编指令

Compiled from "Test.java"
public class com.gj.Test {
  public com.gj.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String hello
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: aload_1
      13: monitorexit
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}

可以看到在同步快的入口和结尾中含有monitorentrymonitorexit指令

四、偏向锁、轻量锁

synchronize很多都称之为重量锁,JDK1.6 中对synchronize进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁和轻量锁

1、偏向锁

为了进一步的降低获取锁的代价,JDK1.6 之后还引入了偏向锁。偏向锁的特征主要是锁不存在多线程竞争,并且应由一个线程多次获得锁

获取锁的流程

1、访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。

2、如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。

3、如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。

4、如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)

5、执行同步代码。

解锁

当有另外一个线程获取这个锁时,持有偏向锁的线程就会释放锁,释放时会等待全局安全点(这一时刻没有字节码运行),接着会暂停拥有偏向锁的线程,根据锁对象目前是否被锁来判定将对象头中的 Mark Word 设置为无锁或者是轻量锁状态。轻量锁可以提高带有同步却没有竞争的程序性能,但如果程序中大多数锁都存在竞争时,那偏向锁就起不到太大作用。可以使用 -XX:-userBiasedLocking=false 来关闭偏向锁,并默认进入轻量锁

适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用

2、轻量锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁

获取锁流程

1、当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Record)区域,同时将锁对象的对象头中Mark Word拷贝到锁记录中,再尝试使用CASMark Word更新为指向锁记录的指针。

2、如果更新成功,当前线程就获得了锁。

3、如果更新失败 JVM 会先检查锁对象的Mark Word是否指向当前线程的锁记录。

4、如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。不是则说明有其他线程抢占了锁,如果存在多个线程同时竞争一把锁,轻量锁就会膨胀为重量锁

解锁

轻量锁的解锁过程也是利用CAS来实现的,会尝试锁记录替换回锁对象的Mark Word。如果替换成功则说明整个同步操作完成,失败则说明有其他线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量锁)轻量锁能提升性能的原因是:认为大多数锁在整个同步周期都不存在竞争,所以使用CAS比使用互斥开销更少。但如果锁竞争激烈,轻量锁就不但有互斥的开销,还有CAS的开销,甚至比重量锁更慢


文章到此结束,如果有任何错误点,可以在下方评论中指出