7.线程与进程

(一)线程与进程基础知识

1. 什么是进程和线程:

(1)进程 —— 操作系统进行资源分配的最小单位(打开的一个应用程序就是一个进程 )
(2)线程 —— CPU调度的最小单位,必须依赖于进程而存在

详细:
进程是操作系统进行资源分配的最小单位,其中资源包括:CPU、内存空间、磁盘 IO等,同一进程中的多条线程共享该进程中的全部系统资源,而进程和进程之间是相互独立的。
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。
进程是程序在计算机上的一次执行活动。当你运行一个程序,你就启动了一个进程。
显然,程序是死的、静态的,进程是活的、动态的。

进程可以分为系统进程和用户进程。
凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由你启动的进程。

线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位。
线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),
但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

2. CPU核心数(内核)和线程数的关系

多线程: Simultaneous Multithreading.简称 SMT.让同一个处理器上的多个线程同步执行并共享处理器的执行资源。

目前主流 CPU 都是多核的。
增加核心数目就是为了增加线程数,因为操作系统是通过线程来执行任务的,
一般情况下它们是 1:1 对应关系,也就是说四核 CPU 一般拥有四个线程。
但 Intel 引入超线程技术后,使核心数与线程数形成 1 : 2 的关系。

3. CPU时间片轮转机制

RR调度
每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
如果在时间片结束时进程还在运行,则 CPU 将被剥夺并分配给另一个进程。
如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换。
调度程序所要做的就是维护一张就绪进程列表,当进程用完它的时间片后,它被移到队列的末尾。

4.并行和并发(面试很有可能会碰到)

(1)并行 —— 可以同时运行的任务数,同时执行不同的任务(两台咖啡机 有两队人可以同时进行)

  • 指应用能够同时执行不同的任务
  • 例:吃饭的时候可以边吃饭边打电话, 这两件事情可以同时执行.

(2)并发 —— 应用交替执行不同的任务,并不是同时进行(一台咖啡机 单位时间内可以提供咖啡的总数)

  • 指应用能够交替执行不同的任务,比如单 CPU 核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,已达到”同时执行效果”,其实并不是的,只是计算机的速度太快,我们无法察觉到而已.

(3)两者区别:一个是交替执行,一个是同时执行

5.高并发编程的意义、好处和注意事项

(1)充分利用CPU的资源

  • 一个线程也只能在一个 CPU 的一个核的一个线程跑,如果你是个 i3 的 CPU 的话,最差也是双核心 4 线程的运算 能力;
  • 如果是一个线程的程序的话,那是要浪费 3/4 的 CPU 性能;
  • 如果设计一个多线程的程序的话,那它就可以同时在多个 CPU 的多个核的多个线程上跑,可以充分地利用 CPU,减少 CPU 的空闲时间,发挥它的运算能力,提高并发量。

(2)加快相应用户的时间

  • 多个线程下载快

(3)可以使你的代码模块化,异步化,简单化

  • 例如我们实现电商系统,下订单和给用户发送短信、邮件就可以进行拆分,
    将给用户发送短信、邮件这两个步骤独立为单独的模块,并交给其他线程去执行。
    这样既增加了异步的操作,提升了系统性能,又使程序模块化,清晰化和简单化。

7.新启线程有几种方式?

Thread源码注释中说明的,只有两种方式
(1)类Thread
(2)接口Runnable
1、X extends Thread;,然后 X.start(派生自Thread)
2、X implements Runnable;然后交给 Thread 运行(实现Runnable接口,然后交给Thread来执行)

Callable
将 Callable 的实例包装成了 FutureTask ,而FutureTask 实现了 RunnableFuture 接口,RunnableFuture 又派生自 Runnable 接口。(本质上还是实现了Runnable接口)

严格来说不能算是,Thread构造方法中没有接收Callable的方法

9.Thread和Runnable的区别

Thread 是 Java里面真正对线程的抽象
Runnable 是对任务(业务逻辑)的抽象
Thread 可以接受任意一个 Runnable 的实例并执行

10.线程stop方法的不安全性

为什么stop不建议使用?(面试可能问)

  1. 不安全性:stop()方法会强制终止线程,这可能导致线程在执行中断的过程中处于不一致的状态。例如,如果线程正在访问共享资源,调用stop()会中断线程,这可能导致数据损坏或程序的其他线程看到不一致的数据。

    • (使用Thread.stop()时),它可能在数据结构的更新中被中断,这会导致资源处于不一致的状态
  2. 资源泄漏:当线程被强制停止时,可能无法正常清理资源,比如关闭文件或释放网络连接,导致资源泄漏。所占用的资源不正常释放

  3. 死锁的风险:如果一个线程在持有锁的情况下被强制停止,其他线程将无法获取到该锁,从而可能导致整个系统的死锁。

  4. 已弃用:由于以上原因,Thread.stop()方法在Java 2(Java 1.2)后被标记为已弃用(deprecated),并在后续版本中不再推荐使用。

11.让Java里的线程安全地停止工作

(1)interrupt 对线程进行中断
调用interrupt只是对线程打了一个招呼,线程不会立即听指挥,线程可以不理会
(只是给到线程一个通知,要不要停止看线程自己决定)
其实设置线程的标识位,将标志位改为true

线程是协作式的,不是抢占式的

(2)isinterrupted 判断当前线程是否被中断(检查中断标志位)

(3)static方法 interrupted 在(2)的基础上将中断标志位由 true 改为 false(用的很少)

处于死锁状态的线程,不会理会中断


如果需要安全地停止线程,可以使用以下方法:

允许线程完成当前的任务和必要的清理工作。

通过让线程决定何时退出,可以确保在退出前完成对共享资源的正确操作,降低数据冲突和不一致的风险。

  • 标志位:使用一个共享的布尔变量作为标志,线程定期检查这个标志,以决定是否终止自己。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    javapublic class MyRunnable implements Runnable {  
    private volatile boolean running = true;

    public void run() {
    try {
    while (running) { //线程可以在决定终止之前 完成当前的循环或任务。
    // 执行任务
    }
    } finally {
    // 执行清理工作
    cleanup(); // 例如关闭文件、释放资源等
    }
    }

    public void stop() {
    running = false;
    }
    }
  • 使用interrupt()方法:可以调用interrupt()方法来中断线程,并在线程的运行逻辑中检查中断状态。不过,这需要在线程代码中适当地处理InterruptedException,以确保线程能够安全停止。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    javapublic class MyRunnable implements Runnable {  
    public void run() {
    try {
    while (!Thread.currentThread().isInterrupted()) {
    // 执行任务
    }
    } catch (InterruptedException e) {
    // 响应中断
    }
    }
    }

3.深入理解start()和run()方法

Thread 类是 Java 里对线程概念的抽象,可以这样理解:我们通过 new Thread()其实只是 new 出一个 Thread 的实例,还没有操作系统中真正的线程挂起钩来。
只有执行了 start()方法后,才实现了真正意义上的启动线程。

  • start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常。
  • 而 run() 方法是业务逻辑实现的地方,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,也可以被单独调用。

yield()方法

yield()方法是Java中Thread类的一个静态方法,用于提示调度器当前线程愿意让出CPU的执行时间,以允许其他线程有机会执行。下面是对yield()方法的详细解释:

yield() 方法

作用

  • 放弃CPU控制:当一个线程调用Thread.yield()时,它告诉线程调度器,它当前愿意暂停执行,并让其他同优先级或更高优先级的线程有机会运行。(使当前线程让出 CPU 占有权)
  • 调度器的选择:调用yield()不保证该线程会立即停止运行或被调度器暂停。调度效率和行为取决于底层的操作系统和JVM的调度策略。调度器可能选择继续执行当前的线程,或让其他线程运行。

使用场景

  1. 优化CPU资源使用:在实际应用中,某个线程可能完成了一部分工作,但希望给其他线程机会执行,以提高整体的响应性或并发性。
  2. 避免CPU占用过高:在高并发的情况下,调用yield()可以有效减少线程竞争,防止某个线程因为持续运行而占用过多CPU资源。
  3. 协作式多任务处理:在一些需要协调多个线程的应用中,yield()可以被用作一种轻量级的同步机制,帮助确保资源的公平使用。

注意:
yield()方法:使当前线程让出 CPU 占有权,但让出的时间是不可设定的。也不会释放锁资源。 并不是每个线程都需要这个锁的,而且执行 yield( )的线程不一定就会持有锁,我们完全可以在释放锁后再调用 yield 方法。
所有执行 yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。
wait()/notify()/notifyAll():后面会单独讲述

14.join方法(常见面试考点)

问:请问你怎么可以保证两个线程可以顺序地执行?
答:使用 join() 方法
相当于插队
把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。
比如:在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。
(此处为常见面试考点)

15.线程的优先级和守护线程 Daemon

(1)优先级
优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,
而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。
在不同的 JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
(2)守护线程
用户线程,非守护线程(需要手动启动的)
JDK自己内部启动的线程,守护线程

非守护线程执行完之后,守护线程跟着结束

Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。
在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理资源的逻辑。


守护线程(Daemon Thread)是Java中的一种特殊类型的线程,它的主要特征是为其他线程提供支持服务,并在所有用户线程(非守护线程)结束后自动终止。以下是有关守护线程的详细介绍,包括其定义、特点、使用场景以及示例代码。

1. 守护线程的定义 使用场景

守护线程是指希望在后台执行某些任务,但不阻止JVM终止的线程。

