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

2,136 阅读24分钟

前言

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

面试环节

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

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

  • 我:Java中每个对象都可以作为锁,这是synchronized实现同步的基础: 1、普通同步方法:锁是当前实例对象。
    2、静态同步方法,锁是当前类的class对象。
    3、同步代码块:锁是括号里的对象。

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

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

  • 面试官:你刚提到了每个对象都有一个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操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。

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

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

4、如果这个更新操作失败了,虚拟机首先会检查对象的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的原则定义如下:
    1、如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
    2、两个操作之间存在happens-before关系,并不一定意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
    下面是happens-before的原则规则:
    1、程序次序规则:一个线程内,按照代码书写顺序,书写在前面的操作先行发生于书写在后面的操作。
    2、锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
    3、volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
    4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
    5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个动作。
    6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
    7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测。
    8、对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。

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

  • 我:不是的,它需要满足以下两个条件:
    1、在单线程环境下不能改变程序运行的结果。
    2、存在数据依赖关系的不允许重排序。
    其实这两点可以归结为一点:无法通过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之间重排序,可能会变成下面这种执行顺序:
1、线程A执行flag=true;
2、线程B执行if(flag);
3、线程B执行int i = a+a;
4、线程A执行a=1。
按照这种执行顺序线程B肯定读不到线程A设置的a值,在这里多线程的语义就已经被重排序破坏了。操作3和操作4之间也可以重排序,这里就不阐述了。但是他们之间存在一个控制依赖的关系,因为只有操作3成立操作4才会执行。当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。假如操作3和操作4重排序了,操作4先执行,则先会把计算结果临时保存到重排序缓冲中,当操作3为真时才会将计算结果写入变量i中。

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

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

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

i=0;  //1
j=i;  //2
i++; //3
i=j+1; //4

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

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

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

那么volatile的内存语义是如何实现的呢?对于一般的变量会被重排序,而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序。
volatile的重排序规则
1、如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前。
2、当第二个操作为volatile写,则不管第一个操作是啥,都不能重排序。这个操作确保了volatile写之前的操作不会被编译器重排序到volatile写之后。
3、当第一个操作为volatile写,第二个操作为volatile读,不能重排序。

volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以JMM采用了保守策略。 如下:
1、在每一个volatile写操作前插入一个StoreStore屏障。
2、在每一个volatile写操作后插入一个StoreLoad屏障。
3、在每一个volatile读操作后插入一个LoadLoad屏障。
4、在每一个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的源码: 1、Unsafe是CAS的核心类,Java无法直接访问底层操作系统,而是通过本地native方法访问。不过尽管如此,JVM还是开了个后门:Unsafe,它提供了 硬件级别的原子操作。
2、valueOffset:为变量值在内存中的偏移地址,Unsafe就是通过偏移地址来得到数据的原值的。
3、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提供了两种方法来实现多处理器的原子操作:总线加锁或者缓存加锁。
    1、总线加锁:总线加锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显然 有点霸道。
    2、缓存加锁:其实针对上面的情况,我们只需要保证在同一时刻,对某个内存地址的操作是原子性的即可。缓存加锁,就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时, 处理器不再输出#LOCK信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行 中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行。

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

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

结语

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

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

往期精彩回顾

今天面试了吗系列
redis:juejin.cn/post/684490…
spring:juejin.cn/post/684490…
mybatis:juejin.cn/post/684490…

数据库系列
mysql索引:juejin.cn/post/684490…
数据库锁:juejin.cn/post/684490…
分库分表:juejin.cn/post/684490…
数据库事务:juejin.cn/post/684490…

java零零星星系列
juejin.cn/post/684490…
juejin.cn/post/684490…