Java 并发编程

1.线程是什么?多线程是什么?

线程: 是最小的调度单位,包含在进程中。
多线程: 多个线程并发执行的技术。

2.守护线程和用户线程

守护线程: jvm给的线程。比如:GC守护线程。
用户线程: 用户自己定义的线程。比如:main()线程。

拓展:
Thread.setDaemon(false)设置为用户线程
Thread.setDaemon(true)设置为守护线程

3.线程的各个状态

新建(New): 新建一个线程。
就绪(Runnable): 抢夺cpu的使用权。
运行(Running): 开始执行任务。
阻塞(Blocked): 让线程等待,等待结束进入就绪队列。
死亡(Dead): 线程正常结束或异常结束。

4.线程相关的基本方法有 wait,notify,notifyAll,sleep,join,yield 等


wait(): 线程等待,会释放锁,用于同步代码块或同步方法中,进入等待状态
sleep(): 线程睡眠,不会释放锁,进入超时等待状态
yield(): 线程让步,会使线程让出cpu使用权,进入就绪状态
join(): 指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行的线程。
notify(): 随机唤醒一个在等待中的线程,进入就绪状态。
notifyAll(): 唤醒全部在等待中的线程,进入就绪状态。

5.wait()和sleep()的区别?


① wait() 来自Object,sleep()来自Thread。
② wait()会释放锁,sleep()不会释放锁。
③ wait()只能用在同步方法或代码块中,sleep()可以用在任何地方。
④ wait()不需要捕获异常,sleep()需要捕获异常。

 

6.为什么 wait()、notify()、notifyAll()方法定义在 Object 类里面,而不是 Thread 类?


①如果定义在Thread类中,那只能是Thread类的对象才能调用这些方法,但是 锁可以是任何对象。
② java中进入临界区(同步代码块或同步方法),线程只需要拿到锁就行,而并不关心锁被那个线程持有。
③ 上面方法是java两个线程之间的通信机制,如果不能通过类似synchronized这样的Java关键字来实现这种机制,那么Object类中就是定义它们最好的地方,以此来使任何Java对象都可以拥有实现线程通信机制的能力。

7.start()和run()的区别


start()方法: 是启动线程,调用了之后线程会进入就绪状态,一旦拿到cpu使用权就开始执行run()方法,不能重复调用start(),否则会报异常。
run()方法: 就相当于一个普通的方法而已。直接调用run()方法就还只有一个主线程,还是会顺序执行,也可以重复调用run()方法。

8.实现多线程的方式

①继承Thread类。
②实现Runnable接口
③实现Callable接口
④线程池

9.Runnable和Callable的区别

①Runnable没有返回值,Callable有返回值。
②Runnable只能抛出异常,不能捕获,Callable 能抛出异常,也能捕获。

10.线程池的好处

① 线程是稀缺资源,使用线程池可以减少线程的创建和销毁,每个线程都可重复使用。
② 可以根据系统的需求,调整线程池里面线程的个数,防止了因为消耗内存过多导致服务器崩溃。

11.线程池的七大参数
corePoolSize: 核心线程数,创建不能被回收,可以设置被回收。
maximumPoolSize: 最大线程数。
keepAliveTime: 空闲线程存活时间。
unit: 单位。
workQueue: 等待队列。
threadFactory: 线程工程,用于创建线程。
handler: 拒绝策略。

12.线程池的执行过程

①接到任务,判断核心线程池是否满了,没满执行任务,满了放入等待队列。
②等待队列没满,存入队列,等待执行,满了去查看最大线程数。
③最大线程数没满,执行任务,满了执行拒绝策略。

13.四大方法


①ExecutorService executor = Executors.newCachedThreadPool(): 创建一个缓存线程池,灵活回收线程,任务过多,会oom。
②ExecutorService executor = Executors.newFixedThreadPool(): 创建一个指定线程数量的线程池。提高了线程池的效率和线程的创建的开销,等待队列可能堆积大量请求,导致oom。
③ExecutorService executor = Executors.newSingleThreadPool(): 创建一个单线程,保证线程的有序,出现异常再次创建,速度没那么快。
④ExecutorService executor = Executors.newScheduleThreadPool(): 创建一个定长的线程池,支持定时及周期性任务执行。

                        

14.四大拒绝策略


①new ThreadPoolExecutor.AbortPolicy(): 添加线程池被拒绝,会抛出异常(默认策略)。
②new ThreadPoolExecutor.CallerRunsPolicy(): 添加线程池被拒绝,不会放弃任务,也不会抛出异常,会让调用者线程去执行这个任务(就是不会使用线程池里的线程去执行任务,会让调用线程池的线程去执行)。
③new ThreadPoolExecutor.DiscardPolicy(): 添加线程池被拒绝,丢掉任务,不抛异常。
④new ThreadPoolExecutor.DiscardOldestPolicy(): 添加线程池被拒绝,会把线程池队列中等待最久的任务放弃,把拒绝任务放进去。

15.shutdown 和 shutdownNow 的区别?


① shutdown没有返回值,shutdownNow会返回没有执行完任务的集合。
②shutdown不会抛出异常,shutdownNow会抛出异常。
③shutdown会等待执行完线程池的任务在关闭,shutdownNow会给所有线程发送中断信号,然后中断任务,关闭线程池。

16.什么是死锁?

各进程互相等待对方手里的资源,导致各进程都阻塞,无法向前推进的现象

17.造成死锁的四个必要条件

互斥: 当资源被一个线程占用时,别的线程不能使用。
不可抢占: 进程阻塞时,对占用的资源不释放。
不剥夺: 进程获得资源未使用完,不能被强行剥夺。
循环等待: 若干进程之间形成头尾相连的循环等待资源关系。

18.线程安全主要是三方面

原子性: 一个或多个操作,要么全部执行,要么全部不执行(执行的过程中是不会被任何因素打断的)。
可见性: 一个线程对主内存的修改可以及时的被其他线程观察到。
有序性: 程序执行的顺序按照代码的先后顺序执行。

保证原子性
使用锁 synchronized和 lock。
使用CAS (compareAndSet:比较并交换),CAS是cpu的并发原语)。

保证可见性
使用锁 synchronized和 lock。
使用volatile关键字 。

保证有序性
使用 volatile 关键字
使用 synchronized 关键字。

19.volatile和synchronized的区别


① volatile仅能使用在变量级别的,synchronized可以使用在变量、方法、类级别的
② volatile不具备原子性,具备可见性,synchronized有原子性和可见性。
③ volatile不会造成线程阻塞,synchronized会造成线程阻塞。
④ volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized要好。

