Java:Java学习笔记之ReentrantLock的简单理解和使用

Posted JMW1407

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java:Java学习笔记之ReentrantLock的简单理解和使用相关的知识,希望对你有一定的参考价值。

ReentrantLock

ReentrantLock

1、相关知识

轻松学习java可重入锁(ReentrantLock)的实现原理

1.1 公平锁和非公平锁

1)公平锁: 多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

2) 非公平锁: 多个线程不会按照申请顺序获取锁,多线程会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。因此是不公平的。

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

1.2 可重入锁

可重入锁的含义就是某个线程持有某个锁的时候,当该线程再次获取这个锁的时候,不会造成死锁。

  • 简单来说就是:拥有该锁的同时再次获取该锁,不会造成死锁。

什么时候会出现上述这种情况呢?例如:

  • 一个类中有多个同步方法(A,B)
  • 一个线程要执行A方法,那么就要获取这个类对象锁,此时已经获取成功了
  • 在执行A方法的过程中又要去调用B方法,由于B方法也是该类的同步方法,因此该线程又要去尝试获取该类对象锁。

这就是拥有该锁再获取该锁的情况

public class Widget 
    public synchronized void doSomething() 
        System.out.println("方法1执行...");
        doOthers();
    

    public synchronized void doOthers() 
        System.out.println("方法2执行...");
    


在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

1.3、CAS算法

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。

  • java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

举例说明:

阿里二面,面试官:说说 Java CAS 原理?

CAS机制当中使用了3个基本操作数:内存地址V旧的预期值A计算后要修改后的新值B

  • (1)初始状态:在内存地址V中存储着变量值为 1
  • (2)线程1想要把内存地址为 V 的变量值增加1。这个时候对线程1来说,旧的预期值A=1,要修改的新值B=2。
  • (3)在线程1要提交更新之前,线程2捷足先登了,已经把内存地址V中的变量值率先更新成了2。
  • (4)线程1开始提交更新,首先将预期值A内存地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
  • (5)线程1重新获取内存地址 V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=2,B=3。这个重新尝试的过程被称为自旋。如果多次失败会有多次自旋。
  • (6)线程 1 再次提交更新,这一次没有其他线程改变地址 V 的值。线程1进行Compare,发现预期值 A 和内存地址V的实际值是相等的,进行 Swap 操作,将内存地址 V 的实际值修改为 B。
  • 总结:更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 中的实际值相同时,才会将内存地址 V 对应的值修改为B,这整个操作就是CAS。

CAS 基本原理:

  • CAS 主要包括两个操作:CompareSwap,有人可能要问了:两个操作能保证是原子性吗?可以的。

CAS 是一种系统原语,原语属于操作系统用语,原语由若干指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条 CPU 的原子指令,由操作系统硬件来保证。

回到 Java 语言,JDK 是在 1.5 版本后才引入 CAS 操作,在sun.misc.Unsafe这个类中定义了 CAS 相关的方法。

public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);

public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);

2、背景、定义和特征

2.1、背景

问题的产生:

  • Java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据 的增删改查),将会导致数据不准确,相互之间产生冲突。

如下例:假设有一个卖票系统,一共有100张票,有4个窗口同时卖。



输出部分结果:

显然上述结果是不合理的,对于同一张票进行了多次售出。

这就是多线程情况下, 出现了数据“脏读”情况。

  • 即多个线程访问余票num时,当一个线程获得余票的数 量,要在此基础上进行-1的操作之前,其他线程可能已经卖出多张票,导致获得的
    num不是最新的,然后-1后更新的数据就会有误。这就需要线程同步的实现了。

问题的解决:

因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证 了该变量的唯一性和准确性。

2.2、定义

ReentrantLock,一个可重入互斥锁,它具有与使用synchronized方法和语句所访 问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。

2.3、特征

具有以下特征:

  • 1、互斥性:同时只有一个线程可以获取到该锁,此时其他线程请求获取锁,会被阻塞,然后被放到该锁内部维护的一个 AQS 阻塞队列中。
  • 2、可重入性:维护 state 变量,初始为 0,当一个线程获取到锁时,state 使用 CAS 更新为 1,本线程再次申请获取锁,会对state 进行 CAS 递增,重复获取次数即 state,最多为 2147483647 。试图超出此限制会从锁定方法抛出 Error。
  • 3、公平/非公平性:在初始化时,可以通过构造器传参,指定是否为公平锁,还是非公平锁。当设置为 true时,为公平锁,线程争用锁时,会倾向于等待时间最长的线程。

3、基本结构


基本结构如图所示,ReentrantLock 类实现了接口 Lock,在接口 Lock 中定义了使用锁时的方法,方法及含义如下:

public interface Lock 
    
    // 获取锁,如果没有获取到,会阻塞。
    void lock();

    // 获取锁,如果没有获取到,会阻塞。响应中断。
    void lockInterruptibly() throws InterruptedException;

    // 尝试获取锁,如果获取到,返回 true,没有获取到 返回 false
    boolean tryLock();

    // 尝试获取锁,没有有获取到,会等待指定时间,响应中断。
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    // 释放锁
    void unlock();
	
	//返回当前线程的Condition ,可多次调用
    Condition newCondition();


