05-线程安全和锁优化

线程安全和锁优化

一、线程安全

  • 多线程访问一个对象时,如果不需要考虑多线程环境下的调度,也不需要进行额外的同步,并且调用对象的行为能够表现出正确的结果,则该对象是线程安全的。

二、Java实现线程安全的方法

2.1 互斥同步

方式:

  • 互斥同步的方式包括:synchronized Semaphore、 Lock(悲观锁)等
二者在锁上的区别:后者可中断、可实现公平、可绑定多个条件、可尝试
  • 弊端:会引起线程的阻塞和唤醒,开销较大

2.2 非阻塞同步

  • 非阻塞同步方式:(乐观锁,需要硬件指令的支持)
乐观并发策略需要硬件指令集的发展才能进行,为什么?

因为乐观锁要求冲突检测和操作两个步骤具备原子性,比如CAS(1,2)将值1改为2,并且是期望值为1,目标修改值
为2,那么检测是否为1这个步骤是冲突检测,将1修改为2这个步骤是修改操作,这两个步骤必须是原子不可中断
的,假设可以中断那么有可能检测为1之后又被修改了,则达不到目的。

这里通过硬件层面来保证一个语义看起来需要多个操作的行为,使用一条处理器指令就能完成。常见的比如:
测试并设置、获取并增加、交换、比较并交换等
  • CAS 包含三个参数:内存地址,期望旧值、目标新值
  • CAS弊端:ABA问题、循环时间长、只能操作单一变量;更多参考:Java多线程、原子操作和CAS

2.3 无同步方案

无同步方案:对应于天生线程安全的场景,一些场景不涉及共享数据,比如:

  • 可重入代码;比如Spring中一个Bean的处理方法,不依赖堆上的变量,或者说输入确定时输出能够确定)
  • 线程本地存储:ThreadLocal ,参考:Java多线程、ThreadLocal

三、对象头

  • JDK 1.6 中的一个重要主题是锁优化,引入了很多优化手段,优化手段和对象头息息相关,先看看对象头部分的内容
  • Java对象头的长度,非数组类型是2个字宽的长度,数组类型是3个字宽的长度,(32位虚拟机字宽是32bit,64位虚拟机字宽为64bit),非数组类型两个字宽分别为Mark Word和指向对象类型的指针,数组类型也是一样,第三个字宽为数组长度;
  • Mark Word是对象头中我们需要重点了解的部分,它和锁息息相关,Mark Word默认存储hashCode,GC年龄,锁标记位等,下面是32位JVM Mark Word的默认存储结构
//32位虚拟机的 MarkWord 在未锁定状态下数据组成 ,共32 bit
25(hasncode)  + 4(GC AGE) + 1(是否是偏向锁) + 2(锁标志位)
  • 需要注意的是运行时期,Mark Word内存储的信息会随着锁标志位的变化而变化,这是为了提高虚拟机的存储效率,毕竟对象头是一个额外的储存成本,Mark Word可能变化为存储下面几种信息:
状态 标志位 存储内存
未锁定 01 对象hash码,对象分代年龄
轻量级锁 00 指向栈中锁记录指针
膨胀(重量级锁) 10 指向重量级锁(互斥量)的指针
GC标记 11 空、不记录信息
可偏向 01 偏向线程ID、Epoch、对象分代年龄、偏向标志位置为1

四、锁优化

  • 锁优化部分包括对锁的状态类型做了细分,比如分为:偏向锁、轻量级锁和重量级锁,因为这三种锁状态下加锁的动作是不一样的,当然原理和效率也有区别;
  • 另外锁优化部分还包括锁消除和锁粗化这种对锁的粒度控制的优化方式;

4.1 自旋锁和自适应自旋锁

  • 线程等待锁的时候,不是挂起,而是通过自旋的方式执行一小段时间;
  • JDK1.6 开始自旋锁默认开启,
  • 自旋锁:默认自旋次数10次,通过参数:-XX:PreBlockSpin 修改次数;
  • 自适应自旋锁:JDK1.6引入自适应自旋锁,JVM会根据自旋锁的成功率调整自旋次数,比如前一次自旋成功,那么后一次就会运行自旋更多的次数,反之则自旋可能更少的次数,让JVM更智能;

4.2 锁消除和锁粗化

  • 锁消除:去掉不必要的锁,JVM在的即时编译器会检测,对于一些不需要同步(比如不存在共享数据竞争的代码)但是声明了同步的代码,将其锁消除掉;