20.synchronized和lock的区别


① synchronized是关键字,lock是java类,默认是不公平锁(源码)。
② synchronized适合少量同步代码,lock适合大量同步代码。
③ synchronized会自动释放锁,lock必须放在finally中手工unlock释放锁,不然容易死锁。

  原文链接:https://blog.csdn.net/twotwo22222/article/details/128937317

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

  • 并行是指多个处理器同时执行多个任务,每个核心实际上可以在同一时间独立地执行不同的任务。
  • 并发是指系统有处理多个任务的能力,但是任意时刻只有一个任务在执行。在单核处理器上,多个任务是通过时间片轮转的方式实现的。但这种切换非常快,给人感觉是在同时执行。

三分恶面渣逆袭:并行和并发

三分恶面渣逆袭:并行和并发

就好像我们去食堂打饭,并行就是每个人对应一个阿姨,同时打饭;而并发就是一个阿姨,轮流给每个人打饭。

三分恶面渣逆袭:并行并发和食堂打饭

2.说说什么是进程和线程?

进程说简单点就是我们在电脑上启动的一个个应用,比如我们启动一个浏览器,就会启动了一个浏览器进程。进程是操作系统资源分配的最小单位,它包括了程序、数据和进程控制块等。

线程说简单点就是我们在 Java 程序中启动的一个 main 线程,一个进程至少会有一个线程。当然了,我们也可以启动多个线程,比如说一个线程进行 IO 读写,一个线程进行加减乘除计算,这样就可以充分发挥多核 CPU 的优势,因为 IO 读写相对 CPU 计算来说慢得多。线程是 CPU 分配资源的基本单位。

三分恶面渣逆袭:进程与线程关系

三分恶面渣逆袭:进程与线程关系

一个进程中可以有多个线程,多个线程共用进程的堆和方法区(Java 虚拟机规范中的一个定义,JDK 8 以后的实现为元空间)资源,但是每个线程都会有自己的程序计数器和栈

3.说说线程有几种创建方式?

推荐阅读:室友打了一把王者就学会了 Java 多线程open in new window

Java 中创建线程主要有三种方式,分别为继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。

二哥的 Java 进阶之路

二哥的 Java 进阶之路

第一种,继承 Thread 类,重写 run()方法,调用 start()方法启动线程。

class ThreadTask extends Thread {
    public void run() {
        System.out.println("看完二哥的 Java 进阶之路,上岸了!");
    }

    public static void main(String[] args) {
        ThreadTask task = new ThreadTask();
        task.start();
    }
}

这种方法的缺点是,由于 Java 不支持多重继承,所以如果类已经继承了另一个类,就不能使用这种方法了。

第二种,实现 Runnable 接口,重写 run() 方法,然后创建 Thread 对象,将 Runnable 对象作为参数传递给 Thread 对象,调用 start() 方法启动线程。

class RunnableTask implements Runnable {
    public void run() {
        System.out.println("看完二哥的 Java 进阶之路,上岸了!");
    }

    public static void main(String[] args) {
        RunnableTask task = new RunnableTask();
        Thread thread = new Thread(task);
        thread.start();
    }
}

这种方法的优点是可以避免 Java 的单继承限制,并且更符合面向对象的编程思想,因为 Runnable 接口将任务代码和线程控制的代码解耦了。

第三种,实现 Callable 接口,重写 call() 方法,然后创建 FutureTask 对象,参数为 Callable 对象;紧接着创建 Thread 对象,参数为 FutureTask 对象,调用 start() 方法启动线程。

class CallableTask implements Callable<String> {
    public String call() {
        return "看完二哥的 Java 进阶之路,上岸了!";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CallableTask task = new CallableTask();
        FutureTask<String> futureTask = new FutureTask<>(task);
        Thread thread = new Thread(futureTask);
        thread.start();
        System.out.println(futureTask.get());
    }
}

这种方法的优点是可以获取线程的执行结果。

4.调用 start()方法时会执行 run()方法,那怎么不直接调用 run()方法?

在 Java 中,启动一个新的线程应该调用其start()方法,而不是直接调用run()方法。

当调用start()方法时,会启动一个新的线程,并让这个新线程调用run()方法。这样,run()方法就在新的线程中运行,从而实现多线程并发。

class MyThread extends Thread {
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start(); // 正确的方式,创建一个新线程,并在新线程中执行 run()
        t1.run(); // 仅在主线程中执行 run(),没有创建新线程
    }
}

如果直接调用run()方法,那么run()方法就在当前线程中运行,没有新的线程被创建,也就没有实现多线程的效果。

5.线程有哪些常用的调度方法?

三分恶面渣逆袭:线程常用调度方法

#说说线程等待与通知?

在 Object 类中有一些方法可以用于线程的等待与通知。

①、wait():当一个线程 A 调用一个共享变量的 wait() 方法时,线程 A 会被阻塞挂起,直到发生下面几种情况才会返回 :

  • 线程 B 调用了共享对象 notify()或者 notifyAll() 方法;
  • 其他线程调用了线程 A 的 interrupt() 方法,线程 A 抛出 InterruptedException 异常返回。

②、wait(long timeout) :这个方法相比 wait() 方法多了一个超时参数,它的不同之处在于,如果线程 A 调用共享对象的 wait(long timeout)方法后,没有在指定的 timeout 时间内被其它线程唤醒,那么这个方法还是会因为超时而返回。

③、wait(long timeout, int nanos),其内部调用的是 wait(long timout) 方法。

唤醒线程主要有下面两个方法:

①、notify():一个线程 A 调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程。

一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。

②、notifyAll():不同于在共享变量上调用 notify() 方法会唤醒被阻塞到该共享变量上的一个线程,notifyAll 方法会唤醒所有在该共享变量上调用 wait 系列方法而被挂起的线程。

Thread 类还提供了一个 join() 方法,意思是如果一个线程 A 执行了 thread.join(),当前线程 A 会等待 thread 线程终止之后才从 thread.join() 返回。

#说说线程休眠

sleep(long millis):Thread 类中的静态方法,当一个执行中的线程 A 调用了 Thread 的 sleep 方法后,线程 A 会暂时让出指定时间的执行权。

但是线程 A 所拥有的监视器资源,比如锁,还是持有不让出的。指定的睡眠时间到了后该方法会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就可以继续运行。

#说说让出优先权

yield():Thread 类中的静态方法,当一个线程调用 yield 方法时,实际是在暗示线程调度器,当前线程请求让出自己的 CPU,但是线程调度器可能会“装看不见”忽略这个暗示。

