跟随黑马面Java-8,多线程篇

第八篇:多线程篇

“求其上,得其中;求其中,得其下,求其下,必败”

新版Java面试专题视频教程,java八股文面试全套真题+深度详解(含大厂高频面试真题)_哔哩哔哩_bilibili

开篇


image-20240518152943716

线程基础知识


线程和进程的区别

进程是一个具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统资源分配和独立运行的最小单位;线程是进程的一个执行单元,是任务调度和系统执行的最小单位;协程是一种用户态的轻量级线程,协程的调度完全由用户控制。

  • 进程

    • 进程是操作系统资源分配和独立运行的最小单位。
    • 每个进程都有独立的地址空间,不同进程通过进程间通信来通信。
    • 进程占据独立的内存,上下文切换开销(栈、寄存器、页表、文件句柄等)比较大,但相对比较稳定安全。
  • 线程

    • 线程是CPU调度和系统执行的最小单位。
    • 线程从属于进程,一个进程至少包含一个主线程,也可以有更多的子线程。
    • 多个线程共享所属进程的资源,同时线程也拥有自己的专属资源。
    • 线程上下文切换开销较小,但相比进程不够稳定容易丢失数据。
  • 协程

    • 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。
    • 协程不是被操作系统内核所管理,而是完全由程序所控制。
    • 一个线程可以拥有多个协程,协程的切换开销极小。
    • 协程不需要多线程的锁机制,因为只有一个线程,执行效率比多线程高。

并发和并行的区别
  • 单核CPU

    • 在单核CPU下,线程实际上是串行执行的。
    • 操作系统中的任务调度器会将CPU的时间片(在Windows下最小约为15毫秒)分配给不同的程序使用。由于CPU在线程间切换的速度非常快,给人的感觉是程序同时运行的。
    • 简而言之:微观上是串行的,宏观上是并行的。
    • 这种线程轮流使用CPU的方式通常称为并发(concurrent)。
  • 多核CPU

    • 每个核心(core)都可以独立调度运行线程,此时线程可以实现真正的并行。
      image-20240518154549597
  • 并发(Concurrency)

    • 指多个任务在同一时间段内交替执行,看起来像是同时执行。
    • 多个任务共享同一个CPU资源,通过CPU快速切换执行不同任务,造成并发的错觉。
    • 目的是提高系统的响应速度和吞吐量。
    • 可以在单核CPU上实现。
  • 并行(Parallelism)

    • 指多个任务在同一时刻真正同时执行。
    • 需要多个CPU核心或多个处理器同时为不同任务服务。
    • 目的是提高系统的处理能力和效率。
    • 通常需要多核CPU或分布式系统的支持。

