生于忧患,死于安乐~ 道理都懂,有些人却醒着醉
此前项目中看到了 AtomicInteger 这个关键字,然后顺藤摸瓜瞅到了介个,构造器~还有 一个 加持了volatile关键字的 value。
private volatile int value;
/**
* Creates a new AtomicInteger with the given initial value.
*
* @param initialValue the initial value
*/
public AtomicInteger(int initialValue) {
value = initialValue;
}
为什么需要AtomicInteger原子操作类?
-
对于Java中的运算操作,例如自增或自减,若没有进行额外的同步操作,在多线程环境下就是线程不安全的。
-
原子类相比于普通的锁,粒度更细、效率更高(除了高度竞争的情况下)。
而AtomicInteger 的自增 最后会调用如下:
unsafe.compareAndSwapInt(this, valueOffset, expect, update);
这里利用Unsafe类的JNI方法实现,使用CAS指令,可以保证读-改-写是一个原子操作。
快走远了,后续有空写个再写原子类吧,回到正题,总而言之利用了 关键字 volatile。
正题
先看一段代码(基于JDK1.8)
/**
*
* @Description
* @author saiuna
*/
public class saiuna_Volatile {
boolean running = true;
void saiuna() {
System.out.println("t1 start");
while(running) {
}
System.out.println("t1 end!");
}
public static void main(String[] args) throws InterruptedException {
saiuna_Volatile t = new saiuna_Volatile();
new Thread(t::saiuna, "t1").start();
//上一行代码等价于 下面注释的代码
/*new Thread(new Runnable() {
@Override
public void run() {
t.m();
}
}, "t1").start();*/
TimeUnit.SECONDS.sleep(1);
t.running = false;
}
}
发现lamda表达式真香~🤭 运行结果输出了: t1 start, 不会运行t1 end!,
running是存在于堆内存的 t 对象中
当线程t1开始运行的时候,会把running值从内存中读到t1线程的工作区,在运行过程中直接使用这个copy,并不会每次都去读取堆内存,这样,当主线程修改running的值之后,t1线程感知不到,所以不会停止运行。
啥主内存?😮 让JMM出来解释下
JMM(JavaMemoryModel)
现代计算机的内存模型
早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。
将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。=
Java内存模型(JavaMemoryModel)
描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
JMM规定:
- 所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
- 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM的抽象示意图如图:
从图中可以看出:
- 所有的共享变量都存在主内存中。
- 每个线程都保存了一份该线程使用到的共享变量的副本。
- 如果线程A与线程B之间要通信的话,必须经历下面2个步骤:
- 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已经更新过的共享变量。
所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。
注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。
所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。
那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。
Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序(下文提及)。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。
如何解决可见性问题呢?
用volatile修饰共享变量, 优化后的代码如下:
/**
*
* @Description
* @author saiuna
*/
public class saiuna_Volatile {
//仅仅加了volatile
volatile boolean running = true;
void saiuna() {
System.out.println("t1 start");
while(running) {
}
System.out.println("t1 end!");
}
public static void main(String[] args) throws InterruptedException {
saiuna_Volatile t = new saiuna_Volatile();
new Thread(t::saiuna, "t1").start();
//上一行代码等价于 下面注释的代码
/*new Thread(new Runnable() {
@Override
public void run() {
t.m();
}
}, "t1").start();*/
TimeUnit.SECONDS.sleep(1);
t.running = false;
}
}
输出(输出了 t1 end!):
t1 start
t1 end!
使用了volatile
每个线程操作数据时会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写回了,其他线程都会感知到,并且将已读取的变量副本失效,需要操作数据时都得去堆内存中读取running的值。
volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如何解决呢?
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,
这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。我们看MESI协议
MESI缓存一致性协议
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的(嗅探),那么它就会从内存重新读取。
嗅探:
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
嗅探缺点:( 总线风暴)
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。
指令重排序
什么是重排序?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这个代码的执行情况:
a = b + c;
d = e - f ;
先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
重排序的类型有哪些呢?
指令重排一般分为以下三种:
-
编译器优化重排
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令并行重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
-
内存系统重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
这里还得提一个概念,
as-if-serial
。不管怎么重排序,单线程下的执行结果不能被改变。
编译器、runtime和处理器都必须遵守as-if-serial语义。
那Volatile是怎么保证不会被执行重排序的呢?
内存屏障
java编译器会在生成指令系列时在适当的位置会插入内存屏障
指令来禁止特定类型的处理器重排序。
需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
写
读
上面的我提过重排序原则,为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。
如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。
从JDK5开始,提出了happens-before
的概念,通过这个概念来阐述操作之间的内存可见性。
happens-before
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
注意: Volatile是没办法保证原子性的,如何解决呢,开头的原子类 AtomicInteger 就是一个例子。
简单的应用(涉及单例模式)
/**
* 线程安全的懒汉式单例---双重检查(Double-Check idiom)
* @author saiuna
*/
public class Singleton6 {
/**
* 使用volatile关键字防止重排序,因为 new Instance()是一个非原子操作,可能创建一个不完整的实例
*/
private static volatile Singleton6 singleton6;
private Singleton6() {
}
public static Singleton6 getInstance() {
// Double-Check idiom 第一次非 null检查,避免频繁上锁
if (singleton6 == null) {
synchronized (Singleton3.class) {
// 只需在第一次创建实例时才同步
if (singleton6 == null) {
singleton6 = new Singleton6();
}
}
}
return singleton6;
}
}
单例的一种涉及到volatile的模式。不做详解~后续写设计模式补上。
volatile与synchronized的区别
volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。
volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
总结
-
volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步。
-
volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
-
volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
-
volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取。
-
volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
-
volatile可以使得long 和 double的赋值是原子的。
-
volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
参考:
http://concurrent.redspider.group/article/02/8.html 深入浅出多线程