Skip to content

synchronized

ZhangPan edited this page Jul 13, 2025 · 3 revisions

synchronized 使用

synchronized可以修饰方法或者同步代码块,主要确保多个线程在同一时刻只能有一个线程处于该方法或者同步代码块中,它保证了线程对变量访问的可见性和排他性。

1.修饰代码块,对指定的对象加锁

public void add() {
    synchronized (this) {
        i++;
    }
}

2.修饰实例方法,对当前实例对象this加锁

public synchronized void add(){
       i++;
}

3.修饰静态方法,对该类的Class对象加锁

public static synchronized void add(){
       i++;
}

要注意synchronized关键字是一个对象锁,无论怎么使用,它一定是对某个对象加锁。

Java对象头与monitor对象

Java中对象由三部分构成,分别为对象头、实例变量、填充字节。

  • (1) 实例数据:存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐。
  • (2) 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  • (3) 对象头:它是实现synchronized的锁对象的基础,这点我们重点讨论它。

synchronized 锁是存储在 Java 对象头中的,JVM中采用两个字节来存储对象头,以 Hotspot 为例,对象头主要包括三部分:Mark Word(标记字段)、Klass Pointer(类型指针)以及数组数组长度(只有数组对象有)。其中Mark Word用于存放对象自身的运行时数据。

Mark Word主要用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID、偏向时间戳等。Mark Word同时也记录了对象和锁有关的信息。当这个对象被 synchronized 关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中的存储内容如下图,其中无锁和偏向锁的标记状态都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

可以看到,当对象状态为偏向锁(biasable)时,markword存储的是偏向的线程 ID;当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中 Lock Record 的指针;当状态为重量级锁(inflated)时,为指向堆中的 monitor 对象的指针。

monitor对象

 由上述内容可知,重量级锁 synchronized 的标识位是10,其中指针指的是 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 ; //处于获取锁失败的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

synchronized 是如何获取和释放 monitor 的

1.同步代码块

synchronized底层是通过monitorenter和moniterexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束和异常处。 通过javap的工具对上述同步代码块进行反汇编

public void add() {
    synchronized (this) {
        i++;
    }
}

可以看到字节码指令:

public class com.zhangpan.text.TestSync {
  public com.zhangpan.text.TestSync();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void add();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter    // synchronized关键字的入口
       4: getstatic     #2                  // Field i:I
       7: iconst_1
       8: iadd
       9: putstatic     #2                  // Field i:I
      12: aload_1
      13: monitorexit  // synchronized关键字的出口
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit // synchronized关键字的出口
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}

当代码执行到 monitorenter 指令时,将会尝试获取该对象对应的 Monitor 的所有权。即尝试获得该对象的锁。当该对象的 monitor 的计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有该对象 monitor 的持有权,那它可以重入这个 monitor ,计数器的值也会加 1。与之对应的执行 monitorexit 指令时,锁的计数器会减1。倘若其他线程已经拥有 monitor 的所有权,那么当前线程获取锁失败将被阻塞并进入到 _WaitSet 中,直到等待的锁被释放为止。也就是说,当所有相应的monitorexit 指令都被执行,计数器的值减为 0,执行线程将释放 monitor(锁),其他线程才有机会持有 monitor 。

需要注意的是,字节码中有两个 monitorexit 指令,因为编译器需要确保方法中调用过的每条 monitorenter 指令都有执行对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常时,monitorenter 和 monitorexit 指令也能正常配对执行,编译器会自动产生一个可以处理所有异常的异常处理器,它的目的就是用来执行异常的monitorexit 指令。而字节码中多出的 monitorexit 指令,就是异常结束时用来释放monitor的指令。

上述过程可以总结如下:

  • (1) 如果monitor的进入数为0,则该线程进入 monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • (2) 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加1.
  • (3) 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权。

2.同步方法的实现

public synchronized void add(){
       i++;
}

通过javap -v 获取上面代码字节码的附件信息,得到如下结果:

 public synchronized void add();
    descriptor: ()V
    flags: (0x0021) 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 5: 0
        line 6: 10

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

Java 虚拟机对 synchronized 的优化

需要注意的是,在 Java 早期版本中,synchronized 属于重量级锁,效率低下。这是因为在实现上,JVM会阻塞未获取到锁的线程,直到锁被释放的时候才唤醒这些线程。阻塞和唤醒操作是依赖操作系统来完成的,所以需要从用户态切换到内核态,开销很大。并且 monitor调用的是操作系统底层的互斥量(mutex),本身也有用户态和内核态的切换。Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单介绍一下Java官方在JVM层面对synchronized锁的优化。

