前言
最近在看并发编程艺术这本书,对看书的一些笔记及个人工作中的总结。
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”.可见性指的是当一个线程修改一个共享变量时,另外一个线程读到这个修改的值。如果volatile关键字使用恰当的话,它比synchronized的使用和执行成本更低,因为其不会引起线程上下文的切换和调度。
看一个demo:
public class VolatileTest extends Thread{
//volatile
private volatile boolean isRunning = true;
//private boolean isRunning = true;
private void setRunning(boolean isRunning){
this.isRunning = isRunning;
}
public void run(){
System.out.println("进入run方法..");
int i = 0;
while(isRunning == true){
//..
}
System.out.println("线程停止");
}
public static void main(String[] args) throws InterruptedException {
VolatileTest rt = new VolatileTest();
rt.start();
Thread.sleep(3000);
rt.setRunning(false);
System.out.println("isRunning的值已经被设置了false");
Thread.sleep(1000);
System.out.println(rt.isRunning);
}
}
中文名词 | 英文名词 | 说明 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | 缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适合的缓存(l1,l2,l3的或所有) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存行,而不是写回到内存,这个操作被称为写命中 |
volatile boolean isRunning = true; //isRunning是volatile修饰的变量
转变成汇编语言:
0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: **lock** addl $0x0,(%esp);
lock指令在多核处理器下会引发了二件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他cpu里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是将系统内存读到内部缓存(l1,l2或其他)后进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,jvm就会向处理器发送一条lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写到了内存,如果其他处理器缓存还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存时一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对着数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
volatile的特性
只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile变量自身具有下列特性。
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
看个demo:
public class VolatileTest2 extends Thread{
private static volatile int count;
private static void addCount(){
for (int i = 0; i < 10000; i++) {
count++ ;
}
System.out.println(count); //85821,如果是具有原子性的,那么打印出来的应该是100000
}
public void run(){
addCount();
}
public static void main(String[] args) {
VolatileTest2[] arr = new VolatileTest2[100];
for (int i = 0; i < 10; i++) {
arr[i] = new VolatileTest2();
}
for (int i = 0; i < 10; i++) {
arr[i].start();
}
}
}
结果:
14024
19308
31530
33912
44302
52539
62539
78652
79881
85821
当每个线程循环1000次的时候,打印出来的结果大多情况下是10000,说明volatile++的时候在次数比较少的时候还是具有原子特性的。
如果想要是的类型++具有原子特性可以使用并发包下提供的AtomicInteger类。
volatile的内存语义
volatile读的内存语义如下:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
下面对volatile写和volatile读的内存语义做个总结。
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
volatile的内存语义实现
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
其实在执行volatile读写的时候会插入不同的内存屏障,不同的处理器比如说32位处理器和x86处理器的屏障也不一样,增加了内存屏障导致单个volatile读写具有原子性。
注:
JMM是指java内存模型