Java 线程安全问题及线程锁(读书笔记)

多线程安全问题:

首先整理多线程同步的知识点,开头肯定是要先探讨探讨多线程安全的问题。那么嘛叫线程安全问题呢?
答: 我们知道Jvm虚拟机的设计中线程的执行是抢占式的,线程的执行时间是由底层系统决定的。所以就会有多个线程修改同一个数据时不同步问题。就叫多线程安全问题。

我们用一个例子来实际探讨一下这种情况!比如现在我们有一个银行账号,里面有1000元钱。然后有两个用户要取钱,我们启动两个线程去访问去取钱,每个取800。 按照逻辑来说的话,应该是第一个取出来800,第二个取钱的时候提示余额不足。

下面我们来看一下代码,首先是Account类:

package com.example.thread;
/** * auther: Simon zhang * Emaill:18292967668@163.com */
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 account= (Account) obj;
            return account.getAccountNO().equals(accountNO);
        }
        return false;
    }
}

然后是取钱线程:

package com.example.thread;

/** * auther: Simon zhang * Emaill:18292967668@163.com */
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;
    }

    @Override
    public void run() {
        if(account.getBalance()>=drawAmount){
            System.out.println(getName()+"取钱成功! 取出="+drawAmount);
         /* try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); }*/
            //修改金额
            account.setBalance( (account.getBalance()-drawAmount));
            System.out.println("\t 余额为:"+account.getBalance());
        }else{
            System.out.println(getName()+"取钱失败,账号余额不足!");
        }
    }
}

最后是测试代码:

public class Test {
    public static void main(String args[]) throws Exception {
            Account account=new Account("123456",1000);
            DrawThread a = new DrawThread("A", account, 800);
            a.start();
            DrawThread b =  new DrawThread("B",account,800);
            b.start();
    }
}

多运行几次,看一下控制台的输出。竟然出现一个让人大跌眼镜的结果。

 A取钱成功! 取出=800.0 B取钱成功! 取出=800.0 余额为:-600.0 余额为:200.0

这尼玛什么情况,怎么能取出两个800呢。这银行还不得赔死呀。 ok. 说回正题,我们来分析一下这种结果是怎么产生的,假设第一个线程执行完成 account.getBalance()>=drawAmount 这行代码后,cpu将它停止,切换到第二线程去执行account.getBalance()>=drawAmount。这个时候内存中的account还是1000, 因为第一个线程没有执行完成account.setBalance( (account.getBalance()-drawAmount))去修改account值。所以第二个线程继续还是进入了取钱的代码块。这个时候其实逻辑上已经出错了,所以当前两个线程都减去800的时候就会出现负值。这就是多线程的安全问题。

既然发现了这个问题,我们就应该要面对它,解决它。那怎么解决呢,其实我们只要想想如果有一种方式可以保证一个线程进入run方法后,直到它修改完account退出。其他线程都不能进入run不就可以了。Java语言的设计者也是这么想的,而且他们想了很多的方式来解决这个问题。

  • 同步代码块
  • 同步方法
  • 同步锁

下面我们一个一个来梳理一下它们!

同步代码块

语法格式:

 synchronized(obj){
   ...
   //此处代码就是同步代码
 }  

上面的语法格式中的synchronized后括号里面的就是同步监视器,上面代码的含义是:线程开始执行同步代码块之前,必须获得对同步监视器的锁定。
那么使用同步代码块的话,我们可以把取钱的代码修改成如下格式!

package com.example.thread;

/** * auther: Simon zhang * Emaill:18292967668@163.com * 同步代码块 */
public class SyncCodeDrawThread extends Thread{

    //取钱的用户
    private Account account;

    //当前线程所希望取的钱
    private double drawAmount;

    public SyncCodeDrawThread(String name, Account account, double drawAmount){
        super(name);
        this.account=account;
        this.drawAmount=drawAmount;
    }

    @Override
    public void run() {
        synchronized (account){
            if(account.getBalance()>=drawAmount){
                System.out.println(getName()+"取钱成功! 取出="+drawAmount);
             /* try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }*/
                //修改金额
                account.setBalance(account.getBalance()-drawAmount);
                System.out.println("\t 余额为:"+account.getBalance());
            }else{
                System.out.println(getName()+"取钱失败,账号余额不足!");
            }
        }
// System.out.println("测试同步代码块后面的代码能否执行!");
    }
}

