并发编程(八)synchronized关键字解析
一、背景分析
设计同步机制的意义
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源,这种资源可能是:对象、变量、文件等。
- 共享:资源可以由多个线程同时访问
- 可变:资源可以在其生命周期内被修改
引出的问题:由于线程执行的过程是不可控的,所以需要采用同步机制来控制对象的访问!
如何解决线程并发安全问题?
解决方案:序列化访问临界资源。【即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。】
Java 中,提供了两种方式来实现同步互斥访问:
- synchronized【内置锁,隐式锁】
- Lock【显式锁】
PS:同步机制的本质就是加锁。
加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源【同步互斥访问】
PS:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,是不会导致线程安全问题的。
技术发展背景
二、synchronized原理详解
定义:synchronized内置锁是一种对象锁【锁的是对象而非引用】,作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
加锁的方式:
- 同步实例方法,锁的是当前实例对象【new 出来的对象上】(加锁粒度较细)
- 同步静态方法【类方法】,锁的是当前类对象【XXX.class】(加锁粒度较广)
- 同步代码块,锁的是括号里面的对象
PS:若一个类中有多于1个的静态方法设置了Synchronized关键字时,会非常影响性能,因为这两个Synchronized关键字相当于都所在同一个class对象上【比如用Spring容器管理的话,这两个方法锁的是同一个bean】
PS:不要不要在项目中写System.out.printIn(),因为这个底层加了synchronized关键字,且为单例,那么单例就意味着所有存在System.out.printIn()语句的地方锁的都是同一个对象!
synchronized底层原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor【监视器锁】实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock【互斥锁】实现,它是一个重量级锁性能较低。经优化后内置锁的并发性能已经基本与Lock持平。 synchronized关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
PS:synchronized的语义底层是通过一个Monitor的对象来完成,所以只有在同步代码块或方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常【对象的监视器状态为不是需要同步的场景】。
每个同步对象都有一个自己的Monitor【监视器锁】,加锁过程如下图所示:
三、Monitor监视器锁
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
monitor监视器锁核心指令
monitorenter
每个对象可以成为一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
- 2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。【可重入】
- 3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit
PS:执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
什么是Monitor?
可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中,每一个Java对象自产生开始就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
四、锁的膨胀升级
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
偏向锁
产生的背景:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。【获取锁会涉及到一些CAS操作比较耗时】
核心思想:如果一个线程获得了🔒,那么🔒就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求🔒时,无需再做任何同步操作,这样就省去了大量有关锁申请的操作,提搞程序的性能。
PS:🔒竞争不激烈的时候偏向锁对性能提升很显著,但是🔒竞争很激烈时,偏向锁对性能提升不大了,就会再向上升级以满足同步线程所需要的资源开销。
默认开启偏向锁 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
产生的背景:若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。
适应场景:线程交替执行同步块,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
自旋锁
产生的背景:轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
解析:这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,因此自旋锁会假设在不久将来,当前的线程可以获得锁,虚拟机会让当前想要获取锁的线程做几个空循环【这也是称为自旋的原因】,一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。
如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的,最后没办法也就只能升级为重量级锁了。
锁消除
锁消除是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,即消除没有必要的锁,可以节省毫无意义的请求锁时间。
举个🌰:
public void setString(String key) { //1、方法内创建一个StringBuffer的对象 StringBuffer buffer = new StringBuffer(); //2、buffer.append()是同步方法【但由于str变量是局部变量,并且不会被其他线程所使用,这种情况JVM会自动把锁消除】 String str = buffer.append("").toString(); }
锁消除的依据是逃逸分析的数据支持。
锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析 :
- -XX:+DoEscapeAnalysis 开启逃逸分析
- -XX:+EliminateLocks 表示开启锁消除
五、逃逸分析
定义:分析对象动态作用域。当一个对象在方法中被定义后,分析它是否被外部方法所引用,例如作为调用参数传递到其他地方中。
使用逃逸分析,编译器可以对代码做如下优化:
- 同步省略 :如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。【对象不会逃逸则可以不考虑同步的问题】
- 将堆分配转化为栈分配 :如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配,而不是堆分配。【栈内存对象】
- 分离对象或标量替换 :有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。【标量替换的概念】
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律