4、基本使用

class X 
    private final ReentrantLock lock = new ReentrantLock();
    // ...

    public void m() 
        lock.lock();  // block until condition holds
        try 
        // ... method body
         finally 
        lock.unlock()
        



4.1、解决背景问题

具体的解决方式如下:

4.2、重入锁 使用

当一个线程得到一个对象后,再次请求该对象锁时是可以再次得到该对象的锁的。 具体概念就是:自己可以再次获取自己的内部锁。

Java里面内置锁(synchronized)Lock(ReentrantLock)都是可重入的。


上面便是ReentrantLock重入锁特性,即调用method1()方法时,已经获得了锁, 此时内部调用method2()方法时, 由于本身已经具有该锁,所以可以再次获取。

上面便是synchronized的重入锁特性,即调用method1()方法时,已经获得了锁, 此时内部调用method2()方法时,由于本身已经具有该锁,所以可以再次获取。

4.3、公平锁使用

ReentrantLock便是一种公平锁,通过在构造方法中传入true就是公平锁,传入 false,就是非公平锁


以下是使用公平锁实现的效果:

实验结果:

这是截取的部分执行结果,分析结果可看出两个线程是交替执行的,几乎不会出现 同一个线程连续执行多次。

4.4、可中断使用

当使用synchronized实现锁时,阻塞在锁上的线程除非获得锁否则将一直等待下去,也就是说这种无限等待获取锁的行为无法被中断。

而ReentrantLock给我们提供了一个可以响应中断的获取锁的方法lockInterruptibly()。该方法可以用来解决死锁问题。

public class ReentrantLockTest 
    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException 

        Thread thread = new Thread(new ThreadDemo(lock1, lock2));//该线程先获取锁1,再获取锁2
        Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));//该线程先获取锁2,再获取锁1
        thread.start();
        thread1.start();
        thread.interrupt();//是第一个线程中断
    

    static class ThreadDemo implements Runnable 
        Lock firstLock;
        Lock secondLock;
        public ThreadDemo(Lock firstLock, Lock secondLock) 
            this.firstLock = firstLock;
            this.secondLock = secondLock;
        
        @Override
        public void run() 
            try 
                firstLock.lockInterruptibly();
                TimeUnit.MILLISECONDS.sleep(10);//更好的触发死锁
                secondLock.lockInterruptibly();
             catch (InterruptedException e) 
                e.printStackTrace();
             finally 
                firstLock.unlock();
                secondLock.unlock();
                System.out.println(Thread.currentThread().getName()+"正常结束!");
            
        
    


构造死锁场景:创建两个子线程,子线程在运行时会分别尝试获取两把锁。其中一个线程先获取锁1在获取锁2,另一个线程正好相反。

如果没有外界中断,该程序将处于死锁状态永远无法停止。

我们通过使其中一个线程中断,来结束线程间毫无意义的等待。被中断的线程将抛出异常,而另一个线程将能获取锁后正常结束。

4.5、可限时使用

使用lock.tryLock(long timeout, TimeUnit unit)来实现可限时锁,参数为时间和单位

  • 无参则表示立即返回锁申请的结果:true表示获取锁成功,false表示获取锁失败。
public class ReentrantLockTest 
    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException 

        Thread thread = new Thread(new ThreadDemo(lock1, lock2));//该线程先获取锁1,再获取锁2
        Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));//该线程先获取锁2,再获取锁1
        thread.start();
        thread1.start();
    

    static class ThreadDemo implements Runnable 
        Lock firstLock;
        Lock secondLock;
        public ThreadDemo(Lock firstLock, Lock secondLock) 
            this.firstLock = firstLock;
            this.secondLock = secondLock;
        
        @Override
        public void run() 
            try 
                while(!lock1.tryLock())
                    TimeUnit.MILLISECONDS.sleep(10);
                
                while(!lock2.tryLock())
                    lock1.unlock();
                    TimeUnit.MILLISECONDS.sleep(10);
                
             catch (InterruptedException e) 
                e.printStackTrace();
             finally 
                firstLock.unlock();
                secondLock.unlock();
                System.out.println(Thread.currentThread().getName()+"正常结束!");
            
        
    


线程通过调用tryLock()方法获取锁,第一次获取锁失败时会休眠10毫秒,然后重新获取,直到获取成功。

第二次获取失败时,首先会释放第一把锁,再休眠10毫秒,然后重试直到成功为止。

线程获取第二把锁失败时将会释放第一把锁,这是解决死锁问题的关键,避免了两个线程分别持有一把锁然后相互请求另一把锁。

4.6、Condition实现等待/通知

  • 1、Condition接口在使用前必须先调用ReentrantLocklock()方法获得锁。
  • 2、调用Condition接口的await()将释放锁,并且在该Condition上等待,直到有其他线程调用Conditionsignal()方法唤醒线程。
