面试16解析-深挖锁(上)
题目:请分析一下Java锁机制的实现原理(主要是画Monitor示意图);Java锁有哪些种类,以及它们的区别?
本文阅读大概需要25分钟。
这个题目主要考查锁的原理及种类。由于这个题目涉及的内容比较多,分为多篇来解答。
什么是锁(Lock)?
这里我们先来说说什么是锁?在计算机科学设计的模型中,很多模型都来自于现实生活,锁便是其中一例。在现实生活中,为了保护我们的房间不被其他人随意进入,可以给房门上把锁,只有获取了该锁钥匙的人,才能打开锁,进入房间。而在软件开发中,也正借鉴了现实生活中的锁的功能与用途,抽象出了锁的概念,多线程就类比进入该房间的人,而被锁保护的代码就是房间,只有拥有钥匙(获取了锁)的人(线程)才能进入房间(被保护的同步块代码)。
synchronized
Java中的关键字synchronized便是一种锁,只是synchronized会隐式的进行获取锁与释放锁的操作。下面我们通过一个例子来分析一下synchronized的使用:
static Object lock = new Object();
static int shareSafeCount = 0;
static int shareCount = 0;
static void synchronizedExample() throws Exception {
for (int ix = 0; ix != 2000; ix++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
shareCount++;
synchronized (lock) {
shareSafeCount++;
}
}
}).start();
}
Thread.sleep(10000); //sleep2秒,等待线程执行完成(实际代码中不推荐这样等待线程完成)
System.out.println("shareCount:" + shareCount);
System.out.println("shareSafeCount:" + shareSafeCount);
}
作者本机的执行结果:
shareCount:1988
shareSafeCount:2000
在该示例中,创建了2000个工作线程对两个共享变量进行自增操作,其中对shareSafeCount的累加操作是线程安全的,被synchronized同步块保护,其运行结果不出所料是2000,而线程对于shareCount变量的操作却由于没有被互斥保护,其运行结果不一定是2000(注意是不一定!)。有很多同学在编写这样的例子中,经常会发现两个变量的运行结果都是2000,其原因在于自增操作(同步块内的操作)运行速度很快,其运行速度快于线程创建的速度,因此看似创建了2000个线程,实则只是依次创建了2000个线程对一个变量进行累加而已,并没有遇到多线程对同一资源竞争的情况,这也是本作者在run()函数中让线程休眠100ms的原因,目的在于不让线程退出的那么快,对变量进行资源竞争。
好了,我们回归正题,来看看jvm是如何保证synchronized包裹的代码线程安全的。我们看看这段代码对应的jvm指令是如何的(这里只截取了关键jvm指令部分):
10: getstatic #6 // Field MutiThreadTest.shareCount:I
13: iconst_1
14: iadd
15: putstatic #6 // Field MutiThreadTest.shareCount:I
18: getstatic #7 // Field MutiThreadTest.lock:Ljava/lang/Object;
21: dup
22: astore_1
23: monitorenter
24: getstatic #8 // Field MutiThreadTest.shareSafeCount:I
27: iconst_1
28: iadd
29: putstatic #8 // Field MutiThreadTest.shareSafeCount:I
32: aload_1
33: monitorexit
34: goto 42
其中指令10到22是对shareCount的自增操作,指令23到33是对shareSafeCount的自增操作,通过对比可以发现,对shareSafeCount的操作多了两条指令moniterenter和moniterexit。这两条指令便是synchronized关键字的隐式获取锁与释放锁对应的指令,这两个指令划分了一片同步块,具有排他性,当有线程进入该同步块后,其他线程必须等待在monitereneter指令上,直到进入同步块的线程通过moniterexit指令退出后,其他线程才可以进入同步块。
从本质上来说,moniterenter与moniterexit是一组排他的对某一对象监视器进行尝试获取的过程(该对象正是示例中的lock),同一时刻只有一个线程成功获取对象监视器。在线程运行到同步块时,会通过moniterenter指令尝试获取对象的监视器,如果获取成功,则进入同步块,执行同步块内指令,如果获取失败,则会进入同步队列(SynchronizedQueue)中进行等待,线程当前状态变为BLOCK(阻塞)状态,直到有线程释放监视器。下图描述了moniterenter与moniterexit与线程运行的关系:
深挖对象监控器Moniter
上文的分析过程,我们理解了sychronized同步代码块的同步原理是jvm提供的对象监控器获取机制来进行同步控制的,这里我们再来深挖一下对象监控器获取的实现,这一实现当然是在java虚拟机中实现的,下面是OpenJDK8中的Hotspot虚拟机源码(ObjectMoniter.cpp),对moniterenter的实现:
bool ObjectMonitor::try_enter(Thread* THREAD) {
if (THREAD != _owner) {
if (THREAD->is_lock_owned ((address)_owner)) {
assert(_recursions == 0, "internal state error");
_owner = THREAD ;
_recursions = 1 ;
OwnerIsThread = 1 ;
return true;
}
if (Atomic::cmpxchg_ptr (THREAD, &_owner, NULL) != NULL) {
return false;
}
return true;
} else {
_recursions++;
return true;
}
}
我们可以看到,运行线程获取到moniter的核心就是一个原子操作的Atomic::cmpxchg_ptr()是否成功(cmpxchg指令还熟悉吗?CAS的实现便是CMPXCHG),因此线程对moniter的获取操作同步控制的核心依然是依靠CAS操作的原子性来实现的。
JVM的Atomic::cmpxchg_ptr()实现
这里我们对于同步的理解已经深挖如java虚拟机了,既然这样我们就深挖到底,看看java同步控制到底在cpu级别是如何保证的,这也是对之前一系列java同步文章的补充。下面是HotSpot虚拟机Atomic::cmpxchg_ptr()的在windows平台下的实现(作者比较熟悉windows系统编程,源码在atomic_windows_x86.inline.hpp中):
inline intptr_t Atomic::cmpxchg_ptr(intptr_t exchange_value, volatile intptr_t* dest, intptr_t compare_value) {
return (intptr_t)cmpxchg((jint)exchange_value, (volatile jint*)dest, (jint)compare_value);
}
这里还不是核心,继续看Atomic::cmpxchg()的实现:
inline jlong Atomic::cmpxchg (jlong exchange_value, volatile jlong* dest, jlong compare_value) {
__asm {
push ebx
push edi
mov eax, cmp_lo
mov edx, cmp_hi
mov edi, dest
mov ebx, ex_lo
mov ecx, ex_hi
LOCK_IF_MP(mp)
cmpxchg8b qword ptr [edi]
pop edi
pop ebx
}
}
看到了吗?这段内联汇编代码便是CAS的同步操作的核心实现,在汇编指令CMPCHG指令之前,加入了lock前缀,如果是单处理器便会省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果),lock前缀在处理器级别确保了对内存的读-改-写操作原子执行。
介于微信公众号文章篇幅不宜过长,关于java中其他类型锁的讲解,放在下篇。
记住,这里全部都是干货!!!