查看原文
其他

面试16解析-深挖锁(上)

2017-05-09 javatiku Java面试那些事儿

题目:请分析一下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中其他类型锁的讲解,放在下篇。


记住,这里全部都是干货!!!

这是一个靠谱的Java圈子



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存