java并发学习——聊一聊juc中的共享锁 、可重入锁

451 阅读8分钟

      这是我参与8月更文挑战的第4天,活动详情查看8月更文挑战

     通过文章你将主要了解到java中所提及的各种锁同时了解到各种锁背后的含义和特点。本文主要介绍上篇未提及的共享锁、独占锁、可重入锁等信息。

     上篇地址: java并发学习——聊一聊juc中的各种各样锁(一)

共享还是独占?这是个问题!

  无论在现实社会中还是在程序的世界里,我们都希望持有更多的资源,但有时资源信息就那么多,又该如何去进行划分呢?为此根据对资源的态度延伸出了的共享锁独占锁.

共享锁

  共享锁有时也称读锁。获得共享锁之后无法修改及删除数据,它同时被多个线程获取,获取到后仅能进行信息的查看,这也是其读锁的由来。

  Juc中的ReentrantReadWriteLock.ReadLock是最典型的读锁,它可以保证并发的场景下进行高效的信息读取操作。

/**
 * @author: yihang
 * @description: 演示读锁
 * @date: 2021/8/4 20:12
 * @parms 
 * @return 
 */

*/
public class CinemaReadWrite {
    // 构建一个读锁
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.writeLock();
    // 
    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        // 多线程来进行读取
        new Thread(()->read(),"Thread1").start();
        new Thread(()->read(),"Thread2").start();
        new Thread(()->read(),"Thread5").start();
    }
}

输出信息:
    Thread1得到了读锁,正在读取
    Thread2得到了读锁,正在读取
    Thread5得到了读锁,正在读取
    Thread1释放读锁
    Thread2释放读锁
    Thread5释放读锁
从输出信息不难看出,在使用读锁的情况下,是可以实现多线程对信息的同时读取。

独占锁

  独占锁在获取到的资源后采取独占的策略。当获取到共享资源后独占锁就占为己有,当其内部的操作处理完成后才将锁信息进行释放 有时称其为写锁,典型例子就是JUC中的ReentrantReadWriteLock.WriteLock

// 演示写锁情况
public class CinemaReadWrite {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

  
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
     
        new Thread(()->write(),"Thread3").start();
        new Thread(()->write(),"Thread4").start();

    }
}
输出结果:
Thread3得到了写锁,正在写入
Thread3释放写锁
Thread4得到了写锁,正在写入
Thread4释放写锁

读锁、写锁知多少

   一般情况下我们谈论最多的其实还是读锁和写锁。当谈到读锁和写锁时最长讨论如下几个问题:

1. 读锁写锁的升高降级策略
2. 读锁写锁的公平性问题及插队厕略
3,读写锁常见的规则是什么

读写锁中的升降级

  在读锁和写锁中其实存在一个锁的升降级的概念。锁的升级主要是指读锁在一些情况下可以升级为写锁。而锁的降级则指写锁在一定情况下会降级为读锁。

在JUC中支持锁的降级,但不支持锁的升级。这里所谓的升降级主要是是否 在获取到一把锁的情况况下是否可以获取到另外一把锁信息。

  演示锁的升降级现象:

升级阻塞

public class Upgrading {

 private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
         false);
  //声明读锁写锁信息
 private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
 private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

 private static void readUpgrading() {
     readLock.lock();
     try {
         System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
         Thread.sleep(1000);
         System.out.println("升级会带来阻塞");
         writeLock.lock();
         System.out.println(Thread.currentThread().getName() + "获取到了写锁,升级成功");
     } catch (InterruptedException e) {
         e.printStackTrace();
     } finally {
         System.out.println(Thread.currentThread().getName() + "释放读锁");
         readLock.unlock();
     }
 }


 public static void main(String[] args) throws InterruptedException {
     // 演示锁的升级
     Thread thread2 = new Thread(() -> readUpgrading(), "Thread2");
     thread2.start();
 }
}

image.png

读锁升级阻塞

