JUC并发编程面试题

文章目录

    • 1. 并行和并发有什么区别?
    • 2. 线程和进程的基本概念、线程的基本状态以及状态之间的关系?
    • 3.守护线程是什么?
    • 4.创建线程有哪几种方式?
    • 5. sleep() 和 wait() 有什么区别?
    • 6.线程的 run() 和 start() 有什么区别?
    • 7.创建线程池有哪几种方式?
    • 8.在 Java 程序中怎么保证多线程的运行安全?
    • 9.什么是死锁?怎么防止死锁?
    • 10.synchronized 和 volatile 的区别是什么?
    • 11.synchronized 和 Lock 有什么区别?
    • 12.synchronized 和 ReentrantLock 区别是什么?
    • 13.为什么使用线程池?
    • 14.并发编程三要素?
    • 15.实现可见性的方法有哪些?
    • 16.实现原子性的方法有哪些?
    • 17.synchronized的作用?
    • 18.volatile关键字的作用
    • 19.什么是CAS
    • 20.CAS的问题
    • 21.什么是Future?
    • 22.FutureTask是什么
    • 23.synchronized和ReentrantLock的区别
    • 24.什么是乐观锁和悲观锁
    • 25.synchronized、volatile、CAS比较
    • 26.ThreadLocal是什么?有什么用?
    • 27.什么是多线程的上下文切换
    • 28.Semaphore有什么作用
    • 29.多线程同步有哪几种方法?
    • 30.synchronized底层如何实现?
    • 31.AtomicInteger底层实现原理是什么?
    • 32.voliate 的实现原理
    • 33.ThreadLocal的底层原理
    • 34.分布式锁的实现,目前比较常用的有以下几种方案:
    • 35.如何判断线程是否安全?
    • 36.java中用到的线程调度算法是什么?
    • 37.CyclicBarrier和CountDownLatch的区别
    • 38.ReentrantLock 底层实现
    • 39.为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
    • 40.线程池
    • 41.线程间的同步的方式有哪些
    • 42.进程的调度算法
    • 43.AQS 底层原理
    • 44.一致性哈希

1. 并行和并发有什么区别?

  • 并行(Parallel):指两个或者多个事件在同一时刻发生,即同时做不同事的能力。例如垃圾回收时,多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指两个或多个事件在同一时间间隔内发生,即交替做不同事的能力,多线程是并发的一种形式。例如垃圾回收时,用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

2. 线程和进程的基本概念、线程的基本状态以及状态之间的关系?

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位。
    地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间。

  • 关系:一个程序至少一个进程,一个进程至少一个线程。

  • 线程的基本状态:新建、就绪、运行状态、阻塞状态、死亡状态

  • 新建状态:利用NEW运算创建了线程对象,此时线程状态为新建状态,调用了新建状态线程的start()方法,将线程提交给操作系统,准备执行,线程将进入到就绪状态。

  • 就绪状态:由操作系统调度的一个线程,没有被系统分配到处理器上执行,一旦处理器有空闲,操作系统会将它放入处理器中执行,此时线程从就绪状态切换到运行时状态。

  • 运行状态:线程正在运行的过程中,碰到调用Sleep()方法,或者等待IO完成,或等待其他同步方法完成时,线程将会从运行状态,进入到阻塞状态。

  • 死亡状态:线程一旦脱离阻塞状态时,将重新回到就绪状态,重新向下执行,最终进入到死亡状态。一旦线程对象是死亡状态,就只能被GC回收,不能再被调用。

3.守护线程是什么?

  • 守护线程又称为后台线程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件
  • 正常创建的线程都是普通线程,或称为前台线程,守护线程与普通线程在使用上没有什么区别,但是他们有一个最主要的区别是在于进程的结束中。当一个进程中所有普通线程都结束时,那么进程就会结束。如果进程结束时还有守护线程在运行,那么这些守护线程就会被强制结束
  • 在 Java 中垃圾回收线程就是特殊的守护线程

4.创建线程有哪几种方式?

  • 继承Thread类(真正意义上的线程类),是Runnable接口的实现。
  • 实现Runnable接口,并重写里面的run方法。
  • 实现Callable接口,并重写里面的call方法。
  • 使用Executor框架创建线程池。Executor框架是juc里提供的线程池的实现。

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

  • 类的不同:sleep() 来自 Thread类,wait() 来自 Object类。
  • 释放锁:sleep() 不释放锁;wait() 释放锁。
  • 使用的范围是不同的:wait:只能在同步代码块中使用;sleep:可以在任何地方使用
  • 用法不同:sleep() 时间到会自动恢复;wait() 可以使用 notify()/notifyAll()直接唤醒。

