并发编程 - JMM和底层实现原理(volatile、final、synchronized)、JDK8新特性

操作系统内存划分

寄存器->一级缓冲区(分为数据缓冲区,指令缓冲区)->二级缓冲区->三级缓冲区->内存->磁盘->远程文件服务器,缓冲区按照缓冲行存储与读取

物理内存模型带来的问题

因为每个cpu核心都享有自己的缓冲区,所以实际上在数据操作后,会先写到自己的缓冲区内,然后再刷新回主存,至于什么时候刷新是个不确定的因素。因此,在多线程环境下,对两个变量进行操作时,有可能导致另一个线程获取的数据不是最新的,可以采用加锁或者volatile关键字来保证数据的一致性。

伪共享

已知cpu在对数据存取的时候,是按照缓冲行的大小进行读取的,也就是说,会把cache,实际划分成很多个缓冲行大小,在每一次读取一个缓冲行的时候,会把整个缓冲行加载到缓冲区,因此可能在此缓冲区中有多个变量(假设有变量a,b),如果线程A和线程B同时读取了该缓冲行(cache2)到自己的cache1,此时线程A修改变量a,线程B修改了b,再写回到cache2时,就会将其中一个变量进行覆盖,所以这就叫做伪共享。解决伪共享的方式,采用数据填充,即补位,如果一个缓冲行不满,那么就进行数据填充。在concurrentHashMap中采用了sum.misc.Contended注解,但是需要启动参数上加上-XX:-RestrictContened才能生效。

JMM内存模型

从抽象的角度来看,Java内存模型与操作系统之间有着很相似的概念,即在操作系统中,内存(RAM)与JVM中的堆相似,高速缓冲区与Java中栈内存相似,cpu与Java中线程相似。因此,Java中内存模型其实是一个抽象的概念,实际上栈内存并不存在。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。

JMM导致的并发安全问题

引发可见性、原子性问题。
由JMM内存模型可发现,实际上线程是不能直接操作堆(主内存)的,操作的是其数据的副本,在多线程环境下,两个线程对同一个变量a(初始为0)进行操作时,线程A取到了变量a的初始值为0,然后对其进行操作,但是,在操作完之后,在刷新回内存之前,线程B又从内存中读取了变量a,此时线程B获取到a的值还是为0,此时线程A把计算后的数据写回到内存,再线程B写回到内存时,就会发现线程B把线程A的数据覆盖了,同时也失去了原子性。解决方案:采用volatile来保证数据的可见性,采用加锁或循环CAS保证数据原子性。

Java内存模型中重排序

什么是重排序?

解析过程:源代码 -> 编译器优化重排序(编译期间) -> 指令级并行重排序 -> 内存系统重排序 -> 最终执行指令序列

  • 存在数据有依赖性,不会进行重排序

写后读,写后写,都后写

//如果有以下代码
int a = 1;
int b = 2;
int c = a + b;

上面代码可以发现,变量c是依赖于变量a、b,所以c的顺序是固定的,但是a和b互不依赖,所以a和b的执行顺序并不是按照编写的顺序来进行的,也有可能先执行b再执行a。但是不会对c进行调整,因为存在数据依赖性。

  • 存在控制依赖性,不会进行重排序
int a = 2;
int b = 3;
int c = 0;
boolean flag = true;
if(flag){
	c = a*b; 
}

上面代码可以看到,a、b、c、可能发生重排序。但是在if和c=a*b是不会发生重排序的。但是在操作系统中,也会出现猜测执行,即可能会先执行a*b,将结果存放到重排序缓存中,当flag为true的时候,就可以直接将缓存中的值拿出来进行赋值,但是也会出现猜测执行时,a和b还没有进行赋值,所以也有可能会猜测失败,猜测失败就会重新进行计算。

禁止重排序 - 内存屏障(强制刷出cache)

在编译期间,会在生成指令序列的时候,会在一些禁止重排序的地方,插入内存屏障从而禁止操作系统对指令进行重排序。

volatile关键字就是通过该方式来保证数据的可见性

  • Load1;LoadLoad;Load2 确保Load1的数据装载,是在Load2及之后所有指令之前进行装载,即Load1装载完毕之后,后面的才能够装载。
  • Store1;StoreStore;Store2 确保Store1资源已经刷新到内存,即对其他处理器可见时,才会去执行Store2或之后的指令。
  • Load1;LoadStore;Store2 确保Load1的数据装载之后才会执行Store2。
  • Store1;StoreLoad;Load1 确保Store1刷新回内存之后,才会去执行Load1(x86只提供该指令)。

临界区

