多线程编程常见面试题讲解(锁策略,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天前
|
消息中间件 存储 缓存
大厂面试高频:Kafka 工作原理 ( 详细图解 )
本文详细解析了 Kafka 的核心架构和实现原理,消息中间件是亿级互联网架构的基石,大厂面试高频,非常重要,建议收藏。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:Kafka 工作原理 ( 详细图解 )
|
9天前
|
SQL 缓存 监控
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
本文详细解析了数据库、缓存、异步处理和Web性能优化四大策略,系统性能优化必知必备,大厂面试高频。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:4 大性能优化策略(数据库、SQL、JVM等)
|
11天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
11天前
|
存储 安全 Java
面试高频:Synchronized 原理,建议收藏备用 !
本文详解Synchronized原理,包括其作用、使用方式、底层实现及锁升级机制。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
面试高频:Synchronized 原理,建议收藏备用 !
|
1月前
|
存储 监控 算法
美团面试:说说 G1垃圾回收 底层原理?说说你 JVM 调优的过程 ?
尼恩提示: G1垃圾回收 原理非常重要, 是面试的重点, 大家一定要好好掌握
美团面试:说说 G1垃圾回收 底层原理?说说你 JVM 调优的过程  ?
|
1月前
|
SQL 存储 关系型数据库
美团面试:binlog、redo log、undo log的底层原理是什么?它们分别实现ACID的哪个特性?
老架构师尼恩在其读者交流群中分享了关于 MySQL 中 redo log、undo log 和 binlog 的面试题及其答案。这些问题涵盖了事务的 ACID 特性、日志的一致性问题、SQL 语句的执行流程等。尼恩详细解释了这些日志的作用、所在架构层级、日志形式、缓存机制以及写文件方式等内容。他还提供了多个面试题的详细解答,帮助读者系统化地掌握这些知识点,提升面试表现。此外,尼恩还推荐了《尼恩Java面试宝典PDF》和其他技术圣经系列PDF,帮助读者进一步巩固知识,实现“offer自由”。
美团面试:binlog、redo log、undo log的底层原理是什么?它们分别实现ACID的哪个特性?
|
1月前
|
负载均衡 算法 Java
蚂蚁面试:Nacos、Sentinel了解吗?Springcloud 核心底层原理,你知道多少?
40岁老架构师尼恩分享了关于SpringCloud核心组件的底层原理,特别是针对蚂蚁集团面试中常见的面试题进行了详细解析。内容涵盖了Nacos注册中心的AP/CP模式、Distro和Raft分布式协议、Sentinel的高可用组件、负载均衡组件的实现原理等。尼恩强调了系统化学习的重要性,推荐了《尼恩Java面试宝典PDF》等资料,帮助读者更好地准备面试,提高技术实力,最终实现“offer自由”。更多技术资料和指导,可关注公众号【技术自由圈】获取。
蚂蚁面试:Nacos、Sentinel了解吗?Springcloud 核心底层原理,你知道多少?
|
1月前
|
SQL 关系型数据库 MySQL
阿里面试:MYSQL 事务ACID,底层原理是什么? 具体是如何实现的?
尼恩,一位40岁的资深架构师,通过其丰富的经验和深厚的技術功底,为众多读者提供了宝贵的面试指导和技术分享。在他的读者交流群中,许多小伙伴获得了来自一线互联网企业的面试机会,并成功应对了诸如事务ACID特性实现、MVCC等相关面试题。尼恩特别整理了这些常见面试题的系统化解答,形成了《MVCC 学习圣经:一次穿透MYSQL MVCC》PDF文档,旨在帮助大家在面试中展示出扎实的技术功底,提高面试成功率。此外,他还编写了《尼恩Java面试宝典》等资料,涵盖了大量面试题和答案,帮助读者全面提升技术面试的表现。这些资料不仅内容详实,而且持续更新,是求职者备战技术面试的宝贵资源。
阿里面试:MYSQL 事务ACID,底层原理是什么? 具体是如何实现的?
|
1月前
|
消息中间件 Java Linux
得物面试:什么是零复制?说说 零复制 底层原理?(吊打面试官)
尼恩,40岁老架构师,专注于技术分享与面试辅导。近期,尼恩的读者群中有小伙伴在面试一线互联网企业如得物、阿里、滴滴等时,遇到了关于零复制技术的重要问题。为此,尼恩系统化地整理了零复制的底层原理,包括RocketMQ和Kafka的零复制实现,以及DMA、mmap、sendfile等技术的应用。尼恩还计划推出一系列文章,深入探讨Netty、Kafka、RocketMQ等框架的零复制技术,帮助大家在面试中脱颖而出,顺利拿到高薪Offer。此外,尼恩还提供了《尼恩Java面试宝典》PDF等资源,助力大家提升技术水平。更多内容请关注尼恩的公众号【技术自由圈】。
得物面试:什么是零复制?说说 零复制 底层原理?(吊打面试官)
|
1月前
|
负载均衡 算法 Java
腾讯面试:说说6大Nginx负载均衡?手写一下权重轮询策略?
尼恩,一位资深架构师,分享了关于负载均衡及其策略的深入解析,特别是基于权重的负载均衡策略。文章不仅介绍了Nginx的五大负载均衡策略,如轮询、加权轮询、IP哈希、最少连接数等,还提供了手写加权轮询算法的Java实现示例。通过这些内容,尼恩帮助读者系统化理解负载均衡技术,提升面试竞争力,实现技术上的“肌肉展示”。此外,他还提供了丰富的技术资料和面试指导,助力求职者在大厂面试中脱颖而出。
腾讯面试:说说6大Nginx负载均衡?手写一下权重轮询策略?