2. 守护线程的特点、应用场景

  • 自动结束:当所有用户线程(非守护线程)结束时,守护线程会自动终止。这意味着守护线程不阻止 JVM 的退出。

  • 优先级较低:通常,守护线程的优先级低于用户线程。

  • 适用于后台服务:守护线程常用于执行周期性的任务,例如,定时清理、后台统计等服务。

    • 后台服务:守护线程适用于执行一些后台任务,不需要干预用户的操作,例如日志记录、后台数据传输等。

    • 资源监控:用于监控系统资源或状态,并在有必要时进行一定的处理。

    • 定时任务:可用于定期执行的任务,例如清理旧数据或者过期缓存。

3. 设置守护线程

在创建线程后,可以使用 Thread.setDaemon(boolean on) 方法将线程标记为守护线程。必须在调用 start() 方法之前设置线程为守护线程,否则会抛出 IllegalThreadStateException。以下是设置守护线程的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class DaemonThreadExample {
public static void main(String[] args) {
Thread daemonThread = new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
System.out.println("Daemon thread is running...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

// 将线程设置为守护线程
daemonThread.setDaemon(true);

daemonThread.start();

// 主线程执行
System.out.println("Main thread is running...");
try {
Thread.sleep(3000); // 主线程睡眠3秒
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("Main thread is finished.");
// 主线程结束时,守护线程将自动结束
}
}

小结

守护线程在Java中是一个非常有用的功能,它允许开发者实现一些低优先级的后台服务任务,而不阻塞应用程序的正常退出。合理使用守护线程可以帮助提升程序的效率,同时通过自动管理线程的生存周期来简化资源管理。不过,使用守护线程需要谨慎,以确保重要任务的完成不会因守护线程的退出而受到影响。

16.synchronized 内置锁

为什么要启动线程?
希望多个线程之间可以相互配合地进行工作,可以实现数据共享、协同处理事情。

线程同步问题
Java 支持多个线程同时访问一个对象或者对象的成员变量,关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,
它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,
它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。

对象锁和类锁:

  • 对象锁是用于对象实例方法,或者一个对象实例上的。

    • public synchronized void intcCount(){}(锁的是当前所在类的对象实例)
    • synchronized(i){count = count++;}
  • 类锁是用于类的静态方法(锁的是类所对应的class对象)或者一个类的 class 对象上的。

    • public static synchronized void intStatic(){}
  • 我们知道,类的对象实例可以有很多个,但是每个类只有一个 class 对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。

  • 但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的 class 对象。

  • 类锁和对象锁之间也是互不干扰的。

synchronized 可以用在同步代码块上、用在方法上
synchronized 锁的是对象(基本数据类型之类的不可以)

2.volatile 关键字

最轻量的同步机制:可以保证可见性,但是不能保证原子性

  • 确保变量的 可见性 —— 即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 不具备原子性 —— 尽管 volatile 提供了可见性,但它并不保证原子性。如果多个线程同时读写 volatile 变量,依然可能会出现不一致的情况。
    • 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

应用场景:
一写多读(只有一个线程在写,其他线程在读)

volatile底层实现原理:

通过对 OpenJDK 中的 unsafe.cpp 源码的分析,会发现被 volatile 关键字修饰的变量会存在一个“lock:”的前缀。
Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。
同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写 回内存的操作会使在其他 CPU 里缓存了该地址的数据无效。

抑制重排序

写操作之间没有任何关联


volatile 是 Java 中的一个关键字,用于修饰变量。它的主要作用是确保变量的 可见性 和 有序性,但不保证原子性。volatile 通常用于多线程环境中,解决线程间的变量同步问题。

1. volatile 的作用

1.1 可见性(Visibility)

  • 问题:
    • 在多线程环境中,每个线程都有自己的工作内存(缓存),线程对变量的操作会先在工作内存中进行,然后再同步到主内存。
    • 如果一个线程修改了变量的值,其他线程可能无法立即看到这个修改,导致数据不一致。
  • 解决:
    • 使用 volatile 修饰的变量,会强制所有线程直接从主内存中读取变量的值,并将修改后的值立即写回主内存。
    • 这样,所有线程都能看到变量的最新值,保证了可见性。

1.2 有序性(Ordering)

  • 问题:
    • 为了提高性能,编译器和处理器可能会**对指令进行重排序**(Reordering)。
    • 这种重排序在多线程环境中可能导致意外的行为。
  • 解决:
    • 使用 volatile 修饰的变量,会禁止指令重排序,确保变量的读写操作按照代码的顺序执行。

2. volatile 的使用场景

volatile 适用于以下场景:

  1. 状态标志:

    • 用于标记某个状态的变化,例如线程的启动或停止。
    • 示例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      volatile boolean running = true;

      public void start() {
      new Thread(() -> {
      while (running) {
      // 执行任务
      }
      }).start();
      }

      public void stop() {
      running = false;
      }
  2. 双重检查锁定(Double-Checked Locking):

    • 在单例模式中,使用 volatile 确保实例的可见性。
    • 示例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      public class Singleton {
      private static volatile Singleton instance;

      private Singleton() {}

      public static Singleton getInstance() {
      if (instance == null) {
      synchronized (Singleton.class) {
      if (instance == null) {
      instance = new Singleton();
      }
      }
      }
      return instance;
      }
      }
  3. 一次性发布(One-Time Safe Publication):

    • 用于确保对象的构造过程对其他线程可见。
    • 示例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      class Resource {
      private volatile Resource instance;

      public Resource getInstance() {
      if (instance == null) {
      synchronized (this) {
      if (instance == null) {
      instance = new Resource();
      }
      }
      }
      return instance;
      }
      }

3. volatile 的局限性

volatile 只能保证可见性和有序性,但不能保证原子性。例如:

  • 自增操作(i++):
    • i++ 实际上分为三个步骤:读取 i 的值、增加 i 的值、写回 i 的值。
    • 如果多个线程同时执行 i++,可能会导致数据不一致。
  • 复合操作:
    • 例如 check-then-act(检查后行动)操作,volatile 无法保证原子性。

如果需要保证原子性,可以使用 synchronized 或 java.util.concurrent.atomic 包中的原子类(如 AtomicInteger)。


4. volatile 与 synchronized 的区别

特性 volatile synchronized
可见性 保证变量的可见性 保证变量的可见性
有序性 禁止指令重排序 禁止指令重排序
原子性 不保证原子性 保证原子性
性能 轻量级,性能较高 重量级,性能较低
适用场景 状态标志、一次性发布等简单场景 需要保证原子性的复杂场景

5. 示例代码

以下是一个使用 volatile 的示例,展示了如何保证变量的可见性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class VolatileExample {
private volatile boolean flag = false;

public void start() {
new Thread(() -> {
while (!flag) {
// 等待 flag 变为 true
}
System.out.println("Flag is now true!");
}).start();
}

public void stop() {
flag = true;
System.out.println("Flag set to true.");
}

public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
example.start();
Thread.sleep(1000); // 模拟延迟
example.stop();
}
}

输出结果:

1
2
Flag set to true.
Flag is now true!

说明:

  • 如果没有 volatile 修饰 flag,子线程可能无法及时看到主线程对 flag 的修改,导致程序无法正常退出。
  • 使用 volatile 后,子线程能够立即看到 flag 的变化,程序正常退出。

6. 总结

  • volatile 用于保证变量的可见性和有序性,但不保证原子性。
  • 适用于状态标志、双重检查锁定等简单场景。
  • 如果需要保证原子性,应使用 synchronized 或原子类。
  • 在多线程编程中,合理使用 volatile 可以提高程序的正确性和性能。

3.ThreadLocal 的使用

ThreadLocal 和 Synchonized 都用于解决多线程并发访问

在多线程下,每个线程都有变量的副本

ThreadLocal 的使用
实现解析
有一个线程需要使用 ThreadLocal 标注的变量时,在该线程的内部声明一个ThreadLocalMap,其在数据结构上有很多 Entry 类型的数组。

当使用 set 时,其 set 的其实是当前线程内部 ThreadLocalMap 中的某一个条目而已,与多线程没有关系

区别:
ThreadLocal 和 Synchonized 都用于解决多线程并发访问。可是 ThreadLocal与 synchronized 有本质的差别。synchronized 是利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问。而 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间訪问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。

3.5 引发的内存泄漏分析(面试大厂可能会问)

强引用
软引用
弱引用
虚引用

ThreadLocalMap 内部使用了弱引用

ThreadLocal 的线程不安全(面试大厂可能会问)

ThreadLocal 引用链.png
根据我们前面对 ThreadLocal 的分析,我们可以知道每个 Thread 维护一个ThreadLocalMap,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key来让线程从 ThreadLocalMap 获取 value。
仔细观察 ThreadLocalMap,这个 map 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。

7.线程间的wait()等待、notify()/notifyAll()通知

一旦一个线程调用了 wait() 方法之后,就会释放掉其持有的锁
调用 notify/notifyAll (放在最后一行) 方法时不会释放,直到 synchronized 代码块走完了才会释放锁

8.notify() 和 wait() (面试常问)

问:采用Java多线程技术(例如wait() 和 notify())设计实现一个符合生产者和消费者问题的程序?

两者在对象上面等待和唤醒

等待和通知的标准范式
notify和notifyAll应该用谁?


在Java多线程面试中,notify() 和 wait() 是常见的考察点。以下是一些常见问题:

1. wait() 和 notify() 的作用是什么?

  • wait(): 使当前线程进入等待状态,并**释放对象锁**,直到其他线程调用该对象的 notify() 或 notifyAll() 方法。
  • notify(): 唤醒一个在该对象上等待的线程,具体唤醒哪个线程由 JVM 决定。

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

  • wait(): 释放对象锁,必须在同步块或方法中使用。
  • sleep(): 不释放锁,可以在任何地方使用。使线程失眠,让出CPU,结束后自动继续执行

3. 为什么 wait() 和 notify() 必须在同步块or同步方法中调用?

  • 确保线程在调用这些方法时持有对象锁,避免竞态条件。

1. 确保线程持有对象锁

  • wait() 和 notify() 是与对象锁(monitor lock)紧密相关的操作。
  • 调用 wait() 时,线程会释放它持有的对象锁,并进入等待状态。
  • 调用 notify() 时,线程会唤醒一个在该对象上等待的线程。
  • 为了保证这些操作的正确性,线程在调用 wait() 或 notify() 时必须持有对象锁。如果没有持有锁,程序会抛出 IllegalMonitorStateException 异常。

2. 避免竞态条件

  • 竞态条件是指多个线程同时访问共享资源时,由于执行顺序的不确定性导致程序行为出现错误。
  • 如果没有同步机制,可能会出现以下问题:
    • 线程A检查某个条件,发现条件不满足,准备调用 wait()。
    • 在线程A调用 wait() 之前,线程B修改了条件并调用了 notify()。
    • 线程A最终调用 wait(),但由于错过了 notify(),它会一直等待下去(死锁)。
  • **通过在同步块中调用 wait() 和 notify(),可以确保检查和修改条件的操作是原子的**,从而避免竞态条件。

3. 保证可见性

  • 在多线程环境中,线程可能会缓存变量的值,导致一个线程对共享变量的修改对其他线程不可见。
  • 同步块或方法通过加锁和释放锁的机制,确保线程对共享变量的修改对其他线程是可见的。
  • 调用 wait() 和 notify() 时,必须确保线程对共享变量的修改对其他线程可见,否则可能会导致线程等待的条件永远无法满足。

4. 调用 wait() 后线程如何恢复执行?

  • 线程可以通过 notify() 或 notifyAll() 被唤醒,或者被中断。

5. notify() 和 notifyAll() 的区别?

  • notify(): 唤醒一个等待线程。
  • notifyAll(): 唤醒所有等待线程。

6. 如何避免 wait() 导致的虚假唤醒?

  • 使用循环检查条件,确保条件满足后再继续执行。
1
2
3
4
5
6
synchronized (obj) {
while (!condition) {
obj.wait();
}
// 执行操作
}

7. 编写生产者-消费者模型

  • 使用 wait() 和 notify() 实现生产者和消费者之间的同步。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Buffer {
private int data;//共享数据
private boolean available = false;//标记数据是否可用

//生产者方法
public synchronized void produce(int newData) {
while (available) {
wait();
}
data = newData;
available = true;
notifyAll();
}

//消费者方法
public synchronized int consume() {
while (!available) {
wait();
}
available = false;
notifyAll();
return data;//返回消费的数据
}
}

8. wait() 和 notify() 在 Object 类中的原因

  • 因为它们与对象锁相关,而锁是对象级别的。

9. 调用 wait() 后线程的状态

  • 线程进入 WAITING 或 TIMED_WAITING 状态。

10. 如何处理 wait() 和 notify() 的异常?

  • 捕获 InterruptedException,通常选择重新设置中断状态或处理中断。
1
2
3
4
5
6
try {
obj.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// 处理中断
}

9.等待超时模式实现一个连接池(没听懂)

等待超时模式实现一个连接池

10.调用yield()、sleep()、wait()、notify()等方法对锁有何影响?(面试会问)

yield() 只是让出了CPU的执行权,不会释放锁
sleep() 不会释放锁
wait() 会释当前线程持有的锁
notify() 对锁无影响 不会释放锁

11.分而治之 和 归并排序

十大计算机经典算法:快速排序、堆排序、归并排序、二分查找、线性查找、深度优先、广度优先、Dijkstra、动态规划、朴素贝叶斯分类。
属于分而治之?3 个 —— 快速排序、归并排序、二分查找,还有大数据中 M/R 都是。

分治法的设计思想是:
将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
分治策略是:对于一个规模为 n 的问题,若该问题可以容易地解决(比如说规模 n 较小)则直接解决,否则将其分解为 k 个规模较小的子问题,这些子问题互相独立且与原问题形式相同(子问题相互之间有联系就会变为动态规范算法),递归地解这些子问题,然后将各子问题的解合并得到原问题的解。
这种算法设计策略叫做分治法。

归并排序
归并排序是建立在归并操作上的一种有效的排序算法。
该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
若将两个有序表合并成一个有序表,称为 2-路归并,与之对应的还有多路归并。
对于给定的一组数据,利用递归与分治技术将数据序列划分成为越来越小的半子表,在对半子表排序后,再用递归方法将排好序的半子表合并成为越来越大
的有序序列。
为了提升性能,有时我们在半子表的个数小于某个数(比如 15)的情况下,对半子表的排序采用其他排序算法,比如插入排序。

12.Fork-Join 原理

Fork-Join 就是一个分而治之的框架
什么事ForkJoin?
Fork/Join 是一种在多线程领域中常用的算法或技术,它的核心思想是将大任务分割成若干个小任务,然后将这些小任务分配给多个线程并行处理,最终将结果合并起来。这种思想可以应用于多种场景,例如图像处理、批处理、并行排序等。

Fork/Join 使用的标准范式
我们要使用 ForkJoin 框架,必须首先创建一个 ForkJoin 任务。
它提供在任务中执行 fork 和 join 的操作机制,通常我们不直接继承 ForkjoinTask 类,只需要直接继承其子类。

  1. RecursiveAction,用于没有返回结果的任务
  2. RecursiveTask,用于有返回值的任务
    task 要通过 ForkJoinPool 来执行,使用 submit 或 invoke 提交,两者的区 别是:
  • 同步提交 —— invoke() 调用(是同步执行,调用之后需要等待任务完成,才能执行后面的代码; )
  • 异步提交 —— submit() 提交、execute()执行

join()和 get() 方法当任务完成的时候返回计算结果。

在我们自己实现的 compute 方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。
如果不足够小,就必须分割成两个子任务,每个子任务在调用 invokeAll 方法时,又会进入 compute 方法,看看当前子任务是否需要继续分割成孙任务,如果不需要继续分割,则执行当前子任务并返回结果。
使用 join 方法会等待子任务执行完并得到其结果。

15.CountDownLatch

闭锁,CountDownLatch 这个类能够使一个线程等待其他线程完成各自的工作后再执行。
例如,应用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。
实现:
CountDownLatch 是通过一个计数器来实现的,计数器的初始值为初始任务的数量。
每当完成了一个任务后,计数器的值就会减 1(CountDownLatch.countDown()方法)。
当计数器值到达 0 时,它表示所有的已经完成了任务,然后在闭锁上等待 CountDownLatch.await()方法的线程就可以恢复执行任务。

应用场景:
实现最大的并行性:有时我们想同时启动多个线程,实现最大程度的并行性。
例如,我们想测试一个单例类。如果我们创建一个初始计数为 1 的CountDownLatch,并让所有线程都在这个锁上等待,那么我们可以很轻松地完成测试。我们只需调用 一次 ountDown()方法就可以让所有的等待线程同时恢复执行。
开始执行前等待 n 个线程完成各自任务:例如应用程序启动类要确保在处理用户请求前,所有 N 个外部系统已经启动和运行了,例如处理 excel 中多个表单。


(二) 并发编程补充

2.线程的状态(线程的生命周期)(面试)

线程的状态.png

Java 中线程的状态分为 6 种:

  1. 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。
  2. 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种状态统称为“运行”。
  • 线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,此时处于就绪状态(ready)。
  • 就绪状态的线程在获得 CPU 时间片后变为运行中状态(running)。
  1. 阻塞(BLOCKED):表示线程阻塞于锁。 (未获取锁的线程会进入阻塞状态,直到锁被释放。)
  2. 等待(WAITING):进入该状态的线程需要 等待其他线程 做出一些特定动作(通知或中断)。
  3. 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时间后自行返回。
  4. 终止(TERMINATED):表示该线程已经执行完毕。

3.死锁(面试喜欢问)

面试:

1.什么是死锁呢?

多个线程,他们之间是相互竞争的关系,他们互持资源,又相互等待,就会导致永久的这种阻塞的现象

2.诱发死锁的原因有哪些呢?

  • 互斥条件
  • 占有等待
  • 不可抢夺(不可抢占)
  • 循环等待

3.遇到死锁问题是怎么解决的?

基本上死锁一旦发生,就很难去人为干预他,所以就尽量去规避它,上述四个条件只要同时满足了就会触发死锁的这种现象,所以去打破其中的任意一条,死锁就不会发生

  • 互斥条件:基本上是无法被破坏的,因为线程本身就是通过互斥来解决线程安全这个问题的,所以这一条就不用考虑了
  • 占有且等待:可以一次性去申请所需的所有资源,就不存在等待这个问题了。(指一个进程持有某些资源同时又在等待其他资源,而这些资源又被其他进程占有。)
  • 不可抢夺(不可抢占):使占有部分资源的线程,进一步去申请其他资源的时候,如果申请不到就主动释放掉他现在目前已经占有的。
  • 循环等待:按照这种申请资源来进行预防,按顺序去申请资源预防的,因为这个资源是有线性顺序的,可以先申请序号比较小的,再申请序号比较大的,这样就不存在循环等待的问题了
    • 例如,如果进程A持有资源1并请求资源2,而进程B持有资源2并请求资源1,按照顺序请求的规则,进程A和B都不能获取比它们当前持有的资源编号更低的资源,因此无法形成循环。

概念:
是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。
此时称系统处于死锁状态或系统产生了死锁。

举例:
有两个线程A和B,他们都想同时拥有1、2两种资源,这个时候,A先抢到了1,B先抢到了2,但是两个线程都想同时拥有,于是就互不相让,A抢到1想要2,B抢到2想要1(都想抢夺对方已经拥有的那个资源),在想同时拥有1和2资源这个问题上A和B就产生了死锁。

总结:
死锁是必然发生在多操作者(M>=2 个)情况下,争夺多个资源(N>=2 个,且 N<=M)才会发生这种情况。

  • 很明显,单线程自然不会有死锁。
  • 单资源呢?只有 13,A 和 B 也只会产生激烈竞争,谁抢到就是谁的,但不会产生死锁。
    同时,死锁还有几个要求:
    1、争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁;
    2、争夺者拿到资源不放手。

危害:
1、线程不工作了,但是整个程序还是活着的。
2、没有任何的异常信息可以供我们检查。
3、一旦程序发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。

其他线程安全问题

活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。

  • 解决办法:每个线程休眠随机数,错开拿锁的时间。

线程饥饿
低优先级的线程,总是拿不到执行时间

了解各种锁

锁的状态:
一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态, 它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

轻量级——通过CAS操作来加锁和解锁(消耗CPU),如果自旋超过了一定的次数,会膨胀为重量级锁

重量级——上下文切换,阻塞状态

偏向锁——一个锁总是由同一个线程获得,CAS操作都不想做了,干脆只是测试一下,当前拥有这把锁的是不是我自己,是的话就直接拿过来用(在线程拿锁的过程中,这个锁的获得者,总是偏向第一次拿到这把锁的线程)

  • 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。无竞争时不需要进行CAS操作来加锁和解锁。
  • 如果有第二个线程竞争,偏向锁被撤销,锁会升级,升级为轻量级锁
  • 偏向锁的撤销涉及 STW

image.png

(三)CAS(Compare And Swap)

CAS(Compare-And-Swap) 是一种用于实现多线程同步的原子操作。它通过比较内存中的值与预期值,若相等则更新为新值,否则不做操作。CAS操作是乐观锁的核心机制,广泛应用于无锁数据结构和并发控制


比较和交换
希望有一种轻量级的,不至于让所有其他线程在锁外面去等待

为什么会有 CAS 的出现?

实现原子操作可以使用锁,锁机制,满足基本的需求是没有问题的了,但是有的时候我们的需求并非这么简单,我们需要更有效,更加灵活的机制,synchronized 关键字是基于阻塞的锁机制,也就是说当一个线程拥有锁的时候,访问同一资源的其它线程需要等待,直到该线程释放锁。
这里会有些问题:首先,如果被阻塞的线程优先级很高很重要怎么办?其次,如果获得锁的线程一直不释放锁怎么办?(这种情况是非常糟糕的)。还有一种情况,如果有大量的线程来竞争资源,那 CPU 将会花费大量的时间和资源来处理这些竞争,同时,还有可能出现一些例如死锁之类的情况,最后,其实锁机制是一种比较粗糙,粒度比较大的机制,相对于像计数器这样的需求有点儿过于笨重。

1.原理

  1. 比较:读取内存中的当前值,与预期值比较。
  2. 交换:若当前值与预期值相等,则将内存值更新为新值;否则,操作失败。
  3. 原子性:整个操作由硬件保证原子性,确保在多线程环境下的线程安全。

什么是原子操作? —— 要么全部完成,要么全部没做。
假定有两个操作 A 和 B(A 和 B 可能都很复杂),如果从执行 A 的线程来看,当另一个线程执行 B 时,要么将 B 全部执行完,要么完全不执行 B,那么 A 和 B 对彼此来说是原子的

悲观锁:synchronized 一定有人要改我的 (会出现阻塞,即会上下文切换 会多花费时间)
乐观锁:CAS 可能有人要改我的 先做了再说(不会主动进入阻塞状态 只会不断重试)

CAS.png

CAS的原理:
利用了现代处理器都支持的 CAS的指令
循环这个指令,直到成功为止
具体过程:
实现原子操作还可以使用当前的处理器基本都支持 CAS()的指令,只不过每个厂家所实现的算法并不一样,
每一个 CAS 操作过程都包含三个运算符:

  • 一个内存地址 V
  • 一个期望的值 A
  • 一个新值 B
    操作的时候如果这个地址上存放的值等于这个期望的值 A,则将地址上的值赋为新值 B,否则不做任何操作。
    CAS 先把值取出来,先算了再讲,如果在进行操作时发现已经有其他线程操作了,就使用CAS再重新操作 。

CAS基本思路:
如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。
循环 CAS 就是在一个循环里不断的做 CAS 操作,直到成功为止。

(某个地址上的变量,比较变量值和期望值一样,交换为新值)

2.CAS的问题

(1)ABA问题

(如果面试问到CAS相关题 一定会提及ABA问题)

如何解决ABA问题(加个版本戳 知道别人有没有动过我的东西)
以下这两个类有什么区别?(面试可能会问)
(在 Android 开发中 使用原子变量的概率比较小)

  • AtomicMarkableReference 只关系有没有被动过
  • AtomicStampedReference 还会记录被动过几次

​ 因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。(CAS操作误判为未发生变化)
​ ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A→B→A 就会变成 1A→2B→3A。
​ 举个通俗点的 例子,你倒了一杯水放桌子上,干了点别的事,然后同事把你水喝了又给你重新倒了一杯水,你回来看水还在,拿起来就喝,如果你不管水中间被人喝过,只关心水还在,这就是 ABA 问题。
​ 如果你是一个讲卫生讲文明的小伙子,不但关心水在不在,还要在你离开的时候水被人动过没有,因为你是程序员,所以就想起了放了张纸在旁边,写上初始值 0,别人喝水前麻烦先做个累加才能喝水。

(2)开销问题 —— 自旋

自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销

(3)只能保证一个共享变量的原子操作

(只能修改比较简单的变量 复合变量就比较麻烦了)

AtomicReference 解决
假如要同时修改两个变量
将多个共享变量打包到一个对象里面去,一次性修改这个对象

​ 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
​ 还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作

3.原子操作类的使用

AtomicIntegerArray
主要是提供原子的方式更新数组里的整型,其常用方法如下。

  • int addAndGet(int i,int delta):以原子方式将输入值与数组中索引 i 的元素相加。

  • boolean compareAndSet(int i,int expect,int update):如果当前值等于预期值,则以原子方式将数组位置 i 的元素设置成 update 值。

    ​ 需要注意的是,数组 value 通过构造方法传递进去,然后 AtomicIntegerArray 会将当前数组复制一份,所以当 AtomicIntegerArray 对内部的数组元素进行修改 时,不会影响传入的数组。

(四)阻塞队列和线程池原理

1.阻塞队列

什么是阻塞队列 ?

  • 支持阻塞的插入方法:
    意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
  • 支持阻塞的移除方法:
    意思是在队列为空时,获取元素的线程会等待队列变为非空。

​ 在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序整体处理数据的速度。

在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。
在多线程开发中:

  • 如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。

  • 同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。

  • 为了解决这种生产消费能力不均衡的问题,便有了生产者和消费者模式。

  • 生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。

  • 生产者和消费者彼此之间不直接通信,而是通过阻塞队列来进行通信,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

​ 阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程, 消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用 来获取元素的容器。
BlockingQueue.png

2.常用阻塞队列

常用阻塞队列 :

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。

  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。

  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。

  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。

  • SynchronousQueue:一个不存储元素的阻塞队列。

  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

    以上的阻塞队列都实现了 BlockingQueue 接口,也都是线程安全的。

3.什么是线程池? 为什么要用线程池?

线程池是一种用于管理和复用多个线程的技术。它通过 预先创建一定数量的线程,将这些线程存储在一个池中,以便在需要执行任务时快速分配和重用,从而减少线程的创建和销毁开销,提高程序的性能和响应速度。

Java 中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。

在开发过程中,合理地使用线程池能够带来 3 个好处。

  • 第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

    • 假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。
    • 如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。
    • 线程池技术正是关注如何缩短或调整 T1,T3 时间的技术,从而提高服务器程序性能的。
    • 它把 T1,T3 分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时, 不会有 T1,T3 的开销了。
  • 第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

4.JDK中的线程池和工作机制(非常非常高频面试)

(1)ThreadPoolExecutor 的类关系

  • Executor 是一个接口,它是 Executor 框架的基础,它将任务的提交与任务的 执行分离开来。
  • ExecutorService 接口继承了 Executor,在其上做了一些 shutdown()、submit() 的扩展,可以说是真正的线程池接口;
  • AbstractExecutorService 抽象类实现了 ExecutorService 接口中的大部分方法;
  • ThreadPoolExecutor 是线程池的核心实现类,用来执行被提交的任务。
  • ScheduledExecutorService 接口继承了 ExecutorService 接口,提供了带”周期 执行”功能 xecutorService;
  • ScheduledThreadPoolExecutor是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。ScheduledThreadPoolExecutor 比 Timer 更灵活,功能更强大。

提交任务
关闭线程池

(2)线程池创建时 各个参数含义(面试会问)

1
2
3
4
5
6
7
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
  • corePoolSize 线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任 务,直到当前线程数等于 corePoolSize; 如果当前线程数为 corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行; 如果执行了线程池的 prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。 (核心线程数,线程池中始终保持存活的线程数量。)

  • maximumPoolSize 线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于 maximumPoolSize 。

  • keepAliveTime 线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于 corePoolSize 时才有用 。

  • TimeUnit keepAliveTime 的时间单位。

  • workQueue workQueue 必须是 BlockingQueue 阻塞队列。当线程池中的线程数超过它的 corePoolSize 的时候,线程会进入阻塞队列进行阻塞等待。通过 workQueue,线程池实现了阻塞功能。

    • 一般来说,我们应该尽量使用有界队列,因为使用无界队列作为工作队列会 对线程池带来如下影响。
      • 1)当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待, 因此线程池中的线程数不会超过 corePoolSize。
      • 2)由于 1,使用无界队列时 maximumPoolSize 将是一个无效参数。
      • 3)由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数。
      • 4)更重要的,使用无界 queue 可能会耗尽系统资源,有界队列则有助于防止资源耗尽,同时即使使用有界队列,也要尽量控制队列的大小在一个合适的范 围。
  • threadFactory 创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,当然还可以更加自由的对线程做更多的设置,比如设置所有的线程为守护线程。 Executors 静态工厂里默认的 threadFactory,线程的命名规则是“pool-数字 -thread-数字”。

  • RejectedExecutionHandler 线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,

  • 线程池提供了 4 种策略: (四种拒绝策略 面试会问)

    • (1)AbortPolicy:直接抛出RejectedExecutionException异常,默认策略;

    • (2)CallerRunsPolicy:用调用者所在的线程来执行任务;

    • (3)DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

    • (4)DiscardPolicy:直接丢弃任务;

      当然也可以根据应用场景实现 RejectedExecutionHandler 接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。

