2.java并发机制的底层实现

一、volatile的应用

1.1、volatile的实现原理

  • 原子性:

操作A和操作B:
对于操作A来说:操作B要么不执行,要么完全执行完。B对于A就有原子性

  • 可见性:(加锁)

一个线程对一个变量进行修改,另外一个线程可以立马感知到,必须等待。

  • 有序性:

代码执行的顺序和大脑想象的顺序是一样的,所见即所知。先定义谁,先执行谁。

volatile:只能满足可见性有序性

术语 英文单词 术语描述
内存屏障 memory 是一组处理器的指令,用于实现对内存操作的顺序限制
缓冲行 cache line CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时,会加载整个缓存行,现代CPU需要执行几百次CPU指令
原子操作 atomic operations 不可中断的一系列操作
缓存行填充 cache line fill 见百度www.4399.com
缓存命中 cache hit 见百度www.4399.com
写命中 write hit 见百度www.4399.com
写缺失 write misses the cache 一个有效的缓存行被写入到一个不存在的内存区域

Lock前缀的指令在多核处理器下回引发两件事情:

1、将当前处理器缓存行的数据写会到主内存

2、这个写回内存的操作会使在其他CPU里缓存了该内存的地址的数据无效

总结:修改了主内存的数据,其他线程会立刻感知到

如果对声明了 volatile 的变量进行了 写操作,JVM就会向处理器发送一条Lock 前缀指令,将这个变量所在的缓存行的数据写回到系统内存。但是,就算是写回到系统内存,如果其他处理器缓存的值还是旧的,在执行计算操作就会有问题。所以,在多核处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是否是过期的,当处理器发现自己缓存过期了,就会将当前处理器的缓存设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理缓存中

下面来讲解volatile的两条实现原则:

  • 1、Lock前缀指令会引起处理器缓存写到内存。(不锁总线,开销大,所以使用“缓存锁定”)

  • 2、一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

1.2、volatile的使用优化

著名的Java并发编程大师Doug lea在JDK 7的并发包里新增一个队列集合类Linked-TransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。LinkedTransferQueue的代码如下。

 	队列中的头部节点 

private transient f?inal PaddedAtomicReference<QNode> head;

	 队列中的尾部节点 

private transient f?inal PaddedAtomicReference<QNode> tail;

static f?inal class PaddedAtomicReference <T> extends AtomicReference T> {

	 使用很多4个字节的引用追加到64个字节

	Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;

	PaddedAtomicReference(T r) {

		super(r);

	}

}

public class AtomicReference <V> implements java.io.Serializable {

	private volatile V value;

	 省略其他代码

}

1.2.1、追加字节能优化性能?

1.2.2、为什么追加64字节能够提高并发编程的效率呢?

1.2.3、那么是不是在使用volatile变量时都应该追加到64字节呢?答:不是的,在两种情况下不适应。

  • 1、缓存行非64字节宽的处理器

  • 2、共享变量不会被频繁的写。

二、synchronized的实现原理与应用

  • 对于同步方法,锁的是当前对象(this)

  • 对于静态同步方法,锁的是当前类的Class的对象

  • 对于同步代码块,锁的是()里面的对象

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者实现的细节不一样。

  • 代码块同步使用的monitorenter和monitorexit指令实现的

  • 方法同步使用另外一种方式实现的,细节在JVM规范里没有说。但是方法的同步同样是使用这两个指令号来实现的。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit插入到方法结束处和异常处,必须成对儿出现。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenet指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

2.1、java对象头

synchronized用的锁是存在java对象头里面的。如果对象是数组类型,则虚拟机用3个字宽(word)存储对象头;如果对象是非数组类型,则用2个字宽来存储对象头。1字宽=4字节;

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode、锁信息、分代年龄
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Arrays Length 数组的长度(如果当前对象是数组)

2.2、锁的升级与对比

锁一共有四种状态的 级别 从低到高:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几种状态会随着竞争情况逐渐升级注意:不能降级。

2.2.1、偏向锁

为了让线程获得锁的代价低而引入的偏向锁。

当一个线程访问同步代码块并且获得锁的时候,会在对象头和栈帧的锁记录中存储着偏向锁的线程ID,以后该线程进入或者退出代码块时,不需要进行CAS来操作加锁和解锁,只需要简单测试一下对象头里是否存着指向当前线程的偏向锁。

如果失败了还需要进行CAS操作。

2.2.1.1、偏向锁的撤销

偏向锁使用了一种** 等到 竞争出现 才释放锁的机制,,所以,当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放。 (弱势群体)**

2.2.1.2、偏向锁的关闭

偏向锁在JDK6和7中是默认启动的。但是是有延迟的。
关闭延迟参数:-XX:BiasedLockingStartupDelay=0。
如果你确定所有锁通常情况下处于竞争状态,可以关闭偏向锁。
关闭偏向锁: -XX:UseBiasedLocking=false。那么程序会默认进入轻量级锁的状态。

