多线程-synchronized深入分析

 

:什么是synchronized

  synchronized是java关键字,初学者接触多线程,为了保证线程安全,接触的最多的应该是synchronized。

  synchronized可以保证方法、代码块在运行时,同一时刻只有一个线程可以执行,基于happens-before的监视器锁规则可以推断出共享变量的可见性(happens-before规则概念前文有写)。

  可以把synchronized当作一把隐式锁,因为锁的释放和获取都是由jvm来控制的。

 

二:synchronized的实现原理

2.1 从底层了解synchronized关键字

   每一个Java对象都可以当做一把锁(下文会有解释),锁是实现synchronized的基础。

   1、synchronized修饰普通方法时,锁是当前实例对象。

   2、synchronized修饰静态方法时,锁是当前class对象。

   3、synchronized修饰代码块时,锁是括号中的对象。

   synchronized修饰代码块时实现是基于monitorenter、monitorexit指令实现, synchronized修饰方法时实现是基于方法修饰符上的ACC_SYNCHRONIZED 实现。

   下图是同步代码块被编译后生成的字节码。

   synchronized关键字基于上述两个指令实现了锁的获取和释放过程,解释器执行monitorenter时会进入到InterpreterRuntime.cppInterpreterRuntime::monitorenter函数(字节码解释执行时,底层调用的是c++语言,这些函数的执行过程对应到Java层面是Java的对象头中Mark WordMonitor Record

2.2 Java对象头

  Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。

  Mark Word里存储着对象自身的运行数据,如HashCode,分代年龄,锁标志位,是否为偏向锁,偏向线程id。

2.3 Monitor Record

  1、每个线程内部会开辟一块空间,用来存储锁记录

  2、一个被锁住的对象都会和一个MR关联(对象头的MarkWord中的LockWord指向MR的起始地址)

  3、MR中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用

                                                     Monitor Record结构

                                                   Monitor Record工作机理

 

1、线程如果获得监视锁成功,将成为该监视锁对象的拥有者

2、在任一时刻,监视器对象只属于一个活动线程(Owner)

3、拥有者可以调用wait方法自动释放监视锁,进入等待状态

 

三:锁优化

3.1 自旋锁

  痛点:由于线程的阻塞/唤醒需要CPU在用户态和内核态间切换,频繁的转换对CPU负担很重,进而对并发性能带来很大的影响

  现象:通过大量分析发现,对象锁的锁状态通常只会持续很短一段时间,没必要频繁地阻塞和唤醒线程

  原理:通过执行一段无意义的空循环让线程等待一段时间,不会被立即挂起,看持有锁的线程是否很快释放锁,如果锁很快被释放,那当前线程就有机会不用阻塞就能拿到锁了,从而减少切换,提高性能

  隐患:若锁能很快被释放,那么自旋效率就很好(真正执行的自旋次数越少效率越好,等待时间就少);但若是锁被一直占用,那自旋其实没有做任何有意义的事但又白白占用和浪费了CPU资源,反而造成资源浪费

  注意:自旋次数必须有个限度(或者说自旋时间),如果超过自旋次数(时间)还没获得锁,就要被阻塞挂起

  使用: JDK1.6以上默认开启-XX:+UseSpinning,自旋次数可通过-XX:PreBlockSpin调整,默认10次

3.2 自适应自旋锁

  痛点:由于自旋锁只能指定固定的自旋次数,但由于任务的差异,导致每次的最佳自旋次数有差异

  原理:通过引入"智能学习"的概念,由前一次在同一个锁上的自旋时间和锁的持有者的状态来决定自旋的次数,换句话说就是自旋的次数不是固定的,而是可以通过分析上次得出下次,更加智能

  实现:若当前线程针对某锁自旋成功,那下次自旋此时可能增加(因为JVM认为这次成功是下次成功的基础),增加的话成功几率可能更大;反正,若自旋很少成功,那么自旋次数会减少(减少空转浪费)甚至直接省略自旋过程,直接阻塞(因为自旋完全没有意义,还不如直接阻塞)

   补充:有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,JVM对锁的状况预测会越来越准确,JVM会变得越来越智能

3.3 锁消除

   痛点:根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁

   原理: JVM在编译时通过对运行上下文的描述,去除不可能存在共享资源竞争的锁,通过这种方式消除无用锁,即删除不必要的加锁操作,从而节省开销

  使用: 逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启

  补充:在JDK内置的API中,例如StringBuffer、Vector、HashTable都会存在隐性加锁操作,可消除

 public static void main(String[] args) { 
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
 for (int i = 0 ; i < 10000 ; i++)
{ 
synchronizedDemo.append("kira","sally");
 } 
} 

public void append(String str1,String str2){

 //由于StringBuffer对象被封装在方法内部,不可能存在共享资源竞争的情况
 //因此JVM会认为该加锁是无意义的,会在编译期就删除相关的加锁操作 
//还有一点特别要注明:明知道不会有线程安全问题,代码阶段就应该使用StringBuilder
 //否则在没有开启锁消除的情况下,StringBuffer不会被优化,性能可能只有StringBuilder的1/3 

StringBuffer stringBuffer = new StringBuffer(); stringBuffer.append(str1).append(str2);

 }

3.4 锁粗化

   痛点:多次连接在一起的加锁、解锁操作会造成

   原理:将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁

   使用:将多个彼此靠近的同步块合同在一个同步块 或 把多个同步方法合并为一个方法

   补充:在JDK内置的API中,例如StringBuffer、Vector、HashTable都会存在隐性加锁操作,可合并

StringBuffer stringBuffer = new StringBuffer();
 public void append(){ 
stringBuffer.append("kira"); stringBuffer.append("sally"); stringBuffer.append("mengmeng");
 }

3.5 锁升级

   从JDK1.6开始,锁一共有四种状态:无锁状态、偏向锁状态、轻量锁状态、重量锁状态锁的状态会随着竞争情况逐渐升级,锁允许升级但不允许降级,不允许降级的目的是提高获得锁和释放锁的效率,后面笔者会通过倒序的方式,即重量级锁->轻量级锁->偏向锁进行讲解,因为通常是前者的优化

  3.5.1: 重量级锁

   1、重量级锁通过对象内部的monitor实现

   2、monitor的本质是依赖于底层操作系统的MutexLock实现,操作系统实现线程间的切换是通过用户态与内核态的切换完成的,而切换成本很高

   3、MutexLock最核心的理念就是 尝试获取锁.若可得到就占有.若不能,就进入睡眠等待

   (有兴趣的读者可以阅读 浅谈Mutex (Lock) ,该篇对Liunx的MutexLock做了很好的讲解)

 

  3.5.2  轻量级锁

   痛点:由于线程的阻塞/唤醒需要CPU在用户态和内核态间切换,频繁的转换对CPU负担很重,进而对并发性能带来很大的影响

   主要目的: 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

   升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁

   原理: 在只有一个线程执行同步块时进一步提高性能

   数据结构: 包括指向栈中锁记录的指针锁标志位

 

   3.5.2.1:轻量级锁加锁

   1、线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中(Displaced Mark Word-即被取代的Mark Word)做一份拷贝

   2、拷贝成功后,线程尝试使用CAS将对象头的Mark Word替换为指向锁记录的指针(将对象头的Mark Word更新为指向锁记录的指针,并将锁记录里的Owner指针指向Object Mark Word)

  3、如果更新成功,当前线程获得锁,继续执行同步方法

  4、如果更新失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁,若自旋后没有获得锁,此时轻量级锁会升级为重量级锁,当前线程会被阻塞

 

   3.5.2.2:轻量级锁解锁

   1、解锁时会使用CAS操作将Displaced Mark Word替换回到对象头,

   2、如果解锁成功,则表示没有竞争发生

  3、如果解锁失败,表示当前锁存在竞争,锁会膨胀成重量级锁,需要在释放锁的同时唤醒被阻塞的线程,之后线程间要根据重量级锁规则重新竞争重量级锁

 

 3.5.2.3:轻量级锁注意事项

   隐患:对于轻量级锁有个使用前提是"没有多线程竞争环境",一旦越过这个前提,除了互斥开销外,还会增加额外的CAS操作的开销,在多线程竞争环境下,轻量级锁甚至比重量级锁还要慢

 

3.5.3偏向锁

   痛点: Hotspot作者发现在大多数情况下不存在多线程竞争的情况,而是同一个线程多次获取到同一个锁,为了让线程获得锁代价更低,因此设计了偏向锁 (这个跟业务使用有很大关系)

   主要目的: 为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径

   原理: 在只有一个线程执行同步块时通过增加标记检查而减少CAS操作进一步提高性能

   数据结构: 包括占有锁的线程id,是否是偏向锁,epoch(偏向锁的时间戳),对象分代年龄、锁标志位

 

  3.5.3.1: 偏向锁初始化

   当一个线程访问同步块并获取到锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而是先简单检查对象头的MarkWord中是否存储了线程:

   如果已存储,说明线程已经获取到锁,继续执行任务即可

   如果未存储,则需要再判断当前锁否是偏向锁(即对象头中偏向锁的标识是否设置为1,锁标识位为01):

   如果没有设置,则使用CAS竞争锁(说明此时并不是偏向锁,一定是等级高于它的锁)

   如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程,也就是结构中的线程ID

 

   3.5.3.2:偏向锁撤销锁

    偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的线程才会释放锁

    偏向锁的撤销需要等待全局安全点(该时间点上没有字节码正在执行)

    偏向锁的撤销需要遵循以下步骤:

      1、首先会暂停拥有偏向锁的线程并检查该线程是否存活:

      2、如果线程非活动状态,则将对象头设置为无锁状态(其他线程会重新获取该偏向锁)

      3、如果线程是活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,并将对栈中的锁记录和对象头的MarkWord进行重置:

      4、要么重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁)

      5、要么恢复到无锁或者标记锁对象不适合作为偏向锁(此时锁会被升级为轻量级锁)

最后唤醒暂停的线程,被阻塞在安全点的线程继续往下执行同步代码块

 

    3.5.3.3:偏向锁关闭锁

     偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活

    有必要可以使用JVM参数来关闭延迟 -XX:BiasedLockingStartupDelay = 0

     如果确定锁通常处于竞争状态,则可通过JVM参数 -XX:-UseBiasedLocking=false 关闭偏向锁,那么默认会进入轻量级锁

 

三:总结

   synchronized修饰的代码块编译后会在代码块前后插入monitorenter、monitorexit。当解释执行字节码时,jvm会调用底层的c++代码,对应java层面实际是操作java对象的对象头Mark Word、Monitor Record。在jdk1.6后synchronized引入了锁升级,锁消除,锁粗化等优化策略。且锁升级针对应用程序各业务的线程执行情况不同,引入了偏向锁、轻量级锁、重量级锁等概念。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值