JAVA 多线程之 volatile 与 synchronized 的比较

一,volatile 关键字的可见性

要想理解 volatile 关键字,得先了解下 JAVA 的内存模型,Java 内存模型的抽象示意图如下:

从图中可以看出:

①每个线程都有一个自己的本地内存空间–线程栈空间??? 线程执行时,先把变量从主内存读取到线程自己的本地内存空间,然后再对该变量进行操作

②对该变量操作完后,在某个时间再把变量刷新回主内存

关于 JAVA 内存模型,更详细的可参考: 深入理解 Java 内存模型(一)——基础

因此,就存在内存可见性问题,看一个示例程序:(摘自书上)

public class RunThread extends Thread {

    private boolean isRunning = true;

    public boolean isRunning() {
        return isRunning;
    }

    public void setRunning(boolean isRunning) {
        this.isRunning = isRunning;
    }

    @Override
    public void run() {
        System.out.println("进入到run方法中了");
        while (isRunning == true) {
        }
        System.out.println("线程执行完成了");
    }
}

public class Run {
    public static void main(String[] args) {
        try {
            RunThread thread = new RunThread();
            thread.start();
            Thread.sleep(1000);
            thread.setRunning(false);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Run.java 第 28 行,main 线程 将启动的线程 RunThread 中的共享变量设置为 false,从而想让 RunThread.java 第 14 行中的 while 循环结束。

如果,我们使用 JVM -server 参数执行该程序时,RunThread 线程并不会终止!从而出现了死循环!!

原因分析:

现在有两个线程,一个是 main 线程,另一个是 RunThread。它们都试图修改 第三行的 isRunning 变量。按照 JVM 内存模型,main 线程将 isRunning 读取到本地线程内存空间,修改后,再刷新回主内存。

而在 JVM 设置成 -server 模式运行程序时,线程会一直在私有堆栈中读取 isRunning 变量。因此,RunThread 线程无法读到 main 线程改变的 isRunning 变量

从而出现了死循环,导致 RunThread 无法终止。这种情形,在《Effective JAVA》中,将之称为“活性失败”

解决方法,在第三行代码处用 volatile 关键字修饰即可。这里,它强制线程从主内存中取 volatile 修饰的变量。

    volatile private boolean isRunning = true;

扩展一下,当多个线程之间需要根据某个条件确定 哪个线程可以执行时,要确保这个条件在 线程 之间是可见的。因此,可以用 volatile 修饰。

综上,volatile 关键字的作用是:使变量在多个线程间可见(可见性)

二,volatile 关键字的非原子性

所谓原子性,就是某系列的操作步骤要么全部执行,要么都不执行。

比如,变量的自增操作 i++,分三个步骤:

①从内存中读取出变量 i 的值

②将 i 的值加 1

③将 加 1 后的值写回内存

这说明 i++ 并不是一个原子操作。因为,它分成了三步,有可能当某个线程执行到了第②时被中断了,那么就意味着只执行了其中的两个步骤,没有全部执行。

关于 volatile 的非原子性,看个示例:

public class MyThread extends Thread {
    public volatile static int count;

    private static void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }

    @Override
    public void run() {
        addCount();
    }
}

public class Run {
    public static void main(String[] args) {
        MyThread[] mythreadArray = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            mythreadArray[i] = new MyThread();
        }

        for (int i = 0; i < 100; i++) {
            mythreadArray[i].start();
        }
    }
}

MyThread 类第 2 行,count 变量使用 volatile 修饰

Run.java 第 20 行 for 循环中创建了 100 个线程,第 25 行将这 100 个线程启动去执行 addCount(),每个线程执行 100 次加 1

期望的正确的结果应该是 100*100=10000,但是,实际上 count 并没有达到 10000

原因是:volatile 修饰的变量并不保证对它的操作(自增)具有原子性。(对于自增操作,可以使用 JAVA 的原子类 AutoicInteger 类保证原子自增)

比如,假设 i 自增到 5,线程 A 从主内存中读取 i,值为 5,将它存储到自己的线程空间中,执行加 1 操作,值为 6。此时,CPU 切换到线程 B 执行,从主从内存中读取变量 i 的值。由于线程 A 还没有来得及将加 1 后的结果写回到主内存,线程 B 就已经从主内存中读取了 i,因此,线程 B 读到的变量 i 值还是 5

相当于线程 B 读取的是已经过时的数据了,从而导致线程不安全性。这种情形在《Effective JAVA》中称之为“安全性失败”

综上,仅靠 volatile 不能保证线程的安全性。(原子性)

此外,volatile 关键字修饰的变量不会被指令重排序优化。这里以《深入理解 JAVA 虚拟机》中一个例子来说明下自己的理解:

线程 A 执行的操作如下:

Map configOptions ;
char[] configText;

volatile boolean initialized = false;

// 线程 A 首先从文件中读取配置信息, 调用 process…处理配置信息, 处理完成了将 initialized 设置为 true
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfig(configText, configOptions);// 负责将配置信息 configOptions 成功初始化
initialized = true;

Map configOptions ;
char[] configText;

volatile boolean initialized = false;

// 线程 A 首先从文件中读取配置信息, 调用 process…处理配置信息, 处理完成了将 initialized 设置为 true
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfig(configText, configOptions);// 负责将配置信息 configOptions 成功初始化
initialized = true;

[](javascript:void(0); “复制代码”)

线程 B 等待线程 A 把配置信息初始化成功后,使用配置信息去干活…..线程 B 执行的操作如下:

[](javascript:void(0); “复制代码”)

while(!initialized)
{
sleep();
}

// 使用配置信息干活
doSomethingWithConfig();

[](javascript:void(0); “复制代码”)

如果 initialized 变量不用 volatile 修饰,在线程 A 执行的代码中就有可能指令重排序。

即:线程 A 执行的代码中的最后一行:initialized = true 重排序到了 processConfig 方法调用的前面执行了,这就意味着:配置信息还未成功初始化,但是 initialized 变量已经被设置成 true 了。那么就导致 线程 B 的 while 循环“提前”跳出,拿着一个还未成功初始化的配置信息去干活(doSomethingWithConfig 方法)。。。。

因此,initialized 变量就必须得用 volatile 修饰。这样,就不会发生指令重排序,也即:只有当配置信息被线程 A 成功初始化之后,initialized 变量才会初始化为 true。综上,volatile 修饰的变量会禁止指令重排序(有序性)

三,volatile 与 synchronized 的比较

volatile 主要用在多个线程感知实例变量被更改了场合,从而使得各个线程获得最新的值。它强制线程每次从主内存中讲到变量,而不是从线程的私有内存中读取变量,从而保证了数据的可见性。

关于 synchronized,可参考:JAVA 多线程之 Synchronized 关键字–对象锁的特点

比较:

①volatile 轻量级,只能修饰变量。synchronized 重量级,还可修饰方法

②volatile 只能保证数据的可见性,不能用来同步,因为多个线程并发访问 volatile 修饰的变量不会阻塞。

synchronized 不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢 synchronized 锁对象时,会出现阻塞。

四,线程安全性

线程安全性包括两个方面,①可见性。②原子性。

从上面自增的例子中可以看出:仅仅使用 volatile 并不能保证线程安全性。而 synchronized 则可实现线程的安全性。