当前位置: 首页 > news >正文

Java中的synchronized和锁

前几天面试时被问到了Java中的自旋锁、轻量锁等,我只略有印象,似乎在哪看见过,但是说不上来,面试结束后就开始搜索,现在的感觉就是——以我工作10余年的经验来看,是否知道这些这么底层的东西对于实际工作来说毫无影响,但是现在就是这么内卷,就像一个每天开着三轮车送快递的快递员去面试,要求快递员明白发动机的原理并会修发动机——虽然实际工作中发动机坏了要去找专业的修车员。无奈,还是学习下吧。

先讲一些更加靠近底层的机制和名词

自旋锁、自适应自旋锁

不管是采用synchronized还是锁,只要存在多线程对共享数据的竞争和同步,就少不了线程的切换,而切换是需要开销的,比如线程挂起并释放CPU等待其他线程执行、线程拿到锁继续运行,而实际应用中经常会出现线程切换开销比数据处理更耗时的情况,例如线程切换耗费了10ms的时间,但拿到锁后处理数据实际只用了2ms,为了优化这种场景,虚拟机的开发团队想到了一种优化措施,即等待锁的线程不再挂起也不释放CPU而是让线程空跑(执行一个忙循环---循环检查锁的状态,术语叫自旋。假设执行了3ms),直到另一个持有锁的线程释放了锁,该线程再中断空跑拿到锁去执行正常逻辑,这样,原本需要12ms的操作,现在就只需要3+2=5ms了。这就是自旋锁技术。默认是忙循环10次,超过了限定次数还是会转换为传统的锁方式执行。

自适应自旋锁,简单来说,就是更加智能的自旋锁,它基于以往经验(例如上一次在同一个锁上自旋了多久)来决定这次自旋的次数,可能增加自旋次数也可能取消自旋。这样,程序跑得越多,程序预测得就会越准。

注意:自旋锁,包括下面要讲的锁消除、锁粗化,都是程序运行时由系统自动完成的,并非由程序员通过Java代码实现。

锁消除

有些场景下,虽然我们出于安全的考虑做了同步措施,但是实际执行时如果虚拟机发现根本不可能存在竞争的情况(例如,我们调用别人(包括Java的API)写的同步方法,但实际始终只有一条线程在访问共享数据),此时即时编译起就会把这个锁(或叫同步逻辑)消除掉以提高运行效率。下面就是一个例子

public String concatString(String s1,String s2,String s3){StringBuffer sb = new StringBuffer();sb.append(s1);sb.append(s2);sb.append(s3);return sb.toString();}

大家看StringBuffer的源码如下

   public synchronized StringBuffer append(String str) {toStringCache = null;super.append(str);return this;}

锁粗化

我们在编写代码时通常会尽可能使同步块的范围最小,以避免其他线程不必要的等待,但是如果一系列的操作都是对同一个对象反复的加锁和解锁,甚至加锁操作是发生在一个循环体中,就会导致不必要的性能损耗。上面 concatString(s1,s2,s3) 就是一个例子,三次调用sb.append(),实际每次调用都是一次加锁、解锁的过程。此时虚拟机会将锁的范围扩大,变为如下伪代码的形式