创建线程的方式有哪些
创建线程的四种方式
  1. 继承Thread类

    需要重写run()方法,在main方法中先new一个对象,然后.start()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class MyThread extends Thread {
    @Override
    public void run() {
    System.out.println("Thread is running");
    }
    }
    public class Main {
    public static void main(String[] args) {
    // 创建对象
    MyThread thread = new MyThread();
    // 启动线程
    thread.start();
    }
    }
  2. 实现Runnable接口

    需要重写run()方法。先new一个此类的对象,然后再包装在Thread类中,最后再thread.start()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class MyRunnable implements Runnable {
    @Override
    public void run() {
    System.out.println("Runnable is running");
    }
    }
    public class Main {
    public static void main(String[] args) {
    // 创建MyRunnable对象并包装在Thread中
    Thread thread = new Thread(new MyRunnable());
    // 启动线程
    thread.start();
    }
    }
  3. 实现Callable接口

    需要重写call()方法,先new一个此类的对象,然后包装在Future类中,然后将future再包装在Thread类中,最后通过thread.start()启动线程。能够通过future的get方法获取执行结果。

    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
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Future;

    class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception { // 方法的返回类型和Callable<>的泛型要一致
    return "Callable result";
    }
    }
    public class Main {
    public static void main(String[] args) {
    // 创建MyCallable对象
    MyCallable mc = new MyCallable();
    // 创建FutureTask
    FutureTask<String> future = new FutureTask<String>(mc);
    // 创建Thread对象
    Thread thread = new Thread(future);
    // 启动线程
    thread.start();
    // 调用 future的get方法获取执行结果
    System.out.println(future.get());
    }
    }
  4. 线程池创建线程

    需要实现Runnable或者Callable接口。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;

    class MyRunnable implements Runnable {
    @Override
    public void run() {
    System.out.println("Thread pool is running");
    }
    }
    public class Main {
    public static void main(String[] args) {
    // 创建线程池对象
    ExecutorService executor = Executors.newFixedThreadPool(3);
    for (int i = 0; i < 5; i++) {
    executor.execute(new MyRunnable());
    }
    //关闭线程池
    executor.shutdown();
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Future;

    class MyCallable implements Callable<String> {
    public String call() {
    return "Callable result";
    }
    }
    public class Main {
    public static void main(String[] args) {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<String> future = executor.submit(new MyCallable());
    try {
    String result = future.get();
    System.out.println(result);
    } catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
    }
    executor.shutdown();
    }
    }
Runnable和Callable有什么区别
  1. Runnable接口的run方法没有返回值。

  2. Callable接口的call方法有返回值,是个泛型,可与Future、FutureTask配合使用来获取异步执行的结果。

  3. Callable接口的call方法允许抛出异常;而Runnable接口的run方法的异常只能在内部消化,不能继续上抛。

线程的run()start()有什么区别
  • start():用于启动线程,通过该线程调用run()方法执行其中所定义的逻辑代码。start()方法只能被调用一次。

  • run():封装了要被线程执行的代码,不会创建新线程 ,可以被调用多次。


线程包括哪些状态,状态之间是如何变化的
  • 线程状态

    1. 新建(New)状态:创建了线程对象,但还未调用start()方法。
    2. 就绪(Runnable)状态:线程对象创建后,调用了start()方法,等待CPU分配时间片。
    3. 阻塞(Blocked)状态:运行的线程由于某些原因放弃CPU,暂时无法运行。如尚未获取锁、等待I/O操作完成等。
    4. 等待(Waiting)状态:运行的线程等待其他线程通知,如调用了Object.wait()Thread.join()等方法。
    5. 超时等待(Timed Waiting)状态:运行的线程等待一定时间,如调用了Thread.sleep(millis)等方法。
    6. 终止(Terminated)状态:线程执行完毕run()方法或调用stop()方法后进入终止状态。
    image-20240518163442640
image-20240518163849298
线程按顺序执行、notify()notifyAll()的区别
新建T1、T2、T3三个线程,如何保证其按照顺序执行

join()方法能够等待线程结束再运行。

下图中,t2中调用了t1.join()t3中调用了t2.join(),那么只有当t1运行结束后才能运行t2,只有t2运行结束后才能运行t3

image-20240518164418678
notify()notifyAll()有什么区别

notify()方法用于随机唤醒一个等待线程,而notifyAll()方法则是唤醒所有等待线程。


Java中wait()sleep()的不同
  • 共同点

    wait()wait(long)sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。

  • 不同点

    • 方法归属不同

      • sleep(long)Thread 的静态方法,而 wait()wait(long)Object 的成员方法,每个对象都有。
    • 醒来时机不同

      • 执行 sleep(long)wait(long) 的线程都会在等待相应毫秒后醒来。
      • wait(long)wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去。它们都可以被打断唤醒。
    • 锁特性不同 (重点)

      • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制。

        wait方法的调用必须先获取wait对象的锁,原因是:

        1. wait方法是Object类的一个native方法,它的语义是让当前线程释放对象的锁,并加入该对象的等待队列中,等待被其他线程通过notify/notifyAll唤醒。

        2. 如果线程在调用wait方法时没有持有相应的对象锁,就会抛出IllegalMonitorStateException异常。这是为了避免出现混乱和不确定性。

        3. 通过这种设计,可以确保wait方法只能在同步代码块中被调用,从而保证线程安全和对象状态的一致性。

        而sleep方法则无需获取任何对象锁,原因是:

        1. sleep是Thread类的静态方法,它的作用是让当前线程暂时放弃CPU时间片,进入阻塞状态指定的时间。

        2. 在sleep期间,线程不会释放任何锁资源,也不会加入任何对象的等待队列。

        3. sleep方法主要用于模拟线程的工作延时,或者防止线程消耗过多CPU资源而已。

      • wait 方法执行后会释放对象锁,允许其他线程获得该对象锁(我放弃 CPU,但你们还可以用);

        sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 CPU,你们也用不了)。


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

有三种方式可以停止线程

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

  • 使用stop方法强行终止(不推荐,方法已作废)

  • 使用interrupt方法中断线程

    • 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出InterruptedException异常

      image-20240518170652135
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

      image-20240518170717629

线程安全


