likes
comments
collection
share

2022最新JUC+多线程面试题

作者站长头像
站长
· 阅读数 17

Java中实现多线程有几种方法

创建线程的常用的几种方式:

  1. 继承Thread类
  2. 实现Runnable接口 (重写run方法,无返回值)
  3. 实现Callable接口( JDK1.5>=,重写call方法,可以自定义返回值 )
  4. 线程池方式创建

线程的几个基本状态

2022最新JUC+多线程面试题

  1. 新建状态(New):新创建了一个线程对象。

  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。

  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。

  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:

    • 等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的锁)
    • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
    • 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep是不会释放持有的锁)
  5. 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

如何停止一个正在运行的线程

  1. 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。

  2. 使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作 废的方法。

  3. 使用interrupt方法中断线程。

    名称解释
    public void interrupt()该方法只是设置当前线程的中断状态为true,发起一个协商而不会立刻停止线程
    public static boolean interrupted()返回当前线程的中断状态,将当前线程的中断状态清空并重新设置为false
    public boolean interrupted()判断当前线程是否被中断,通过检查中断标志位

notify()和notifyAll()有什么区别?

  1. notify可能会导致死锁,而notifyAll则不会 任何时候只有一个线程可以获得锁,也就是说只有一个线程可以运行synchronized 中的代码 使用notifyall,可以唤醒 所有处于wait状态的线程,使其重新进入锁的争夺队列中,而notify只能唤醒一个。
  2. wait() 应配合while循环使用,不应使用if,务必在wait()调用前后都检查条件,如果不满足,必须调 用notify()唤醒另外的线程来处理,自己继续wait()直至条件满足再往下执行。
  3. notify() 是对notifyAll()的一个优化,但它有很精确的应用场景,并且要求正确使用。不然可能导致死锁。正确的场景应该是 WaitSet中等待的是相同的条件,唤醒任一个都能正确处理接下来的事 项,如果唤醒的线程无法正确处理,务必确保继续notify()下一个线程,并且自身需要重新回到 WaitSet中.

sleep()和wait() 有什么区别?

  1. 对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类 中的。
  2. sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象 锁。
  3. 当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify()方法后本线程才进入对象锁定池准备,获取对象锁进入运行状态。

Thread 类中的start() 和 run() 方法有什么区别?

start()方法被用来启动新创建的线程,而且start()内部调用了run()方法,这和直接调用run()方法的 效果不一样。当你调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start() 方法才会启动新线程。

为什么wait, notify 和 notifyAll这些方法不在thread类里面?

明显的原因是JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。如果wait()方法定义在Thread类中,线 程正在等待的是哪个锁就不明显了。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所 以把他们定义在Object类中因为锁属于对象。

synchronized 和 ReentrantLock 有什么不同?

  1. 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用于代码块。

  2. 获取锁和释放锁的机制不同:synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要手动加锁和释放锁。

  3. 锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁。

  4. 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。

    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
     
    public class ReentrantLockInterrupt {
        static Lock lockA = new ReentrantLock();
        static Lock lockB = new ReentrantLock();
        public static void main(String[] args) throws InterruptedException {
            // 线程 1:先获取 lockA 再获取 lockB
            Thread t1 = new Thread(() -> {
                try {
                    // 先获取 LockA
                    lockA.lockInterruptibly();
                    // 休眠 10 毫秒
                    TimeUnit.MILLISECONDS.sleep(100);
                    // 获取 LockB
                    lockB.lockInterruptibly();
                } catch (InterruptedException e) {
                    System.out.println("响应中断指令");
                } finally {
                    // 释放锁
                    lockA.unlock();
                    lockB.unlock();
                    System.out.println("线程 1 执行完成。");
                }
            });
            // 线程 2:先获取 lockB 再获取 lockA
            Thread t2 = new Thread(() -> {
                try {
                    // 先获取 LockB
                    lockB.lockInterruptibly();
                    // 休眠 10 毫秒
                    TimeUnit.MILLISECONDS.sleep(100);
                    // 获取 LockA
                    lockA.lockInterruptibly();
                } catch (InterruptedException e) {
                    System.out.println("响应中断指令");
                } finally {
                    // 释放锁
                    lockB.unlock();
                    lockA.unlock();
                    System.out.println("线程 2 执行完成。");
                }
            });
            t1.start();
            t2.start();
            TimeUnit.SECONDS.sleep(1);
            // 线程1:执行中断
            t1.interrupt();
        }
    }
    

    2022最新JUC+多线程面试题

  5. 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