多次运行后,代码都是正确执行的。 说明我们做对了。庆祝一下!

同步代码块使用时的注意事项:
1.我们一定要保证使用的同步监视器对象对于调用这个方法的线程是唯一的。怎么理解这句话呢?
比如上面的代码account对象对于两个取钱线程就是唯一的,因为它是同一个对象。 如果我们把synchronized (account)改成synchronized (this),多次运行代码后,我们发现还是得到的错误的结果。因为this对于的是取钱线程本身,每个线程都不一样。 其实我们可以把同步监视器理解成一个门,要锁定的代码理解成门后的东西。我们要禁止其他人通过门去访问门后的东西,那就要把门锁住,而且要保证只有一个门,要不然别人从别的门中直接就去访问了。 是不是呢!!

2.任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对同步监视器的锁定。

3.有时候我们会在一个方法中,用同步代码块只锁定住一段修改资源的代码,而在同步代码块之后还有一些其他代码。那么我们有线程锁定同步代码块后,其他线程可以执行同步代码块后面的东西吗?

答案是:不行,亲测!

    @Override
    public void run() {
        synchronized (account){
            if(account.getBalance()>=drawAmount){
                System.out.println(getName()+"取钱成功! 取出="+drawAmount);
             /* try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }*/
                //修改金额
                account.setBalance(account.getBalance()-drawAmount);
                System.out.println("\t 余额为:"+account.getBalance());
            }else{
                System.out.println(getName()+"取钱失败,账号余额不足!");
            }
        }
        System.out.println(getName()+"测试同步代码块后面的代码能否执行!");
    }

同步方法:

同样的我们也可以使用同步方法来限定多个线程访问同一个资源的问题,同步方法就是使用synchronized关键字来修饰某一个方法,则该方法称为同步方法。对于同步方法而言,无需显示的指定同步监视器,同步方法的同步监视器就是this,也就是该对象本身。
那如果用同步方法,我们要怎么上面的取钱修改代码呢?
先修改Account类,为它添加取钱的方法draw.

public synchronized  void  draw(String name,double drawAmount){
        if(balance>=drawAmount){
            System.out.println(name+"取钱成功! 取出="+drawAmount);
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //修改金额
            balance=balance-drawAmount;
            System.out.println("\t 余额为:"+balance);
        }else{
            System.out.println(name+"取钱失败,账号余额不足!");
        }
}

再修改Thread,在run方法中调用account的run方法去取钱!

  @Override
    public void run() {
        account.draw(getName(),drawAmount);
    }

为什么要这么修改呢,还是老话题,我们要保证同步锁对象对于两个调用的线程是唯一的,而现在同步方法的同步锁对象默认是this对象。那只有account对象对于这两线程是唯一的。所以通过给account添加一个同步方法draw来同步限定。

同步方法使用时的注意事项:
1.实例方法默认的同步锁对象是this,而静态方面默认的同步锁对象是类对象。
2.同步方法默认锁定的是所有添加了相关同步锁的对象。怎么理解这句话呢?
比如下面account对象有两个方法draw,otherDraw都是实例方法,默认同步锁是this。 那么如果有一个线程锁定了draw方法,那么其他线程也就不能访问otherDraw方法了。需要等地一个线程释放了锁后,才能访问。

  /** * 锁如果没有被释放,那其他地方就不能调用这个锁锁定的代码 */
    public synchronized  void  draw(String name,double drawAmount){
            if(balance>=drawAmount){
                System.out.println(name+"取钱成功! 取出="+drawAmount);
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //修改金额
                balance=balance-drawAmount;
                System.out.println("\t 余额为:"+balance);
            }else{
                System.out.println(name+"取钱失败,账号余额不足!");
            }
    }

    /** * 如果有其他的同步锁没有被释放,那就不能访问这个方法 */
    public synchronized void otherDraw(String name){
        System.out.println("----adraw-----");
    }

同步锁(Lock)

Java1.5开始,Java提供了一种功能更强大的线程同步机制—-通过显示定义同步锁对象来实现同步,在这种机制下,同步锁使用Lock对象充当。
Lock有很多种类,某些锁可能允许对共享资源并发访问,如ReadWriteLock(读写锁),Lock和ReadWriteLock是java 5新提供的两个根接口,并为Lock提供了ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。