synchronized关键字底层原理
  • 案例

    image-20240518181730747

    Synchronized【对象锁】采用互的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住

  • Monitor

    我们可以通过javap -v xx.class查看class字节码信息,以下代码通过此命令可以获得其汇编代码。

    image-20240518182052387 image-20240518182216798
    • Monitor(监视器)由JVM提供,C++实现。

      Monitor中的属性有WaitSetEntryListOwner。当一个线程1要执行当前代码,其中包含一个synchronized代码块,该代码块使用了一个对象lock。首先,线程1会将lock与Monitor关联起来,然后检查Monitor属性中的Owner是否为空。如果为空,线程1直接获取对象锁;否则,进入EntryList中等待,此时等待的线程处于blocked状态。当线程1执行完毕释放锁时,Owner变为空,然后唤醒EntryList中处于阻塞状态的线程,让它们争夺Owner的所有权,而不是按照先来后到的顺序。

      • Owner:存储当前持有锁的线程,只能有一个线程可以持有。

      • EntryList:关联那些未能获取锁的线程,处于Blocked状态的线程。

      • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程。

    image-20240518183508854
image-20240518183654129
synchronized关键字底层原理-进阶
  • 锁的升级

    • Monitor实现的锁属于重量级锁,里面涉及到了用户态(Java代码)和内核态(CPU层面)的切换、进程的上下文切换,成本较高,性能比较低。

    • 在JDK1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争(锁只被一个线程持有)或基本没有竞争的场景(不同线程交替持有)下因使用传统锁机制带来的性能升销问题。

  • 当一个线程获取锁的时候,需要让lock对象锁与Monitor进行关联,那它们是怎么关联上的呢?

    image-20240518190404313
    • 在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data) 和对齐填充

      image-20240518184340706
    • 下图展示了不同状态下对象中的 MarkWord 的详细信息:

      image-20240518190001935
      • 第一行展示了一个没有上锁的对象的信息。每个对象都有一个 hashcode 值,其在 MarkWord 中占用25个bit位;age 表示对象分代回收的年龄,在堆内存中,对象分为新生代和老年代,这个 age 表示对象在新生代中移动的次数,如果移动超过一定阈值,就会移动到老年代。biased_lock 表示一个偏向锁的标识,占用一个bit位,如果是0的话,表示没有开启偏向锁;如果为1的话,表示开启。第一行这个是未开启偏向锁的,所以 biased_lock 为0。后面那个是锁的标识,占两位。如果配合 biased_lock 起来,是001的话,就表示是一个无锁的状态。

      • 第二行是一个偏向锁。这个偏向锁中,有一个线程 thread 占了23位;第二个 epoch 表示偏向锁的时间戳,占两位。然后是 age,和上面一样。接着是 biased_lock,为1。然后后两位是它的锁的标识01,结合起来就是101,表示偏向锁。

      • 第三行是一个轻量级锁。ptr_to_lock_record 是轻量级锁的记录,锁的标识为00。

      • 第四行是一个重量级锁。锁的标识为10,前面的 ptr_to_heavyweight_monitor 占用30个bit位,是一个指针,指向重量级锁要关联的Monitor。

      属性 描述
      hashcode 25位的对象标识Hash码
      age 对象分代年龄占4位
      biased_lock 偏向锁标识,占1位,0表示没有开始偏向锁,1表示开启了偏向锁
      thread 持有偏向锁的线程ID,占23位
      epoch 偏向时间戳,占2位
      ptr_to_lock_record 轻量级锁状态下,指向栈中锁记录的指针,占30位
      ptr_to_heavyweight_monitor 重量级锁状态下,指向对象监视器Monitor的指针,占30位

    现在我们可以知道,一个对象锁想要关联到 Monitor,就是在对象头的 MarkWord 中记录了 Monitor 的地址,这样就关联起来了。总结就是:每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。

  • 轻量级锁

    • 在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

      image-20240518190841574
    • 对于 Object,其实例化对象内存结构包含三部分:MarkWord、KlassWord、ObjectBody(对象实例数据)。

      在没有线程来执行上图中代码的时候,就是无锁状态。MarkWord 中就包含了像 hashcodeagebiased_lock,以及锁标志。假如此时来了一个线程,要去执行上述代码,在这个线程执行的时候,就会去创建一个锁记录,叫做 Lock Record。那每个线程的栈帧就会包含一个锁记录的结构,内部就可以存储锁定对象的 MarkWord。首先会让 Object Reference 去指向锁对象,也就是 Object 的实例。同时在 Lock Record 上还有一个 Lock Record 地址,这其实就是一个锁记录地址,然后还有个00。其实,在当前的线程来尝试持有锁的时候,他要去修改对象的 MarkWord,这里面主要是使用的 CAS 的方式交换数据。它会将对象的 MarkWord 中的数据与锁记录的数据进行交换。如果 CAS 替换成功了,那对象头就存储了锁记录的地址和状态00,表示是由该线程拥有了当前的对象锁。

      当然,如果 CAS 交换失败,一般可以从两个原因分析:一是有多个线程竞争,那此时就不能使用轻量级锁了,它会直接升级成重量级锁。二是当前的锁重入了,那此时就会在栈帧中再添加一条 Lock Record,作为重入的计数。这里要注意,加入两个锁记录,至少需要两次 CAS 操作,也就是每加一个锁就要进行 CAS 操作。当然,因为我们第一次 CAS 的时候,已经将锁的地址记录到了 MarkWord 中,所以第二次就不用真正修改了,只需要添加一个记录就可以了。但是它依然要 CAS 操作。当然,第二个锁记录也会去指向此 Object 实例,因为它们本身都是同一个对象锁。

      然后,我们还要解锁(退出 synchronized 代码块)。首先我们要判断下当前的锁记录是否为 null,如果为 null,则表明有重入,这时需要重置当前这个锁的记录,将计数减一(删除栈顶一个 Lock Record)。如果不为 null,那它就会再来一次 CAS 的操作,把这些值都换回来。这时候就算是解锁成功了。

      image-20240518194018162
    • 加锁和解锁流程

      • 加锁流程:
        1. 在线程栈中创建一个 LockRecord,将其 obj 字段指向锁对象。
        2. 通过 CAS 指令将 Lock Record 的地址存储在对象头的 MarkWord 中。如果对象处于无锁状态,则修改成功,代表该线程获得了轻量级锁。
        3. 如果当前线程已经持有该锁,则表示发生了锁重入。将 Lock Record 的第一部分设为 null,起到重入计数器的作用。
        4. 如果 CAS 修改失败,说明发生了竞争,需要升级为重量级锁。
      • 解锁过程:
        1. 遍历线程栈,找到所有 obj 字段等于当前锁对象的 LockRecord。
        2. 如果 Lock Record 的 MarkWord 为 null,表示这是一次锁重入。将 obj 设置为 null 后继续遍历。
        3. 如果 Lock Record 的 MarkWord 不为 null,则利用 CAS 指令将对象头的 MarkWord 恢复为无锁状态。如果失败,则需要升级为重量级锁。
  • 偏向锁

    • 轻量级锁在没有竞争时(即只有当前线程),每次重入仍然需要执行 CAS 操作。

      Java 6 引入了偏向锁来进一步优化:只有第一次使用 CAS 将线程ID 设置到对象的 MarkWord 头,之后如果发现这个线程ID是自己的,就表示没有竞争,不需要重新执行 CAS 操作。只要不发生竞争,这个对象就会一直归该线程所有,从而减少了锁操作的开销。

      image-20240518194150174
    • 在上锁的过程中,对于对象实例锁仍然需要进行 CAS 交换。但在偏向锁的情况下,这次交换的数据略有不同。偏向锁会记录某个线程的 ID,并将偏向锁的标志位设置为 1。此后,若发生重入,会先检查锁中记录的线程 ID 是否与当前线程相同,如果相同,则无需再执行 CAS 操作,而是直接添加一个新的 Lock Record

      image-20240518194713349 image-20240518195055854
