Synchronized同步锁的全方位剖析与实战运用

news/2024/5/17 19:19:03 标签: java, 同步锁, Synchronized, 锁优化, Java对象头

目录

一、同步锁简介        

Synchronized%20%E5%90%8C%E6%AD%A5%E5%8E%9F%E7%90%86-toc" style="margin-left:0px;">二、Synchronized 同步原理

三、锁升级优化

java%20%E5%AF%B9%E8%B1%A1%E5%A4%B4-toc" style="margin-left:40px;">        3.1 java 对象头

        3.2 偏向锁

        3.3 轻量级锁

        3.4 自旋锁与重量级锁

        3.5 锁优化

        锁消除、锁粗化

        减小锁粒度


一、同步锁简介        

        在并发编程中,多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。在 JDK1.5 之前,Java 是依靠 Synchronized 关键字实现锁功能来做到这点的。Synchronized 是 JVM 实现的一种内置锁,锁的获取和释放是由 JVM 隐式实现。

        到了 JDK1.5 版本,并发包中新增了 Lock 接口来实现锁功能,它提供了与 Synchronized 关键字类似的同步功能,只是在使用时需要显式获取和释放锁。

        Lock 同步锁是基于 Java 实现的,而 Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized 同步锁在性能上表现较差,常被称为重量锁。

        特别是在单个线程重复申请锁的情况下,JDK1.5 版本的 Synchronized 锁性能要比 Lock 的性能差很多。如果使用 Synchronized 同步锁,那么每当同一个线程请求锁资源时,都会发生一次用户态和内核态的切换。

到了 JDK1.6 版本之后,Java 对 Synchronized 同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了 Lock 同步锁Synchronized 同步锁究竟是通过了哪些优化,实现了性能地提升。

Synchronized%20%E5%90%8C%E6%AD%A5%E5%8E%9F%E7%90%86">二、Synchronized 同步原理

        通常 Synchronized 实现同步锁的方式有两种,一种是修饰方法,一种是修饰方法块。以下就是通过 Synchronized 实现的两种同步方法加锁的方式:

java">  // 关键字在实例方法上,锁为当前实例
  public synchronized void method1() {
      // code
  }

  
  // 关键字在代码块上,锁为括号里面的对象
  public void method2() {
      Object o = new Object();
      synchronized (o) {
          // code
      }
  }

        通过反编译看下具体字节码的实现,运行以下反编译命令,就可以输出我们想要的字节码:

java">javap -v SyncTest.class //再通过javap打印出字节文件

        通过输出的字节码会发现:Synchronized 在修饰同步代码块时,是由 monitorenter 和 monitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。

java">public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #2                  // class java/lang/Object
         3: dup
         4: invokespecial #1                  // Method java/lang/Object."<init>":()V
         7: astore_1
         8: aload_1
         9: dup
        10: astore_2
        11: monitorenter
        12: aload_2
        13: monitorexit
        14: goto          22
        17: astore_3
        18: aload_2
        19: monitorexit
        20: aload_3
        21: athrow
        22: return
      Exception table:
         from    to  target type
            12    14    17   any
            17    20    17   any
      LineNumberTable:
        line 16: 0
        line 17: 8
        line 19: 12
        line 20: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/scf/SyncTest, class java/lang/Object, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

        再来看同步方法的字节码,你会发现:当 Synchronized 修饰同步方法时,并没有发现 monitorenter 和 monitorexit 指令,而是出现了一个 ACC_SYNCHRONIZED 标志。

java">public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 12: 0

        这是因为 JVM 使用了 ACC_SYNCHRONIZED 访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRONIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。

        JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由C++ 的 ObjectMonitor.hpp 实现的。

java">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 ;
}

        当多个线程访问一段代码时,多个线程会先被存放在 _EntryList 集合中,处于 block 状态的线程会被加入到该列表中。接下来,当线程获取到 Monitor 时,Monitor是依靠底层操作系统的Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其他线程将无法获取到该Mutex,竞争失败的线程会再次进入 _EntryList 被挂起。

        如果线程调用 wait 方法,就会释放当前持有的 Mutex,并且该线程进入到 _WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完成,也将释放 Mutex。

        同步锁在这种实现方式中,因 Monitor 是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。

