深入理解synchronized关键字

本文详细介绍了Java中的synchronized关键字,包括其概念、基本用法和实现原理。讲解了锁的膨胀过程,从偏向锁到轻量级锁再到重量级锁,以及synchronized与Lock的区别。此外,还探讨了Monitor对象、Mark Word、锁优化策略如自旋锁等,旨在帮助读者深入理解synchronized的线程安全机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

带着问题看文章

  • synchronized 是什么,怎么用?对象锁,类锁有什么区别
  • synchronized 有什么缺陷?
  • synchronized 怎么保证线程安全?
  • jdk1.6 对 synchronized 做了哪些优化?
  • synchronized修饰实例方法,抛出异常后会释放锁吗?
  • 聊一聊锁升级的过程
  • 什么是自旋锁,自适应自旋锁,偏向锁,轻量级锁,重量级锁
  • JVM是怎么知道是哪个线程持有的锁呢?对象头结构
  • synchronized 与 Lock 区别

概念

synchronized 是 Java 提供的一个内置锁,也叫做监视器锁。线程的代码在执行 synchronized 代码块前会自动获取内部锁,这时其他线程访问该同步代码块会阻塞挂起。拿到锁的线程在正常退出代码块或者抛出异常后会释放锁。
synchronized 是可重入的互斥锁。同一个时间只能有一个线程可以获取同一个对象的锁,能保证线程安全

可重入避免了死锁的发生

基本用法

Java中的每一个对象都可以作为锁,具体表现为以下3种形式

  • 修饰普通方法,锁的是当前实例对象
public synchronized void method(){}
  • 修饰静态方法,锁的是当前类的Class对象
public synchronized static void method(){}
  • 修饰代码块,锁的 synchronized 括号里配置的对象
// 锁的是当前实例对象
public void method(){
    synchronized(this){
    }
}
// 锁的是类对象A,A的所有实例对象共用1把锁
public void method(){
    synchronized(A.class){
    }
}
// 锁的是实例对象obj
Object obj = new Object();
public void method(){
	synchronized(obj.class){
	}
}

实现原理

JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步。每个对象都有一个Monitor对象与之对应,一个线程去获取对象锁时,其实是获取对象对应的Monitor的所有权,当Monitor对象被持有后,它将处于锁定状态

同步代码块

synchronized 作用在同步代码块,经过编译后,会在同步代码块前后分别形成 monitorentermonitorexit 这两个字节码指令。

  • monitorenter:尝试去获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;
  • monitroexit:将锁的计数器减1,当减到0时,锁就被释放了。

如果获取锁失败,那当前线程就需要阻塞等待,直到对象锁被另一个线程释放为止。

同步方法

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法的调用和返回操作之中。
如果一个方法被 synchronized 修饰,那么在方法常量池的方法表结构中有一个 ACC_SYNCHRONIZED 访问标志。
当持有 ACC_SYNCHRONIZED 标志的方法被访问时,执行线程就需要先尝试去持有 Monitor 对象,成功持有 Monitor 对象后,才能执行方法,方法执行结束,释放 Monitor 对象。如果方法执行过程中抛了异常,则 Monitor 在方法抛出异常之后自动释放。

对象头

在HotSpot虚拟机中,对象在内存中存储的布局分为3个区域:对象头、实例数据和对齐填充。
在这里插入图片描述
对象头:普通对象包含2部分数据,Mark Word和 Class Pointer(类型指针),数组对象还包含数组长度

  • Mark Word:存储对象自身运行时数据,如hashcode,gc分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等
  • Class Pointer:对象指向它的类元数据的指针,通过这个指针来确定这个对象是哪个类的实例,开启指针压缩的情况下占4字节32位,关闭指针压缩-XX:-UseCompressedOops 占8字节64位

指针压缩:

  • 数组长度:如果是数组对象,还有一块区域记录数组长度

实例数据:对象真正存储的有效信息
对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

8字节对齐,是为了效率的提升,以空间换时间的一种方案
对齐填充不是必然存放的,它仅仅起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍

Mark Word

在32位虚拟机中,Mark Word 数据长度为 32 bit,如果对象未处于锁定状态,那么25bit用于存储对象hashCode,4bit存储分代年龄,2bit存储锁标志位,1bit固定为0
在这里插入图片描述
在64位虚拟机中,Mark Word 数据长度为 64 bit,无锁状态下,前25位未使用,31位存储hashCode,cms_free从字面意思猜测可能和cms垃圾回收有关,有读者知道的话,可以留言告知。
在这里插入图片描述

Monitor

在HotSpot虚拟机中,Monitor由C++中的ObjectMonitor实现
synchronized的运行机制,就是当JVM监测到对象不同的竞争状态时,会自动切换到合适的锁实现,这种切换就是锁升级和降级
偏向锁,轻量级锁,重量级锁对应的就是三种Monitor的实现,当一个Monitor被某个线程持有后,它就处于锁定状态

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;       // 记录个数
    _waiters      = 0,
    _recursions   = 0;       // 线程重入次数
    _object       = NULL;    // 存储 Monitor 对象
    _owner        = NULL;    // 持有当前线程的 owner
    _WaitSet      = NULL;    // 处于wait状态的线程,会被加入到 _WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;   // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;   // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}
  • _count:记录 owner 线程获取锁的次数
  • _owner:指向持有ObjectMonitor对象的线程
  • _EntryList:存放处于等待锁 block 状态的线程队列
  • _WaitSet:存放处于wait 状态的线程队列
    在这里插入图片描述
  • 想要获取 Monitor 对象的线程,先进入 EntryList
  • 当某个线程获取到 Monitor 对象后,进入到 Owner区,设置为当前线程,计数器_count + 1
  • 当线程调用wait() 方法时,将 owner 设置为 null,线程进入 WaitSet 队列,释放锁,计数器 _count - 1
  • 如果其他线程调用 notify() 或 notifyAll() ,唤醒 WaitSet 队列中的某个线程,该线程尝试去获取 Monitor对象,成功则进入 owner 区
  • 同步方法执行完毕,线程退出临界区,将monitor的owner设置为null,并释放监视器锁
