分享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,只生效了一次的线程安全问题。
使用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,进入运行态,重复以上步骤运行下去,直到三个线程都运行完毕。
上述过程有几个问题
- Lock环节,多线程时保证有且仅有一个线程通过Lock环节(获得锁资源),这个应该怎么实现?有同学回答我:用synchronized。MyGod!!ReentrantLock出现原因就是要取代synchronized
- 线程A通过了Lock环节执行运行态代码时,获锁失败后的B和C线程要去做什么?
- unLock后线程A释放锁资源,是如何找到、通知线程B或C去竞争锁资源的?
- 锁是个什么类型?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 变量修饰符可保证变量可见性。 支持并发三要素:可见性、原子性、有序性。
private static volatile boolean mBooleanLock = false;
运行台,最终结果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的重要性。
使用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
从运行过程截图看出,在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)-->死亡
问题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锁机制源码篇