6.线程的 run() 和 start() 有什么区别?

  • start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。
  • run() 可以重复调用,而 start() 只能调用一次。
  • 第二次调用start() 必然会抛出运行时异常

7.创建线程池有哪几种方式?

  • newSingleThreadExecutor():它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目;

  • newCachedThreadPool():它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列;

  • newFixedThreadPool(int nThreads):重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads;

  • newScheduledThreadPool(int corePoolSize):和newSingleThreadScheduledExecutor()类似,创建的是个 ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程;(创建一个定长线程池,支持定时及周期性任务执行。)

  • ThreadPoolExecutor():是最原始的线程池创建,上面1-3创建方式都是对ThreadPoolExecutor的封装。

8.在 Java 程序中怎么保证多线程的运行安全?

  • 使用安全类,比如 Java. util. concurrent 下的类。
  • 使用自动锁 synchronized。
  • 使用手动锁 Lock。

9.什么是死锁?怎么防止死锁?

  • 当线程 A 持有独占锁a,并尝试去获取独占锁 b 的同时,线程 B 持有独占锁 b,并尝试获取独占锁 a 的情况下,就会发生 AB 两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。
  • 防止死锁方法:
    • 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁。
    • 尽量使用 Java. util. concurrent 并发类代替自己手写锁。
    • 尽量降低锁的使用粒度,尽量不要几个功能用同一把锁。
    • 尽量减少同步的代码块。

10.synchronized 和 volatile 的区别是什么?

  • volatile 是变量修饰符;synchronized 是修饰类、方法、代码段。
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

11.synchronized 和 Lock 有什么区别?

  • synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
  • synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

JUC并发编程面试题_第1张图片

12.synchronized 和 ReentrantLock 区别是什么?

  • ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
  • ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
  • ReentrantLock 只适用于代码块锁,而 synchronized 可用于修饰方法、代码块等。

13.为什么使用线程池?

由于创建和销毁线程都需要很大的开销,运用线程池就可以大大的缓解这些内存开销很大的问题;可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存。

14.并发编程三要素?

  • 原子性 :指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行。

  • 可见性:指多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。

  • 有序性:即程序的执行顺序按照代码的先后顺序来执行。

15.实现可见性的方法有哪些?

synchronized或者Lock:保证同一个时刻只有一个线程获取锁执行代码,锁释放之前把最新的值刷新到主内存,实现可见性。

16.实现原子性的方法有哪些?

添加lock锁和synchronized锁,或者使用原子类

17.synchronized的作用?

在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。synchronized既可以加在一段代码上,也可以加在方法上。

18.volatile关键字的作用

对于可见性,Java提供了volatile关键字来保证可见性。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

从实践角度而言,volatile的一个重要作用就是和CAS结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如AtomicInteger。

19.什么是CAS

CAS是compare and swap的缩写,即我们所说的比较交换。

cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

java.util.concurrent.atomic 包下的类大多是使用CAS操作来实现的( AtomicInteger,AtomicBoolean,AtomicLong)。

20.CAS的问题

1)CAS容易造成ABA问题

一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,而这个问题的解决方案可以使用版本号标识,每操作一次version加1。在java5中,已经提供了AtomicStampedReference来解决问题。

2) 不能保证代码块的原子性

CAS机制所保证的知识一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。

3)CAS造成CPU利用率增加

之前说过了CAS里面是一个循环判断的过程,如果线程一直没有获取到状态,cpu资源会一直被占用。

21.什么是Future?

在并发编程中,我们经常用到非阻塞的模型,在之前的多线程的三种实现中,不管是继承thread类还是实现runnable接口,都无法保证获取到之前的执行结果。通过实现Callback接口,并用Future可以来接收多线程的执行结果。

Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。

22.FutureTask是什么

这个其实前面有提到过,FutureTask表示一个异步运算的任务。FutureTask里面可以传入一个Callable的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。当然,由于FutureTask也是Runnable接口的实现类,所以FutureTask也可以放入线程池中。

23.synchronized和ReentrantLock的区别

synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:

1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁

2)ReentrantLock可以获取各种锁的信息

3)ReentrantLock可以灵活地实现多路通知

24.什么是乐观锁和悲观锁

1)乐观锁:就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。

2)悲观锁:还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像synchronized,不管三七二十一,直接上了锁就操作资源了。

25.synchronized、volatile、CAS比较

  • synchronized是悲观锁,属于抢占式,会引起其他线程阻塞。
  • volatile提供多线程共享变量可见性和禁止指令重排序优化。
  • CAS是基于冲突检测的乐观锁(非阻塞)

26.ThreadLocal是什么?有什么用?

ThreadLocal是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。

简单说ThreadLocal就是一种以空间换时间的做法,在每个Thread里面维护了一个以开地址法实现的ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了。

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

多线程的上下文切换是指CPU控制权由一个已经正在运行的线程切换到另外一个就绪并等待获取CPU执行权的线程的过程。

28.Semaphore有什么作用

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

JUC并发编程面试题_第2张图片
6车—抢3个停车位置

public class SemaphoreDemo {

    public static void main(String[] args) {
        // 线程数量:停车位! 限流!
        Semaphore semaphore = new Semaphore(3);

        for (int i = 1; i <=6 ; i++) {
            new Thread(()->{
                // acquire() 得到
                try {
                //三辆车抢3个位置,抢到之后休息2S,然后释放资源,3个位置一被释放就立刻被其他等待的3辆车抢占
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName()+"抢到车位");
                    TimeUnit.SECONDS.sleep(2);
                    System.out.println(Thread.currentThread().getName()+"离开车位");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release(); // release() 释放
                }

            },String.valueOf(i)).start();
        }

    }


}

JUC并发编程面试题_第3张图片

总结: semaphore.acquire()假如我们的信号量已经满了,就会等待,直到semaphore释放就会获得资源;semaphore.release()会将当前的信号量释放,然后唤醒等待的线程。

作用: 多个共享资源互斥的使用、并发限流、控制最大的线程数。

29.多线程同步有哪几种方法?

Synchronized关键字,Lock锁实现,分布式锁等。

30.synchronized底层如何实现?

synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。

原理:
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性

底层实现:
1)同步代码块是使用monitorenter和monitorexit指令实现的, ,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;
2)同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。 synchronized方法是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示 Klass 做为锁对象。

Java对象头和monitor是实现synchronized的基础!
synchronized存放的位置:
synchronized用的锁是存在Java对象头里的。
其中, Java对象头包括:
Mark Word(标记字段): 用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。它是实现轻量级锁和偏向锁的关键
Klass Pointer(类型指针): 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
monitor: 可以把它理解为一个同步工具, 它通常被描述为一个对象。 是线程私有的数据结构。

JUC并发编程面试题_第4张图片

31.AtomicInteger底层实现原理是什么?

AtomicIntger 是对 int 类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于 CAS(compare-and-swap)技术。从 AtomicInteger 的内部属性可以看出,它依赖于 Unsafe 提供的一些底层能力,进行底层操作,以 volatile 的 value 字段,记录数值,以保证可见性,Unsafe 会利用 value 字段的内存地址偏移,直接完成操作。

32.voliate 的实现原理

volatile可以保证线程可见性且禁止指令重排序,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的, 加入volatile关键字时,汇编后会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。。
happen-before原则保证了程序的“有序性,对volatile变量的写操作 happen-before 后续的读操作.
当读取一个被volatile修饰的变量时,会直接从共享内存中读,而非线程专属的存储空间中读。
当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。
对该变量的写操作之后,编译器会插入一个写屏障。对该变量的读操作之前,编译器会插入一个读屏障。
线程写入,写屏障会通过类似强迫刷出处理器缓存的方式,让其他线程能够拿到最新数值。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

33.ThreadLocal的底层原理

参考链接

34.分布式锁的实现,目前比较常用的有以下几种方案:

分布式锁应该是怎么样的?
1)可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
2)这锁要是一把可重入锁(避免死锁)
3)这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
4)有高可用的获取锁和释放锁功能
5)获取锁和释放锁的性能要好

基于数据库实现分布式锁
1)最简单的方式可能就是直接创建一张锁表
1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
非阻塞的?搞一个while循环,直到insert成功再返回成功。
非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

借助数据中自带的锁来实现分布式的锁(select *** for update)

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。 通过connection.commit()操作来释放锁

