概述
Java中Volatile 修饰符
上次我们说到为什么会多卖一张票出去,其实是因为在多线程的情况下
如果不同步 那么 线程与线程之间不是不具有可见性的。
下面是摘自 http://www.cnblogs.com/zhengbin/p/5654805.html
郑州文武对 可见性 和原子性的理解
可见性:
可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就这这个操作同样存在线程安全问题。
原子性:
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。也就是说 在一个线程执行到某个
地方 也就是某个操作 这种 操作不能被其他线程打断 也就是
线程A 做 int x=1 的操作 首先线程A会去 读取主内存中
变量 x的值 在存在自己的 线程本地栈区 在做赋值为1的操作
这连续的操作不能被打断。 比如在读到X的时候 还没有赋值
为1 其他的线程抢占了CPU的执行权力 导致没有成功赋值为1
这就不是原子性操作
重排序:什么是重排序 其实我们写的代码并不是 顺序执行的
因为 java内存模型 会对其我们写的代码进行优化
int x = 1 ;
boolean b=false;
1
2
3
这两句代码 并不是顺序执行的 可能会被优化成
“`
boolean b=false;
int x = 1 ;
1
2
所有的优化 都是为了 我们的CPU能 并发执行 意思就是一个CPU同时运行几个线程 尽量节省 运行时间 不让CPU闲着。
但是这种重排序也要满足appens-before原则
在什么之前执行 下面我们看
int x=1; A
int y =x; B
boolean b = true; C
1
2
3
4
这种情况下java内存模型 简称 JMM 就不会对 A 和 B 进行重排序
因为 有一个数据依赖关系原则 如果对他们 重新排序 那么执行结果
会发生改变 所以不符合 jmm的 as-if-serial 原则
as-if-serial 原则 是指 不管你 为了提高运算速度 不管怎么样排序 你都必须 要保证最后的执行结果不能改变。
int x=1; A
int y =x; B
int i = y; C
1
2
3
appens-before 原则: 如果 代码 A依赖 B 那么 B又依赖C
A-C A间接依赖C 传递依赖, 所以C的代码 也不能放到A的前面
执行 也就是说不会执行重排序。
大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
int i = i+1;
1
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。
比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
1)通过在总线加LOCK#锁的方式
2)通过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
在多线程 如果一个线程执行的代码进行重排序 对其他线程都是不可见
的
int i= 10;
boolean b= true; //线程1
public get(){ //线程2
if(b){
int y = i;
}
}
1
2
3
4
5
6
7
8
9
10
如果这里线程 1 进行重排序 b=true;先执行 接着 线程2抢到CPU的
执行权力 进入判断 y的值就是 0 而不是我们的10(int类型初始化都为0)
因为一个线程执行对另外一个线程来说是不可见的,他们之间也没有数据依赖 所以也有可能不会顺序执行 这不是我们想要看到的,
int x =0;
x++;
1
2
我们再这里必须明白 线程都有自己的一个线程本地栈区 他们会从主内存中读取变量值 并存储在CPU的高速缓存中 (为什么出现CPU高速缓存
因为cpu执行过快 不停的从主内存中读写并不能满足CPU的执行速度所以出现CPU缓存,每一个CPU都有一个高速缓存 意思4核的应该有4个), 当同事启动两个线程执行 他们会首先将X的值读入到所执行的CPU高速缓存中,在读取到本地栈去 进行操作 在写到主内存中
这里假如 A线程 读到x的值为 0 并做了 +1的操作 那么B线程获取
执行权力 B也读到了 x=0 那么并做+1 操作 写到内存中 那么x=1
A线程获取执行权力 开始 写 x=1 那么最后开启两个线程 结果还是x=1
就是因为这种缓存不一致的原因,因为x++不是原子性操作 可以被其他线程打断如何避免这种情况呢。
volatile 能满足 可见性 和有序性 意思被 volatile 修饰的变量 x 两个线程
去读取 如果A线程 改变了 值 那么其他线程必须从 主内存中重新读取 x变量的值 不能冲高速缓存中读取了,保证了所有线程对共享变量X
是可见的。
volatile 有序性:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
1
2
3
4
5
6
7
8
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
volatile保证原子性吗?
public 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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。
可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。
这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量inc的值为10,
线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1从主内存中读取inc的值 x++ 实际就是 x+1的操作
执行过程应该是 线程1读取inc的值 并且+1 刷新回主内存中 让其他线程发现主内存中inc值发生改变
但是因为不是原子性的操作所以 线程1 读取了inc的值后 可能线程又执行了
线程2读取 inc的值也是10 并且+1 刷回主内存中让 线程1发现 inc的值发生了改变
使线程1放弃本地区域的inc值 重新从主内存中读取
但是 可能线程2在做完+1的操作后 要刷回主内存中时
线程1又执行了 此时 线程2虽然做了+1操作 但是可能并没有刷回主内存中
所以线程1发现的是主内存中的inc还是10 线程1做了+1 操作 线程2 把本地保存的 2刷入到主内存
线程1把本地保存的2刷入到 主内存 这就导致了 两个线程做+1操作最后值还是11 而这种情况是可能发生的
并不是一定发生 但是这个代码 还是线程不安全的
1
2
3
4
5
6
7
8
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
采用synchronized
public class Test {
public int inc = 0;
public synchronized 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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这个样子就OK了
synchronized Lock 都会保证原子性 有序性 和可见性
volatile 只能满足 可见性 和有序性 Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件 —— 即变量真正独立于其他变量和自己以前的值 —— 在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错
volatile 使用场景
volatile 和 synchronized 实现 “开销较低的读-写锁”
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
之所以将这种技术称之为 “开销较低的读-写锁” 是因为您使用了不同的同步机制进行读写操作。因为本例中的写操作违反了使用 volatile 的第一个条件,因此不能使用 volatile 安全地实现计数器 —— 您必须使用锁。然而,您可以在读操作中使用 volatile 确保当前值的可见性,因此可以使用锁进行所有变化的操作,使用 volatile 进行只读操作。其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作,因此当使用 volatile 保证读代码路径时,要比使用锁执行全部代码路径获得更高的共享度 —— 就像读-写操作一样。然而,要随时牢记这种模式的弱点:如果超越了该模式的最基本应用,结合这两个竞争的同步机制将变得非常困难。
volatile的原理和实现机制:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
最后
以上就是忧虑红酒为你收集整理的Java中Volatile 修饰符的全部内容,希望文章能够帮你解决Java中Volatile 修饰符所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复