Java并发系列之Synchronized 和 ReentrantLock

在大多数实际的多线程应用中, 两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象, 并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢? 可以想象,线程彼此踩了对方的脚。线程操作的数据可能不是之前准备操作的那个数据了。这样一个情况通常称为竞争条件(race condition)。这样说可能还是会有点抽象。下面举个栗子,并以这个栗子贯穿这次的主题。

一、抛砖引玉

(一)来个栗子
一个有若干账户的银行,随机地生成在这些账户之间转移钱款的交易。每一个账户都有一个线程,每一笔交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。 假设A账户有1000元,A账户要转账800元到B账户,转账成功后,B账户增加了800元,但此时A的账户由于还没减去转账给B的800元。它的账户还是1000元。这时候另外一个线程C过来了,抢走了CPU使用权,C要转账200元给A。这时A的账户变成了1200(1000+200)元,C账户转账结束后放出了CPU使用权给了A,A继续完成之前未完成的操作,减去800元(1200-800=400)。从整个过程中我们发现总的账户钱款总数变化了,增加了200元。这就出现了上述所说的竞争条件引发的问题。

(二)栗子分析

问题在于这不是原子操作。当两个线程试图同时更新同一个账户的时候,问题就出现了。.

假定两个线程同时执行指令 accounts[to] -= amount ,该指令可能被处理如下: 

1.将 accounts[to] 加载到寄存器。 

2.减少 amount。 

3..将结果写回 accounts[to]。 

现在,假定第 1 个线程执行步骤 1 和 2, 然后,它被剥夺了运行权。假定第2个线程被 唤醒并修改了 accounts 数组中的同一项。然后,第 1 个线程被唤醒并完成其第 3 步。 这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。

二、锁对象

从上面问题的分析来看,解决这个问题关键就是,在对一个账户进行操作时,只能有一个线程,只有当这个线程对这个账户操作完成后,才能由另外一个线程进行操作,可以理解成在操作该账户时候上了一把锁,其他线程这时候进不来,当该线程完成后打开这把锁,其他线程这时才能进来操作。

针对这个问题,Java语言提供一个synchronized关键字达到这一目的,并在Java SE 5.0引入了ReentrantLock 类。

synchronized 是 Java 内建的同步机制,所以也有人称其为内部锁(Intrinsic Locking),它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。

ReentrantLock,通常翻译为再入锁,是 Java 5 提供的锁实现,它的语义和 synchronized 基本相同。再入锁通过代码直接调用 lock() 方法获取,代码书写也更加灵活。

对于大多数需要显式锁的情况,synchronized 这是很便利的。但是,我们先了解锁和条件的内容之后,再去理解synchronized会更容易。

(一)ReentrantLock的使用

用 ReentrantLock 保护代码块的基本结构如下: 

myLock.lockO;         // 一个ReentrantLock对象 
try { 
	//写需要的同步的操作
	} 
finally { 
	myLock.unlockO;    //锁一定要释放
}}

下面用ReentrantLock写一下转账的逻辑:

public void transfer(int from,int to,double amount) throws InterruptedException {
		bankLock.lock();
		try{
			System.out.println(Thread.currentThread());
			accounts[from]-=amount;          //从from的账户里转出amount资产
			System.out.printf("从 %d号账户 转 %5.2f元 到  %d号账户-------》",from,amount,to);
			accounts[to]+=amount;		 //向to的账户里添加amount资产
			System.out.printf("转后所有账户的资金总数:%10.2f%n",getTotalBanlance());
		}finally {
			bankLock.unlock();
		}	
	}

假定一个线程调用 transfer,在执行结束前被剥夺了运行权。假定第二个线程也调用 transfer, 由于第二个线程不能获得锁,将在调用 lock 方法时被阻塞。它必须等待第一个线程 完成 transfer 方法的执行之后才能再度被激活。当第一个线程释放锁时, 那么第二个线程才能开始运行。通过这种加锁的方式就可以避免转账出现问题。

(二)ReentrantLock的公平锁

ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness,也就是公平性。

ReentrantLock(boo1ean fair )  

 上述代码就是构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。所以,默认情况下,锁没有被强制为公平的。 

注意:听起来公平锁更合理一些,但是使用公平锁比使用常规锁要慢很多。只有当你确实了解自己要做什么并且对于你要解决的问题有一个特定的理由必须使用公平锁的时候, 才可以使用公平锁。即使使用公平锁,也无法确保线程调度器是公平的。 如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。

三、条件对象

通常,线程进人临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。这时候就需要 Java 库中条件对象的实现。 条件对象经常被称为条件变量(conditional variable)。

现在来细化银行的转账程序。避免选择没有足够资金的账户作为转出账户。

在线程再次运行前,账户余额可能已经低于提款金额。必须确保没有其他线程在本检査余额与转账活动之间修改余额。通过使用锁来保护检査与转账动作来做到这一点: 

public void transfer(int from, int to,int amount) 
{
	bankLock.1ock(); 
	try { 
		while (accounts[from] < amount)  //检查是否有足够资金的账户作为转出账户
		{ // wait
			
		}
	}finally { 
		bankLock.unlock(); 
		}
}

当账户中没有足够的余额时,应该做什么呢?等待直到另一个线程向账户中注入了资金。但是,这一线程刚刚获得了对 bankLock 的排它性访问,因此别的线程没有进行存 款操作的机会。这就是为什么我们需要条件对象的原因。一个锁对象可以有一个或多个相关的条件对象。你可以用 newCondition 方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置 一个条件对象来表达“ 余额充足” 条件。 