三、锁升级优化

        为了提升性能,JDK1.6 引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的 Java 对象头实现了锁升级功能。

        当 Java 对象被 Synchronized 关键字修饰成为同步锁后,围绕这个锁的一些列升级操作都将和 Java 对象头有关。

java%20%E5%AF%B9%E8%B1%A1%E5%A4%B4">        3.1 java 对象头

        对象实例在堆内存中被分为3部分:对象头、实例数据、填充对齐。其中对象头由 Mark Word、指向类的指针以及数组长度三部分组成。

        Mark Word 记录了对象和锁有关的信息。Mark Word 在 64 位 JVM 中的长度是 64bit,64位JVM存储结构如下图:

        锁升级功能主要依赖于Mark Word中的锁标志位和释放偏向锁标志位,Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。

        下面带你来真实的看下对象头的信息:

        添加依赖

java"><dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>version</version>
</dependency>

        对象头查看

java">// 定义实体类
public class User {
    private String name;
    private Integer age;
    private boolean sex;
    
    // 省略 get set
}


public class Test {
    public static void main(String[] args) {
        User user = new User();
        ClassLayout classLayout = ClassLayout.parseInstance(user);
        // 获取对象头信息
        String s = classLayout.toPrintable();
        System.out.println(s);
    }
}

        64位虚拟机的对象头大小(未开启指针压缩)        

                

        对象头占用内存大小 16*8bit=128bit。jdk8 版本默认是开启指针压缩的,通过 jvm 参数可以关闭指针压缩:-XX:-UseCompressedOops。开启指针压缩后对象的大小如下图,开启指针压缩后对象头的大小为 12*8bit=96bit

        在 java 程序运行的过程中,每创建一个新对象,JVM 就会相应的创建一个对象类型的 oop对象,存储在堆中。markOop 则是 MarkOopDesc 类型指针,markOopDesc 就是MarkWord。                

        3.2 偏向锁

        偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些场景下,大部分时间是同一个线程竞争锁资源,同一个线程每次都需要获取和释放锁,都会发生用户态和内核态的切换。

        偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头 Mark Word 中去判断一下是否偏向锁指向他的ID,无需在进入 Monitor 去竞争锁对象。当对象被当做同步锁并有一个线程抢到锁时,锁标志位还是01,是否偏向锁标志位设置为1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。

        一旦出现其他线程竞争锁资源,偏向锁就会被撤销。偏向锁的撤销需要等到全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行方法,如果是,则升级锁,反之则被其他线程抢占。

        因此,在高并发场景下,当大量线程同时竞争一个锁资源时,偏向锁就会被撤销,发生 STW 后,开启偏向锁无疑会带来更大的性能开销。

        3.3 轻量级锁

        当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换 Mark Word 中的线程 ID 为自己的 ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。

        轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。

        3.4 自旋锁与重量级锁

        轻量级锁 CAS 抢锁失败,线程将会被挂起进入阻塞状态。所有持有锁的线程在很多的时间内释放了锁资源,那么进入阻塞状态的线程无疑又要申请锁资源。

        JVM 提供了一种自旋锁,可以通过自旋的方式不断的获取锁,从而避免线程被阻塞。这是基于大多数情况下,线程持有锁的时间不长,毕竟线程被挂起有点浪费。

        从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里我不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。

        自旋锁重试之后如果抢锁依然失败,同步锁就会升级为重量级锁。在这个状态下,未抢到锁的线程都会进入 Monitor,之后被阻塞在 _WaitSet 队列中。

        在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于 CAS 重试状态,占用 CPU 资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。

        3.5 锁优化

        锁消除、锁粗化

        Java 还使用了编译器对锁进行优化。JIT 编译器在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程。

        如果当前锁不会逃逸出当前的线程,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所表示的锁的申请与释放的机器码,即消除了锁的使用。在 Java7 之后的版本就不需要手动配置了,该操作可以自动实现。

        锁粗化同理,就是在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。

        减小锁粒度

        除了锁内部优化和编译器优化之外,我们还可以通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。

        当我们的锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行度。

        最经典的减小锁粒度的案例就是 JDK1.8 之前实现的 ConcurrentHashMap 版本。我们知道,HashTable 是基于一个数组 + 链表实现的,所以在并发读写操作集合时,存在激烈的锁资源竞争,也因此性能会存在瓶颈。而 ConcurrentHashMap 就很很巧妙地使用了分段锁 Segment 来降低锁资源竞争。

        总之,在工作中药结合实际场景,合理的应用同步锁,使程序达到最优的性能。