基于缓存(redis,memcached,tair)实现分布式锁
基于 REDIS 的 SETNX()、EXPIRE() 方法( 设置过期时间)做分布式锁

基于Zookeeper实现分布式锁
每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

35.如何判断线程是否安全?

考虑原子性,可见性,有序性。
1.明确哪些代码是多线程运行的代码,
2.明确共享数据 对共享变量的操作是不是原子操作 , 当某一个线程对共享变量进行修改的时候,对其他线程是可见的
保证原子性的是加锁或者同步, 提供了volatile关键字来保证可见性, synchronized和锁和 volatile都能保证有序性
JVM还通过被称为happens-before原则隐式地保证顺序性。
3.明确多线程运行代码中哪些语句是操作共享数据.

1.该对象是否会被多个线程访问修改 ,是的话是否有加锁操作。
2.注意静态变量. ,由于静态变量是属于该类和该类下所有对象共享,可直接通过类名访问/修改,因此在多线程的环境下.可以断言所有对静态变量的修改都会发生线程安全问题

36.java中用到的线程调度算法是什么?

抢占式。一个线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。

操作系统中可能会出现某条线程常常获取到VPU控制权的情况,为了让某些优先级比较低的线程也能获取到CPU控制权,可以使用Thread.sleep(0)手动触发一次操作系统分配时间片的操作,这也是平衡CPU控制权的一种操作。

37.CyclicBarrier和CountDownLatch的区别

CountDownLatch

简单理解为减法计数器
JUC并发编程面试题_第5张图片

// 计数器
public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        // 总数是6,必须要执行任务的时候,再使用!
        CountDownLatch countDownLatch = new CountDownLatch(6);

        for (int i = 1; i <=6 ; i++) {
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+" 执行");
                countDownLatch.countDown(); // 数量-1
            },String.valueOf(i)).start();
        }

        countDownLatch.await(); // 等待计数器归零,然后再向下执行

        System.out.println("继续");

    }
}

JUC并发编程面试题_第6张图片

原理:
countDownLatch.countDown(); // 数量-1

countDownLatch.await(); // 等待计数器归零,然后再向下执行

每次有线程调用 countDown() 数量-1,假设计数器变为0,countDownLatch.await() 就会被唤醒,继续执行!

CyclicBarrier

加法计数器
JUC并发编程面试题_第7张图片

public class CyclicBarrierDemo {

    public static void main(String[] args) {
        CyclicBarrier cyclicBarrier=new CyclicBarrier(5,()->{
            System.out.println("学习5个小时了,去睡一觉吧!");
        });


        for (int i = 1; i < 9; i++) {
			 //使用lambda表达式,相当于重新编写了一个类,是没有办法直接使用到上级的变量的
            //此时需要我们自己定义一个临时变量来接受
            int temp=i;
            new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"学习了"+temp+"个小时");

                try {
                    cyclicBarrier.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            },"dessw").start();
        }

    }

}

JUC并发编程面试题_第8张图片

总结: 当i变成5以后,就会执行cyclicBarrier对象中的接口方法,然后再继续执行for函数。值得注意的是,如果我们设置的parties比较小的话,就会提前执行该方法

38.ReentrantLock 底层实现

ReentrantLock的底层实现机制是AQS(Abstract Queued Synchronizer 抽象队列同步器)。AQS没有锁之类的概念,它有个state变量,是个int类型,为了好理解,可以把state当成锁,AQS围绕state提供两种基本操作“获取”和“释放”,有条双向队列存放阻塞的等待线程。AQS的功能可以分为独占和共享,ReentrantLock实现了独占功能(每次只能有一个线程能持有锁)。

在ReentrantLock类中,有一个内部类Sync,它继承了AQS,但是将lock()方法定义为抽象方法,由子类负责实现(采用的是模板方法的设计模式)。

abstract void lock();

Sync分为公平锁和非公平锁,所以又有FairSync和NonfairSync继承Sync。

ReentrantLock的默认构造实现是非公平锁,也就是线程获取锁的顺序和调用lock的顺序无关。所有线程同时去竞争锁,线程发出请求后立即尝试获取锁,如果有可用的则直接获取锁,失败才进入等待队列。
JUC并发编程面试题_第9张图片
在非公平锁NonfairSync中,
JUC并发编程面试题_第10张图片
线程只要有机会就抢占,才不管排队的事。直接尝试将state修改为1,如果修改state失败,则和公平锁一样,调用acquire。(乐观锁的思想)

