Java学习day092 并发(四)(同步(一):竞争条件的一个例子、竞争条件详解、锁对象、条件对象)
发布日期:2021-06-29 11:49:37 浏览次数:2 分类:技术文章

本文共 9082 字,大约阅读时间需要 30 分钟。

使用的教材是java核心技术卷1,我将跟着这本书的章节同时配合视频资源来进行学习基础java知识。

day092   并发(四)(同步(一):竞争条件的一个例子、竞争条件详解、锁对象、条件对象)


同步

在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢?可以想象,线程彼此踩了对方的脚。根据各线程访问数据的次序,可能会产生i化误的对象。这样一个情况通常称为竞争条件(racecondition)。


1.竞争条件的一个例子

为了避免多线程引起的对共享数据的说误,必须学习如何同步存取。在本节中,你会看到如果没有使用同步会发生什么。在下一节中,将会看到如何同步数据存取。

在下面的测试程序中,模拟一个有若干账户的银行。随机地生成在这些账户之间转移钱款的交易。每一个账户有一个线程。每一笔交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。

模拟代码非常直观。我们有具有transfer方法的Bank类。该方法从一个账户转移一定数目的钱款到另一个账户(还没有考虑负的账户余额)。如下是Bank类的transfer方法的代码。

public void transfer(int from, int to, double amount)	{		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", getTotalBalance());	}

这里是Runnable类的代码。它的run方法不断地从一个固定的银行账户取出钱款。在每一次迭代中,run方法随机选择一个目标账户和一个随机账户,调用bank对象的transfer方法,然后睡眠。

Runnable r = () -> {				try				{					while(true)					{						int toAccount = (int) (bank.size() * Math.random());						double amount = MAX_AMOUNT * Math.random();						bank.transfer(fromAccount, toAccount, amount);						Thread.sleep((int) (DELAY * Math.random()));					}				}				catch(InterruptedException e)				{				}			};

当这个模拟程序运行时,不清楚在某一时刻某一银行账户中有多少钱。但是,知道所有账户的总金额应该保持不变,因为所做的一切不过是从一个账户转移钱款到另一个账户。在每一次交易的结尾,transfer方法重新计算总值并打印出来。本程序永远不会结束。只能按CTRL+C来终止这个程序。

出现了错误。在最初的交易中,银行的余额保持在$100000,这是正确的,因为共100个账户,每个账户$1000。但是,过一段时间,余额总量有轻微的变化。当运行这个程序的时候,会发现有时很快就出错了,有时很长的时间后余额发生混乱。这样的状态不会带来信任感,人们很可能不愿意将辛苦挣来的钱存到这个银行。

下面是代码:

/** *@author  zzehao *This program shows data corruption when multiple threads access a data structure. */public class UnsynchBankTest{	public static final int NACCOUNTS = 100;	public static final double INITIAL_BALANCE = 1000;	public static final double MAX_AMOUNT = 1000;	public static final int DELAY = 10;		public static void main(String[] args)	{		Bank bank = new Bank(NACCOUNTS,INITIAL_BALANCE);		for(int i = 0 ;i < NACCOUNTS ; i++)		{			int fromAccount = i;			Runnable r = () -> {				try				{					while(true)					{						int toAccount = (int) (bank.size() * Math.random());						double amount = MAX_AMOUNT * Math.random();						bank.transfer(fromAccount, toAccount, amount);						Thread.sleep((int) (DELAY * Math.random()));					}				}				catch(InterruptedException e)				{				}			};			Thread t = new Thread(r);			t.start();		}	}}
/** *@author  zzehao */import java.util.*;//A bank with a number of bank accounts.public class Bank{	private final double[] accounts;	//Constructs the bank.	public Bank(int n, double initialBalance)	{		accounts = new double[n];		Arrays.fill(accounts, initialBalance);	}	//Transfers money from one account to another.	public void transfer(int from, int to, double amount)	{		if (accounts[from] < amount) 			return;		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", getTotalBalance());	}	//Gets the sun of all account balances.	public double getTotalBalance()	{		double sum = 0;		for (double a : accounts)			sum += a;		return sum;	}	//Gets the number of accounts in the bank.	public int size()	{		return accounts.length;	}}

结果是:


2.竞争条件详解

上一节中运行了一个程序,其中有几个线程更新银行账户余额。一段时间之后,错误不知不觉地出现了,总额要么增加,要么变少。当两个线程试图同时更新同一个账户的时候,这个问题就出现了。假定两个线程同时执行指令

accounts[to]+=amount;

问题在于这不是原子操作。该指令可能被处理如下:

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

2)增加amount。

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

