Java ReentrantLock锁机制概念篇

分享Java锁机制实现原理,细节涉及volatile修饰符、CAS原子操作、park阻塞线程与unpark唤醒、双向链表、锁的公平性与非公平性、独占锁和共享锁、线程等待await、线程中断interrupt。Java ReentrantLock锁机制源码篇

一、看下面的场景

外卖某商家上线了一个【10元一只鸡】拉新活动,外卖APP定时推送活动日营业额。
假如模拟1000个用户同时进行10元购,统计商家日营业额,模拟的脚手架代码实现如下:

public static void main(String[] arg) throws InterruptedException{
    //外卖商家
    BaseMerchant merchantRunnable = new MerchantRunnableUnsafe();
    //模拟1000个用户
    for (int i = 0; i < 1000; i++) {
        Thread client = new Thread(merchantRunnable);
        client.setName("Client-" + i);
        client.start();
    }

    Thread.sleep(2000);
    System.out.println("今天的营业额是 " + merchantRunnable.getTodayRMB());
}

//外卖商家
public static class MerchantRunnableUnsafe extends BaseMerchant {
    @Override
    public void run() {
        try {
            Thread.sleep(1);//耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // todayRMB = todayRMB + 10; 非原子操作
        long temp = todayRMB;
        long update = temp + 10;
        todayRMB = update;
        System.out.println(Thread.currentThread().getName() + " todayRMB:" + todayRMB);
    }
}

public static abstract class BaseMerchant implements Runnable {
    protected volatile long todayRMB = 0;

    public long getTodayRMB() {
        //10元购商家日营业额统计
        return todayRMB;
    }
}

MerchantRunnableUnsafe执行结果:今天的营业额是 9810

线程非安全问题

大家能看出 MerchantRunnableUnsafe实现方式,存在线程安全问题,如下图,打印了两次80。Client-6把80赋值给update后时间片到,压栈后CPU执行权交给Client-7,Client-7顺利把todayRMB更新为80后,CPU执行权让回给Client-6,Client-6出栈后把值为80的update更新给todayRMB,就出现了Client-6和Client-7分别累加10,只生效了一次的线程安全问题。


线程非安全MerchantRunnableUnsafe.png

使用synchronized关键字解决线程非安全问题

public static class MerchantRunnableSync extends BaseMerchant {
    @Override
    public void run() {
        synchronized (MerchantRunnableSync.class) {
            try {
                Thread.sleep(1);//耗时操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            long temp = todayRMB;
            long update = temp + 10;
            todayRMB = update;
            System.out.println(Thread.currentThread().getName() + " todayRMB:" + todayRMB);
        }
    }
}

MerchantRunnableSync执行结果:今天的营业额是 10000

使用ReentrantLock解决线程非安全问题

private static ReentrantLock mLock = new ReentrantLock();

public static class MerchantRunnableLock extends BaseMerchant {
    @Override
    public void run() {
        mLock.lock();

        try {
            Thread.sleep(10);//耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long temp = todayRMB;
        long update = temp + 10;
        todayRMB = update;
        System.out.println(Thread.currentThread().getName() + " todayRMB:" + todayRMB);

        mLock.unlock();
    }
}

MerchantRunnableLock执行结果:今天的营业额是 10000

在1.5之前synchronized效率比ReentrantLock低,并且synchronized属于系统关键字不方便查看实现源码,所以我们研究ReentrantLock实现原理。

ReentrantLock.lock()和 ReentrantLock.unlock() 对应 synchronized 代码块;
ReentrantLock.newCondition.await() 对应 object.wait();
ReentrantLock.newCondition.signal() 对应 object.notify();

要完全搞懂锁,涉及大量概念,再对概念的源码实现,就好比你第一次吃大闸蟹,不告诉你,黄色的是蟹黄,说不定就被你扔了,因为它黄黄的黏黏的像那个啥。。。
所以分两篇介绍锁,概念篇、源码篇。

二、概念篇

ReentrantLock内部是如何实现的?我们先一起来猜想下ReentrantLock内部实现原理。

想象中的多线程运行流程

看上图,三个线程A\B\C,同时进入run()方法,若要确保线程安全,必须保证多线程串行的方式执行运行态,也就是,A\B\C同时执行Lock时,只能有一个通过,假如是A通过,只有A执行完unLock后,B\C其中一个才能再通过Lock,进入运行态,重复以上步骤运行下去,直到三个线程都运行完毕。
上述过程有几个问题

