Java 线程同步 锁 条件变量
1. 死锁的产生条件
计算机系统中同时具备下面四个必要条件时,那么会发生死锁</br>
- 互斥条件。即某个资源在一段时间内只能由一个进程占有,不能同时被两个或两个以上的进程占有。这种独占资源如CD-ROM驱动器,打印机等等,必须在占有该资源的进程主动释放它之后,其它进程才能占有该资源。这是由资源本身的属性所决定的。</br>
- 不可抢占条件。进程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者进程自行释放。</br>
- 占有且申请条件。进程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。</br>
- 循环等待条件。存在一个进程等待序列{P1,P2,...,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一源,......,而Pn等待P1所占有的的某一资源,形成一个进程循环等待环。</br>
当程序存在竞争条件时,需要同步,避免出现不合预期的运行结果。同步实现的两个工具:锁和条件状态。</br>
以银行存取款为例,如果没有采取同步操作</br>
Code1
public class Bank {
private final double[] accounts;
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 , double amount){
if (accounts[from] < amount ) {
return;
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" 10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance();
}
public double getTotalBalance(){
double sum = 0;
for (double a : accounts){
sum += a;
}
return sum;
}
}
public int size(){
return accounts.length;
}
}
如果存在两个线程同时执行指令
accounts[to] += amount;
由于指令不是原子操作,该指令可能被处理为
- 将accounts[to]加载到寄存器</br>
- 增加amount </br>
- 将结果写回account[to]</br>
在线程1执行完步骤1,2还没有执行步骤3的时候,即只是在寄存器中增加了amount,线程1被剥夺了运行权限,处理器将运行权限交给了线程2,线程2执行步骤1,2,还没有执行步骤3,即线程2获取和线程1拥有一样的初始值,并且只是在寄存器中增加了amount值,这时候处理器又将时间片给了线程1,线程1将计算后的值写入内存,而当时间片继续转给线程2的时候,仍然是在和线程一样的初始值上增加amount,这种情况下,则擦去了线程2所做的更新。
2. ReentrantLock可重入锁
可重入锁:是一种特殊的互斥锁,可以被同一个线程多次获取,而不会产生死锁。具有两个特点:</br>
1.是互斥的,任意时刻,只有一个线程锁,假设A线程已经获取了锁,在A线程释放这个锁之前,B线程无法获取到。</br>
2.它可以被同一线程多次持有,即假设A线程已经获取了这个锁,如果A线程在释放这个锁前又一次请求获取这个锁,能够获取成功</br>
锁持有一个计数器,来跟踪lock方法的嵌套调用。如下代码,transfer调用getTotalBalance方法,也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出时,持有计数变回1。当transfer退出时,持有计数变为0。线程锁释放。</br>
Code2
public class Bank {
private final double[] accounts;
private Lock bankLock = new ReentrantLock();
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 , double amount){
bankLock.lock();
try {
if (accounts[from] < amount ) {
return;
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" 10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
} finally {
bankLock.unlock();
}
}
public double getTotalBalance(){
bankLock.lock();
try {
double sum = 0;
for (double a : accounts){
sum += a;
}
return sum;
} finally {
bankLock.unlock();
}
}
public int size(){
return accounts.length;
}
}
package java.util.concurrent.locks.Lock
//获取这个锁,如果锁同时被另一个线程拥有则发生阻塞
void lock():
//释放这个锁
void unlock();
package java.util.concurrent.locks.ReentrantLock
//构建一个可以被用来保护临界区的可重入锁
ReentrantLock();
//构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能,所以默认情况下,锁没有被强制为公平的。
ReentrantLock(boolean fair);
3 条件对象(条件变量)
使用场景:线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象对象来管理那些已经获得了一个锁却不能做有用工作的线程。</br>
银行账户需要转账,账户内只有500元却需要转600元,即账户中没有足够的余额,应该怎么办呢?现实情况下,银行柜员会告诉你账户余额不足,无法办理,直接退出。或者,我们可以等待另一个线程账户注入100元及以上的金额。</br>
当transfer方法写成如下
Code3
public void transfer(int from, int to, int amount){
banklock.lock();
try{
while(accounts[from] < amount){
//wait... 这里采取等待,而不是立即返回
}
//transfer funds...
}finally{
banklock.unlock();
}
}
可以看出这个线程刚刚获得了对banklock的排他性访问,因此别的线程没有进行存取操作的机会。所以这是需要条件对象的原因。</br>
一个锁对象可以有一个或多个相关的条件对象,可以用newCondition方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。</br>
Code4
public class Bank {
private final double[] accounts;
private Lock bankLock;
private Condition sufficientFunds;
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++) {
accounts[i] = initialBalance;
}
//一个bank对象拥有一个ReentrantLock
bankLock = new ReentrantLock();
sufficientFunds = bankLock.newCondition();
}
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
while (accounts[from] < amount) {
//当前线程被阻塞,并且放弃了锁,并且该线程进入该条件的等待集
sufficientFunds.await();
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" 10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
//重新激活因为这一条件而等待的所有线程。这些线程中等待集中移出时,它们再次成为可运行的,调度器将再次激活它们
//同时,它们将试图重新进入该对象
//一旦锁成为可用的,它们中的某一个将从await调用返回,获得该锁并且从被阻塞的地方继续执行
//采用循环while表明此时线程应该再次检测该条件。由于无法确保该条件被满足,signalAll方法仅仅是通知正在等待的线程
//siganlAll语义可以理解为:此时有可能已经满足条件,值得再次去检测条件
sufficientFunds.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bankLock.unlock();
}
}
public double getTotalBalance() {
bankLock.lock();
try {
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
} finally {
bankLock.unlock();
}
}
public int size() {
return accounts.length;
}
}
Condition.signal():是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的等待状态效率要高,但是存在危险。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。
package java.util.concurrent.locks.Lock
//返回一个与该锁相关的条件对象
Condition new Condition();
e.g. Condition sufficientFunds = bankLock.newCondition();
package java.util.concurrent.locks.Condition
//将线程放到条件的等待集合中
void await();
//解除该条件的等待集中的所有线程的阻塞状态
void signalAll();
//从该条件的等待集中随机地选择一个线程,解除其阻塞状态
void Signal();
4 锁与条件对象的关键之处
*. 锁可以用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。</br>
*. 锁可以管理试图进入被保护代码段的线程
*. 锁可以拥有一个或多个相关的条件对象</br>
*. 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程
5 synchronized 关键字
每一对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。</br>
Code5
public class Bank {
private double[] accounts;
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++) {
accounts[i] = initialBalance;
}
}
public synchronized void transfer(int from, int to, double amount) {
try {
while (accounts[from] < amount) {
//将线程添加到一个线程等待集中,该方法只能在一个同步方法中调用方法中调用
wait();
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" 10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
//notifyAll/notify方法解除等待线程的阻塞状态,该方法只能在同步方法或者同步块中调用
notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized double getTotalBalance() {
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
将静态方法声明为synchronized也是合法的,如果调用这种方法,该方法获得相关的类对象的内部锁。如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bank.class对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或者任何其他的同步静态方法。
pulic class Bank
{
private double[] accounts;
private Object lock = new Object();
}
6 锁和条件对象的局限
*. 不能中断一个正在试图获得锁的线程</br>
*. 试图获得锁时不能设定超时
*. 每个锁仅有单一的条件,可能是不够的</br>
*. 最好既不是用Locl/Condition也不使用synchronized关键字
*. 如果synchronized关键字适合程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。</br>
*. 如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition</br>
7 Volatile域
有时,仅仅为了读写一个或两个实例域就使用同步,开销过大。可以采用volatile关键字声明域,该修饰词告诉编译器和虚拟机该域是可能被另一个线程并发更新的。它为实例域的同步访问提供了一个种免锁机制。
8 final变量
将域声明为final,可以安全的访问一个共享域。
final Map<String, Double> accounts = new HashMap<>();
其他线程会在构造函数完成构造之后才看到这个accounts变量。如果不适用final,就不能保证其他线程看到的是account更新后的值,他们可能都只是看到null,而不是新构造的HashMap。当然,对这个映射表的操作不是线程安全的,如果多个线程在读写这个映射表,仍然需要进行的。
学习资料:《Java核心技术卷一》