image-20240518195434663
JMM(Java 内存模型)
  • 定义

    JMM(Java Memory Model)Java内存模型,定义了共享内存多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的止确性

  • 说明

    image-20240518200231704

    JMM(Java 内存模型)将内存分为两块:工作内存和主内存。

    1. 工作内存:每个线程都有自己的工作内存,用于存储线程内的私有数据。线程只能访问自己的工作内存,即使多个线程执行相同的代码,它们的工作内存中的数据也是相互独立的。因此,在工作内存中的数据不存在线程安全问题。

    2. 主内存:主内存中存储共享数据,即共享变量,包括 Java 实例对象、成员变量、数组等。每个线程都可以访问主内存中的共享数据。因此,多个线程对共享数据的操作可能导致线程安全问题。为了解决线程安全问题,需要通过主内存来进行同步。

image-20240518200916053
CAS
CAS在JMM中的应用

CAS 的全称是:Compare And Swap(比较再交换),它体现了一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。

在 JUC(java.util.concurrent)包下,许多类都使用了 CAS 操作,比如 AbstractQueuedSynchronizer(AQS 框架)、AtomicXXX 类。

CAS 数据交换流程:

CAS 数据交换流程

假设主内存中的共享变量 a 的值为 100,记为V。线程 A 和线程 B 分别从主内存中复制了 a 的值到自己的工作内存中,记为 A。它们分别修改了自己工作内存中 a 的值,线程 A 将 a 加一,线程 B 将 a 减一。修改后的 a 值分别用 B 表示。如何将修改同步到主内存呢?

首先,线程会将旧的预期值 A 与主内存中的内存值V进行比较,如果相同,则修改成功,并用即将更新的值 B 覆盖原有内存值。如果不同,则修改失败,开始自旋。

