synchronized实现原理

本文将从编译到JVM实现原理再到底层代码实现方面来讲解一下synchronized实现原理。

synchronized修饰方法时字节码实现

在Java处理多线程时,我们最简单的处理方式是添加 synchronized 关键字。其代码一般如下所示

public class Counter{
private int count = 0;
    public synchronized void increase() {
        ++count;
    }

    public int getCount() {
        return count;
    }
}

其中increase方法即使是线程安全的,其字节码大致如下

0: aload_0 
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return

有趣的是这里没有关于线程安全的任何字节码指令。

那么怎么实现的呢?

Java在碰到同步的方法时会在编译的时候,将方法的 ACC_SYNCHRONIZED 标记位设置为1。

对于Java语言在编译上的设计我们需要梳理一下。

  • 首先Java文件,是程序员实现的源代码。其本质是一个文本文件。需要被处理成字节码文件。
  • 我们生成字节码文件之后,JVM在执行时,是需要拿到这些文件,进行进一步解析的。
  • 那么JVM势必有这样的代码存在:
    • 找字节码文件进行解析,发现这个字节码文件描述了某些类,类中有某些变量,常量,有某些静态方法,成员方法,那么在解析的时候,一定会将其映射成某些"对象"或者结构体。 JVM也是代码堆出来的。如果您是JVM的开发者,肯定也会有差不多的思路,就是根据字节码文件,生成其对应的类数据,方法数据,成员变量数据,常量数据。等等。
    • 那么对于方法的抽象数据,也就是针对方法解析出来的数据结构。在JVM的实现中的确是存在的。其内容大致如下文所示

JVM源代码对方法的实现数据结构

{
  u2 methods_count; //方法的数量
  methods_info methods[methods_count]; //方法的集合,是个数组,每一个元素都描述了一个方法,总共有methods_count个
}

那么我们接下来按照平时工作时研究代码的套路继续扒,这个methods_info的字段都是什么:

method_info {
    u2             access_flags;  //方法的访问标记,用来表示是public,private还是static等等
    u2             name_index; //方法名的索引值,可以通过索引值去常量池里找到对应的字符串常量,这个字符串就是方法名
    u2             descriptor_index; //方法描述符,这个描述符内容是我们经常见到的 "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;" 这之类的.其内容仍然被记录在常量池中
    u2             attributes_count; //方法相关属性的个数
    attribute_info attributes[attributes_count]; //方法相关属性描述合集
}

我们在这里要从顶级设计的视角来看java语言执行时到底是什么。JVM说到底就是一个实现起来比较复杂,技术含量比较高的运行程序。也是由C++等语言实现起来的。在他们的产品设计里,类,方法,运行时方法的执行等等一系列概念都化身为相关的数据结构与算法来实现。

attribute_info 这个结构体,我们用到的时候再讲,了解方法的底层结构,到此为止。

access_flags内容

知道上述方法的数据结构了,对于synchronized修饰的成员方法和类方法而言,JVM的实现策略是,直接用access_flags这个字段来进行标记。我们扩展一下这个字段可以记录什么,其实记录的信息是蛮多的,synchronized只是其中一项而已。

access_flags协议
synchronized成员方法和类方法的实现

对于synchronized成员方法和类方法的实现,Java采用的是用access_flag进行记录。将ACC_SYNCHRONIZED标记位设置为1。 执行线程在执行此方法的时候,会对这个标记位进行查询。如果标记为1的话,就会先获取锁。如果当前方法是一个成员方法,JVM会把当前实例对象作为隐式的监视器,如果当前方法是类方法,则会拿出这个类对应的class对象作为隐式的监视器。当方法完成之后,无论是正常返回还是异常返回都会将这个锁释放掉。

synchronized修饰代码块

对于最初的代码,我们也可以进行下面的改动:

public class Counter{
private int count = 0;
    public void increase() {
        synchronized (this) {
            ++count;
        } 
    }

    public int getCount() {
        return count;
    }
}

尽管其实现的效果与synchronized修饰方法的效果是一致的,但是在编译上是有差异的。

其字节码内容如下:

 0: aload_0
 1: dup
 2: astore_1
 3: monitorenter
 4: aload_0
 5: dup
 6: getfield      #2                  // Field count:I
 9: iconst_1
10: iadd
11: putfield      #2                  // Field count: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