#说说线程中断

推荐阅读:interrupt 方法open in new window

Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行。被中断的线程会根据中断状态自行处理。

  • void interrupt() 方法:中断线程,例如,当线程 A 运行时,线程 B 可以调用线程 interrupt() 方法来设置线程的中断标志为 true 并立即返回。设置标志仅仅是设置标志, 线程 B 实际并没有被中断,会继续往下执行。
  • boolean isInterrupted() 方法: 检测当前线程是否被中断。
  • boolean interrupted() 方法: 检测当前线程是否被中断,与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志。

为了响应中断,线程的执行代码应该这样编写:

public void run() {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            // 执行任务
        }
    } catch (InterruptedException e) {
        // 线程被中断时的清理代码
    } finally {
        // 线程结束前的清理代码
    }
}

stop 方法用来强制线程停止执行,目前已经处于废弃状态,因为 stop 方法会导致线程立即停止,可能会在不一致的状态下释放锁,破坏对象的一致性,导致难以发现的错误和资源泄漏。

7.什么是线程上下文切换?

使用多线程的目的是为了充分利用 CPU,但是我们知道,并发其实是一个 CPU 来应付多个线程。

三分恶面渣逆袭:线程切换

为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换。

8.守护线程了解吗?

Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。

在 JVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。其实在 JVM 内部同时还启动了很多守护线程, 比如垃圾回收线程。

那么守护线程和用户线程有什么区别呢?守护线程是否结束并不影响 JVM 退出。换而言之,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出。

9.线程间有哪些通信方式?

线程之间传递信息有多种方式,每种方式适用于不同的场景。比如说使用共享对象、wait() 和 notify()、Exchanger 和 CompletableFuture。

①、使用共享对象,多个线程可以访问和修改同一个对象,从而实现信息的传递,比如说 volatile 和 synchronized 关键字。

关键字 volatile 用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,保证所有线程对变量访问的可见性。

关键字 synchronized 可以修饰方法,或者以同步代码块的形式来使用,确保多个线程在同一个时刻,只能有一个线程在执行某个方法或某个代码块。

②、使用 wait() 和 notify(),例如,生产者-消费者模式中,生产者生产数据,消费者消费数据,通过 wait() 和 notify() 方法可以实现生产和消费的协调。

一个线程调用共享对象的 wait() 方法时,它会进入该对象的等待池,并释放已经持有的该对象的锁,进入等待状态,直到其他线程调用相同对象的 notify() 或 notifyAll() 方法。

一个线程调用共享对象的 notify() 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。

③、使用 Exchanger,Exchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 exchange() 方法,将数据传递给另一个线程,同时接收另一个线程的数据。

④、使用 CompletableFuture,CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。

10线程安全,说一个使用场景?(补充)

线程安全是 Java 并发编程中一个非常重要的概念,它指的是多线程环境下,多个线程对共享资源的访问不会导致数据的不一致性。

一个常见的使用场景是在实现单例模式时确保线程安全。

单例模式确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例。

饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全。

class Singleton {
    private static final Singleton instance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}

懒汉式单例则在第一次使用时初始化,这种方式需要使用双重检查锁定来确保线程安全,volatile 用来保证可见性,syncronized 用来保证同步。

public class LazySingleton {
    private static volatile LazySingleton instance;

    private LazySingleton() {}

    public static LazySingleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (LazySingleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}

ThreadLocal

10.ThreadLocal 是什么?

ThreadLocal一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离,用于解决多线程中共享对象的线程安全问题。

在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。

在数据库操作中,可以使用 ThreadLocal 存储数据库连接对象,每个线程有自己独立的数据库连接,从而避免了多线程竞争同一数据库连接的问题。

在格式化操作中,例如日期格式化,可以使用 ThreadLocal 存储 SimpleDateFormat 实例,避免多线程共享同一实例导致的线程安全问题。

使用 ThreadLocal 通常分为四步:

①、创建 ThreadLocal变量

//创建一个ThreadLocal变量
public static ThreadLocal<String> localVariable = new ThreadLocal<>();

②、设置 ThreadLocal 的值

//设置ThreadLocal变量的值
localVariable.set("沉默王二是沙雕");

③、获取 ThreadLocal 的值

//获取ThreadLocal变量的值
String value = localVariable.get();

④、删除 ThreadLocal 的值

//删除ThreadLocal变量的值
localVariable.remove();

11.你在工作中用到过 ThreadLocal 吗?

有用到过,用来存储用户信息。

登录后的用户每次访问接口,都会在请求头中携带一个 token,在控制层可以根据这个 token,解析出用户的基本信息。

假如在服务层和持久层也要用到用户信息,就可以在控制层拦截请求把用户信息存ThreadLocal。

这样我们在任何一个地方,都可以取出 ThreadLocal 中存的用户信息。

数据库连接池也可以用 ThreadLocal,将数据库连接池的连接交给 ThreadLocal 进行管理,能够保证当前线程的操作都是同一个 Connnection。

12.ThreadLocal 怎么实现的呢?

ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。

1、当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中。

2、当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象。

3、Map 的大小由 ThreadLocal 对象的多少决定。

ThreadLocal 本身并不存储任何值,它只是作为一个映射,来映射线程的局部变量。当一个线程调用 ThreadLocal 的 set 或 get 方法时,实际上是访问线程自己的 ThreadLocal.ThreadLocalMap。

ThreadLocalMap 是 ThreadLocal 的静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量本身。

三分恶面渣逆袭:ThreadLoca结构图

早期的 ThreadLocal 不是这样的,它的 ThreadLocalMap 中使用 Thread 作为 key,这也是最简单的实现方式。

黑马:JDK 早期设计

优化后的方案有两个好处,一个是 Map 中存储的键值对变少了;另一个是 ThreadLocalMap 的生命周期和线程一样长,线程销毁的时候,ThreadLocalMap 也会被销毁。

Entry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry 的 value 设置为 null,这样在很大程度上可以避免内存泄漏。

13.ThreadLocal 内存泄露是怎么回事?

通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏。

但如果一个线程一直在运行,并且其 ThreadLocalMap 中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题。

那怎么解决内存泄漏问题呢?

很简单,使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间。

remove() 方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。

try {
    threadLocal.set(value);
    // 执行业务操作
} finally {
    threadLocal.remove(); // 确保能够执行清理
}
 

15.ThreadLocalMap 怎么解决 Hash 冲突的?

用的是——开放定址法。简单来说,就是这个坑被人占了,那就接着去找空着的坑。

ThreadLocalMap解决冲突

ThreadLocalMap解决冲突

如上图所示,如果我们插入一个 value=27 的数据,通过 hash 计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry 数据,而且 Entry 数据的 key 和当前不相等。此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,把元素放到空的槽中。

在 get 的时候,也会根据 ThreadLocal 对象的 hash 值,定位到 table 中的位置,然后判断该槽位 Entry 对象中的 key 是否和 get 的 key 一致,如果不一致,就判断下一个位置

Java 内存模型

#18.说一下你对 Java 内存模型的理解?

Java 内存模型(Java Memory Model)是一种抽象的模型,简称 JMM,主要用来定义多线程中变量的访问规则,用来解决变量的可见性、有序性和原子性问题,确保在并发环境中安全地访问共享变量。

深入浅出 Java 多线程:Java内存模型

JMM 定义了线程内存和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了共享变量的副本,用来进行线程内部的读写操作。