谈谈Lock锁底层实现原理

底层基于AQS+CAS+LockSupport锁实现

AQS是什么

AQS抽象的队列同步器,是整个JUC体系的基石,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类型的变量表示持有锁的状态;当有线程获取不到锁时,就将线程加入该队列中,通过CAS自旋和LockSupport.part()的方式,维护state变量,达到并发同步的效果。

AQS的核心原理

AQS使用一个violatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,将每条要抢占资源的线程封装成一个Node节点来完成锁的分配,通过CAS完成对state值的修改;核心就是state+CLH带头节点的双端队列

ReentrantLock获取锁的过程

  1. 公平锁:当一个线程在尝试获得锁时,如果锁的state=0,且等待队列为空则获得锁,否则进入队列尾,当持有资源的线程释放锁时唤醒队首线程
  2. 非公平锁:当一个线程在尝试获得锁时,直接尝试CAS,成功则占有锁,否则进入队列尾,当持有资源的线程释放锁时唤醒队首线程,如果队首线程获得锁成功则弹出,否则不弹出

有三个线程T1,T2,T3,如何保证顺序执行

在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。

Thread类中的yield方法有什么作用?

Yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法 而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU,执行yield()的线程有可 能在进入到暂停状态后马上又被执行。

线程池创建需要的那几个核心参数的含义

ThreadPoolExecutor 最多包含以下七个参数:

  1. corePoolSize:线程池中的核心线程数
  2. maximumPoolSize:线程池中最大线程数
  3. keepAliveTime:闲置超时时间
  4. unit:keepAliveTime 超时时间的单位(时/分/秒等)
  5. workQueue:线程池中的任务队列
  6. threadFactory:为线程池提供创建新线程的线程工厂
  7. rejectedExecutionHandler:线程池任务队列超过最大值之后的拒绝策略

ThreadPoolExecutor 有哪些常用的方法?

ThreadPoolExecutor有如下常用方法:

  1. submit()/execute():执行线程池
  2. shutdown()/shutdownNow():终止线程池
  3. isShutdown():判断线程是否终止
  4. getActiveCount():正在运行的线程数
  5. getCorePoolSize():获取核心线程数
  6. getMaximumPoolSize():获取最大线程数
  7. getQueue():获取线程池中的任务队列
  8. allowCoreThreadTimeOut(boolean):设置空闲时是否回收核心线程这些方法可以用来终止线程池、线程池监控等。

Java线程池中submit() 和 execute()方法有什么区别?

两个方法都可以向线程池提交任务,execute()方法的返回类型是void,它定义在Executor接口中, 而submit()方法可以返回持有计算结果的Future对象,它定义在ExecutorService接口中,它扩展了 Executor接口,其它线程池类像ThreadPoolExecutor和ScheduledThreadPoolExecutor都有这些 方法。

常用的线程池有哪些?

  1. newSingleThreadExecutor:创建一个单线程的线程池,此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
  2. newFixedThreadPool:创建固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。
  3. newCachedThreadPool:创建一个可缓存的线程池,此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
  4. newScheduledThreadPool:创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。
  5. newSingleThreadExecutor:创建一个单线程的线程池。此线程池支持定时以及周期性执行任务的需求。

