《今天面试了吗》-并发编程的锁及内存模型

前言

面试中问的频率很高的一个是分布式,一个就是并发。而JUC(java.util.concurrent)里的东西是并发编程的基石。上次的面试已通过去一段时间,在一边努力工做的同时,我也一边抽出时间准备java并发编程的部分。今天怀着轻松愉快的心情,再次踏上个人大厂面试之旅。java

面试环节

  • 面试官:你先说下你对synchronized的了解。mysql

  • 我:synchronized能够保证方法或者代码在运行时,同一时刻只有一个方法能够进入到临界区,同时还能够保证共享变量的内存可见性。面试

  • 我:Java中每一个对象均可以做为锁,这是synchronized实现同步的基础: 一、普通同步方法:锁是当前实例对象。
    二、静态同步方法,锁是当前类的class对象。
    三、同步代码块:锁是括号里的对象。redis

  • 面试官:当线程访问同步代码块时,它首先要获得锁才能执行代码,退出或者抛异常要释放锁,这是怎么实现的呢?spring

  • 我:同步代码块是使用monitorenter和monitorexit指令实现的,同步方法依靠的是方法修饰符上的ACCSYNCHRONIZED实现的。
    一、同步代码块:monitorenter指令插入到同步代码快的开始位置,monitorexit指令插入到同步代码块的结束位置,jVM保证每个monitorexist都有 一个monitorenter与之相对应。任何对应都有一个monitor与之相关联,当且一个monitor被持有以后,他将处于锁定状态。线程执行到monitorenter指令 时,将会尝试获取对象对应的monitor全部权,即尝试获取对象的锁。
    二、同步方法:synchronized方法是在Class文件的方法表中将该方法的accessflags字段中的synchronized标志位置为1,表示该方法是同步方法 并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。sql

  • 面试官:你刚提到了每一个对象都有一个monitor与之对应,那什么是Monitor呢?数据库

  • 我:咱们能够把它理解为一个同步工具,也能够描述为一种同步机制,它一般被描述为一个对象。与一切皆对象同样,全部的java对象是天生的Monitor, 每个java对象都有成为Monitor的潜质,由于在Java的设计中,每个java对象自打娘胎出来就带了一把看不见的锁,它被叫作内部锁或者Monitor锁。编程

  • 我:(接着说)Monitor是线程私有的数据结构,每个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每个被锁住的对象 都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中由一个Owner字段存放拥有该锁的线程的惟一标识, 表示该锁被这个线程占用。缓存

  • 面试官:很好。咱们知道synchronized是悲观锁,一直以来被当作重量级锁。可是jdk1.6对锁进行了优化,好比自旋锁、适应性自旋锁、锁消除、偏向锁以及 轻量级锁等技术来减小锁操做的开销,这些你都了解吗?安全

  • 我:知道一些。锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。他们会随着竞争的激烈而逐渐升级。注意锁能够升级不可降级, 这种策略是为了提升得到锁和释放锁的效率。

  • 面试官:那你先来讲下自旋锁

  • 我:线程的阻塞和唤醒须要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来讲是一个负担很重的工做,同时影响系统的并发能力,同时咱们发现不少应用上 对象锁的锁状态只会持续很短的一段时间,为了这一段很短的时间频繁的阻塞和唤醒线程是不值得的,因此引入自旋锁。何谓自旋锁呢-就是让线程等待一段时间,不会被 当即挂起,看持有锁的线程是否会很快释放锁。那么问题来了,等多长时间呢?时间短了等不到持有锁的线程释放锁,时间长了占用了处理器的时间,典型的“占着茅坑不拉屎”, 反而带来性能上的浪费。因此,自旋等待的时间(自旋)的次数必须有一个限度,若是自旋超过了定义的时间仍没有得到锁则要被挂起。

  • 面试官:我记得有个适应性自旋锁,更加智能。你能说下么?

  • 我:所谓自适应就意味着自旋的次数再也不是固定的,它是由上一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。线程若是自旋成功了,那么下次自旋的次数 会更加多,由于虚拟机认为既然上次成功了,那么这次自旋也可能成功。反之,若是对于某个锁,不多有自旋能成功的,那么之后等待这个锁的时候自选的次数会减小甚至不自旋。 有了自适应自旋锁,虚拟机对程序锁的情况预测愈来愈准确,虚拟机会愈来愈聪明。

  • 面试官:给你看下面一段代码,你说下会存在加锁的操做吗?