  1. Lock环节,多线程时保证有且仅有一个线程通过Lock环节(获得锁资源),这个应该怎么实现?有同学回答我:用synchronized。MyGod!!ReentrantLock出现原因就是要取代synchronized
  2. 线程A通过了Lock环节执行运行态代码时,获锁失败后的B和C线程要去做什么?
  3. unLock后线程A释放锁资源,是如何找到、通知线程B或C去竞争锁资源的?
  4. 锁是个什么类型?int?boolean?object?

下面我们一个问题一个问题的来解决。

问题1 CAS原子操作——多线程时如何保证有且仅有一个线程通过Lock环节?

用一个全局的boolean型变量?

public static class MerchantRunnableBoolean extends BaseMerchant {
    @Override
    public void run() {
        lock();

        try {
            Thread.sleep(1);//放弃CPU执行权,有可能再也拿不到了
        } catch (InterruptedException e) {
            e.printStackTrace();
            return;
        }
        long temp = todayRMB;
        long update = temp + 10;
        todayRMB = update;
        System.out.println(Thread.currentThread().getName() + " todayRMB:" + todayRMB);

        mBooleanLock = false;
    }

    private void lock() {
        while (true) {
            if (cas(false, true)) {
                break;
            }
        }
    }
}

// true:锁资源已经被占用 false:锁资源未被占用
private static boolean mBooleanLock = false;

private static boolean cas(boolean expect, boolean update) {
    if (mBooleanLock == expect) {
        mBooleanLock = update;
        return true;
    }
    return false;
}

MerchantRunnableBoolean:今天的营业额是 3790

哎,还是存在不安全问题,问题是,无法保证执行函数cas(false, true)时不被其他线程打断,也就是满足原子性。
去趟卫生间
。。。。
。。。。
回来后发现鼠标指针出奇的卡,MAC风扇呼呼转,再一看,run运行台提示代码运行没有结束!!我擦,上面代码有严重毛病!!!!

不信的话,各位客官你们瞪大眼睛仔细看代码

未使用volatile修复符的全局变量

用volatile修饰符保证变量的可见性

什么是可见性?可见性:当多线程访问某一个(同一个)变量时,其中一条线程对此变量作出修改,其他线程可以立刻读取到最新修改后的变量。 volatile 变量修饰符可保证变量可见性。 支持并发三要素:可见性、原子性、有序性。

private static volatile boolean  mBooleanLock = false;
使用volatile修复符的全局变量

运行台,最终结果9800,并且显示运行已结束。原子性怎么办?

用CAS保证原子性

boolean型变量替换为int变量,代码如下

Runnable mRunnable = new Runnable() {
    @Override
    public void run() {
        lock();
        doSomething();
        unlock();
    }
};

private void lock() {
    //获得锁资源
    if (compareAndSetState(0, 1)) {
        mRunningThread = Thread.currentThread();
    } else {
        //当前线程挂起自己,并且不再往后执行
    }
}

/**
 * lockState  0:锁资源未占用 1:锁资源被占用
 */
private int lockState = 0;

/**
 * @param expect 期望值
 * @param update 更新值
 * @return
 */
private final boolean compareAndSetState(int expect, int update) {
    if (expect == lockState) {
        lockState = update;
        return true;
    }
    return false;
}

现在我们把函数compareAndSetState()简称为 CAS。lockState代表锁资源。如果能保证CAS操作为原子操作,就可以保证只有一个线程获得锁资源,其他线程执行else逻辑。

好消息是 CPU底层指令支持CAS原子操作。Java通过Unsafe.java执行CAS。