(3)线程池的工作机制

线程池的工作机制.png

  • 1)如果当前运行的线程少于 corePoolSize,则创建新线程来执行任务(注意, 执行这一步骤需要获取全局锁)。
  • 2)如果运行的线程等于或多于 corePoolSize,则将任务加入 BlockingQueue。
  • 3)如果无法将任务加入 BlockingQueue(队列已满),则在 maximumPoolSize 限制范围内创建新的线程来处理任务。
  • 4)如果创建新线程将使当前运行的线程超出 maximumPoolSize,任务将按照指定的拒绝策略处理,并调用RejectedExecutionHandler.rejectedExecution()方法。

(4)提交任务

  • execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
  • submit()方法用于提交需要返回值的任务。线程池会返回一个 future 类型的对象,通过这个 future 对象可以判断任务是否执行成功,并且可以通过 future 的
    • get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,
    • 而使用 get (long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这 时候有可能任务没有执行完。

(5)关闭线程池

可以通过调用线程池的 shutdown 或 shutdownNow 方法来关闭线程池。

它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别:

  • shutdownNow 首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,
  • 而 shutdown 只是将线程池的 状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程。

只要调用了这两个关闭方法中的任意一个,isShutdown 方法就会返回 true。
当所有的任务都已关闭后,才表示线程池关闭成功,这时调用 isTerminaed 方法会返回 true。

至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown 方法来关闭线程池,如果任务不一定要执行完, 则可以调用 shutdownNow 方法。

5.合理配置线程池(线程池必问面试题)

要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析:

合理配置线程池是确保系统性能和稳定性的关键。线程池的配置需要根据具体的业务场景、系统资源和性能需求来进行调整。以下是一些配置线程池的建议和考虑因素:

1. 核心线程数(corePoolSize)

核心线程数是线程池中始终保持存活的线程数量。合理设置核心线程数可以避免频繁创建和销毁线程,减少系统开销。

  • CPU密集型任务:如果任务主要是CPU密集型(如计算、数据处理等),通常建议将核心线程数设置为CPU核心数或稍多一些(如CPU核心数 + 1)。

    1
    int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;
  • IO密集型任务:如果任务主要是IO密集型(如网络请求、文件读写等),由于线程在等待IO时会阻塞,可以设置更多的核心线程数。通常可以设置为CPU核心数的2倍或更多。

    1
    int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;

2. 最大线程数(maximumPoolSize)

最大线程数是线程池中允许的最大线程数量。当任务队列已满且核心线程都在忙碌时,线程池会创建新的线程,直到达到最大线程数。

  • CPU密集型任务:最大线程数可以设置为与核心线程数相同,因为过多的线程会导致频繁的上下文切换,反而降低性能。

    1
    int maximumPoolSize = corePoolSize;
  • IO密集型任务:最大线程数可以设置得更大一些,以应对大量的IO等待时间。可以根据系统的负载情况和资源限制来调整。

    1
    int maximumPoolSize = corePoolSize * 2;

3. 任务队列(workQueue)

任务队列用于保存等待执行的任务。选择合适的队列类型和大小对线程池的性能有很大影响。

  • 有界队列:如ArrayBlockingQueue,可以防止任务队列无限增长,避免内存溢出。适用于需要控制资源使用的场景。

    1
    BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(100);
  • 无界队列:如LinkedBlockingQueue,适用于任务数量不确定但系统资源充足的场景。需要注意无界队列可能导致内存溢出。

    1
    BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>();
  • 优先级队列:如PriorityBlockingQueue,适用于需要按优先级执行任务的场景。

    1
    BlockingQueue<Runnable> workQueue = new PriorityBlockingQueue<>();

4. 线程空闲时间(keepAliveTime)

线程空闲时间是指当线程池中的线程数量超过核心线程数时,多余的空闲线程在等待新任务时的最长存活时间。

  • 短时间任务:如果任务执行时间较短,可以设置较短的keepAliveTime,以便及时回收空闲线程。

    1
    2
    long keepAliveTime = 60L;
    TimeUnit unit = TimeUnit.SECONDS;
  • 长时间任务:如果任务执行时间较长,可以设置较长的keepAliveTime,避免频繁创建和销毁线程。

    1
    2
    long keepAliveTime = 300L;
    TimeUnit unit = TimeUnit.SECONDS;

5. 拒绝策略(RejectedExecutionHandler)

当任务无法被线程池执行时(如任务队列已满且线程数达到最大线程数),线程池会根据拒绝策略来处理新提交的任务。

  • AbortPolicy:默认策略,直接抛出RejectedExecutionException异常。

    1
    RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();
  • CallerRunsPolicy:由提交任务的线程直接执行该任务。

    1
    RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();
  • DiscardPolicy:直接丢弃任务,不抛出异常。

    1
    RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardPolicy();
  • DiscardOldestPolicy:丢弃队列中最旧的任务,然后重新尝试提交当前任务。

    1
    RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardOldestPolicy();

6. 线程工厂(ThreadFactory)

线程工厂用于创建新线程。可以通过自定义线程工厂来设置线程的名称、优先级、是否为守护线程等。

1
2
3
4
5
6
7
8
9
10
11
ThreadFactory threadFactory = new ThreadFactory() {
private final AtomicInteger threadNumber = new AtomicInteger(1);

@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, "my-thread-" + threadNumber.getAndIncrement());
thread.setPriority(Thread.NORM_PRIORITY);
thread.setDaemon(false);
return thread;
}
};