临界区表示在synchronized代码块,即进入同步块和退出同步块。
在临界区内的代码会发生重排序。

Happens - Befores

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!
happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第 二个操作之前。

  • 前者表示,对于Java程序员来说,一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(就是说前一个指令必须在下一个指令之前完成,至少在执行下一个指令之前,能看到上一个指令执行的结果)
  • 后者表示,对于编译器和处理器来说,两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的。(就上面的代码来说,c依赖于a、b,操作系统完全可以把ab的顺序发生更改,c不会感知到其顺序的改变,只关注结果,不关注过程)
int a = 1;
int b = 2;
int c = a*b;

从上面代码看到:

  • ① a happens-befores b
  • ② b happens-befores c
  • ③ 由①和②推算出 a happens-befores c

由此可见,②和③的执行,如果发生了变化,那么执行结果将不是预期的,所以JMM会禁止编译器和处理器对其进行重排序,但是①和②进行重排序是不会对③的执行结果改变,所以是允许对①和②进行重排序

Volatile关键字的实现

从概念上讲,volatile关键字属于轻量级同步机制,为啥是轻量级同步机制呢?因为在使用volatile关键字能够保证当前变量的可见性,以及当前变量从内存中读取的是最新的,在写回内存时,会强制使其他cpu缓存中该变量值无效。(同步写,同步读,保证多线程环境下同时只有一个线程能读取,一个线程能写入)

//采用volatile关键字设置或读取该变量a的值
volatile int a;
public void setA(int a){
	this.a = a;
} 
public int getA(){
	return a;
}
public void incr(){
//等价于a++
	int b = 1;
	b = b + getA();
	setA(b);
}
//采用synchronized进行同步读或写
int a;
public synchronized void setA(int a){
	this.a = a;
}
public synchronized int getA(){
	return a;
}
public synchronized void incr(){
//等价于a++
	int b = 1;
	b = b + getA();
	setA(b);
}

由上面两段代码可以发现,一个采用volatile关键字修饰的成员变量和一个用synchronized进行同步加锁的方法,在使用上,两者(get、set)其实是等价的。在volatile关键字修饰的变量上,set和get方法,同一时间只能有一个可以操作,例如当前在调用set方法,那么调用get方法的线程此刻会被阻塞,直到set操作完成之后,get方法的线程才能够进行调用,与使用synchronized的效果完全一致。

volatile并不保证原子性

我们从上面代码可以发现,同时存在一个incr方法,由volatile关键字修饰的incr方法和synchronized关键字修饰的incr方法,synchronized修饰的incr方法能够保证原子性,但是由volatile关键字修饰的incr方法是不保证原子性的。
因为在复合操作中,volatile关键字只能保证读取的时候是同步的,写入的时候也是同步的,但不能保证在累加的时候是同步的。

volatile的内存语义

写操作: 在同一个方法体内,对成员变量的修改操作,如果没有被volatile关键字所修饰的成员变量,在被volatile关键字所修饰的变量之前进行了修改操作,那么在执行完volatile关键字所修饰的变量的修改操作之后,会将没有被修饰volatile关键字的且做了修改操作的变量进行写回到主内存中。

//例如这段代码块
int a;
volatile int b;
public void init(){
	this.a = 1;
	this.b = 2;
}
public void add(){
	a = b + a;
	b = 0;
}

先调用init进行初始化,然后再调用add方法进行计算,由于在volatile关键字修饰的b前面修改了a,此时a会跟着b的volatile关键字的特性写回到主内存中。

读操作: 读取的时候,只能读取volatile关键字自身的值,非volatile关键字的值不会被读取,所以没有被volatile关键字所修饰的变量并不会享有写操作那般特性。

volatile重排序规则

如果此时有两个读写操作,在重排序过程中,以下情况禁止重排序:

  • 当第二个操作为volatile写操作时,禁止重排序;
  • 当第一个操作为volatile读操作时,禁止一切重排序;
  • 当第一个操作为volatile写操作时, 禁止一切其他的volatile操作。

volatile实现原理

有volatile关键字修饰的共享变量进行写操作的时候会采用CPU提供的lock前缀指令

  • 将当前处理器缓冲行的数据写回到主内存
  • 在写回主内存时,会将其他处理器中缓存该内存地址的数据无效

final实现原理

先上一段代码

public class Test{
	private int a;
	public Test(int a){
		this.a = a;
	}
}

从上面简单的代码,我们可以发现,创建一个Test类,同时会调用构造方法对a进行赋值。此时理想的状态是,通过new关键字创建了一个Test实例,然后再根据构造器Test对a进行赋值,此时理想的是构造器方法执行完毕之后,那么a就有了初始值。实际上有可能并不是这样,因为在编译器或者操作系统有可能会把a赋值操作移到构造器之外执行。

