当前位置:网站首页>多线程面试指南

多线程面试指南

2022-08-10 15:13:00 Liknananana

多线程面试指南

人生的艰难困苦无法选择,但是可以自己无坚不摧,星光不问赶路人,时间不复有心人

为什么需要多线程

CPU,内存,I/O 设备速度是有极大差异的,为了提高CPU的效率,而引入的多线程

  • CPU的多级缓存机制,导致了可见性问题
  • 操作系统的分时复用CPU,即时间片轮转,导致了原子性问题
  • 编译器的指令优化执行次序,使得缓存能够合理利用,导致了有序性问题

指令重排序的三种类型

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java代码到最终的执行的指令序列的过程
在这里插入图片描述
上面的1属于编译器重排,2和3是属于处理器重排序
JMM的编译器重排序规则:禁止特定的指令重排序
JMM的处理器重排序规则:Java编译器在生成指令时会插入特定的内存屏障

线程安全的实现方法

  • 互斥同步: synchronized 和 ReentrantLock
  • 非阻塞同步: CAS, AtomicXXXX
  • 无同步方案: 栈封闭,Thread Local,可重入代码

JMM内存模型

**Java并发采用的是共享内存模型,**JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。
**在Java中,所有在堆中的元素,**例如实例域,静态域,数组元素,在线程栈中的不会有内存共享的问题
JMM模型
在这里插入图片描述
线程通信的JMM流程:
在这里插入图片描述
这种一个主内存多个工作内存容易导致可见性问题,就是比如,一个工作线程A修改了共享变量x,而x没有及时刷新到主内存中,工作线程B立马就去读x,此时x读数据还没刷新到主内存。这个时候就需要插入内存屏障,插入内存屏障指令来禁止特定类型的处理器重排序

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad BarriersStore1; StoreLoad; Load2确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

happens-before

就是JMM基于指令重排呈现给程序员所规定的编程规则,来维护可见性

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

as-if-serial 语义

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

Synchronized

使用时注意:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待
  • 每个对象实例有一把自己的锁,不同对象实例互不干扰,锁对象是*.class以及synchronized修饰的是static方法时,所有对象公用一把锁
  • syschronized加锁的代码,无论是正常执行还是抛出异常都会释放锁

加锁和释放锁的原理

Syschronized加锁的代码块,被编译成字节码,会有monitor(锁)相关联,Monitorenter代表进入了锁,Monitorexit代表退出了锁

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

synchronized与原子性

通过 monitorenter 和 monitorexit 指令,可以保证被 synchronized修饰的代码在同一时 间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中 可以使用 synchronized 来保证方法和代码块内的操作是原子性的。

synchronized与可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程 能够立即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存, 线程的工作内存保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有 操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之后也无法直接 访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主内存之间 进行数据同步进行。所以,就可能出现线程一修改了某个变量的值,但线程二不可见 的情况。
前面我们介绍过,被 synchronized 修饰的代码,在开始执行时会加锁,执行完成后会 进行解锁。而为了保证可见性,有一条规则是专业的**:对一个变量解锁之前,必须先 把变量同步到主内存中。**这样解锁后,后续线程就可以访问到被修改后的值。 所以,被 synchronized 关键字锁住的对象,其值是具有可见性的

synchronized与有序性

由于synchronized修饰的代码,同一时间只能被同一个线程访问,那么也就是单线程执行,所以可以保证其有序性。

volatile

volatile的作用原理

  • **防止重排序
    **看看单例模式的双重锁检查机制:
public class Singleton {
    
