JDK8锁机制面试终结者

344 阅读12分钟

@[toc]

本文故事

这是一个面试向的文章,所以我不会讲得太过正统,争取把枯燥的理论知识讲得浅显易懂一点。另外,我会尽量把知识点整理得全面完整,所以篇幅会比较长。但是只要你耐心看完,保证以后不管遇到什么java锁机制方面的问题,都可以玩玩全全游刃有余。下次再有面试官要拿锁方面的问题来为难你,嘿嘿,让他小心点,别翻车。

什么是锁?为什么要用锁?

只要有资源竞争,就需要有锁。为了引出锁问题,先看下面一段简单的代码:

import java.util.concurrent.CountDownLatch;
public class ThreadTest {
    private static final CountDownLatch countDown = new CountDownLatch(1);
    private static int m = 0;
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                try {
                    countDown.await();
                    m++;
                    System.out.println("m = " + m);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            },"thread"+i).start();
        }
        countDown.countDown();
    }
}

这段代码执行结果,三个线程都给m进行--操作,我们期待是没能打印出3,2,1的结果,但是执行后发现,打印出来的结果永远不会是3,2,1.而会是3,2,2或者3,3,2这样的。这就是因为多个线程共同竞争资源m造成的。而为了避免这种多线程环境下的资源竞争问题,就需要加入锁。加锁的方式大致有三种,一种是使用Atomic原子类,另一种是使用可重入锁ReentrantLock,这两种是使用的同一个机制。另一种是使用Synchronize关键字。下面会依次来整理一下这些锁。

这里可以延伸出一个经常考的面试题: 给m 加上 volatile 关键字,能不能解决并发问题?

答案是NO,NO,NO。 volatile只是解决多线程及时可读问题,并不能保证原子性。简单理解就是volatile关键字只适用于少量(一个)线程写,多个线程读的场景。

Atomic原子操作 和 ReentrantLock

把这两种方式放在一起,是因为这两种方式都是通过java代码搭配sun.misc.Unsafe中的本地调用实现的,属于同一种锁机制。

以原子操作为例,原子操作是使用java.util.concurrent包下的AtomicXXX类进行原子操作。

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadTest {
    private static final CountDownLatch countDown = new CountDownLatch(1);
    private static AtomicInteger m = new AtomicInteger(0);
    private static int i = 0 ;
    public static void main(String[] args) {
        for (i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    countDown.await();
                    m.incrementAndGet();
                    System.out.println("m = " + m);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            },"thread"+i).start();
        }
        countDown.countDown();
    }
}

这样可以保证每次执行的结果都是1~10(打印时间有先后,这个不用管)。那是怎么做到的?当然是跟踪代码。一路跟踪incrmentAndGet方法,会跟踪到一个sun.mic.Unsafe类,这个是jdk的基础包rt.jar中包含的一个类。里面有一大堆这样的方法。

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

这些方法都是native本地方法,是调用的JVM内的C++语言实现的。实现方法需要查看JDK源码才能看得到。但是这些方法有一个共同点,都是compareAndSwap开头,很显然,这就是他们的共同点, CAS算法。

CAS算法与自旋锁

CAS算法就是比较再交换的算法。为了避免线程在修改一个值的过程当中被其他线程给修改了,在修改一个内存的值时,先把值读出来,为E, 然后计算出值V(计算操作),在把V往原内存写的时候,比较一下原内存地址的值是否和E相同, 相同就往回写,不相同就再次重复。这样可以保证不会有线程共享值冲突。整体如下图:

CAS 这个过程其实就引出了一个重要的概念,自旋锁。即如果线程如果一直更新不成功,那线程就会一直不停去执行红色的流程而不会停止。