线程池中的workQueue有哪几种

  • 1、LinkedBlockingQueue,一个无界缓存等待队列。当前执行的线程数量达到corePoolSize的数量时,剩余的元素会在阻塞队列里等待。(所以在使用此阻塞队列时maximumPoolSizes就相当于无效了),每个线程完全独立于其他线程。生产者和消费者使用独立的锁来控制数据的同步,即在高并发的情况下可以并行操作队列中的数据。
  • 2、SynchronousQueue,无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。拥有公平(FIFO)和非公平(LIFO)策略,使用SynchronousQueue阻塞队列一般要求maximumPoolSizes为无界(Integer.MAX_VALUE),避免线程拒绝执行操作。
  • 3、ArrayBlockingQueue,一个有界缓存等待队列,可以指定缓存队列的大小,当正在执行的线程数等于corePoolSize时,多余的元素缓存在ArrayBlockingQueue队列中等待有空闲的线程时继续执行,当ArrayBlockingQueue已满时,加入ArrayBlockingQueue失败,会开启新的线程去执行,当线程数已经达到最大的maximumPoolSizes时,再有新的元素尝试加入ArrayBlockingQueue时会报错。
  • 4、DelayedWorkQueue,其特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。

shutdownNow() 和 shutdown() 两个方法有什么区别?

shutdownNow() 和 shutdown() 都是用来终止线程池的,它们的区别是,使用 shutdown() 程序不会报错,也不会立即终止线程,它会等待线程池中的缓存任务执行完之后再退出,执行了 shutdown() 之后就不能给线程池添加新任务了;shutdownNow() 会试图立马停止任务,如果线程池中还有缓存任务正在执行,则会抛出 java.lang.InterruptedException: sleep interrupted 异常。

线程池的工作原理

  1. 提交任务后,先判断当前池中线程数是否小于 corePoolSize,如果小于,则创建新线程执行这个任务。
  2. 否则,判断线程池任务队列是否已满,如果没有满,则添加任务到任务队列。
  3. 否则,判断当前池中线程数是否大于 maximumPoolSize,如果大于则执行预设拒绝策略。
  4. 否则,创建一个线程执行该任务,直至线程数达到maximumPoolSize,达到后执行预设拒绝策略。

线程池创建之后,会立即创建核心线程么

并不会立即创建核心线程,而是等到有任务提交时才会开始创建线程,除非调用了prestartCoreThread/prestartAllCoreThreads 事先启动核心线程。

prestartCoreThread: 启动一个核心线程,使其空闲等待工作。这会覆盖仅在执行新任务时启动核心线程的默认策略。如果所有核心线程都已启动,此方法将返回 false。如果线程已启动,则返回 true。

public boolean prestartCoreThread() {
    return workerCountOf(ctl.get()) < corePoolSize &&
        addWorker(null, true);
}

prestartAllCoreThreads:启动所有核心线程,使它们空闲等待工作。这会覆盖仅在执行新任务时启动核心线程的默认策略。返回启动的线程数。

public int prestartAllCoreThreads() {
    int n = 0;
    while (addWorker(null, true))
        ++n;
    return n;
}

核心线程永远不会销毁么

其实核心线程只是一个动态概念,在jdk中并没有给线程打上"core"标记。而在jdk1.6之前,线程池会尽量保证会有corePoolSize个线程存活,即使这些线程已经闲置了很长的时间,这样会造成一部分资源浪费;于是在1.6开始,jdk提供了一个allowCoreThreadTimeOut方法用于控制核心线程是否被销毁。

注意: 这种策略和corePoolSize=0是有区别的

  • corePoolSize=0:在一般情况下只使用一个线程消费任务,只有当并发请求特别多、等待队列都满了之后,才开始用多线程。
  • allowsCoreThreadTimeOut=true && corePoolSize>1:在一般情况下就开始使用多线程(corePoolSize 个),当并发请求特别多,等待队列都满了之后,继续加大线程数。但是当请求没有的时候,允许核心线程也终止。