CAS 自旋

自旋是指一个死循环,只有满足某一定条件才能退出循环。

线程 A 修改成功后,主内存中的值更新为 int a = 101。线程 B 尝试获取 V 并修改,然后尝试提交,直到成功。

自旋锁因为没有加锁,所以线程不会陷入阻塞,效率较高。但是如果竞争激烈,重试频繁发生,效率会受影响。

CAS 重试
CAS底层实现

CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令。

image-20240518203147593

Java中也提供了很多的阻塞式的锁,比如ReentrantLock(可重入锁),其底层也使用到了CAS。

1
2
3
protected final boolean compareAndSetState(int expect, int update) {
return STATE.compareAndSet(this, expect, update); // 当前值(V),期望的值(A),更新后的值(B)
}
乐观锁和悲观锁
  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

image-20240518203921529
volatile的理解
  • 一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义

    1. 保证线程间的可见性
    2. 禁止进行指令重排序
  • 保证线程间的可见性

    用volatile修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。

    • 案例:

      下方stop变量由static修饰,如果线程之间对此变量相互可见,那么t2和t3应该能够发现t1修改了此变量。然而,t2打印出的stop是true,t3并未停止运行。

      image-20240518205419993

      原因是JVM虚拟机中有一个JIT(即时编译器)给代码做了优化。

      image-20240518205845410

      解决方案1:在程序运行的时候加入vm参数-Xint表示禁用即时编译器,不推荐 ,得不偿失(其他程序还要使用)。

      解决方案2:修饰stop变量的时候volatile修饰的变量做优化,当前告诉JIT,不要对volatile修饰的变量做优化。

    • 禁止进行指令重排序

      • 指令重排序是CPU为了提高程序运行效率可能进行的一种优化方式。它包括编译器重排序和处理器重排序两种类型。编译器重排序是指在不改变程序语义的前提下重新安排指令执行顺序,而处理器重排序则是为了提高指令执行的并行度而可能扰乱指令执行顺序。指令重排序的目的在于提高程序的运行效率,通过减少指令之间的依赖性和充分利用处理器资源来实现。然而,在多线程环境下,指令重排序可能会破坏程序原有的执行顺序,导致不可预期的结果。为了保证程序的正确性,在多线程环境下需要采用volatile关键字和内存屏障等方式来禁止指令重排序。

      volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。下方是没有添加volatile的代码,实际的运行情况中出现了(1,0),说明出现了指令重排序,导致先执行y=1;

      而我们在将int y;修改为volatile int y;之后,就没有发生指令重排序的问题。

      image-20240518210714925

      其实volatile的底层是通过对共享指令添加不同的屏障来防止指令重排序。

      image-20240518212408825

      对共享变量设置volatile,将会对此变量的读写操作都添加屏障。写操作的屏障是阻止上方其他的写操作越过屏障排到volatile之下;读操作的屏障是阻止下方其他的写操作越过屏障排到volatile之上。所以,我们如果将volatile添加到int x;,屏障则不生效。

      • 使用volatile需要注意:

        • 对于写操作,尽可能将此行代码写在代码最后的位置
        • 对于读操作,尽可能将此行代码写在代码最开始的位置
image-20240518213140046
AQS
  • 定义

    全称是AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架

    synchronized AQS
    关键字,c++语言实现 java语言实现
    悲观锁,自动释放锁 悲观锁,手动开启和关闭
    锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案

    AQS常见的实现类

    • ReentrantLock 阻塞式锁

    • Semaphore 信号量

    • CountDownLatch 倒计时锁

  • 基本工作机制

    在AQS内部存在一个名为state的状态变量,由volatile修饰。它的取值有两种:0表示无锁,1表示有锁。当一个线程试图获取锁时,首先要修改state的值,将其从0改为1。如果另一个线程也想获取锁,那么它的尝试将会失败。

    除了state变量外,AQS还维护着一个双向队列,用于存储请求锁失败的线程。当某个线程释放了AQS时,AQS会将state修改为0,并唤醒队列中的头部元素,使其获取锁。这就是AQS的基本工作机制。

    • 多个线程同时去抢state时,如何保证原子性?

      如果有两个线程同时去抢state,其中一个抢到了锁,将会通过CAS设置state状态,保证操作的原子性。

    • AQS是公平锁,还是非公平锁?

      新的线程与队列中的线程共同来抢资源,是非公平锁;新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁。

      默认是非公平锁。