细化后的转账逻辑代码如下:

public void transfer(int from,int to,double amount) throws InterruptedException {
		bankLock.lock();
		try{
1.			while (accounts[from]<amount) sufficentFunds.await(); //重复检查,是否余额满足转出金额,不满足线程堵塞
			System.out.println(Thread.currentThread());
			accounts[from]-=amount;  //从from的账户里转出amount资产
			System.out.printf("从 %d号账户 转 %5.2f元 到  %d号账户-------》",from,amount,to);
			accounts[to]+=amount;		 //向to的账户里转入amount资产
			System.out.printf("转后所有账户的资金总数:%10.2f%n",getTotalBanlance());
2.			sufficentFunds.signalAll();			//激活拥有这把锁的其他所有线程
		}finally {
			bankLock.unlock();
		}	
	}

分析一下上面的代码:(数字1处)判断该账户若不满足转账条件,通过条件对象,sufficentFunds.await()让该线程阻塞。这时候其他线程可能就会往该账户里转账。当该线程被激活并当达到该账户的转账条件后,就会进行转账。(数字2处)转账完成后,使用sufficentFunds.signalAll()方法会激活被堵塞在这把锁外的其他所有线程,并在最后释放该锁。一旦释放锁后,这些被激活的线程就又会通过竞争来操作这个账户。

值得注意的是:

除了方法 signalAll(),还有一个方法signal, 这个方法是随机解除等待集中某一个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。如果没有其他线程再次调用 signal, 那么系统就死锁了。 

完整的过程的代码如下:

Bank.java

public class Bank {
	private final double[] accounts;
	private Lock bankLock; //对象锁
	private Condition sufficentFunds; //对象条件
	public Bank(int n,double initBanlance) {
		accounts=new double[n];
		Arrays.fill(accounts,initBanlance);
		bankLock=new ReentrantLock();
		sufficentFunds=bankLock.newCondition();
	}
	
	public void transfer(int from,int to,double amount) throws InterruptedException {
		bankLock.lock();
		try{
			while (accounts[from]<amount) sufficentFunds.await(); //重复检查,是否余额满足转出金额,不满足线程堵塞
			
			System.out.println(Thread.currentThread());
			accounts[from]-=amount;  //从from的账户里转出amount资产
			System.out.printf("从 %d号账户 转 %5.2f元 到  %d号账户-------》",from,amount,to);
			accounts[to]+=amount;		 //向to的账户里转入amount资产
			System.out.printf("转后所有账户的资金总数:%10.2f%n",getTotalBanlance());
			sufficentFunds.signalAll();			//激活拥有这把锁的其他所有堵塞线程
		}finally {
			bankLock.unlock();
		}	
	}

	private double getTotalBanlance() {
		bankLock.lock();
		try{
			double sum=0;
			for(double a:accounts){
				sum+=a;		
			}
			return sum;
		}finally {
			bankLock.unlock();
		}
	
	}
	
	public int size() {
		return accounts.length;
	}
}


BankTest.java

public class BankTest {

	public static final int NACCOUNTS=100;                   //账户总数
	public static final double INITAL_BALANCE=1000;		//每个账户初始的金额
	public static final double MAX_ACCOUNT=100;		//最大的转账金额
	public static final int DELAY=100;			//每100ms转账一次
	public static void main(String[] args) {
		Bank bank=new Bank(NACCOUNTS, INITAL_BALANCE);
		for (int i = 0; i < NACCOUNTS; i++) {
			int fromAccount=i;
			Runnable r=new Runnable() {
				@Override
				public void run() {
					while(true){
						//随机生成一个转账用户
						int toAccount=(int)(bank.size()*Math.random());
						//随机生成一笔转账金额
						double amount=MAX_ACCOUNT*Math.random();
						try {
							//开始转账
							bank.transfer(fromAccount, toAccount, amount);
							Thread.sleep(DELAY);
						} catch (InterruptedException e) {
						
							e.printStackTrace();
						}
					}
				}
			};
			Thread t=new Thread(r);
			t.start();
		}
	}

}

四、Synchronized关键字

在前面介绍了如何使用 Lock 和 Condition对象。在进一步深人之前,总结一下有关锁和条件的关键之处: 

(1)锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。

(2)锁可以管理试图进入被保护代码段的线程。 

(3)锁可以拥有一个或多个相关的条件对象。

(4)每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。 

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

对上面的转账逻辑使用synchronized关键字:

public synchronized void transfer(int from, int to, double amount) throws InterruptedException 
{ 
	while (accounts[from] < amount) 
		wait(); 
	System.out.print(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", getTotalBalanceO); 
	notifyAllQ;
}
可以看到, 使用 synchronized关键字来编写代码要简洁得多。当然,要理解这一代码,你 必须了解每一个对象有一个内部锁, 并且该锁有一个内部条件。由锁来管理那些试图进入 synchronized 方法的线程,由条件来管理那些调用 wait 的线程。 


五、如何选择

在代码中应该使用哪一种?synchronized 和 ReentrantLock ?下面是一些建议: 

1.最好既不使用 ReentrantLock(Lock+Condition )也不使用 synchronized 关键字。在许多情况下你可以使用java.util.concurrent 包中的一种机制,它会为你处理所有的加锁。

2.如果 synchronized 关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。

3.如果特别需要 Lock/Condition结构提供的独有特性时,才使用ReentrantLock.




猜你喜欢

转载自blog.csdn.net/king_guoguo/article/details/80760821