7. 监控和调优

在实际使用中,可以通过监控线程池的状态来调优配置。例如,监控线程池的线程数量、活跃线程数、任务队列大小等指标,根据实际情况调整参数。

1
2
3
4
5
6
7
ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);

// 监控线程池状态
int poolSize = executor.getPoolSize();
int activeCount = executor.getActiveCount();
long completedTaskCount = executor.getCompletedTaskCount();
long taskCount = executor.getTaskCount();

8. 动态调整线程池参数

在某些场景下,可能需要根据系统负载动态调整线程池的参数。可以通过ThreadPoolExecutor提供的方法来动态调整核心线程数和最大线程数。

1
2
executor.setCorePoolSize(newCorePoolSize);
executor.setMaximumPoolSize(newMaximumPoolSize);

总结

合理配置线程池需要综合考虑任务类型、系统资源、性能需求和业务场景。通过合理设置核心线程数、最大线程数、任务队列、线程空闲时间和拒绝策略,可以显著提高系统的并发处理能力和资源利用率。同时,监控和动态调整线程池参数也是确保系统稳定性和性能的重要手段。

(五)深入理解并发编程

1.AQS(AbstractQueuedSynchronizer)

队列同步器 AbstractQueuedSynchronizer(以下简称同步器或 AQS),是用 来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态(比如一个值为0表示未被占用,值为1表示已被占用。),通过内置的 FIFO 队列来完成资源获取线程的排队工作。并发包的大师(Doug Lea)期望它能够成为实现大部分同步需求的基础。