image-20240518215223649
ReentrantLock的实现原理

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:可中断、可以设置超时时间、可以设置公平锁、支持多个条件变量、与synchronized一样,都支持重入。

  • 使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 创建锁对象
    ReentrantLock lock = new ReentrantLock();
    try {
    // 获取锁
    lock.lock();
    // 业务逻辑
    } finally {
    // 释放锁
    lock.unlock();
    }
  • 实现原理

    ReentrantLock主要利用CASAQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。在构造方法中,可以传入一个可选的公平参数(默认为非公平锁)。设置为true时,表示公平锁;否则为非公平锁。公平锁的效率往往没有非公平锁高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

    image-20240518220024300
  • 非公平锁

    image-20240518220308534
    • 线程尝试抢锁时,使用CAS的方式修改state状态。如果成功将状态修改为1,则将**exclusiveOwnerThread**(独占线程)属性指向当前线程,表示获取锁成功。

    • 如果修改状态失败,则线程会进入双向队列中等待,其中head指向队列头部,tail指向队列尾部。

    • 当**exclusiveOwnerThread**为null时,表示没有线程持有锁,此时会唤醒双向队列中等待的线程。

    • 公平锁体现在按照线程申请锁的先后顺序进行获取,而非公平锁允许未排队的线程抢占锁。

image-20240518220630765
synchronizedLock有什么区别

语法层面

  • synchronized是关键字,其源码实现在JVM中,使用C++语言编写。

  • Lock是接口,其源码由JDK提供,使用Java语言编写。

  • 使用synchronized时,退出同步代码块会自动释放锁,而使用Lock时,需要手动调用unlock方法释放锁。

功能层面

  • 二者均属于悲观锁,都具备基本的互斥、同步、锁重入功能。

  • Lock提供了许多synchronized不具备的功能,例如公平锁、可打断、可超时、多条件变量。

  • Lock有适合不同场景的实现,如ReentrantLockReentrantReadWriteLock(读写锁)。

    • 对于可打断的锁,应该使用lock.lockInterruptibly()而不是lock.lock()
    • 对于可超时的锁,应该使用lock.tryLock(timeout, TimeUnit)而不是lock.lock()
    • 对于按照条件进行等待,需要在创建锁之后创建此锁的Condition对象。然后,在需要休眠此锁的地方调用await()方法,在需要唤醒的地方调用signal()方法。与notify()类似,也有signalAll()方法。

性能层面

  • 在没有竞争时,synchronized做了很多优化,如偏向锁、轻量级锁,性能较好。

  • 在竞争激烈时,Lock的实现通常会提供更好的性能。


死锁产生的条件
  • 产生死锁的四个必要条件(操作系统课程): (1) 互斥条件:一个资源每次只能被一个进程使用。 (2) 请求与保持条件:一个进程在请求资源时,可以继续占用已分配到的资源。(3) 不可剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。 (4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

  • 如何进行死锁诊断

    • JDK自带工具

      当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和jstack

      • jps:输出JVM中运行的进程状态信息

      • jstack:查看java进程内线程的堆栈信息

      image-20240518223048825
    • 可视化工具

      • jconsole

        用于对jvm的内存,线程,类的监控,是一个基于jmx 的 GUI 性能监控工具

        打开方式: java安装自录 bin自录下直接双击启动jconsole.exe 就行

      • VisualVM:故障处理工具

        能够监控线程,内存情况,查看方法的CPU时间和内存中的对象,已被GC的对象,反向查看分配的堆栈

        打开方式:java安装自录bin自录下直接双击后动jvisualvm.exe就行

image-20240518223359412
聊一下ConcurrentHashMap
  • 底层结构

    ConcurrentHashMap是一种线程安全的高效Map集合底层数据结构:

    • JDK1.7底层采用分段的数组+链表实现

    • JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树

  • 1.7中的ConcurrentHashMap

    image-20240518235547287
    • 数据结构

      1. ConcurrentHashMap由一个Segment数组组成,默认有16个Segment
      2. 每个Segment相当于一个子哈希表,使用数组+链表的结构存储数据。
      3. 每个Segment都继承自ReentrantLock,拥有一个锁。
    • 并发控制

      1. 新增/修改数据时,需要先获取到相应的Segment锁。
      2. 不同的Segment锁互不影响,因此在同一时间点,最多可以有约16个线程并发写入。
      3. 获取数据时是无锁的,因此并发读的效率很高。
    • 定位过程

      1. 通过Hash算法定位到Segment
      2. 再次通过Hash算法定位到链表头部。
      3. 遍历链表获取节点。
    • 优缺点

      • 优点:最大并发度可达16,并发读的效率很高。
      • 缺点:并发度受Segment数组长度的限制,遍历链表效率低下。
  • JDK1.8中的ConcurrentHashMap

    image-20240519000808783
    • 数据结构

      1. 取消了Segment分段锁的设计,转而使用Node数组+链表/红黑树的数据结构。
      2. 引入了与HashMap类似的Node节点,用于存储键值对数据。
      3. 当链表长度超过一定阈值时,链表将转换为红黑树,提高查找效率。
    • 并发控制

      1. 放弃了Segment分段锁的设计,改为使用Node的synchronized锁和CAS操作来控制并发。
      2. 在put操作时,如果没有发生冲突将直接使用CAS插入。只有在发生冲突时才需要获取锁,而且synchronized只锁定当前链表或红黑树的首节点。
      3. 获取Node的值时不需要加锁,所以读操作的性能很高。
    • 扩容机制

      1. 采用多线程协作的方式进行扩容,不再需要对整个HashMap加锁。
      2. 利用一个Forwarding节点来标记正在转移的节点,并且多个线程可以同时转移。
      3. 扩容时会保留原有数据,而不是重新计算哈希,这样可以减少扩容时的开销。
image-20240519001127829
导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)