综合来看,其实corePoolSize=0的效果基本等同于allowsCoreThreadTimeOut=true&&corePoolSize=1,只是实现细节不同。

线程池的优点,作用

  1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  3. 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池核心线程数怎么设置呢?

分为CPU密集型和IO密集型

  • CPU这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出 来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • IO密集型 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占 用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们 可以多配置一些线程,具体的计算方法是 : 核心线程数=CPU核心数量*2。

线程池为什么需要使用(阻塞)队列?

  1. 因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换。
  2. 创建线程池的消耗较高。

知道线程池中线程复用原理吗?

在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停的检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式将只使用固定的线程就将所有任务的 run 方法串联起来。

线程池参数keepAliveTime=0 会怎么样

keepAliveTime这个参数1.6之前控制的是非核心线程的存活时间,且该参数值不能小于0,否则在创建线程池时会抛出异常。而设置为0的含义其实是指非核心线程执行完属于自己的任务后即刻销毁。从1.6开始,若 allowsCoreThreadTimeOut=true,则keepAliveTime必须大于0,否则也会报错。

线程池的拒绝策略有哪些?

主要有4种拒绝策略:

  1. AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
  2. CallerRunsPolicy:只用调用者所在的线程来处理任务
  3. DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务
  4. DiscardPolicy:直接丢弃任务,也不抛出异常

产生死锁的四个必要条件?

  1. 互斥条件:一个资源每次只能被一个线程使用
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  3. 不剥夺条件:进程已经获得的资源,在未使用完之前,不能强行剥夺
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

什么是多线程中的上下文切换

上下文切换是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是 多任务操作系统和多线程环境的基本特征。在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB)中的。PCB还经常被称 作“切换桢”(switchframe)。“页码”信息会一直保存到CPU的内存中,直到他们被再次使用。

Future和CompletableFuture的区别和对比

  1. Future在执行结束后没法回调,调用get方法会被阻塞;CompletableFuture调用get方法获取结果也会被阻塞。
  2. CompletableFuture可以回调,可不通过get方法获取结果。
  3. Future模式执行批量任务,在完成任务后要想执行其它任务得通过get方法获取结果,在依次遍历Future列表时,各个get方法依赖于Future列表顺序。
  4. CompletableFuture模式通过回调可以得到执行结果,各自完成任务可以立即执行接下来的任务。
  5. 通过实现CompletionStage接口,CompletableFuture对象可以级联地执行任务
  6. 可以通过anyOf方法得到多个任务中首先完成任务的CompletableFuture对象

CompletableFuture的下一步处理的线程池使用规则

如果有使用自定义线程池,那么使用的就是自定义线程池,没有则使用的是默认的ForkJoinPool线程池

如果在下一步使用的thenRunAsync方法,且没有传入自定义的线程池,就使用的是默认的,传入线程池就使用自定义的线程池。

其他thenApply和thenAccept亦是如此。thenRun使用的是上一步的线程池,如果上一步执行的太快,也会用main线程,thenRunAsync使用的是默认线程池

CompletableFuture.supplyAsync的下一步后续处理操作

  1. thenRun():执行异步任务,没有上一步的结果也没有返回值
  2. thenApply():执行异步任务,有上一步的结果也有返回值
  3. thenAccept():执行异步任务,有上一步的结果,但没有返回值
System.out.println(CompletableFuture.supplyAsync(() -> "returnA").thenRun(() -> {
    // 执行异步任务,没有上一步的结果也没有返回值
}).join());
System.out.println(CompletableFuture.supplyAsync(() -> "returnA").thenApply(res -> {
    // 执行异步任务,有上一步的结果也有返回值
    return res + " aaa";
}).join());
System.out.println(CompletableFuture.supplyAsync(() -> "returnA").thenAccept(res -> {
    // 执行异步任务,有上一步的结果,但没有返回值
    System.out.println(res);
}).join());