现在,假定第1个线程执行步骤1和2,然后,它被剥夺了运行权。假定第2个线程被唤醒并修改了accounts数组中的同一项。然后,第1个线程被唤醒并完成其第3步。这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。我们的测试程序检测到这一讹误。(当然,如果线程在运行这一测试时被中断,也有可能会出现失败警告!)

出现这一讹误的可能性有多大呢?这里通过将打印语句和更新余额的语句交织在一起执行,增加了发生这种情况的机会。如果删除打印语句,讹误的风险会降低一点,因为每个线程在再次睡眠之前所做的工作很少,调度器在计算过程中剥夺线程的运行权可能性很小。但是,讹误的风险并没有完全消失。如果在负载很重的机器上运行许多线程,那么,即使删除了打印语句,程序依然会出错。这种错误可能会几分钟、几小时或几天出现一次。坦白地说,对程序员而言,很少有比无规律出现错误更糟的事情了。真正的问题是transfer方法的执行过程中可能会被中断。如果能够确保线程在失去控制之前方法运行完成,那么银行账户对象的状态永远不会出现讹误。


3.锁对象

有两种机制防止代码块受并发访问的干扰。Java语言提供一个synchronized关键字达到这一目的,并且JavaSE5.0引入了ReentrantLock类。synchronized关键字自动提供一个锁以及相关的“条件”,对于大多数需要显式锁的情况,这是很便利的。但是,在分別阅读了锁和条件的内容之后,理解synchronized关键字是很轻松的事情。java.util.concurrent框架为这些基础机制提供独立的类。

myLock.lock();//a ReentrantLock objecttry{    critical section}finally{    myLock.unlock();//make sure the lock is unlocked even if an exception is throm}

这一结构确保任何时刻只有一个线程进人临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们被阻塞,直到第一个线程释放锁对象。

我们使用一个锁来保护Bank类的transfer方法。