锁优化

jdk1.6 对 synchronized 进行了优化,为了减少加解锁带来的性能消耗,提出了 自旋锁,锁消除,锁粗化,偏向锁,轻量级锁等优化策略,来提高并发性能。

  • 自旋锁
    尝试获取锁的线程不会阻塞,而是采用循环的方式去获取锁。在很多应用上面,对象的锁状态只会持续很短一段时间,为了这很短的时间频繁的阻塞唤醒线程非常不值,所以引入了自旋锁
    好处:不会造成上下文切换
    坏处:循环会消耗CPU
    -XX:+UseSpinning 开启自旋锁
    -XX:PreBlockSpin=n 设置自旋次数,默认10
  • 适应性自旋
    自旋的次数不再固定,由上一次在同一个锁上的自旋时间及锁等待拥有者的状态来决定。
    自旋成功,增加下一次自旋次数
    自旋失败,减少下一次自旋次数
  • 锁消除
    虚拟机检测到不可能存在共享数据竞争情况,则会对同步操作进行锁消除,依据逃逸分析的数据支持。
public void vectorTest(){
	// Vector add方法存在加锁操作,JVM检测到变量 vector 没有逃逸到方法外面去,就会进行一个锁消除
    Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
    }
    System.out.println(vector);
}
  • 锁粗化
    将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
    如上面的一段代码,vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外

锁膨胀过程

对象锁状态:无锁,偏向锁,轻量级锁,重量级锁

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是有同一线程多次获得,那么对于同一个线程反复加解锁的过程中其实并没有发生竞争,但是带来了很多上下文切换和不必要的性能开销

  • 这个锁会偏向第一个获取它的线程,如果在接下来的执行过程中,锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
  • 目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
  • 配置命令:
    -XX:+UseBiasedLocking 开启偏向锁
    -XX:-UseBiasedLocking 禁止偏向锁
    -XX:BiasedLockingStartupDelay=0 进行关闭偏向锁延迟
  • 偏斜锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善
    在这里插入图片描述
    获取锁
  • 检查对象是否为可偏向状态,即偏向状态标识为1,锁标志为01
  • 如果是可偏向状态,校验当前对象头Mark Word中的线程ID是否为当前线程,如果是则执行代码块,否则通过CAS修改Mark Wrod的线程ID
  • CAS修改成功,将线程ID修改为当前线程ID
  • CAS修改失败,表示当前存在多线程竞争,当到达安全点后,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行代码块
    释放锁
    偏向锁采用了一种等到竞争出现才会释放锁的机制,当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁。偏向锁的撤销需要等待全局安全点(这个时间点上是没有正在执行的代码),步骤如下:
  • 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  • 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态
轻量级锁

轻量级锁并不是用来替代重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。
获取锁

  • 线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为Displaced Mark Word。
  • 然后,线程尝试使用CAS操作将对象的Mark Word更新为指向Lock Record 的指针。如果更新成功,当前线程获得该对象的锁,并且将对象Mark Word的锁标志位变为 00。如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。

释放锁

  • 使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
    在这里插入图片描述
    因为自旋会消耗CPU,为了避免无用的自旋,一旦锁升级成了重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程会进入下一轮锁争夺。
重量级锁

通过对象内部的监视器(Monitor)实现。
需要向操作系统申请互斥量 mutex,线程阻塞,出让时间片,进行上下文切换(用户态和内核态的切换),切换成本高

各种锁对比
优点缺点适用场景
偏向锁加解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使得自旋会消耗CPU追求响应时间,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较长
synchronized 与 Lock 区别

synchronized缺点:

  • 效率低:锁的释放情况较少,只有执行完代码块或抛异常才能释放锁,获取锁的时候无法设置超时,不能中断一个正在使用锁的线程
  • 不够灵活:加锁和释放锁的时机单一,每个锁仅有一个单一的条件(某个对象)
  • 无法知道是否成功获得锁

Lock能解决的问题

  • 常用的方法:lockunlocktryLocktryLock(long,Timeutil)
  • 获取锁时可以设置超时,可以中断一个正在使用锁的线程
锁膨胀完整流程

在这里插入图片描述

总结

1、对象与Monitor的关系

  • 每个对象都有对象头
  • 对象头里有Mark Word,不同虚拟机 Mark Word 结构不一样
  • Mark Word 有指针指向 Monitor
    在这里插入图片描述

2、对象锁有四种状态:无锁、偏向锁、轻量级锁、重量级锁,随着竞争激烈逐渐升级
3、重量级锁需要申请操作系统互斥量资源,线程竞争需要阻塞,进行上下文切换
4、线程获取锁通过CAS进行操作,CAS底层通过 lock cmpxchg 指令实现

参考资料

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值