Java并发编程实例详解系列(最佳)

173 阅读24分钟

Java并发编程(一)线程定义、状态和属性

Java并发编程(一)线程定义、状态和属性

线程是指程序在执行过程中,能够执行程序代码的一个执行单元。在java语言中,线程有四种状态:运行 、就绪、挂起和结束。 进程是指一段正在执行的程序。而线程有时也被成为轻量级的进程,他是程序执行的最小单元,一个进程可以拥有多个线程,各个线程之间共享程序的内功空间(代码段、数据段和堆空间)及一些进程级的资源(例如打开的文件),但是各个线程都拥有自己的棧空间。

一 、线程和进程

1. 什么是线程和进程的区别:
线程是指程序在执行过程中,能够执行程序代码的一个执行单元。在java语言中,线程有四种状态:运行 、就绪、挂起和结束。
进程是指一段正在执行的程序。而线程有时也被成为轻量级的进程,他是程序执行的最小单元,一个进程可以拥有多个线程,各个线程之间共享程序的内功空间(代码段、数据段和堆空间)及一些进程级的资源(例如打开的文件),但是各个线程都拥有自己的棧空间。
2. 为何要使用多进程
在操作系统级别上来看主要有以下几个方面:

  • 使用多线程可以减少程序的响应时间,如果某个操作和耗时,或者陷入长时间的等待,此时程序讲不会响应鼠标和键盘等的操作,使用多线程后可以把这个耗时的线程分配到一个单独的线程去执行,从而使程序具备了更好的交互性。
  • 与进程相比,线程创建和切换开销更小,同时多线程在数据共享方面效率非常高。
  • 多CPU或者多核计算机本身就具备执行多线程的能力,如果使用单个进程,将无法重复利用计算机资源,造成资源的巨大浪费。在多CPU计算机使用多线程能提高CPU的利用率。
  • 使用多线程能简化程序的结构,使程序便于理解和维护

二、创建线程

多线程的实现一般有以下三种方法其中前两种为最常用的方法:
1. 继承Thread类,重写run()方法
Thread本质上也是实现了Runnable接口的一个实例。需要注意的是调用start()方法后并不是是立即的执行多线程的代码,而是使该线程变为可运行态,什么时候运行多线程代码是由操作系统决定的。
以下是主要步骤:
(1)定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
(2)创建Thread子类的实例,即创建了线程对象。
(3)调用线程对象的start()方法来启动该线程。

public class TestThread extends Thread{
public void run() {
System.out.println(“Hello World”);
}
public static void main(String[] args) {
Thread mThread = new TestThread();
mThread.start();
}
}123456789

2. 实现Runnable接口,并实现该接口的run()方法
以下是主要步骤:
(1)自定义类并实现Runnable接口,实现run()方法。
(2)创建Thread子类的实例,用实现Runnable接口的对象作为参数实例化该Thread对象。
(3)调用Thread的start()方法来启动该线程。

public class TestRunnable implements Runnable {
public void run() {
System.out.println(“Hello World”);
}
}

public class TestRunnable {
public static void main(String[] args) {
TestRunnable mTestRunnable = new TestRunnable();
Thread mThread = new Thread(mTestRunnable);
mThread.start();
}
}12345678910111213

3. 实现Callable接口,重写call()方法
Callable接口实际是属于Executor框架中的功能类,Callable接口与Runnable接口的功能类似,但提供了比Runnable更强大的功能,主要表现为以下的3点:
(1)Callable可以在任务接受后提供一个返回值,Runnable无法提供这个功能。
(2)Callable中的call()方法可以抛出异常,而Runnable的run()方法不能抛出异常。
(3)运行Callable可以拿到一个Future对象,Future对象表示异步计算的结果,他提供了检查计算是否完成的方法。由于线程属于异步计算模型,因此无法从别的线程中得到函数的返回值,在这种情况下就可以使用Future来监视目标线程调用call()方法的情况,但调用Future的get()方法以获取结果时,当前线程就会阻塞,直到call()方法的返回结果。

