第十六章Java多线程-16.5线程同步

16.5线程同步

 16.5.1 线程安全问题

 模拟2个人同时取钱

账号类

package com.cdmt.collection.list;

public class Account {
    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 balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    @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(accountNo);
        }
        return false;
    }
}

取钱线程类

package com.cdmt.collection.list;

public class DrawThread extends Thread {
    //模拟用户账户
    private  Account account;
    //当前取钱线程所希望取得钱数
    private  double drawAccount;
    public DrawThread(String name, Account account,double drawAccount){
        super(name);
        this.account = account;
        this.drawAccount = drawAccount;
    }
    //当多个线程修改同一个共享数据时,将设计数据安全问题

    @Override
    public void run() {
        //账户余额大于取钱数目
        if(account.getBalance() >= drawAccount)
        {
            System.out.println(getName() + "取钱成功!突出钞票: " + drawAccount);
            try{
                Thread.sleep(1);
            }
            catch (InterruptedException ex)
            {
                ex.printStackTrace();
            }

            //修改余额
            account.setBalance(account.getBalance() - drawAccount);
            System.out.println("余额为: " + account.getBalance());

        }else
        {
            System.out.println(getName() + "取钱失败!余额不足");
        }
    }
}

测试类:

package com.cdmt.collection.list;

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();
    }
}

结果:

第十六章Java多线程-16.5线程同步_第1张图片

总结:

2个线程都跳过了账号大于0的条件,但是实际结果与真实情况矛盾。问题在于没有同步资源。run方法的方法体不具有同步线程安全性。一个程序中存在2个并发线程在修改Account对象:系统恰好在粗体字代码处执行线程切换,切换给另外一个修改Account对象的线程。

16.5.2 同步代码块

 为了解决线程同步问题,Java的多线程引入了同步监视器。使用同步监视器的通用方法就是同步代码块:

synchronized(obj)
{
    ...
    //todo 同步代码块
}

在synchronized后括号里的obj就是同步监视器,上面代码含义线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。

PS:任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完毕后,该线程会释放对该同步监视器的锁定。所以一般是对共享资源进行同步监视器。

package com.cdmt.collection.list;

public class DrawThread extends Thread {
    //模拟用户账户
    private  Account account;
    //当前取钱线程所希望取得钱数
    private  double drawAccount;
    public DrawThread(String name, Account account,double drawAccount){
        super(name);
        this.account = account;
        this.drawAccount = drawAccount;
    }
    //当多个线程修改同一个共享数据时,将设计数据安全问题

    @Override
    public void run() {
        //使用account作为同步监视器,任何线程进入下面同步代码块之前
        //必须先获得对account账户的锁定
        //这种做法符合:枷锁-修改-释放锁的逻辑
        synchronized (account){
            //账户余额大于取钱数目
            if(account.getBalance() >= drawAccount)
            {
                System.out.println(getName() + "取钱成功!突出钞票: " + drawAccount);
                try{
                    Thread.sleep(1);
                }
                catch (InterruptedException ex)
                {
                    ex.printStackTrace();
                }

                //修改余额
                account.setBalance(account.getBalance() - drawAccount);
                System.out.println("余额为: " + account.getBalance());

            }else
            {
                System.out.println(getName() + "取钱失败!余额不足");
            }
        }
    }
}

结果:

第十六章Java多线程-16.5线程同步_第2张图片

总结:

使用synchronized将run方法修改成同步代码块,同步监视器对account对象的监视。通过这种方式就可以保证并发线程在任何时刻只有一个线程可以进入修改共享资源的代码块(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证了线程的安全性。

16.5.3 同步方法

 Java多线程安全还支持同步方法。对于synchronized修饰的实力方法(非static方法)而言,无需显示指定同步监视器,同步方法的同步监视器是this,也就是调用该方法的对象

通过使用同步方法可以非常方便的实现线程安全类,线程安全类具有以下特征:

  1. 该类的对象可以被多个线程安全地访问。
  2. 每个线程调用该对象的任意方法之后都将得到正确结果
  3. 每个线程调用该对象的任意方法之后,该兑现状态依然保持合理状态

前面介绍了可变类和不可变类,不可变类总是线程安全的,因为它的对象状态不可变;但是可变类对象需要额外的方法来保证其线程安全。例如上面Account类时可变类,只需要把修改balance的方法变成同步方法即可

账号类

package com.cdmt.collection.list;

public class Account {
    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 balance;
    }

    //提供一个线程安全的draw()方法来完成取钱操作
    public synchronized void draw(double drwaAmount) {
        if(balance >= drwaAmount){
            System.out.println(Thread.currentThread().getName() + "取钱成功!突出钞票: " + drwaAmount);
            try{
                Thread.sleep(1);
            }
            catch (InterruptedException ex)
            {
                ex.printStackTrace();
            }

            //修改余额
            balance -= drwaAmount;
            System.out.println("余额为: " + balance);
        }
        else
        {
            System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足");
        }
    }

    @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(accountNo);
        }
        return false;
    }
}

 线程类:

package com.cdmt.collection.list;

public class DrawThread extends Thread {
    //模拟用户账户
    private  Account account;
    //当前取钱线程所希望取得钱数
    private  double drawAccount;
    public DrawThread(String name, Account account,double drawAccount){
        super(name);
        this.account = account;
        this.drawAccount = drawAccount;
    }
    //当多个线程修改同一个共享数据时,将设计数据安全问题
    @Override
    public void run() {
        //使用account作为同步监视器,任何线程进入下面同步代码块之前
        //必须先获得对account账户的锁定
        //这种做法符合:枷锁-修改-释放锁的逻辑
        account.draw(drawAccount);
    }
}