模版设计模式

View就是 Draw() onDraw() dispatchDraw()

模板方法模式

同步器的设计基于模板方法模式。
模板方法模式的意图是,定义一个操作中的算法的骨架,而将一些步骤的实现延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。我们最常见的就是 Spring 框架里的各种 Template。

实际例子:
我们开了个蛋糕店,蛋糕店不能只卖一种蛋糕呀,于是我们决定先卖奶油蛋糕,芝士蛋糕和慕斯蛋糕。三种蛋糕在制作方式上一样,都包括造型,烘焙和涂抹蛋糕上的东西。所以可以定义一个抽象蛋糕模型,然后就可以批量生产三种蛋糕

这样一来,不但可以批量生产三种蛋糕,而且如果日后有扩展,只需要继承 抽象蛋糕方法就可以了,十分方便,我们天天生意做得越来越赚钱。突然有一天, 我们发现市面有一种最简单的小蛋糕销量很好,这种蛋糕就是简单烘烤成型就可 以卖,并不需要涂抹什么食材,由于制作简单销售量大,这个品种也很赚钱,于 是我们也想要生产这种蛋糕。但是我们发现了一个问题,抽象蛋糕是定义了抽象的涂抹方法的,也就是说扩展的这种蛋糕是必须要实现涂抹方法,怎么办?我们可以将原来的模板修改为带钩子的模板。