public class TestCallable {
//创建线程类
public static class MyTestCallable implements Callable {
public String call() throws Exception {
retun “Hello World”;
}
}
public static void main(String[] args) {
MyTestCallable mMyTestCallable= new MyTestCallable();
ExecutorService mExecutorService = Executors.newSingleThreadPool();
Future mfuture = mExecutorService.submit(mMyTestCallable);
try {
//等待线程结束,并返回结果
System.out.println(mfuture.get());
} catch (Exception e) {
e.printStackTrace();
}
}
}12345678910111213141516171819

上述程序的输出结果为:Hello World

在这三种方式中,一般推荐实现Runnable接口的方式,其原因是:首先,Thread类定义了多种方法可以被派生类使用重写,但是只有run()方法是必须被重写的,实现这个线程的主要功能,这也是实现Runnable接口需要的方法。其次,一个类应该在他们需要加强或者修改时才会被继承。因此如果没有必要重写Thread类的其他方法,那么在这种情况下最好是用实现Runnable接口的方式。

三、中断线程

当线程的run()方法执行方法体中的最后一条语句后,并经由执行return语句返回时,或者出现在方法中没有捕获的异常时线程将终止。在java早期版本中有一个stop方法,其他线程可以调用它终止线程,但是这个方法现在已经被弃用了。
interrupt方法可以用来请求终止线程,当一个线程调用interrupt方法时,线程的中断状态将被置位。这是每个线程都具有的boolean标志,每个线程都应该不时的检查这个标志,来判断线程是否被中断。
要想弄清线程是否被置位,可以调用Thread.currentThread().isInterrupted():

while(!Thread.currentThread().isInterrupted()){
do something
}123

但是如果一个线程被阻塞,就无法检测中断状态。这是产生InterruptedException的地方。当一个被阻塞的线程(调用sleep或者wait)上调用interrupt方法。阻塞调用将会被InterruptedException中断。
如果每次迭代之后都调用sleep方法(或者其他可中断的方法),isInterrupted检测就没必要也没用处了,如果在中断状态被置位时调用sleep方法,它不会休眠反而会清除这一状态并抛出InterruptedException。所以如果在循环中调用sleep,不要去检测中断状态,只需捕获InterruptedException。
在很多发布的代码中会发现InterruptedException被抑制在很低的层次上:

void myTask(){
…
try{
sleep(50)
}catch(InterruptedException e){
…
}
}12345678

不要这样做,如果不认为catch中做一处理有什么好处的话,有两种合理的选择:

  • 在catch中调用Thread.currentThread().interrup()来设置中断状态。调用者可以对其进行检测

  • 更好的选择用throw InterruptedException标记你的方法,不采用try语句块来捕获已成。这样调用者可以捕获这个异常:

    void myTask()throw InterruptedException{ sleep(50) }123

四、线程的状态

(1). 新建状态(New):新创建了一个线程对象。
(2). 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
(3). 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
(4). 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:

  • 等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。

  • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。

  • 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

    (5). 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

五、线程的优先级和守护线程

1. 线程优先级
在java中,每一个线程有一个优先级,默认情况下,一个线程继承它父类的优先级。可以用setPriority方法提高或降低任何一个线程优先级。可以将优先级设置在MIN_PRIORITY(在Thread类定义为1)与MAX_PRIORITY(在Thread类定义为10)之间的任何值。线程的默认优先级为NORM_PRIORITY(在Thread类定义为5)。
尽量不要依赖优先级,如果确实要用,应该避免初学者常犯的一个错误。如果有几个高优先级的线程没有进入非活动状态,低优先级线程可能永远也不能执行。每当调度器决定运行一个新线程时,首先会在具有搞优先级的线程中进行选择,尽管这样会使低优先级的线程完全饿死。

2. 守护线程

调用setDaemon(true);将线程转换为守护线程。守护线程唯一的用途就是为其他线程提供服务。计时线程就是一个例子,他定时发送信号给其他线程或者清空过时的告诉缓存项的线程。当只剩下守护线程时,虚拟机就退出了,由于如果只剩下守护线程,就没必要继续运行程序了。
另外JVM的垃圾回收、内存管理等线程都是守护线程。还有就是在做数据库应用时候,使用的数据库连接池,连接池本身也包含着很多后台线程,监控连接个数、超时时间、状态等等。