1.通过逃逸分析技术,比如堆上的数据不会逃逸出去从而别的线程根本访问不到,那么就可以认为这些数据可以保存在栈
上面,由此可以不加锁

2.另一个场景:对于一些同步方法,对象的声明和使用都在一个线程安全的作用域,则可进行锁消除;比如方法内声明一个
StringBuffer对象并进行字符串拼接,因为StringBuffer内部的方法都是同步方法,但实际上方法内的声明和使用都是线程
安全的,在线程栈中进栈出栈,不涉及线程安全问题,因此可以消除对应的锁
  • 锁粗化:避免出现一系列的频繁加锁操作,尤其是对一个对象频繁的加锁解锁,此时可以扩大锁的范围;

4.3 轻量级锁

  • JDK1.6引入,为了提升重量级锁(传统锁)性能
重量级锁就是我们通常理解的锁,获取不到就阻塞,直到被唤醒,因为线程的阻塞和唤醒对应着内核线程的挂起和解挂,开
销较大,因此性能开销较大
  • 轻量级锁加锁:轻量级锁模式下线程尝试获取一个对象的锁时,线程会在自己的栈帧开辟一块Lock Record的空间存储锁对象的Mark Word拷贝(添加了一个Displaced前缀),然后使用CAS操作将对象头的MarkWord修改为指向栈帧中锁记录的指针,如果CAS成功则线程获取该锁,如果失败,虚拟机还会检查锁一些对象的Mark Word是否指向该线程,如果是则说明线程拥有锁可以执行同步代码,反之说明获取锁失败,线程则会自旋尝试获取锁避免挂起;

  • 加锁流程图:

在这里插入图片描述

  • 轻量级锁解锁:线程解锁时,使用CAS操作将栈帧中保存的Mark Word通过CAS设置到对象头中,CAS成功则说明没有竞争则释放锁;CAS如果失败说明存在锁竞争,线程会释放锁并通知等待锁的线程(此时已经升级为重量级锁);
这里的升级过程梳理一下,线程1获取了轻量级锁,执行完同步代码之后,尝试解锁,通过CAS将原本的Mark Word设置回对象头失败了,为什
么?因为在线程1执行同步代码的时候,假设一个线程2来获取轻量级所,获取过程和前面的图一样,但是线程2显然会获取失败,由此线程2进
入自旋等待的过程,如果等待的过程非常短,那么线程2不会修改锁对象的Mark Word,那么线程1必然会解锁成功,现在的问题是线程2肯定自
旋等待失败了,因此锁对象升级为重量级锁,锁对象的Mark Word已经发生了改变,其锁标志也又00改成了01 (00代表轻量级锁,01代表重量级锁)
并且指针也不是之前的指向线程1的栈帧中的Lock Record,而是指向重量级锁指针(这个修改的过程谁来做?暂不清楚,理解为JVM保证吧),因此
锁升级之后线程2阻塞挂起(自旋没有意义了,浪费CPU),等到线程1解锁的时候,显然CAS会失败因为原本的期望值以及改变了,线程1知道锁升级为
重量级锁了,就清空Mark Word解锁,并唤醒等待锁的线程2;

这里假设有线程3竞争锁也是一样,升级之后线程3直接阻塞(重量级锁的加锁方式,轻量级锁升级为重量级锁之后,就不会再降级,后续获取锁失败
就直接阻塞,不会再自旋了)

线程2自旋的次数参考4.2,默认10次,但是JVM会自适应;
  • 下面是摘自《Java并发编程艺术》的轻量级锁膨胀过程:

在这里插入图片描述

4.4 偏向锁

  • JDK1.6 引入的锁优化机制,偏向锁状态下,锁会偏向于第一个获得锁的线程,如果第一个线程释放锁之后锁未被其他线程获取过,这个线程后面再次获取锁的时候不需要进行同步;
