Java 线程同步

线程安全问题

关于线程安全问题,有一个经典的问题——银行取钱的问题。银行取钱的基本流程基本上能够分为以下几个步骤。java

  1. 用户输入帐户、密码,系统判断用户的帐户、密码是否匹配。
  2. 用户输入取款金额。
  3. 系统判断帐户余额是否大于取款金额。
  4. 若是余额大于取款金额,则取款成功;若是余额小于取款金额,则取款失败。

乍一看上去,这个流程确实就是平常生活中的取款流程,这个流程没有任何问题。但一旦将这个流程放在多线程并发的场景下,就有可能出现问题。注意此处说的是有可能,并非说必定。也许你的程序运行了一百万次都没有出现问题,但没有出现问题并不等于没有问题!编程

按上面的流程去编写取款程序,并使用两个线程来模拟取钱操做,模拟两我的使用同一个帐户并发取钱的问题。此处忽略检查帐户和密码的操做,仅仅模拟后面三步操做。下面先定义一个帐户类,该帐户类封装了帐户编号和余额两个实例变量。安全

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次出现了错误,那就是编程错误引发的。ui

同步代码块

之因此出现如上图所示的结果,是由于 run() 方法的方法体不具备同步安全性——程序中有两个并发线程在修改 Account 对象;并且系统刚好在粗体字代码处执行线程切换,切换给另外一个修改 Account 对象的线程,因此就出现了问题。this

提示:就像前面介绍的文件并发访问,当有两个进程并发修改同一个文件时就有可能形成异常。spa

为了解决这个问题, 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 再也不推荐使用该方法来暂停线程的执行。

相关文章
相关标签/搜索