线程的同步

一个经典的关于线程安全性的问题:银行取钱问题。

银行取钱的基本流程基本可以分为以下几个步骤:
(1)用户输入账户、密码,系统判断用户的账户、密码是否匹配。
(2)用户输入取款金额。
(3)系统判断账户余额是否大于取款金额。
(4)如果余额大于取款余额,取款成功;如果余额小于取款金额,则取款失败。

要求:按照以上步骤编写取款程序,并且要使用两个线程模拟取钱操作,即模拟两个人使用同一账户并发取钱。不用考虑检查账户和密码的操作,仅模拟后面三步操作。

编写程序的步骤如下:

1、定义一个账户类,该账户类封装了账户编号和余额两个属性。
源代码:Account.java
public class Account{
//定义账户编号、账户余额两个属性
private String accountNo;
private double balance;
//构造方法
public Account(String accountNo,double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//获得属性accountNo的值
public String getAccountNo(){
return accountNo;
}
//设置属性accountNo的值
public void setAccountNo(String accountNo){
this.accountNo = accountNo;
}
//获得属性balance的值
public double getBalance(){
return balance;
}
//设置属性balance的值
public void setBalance(double balance){
this.balance = balance;
}
//根据accountNo计算hashCode
public int hashCode(){
return accountNo.hashCode();
}
//重写equals()方法
public boolean equals(Object obj){
if(obj!=null&&obj.getClass()==Account.class){
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}


2、定义一个取钱的线程类,该线程类根据执行账户、取钱数目进行取钱操作。其中,取钱的逻辑是当余额不足时无法取出现金,当余额足够时系统吐出现金,金额减少。
源代码:DrawThread.java
public class DrawThread extends Thread{
//模拟用户账户
private Account account;
//当前取钱线程所希望取钱的数目
private double drawAmount;
//构造方法
public DrawThread(String name,Account account,double drawAmount){
super(name);
this. account = account;
this.drawAmount = drawAmount;
}
//当多条线程修改同一个共享数据时,将涉及数据安全问题
public void run(){
//账户余额大于取钱的数目
if(account.getBalance()>=drawAmount){
//取出钞票
System.out.println(getName()+"取钱成功!您取钱的数目为:"+drawAmount);
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("您账户的余额为:"+account.getBalance());
}
else{
System.out.println(getName()+"余额不足!取钱失败!");
}
}
}


3、主程序,创建一个账户,并启动两个线程从该账户账户取钱。
源代码:DrawTest.java
public class DrawTest{
public static void main(String args[]){
//创建一个账户
Account account = new Account("12345678",1000);
//模拟两个线程对同一个账户取钱操作
new DrawThread("甲",account,800).start();
new DrawThread("乙",account,800).start();
}
}

程序的运行结果如下(结果有多种):
结果一:
[color=blue]甲取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
乙取钱成功!您取钱的数目为:800.0
您账户的余额为:-600.0[/color]
结果二:
[color=blue]甲取钱成功!您取钱的数目为:800.0
乙取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
您账户的余额为:-600.0[/color]
结果三:
[color=blue]乙取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
甲取钱成功!您取钱的数目为:800.0
您账户的余额为:-600.0[/color]
结果四:
[color=blue]乙取钱成功!您取钱的数目为:800.0
甲取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
您账户的余额为:-600.0[/color]

[b]说明[/b]:多次运行该程序,很难产生我们希望的正确的结果!这是因为线程调度的不确定性。

[b]同步代码块[/b]

为了解决这个问题,Java的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块。同步代码块的语法格式如下:
synchronized(obj){
…….
//此处的代码就是同步代码块
}

其中,obj是一个同步监视器,上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。

[b]注意[/b]:任何时刻只能有一条线程可以获得对同步监视器的锁定,当同步代码块执行结束后,该线程自然释放了对该同步监视器的锁定。

虽然Java程序允许使用任何对象来作为同步监视器,但想一下同步监视器的目的:阻止两条线程对同一个共享资源进行并发访问。因此[b]通常推荐使用可能被并发访问的共享资源充当同步监视器[/b]。对于上面的取钱模拟程序,应该考虑使用账户(account)作为同步监视器。

修改取钱线程类的代码如下:
public class DrawThread extends Thread{
//模拟用户账户
private Account account;
//当前取钱线程所希望取钱的数目
private double drawAmount;
//构造方法
public DrawThread(String name,Account account,double drawAmount){
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
//当多条线程修改同一个共享数据时,将涉及数据安全问题
public void run(){
//使用account作为同步监视器,任何线程进入下面同步代码块之前,
//必须先获得对account账户的锁定——其他线程无法获得锁,也就无法修改它
//这种做法符合:加锁——>修改完成——>释放锁的逻辑
synchronized(account){
//账户余额大于取钱的数目
if(account.getBalance()>=drawAmount){
//取出钞票
System.out.println(getName()+"取钱成功!您取钱的数目为:"+drawAmount);
account.setBalance(account.getBalance()-drawAmount);
System.out.println("您账户的余额为:"+account.getBalance());
}
else{
System.out.println(getName()+"余额不足!取钱失败!");
}
}
//同步代码块结束,该线程释放同步锁
}
}

再次运行该程序,结果如下:
[color=blue]甲取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
乙余额不足!取钱失败![/color]

[b]说明[/b]:上述程序使用synchronized将run方法里方法体修改成了同步代码块,该同步代码块的同步监视器是account对象,这样的做法符合“加锁——>修改完成——>释放锁”的逻辑,任何线程想修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当该线程修改完成,该线程将释放对该资源的锁定。通过这种方式就可以保证并发线程在任何一个时刻只有一条线程可以进入修改共享资源的代码区(也称为临界区),所以同一个时刻最多只有一条线程处于临界区内,从而保证了线程的安全。

[b]同步方法[/b]

与同步代码块相对应的是,Java的多线程安全支持还提供了同步方法,同步方法就是使用synchronized关键字来修饰的某个方法。对于同步方法而言,无需显式指定同步监视器,同步方法的同步监视器是this,也就是该对象本身。

通过使用同步方法可以非常方便地将某类变成线程安全的类,线程安全的类具有如下特性:
(1)该类的对象可以被多个线程安全的访问。
(2)每个线程调用该对象的任何方法之后都将得到正确的结果。
(3)每个线程调用该对象的任何方法之后,该对象状态依然保持合理状态。

对于可变类和不可变类,不可变类总是线程安全的,因为它的对象的状态不可改变。而可变类需要额外的方法来保证其线程安全。例如上面的Account类就是一个可变类,它的account和balance两个属性都是可变的,当两个线程同时修改Account对象的balance属性时,程序就出现了异常。下面将Account类对balance的访问设置成线程安全的,那么程序只要把修改的balance方法修改成同步方法即可。
程序如下:
源代码:Account.java
public class Account{
//定义账户编号、账户余额两个属性
private String accountNo;
private double balance;
//构造方法
public Account(String accountNo,double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//获得属性accountNo的值
public String getAccountNo(){
return accountNo;
}
//设置属性accountNo的值
public void setAccountNo(String accountNo){
this.accountNo = accountNo;
}
//获得属性balance的值
public double getBalance(){
return balance;
}
//提供一个线程安全draw方法来完成取钱操作
public synchronized void draw(double drawAmount){
//账户余额大于取钱的数目
if(getBalance()>=drawAmount){
//取出钞票
System.out.println(Thread.currentThread().getName()+"取钱成功!您取钱的数目为:"+drawAmount);
balance-=drawAmount;
System.out.println("您账户的余额为:"+balance);
}
else{
System.out.println(Thread.currentThread().getName()+"余额不足!取钱失败!");
}
}
//根据accountNo计算hashCode
public int hashCode(){
return accountNo.hashCode();
}
//重写equals()方法
public boolean equals(Object obj){
if(obj!=null&&obj.getClass()==Account.class){
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}

[b]说明[/b]:以上程序中增加了一个代表取钱操作的draw方法,并使用了synchronized关键字修饰了该方法,把该方法变成同步方法。同步方法的同步监视器是this,因此对于同一个Account账户来说,[b]任何时刻只能有一条线程获得对Account对象的锁定[/b],然后进入draw方法执行取钱操作——这样也可以保证多条线程并发存钱的线程安全。

[b]注意[/b]:synchronized关键字可以修饰方法,可以修饰代码块,但不能修饰构造方法、属性等。

因为Account类中已经提供了draw()方法,而且取消了setBalance()方法,DrawThread线程类也需要修改,该类只要调用Account对象的draw()方法来执行取钱操作就可以了。
程序如下:
源代码:DrawThread.java
public class DrawThread extends Thread{
//模拟用户账户
private Account account;
//当前取钱线程所希望取钱的数目
private double drawAmount;
//构造方法
public DrawThread(String name,Account account,double drawAmount){
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
//当多条线程修改同一个共享数据时,将涉及数据安全问题
public void run(){
//直接调用account对象的draw方法来执行取钱
account.draw(drawAmount);
}
}

程序的运行结果如下:
[color=blue]甲取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
乙余额不足!取钱失败![/color]

[b]提示[/b]:此时程序把draw方法定义在Account里,而不是直接在run方法中实现逻辑,这种做法更符合面向对象规则。在面向对象里有一种流行的设计方式:Domain Driven Design(即领域驱动设计,简称DDO),这种方式认为每个类都应该是完备的领域对象,例如Account它代表用户账户,它应该提供账户的相关方法,例如通过draw()方法来执行取钱操作,而不是直接将setBalance()方法暴露出来任人操作,这样才可以更好地保证Account对象的完整性和一致性。

可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采取如下策略:
(1)不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步,例如上面的Account类中accountNo属性就无需同步,所以程序只对draw方法进行同步控制。
(2)如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本:线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。

[b]释放同步监视器的锁定[/b]

任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定:
(1)当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器。
(2)当线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器。
(3)当线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时将会释放同步监视器。
(4)当线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。
在下面的情况下,线程不会释放同步监视器:
(1)线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
(2)线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。当然,我们应该尽量避免使用suspend和resume方法来控制线程。

[b]同步锁(Lock)[/b]

从JDK1.5之后,Java提供了另一种线程同步的机制:它通过显式定义同步锁对象来实现同步,在这种机制下,同步锁应该使用Lock对象充当。

通常认为:Lock提供了比synchronized方法和synchronized代码块更广泛的锁定操作,Lock实现允许更灵活的结构,可以具有差别很大的属性,并且可以支持多个相关的Condition对象。

Lock是控制多线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应该先获得Lock对象。不过,某些锁可能允许对共享资源并发访问,如RendWriteLock(读写锁)。当然,在实现线程安全的控制中,通常喜欢使用ReentrantLock(可重入锁)。使用该Lock对象可以显示加锁、释放锁,通常使用Lock对象的代码格式如下:
class X{
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
//……
//定义需要保证线程安全的方法
public void m(){
//加锁
lock.lock();
try{
//需要保证线程安全的代码
//…...
//…method body
}
//使用finally块来保证释放锁
finally{
lock.unlock();
}
}
}

[b]使用Lock对象来进行同步时,锁定和释放锁出现在不同作用范围中时,通常建议使用finally块来确保在必要时释放锁。[/b]通过使用Lock对象我们可以把Account类改写成如下形式,它依然是线程安全的。
程序如下:
import java.util.concurrent.locks.*;
public class Account{
//定义锁对象
private final ReentrantLock lock =new ReentrantLock();
private String accountNo;
private double balance;
//构造方法
public Account(String accountNo,double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//获得属性accountNo的值
public String getAccountNo(){
return accountNo;
}
//设置属性accountNo的值
public void setAccountNo(String accountNo){
this.accountNo = accountNo;
}
//获得属性balance的值
public double getBalance(){
return balance;
}
//提供一个线程安全draw方法来完成取钱操作
public void draw(double drawAmount){
lock.lock();
try{
//账户余额大于取钱的数目
if(getBalance()>=drawAmount){
//取出钞票
System.out.println(Thread.currentThread().getName()+"取钱成功!您取钱的数目为:"+drawAmount);
balance-=drawAmount;
System.out.println("您账户的余额为:"+balance);
}
else{
System.out.println(Thread.currentThread().getName()+"余额不足!取钱失败!");
}
}
//使用finally块来确保释放锁
finally{
lock.unlock();
}
}
//根据accountNo计算hashCode
public int hashCode(){
return accountNo.hashCode();
}
//重写equals()方法
public boolean equals(Object obj){
if(obj!=null&&obj.getClass()==Account.class){
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}

程序运行结果如下:
[color=blue]甲取钱成功!您取钱的数目为:800.0
您账户的余额为:200.0
乙余额不足!取钱失败![/color]

[b]提示[/b]:使用Lock与使用同步方法有点相似,只是使用Lock时显式使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器,同样都符合“加锁——>访问——>释放锁”的操作模式,而且使用Lock对象时每个Lock对象对应一个Account对象,一样可以保证对于同一个Account对象,同一个时刻只能有一条线程能进入临界区。

同步方法或同步代码块使用与竞争资源相关的、隐式的同步监视器,并且强制要求加锁和释放锁要出现在一个块结构中,而且当获取了多个锁时,它们必须以相反的顺序释放,且必须在与所有锁被获取时相同的范围内释放所有锁。

虽然同步方法和同步代码块的范围机制使得多线程安全编程非常方便,而且还可以避免了很多涉及锁的常见编程错误,但有时也需要以更为灵活的方式使用锁。Lock提供了同步方法和同步代码块没有的其他功能,包括用于非块结构的tryLock方法,以及试图获取可中断锁lockInterruptibly()方法,还有获取超时失效锁的tryLock(long,TimeUnit)方法。

ReentrantlLock锁具有重入性,也就是说线程可以对它已经加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock方法的嵌套调用,线程在每次调用lock()加锁后,必须显式调用unlock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值