public static void main(String [] args) {
        Vector<String> vector = new Vector<>();
        for (int i=0; i<10; i++) {
            vector.add(i+"");
        }
        System.out.println(vector);
    }
复制代码
  • 我:不会。这种状况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的基础是逃逸分析的数据支持。

  • 面试官:再看一段代码,分析一下是在什么地方加锁的?

public static void test() {
        List<String> list = new ArrayList<>();
        for (int i=0; i<10; i++) {
            synchronized (Demo.class) {
                list.add(i + "");
            }
        }
        System.out.println(list);
    }
复制代码
  • 我:虽然synchronized是在循环里面,但实际上加锁的范围会扩大到循环外,这是锁粗化。锁粗化就是将多个连续的加锁、解锁操做链接在一块儿,扩展 成一个范围更大的锁。

  • 面试官:你能说下轻量级锁吗?

  • 我:轻量级锁提高程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内是不存在竞争的(区别于偏向锁),这是一个经验数据。若是没有竞争,轻量级锁使用CAS操做避免了使用互斥 量的开销,但若是存在竞争,除了互斥量的开销,还额外发生了CAS操做,所以在有竞争的状况下,轻量级锁比传统的重量级锁更慢。

  • 我接着说:轻量级锁的加锁过程是: 一、在代码进入同步块的时候,若是同步对象锁状态为无锁状态(锁标志位为“01”,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word,这时候线程堆栈与对象头的状态如图:
    轻量级锁的加锁过程1.png

二、拷贝对象头中的Mark Word复制到锁记录(Lock Record)中。
三、拷贝成功后,虚拟机将使用CAS操做尝试将锁对象的Mark Word更新为指向Lock Record的指针,并将线程栈帧中的Lock Record里的owner指针指向Object的Mark Word。若是这个更新 动做成功了,那么这个线程就拥有了该对象的锁,而且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

四、若是这个更新操做失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,若是是就说明当前线程已经拥有了这个对象的锁,那就能够直接进入同步块继续执行。不然说明 多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志位的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

  • 面试官:很详细。那你能再解释下偏向锁吗?

  • 我:偏向锁的目的是消除数据在无竞争状况下的同步原语,进一步提升程序的运行性能。偏向锁会偏向于第一个得到它的线程,若是在接下来的执行过程当中,该锁没有被其余线程获取,那持有 偏向锁的线程将永远不须要同步。

  • 我顿了下,接着说:当锁第一次被线程获取的时候,线程使用CAS操做把这个线程的ID记录在对象Mark Word中,同时置偏向标志位1.之后该线程在进入和退出代码块时不须要进行CAS操做 来加锁和解锁,只须要简单测试一下对象头的Mark Word里是否存储着指向当前线程的ID。若是测试成功,表示线程已经得到了锁。当有另一个线程去尝试获取这个锁时,偏向模式就宣告结束。 根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定状态。

  • 面试官:那偏向锁、轻量级锁和重量级锁有什么区别呢

  • 我:偏向锁、轻量级锁都是乐观锁,重量级锁是悲观锁。一个对象刚开始实例化的时候,没有任何线程来访问它时,它是可偏向的,意味着它认为只可能有一个线程来访问它,因此当第一个线程 访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操做,并将对象头中的ThreadID改为本身的Id,以后再访问这个对象只须要对比ID。一旦有第二个线程访问这个对象,由于偏向锁不会释放,因此第二个线程看到对象是偏向状态,代表在这个对象上存在竞争了,检查原来持有该对象的线程是否依然存活,若是挂了,则能够将对象变为无锁状态,而后从新偏向新的线程。若是原来的线程依然存活,则立刻执行那个线程的操做栈,检查该对象的使用状况,若是仍然须要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是此时升级为轻量级锁)。若是不存在使用了,则能够将对象恢复成无锁状态,而后从新偏向。

  • 我:(接着说)轻量级锁认为竞争存在,可是竞争的程度很轻,通常两个线程对于同一个锁的操做都会错开,或者说自旋一下,另外一个线程就会释放锁。可是当自旋超过必定次数,或者一个线程持有锁, 一个线程在自旋,又有第三个来访,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程之外的线程都阻塞,防止CPU空转。简单的说就是:有竞争,偏向锁升级为轻量级锁,竞争逐渐激烈, 轻量级锁升级为重量级锁。

  • 面试官:你了解java的内存模型吗?能说下对JMM的理解吗?

  • 我:在JSR113标准中有有一段对JMM的简单介绍:Java虚拟机支持多线程执行。在Java中Thread类表明线程,建立一个线程的惟一方法就是建立一个Thread类的实例对象,当调用了对象的start方法后,相应的线程将会执行。线程的行为有时会与咱们的直觉相左,特别是在线程没有正确同步的状况下。本规范描述了JMM平台上多线程程序的语义,具体包含一个线程对共享变量的写入什么时候能被其余线程看到。这是官方的接单介绍。

  • 我:Java内存模型是内存模型在JVM中的体现。这个模型的主要目标是定义程序中各个共享变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量这类的底层细节。经过这些规则来规范对内存的读写操做,保证了并发场景下的可见性、原子性和有序性。 JMM规定了多有的变量都存储在主内存中,每条线程都有本身的工做内存,线程的工做内存保存了该线程中用到的主内存副本拷贝,线程对变量的全部操做都必须在工做内存中进行,而不是直接读写主内存。不一样线程之间也没法直接访问对方工做内存中的变量,线程间变量的传递均须要本身的工做内存和主存之间 进行数据同步。而JMM就做用于工做内存和主存之间数据同步过程。他规定了如何作数据同步以及何时作数据同步。也就是说Java线程之间的通讯由Java内存模型控制,JMM决定一个线程对共享变量的写入什么时候对另外一个线程可见。

  • 我:简单的说:Java的多线程之间是经过共享内存进行通讯的,而因为采用共享内存进行通讯,在通讯过程当中会存在一系列如原子性、可见性和有序性的问题。JMM就是为了解决这些问题出现的, 这个模型创建了一些规范,能够保证在多核CPU多线程编程的环境下,对共享变量的读写的原子性、可见性和有序性。

  • 面试官:那你说下Java内存模型的happens-before规则?

  • 我:在JMM中,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必须存在happens-before关系。happens-before原则是JMM中很是重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。下面我说下happens-before的内容: happens-before的原则定义以下:
    一、若是一个操做happens-before另外一个操做,那么第一个操做的执行结果将对第二个操做可见,并且第一个操做的执行顺序排在第二个操做以前。
    二、两个操做之间存在happens-before关系,并不必定意味着必定要按照happens-before原则制定的顺序来执行。若是重排序以后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
    下面是happens-before的原则规则:
    一、程序次序规则:一个线程内,按照代码书写顺序,书写在前面的操做先行发生于书写在后面的操做。
    二、锁定规则:一个unLock操做先行发生于后面对同一个锁的lock操做。
    三、volatile变量规则:对一个变量的写操做先行发生于后面对这个变量的读操做。
    四、传递规则:若是操做A先行发生于操做B,而操做B又先行发生于操做C,则能够得出操做A先行发生于操做C。
    五、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动做。
    六、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
    七、线程终结规则:线程中全部的操做都先行发生于线程的终止检测。
    八、对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。

  • 面试官:你刚才提到了JVM会对咱们的程序进行重排序,那是随便重排序吗?

  • 我:不是的,它须要知足如下两个条件:
    一、在单线程环境下不能改变程序运行的结果。
    二、存在数据依赖关系的不容许重排序。
    其实这两点能够归结为一点:没法经过happens-before原则推导出来的,JMM容许任意的排序。

  • 我:这里有必要提到as-if-serial语义:全部的操做均可觉得了优化而被重排序,可是你必须保证重排序后执行的结果不能被改变,编译器、runtime和处理器都必须遵照 as-if-serial语义。注意as-if-serial只保证单线程环境,多线程环境下无效。举个栗子:

int a=1; //A
int b=2; //B
int c=a+b; //C
复制代码

A,B,C三个操做存在以下关系:A和B不存在数据依赖,A和C,B和C存在数据依赖,所以在重排序的时候:A和B能够随意排序,可是必须位于C的前面,但不管何种顺序,最终结果C都是3.

  • 我接着说:下面举个重排序对多线程影响的栗子:
public class RecordExample2 {
    int a = 0;
    boolean flag = false;

    /**
     * A线程执行
     */
    public void writer(){
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
     * B线程执行
     */
    public void read(){
        if(flag){                  // 3
           int i = a + a;          // 4
        }
    }}
复制代码

假如操做1和操做2之间重排序,可能会变成下面这种执行顺序:
一、线程A执行flag=true;
二、线程B执行if(flag);
三、线程B执行int i = a+a;
四、线程A执行a=1。
按照这种执行顺序线程B确定读不到线程A设置的a值,在这里多线程的语义就已经被重排序破坏了。操做3和操做4之间也能够重排序,这里就不阐述了。可是他们之间存在一个控制依赖的关系,由于只有操做3成立操做4才会执行。当代码中存在控制依赖性时,会影响指令序列的执行的并行度,因此编译器和处理器会采用猜想执行来克服控制依赖对并行度的影响。假如操做3和操做4重排序了,操做4先执行,则先会把计算结果临时保存到重排序缓冲中,当操做3为真时才会将计算结果写入变量i中。