往期经典推荐

揭秘操作系统内核:深入浅出进程阻塞与唤醒-CSDN博客

Spring循环依赖的成因与破局-CSDN博客

SpringBoot项目并发处理大揭秘,你知道它到底能应对多少请求洪峰?_一个springboot能支持多少并发-CSDN博客

TiDB内核解密:揭秘其底层KV存储引擎如何玩转键值对-CSDN博客

Redis性能大挑战:深入剖析缓存抖动现象及有效应对的战术指南_redis 缓存抖动怎么解决-CSDN博客


http://www.niftyadmin.cn/n/5427514.html

相关文章

JAVA初阶数据结构栈(工程文件后续会上传)(+专栏数据结构练习是完整版)

1.栈的概念讲解(Stack)&#xff09; 定义&#xff1a;栈是一种先进后出的数据结构 要想拿到12就要把它头上的所有东西给移出去 2.栈的实现&#xff08;代码&#xff09; 2.1栈的方法逻辑的讲解 &#xff08;1&#xff09;新建一个测试类Frank &#xff08;2&#xff09;进…

【MATLAB源码-第161期】基于matlab的OQPSK系统仿真,输出误码率曲线图,眼图以及各节点信号图像对比。

操作环境&#xff1a; MATLAB 2022a 1、算法描述 正交四相移相键控&#xff08;OQPSK&#xff0c;Orthogonal Quadrature Phase Shift Keying&#xff09;是一种数字调制技术&#xff0c;它在传统的QPSK&#xff08;Quadrature Phase Shift Keying&#xff0c;四相移相键控&…

Flutter第三弹:常用的Widget

目标&#xff1a; 1&#xff09;常用的Widget有哪些&#xff1f;有什么特征&#xff1f; 2&#xff09;开发一个简单的登录页面。 一、Flutter常用Widget 对于Flutter来说&#xff0c;一切皆Widget. 常用的Widget&#xff0c;包括一些基础功能的Widget. 控件名称功能备注…

odoo中_name_search用法

_name_search是Odoo中一个重要的方法&#xff0c;用于实现模型记录的搜索逻辑。这个方法通常在后端被调用&#xff0c;以便在Odoo的各种视图中&#xff08;如下拉列表选择框&#xff09;搜索和筛选记录。_name_search是模型中的一个API方法&#xff0c;可以被重写以自定义搜索逻…

godot shader参考

原理是获取图像的A通道进行膨胀高斯模糊uv移动旋转 shader_type canvas_item;uniform vec2 offset; uniform float rotation;uniform int blursize; uniform float shadow_size;float normpdf(in float x, in float sigma){return 0.39894*exp(-.5*x*x/(sigma*sigma))/sigma; …

Python中的类【详谈】

零.前言&#xff1a; 本文适合对Python有浅浅了解的读者&#xff0c;并不能作为Python入门使用。 一.Python中类的属性、方法 在Python中有变量&#xff0c;有函数&#xff0c;例如下方&#xff1a; def IAmAFunction():print("I am a function")IAmVariable 25…

车载诊断协议DoIP系列 —— 诊断报文和诊断报文应答传输层安全(TLS)

车载诊断协议DoIP系列 —— 诊断报文和诊断报文应答&传输层安全(TLS) 我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师(Wechat:gongkenan2013)。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 本就是小人物,输了就是输了,不要在意别人怎…

关于Oracle Primavera P6的各数据库帐号用途

在使用/维护P6时&#xff0c;经常会用到各种不同的P6数据库用户&#xff0c;如在连接配置P6 Professional时用到的公共帐号pubuser&#xff0c;进入后台维护p6配置信息(adminpv)或开发常连接的privuser&#xff0c;亦或是配置BI Report/BUSINESS Intelligence报表套件用到的pxr…