Skip to content

面试煎熬成蛋_Java并发

这篇文章,一方面是线程的基础内容,一方面是锁机制,包括JMM、volatile、JUC、AQS 等;

还有一方面是线程池相关的内容,会部分结合一下常见的面试题作为复习的一部分。

对于线程相关的一些复习,主要参考是: https://www.bilibili.com/video/BV1yT411H7YK

下面是该面试视频关于Java 并发的一些笔记,并结合 Gpt4 和 谷歌搜索内容进行补充完善。

线程基础内容

  • 1、线程和进程的区别?
  • 2、并行和并发的区别?
  • 3、讲一下线程创建的方式?Runable 和 Callable 的区别;strat 和 run 的区别。
  • 4、线程包括哪些状态,状态之间是如何变化的
  • 5、线程按顺序执行 join、notify 和 notifyall 的区别。
  • 6、Java 中 wait 和 sleep 的区别。
  • 7、如何停止一个正在运行的线程。
  • 8、什么是线程死锁,如何预防和避免线程死锁。

线程和进程的区别?

  • 进程:资源分配和调度的最小单位,一个进程中可以包含一个或者多个线程
  • 线程:运算调度的最小单位,是进程的一部分,共享进程的资源,上下文切换成本较低。

并行和并发的区别?

  • 并发:同一个时间段同时进行的操作
  • 并行:同一个时刻中同时进行的操作

最关键的点是:是否是 同时 执行

同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

线程创建的方式?

在Java中,有三种常见的线程创建方式:使用Thread类、实现Runnable接口和实现Callable接口。

  • 继承Thread:创建一个新的类继承Thread类,并重写run()方法。
  • 实现Runnable接口:创建一个实现Runnable接口的类,然后将它实例化传递给Thread对象的构造函数。
  • 实现Callable接口:创建一个实现Callable接口的类,使用FutureTask包装器来创建Thread对象。Callable可以返回结果和抛出被检查的异常。

使用示例:

public class MyThread extends Thread {
    public void run() {
        // 任务代码
    }
}
MyThread t = new MyThread();
t.start(); // 启动新线程

public class MyRunnable implements Runnable {
    public void run() {
        // 任务代码
    }
}
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程

public class MyCallable implements Callable<Integer> {
    public Integer call() throws Exception {
        // 任务代码
        return 123;
    }
}
FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
Thread t = new Thread(futureTask);
t.start(); // 启动新线程

Runable 和 Callable 的区别;strat 和 run 的区别。

使用 Runable 接口和实现 Callable 最大的区别是后者能返回一个结果,还有一个区别是 call() 方法可以抛出异常,而 Runable 接口下的 run() 方法不可以抛出异常。

  • start 方法是启动一个新进程,新线程会执行相应对象的run()方法。
  • run 方法只是一个普通的方法,如果调用 run 方法,也只是在当前线程下调用执行这个方法。

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

在我们的 Thread 类下,有一个枚举类,里面的状态有:新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、时间等待(TIMED_WALTING)、终止(TERMINATED)

分析流程图

image.png

.线程状态之间是如何变化的

状态切换的一些情况:

  • 创建线程对象是新建状态
  • 调用了stat()方法转变为可执行状态
  • 线程获取到了CPU的执行权,执行结束是终止状态
  • 在可执行状态的过程中,如果没有获取 CPU 的执行权,可能会切换其他状态
    • 如果没有获取锁(synchronized 或Iock)进入阻塞状态,获得锁再切换为可执行状态
    • 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可切换为可执行状态
    • 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为可执行状态

你觉得阻塞线程和等待线程的区别

再讲一下关于线程状态相关的内容

  • 新建
  • 就绪
    • start() 从 新建变为就绪
  • 运行
    • 线程获取到了CPU执行权
  • 计时等待
    • wait(50) 到时间后会进入到就绪
  • 等待
    • wait() 需要唤醒操作
  • 阻塞
    • 如果没有获取锁(synchronized 或Iock)进入阻塞状态,获得锁再切换为可执行状态
  • 死亡
    • 线程死亡

新建T1、T2、T3三个线程,如何保证它们按顺序执行?

