这里写目录标题
前情引入
我们都知道,在多线程的情况下,如果不注意线程安全问题,可能会出现一些意想不到的问题,比如下面这段代码:
public class GeneralThreadProblem {
public static int i = 0;
public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
for (int j = 0; j < 1000; j++) {
Thread thread = new Thread(() -> i++);
threads.add(thread);
thread.start();
}
// 确保所有线程都执行完毕
for (Thread thread : threads) {
thread.join();
}
System.out.println(i);
}
}
本意是想使用多个线程,对i增加到1000,但是由于没有做好同步工作,所以这段代码有时输出正确的结果,有时输出错误的结果。这种情况被称为竞态条件。
当某个计算的正确性取决于多个线程交替执行的时序时,那么就会发生竞态条件(引自《java并发编程实战》2.2.1竞态条件)
这种问题,一般来说,将写入操作做同步即可解决问题,例如像下面这样
public class GeneralThreadProblem {
public static int i = 0;
public static synchronized void setI() {
i++;
}
public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
for (int j = 0; j < 1000; j++) {
Thread thread = new Thread(() -> setI());
threads.add(thread);
thread.start();
}
// 确保所有线程都执行完毕
for (Thread thread : threads) {
thread.join();
}
System.out.println(i);
}
}
可能也是由于我经常看到这种情况的线程安全问题,所以我想当然的认为:只需要对写入操作进行同步就可以了,而读取操作可以不用管 (注意这是错误的想法),实际上读取操作也可能会存在线程安全问题
可见性问题
读取操作也会存在线程安全问题,比如:
public class VisibilityProblem {
private static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Thread aThread = new Thread(() -> {
int i = 0;
while (!ready) {
i++;
}
System.out.println("Process finished with exit code 0,i: " + i);
});
aThread.start();
Thread.sleep(1000);
ready = true;
}
}
这代代码中,我开启了一个线程a,去i++,并且设置了一个开关,以期望可以随时停止这个线程a的任务,但实际上,有很大可能无法停止线程a,出现死循环。这是因为线程a无法“看到”ready被改变的值,读到的是之前的旧值,这种现象被称为重排序。
在没有同步的情况下,编译器、处理器以及运行时等都可以对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,想要对内存操作的执行顺序进行判断,几乎无法得出正确的结论(引自《java并发编程实战》3.1可见性)
这种情况解决办法有两种:
- 加锁,还是以最常用的synchronized为例:将读取方法加锁
public class VisibilityProblem {
private static boolean ready = false;
public synchronized static boolean isReady() {
return ready;
}
public static void main(String[] args) throws InterruptedException {
Thread aThread = new Thread(() -> {
int i = 0;
while (!isReady()) {
i++;
}
System.out.println("Process finished with exit code 0,i: " + i);
});
aThread.start();
Thread.sleep(1000);
ready = true;
}
}
- 使用volatile关键字,对ready变量使用关键字volatile,
public class VisibilityProblem {
private volatile static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Thread aThread = new Thread(() -> {
int i = 0;
while (!ready) {
i++;
}
System.out.println("Process finished with exit code 0,i: " + i);
});
aThread.start();
Thread.sleep(1000);
ready = true;
}
}
这两种方式仅仅从表面上就看出了差异,使用volatile关键字改动如此简便,事实上方法2是这个问题的最优解,这种场景也是volatile的最佳实践。
volatile作用
经过了上面的铺垫,我们再来看volatile关键字的作用。
java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程,当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。(引自《java并发编程实战》3.1.4volatile变量)
说白了,就是不让编译器优化与这个变量相关的操作,好让其他读取这个变量的线程能实时的拿到最新值。
总结
volatile变量的作用可归纳如下:
编译器以优化为目的对内存操作(通俗来说就是代码顺序)进行重排序,会导致未做同步的变量可能对其他线程不可见,从而造成代码运行出现未知结果。如果对变量进行同步操作,虽然可以解决问题,但是成本较高,无论是使用synchronized还是其他锁,而volatile则可以以较低的成本解决这个可见性问题。
但需要注意:
volatile虽然能保证变量在线程间的可见性,但它并不能保证变量操作的原子性,比如最开始的例子,即便将成员变量i用volatile关键字修饰,仍然不能解决问题。加锁既可以确保可见性又可以确保原子性,而volatile只能确保可见性
因为volatile关键字的作用有限,所以在使用时得谨慎。当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 改变量不会与其他状态变量一起纳入不变形条件中
- 在访问变量是不需要加锁。
水平有限,如果有什么错误,还请指正。有什么疑问,也可以在评论里提出。