线程安全需要保证几个基本特征?

  1. 原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
  2. 可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将 线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
  3. 有序性,是保证线程内串行语义,避免指令重排等。

下线程之间是如何通信的?

线程之间的通信有两种方式:共享内存和消息传递。

共享内存 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来 隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。

例如线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤:

  1. 线程 A 把本地内存 A 更新过得共享变量刷新到主内存中去。
  2. 线程 B 到主内存中去读取线程 A 之前更新过的共享变量。

消息传递 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行 通信。在 Java 中典型的消息传递方式,就是 wait() 和 notify() ,或者 BlockingQueue 。

什么是乐观锁什么是悲观锁

悲观锁: 就是默认认为在本线程使用该系统资源的时候就一定会有别的线程来进行争抢,就默认加锁。适合写多读少的场景,先加锁可以保证写操作是数据正确

乐观锁: 认为自己再使用数据的时候不会有别的线程来修改数据或资源,所以不会加锁。只是在更新数据的时候去判断一下有没有别的线程更新了这个数据,如果这个数据没有被更新,就将当前线程的数据写入,如果有更新,则根据不同的实现方式来执行不同的操作,比如放弃修改,重试抢锁等,适合读多写少的情况

判断规则:

  • 版本号机制version
  • 最常采用的是CAS算法,Java原子类中的递增操作就是通过CAS自旋实现

为什么每一个对象都可以成为一个锁

Java中每一个对象都继承了Object类,Object类中有一个叫ObjectMonitor.java的监视器,java的底层是c++,在ObjectMonitor.java中又调用了ObjectMonitor.cpp,在ObjectMonitor.cpp中又调用了ObjectMonitor.hpp,在ObjectMonitor.hpp文件中有很多属性,比如_owner属性,就指向持有ObjectMonitor对象的线程

ObjectMonitor.hpp中的构造方法中的属性

属性解释
_owner指向持有ObjectMonitor对象的线程
_WaitSet存放处于wait状态的线程队列
_EntryList存在处于等待锁block状态的线程队列
_recursions锁的重入次数
_count用来记录该线程获取锁的次数

什么是管程monitor

管程就是锁,在虚拟机中monitor使用的是ObjectMonitor实现,每一个对象都天生带有一个对象监视器,每一个被锁住的对象都活和Monitor相关

什么是公平锁非公平锁

公平锁: 是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买,后来的人在队尾排队,这是公平的ReentrantLock reentrantLock = new ReentrantLock(true); true表示公平锁,先来先得

非公平锁: 是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程有限获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)

为什么会有公平锁,非公平锁的设计,为什么默认非公平

  1. 恢复挂起的线程对于CPU来说是处在时间差的,所以非公平锁能充分利用CPU的时间片,尽量减少了空闲状态时间
  2. 使用多线程的时候很重要的点就是线程切换带来的开销,当采用非公平锁的时候,当一个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销

什么时候使用公平锁,什么时候用非公平锁

如果在业务场景上需要提高吞吐量的话,建议使用非公平锁,因为可以节省线程之间的切换时间,否则就使用公平锁,大家公平使用

什么是可重入锁(递归锁)

是指同一个线程在外层方法获取锁的时候,在进入改线程的内层方法会自动获取锁(前提,锁的是同一个对象),不会因为之前已经获取过锁还没有释放而阻塞。

例如:synchronized修饰的一个递归方法,程序在第二次调用的时候不需要再次去获取锁,避免自己阻塞自己,所以在Java中ReentrantLock 和synchronized都是可重入锁,这样的主要是在一定程度上避免了死锁

synchronized的重入实现原理

当执行monitorenter时,如果目标锁对象的计数器(_count)为0,那么就代表该锁没有被别的线程所持有,然后Java虚拟机就会吧该锁的持有线程设置为当前线程,并把计数器加1