  /**
 * From  Unsafe.java
 *
 * @param object      变量所在的当前类
 * @param valueOffset 变量在内存中偏移地址
 *                    获取方法:unsafe.objectFieldOffset(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
 * @param expect      期望变量的当前值
 * @param update      满足期望值时,变量要更新的值
 * @return ture:成功
 */
public final native boolean compareAndSwapInt(Object object, int valueOffset, int expect, int update);

为什么使用偏移地址而不直接用变量值?是因为直接用变量值,不能保证可见性。

到此,我们借用CPU提供的CAS原子操作命令,解决了如何保证只有一个线程获得锁资源的问题。

CAS属于乐观锁、非阻塞锁,乐观锁:先执行如果遇到错误了再汇报,非阻塞:在不阻塞线程的情况下,借助原子操作保证线程安全。相比于阻塞锁,没有切换线程的开销,所以更高效。CAS是AtomicInteger的基础,AtomicInteger是线程池的基础,是ReentrantLock的基础,ReentrantLock是阻塞队列BlockingQueue的基础。可见CAS的重要性。

并发包.png

使用CAS解决线程非安全问题

有两个思路,思路一:todayRMB自身作为锁资源,思路二:新增一个变量作为锁资源,思路一代码如下:

//10元购 商家日营业额统计
public static class MerchantRunnableCAS extends BaseMerchant {

    @Override
    public void run() {
        try {
            Thread.sleep(10);//耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
            return;
        }

        int reCount = 0;
        for (; ; ) {
            reCount++;
            System.out.println(Thread.currentThread().getName());
            long temp = todayRMB;
            long update = temp + 10;

            if (compareAndSetState(temp, update)) {
                System.out.println(Thread.currentThread().getName() + " reCount:" + reCount);
                break;
            }
        }
    }

    protected final boolean compareAndSetState(long expect, long update) {
        return U.compareAndSwapLong(this, TODAY_RMB, expect, update);
    }

    private static final sun.misc.Unsafe U = getUnsafe();
    private static final long TODAY_RMB;