这里有两个面试题: 1、ABA问题。就是在一次自旋过程中,一个值经过多次修改(值由A变成B,又由B又变成A,最终又变回原来的值,而CAS会认为值没有变,就会发生同步问题。实际上,在绝大部分情况下,虽然中间过程被忽略了,但是只要语义正确,并不会引起太大问题,而如果要彻底解决ABA问题,可以加个加版本号(AtomicStampedReference)或者加Boolean标志(AtomicMarkableReference)来解决。 2、CAS算法本身并不能保证线程操作的原子性。在比较完,到更新值V之前,依然可以被其他线程修改。而JAVA的C++底层,最终还是调用汇编指令lock申请了一个信号锁,最终保持整个线程操作的原子性。所以CAS要保证原子性,还是需要锁,只是锁已经转移到了汇编级别。

基于这种机制,在JDK中还有很多相关的工具,如RetrantLock、ReadWriteLock等很多工具。 Atomic操作其实更多的细节在底层C++代码中,对于我们来说能看到的细节比较少。那别急,下面来仔细看看JDK提供的Synchronize关键字。

Synchronized关键字

Synchronized关键字是通过一对字节码汇编指令monitorenter/monitorexit来实现的,是JDK一开始就有的关键字。自JDK1.6以后,synchronized被进行了大幅度的优化,由重量级锁改为了轻量级锁,性能得到极大的优化。因此,官方也开始建议优先使用Synchronized关键字来执行同步操作。

JOL: Java Object Layout

在了解Synchronized机制前,还是先来个示例看看。先建立一个Maven工程,引入下面openjdk中的一个依赖来辅助我们了解Synchronized的实现机制。

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
 </dependency>

这个工具包可以辅助打印出java对象的内存结果。先不用管细节,来一个简单的示例,看看新鲜:

public static void main(String[] args) throws Exception{
        Object o = new Object();
        System.out.println("step 1: new Object");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

        synchronized(o){
            System.out.println("step 2: add synchronized");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
       }
    }

下面来分析下执行结果: JOL 首先看红色部分,这里带出了java对象的一些内存结构。一个对象由16字节构成,前面12个都是object header,最后一个是补充字节,让对象整体长度可以被8整除。三个object header中,第一个叫做markword,类似一个对象标记位。第二个是class point,这个是对象的类指针,指向所属的class类,当然,这是经过压缩过的。第三个是成员变量的指针,当然,成员变量是可以有多个的。

然后我们看黄色部分,细节部分先不用管,但是通过比对上锁前后的类布局,我们首先可以得到第一个结论:JVM锁只作用在对象的markword位。简单的说就是上锁并不会改变对象本身,只是影响他的一个标志位。

有了这个示例后,就可以为我们后面的钻牛角尖打个基础了。

轻量级锁(用户空间锁) VS 重量级锁

其实前面提到了一下,jdk1.6对synchronized的一个重大改进就是将其由重量级锁改为了轻量级锁。那就先来扯扯这两种锁状态。

首先,在操作系统中,CPU有两种运行状态,一种是内核态,一种是用户态。其中,内核态主要是运行操作系统程序的,可以操作硬件,而用户态就是主要用来运行用户的应用程序的,不可以直接操作硬件。有这样的区分就是为了防止用户应用程序直接操作硬件,造成不可控的后果。

可以想象如果有一个应用程序可以把硬盘的引导区给格式化掉,那会是什么状况?传说在早期操作系统中,还真出现过这样的应用程序。

然后,轻量级锁就相当于用户态的锁,由应用程序自己控制,例如之前提到的自旋锁。而重量级锁,就相当于内核态的锁,交由操作系统进行管理,例如我们将java线程wait()后,实际上就相当于是上了重量级锁。

把用户程序比作一个房子,锁就可以比作房子内的一张门,门上的轻量级的锁就相当于房主可以自己开门关门,而重量级锁就相当于找了小区物业来帮你管理这张门,开门关门都需要找物业申请。这个例子也就可以用来理解为什么说jdk1.6后synchronized关键字的性能得到了大幅度的提升。

轻量级锁在一定条件下(锁数量、CPU占用率,可以配置),会升级为重量级锁。因为轻量级锁会一直不断的自旋,而自旋显然是要消耗系统资源的,当消耗到一定程度,当然就会想办法减少自旋,这时就会升级为重量级锁。当锁竞争升级为重量级锁后,就会停止自旋,而另外有机制让线程进入休眠,等待唤醒。

轻量级锁竞争 VS 重量级锁竞争

经过前面的描述,就可以有个大致的概念,轻量级锁是将锁对象的markword部分指向一个用户空间里的对象,而重量级锁就是将锁对象的markword部分指向一个操作系统内的数据结构。那显然,他们竞争锁的过程也是不同的。

重量级锁竞争过程中,被锁对象的markwork会通过CAS操作尝试更新为一个包含了操作系统互斥量(mutex)和条件变量(condition variable)的指针。交由操作系统来控制对象的markword信息。

轻量级锁竞争过程才是我们关注的重点。JVM中线程竞争轻量级锁的过程大致有一下几步

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁,虚拟机首先会在当前线程的栈帧中建立一个名为锁记录Lock Record的控件,用来存储锁对象目前的MarkWord拷贝。官方称之为Displaced Mark Word

  2. 拷贝对象头中的MarkWord复制到锁记录中,这表明当前线程要竞争锁对象。这时线程堆栈与对象头的状态如下图: JVM锁竞争1

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将锁对象的MarkWord更新为指向Lock Rocket的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功,就往下执行,否则就开始自旋,自旋一定次数后,就会往重量级锁升级。

  4. 如果更新动作成功,那么这个线程有拥有了该对象的锁,并且锁对象的MarkWord锁状态值为00(偏向锁,下面会提到),就表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如下图所示: JVM锁竞争2

这个锁竞争过程可以比喻为很多人(线程)一起上厕所抢蹲位(锁对象)。每个人上厕所时,会把自己的名牌贴在厕所门上,上完了厕所再把名牌拿走。后面的人看到门上有人了,就出去转转,等下再来。如果下次回来门上的名牌没了,他就上。但是如果出去转了很多次,厕所还是没空出来,那他就没办法了,只能去找物业帮忙协调找厕所蹲位了。

JVM锁升级

在JDK1.6后,JDK在实践中发现一种假设,即大多数synchronized竞争情况下,其实只有一个线程在运行。于是,JVM在实际中又加入了另一种更轻量的锁,叫做偏向锁。所以JVM中的锁机制有如下三种:

偏向锁(Biased Lock)>轻量级锁(Lightweight Lock)>重量级锁(Heavyweight Lock)

偏向锁也是属于一种轻量级锁。这三种机制的切换是根据资源竞争激烈程度进行的。在几乎无竞争的条件下,会使用偏向锁。当竞争渐趋激烈,会升级为轻量级锁。当竞争过于激烈,就会升级为重量级锁。JVM中锁升级的过程如下图: JVM锁升级 看上图,升级的过程大致都已经比较清楚了,那其中有一个奇怪的文字,偏向锁未启动,这是什么意思呢?

这里涉及到一个面试经常要问的问题:打开偏向锁一定能够提升性能吗?为什么?

既然这么问,答案肯定是否了。为什么呢?在某些明知资源竞争非常激烈的情况下(例如应用启动过程,需要加载大量的资源,肯定会有非常激烈的资源竞争),如果还要打开偏向锁,那厕所门上贴名牌撕名牌的过程也会相当频繁,这也会消耗相当多的资源。这时,跳过偏向锁,直接升级为轻量级锁,更能节省资源。

实际上, JVM中有两个跟偏向锁有关的启动参数:

-XX:-UseBiasedLocking 启动偏向锁

-XX:BiasedLockingStartupDelay=0 偏向锁启动延迟。默认值是4秒,即JVM启动4秒左右后才会打开偏向锁。

这里首先插入一个小知识,就是HotSpot的JAVA对象内存布局。整体如下图所示。其中红框的标志位部分就是对象的锁状态。现在看不懂没关系,我们结合这个表和下面的示例就能看明白这个锁标志了。先看最后两位,如果是01(偏向锁),那就再往前看一位。 HotSpot对象头结构 既然有配置,那就要验证一下。我们用下面这个简单的例子来演示一下。

    public static void main(String[] args) throws Exception{
        Object o = new Object();
        System.out.println("object 1 no biasedLock");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        Thread.sleep(5000);//休眠时间超过-XX:BiasedLockingStartupDelay默认值4秒
        Object o2 = new Object();
        System.out.println("object 2 biasedLock opened");
        System.out.println(ClassLayout.parseInstance(o2).toPrintable());
    }

我们来看下执行结果:(看到这里,记得回头再看看上面加了synchronized关键字后的对象布局情况) JVM锁状态2 JVM的锁机制到这里,大致就已经弄完了。把这些细节搞明白了,至少java面试官就不用怕了吧。

什么? 这个锁状态位怎么还扯到GC了?好吧。 下一章,我们就来搞定GC。