多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)(上)

简介: 多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)

💕"跑起来就有意义"💕

作者:Mylvzi

文章主要内容:多线程编程常见面试题讲解

hello各位朋友们,最近笔者刚刚结束了学校的期末考试,现在回来继续更新啦!!!

今天要学习的是多线程常见面试题讲解,这些内容都是面试中常考的一些问题!

一.常见的锁策略

1.乐观锁/悲观锁

乐观/悲观都是对某件事情发展的预测,在多线程中,乐观/悲观都是对锁冲突发生概率的一个预测

乐观锁:如果预测接下来锁冲突发生的概率,就减少一些工作,称之为乐观锁

悲观锁:如果预测接下来锁冲突发生的概率,就增加一些工作,称之为悲观锁

乐观锁和悲观锁不是一把具体的锁,而是描述锁的特性,是对锁冲突概率的一个预测!

2.轻量级/重量级锁

轻量级锁和重量级锁的本质区别就在于是否使用了阻塞这种策略

对于轻量级锁来说,不涉及到阻塞等待,而是一种纯用户态的操作,最常见的策略就是使用while循环不断等待获取锁

对于重量级锁来说,需要通过阻塞等待来避免锁冲突,是用户态和内核态交互的一种方式,阻塞必然会带来一定的系统资源消耗,使性能降低

轻量级锁对应的就是乐观锁,重量级锁对应的是悲观锁

如果你认为前路是乐观的,你就轻装上阵,如果你认为前路是充满坎坷与荆棘的,那就负重前行

3.自旋锁(Spin Lock)/挂起等待锁

自旋锁是轻量级锁的一种典型实现方式,是一种纯用户态的操作,尽管消耗了一定的cpu资源,但是带来了更快地响应速度

挂起等待锁是重量级锁的一种典型实现方式,是需要操纵系统的api的,往往要使用阻塞等待,性能下降,反应变慢,但是更加安全

以上三种锁策略其实是一一对应的,可以总结为下图

自旋锁的特点可以总结为以下两点:

  1. 无阻塞:使用自旋锁,不会让线程进入阻塞等待的状态,而是不断地尝试获取,直到获取到,减少了因为阻塞带来的开销
  2. 忙等:自旋锁会让线程进入忙等的状态,在某下情况下会出现长时间消耗cpu资源的情况

自旋锁的适用场景:

  1. 对共享资源是短暂访问/持有的,如果长时间的持有,会导致其他线程处于忙等的状态
  2. 并发程度较低的场景,如果是高并发,竞争激烈的场景会消耗过多的cpu资源,得不偿失

4.读写锁

读写锁其实并不是第一次接触,在之前的MySQL学习过程中就接触过.MySQL的读写锁主要是为了解决脏读,幻读的问题,最典型的特征就是读的时候不能写,写的时候不能读,在多线程中,读写锁和MySQL中的有一点区别,主要在于:

读加锁:当前线程读的时候,别的线程可以读取,但是不能写

写加锁:当前线程写的时候,别的线程既不能读取,也不能写

读写锁在多线程中是一种控制对共享资源进行并发访问的机制,读加锁表示允许多个线程对共享资源进行同时读取,因为同时读取共享资源不会发生线程安全问题,这样也提升了多个线程读取数据的效率,写加锁对于共享资源的访问更加严苛一些,当一个线程获取到共享资源时,其他线程既不能读,也不能写,只能等该线程释放锁.这是因为操作往往会引发多个线程针对同一个变量进行修改这样的线程安全问题,灵活的使用读写锁,既可以提高并发编程的效率,也能保证线程安全

为什么读写锁这么重要呢?因为在日常的开发过程中,读往往要比写更加的频繁,在Java的标准库内部提供了现成的读写锁,需要用的时候直接查询即可

不同的语言中有不同的读写锁的实现方式,在许多编程语言中,常见的读写锁实现包括Java的ReentrantReadWriteLock、C++的std::shared_mutex等。这里主要介绍Java中的ReentrantReadWriteLock

5.公平/非公平锁

公平/非公平描述的是线程获取锁对象的几率

当拥有锁的线程释放锁之后,等待的线程获得锁对象的概率是遵从先来后到,这就是公平锁

当拥有锁的线程释放锁之后,等待的线程获得锁对象的概率是均等的,这就是非公平锁

对于系统api提供的锁,默认都是非公平锁,也就是每个线程获取到锁对象的几率是均等的

那如何实现一个公平锁呢?最重要的点在于如何确定获取锁对象的线程的先来后到,最直观的想法是可以通过一个队列来规定他们之间的执行顺序

