- 前言
- 01、volatile原理
- 02、synchronized原理
- 总结
记录一下Java并发编程的知识点。有部分内容是借鉴《Java并发编程的艺术》这本书的。本次介绍一下Java并发机制的底层实现,主要是volatile和synchronized关键字。
01、volatile原理
volatile的应用
volatile可以保证并发过程中变量的“可见性”。同时可以禁止指令重排
“可见性”的原理
在了解volatile实现原理之前,先看看与其实现原理相关的CPU术语与说明。下表来自《Java并发编程的艺术》
还有就是为了提高处理速度,CPU处理器不会直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行 *** 作,但是 *** 作完不知道何时会写到内存。如下图
当有volatile变量修饰的共享变量进行写 *** 作的时候,JVM会向处理器发送一条Lock前缀的指令,这个指令在多核处理器下会引发两件事:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的 *** 作会导致其他CPU里缓存了该内存地址的数据无效
这里第二点涉及到MESI(缓存一致性协议):每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改 *** 作的时候,会重新从系统内存中把数据读到处理器缓存中。
禁止指令重排的原理
首先我们先看一下内存屏障的指令类型,如下表
为了实现volatile禁止指令重排,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。策略如下:
- 在每个volatile写 *** 作的前面插入一个StoreStore屏障。
- 在每个volatile写 *** 作的后面插入一个StoreLoad屏障。
- 在每个volatile读 *** 作的后面插入一个LoadLoad屏障。
- 在每个volatile读 *** 作的后面插入一个LoadStore屏障。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
synchronized的使用
利用synchronized实现同步的基础:Java中的每个对象都可以作为锁。具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是Synchonized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或者抛出异常时也必须释放锁。那么锁到底存在哪里呢?
Java对象头
Java对象头主要由两部分组成:Mark Word(存储对象的hashCode或锁信息)和Class Metadata Address(存储到对象类型数据的指针),如果是数组对象,还有一个Array length来存储数组的长度。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化,存储结构可能存在下面5种变化。下图来自黑马程序员的课程。
synchronized锁的初步了解
从JVM规范中可以看到synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。细节实现是使用monitorenter和monitorexit指令实现的。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
任何对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的Monitor的所有权,即尝试获得对象的锁。
而对象和Monitor关联就是通过对象头中的Mark Word。
Monitor
结合对象头和上面synchronized锁的初步了解,我们可以理解为当我们使用synchronized给对象加锁(重量级)后,该锁对象的对象头Mark Word中就被设置为指向Monitor对象的指针。
Monitor结构如下:
- 当一个线程去访问synchronized所修饰的同步资源时,锁对象的对象头Mark Word会修改为指向Monitor的指针
- 假设Thread-2抢夺成功,Monitor中的Owner指向Thread-2
- 后续的线程进行抢夺的话会被加入Monitor中的EntryList队列中
synchronized锁升级
jdk1.6以后,为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
偏向锁
1、偏向锁加锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头的Mark Word和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS *** 作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):
- 如果没有设置,则使用CAS竞争锁;
- 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
2、偏向锁的撤销
- 其他线程争夺锁对象,会将偏向锁升级为轻量级锁
- 当调用了锁对象的hashCode方法对导致偏向锁被撤销,因为Mark Word中会因为存放hashCode而无法存放线程id
轻量级锁
1、轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为DisplacedMark Word。
然后线程尝试使用CAS(比较并交换)将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。过程如下图
如果线程自己执行了synchronized锁重入,那么会再添加一条锁记录作为重入的计数。
2、轻量级锁解锁
轻量级解锁时,如果有锁记录为null的,表示有重入重置锁记录,表示重入计数减一。如果锁记录不为null,会使用原子的CAS *** 作将Displaced Mark Word替换回到对象头
- 如果成功,则表示没有竞争发生。
- 如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
3、锁膨胀过程
- Thread-1想要加锁时,Thread-0已经对锁对象加了轻量级锁
- Thread-1加锁失败,进入膨胀过程
- 修改锁对象中Mark Word指向重量级锁地址,并设置Owner指向Thread-0
- 自己进入Monitor的EntryList阻塞
- Thread-0解锁是,CAS恢复Mark Word的时候失败,释放锁(把Owner设置null)并且唤醒Monitor中EntryList阻塞的线程
总结
以上就是本次文章的内容了,感觉涉及到很多平时没有接触到的知识点,比如内存屏障、Monitor对象等等,一开始理解可能比较难,等接受了有这些东西的存在以后,就感觉一切都是那么顺其自然了。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)