// 演示写锁降级
public class Downgrading {

  private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
          false);
  private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
  private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();


  private static void writeDowngrading() {
      writeLock.lock();
      try {
          System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
          Thread.sleep(1000);
          readLock.lock();
          System.out.println("在不释放写锁的情况下,直接获取读锁,成功降级");
      } catch (InterruptedException e) {
          e.printStackTrace();
      } finally {
          readLock.unlock();
          System.out.println(Thread.currentThread().getName() + "释放写锁");
          writeLock.unlock();
      }
  }

  public static void main(String[] args) throws InterruptedException {
      Thread thread1 = new Thread(() -> writeDowngrading(), "Thread1");
      thread1.start();
      thread1.join();
   

  }
}

image.png

写锁降级成功

锁的降级带来那些优势?

  锁的降级带来最大的优势在于,其在一定情况下可以提升执行效率。如果没有锁的降级策略,当线程获取到写锁后,如果想获取读锁,则必须释放掉持有的写锁。如果其占用读锁时间较短,而等待线程又比较多的话,当释放掉读锁获取写锁时其不得不排队获取。而如果有锁的降级,则可以尽量减少这部分的等待时间。

为什么不支持锁定升级?

  读写锁的特点就是允许多个线程同时读取,但是不能让多个线程同时去写。更不能同时有写又有读,因此,如果想升级为写锁,需要等到所有的读锁都释放,此时才能进行升级。

  假设如下场景:有线程A,B,它们都已持有读锁。此时线程A,B都想尝试从读锁升级到写锁。则此时需要另一方释放读锁。此时双方都在等待,谁都不肯释放自己的持有的读锁也即进入死锁状态。

公平性及插队策略分析

  在构建读锁、写锁时我们使用ReentrantReadWriteLock并在其构造函数中传入了false,此时表明我们构建的锁是非公平锁

在之前文章有提到公平锁、非公平锁,如果忘了可以点击链接快速回顾一下。

  那么当构建的锁是非公平锁时,读锁写锁的在处理插队上采取何种策略?接下来我们就对这个问题进行探讨,探讨之初我们首先来看看常见的几种插队处理策略。

允许插队

image.png

 此时线程2,4,5获取到读锁信息,线程3在等待队列中,此时线程6也想获取读锁信息,在该
策略下线程6会进行抢在3前执行,此时读锁任意的插队所带了的直接问题就是有可能让某一线程
陷入饥饿状态

不允许插队

image.png

在这种策略下线程按照先后顺序进行执行,不存在所谓的插队情况。

  在ReentrantReadWriteLock的读写锁之中,如果创建ReentrantReadWriteLock之指定为公平锁则不允许插队。而如果指定为非公平锁则其插队策略是这样的:

  • 写锁可以随时插队

   这是因为在读写锁中,读锁写锁并不能同时共存,当想获取写锁时,必须释放读锁。此时当想获取写锁时,会首先进行尝试,如果获取不到则进入等待队列。如果获取到则优先执行。

  • 读锁一般被认为的不允许插队,但是这样描述不准确。读锁仅在等待队列头结点不是写锁的时候可以插队。

   这种情况一般而言相对比较难模拟,我们知道读锁时可以被多个线程锁共享的,如果进入等待队列,那么此时头节点一般多为想获取写锁的线程。而当获取到写锁后读锁必然是不可能获取到的,此时也就不存在 这个插队这个问题。

image.png

一般情况下等待队列信息如图所示

为了解决这个问题,我们通过构建大量子线程来进行抢占读锁,构建出队列首元素为读锁的情况