-XX:+UseBiasedLocking 开启偏向锁,默认打开;
-XX:-UseBiasedLocking 关闭偏向锁
-XX:BiasedLockingStartupDelay=0 关闭延迟让程序启动后立刻启用偏向锁,默认情况下程序启动后几秒才会激活偏向锁
  • 偏向锁的获取:假设程序开启了偏向锁,线程首次对一个对象加锁时,JVM会通过CAS将线程ID写入到对象头,如果成功则代表加锁成功,然后即可运行同步代码,后续该线程再次进入同步代码块就不需要进行CAS操作,只需要检测MarkWord中是否存储指向当前线程的偏向锁,如果是则直接获得锁,如果不是则先判断是否为偏向模式,是偏向模式则使用CAS设置线程ID到对象头,设置失败则撤销偏向锁,设置成功则获得偏向锁;

  • 偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着:如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

  • 偏向锁在线程竞争较少的情况下可以提升性能,如果锁大部分时候都被很多线程竞争,则偏向锁并不一定对程序有利;

  • 下面是偏向锁的初始化流程和撤销流程,摘自《Java并发编程艺术》;

在这里插入图片描述

在这里插入图片描述

4.5 对比

  • 下面是几种锁的对比
锁类型 优点 缺点 适用场景
偏向锁 直接获取锁、加解锁不需要额外消耗、性能最优 存在锁竞争时,会增加多于的锁撤销操作 适用于只有一个线程访问同步代码的场景
轻量级锁 锁竞争时不阻塞线程,通过自旋方式减少了阻塞线程的开销 竞争大时可能造成CPU消耗 同步代码块执行很快、竞争不多、快速响应优先场景
重量级锁 锁竞争时不会消耗CPU 获取锁失败直接阻塞 锁冲突较多、同步代码块执行较久、吞吐量优先场景

五、锁升级

  • 下面在摘自[深入理解Java虚拟机-13章]的图片:

在这里插入图片描述

先看纵向的转换关系:

  • 初始分配对象,根据偏向锁是否启用有两种状态,偏向锁可用状态(左1)和不可用状态(右1),刚开始对象都没有被锁定;
  • 可偏向状态:初始线程A加锁锁定该对象后,进入偏向锁定状态(左2),初始线程的加解锁会在(左1)和(左2)两个状态之间转换;
  • 不可偏向状态:线程加锁后进入轻量级锁定状态(右2),轻量级锁定状态在竞争情况下会膨胀为重量级锁定状态(右3),轻量级和重量级锁的解锁操作都会回到(右1);

再看横向的转换关系:

  • 如果偏向锁模式下,对象头已经记录了初始线程的线程ID,此时线程B来竞争锁(此时可能锁定也可能没有锁定,因为偏向锁不会自动撤销):

  • 如果处于未锁定状态那么就会取消偏向锁,回到(右1),

  • 如果处于锁定状态,那么线程B的竞争锁就会导致初始线程持有的锁进入轻量级锁;

  • JDK1.6中锁共有4种状态,级别从低到高依次为:无锁 -> 偏向锁状态 -> 轻量级锁 -> 重量级锁,锁可升级但不可降级(意味着偏向锁一旦升级后就不能再降级);

六、代码验证

  • 为了优化下面两种场景,出现了批量重偏向(bulk rebias)机制和批量撤销(bulk revoke)机制。
1.一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。

2.存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。

6.1 重偏向

6.1.1 代码

  • 一个线程获得偏向锁之后,另一个线程尝试获取失败,则锁会撤销并升级为轻量级锁,这里算是撤销一次,如果一个类的对象作为锁撤销到达阈值之后(默认20),则后面的线程竞争偏向锁不会导致其撤销,而是偏向新的线程;

  • 代码是这样的,首先我们有一个线程1,对100个对象加锁,然后释放,由此我们知道这100个对象自然是偏向线程1的,

  • 然后我们再有一个线程2,依次对这100个对象加锁,按照偏向锁的撤销机制,这些锁对象会撤销偏向锁,但是撤销到了一个阈值之后,就不会再撤销锁了,而是会重新偏向到线程2(也就是锁对象由偏向线程1改为偏向线程2),先看看这个例子

  • 代码:Test1.java

  • 输出:test1.txt

  • 代码中先创建30个锁对象,并打印对象头,然后创建线程1,对30个LockObject对象加锁,线程1继续保持运行状态再打印锁对象的对象头,然后启动线程2依次对30个对象加锁,然后打印锁对象的对象头(其实就是看MarkWord,看他偏向哪个线程)

  • 输出比较多,小结一下,

  • 最前面的锁对象的30次打印,打印结果都是一样的,此时是无锁状态,对应的钱4个字节是5 (00000101 00000000 00000000 00000000)

  • 线程1执行完后线程2开始之前打印对象头,我们看到30次打印对象头指针值也是一样的,值为:552321029 (00000101 11000000 11101011 00100000),我们可以认为这就是偏向线程1,(注意每次运行这个值都不一样)

  • 线程2加锁后打印,第 1-19 次打印的是:562492272 (01110000 11110011 10000110 00100001),第 20-30 次打印的是:551340293 (00000101 11001001 11011100 00100000),即从20次开始变化了,且后面是一致的,

  • 根据64位虚拟机的对象头结构,MarkWord 长度为64bit,就是前面两行共8个字节,但是我们看到第二行一直是0,因此对象头信息应该和32位一样保存在前面的4个字节共32bit里面