Java并发编程三大特征

  • 原子性

    一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行。

    原子性
    • 解决方法:加锁

      1. synchronize同步加锁

        synchronize同步加锁
      2. JUC里面的Lock加锁

  • 可见性

    内存可见性:让一个线程对共享变量的修改对另一个线程可见。

    内存可见性
  • 有序性

    指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化。它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

    指令重排
image-20240519002101434

线程池


为什么要使用线程池?

  • 每次创建线程的时候 ,都会占用一定内存空间。如果是无限的去创建线程,则有可能会浪费内存。严重的情况下可能导致内存溢出。

  • CPU资源毕竟有限,同一时刻只能处理一个线程。如果有大量的请求来了,我们创建了大量的线程,很多线程又没有CPU的执行权,那这些线程就得出去等待,会造成大量线程之间的切换。也会导致性能变慢。


说一下线程池的核心参数(线程池的执行原理知道吗)
  • 主要有7个核心参数

    1
    2
    3
    4
    5
    6
    7
    public ThreadPoolExecutor(int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler)
    参数 说明 描述
    corePoolSize 核心线程数 线程池中始终保持存活的线程数量。
    maximumPoolSize 最大线程数 线程池中允许存在的最大线程数。最大线程数目=(核心线程+救急线程的最大数目)
    keepAliveTime 线程空闲时间 当线程池中的线程数大于核心线程数时,多余的空闲救急线程的存活时间。
    unit 时间单位 用于指定 keepAliveTime 的时间单位。
    workQueue 任务队列(阻塞) 用于存放等待执行的任务的队列。如果没有空闲核心线程,那么就会将任务放入此队列;如果队列满,则会创建救急线程。
    threadFactory 线程工厂 用于创建新线程的工厂。
    handler 拒绝策略 当任务队列已满且线程池中的线程数达到最大线程数时,新提交的任务将由拒绝策略处理。
    image-20240519004518597
  • 执行原理

    image-20240519003620549
    拒绝策略 描述
    AbortPolicy(默认) 抛出 RejectedExecutionException 异常,表示拒绝执行新任务。
    CallerRunsPolicy 在调用者线程中执行任务,即让提交任务的线程自己去执行该任务,如果提交线程本身就是一个线程池线程,那么它会直接执行该任务。
    DiscardPolicy 直接丢弃该任务,不做任何处理。
    DiscardOldestPolicy 丢弃任务队列中最老的一个任务,将新提交的任务加入队列。

线程池中有哪些常见的阻塞队列

workQueue(任务队列): 当没有空闲核心线程时,新来的任务会加入到此队列排队。当队列满时,会根据线程池的策略来处理新任务,通常是创建新的救急线程来执行任务。

  1. ArrayBlockingQueue(数组阻塞队列): 基于数组结构的有界阻塞队列,采用先进先出(FIFO)的顺序进行操作。

  2. LinkedBlockingQueue(链表阻塞队列): 基于链表结构的有界阻塞队列,同样采用先进先出(FIFO)的顺序进行操作。

  3. DelayedWorkQueue(延迟工作队列): 是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的任务。(可以给任务设置时间,然后按照时间出队列)

  4. SynchronousQueue(同步队列): 是一个不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

  • LinkedBlockingQueueArrayBlockingQueue的区别

    特性 LinkedBlockingQueue ArrayBlockingQueue
    是否有界 默认无界,支持有界 强制有界
    底层数据结构 链表 数组
    节点创建 懒惰的,创建节点的时候添加数据 提前初始化Node数组
    入队操作 入队会生成新 Node Node需要是提前创建好的
    锁机制 两把锁(头尾)。出入队的时候分别在合适的位置加锁 一把锁。出入队都对整个队列加锁
    image-20240519005524727