要保证三个线程T1、T2、T3按顺序执行,即先执行T1,T1执行完毕后执行T2,T2执行完毕后执行T3,可以使用多种方法来实现。

这里讲一下常见的两种方法:

方法1:使用join()方法

join()方法可以使当前线程等待另一个线程完成后再继续执行。

public class ThreadOrder {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> System.out.println("T1 is running"));
        Thread t2 = new Thread(() -> System.out.println("T2 is running"));
        Thread t3 = new Thread(() -> System.out.println("T3 is running"));
        
        try {
            t1.start();
            t1.join(); // 等待t1执行完毕
            
            t2.start();
            t2.join(); // 等待t2执行完毕
            
            t3.start();
            t3.join(); // 等待t3执行完毕
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

方法2:使用wait()notify()/notifyAll()方法

通过对象监视器的wait()notify()方法来控制线程的执行顺序。这种方法相对复杂,需要确保wait()notify()调用在同步块中。

public class ThreadOrder {
    public static void main(String[] args) {
        final Object lock = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("T1 is running");
                lock.notify(); // 唤醒等待lock对象的一个线程
            }
        });
        
        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait(); // 使当前线程等待直到lock对象被唤醒
                    System.out.println("T2 is running");
                    lock.notify(); // 唤醒等待lock对象的一个线程
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        Thread t3 = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait(); // 使当前线程等待直到lock对象被唤醒
                    System.out.println("T3 is running");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        try {
            t2.start();
            t3.start();
            Thread.sleep(100); // 确保t2和t3启动并进入wait状态
            t1.start();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

notify 和 notifyall 的区别

  • notifyAll:唤醒所有wait的线程
  • notify:只随机唤醒一个wait线程

Java 中 wait 和 sleep 的区别。

  • 共同点
    • wait(),wait(long)和sleep(long)的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态
  • 不同点
    • 1.方法归属不同
      • sleep(long)是Thread的静态方法
      • 而wait(),wait(long)都是Object的成员方法,每个对象都有
    • 2、醒来时机不同
      • 执行sleep(long)和wait(long)的线程都会在等待相应毫秒后醒来
      • wait(long)和wait0还可以被notify唤醒,wait()如果不唤醒就一直等下去
      • 它们都可以被打断唤醒
    • 3.锁特性不同(重点)
      • wait方法的调用必须先获取wait对象的锁,而sleep则无此限制
      • wait方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃cpu,但你们还可以用)
      • 而sleep如果在synchronized代码块中执行,并不会释放对象锁(我放弃cpu,你们也用不了)
class WaitSleepExample {
    private static final Object LOCK = new Object();

