Java多线程之间的通信使用的是共享内存的方法,问题在于一个线程修改了共享变量,另一个变量不一定能立刻感知到这个修改,这就是所谓的内存可见性问题,这里的共享变量指的是Java对象中的属性,类静态变量等存在堆中的数据,方法中的内部变量存在栈中,是不存在可见性问题的因为只有当前调用该方法的线程可见,希望通过一篇文章说清底层原理和方法,应用。
Java线程之间的通信由Java内存模型控制(JMM)
1.volatile的用法
volatile关键字是Java虚拟机提供的的最轻量级的同步机制,它作为一个修饰符出现,用来修饰变量,也就是对象属性,不是方法里面的局部变量,方法里面的局部变量本身就不存在多线程可见性问题
public class VolatileTest {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread t1 = new Thread(task, "线程t1");
t1.start(); //开启线程t1
Thread.sleep(1000);
task.stop = true;
Thread.sleep(1000*5);
System.out.println("主线程退出");
}
}
class Task implements Runnable {
boolean stop = false;
int i = 0;
@Override
public void run() {
long s = System.currentTimeMillis();
while (!stop) {
i++;
}
System.out.println("线程退出" + (System.currentTimeMillis() - s));
}
}
上面代码结果显示 t1 永远在循环中,t1 线程对主线程对变量的修改感知不到。 但是若把 Task 类的 stop 属性用 volatile 修饰的话,t1 就能感知主线程的修改。
2. volatile修饰变量的作用
- 1 保证变量对所有线程可见性(对一个volatile变量的读,总是能看到任意线程对它的最后一个写)
- 2 禁止指令重排
- 3 不保证原子性(过去是使用锁的方式,现在可以使用volatile机制加循环CAS实现原子性,但是只是对单个变量而言的,多个变量的修改保证原子性仍然需要用锁,或者将多个变量合并成一个变量)
3. 现代计算机的内存模型
3.1 计算机模型
多核处理器的情况,每个核都有自己的高速缓存,共享同一主存,正是因为缓存的存在才会导致数据不一致的问题,Java多线程也是如此
当多个处理器的运算任务都涉及同一块主内存区域,可能导致缓存数据不一致问题。和上文的Java多线程有点类似,有两种方案:
- 1 通过在总线加LOCK#锁的方式
- 2 通过缓存一致性协议(Cache Coherence Protocol)
CPU和其他功能部件是通过总线通信的,如果在总线加LOCK#锁,那么在锁住总线期间,其他CPU是无法访问内存,这样一来,「效率就比较低了」。
3.1 MESI协议
为了解决一致性问题,还可以通过缓存一致性协议。即各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。比较著名的就是Intel的MESI(Modified Exclusive Shared Or Invalid)协议,它的核心思想是:
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
CPU中每个缓存行标记的4种状态(M、E、S、I),也了解一下吧:
| 缓存状态 | 描述 |
|---|---|
| M,被修改(Modified) | 该缓存行只被该CPU缓存,与主存的值不同,会在它被其他CPU读取之前写入内存,并设置为Shared |
| E,独享的(Exclusive) | 该缓存行只被该CPU缓存,与主存的值相同,被其他CPU读取时置为Shared,被其他CPU写时置为Modified |
| S,共享的(Shared) | 该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同 |
| I,无效的(Invalid) | 该缓存行数据是无效,需要时需重新从主存载入 |
MESI协议是通过嗅探技术实现的:
3.2 嗅探技术
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。
4 Java内存模型 JMM
Java内存模型类别计算机内存模型,为 Java应用提供了一个抽象的内存模型,体现了与平台无关的特性,为了更好的执行性能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存打交道,也没有限制编译器进行调整代码顺序优化。所以Java内存模型会存在缓存一致性问题和指令重排序问题的。
Java线程类比不同的cpu核心,线程的工作内存类比高速缓存,共享一个主内存,所以一个线程的修改若只改了工作内存的话,其它线程是感知不到的。
5.并发编程的3个特性(原子性、可见性、有序性)
- 1 原子性:
- 2 可见性:指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改
- 3 有序性:Java虚拟机这样描述Java程序的有序性的:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中,观察另一个线程,所有的操作都是无序的。后半句意思就是,在Java内存模型中,「允许编译器和处理器对指令进行重排序」,会影响到多线程并发执行的正确性;前半句意思就是 「as-if-serial」的语义,即不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会被改变。
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
第三行代码依赖第一行和第二行,所以第一行代码和第二行代码执行完才能执行第三行,但是第一行和第二行代码可以无序执行,Java编译器为了提高性能会做优化使得代码执行具有无序性
Java语言中,有一个先行发生原则(happens-before):
- 「程序次序规则」:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
- 「管程锁定规则」:一个unLock操作先行发生于后面对同一个锁lock操作
- 「volatile变量规则」:对一个变量的写操作先行发生于后面对这个变量的读操作
- 「线程启动规则」:Thread对象的start()方法先行发生于此线程的每个一个动作
- 「线程终止规则」:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 「线程中断规则」:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 「对象终结规则」:一个对象的初始化完成先行发生于他的finalize()方法的开始
- 「传递性」:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
根据happens-before的八大规则,看看下面代码的规则:
假设线程A执行read方法,线程B执行add方法
volatile bool flag = false;
int b = 0;
public void read() {
b = 1; //1
flag = true; //2
}
public void add() {
if (flag) { //3
int sum =b+b; //4
System.out.println("bb sum is"+sum);
}
}
- 首先呢,flag加上volatile关键字,那就禁止了指令重排,也就是1 happens-before 2了
- 根据volatile变量规则,2 happens-before 3
- 由程序次序规则,得出 3 happens-before 4
- 最后由传递性,得出1 happens-before 4,因此妥妥的输出sum=2啦~
(个人理解:volatile的内存可见性是针对单个变量的,多个变量的情况使用happens-before去分析)
6.volatile底层原理
以上讨论学习,知道volatile的语义就是保证变量对所有线程可见性以及禁止指令重排优化。那么,它的底层是如何保证可见性和禁止指令重排的呢?
- JMM的编译器重排序规则:禁止特定类型的编译器重排序
- JMM的处理器重排序规则:Java编译器生成指令序列的时候插入内存屏障,禁止特定类型的处理器重排序
- JMM是语言级别的内存模型,确保在不同的编译器和处理器上,通过禁止特定类型的编译器和处理器重排序来提供一致的内存可见性保证
编译下面代码:
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
得出
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000
0x01a3de1a: shr $0x9,%esi ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00
;*putstatic instance
; - Singleton::getInstance@24
lock指令相当于一个内存屏障,它保证以下这几点:
- 1 重排序时不能把后面的指令重排序到内存屏障之前的位置
- 2 将本处理器的缓存写入内存
- 3 如果是写入动作,会导致其他处理器中对应的缓存无效。
显然,第2、3点不就是volatile保证可见性的体现嘛,第1点就是禁止指令重排列的体现。
其它
谈谈volatile的特性
volatile的内存语义
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
说说并发编程的3大特性
- 原子性
- 可见性
- 有序性
什么是内存可见性,什么是指令重排序?
- 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
- 指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
volatile是如何解决java并发中可见性的问题
底层是通过内存屏障实现的哦,volatile能保证修饰的变量后,可以立即同步回主内存,每次使用前立即先从主内存刷新最新的值。
volatile如何防止指令重排
也是内存屏障哦,讲下Java内存的保守策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
再讲下volatile的语义哦,重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置
volatile可以解决原子性嘛?为什么?
不可以,可以直接举i++那个例子,原子性需要synchronzied或者lock保证
public class Test {
public volatile int race = 0;
public void increase() {
race++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
//等待所有累加线程结束
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.race);
}
}
volatile底层的实现机制
volatile底层原理哈,volatile如何保证可见性和禁止指令重排,需要讲到内存屏障~
volatile和synchronized的区别?
- volatile修饰的是变量,synchronized一般修饰代码块或者方法
- volatile保证可见性、禁止指令重排,但是不保证原子性;synchronized可以保证原子性
- volatile不会造成线程阻塞,synchronized可能会造成线程的阻塞,所以后面才有锁优化那么多故事~
参考: www.modb.pro/db/180667 《Java并发编程的艺术》--方腾飞等