  • 当一个线程更改了本地内存中共享变量的副本后,它需要将这些更改刷新到主内存中,以确保其他线程可以看到这些更改。
  • 当一个线程需要读取共享变量时,它可能首先从本地内存中读取。如果本地内存中的副本是过时的,线程将从主内存中重新加载共享变量的最新值到本地内存中。

20.那说说什么是指令重排?

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型。

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

从 Java 源代码到最终实际执行的指令序列,会分别经历下面 3 种重排序,如图:

多级指令重排

多级指令重排

我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子,Singleton instance=new Singleton();对应的 JVM 指令分为三步:分配内存空间-->初始化对象--->对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三步就可能会重排序。

21.指令重排有限制吗?happens-before 了解吗?

指令重排也是有一些限制的,有两个规则happens-beforeas-if-serial来约束。

happens-before 的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法

2.as-if-serial 又是什么?单线程的程序一定是顺序的吗?

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

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

volatile 怎么保证有序性的呢?

在程序执行期间,为了提高性能,编译器和处理器会对指令进行重排序。但涉及到 volatile 变量时,它们必须遵循一定的规则:

  • 写 volatile 变量的操作之前的操作不会被编译器重排序到写操作之后。
  • 读 volatile 变量的操作之后的操作不会被编译器重排序到读操作之前。

这意味着 volatile 变量的写操作总是发生在任何后续读操作之前。

#volatile和synchronized的区别

volatile 关键字用于修饰变量,确保该变量的更新操作对所有线程是可见的,即一旦某个线程修改了 volatile 变量,其他线程会立即看到最新的值。

synchronized 关键字用于修饰方法或代码块,确保同一时刻只有一个线程能够执行该方法或代码块,从而实现互斥访问。

23.volatile 实现原理了解吗?

volatile 关键字主要有两个作用,一个是保证变量的内存可见性,一个是禁止指令重排序。

#volatile 怎么保证可见性的呢?

当一个变量被声明为 volatile 时,Java 内存模型会确保所有线程看到该变量时的值是一致的。

深入浅出 Java 多线程:Java内存模型

也就是说,当线程对 volatile 变量进行写操作时,JMM 会在写入这个变量之后插入一个 Store-Barrier(写屏障)指令,这个指令会强制将本地内存中的变量值刷新到主内存中。

当线程对 volatile 变量进行读操作时,JMM 会插入一个 Load-Barrier(读屏障)指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。

volatile 怎么保证有序性的呢?

在程序执行期间,为了提高性能,编译器和处理器会对指令进行重排序。但涉及到 volatile 变量时,它们必须遵循一定的规则:

  • 写 volatile 变量的操作之前的操作不会被编译器重排序到写操作之后。
  • 读 volatile 变量的操作之后的操作不会被编译器重排序到读操作之前。

24.synchronized 用过吗?怎么使用?

在 Java 中,synchronized 是最常用的锁,它使用简单,并且可以保证线程安全,避免多线程并发访问时出现数据不一致的情况。

synchronized 可以用在方法和代码块中。

①、修饰方法

当在方法声明中使用了 synchronized 关键字,就表示该方法是同步的,也就是说,线程在执行这个方法的时候,其他线程不能同时执行,需要等待锁释放。

public synchronized void increment() {
    this.count++;
}

②、修饰代码块

同步代码块可以减少需要同步的代码量,颗粒度更低,更灵活。

public void increment() {
    synchronized (this) {
        this.count++;
    }
}

synchronized 后面的括号中指定了要锁定的对象,可以是 this,也可以是其他对象。

25.synchronized 的实现原理?

synchronized 是怎么加锁的呢?

我们使用 synchronized 的时候,发现不用自己去 lock 和 unlock,是因为 JVM 帮我们把这个事情做了。

  1. synchronized 修饰代码块时,JVM 采用monitorentermonitorexit两个指令来实现同步,monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指向同步代码块的结束位置。