    public static void main(String[] args) {
        // 创建一个线程执行等待操作
        Thread waitThread = new Thread(() -> {
            synchronized (LOCK) { // 必须在同步块内调用wait,这表示必须持有对象的锁
                try {
                    System.out.println("Wait Thread: Holding lock, now wait");
                    LOCK.wait(); // 调用wait方法后,当前线程会释放LOCK对象上的锁,并进入等待状态
                    System.out.println("Wait Thread: Exited wait");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 创建一个线程执行睡眠操作
        Thread sleepThread = new Thread(() -> {
            synchronized (LOCK) { // 进入同步块,持有LOCK对象的锁
                try {
                    System.out.println("Sleep Thread: Holding lock, now sleep");
                    Thread.sleep(1000); // 调用sleep方法,但不会释放LOCK对象上的锁
                    System.out.println("Sleep Thread: Exited sleep");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 创建一个线程执行通知操作
        Thread notifyThread = new Thread(() -> {
            synchronized (LOCK) { // 进入同步块,持有LOCK对象的锁
                // 发送通知,告诉等待在LOCK对象上的线程可以继续执行
                LOCK.notifyAll();
                System.out.println("Notify Thread: Sent notification");
            }
        });

        waitThread.start(); // 启动等待线程
        try {
            Thread.sleep(500); // 确保waitThread先执行
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        sleepThread.start(); // 启动睡眠线程
        try {
            Thread.sleep(500); // 确保sleepThread有机会执行
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        notifyThread.start(); // 启动通知线程
    }
}
  • 关于锁的不同处理
    • wait()方法:调用wait()时,线程必须持有对象的锁(这是通过在synchronized块内调用wait()来保证的)。一旦wait()被调用,线程会释放这个对象上的锁,允许其他线程获取这个锁。当其他线程在这个对象上调用notify()notifyAll()时,等待的线程才有机会重新获取锁并继续执行。
    • sleep()方法:与wait()不同,调用sleep()时线程不会释放任何持有的锁。即使是在synchronized块内调用sleep(),当前线程仍然会保持对锁的持有,直到sleep()完成。这意味着,如果一个线程在持有某个对象锁的同时调用了sleep(),其他想要访问这个同步块的线程会被阻塞,直到睡眠线程醒来并退出同步块,释放锁。

→ 使用 sleep 的时候同步块代码线程会被阻塞。

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

有三种方式可以停止线程

  • 使用退出标志,使线程正常退出,也就是当 run 方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程
    • 打断阻塞的线程(sleep,wait,join) 的线程,线程会抛出 InterruptedException 异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

什么是线程死锁,如何预防和避免线程死锁。

线程死锁:两个或多个线程在执行过程中因争夺资源而造成的一种相互等待的现象,导致它们都无法继续执行。

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件;
  • 持有并等待条件;
  • 不可剥夺条件;
  • 环路等待条件

预防和避免方法:最常用的方法就是使用资源有序分配法来破坏环路等待条件。

使用超时尝试获取锁: 在尝试获取锁时使用超时机制,使得线程在等待超过一定时间后自动放弃,从而避免永久等待。

参考: https://www.xiaolincoding.com/os/4_process/deadlock.html

锁机制

  • 1、synchronized 关键字的底层原理。
    • 基础回答
    • 进阶回答
  • 2、谈谈 JMM (Java 内存模型)
  • 3、你谈谈对 CAS 的理解
  • 4、请谈谈你对 volatile 的理解(可见性,禁止指令重排序)
  • 5、什么是 AQS
  • 6、ReentantLock 的实现原理
  • 7、synchronized 和 Lock 有什么区别
  • 8、死锁产生的条件以及死锁排查方案
  • 9、聊一下 ConcurrentHashMap
  • 10、导致并发编程出现问题的根本原因是什么

原子性

image.png

synchronized 关键字的底层原理

建议观看视频: https://www.bilibili.com/list/watchlater?oid=485022417&bvid=BV1yT411H7YK&spm_id_from=333.1245.top_right_bar_window_view_later.content.click&p=95

synchronized:对象锁

作用:采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程再想获取这个对象锁时就会阻塞

底层原理:

Java对象头和monitorsynchronized实现同步的基础。通过 monitor 实现了对于对象的重量级锁操作,而Java 对象头是锁升级机制的关键。

讲一下 Monitor

在 互斥锁 synchronized 加锁的过程中,重量级锁的实现是通过 monitor 来实现的。(记住这一句)

Monito实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

monitor 是 jvm 级别的对象(C++实现),线程获得锁需要使用对象(锁)关联monitor

Monitor :

Monitor 被翻译为监视器,是由 jvm 提供,c ++ 语言实现

Monitor 结构

  • Owner:存储当前获取锁的线程的,只能有一个线程可以获取
  • EntryList:关联没有抢到锁的线程,处于 Blocked 状态的线程
  • WaitSet:关联调用了 wait 方法的线程,处于 Waiting 状态的线程

image.png

synchronized 方法使用:

在我们的测试类,进行反编译后可以看到会有 monitor 对锁相关的操作

image.png

讲一下 Java 对象头,以及和 synchronized 的关联

在 JDK1.6 引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

而偏向锁和轻量级锁的使用是跟 Java 对象头的结构设计有关。

对象的内存结构

image.png

MardWord

在对象头中的 MarkWord 中,其结构设计:

image.png

重量级锁

重量级锁(可以看到上面的 ptr_to_heavyweight_monitor ,重量级锁状态的时候会通过这个指向监视器 Monitor )

  • 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mard Word 中就被设置指向 Monitor 对象的指针。

image.png

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

轻量级锁

在轻量级锁状态下,锁记录会和 Object 的对象头中的 MarkWord 进行 CAS 交互

每进来一个线程,都会进行一次 CAS 操作

image.png

轻量级锁

如果一个锁是轻量级竞争,即被锁定的代码块快速执行完毕且没有其他线程竞争该锁,那么可以避免重量级锁的开销,从而提高性能。(交叉执行)

加锁流程

  • 1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
    • 锁标记:当代码进入同步块的时候,如果同步对象锁状态为无锁状态(即,该对象头的Mark Word未被锁定),JVM首先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间。
  • 2.通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
    • 尝试加锁:JVM会尝试使用CAS(Compare-And-Swap)操作将对象头的Mark Word更新为指向锁记录的指针。此时,锁记录也会指回对象头的Mark Word,这样就建立了一种双向关系。
  • 3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
  • 4.如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
    • 锁升级:如果这个CAS操作成功,当前线程就持有了这个对象的轻量级锁。如果CAS操作失败,表示有其他线程竞争这个锁。JVM会检查这个锁是否已经被当前线程持有:
      • 如果是,线程可以直接进入同步块,实现了锁的重入。
      • 如果不是,表示存在锁竞争。JVM首先会尝试进行自旋等待(spin-waiting),以期待很快获取锁。如果自旋失败并且竞争持续存在,轻量级锁就会升级为重量级锁。

解锁过程

  • 1.遍历线程栈,找到所有obj字段等于当前锁对象的 Lock Record。
  • 2.如果 Lock Record 的 Mark Word 为 null ,代表这是一次重入,将obj设置为null后continue。
  • 3.如果Lock Record 的Mark Word不为 null, 则利用CAS指令将对象头的 mark word 恢复成为无锁状态。如果失败则膨胀为重量级锁。

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。

Java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。

image.png

锁升级

Monito 实现的锁属于重量级锁,你了解过锁升级吗 ?

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

描述
重量级锁底层使用的 Monitor 实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是 CAS 操作,保证原子性
偏向锁一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个 CAS 操作,之后该线程再获取锁,只需要判断 mark word中是否是自己的线程id即可,而不是开销相对较大的 CAS 命令

一旦锁发生了竞争,都会升级为重量级锁。

聊一下 synchronized

synchronized 是Java的内置锁,属于悲观锁,悲观锁就是每次读写的 时候都会加锁,想要⽤必须等待前⾯的线程解锁。

  • 在JDK 6之前是基于Monitor机制实现的,依赖于底层互斥原语 Mutex,属于重量级锁,性能较低
  • 在JDK 6之后迫于JUC包的压⼒,对synchronzied进⾏了优化,引 ⼊了偏向锁、轻量级锁等机制,
    • synchronized为了避免直接从⽤ 户态切换到内核态park线程,所以在单⼀线程的情况下默认开启 偏向锁,
    • 在线程交替执⾏的时候会使⽤轻量级锁,偏向锁和轻量 级锁操作都是基于⽤户态的对象MarkWord,也就表示这两种情况 下加解锁不需要切换到内核态,性能⼏乎⽆损耗,
    • 如果竞争相对 激烈,轻量级锁优先采⽤⾃适应⾃旋来避免park,
    • 如果实在不 ⾏,才会启动重量级锁,park让Mutex互斥量来进⾏,如今性能基 本与JUC持平

synchronized的⽤法

  • 对类对象加锁
    • synchronized(A.class)
    • synchronized static a()
  • 对类实例对象加锁
    • synchronized(this)
    • synchronized(obj)
    • synchornized a()

synchonirzd如何保证并发的三⼤特性的

  • 原⼦性
    • 原⼦性是通过加锁和释放锁保证的
  • 有序性、可⻅性
    • 通过在加锁和解锁过程中使⽤内存屏障来确保共享变量的变化 对所有线程都是可⻅的以及防⽌指令重排序
      • 加锁
        • load屏障 + acquire屏障
      • 解锁
        • store屏障 + release屏障

从字节码层⾯看synchronized

  • 同步代码块
    • 通过monitorenter和两个monitorexit实现的,对于第⼀个 monitorexit处理正常返回,第⼆个monitorexit处理异常情况 ⾃动释放锁,所以synchronized不像Lock那样需要⾃⼰释放 锁,不过也可以通过Unsafe类去⼿动控制synchronized,但是 不推荐
    • synchronized的可重⼊的实现
      • synchronized会维护⼀个计数器,当monitorenter时计数器 +1,monitorexit时计数器 -1
  • 同步⽅法
    • 通过 acc_synchronized 访问标志实现

解释一下上面的 synchronized的⽤法

类对象加锁

public class MyClass {
    public void someMethod() {
        synchronized(MyClass.class) {
            // 代码块,对MyClass类对象加锁
        }
    }
}

通过静态方法

public class MyClass {
    public synchronized static void someStaticMethod() {
        // 静态方法体,对MyClass类对象加锁
    }
}

对类实例对象加锁

通过代码块(使用this):

public class MyClass {
    public void instanceMethod1() {
        synchronized(this) {
            // 代码块,对当前实例对象this加锁
        }
    }
}

通过代码块(使用特定对象):

public class MyClass {
    private final Object lock = new Object();
    
    public void instanceMethod2() {
        synchronized(lock) {
            // 代码块,对特定对象lock加锁
        }
    }
}

通过实例方法

public class MyClass {
    public synchronized void instanceMethod3() {
        // 实例方法体,对当前实例对象this加锁
    }
}

使用建议:尽量避免将synchronized用在方法声明上,特别是在高度竞争的环境下,可以通过减小锁的粒度来提高效率,例如,使用同步代码块。


对于内存屏障的解释:

内存屏障(Memory Barrier)是一种同步机制,用于控制指令的执行顺序和确保内存操作的可见性。它是并发编程中解决CPU指令重排序和内存可见性问题的关键技术。内存屏障可以确保在屏障之前的所有操作在屏障之后的操作开始前完成。在多核处理器系统中,这对于维护数据的一致性和正确性至关重要。

内存屏障主要有以下几种类型:

  • Load屏障(Load Barrier)
    • 目的:确保Load屏障之前的所有读操作在屏障之后的读操作之前完成。
    • 使用场景:主要用于确保对共享变量的修改对当前线程立即可见。它防止了后续的读操作被重排序到屏障之前。
  • Store屏障(Store Barrier)
    • 目的:确保Store屏障之前的所有写操作在屏障之后的写操作之前完成。
    • 使用场景:用于确保对共享变量的修改对其他线程立即可见。它阻止了之前的写操作被重排序到屏障之后。
  • Acquire屏障(Acquire Barrier)
    • 目的:防止屏障之后的读写操作被重排序到屏障之前。
    • 使用场景:通常与锁获取操作相关联。它确保获取锁之后的操作不会与之前的操作发生重排序。
  • Release屏障(Release Barrier)
    • 目的:防止屏障之前的读写操作被重排序到屏障之后。
    • 使用场景:与锁释放操作相关联。它确保释放锁之前的操作不会与之后的操作发生重排序。

synchronized中的应用

当进入synchronized块时,会插入一个Acquire屏障,它确保进入synchronized块后的所有读写操作不会被重排序到屏障之前。这保证了进入同步块时,对共享变量的所有修改都对当前线程可见。

当退出synchronized块时,会插入一个Release屏障,它确保退出synchronized块前的所有读写操作不会被重排序到屏障之后。这确保了退出同步块时,对共享变量的修改对其他线程立即可见。

通过使用这些内存屏障,synchronized能够为Java程序提供原子性、有序性和可见性保证。原子性是通过加锁和释放锁来实现的,而有序性和可见性则是通过在加锁和解锁过程中使用内存屏障来确保的。


synchronized的锁变化

  • ⾸先我们先判断有没有禁⽤偏向锁或者不满⾜延迟偏向条件,如果没 有禁⽤偏向锁以及满⾜延迟偏向条件,那么就开始延迟偏向4s,然后 创建锁对象,对于此时处于匿名偏向状态,当在同步代码块中时,会 通过CAS将当前线程ID设置到锁对象的MarkWord中去
    • 当处于偏向锁时,调⽤hashCode⽅法会撤销为⽆锁状态,因为偏 向锁没有存放hashcode的位置,⽆锁状态可以存放
    • 当处于偏向锁时,在同步代码块中调⽤notify⽅法会撤销为轻量级 锁
    • 当处于偏向锁时,在同步代码块中调⽤hashCode⽅法以及wait⽅ 法会撤销为重量级锁
    • 当处于偏向锁时,偏向锁可能会重偏向为匿名偏向状态
  • 当我们禁⽤偏向锁或者不满⾜延迟偏向条件时,先创建锁状态此时为 ⽆锁状态
    • 当发⽣轻微竞争时,⽆锁会升级为轻量级锁,CAS修改锁对象中 的MarkWord并且拷⻉⼀份MarkWord到栈中的锁记录⾥
    • 当处于轻量级锁时,如果发⽣激烈竞争会膨胀为重量级锁,创建 Monitor对象,CAS修改MarkWord 当处于⽆锁状态时,如果碰到激烈竞争,会创建Monitor对象,CAS 修改MarkWord
    • 重量级锁可以解锁到⽆锁状态
    • 轻量级锁也可以解锁到⽆锁状态

synchronized 锁粗化、锁消除

  • 锁粗化
    • ⽐如锁对象出现在循环体中,反复加锁释放锁,即使没有出现线 程竞争也会导致性能下降,当JVM检测到这种操作,会将锁的范 围扩⼤到循环体外
      • 锁粗化是指将多个连续的锁扩展(或合并)为一个范围更大的锁,以减少锁操作的开销。正常情况下,如果在一系列的操作中反复对同一个对象加锁和解锁,即使没有线程竞争,频繁的锁操作也会消耗大量资源。
      • 如果这个循环执行的次数非常多,那么加锁解锁的开销就会变得很大。在这种情况下,JVM会采取锁粗化策略,将整个循环块外围用一个较大范围的锁替代,从而减少锁的开销。
  • 锁消除
    • ⽐如在单线程情况加锁,不存在竞争,JVM会清除锁
      • 锁消除是指JVM在JIT编译阶段通过运行时代码分析,去除那些不可能存在共享数据竞争的锁。这是一种优化技术,目的是减少不必要的锁操作,提高程序性能。

锁清除: 考虑以下代码:

public void method() {
    Object lock = new Object();
    synchronized(lock) {
        // 操作非共享资源
    }
}

在这个示例中,lock是一个方法内的局部变量,它不可能被其他线程访问。

因此,对这个对象的加锁实际上是不必要的。JVM的锁消除机制可以检测到这种情况,并完全去除这个锁,减少运行时的开销。

谈一下 JMM

谈谈JMM(Java内存模型)

  • JMM(ava Memory Model)Java内存模型,定义了共享内存多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
  • JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
  • 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存

谈一下 volatile

volatile 的两个作用:

  • 可见性

    • 用volatile修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
      • JIT(即时编译器)
  • 禁止指令重排序

    • 用volatile修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
      • 写变量让volatile修饰的变量的在代码最后位置
      • 读变量让volatile修饰的变量的在代码最开始位置
  • 1、了解一下在 Java 代码多线程的情况下会出现指令重排序的情况

  • 2、通过 volatile 修饰变量能够进行阻止指令重排序的操作

重排序的情况

image.png

volatile使用技巧:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

image.png

image.png

image.png

CAS 🚩

CAS 不管是在 AQS 框架中,还是在上面提到的 synchronized 都使用到了;

其中的核心思想是 → 比较再交互

如果工作区域的最初值和主内存的值比较不相等,则自旋等待。(自旋需要确认一下 🚩)

CAS 方法是 unsafe 修饰的,调用是操作系统的接口。

image.png

乐观锁和悲观锁的区别

乐观锁:乐观的看待并发修改问题,其他线程不会修改共享变量,如果改了,就自旋等待。

悲观锁:悲观的认为别人的线程都不是好人,我获取这把锁后,你们都不能访问了。

你有用过 Java 里面的锁吗

synchronized 和 ReentantLock 的用法讲一下

实现层面:synchronized 和 ReentantLock 有什么区别

synchronized 修饰普通方法和修饰静态方法有什么区别

AQS

  • AQS
    • 悲观锁,手动开启和关闭,锁和一些同步组件的基础框架
  • 基本工作机制
    • 维护了一个 int 同步状态变量 state (默认是 0 无锁)和 一个 FIFO 先进先出的双向队列
    • 如果某个线程成功获取了同步状态(或锁),则该线程可以执行相应的临界区代码。如果获取同步状态失败,该线程将会被加入到一个队列中,并在适当的时候被重新唤醒(比如,当前持有锁的线程释放锁之后)。
  • 原子性
    • 通过 cas 操作对于 state 状态的修改
  • 公平锁和非公平锁(两种实现方式)
    • 非公平锁
      • 新的线程与队列中的线程共同来抢资源,是非公平锁
    • 公平锁
      • 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁

全称是AbstractQueuedSynchronizer,即抽象队列同步器。

它是构建锁或者其他同步组件的基础框架

AQS与Synchronizedl的区别

image.png

image.png

image.png

image.png

ReentrantLock

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

image.png

image.png

总结

image.png

synchronized 与 Lock 有什么区别

synchronized和Lock有什么区别?

  • 语法层面
    • synchronized是关键字,源码在jvm中,用c++语言实现
    • Lock是接口,源码由jdk提供,用java语言实现
    • 使用synchronized时,退出同步代码块锁会自动释放,而使用Lock时,需要手动调用unlock方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock提供了许多synchronized不具备的功能,例如公平锁、可打断、可超时、多条件变量
    • Lock有适合不同场景的实现,如ReentrantLock,ReentrantReadWriteLock(读写锁)
  • 性能层面
    • 在没有竞争时,synchronized做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,L0ck的实现通常会提供更好的性能

image.png

死锁产生的条件是什么

  • 死锁:线程互相等待对方的锁释放
  • 诊断:
    • 1、可视化工具 jconsole、VisualVM
    • 2、JDK 自带的:jps 和 jstack

死锁:一个线程需要同时获取多把锁,这时就容易发生死锁

此时程序并没有结束,这种现象就是死锁现象.线程t1 持有A的锁等待获取B锁,线程t2持有B的锁等待获取A 的锁。

如何进行死锁诊断?

  • 当程序出现了死锁现象,我们可以使用jdk自带的工具: jps和jstack
    • jps:输出 JVM中运行的进程状态信息
    • jstack:查看java进程内线程的堆栈信息

2.如何进行死锁诊断?

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

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

  • jstack:查看java进程内线程的堆栈信息,查看日志,检查是否有死锁; 如果有死锁现象,需要查看具体代码分析后,可修复

  • 可视化工具 jconsole、VisGalVM也可以检查死锁问题

image.png

image.png

导致并发程序出现问题的根本原因是什么

Java并发编程三大特性

  • 原子性
    • 一个线程在 CPU 中操作不可暂停,也不可中断,要不执行完成,要不不执行
    • 一般操作(加锁)
      • synchronized:同步加锁
      • JUC里面的lock:加锁
  • 可见性
    • 内存可见性:让一个线程对共享变量的修改对另一个线程可见
    • 解决方式
      • synchronized
      • volatile (一般使用)
      • LOCK
  • 有序性
    • 指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
    • 解决方案:volatile

image.png

线程池

image.png

  • 1、说一下线程池的核心参数(线程池的执行原理知道吗)
  • 2、线程池中有哪些常见的阻塞队列
  • 3、如何确认核心线程数
  • 4、线程池的种类有哪些
  • 5、为什么不建议使用 Executor 创建线程池

为什么要使用线程池

  • 1、每次创建线程需要占用一定的内存空间

  • 2、线程的执行需要 CPU 的执行权,CPU 的运算能力是有限的

  • 线程池的核心参数(执行原理)

线程池的核心参数

image.png

  • 核心参数
    • 核心线程数目
    • 最大线程数目
    • 生存时间
    • 时间单位
    • 阻塞队列
    • 线程工厂
    • 拒绝策略
  • 执行原理

image.png

线程池的执行原理知道嘛

  • 执行原理
    • 当向线程池提交一个任务
    • 判断一下核心线程是否已满
      • 是:判断阻塞队列是否已满
      • 否:

image.png

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

workQueue-当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  • 1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
  • 2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
  • 3.DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
  • 4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

image.png

如何确认核心线程数 🚩

image.png

image.png

线程池的种类有哪些 🚩

image.png

  • FixedThreadPool:固定大小的线程池,适用于需要限制线程数量的场景。
  • CachedThreadPool:可缓存的线程池,适用于执行许多短期异步任务的场景。
  • ScheduledThreadPool:支持定时和周期性任务执行的线程池。
  • SingleThreadExecutor:单线程执行器,适用于顺序执行任务的场景。

核心线程数可以为零吗

怎么判断这个线程是可以被回收的

线程池的状态有哪些

image.png

Shutdown 和 shutdownnow 的区别,是通过什么方式关闭

怎么将一个正在运行的线程关闭,什么样的线程是可以被中断的

项目中线程池的使用

直接使用@Autowired注入一个ThreadPoolTaskExecutor实例通常意味着你要使用Spring的默认配置

  • 1、一般会注入一个线程池的配置类的 @Bean

线程池如何监听子线程的异常,并作出处理

使用场景

  • 1、讲述一下你们项目中哪里用到了线程池(异步线程)
  • 2、如何控制某个方法允许并发访问线程的数量
  • 3、谈谈你对 ThreadLocal 的理解

image.png

线程池使用场景

  • CountDownLatch
  • Future
es 数据批量导入

CountDownLatch 的使用: https://www.bilibili.com/list/watchlater?bvid=BV1yT411H7YK&oid=485022417&p=111

image.png

CountDownLatch

image.png

image.png

image.png

CountDownLatch 的部分代码使用示例

image.png

Future (报表汇总也可以用到)

image.png

这里是一种做法是这些流程实际是可以并行的,然后通过 Futrue 获取返回结果,后面再拼接到一起。

image.png

image.png

给一个 Future 和 线程池的使用示例

异步线程

image.png

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

  • Semaphore

image.png

image.png

ThreadLocal

  • ThreadLocal:独立变量副本,避免线程共享可能导致的并发问题

image.png

ThreadLocal 一般是用作独立变量副本(线程的变量副本),避免线程共享变量可能导致的并发问题。

建议先看一下这篇文章: https://cloud.tencent.com/developer/article/1636025

ThreadLocal包含了四个方法:

void set(Object value)设置当前线程的线程局部变量的值。
public Object get()该方法返回当前线程所对应的线程局部变量。
public void remove()将当前线程局部变量的值删除,其目的是为了减少内存使用,加快内存回收。
protected Object initialValue()返回该线程局部变量的初始值,该方法是一个protected的方法,目的是为了让子类覆盖而设计的。

使用到 ThreadLocal 的三个常用场景:

    1. 代替参数的显示传递
    1. 全局存储用户信息
    1. 解决线程安全问题(比如数据库连接的Connection)

慎用的场景:线程池中线程调用使用ThreadLocal、异步程序;同时注意的是使用完ThreadLocal ,最好手动调用 remove() 方法。


关于 ThreadLocal 的数据结构,方法源码,扩容机制等可以看一下这篇文章: https://javaguide.cn/java/concurrent/threadlocal.html

Future

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;

public class FutureExample {

    public static void main(String[] args) {
        // 创建一个固定大小的线程池
        ExecutorService executorService = Executors.newFixedThreadPool(3);

        // 定义一个 Callable 任务
        Callable<String> callableTask = () -> {
            // 模拟耗时操作
            Thread.sleep(2000);
            return "Task's execution result";
        };

        // 提交任务并获取 Future 对象
        Future<String> future = executorService.submit(callableTask);

        try {
            // 其他操作可以在这里进行
            System.out.println("Do something else while task is executing...");

            // 获取任务执行结果(会阻塞直到任务完成)
            String result = future.get();
            System.out.println("Task result: " + result);

        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            // 关闭线程池
            executorService.shutdown();
        }
    }
}

CompletableFuture

CountDownLatch