  • 面试官:你能给我讲下对volatile的理解吗?
  • 我:讲volatile以前,先补充说明下Java内存模型中的三个概念:原子性、可见性和有序性

一、可见性:可见性是指线程之间的可见性,一个线程修改的状态对另外一个线程是可见的。也就是一个线程的修改的结果,另外一个线程可以立刻看到。好比:用volatile修饰的变量,就会具备可见性,volatile修饰的变量不容许线程内部缓存和重排序,即直接修改内存,因此对其余线程是可见的。但这里要注意一个问题,volatile只能让被他修饰的内容具备可见性,不能保证它具备原子性。好比 volatile int a=0; ++a;这个变量a具备可见性,可是a++是一个非原子操做,也就是这个操做一样存在线程安全问题。在Java中,volatile/synchronized/final实现了可见性。

二、**原子性:即一个操做或者多个操做要么所有执行而且执行的过程不会被任何因素打断,要么都不执行。**原子就像数据库里的事务同样,他们是一个团队,同生共死。看下面一个简单的栗子:

i=0;  //1
j=i;  //2
i++; //3
i=j+1; //4
复制代码

上面的四个操做,只有1是原子操做,其余都不是原子操做。好比2包含了两个操做:读取i,将i值赋给j。在Java中synchronized/lock操做中保证原子性。

三、有序性:程序执行的顺序按照代码的前后顺序执行。 前面JMM中提到了重排序,在java内存模型中,为了效率是容许编译器和处理器对指令进行重排序,并且重排序不会影响单线程的运行结果,可是对多线程有影响。Java中提供了volatile和synchronized保证有序性。

  • 我:volatile的原理是volatile能够保证线程可见性且提供了必定的有序性,可是没法保证原子性,在JVM底层volatile是采用“内存屏障”来实现的。总结起来就是: 一、保证可见性,不保证原子性。 二、禁止指令重排序。
  • 我:下面我来分析下volatile的这两条性质。 volatile的内存语义是: 一、当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值当即刷新到主内存中。
    二、当读一个volatile变量时,JMM会把线程的本地内存置为无效,直接从主内存中读取共享变量。 因此volatile的写内存语义是直接刷新到主内存中,读内存语义是直接从主内存中读取---因此才能实现线程可见性。

那么volatile的内存语义是如何实现的呢?对于通常的变量会被重排序,而对于volatile则不能,这样会影响其内存语义,因此为了实现volatile的内存语义JMM会限制重排序。
volatile的重排序规则
一、若是第一个操做为volatile读,则无论第二个操做是啥,都不能重排序。这个操做确保volatile读以后的操做不会被编译器重排序到volatile读以前。
二、当第二个操做为volatile写,则无论第一个操做是啥,都不能重排序。这个操做确保了volatile写以前的操做不会被编译器重排序到volatile写以后。
三、当第一个操做为volatile写,第二个操做为volatile读,不能重排序。

volatile的底层实现是经过插入内存屏障,可是对于编译器来讲,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,因此JMM采用了保守策略。 以下:
一、在每个volatile写操做前插入一个StoreStore屏障。
二、在每个volatile写操做后插入一个StoreLoad屏障。
三、在每个volatile读操做后插入一个LoadLoad屏障。
四、在每个volatile读操做后插入一个LoadStore屏障。
总结:StoreStore屏障->写操做->StoreLoad屏障->读操做->LoadLoad屏障->LoadStore屏障。 下面经过一个例子简单分析下: volatile原理分析.jpg