    public static volatile Singleton singleton;
    /** * 构造函数私有,禁止外部实例化 */
    private Singleton() {
    };
    public static Singleton getInstance() {
    
        if (singleton == null) {
    
            synchronized (singleton.class) {
    
                if (singleton == null) {
    
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

这里加上volatile的关键字就是防止指令重排序,为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。
  • 初始化对象。
  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • 分配内存空间。
  • 将内存空间的地址赋值给对应的引用。
  • 初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

实现可见性

为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升,但是由于 引入了多级缓存,就存在缓存数据不一致的问题。 但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一 条Lock前缀的指令,lock 前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

但是就算回写内存,如果其他处理器缓存的值还是旧的,在执行计算操作就会有问 题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。
缓存一致性协议:
每个处理器通过嗅探在总线上传播的数据来检测自己缓存的信息是不是过期了,当处 理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。 所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷新入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从 主存加载到自己的缓存中,这就保证了一个volatile修饰的变量在多个缓存中是可见的。
volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。

volatile与可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程 也能够立即看到修改的值。前面在关于volatile原理的时候讲过,Java中的volatile关键字提供了一个功能,那就是 被修饰的变量在被修改后可以立即同步到主存中,被其修饰的变量在每次使用之前都 是从主内存中刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

volatile与有序性

volatile禁止指令重排优化,这就保证了代码的程序会严格按照代码的先后顺序执行, 这就保证了有序性。

volatile与原子性

在上面介绍synchronized的时候,提到过,为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令没有任何关系。所以,volatile是不能保证原子性的。在以下两个场景中可以使用volatile替代synchronized:运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的 值 变量不需要与其他状态变量共同参与不变约束 除了以上场景,都需要使用其他方式来保证原子性,如synchronized或者concurrent包。** synchronized可以保证原子性、有序性和可见性,而volatile只能保证有序性和可见性**

JVM中锁的优化

synchronized的加锁解锁需要很多性能,由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(LockElimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除(Lock Elimination):通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
  • 轻量级锁(Lightweight Locking):即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下,一个线程获得了这个锁之后,下次还是有很大的可能再次获得这个锁,避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小。
  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

锁的类型

sychronized同步锁,一共有四种状态:无锁,偏向锁,轻量级锁,重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

Synchronized与Lock

synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,…,如果获取失败,…

Lock解决相应问题

Lock类这里不做过多解释,主要看里面的4个方法:

  • lock(): 加锁
  • unlock(): 解锁
  • tryLock(): 尝试获取锁,返回一个boolean值
  • tryLock(long,TimeUtil): 尝试获取锁,可以设置超时

Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。

synchronized和ReentrantLock的区别

1. 锁的实现
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
2. 性能
新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
3. 等待可中断
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
ReentrantLock 可中断,而 synchronized 不行。
4. 公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
5. 锁绑定多个条件
一个 ReentrantLock 可以同时绑定多个 Condition 对象。使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

ReentrantLock

可重入锁:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞
**类的继承关系:**ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个条件。
![image.png](https://img-blog.csdnimg.cn/img_convert/79a177492789bf7401bc9b43cf10ae3a.png#clientId=u9e65d341-b77a-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=254&id=ue0ead942&margin=[object Object]&name=image.png&originHeight=254&originWidth=384&originalType=binary&ratio=1&rotation=0&showTitle=false&size=12148&status=done&style=none&taskId=u7d3cd410-2422-403a-b60f-b5958746b76&title=&width=384)
说明: ReentrantLock类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。

重入性的实现原理

我们来看看ReentrantLock是怎样实现的,以非公平锁为例,判断当前线程能否获得锁为例,核心方法为nonfairTryAcquire:

final boolean nonfairTryAcquire(int acquires) {
    
    final Thread current = Thread.currentThread();
    int c = getState();
    //1. 如果该锁未被任何线程占有,该锁能被当前线程获取
	if (c == 0) {
    
        if (compareAndSetState(0, acquires)) {
    
            setExclusiveOwnerThread(current);
            return true;
        }
    }
	//2.若被占有,检查占有线程是否是当前线程
    else if (current == getExclusiveOwnerThread()) {
    
		// 3. 再次获取,计数加一
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回true,表示可以再次获取成功。每次重新获取都会对同步状态进行加一的操作,那么释放的时候处理思路是怎样的了?(依然还是以非公平锁为例)核心方法为tryRelease:

protected final boolean tryRelease(int releases) {
    
	//1. 同步状态减1
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
    
		//2. 只有当同步状态为0时,锁成功被释放,返回true
        free = true;
        setExclusiveOwnerThread(null);
    }
	// 3. 锁未被完全释放,返回false
    setState(c);
    return free;
}

重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。到现在我们可以理清ReentrantLock重入性的实现了,也就是理解了同步语义的第一条。

公平锁与非公平锁

ReentrantLock支持两种锁:公平锁非公平锁何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。ReentrantLock的构造方法无参时是构造非公平锁,源码为:

public ReentrantLock() {
    
    sync = new NonfairSync();
}

另外还提供了另外一种方式,可传入一个boolean值,true时为公平锁,false时为非公平锁,源码为:

public ReentrantLock(boolean fair) {
    
    sync = fair ? new FairSync() : new NonfairSync();
}

在上面非公平锁获取时(nonfairTryAcquire方法)只是简单的获取了一下当前状态做了一些逻辑处理,并没有考虑到当前同步队列中线程等待的情况。我们来看看公平锁的处理逻辑是怎样的,核心方法为:

protected final boolean tryAcquire(int acquires) {
    
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
    
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
    
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
    
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
  }
}

这段代码的逻辑与nonfairTryAcquire基本上一直,唯一的不同在于增加了hasQueuedPredecessors的逻辑判断,方法名就可知道该方法用来判断当前节点在同步队列中是否有前驱节点的判断,如果有前驱节点说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败。如果当前节点没有前驱节点的话,再才有做后面的逻辑判断的必要性。公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁

公平锁 VS 非公平锁

  1. 公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象
  2. 公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量
原网站

版权声明
本文为[Liknananana]所创,转载请带上原文链接,感谢
https://blog.csdn.net/weixin_45882303/article/details/126210015