在公平锁FairSync中,线程去竞争一个锁,可能成功也可能失败。成功就直接持有资源,不需要进入队列;失败的话进入队列阻塞,等待唤醒后再尝试竞争锁。
JUC并发编程面试题_第11张图片
首先尝试着去获取锁,如果state的当前状态为0,且没有前继线程在等待,表明没有线程占用,此时可以获得锁,然后设置当前线程为独占线程,并返回true,此时不会调用acquireQueued()方法(&& : 只有当&&左边程序为真,才会执行&&右边的程序,否者不会执行后面的),且不会执行selfInterrupt()方法。
JUC并发编程面试题_第12张图片
相反,如果获取锁失败,则会执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

该首先会将当前线程包装成一个Node,插入到双向队列尾

该首先会将当前线程包装成一个Node,插入到双向队列尾JUC并发编程面试题_第13张图片
标记1判断该Node的前继节点p,如果前一个节点正好是head,表示自己排在第一位,可以马上调用tryAcquire尝试。如果获取成功就简单了,直接修改自己为head。这步是实现公平锁的核心。标记2是线程没有获取到锁的情况(当前线程没有排到第一位),这个时候,线程可能等着下一次获取,也可能不想要了,Node变量waitState描述了线程的等待状态。
JUC并发编程面试题_第14张图片
shouldParkAfterFailedAcquire()方法会根据前一个节点的waitStatus作出抉择,如果前节点状态是SIGNAL,则当前线程需要阻塞。
JUC并发编程面试题_第15张图片
如果线程需要阻塞,则由parkAndCheckInterrupt()方法进行操作。LockSupport和cas一样,最终使用UNSAFE调用Native方法实现线程阻塞(park和unpark方法作用类似于wait和notify)在这里插入图片描述
释放锁:

public void unlock() {
sync.release(1);
}
头节点是获取锁的线程,如果tryRelease()释放成功,会将头节点先移出队列,再通知后面的节点获取锁JUC并发编程面试题_第16张图片
如果有后继节点,则唤醒线程,重新尝试去获取锁
JUC并发编程面试题_第17张图片

39.为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

JUC并发编程面试题_第18张图片

40.线程池

方法:
JUC并发编程面试题_第19张图片
七大参数

public ThreadPoolExecutor(

int corePoolSize, // 核心线程池大小

int maximumPoolSize, // 最大核心线程池大小

long keepAliveTime, // 超时了没有人调用就会释放

TimeUnit unit, // 超时单位

BlockingQueue<Runnable> workQueue, // 阻塞队列

ThreadFactory threadFactory, // 线程工厂:创建线程的,一般不用动

RejectedExecutionHandler handle // 拒绝策略 
) 

正常情况下,就只会开启两个Core线程1,2,然后新开启的线程就在阻塞队列中。当阻塞队列满了以后,就会开启Max线程,来保证更多的线程能够执行。如果线程还在不断的增加,最终达到了线程的最大值,就会使用拒绝策略,不再接受对应的线程请求。当线程业务处理完毕以后,指定的时间以后,就可以关闭因业务量后来新开的线程,即窗口3、4、5。
JUC并发编程面试题_第20张图片
四种拒绝策略

//多出来的线程,直接抛出异常
new ThreadPoolExecutor.AbortPolicy()
    
//谁开启的这个线程,就让这个线程返回给谁执行。比如main线程开启的,那就返回给main线程执行    
new ThreadPoolExecutor.CallerRunsPolicy()
    
//如果队列线程数量满了以后,直接丢弃,不抛出异常
new ThreadPoolExecutor.DiscardPolicy()
    
//队列满了以后,尝试去和最早的线程竞争,也不会抛出异常
new ThreadPoolExecutor.DiscardOldestPolicy

41.线程间的同步的方式有哪些

JUC并发编程面试题_第21张图片

42.进程的调度算法

JUC并发编程面试题_第22张图片

43.AQS 底层原理

AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。

AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。

AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

JUC并发编程面试题_第23张图片

如图示,AQS维护了一个volatile int state和一个FIFO线程等待队列,多线程争用资源被阻塞的时候就会进入这个队列。state就是共享资源,其访问方式有如下三种:getState();setState();compareAndSetState();

