synchronized关键字
基本介绍
synchronized关键字解决的是多个线程之间访问资源的同步性,它可以保证被它修饰的方法或者代码块在任意时刻只能由一个线程访问执行。synchronized可以修饰普通方法、静态方法、修饰代码块。
- 对于普通方法,锁的是当前实例对象
- 对于静态方法,锁的是当前类的Class对象
- 对于同步代码块,锁的是synchronized括号里的对象
底层原理
如果是同步代码块,从编译后得到的字节码文件中可以看出同步代码块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入同步代码块的结束位置,线程执行到monitorenter指令时,将试图获取对象所对应的monitor的持有权,如果monitor的计数器为0,则线程就可以成功获得锁,获取后将计数器设为1, 直到执行线程执行完毕,线程才会执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
在java虚拟机中,monitor主要是基于ObjectMonitor(C++代码中)结构体中的EntryList和WaitSet两个队列以及计数器count实现的。
- 当有多个线程同时想获取某个对象锁时,首先会进入EntryList队列
- 当某个线程执行monitorenter指令获取到对象锁时,设置count=1,由于synchronized锁是可重入的,如果线程还要获取这个锁,直接进行count++,而不是在EntryList队列中阻塞等待锁;
- 线程执行完成或者因为异常退出时,会执行monitorexit指令,此时count--;,当count变为0时,对象锁的拥有者线程释放锁。
- 拥有锁的线程在运行过程中调用了wait()方法,那么线程会进入到WaitSet对象,等待被notify()或等待的时间已到,才有可能再次成为对象锁的拥有者。
如果是同步方法,JVM直接从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志位来区分该方法是否为同步方法,当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor对象,然后再执行方法,最后在方法完成时释放monitor对象。
锁的升级
早期synchronized属于重量级锁,是因为监视器锁(monitor)是依赖底层操作系统来实现的,Java的线程是映射到操作系统原生线程之上的,如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。但是,随着Java SE 1.6 对synchronized进行了各种优化之后,它变的不再那么笨重了。
在介绍JDK1.6引入的优化之前,先讲讲Java对象头。synchronized用的锁是存在Java对象头里面的,对象头包含两个部分:Mark Word和类型指针(指向对象所属的类)。Mark Word里面默认存储对象的HashCode、分代年龄和锁标志位,32位的JVM的Mark Word的默认存储格式如下所示:
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化,它可能变化为以下4种数据状态:
JDK1.6引入的优化:
-
自旋锁
大多数情况下,线程持有锁的时间都不会太长,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。思想:当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。在经过若干次循环后,如果得到锁,就顺利进入临界区;如果还不能获得锁,那就会将线程在操作系统层面挂起。缺点:自旋的次数直接决定了自旋锁的性能。 -
自适应自旋锁
自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。如果对于某个锁,很少有自旋能够成功的,那么在以后要获得这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 -
锁消除
指虚拟机即时编译器在运行时检测到某段需要同步的代码根本不可能存在共享数据竞争而实施的一种对锁进行消除的优化策略。主要判断依据来源于逃逸分析( 分析对象的动态作用域,当一个对象在方法里面被定义后,如果它能被外部方法引用就称为方法逃逸(作为实参),如果能被外部线程访问就称为线程逃逸(赋值给可以在其他线程被访问的变量),逃逸分析并不直接优化代码,是为其他优化措施提供依据),就是判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他的线程访问到,就认为他们是线程私有的。StringBuffer类的append方法用了synchronized关键词,它是线程安全的,但很多时候只是把StringBuffer对象当作局部变量使用,这个时候就没必要同步。 -
锁粗化
很多时候为了避免不必要的阻塞,锁的范围应该尽可能的小,但是如果在一段代码中,多处需要加同一个锁,甚至加锁操作出现在循环体中的时候,就会导致不必要的性能损耗,这种情况就需要锁粗化。锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
在JavaSE 1.6中,锁的状态总共有四种,级别从低到高依次为:无锁状态、偏向锁、轻量级锁和重量级锁。偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态。
锁的膨胀过程:
- 一个锁对象刚刚开始创建的时候,没有任何线程来访问它,此时为无锁状态。Mark Word(锁标志位-01 是否偏向-0)。
无锁 → 偏向锁
- 当线程A来访问这个对象锁时,线程A检查Mark Word(锁标志位-01 是否偏向-0)为无锁状态。此时,无锁升级为偏向锁,偏向这个线程A,Mark Word(锁标志位-01,是否偏向-1,线程ID-线程A的ID)。
- 当线程A执行完同步块时,不会主动释放偏向锁,Mark Word不变(锁标志位-01,是否偏向-1,线程ID-线程A的ID)。持有偏向锁的线程执行完同步代码后不会主动释放偏向锁,而是等待其他线程来竞争才会释放锁。 当线程A再次获取这个对象锁时,检查Mark Word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A,可以直接执行同步代码。这样偏向锁保证了总是同一个线程多次获取锁的情况下,每次只需要检查标志位就行,效率很高。
偏向锁 → 轻量级锁
- 当线程A持有偏向锁时,线程B来获取锁。首先判断是否为可偏向状态,如果是,再判断是否指向自己的偏向锁,如果是,表示已经持有锁,如果不是,则使用CAS获取锁,如果是则尝试CAS将偏向锁指向自己,CAS失败就撤销偏向锁,需要等到全局安全点,然后暂停持有偏向锁的线程A并检查程A的状态,此时A可能有两种状态:
- 线程A已经不存活或者它还存活,但是从它的栈帧信息可以判断出它不会再使用这个锁对象了,如果允许重偏向,那么重新偏向线程B(不升级),否则升级为轻量级锁,由线程B通过CAS获得轻量级锁。
- 线程A存活且还会执行同步代码块,则偏向锁膨胀为轻量级锁,A继续往下执行同步代码块,B通过CAS操作获取锁,如果CAS失败,则进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁。
获取轻量级锁的过程:首先在线程栈帧中创建一个名为锁记录的空间(Lock Record),然后,将锁对象头中的Mark Word拷贝到线程的锁记录中,最后,线程通过 CAS 将 Mark Word 中指向锁记录的指针指向自己的锁记录地址。CAS成功的线程拥有锁,失败的线程自旋等待。
释放轻量级锁操作:线程通过CAS将的锁记录(Lock Record)中的Mark Word替换回锁对象头中。
轻量级锁 → 重量级锁
- 如果线程A持有轻量级锁,此时线程B也来获取锁,线程B CAS失败之后,线程B尝试使用自旋的方式来等待线程A释放锁。如果自旋次数到了线程A还没有释放锁,或者线程A还在执行,线程B还在自旋等待,这时又有一个线程C过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把所有等待的线程都阻塞,防止CPU空转。线程A还在执行过程中如果锁已经升级为重量级锁了,那它执行释放轻量级锁CAS会失败,则会在释放锁的同时唤醒被挂起的这些线程,被唤醒线程就会进行新一轮的夺锁之争。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?