AQS的基本思想CLH队列锁

CLH 队列锁即 Craig, Landin, and Hagersten (CLH) locks。
CLH 队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。

​ 当一个线程需要获取锁时:

  1. 创建一个的QNode,将其中的locked设置为true表示需要获取锁,myPred 表示对其前驱结点的引用。

    节点.png
  2. 线程 A 对 tail 域调用 getAndSet 方法,使自己成为队列的尾部,同时获取一个指向其前驱结点的引用 myPred

  3. 线程就在前驱结点的 locked 字段上旋转,直到前驱结点释放锁(前驱节点的锁值 locked == false)

    获取锁.png
  4. 当一个线程需要释放锁时,将当前结点的 locked 域设置为 false,同时回收前驱结点

ReentrantLock 可重入锁的实现

锁的可重入:

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞, 该特性的实现需要解决以下两个问题。 我已经拿到了这把锁,但是以为自己没有,就等待前面释放锁(不知道自己拿了锁)

  • 1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程, 如果是,则再次成功获取。

  • 2)锁的最终释放。线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放。

​ nonfairTryAcquire 方法增加了再次获取同步状态的处理逻辑:通过判断当前 线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回 true,表示获取同步状态成功。同步状态表示锁被一个线程重复获取的次数。

​ 如果该锁被获取了 n 次,那么前(n-1)次 tryRelease(int releases)方法必须返回 false,而只有同步状态完全释放了,才能返回 true。可以看到,该方法将同步状态是否为 0 作为最终释放的条件,当同步状态为 0 时,将占有线程设置为 null, 并返回 true,表示释放成功。

ReentrantLock 的实现原理:

线程可以重复进入任何一个它已经拥有的锁所同步着的代码块, synchronized、ReentrantLock 都是可重入的锁。在实现上,就是线程每次获取锁时判定如果获得锁的线程是它自己时,简单将计数器累积即可,每释放一次锁, 进行计数器累减,直到计算器归零,表示线程已经彻底释放锁。底层则是利用了 JUC 中的 AQS 来实现的。

公平和非公平锁:

  • ReentrantLock 的构造函数中,默认的无参构造函数将会把 Sync 对象创建为 NonfairSync 对象,这是一个“非公平锁”;而另一个构造函数 ReentrantLock(boolean fair)传入参数为 true 时将会把 Sync 对象创建为“公平锁” FairSync。

  • nonfairTryAcquire(int acquires)方法,对于非公平锁,只要 CAS 设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同。tryAcquire 方法,该方法与 nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了 hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回 true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。

2.JMM 基础-计算机原理

Java Memory Model
Java内存模型

(六)一线大厂面试题

1.sychronized 修饰普通方法和静态方法的区别?什么是可见性?

​ 对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的 class 对象上的。我们知道,类的对象实例可以有很多个,但是每个类只有一个 class 对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。

​ 但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的 class 对象。类锁和对象锁之间也是互不干扰的。

​ 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值, 其他线程能够立即看得到修改的值。
​ 由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量 V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。要解决共享对象可见性这个问题,我们可以使用 volatile 关键字或者是加锁。

2.锁分哪几类?

锁的分类.png

1. 乐观锁与悲观锁

线程要不要锁住同步资源

  • 乐观锁:假设并发冲突很少发生,因此在操作数据时不会加锁,而是在提交时检查数据是否被修改。常见的实现方式是通过版本号或时间戳。
    • CAS(Compare-And-Swap):Java中的Atomic类(如AtomicInteger)就是基于CAS实现的乐观锁。
  • 悲观锁:假设并发冲突经常发生,因此在操作数据时直接加锁,确保同一时刻只有一个线程可以操作数据。
    • synchronized:Java中的synchronized关键字就是悲观锁的实现。
    • ReentrantLock:java.util.concurrent.locks.ReentrantLock也是悲观锁的一种实现。

2. 自旋锁与阻塞锁

锁住同步资源失败,线程要不要阻塞

  • 自旋锁:线程在获取锁时,如果锁已经被其他线程持有,当前线程不会立即阻塞,而是会循环检查锁是否被释放。
    • Atomic类:Atomic类的CAS操作可以看作是一种自旋锁的实现。
  • 阻塞锁:线程在获取锁时,如果锁已经被其他线程持有,当前线程会进入阻塞状态,直到锁被释放。
    • synchronized:synchronized是阻塞锁。
    • ReentrantLock:ReentrantLock也是阻塞锁。

3. 偏向锁、轻量级锁与重量级锁

多个线程竞争同步资源的流程细节有没有区别?

  • 偏向锁:在无竞争的情况下,锁会偏向于第一个获取它的线程,减少同步开销。(如果后续的操作仍然是同一个线程所执行,它可以反复进入这个同步块,而不需要进行任何锁的获取和释放的操作,这样省去了许多性能开销。)
  • 轻量级锁:当有少量竞争时,锁会升级为轻量级锁,通过CAS操作来避免线程阻塞。(没有获取同步资源的线程自旋等待锁释放)
  • 重量级锁:当竞争激烈时,锁会升级为重量级锁,线程会进入阻塞状态。(没有获取同步资源的线程阻塞等待唤醒)
    • synchronized:synchronized在JVM中的实现会根据竞争情况自动升级锁的状态。

4. 公平锁与非公平锁

多个线程竞争锁时要不要排队

  • 公平锁:多个线程按照申请锁的顺序来获取锁,先到先得。
    • **ReentrantLock(true)**:通过构造函数传入true可以创建一个公平锁。
  • 非公平锁:多个线程获取锁的顺序不一定按照申请锁的顺序,有可能后申请的线程先获取锁。
    • **ReentrantLock(false)**:默认情况下,ReentrantLock是非公平锁。
    • synchronized:synchronized也是非公平锁。

5. 可重入锁与非可重入锁

一个线程中的多个流程,可不可以获取同一把锁

  • 可重入锁:同一个线程可以多次获取同一把锁,而不会造成死锁。
    • ReentrantLock:ReentrantLock是可重入锁。
    • synchronized:synchronized也是可重入锁。
  • 非可重入锁:同一个线程多次获取同一把锁时,会造成死锁。
    • Java中没有直接的非可重入锁实现,但可以通过自定义锁来实现。

6. 共享锁与排他锁

多个线程能不能获取同一把锁

  • 共享锁:多个线程可以同时持有共享锁,通常用于读操作。
    • ReadWriteLock.ReadLock:ReentrantReadWriteLock中的读锁是共享锁。
  • 排他锁:同一时刻只有一个线程可以持有排他锁,通常用于写操作。
    • ReadWriteLock.WriteLock:ReentrantReadWriteLock中的写锁是排他锁。
    • ReentrantLock:ReentrantLock也是排他锁。

0.1. 分段锁

  • 分段锁:将锁的粒度细化,将数据分成多个段,每个段独立加锁,从而提高并发性能。
    • ConcurrentHashMap:ConcurrentHashMap在JDK 1.7及之前使用了分段锁机制。

0.2. 条件锁

  • 条件锁:基于条件变量实现的锁,允许线程在某个条件不满足时等待,直到条件满足时被唤醒。
    • Condition:ReentrantLock中的Condition接口可以实现条件锁。

0.3. 读写锁

  • 读写锁:将锁分为读锁和写锁,读锁是共享锁,写锁是排他锁。
    • ReentrantReadWriteLock:ReentrantReadWriteLock是Java中读写锁的实现。

0.4. 分布式锁

  • 分布式锁:在分布式系统中,用于控制多个节点对共享资源的访问。
    • Zookeeper:通过Zookeeper的临时节点实现分布式锁。
    • Redis:通过Redis的SETNX命令实现分布式锁。

3.CAS 无锁编程的原理。

​ 使用当前的处理器基本都支持 CAS()的指令,只不过每个厂家所实现的算法并不 一样,

每一个 CAS 操作过程都包含三个运算符:一个内存地址 V,一个期望的值 A 和一个新值 B,操作的时候如果这个地址上存放的值等于这个期望的值 A,则将地址上的值赋为新值 B,否则不做任何操作。
​ CAS 的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值, 否则不做任何事儿,但是要返回原值是多少。循环 CAS 就是在一个循环里不断的做 CAS操作,直到成功为止。
​ 还可以说说 CAS 的三大问题。

4.ReentrantLock 的实现原理。

​ 线程可以重复进入任何一个它已经拥有的锁所同步着的代码块, synchronized、ReentrantLock 都是可重入的锁。在实现上,就是线程每次获取锁时判定如果获得锁的线程是它自己时,简单将计数器累积即可,每释放一次锁, 进行计数器累减,直到计算器归零,表示线程已经彻底释放锁。底层则是利用了 JUC 中的 AQS 来实现的。