2.2.2、轻量级锁

2.2.2.1、轻量级锁的加锁

线程在执行同步代码快** 之前,JVM会先在当前线程的栈帧里创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录中,官方称:Displaced Mark Word。
然后线程尝试使用CAS将对象头的Mark Word替换为指向锁记录的指针。**

如果成功,获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁。

2.2.2.1、轻量级锁的解锁

轻量级锁的解锁时,会使原子的CAS操作将Displaced Mark Word 替换回到对象头。

如果成功,表示没有竞争发生,解锁成功。
如果失败,表示当前锁存在竞争,锁就会膨胀成为“重量级锁”(因为两个线程同时竞争锁,导致锁升级)

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级,就不会在恢复到轻量级锁的状态。

2.2.3、锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的,和执行非同步代码块速度差距很小 如果线程之间存在着锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步代码快场景
轻量级锁 竞争的线程不会阻塞,提高了线程的响应速度 如果始终得不到锁竞争的线程,会使用自旋来消耗CPU 追求响应速度,同步代码快执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应速度慢 追求吞吐量,同步代码快执行速度较长

三、原子操作实现原理

3.1、术语定义

3.2、处理器如何实现原子操作

32位IA-32处理器采用的是总线加锁或者缓存加锁的方式来实现原子操作。首先处理器会自动保证基本的操作原子性。

1、使用总线锁来保证原子性。
第一个机制就是通过总线锁来保证原子性。如果多个处理器同时对共享变量进行修改时,就会有线程安全问题,结果可能会和自己想要的结果不一样。

如果想要保证读写共享操作是原子的,就必须保证CPU1读改写的时候,CPU2不能操作该变量。

** 所谓总线锁:使用处理器提供的LOCK # 信号,当一个处理器在总线上输出信号时,其他处理器的请求就会被阻塞,那么该处理器会独占共享内存。**

2、使用缓存锁来保证原子性
第二个机制就是通过缓存锁来保证原子性。我们只需要保证对某个内存地址的操作时原子的就OK啦,但是总线锁是把CPU和内存之间的通信全锁住了,这使得锁定期间,别的内存也不能修改啦,所以总线锁的开销比较大。所以才出现了缓存锁。

所谓缓存锁:内存区域如果被缓存在处理器的缓存行中,并且Lock操作期间被锁定,那么他执行锁操作回写到内存时,处理器不在总线上 发出Lock # 信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证原子性。

缓存一致性:会阻止同时修改由两个以上处理器缓存的内存区数据,当其他处理器回写已被锁定的缓存行数据时,会使得缓存行数据失效。

例如:当CPU1修改了缓存行中的i时,使用了缓存锁,那么CPU2就不能同时缓存i所在的缓存行。

但是有两种情况不会使用缓存锁定:
1、当操作的数据不能被缓存到处理器的内部时,或者操作的额数据横跨多个缓存行,则处理器需要使用总线锁定。

2、有的处理器不支持缓存锁定。

3.3、java如何实现原子操作的

在java中通过锁和循环CAS的方式来实现原子操作。

3.3.1、使用循环CAS来实现原子操作

使用java.util.atomic包下的原子类。

3.3.2、使用CAS实现原子操作的三大问题

1、ABA问题:使用版本号

因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,最后又变回了A,那么CAS在检查的时候,就没有发现他的变化,实际上是发生了变化,ABA问题的解决思路就是加版本号,每次变化让版本号+1即可,那么原来的** A->B->A就变成了现在的A1->B2->A3。从JDK1.5开始,JDK的Atomic包中提供了一个类AtomicStampedReference(原子时间戳引用)来解决ABA问题。这个类的compareAndSet方法的作用就是首先检验当前引用是否等于预期引用,并且检查当前标志(版本)是否等于预期标志(版本),如果全部相等,则以原子方式将引用和该标志的值设定为给定的更新值**

public boolean compareAndSet(V expectedReference,   预期引用
                             V newReference,  		更新后的引用
                             int expectedStamp,     预期标志(版本)
                             int newStamp)			更新后的标志(版本)

2、循环时间长开销大:

自旋CAS如果长时间不成功,那么会一直占用CPU资源,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

3、只能保证一个共享变量的原子操作:

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。这个时候可以用锁。还有一个巧妙的方法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用CAS来操作 ij。从jdk1.5开始,jdk提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作了。

3.3.3、使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多锁机制,偏向锁、轻量级锁、互斥锁。有意思的是 除了 偏向锁JVM实现锁的方式都是用了循环CAS,即当一个线程想进入同步代码快的时候使用循环CAS的方式来获取锁,当它退出同步代码块的时候循环CAS释放锁

posted @ 2021-08-13 19:14  宋佳强  阅读(39)  评论(0编辑  收藏  举报