Java并发编程(二)同步

线程是指程序在执行过程中,能够执行程序代码的一个执行单元。在java语言中,线程有四种状态:运行 、就绪、挂起和结束。 进程是指一段正在执行的程序。而线程有时也被成为轻量级的进程,他是程序执行的最小单元,一个进程可以拥有多个线程,各个线程之间共享程序的内功空间(代码段、数据段和堆空间)及一些进程级的资源(例如打开的文件),但是各个线程都拥有自己的棧空间。

同步一直是java多线程的难点,在我们做android开发时也很少应用,但这并不是我们不熟悉同步的理由。希望这篇文章能使更多的人能够了解并且应用java的同步。
在多线程的应用中,两个或者两个以上的线程需要共享对同一个数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法,这种情况通常成为竞争条件。
竞争条件最容易理解的例子就是:比如火车卖票,火车票是一定的,但卖火车票的窗口到处都有,每个窗口就相当于一个线程,这么多的线程共用所有的火车票这个资源。并且无法保证其原子性,如果在一个时间点上,两个线程同时使用这个资源,那他们取出的火车票是一样的(座位号一样),这样就会给乘客造成麻烦。解决方法为,当一个线程要使用火车票这个资源时,我们就交给它一把锁,等它把事情做完后在把锁给另一个要用这个资源的线程。这样就不会出现上述情况。

1. 锁对象

synchronized关键字自动提供了锁以及相关的条件,大多数需要显式锁的情况使用synchronized非常的方便,但是等我们了解ReentrantLock类和条件对象时,我们能更好的理解synchronized关键字。ReentrantLock是JAVA SE 5.0引入的, 用ReentrantLock保护代码块的结构如下:

mLock.lock();
try{
…
}
finally{
mLock.unlock();
}1234567

这一结构确保任何时刻只有一个线程进入临界区,一旦一个线程封锁了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们则被阻塞直到第一个线程释放锁对象。把解锁的操作放在finally中是十分必要的,如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远阻塞。

2. 条件对象

进入临界区时,却发现在某一个条件满足之后,它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程,条件对象又称作条件变量。
我们来看看下面的例子来看看为何需要条件对象

假设一个场景我们需要用银行转账,我们首先写了银行的类,它的构造函数需要传入账户数量和账户金额

public class Bank {
private double[] accounts;
private Lock bankLock;
public Bank(int n,double initialBalance){
accounts=new double[n];
bankLock=new ReentrantLock();
for (int i=0;i<accounts.length;i++){
accounts[i]=initialBalance;
}
}
}1234567891011

接下来我们要提款,写一个提款的方法,from是转账方,to是接收方,amount转账金额,结果我们发现转账方余额不足,如果有其他线程给这个转账方再存足够的钱就可以转账成功了,但是这个线程已经获取了锁,它具有排他性,别的线程也无法获取锁来进行存款操作,这就是我们需要引入条件对象的原因。

public void transfer(int from,int to,int amount){
bankLock.lock();
try{
while (accounts[from]<amount){
//wait
}
}finally {
bankLock.unlock();
}
}12345678910

一个锁对象拥有多个相关的条件对象,可以用newCondition方法获得一个条件对象,我们得到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁

public class Bank {
private double[] accounts;
private Lock bankLock;
private Condition condition;
public Bank(int n,double initialBalance){
accounts=new double[n];
bankLock=new ReentrantLock();
//得到条件对象
condition=bankLock.newCondition();
for (int i=0;i<accounts.length;i++){
accounts[i]=initialBalance;
}
}
public void transfer(int from,int to,int amount) throws InterruptedException {
bankLock.lock();
try{
while (accounts[from]<amount){
//阻塞当前线程,并放弃锁
condition.await();
}
}finally {
bankLock.unlock();
}
}
}12345678910111213141516171819202122232425