如果计数器不为0的时候,就会去判断该锁的owner属性锁指向的线程是不是当前线程,是的话就把计数器( count,recursions)加1,否则需要等待,直至持有线程释放该锁

当执行monitorexit时,java虚拟机就会把改锁的计数器(count),可重入recursions减去1,计数器为0的时候就代表锁已经被释放

什么是LockSupport

用于创建锁和其他同步类的基本线程阻塞原语。本质上是一个线程阻塞的工具类,所有的方法是今天方法,LockSupport调用的是Unsafe中的native方法,可以在任意位置唤醒线程,其中提供可一个许可证的概念,但许可证只有一个,不会累加,可以通过park方法使其阻塞,unpark方法为某一个线程发放通行证

为什么LockSupport可以突破像类似wait和notify,lock和containal的顺序关系

因为unpark就已经为该线程发放了一张通行证,在调用park方法的时候回去检查该线程有没有通行证,有就直接放行,没有则阻塞

为什么LockSupport被唤醒两次后阻塞两次,但最终结果还是会阻塞线程

因为凭证的数量始终只有一个,在park两次之后凭证不够两次消费,所以不能放行

为什么volatile不具备原子性

举一个例子,就相当于i++这样的操作来说,在多线程的环境下,每个线程一开始都获得了i的值放回到自己的工作内存中之后,需要对i进行计算,然后再进行赋值,当在这个计算的时间中,有别的线程速度比较快,计算完成之后写入主内存,然后发出通知,当前线程收到通知的时候本次计算没有完成就又重新读取,重新计算,所以会导致计算丢失。也就是说在对一个值的取值,计算,复制的这三个步骤来说没有保证原子性,所以volatile也不具备原子性

综上所述,volatile变量不适合参加到依赖当前值的运算,如i=i+1;i++等的之类的,所以我们通常用volatile保存某个状态的boolean值或者int值

为什么我们只要加了一个volatile修饰一个变量的时候,就会去加上一个内存屏障

当我们用volatile修饰一个变量的时候,在他的class文件中我们可以发现该变量被加上了一个标签,也就是ACC_VOLATILE,然后吧字节码生成机器码的时候,就会按照JMM的规范,在相应位置插入内存屏障;

2022最新JUC+多线程面试题

什么叫做内存屏障

是一种屏障指令,它是的CPU或者编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束,也叫做内存栅栏或者栅栏指令

CAS的原理呢?

CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数:

  1. 变量内存地址,V表示
  2. 旧的预期值,A表示
  3. 准备设置的新值,B表示

当执行CAS指令时,只有当V等于A时,才会用B去更新V的值,否则就不会执行更新操作。底层调用Unsafe类中的方法

CAS有什么缺点吗

  1. ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是 A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是 ABA的问题大部分场景下都不影响并发的最终效果(Java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段, 更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。)
  2. 循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。
  3. 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多 个可以通过AtomicReference来处理或者使用锁synchronized实现。

happen-before规则

虽然指令重排提高了并发的性能,但是Java虚拟机会对指令重排做出一些规则限制,并不能让所有 的指令都随意的改变执行位置,主要有以下几点:

  1. 单线程每个操作,happen-before于该线程中任意后续操作
  2. volatile写happen-before与后续对这个变量的读
  3. synchronized解锁happen-before后续对这个锁的加锁
  4. final变量的写happen-before于final域对象的读,happen-before后续对final变量的读
  5. 传递性规则,A先于B,B先于C,那么A一定先于C发生

LongAdder为什么比AtomicLong块

LongAdder的基本思路就是分散热点,将value的值分散到一个Cell数组中,不同的线程会命中不同的Cell,各个现在只对自己槽中的那个值进行Cas操作,这样热点就被分散了,冲突的概率小很多,想要获得真正的long值,只要将各个槽中的变量值累加返回就行。

sum()方法会将Cell数组中的value和base累加作为返回值。