结果:

第十六章Java多线程-16.5线程同步_第3张图片

总结:

上面的DrwaThread类无需自己实现取钱操作,直接调用account的draw方法执行操作。由于已经使用synchronized关键字修饰draw方法,同步方法的同步监视器是this,而this总代表调用该方法的对象。调用draw方法的对象是account,因此多个线程并发修改同一份account之前,必须先对account对象枷锁。

PS:在Account的draw方法,而不是直接在run方法中实现取钱逻辑,这种做法更符合面向对象规则。在棉线搞对象里有一种流行的设计方式:Domain Driven Design(领域驱动设计,DDD),这种方式认为每个类都应该是完备的领域对象,例如Account代表用户账户,应该提供用户账户的相关方法;通过draw方法来执行取钱操作,而不是直接将setBalance方法暴露出来让人操作,这样才可以更好的保证Account兑现的完整性和一致性。

可变类的线程安全是以降低程序的运行效率作为代价,为了减少线程安全所带来的负面影响,程序可以采用如下策略:

不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源的方法进行同步。

如果可变类有两种运行环境:单线程和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本。

PS:JDK所提供的StringBuilder、StringBuffer就是为了照顾单线程环境和多线程环境提供的类,在单线程环境下应该使用StrtingBuidler来保证较好的性能;当需要保证多线程安全时,就应该使用StringBuffer。

16.5.4 释放同步监视器的锁定

任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时回释放对同步监视器的锁定呢?程序无法显示释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定:

  1. 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器
  2. 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器。
  3. 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时,当前线程将会释放同步监视器
  4. 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait方法,则当前线程暂停,并释放同步监视器

如下几种情况,线程不会释放同步监视器:

线程执行同步代码块或同步方法时,程序调用Thread.sleep、Thread.yeild方法来暂停当前线程的执行,当前线程不会释放同步监视器

线程执行同步代码块时,其他线程调用了该线程的suspend方法将线程挂起,该线程不会释放同步监视器。当然,程序应该尽量避免使用suspend和resume方法来控制线程

16.5.5 同步锁(Lock)

 Java5开始,提供了一种功能强大的线程同步机制----通过显示定义同步锁对象来实现同步。同步锁由Lock对象充当。

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

某些锁可能允许对共享资源并发访问,ReadWriteLock、Lock是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类改为如下形式,它依然是线程安全的

package com.cdmt.collection.list;

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 balance;
    }

    //提供一个线程安全的draw()方法来完成取钱操作
    public void draw(double drwaAmount) {
        //加锁
        lock.lock();
        try{
            if(balance >= drwaAmount) {
                System.out.println(Thread.currentThread().getName() + "取钱成功!突出钞票: " + drwaAmount);
                try {
                    Thread.sleep(1);
                }
                catch (InterruptedException ex) {
                    ex.printStackTrace();
                }

                //修改余额
                balance -= drwaAmount;
                System.out.println("余额为: " + balance);
            }
            else
            {
                System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足");
            }
        }
        finally {
            //修改完成,释放锁
            lock.unlock();
        }


    }

    @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(accountNo);
        }
        return false;
    }
}

使用Lock与使用同步方法有点相似,只是使用Lock显示式使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器,同样都符合加锁-修改-释放锁的操作模式,而且使用Lock对象时每个Lock对象对应一个Account对象,一样可以保证对于同一个Account对象,同一时间只能有一个线程能进入临界区。

PS:ReentrantLock锁具有可重入性,也就是说,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock方法的嵌套调用,线程每次调用lock加锁后,必须显示调用unlock来释放锁,所以一段被锁保护的代码可以调用另外一个呗仙童锁保护的方法。 

16.5.6 死锁

当两个线程互相等待对方释放同步监视器的时候就会发生死锁。Java虚拟机没有检测,也没有采取措施来处理死锁的情况,所以多线程变成时应该采取措施避免死锁出现。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

PS:死锁是很容易出现的,尤其在系统中出现多个同步监视器的请款下,如下:

package com.cdmt.collection.list;

public 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方法内部");
    }
}

class DeadLock implements Runnable{
    A a = new A();
    B b = new B();

    public void init()
    {
        Thread.currentThread().setName("主线程");
        a.foo(b);
        System.out.println("进入了主线程之后");
    }

    @Override
    public void run() {
        Thread.currentThread().setName("副线程");
        b.bar(a);
        System.out.println("进入了主线程之后");
    }

    public static void main(String[] args) {
        DeadLock dl = new DeadLock();
        new Thread(dl).start();
        dl.init();
    }
}

 结果:

第十六章Java多线程-16.5线程同步_第4张图片

总结:

  1. 程序进入主线程,执行init方法打印第一句,此时A对象枷锁,之后主线程休眠。
  2. 执行子线程打印第二句,此时B对象枷锁,子线程休眠。
  3. 主线程应该先苏醒。然后主线程希望调用B兑现的last方法,之前该方法之前必须对B对象枷锁,由于B已经枷锁,所以主线程阻塞
  4. 子线程苏醒,希望调用A的last方法,必须对A枷锁,此时A对象已经枷锁,所以子流程阻塞。

 线程互相等待对方释放,互相等待,所以出现了死锁

你可能感兴趣的:(Java基础)