等待获得锁的线程和调用await方法的线程本质上是不同的,一旦一个线程调用的await方法,他就会进入该条件的等待集。当锁可用时,该线程不能马上解锁,相反他处于阻塞状态,直到另一个线程调用了同一个条件上的signalAll方法时为止。当另一个线程准备转账给我们此前的转账方时,只要调用condition.signalAll();该调用会重新激活因为这一条件而等待的所有线程。
当一个线程调用了await方法他没法重新激活自身,并寄希望于其他线程来调用signalAll方法来激活自身,如果没有其他线程来激活等待的线程,那么就会产生死锁现象,如果所有的其他线程都被阻塞,最后一个活动线程在解除其他线程阻塞状态前调用await,那么它也被阻塞,就没有任何线程可以解除其他线程的阻塞,程序就被挂起了。
那何时调用signalAll呢?正常来说应该是有利于等待线程的方向改变时来调用signalAll。在这个例子里就是,当一个账户余额发生变化时,等待的线程应该有机会检查余额。

public void transfer(int from,int to,int amount) throws InterruptedException {
bankLock.lock();
try{
while (accounts[from]<amount){
//阻塞当前线程,并放弃锁
condition.await();
}
//转账的操作
…
condition.signalAll();
}finally {
bankLock.unlock();
}
}1234567891011121314

当调用signalAll方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能够在当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法是signal,它则是随机解除某个线程的阻塞,如果该线程仍然不能运行,那么则再次被阻塞,如果没有其他线程再次调用signal,那么系统就死锁了。

3. Synchronized关键字

Lock和Condition接口为程序设计人员提供了高度的锁定控制,然而大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到java语言内部的机制。从Java1.0版开始,Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说,

public synchronized void method(){

}123

等价于

public void method(){
this.lock.lock();
try{

}finally{
this.lock.unlock();
}1234567

上面银行的例子,我们可以将Bank类的transfer方法声明为synchronized,而不是使用一个显示的锁。
内部对象锁只有一个相关条件,wait方法添加到一个线程到等待集中,notifyAll或者notify方法解除等待线程的阻塞状态。也就是说wait相当于调用condition.await(),notifyAll等价于condition.signalAll();

我们上面的例子transfer方法也可以这样写:

public synchronized void transfer(int from,int to,int amount)throws InterruptedException{
while (accounts[from]<amount) {
wait();
}
//转账的操作
…
notifyAll();
}12345678

可以看到使用synchronized关键字来编写代码要简洁很多,当然要理解这一代码,你必须要了解每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。

4. 同步阻塞

上面我们说过,每一个Java对象都有一个锁,线程可以调用同步方法来获得锁,还有另一种机制可以获得锁,通过进入一个同步阻塞,当线程进入如下形式的阻塞:

synchronized(obj){

}123

于是他获得了obj的锁。再来看看Bank类

public class Bank {
private double[] accounts;
private Object lock=new Object();
public Bank(int n,double initialBalance){
accounts=new double[n];
for (int i=0;i<accounts.length;i++){
accounts[i]=initialBalance;
}
}
public void transfer(int from,int to,int amount){
synchronized(lock){
//转账的操作
…
}
}
}12345678910111213141516

在此,lock对象创建仅仅是用来使用每个Java对象持有的锁。有时开发人员使用一个对象的锁来实现额外的原子操作,称为客户端锁定。例如Vector类,它的方法是同步的。现在假设在Vector中存储银行余额

public void transfer(Vector<Double>accounts,int from,int to,int amount){
accounts.set(from,accounts.get(from)-amount);
accounts.set(to,accounts.get(to)+amount;
}1234

Vecror类的get和set方法是同步的,但是这并未对我们有所帮助。在第一次对get调用完成以后,一个线程完全可能在transfer方法中被被剥夺运行权,于是另一个线程可能在相同的存储位置存入了不同的值,但是,我们可以截获这个锁

public void transfer(Vector<Double>accounts,int from,int to,int amount){
synchronized(accounts){
accounts.set(from,accounts.get(from)-amount);
accounts.set(to,accounts.get(to)+amount;
}
}123456

客户端锁定(同步代码块)是非常脆弱的,通常不推荐使用,一般实现同步最好用java.util.concurrent包下提供的类,比如阻塞队列。如果同步方法适合你的程序,那么请尽量的使用同步方法,他可以减少编写代码的数量,减少出错的几率,如果特别需要使用Lock/Condition结构提供的独有特性时,才使用Lock/Condition。

Java并发编程(三)volatile域

前言

有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大,volatile关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。再讲到volatile关键字之前我们需要了解一下内存模型的相关概念以及并发编程中的三个特性:原子性,可见性和有序性。

1. java内存模型与原子性,可见性和有序性

Java内存模型规定所有的变量都是存在主存当中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
在java中,执行下面这个语句:

int i=3;1

执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值3写入主存当中。
那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?

原子性

对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
来看一下下面的代码:

x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句41234

只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
java.util.concurrent.atomic包中有很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。例如AtomicInteger类提供了方法incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数自增和自减。可以安全地使用AtomicInteger类作为共享计数器而无需同步。
另外这个包还包含AtomicBoolean,AtomicLong和AtomicReference这些原子类仅供开发并发工具的系统程序员使用,应用程序员不应该使用这些类。

可见性

可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,所以对其他线程是可见的,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

2. volatile关键字

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 禁止进行指令重排序。

先看一段代码,假如线程1先执行,线程2后执行:

//线程1
boolean stop = false;
while(!stop){
doSomething();
}

//线程2
stop = true;12345678

很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
为何有可能导致无法中断线程?每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:

  • 使用volatile关键字会强制将修改的值立即写入主存;
  • 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效;
  • 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

volatile保证原子性吗?

我们知道volatile关键字保证了操作的可见性,但是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);
}
}12345678910111213141516171819202122

