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