5.AQS 原理 (小米 京东)

​ 是用来构建锁或者其他同步组件的基础框架,比如 ReentrantLock、 ReentrantReadWriteLock 和 CountDownLatch 就是基于 AQS 实现的。它使用了一 个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作。它是 CLH 队列锁的一种变体实现。它可以实现 2 种同步方式:独占式,共享式。 AQS 的主要使用方式是继承,子类通过继承 AQS 并实现它的抽象方法来管理同步状态,同步器的设计基于模板方法模式,所以如果要实现我们自己的同步工具类就需要覆盖其中几个可重写的方法,如 tryAcquire、tryReleaseShared 等等。
​ 这样设计的目的是同步组件(比如锁)是面向使用者的,它定义了使用者与同步组件交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同 步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程 的排队、等待与唤醒等底层操作。这样就很好地隔离了使用者和实现者所需关注 的领域。

​ 在内部,AQS 维护一个共享资源 state,通过内置的 FIFO 来完成获取资源线程的排队工作。该队列由一个一个的 Node 结点组成,每个 Node 结点维护一个 prev 引用和 next 引用,分别指向自己的前驱和后继结点,构成一个双端双向链表。

6.Synchronized 的原理以及与 ReentrantLock 的区别(360)

​ synchronized (this)原理:涉及两条指令:monitorenter,monitorexit;再说同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来实现,相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。
​ JVM 就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。

7.Synchronized 做了哪些优化 (京东)

引入如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁、逃逸分析等技术来减少锁操作的开销。 逃逸分析 如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:
同步消除 synchronization Elimination,如果一个对象不会逃逸出线程,则对 此变量的同步措施可消除。
锁消除和粗化

  • 锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上 不可能发生共享数据竞争,则会去掉这些锁。
  • 锁粗化:将临近的代码块用同一个锁合并起来。 消除无意义的锁获取和释放,可以提高程序运行性能。

自旋锁、适应性自旋锁、偏向锁和轻量级锁都是用于多线程编程中的同步机制,旨在减少线程之间的竞争和提高性能。下面是它们的简要定义和特点:

1.

  1. 偏向锁(Biased Locking):

    • 偏向锁是Java虚拟机中的一种锁优化,旨在减少无竞争情况下的锁操作开销。
    • 当一个线程获得偏向锁后,后续的锁请求如果是同一个线程,可以不进行任何锁操作,直接使用偏向锁,提高性能。
    • 如果其他线程尝试获得锁,偏向锁将被撤销,转化为标准的重量级锁,导致一定的性能损失。
  2. 轻量级锁(Lightweight Locking):

    • 轻量级锁是一种优化的锁机制,旨在减少锁的开销和上下文切换。
    • 在多个线程竞争锁的情况下,轻量级锁会使用自旋的方式进行锁的尝试,只有在竞争激烈时才升级为重量级锁。
    • 轻量级锁适合于非竞争场景,这样可以显著提高性能。

在Java中,synchronized关键字用于实现同步,确保多线程环境下对共享资源的安全访问。为了提高性能,Java对synchronized进行了多项优化。以下是一些主要的优化措施:

  1. 偏向锁(Biased Locking):
    • 默认情况下,对于一段没有竞争的代码,synchronized会使用偏向锁。偏向锁是为了优化无竞争情况下的锁获取,允许一个线程在获得锁后,不进行额外的锁操作,减少开销。当其他线程尝试获取偏向锁时,偏向锁会被撤销。
  2. 轻量级锁(Lightweight Locking):
    • 当偏向锁被撤销后,synchronized会尝试使用轻量级锁。这种锁通过在栈帧中维护一个锁记录,使用自旋的方式检查锁的状态,从而避免更昂贵的上下文切换。如果轻量级锁的获取失败并且多个线程竞争锁,则会升降为重量级锁。
  3. 锁消除(Lock Elimination):
    • 锁消除是一种优化策略,JVM会在编译时分析代码,发现某些锁可以在编译阶段被消除。例如,如果一个锁只在某个线程的局部变量上使用,而没有在任何其他线程被访问,则该锁是多余的,可以去除。
  4. 锁粗化(Lock Coarsening):
    • 当一个方法中的多个连续操作都被同一个锁保护时,JVM会将这些锁合并为一个更大的锁,这样可以减少上下文切换的频率,从而优化性能。
  5. 自旋锁(Spin Lock):
    • 自旋锁是一种简单的锁机制,线程在请求锁时,会不断地循环(自旋)检查锁的状态,直到成功获取锁。
    • 优点:没有上下文切换开销,适合锁持有时间很短的情况。
    • 缺点:如果锁的持有时间较长,自旋会浪费CPU资源,因为线程在等待时并没有执行其他任务。
  6. 适应性自旋锁(Adaptive Spin Lock):
    • 适应性自旋锁是自旋锁的一种改进,结合了自旋和睡眠的特性,能够根据临界区的竞争情况动态调整自旋的时间。
    • 如果自旋一定时间后仍未获取到锁,线程会放弃自旋而进入休眠状态,减少CPU资源浪费。
    • 适应性自旋锁能够在多核系统中提高性能,适应不同的竞争情况。

通过这些优化措施,Java的synchronized关键字在多线程编程中提供了更高的性能和更低的开销,使得开发者在使用时更加方便和高效。

8.Synchronized static 与非 static 锁的区别和范围(小米)

​ 对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的 class 对象上的。我们知道,类的对象实例可以有很多个,但 是每个类只有一个 class 对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。

​ 但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的 class 对象。类锁和对象锁之间也是互不干扰的。

9.volatile 能否保证线程安全?在 DCL 上的作用是什么?

不能保证,在 DCL 的作用是:volatile 是会保证被修饰的变量的可见性和有序性, 保证了单例模式下,保证在创建对象的时候的执行顺序一定是

  1. 分配内存空间
  2. 实例化对象 instance
  3. 把 instance 引用指向已分配的内存空间,此时 instance 有了内存地址,不再为 null 了 的步骤, 从而保证了 instance 要么为 null 要么是已经完全初始化好的对象。

10.volatile 和 synchronize 有什么区别?(B 站 小米 京东)

​ volatile 是最轻量的同步机制。 volatile 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。但是 volatile 不能保证操作的原子性,因此多线程下的写复合操作会导致线程安全问题。

​ 关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程 对变量访问的可见性和排他性,又称为内置锁机制。

11.什么是守护线程?你是如何退出一个线程的?

Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调 度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候,Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。

​ 线程的中止:
​ 要么是 run 执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。 暂停、恢复和停止操作对应在线程 Thread 的 API 就是 suspend()、resume()和 stop()。 但是这些 API 是过期的,也就是不建议使用的。因为会导致程序可能工作在不确 定状态下。

​ 安全的中止则是其他线程通过调用某个线程 A 的 interrupt()方法对其进行中断操 作,被中断的线程则是通过线程通过方法 isInterrupted()来进行判断是否被中断, 也可以调用静态方法 Thread.interrupted()来进行判断当前线程是否被中断,不过 Thread.interrupted()会同时将中断标识位改写为 false。

12.sleep 、wait、yield 的区别,wait 的线程如何唤醒它?(东方 头条)

  • yield()方法:使当前线程让出 CPU 占有权,但让出的时间是不可设定的。也 不会释放锁资源。所有执行 yield()的线程有可能在进入到就绪状态后会被操作系 统再次选中马上又被执行。
  • yield() 、sleep()被调用后,都不会释放当前线程所持有的锁。 调用 wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新 去竞争锁,锁竞争到后才会执行 wait 方法后面的代码。
  • wait() 通常被用于线程间交互,sleep 通常被用于暂停执行,yield()方法使当 前线程让出 CPU 占有权。
  • wait() 的线程使用 notify/notifyAll()进行唤醒。

13.sleep 是可中断的么?(小米)

sleep 本身就支持中断,如果线程在 sleep 期间被中断,则会抛出一个中断异常。

14.ThreadLocal 是什么?

​ ThreadLocal 是 Java 里一种特殊的变量。ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间訪问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。

​ 在内部实现上,每个线程内部都有一个 ThreadLocalMap,用来保存每个线程所拥有的变量副本。

15.线程池基本原理(说说你对线程池的理解)

在开发过程中,合理地使用线程池能够带来 3 个好处。

第一:降低资源消耗。

第二:提高响应速度。

第三:提高线程的可管理性。

1)如果当前运行的线程少于 corePoolSize(核心线程数),则创建新线程来执行任务(注意, 执行这一步骤需要获取全局锁)。

2)如果运行的线程等于或多于 corePoolSize,则将任务加入BlockingQueue。

3)如果无法将任务加入 BlockingQueue(阻塞队列已满),则创建新的线程来处 理任务。

4)如果创建新线程将使当前运行的线程超出 maximumPoolSize,任务将被 拒绝,并调用 RejectedExecutionHandler.rejectedExecution()方法。


是一种池化技术,其实是资源复用的一种思想的利用,常见的池化技术有线程池、连接池、内存池、对象池,都属于池化技术的使用。

线程池里面复用的是线程资源,肯定是要减少我们频繁的去创建或者说去消费这个线程对象所带来的一些性能开销,因为在线程创建过程中涉及到CPU上下文的切换,涉及到内存的再分配这些工作。

