Java并发编程实战之互斥锁

Posted c.

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程实战之互斥锁相关的知识,希望对你有一定的参考价值。

文章目录

Java并发编程实战之互斥锁

之前在《Java并发编程实战基础概要》中提到了原子性这一个概念,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。 那么原子性问题到底改如何解决呢?

如何解决原子性问题?

“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。所以本质来说,解决原子性问题,是要保证中间状态对外不可见。(这一点需要细细品一品)

原子性问题的源头是线程切换,多个线程同时操作同一个变量。这样就会出现线程冲突的问题。所以需要一种机制保持在多核CPU下,同一个时刻只有一个线程更改某个共享变量。(其实没有共享变量,也不会存在并发问题),所以说如果我们能够保证对共享变量的修改是互斥的,那么就能保证原子性了。

锁模型

一谈到互斥,我们很自然就会想到了锁。首先我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()

这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。

上面的例子虽然挺形象的,但是容易忽略锁的两个很重要的点,分别是我们锁的是什么?和我们想要保护的又是什么?

  • 第一个问题,锁的到底是什么?我们单纯锁的是门吗?这么理解也没有错,但是想一想,实际上我们想要锁的是对这个厕所的使用,因为你不可能锁上这个厕所的门,去上另一个厕所,这样没任何意义。所以锁跟你想保护的东西是有一个对应关系的。所以对应到编程世界中,锁的其实是对共享变量的访问。
  • 第二个问题,我们想要保护的是什么?我们保护的其实就是我们即将要使用的这个厕所。对应到编程世界中,保护的就是共享变量。

所以对应编程世界中,锁和资源是有一个对应关系的,所以锁的模型如下:

Java synchronized 关键字

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块


class X 
  // 修饰非静态方法
  synchronized void foo() 
    // 临界区
  
  // 修饰静态方法
  synchronized static void bar() 
    // 临界区
  
  // 修饰代码块
  Object obj = new Object()void baz() 
    synchronized(obj) 
      // 临界区
    
  
  

我们可以通过synchronized关键字对我们的临界区进行加锁和解锁。但是我们从代码中并没有看到这个加锁和解锁的动作,这是因为这些操作是由Java编译器为我们加上的。

我们可以利用javap命令来查看生成的字节码文件,就可以看出来Java编译器会为我们synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()

下面通过javap命令查看下面代码生成的字节码文件

public void method() 
     synchronized (this) 
         System.out.println("start");
     
 

字节码文件如下:

图中的monitorenter对应的就是加锁,而monitorexit对应的就是解锁

至于为什么会有两个monitorexit指令呢?

是因为对于synchronized关键字而言,javac在编译时,会生成对应的monitorentermonitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。

参考: 《Java锁synchronized关键字学习系列之重量级锁》

synchronized锁的是代码块还是锁的是对象?从上面我们可以总结出synchronized锁的其实是对象

synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象是什么?

下面的代码我们看到只有 synchronized修饰代码块的时候,锁定了一个obj 对象,那 synchronized修饰方法的时候锁定的是什么呢?


class X 
  // 修饰非静态方法
  synchronized void foo() 
    // 临界区
  
  // 修饰静态方法
  synchronized static void bar() 
    // 临界区
  
  // 修饰代码块
  Object obj = new Object()void baz() 
    synchronized(obj) 
      // 临界区
    
  
  
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X
class X 
  // 修饰静态方法
  synchronized(X.class) static void bar() 
    // 临界区
  

  • 当修饰非静态方法的时候,锁定的是当前实例对象 this
class X 
  // 修饰非静态方法
  synchronized(this) void foo() 
    // 临界区
  

到这里我们引申出另一个问题,当我们锁住了对象的时候,对象身上发生了什么变化,jvm如何知道这个对象被“锁“住了,关于这个题外话这里不多赘述,可以参考:《Java锁synchronized关键字学习系列之CAS和对象头》

Java synchronized 关键字 只能解决原子性问题?

并发会产生三大问题

  1. 原子性问题
  2. 可见性问题
  3. 有序性问题

前面我们一直在说,锁可以解决原子性问题,Java synchronized 关键字只能解决原子性问题吗?

答案肯定是否定的,前面在《Java并发编程实战基础概要》 说到了Java内存模型规范了Java虚拟机(JVM)如何提供按需禁用缓存和编译优化的方法。这些方法包括:volatile、synchronized和final关键字,以及Java内存模型中的Happens-Before规则

所以synchronized关键字还可以解决可见性问题(可以参考Happens-Before的锁定规则:对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作)。但是以synchronized关键字不能完全解决有序性问题,因为synchronized关键字不能避免指令重排,所以我们在之前《Java并发编程实战基础概要》双重检验的单例模式中,必须加volatile来避免因为发生指令重排,返回错误实例。

如何正确使用Java synchronized 关键字?

正确使用Java synchronized 关键字主要是关注在synchronized锁定的对象跟受保护资源的关系。如何理解呢?举一个例子:

class SafeCalc 
  static long value = 0L;
  synchronized long get() 
    return value;
  
  synchronized static void addOne() 
    value += 1;
  

