Java性能优化(四)-多线程调优-Synchronized优化

本文涉及的产品
应用实时监控服务-应用监控,每月50GB免费额度
云原生网关 MSE Higress,422元/月
注册配置 MSE Nacos/ZooKeeper,118元/月
简介: JVM在JDK1.6中引入了分级锁机制来优化Synchronized,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。减少锁竞争,是优化Synchronized同步锁的关键。
  • 作者简介:阿里非典型程序员一枚 ,记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法(公众号同名

  • ❤️觉得文章还不错的话欢迎大家点赞👍➕收藏⭐️➕评论,💬支持博主,记得点个大大的,持续更新🤞
    ————————————————-

引言

在并发编程中,多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。早期,Java 依靠 Synchronized 关键字实现锁功能,但随着 Java 的发展,锁的实现和性能优化得到了极大的关注。

Synchronized 与 Lock 的比较

JDK 1.5 之前的 Synchronized

在 JDK 1.5 之前,Java 是依靠 Synchronized 关键字实现锁功能。Synchronized 是 JVM 实现的一种内置锁,锁的获取和释放由 JVM 隐式实现。

JDK 1.5 引入的 Lock

到了 JDK 1.5 版本,Java 并发包中新增了 Lock 接口来实现锁功能。它提供了与 Synchronized 类似的同步功能,但在使用时需要显式获取和释放锁。

性能比较

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

Lock 同步锁是基于 Java 实现的,具有更高的灵活性,并且在某些场景下性能更优。特别是在单个线程重复申请锁的情况下,Lock 的性能通常优于 Synchronized

Synchronized 的优化

JDK 1.6 之后的优化

从 JDK 1.6 开始,Java 对 Synchronized 同步锁进行了充分的优化,使得其性能得到了显著提升。在某些场景下,其性能甚至超越了 Lock 同步锁。

优化技术

  1. 锁升级

    Java 锁的状态有四种:无锁状态、偏向锁、轻量级锁和重量级锁。JVM 会根据竞争情况,从低级别锁依次升级到高级别锁。

  2. 偏向锁

    当线程首次访问某个同步代码块或方法时,JVM 会使用偏向锁来优化性能。偏向锁会记录首次进入该同步块的线程 ID,后续如果仍是这个线程进入,则无需再进行任何同步操作。

  3. 轻量级锁

    当多个线程交替访问某个同步代码块或方法时,使用轻量级锁可以减少线程挂起和阻塞的开销。如果轻量级锁竞争失败,则会膨胀为重量级锁。

  4. 锁消除

    JVM 在即时编译(JIT)时,会分析锁的使用情况,如果发现某个锁在整个执行过程中只被一个线程访问,那么 JVM 就会消除这个锁,从而提高性能。

  5. 锁粗化

    如果一系列连续的操作都对同一个对象反复加锁和解锁,那么性能开销会很大。为了降低这种开销,JVM 会将这些加锁和解锁操作合并为一个范围更大的锁,这就是锁粗化。

  6. 适应性自旋

    自旋锁虽然可以减少线程阻塞的开销,但是并不是所有情况下都适用。适应性自旋锁会根据线程之前获取锁的成功率来决定自旋的次数,从而更加智能地利用 CPU 资源。

Synchronized 的实现原理

同步方式

Synchronized 实现同步锁的方式有两种:修饰方法和修饰方法块。

修饰方法

在实例方法上使用 synchronized 关键字,锁为当前实例。

public synchronized void method1() {
   
   
    // code
}

修饰方法块

在代码块上使用 synchronized 关键字,锁为括号里面的对象。

public void method2() {
   
   
    Object o = new Object();
    synchronized (o) {
   
   
        // code
    }
}

字节码实现

在修饰同步代码块时,Synchronized 是由 monitorentermonitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。

查看字节码

可以通过以下命令来查看具体字节码的实现:

javac -encoding UTF-8 SyncTest.java  # 编译class文件
javap -v SyncTest.class             # 通过javap打印出字节文件

字节码详情

修饰同步代码块

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

  public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #2                  
         3: dup
         4: invokespecial #1                  
         7: astore_1
         8: aload_1
         9: dup
        10: astore_2
        11: monitorenter //monitorenter 指令
        12: aload_2
        13: monitorexit  //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 18: 0
        line 19: 8
        line 21: 12
        line 22: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/demo/io/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标志。

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

 public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 8: 0

以上的源码,我们再来看看Synchronized修饰方法是怎么实现锁原理的。

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

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

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

如果线程调用wait() 方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex。
在这里插入图片描述

看完上面的讲解,相信你对同步锁的实现原理已经有个深入的了解了。总结来说就是,同步锁在这种实现方式中,因Monitor是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。

锁升级优化

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

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

Java对象头

在JDK1.6 JVM中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中Java对象头由Mark Word、指向类的指针以及数组长度三部分组成。

Mark Word记录了对象和锁有关的信息。Mark Word在64位JVM中的长度是64bit,我们可以一起看下64位JVM的存储结构是怎么样的。如下图所示:
在这里插入图片描述

锁升级功能主要依赖于Mark Word中的锁标志位和释放偏向锁标志位,Synchronized同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。下面我们就沿着这条优化路径去看下具体的内容。

1.偏向锁

偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。

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

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

下图中红线流程部分为偏向锁获取和撤销流程:
在这里插入图片描述

因此,在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生stop the word后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加JVM参数关闭偏向锁来调优系统性能,示例代码如下:

-XX:-UseBiasedLocking //关闭偏向锁(默认打开)

-XX:+UseHeavyMonitors  //设置重量级锁

2.轻量级锁

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

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

下图中红线流程部分为升级轻量级锁及操作流程:

在这里插入图片描述

3.自旋锁与重量级锁

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

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

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

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

下图中红线流程部分为自旋后升级为重量级锁的流程:
image.png

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

在高负载、高并发的场景下,我们可以通过设置JVM参数来关闭自旋锁,优化系统性能,示例代码如下:

-XX:-UseSpinning //参数关闭自旋锁优化(默认打开) 
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由jvm控制

动态编译实现锁消除/锁粗化

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

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

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

减小锁粒度

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

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

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

总结

JVM在JDK1.6中引入了分级锁机制来优化Synchronized,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。

减少锁竞争,是优化Synchronized同步锁的关键。我们应该尽量使Synchronized同步锁处于轻量级锁或偏向锁,这样才能提高Synchronized同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高Synchronized同步锁在自旋时获取锁资源的成功率,避免Synchronized同步锁升级为重量级锁。

欢迎一键三连(关注+点赞+收藏),技术的路上一起加油!!!代码改变世界

  • 关于我:阿里非典型程序员一枚 ,记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法(公众号同名),回复暗号,更能获取学习秘籍和书籍等

  • ---⬇️欢迎关注下面的公众号:进朱者赤,认识不一样的技术人。⬇️---

相关文章
|
19天前
|
监控 算法 Java
Java虚拟机(JVM)垃圾回收机制深度剖析与优化策略####
本文作为一篇技术性文章,深入探讨了Java虚拟机(JVM)中垃圾回收的工作原理,详细分析了标记-清除、复制算法、标记-压缩及分代收集等主流垃圾回收算法的特点和适用场景。通过实际案例,展示了不同GC(Garbage Collector)算法在应用中的表现差异,并针对大型应用提出了一系列优化策略,包括选择合适的GC算法、调整堆内存大小、并行与并发GC调优等,旨在帮助开发者更好地理解和优化Java应用的性能。 ####
25 0
|
19天前
|
存储 监控 小程序
Java中的线程池优化实践####
本文深入探讨了Java中线程池的工作原理,分析了常见的线程池类型及其适用场景,并通过实际案例展示了如何根据应用需求进行线程池的优化配置。文章首先介绍了线程池的基本概念和核心参数,随后详细阐述了几种常见的线程池实现(如FixedThreadPool、CachedThreadPool、ScheduledThreadPool等)的特点及使用场景。接着,通过一个电商系统订单处理的实际案例,分析了线程池参数设置不当导致的性能问题,并提出了相应的优化策略。最终,总结了线程池优化的最佳实践,旨在帮助开发者更好地利用Java线程池提升应用性能和稳定性。 ####
|
11天前
|
存储 Java
Java 11 的String是如何优化存储的?
本文介绍了Java中字符串存储优化的原理和实现。通过判断字符串是否全为拉丁字符,使用`byte`代替`char`存储,以节省空间。具体实现涉及`compress`和`toBytes`方法,前者用于尝试压缩字符串,后者则按常规方式存储。代码示例展示了如何根据配置决定使用哪种存储方式。
|
15天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
15天前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
40 3
|
20天前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
46 5
|
18天前
|
存储 监控 算法
Java虚拟机(JVM)垃圾回收机制深度解析与优化策略####
本文旨在深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法及参数调优方法。通过剖析垃圾回收的生命周期、内存区域划分以及GC日志分析,为开发者提供一套实用的JVM垃圾回收优化指南,助力提升Java应用的性能与稳定性。 ####
|
21天前
|
安全 Java 开发者
Java中的多线程编程:从基础到实践
本文深入探讨了Java多线程编程的核心概念和实践技巧,旨在帮助读者理解多线程的工作原理,掌握线程的创建、管理和同步机制。通过具体示例和最佳实践,本文展示了如何在Java应用中有效地利用多线程技术,提高程序性能和响应速度。
54 1
|
2月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
58 1
C++ 多线程之初识多线程
|
2月前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
27 3
下一篇
DataWorks