public class ConditionTest 

    static ReentrantLock lock = new ReentrantLock();
    static Condition condition = lock.newCondition();
    public static void main(String[] args) throws InterruptedException 

        lock.lock();
        new Thread(new SignalThread()).start();
        System.out.println("主线程等待通知");
        try 
            condition.await();
         finally 
            lock.unlock();
        
        System.out.println("主线程恢复运行");
    
    static class SignalThread implements Runnable 

        @Override
        public void run() 
            lock.lock();
            try 
                condition.signal();
                System.out.println("子线程通知");
             finally 
                lock.unlock();
            
        
    

5、源码分析


ReentrantLock 也只是实现了 Lock 接口,并实现了这些方法,那 ReentrantLockAQS 到底有什么关系呢?

这就需要看内部具体如何实现的了。

通过上面类图可以看出,在 ReentrantLock 中含有两个内部类,分别是 NonfairSyncFairSync 而它俩又实现了 抽象类 Sync,抽象类 Sync 继承了 AbstractQueuedSynchronizerAQS。具体代码如下:

public class ReentrantLock implements Lock, java.io.Serializable 

    private final Sync sync;

    // 锁的同步控制基础类。 子类具体到公平和非公平的版本。 使用AQS状态来表示持有该锁的数量。
    abstract static class Sync extends AbstractQueuedSynchronizer  
        // 省略 ...
    

    static final class NonfairSync extends Sync  
        // 非公平锁逻辑 省略 ...
    

    static final class FairSync extends Sync  
        // 公平锁逻辑 省略 ...
    
    // 默认非公平锁
    public ReentrantLock() 
        sync = new NonfairSync();
    
    // 根据传参指定公平锁还是非公平锁,true 公平锁,false 非公平锁
    public ReentrantLock(boolean fair) 
        sync = fair ? new FairSync() : new NonfairSync();
    



ReentrantLock有两个构造方法,无参构造方法默认是创建非公平锁,而传入true为参数的构造方法创建的是公平锁。

public ReentrantLock() 
    sync = new NonfairSync();


public ReentrantLock(boolean fair) 
    sync = fair ? new FairSync() : new NonfairSync();


5.1、AQS

从ReentrantLock的实现看AQS的原理及应用

AQS核心思想:

  • 如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;
  • 如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
  • AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配

AQSAbstractQueuedSynchronizer的缩写,这个是个内部实现了两个队列的抽象类,分别是同步队列条件队列

  • 同步队列是一个双向链表,里面储存的是处于等待状态的线程,正在排队等待唤醒去获取锁
  • 条件队列是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾,AQS所做的就是管理这两个队列里面线程之间的等待状态-唤醒的工作。

在同步队列中的中模式,分别是独占模式共享模式,这两种模式的区别就在于AQS在唤醒线程节点的时候是不是传递唤醒,这两种模式分别对应独占锁共享锁

AQS是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承AQS然后重写获取锁的方式和释放锁的方式还有管理state,

  • ReentrantLock就是通过重写了AQStryAcquiretryRelease方法实现的lockunlock

5.2、非公平锁的实现原理

当我们使用无参构造方法构造的时候即ReentrantLock lock = new ReentrantLock(),创建的就是非公平锁

public ReentrantLock() 
    sync = new NonfairSync();


//或者传入false参数 创建的也是非公平锁
public ReentrantLock(boolean fair) 
    sync = fair ? new FairSync() : new NonfairSync();


5.2.1、lock方法获取锁:tryAcquire


final void lock() 
    //CAS操作设置state的值
    if (compareAndSetState(0, 1))
        //设置成功 直接将锁的所有者设置为当前线程 流程结束
        setExclusiveOwnerThread(Thread.currentThread());
    else
        //设置失败 则进行后续的加入同步队列准备
        acquire(1);


public final void acquire(int arg) 
    //调用子类重写的tryAcquire方法 如果tryAcquire方法返回false 那么线程就会进入同步队列
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();


//子类重写的tryAcquire方法
protected final boolean tryAcquire(int acquires) 
    //调用nonfairTryAcquire方法
    return nonfairTryAcquire(acquires);


final boolean nonfairTryAcquire(int acquires) 
    final Thread current = Thread.currentThread();
    int c = getState();
    //如果状态state=0,即在这段时间内 锁的所有者把锁释放了 那么这里state就为0
    if (c == 0) 
        //使用CAS操作设置state的值
        if (compareAndSetState(0, acquires)) 
            //操作成功 则将锁的所有者设置成当前线程 且返回true,也就是当前线程不会进入同步
            //队列。
            setExclusiveOwnerThread(current);
            return true;
        
    
    //如果状态state不等于0,也就是有线程正在占用锁,那么先检查一下这个线程是不是自己
    else if (current == getExclusiveOwnerThread()) 
        //如果线程就是自己了,那么直接将state+1,返回true,不需要再获取锁 因为锁就在自己
        //身上了。
        以上是关于Java:Java学习笔记之ReentrantLock的简单理解和使用的主要内容,如果未能解决你的问题,请参考以下文章

Java学习笔记之:java引用数据类型之字符串

Java学习笔记之:Java数组

Java学习笔记之:java的数据类型

Java学习笔记之:java数据类型的转换

Java学习笔记之:java的变量

Java学习笔记之:Java流程控制