本文阅读大概需要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前缀在处理器级别确保了对内存的读-改-写操作原子执行。