  2. synchronized 修饰同步方法时,JVM 采用ACC_SYNCHRONIZED标记符来实现同步,这个标识指明了该方法是一个同步方法。

26.除了原子性,synchronized 可见性,有序性,可重入性怎么实现?

#synchronized 怎么保证可见性?
  • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  • 线程加锁后,其它线程无法获取主内存中的共享变量。
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中。
#synchronized 怎么保证有序性?

synchronized 同步的代码块,具有排他性,一次只能被一个线程拥有,所以 synchronized 保证同一时刻,代码是单线程执行的。

因为 as-if-serial 语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。

所以 synchronized 保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

#synchronized 怎么实现可重入的呢?

可重入意味着同一个线程可以多次获得同一个锁,而不会被阻塞。具体来说,如果一个线程已经持有某个锁,那么它可以再次进入该锁保护的代码块或方法,而不会被阻塞。

synchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。

当一个线程获取对象锁时,JVM 会将该线程的 ID 写入 Mark Word,并将锁计数器设为 1。

如果一个线程尝试再次获取已经持有的锁,JVM 会检查 Mark Word 中的线程 ID。如果 ID 匹配,表示的是同一个线程,锁计数器递增。

当线程退出同步块时,锁计数器递减。如果计数器值为零,JVM 将锁标记为未持有状态,并清除线程 ID 信息。

27.锁升级?synchronized 优化了解吗?

#什么是锁升级?

在 Java 对象头里,有一块结构,叫Mark Word,里面会记录锁的状态。

Mark Word 会记录对象自身的运行数据,如哈希码、GC 分代年龄、锁状态标志、偏向时间戳(Epoch) 等。

其中的锁状态标志位就是用来记录锁的状态的,有四种状态:

①、无锁状态,在这个状态下,没有线程试图获取锁。

②、偏向锁,当第一个线程访问同步块时,锁会进入偏向模式。

Mark Word 会被设置为偏向模式,并且存储了获取它的线程 ID。

偏向锁的目的是消除同一线程的后续锁获取和释放的开销。如果同一线程再次请求锁,就无需再次同步。

③、当有多个线程竞争锁,但没有锁竞争的强烈迹象(即线程交替执行同步块)时,偏向锁会升级为轻量级锁。

线程尝试通过CAS 操作(Compare-And-Swap)将对象头的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获取轻量级锁;如果失败,说明有竞争。

④、重量级锁,当锁竞争激烈时,轻量级锁会膨胀为重量级锁。

重量级锁通过将对象头的 Mark Word 指向监视器(Monitor)对象来实现,该对象包含了锁的持有者、锁的等待队列等信息。

三分恶面渣逆袭:Mark Word变化

synchronized 做了哪些优化?

在 JDK1.6 之前,synchronized 是直接调用 ObjectMonitor 的 enter 和 exit 实现的,这种锁也被称为重量级锁。这也是为什么很多声音说不要用 synchronized 的原因,有点“谈虎色变”的感觉。

从 JDK 1.6 开始,HotSpot 对 Java 中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,极大提升了 synchronized 的性能。

①、偏向锁:当一个线程首次获得锁时,JVM 会将锁标记为偏向这个线程,将锁的标志位设置为偏向模式,并且在对象头中记录下该线程的 ID。

之后,当相同的线程再次请求这个锁时,就无需进行额外的同步。如果另一个线程尝试获取这个锁,偏向模式会被撤销,并且锁会升级为轻量级锁。

②、轻量级锁:多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。

当一个线程尝试获取轻量级锁时,它会在自己的栈帧中创建一个锁记录(Lock Record),然后尝试使用 CAS 操作将对象头的 Mark Word 替换为指向锁记录的指针。

如果成功,该线程持有锁;如果失败,表示有其他线程竞争,锁会升级为重量级锁。

③、自旋锁:当线程尝试获取轻量级锁失败时,它会进行自旋,即循环检查锁是否可用,以避免立即进入阻塞状态。

自旋的次数不是固定的,而是根据之前在同一个锁上的自旋时间和锁的状态动态调整的。

④、锁粗化:如果 JVM 检测到一系列连续的锁操作实际上是在单一线程中完成的,则会将多个锁操作合并为一个更大范围的锁操作,这可以减少锁请求的次数。

锁粗化主要针对循环内连续加锁解锁的情况进行优化。

⑤、锁消除:JVM 的即时编译器(JITopen in new window)可以在运行时进行代码分析,如果发现某些锁操作不可能被多个线程同时访问,那么这些锁操作就会被完全消除。锁消除可以减少不必要的同步开销

28.说说 synchronized 和 ReentrantLock 的区别?

synchronized 是一个关键字,而 Lock 属于一个接口,其实现类主要有 ReentrantLockopen in new windowReentrantReadWriteLockopen in new window

三分恶面渣逆袭:synchronized和ReentrantLock的区别

#29.AQS 了解多少?

推荐阅读:到底什么是 AQS?open in new window

AQS,全称是 AbstractQueuedSynchronizer,中文意思是抽象队列同步器,,许多同步类的实现都依赖于它,如 ReentrantLock、Semaphore、CountDownLatch 等。

AQS 的思想是,如果被请求的共享资源空闲,则当前线程能够成功获取资源;否则,它将进入一个等待队列,当有其他线程释放资源时,系统会挑选等待队列中的一个线程,赋予其资源。

整个过程通过维护一个 int 类型的状态和一个先进先出(FIFO)的队列,来实现对共享资源的管理。

三分恶面渣逆袭:AQS抽象队列同步器

AQS 支持两种同步方式:

  • 独占模式:这种方式下,每次只能有一个线程持有锁,例如 ReentrantLock。
  • 共享模式:这种方式下,多个线程可以同时获取锁,例如 Semaphore 和 CountDownLatch。

子类可以通过继承 AQS 并实现它的方法来管理同步状态,这些方法包括:

  • tryAcquire:独占方式尝试获取资源,成功则返回 true,失败则返回 false;
  • tryRelease:独占方式尝试释放资源;
  • tryAcquireShared(int arg):共享方式尝试获取资源;
  • tryReleaseShared(int arg):共享方式尝试释放资源;
  • isHeldExclusively():该线程是否正在独占资源。

30.ReentrantLock 实现原理?

ReentrantLock 是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞。

可重入表示当前线程获取该锁后再次获取不会被阻塞,也就意味着同一个线程可以多次获得同一个锁而不会发生死锁。

ReentrantLock 的加锁和解锁:

// 创建非公平锁
ReentrantLock lock = new ReentrantLock();
// 获取锁操作
lock.lock();
try {
    // 执行代码逻辑
} catch (Exception ex) {
    // ...
} finally {
    // 解锁操作
    lock.unlock();
}

new ReentrantLock() 默认创建的是非公平锁 NonfairSync。

#公平锁 FairSync

在公平锁模式下,锁会授予等待时间最长的线程。

#非公平锁 NonfairSync

在非公平锁模式下,锁可能会授予刚刚请求它的线程,而不考虑等待时间。

ReentrantLock 内部通过一个计数器来跟踪锁的持有次数。

当线程调用lock()方法获取锁时,ReentrantLock 会检查当前状态,判断锁是否已经被其他线程持有。如果没有被持有,则当前线程将获得锁;如果锁已被其他线程持有,则当前线程将根据锁的公平性策略,可能会被加入到等待队列中。

线程首次获取锁时,计数器值变为 1;如果同一线程再次获取锁,计数器增加;每释放一次锁,计数器减 1。

当线程调用unlock()方法时,ReentrantLock 会将持有锁的计数减 1,如果计数到达 0,则释放锁,并唤醒等待队列中的线程来竞争锁。

31.ReentrantLock 怎么实现公平锁的?

ReentrantLock 的默认构造方法创建的是非公平锁 NonfairSync。

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

可以通过有参构造方法传递 true 参数来创建公平锁 FairSync。

ReentrantLock lock = new ReentrantLock(true);
--- ReentrantLock
// true 代表公平锁,false 代表非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

FairSync、NonfairSync 都是 ReentrantLock 的内部类,分别实现了公平锁和非公平锁的逻辑。

怎么实现一个非公平锁呢?

要实现一个非公平锁,只需要在创建 ReentrantLock 实例时,不传递任何参数或者传递 false 给它的构造方法就好了。

32.CAS 了解多少?

CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。

在 Java 中,我们可以使用 synchronizedo关键字和 CAS 来实现加锁效果。

synchronized 是悲观锁,线程开始执行第一步就要获取锁,一旦获得锁,其他的线程进入后就会阻塞并等待锁。

CAS 是乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就重试,直到成功为止。

在 CAS 中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程参与了更新,于是当前线程放弃更新,什么都不做。

这个比较和替换的操作是原子的,即不可中断,确保了数据的一致性。

33.CAS 有什么问题?如何解决?

CAS 存在三个经典问题。

三分恶面渣逆袭:CAS三大问题

三分恶面渣逆袭:CAS三大问题

#什么是 ABA 问题?如何解决?

如果一个位置的值原来是 A,后来被改为 B,再后来又被改回 A,那么进行 CAS 操作的线程将无法知晓该位置的值在此期间已经被修改过。

可以使用版本号/时间戳的方式来解决 ABA 问题。

比如说,每次变量更新时,不仅更新变量的值,还更新一个版本号。CAS 操作时不仅要求值匹配,还要求版本号匹配。

#循环性能开销

自旋 CAS,如果一直循环执行,一直不成功,会给 CPU 带来非常大的执行开销。

怎么解决循环性能开销问题?

在 Java 中,很多使用自旋 CAS 的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。

#只能保证一个变量的原子操作

CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。

怎么解决只能保证一个变量的原子操作问题?