public class Bank{    private Lock banklock = new ReentrantLock();//ReentrantLock implements the Lock interface    ...    public void transfer(int frommint to,int amount)    {        bankLock.lock()        try        {            System.out.print(Thread.currentThread());            account[from] -= amount;            System.out.printf("%10.2f from %d to %d",amount,from,to);            accounts[to]+=amount;            System.out.printf(" Total Balance: X10.2f%n", getTotalBalance());        }        finally        {            bankLock.unlock();        }    }}

假定一个线程调用transfer,在执行结束前被剥夺了运行权。假定第二个线程也调用transfer,由于第二个线程不能获得锁,将在调用lock方法时被阻塞。它必须等待第一个线程完成transfer方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行。

                           

添加加锁代码到transfer方法并且再次运行程序。你可以永远运行它,而银行的余额不会出现讹误。

注意每一个Bank对象有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的Bank对象,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。本该如此,因为线程在操纵不同的Bank实例的时候,线程之间不会相互影响。

锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(holdcount)来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。

例如,transfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出的时候,持有计数变回1。当transfer方法退出的时候,持有计数变为0。线程释放锁。通常,可能想要保护需若干个操作来更新或检查共享对象的代码块。要确保这些操作完成后,另一个线程才能使用相同对象。


4.条件对象

通常,线程进人临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。在这一节里,我们介绍Java库中条件对象的实现。(由于历史的原因,条件对象经常被称为条件变量(conditionalvariable)。)

现在来细化银行的模拟程序。我们避免选择没有足够资金的账户作为转出账户。注意不能使用下面这样的代码:

if (bank.getBalance(fron)>= amount)    bank.transfer(from , to, amount);

当前线程完全有可能在成功地完成测试,且在调用 transfer方法之前将被中断。

if (bank.getBalance(from)>=amount)    //thread night be deactivated at this point    bank.transfer(from, to, amount):

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

public void transfer(int from, int to, int amount){    bankLock.lock();    try    {        while (accounts[from]

现在,当账户中没有足够的余额时,应该做什么呢?等待直到另一个线程向账户中注入了资金。但是,这一线程刚刚获得了对bankLock的排它性访问,因此别的线程没有进行存款操作的机会。这就是为什么我们需要条件对象的原因。

一个锁对象可以有一个或多个相关的条件对象。你可以用newCondition方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“余额充足”条件。

class Bank{    private Condition sufficientFunds;    ...    public Bank()    {        ...        sufficientFunds = bankLock.newCondition();    }}

如果 transfer方法发现余额不足,它调用

sufficientFunds.await();

当前线程现在被阻塞了,并放弃了锁。我们希望这样可以使得另一个线程可以进行增加账户余额的操作。

等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法,它进人该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll方法时为止。当另一个线程转账时,它应该调用

sufficientFunds.signalAll();

这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进人该对象。一旦锁成为可用的,它们中的某个将从await调用返回,获得该锁并从被阻塞的地方继续执行。

此时,线程应该再次测试该条件。由于无法确保该条件被满足—signalAll方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。

至关重要的是最终需要某个其他线程调用signalAll方法。当一个线程调用await时,它没有办法重新激活自身。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁(deadlock)现象。如果所有其他线程被阻塞,最后一个活动线程在解除其他线程的阻塞状态之前就调用await方法,那么它也被阻塞。没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。

应该何时调用signalAll呢?经验上讲,在对象的状态有利于等待线程的方向改变时调用signalAll。例如,当一个账户余额发生改变时,等待的线程会应该有机会检查余额。在例子中,当完成了转账时,调用signalAll方法。

public void transfer(int from,int to,int amount){    bankLock.lock();    try    {        while (accounts[from]

注意调用signalAll不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。另一个方法signal,则是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。

如果你运行下面的程序,会注意到没有出现任何错误。总余额永远是 $100000。没有任何账户曾出现负的余额(但是,你还是需要按下CTRL+C键来终止程序)。你可能还注意到这个程序运行起来稍微有些慢—这是为同步机制中的簿记操作所付出的代价。实际上,正确地使用条件是富有挑战性的。

/** *@author  zzehao */import java.util.*;import java.util.concurrent.locks.*;//A bank with a number of bank accounts that uses locks for serializing access.public class Bank{	private final double[] accounts;	private Lock bankLock;	private Condition sufficientFunds;	//Constructs the bank.	public Bank(int n, double initialBalance)	{		accounts = new double[n];		Arrays.fill(accounts, initialBalance);		bankLock = new ReentrantLock();		sufficientFunds = bankLock.newCondition();	}	//Transfers money from one account to another.	public void transfer(int from, int to, double amount) throws InterruptedException	{		bankLock.lock();		try		{			while(accounts[from] < amount) 				sufficientFunds.await();			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", getTotalBalance());			sufficientFunds.signalAll();		}		finally		{			bankLock.unlock();		}	}	//Gets the sun of all account balances.	public double getTotalBalance()	{		bankLock.lock();		try		{			double sum = 0;			for (double a : accounts)				sum += a;			return sum;		}		finally		{			bankLock.unlock();		}	}	//Gets the number of accounts in the bank.	public int size()	{		return accounts.length;	}}

           


 

转载地址:https://blog.csdn.net/Zzehao11/article/details/105845157 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:Java学习day093 并发(四)(同步(二):synchronized 关键字、同步阻塞、监视器概念、Volatile 域、final变量、原子性)
下一篇:Java学习day091 并发(三)(线程属性:线程优先级、守护线程、未捕获异常处理器)

发表评论

最新留言

表示我来过!
[***.240.166.169]2024年04月30日 15时28分03秒