这段代码每次运行结果都不一致,都是一个小于10000的数字,在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行。
假如某个时刻变量inc的值为10,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,然后进行加1操作,并把11写入工作内存,最后写入主存。线程1接着进行加1操作,由于此前已经读取了inc的值,此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。那么两个线程分别进行了一次自增操作后,inc只增加了1。
自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

volatile能保证有序性吗?

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

3. 正确使用volatile关键字

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中

第一个条件就是不能是自增自减等操作,上文已经提到volatile不保证原子性。
第二个条件我们来举个例子它包含了一个不变式 :下界总是小于或等于上界

public class NumberRange {
private volatile int lower, upper;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(…);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(…);
upper = value;
}
}123456789101112131415

这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全,从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3),这显然是不对的。
其实就是要保证操作的原子性就可以使用volatile,使用volatile主要有两个场景:

状态标志

volatile boolean shutdownRequested;
…
public void shutdown()
{
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}1234567891011

很可能会从循环外部调用 shutdown() 方法 —— 即在另一个线程中 —— 因此,需要执行某种同步来确保正确实现 shutdownRequested 变量的可见性。然而,使用 synchronized 块编写循环要比使用volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

双重检查模式 (DCL)

public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}12345678910111213

在这里使用volatile会或多或少的影响性能,但考虑到程序的正确性,牺牲这点性能还是值得的。
DCL优点是资源利用率高,第一次执行getInstance时单例对象才被实例化,效率高。缺点是第一次加载时反应稍慢一些,在高并发环境下也有一定的缺陷,虽然发生的概率很小。
DCL虽然在一定程度解决了资源的消耗和多余的同步,线程安全等问题,但是他还是在某些情况会出现失效的问题,也就是DCL失效,在《java并发编程实践》一书建议用以下的代码(静态内部类单例模式)来替代DCL:

public class Singleton {
private Singleton(){
}
public static Singleton getInstance(){
return SingletonHolder.sInstance;
}
private static class SingletonHolder {
private static final Singleton sInstance = new Singleton();
}
}12345678910

4. 总结

与锁相比,Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件即变量真正独立于其他变量和自己以前的值 ,在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错。本文介绍了可以使用 volatile 代替 synchronized 的最常见的两种用例,其他的情况我们最好还是去使用synchronized 。