Java-Note-Concurrency-volatile关键字
volatile关键字- 要理解
volatile为什么能确保可见性, 就要先理解Java中的内存模型. 关于内存模型详见本目录下的Java-Note-JVM-内存模型.md - Java内存模型规定了所有的变量都存储在主内存中. 每条线程还有自己的工作内存用来保存被该线程所使用的变量(这些变量是从主内存拷贝而来).
- 线程对变量的所有操作(读取/赋值)都在工作内存中进行.
- 不同的线程之间无法直接访问对方工作内存中的变量, 线程间变量值的传递需要通过主内存来完成
- 这种内存模型产生了多线程编程中的数据”脏读”等问题.
- 例: 两个线程同时对一个共享变量进行
+1操作, 就有可能出现这种情况:- 初始时, 两个线程把值分别读入自己的工作内存
- 然后线程
1进行+1操作, 把结果写入共享内存 - 线程
2进行+1操作, 把结果写入共享内存, 此次的结果就覆盖了2中的结果, 导致结果出错
- 例: 两个线程同时对一个共享变量进行
解决”脏读”问题:
了解并发编程的三大概念:
原子性/可见性/有序性, 如果三个性质中有一个没有保证, 就有可能导致程序运行不正确原子性:
- 定义: 一个操作(如
i = 10)或多个操作(如synchronized修饰的代码块) Java中的原子性:规则:
对基本数据类型的变量的读取和赋值操作是原子性操作. 例:
1
2
3
4x = 10
y = x
x++
x = x + 1以上四条语句中只有语句1是原子性操作:
- 语句1是直接将数值10赋值给x, 也就是线程执行这个语句会直接将10写入工作内存
- 语句2先要读取
x的值, 再将x的值写入工作内存 3和4包含三个操作: 读取x的值/进行+1操作/赋新值给x
- 总结:
Java内存模型只保证基本读取和赋值是原子性操作, 大范围操作的原子性, 需要通过synchronized或Lock来实现
- 定义: 一个操作(如
可见性:
- 定义: 可见性是指当多个线程访问同一个变量时, 一个线程修改了这个变量的值, 其它线程能够立即看得到修改的值.
例子:
1
2
3
4
5
6//线程1执行的代码
int i = 0;
i = 10;
// 线程2执行的代码
j = i当线程1执行
i = 10这句时, 会先把i的初始值加载到工作内存中, 然后赋值为10, 此时在线程1的工作内存当中i的值变为10, 但没有立即写入主存当中.
此时线程2执行j = i, 则j的值为0, 结果出错
这就是可见性问题, 线程1对变量i修改了以后, 线程2没有立即看到线程1的修改- Java中的可见性:
- 规则:
- Java提供了volatile关键字来保证可见性
- 当一个共享变量被volatile修饰时, 它会保证修改的值会立即被更新到主存, 当有其它线程需要读取时, 它会去内存中国读取新值.
- 通过synchronized和Lock也能保证可见性, 在释放锁之前会将对变量的修改刷新到主存当中. 因此可以保证可见性
- 规则:
有序性
- 定义: 程序执行的顺序按照代码的先后顺序执行
规则: JVM在真正执行一段代码时会发生
指令重排序指令重排序:- 定义: 处理器为了提高程序的运行效率, 可能对输入的代码进行优化, 它不保证程序中的各个语句的执行先后顺序一致, 但是
会保证程序最终执行结果和代码顺序执行的结果一致- 如何保证: 处理器在进行重排序时会考虑指令间的数据依赖性, 如果instruction2必须用到instruction1的结果, 那么处理器会保证instruction1在instruction2之前执行
多线程中的指令重排序
例子:
1
2
3
4
5
6
7
8
9// 线程1:
context = loadContext();
inited = true;
// 线程2:
while(!inited) {
sleep()
}
doSomethingwithconfig(context);上述代码中, 由于语句1和2没有数据依赖性, 可能会重排序, 如果发重排序, 在线程1中先执行语句2, 而此时线程2会认为初始化已完成, 而执行
doSomethingwithConfig(context), 而此时context没有被初始化, 从而导致程序错误- 总结:
- 从上面的例子可以看出, 指令重排序不会影响单个线程的执行, 但是会影响线程并发执行的正确性
- 如果一个线程依赖于另一个线程中语句的执行次序, 如果不做处理, 则并发时会出错.
- 定义: 处理器为了提高程序的运行效率, 可能对输入的代码进行优化, 它不保证程序中的各个语句的执行先后顺序一致, 但是
- Java中的有序性:
- 规则:
- 在Java内存模型中, 允许编译器和处理器对指令进行重排序, 但是重排序过程不会影响到单线程程序的执行, 却会影响到多线程并发执行的正确性
- 通过volatile关键字可以保证一定的有序性, 另外可以通过synchronized和Lock保证有序性
- Java内存模型先天有序性:
happens-before原则, 如果两个操作的执行次序无法从happens-before原则推导出来, 则无法保证其可见性, 虚拟机可以随意对他们进行重排序: 参考文档happens-before:- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 解释: 虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,但是虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- 解释: 一个锁如果处在被锁定的状态, 必须先对锁进行释放操作, 再进行lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 解释: 如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 规则:
深入了解volatile关键字
- volatile保证可见性: 当一个共享变量(类的成员变量/类的静态成员变量)被volatile修饰后, 有两层语义:
- 保证不同线程对这个变量进行操作的可见性
- 注意: JVM只能保证对volatile修饰的变量在加载时得到最新值, 而如果在变量加载后线程被打断而共享变量发生了变化, 则被打断的线程中的工作变量不会被改变
- 使用volatile关键字会强制将修改的值立即写入主存
使用volatile关键字, 当线程2进行修改时, 会导致线程1的工作内存中缓存变量的缓存行无效(反映到硬件层的话, 就是cpu的L1或L2缓存中对象的缓存行无效)- 由于线程1中的estop工作内存的变量的缓存行无效, 所以线程1会再次到主存读取变量, 从而读取最新的值
- 禁止进行指令重排序
- 保证不同线程对这个变量进行操作的可见性
volatile不保证原子性
例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
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<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}在上述代码中, 由于volatile不保证原子性, 如果线程1在将inc的值存入工作区后被线程2打断, 线程2改变inc的值并存入共享变量,
由于JVM只能保证对volatile修饰的变量在加载时得到最新值, 而如果在变量加载后线程被打断而共享变量发生了变化, 则被打断的线程中的工作变量不会被改变, 则此时再继续运行线程1, 其依然使用原来的变量, 因此会报错- 解决方案: 使用synchronized或Lock, 也可以通过AtomicInteger
- AtomicInteger: JDK1.5下的java.util.concurrent.atomic包提供了一些原子操作类, 即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
volatile保证有序性
volatile关键字禁止重排序, 从而一定程度上保证有序性
- 当程序执行到volatile变量的读或写操作时, 在其前面的操作的更改肯定全部已经进行, 且结果对后面的操作可见; 在其后面的操作肯定还没有进行
在进行指令优化时, 不能讲在对volatile变量的读/写操作放在后面语句的前面, 也不能把后面的语句放在其前面执行
例子:
1
2
3
4
5
6
7
8//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5语句4不能放在1/2前面, 但1,2之间的顺序不做保证. 同时执行3时, 1/2一定已经执行完毕, 且1/2的执行结果对3/4/5都是可见的
- volatile保证可见性: 当一个共享变量(类的成员变量/类的静态成员变量)被volatile修饰后, 有两层语义:
volatile的实现原理
可见性: 处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内存的数据独到内部缓存后再进行操作,但操作完后不知什么时候会写到内存。
如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。
但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。
- 有序性: Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
- 要理解