//这一行一直是0
 4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  • 现在我们按照对象头的结构,对比对象头结果来分析其变化

在这里插入图片描述

  • 初始是 5,(00000101 00000000 00000000 00000000) ,对比我们的结构发现不好理解,这里我猜测JVM应该是按照小字节序,因此最后一个字节在前面,因此前面的000000101对应的就是上图的最后8bit,因此最后的101,第一个1代表开启偏向锁,后面的01是无锁状态标志,其余的hash,分代年龄都是0
  • 线程1加锁后对象头为:552321029 (00000101 11000000 11101011 00100000),根据上图偏向锁的结构00000101中的最后101表示开启偏向锁和锁标志,其余的为线程ID和Epoch等信息
  • 线程2加锁前19次是:562492272 (01110000 11110011 10000110 00100001),我们看到锁标志为000,是轻量级锁,说明前19次都发生了锁撤销,导致偏向锁转换为轻量级锁
  • 线程2加锁后11次是:551340293 (00000101 11001001 11011100 00100000),我们看到锁标志是101,是偏向锁,

6.1.2 小结

  • 原理是这样,线程1首先对LockObject类的30个对象加锁,此时30个锁对象都偏向线程1,然后线程2依次对30个对象加锁,线程都会竞争失败,此时前面19个直接就撤销偏向锁升级为轻量级锁了,但是从第20个开始(这也是默认的阈值),线程2对锁对象的竞争不会导致锁撤销并升级为轻量级锁,而是偏向线程2;

  • 需要注意的是重偏向是根据类来统计的

6.2 批量撤销

原理:

对象所属的类 class 中, 会保存一个 epoch值,每一次该class的对象发生偏向撤销操作时,该值+1。当这
个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。(对应前面的
例子就是线程2加锁导致撤销了19次,第20次开始不升级为轻量级锁,而是重偏向)当这个值达到批量撤销阈
值(默认40)时,就会执行批量撤销。


批量重偏向:
发生批量重偏向时,将class中的epoch值+1,同时遍历JVM中所有线程栈, 找到该class所有正处于加锁状态
的偏向锁对象,将其对象的epoch字段改为class中epoch的新值。下次获得锁时,发现当前对象的epoch值
和class的epoch不相等(说明该对象目前没有线程在执行同步块),所以算当前对象已经偏向了其他线程,也
不会执行撤销操作,而是可以直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。


批量撤销:
当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为
该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级
锁的逻辑。
  • 需要注意的是批量撤销是根据类来统计的

  • 后面的示例是,我初始创建50个锁对象,然后线程1依次加锁一次,然后创建线程2依次加锁一次,由前面的可知前面19次会撤销锁,然后后面的21次会偏向线程2,然后我再创建线程3,从第20个对象开始加锁31次到第50个对象,这31个对象会撤销为轻量级锁(重偏向只能发生一次,不能再从2偏向3,线程3使用已经偏向线程2的锁时,就会升级为轻量级锁),由前面的原理可知,到此时锁撤销的次数是大于40次的(线程2有19次,线程3有31次),因此JVM会标记这个类所有的对象都不再适合偏向锁,直接走轻量级锁,用什么来说明这一点呢?很好办,在程序最开始和程序最末尾我分表创建了两个对象,这两个对象都没有加过锁,我就看它的对象头,发现最前面的锁标志是101,即可偏向的偏向锁,但是最后的对象的对象头锁标志是001,即不可偏向的偏向锁,后续这个类的对象加锁都走轻量级锁,这就是偏向锁的批量撤销;

  • 代码:Test2.java

  • 输出:test2.txt

七、参考

发布了270 篇原创文章 · 获赞 30 · 访问量 11万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 精致技术 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览