公平锁的实现依赖于Java标准库内部的一个类ReentrantReadWriteLock,他有两个构造方法

  1. 无参构造方法 ReentrantLock()
  2. 有参的构造方法 ReentrantLock(boolean fair)
    默认是非公平锁,如果将fair设置为true,那他就是公平锁,会按照线程的调度顺序去执行

二.CAS策略

CAS全程Comapre and Swap,比较并交换,是Java内部一个方法,这里比较交换的是寄存器和内存的值,比如现在有一个内存M,和两个寄存器A,B

如果内存M上的值和寄存器A的值相同,就把B赋值给M,并返回true,如果不相同,就什么也不做,返回false

伪代码:

boolean CAS(address,expectValue,swapValue) {
if(address == expectValue) {
  address = swapValue;
  return true;
}
return false;

本质上,CAS其实不是方法,而是一个cpu指令,也就是说一个CAS指令就完成了比较+交换+返回值的操作,这不就保证了操作的原子性吗?java中的CAS方法知识JVM对原有cpu指令的一个包装!通过CAS方法就能实现无锁编程

下面讲解CAS最常见的两种用法

1.实现原子类

首先,如果我们使用Integer来定义一个变量cnt,并让其加一

Integer cnt = 0;
cnt++;

从cpu的角度来看cnt++这个操作,其实是分为三步的

  1. load 将内存的值加载到寄存器中
  2. add 将寄存器中的值+1
  3. save 将修改过后的值重新存储到内存之中

也真是因为这个操作是分步的,当多个线程尝试对cnt进行修改的时候就会触发线程安全问题,要想解决需要加锁,如果使用基于CAS实现的AtomicInteger类来修饰,cnt++这个操作就只有一步,相当于通过CAS将上述三步骤给封装起来,让三步变为了一步,这样就不会触发线程安全问题,也不需要加锁来避免线程安全,以代码为例

现在需要使用两个线程对cnt变量分别自增5000次的操作

private static int cnt;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt++;
            }
        });
        t1.start();
        t2.start();
        // 让主线程等待两个线程都执行完毕
        t1.join();
        t2.join();
        System.out.println(cnt);
    }

如果不加限制,cnt的值是随机的,要想精确地获得答案,有两种解决方式

1.加锁

// 设置一个加锁的对象
    private static Object locker = new Object();
    private static int cnt;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                for (int i = 0; i < 5000; i++) {
                    cnt++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker) {
                for (int i = 0; i < 5000; i++) {
                    cnt++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(cnt);// 输出10000
    }

2.使用基于CAS实现的AtomicInteger类

// 这里有一个小细节  必须要实例化一个AtomicInteger对象
    // 常规的成员类模式是0  但是此处是利用了外部类  是一个引用  如果不实例化就会产生空指针异常
    private static AtomicInteger cnt = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                // 等价于cnt++;
                cnt.getAndIncrement();
                // 等价与cnt--
//                cnt.getAndDecrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(cnt);// 输出10000
    }

我们可以使用伪代码的方式来理解这里的操作

// 这里存储的就是内存中的值
private int value;
public int getAndIncrement() {
  int oldvalue = value;
  // 这里的判断本质上是判断在这之前有没有其他线程传插进来执行
  // CAS方法既实现了判断又实现了++的操作
  while(CAS(value,oldvalue,oldvalue+1) != true) {
    // 返回false 证明有其他线程穿插进来执行
    // 此时value的值已经被更新  同时也要更新本线程的oldvalue
    oldvalue = value;
  }
  return oldvalue;
}

使用CAS实现的AtomicInteger类来实现cnt++这个操作相比于加锁(synchronized)效率更高,因为这是纯用户态的操作,不涉及到阻塞的开销,但同时,CAS会吃大量的cpu资源,且这种操作没有加锁具有普适性,只能在一些特定场景(比如整数的±)使用,在需要使用的时候直接查看文档即可

还有其他的基于CAS实现的原子类,都存储在java.util.concurrent.atomic包中

2.实现自旋锁(Spin Lock)

上文已经说过自旋锁是一种轻量级锁,通过吃cpu资源的方式来避免加锁的开销,使用CAS也可以帮助我们实现一个自旋锁

class SpinLock {
    // 此处owner就相当于"内存值"
    private Thread owner = null;
    // 其他线程进行加锁
    public void lock() {
        // 通过CAS来判断当前锁是否被其他线程持有
        // 如果没由被其他线程持有 就是null  当前线程就可以持有这个锁
        // 如果不为null  证明这个锁已经被其他线程持有  当前线程需要等待
        while (!CAS(this.owner,null,Thread.currentThread())) {
        }
    }
    // 解锁
    public void unlcok() {
        this.owner = null;
    }
}

3.ABA问题