    static {
        try {
            TODAY_RMB = U.objectFieldOffset(getDeclaredField(MerchantRunnableCAS.class, "todayRMB"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    public static Field getDeclaredField(Class clazz, String fieldName) {
        Field field = null;

        for (; clazz != Object.class; clazz = clazz.getSuperclass()) {
            try {
                field = clazz.getDeclaredField(fieldName);
                return field;
            } catch (Exception e) {
                //这里甚么都不要做!并且这里的异常必须这样写,不能抛出去。
                //如果这里的异常打印或者往外抛,则就不会执行clazz = clazz.getSuperclass(),最后就不会进入到父类中了
            }
        }

        return null;
    }

    static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            return (Unsafe) f.get(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

MerchantRunnableCAS执行结果:今天的营业额是 10000

MerchantRunnableCAS原子性锁乐观、非阻塞的体现.png

从运行过程截图看出,在CAS原子锁下,线程Client-451 执行了两次compareAndSetState(),第一次执行失败,第二次执行成功,消除了MerchantRunnableUnsafe类中运行结果被覆盖的非安全问题。

问题2 Unsafe.park、unpark ——如何管理获锁失败后的B和C线程?

上面用CAS解决的多线程问题,会发现获锁失败后,一般通过死循环的方式重新获取锁,直到成功为止,这种方案叫自旋锁,对耗时任务加锁情况下,缺点很明显,CPU资源耗尽、应用卡死、手机发烫,还有其他做法么?
当然有,ReentrantLock的做法是,第一次获锁失败后,将当前线程插入双向链表的末尾,然后阻塞当前线程,有锁资源时再被唤醒,解决了自旋锁的缺点。这种方案叫阻塞锁
如何阻塞、唤醒当前线程呢?系统提供的API如下:
阻塞当前线程

LockSupport.park(Thread.currentThread());

唤醒当前线程

LockSupport.unpark(Thread.currentThread());

如下源码,Unsafe.class好眼熟,上文实现原子性核心函数用的也是Unsafe.class内的方法,so,大家自己看下Unsafe源码。

public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    U.park(false, 0L);
    setBlocker(t, null);
}
public static void unpark(Thread thread) {
    if (thread != null)
        U.unpark(thread);
}
private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();

问题3 公平锁和非公平锁

上文提到,有锁资源时,双向链表中阻塞线程会被再次唤醒,假如有锁资源时,恰好又来了个新线程D,这个时候,新线程D和阻塞队列中线程B,要竞争锁资源,应该给谁呢?这个地方存在锁的公平性的问题。
公平锁:锁给线程B,新线程D加入FIFO链表对尾并阻塞。先来先执行。
非公平锁:锁给新线程D,线程B继续阻塞。先来不一定先执行。

问题4 锁的可重入性 独占锁和共享锁

回头看MerchantRunnableBoolean类中 lock方法。如果一个线程多次执行lock会发生死锁的后果。锁的可重入性是指,同一个线程可以执行多次lock。
用途:考虑到代码的复用性,重构如下,funcA和funcB存在多线程访问,要用到mLock的可重入性,否则funcB()存在线程安全。

private void funcA() {
    mLock.lock();
    doSomething();
    funcB();
    mLock.unlock();
}

private void funcB() {
    mLock.lock();
    doSomething();
    mLock.unlock();
}

优化读、写频繁的DB模块,首要任务是独占锁替换为共享锁。
读操作加读锁,写操作加写锁,共享锁有N个读锁资源,一个写锁资源,并且读和写互斥,共享锁允许多线程同时执行读操作,减少了读操作时阻塞、唤醒的次数,而独占锁不区分读、写,只有一个锁。

可重入性锁和共享锁里的读锁,有一个相似之处,都是可以多次。可重入锁可以让一个线程多次执行lock操作,读锁可以让多个线程分别执行lock操作,一个是作用于单线程,一个作用于多线程,后面分析源码时,你会发现,他们的实现原理却是一样的。

问题5 锁是个什么类型?int?boolean?object?

int型。boolean型无法实现锁的可重入特性。

问题6 执行await后,线程状态有什么变化?如何被恢复?

await()后,线程A放弃CPU执行权,释放锁资源,并且进入等待队列,而不是阻塞队列,当结束等待条件满足后,也就是其他线程执行signal(),线程A将会从等待队列 转移到 阻塞对列,当竞争到锁资源后,才进入运行态。

新生-->就绪(Runnable)-->运行(Running)-->遇到wait造成的等待需要唤醒notify,醒了后-->回到就绪(Runnable)-->运行(Running)-->死亡

await

问题7 执行Thread.interrupt()后,线程为什么不停止运行?

因为执行Thread.interrupt(),只会引起如下两个地方发生变化,不会抛出InterruptedException异常,不会停止当前线程。

  • Thread内Interrupted状态位被设置为true。

  • 从 LockSupport.park() 阻塞态退出,进入运行态,执行后续代码。

      private static boolean TestPark() {
      threadPark = new Thread(new Runnable()  {  
          @Override
          public void run() {
              mLock.lock();
    
              try {
                  System.out.println("1 before park");
                  LockSupport.park(Thread.currentThread());
                  System.out.println("3 after park");
                  //Thread.interrupted();
                  System.out.println("4 isInterrupted " + threadPark.isInterrupted());
    
                  try {
                      mCondition.await();
                  } catch (Exception e) {
                      System.out.println("5 发生中断 " + e.getMessage());
                  }
              } finally {
                  mLock.unlock();
              }
          }
      });
      threadPark.start();
    
      try {
          Thread.sleep(1000);
          System.out.println("Thread.sleep(1000)");
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
    
      System.out.println("2 执行interrupt()");
      threadPark.interrupt();
      return true;
    }
    

无Thread.interrupted()的执行结果
1 before park
Thread.sleep(1000)
2 执行interrupt()
3 after park
4 isInterrupted true
5 发生中断 null
Process finished with exit code 0

再介绍一个常用函数Thread.interrupted() ,具有消费Interrupted消息能力,运行结果如下:

有Thread.interrupted()的执行结果
1 before park
Thread.sleep(1000)
2 执行interrupt()
3 after park
4 isInterrupted false
Process finished with exit code 130 (interrupted by signal 2: SIGINT)

可以看出,Interrupted消息被提前消费后,mCondition.await()不再发生中断。

有什么用呢?用于区分处理不同阶段发生的中断。比如执行await()后,线程进入等待态,当执行signal()由等待态进入阻塞态。通过Thread.interrupt()可以区分等待态还是阻塞态发生的中断,最终做区分处理,如下:

  • 等待态执行 Thread.interrupt(),提前进入阻塞态,当获得锁资源后,会抛出InterruptedException异常,由用户catch自行处理。
  • 阻塞态执行 Thread.interrupt(),无反应,会再次进入阻塞态,当获得锁资源后,不抛出异常,需要用户自行判断处理。

概念汇总

概念汇总

下文Java ReentrantLock锁机制源码篇

你可能感兴趣的:(Java ReentrantLock锁机制概念篇)