同步一直是Java多线程的难点,而且在我们做Android开发时也很少会应用到它,但这并不是我们不熟 悉同步的理由。本节我们就来学习一下同步的基础知识。在多线程应用中,两个或者两个以上的线程需要 共享对同一个数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法, 这种情况通常被称为竞争条件。竞争条件最容易理解的例子如下:比如车站售卖火车票,火车票是一定 的,但卖火车票的窗口到处都有,每个窗口就相当于一个线程。这么多的线程共用所有的火车票资源,如 果不使用同步是无法保证其原子性的。在一个时间点上,两个线程同时使用火车票资源,那其取出的火车 票是一样的(座位号一样),这样就会给乘客造成麻烦。解决方法如下:当一个线程要使用火车票这个资 源时,我们就交给它一把锁,等它把事情做完后再把锁给另一个要用这个资源的线程。这样就不会出现上 述情况了。
目录
1.重入锁与条件对象
synchronized 关键字自动提供了锁以及相关的条件。大多数需要显式锁的情况使用synchronized非常方 便,但是等我们了解了重入锁和条件对象时,能更好地理解synchronized关键字。重入锁ReentrantLock是 Java SE 5.0引入的,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。用 ReentrantLock保护代码块的结构如下所示:
这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码 区。一旦一个线程封锁了锁对象,其他任何线程都无法进入Lock语句。把解锁的操作放在finally中是十分必 要的。如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远被阻塞。进入临界区时,却 发现在某一个条件满足之后,它才能执行。这时可以使用一个条件对象来管理那些已经获得了一个锁但是 却不能做有用工作的线程,条件对象又被称作条件变量。通过下面的例子来说明为何需要条件对象。假设 一个场景需要用支付宝转账。我们首先写了支付宝的类,它的构造方法需要传入支付宝账户的数量和每个 账户的账户金额。
接下来我们要转账,写一个转账的方法,from是转账方,to是接收方,amount是转账金额,如下所示:
结果我们发现转账方余额不足;如果有其他线程给这个转账方再转足够的钱,就可以转账成功了。但 是这个线程已经获取了锁,它具有排他性,别的线程无法获取锁来进行存款操作,这就是我们需要引入条 件对象的原因。一个锁对象拥有多个相关的条件对象,可以用newCondition方法获得一个条件对象,我们得 到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁。整理以上代码,加入条件对象,代码如下 所示:
一旦一个线程调用 await 方法,它就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同 一个条件的signalAll方法时为止。当另一个线程转账给我们此前的转账方时,只要调用 condition.signalAll(),就会重新激活因为这一条件而等待的所有线程。代码如下所示:
当调用signalAll方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能 够在当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法是signal,它则是随机解除某个 线程的阻塞。如果该线程仍然不能运行,则再次被阻塞。如果没有其他线程再次调用signal,那么系统就死 锁了。
2.同步方法
Lock和 Condition接口为程序设计人员提供了高度的锁定控制, 然而大多数情况下, 并不需要那样的控
制, 并且可以使用一种嵌入到Java语言内部的机制。 从Java 1.0版开始, Java中的每一个对象都有一个内部锁。 如果一个方法用 synchronized 关键字声明, 那么对象的锁将保护整个方法。 也就是说, 要调用该方法, 线程必须获得内部的对象锁。 也就是如下代码
public synchronized void method(){
.....
}
等价于:
Lock mLock = new ReentrantLock();
public void method(){
mLock.lock();
try{
......
}finally{
mLock.unlock();
}
}
对于上面支付宝转账的例子, 我们可以将Alipay类的transfer方法声明为synchronized, 而不是使用一个显式的锁。 内部对象锁只有一个相关条件, wait方法将一个线程添加到等待集中, notifyAll或者notify方法解除等待线程的阻塞状态。 也就是说wait相当于调用condition.await() , notifyAll等价于condition.signalAll() ;
上面例子中的transfer方法也可以这样写:
可以看到使用 synchronized 关键字来编写代码要简洁很多。 当然要理解这一代码, 你必须要了解每一个对象有一个内部锁, 并且该锁有一个内部条件。 由该锁来管理那些试图进入synchronized方法的线程, 由该锁中的条件来管理那些调用wait的线程。
3.同步代码块
上面我们说过, 每一个Java对象都有一个锁, 线程可以调用同步方法来获得锁。 还有另一种机制可以获得锁, 那就是使用一个同步代码块, 如下所示:
synchronized(obj){
}
其获得了obj的锁, obj指的是一个对象。 再来看看Alipay类, 我们用同步代码块进行改写。
在这里创建了一个名为Lock的Object类, 为的是使用Object类所持有的锁。 同步代码块是非常脆弱的,通常不推荐使用。 一般实现同步最好用java.util.concurrent包下提供的类, 比如阻塞队列。 如果同步方法适合你的程序, 那么请尽量使用同步方法, 这样可以减少编写代码的数量, 减少出错的概率。 如果特别需要使用Lock/Condition结构提供的独有特性时, 才使用Lock/Condition。
4.volatile
当一个共享变量被volatile修饰之后, 其就具备了两个含义, 一个是线程修改了变量的值时, 变量的新值对其他线程是立即可见的。 换句话说, 就是不同线程对这个变量进行操作时具有可见性。 另一个含义是禁止使用指令重排序。
这里提到了重排序, 那么什么是重排序呢? 重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。 重排序分为两类: 编译期重排序和运行期重排序, 分别对应编译时和运行时环境。
下面我们来看一段代码, 假设线程1先执行, 线程2后执行, 如下所示:
很多开发人员在中断线程时可能会采用这种方式。 但是这段代码不一定会将线程中断。 虽说无法中断线程这个情况出现的概率很小, 但是一旦发生这种情况就会造成死循环。 为何有可能无法中断线程? 在前面我提到每个线程在运行时都有私有的工作内存, 因此线程1在运行时会将stop变量的值复制一份放在私有的工作内存中。 当线程2更改了stop变量的值之后, 线程2突然需要去做其他的操作, 这时就无法将更改的stop变量写入主存当中, 这样线程1就不会知道线程2对stop变量进行了更改, 因此线程1就会一直循环下去。 当stop用volatile修饰之后, 那么情况就变得不同了, 当线程2进行修改时, 会强制将修改的值立即写入主存, 并且会导致线程1的工作内存中变量stop的缓存行无效, 这样线程1再次读取变量stop的值时就会去主存读取。
volatile不保证原子性
我们知道volatile保证了操作的可见性, 下面我们来分析volatile是否能保证对变量的操作是原子性的。现在先阅读以下代码:
这段代码每次运行, 结果都不一致。 在前面已经提到过, 自增操作是不具备原子性的, 它包括读取变量的原始值、 进行加 1、 写入工作内存。 也就是说, 自增操作的 3 个子操作可能会分割开执行。 假如某个时刻变量inc的值为9, 线程1对变量进行自增操作, 线程1先读取了变量inc的原始值, 然后线程1被阻塞了。 之后线程2对变量进行自增操作, 线程2也去读取变量inc的原始值, 然后进行加1操作, 并把10写入工作内存,最后写入主存。 随后线程1接着进行加1操作, 因为线程1在此前已经读取了inc的值为9, 所以不会再去主存读取最新的数值, 线程1对inc进行加1操作后inc的值为10, 然后将10写入工作内存, 最后写入主存。 两个线程分别对inc进行了一次自增操作后, inc的值只增加了1, 因此自增操作不是原子性操作, volatile也无法保证对变量的操作是原子性的。
volatile保证有序性
volatile关键字能禁止指令重排序, 因此volatile能保证有序性。 volatile关键字禁止指令重排序有两个含义: 一个是当程序执行到volatile变量的操作时, 在其前面的操作已经全部执行完毕, 并且结果会对后面的操作可见, 在其后面的操作还没有进行; 在进行指令优化时, 在volatile变量之前的语句不能在volatile变量后面执行; 同样, 在volatile变量之后的语句也不能在volatile变量前面执行。
正确使用volatile关键字
synchronized关键字可防止多个线程同时执行一段代码, 那么这就会很影响程序执行效率。 而volatile关键字在某些情况下的性能要优于synchronized。 但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。 通常来说, 使用volatile必须具备以下两个条件:
(1) 对变量的写操作不会依赖于当前值。
(2) 该变量没有包含在具有其他变量的不变式中。
第一个条件就是不能是自增、 自减等操作, 上文已经提到volatile不保证原子性。 关于第二个条件, 我们来举一个例子, 它包含了一个不变式: 下界总是小于或等于上界, 代码如下所示:
这种方式将lower和upper字段定义为volatile类型不能够充分实现类的线程安全。 如果当两个线程在同一时间使用不一致的值执行setLower和setUpper的话, 则会使范围处于不一致的状态。 例如, 如果初始状态是(0, 5) , 在同一时间内, 线程A调用setLower(4) 并且线程B调用setUpper(3) , 虽然这两个操作交叉存入的值是不符合条件的, 但是这两个线程都会通过用于保护不变式的检查, 使得最后的范围值是(4,3) 。 这显然是不对的, 因此使用 volatile 无法实现setLower和setUpper操作的原子性。
使用volatile有很多种场景, 这里介绍其中的两种。
(1) 状态标志
如果在另一个线程中调用 shutdown 方法, 就需要执行某种同步来确保正确实现shutdownRequested 变量的可见性。 但是, 使用synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。 在这里推荐使用volatile, 状态标志shutdownRequested 并不依赖于程序内的任何其他状态, 并且还能简化代码。 因此, 此处适合使用volatile。
(2) 双重检查模式(DCL)
getInstance方法中对Singleton进行了两次判空, 第一次是为了不必要的同步, 第二次是只有在Singleton等于null的情况下才创建实例。 在这里用到了volatile关键字会或多或少地影响性能, 但考虑到程序的正确性, 牺牲这点性能还是值得的。 DCL的优点是资源利用率高, 第一次执行getInstance方法时单例对象才被实例化, 效率高。 其缺点是第一次加载时反应稍慢一些, 在高并发环境下也有一定的缺陷(虽然发生的概率很小) 。
5.小结
与锁相比, volatile变量是一种非常简单但同时又非常脆弱的同步机制, 它在某些情况下将提供优于锁的性能和伸缩性。 如果严格遵循volatile的使用条件, 即变量真正独立于其他变量和自己以前的值, 在某些情况下可以使用volatile代替synchronized来简化代码。 然而, 使用volatile的代码往往比使用锁的代码更加容易出错。 在前面的介绍了可以使用 volatile 代替synchronized的最常见的两种用例, 在其他情况下我们最好还是使用synchronized。