public class WriteLockInsetQueue {


    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
            false);

    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();


    public static void readMessage() {
        System.out.println(Thread.currentThread().getName() + " 开始尝试获取到读锁信息...");
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 得到读锁开始信息的读取.....");
            Thread.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() +"  释放读锁.......");
            readLock.unlock();
        }
    }

    public static void writeMessage() {
        System.out.println(Thread.currentThread().getName() + " 开始尝试获取到写锁信息...");
       writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 得到写锁开始信息的读取.....");
            Thread.sleep(40);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() +"  释放写锁.......");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(()->writeMessage(),"Thread1").start();
        new Thread(()->readMessage(),"Thread2").start();
        new Thread(()->readMessage(),"Thread3").start();
        new Thread(()->writeMessage(),"Thread4").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                Thread thread[] = new Thread[1000];
                for (int i = 0; i < 1000; i++) {
                    thread[i] = new Thread(() -> readMessage(), "子线程创建的Thread" + i);
                }
                for (int i = 0; i < 1000; i++) {
                    thread[i].start();
            }
        }}).start();
    }

}

输出结果: 初始

Thread1 开始尝试获取到写锁信息...
Thread2 开始尝试获取到读锁信息...
Thread1 得到写锁开始信息的读取.....
Thread3 开始尝试获取到读锁信息...
Thread4 开始尝试获取到写锁信息...

大批量读的子线程被创建

子线程创建的Thread0 开始尝试获取到读锁信息...
子线程创建的Thread1 开始尝试获取到读锁信息...
子线程创建的Thread2 开始尝试获取到读锁信息...

插队

 Thread1  释放写锁.......
 子线程创建的Thread559 开始尝试获取到读锁信息...
 子线程创建的Thread560 开始尝试获取到读锁信息...
 子线程创建的Thread560 得到读锁开始信息的读取.....

   不难发现,如果如果读锁不允许插队的话,此时子线程560是无法获取到的读锁信息的。但实际上560线程确实获得到了锁信息。之所以可以获取到主要是因为在对头构建了大量读的子线程信息,构建出了对头元素非写锁线程的情况,此时读锁插队现象随即发生。

   如果创建ReentrantReadWriteLock指定为true,则不会出现插队的现象,有兴趣的小伙伴可以下来试一试。

读写锁中常见的规则

   读写锁中一般有这样的规则

  • 多个线程只能申请到读锁信息
  • 如果有一个线程占有了读锁信息,那此时其他线程无法申请到写锁
  • 如果一个向后才能已经占有写锁,那么此时其他线程无法申请到写锁或读锁

总结来看:要么是多读,要么是一写。不可能同时持有读锁和写锁。