从上面代码我们可以看出来synchronized 关键字锁定的是两个不同的对象,在之前我们讲过,synchronized 关键字修饰非静态方法的时候,锁定的是当前实例对象 this。而当修饰静态方法的时候,锁定的是当前类的 Class 对象。所以我们现在相当于用两个锁保护一个资源(一个共享变量value)。

从上图可以看出来,由于get() 方法和addOne()方法是两把不同的锁,说明执行addOne()方法的过程中可以执行get() 方法,并发性不能得到保证,所以这两临界区并不是互斥的,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

再举一个例子,下面这个例子是否正确使用synchronized 关键字

class SafeCalc 
  long value = 0L;
  long get() 
    synchronized (new Object()) 
      return value;
    
  
  void addOne() 
    synchronized (new Object()) 
      value += 1;
    
  

答案很明显是错误的使用,synchronized 关键字锁定的new object每次在内存中都是新对象,所以每次锁的都不是同一个对象,怎么做到互斥呢?

所以要真正使用好互斥锁,必须深入分析锁定的对象和受保护资源的关系。

锁和受保护资源的合理关联关系

直接给出结论:受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源。

我们来举一个用一把锁来保护多个资源的例子。

class Account 
  // 账户余额  
  private Integer balance;
  // 账户密码
  private String password;

  // 取款
  synchronized void withdraw(Integer amt) 
    if (this.balance > amt)
        this.balance -= amt;
      
   
  // 查看余额
  synchronized Integer getBalance() 
     return balance;
  

  // 更改密码
  synchronized void updatePassword(String pw)
    this.password = pw;
   
  // 查看密码
  synchronized String getPassword() 
     return password;
  

从上面代码可以看出来,我们是使用当前实例this来管理Account类中所有的资源。所以会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的,所以不会有并发问题。但是却会产生另一个问题,就是性能太差了。

我们可以稍微修改一下,使用两把锁,让取款和修改密码是可以并行的,因为这两个行为互不干扰。

class Account 
  // 锁:保护账户余额
  private final Object balLock
    = new Object();
  // 账户余额  
  private Integer balance;
  // 锁:保护账户密码
  private final Object pwLock
    = new Object();
  // 账户密码
  private String password;

  // 取款
  void withdraw(Integer amt) 
    synchronized(balLock) 
      if (this.balance > amt)
        this.balance -= amt;
      
    
   
  // 查看余额
  Integer getBalance() 
    synchronized(balLock) 
      return balance;
    
  

  // 更改密码
  void updatePassword(String pw)
    synchronized(pwLock) 
      this.password = pw;
    
   
  // 查看密码
  String getPassword() 
    synchronized(pwLock) 
      return password;
    
  

用不同的锁对受保护资源进行精细化管理,能够提升性能,这种锁还有个名字,叫细粒度锁。所以我们上锁的时候需要考虑锁的粒度。

所以我们在上锁的时候,应该分析多个资源的关系。如果资源之间没有关系,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。

死锁

前面说到使用细粒度锁可以提高并行度,是性能优化的一个重要手段。但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

下面举一个死锁的例子:

public class T 
    private Object o1 = new Object();
    private Object o2 = new Object();

    public void m1() 
        synchronized (o1) 
            try 
                Thread.sleep(10000);
             catch (InterruptedException e) 
                e.printStackTrace();
            

            synchronized (o2) 
                System.out.println("如果出现这句话表示没有死锁");
            
        

    

    public void m2() 
        synchronized(o2) 

            synchronized (o1) 
                System.out.println("如果出现这句话表示没有死锁");
            

        

    
    public static void main(String[] args) 
        T t=new T();
        new Thread(t::m1).start();
        new Thread(t::m2).start();
    

上面这个例子死锁是怎么发生的呢?假设当线程1持有锁对象o1,然后当线程2持有锁对象o2的时候;然后线程1需要对对象o2加锁,但是因为线程2已经对对象o2加锁了,所以线程1需要等待线程2解除锁占用。然后线程2同样需要对对象o1加锁,但是因为线程1已经对对象o1加锁了,所以线程2同样要等待线程1解除锁占用。所以现在就出现了线程1和线程2互相在等待对方解除锁占用,于是就出现了死锁。

预防死锁

那我们如何去预防死锁呢?

那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,只有以下这四个条件都发生时才会出现死锁:

  • 互斥:一个资源每次只能被一个进程(或者线程)使用。进程(或者线程)对所分配到的资源不允许其他进程(或者线程)进行访问,若其他进程(或者线程)访问该资源,只能等待,直至占有该资源的进程(或者线程)使用完成后释放该资源
  • 占有且等待:进程(或者线程)获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程(或者线程)占有,此时请求阻塞,但又对自己获得的资源保持不放
  • 不可抢占:是指进程(或者线程)已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放
  • 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待

所以我们想要避免死锁,其实只要破坏掉上面其中一个条件即可。但是第一个条件互斥是没办法破坏的,因为我们用锁的初衷就是为了互斥,所以我们需要从其他三个条件下手。

破坏占有且等待条件