AQS 定义了两种资源共享方式:

  • 1.Exclusive:独占,只有一个线程能执行,如ReentrantLock
  • 2.Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

AQS底层使用了模板方法模式:

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  • 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
  • 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS已经在顶层实现好了。自定义同步器实现的时候主要实现下面几种方法

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

独占锁:
ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。
注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。

共享锁:
以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。

分析:

独占锁的获取
调用lock()方法是获取独占锁,获取锁失败后调用AQS提供的acquire(int arg)模板方法将当前线程加入同步队列,成功则线程执行。来看ReentrantLock源码

final void lock() {    
	if (compareAndSetState(0, 1))        
		setExclusiveOwnerThread(Thread.currentThread());   
	else       
		acquire(1); 
}

lock方法使用CAS来尝试将同步状态改为1,如果成功则将同步状态持有线程置为当前线程。否则将调用AQS提供的 acquire()方法。

public final void acquire(int arg) {
 	    // 再次尝试获取同步状态,如果成功则方法直接返回    
 	    // 如果失败则先调用addWaiter()方法再调用acquireQueued()方法
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
}

tryAcquire(arg):再次尝试获取同步状态,成功直接方法退出,失败调用addWaiter();
addWaiter(Node.EXCLUSIVE), arg):将当前线程以指定模式(独占式、共享式)封装为Node节点后置入同步队列

private Node addWaiter(Node mode) {
    	// 将线程以指定模式封装为Node节点
        Node node = new Node(Thread.currentThread(), mode);
        // 获取当前队列的尾节点
        Node pred = tail;
        // 若尾节点不为空
        if (pred != null) {
            node.prev = pred;
            // 使用CAS将当前节点尾插到同步队列中
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                // CAS尾插成功,返回当前Node节点
                return node;
            }
        }
        // 尾节点为空 || CAS尾插失败
        enq(node);
        return node;
    }

分析上面的注释。程序的逻辑主要分为两个部分:

  1. 当前同步队列的尾节点为null,调用方法enq()插入;
  2. 当前队列的尾节点不为null,则采用尾插入(compareAndSetTail()方法)的方式入队。
  3. 另外还会有另外一个问题: 如果 if (compareAndSetTail(pred, node))为false怎么办?会继续执行到enq()方法,同时很明显compareAndSetTail() 是一个CAS操作,通常来说如果CAS操作失败会继续自旋(死循环)进行重试。

因此,经过我们这样的分析,enq()方法可能承担两个任务:

  • 处理当前同步队列尾节点为null时进行入队操作;
  • 如果CAS尾插入节点失败后负责自旋进行尝试
private Node enq(final Node node) {
    	// 直到将当前节点插入同步队列成功为止
        for (;;) {
            Node t = tail;
            // 初始化同步队列
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
            	// 不断CAS将当前节点尾插入同步队列中
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
       }
}

在上面的分析中我们可以看出在第1步中会先创建头结点,说明同步队列是带头结点的链式存储结构。带头结点与不带头结点相比,会在入队和出队的操作中获得更大的便捷性,因此同步队列选择了带头结点的链式存储结构。那么带头节点的队列初始化时机是什么?自然而然是在tail==null时,即当前线程是第一次插入同步队列。 compareAndSetTail(t, node)方法会利用CAS操作设置尾节点,如果CAS操作失败会在for (;;)死循环中不断尝试,直至成功return返回为止。

因此,对enq()方法可以做这样的总结:

  • 在当前线程是第一个加入同步队列时,调用compareAndSetHead(new Node())方法,完成链式队列头结点的初始化;
  • 自旋不断尝试CAS尾插入节点直至成功为止。

现在我们已经很清楚获取独占式锁失败的线程包装成Node然后插入同步队列的过程了。那么紧接着会有下一个问题:在同步队列中的节点(线程)会做什么事情来保证自己能够有机会获得独占式锁?

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            // 自旋
            for (;;) {
            	// 获取当前节点的前驱节点
                final Node p = node.predecessor();
                // 获取同步状态成功条件
                // 前驱节点为头结点并且获取同步状态成功
                if (p == head && tryAcquire(arg)) {
                	// 将当前节点设置为头结点
                    setHead(node);
                    // 删除原来的头结点
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
        	// 获取失败将当前节点取消
            if (failed)
                cancelAcquire(node);
        }
}