  • 面试官:很好,看来你对volatile理解的挺深刻的了。咱们换个话题,你知道CAS吗,能跟我讲讲吗?
  • 我:CAS(Compare And Swap),比较并交换。整个AQS同步组件,Atomic原子类操做等等都是基于CAS实现的,甚至ConcurrentHashMap在JDK1.8版本中,也调整为CAS+synchronized。 能够说,CAS是整个JUC的基石。以下图:

  • 我:CAS的实现方式其实不难。在CAS中有三个参数:内存值V、旧的预期值A、要更新的值B,当且仅当内存值V的值等于旧的预期值A时,才会将内存值V的值修改成B,不然什么也不干,是一种乐观锁。 其伪代码以下:
if (this.value == A) {
    this.value = B
    return true;
} else {
    return false;
}
复制代码
  • 我:接着我举了个AtomicInteger的例子,来给面试官阐述CAS的实现。
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;
复制代码

如上是AtomicInteger的源码: 一、Unsafe是CAS的核心类,Java没法直接访问底层操做系统,而是经过本地native方法访问。不过尽管如此,JVM仍是开了个后门:Unsafe,它提供了 硬件级别的原子操做。
二、valueOffset:为变量值在内存中的偏移地址,Unsafe就是经过偏移地址来获得数据的原值的。
三、value:当前值,使用volatile修饰,保证多线程环境下看见的是同一个。

// AtomicInteger.java
public final int addAndGet(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}

// Unsafe.java
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
复制代码

在方法compareAndSwapInt(var1, var2, var5, var5 + var4)中,有四个参数,分别表明:对象,对象的地址,预期值,修改值。

  • 我:CAS能够保证一次的读-改-写操做是原子操做,在单处理器上该操做容易实现,可是在多处理器上实现就有点复杂。CPU提供了两种方法来实现多处理器的原子操做:总线加锁或者缓存加锁。
    一、总线加锁:总线加锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其余处理器的请求将被阻塞住,那么该处理器能够独占使用共享内存。可是这种处理方式显然 有点霸道。
    二、缓存加锁:其实针对上面的状况,咱们只须要保证在同一时刻,对某个内存地址的操做是原子性的便可。缓存加锁,就是缓存在内存区域的数据若是在加锁期间,当它执行锁操做写回内存时, 处理器再也不输出#LOCK信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制能够保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行 中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行。

  • 面试官:那CAS有什么缺陷吗?

  • 我:CAS虽然高效的解决了原子问题,可是仍是存在一些缺陷的,主要体如今三个方面:
    一、循环时间太长:若是自旋CAS长时间不成功,则会给CPU带来很是大的开销,在JUC中,有些地方就会限制CAS自旋的次数。
    二、只能保证一个共享变量原子操做:看了CAS的实现就知道这只能针对一个共享变量,若是是多个共享变量就只能使用锁了。或者把多个变量整成一个变量也能够用CAS。
    三、ABA问题:CAS须要检查操做值有没有发生改变,若是没有发生改变则更新,可是存在这样一种状况:若是一个值原来是A,变成了B,而后又变成了A,那么在CAS检查的时候会发现没有改变,可是实质上它已经发生了改变,这就是所谓的ABA问题。对于ABA问题的解决方案是加上版本号,即在每一个变量都加上一个版本号,每次改变时加1,即A->B->A,变成1A->2B->3A。例如原子类中AtomicInteger会发生ABA问题,使用AtomicStampedReference能够解决ABA问题。

结语

有段时间没更《今天面试了吗》系列了。在面试里,多线程,并发这块问的仍是很是频繁的,不过JUC这块的内容实在太多,一篇文章很难理清楚。今天是第一章节,未完待续...

关注公众号随时阅读精彩文章

往期精彩回顾

今天面试了吗系列
redis:juejin.im/post/5dccf2…
spring:juejin.im/post/5e6d99…
mybatis:juejin.im/post/5e80b6…

数据库系列
mysql索引:juejin.im/post/5d6770…
数据库锁:juejin.im/post/5dbbc1…
分库分表:juejin.im/post/5dc77a…
数据库事务:juejin.im/post/5dcb9c…

java零零星星系列
juejin.im/post/5d5e26…
juejin.im/post/5d427f…

相关文章
相关标签/搜索