很明显,相比于synchronized直接修饰方法而言,局部修饰明显多了很多细节!其中很明显的就是

  • 第3行和第15行,直接加了获取监视器释放监视器的操作指令,很明显。
  • 第19行到22行,是抛异常了,咱们自己写的代码里可没有明显的写try catch代码,但是编译后加上了异常处理逻辑。

监视器锁获取和释放指令

我们首先看第3行 monitorenter 指令:获取栈顶对象的监视器锁(看代码,第零行是aload_0 不用追究,就是this!用到再讲这种指令的规则)。如果还对象的监视器锁没有被其他线程占有,则当前线程就会获得该锁,继续执行后续的代码。如果已经被其他县城占有,则当前线程阻塞,直到锁被释放。

第21行 monitorexit 指令:释放栈顶对象(当前对象)的监视器锁,确保即使发生异常,锁也能被正确释放,避免死锁。

字节码中为什么会有异常处理

当synchronized直接修饰方法的时候,我们并没有见字节码文件中生成什么try catch 异常代码块,为什么synchronized修饰局部代码的时候又加上了看起来如此冗长的善后代码呢?

原因是:Java的编译器遵循的铁律。即:Java编译器,无论同步代码块中逻辑以何种方式结束,一定会保证调用

monitorenter 指令后必须执行对应的monitorexit指令。如果执行monitorenter之后没有执行monitorexit指令,则监视器一直被占有,其他的线程根本没有办法获取锁。为了保证这一点,编译器会自动生成异常处理器。

我看着重看一下这个异常是怎么处理的:

Exception table:
 from    to  target type 
     4    16    19   any //如果4到16字节码偏移量的范围内发生任何类型的异常,会跳转到19的偏移量位置进行异常处理
    19    22    19   any //如果19到22字节码偏移量的范围内发生任何类型的异常,会跳转到19的偏移量位置进行异常处理

19偏移位置的代码这么重要干了什么呢?

19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return

您会发现,是无论如何,都 执行monitorexit了!

就是确保这么释放的,但是后期 执行了 athrow 指令,这个指令的意思是: 抛出操作数栈顶的异常对象,将异常继续向上传播。

如此闭环,就很好的确保无论如何释放锁的问题。并也解释了为什么死锁最后会崩溃,因为athrow了,我们又没有捕获处理。所以就崩了。

什么是监视器锁

synchronized的处理多次提到了监视器锁这个概念。什么是监视器锁呢?监视器锁是synchronized实现的核心,也是JVM保证多线程机制的核心。

监视器锁严格来讲,**要分成,锁和监视器来讲。**我们先对这两个概念进行初步的了解,之后再串联动态执行时的机制。

所谓的"锁"究竟是什么

对象头数据解析

在java中,每一个对象在存储时,具备一个对象头。他是对象在内存布局中的一部分。主要是用于存储对象的一些元数据信息,比如哈希码,分代年龄,锁状态等。对象头的信息对于JVM实现锁机制,垃圾回收机制,具有重大的意义。

因为我们此时要研究JVM的同步机制,所以我们目前重点注意一下,对象头里面的信息,有一部分是记录和锁相关的,即可。

对象头的数据如下图所示:

所谓的"锁",其在数据上来讲,一段数据或者一个标志位。加锁,解锁,本质上就是改的这个标志位。如果这个标志位改的动,那么就相当于获取了锁,如果改不动,那就是没有获取成功。 但是"改"操作,是有讲究的,它需要依赖一个原子级的操作,来达成只有一个线程能够改的动,后来的线程因为前一个原子级操作改了值,而导致判断不通过,改不动。这种相当于闸机的模拟操作。就可以实现线程的同步,和线程的协同操作。当先,实际上JVM实现中会更为复杂。之后我们做讲解。

监视器

监视器在JVM源码实现中,其本质也是一个数据结构,这个数据结构,与其相对应的对象相关联 ,其内容描述了,等待队列,入口队列,和锁标志等 。当一个线程获取锁失败时,就会被放在入口队列中进行等待。而当线程调用wait方法时,会进入等待队列,直到被其他线程调用notify()notifyAll() 方法唤醒。

监视器主要记录了,正在持有这个锁的线程和即将持有这个锁的线程是哪些,和他们相关的状态。

上图描述了监视器中大致记录的内容,以及它与对象头之间的关系-通过对象头能找到监视器-通过监视器能找到正在执行的线程,因为没有获取锁而阻塞住的线程(Entry set), 以及调用了该对象的wait方法,而导致在等待的线程(Wait set)。