自旋锁部分我们已经介绍到CAS其实是通过内存值和寄存器值是否相等来作为线程是否穿插执行的判断依据,值相等就证明线程没有穿插执行,不相等,有线程穿插执行,但如果另一个线程执行的逻辑是A->B->A这样的逻辑,最后值没有发生改变,但实际上已经有了线程穿插执行.

对于这种问题,一般来说一般不会发生bug,在逻辑上其实是没有影响的,这就好比手机中的翻新机,虽然不是新的,但是不影响正常使用,但也有一些极端情况可能会因为ABA问题导致bug的出现,下面以一个账户存取的例子进行讲解

此时这种情况并不会产生bug,但如果在t2线程扣款完毕之后紧接着在t3线程中执行存储500r的操作,就会产生bug

这就是一个经典的由于CAS的漏洞而引发的"A-B-A"问题,那如何解决这种问题呢?核心思路在于让判定的数值不要反复横跳,而是保持只增不减/只减不增,可以引入一个版本号来解决,使用版本号(stamp)来规避,线程每执行一次操作,就让版本号++一次,这样线程的每次操作对应的就不是相同的版本号,此时比较对象就不再是账户余额了,而是版本号是否相同.如果不相同,就证明一定有线程穿插执行,即使有"A-B-A"这样的问题出现,也能规避掉

在实际的开发中,我们并不会直接使用CAS,而是使用已经封装好的,但是在面试中会考关于CAS的一些问题,最常见的就是"A-B-A"问题

三.synchronized原理

1.synchronized的基本特性

第一部分的锁策略大部分描述的是锁的特性,synchronized都具有哪些特性呢?

  1. 乐观/悲观 是自适应的
  2. 轻量级/重量级 是自适应的
  3. 自旋/挂起等待 是自适应的
  4. 不是读写锁
  5. 非公平锁
  6. 是可重入锁

所谓的自适应就是根据当前代码的具体情况而定,发生锁冲突的概率大,就自动升级为悲观锁,锁冲突概率小,就是轻量级锁,在synchronized背后是存在一系列的编译器的优化机制来帮助我们更加高效的使用加锁这个机制,对于synchronized来说,有几个常用的机制:

  1. 锁升级
  2. 锁消除
  3. 锁的粒度粗化

2.锁升级

被synchronized包裹起来的代码块又被称为同步块,在synchronized关键字的使用过程中,锁的状态会发生一系列的升级,主要涉及到四个方面的升级:

  1. 无锁
  2. 偏向锁
  3. 轻量级锁
  4. 重量级锁

无锁,就是不加锁,就是不适用synchronized修饰的代码

偏向锁(Biased Locking) 当一个线程访问到同步块时,JVM会将对象头的标志位设置为偏向锁,如果以后只有一个线程持有该对象的锁,JVM就会做出优化,即当该线程再次访问同步块时,并不会加锁,这样就减少了每次加锁/开锁的开销,所以偏向锁适用于单个线程对于共享资源的频繁访问的场景,如果有多个线程尝试访问这个同步块,偏向锁就会升级为轻量级锁

有人可能会说为什么要有偏向锁的存在呢?既然只有一个线程访问该资源,就一定不会发生锁冲突,直接不加锁不就行了吗?其实,偏向锁的设置体现了未雨绸缪的思想,我们无法保证在未来的系统优化过程中其他线程不会访问同样的共享资源,假设存在,如果使用无锁就会产生线程不安全问题,但是如果使用偏向锁这种机制,就可以及时的升级为轻量级锁,来避免线程安全问题的出现!

其实偏向锁体现了一种能不加锁就不加锁的思想,和单例模式中的懒汉模式有异曲同工之妙,懒汉模式是"能不创建对象就先不创建,什么时候用就什么时候创建"

轻量级锁(Lightweight Locking) 当多个线程同时竞争一个锁的时候,偏向锁就会升级为轻量级锁,轻量级锁通过CAS(Compare and Swap)策略来实现多线程之间的同步访问,提高了并发性,区别于传统的重量级锁的互斥访问,不会产生线程的阻塞

重量级锁(Heavyweight Locking) 当轻量级锁无法满足锁竞争时,就会升级为重量级锁.对于重量级锁来说,线程与线程之间如果同时竞争同一把锁,就会产生阻塞等待,直到一个线程释放了锁

锁的升级主要依赖于JVM,JVM会根据不同场景的锁的竞争程度,线程的访问频率来进行相应的锁的升级,注意升级是单向的,不会发生退化

3.锁消除

锁消除也是编译器优化手段的一种,编译器在编译阶段会对synchronized修饰的代码进行判定,如果编译器觉得你写的代码不需要加锁,就会自动消除锁.注意,锁的消除是发生在编译阶段

当然,为了线程安全,触发锁消除的概率是很小的,编译器只有在其有把握的情况下才会进行消除.