要破坏这个条件,可以一次性申请所有资源。上面的例子一次性申请所有的资源,就相当于一次性加锁了o1和o2对象,解锁的时候也是一次性解锁了o1和o2对象,所以上面的例子可以改成下面这种方式

public class T 
    private Object o1 = new Object();

    public void m1() 
        synchronized (o1) 
            try 
                Thread.sleep(10000);
             catch (InterruptedException e) 
                e.printStackTrace();
            

            System.out.println("如果出现这句话表示没有死锁");
        

    

    public void m2() 
        synchronized (o1) 
                System.out.println("如果出现这句话表示没有死锁");
            
    
    public static void main(String[] args) 
        T t=new T();
        new Thread(t::m1).start();
        new Thread(t::m2).start();
    

上面这种方式直接使用了一个锁,这种肯定是不会有死锁的。

或者我们还是锁定两个不同的对象,我们还可以这么改造

class M 

  private List<Object> list = new ArrayList<>();

  public synchronized boolean lock(Object o1, Object o2) 
    if (list.contains(o1) || list.contains(o2)) 
      return false;
     else 
      list.add(o1);
      list.add(o2);
    
    return true;
  

  public synchronized void unlock(Object o1, Object o2) 
    list.remove(o1);
    list.remove(o2);
  


class T 

  private Object o1 = new Object();
  private Object o2 = new Object();
  private M m = new M();

  public void m1() 
    while (!m.lock(o1, o2)) 

    
    try 
      synchronized (o1) 
        try 
          Thread.sleep(10000);
         catch (InterruptedException e) 
          e.printStackTrace();
        

        synchronized (o2) 
          System.out.println("如果出现这句话表示没有死锁");
        
      
     finally 
      m.unlock(o1, o2);
    

  

  public void m2() 
    while (!m.lock(o1, o2)) 

    
    try 
      synchronized (o2) 
        synchronized (o1) 
          System.out.println("如果出现这句话表示没有死锁");
        

      
     finally 
      m.unlock(o1, o2);
    

  

  public static void main(String[] args) 
    T t = new T();
    new Thread(t::m1).start();
    new Thread(t::m2).start();
  


从上面代码可以看出来,我们抽取了一个类M来同时申请多个资源,从而破坏了占有且等待条件

破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。Java 在语言层次确实没有解决这个问题,但是java.util.concurrent 这个包下面提供的 Lock类中的tryLock(long, TimeUnit) 方法,可以帮我们在一段时间尝试获取锁,所以可以轻松解决这个问题的

破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源,这样就不会出现两个线程交错加锁的情况。上面的情况就是因为我们申请资源其实不是顺序的,也就是加锁不是顺序的,T1加锁的是o1然后o2,T2加锁的是o2然后o1。 如果T1和T2都是加锁o1然后o2,其实就不会有这种问题。

class T 
  private Object o1 = new Object();
  private Object o2 = new Object();

  public void m1() 
    synchronized (o1) 
      try 
        Thread.sleep(10000);
       catch (InterruptedException e) 
        e.printStackTrace();
      

      synchronized (o2) 
        System.out.println("如果出现这句话表示没有死锁");
      
    

  

  public void m2() 
    synchronized(o1) 

      synchronized (o2) 
        System.out.println("如果出现这句话表示没有死锁");
      

    

  
  public static void main(String[] args) 
    T t=new T();
    new Thread(t::m1).start();
    new Thread(t::m2).start();
  

总结

但实际上开发过程中的案例肯定不会像我们举例的这么简单,具体问题具体分析,但是我们还是需要从这三个条件出发,去破坏掉我们这三个条件,才能够避免死锁的问题。

用 synchronized 实现等待 - 通知机制

前面我们在讲死锁的破坏占用且等待条件的时候,使用了一个死循环的方式来循环等待

while (!m.lock(o1, o2)) 


这种方案,在并发冲突大的场景(也就是可能很久都获取不到锁)不适用,因为这种场景下可能要循环上万次才能获取到锁,太消耗 CPU 了。

那有没有更好的方案呢?那就是使用"等待 - 通知机制"。怎么理解"等待 - 通知机制"呢?你可以类比于医院排队叫号。如果没有排队叫号系统,每个人都需要去问医生是不是轮到我了。而有了排队叫号,病人只需要等着医生把你叫过来,病人和医生是不是都省心省力了。

而在编程世界中,一个完整的“等待 - 通知机制”是这样的:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。

那在Java的世界中如何实现的“等待 - 通知机制”? Java 语言内置的 synchronized 配合 wait()notify()notifyAll() 这三个方法就能轻松实现。

我们看方法名称就可以知道,wait()顾名思义就是让线程等待,而、notify()notifyAll()就是唤醒线程。

那么wait()的实现机制是怎样的?

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的

以上是关于Java并发编程实战之互斥锁的主要内容,如果未能解决你的问题,请参考以下文章

Java并发编程实战 04死锁了怎么办?

Java并发编程实战 04死锁了怎么办?

转:Java并发编程之七:使用synchronized获取互斥锁的几点说明

并发编程之互斥锁

Java并发编程:使用synchronized获取互斥锁的几点说明

java并发编程之互斥