多线程编程是有趣的事情,它很容易突然出现“错误情况”,这是由系统的线程调度具有一定的随机性造成的,不过即使程序偶然出现问题,那也是由于编程不当引起的。当使用多个线程来访问同一个数据时,很容易“偶然”出现线程安全问题。
一、线程安全问题
关于线程安全问题,有一个很经典的问题——银行取钱的问题。银行取钱的流程基本上可以分为如下几个步骤。
①用户输入账户、密码,系统判别用户的账户、密码是否匹配
②用户输入取款金额
③系统判断账户余额是否大于取款金额
④如果金额大于取款金额,则取款成功;如果金额小于取款金额,则取款失败。
乍一看上去,这个流程确实就是日常生活中的取款流程,这个流程没有任何问题。但一旦将这个流程放在多线程并发的场景下,就有可能出现问题。
按照上面的流程去编写取款程序,并使用两个线程来模拟取钱操作,模拟两个人使用同一个账户并发取钱的问题,此处忽略检查账户和密码的操作,仅仅模拟后面三步操作。下面先定义一个账户类,该账户类封装了账户编号和余额两个实例变量。
public class Account {
//封装账户编号、账户余额的两个成员变量
private String accountNo;
private double balance;
//构造器
public Account(String accountNo , double balance)
{
this.balance = balance;
this.accountNo = accountNo;
}
public 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;
}
//下面两个方法根据accountNo来重写hashCode()和equals()方法
public int hashCode()
{
return accountNo.hashCode();
}
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(accountNo);
}
return false;
}
}
接下来提供一个取钱的线程类,该线程类根据执行账户、取钱数量进行取钱操作,取钱的逻辑是当其余额不足时无法提取现金,当其余额足够时系统吐出钞票,余额减少。
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);
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 class DrawTest {
public static void main(String[] args) {
//创建一个账户
Account acct = new Account("1234567",1000);
//模拟两个线程对同一个账户取钱
new DrawThread("甲" , acct , 800).start();
new DrawThread("乙" , acct , 800).start();
}
}
运行上面程序,很有可能看到如图所示的错误结果。
甲取钱成功!吐出钞票:800.0
乙取钱成功!吐出钞票:800.0
余额为:200.0
余额为:-600.0
这正是多线程编程突然出现的“偶然”错误——因为线程调度的不确定性。
账户余额只有1000时取出了1600,而且账户余额出现了负值,这不是银行希望的结果。虽然上面程序是人为地使用Thread.sleep(1)来强制线程调度切换,但这种切换也是完全可能发生的。
二、同步代码块
之所以出现上面的结果,是因为run()方法的方法体不具有同步安全性——程序中有两个并发线程在修改Account对象;而且系统切好在Thread.sleep(1)时执行线程切换,切换给另一个修改Account对象的线程,所以就出现了问题。
提示:
就像前面介绍的文件并发访问,当有两个进程并发修改同一个文件就可能造成异常。
为了解决这个问题,Java的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块。同步代码块的语法格式如下:
synchronized(obj)
{
...
//此处的代码就是同步代码块
}
上面语法格式中synchronized后括号里的obj就是同步监视器,上面的代码的含义是:线程开始执行同步代码块之前,必须要获得对同步监视器的锁定。
注意:任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程将就会释放对同步监视器的锁定。
虽然Java程序允许使用任何对象作为同步监视器,但想一下同步监视器的目的:阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器。
对于取钱模拟程序,应该考虑使用账户(account)作为同步监视器,把程序修改成如下形式。
package test;
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);
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() + "取钱失败,余额不足!");
}
}
}
}
运行结果:
甲取钱成功!吐出钞票:800.0
余额为:200.0
乙取钱失败,余额不足!
上面程序使用synchronized将run()方法里的方法体修改成同步代码块,该同步代码可的同步监视器是account对象,这样的做法符合“加锁 → 修改 → 释放锁”的逻辑,任何线程在修改指定资源之前,首先对该资源加锁,在加锁其间其他线程无法修改该资源,当该线程修改完成后,该线程释放对该资源的锁定。
通过这种方式可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证线程的安全。
三、同步方法
与同步代码块对应,Java的多线程安全支持还提供了同步方法,同步方法就是使用synchronized关键字来修饰某个方法,则该方法称为同步方法。
对于synchronized修饰的实例方法(非static方法)而言,无须显式指定同步监视器,同步方法的同步监视器就是this,也就是调用该方法的对象。
使用同步方法可以非常方便地实现线程安全的类,线程安全的类具有如下特征。
- 该类的对象可以被多个线程安全地访问。
- 每个线程调用该对象的任意方法之后都将得到正确结果。
- 每个线程调用该对象的任意方法之后,该对象依然保持合理的状态。
前面介绍了可变类和不可变类,其中不可变类总是线程安全的,因为它的对象状态不可改变类但可变对象需要额外的方法来保证其线程安全。
例如上面的Account就是一个可变类,它的accountNo和balance两个成员变量都可以被改变,当两个线程同时修改Account对象的balance成员变量的值时,程序就出现了异常。
下面将Account类对balance的访问设置成线程安全的,那么只要把修改balance的方法变成同步方法即可。
程序如下所示。
public class Account {
//封装账户编号、账户余额的两个成员变量
private String accountNo;
private double balance;
//构造器
public Account(String accountNo , double balance)
{
this.balance = balance;
this.accountNo = accountNo;
}
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因为账户余额不允许随便修改,所以只为balance方法提供getter方法
public double getBalance() {
return balance;
}
//提供一个线程安全的draw()方法来完成取钱操作
public synchronized void draw(double drawAmount)
{
//账户余额大于取钱数目
if(balance >= drawAmount)
{
//吐出钞票
System.out.println(Thread.currentThread().getName() + "取钱成功!吐出超片:" + drawAmount);
//修改余额
balance -= drawAmount;
System.out.println("余额为:" + balance);
}
else
{
System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
}
}
//下面两个方法根据accountNo来重写hashCode()和equals()方法
public int hashCode()
{
return accountNo.hashCode();
}
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(accountNo);
}
return false;
}
}
上面程序中增加了一个代表取钱操的draw()方法,并使用了synchronized关键字修饰该方法,把该方法变成同步方法,该同步方法的监视器为this,因此对于同一个Account账户而言,任意时刻只能有一个线程获得Account对象的锁定,然后进入draw()方法执行取钱操作——这样也可以保证多个线程并发取钱的线程安全。
因为Account类中已经提供了draw()方法,而且取消了setBalacne()方法,DrawThread线程类需要改写,该线程类的run()方法只要调用Account对象的draw()方法即可执行取钱操作。
run()方法代码片如下:
//当多个线程修改同一个共享数据时,将涉及数据安全问题
public void run()
{
//直接调用account对象的draw()方法来执行取钱操作
//同步方法的同步监视器是this,this代表调用draw()方法的对象
//也就是说,线程进入draw()方法之前,必须先对account对象加锁
account.draw(drawAmount);
}
注意:
synchronized关键字可以修饰方法,可以修饰代码块,DansGuardian不能修饰构造器、成员变量等。
上面的DrawThread类无须自己实现取钱操作,而是直接调用account的draw()方法来执行取钱操作。由于已经使用了synchronized关键字修饰了draw()方法执行取钱操作——这样也可以保证多个线程并发取钱的线程安全。
提示:
在Account里定义draw()方法,而不是直接在run()方法里实现取钱逻辑,这种做法更符合面向对象规则。在面向对象里有一种流行的设计方式:Domain Driven Design(领域驱动设计,DDD)。这种方式认为每个类都应该是完备的领域对象,例如Account代表用户账户,应该提高用户账户的相关方法;通过draw()方法来执行取钱操作(实际上还应该提高transfer()等方法来完成转账等操作),而不是直接将setBalacne()方法暴露出来任人操作。这样才可以更好地保证Account对象的完整性和一致性。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序采用如下策略。
- 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步。例如上面Account类中的accountNo实例变量就无须同步。
- 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。
四、释放同步监视器的锁定
任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时回释放对同步监视器的锁定呢?
程序无法显式释放对同步监视器的锁定,线程会再如下几种情况下释放对同步监视器的锁定。
- 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器
- 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器
- 当前线程在同步代码块、同步方法中出现了未处理的Error和Exception,导致该代码块、该方法异常结束,当前线程会释放同步监视器。
- 当前线程执行同步代码块或同步方法时,程序执行了同步监视器的wait()方法,则当前线程会释放同步监视器。
在如下所示的情况下,线程不会释放同步监视器。
- 线程执行同步代码块或同步方法时,程度调用了Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器
- 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。
五、同步锁(Lock)
Java 5开始,Java提供了一种功能更强大的线程同步机制——通过显式定义同步锁对象来实现同步。在这种机制下,同步锁通过Lock对象充当。
Lock提供了比synchronized方法和synchronized代码块更广泛的锁定操作,Lock允许实现更灵活的结构,可以具有差别很大的属性,并且支持多个相关的Condition对象。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
某些锁可能允许对共享资源并发访问,如ReadWriteLock(读写锁),Lock、ReadWriteLock是Java 5提供了两个根接口,并为Lock提供了ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。
**Java 8提供了新型的StampedLock类,在大多数场景中它可以替代传统的ReentrantReadWriteLock。**ReentrantReadWriteLock为读写锁提供了三种锁模式:Writing、ReadingOptimistic、Reading。
在实现线程安全的控制中,比较常用的是ReentrantLock(可重入锁)。使用该Lock对象可显式地加锁、释放锁,通常使用ReentrantLock的代码格式如下:
class X
{
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
//...
//定义需要保证线程安全的方法
public void m()
{
//加锁
lock.lock()
try
{
//需要保证线程安全的代码
//...method body
}
//使用finaly块来保证释放锁
finally
{
lock.unlock();
}
}
}
使用ReentrantLock对象来进行同步的,加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要时释放锁。通过使用ReentrantLock对象,可以把Account类改为如下形式。它依然是线程安全的。
import java.util.concurrent.locks.ReentrantLock;
public class Account {
//定义锁对于
private final ReentrantLock lock = new ReentrantLock();
//封装账户编号、账户余额的两个成员变量
private String accountNo;
private double balance;
//构造器
public Account(String accountNo , double balance)
{
this.balance = balance;
this.accountNo = accountNo;
}
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因为账户余额不允许随便修改,所以只为balance方法提供getter方法
public double getBalance() {
return balance;
}
//提供一个线程安全的draw()方法来完成取钱操作
public void draw(double drawAmount)
{
lock.lock();
try
{
//账户余额大于取钱数目
if(balance >= drawAmount)
{
//吐出钞票
System.out.println(Thread.currentThread().getName() + "取钱成功!吐出超片:" + drawAmount);
//修改余额
balance -= drawAmount;
System.out.println("余额为:" + balance);
}
else
{
System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
}
}
finally
{
//修改完成,释放锁
lock.unlock();
}
}
//下面两个方法根据accountNo来重写hashCode()和equals()方法
public int hashCode()
{
return accountNo.hashCode();
}
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(accountNo);
}
return false;
}
}
定义了yigeReentrantLock对象,程序实现draw()方法时,进入方法开始执行后立即请求对ReentrantLock对象进行加锁,当执行完draw()方法的取钱逻辑后,程序使用finally块来确保释放锁。
提示:使用Lock方法与使用同步方法有点相似,只是使用Lock时显式使用Lock对象作为同步锁,而使用同步方法系统隐式使用当前对象作为同步监视器,同样都符合“加锁 → 修改 → 释放锁”的操作模式,而且使用Lock对象对应一个Account对象,一样可以保证对于同一个Account对象,同一时刻只有一个线程能进入临界区。
同步方法或同步代码块使用与竞争资源相关的、隐式的同步监视器,并且强制要求加锁和释放锁要出现在同一个块结构中,而且当获取多个锁时,它们必须以相反的顺序释放,且必须在与所有锁被获取时相同的范围内释放所有锁。
**虽然同步方法和同步代码块的范围机制使得多线程安全编程非常方便,而且可以避免很多涉及锁的常见编程错误,但有时也需要更为灵活的方式使用锁。**Lock提供了同步方法和同步代码块所没有的其他功能,包括用于非块结构的tryLock()方法,以及试图获取可中断锁的lockInterruptibly()方法,还有获取超时失效锁的tryLock(long ,TimeUnit)方法。
ReentrantLock锁具有可重入性,也就是说,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套调用,线程再每次调用lock()加锁后,必须显式调用unlock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。
六、线程死锁
当两个线程互相等待对方释放同步监视器时就会发生死锁,Java虚拟机没有检测,也没有采取措施来处理死锁情况,所以多线程编程时应该采取措施避免死锁情况出现。一旦出现死锁,整个程序既不会发生任何一次,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
死锁是很容易发生的,尤其是在系统中出现多个同步监视器的情况下,如下程序将会出现死锁。
class A
{
public synchronized void foo(B b)
{
System.out.println("当前线程名:" + Thread.currentThread().getName() + "进入了A的实例的foo()方法" );//①
try{
Thread.sleep(200);
}
catch(InterruptedException e)
{
e.printStackTrace();
}
System.out.println("当前线程名:" + Thread.currentThread().getName() + "企图调用B实例的last()方法");
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()方法");//②
try
{
Thread.sleep(200);
}
catch(InterruptedException e)
{
e.printStackTrace();
}
System.out.println("当前线程名:" + Thread.currentThread().getName() + "企图调用A实例的last()方法");//④
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(b);
System.out.println("进入主线程之后");
}
public void run()
{
Thread.currentThread().setName("副线程");
b.bar(a);
System.out.println("进入副线程之后");
}
public static void main(String[] args) {
DeadLock dl = new DeadLock();
//以dl位target启动新线程
new Thread(dl).start();
//调用init()方法
dl.init();
}
}
运行上面的程序,出现入下运行结果。
当前线程名:主线程进入了A的实例的foo()方法
当前线程名:副线程进入了B实例的bar()方法
当前线程名:副线程企图调用A实例的last()方法
当前线程名:主线程企图调用B实例的last()方法
程序既无法向下执行,也不会抛出任何问题,就一直“僵持”着。
究其原因,是因为上面程序中A对象和B对象的方法都是同步方法,也就是A对象和B对象都是同步锁。程序中两个线程执行,一个线程是DeadLock类的run()方法,另一个是DeadLock的init()方法(主线程调用了init()方法)。其中run()方法让B对象调用bar()方法,而init()方法让A对象调用foo()方法。
上面运行结果显示init()方法先执行,调用了A对象的foo()方法,进入foo()方法之前,该线程对A对象加锁。程序执行到①号代码时,主线程暂停200ms,CPU切换到执行另一个线程,让B对象执行bar()方法,所以看到副线程开始执行B对象的bar()方法,该线程对B对象加锁——当程序执行到②号代码时,副线程暂停200ms,CPU切换到住线程,此时③号处代码希望调用B对象的last()方法——执行该方法必须对B加锁,但此时副线程正保持对B对象的锁,同理副线程希望对A对象加锁,但主线程保持对A对象的加锁,两个对象互相等待对方先释放,所以就出现了死锁。