13.5 线程同步
多条线程并发修改共享资源就容易引发线程安全问题。
一、线程安全问题
关于线程安全问题,有一个经典案例——银行取钱。银行取钱的基本流程基本上可以分为如下几个步骤:
1、用户输出账户、密码,系统判断账户、密码是否匹配;
2、用户输入取钱金额;
3、系统判断账户余额是否大于取款金额;
4、如果余额大于取款金额,则取款成功;如果余额小于取款金额,则取款失败。
按照上面的流程去编写取款程序,并使用两个线程来模拟取钱操作,模拟两个人使用同一个账户并发取钱的问题。此处忽略检查账户、密码的操作,仅仅模拟后面三步操作。
下面定义了一个账户类,该账户类封装了账户编号和余额两个实例变量。
package section5;
public class Account
{
//封装账户编号、账户余额两个成员变量
private String accountNo;
private double balance;
public Account(){}
//构造器
public Account(String accountNo, double balance)
{
this.accountNo=accountNo;
this.balance=balance;
}
private String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
//下面两个方法根据aaccountNo来重写hashCode()和equals()方法
@Override
public int hashCode()
{
return accountNo.hashCode();
}
@Override
public boolean equals(Object obj)
{
if(this==obj)
return true;
if(obj!=null&&obj.getClass()== Account.class)
{
Account target=(Account)obj;
return target.getAccountNo().equals(this.getAccountNo());
}
return false;
}
}
接下来提供一个取钱的线程类,该线程类执行账户、取钱数量进行取钱操作,取钱的逻辑是当器余额不足时无法提取现金,当余额充足时系统吐出现金,余额减少。
package section5;
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(this.getName()+"取钱成功!吐出钞票:"+drawAmount);
/*
try{
Thread.sleep(1);
}
catch(InterruptedException ex)
{
ex.printStackTrace()
}
*/
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("\t余额为:"+account.getBalance());
}
else
{
System.out.println(getName()+"取钱失败!余额不足!");
}
}
}
程序的主程序创建了一个账户,并启动了两个线程从该线程中取钱,程序如下:
package section5;
public class DrawTest
{
public static void main(String[] args)
{
//创建一个账户
var acct=new Account("1234567",1000);
//模拟两个线程对同一个账户取钱
new DrawThread("甲",acct,800).start();
new DrawThread("乙",acct,800).start();
}
}
运行上面的程序将看到下图所示的错误结果:
上面的运行结果并不是银行想看到的结果,这正是多线程编程突然出现的“偶然”错误——因为线程调度的不确定性。取消上面注释的代码,让系统调度器在此处暂停一下,然另一个线程执行——取消上面注释后再次编译DrawThread.java,并再次运行DrawTest,将总是可以看到上面错误的运行结果。
二、同步代码块
之所以出现上面的运行结果,是因为run()方法的方法体不具有同步安全性——程序中有两个并发线程在修改Account对象;而且系统恰好在注释代码处执行线程切换,切换给另一个修改Acoount对象的线程,所以就出现问题啦。
当有两个进程并发修改同一个文件时就有可能造成异常
为了解决这个问题,Java的多线程支持引入同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块。
synchronized (obj)
{
...
//此处的代码就是同步代码块
}
synchronized后括号里的obj就是同步监视器,上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。
任何时刻只能有一个线程可以获得对同步监听器的锁定,当同步代码块执行完成后。该线程会释放对同步监听器的锁定。
虽然Java程序允许使用任何对象作为同步监视器,但想一下同步监视器的目的:阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器。
对于上面模拟取钱程序,应该考虑使用账户(account)作为同步监视器,把下面程序修改为:
package section5;
public class SynchronizedDrawThread extends Thread
{
// 模拟用户账户
private Account account;
// 当前取钱线程所希望取的钱数
private double drawAmount;
public SynchronizedDrawThread(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);
try
{
Thread.sleep(1);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为: " + account.getBalance());
}
else
{
System.out.println(getName() + "取钱失败!余额不足!");
}
}
// 同步代码块结束,该线程释放同步锁
}
public static void main(String[] args)
{
//创建一个账户
Account acct=new Account("123456",1000);
//两个线程同时访问同一个账户
new SynchronizedDrawThread("甲",acct,800).start();
new SynchronizedDrawThread("乙",acct,800).start();
}
}
甲取钱成功!吐出钞票:800.0
余额为: 200.0
乙取钱失败!余额不足!
上面程序使用synchronized将run()方法里的方法体修改成同步代码块,改代码块的同步监视器是account对象,这样的做法符合“加锁——>修改——>释放锁”的逻辑,任何线程在修改指定资源前,首先要先对该资源加锁,在加锁期间,其他线程无法修改该资源,当该线程修改完成后,该线程释放对资源的锁定。通过这种方式,可以保证并发线程在任何一刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证线程的安全性。
三、同步方法
synchronized关键字可以修饰方法、代码块,但不能修饰构造器、成员变量等
Java的多线程安全支持还提供了同步方法,同步方法就是使用synchronized关键字来修饰某个方法,则该方法成为同步方法。对于同步方法而言,无需显式指定同步监视器,同步方法的同步监视器是this,也就是该对象本身。
3.1 线程安全的类和同步方法
通过同步方法可以很好地实现线程安全的类,线程安全的类具有以下特征:
(1)该类的对象可以被多个线程安全的访问。
(2)每个线程调用该对象的任意方法之后都将得到正确结果。
(3)每个线程调用该对象的任意方法之后,该对象状态依然保持合理状态。
前面介了可变类和不可变类,其中不可变类总是线程安全的类,因为它的对象状态不可改变;但可变对象需要额外的方法来保证器线程安全。例如上面Account就是一个accountNo和balance两个成员变类都可以被改变,当两个线程同时修改Account对象的balance成员变量的值时,程序就出现异常。下面将Account类对balance的访问设置成线程安全的,那么只要把修改balance的方法变成同步方法即可。程序如下:
package section5;
public class SynchronizedAccount
{
//封装账户编号、账户余额两个成员变量
private String accountNo;
private double balance;
public SynchronizedAccount(){}
//有参数构造器
public SynchronizedAccount(String accountNo,double balance)
{
this.accountNo=accountNo;
this.balance=balance;
}
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因为账户余额不允许随便改变,所以只能为balance提供get方法
public double getBalance()
{
return this.balance;
}
//提供一个线程安全的draw()方法来完成取钱操作
public synchronized void draw(double drawAmount)
{
//账户余额大于取钱数
if(this.getBalance()>=drawAmount)
{
//吐出钞票
System.out.println(Thread.currentThread().getName()+"取钱成功!吐出钞票:"+drawAmount);
try
{
Thread.sleep(1);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
//修改余额
this.balance=this.balance-drawAmount;
System.out.println("\t余额为:"+balance);
}
else
{
System.out.println(Thread.currentThread().getName()+"取钱失败!余额不足");
}
}
@Override
public int hashCode()
{
return this.accountNo.hashCode();
}
@Override
public boolean equals(Object obj)
{
if(this==obj)
return true;
if(obj!=null&&obj.getClass()==SynchronizedAccount.class)
{
var target=(SynchronizedAccount)obj;
return target.getAccountNo().equals(this.getAccountNo());
}
else
return false;
}
}
上面程序增加了一个代表取钱行为的draw()方法,并使用Syschronized关键字修饰,把该方法变成同步方法,该同步方法的同步监视器就是this,因此对于同一个SyschronizedAccount账户而言,任何时刻都只能有一个线程获得对Account对象的锁定,然后进入draw()方法执行取钱行为——这样也可以保证多个线程并发取钱行为的线程安全。
下面测试多个线程并发执行取钱操作:
package section5;
public class SynchrnizedMedTd extends Thread
{
private SynchronizedAccount account;
private double drawAmount;
public SynchrnizedMedTd(String name,SynchronizedAccount acct,double drawAmount)
{
super(name);
this.account=acct;
this.drawAmount=drawAmount;
}
@Override
public void run()
{
this.account.draw(this.drawAmount);
}
public static void main(String[] args)
{
SynchronizedAccount acct=new SynchronizedAccount("1234567",1000);
new SynchrnizedMedTd("甲线程",acct,800).start();
new SynchrnizedMedTd("乙线程",acct,800).start();
}
}
甲线程取钱成功!吐出钞票:800.0
余额为:200.0
乙线程取钱失败!余额不足
上面程序直接调用SynchronizedAccount的draw()方法。由于已经使用了synchronized关键字修饰了draw()方法,同步方法监视器就是this,而this总是代表SynchronizedAccount,因此多个线程并发修改同一份SynchronizedAccount之前,必须先对SynchronizedAccount acct对象加锁。这符合了“加锁->修改->释放锁”的逻辑。
3.2 线程安全的处理
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:
1、不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步。例如上面的Account类中accountNo属性就无需同步,所以程序只对draw方法进行同步控制。
2、如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本:线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。
四、释放同步监视器的锁定
4.1 释放同步监视器锁定的情况
线程会在如下几种情况下释放对同步监视器的锁定:
1、当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器。
2、当线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器。
3、当线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时将会释放同步监视器。
4、当线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。
4.2 不释放同步监视器锁定的情况
1、当前线程的同步方法、同步代码块执行时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
2、线程执行同步代码块时,其他线程应该避免使用该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。当然程序应该尽量避免使用suspend()和resume()方法来控制线程。
五、同步锁(Lock)
从Java 5开始,Java提供了一种功能更强大的线程同步机制——通过显示定义同步锁对象来实现同步,在这种机制下,同步锁又Lock对象充当。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。不过,某些锁可能允许对共享资源并发访问,如 ReadWriteLock(读写锁),Look、ReadWriteLock是Java 5提供的两个根接口,并为Lock提供了ReentrantLook(可重入许锁)实现类。
在实现线程安全的控制中,通常喜欢使用ReentrantLock(可重入锁)。使用该Lock对象可以显式地加锁、释放锁。通常使用ReentrantLock的代码格式如下:
class X
{
//定义锁对象
private final ReentrantLock lock=new ReentrantLock();
//...
//定义需要保证线程安全的方法
public void m()
{
//加锁
lock.lock();
try
{
//需要保证线程安全的代码
//...method body
}
//使用finally块来保证释放锁
finally
{
lock.unlock();
}
}
}
使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要的时候释放锁。通过使用ReentrantLock对象,可以把Account类改成如下形式,它的线程依然是安全的:
package section5.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Account
{
//定义锁对象
private final ReentrantLock lock=new ReentrantLock();
//封装账户编号、账户余额两个成员变量对象
private String accountNo;
private double balance;
public Account(){}
//构造器
public Account(String accountNo,double balance)
{
this.accountNo=accountNo;
this.balance=balance;
}
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
public double getBalance() {
return this.balance;
}
//提供一个线程安全的draw()方法来完成取钱行为
public void draw(double drawAmount)
{
//加锁
lock.lock();
try
{
//账户余额大于取钱金额
if(this.balance>=drawAmount)
{
System.out.println(Thread.currentThread().getName()+"取钱成功!吐出钞票:"+drawAmount);
try
{
Thread.sleep(1);//不会释放同步监视器
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
//修改账户金额
this.balance=this.balance-drawAmount;
System.out.println("\t余额为:"+this.balance);
}
else {
System.out.println(Thread.currentThread().getName()+"取钱失败!余额不足!");
}
}
finally {
//修改完成,释放锁
lock.unlock();
}
}
@Override
public int hashCode()
{
return this.accountNo.hashCode();
}
@Override
public boolean equals(Object obj)
{
if(obj==this)
return true;
if(obj.getClass()==Account.class)
{
var target=(Account)obj;
return target.getAccountNo()==this.getAccountNo();
}
else
return false;
}
}
上面程序private final ReentrantLock lock=new ReentrantLock();定义了一个ReentrantLock对象,程序中实现了draw()方法时,进入方法开始执行后立即请求对ReentrantLock加锁,当执行完draw()取钱逻辑之后,程序使用finnal块来确保释放锁。
注意:使用Lock与使用同步方法有点类似,只是使用Lock显示使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器,同样都符合“加锁->修改->释放锁”的操作模式,而使用Lock对象时每个Lock对应一个Account对象,一样可以保证对对一个Account对象,同一时刻只能由一个线程进入临界区。
同步方法或同步代码块使用与竞争资源相关的、隐式的同步监视器,并且强制要求加锁和释放锁必须出现在一个块结构中,而且获取多个锁时,他们必须与相反的顺序释放,且必须在与所有锁被获取时相同范围内释放所有的锁。
虽然同步方法和同步代码块的范围机制使得多线程安全变成非常方便,而且避免了许多设计锁的常见编译错误,但有时需要以更加灵活的方式使用锁。Lock提供了同步方法和同步代码块没有的其他功能,包括用于非块结构的tryLock()方法,以及试图获取可中断锁的lookInterruptibly()方法,还可以获取超时失效锁的tryLock(long,TimeUnit)。
ReentrantLock锁具有可重入性,也就是说线程可以对它已经加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock方法的嵌套调用,线程在每次调用lock()方法加锁后,必须显式调用unlock()方法来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法
六、死锁及常用处理策略
当两个线程相互等待对方释放同步监视器时就会发生死锁,Java虚拟机没有监测、也没有采用措施来处理死锁情况,所以多线程编程时应该采取措施避免死锁的出现。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
死锁很容易发生,尤其在系统中出现了多个监视器的情况下,如下程序将会出现死锁
package section5;
class A
{
public synchronized void foo(B b)
{
System.out.println("当前线程名:"+Thread.currentThread().getName()+"进入A实例的foo()方法");//代码1
try{
Thread.sleep(200);
}
catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("当前线程名:"+Thread.currentThread().getName()+"企图调用B实例的last()方法");//代码3
b.last();
}
public synchronized void last()
{
System.out.println("进入A类的last()方法的内部");
}
}
class B
{
public synchronized void bar(A a)
{
System.out.println("当前线程名:"+Thread.currentThread().getName()+"进入B实例的bar()方法");//代码2
try{
Thread.sleep(200);
}
catch (InterruptedException ex) {
ex.printStackTrace();
}
System.out.println("当前线程名:"+Thread.currentThread().getName()+"企图调用A实例的last()方法");//代码4
a.last();
}
public synchronized void last()
{
System.out.println("进入B类的last()方法的内部");
}
}
public class DeadLock implements Runnable
{
A a=new A();
B b=new B();
public void init()
{
Thread.currentThread().setName("主线程");
//调用a对象的foo()方法
a.foo(b);
System.out.println("进入主线程之后");
}
public void run()
{
Thread.currentThread().setName("副线程");
//调用b对象的bar()方法
b.bar(a);
System.out.println("进入副线程之后");
}
public static void main(String[] args)
{
var dl=new DeadLock();
//dl为target启动新线程
new Thread(dl).start();
dl.init();
}
}
从上面运行结果可以看出,程序先运行B对象的bar()方法,进入B对象之前,先对B加锁,当程序执行到代码2处,副线程暂停200ms;CPU切换执行另一个线程,调用A对象的foo()方法,进入foo()方法之前,先对A对象加锁,当程序执行到代码1处,主线程耶暂停200ms;接下来副线程先醒过来,继续向下执行,执行到代码4处,希望调用A对象的last()方法——执行该方法必须先对A对象加锁,但此时主线程正在保持着A对象的锁,所以副线程阻塞;接下来主线程醒过来,继续向下执行,直到执行到代码3处希望调用B对象的last
()方法,但此时副线程保持着B对象的锁,所以主线程阻塞。至此救出吸纳了主线程保持着A对象的锁,等待B对象的加锁,而副线程保持着B对象的锁,等待A对象加锁,两个线程互相等待对方先释放,所以出现了死锁。
死锁的解决方式:
1、避免多次锁定:尽量避免在同一个线程对多个同步监视器进行锁定。比如上面的程序,主线程要对A,B对象进行锁定,副线程也要多A,B对象锁定。
2、具有相同的加锁顺序:如果多个线程需要对同步监视器进行锁定,则应该保证他们以相同的顺序请求加锁。比如上面程序,主线程先对A对象加锁,在B对象加锁;副线程先对B对象加锁,再对A对象加锁。如果让主副线程以相同的顺序加锁,就可以避免死锁问题。
3、使用定时锁:程序调用Lock对象的tryLock()方法加锁时可指定time,unit参数,当超过指定时间后会自动释放对Lock的锁定,这样就可以解开死锁。
4、死锁监测:这是一种依靠算法来实现大的死锁预防机制,它主要针对那些不可能实现按序加锁,也不能使用定时锁的场景。