1.Java6 之后synchronized引入的多种锁机制

  锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。前面已经详细分析过重量级锁,下面将介绍偏向锁和轻量级锁以及JVM的其他优化手段。

  • 1.偏向锁

  偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段.经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是被同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构。当这个线程再次请求锁时,无需再做任何同步操作,即可获取锁的过程。这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了。因为这样场合极有可能每次申请锁的线程都是不相同的。因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

  • 2.轻量级锁   轻量级锁优化性能的依据是对于大部分的锁,在整个同步生命周期内都不存在竞争。当升级为轻量级锁之后,MarkWord 的结构也会随之变为轻量级锁结构。JVM 会利用 CAS 尝试把对象原本的 Mark Word 更新为Lock Record的指针,成功就说明加锁成功,改变锁标志位为00,然后执行相关同步操作。

轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁就会失效,进而膨胀为重量级锁。

  • 3.自旋锁   轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。   自旋会跑一些无用的CPU指令,所以会浪费处理器时间,如果锁被其他线程占用的时间短的话确实是合适的。但是如果长的话就不如直接使用阻塞。那么 JVM 怎么知道锁被占用的时间到底是长还是短呢?因为 JVM 不知道锁被占用的时间长短,所以使用的是自适应自旋。就是线程空循环的次数时会动态调整的。可以看出,自旋会导致不公平锁,不一定等待时间最长的线程会最先获取锁。

  • 4.锁消除   消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。例如,StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

2.synchronized关键字锁升级过程

  • (1)当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0;
  • (2)当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态;
  • (3) 当线程A再次试图来获得锁时,JVM 发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步中的代码;
  • (4) 当线程B试图获得这个锁时,JVM 发现同步锁处于偏向状态,但是 Mark Word 中的线程 id 记录的不是 B,那么线程 B 会先用 CAS 操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程 A 一般不会自动释放偏向锁。如果抢锁成功,就把 Mark Word 里的线程 id 改为线程 B 的 id,代表线程 B 获得了这个偏向锁,可以执行同步代码。如果抢锁失败,则继续执行步骤5;
  • (5) 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM 会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁 Mark Word 的指针,同时在对象锁 Mark Word 中保存指向这片空间的指针。上述两个保存操作都是 CAS 操作,如果保存成功,代表线程抢到了同步锁,就把 Mark Word 中的锁标志位改成 00,可以执行同步代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤 6;
  • (6) 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由 JVM 决定。如果抢锁成功则执行同步代码,如果失败则继续执行步骤7;
  • (7) 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

重量级锁的实现原理

ObjectMonitor 中有五个重要部分,分别为_ower,_WaitSet,_cxq,_EntryList和 count。

  • _ower 用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁之后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL;
  • _WaitSet 调用了锁对象的wait方法后的线程会被加入到这个队列中;
  • _cxq 是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList;
  • _EntryList 没有抢到锁的线程会被放到这个队列;
  • count 用于记录线程获取锁的次数,成功获取到锁后count会加1,释放锁时count减1。

如果线程获取到对象的 monitor 后,就会将 monitor 中的 ower 设置为该线程的 ID,同时 monitor 中的 count 进行加 1. 如果调用锁对象的 wait() 方法,线程会释放当前持有的 monitor,并将 owner 变量重置为 NULL,且 count 减 1,同时该线程会进入到_WaitSet 集合中等待被唤醒。

另外_WaitSet,_cxq与_EntryList都是链表结构的队列,存放的是封装了线程的 ObjectWaiter 对象。如果不深入虚拟机查看相关源码很难理解这几个队列的作用。这里我简单说下它们之间的关系:

在多条线程竞争 monitor 锁的时候,所有没有竞争到锁的线程会被封装成 ObjectWaiter 并加入 _EntryList 队列。 当一个已经获取到锁的线程,调用锁对象的 wait 方法后,线程也会被封装成一个 ObjectWaiter 并加入到 _WaitSet 队列中。 当调用锁对象的 notify 方法后,会根据不同的情况来决定是将 _WaitSet 集合中的元素转移到 _cxq 队列还是 _EntryList 队列。 等到获得锁的线程释放锁后,又会根据条件来执行 _EntryList 中的线程或者将 _cxq 转移到 _EntryList 中再执行 _EntryList 中的线程。

所以,可以看得出来,_WaitSet 存放的是处于 WAITING 状态等待被唤醒的线程。而 _EntryList 队列中存放的是等待锁的 BLOCKED 状态。_cxq 队列仅仅是临时存放,最终还是会被转移到 _EntryList 中等待获取锁。

这一次,彻底搞懂Java中的synchronized关键字

深入理解Java线程的等待与唤醒机制(一)

深入理解Java中synchronized关键字的实现原理

死磕Synchronized底层实现--概论

死磕synchronized底层实现

公众号:玩转安卓Dev

Java基础

面向对象与Java基础知识

Java集合框架

JVM

多线程与并发

设计模式

Kotlin

Android

项目相关问题

Android基础知识

Android消息机制

Android Binder

View事件分发机制

Android屏幕刷新机制

View的绘制流程

Activity启动

Framework

性能优化

Jetpack&系统View

第三方框架实现原理

计算机网络

算法

Clone this wiki locally