整体来看这是一个这又是一个自旋的过程(for(;),代码首先获取当前节点的先驱节点,如果先驱节点是头结点的并且成功获得同步状态的时候(if (p == head && tryAcquire(arg))),表示当前节点所指向的线程能够获取锁,方法执行结束。反之,获取锁失败进入等待状态,先不断自旋将前驱节点状态置为SIGINAL,
而后调用LockSupport.park()方法将当前线程阻塞。整体示意图为下图:
JUC并发编程面试题_第24张图片

获取锁成功并且节点出队的逻辑

// 当前节点前驱为头结点并且再次获取同步状态成功
if (p == head && tryAcquire(arg)) {
	//队列头结点引用指向当前节点 
	setHead(node); 
	//释放前驱节点 
	p.next = null; // help GC
	failed = false;
	return interrupted;
}

private void setHead(Node node) {
	head = node;
	node.thread = null;
	node.prev = null;
}

将当前节点通过setHead()方法设置为队列的头结点,然后将之前的头结点的next域设置为null并且pre域也为null,即与队列断开,无任何引用方便GC时能够将内存进行回收。
JUC并发编程面试题_第25张图片
那么当节点在同步队列中获取锁失败的时候会调用shouldParkAfterFailedAcquire()方法。此方法主要逻辑
是使用CAS将前驱节点状态由INITIAL置为SIGNAL,表示需要将当前节点阻塞。如果CAS失败,说明 shouldParkAfterFailedAcquire()方法返回false,然后会在acquireQueued()方法中的for (;;)死循环中不断自旋直到前驱节点状态置为SIGANL为止,返回true时才会执行方法 parkAndCheckInterrupt()方法。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 获取前驱节点的节点状态
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
            // 前驱节点已被取消
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            // 不断重试直到找到一个前驱节点状态不为取消状态
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            // 前驱节点状态不是取消状态时,将前驱节点状态置为-1,
            // 表示后继节点应该处于等待状态
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

parkAndCheckInterrupt()方法的源码为

private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

该方法的关键是会调用LookSupport.park()方法,该方法是用来阻塞当前线程的。

整体上看,acquireQueued()在自旋过程中主要完成了两件事情:

  • 如果当前节点的前驱节点是头节点,并且能够获得同步状态的话,当前线程能够获得锁该方法执行结束退出。
  • 获取锁失败的话,先将节点状态设置成SIGNAL,然后调用LookSupport.park方法使得当前线程阻塞。

独占式锁的获取过程也就是acquire()方法的执行流程
JUC并发编程面试题_第26张图片
独占锁的释放(release()方法)
独占锁的释放调用unlock方法,而该方法实际调用了AQS的release方法

public void unlock() {    
	sync.release(1); 
}

public final boolean release(int arg) {    
	if (tryRelease(arg)) {        
		Node h = head;        
		if (h != null && h.waitStatus != 0)            
			unparkSuccessor(h);        
			return true;    
		}    
		return false; 
	}
}	

这段代码逻辑就比较容易理解了,如果同步状态释放成功(tryRelease返回true)则会执行if块中的代码,当head指向的头结点不为null,并且该节点的状态值不为0的话才会执行unparkSuccessor()方法。

private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
         // 头结点的后继节点 
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
        	 // 后继节点不为null时唤醒 
            LockSupport.unpark(s.thread);
    }

首先获取头节点的后继节点,当后继节点不为空的时候会调用LookSupport.unpark()方法,该方法会唤醒该节点的后继节点所包装的线程。因此,每一次锁释放后就会唤醒队列中该节点的后继节点所引用的线程,从而进一步可以佐证获得锁的过程是一个FIFO(先进先出)的过程。

独占式锁获取与释放总结

  • 线程获取锁失败,将线程调用addWaiter()封装成Node进行入队操作。addWaiter()中enq()方法完成对同步队列的头节点初始化以及CAS尾插失败后的重试处理。
  • 入队之后排队获取锁的核心方法acquireQueued(),节点排队获取锁是一个自旋过程。当且仅当当前节点的前驱节点为头节点并且获取同步状态时,节点出队并且该节点引用的线程获取到锁。否则不满足条件时会不断自旋将前驱节点的状态置为SIGNAL后调用LockSupport.part()将当前线程阻塞。
  • 释放锁时会唤醒后继结点(后继结点不为null)。

44.一致性哈希

你可能感兴趣的:(面试,juc,多线程,并发编程,java)