  • 可以考虑改用锁来保证操作的原子性
  • 可以考虑合并多个变量,将多个变量封装成一个对象,通过 AtomicReference 来保证原子性。

34.Java 有哪些保证原子性的方法?如何保证多线程下 i++ 结果正确?

Java保证原子性方法

Java保证原子性方法

  • 使用循环原子类,例如 AtomicInteger,实现 i++原子操作
  • 使用 juc 包下的锁,如 ReentrantLock ,对 i++操作加锁 lock.lock()来实现原子性
  • 使用 synchronized,对 i++操作加锁

35.原子操作类了解多少?

当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望之外的值,比如变量 i=1,A 线程更新 i+1,B 线程也更新 i+1,经过两个线程操作之后可能 i 不等于 3,而是等于 2。因为 A 和 B 线程在更新变量 i 的时候拿到的 i 都是 1,这就是线程不安全的更新操作,一般我们会使用 synchronized 来解决这个问题,synchronized 会保证多线程不会同时更新变量 i。

其实除此之外,还有更轻量级的选择,Java 从 JDK 1.5 开始提供了 java.util.concurrent.atomic 包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。

因为变量的类型有很多种,所以在 Atomic 包里一共提供了 13 个类,属于 4 种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。

原子操作类

原子操作类

Atomic 包里的类基本都是使用 Unsafe 实现的包装类。

使用原子的方式更新基本类型,Atomic 包提供了以下 3 个类:

  • AtomicBoolean:原子更新布尔类型。

  • AtomicInteger:原子更新整型。

  • AtomicLong:原子更新长整型。

通过原子的方式更新数组里的某个元素,Atomic 包提供了以下 4 个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。

  • AtomicLongArray:原子更新长整型数组里的元素。

  • AtomicReferenceArray:原子更新引用类型数组里的元素。

  • AtomicIntegerArray 类主要是提供原子的方式更新数组里的整型

原子更新基本类型的 AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic 包提供了以下 3 个类:

  • AtomicReference:原子更新引用类型。

  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段。

  • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是 AtomicMarkableReference(V initialRef,boolean initialMark)。

如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic 包提供了以下 3 个类进行原子字段更新:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

37.线程死锁了解吗?该如何避免?

死锁发生在多个线程相互等待对方释放锁资源,导致所有线程都无法继续执行。

三分恶面渣逆袭:死锁示意图

三分恶面渣逆袭:死锁示意图

#那么为什么会产生死锁呢?

讲个笑话,死锁的产生也不是你想产生就产生的,它是有条件的:

三分恶面渣逆袭:死锁产生必备四条件

三分恶面渣逆袭:死锁产生必备四条件

  • 互斥条件:资源不能被多个线程共享,一次只能由一个线程使用。如果一个线程已经占用了一个资源,其他请求该资源的线程必须等待,直到资源被释放。
  • 持有并等待条件:一个线程至少已经持有至少一个资源,且正在等待获取额外的资源,这些额外的资源被其他线程占有。
  • 不可剥夺条件:资源不能被强制从一个线程中抢占过来,只能由持有资源的线程主动释放。
  • 循环等待条件:存在一种线程资源的循环链,每个线程至少持有一个其他线程所需要的资源,然后又等待下一个线程所占有的资源。这形成了一个循环等待的环路。
#该如何避免死锁呢?

理解产生死锁的这四个必要条件后,就可以采取相应的措施来避免死锁,换句话说,就是至少破坏死锁发生的一个条件

  • 破坏互斥条件:这通常不可行,因为加锁就是为了互斥。
  • 破坏持有并等待条件:一种方法是要求线程在开始执行前一次性地申请所有需要的资源。
  • 破坏非抢占条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件:对所有资源类型进行排序,强制每个线程按顺序申请资源,这样可以避免循环等待的发生。

38.那死锁问题怎么排查呢?

首先从系统级别上排查,比如说在 Linux 生产环境中,可以先使用 top ps 等命令查看进程状态,看看是否有进程占用了过多的资源。

接着,使用 JDK 自带的一些性能监控工具进行排查,比如说 jps、jstat、jinfo、jmap、jstack、jcmd 等等。

比如说,使用 jps -l 查看当前 Java 进程,然后使用 jstack 进程号 查看当前 Java 进程的线程堆栈信息,看看是否有线程在等待锁资源。

39.聊聊悲观锁和乐观锁?(补充)

2024 年 05 月 01 日增补

对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

悲观锁的代表有 synchronized 关键字open in new window和 Lock 接口open in new window

乐观锁,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CASopen in new window 的技术来保证线程执行的安全性。

由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁。

  • 乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;
  • 悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能

41.聊聊如何进行线程同步?

所谓同步,即协同步调,按预定的先后次序访问共享资源,以免造成混乱。

线程同步是多线程编程中的一个核心概念,它涉及到在多线程环境下如何安全地访问和修改共享资源的问题。

线程同步的实现方式有 6 种:互斥量、读写锁、条件变量、自旋锁、屏障、信号量。