核心思想就是将AtomicLong的一个value的更新压力分散到多个vulue中去

说说CyclicBarrier和CountDownLatch的区别?

  1. CyclicBarrier的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了 这个点,所有线程才重新运行;CountDownLatch则不是,某线程运行到某个点上之后,只是给某 个数值-1而已,该线程继续运行
  2. CyclicBarrier只能唤起一个任务,CountDownLatch可以唤起多个任务
  3. CyclicBarrier可重用,CountDownLatch不可重用,计数值为0该CountDownLatch就不可再 用了

什么是AQS?

简单说一下AQS,AQS全称为AbstractQueuedSychronizer,翻译过来应该是抽象队列同步器。

如果说java.util.concurrent的基础是CAS的话,那么AQS就是整个Java并发包的核心了, ReentrantLock、CountDownLatch、Semaphore等等都用到了它。

AQS实际上以双向队列的形式 连接所有的Entry,比方说ReentrantLock,所有等待的线程都被放在一个Entry中并连成双向队 列,前面一个线程使用ReentrantLock好了,则双向队列实际上的第一个Entry开始运行。

AQS定义了对双向队列所有的操作,而只开放了tryLock和tryRelease方法给开发者使用,开发者可 以根据自己的实现重写tryLock和tryRelease方法,以实现自己的并发功能。

了解Semaphore吗?

semaphore就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数, 可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待, 等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore构造函数中 传入的int型整数n=1,相当于变成了一个synchronized了。

ThreadLocal原理?

ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部 副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。 ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap又包含了一个Entry数组, Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对 的能力。

为什么ThreadLocal要使用弱引用

弱引用的目的是为了防止内存泄露,如果是强引用那么ThreadLocal对象除非线程结束否则始终无 法被回收,弱引用则会在下一次GC的时候被回收。 但是这样还是会存在内存泄露的问题,假如key和ThreadLocal对象被回收之后,entry中就存在key 为null,但是value有值的entry对象,但是永远没办法被访问到,同样除非线程结束运行。 但是只要ThreadLocal使用恰当,在使用完之后调用remove方法删除Entry对象,实际上是不会出 现这个问题的

对象的内存布局

Object o = new Object();

对于我们new一个新的对象,这个对象在java的堆存储的内存布局可分为,对象头,实例数据,对齐填充(不够8的倍数,就填充)

锁的升级流程

  1. 当没有被当做锁的时候,这就是个普通对象,就是无锁的状态
  2. 当对象被当做同步锁时,一个线程A抢到锁时,此时锁升级为偏向锁,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码,这也是偏向锁的意义
  3. 当一个线程B尝试获取锁,JVM发现当前的锁处于偏向状态,并且现场ID不是B线程的ID,那么线程B会先用CAS将线程id改为自己的,这里是有可能成功的,因为A线程一般不会释放偏向锁。
  4. 偏向锁抢锁失败,则说明当前锁存在一定的竞争,偏向锁就升级为轻量级锁。
  5. 轻量级锁抢锁失败,则JVM会使用自旋锁,自旋锁并非是一个锁,则是一个循环操作,不断的尝试获取锁。自旋锁重试之后仍然未抢到锁,同步锁会升级至重量级锁,锁标志位改为10,在这个状态下,未抢到锁的线程都会被阻塞,由Monitor来管理,并会有线程的park与unpark,因为这个存在用户态和内核态的转换,比较消耗资源,故名重量级锁

2022最新JUC+多线程面试题

什么叫锁消除

锁的对象要是同一个,而不是多个对象

2022最新JUC+多线程面试题

什么叫锁粗化

假如方法中收尾相接,而且前后相邻的同步代码块都是锁的统一个对象,JIT编译器就会把这几个同步块合成一个大块,加粗加大范围,一次申请使用即可,避免每次的申请和释放锁,提升了性能

文件下载

2022最新JUC+多线程面试题