另外呢,这个线程池也可以通过参数来控制线程池创建的数量,这样就可以以避免我们无休止的创建,线程对象带来的一下资源利用过高的问题,就保护了这个资源

了解哪些线程池参数?

主要用到的就是两个,一个是核心线程数、另一个是最大的线程数。

  • 核心线程数是默认的长期在工作的这种工作线程;
  • 最大的线程数是动态的,就是说执行任务的过程中,动态创建的线程,这个过程中线程池中的线程是不可控的,是用了阻塞队列来实现的,主要是为了提高阻塞队列里面这个任务处理的数量

16.有三个线程 T1,T2,T3,怎么确保它们按顺序执行?

在多线程编程中,如果希望三个线程 T1、T2、T3 按顺序执行(即 T1 执行完后 T2 执行,T2 执行完后 T3 执行),可以通过以下方法实现:


方法 1:使用 join() 方法

join() 方法可以让当前线程等待目标线程执行完毕后再继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class SequentialThreads {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println("T1 is running");
});

Thread t2 = new Thread(() -> {
try {
t1.join(); // 等待 T1 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 is running");
});

Thread t3 = new Thread(() -> {
try {
t2.join(); // 等待 T2 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 is running");
});

t1.start();
t2.start();
t3.start();
}
}

输出结果:

1
2
3
T1 is running
T2 is running
T3 is running

方法 2:使用 wait() 和 notify() 方法

通过 wait() 和 notify() 实现线程间的通信,确保线程按顺序执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class SequentialThreads {
private static final Object lock = new Object();
private static int flag = 1; // 标志位,用于控制线程执行顺序

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
while (flag != 1) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("T1 is running");
flag = 2;
lock.notifyAll();
}
});

Thread t2 = new Thread(() -> {
synchronized (lock) {
while (flag != 2) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("T2 is running");
flag = 3;
lock.notifyAll();
}
});

Thread t3 = new Thread(() -> {
synchronized (lock) {
while (flag != 3) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("T3 is running");
}
});

t1.start();
t2.start();
t3.start();
}
}

输出结果:

1
2
3
T1 is running
T2 is running
T3 is running

方法 3:使用单线程池(SingleThreadExecutor)

通过 Executors.newSingleThreadExecutor() 创建一个单线程池,任务会按提交顺序依次执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SequentialThreads {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();

executor.submit(() -> System.out.println("T1 is running"));
executor.submit(() -> System.out.println("T2 is running"));
executor.submit(() -> System.out.println("T3 is running"));

executor.shutdown();
}
}

输出结果:

1
2
3
T1 is running
T2 is running
T3 is running

方法 4:使用 CompletableFuture

CompletableFuture 是 Java 8 引入的工具类,可以方便地实现任务的有序执行。

1
2
3
4
5
6
7
8
9
10
import java.util.concurrent.CompletableFuture;

public class SequentialThreads {
public static void main(String[] args) {
CompletableFuture.runAsync(() -> System.out.println("T1 is running"))
.thenRun(() -> System.out.println("T2 is running"))
.thenRun(() -> System.out.println("T3 is running"))
.join(); // 等待所有任务完成
}
}

输出结果:

1
2
3
T1 is running
T2 is running
T3 is running

总结

  • join() 方法:简单直接,适合少量线程的顺序控制。
  • **wait() 和 notify()**:适合需要复杂同步逻辑的场景。
  • 单线程池:适合任务提交顺序固定的场景。
  • **CompletableFuture**:适合需要链式任务处理的场景。

根据具体需求选择合适的方法即可。

如果一个线程调用了两次start()方法会出现什么问题?

会抛出异常,线程里面只能调用一次start()方法,第二次调用会抛出非法线程的这个异常状态

当你创建一个线程对象并调用 start() 方法时,线程的状态会从“新建”转变为“可运行”状态。

再调用一次start()让这个正在运行的线程去重新运行,不管从线程安全的角度还是从线程本身的自身逻辑上来讲,都是不合理不可取的,为了避免这个问题,他就会先去判断当前线程的状态,如果是处于运行状态or就绪状态,他会中断操作抛出异常

3.4 谈谈线程死锁,如何有效的避免线程死锁?(享学)

1.什么是死锁呢?

多个线程,他们之间是相互竞争的关系,他们互持资源,又相互等待,就会导致永久的这种阻塞的现象

2.诱发死锁的原因有哪些呢?

  • 互斥条件
  • 占有等待
  • 不可抢夺(不可抢占)
  • 循环等待

3.遇到死锁问题是怎么解决的?

基本上死锁一旦发生,就很难去人为干预他,所以就尽量去规避它,上述四个条件只要同时满足了就会触发死锁的这种现象,所以去打破其中的任意一条,死锁就不会发生

  • 互斥条件:基本上是无法被破坏的,因为线程本身就是通过互斥来解决线程安全这个问题的,所以这一条就不用考虑了
  • 占有且等待:可以一次性去申请所需的所有资源,就不存在等待这个问题了。(指一个进程持有某些资源同时又在等待其他资源,而这些资源又被其他进程占有。)
  • 不可抢夺(不可抢占):使占有部分资源的线程,进一步去申请其他资源的时候,如果申请不到就主动释放掉他现在目前已经占有的。
  • 循环等待:按照这种申请资源来进行预防,按顺序去申请资源预防的,因为这个资源是有线性顺序的,可以先申请序号比较小的,再申请序号比较大的,这样就不存在循环等待的问题了
    • 例如,如果进程A持有资源1并请求资源2,而进程B持有资源2并请求资源1,按照顺序请求的规则,进程A和B都不能获取比它们当前持有的资源编号更低的资源,因此无法形成循环。

3.5谈谈线程阻塞的原因有哪些?(享学)

线程阻塞是指线程因为某些原因暂时停止执行,进入等待状态。线程阻塞的原因有很多,以下是一些常见的原因:


1. 等待获取锁

  • 当多个线程竞争同一把锁时,未获得锁的线程会进入阻塞状态,直到锁被释放。
  • 例如,使用 synchronized 关键字或 ReentrantLock 时,未获得锁的线程会被阻塞。

2. 等待 I/O 操作完成

  • 当线程执行 I/O 操作(如读取文件、网络请求等)时,如果数据未准备好,线程会进入阻塞状态,直到 I/O 操作完成。
  • 例如,InputStream.read() 或 Socket.accept() 方法会阻塞线程。

3. 调用了 sleep() 方法

  • 线程调用 Thread.sleep() 方法后,会主动进入阻塞状态,暂停执行指定的时间。
  • 例如:Thread.sleep(1000) 会让线程休眠 1 秒。

4. 调用了 wait() 方法

  • 线程调用 Object.wait() 方法后,会释放锁并进入阻塞状态,直到其他线程调用 notify() 或 notifyAll() 唤醒它。
  • 例如,在生产者-消费者模型中,消费者线程可能会调用 wait() 等待数据。

5. 调用了 join() 方法

  • 线程调用 Thread.join() 方法后,会等待目标线程执行完毕,当前线程进入阻塞状态。
  • 例如:thread1.join() 会让当前线程等待 thread1 执行完毕。

6. 等待条件变量

  • 在使用 Condition 或 LockSupport 时,线程可能会因为条件不满足而进入阻塞状态。
  • 例如,Condition.await() 会让线程等待,直到其他线程调用 signal() 或 signalAll()。

7. 死锁

  • 当多个线程互相持有对方需要的锁时,会导致所有相关线程进入阻塞状态,无法继续执行。
  • 例如,线程 A 持有锁 1 并等待锁 2,线程 B 持有锁 2 并等待锁 1。

8. 等待线程池任务

  • 当线程池的任务队列已满时,新提交的任务可能会被阻塞,直到队列中有空闲位置。
  • 例如,使用 ThreadPoolExecutor 时,如果任务队列和线程池都已满,新任务会被阻塞。

9. 等待信号量

  • 当线程尝试获取信号量(Semaphore)时,如果信号量的许可数为 0,线程会进入阻塞状态,直到有其他线程释放许可。
  • 例如,semaphore.acquire() 会让线程等待信号量。

10. 等待屏障

  • 当线程调用 CyclicBarrier.await() 或 CountDownLatch.await() 时,会进入阻塞状态,直到所有线程都到达屏障点或计数器归零。
  • 例如,CyclicBarrier 可以用于多线程任务的同步。

11. 等待 Future 结果

  • 当线程调用 Future.get() 方法时,如果任务尚未完成,线程会进入阻塞状态,直到任务完成并返回结果。
  • 例如:future.get() 会阻塞线程,直到异步任务完成。

12. 等待线程调度

  • 在多核 CPU 环境下,线程可能会因为 CPU 资源竞争而进入阻塞状态,等待操作系统调度。

总结

线程阻塞的原因主要包括:

  1. 锁竞争(如 synchronized、ReentrantLock)。
  2. I/O 操作(如文件读写、网络请求)。
  3. 主动阻塞(如 sleep()、wait()、join())。
  4. 条件变量(如 Condition.await())。
  5. 死锁或资源竞争。
  6. 线程池任务队列已满。
  7. 信号量或屏障(如 Semaphore、CyclicBarrier)。
  8. 等待异步任务结果(如 Future.get())。

理解这些阻塞原因有助于更好地设计和调试多线程程序,避免性能问题和死锁等并发问题。

Donate
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2023-2025 Annie
  • Visitors: | Views:

嘿嘿 请我吃小蛋糕吧~

支付宝
微信