有了对象头和监视器,我们几乎搞定了线程获取锁,释放锁,竞争,以及调用等等各种功能所需的数据结构。

对象头和监视器实现线程同步

如果您仔细阅读本文中的图片,会发现,在Object header的数据里,前两位的数据,存在四种状态。并且这个状态位,是实现监视器锁机制的关键数据之一。他们的状态分别是: 无锁,偏向锁,轻量级锁,重量级锁,GC标记。

其中GC标记和同步机制联系不大,先不做探索。我们重点来看下,偏向锁,轻量级锁,重量级锁。

为什么Mark word中,锁存在三种状态

是为了将并发场景细分,从而达到更高的运行效率。 并发场景被分为下列三种细分场景

  • 同一个线程不断地重复执行同步代码块,那么会出现同一个线程不断地获取锁,释放锁的操作。
  • 不同的线程,交替执行同步代码块,尽管要的是同样的锁,但极少出现两个线程同时调用竞争这把锁的情况。
  • 不同的线程,同时执行同步代码块,会存在同时竞争同一把锁,而且有时候这种竞争异常激烈。

如果我们不将场景进行细分,统一按照调用C++ Mutex来实现线程的同步(JVM本质上是一个主要由C++实现的应用程序,其处理线程最终也是用C++实现的),但是Mutex信号量这种机制,它是操作系统提供的,调用操作系统API会引发用户态到内核态的切换。这对于计算机而言是非常耗时的。如果一刀切这样处理,会使得Java程序在处理并发时,效率较低。所以这个Mutex可以按照非必要不调用的原则来处理。

所以针对那么多种场景,是不能一刀切的。

于是才出现了将锁分为三种状态的解决方案。

  • 当Flag为01时代表没有锁,或者偏向锁。处理场景为,同一线程不断重复执行同步代码块。
  • 当Flag为00时代表轻量级锁,处理场景为,不同线程,交替执行同步代码块。
  • 当Flag为10时代表重量级锁,处理场景为,不同线程,高频同时执行同步代码块。

好,我们再看看这三种状态的切换时,数据有什么变化。

我们再拉出来刚才讲的Object Header中Mark word,重新铺开一些细节:

锁升级策略

JVM对于不同的并发场景,要进行特殊的处理,其解决策略采取的是锁升级策略。

上一节图中已经表示出了,各类锁状态的不同,对应的mark word中内容的区别。针对上述不同的并发场景,我们也将锁分成了四个状态:

  • 1 没有锁
  • 2 偏向锁: 处理单线程访问同步代码块
  • 3 轻量级锁:处理多线程交替访问同步代码块
  • 4 重量级锁:处理多线程同时访问代码块
偏向锁

当一个线程首次执行同步代码块的时候,synchronized(object) 会拿到object对象的信息。于是便能访问到mark word的内容

  • 1 JVM会拿到当前执行的线程,并将其ID记录在mark word的 31-53 位
  • 2 将mark word 的偏向锁标志位改为 1, 表示该对象当前处于偏向锁状态。
  • 3 当线程再次访问该同步块的时候,会拿出来正在执行的线程,与记录在mark word中的线程ID进行比较。如果相同,则无需进行任何同步操作,直接执行同步代码块内容。此种情况下,也就是偏向锁在没有竞争的情况,不会主动释放锁,这样当同一个线程不断访问同步代码块的时候,就不会有多余的释放逻辑。省了一些执行步骤。

但是如果,当一个新的线程也访问该同步代码块的时候,在比较mark word 中的线程ID,就会不相同了,此时也叫做其他线程竞争该锁。于是线程进入了竞争状态。那么此时,偏向锁会进行撤销。

撤销流程为:

JVM会暂停持有偏向锁的线程-> 将mark word 中的线程ID清除->将偏向锁标志改为0 -> 根据竞争情况将锁升级为轻量锁或者重量锁

轻量锁

轻量锁获取稍微有些复杂,我画了个图。图中的标号就是步骤。

  • 当获取轻量级锁的时候,首先会在当前线程的栈中新建一个栈帧保存锁记录,并将mark word复制到锁记录中。
  • 线程通过CAS操作将mark word中的数据最后几位,改成指向栈帧的那个锁记录的地址。 这个修改操作是通过CAS操作来实现的,它是一个原子操作,线程是安全的。但是这个操作成不成功,就代表到底有没有获取偏向锁成功,即如果成功的将mark word中的数据最后几位改掉,则代表获取锁成功。
  • 如果改不掉,那就会失败。如果失败的话,则代表当前存在竞争,也就肯定不符合多线程交替执行的场景了,此时会将锁升级为重量锁,以应对多线程同时竞争的场景。
