并发锁机制之synchronized

2022年1月17日15:50:38

synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。

加锁方式有:锁类、示例锁

i++的问题

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

结果可能为正数、负数、零。因为Java对自增、自减的操作并不是原子性,多个资源对共享资源读写操作时,发生指令交错,就会出问题。这种多个线程对共享资源的多线程操作,这段代码被称为临界区,其共享资源被称为临界资源,解决这种竞态的方案有:

    • 阻塞是的方案:synchronized、lock
    • 非阻塞的方案:原子变量

synchronized底层原理

synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。

JVM内置锁在1.5之后版本做了重大的优化,如锁粗化、锁消除、轻量级锁、偏向锁、自适应自旋等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

Monitor管程

Monitor在操作系统中被称为管程,管理共享变量以及对共享变量操作的过程,让他们支持开发。java的并发SDK包也是以管程为基础,除了java其他高级语言也支持管程,synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。

synchronized加锁加在对象上,锁对象是如何记录锁状态的?

锁状态被记录在每个对象的对象头的Mark Word中,即存放在对象头中

Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制,

ObjectMonitor对象记录了对象头、锁重入次数、存储锁对象、拥有该monitor的线程、等待线程等信息,其中对象头又保存有对象锁、锁状态、偏向锁

ObjectMonitor() {
    _header       = NULL; //对象头  markOop
    _count        = 0;  
    _waiters      = 0,   
    _recursions   = 0;   // 锁的重入次数 
    _object       = NULL;  //存储锁对象
    _owner        = NULL;  // 标识拥有该monitor的线程(当前获取锁的线程) 
    _WaitSet      = NULL;  // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;    
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
    FreeNext      = NULL ;
    _EntryList    = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

对象的内存布局

Hotspot虚拟机中,对象在内存中的存储布局可以分为三块区域,对象头、实例数据和对齐填充,可以使用 jol-core查看

  • 对象头:如hash码、对象所属年代、对象锁、锁状态标志、偏向锁(线程)Id、偏向时间、数组长度(数组对象才有)等
  • 实例数据:存放类的属性数据信息,包括父类的属性信息
  • 对其填充:虚拟机要求对象起始地址必须是8字节的正数倍数,仅为了对其填充

对象头详解

  • Mark word:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程Id、偏向时间戳,这部分在32和64位虚拟中分别为32bit和64bit,官方称之为"Mark word"
  • klass Pointer:对象头的另一部分是klass类型指正,虚拟机通过这个指正来确定该对象是哪个类的实例
  • 数组长度:只有数组对象才有,记录数组的长度,4字节

偏向锁

偏向锁模式存在偏向锁延迟,Hotspot虚拟机在启动后有4s的延迟才会开启偏向锁。因为JVM启动时会有一系列复杂的活动,比如装载、系统类初始化等,这些过程大量使用synchronized关键字对对象加锁,为了减少初始化时间,JVM默认延时加载偏向锁。也可以修改JVM关闭偏向锁

偏向锁是一种针对加锁操作的优化手段,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。

偏向锁撤销

  • 调用对象的HashCode: 轻量级锁会在锁记录中记录 hashCode,调用hashCode会导致该对象的偏向锁被撤销
  • 调用wait/notify:偏向锁状态执行notify会升级为轻量级锁,调用wait会升级为重量级锁

轻量级锁

偏向锁失败,虚拟机并不会立刻升级为重量级锁,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。

synchronized锁优化,

偏向锁批量重偏向&批量撤销

多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

  • 批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
  • 批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。、、

总结:

  1. 批量重偏向和批量撤销是针对类的优化,和对象无关。
  2. 偏向锁重偏向一次之后不可再次重偏向。
  1. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

自旋优化

重量级锁竞争的时候,还可以通过自旋进行优化,如果当前线程自旋成功(即这是持有锁的线程退出了同步块,释放了锁),这时当前线程就可以避免阻塞

  • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核才能发挥优势
  • java6之后锁是自适应的,java7只有不能控制是否开启自旋
  • 自旋的目的是为了减少线程挂起的次数,尽量减少用户态到内核态的切换,这才是重量级锁最大的开销

锁粗话

假设一系列的连续操作都会对同一个对象返回加锁及解锁,甚至加锁是在循环体同,及时没有出现线程竞争也在不停的互斥同步操作造成不必要的损耗,JVM检测到有一连串零碎的操作都是对同一对象加锁,将会扩大加锁的同步范围(即锁粗话)到整个操作序列的外部。

如下,每次调用buffer.append方法都需要加锁和解锁,JVM会将其合并成一次范围更大的加锁和解锁操作,即第一次append到最后一次append操作后解锁

StringBuffer buffer = new StringBuffer();
/**
 * 锁粗化
 */
public void append(){
    buffer.append("aaa").append(" bbb").append(" ccc");
}

锁消除

值删除不必要的加锁操作,所消除是在java虚拟机JIT编译期间,通过运用上下文扫描,去除不存在共享资源竞争的锁,可以节省毫无意义的请求锁的时间

逃逸分析

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。

方法逃逸(对象逃出当前方法)

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

线程逃逸((对象逃出当前线程)

这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。

使用逃逸分析,编译器可以对代码做如下优化:

1.同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

2.将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

3.分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

问题

  • sleep、wait、join、yield

sleep:线程休眠,即占着茅坑不拉屎,不释放锁,休眠到时间会自动环境开始执行;

wait:线程睡眠,释放锁重新去排队,如果没有主动唤醒会在线程结束后自动结束

join:主线程等待子线程完成后,主线程再执行

yield:释放cpu时间片,让其他线程现行,释放后重新进入就绪状态进行等待执行,也有可能释放cpu时间片后抢到了执行权

  • sychronized的自旋锁、偏向锁、轻量级锁、重量级锁,分别介绍和联系

锁升级步骤:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,重量级锁的优化: 自旋获取锁,尽量减少用户态到内核态的切换

无锁:就是无锁

自旋锁:也成乐观锁,认为大概率能拿到锁,会多次尝试获取锁,如果一直获取不到会进入重量级锁阻塞;单核CPU自旋无意义,多核CPU才能发挥优势,缺点是长时间空转浪费大量CPU资源

偏向锁:标记线程优先获取到锁,其他的正常竞争,如果出现竞争会升级到轻量级锁甚至重量级锁

轻量级锁:适应线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。

重量级锁:除了持有锁的线程,其他线程在释放之前不可获取到锁,阻塞等待

  • 轻量级锁是否可以降级为偏向锁

一但升级为轻量级锁,就不会再降级为偏向锁了,在没有竞争的情况下,持有轻量级锁的线程在执行完之后,会变成无锁状态

  • 重量级锁释放之后变为无锁,此时有新的线程来调用同步块,会获取什么锁?

答案是获取到偏向锁,新线程访问同步代码块,会先取到偏向锁,如果再轻量级、重量级升级

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值