  • 互斥量:互斥量(mutex)是一种最基本的同步手段,本质上是一把锁,在访问共享资源前先对互斥量进行加锁,访问完后再解锁。对互斥量加锁后,任何其他试图再次对互斥量加锁的线程都会被阻塞,直到当前线程解锁。
  • 读写锁读写锁open in new window有三种状态,读模式加锁、写模式加锁和不加锁;一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。非常适合读多写少的场景。
  • 条件变量条件变量open in new window是一种同步手段,它允许线程在满足特定条件时才继续执行,否则进入等待状态。条件变量通常与互斥量一起使用,以防止竞争条件的发生。
  • 自旋锁:自旋锁是一种锁的实现方式,它不会让线程进入睡眠状态,而是一直循环检测锁是否被释放。自旋锁适用于锁的持有时间非常短的情况。
  • 信号量:信号量(Semaphoreopen in new window)本质上是一个计数器,用于为多个进程提供共享数据对象的访问。

线程池

#44.什么是线程池?

线程池,简单来说,就是一个管理线程的池子。

①、频繁地创建和销毁线程会消耗系统资源,线程池能够复用已创建的线程。

②、提高响应速度,当任务到达时,任务可以不需要等待线程创建就立即执行。

③、线程池支持定时执行、周期性执行、单线程执行和并发数控制等功能。

45.能说说工作中线程池的应用吗?

①、快速响应用户请求

当用户发起一个实时请求,服务器需要快速响应,此时如果每次请求都直接创建一个线程,那么线程的创建和销毁会消耗大量的系统资源。

使用线程池,可以预先创建一定数量的线程,当用户请求到来时,直接从线程池中获取一个空闲线程,执行用户请求,执行完毕后,线程不销毁,而是继续保留在线程池中,等待下一个请求。

注意:这种场景下需要调高 corePoolSize 和 maxPoolSize,尽可能多创建线程,避免使用队列去缓存任务。

当用户请求首页时,就使用了线程池去加载首页的热门文章、置顶文章、侧边栏、用户登录信息等。

②、快速处理批量任务

这种场景也需要处理大量的任务,但可能不需要立即响应,这时候就应该设置队列去缓冲任务,corePoolSize 不需要设置得太高,避免线程上下文切换引起的频繁切换问题

46.能简单说一下线程池的工作流程吗?

当应用程序提交一个任务时,线程池会根据当前线程的状态和参数决定如何处理这个任务。

  • 如果线程池中的核心线程都在忙,并且线程池未达到最大线程数,新提交的任务会被放入队列中进行等待。
  • 如果任务队列已满,且当前线程数量小于最大线程数,线程池会创建新的线程来处理任务。

空闲的线程会从任务队列中取出任务来执行,当任务执行完毕后,线程并不会立即销毁,而是继续保持在池中等待下一个任务。

当线程空闲时间超出指定时间,且当前线程数量大于核心线程数时,线程会被回收。

7.线程池主要参数有哪些?

线程池有 7 个参数,需要重点关注corePoolSizemaximumPoolSizeworkQueuehandler 这四个。

三分恶面渣逆袭:线程池参数

①、corePoolSize

定义了线程池中的核心线程数量。即使这些线程处于空闲状态,它们也不会被回收。这是线程池保持在等待状态下的线程数。

②、maximumPoolSize

线程池允许的最大线程数量。当工作队列满了之后,线程池会创建新线程来处理任务,直到线程数达到这个最大值。

③、keepAliveTime

非核心线程的空闲存活时间。如果线程池中的线程数量超过了 corePoolSize,那么这些多余的线程在空闲时间超过 keepAliveTime 时会被终止。

④、unit

keepAliveTime 参数的时间单位:

  • TimeUnit.DAYS; 天
  • TimeUnit.HOURS; 小时
  • TimeUnit.MINUTES; 分钟
  • TimeUnit.SECONDS; 秒
  • TimeUnit.MILLISECONDS; 毫秒
  • TimeUnit.MICROSECONDS; 微秒
  • TimeUnit.NANOSECONDS; 纳秒

⑤、workQueue

用于存放待处理任务的阻塞队列。当所有核心线程都忙时,新任务会被放在这个队列里等待执行。

⑥、threadFactory

一个创建新线程的工厂。它用于创建线程池中的线程。可以通过自定义 ThreadFactory 来给线程池中的线程设置有意义的名字,或设置优先级等。

⑦、handler

拒绝策略 RejectedExecutionHandler,定义了当线程池和工作队列都满了之后对新提交的任务的处理策略。常见的拒绝策略包括抛出异常、直接丢弃、丢弃队列中最老的任务、由提交任务的线程来直接执行任务等

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

我现在去银行办理业务,被经历“薄纱”了:“我们系统瘫痪了”、“谁叫你来办的你找谁去”、“看你比较急,去队里加个塞”、“今天没办法,不行你看改一天”。

三分恶面渣逆袭:四种策略

分别对应上了线程池中的四种拒绝策略:

  • AbortPolicy:这是默认的拒绝策略。该策略会抛出一个 RejectedExecutionException 异常。也就对应着“我们系统瘫痪了”。
  • CallerRunsPolicy:该策略不会抛出异常,而是会让提交任务的线程(即调用 execute 方法的线程)自己来执行这个任务。也就对应着“谁叫你来办的你找谁去”。
  • DiscardOldestPolicy:策略会丢弃队列中最老的一个任务(即队列中等待最久的任务),然后尝试重新提交被拒绝的任务。也就对应着“看你比较急,去队里加个塞”。
  • DiscardPolicy:策略会默默地丢弃被拒绝的任务,不做任何处理也不抛出异常。也就对应着“今天没办法,不行你看改一天”

49.线程池有哪几种阻塞队列?

在 Java 中,线程池(ThreadPoolExecutor)使用阻塞队列(BlockingQueue)来存储待处理的任务。

三分恶面渣逆袭:线程池常用阻塞队列

①、ArrayBlockingQueue:一个有界的先进先出的阻塞队列,底层是一个数组,适合固定大小的线程池。

②、LinkedBlockingQueue:底层数据结构是链表,如果不指定大小,默认大小是 Integer.MAX_VALUE,相当于一个无界队列。

③、PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。任务按照其自然顺序或通过构造器给定的 Comparator 来排序。

适用于需要按照给定优先级处理任务的场景,比如优先处理紧急任务。

④、DelayQueue:类似于 PriorityBlockingQueue,由二叉堆实现的无界优先级阻塞队列。

⑤、SynchronousQueue:实际上它不是一个真正的队列,因为没有容量。每个插入操作必须等待另一个线程的移除操作,同样任何一个移除操作都必须等待另一个线程的插入操作。

51.线程池怎么关闭知道吗?

可以通过调用线程池的shutdownshutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。

shutdown() 将线程池状态置为 shutdown,并不会立即停止