重量锁

当线程尝试获取重量级锁时,会检查对象头的 Mark Word 是否指向一个监视器(Monitor)。如果是,说明锁已经被其他线程持有,线程会被放入监视器的入口集(Entry Set)中等待。当持有锁的线程释放锁时,JVM 会从入口集中选择一个线程,将其唤醒并让它获取锁。

注意第 54-63位此时已经指向了监视器,由于轻量锁在最初的时候就已经将mark word数据存储在栈帧里面了,所以如果用到旧值,会在里面找。

而监视器地址是哪里来的呢? 正是当前thread中记录的。

在重量锁的状态下,JVM首先监视器里存了需要即将竞争的线程数据,可以完成符合其运行环境的调度从策略。从最后调用线程切换的时候,调用的则是C++的mutex。 由这套锁升级策略,我们避开了一些不必要的线程切换场景。

总结

  • synchronized可以用来修饰成员方法,类方法。且这种情况下,编译出来的字节码文件,并没有特殊的逻辑指明此方法是一个同步方法。只是在方法描述的元数据里能找到一个标志位,来确定是不是同步方法。
  • synchronized用来修饰代码片段的时候,编译出来的字节码文件中,含有获取锁,释放锁,以及一些异常处理操作。异常处理操作是编译时自动生成,代码相当于加finnaly操作,以确保无论怎样,获取到的锁会得到释放。
  • 尽管synchronized在使用时,根据场景,编译出来的字节码文件不同。但本质上都是加了获取锁,释放锁的操作。只不过一个是隐式的,一个是显式的。到JVM真正执行的时候本质上没什么不同。
  • JVM在执行时,针对这些同步操作。利用了被监视的对象(synchronezed(object)中的object),其数据中对象头的mark word部分标志位,来记录是否有锁,以及当前锁是处于什么状态。 其中锁有四个状态,无锁,偏向锁,轻量锁,重量锁。 其中,无锁,偏向锁,轻量锁,都是JVM层用原子操作限制来进行线程同步的,因为这三种状态分别针对,1 无同步代码块 2 单线程执行同步代码块,3 多线程交替执行同步代码块。的场景。 其共同的特征是,资源其实并没有发生争抢情况,都是同时只有一个线程访问那段同步代码的场景。只有在重量锁的情况下,才是标准的,同事竞争一个资源的情况,此时JVM最终会调用mutex来搞定这种真正并发的情况。
  • JVM在上述思路的实践过程中,采取了锁升级策略。锁升级策略中,锁转换时运用了JVM CAS机制,也称为乐观锁机制。这个机制的实现过程中利用了JVM的一些原子操作得以保证线程安全的。有兴趣可以研究下这个CAS。本文不做拓展。
  • JVM只有在到重量锁的情况下,来实现真正的线程同步。此时可以从被监控的对象数据里面,可以拿到监视器(最初是从线程数据中捞过来拷贝到mark word的),监视器里记录了很多对此被监控对象"排在外面的"线程队列。并对这些队列进行管理和调度。
重量锁状态下 监视器与被监视对象的数据关系

一个线程上可以关联很多监视器

一个被监视对象,指向一个监视器

一个监视器里,可以存很多要拿同一把锁的线程。

相关推荐
yngsqq3 小时前
c# —— StringBuilder 类
java·开发语言
星星点点洲3 小时前
【操作幂等和数据一致性】保障业务在MySQL和COS对象存储的一致
java·mysql
xiaolingting3 小时前
JVM层面的JAVA类和实例(Klass-OOP)
java·jvm·oop·klass·instanceklass·class对象
风口上的猪20154 小时前
thingboard告警信息格式美化
java·服务器·前端
追光少年33224 小时前
迭代器模式
java·迭代器模式
超爱吃士力架5 小时前
MySQL 中的回表是什么?
java·后端·面试
扣丁梦想家5 小时前
设计模式教程:装饰器模式(Decorator Pattern)
java·前端·装饰器模式
drebander5 小时前
Maven 构建中的安全性与合规性检查
java·maven
drebander5 小时前
Maven 与 Kubernetes 部署:构建和部署到 Kubernetes 环境中
java·kubernetes·maven
王会举6 小时前
DeepSeek模型集成到java中使用(阿里云版)超简单版
java·阿里云·deepseek