volatile关键字

Java 内存模型

JMM定义了内存中各个共享变量访问的规则。

共享变量包括包括实例字段 静态字段和构成数组的元素,即所有线程都可以访问到的,不包括局部变量和方法参数,这是线程私有的。

title

JMM规定了所有共享变量都存储在主内存,每条线程还有自己的工作内存,工作内存除了存储线程私有的局部变量以及方法参数等,还有该线程中需要用到的主内存中的共享变量的拷贝。

线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量。

工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

设置工作内存的目的主要是为了解决内存与处理器速度不一致的问题,一般来说,主内存存放在内存中,工作内存存放在高速缓存中,因此,工作内存数据操作速度很快。

但是,JMM有一个问题,就是主内存与工作内存不一致的问题,可能工作内存修改了某个工作变量,但是没有同步到主内存中。

重排序问题

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排。

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

两条指令没有数据依赖性的时候,就又可能对他进行指令重排序。

JMM 三个特性

JMM规定了三个特性,原子性,有序性,可见性。

其中,重排序破坏了有序性,JMM内存结构破坏了可见性。

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉

JMM中read,load,use,store,write都是原子操作,所以,基本数据类型的操作都是具备原子性的(long和double例外,因为long和double占64位,可能存在读取半个变量)。

如果应用场景需要一个更大的原子范围,可以使用sychronized等来解决。

可见性

可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。变量更新后,主内存中立即更新,并且根据缓存一致性协议,其他线程中的变量也会更新。

除了sychronized和volatile,final也具有可见性,因为final是不可以被修改的。但是,也有一个前提,被final修饰的字段在构造器中一旦初始化完成,并且没有this引用逃逸,那么其他线程就能看到final字段的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThisEscape {

public final int id;
public final String name;
public ThisEscape(EventSource<EventListener> source) {
id = 1;
source.registerListener(new EventListener() {
public void onEvent(Object obj) {
System.out.println("id: "+ThisEscape.this.id);
System.out.println("name: "+ThisEscape.this.name);
}
});
name = "flysqrlboy";

}
}

ThisEscape在构造函数中引入了一个内部类EventListener,而内部类会自动的持有其外部类(这里是ThisEscape)的this引用。source.registerListener会将内部类发布出去,从而ThisEscape.this引用也随着内部类被发布了出去。但此时ThisEscape对象还没有构造完成 —— id已被赋值为1,但name还没被赋值,仍然为null。

有序性

volatile

volatile保证可见性与有序性,但是不保证原子性。

保证可见性

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令。

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。

所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

保证顺序性

volatile会在内存中和插入一个内存屏障指令(lock指令),来禁止指令重排序。

因此,volatile读操作跟普通变量相比,没有什么差别,但是写操作因为要插入许多内存屏障,因此,效率会低一些。

happens-before

在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。

  • 程序次序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作

  • 管程锁定规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。同步快中线程安全。

  • volatile变量规则:对一个volatile变量的写操作happens-before对这个变量的读操作。

  • 线程启动规则:Thread.start() happens before 所有操作。

  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

  • 线程终止规则:线程中所有操作都happens-before对此线程的终止检测。

  • 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始

  • 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

满足任意一个原则,对于读写共享变量来说,就是线程安全。

关于可见性的一些问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class T {
/*volatile*/ boolean running=true;
void m() {
System.out.println("m start");
while(running) {

}
System.out.println("m end");
}
public static void main(String args[]) {
T t =new T();
new Thread(()->t.m(),"t1").start();
try {
//睡眠1s,保证t1先执行
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.running=false;
}
}

这段代码肯定是无法停止t1线程的,加上volatile就可以了。

那么,对m加上sychronized呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class T {
boolean running=true;
synchronized void m() {
System.out.println("m start");
while(running) {
}
System.out.println("m end");
}
public static void main(String args[]) {
T t =new T();
new Thread(()->t.m(),"t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.running=false;

}
}

这样还是不可以的,因为sychronied修饰的代码块中并没有改写running变量,synchronized会把同步块内更新的值再给同步到内存中。

但是,当我在循环里里面加了一个打印输出的语句,就可以终止线程了,为什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package Thread;

import java.util.concurrent.TimeUnit;
public class T {
boolean running=true;
void m() {
System.out.println("m start");
while(running) {
System.out.println("2");
}
System.out.println("m end");
}
public static void main(String args[]) {
T t =new T();
new Thread(()->t.m(),"t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
t.running=false;
}
}

实际上,JVM对于现代的机器做了最大程度的优化,也就是说,最大程度的保障了线程和主存之间的及时的同步,也就是相当于虚拟机尽可能的帮我们加了个volatile,但是,当CPU被一直占用的时候,同步就会出现不及时,也就出现了后台线程一直不结束的情况。

也就是说,在cpu空闲的时候,可能会更新一下主内存的内容。

比如,我们在循环中sleep一下,亦可以结束线程。

volatile使用场景

状态标志

volatile来修饰一个Boolean状态标志,用于指示发生了某一次的重要事件,例如完成初始化或者请求停机。

1
2
3
4
5
6
7
8
9
10
11
volatile boolean shutdownRequested;

...

public void shutdown() { shutdownRequested = true; }

public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}

DCL

通过volatile禁止指令重排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
//使用 volatile 修饰。
private volatile static Singleton sInstance;

public static Singleton getInstance() {
if (sInstance == null) { //(0)
synchronized (Singleton.class) { //(1)
if (sInstance == null) { //(2)
sInstance = new Singleton(); //(3)
}
}
}
return sInstance;
}

读操作远远大于写操作

如果读操作远远超过写操作,您可以结合使用内部锁和volatile变量来减少公共代码路径的开销。下面的代码中使用synchronized确保增量操作是原子的,并使用volatile保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及volatile读操作,这通常要优于一个无竞争的锁获取的开销。

1
2
3
4
5
6
7
public class CheesyCounter {
private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}