  1. 停止接收外部 submit 的任务
  2. 内部正在跑的任务和队列里等待的任务,会执行完
  3. 等到第二步完成后,才真正停止

shutdownNow() 将线程池状态置为 stop。一般会立即停止,事实上不一定

  1. 和 shutdown()一样,先停止接收外部提交的任务
  2. 忽略队列里等待的任务
  3. 尝试将正在跑的任务 interrupt 中断
  4. 返回未执行的任务列表

shutdown 和 shutdownnow 简单来说区别如下:

  • shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
  • shutdown()只是关闭了提交通道,用 submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池

2.线程池的线程数应该怎么配置?

首先,我会分析线程池中执行的任务类型是 CPU 密集型还是 IO 密集型?

①、对于 CPU 密集型任务,我的目标是尽量减少线程上下文切换,以优化 CPU 使用率。一般来说,核心线程数设置为处理器的核心数或核心数加一(以备不时之需,如某些线程因等待系统资源而阻塞时)是较理想的选择。

②、对于 IO 密集型任务,由于线程经常处于等待状态(等待 IO 操作完成),可以设置更多的线程来提高并发性(比如说 2 倍),从而增加 CPU 利用率。

53.有哪几种常见的线程池?

三分恶面渣逆袭:四大线程池

可以通过 Executors 工厂类来创建四种常见的线程池:

  • newFixedThreadPool (固定线程数目的线程池)
  • newCachedThreadPool (可缓存线程的线程池)
  • newSingleThreadExecutor (单线程的线程池)
  • newScheduledThreadPool (定时及周期执行的线程池)

54.能说一下四种常见线程池的原理吗?

前三种线程池的构造直接调用 ThreadPoolExecutor 的构造方法。

#newSingleThreadExecutor(单线程的线程池)

线程池特点

  • 核心线程数为 1
  • 最大线程数也为 1
  • 阻塞队列是无界队列 链表结构队列LinkedBlockingQueue,可能会导致 OOM
  • keepAliveTime 为 0

SingleThreadExecutor运行流程

工作流程:

  • 提交任务
  • 线程池是否有一条线程在执行任务,如果没有,新建线程执行任务
  • 如果有,将任务加到阻塞队列
  • 当前的唯一线程,从队列取任务,执行完一个,再继续取,一个线程执行任务。

适用场景

适用于串行执行任务的场景,一个任务一个任务地执行

#newFixedThreadPool(固定线程数目的线程池)

线程池特点:

  • 核心线程数和最大线程数大小一样
  • 没有所谓的非空闲时间,即 keepAliveTime 为 0
  • 阻塞队列为无界队列 LinkedBlockingQueue,可能会导致 OOM

FixedThreadPool

工作流程:

  • 提交任务
  • 如果线程数少于核心线程,创建核心线程执行任务
  • 如果线程数等于核心线程,把任务添加到 LinkedBlockingQueue 阻塞队列
  • 如果线程执行完任务,去阻塞队列取任务,继续执行。

使用场景

FixedThreadPool 适用于处理 CPU 密集型的任务,确保 CPU 在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。

#newCachedThreadPool(可缓存线程的线程池)

线程池特点:

  • 核心线程数为 0
  • 最大线程数为 Integer.MAX_VALUE,即无限大,可能会因为无限创建线程,导致 OOM
  • 阻塞队列是无元素存储阻塞队列 SynchronousQueue
  • 非核心线程空闲存活时间为 60 秒

当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。

工作流程:

  • 提交任务
  • 因为没有核心线程,所以任务直接加到 SynchronousQueue 队列。
  • 判断是否有空闲线程,如果有,就去取出任务执行。
  • 如果没有空闲线程,就新建一个线程执行。
  • 执行完任务的线程,还可以存活 60 秒,如果在这期间,接到任务,可以继续活下去;否则,被销毁。

适用场景

用于并发执行大量短期的小任务。

#newScheduledThreadPool(定时及周期执行的线程池)

线程池特点

  • 最大线程数为 Integer.MAX_VALUE,也有 OOM 的风险
  • 阻塞队列是 延迟执行队列DelayedWorkQueue
  • keepAliveTime 为 0
  • scheduleAtFixedRate() :按某种速率周期执行
  • scheduleWithFixedDelay():在某个延迟后执行

周期性执行任务的场景,需要限制线程数量的场景

使用无界队列的线程池会导致什么问题吗?

例如 newFixedThreadPool 使用了无界的阻塞队列 LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致机器内存使用不停飙升,最终导致 OOM。

55.线程池异常怎么处理知道吗?

线程池异常处理

1在任务内部捕获和处理异常:
最直接的方法是在提交给线程池的任务(Runnable或Callable)内部捕获并处理异常。这可以防止异常传播到线程池本身,从而保持线程池的稳定。


2使用Future的get方法捕获异常:
如果你使用的是Callable任务,并且需要获取任务的执行结果,你可以通过Future的get方法来捕获执行过程中抛出的异常。get方法会阻塞当前线程直到任务完成,并返回执行结果;如果任务执行过程中抛出了异常,get方法会抛出ExecutionException,你可以通过它来获取原始异常。


3自定义未捕获异常处理器UncaughtExceptionHandler:
对于Thread对象,Java允许你设置一个UncaughtExceptionHandler来处理未捕获的异常

56.能说一下线程池有几种状态吗?

线程池有这几个状态:RUNNING,SHUTDOWN,STOP,TIDYING,TERMINATED。

线程池各个状态切换图:

线程池状态切换图

线程池状态切换图

RUNNING

  • 该状态的线程池会接收新任务,并处理阻塞队列中的任务;
  • 调用线程池的 shutdown()方法,可以切换到 SHUTDOWN 状态;
  • 调用线程池的 shutdownNow()方法,可以切换到 STOP 状态;

SHUTDOWN

  • 该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;
  • 队列为空,并且线程池中执行的任务也为空,进入 TIDYING 状态;

STOP

  • 该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;
  • 线程池中执行的任务为空,进入 TIDYING 状态;

TIDYING

  • 该状态表明所有的任务已经运行终止,记录的任务数量为 0。
  • terminated()执行完毕,进入 TERMINATED 状态

TERMINATED

  • 该状态表示线程池彻底终止

转载于:Java并发编程面试题,70道Java多线程八股文(2.1万字92张手绘图),面渣逆袭必看👍 | 二哥的Java进阶之路

  • 23
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值