举一个简单的例子,我们熟知的StringBuffer和StringBuilder的最主要的区别在于StringBuffer是带有synchronized的,但是如果编译器发现只有一个线程操纵你的StringBuffer对象,就会自动消除掉锁,减少不必要的开销

4.锁粗化

先来了解什么是粒度,对于一把锁来说,锁的粒度描述的是其内部被加锁的代码数量,如果被加锁代码的数量,就是一个粗粒度的锁,如果被加锁的代码数量,就是一个细粒度的锁

对于细粒度的锁来说,能够并发执行的代码更多,能够充分的利用多核CPU资源,能够更好的实现并发编程,但是如果细粒度的锁涉及到频繁的锁竞争,其效率可能还不及粗粒度的锁,最常见的就是将一个大任务拆分为小任务的场景中,粗粒度的锁可能直接对整个大任务进行加锁,一次只能有一个线程去执行对应的任务,细粒度的锁是将整个大任务拆分为一个一个的小任务,给有必要加锁的地方加锁,但如果在高并发环境下,就会出现因为任务的细分导致频繁地锁竞争,就会产生频繁的上下文切换,带来更多的因锁竞争带来的开销

就像给老板汇报任务一样,老板给你布置了三个任务,你每完成一个任务就给老板打一次电话,不如你一次性把所有任务都完成,一个电话就能解决,这样也省去了老板的时间!

锁粗化也是一种编译器的优化手段,用于减少因为细粒度锁导致的频繁的锁竞争带来的开销.比如当编译器发现有多个锁涉及到频繁地上锁和解锁,而这些锁包含的代码之间的执行时间很短,编译器就会讲这些锁合并,转换为一个范围更大锁,使锁的粒度加粗

同样的,锁粗化可能也会带来一些问题,比如降低了代码并发执行的程度,没有充分利用多核CPU资源,在实际的开发中应该针对具体的场景进行性能测试,判断锁粗化的必要性

多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)(下)

https://developer.aliyun.com/article/1480734?spm=a2c6h.13148508.setting.18.5f4e4f0etCqnjj

目录
相关文章
|
9天前
|
存储 安全 Java
Java面试题:请解释Java内存模型(JMM)是什么,它如何保证线程安全?
Java面试题:请解释Java内存模型(JMM)是什么,它如何保证线程安全?
50 13
|
6天前
|
安全 Java 开发者
Java并发编程中的线程安全性与性能优化
在Java编程中,处理并发问题是至关重要的。本文探讨了Java中线程安全性的概念及其在性能优化中的重要性。通过深入分析多线程环境下的共享资源访问问题,结合常见的并发控制手段和性能优化技巧,帮助开发者更好地理解和应对Java程序中的并发挑战。 【7月更文挑战第14天】
|
6天前
|
监控 Java API
Java并发编程之线程池深度解析
【7月更文挑战第14天】在Java并发编程领域,线程池是提升性能、管理资源的关键工具。本文将深入探讨线程池的核心概念、内部工作原理以及如何有效使用线程池来处理并发任务,旨在为读者提供一套完整的线程池使用和优化策略。
|
8天前
|
存储 安全 算法
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第72天】 在现代软件开发中,尤其是Java应用开发领域,并发编程是一个无法回避的重要话题。随着多核处理器的普及,合理利用并发机制对于提高软件性能、响应速度和资源利用率具有重要意义。本文旨在探讨Java并发编程的核心概念、线程安全的策略以及性能优化技巧,帮助开发者构建高效且可靠的并发应用。通过实例分析和理论阐述,我们将揭示在高并发环境下如何平衡线程安全与系统性能之间的关系,并提出一系列最佳实践方法。
|
9天前
|
监控 Java 调度
Java面试题:描述Java线程池的概念、用途及常见的线程池类型。介绍一下Java中的线程池有哪些优缺点
Java面试题:描述Java线程池的概念、用途及常见的线程池类型。介绍一下Java中的线程池有哪些优缺点
23 1
|
9天前
|
Java 调度
Java面试题:简述Java线程的生命周期及其状态转换。
Java面试题:简述Java线程的生命周期及其状态转换。
11 0
|
9天前
|
缓存 Java
Java面试题:描述Java中的线程池及其实现方式,详细说明其原理
Java面试题:描述Java中的线程池及其实现方式,详细说明其原理
14 0
|
14天前
|
存储 算法 Java
Java面试之SpringCloud篇
Java面试之SpringCloud篇
30 1
|
14天前
|
缓存 NoSQL Redis
Java面试之redis篇
Java面试之redis篇
34 0
|
14天前
|
SQL 关系型数据库 MySQL
java面试之MySQL数据库篇
java面试之MySQL数据库篇
22 0
java面试之MySQL数据库篇