我们使用ReentrantLock来修改取钱的代码,添加同步的限制:

      private  final ReentrantLock look=new ReentrantLock();

      public  void  draw(String name,double drawAmount){
         //加锁
          look.lock();
         try {
            if(balance>=drawAmount){
                System.out.println(name+"取钱成功! 取出="+drawAmount);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //修改金额
                balance=balance-drawAmount;
                System.out.println("\t 余额为:"+balance);
            }else{
                System.out.println(name+"取钱失败,账号余额不足!");
            }
        }finally {
            //释放锁
            look.unlock();
        }
    }

上面程序中的第一行代码定义了一个ReentrantLock对象,程序中实现draw()方法时,进入方法执行后立即请求对ReentrantLock对象进行加锁,当执行完draw()方法的取钱逻辑之后,程序使用finally块来确保锁的释放。

注意: 同步方法或同步代码块使用与竞争资源相关的,隐式的同步监视器,并且强制要求加锁和释放锁要出现在一个块结构中,而且当获取到多个锁时,它们必须以相反的顺序释放,且必须在与所有锁被释放时相同的范围内释放所有锁。

同步锁(Lock)使用的注意事项:
1. Lock提供了同步方法和同步代码块所没有的其他功能,包括用于非块结构的tryLock()方法,以及试图获取可中断锁的lockInterruptibly方法,还有获取超时失效锁tryLock(long,TimeUnit); 方法。
2. ReentrantLock锁具有可重入性,也是说,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套调用,线程在每次调用lock()加锁后,必须显示调用unlock()释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。

释放线程锁的锁定

注:这里说的线程锁的锁定,只指的是同步代码块和同步方法,Lock需要显示释放锁定所以不在讨论范围之内。
任何线程在进入同步代码块,同步方法之前,必须先获得对同步代码块的锁定,那么何时会释放同步监视器的锁定呢?

程序无法显示的释放同步监视器的锁定。线程会在如下几种情况下释放对同步监视器的锁定:

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

在如下情况下,线程不会释放同步锁:

  • 线程执行同步代码块或同步方法时,程序调用了Thread.sleep(),Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
  • 程序执行同步代码块时,其他线程执行了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。

    死锁

    最后讨论一下这个牛逼的概念以及它是怎么产生的。死锁是个啥?当两个线程相互等待对方释放同步监视器时就会发生死锁,Java虚拟机没有检测,也没有采取措施处理死锁这种情况,所以多线程编程时应该采取措施避免死锁出现。一旦出现死锁,整个程序即不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
    下面用代码来实现一下死锁,哈哈!

package com.example.thread;

/** * auther: Simon zhang * Emaill:18292967668@163.com */

public class DeadLock implements Runnable {

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

    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[]) throws Exception {
       DeadLock dl=new DeadLock();
       new Thread(dl).start();
        dl.init();
    }
}

运行上面的程序:

当前线程名:主线程进入了A实例的foo方法
当前线程名:子线程进入了B实例的bar方法
当前线程名:主线程企图调用B实例的last方法
当前线程名:子线程企图调用A实例的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方法,进入bar方法之前,该线程对B对象加锁—–当程序执行到②号代码的时候,子线程也暂停200ms;接下来主线会先醒过来,继续向下执行,直到③号代码处希望调用B对象的last方法—-执行前必须对B对象进行加锁,但此时子线程正保持着对B对象的锁,所以主线程阻塞;接下来子线程也醒过来了,继续向下执行,直到④号代码处希望调用A对象的last()方法—–执行该方法之前必须对A对象进行加锁,但此时主线程没有释放A对象的锁。 到这里就出现主线程保持A对象的锁,等待对B对象加锁,而子线程保持着B对象的锁,等待对A对象加锁,两个线程互相等待对方先释放,所以就出现了死锁。

哈哈哈,,,真尼玛 累死。。

ok. 到这里,线程安全问题和线程锁的分析就告一段落了。。。 不容易呀!!!!

    原文作者:java锁
    原文地址: https://blog.csdn.net/nightcurtis/article/details/78532818
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