面试必看花了三天整理出来的并发编程的锁及内存模型看完你就明白了

8次阅读

共计 10161 个字符,预计需要花费 26 分钟才能阅读完成。

前言

最近看到有不少粉丝私信我说,能不能给整理出一份面试的要点出来,说自己复习的时候思绪很乱,老是找不到重点。那么今天就先给大家分享一个面试几乎必问的点,并发!在面试中问的频率很高的一个是分布式,一个就是并发,具体干货都在下方了。

面试环节

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

  • 我:synchronized 可以保证方法或者代码在运行时,同一时刻只有一个方法可以进入到临界区,同时还可以保证共享变量的内存可见性。
  • 我:Java 中每个对象都可以作为锁,这是 synchronized 实现同步的基础:1、普通同步方法:锁是当前实例对象。
    2、静态同步方法,锁是当前类的 class 对象。
    3、同步代码块:锁是括号里的对象。

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

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

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

  • 我:我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的 java 对象是天生的 Monitor,每一个 java 对象都有成为 Monitor 的潜质,因为在 Java 的设计中,每一个 java 对象自打娘胎出来就带了一把看不见的锁,它被叫做内部锁或者 Monitor 锁。
  • 我:(接着说)Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象 都会和一个 monitor 关联(对象头的 MarkWord 中的 LockWord 指向 monitor 的起始地址),同时 monitor 中由一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

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

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

5. 面试官:那你先来说下 自旋锁

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

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

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

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

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 会对这些同步锁进行 锁消除。锁消除的基础是逃逸分析的数据支持。

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

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 是在循环里面,但实际上加锁的范围会扩大到循环外,这是锁粗化。锁粗化 就是将多个连续的加锁、解锁操作连接在一起,扩展 成一个范围更大的锁。

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

  • 我:轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内是不存在竞争的(区别于偏向锁),这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥 量的开销,但如果存在竞争,除了互斥量的开销,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
  • 我接着说:轻量级锁的加锁过程是:1、在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word,这时候线程堆栈与对象头的状态如图:
    轻量级锁的加锁过程

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 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

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

  • 我:偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,那持有 偏向锁的线程将永远不需要同步。
  • 我顿了下,接着说:当锁第一次被线程获取的时候,线程使用 CAS 操作把这个线程的 ID 记录在对象 Mark Word 中,同时置偏向标志位 1. 以后该线程在进入和退出代码块时不需要进行 CAS 操作 来加锁和解锁,只需要简单测试一下对象头的 Mark Word 里是否存储着指向当前线程的 ID。如果测试成功,表示线程已经获得了锁。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定状态。

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

  • 我:偏向锁、轻量级锁都是乐观锁,重量级锁是悲观锁。一个对象刚开始实例化的时候,没有任何线程来访问它时,它是可偏向的,意味着它认为只可能有一个线程来访问它,所以当第一个线程 访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 Id,之后再访问这个对象只需要对比 ID。一旦有第二个线程访问这个对象,因为偏向锁不会释放,所以第二个线程看到对象是偏向状态,表明在这个对象上存在竞争了,检查原来持有该对象的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是此时升级为轻量级锁)。如果不存在使用了,则可以将对象恢复成无锁状态,然后重新偏向。
  • 我:(接着说)轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说自旋一下,另一个线程就会释放锁。但是当自旋超过一定次数,或者一个线程持有锁,一个线程在自旋,又有第三个来访,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转。简单的说就是:有竞争,偏向锁升级为轻量级锁,竞争逐渐激烈,轻量级锁升级为重量级锁。

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

  • 我:在 JSR113 标准中有有一段对 JMM 的简单介绍:Java 虚拟机支持多线程执行。在 Java 中 Thread 类代表线程,创建一个线程的唯一方法就是创建一个 Thread 类的实例对象,当调用了对象的 start 方法后,相应的线程将会执行。线程的行为有时会与我们的直觉相左,特别是在线程没有正确同步的情况下。本规范描述了 JMM 平台上多线程程序的语义,具体包含一个线程对共享变量的写入何时能被其他线程看到。这是官方的接单介绍。
  • 我:Java 内存模型是内存模型在 JVM 中的体现。这个模型的主要目标是定义程序中各个共享变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量这类的底层细节。通过这些规则来规范对内存的读写操作,保证了并发场景下的可见性、原子性和有序性。 JMM 规定了多有的变量都存储在主内存中,每条线程都有自己的工作内存,线程的工作内存保存了该线程中用到的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不是直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间 进行数据同步。而 JMM 就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。也就是说 Java 线程之间的通信由 Java 内存模型控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
  • 我:简单的说:Java 的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如原子性、可见性和有序性的问题。JMM 就是为了解决这些问题出现的,这个模型建立了一些规范,可以保证在多核 CPU 多线程编程的环境下,对共享变量的读写的原子性、可见性和有序性。

12. 面试官:那你说下 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()方法的开始。

13 面试官:你刚才提到了 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 中。

14. 面试官:你能给我讲下对 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 原理分析

15. 面试官:很好,看来你对 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 的缓存行。

16. 面试官:那 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 问题。

结语

其实在面试里,多线程,并发这块问的还是非常频繁的,大家看完之后有什么不懂的欢迎在评论区讨论,也可以私信问我,一般我看到之后都会回的的!欢迎关注我的公众号:前程有光,领取更多 java 面试资料

正文完
 0