public String concatString(String s1,String s2,String s3){StringBuffer sb = new StringBuffer();//加锁sb.append(s1);//即时编译器删掉同步逻辑sb.append(s2);//即时编译器删掉同步逻辑sb.append(s3);//即时编译器删掉同步逻辑//解锁return sb.toString();}

轻量级锁和重量级锁

轻量级是相对于传统的锁即重量级锁而言的。重量级锁,是基于操作系统的 互斥量(Mutex) 实现的,依赖于底层操作系统的线程调度,需在用户态和内核态之间切换。轻量级锁是为了在没有多线程竞争或竞争不激烈的情况下减少传统重量级锁的性能消耗而在JDK 1.6设计和引入的,主要通过 CAS(Compare-And-Swap) 操作实现。CAS操作是一种无锁的原子操作算法,由CPU的硬件指令保证原子性。

在JDK 1.6之前,synchronized也是采用操作系统互斥量实现的,即也属于重量锁的范畴;JDK 1.6开始,引入了偏向锁、轻量级锁等优化措施,性能得到了大幅提升。

 偏向锁

Java中的每个对象都有一个内置锁。这个锁并不是一个Lock的实例对象,说它是一种机制更合适。它的基础就是对象的头中一块叫Mark Word的区域,简单来说就是在Mark Word中记录一些同步所需的数据,用以标识目前锁或同步的情况。

为便于讲解 ,先贴出一段代码

public class Wind {private Object synObj = new Object();public static synchronized void play1(){// ...}public synchronized void play2(){//...}public void play3(){synchronized (synObj){//...}}}

同步对象:就是被当做锁的对象,例如,上面代码中的synObj和Wind对象。

当一条线程进入同步代码块但实际上又没有其他线程跟它竞争的时候,同步对象就会使用CAS操作(一种由CPU硬件指令来保证原子性的原子操作)在自己的Mark Word区记录下当前这条线程的ID,意思就是——现在我全听您的调遣,只为您一个人服务。这样当这条线程再次走到这块同步代码块的时候就会省略掉不必要的同步措施,直接允许该条线程进入同步代码区。就像同步对象承认这条线程是免检产品一样---偏袒这条线程。注意:偏向锁的生效场景是 虽然有同步逻辑,但是只有一条线程进入同步逻辑,没有其他线程来竞争。换句话说:适用于单线程环境,且锁对象的锁总是被同一个线程多次获取的场景。当有其他线程来竞争锁时,偏向锁就会被打破,进而转为轻量级锁或重量级锁

当一个线程释放偏向锁时,JVM并不会立即清除对象头中的偏向线程ID,而是将其设置为一个特殊值,表示该锁已经被释放。这样,当其他线程尝试获取这个锁时,JVM会检测到这个特殊值,从而知道该锁已经被释放,可以重新进行偏向锁的获取操作。

轻量级锁

轻量级锁是为了减少获得锁和释放锁所带来的性能消耗而引入的。在锁竞争不激烈的情况下,轻量级锁可以提高程序的性能。它的实现主要依赖于自旋和CAS操作。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”,都属于Mark Word区),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示(此时还未发生下方所说的CAS操作)

然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示此对象出于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示

如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

轻量级锁适用于多线程交替执行同步块的场景,即线程间不存在锁竞争或竞争很少的场景

当线程尝试获取一个被其他线程持有的轻量级锁时,它会进入自旋状态,尝试通过CAS操作获取锁。如果自旋等待超过预定的次数仍然没有成功获得锁,那么该线程将会被挂起,转换为重量级锁。在Java 6之后,对于非偏向锁的同步块,在第一次被访问时也会尝试使用轻量级锁。

至此,再回头捋一下就会发现,有一条锁升级的策略:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。下面就来描述一下这条升级策略的典型顺序

一条线程A进入同步块,此时没有其他线程跟它竞争,所以它通过CAS操作(强调:CAS操作具有原子性,不会出现脏数据)把自己的线程ID写进了锁的Mark Word从而持有了这个锁,此时这个 锁就是偏向锁,以后每次线程A进入同步块时都不必再通过CAS操作了,可以直接进入同步代码块;再后来,又来了一条线程B,要跟A线程争夺这个锁,系统就会等线程A到达安全点(线程并不是随处都可暂停的),此时,会有两种情况,1是恰好线程A已经退出了同步代码块,此时锁的Mark Word就会恢复为无锁状态(偏向锁被撤销),线程B可以通过CAS操作(将 Mark Word 设置为自己的锁记录指针),进入轻量级锁状态,如果CAS操作失败了,说明此时有其他线程在跟B竞争,进入重量级锁状态;2是线程A仍在同步代码块内,此时偏向锁就会先撤销,随后膨胀为轻量级锁(线程A在它的栈帧中创建Lock Record -> Mark Word指向线程A栈帧),线程B通过CAS去竞争这个锁,即先自旋(仍持有CPU,没有阻塞)一下,如果还没有抢到锁,此时的轻量级锁就会膨胀为重量级锁,线程B挂起(释放CPU,线程阻塞)。

偏向锁,在只有一条线程进入同步块的情况下避免了使用重量级锁的开销;轻量级锁利用自旋会在一定概率上拿到锁的特点,通过CAS操作,也避免了使用重量级锁的开销;但是,无论偏向锁还是轻量级锁它们在遇到比自己适用场景更复杂的场景的时候都会升级锁,这也同样带来了花销,如果最后都升级到了重量级锁,那肯定是比直接采用重量级锁更加耗费资源和时间的,但根据现实中的数据统计来看,大部分的同步场景偏向锁和轻量级锁就足够应对了,所以从总体上来看,它们还是提高了程序性能的

Synchronized和锁(Lock)的比较

先说锁,也就是通常用到的Lock、显式锁,比如可重入锁ReentrantLock,有点编程经验的人应该都知道锁的使用方法,这里不再多述。

关于synchronized,先贴出如下代码(和上面的代码一样)

public class Wind {private Object synObj = new Object();public static synchronized void play1(){// ...}public synchronized void play2(){//...}public void play3(){synchronized (synObj){//...}}}

该关键字既可用于方法,也可用于对象,它的底层实现原理其实也是锁。例如,它的方法 play2()

其实就是相当于

public void play2(){synchronized (this){//...}
}

也就是Wind类的实例对象充作了一个锁,而play1(),则相当于如下

public static void play1(){synchronized (Wind.class){//...}
}

可能有人要疑惑了:Wind.class是什么?

在Java中,每个类都有一个且仅有一个与之对应的.class对象,这是Java的类加载机制的一部分,在虚拟机加载该类时由虚拟机创建。这个.class对象代表了类的元数据,包含了类的结构信息,例如类的字段、方法、构造函数等。这些信息在Java虚拟机(JVM)中是必需的,以便进行诸如方法调用、类型检查等操作。

play3(),就是特意指定一个同步对象。

维度synchronizedLock(以ReentrantLock为例)
实现层面关键字,由 JVM 底层实现(基于 Monitor 机制)接口 / 类,由 Java 代码实现(基于 AQS 框架)
锁获取方式自动获取与释放(出作用域或异常时自动释放)需手动调用lock()获取,unlock()释放
锁释放保障无需手动释放,避免死锁(JVM 自动处理)必须在finally块中释放,否则可能死锁
锁的公平性非公平锁(默认),无法指定公平性可通过构造函数指定公平锁(fair=true
锁的可中断性不可中断(阻塞时无法响应中断)支持lockInterruptibly()响应中断
尝试获取锁无法主动尝试获取(只能阻塞等待)支持tryLock()(立即返回)和tryLock(timeout, unit)(超时返回)
锁状态查询无法查询锁是否被获取可通过isLocked()isHeldByCurrentThread()等方法查询
绑定条件变量内置wait()/notify()机制可通过newCondition()创建多个条件变量
锁升级机制支持偏向锁、轻量级锁、重量级锁的自动升级无锁升级概念,始终为重量级锁(基于 AQS)
性能优化JDK 1.6 后引入多种优化(如自旋锁)

基于 CAS 和 AQS 实现,性能稳定

AQS(抽象队列同步器)是 Java 并发包java.util.concurrent的核心基础框架,它通过一个 int 类型的状态变量(state)和双向链表(等待队列)实现了锁和同步器的基础功能。许多并发工具(如ReentrantLockCountDownLatchSemaphore)都是基于 AQS 构建的。

synchronized和Lock的实现原理

重量级锁的实现依赖于底层的 Monitor 机制。Java中每个对象都有一个与之关联的 Monitor,偏向锁、轻量级锁阶段都不会激活Monitor,只使用Mark Word就够了,当锁对象(即同步对象)转为重量级锁后,JVM就会给锁对象实例化出一个关联的Monitor,当线程尝试获取重量级锁时,会被放入 Monitor 的入口等待队列中。如果获取锁失败,线程会被阻塞并放入等待队列,直到持有锁的线程释放锁。

Lock的实现方案是 CAS操作+AQS 为主、重量级锁为辅的方案:Lock会通过大量CAS和自旋/自适应自旋的方式在用户态完成同步,尽量避免进入内核态,只有在竞争很激烈时(CAS总是失败、自旋总是失败或锁长时间被持有)才会转入内核态,此时性能开销将大幅增加,进入广义上的重量级锁状态。

在JDK 1.6之前,synchronized的实现方案是 Monitor+重量级锁 ,所以它的性能通常就比Lock要差,从JDK 1.6开始,实现方案变为 偏向锁 -> 轻量级锁 -> 重量级锁(即Monitor+重量级锁)的动态升级方案,性能得到大幅提升,和Lock一样都是由重量级锁来兜底,如今synchronized和Lock性能已相差无几,应用中到底采用哪个要根据具体场景来看

  • 简单场景:优先使用synchronized,因其代码简洁、自动管理锁释放,且 JDK 优化后性能足够好,尤其在无竞争或低竞争的场景更推荐使用。
  • 复杂场景:选择Lock,利用其灵活的 API 实现公平性、可中断性、多条件变量等需求,但需注意手动释放锁的正确性。

为什么重量级锁成为synchronized和Lock的终极方案?

自旋会让线程空跑,CPU仍被线程占用,如果长时间自旋,就是在长时间让CPU空跑,而重量级锁方案可以挂起线程(需要进入内核态),释放CPU资源。

http://www.lryc.cn/news/575002.html

相关文章:

  • 在NPU平台上,如何尝试跑通Ktransformers + DeepSeek R1?
  • 基于LangChat搭建RAG与Function Call结合的聊天机器人方案
  • 前端使用rtsp视频流接入海康威视摄像头
  • QT 学习笔记摘要(三)
  • HCIA-IP路由基础
  • C++ 多线程深度解析:掌握并行编程的艺术与实践
  • 基于CMS的黄道吉日万年历源码(自适应)
  • 商务年度总结汇报PPT模版分享
  • 板凳-------Mysql cookbook学习 (十--10)
  • LeetCode 3258.统计满足K约束的子字符串数量1
  • HTML表单元素
  • 线性结构之链表
  • 深度学习实战112-基于大模型Qwen+RAG+推荐算法的作业互评管理系统设计与实现
  • 机器学习01
  • SpringBoot高校党务系统
  • SpringBoot项目快速开发框架JeecgBoot——数据访问!
  • ros (二) 使用消息传递点云+rviz显示
  • Happy-LLM-Task06 :3.1 Encoder-only PLM
  • C++设计模式(GOF-23)——04 C++装饰器模式(Decorator)(一个类同时继承和组合另一个类)解决类爆炸问题、模板装饰器
  • python3文件操作
  • Node.js特训专栏-实战进阶:8. Express RESTful API设计规范与实现
  • python的智慧养老院管理系统
  • klayout db::edge 里的 crossed_by_point 的坑点
  • mbedtls ssl handshake error,res:-0x2700
  • 从零开始的云计算生活——第二十三天,稍作休息,Tomcat
  • Excel数据转SQL语句(增删改查)
  • 阿里云Web应用防火墙3.0使用CNAME接入传统负载均衡CLB
  • DDNS-GO 使用教程:快速搭建属于自己的动态域名解析服务(Windows 版)
  • 大语言模型的通用局限性与全球技术演进
  • React Native【实战范例】账号管理(含转换分组列表数据的封装,分组折叠的实现,账号的增删改查,表单校验等)