如何确定核心线程数
  • IO密集型任务

    一般来说:文件读写、DB读写、网络请求等

    我们一般会设置核心线程数为2N+1,N为当前CPU的核数

  • CPU密集型任务

    一般来说:计算型代码、Bitmap转换、Gson转换等

    我们一般会设置核心线程数为N+1,N为当前CPU的核数

因为IO密集型一般不会大量占用CPU,而CPU密集型会,所以在CPU密集型任务上我们就不要设置那么多线程数,以减少其切换。

image-20240519010206157
线程池的种类有哪些
  • 在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种

    1. 创建使用固定线程数的线程池

      image-20240519010344415
      • 核心线程数与最大线程数一样,没有救急线程

      • 阻塞队列是LinkedBlockingQueue,最大容量为lnteger.MAX_VALUE

      适用于任务量已知,相对耗时的任务

    2. 单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行

      image-20240519010634339
      • 核心线程数和最大线程数都是1

      • 阻塞队列是LinkedBlockingQueue,最大容量为lnteger.MAX_VALUE

      适用于按照顺序执行的任务

    3. 可缓存线程池

      image-20240519010850440
      • 核心线程数为0

      • 最大线程数是Integer.MAX_VALUE

      • 阻塞队列为SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

      适合任务数比较密集,但每个任务执行时间较短的情况

    4. 提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。

      image-20240519011213853
image-20240519011239897
为什么不建议用Executors创建线程池
  • 参考《阿里开发手册(嵩山版)》

    image-20240519011408501

使用场景


线程池使用场景(CountDownLatch、Future)(你们项自哪里用到了多线程)
  • CountDownLatch

    CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)

    • 其中构造参数用来初始化等待计数值

    • await()用来等待计数归零

    • countDown()用来让计数减一

    image-20240519012010469 image-20240519012139527
  • 多线程使用场景一(es数据批量导入)

    在我们上线之前,需要将数据库中的数据一次性同步到ES索引库中。但是数据量大约有1000万条,一次性读取数据肯定不可行(可能会引发OOM异常)。因此,我考虑使用线程池的方式进行导入,利用CountDownLatch来控制线程的执行,避免一次性加载过多,防止内存溢出。

    image-20240519012509886
  • 多线程使用场景二(数据汇总)

    在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?

    在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能

    image-20240519013129758 image-20240519013539123
  • 多线程使用场景三(异步调用)

    在用户搜索的时候,可以将搜索记录使用异步的方法保存

    image-20240519014010417
image-20240519014028965
如何控制某个方法允许并发访问线程的数量

Semaphore信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量

使用场景:通常用于那些资源有明确访问数量限制的场景,常用于限流。

  • Semaphore使用步骤

    • 创建Semaphore对象,可以给一个容量

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      // 1. 创建 semaphore 对象
      Semaphore semaphore = new Semaphore(3);

      // 2. 10 个线程同时运行
      for (int i = 0; i < 10; i++) {
      new Thread(() -> {
      try {
      // 3. 获取许可
      semaphore.acquire();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      try {
      System.out.println("running...");
      Thread.sleep(1000);
      } catch (InterruptedException e) {
      e.printStackTrace();
      } finally {
      // 4. 释放许可
      semaphore.release();
      System.out.println("end...");
      }
      }).start();
      }
    • semaphore.acquire():请求一个信号量,这时候的信号量个数-1(—旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)

    • semaphore.release():释放一个信号量,此时信号量个数+1

image-20240519014750773
谈谈你对ThreadLocal的理解
  • 概述

    ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享。

    • 案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的Connection上进行数据库的操作,避免A线程关闭了B线程的连接。

      image-20240519015201494
  • ThreadLocal基本使用

    • set(value)设置值
    • get()获取值
    • remove()清除值
    image-20240519015301782
  • ThreadLocal的实现原理&源码解析

    ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离。

    在ThreadLocal中有一个ThreadLocalMap,这里面存放的是线程真正的数据

    image-20240519015553968
    • set()方法

      image-20240519015841365

      在第一次set时,将会创建Map,默认长度为16。从这能看出来ThreadLocal能存多个值,只要创建多个ThreadLocal对象就行了。

    • get()/remove()方法

      根据线程对象,查找对应的map,然后再在map中查找对应的Entry,进行查找或置空

      image-20240519020159973
  • ThreadLocal - 内存泄露问题

    Java对象中的四种引用类型包括:强引用、软引用、弱引用、虚引用

    • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收。

      1
      User user = new User();
    • 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。

      1
      2
      User user = new User();
      WeakReference weakReference = new WeakReference(user);

    每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本。

    image-20240519020935033
image-20240519021046016