什么是可重入?

  这里的可重入主要是说:某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。 比如synchonrized、ReentrantLock都是典型的可重入锁。

  如果对可重入认识还不清楚的话,我们以生活中的摇号挂牌照来进行类比,如果摇到号后我们可以为多辆车同时申请牌照的话,那么这就是`可重入。即当我获取到资源后,我可以一直重复的使用。而实际则是当我们摇到号后只能为一辆车上牌照,所以这就是不可重入!

/**
* @author: yihang
* @description: 演示可重入锁信息
* @date: 2021/8/4 21:55
* @parms
* @return
*/
public class ReentrantDemo {

  public static void main(String[] args) {
      Thread t = new Thread(new JobRunnable());
      t.start();
  }

}
// 任务线程
class JobRunnable implements Runnable {
  private static Object lock = new Object();


  @Override
  public void run() {
      synchronized (lock) {
          System.out.println("第一次获取锁信息,只获取但不进行释放 ");
          // 证明锁的可重入性
          int count = 0 ;
          while (true) {
              synchronized (lock) {
                  System.out.println("第 " + count + " 次获取锁信息");
                  count ++ ;
              }
              if (count == 5) {
                  break;
              }
          }
      }
  }
}
结果输出

第一次获取锁信息,只获取但不进行释放 
第 0 次获取锁信息
第 1 次获取锁信息
第 2 次获取锁信息
第 3 次获取锁信息
第 4 次获取锁信息

   不难发现如果synchonrized不具备可重入的性质,那么这段代码会陷入死锁的状态,并不会继续执行后续任务。

自旋锁、阻塞锁到底是什么

   cpu通过线程间快速切换来实现多任务的处理,在线程的切换过程中通常需要阻塞或唤醒一个线程、如果同步代码快中的内容过于简单,状态转换消耗的时间有可能比用户代码执行时间都长。

   在许多同步场景中,同步资源锁定的时间是很短的为了这一小段时间去切换,是得不偿失的,所以也就有了自旋锁、阻塞锁的概念。

   自旋锁的特性是:当线程尝试获取锁失败时它不会将线程切换为沉睡状态,而是开启无限循环,不断地轮询锁的状态

    这便是“自旋”的来历,当锁状态被更改时便立刻尝试获取到锁信息。

    阻塞锁则是指当线程尝试获取锁失败时,线程进入阻塞状态,直到接收信号后被唤醒。 线程Api方法中能够唤醒阻塞线程的操作包括:notify, notifyAll等操作。

    阻塞锁的优点是在线程获取锁失败后,不会一直处于运行状态(占用CPU)。因此在竞争激烈的情况下, 阻塞锁的性能将明显优于自旋锁。


/** 
AtomicInteger中的自增方法

* Atomically increments by one the current value.
*
* @return the previous value
*/
public final int getAndIncrement() {
 return unsafe.getAndAddInt(this, valueOffset, 1);
}


public final int getAndAddInt(Object var1, long var2, int var4) {
 int var5;
 do {
     var5 = this.getIntVolatile(var1, var2);
 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

 return var5;
}

    在AtomicInteger这段代码中,调用unsafe中的getAndAddInt来实现信息的自增操作,其底层实现就是一个自旋操作如果修改过程中遇到其他线程竞争导致没修改成功,则一直在do-while中进行循环,直至修改成功。

自己尝试实现自旋锁

 你需要知道的:自旋锁的实现一般都需要借助cas算法

**
* @author: yihang
* @description: 自己手动实现自旋锁
* @date: 2021/8/4 22:34
* @parms
* @return
*/
public class SpinLockDemo {
   // 使得线程成为一个原子类,主要方便调用cas进行内容的设定
   private AtomicReference<Thread> sign = new AtomicReference<>();

   // 加锁操作

   public void lock() {
       Thread currentThread = Thread.currentThread();

       while (!sign.compareAndSet(null,currentThread)) {
           System.out.println("锁被占用中,进行锁的尝试性获取");
       }
   }

   public void unlock() {
       Thread current = Thread.currentThread();
       sign.compareAndSet(current, null);
   }

   public static void main(String[] args) {
       SpinLockDemo spin = new SpinLockDemo();
       Runnable runnable = new Runnable() {
           @Override
           public void run() {
               System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
               // 上锁
               spin.lock();
               System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
               try {
                   // 加300ms延迟后释放锁信息
                   Thread.sleep(300);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               } finally {
                   // 解锁
                   spin.unlock();
                   System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
               }
           }
       };
       
       Thread t1 = new Thread(runnable);
       Thread t2 = new Thread(runnable);

       t1.start();;
       t2.start();
       
   }

}

输出结果
Thread-0开始尝试获取自旋锁
Thread-1开始尝试获取自旋锁
Thread-0获取到了自旋锁
锁被占用中,进行锁的尝试性获取
锁被占用中,进行锁的尝试性获取
.......
Thread-1获取到了自旋锁
Thread-0释放了自旋锁
Thread-1释放了自旋锁

    可以看到当线程1无法获取到锁信息后,就一直在不停的尝试去获取锁,当线程0释放后便立刻去进行获取。

可中断锁

    如果在某一线程A中执行锁的代码,另一线程B正在等待获取该锁,有可能由于等待时间过程,线程B不想等待了,想处理其他事情。如果我们可以中断他,那么他就是可中断锁。

比如lock中tryLock和lockInterruptibly都能响应中断。Lock也称为是一个可中断锁。

小结

    本文对常见的几种锁信息进行了总结回顾,希望对你有所帮助 。