添加final关键字

public class Test{
	private final int a;
	public Test(int a){
		this.a = a;
	}
}

我们将a添加了一个final关键字之后,此时编译器就会对final关键字进行特殊处理,能够保证在构造器结束之前,a已经被初始化。

总结

  • 在构造器中对一个final域的写入,与之随后将这个对象的引用赋值给一个引用变量,该操作不能进行重排序。
  • 初次读取一个包含final域的对象的引用,与随后读取这个对象中final域,这两个操作之间不能进行重排序。

final对象从构造器中逃逸

public class Test{
	private final int a;
	static Test t;
	public Test(int a){
		this.a = a;
		this.t = this;
	}
}

上面代码可以发现,在t对象实际上引用的是自身,也就是说,外部有其他线程获取t对象时,判断不为空成立,然后再去获取a的值,此时a的值就有可能不是预期的值。因为在构造器中,已经将this提前暴露出去,再由于构造器内可能会发生重排序,有可能是先执行了this.t = this再执行this.a=a

synchronized实现原理

使用monitor指令实现,在synchronized同步块中,在开始位置插入指令monitorenter表示获取锁,结束位置或异常位置插入指令monitorexit表示释放锁,每个对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。
如果是在方法上添加synchronized,那么会在方法上添加标识符ACC_SYNCHRONIZED但是在方法内部使用synchronized时,就会添加monitor指令

  • 无锁状态
  • 偏向锁 :当一个同步代码块当前只有一个线程使用时,那么会采取偏向锁,将当前线程的id写入到monitor对象的中。
  • 轻量级锁:当一个同步代码块有多个线程使用时,就会升级到轻量级锁,然后此线程就会采取循环CAS方式获取锁,在jdk1.5时为固定10次,在之后升级为自适应次数。次数达到后,就会升级为重量级锁。
  • 重量级锁: 当前线程经过了偏向锁->轻量级锁都没有获取到锁,此时升级到重量级锁,将会把当前线程进行阻塞,直到锁释放才会去竞争锁。

锁的逃逸分析

如果你加锁的对象,不会逃出该方法或者线程之外的话,那么编译器将会把该锁消除。

锁消除

如果你加锁的方法或者对象,在多线程环境下没有发生资源竞争,那么就会把锁消除。

锁粗话

在同一方法内,两次对同一对象加锁的间隔非常短暂,此时编译器就会把两次加锁合并成一次加锁,这也就是锁粗话。

所以,如果不是特殊情况下,尽量使用synchronized关键字使用,因为现在的synchronized已经足够智能化。

Java8新特性

LongAdder

更快的原子类,分离热点

LongAdder与AtomicLong的区别

1、AtomicLong内部的value是一个long类型的变量,所有线程在竞争时,都会对该变量进行读写操作;LongAdder将内部的value变化成一个数组的long类型,当多线程环境竞争时,将线程的写命中到指定数组下标进行写操作,在读的时候,对数组进行遍历求和取得值。
2、方法上,LongAdder更在于对数据的累加和累减,相比LongAdder所提供的方法更少。
3、LongAdder采用的是空间换时间的思想。
4、LongAdder适用于写多读少的情况且并发量大的情况下,AtomicLong适用于并发量较小的场景。

LongAdder继承于Striped64,在Striped64中存在两个关键变量,base和cells,如果当前没有竞争的情况下,默认添加在base上面,如果有多线程竞争的话,就会添加到cells数组里面。
在LongAdder的sum方法,并没有加锁,也就是说获取到的结果只能保证最终一致性,如果需要计算的是非常准确的值的话,就不适合使用LongAdder

与之相关的还有DoubleAdder、LongAccumulator、DoubleAccumulator类

StampLock

对读写锁的改进

StampedLock与ReentrantReadWriteLock的区别

  • StampedLock悲观的写,乐观/悲观的读;ReentrantReadWriteLock悲观的读、悲观的写
  • StampedLock在读操作上改进,先去尝试获取版本号,然后再运算的时候判断获取的版本号是否与最新的版本号一致,如果不一致可以采取自旋cas进行获取或者升级为readLock来处理。
  • StampedLock提供了读锁升级为写锁(实际中可能并没有什么场景能用到),tryConvertToOptimisticRead将当前读锁升级为写锁

CompletableFuture

对Future的改进

CompletableFuture与Future的区别

  • 在Future中,结果的获取并不方便,因为每次获取都会对
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值