当前位置: 首页 > news >正文

硅基计划6.0 JavaEE 壹 多线程及核心内容

122370873_p0


文章目录

  • 一、定义
  • 二、多线程
    • 1. 定义
    • 2. 进一步解析Thread类
      • 1. 构造方法
      • 2. 常见属性
      • 3. `start`方法
      • 4. 中断一个线程
      • 5. 线程等待-join
      • 6. 获取当前的线程引用
      • 7. 休眠当前线程-sleep
    • 3. 线程状态
    • 4. 线程安全问题(面试经典)
      • 1. 线程安全问题产生的原因
      • 2. 线程安全问题的普遍解决方案
      • 3. 锁
      • 4. 死锁问题场景
      • 5. 产生死锁的必要条件(面试高频)
      • 6. 避免死锁
      • 7. 内存可见性引起的线程安全问题
    • 5. wait和notify
      • 1. 基本使用
      • 2. wait与sleep区别
    • 6. 单例模式
    • 7. 指令重排序
    • 8. JDBC编程中数据库单例模式
    • 9. 阻塞队列
      • 1. 生产者消费者模型
      • 2. 作用
        • 1.降低资源竞争
        • 2. 解耦合
        • 3. 削峰补枯
      • 3. 弊端
    • 10. Java中的BlockingQueue
    • 11. 模拟实现Java中的BlockingQueue
    • 12. 线程池
      • 1. 线程池类构造方法参数解析(经典面试题)
      • 2. 使用ThreadPoolExecutor快速创建线程池
      • 3. 模拟实现一个线程池
    • 13. 定时器
      • 1. 模拟实现定时器
      • 2. 手动加锁原因


一、定义

  1. 本质上就是为了去实现并发编程,去解决多个进程之间的频繁的创建与销毁中所耗费的时间
    并且本质上,线程就是一个轻量级的进程

  2. 随着线程的增多,我们可以提高进程的执行效率,但是也不可以无限的多,因为这涉及到CPU,而CPU的并发是有限度的

  3. 进程与线程之间的关系就是进程是操作系统分配的基本单位,并且在一个进程中的若干个线程可以共用操作系统的资源,比如共享内存与文件

  4. 对于线程来说,它是操作系统调度的基本单位,每一个线程都是可以进行线程调度的,即CPU调度的“执行流”

  5. 虽然在同一个进程内,多个线程可以共享PCB的内存指针和文件操作符表,但是各个线程之间也有自己的上下文,优先级以及记账信息


什么是PCB,我们可以把PCB比喻成身份证+病历+简历的综合体
上下文就是处理器的某个时间点的状态,保存在CPU的寄存器上
优先级用于表示CPU资源的紧张程度与重要性
记账信息用于记录进程对操作系统资源的使用情况的统计数据


  1. 线程安全问题,说白了就是在同一个进程内,多个线程操作同一个数据的时候,可能会产生冲突,从而导致程序BUG,但凡其中一个线程并未处理好,程序就会直接终止

二、多线程

1. 定义

Java中的类名是Thread。当我们对其进行操作的时候,说白了就是去调用操作系统提供的API,其原生就是使用C语言写的,并且不同操作系统是有一定区别的,因此Java标准库中对其进行了统一封装

class MyThead extends Thread{@Overridepublic void run() {//这里就是线程入口while(true) {System.out.println("hello");}}
}public class Demo1 {public static void main(String[] args) {//我们使用向上转型,这是Java中常见的写法Thread thread = new MyThead();//我们通过start方法去调用操作系统的API,在系统内部创建了线程//此时start方法内部自动调用了run方法thread.start();System.out.println("hello main");}
}

在上述的代码中,我们调用start方法的时候自动调用了run方法,我们把这种函数称作为回调函数

我们只有通过start去调用线程的时候,才涉及到多线程,如果只是通过调用run并不会涉及多线程

并且由于线程是随机调度的,我们可以在打印结果中发现其顺序不一致

我们通过死循环一直打印,会导致占用非常多的CPU资源,因此我们可以加入一个Thread.sleep(毫米数)方法,降低下CPU的资源耗费

class MyThead extends Thread{@Overridepublic void run() {//这里就是线程入口while(true) {System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}public class Demo1 {public static void main(String[] args) throws InterruptedException {//我们使用向上转型,这是Java中常见的写法Thread thread = new MyThead();//我们通过start方法去调用操作系统的API,在系统内部创建了线程//此时start方法内部自动调用了run方法thread.start();while(true) {System.out.println("hello main");Thread.sleep(1000);}}
}

image-20251103193540033

可以看到我们的打印结果是交替进行的,大大减少了CPU的资源调度压力

如果我们想窥见一个进程内部包含哪些线程,我们可以使用Java官方提供的软件jconsole


除了我们上述的那种线程的写法,我们还可以通过实现Runnable接口,重写run方法来实现

class MyRunnable implements Runnable{@Overridepublic void run() {while(true){System.out.println("hello Runnable");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}
}public class Demo2 {public static void main(String[] args) {MyRunnable myRunnable = new MyRunnable();//Runnable自身不可以单独执行,我们需要搭配一个线程载体Thread thread = new Thread(myRunnable);//因此此时我们Thread类无需重写Run方法,这样更加的解耦合thread.start();}
}

什么是耦合
定义是不同的模块之间的联系,如果一个模块的改动会影响另一个模块,我们就说其耦合度高
在之前的MyThread类中,任务和线程之间是强绑定的,我们各个模块之间需要大规模修改代码
而在我们的MyRunnable类中,并不存在相互绑定,因此我们可以方便迁移到其他的载体



我们再来给出其他的几种写法

  1. 继承Thread的匿名内部类
public class Demo3 {public static void main(String[] args) {Thread thread = new Thread(){@Overridepublic void run() {while (true) {System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}};thread.start();}
}
  1. 实现Runnable接口的匿名内部类
public class Demo4 {public static void main(String[] args) {Thread thread = new Thread(new MyRunnable()){@Overridepublic void run() {while(true){System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}}};thread.start();}
}
  1. 继承Thread类并使用lambda表达式
public class Demo5 {public static void main(String[] args) {Thread thread = new Thread(()->{while(true){System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}});}
}

此外还有很多种方法,但是使用lambada表达式是我们最常见的用法

2. 进一步解析Thread类

1. 构造方法

  • Thread()不带参的构造方法
  • Thread(String name)创建线程的时候顺便给线程起名字
  • Thread(Runnable target)使用Runnable创建线程对象
  • Thread(Runnable target,String name)使用Runnable创建对象并命名
  • Thread(TheadGroup group,Ruunable target)分组管理,但后续被线程池代替了

2. 常见属性

  • ID,通过getID()方法可得,是一个线程的身份标识,是自动生成的,类似于MySQL中的主键
  • 名称,通过getName()获得
  • 状态,通过getState()获得
  • 优先级,可以进行getset操作
  • 是否为后台线程,可以通过isDaemon()返回结果
  • 是否存活,通过isAlive()返回结果
  • 是否被中断,通过isInterrupted返回结果

什么是后台线程,即不会阻止整个线程结束
反之前台线程,会组织整个线程的结束
后台线程相当于一个守护线程,只有当前台的所有线程都结束了,后台线程才会结束
因此我们可以在线程start执行前,把其设为后台线程thread.setDaemon(true)


3. start方法

其内部调用的是操作系统的API,系统内部创建了一个线程,同时操作系统会创建一个PCB,设定id值,再加入到原来的线程中,使其进一步扩大

□-->□  启动该线程后,加入到原先的线程队列中 □-->□-->□

并且我们Java还规定,一个Thread类对象与一个操作系统的线程一一对应

4. 中断一个线程

在Java中,一个线程的终止代表的是线程的入口方法执行完毕,然而Java并不提供“强制终止”,原因就是并不晓得每个线程到底执行到了哪里
因此我们要让所有的线程终止,本质上就是要让其入口方法结束


image-20251103202901675

为什么我们把running定义为局部变量会报错,因为Java捕获变量是必须是final或者是事实final修饰的,即使没有被final修饰,因为我们在用户输入的时候对running的值进行了修改,因此不再是事实final

总的来说,因为Java的变量捕获实质上会把running的值拷贝一份,导致内外置不一样,因此此时我们直接禁止对其进行修改
当我们写回我们写成成员变量,此时不再是变量的捕获,而是内部类访问外部类的成员,并且外部类成员可以被内部类成员随时的访问,因为我们的lambada表达式本质上是一个匿名内部类重写对应的方法


好,我们再针对这个lambda表达式的问题,如果我们就是想使用局部变量怎么办呢,诶,我们可以通过一个方法

public static void main(String[] args) {Thread t = new Thread(()->{//我们通过currentThread方法获取当前的线程,再用isInterrupted判断当前线程是否被中断while(Thread.currentThread().isInterrupted()){System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}});t.start();//我们想通过用户输入去终止这个线程Scanner scanner = new Scanner(System.in);int input = scanner.nextInt();if(input == 1){//我们通过用户手动输入的方式中断线程t.isInterrupted();}}

刚刚那个代码中,你会说我们为什么不把循环条件改成while(t.isInterrupted)呢?
因为在lambda表达式中,我们是先对Thread()中参数进行求值,再执行Thread()的构造方法,再把这个构造结果赋予t
因此直接使用t是未被初始化的,你可以想想匿名内部类的知识

我们刚刚的代码是一旦线程结束就抛出异常,如果我们不抛出异常,通过e.printStackTrace()打印异常日志的话,线程进行到这里就不会结束
但是你还记得我们通过用户输入把isInterrupted()置为false了啊,应该while循环进不来才对呀,但是为什么线程还是没有结束啊
但是这里有个容易被忽视的地方,一旦我们通过sleep()唤醒线程后,并且通过异常的方式返回时,会自动把当前线程的中断标志位isInterrupted重新设置为false
因此我们可以手动使用break跳出循环,以便在catch块中去释放资源或者是中断和继续线程或进行其他工作等等

5. 线程等待-join

前面我们说过,线程之间的调度是随机的,虽然线程调度是随机的,但是我们可以通过干预两个线程的结束顺序来去等待先结束的线程执行完毕,即确定型调用

并且在创建线程的时候,我们一般都是要去调用系统的API-->系统创建这个线程-->CPU参与其调度,因此大部分时候都是main线程先执行完毕,但是也不是绝对的,毕竟还和你硬件有关

因此我们可以使用join,让一个线程去等待另一个线程
比如在main方法内调用t.join,就是让main线程去等待t线程


关于join方法的参数解析

  • join()不带任何参数,就是死等,等到昏天暗地
  • join(long 毫秒值)等待指定时间
  • join(long 毫秒值,int 纳秒值)更高精度的指定等待时间

同时如果在调用join前已经执行完了其他线程,即除了main线程以外的线程都执行完了,此时的join就不会生效,即不会进入阻塞

并且由于windows系统和linux系统线程调度都是毫秒级别的,因此高精度的join参数是不能有效执行的


public class Demo8 {public static int result = 0;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(()->{for (int i = 0; i < 10000000; i++) {result++;}});t.start();t.join(1);System.out.println(result);}
}

打印结果是34461,可以看到我们只让main线程等待一秒,t1线程并未执行完毕,因此结果是不对的

6. 获取当前的线程引用

我们刚刚的代码中的while(Thread.currentThread())中就是获取当前线程引用的方法
除了通过Thread类名去指定,我们还可以在重写run方法的时候使用this关键字表明当前引用的线程

public class Demo9 {public static void main(String[] args) {Thread t = new Thread(){@Overridepublic void run() {System.out.println(this.getName());}};t.start();}
}

我们不可以通过实现Runnable方法然后重写run方法来使用this关键字,不能通过this调用接口中不存在的方法

image-20251104142633237

7. 休眠当前线程-sleep

我们想让当前线程休眠,暂时性的不参与CPU的调度执行,让其进入阻塞状态
我们让其从就绪队列进入到阻塞队列,我们使用sleep(毫秒值)方法实现

就绪队列: □-->□
阻塞队列: □-->□-->□(新加入的)

由于我们只是暂时性阻塞,到时候这个线程还是要回到就绪队列中的
因此我们有一个定时器,到了时间就重新回到系统的调度上


我们有个特殊情况sleep(0),这个只要在我们资源紧张的时候才用得到
其实就是线程主动放弃了CPU的调度,即CPU给这个线程放权了,每一个线程都有自己的时间片(即执行时间,等到其他线程执行完一轮后自己再执行),但是我们使用sleep(0)可以让应用主动放弃当前的时间片,等待CPU下一次的调度执行


3. 线程状态

我们Java中对于线程的状态引用了六种状态

以下是就绪状态

  • new创建了Thread对象,但是还没用调用start方法执行
  • terminated操作系统的内部线程已经被销毁了,但是Thread对象还在
  • runnable就绪状态,细分为正在执行状态和即将被执行状态

以下是阻塞状态

  • blocked特指由于“锁”引起的阻塞
  • waiting比如由join()方法引起的持续等待的状态
  • time-waiting带有起始时间的等待的状态

上述六种状态都是我们工作的时候对于线程debug的重要参考的依据

4. 线程安全问题(面试经典)

最经典的就是多个线程去修改同一个变量从而产生的bug

public class Demo10 {private static int num = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {num++;}});Thread t2 = new Thread(()->{for (int i = 0; i < 5000; i++) {num++;}});t1.start();t2.start();//让main线程等待两个线程去执行完毕t1.join();t2.join();System.out.println(num);}
}

image-20251104144804969

通过图片我们可以看到,打印的正确结果本应该是10000,但是却给我们返回了一个错误的结果,这是怎么回事呢,这其实和我们之前提到过的CPU的随机调用有很大的关系

对于num++这条语句,在CPU看来,总共三个步骤load写入寄存器-->add执行++逻辑-->再把结果写回内存,但是这三个步骤具体是按照什么顺序执行的完全是随机的

image-20251104145555260

可以看到我们两个线程都对同一个变量num执行了++操作,但是由于两个线程触发了CPU线程的随机调度,导致各个语句之间的执行顺序并不是按照既定顺序执行的,从而导致值被覆盖

1. 线程安全问题产生的原因

我们通过刚刚的例子我们可以看到,线程安全问题有以下两种原因

  1. 操作系统的CPU线程调度是随机的,这是最根本的原因并且不可控
  2. 多个线程针对同一个变量进行修改操作

那我们如何去应对呢?
既然是多线程产生的问题,那我们干脆不用多线程,就用单线程嘛,但是有的业务场景你也必须要用到多线程
好,那既然是通过修改一个变量产生的,那我们定义多个变量不就好了吗,但是到了后期变量整合的时候,非常耗费逻辑和精力
好,那我们把两个线程针对同一个变量进行读取操作,但是在Java中少用,比如String类它是不可改变的类,所以就不好处理

2. 线程安全问题的普遍解决方案

我们回想刚刚那幅图,线程安全问题不就是因为我们对一个变量的修改是经过了三个步骤,各个步骤之间是分开执行的
那我们可不可以这样子,把这三个步骤合并为一个操作,即原子性,因此我们引入了,这样不就可以解决了吗,类似于MySQL中的事务

3. 锁

锁这个操作是多个语言通用的,在Java中,我们有很多种实现锁的方式,本质上都是为了达到互斥、独占的作用
既然是锁,那就涉及到了加锁和解锁两个操作,内部原理本质上都是去调用操作系统的API

我们使用synchronized关键字,语法是synchronized(锁对象){....},进入大括号作用域{加锁,出大括号作用域}解锁

我们加锁的本质并不是去阻止线程调度,而是防止其他线程插队执行

对于锁对象的定义,只要是Object类以及其子类都可以
在不同的线程之中,填写相同的锁对象才会发生锁竞争,毕竟你每个线程都是不同的锁,我还竞争什么,我每个线程自己都有一把锁了

我们把我们之前的两个线程同时对num值进行++的代码改进一下

public class Demo11 {private static int num = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(()->{synchronized (locker){//防止其他线程插队for (int i = 0; i < 5000; i++) {num++;}}});Thread t2 = new Thread(()->{synchronized (locker){//防止其他线程插队for (int i = 0; i < 5000; i++) {num++;}}});t1.start();t2.start();//让main线程等待两个线程去执行完毕t1.join();t2.join();System.out.println(num);}
}

同样我们加锁也可以写在循环里面,因为毕竟每一次++都要保证操作是原子的

image-20251104151357169

可以看到我们最终的结果就是正确的结果了,我们画个简单的线程图来解释下

image-20251104153009989

看完上面那个图,你是否想过,如果我只给一个线程加锁呢,那还是会有线程安全问题
比如我t2线程没有上锁,此时触发CPU线程随机调度,变成了t1线程执行,t1执行完毕后写回内存,此时我t2再执行
由于我t2读取的数值是在t1之前的,因此此时t2执行完毕后写回内存的数据还是一个错误的数据


我们再补充一个知识,即锁的粒度
所谓的粒度,就是锁内部代码执行的逻辑复杂程度,像我们的

synchronized (locker){//防止其他线程插队for (int i = 0; i < 5000; i++) {num++;}
}

内部有一个for循环在执行逻辑,此时锁的粒度就大,反之

for (int i = 0; i < 5000; i++) {synchronized(locker){num++;}
}

此时锁的内部只有一个++逻辑,我们此时就认为锁的粒度较小


你一定会好奇为什么我们Java中使用{}作用域表示一把锁的作用范围呢?因为如果我们加了锁之后忘了解锁或者是并未执行到解锁的语句就执行结束,可能会产生一些bug


除了我们上述锁包裹一个代码块,我们还可以添加到一个普通的方法内并添加this关键字

class Counter{public int num = 0;public void add(){synchronized (this){num++;}}
}public class Demo12 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(()->{for (int i = 0; i < 100; i++) {counter.add();}});Thread t2 = new Thread(()->{for (int i = 0; i < 100; i++) {counter.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.num);}
}

我们在方法中的synchronized关键字中this,谁调用这个方法,这个this锁对象就指向谁,就给谁加锁


除了普通方法,我们还可以给静态方法添加

class Counters{public static int num = 0;synchronized public static void add(){num++;}
}public class Demo13 {public static void main(String[] args) throws InterruptedException {Counters counters = new Counters();Thread t1 = new Thread(()->{for (int i = 0; i < 100; i++) {counters.add();}});Thread t2 = new Thread(()->{for (int i = 0; i < 100; i++) {counters.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(Counters.num);}
}

除了可以把synchronized关键字添加到方法签名,还可以添加到方法内部,通过反射机制,语法类名.class,因为add是类对象共用方法

class Counters{public static int num = 0;public static void add(){synchronized (Counters.class){num++;}}
}

我们总结一下,不管我们使用的是哪一种写法,针对什么对象去加锁并不重要,重要的是我们是否针对的是同一个对象进行的加锁


之前在网上看到有人说有一些类自身使用了synchronized,本身线程是安全的,但是为什么我们还是在实际工作中很少使用呢
因为这些类是自动进行加锁的,这样就会导致不同线程之间产生了锁竞争,并且可能会去调用操作系统内核的API,从而降低了程序的执行效率,我们写程序不喜欢变数,我们希望把加锁决定权放在我们自己手中
只有当有线程安全问题我们才继续加锁,否则我们一般不会盲目的加锁


我们看看下面那个代码是否会产生锁竞争,从而使得线程进入无限阻塞状态

class Counterss{public static int num = 0;public void add(){synchronized (this){num++;}}
}
public class Demo14 {public static void main(String[] args) throws InterruptedException {Counterss counterss = new Counterss();Thread t1 = new Thread(()->{for (int i = 0; i < 100; i++) {synchronized (counterss){counterss.add();}}});Thread t2 = new Thread(()->{for (int i = 0; i < 100; i++) {synchronized (counterss){counterss.add();}}});t1.start();t2.start();t1.join();t2.join();System.out.println(Counterss.num);}
}

我们想的是:根据用同一个对象进行加锁就会陷入阻塞,我们进入循环,此时conterss对象已经被上锁了,通过线程调用add()方法对同一个对象进行第二次加锁,应该被阻塞,产生死锁问题

但是我们结果就是代码可以被顺利执行,为什么呢
原来是因为在Java线程执行中对上述情况进行了特殊的处理,当第一次加锁成功后,当第二次加锁的时候,synchronized内部会判断第二次加锁的线程是否和第一次线程相同
如果相同,此次加锁跳过,反之就加锁
这种我们就称之为可重入锁,其目的就是为了防止锁太深或者是锁被多重调用时不好进行锁检查
本质上就是synchronized(c){synchronized(c)....}}只有第一个synchroinzed才进行加锁,最后一个}进行解锁


4. 死锁问题场景

  1. 刚刚那种情况,一个线程一把锁,并且连续加锁
  2. 两个线程两把锁,并且相互竞争锁。比如你把车钥匙放在屋子里,再把屋子钥匙放在车里,互相上锁,这样哪个钥匙也拿不到
public class Demo15 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(()->{synchronized (locker1){System.out.println("拿到了一号锁");try {//让t2拿到自己的锁后尝试竞争一号锁Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2){System.out.println("拿到了二号锁");}}});Thread t2 = new Thread(()->{synchronized (locker2){System.out.println("拿到了二号锁");try {//让t1拿到自己的锁后尝试竞争二号锁Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2){System.out.println("拿到了一号锁");}}});t1.start();t2.start();}
}

image-20251104163934503

结果我们可以看到,只打印了它们各自的锁的语句,并没有打印它们竞争得到的锁的语句,说明两个线程被阻塞到这个地方了

  1. M个线程N把锁,这个问题有一个非常经典的“哲学家就餐问题”

image-20251104164908632

5. 产生死锁的必要条件(面试高频)

  1. 锁是互斥的synchronized
  2. 锁不可被抢占,即A线程得到a锁,B线程抢不到A锁,A不会进入阻塞状态
  3. 请求和等待,即A线程得到了a锁并且去持有a锁,再去尝试去获取b锁
  4. 循环等待,也称环路等待,即之前的车钥匙和屋子钥匙的例子

6. 避免死锁

  1. 避免锁嵌套,比如我们优化下刚刚两个锁嵌套的代码
public class Demo16 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {synchronized (locker1) {System.out.println("拿到了一号锁");try {//让t2拿到自己的锁后尝试竞争一号锁Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}synchronized (locker2) {System.out.println("拿到了二号锁");}});Thread t2 = new Thread(() -> {synchronized (locker2) {System.out.println("拿到了二号锁");try {//让t1拿到自己的锁后尝试竞争二号锁Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}synchronized (locker1) {System.out.println("拿到了一号锁");}});t1.start();t2.start();}
}

image-20251104165608864

  1. 打破循环等待,比如我们给锁加上编号,约定每一个线程的固定加锁顺序
    就比如我们刚刚的哲学家就餐问题,我们让每一个人拿起编号小的筷子,如果编号小的筷子被别人拿了,则不可再拿

image-20251104170237942

因此我们利用这个思想去修改我们的代码

public class Demo16 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {synchronized (locker1) {System.out.println(Thread.currentThread().getName()+"拿到了一号锁");try {//让t2拿到自己的锁后尝试竞争一号锁Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2) {System.out.println(Thread.currentThread().getName()+"拿到了二号锁");}}});Thread t2 = new Thread(() -> {synchronized (locker1) {System.out.println(Thread.currentThread().getName()+"拿到了一号锁");try {//让t1拿到自己的锁后尝试竞争二号锁Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2) {System.out.println(Thread.currentThread().getName()+"拿到了二号锁");}}});t1.start();t2.start();}
}

即先去获取编号小的锁的,再去获取编号大的锁
因为锁的作用域是在{}内的,当我的t1执行完之后锁就被释放了,此时t2就可以去获取全部锁了

7. 内存可见性引起的线程安全问题

public class Demo17 {private static int num = 0;public static void main(String[] args) {Thread t1 = new Thread(()->{while (num == 0){}System.out.println("t1线程结束");});Thread t2 = new Thread(()->{Scanner scanner = new Scanner(System.in);num = scanner.nextInt();System.out.println("t2线程结束");});t1.start();t2.start();}
}

image-20251104191322694

这段代码,我们在输入1之后,本应该while(num == 0)判断不成立,应该终止循环线程结束才对,但是为什么我们在输入1之后,线程t1仍然在继续呢?
这就涉及到了编译器优化的问题,这就是由于内存可见性引起的线程安全问题
如果我们的代码执行不够高效,编译器会自动去优化我们的代码,在保持代码逻辑不变的情况下让代码执行变得更加高效


我们来具体分析下编译器到底是怎么去优化的
while循环处,我们CPU有两个指令

  • load读取num的值到寄存器中
  • cmp比较寄存器的值是否与0这个值相同,相同我们就继续执行while,否则就结束循环跳转到对应位置

同时,由于load指令开销远大于cmp的开销,(一秒钟可以读取上万次)因此编译器发现每一次num的值读取到的都相同
并且也未发现哪里有对num的值进行修改的地方,即无法检测另外一个线程对这个num变量的修改时机,因此编译器就把load操作优化掉了,因此只剩下从CPU缓存去读取num的值

我们画个简单的图来说说

image-20251104192142135


那我们要如何应对这种问题呢,我们可以使用volite关键字来避免
加上这个关键字之后,我们可以让步编译器明白这个num变量是容易改变的,此时编译器针对这个变量的后续读写操作就不会进行优化了

image-20251104192428837

注意,volatile关键字并没有使得两个线程相互排斥,也没有针对是否是原子项问题,而它针对的是线程读写问题,即内存可见性问题


这里我们拓展一个知识,即Java内存模型JMM
这个和我们之前编译器优化的说法类似,但我们现在说的JMM这个是Java官方说的,只针对Java,总的来说就以下三点

  • 在Java进程中,每一个线程都有一份工作内存(Work Memory),这些线程会共享一个主内存(Main Memory)
  • 当一个线程针对某个数据进行修改的时候,先把当前数据从主内存拷贝到工作内存,在工作内存内完成一些列操作再写回主内存
  • 同时当一个线程对一个数据进行读取的时候,会先把数据从主内存拷贝工作内存,再从工作内存中读取数据,此时读取的就是这个变量在主内存的副本
  • 回到刚刚的两个线程中,此时t1循环读取判断的不是工作内存的数值,同时t2修改的是主内存的数值,它们两个值完全不对等,从而你t2修改的值不会影响到这个变量在工作内存中的值,因此就陷入死循环了

诶,除了使用volatile关键字,还有什么方法吗,有,只需要我们不把循环体里面加入Thread.sleep()就好

public class Demo17 {private static int num = 0;public static void main(String[] args) {Thread t1 = new Thread(()->{while (num == 0) {try {Thread.sleep(1);} catch (InterruptedException e) {throw new RuntimeException(e);}}System.out.println("t1线程结束");});Thread t2 = new Thread(()->{Scanner scanner = new Scanner(System.in);num = scanner.nextInt();System.out.println("t2线程结束");});t1.start();t2.start();}
}

image-20251104193555650

5. wait和notify

我们之前就说过,由于线程调度是随机的,如果我们想让多个线程去执行同一个逻辑,我们就需要使用这两个关键字
wait就是让线程阻塞等待,notify就是让线程唤醒


我们先来说说什么事线程饿死问题
我们举一个例子,有很多人去上厕所,假设A发现厕所是坏的,此时A出来了,但是A又回去了,又发现厕所是坏的,此时A再出来,A再进去…一直循环往复,导致其他线程不能及时享用到“厕所”这个资源,引发了其他线程饿死的问题


1. 基本使用

如果我们这么写

public class Demo18 {public static void main(String[] args) throws InterruptedException {Object lockers = new Object();lockers.wait();}
}

image-20251104194655042

此时我们抛出一个非法监视器的状态异常,因为此处我们进行加锁是合法的,但是我们当前并没有针对main线程进行加锁,而且wait方法内是先进行解锁操作再去释放这把锁

public class Demo18 {public static void main(String[] args) throws InterruptedException {Object lockers = new Object();synchronized (lockers){lockers.wait(10);}}
}

既然wait是解锁,那我们还需要出了synchronized作用域释放锁吗
虽然wait内部有释放锁,此时这把锁就给其他线程了,但是等其他线程进入阻塞状态了,当前线程继续执行,重新拿到了这把锁,因此直到出synchronized作用域之前线程仍然是被锁状态

我们可以来举个例子

public class Demo19 {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(()->{System.out.println("t1的wait方法之前");synchronized (locker){try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1的wait方法之后");}});Thread t2 = new Thread(()->{synchronized (locker){System.out.println("t2的notify方法之前");Scanner sc = new Scanner(System.in);//我们要让t2线程在此阻塞sc.next();//此时唤醒t1线程locker.notify();System.out.println("t2的notify方法之后");}});t1.start();//确保t1先运行进入wait状态Thread.sleep(10);t2.start();}
}

我们输入内容后,虽然t1被唤醒,但是此时锁在t2手中,因此要等到t2执行完毕后t1才能执行剩下的语句

我们wait方法跟sleep方法参数一样,有指定时间等待,也有毫秒与纳秒的高精度等待

如果我们想一次性唤醒所有进入阻塞状态的线程,我们可以使用notifyAll

2. wait与sleep区别

  • wait一般和notify绑定在一起使用,内部参数就是最大等待时间,可以手动被唤醒。而sleep只能是被动阻塞后唤醒
  • wait由于有释放锁的机制,因此必须要去配合锁使用,而sleep不用
  • wait会把锁释放后再重新获取,如果我们把sleep加入到锁内部,休眠的时候并不会释放锁
  • 虽然interrupt唤醒,但是这样可能会抛异常导致线程终止,而我们使用notify可以不断的等待与唤醒,释放锁和获取锁

6. 单例模式

还记得我们之前的写图书管理系统文章说的会有线程安全问题吗,我们就来说说为什么会有线程安全问题

首先我们来创建饿汉式的单例模式

class Singleton {//饿汉private static Singleton instance = new Singleton();//保证一个进程只有一个实例public static Singleton getInstance() {return instance;}private Singleton(){//构造方法私有化,防止通过new对象new出来}
}class Singletons{//懒汉private static Singletons instance;public static Singletons getInstance() {//调用该方法的时候才new对象if(instance == null){instance = new Singletons();}return instance;}private Singletons(){}
}

为什么在我看来饿汉模式天然就是线程更加安全的呢,因为我们多个线程进行获取对象操作的时候,在类创建时就已经实例化好了,我们获取到的都是同一个有引用的对象

反之,对于懒汉模式,因为我们创建实例化对象的操作并不是原子性的,我们画个图来讲解

image-20251104202729352

如果我们在if条件内加锁,理由不就是new对象的时候其他线程来干扰吗

class Singleton {private static Singleton instance = new Singleton();//保证一个进程只有一个实例public static Singleton getInstance() {return instance;}private Singleton(){//构造方法私有化,防止通过new对象new出来}
}class Singletons{public static Object locker = new Object();private static Singletons instance;public static Singletons getInstance() {//调用该方法的时候才new对象synchronized (locker){if(instance == null){instance = new Singletons();}}return instance;}private Singletons(){}
}

我们来分析下这样为什么可以避免线程安全问题

image-20251105092340922

但是如果你进一步分析,我们这个线程安全问题其实只是在实例化对象之前存在,如果我们是梨花对象之后,还是会进行加锁判断然后再解锁,此时另一个线程会产生阻塞,降低整个系统的执行效率,并且线程之间的调度成本也很高

因此我们可以在加锁的前一步就判断,这个对象如果是空对象,我们才要进行加锁,里面再进行实例化对象
因此我们的getInstance()方法就变成

public static Singletons getInstance() {//调用该方法的时候才new对象if(instance == null) {//这个判断是为了决定是否有枷锁的必要synchronized (locker) {if (instance == null) {//这个才是真正判断是否要实例化对象instance = new Singletons();}}}return instance;}

7. 指令重排序

这个本质上也是一种编译器的优化,编译器初衷是为了调整指令的执行顺序从而让代码执行效率更高一点
我们回到我们之前写的优化过后的单例模式的代码

public static Singletons getInstance() {//调用该方法的时候才new对象if(instance == null) {//这个判断是为了决定是否有枷锁的必要synchronized (locker) {if (instance == null) {//这个才是真正判断是否要实例化对象instance = new Singletons();}}}return instance;}

对于instance = new Singletons()这个代码,大致可以分成三个指令步骤

  1. 分配内存的空间
  2. 针对空间进行初始化
  3. 把内存空间的首地址赋值到引用的变量之中

那么以上三个步骤可能被打乱

image-20251105094119323

那要怎么让编译器知道这里的指令是不能重排序的呢
我们还是要对对象字段加上volatile关键字修饰

private static volatile Singletons instance;

8. JDBC编程中数据库单例模式

我们在连接数据库的时候,希望只连接一次,避免多次连接的开销

public class DB {private static volatile DataSource dataSource;public static DataSource getInstance(){if(dataSource == null){synchronized (DB.class){if(dataSource == null){dataSource = new MysqlDataSource();((MysqlDataSource)dataSource).setURL("jdbc:mysql://127.0.0.1:3306/home?CharacterEncoding=utf8&useSSL=false");((MysqlDataSource)dataSource).setUser("root");((MysqlDataSource)dataSource).setPassword("123456789");}}}return dataSource;}
}

但是为什么说加了锁,线程还是存在不安全的问题呢?
本质上还是因为我们对数据库的操作不是原子化的

image-20251105095747009

因此我们加锁≠百分之百的安全

那要怎么办呢,我们可以在锁的内部进行初始化,先创建一个临时数据库对象,把这个对象初始化好之后再赋给我们真正的数据库对象就好了

public class DB {private static volatile DataSource dataSource;public static DataSource getInstance(){if(dataSource == null){synchronized (DB.class){if(dataSource == null){MysqlDataSource tmp = new MysqlDataSource();tmp = new MysqlDataSource();tmp.setURL("jdbc:mysql://127.0.0.1:3306/home?CharacterEncoding=utf8&useSSL=false");tmp.setUser("root");tmp.setPassword("123456789");//关键一步dataSource = tmp;}}}return dataSource;}
}

9. 阻塞队列

这个阻塞队列相对于普通的队列,其默认是线程安全的,并且带有线程阻塞功能
当队列是空的,尝试出队,产生阻塞,直到队列不为空
当队列是慢的,尝试入队,产生阻塞,直到队列不为满

1. 生产者消费者模型

我们就拿包饺子举个例子
我们假设总共有四个人,但是只有两个个擀面杖,轮着来使用擀面杖包饺子,造成其他人阻塞等待
其实我们可以这样子,即分工协作,两个人负责擀面皮,剩下两个人负责包饺子就好,当两个人擀好饺子皮后,只需要把饺子皮放在桌上,并不用管那两个人什么时候拿,反正有饺子皮就拿,没有就等,此时桌子就是联系四个人的媒介


而我们在网上经常听别人念叨的消息队列(MessageQueue,简称MQ),就是把阻塞队列这种数据结构单独提取出来封装为一组服务器


2. 作用

1.降低资源竞争

我们刚刚已经举过了例子

2. 解耦合

我们先来解释下什么是耦合,就是一个模块的修改对另一个模块有影响
比如我们用一个网购的例子来解释下什么是强耦合性

浏    
览    网    订
器    关	  单	  □ 业务A
□ --> □ --> □ ↗️↘️□ 业务B

当我们下单的时候,此时订单服务器就会要访问数据库,修改表,比如访问业务B服务器,再把修改结果返回给订单服务器
同时订单服务器在业务B服务器比如消费记录,也要进行修改
如果订单服务器出现了bug,那么由于其他服务器和订单服务器的强关联性,也会跟着出现bug
因此我们引入消息队列MQ,充当订单服务器和其他服务器的媒介

浏    
览    网    订     
器    关	  单	   MQ  □ 业务A
□ --> □ --> □ --> □↗️↘️□ 业务B

此时订单服务器和其他业务服务器的关联性就大大降低,一切的操作都要通过MQ服务器来进行
这样如果我们后续再添加新的业务服务器,也就不用进行大范围的修改,同时也就无法影响到其他服务器了

3. 削峰补枯

我们每一个服务器都有自己的最大负荷量,假设我们刚刚的订单服务器突然来了很多订单,此时经过MQ服务器,对这些订单进行排列整理,再交给两个业务服务器,此时它们的峰值就小了很多
等到订单服务器流量小了之后,由于MQ服务器保存了很多订单,此时两个业务服务器还在处理订单,到了最后,就可以趋于平稳,我们分别画出两个服务器的流量图

|     →         |
|   ↗️  ↘️       |   → → → → → 
| ↗️      ↘️     | ↗️          ↘️
L___________    L_______________订单服务器          业务服务器

因此我们可以看到,业务服务器的流量有一定的滞后性,但是又不会快速到达峰值,从而减小了服务器压力

3. 弊端

一种数据结构,肯定有利有弊,现在我们来说它的弊端
处理的效率会受到影响,因为不是实时处理,因此请求时间可能过长

  • 当我们下订单的时候,并不知道在MQ之中有多少其他的订单还在排队,因此可能会产生请求超时现象,因此这个模型适合那种异步操作
  • 并且使用MQ可能会使得服务器架构更加复杂,我们还是要明确是否有使用消息队里的需求的

什么是同步操作&异步操作

  • 同步操作:A请求B,A会一直等待B的结果,拿到结果后再处理其他请求
  • 异步操作:A请求B,A不回去等B,等B拿到结果给A的时候,A再去看一眼

10. Java中的BlockingQueue

在我们Java中,BlockingQueue是一个接口,根据你选择的阻塞队列的数据结构类型去实例化

  • new ArrayBlockingQueue<>()基于数组实现的阻塞队列,需要指定具体大小
  • new LinkedBlockingQueue<>()基于链表实现的阻塞队列
  • new PriorityBlockingQueue<>()基于优先级队列实现的阻塞队列

入队代码是blockingQueue.put()
出队代码是blockingQueue.take()
即使BlockingQueue类继承了Queue类,但是如果使用Queueadd()方法等待是不带阻塞

我们来具体实现下代码,我们用一个线程代表生产者,往阻塞队列里添加数据
我们用另一个线程代表消费者,往阻塞队列中获取数据
我们分别去模拟生产者速度比消费者快,消费者速度比生产者快,两者一样快

public static void main(String[] args) {BlockingQueue<Long> blockingDeque = new ArrayBlockingQueue<>(1000);Thread t1 = new Thread(()->{//生产者慢long n = 0;while(true){try {blockingDeque.put(n);System.out.println(n);n++;Thread.sleep(1000);}catch (InterruptedException e){throw new RuntimeException(e);}}});Thread t2 = new Thread(()->{//消费者快try {Long n = blockingDeque.take();System.out.println(n);} catch (InterruptedException e) {throw new RuntimeException(e);}});t1.start();t2.start();}

此时t1生产一次,t2才消费一次

public static void main(String[] args) {BlockingQueue<Long> blockingDeque = new ArrayBlockingQueue<>(1000);Thread t1 = new Thread(()->{//生产者快long n = 0;while(true){try {blockingDeque.put(n);System.out.println(n);n++;}catch (InterruptedException e){throw new RuntimeException(e);}}});Thread t2 = new Thread(()->{//消费者慢try {Long n = blockingDeque.take();System.out.println(n);Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}});t1.start();t2.start();}

此时t1生产了很多次,t2慢慢的在消费

public static void main(String[] args) throws InterruptedException {BlockingQueue<Long> blockingDeque = new ArrayBlockingQueue<>(1000);Thread t1 = new Thread(()->{long n = 0;while(true){try {blockingDeque.put(n);System.out.println(n);n++;}catch (InterruptedException e){throw new RuntimeException(e);}}});Thread t2 = new Thread(()->{try {Long n = blockingDeque.take();System.out.println(n);} catch (InterruptedException e) {throw new RuntimeException(e);}});t1.start();t2.start();}

由于控制台输出重叠,因此看起来只有一次,实际上打印了两次

11. 模拟实现Java中的BlockingQueue

我们使用循环队列,这个我们之前在数据结构的时候写过
我们学校,阻塞等待我们用什么方法
如果使用sleep,它任何线程都可以用,并不是针对BlockingQueue
如果使用join,等待的是线程结果,不可行
因此我们使用wait,在队列是空的或者是满的时候进行唤醒

public class MyBlockingQueue {private String[] array;//环形队列队头队尾指针private int head;private int end;//判断数组是否满了private int usedSize;//锁private Object locker = new Object();public MyBlockingQueue(int size){array = new String[size];}private void put(String str) throws InterruptedException {synchronized (locker){while(usedSize == array.length){locker.wait();}array[end] = str;end++;if(end >= array.length){end = 0;}usedSize++;//唤醒另一个因为队列为空而阻塞的线程locker.notify();}}private String take() throws InterruptedException {synchronized (locker){while(usedSize == 0){locker.wait();}String str = array[head];head++;if(head >= array.length){head = 0;}usedSize--;//唤醒另一个因为队列满了而阻塞的线程locker.notify();return str;}}public static void main(String[] args) throws InterruptedException {MyBlockingQueue myBlockingQueue = new MyBlockingQueue(10);myBlockingQueue.put("aaa");myBlockingQueue.put("bbb");System.out.println(myBlockingQueue.take());}
}

为什么我们使用while循环来进行阻塞等待,因为在jdk8中的源码就是用的这个
这个便于进行多次判断队列是不是满的或者是空的情况,更加的稳健

12. 线程池

我们之前讲过,所谓的“池”,就是随取随用的
我们的线程池,就是把线程提前创建好放入其中,要用的时候再去取,这样子我们就可以进一步去降低操作系统对于线程创建的系统开销


什么是用户态和内核态
我们的操作系统分为内核的底层操作和系统自带的应用程序
当我们使用应用程序的时候,此时代码就执行在用户态
当操作系统内核调用的时候,此时代码就执行在内核态
我们之前start运行线程的时候就是先在用户态执行调用的逻辑,再在内核态去调用操作系统的API,再把结果返回到用户态,两者互相调用是有开销的


因此我们使用了线程池,提前把线程通过操作系统的API创建好,再把这些创建好的Thread对象放在一个集合类中,便于日后取用
我们做一个比喻,你想去买包辣条,如果你去商店你要问老板辣条还有没有存货,这就是不可控情况;如果你使用自助售货机你就可以不用问直接看到有没有库存,这就是可控情况
我们日常工作中并不喜欢不可控的随机情况,我们更加希望可控情况,把代码掌握在自己手中

当然,它也有确定,即

  • 如果线程池中线程数目太多,即使是线程,调度也有开销,可能会影响效率
  • 针对上述情况,于是就有了协程,它是更加轻量级的线程,因为协程的调度并不是在操作系统进行的,而是用户自己手动进行的

1. 线程池类构造方法参数解析(经典面试题)

我们直接对最后一种带有完整参数的构造方法参数进行解析

image-20251105112505371


  1. int corePoolSizemaximumPoolSize这个是做到线程池自动扩容减容的操作
    在Java中,我们把线程池中的线程分成了两类
    一类是核心线程,即corePoolSize参数
    另一类是非核心线程,这个后续在线程池中因为任务太多而额外创建额线程,即maximunPoolSize - corePoolSize的结果

  1. long keepAliveTimetimeUnit unit分别是非核心线程的释放实际和非核心线程的允许的最大空闲时间

  1. BlockingQueue<Runnable> workQueue即任务队列,明确你要执行哪一些任务
    为什么使用阻塞队列,为了让线程池中的工作线程可以阻塞等待,也是为了让我们手动调度增加灵活性,同时也是为了让我们自己制定这个阻塞队列具体用什么数据结构去实现

  1. ThreadFactory threadFactory就像我们之前写图书管理系统一样,使用工厂类根据不同线程类型快速创建类型,本质上是一个接口
    允许我们自己实现不同线程类型的工厂类,就像我们图书管理系统中不同的用户类型一样

  1. RejectedExecutionHandler即拒绝策略
    如果我们的线程池达到上限了,再次添加任务就会触发

我们来具体描述下四种拒绝策略

  • 策略一:AbortPolicy即在任务队列满的时候,如果继续添加会抛出异常,异常处理不当可能导致程序崩溃
  • 策略二:CallerRunsPolicy即某个程序调用submit添加任务的时候,如果任务队列已满,新添加的任务就无法加入任务队列,就让调用submit这个线程去执行
  • 策略三:DiscordOldestPolicy即把任务队列最早添加的并且还未执行的任务给丢弃掉,再把这个空出来的位置让给新添加的任务
  • 策略四:DiscardPolicy即把在队列中的比较新的任务给丢弃,然后把空出来的位置让给最新添加的任务

你肯定会好奇任务队列满了不应该等待吗,但是上述四种策略本质上都是尽力把新的任务加入任务队列中去执行

2. 使用ThreadPoolExecutor快速创建线程池

如果你嫌手动创建线程池太麻烦了,现在提供了线程池的工厂类,直接调用就好了,我们使用ExecutorSerive类接收

public static void main(String[] args) {//普通类型线程池,可以自动扩容ExecutorService currentThread1 = Executors.newCachedThreadPool();//固定大小的线程池,需要指定大小ExecutorService currentThread2 = Executors.newFixedThreadPool(10);//定时器类型的线程池,需要指定盒型线程数量ExecutorService currentThread3 = Executors.newScheduledThreadPool(10);//只包含一个线程的线程池ExecutorService currentThread4 = Executors.newSingleThreadExecutor();//只包含一个线程并且加入定时器的线程池ExecutorService currentThread5 = Executors.newSingleThreadScheduledExecutor();}

如果想快速创建线程池我们就可以使用这种方法
但是如果想要精细化调控线程池,我们还是推荐原生方法

3. 模拟实现一个线程池

public class MyThreadPool {private BlockingQueue<Runnable> blockingQueue = new LinkedBlockingQueue<>();private MyThreadPool(int capacity){//capacity代表线程的数目for (int i = 0; i < capacity; i++) {Thread t = new Thread(()->{while(true){//让每一个线程去任务队列中取一个任务Runnable task = null;try {task = blockingQueue.take();} catch (InterruptedException e) {throw new RuntimeException(e);}task.run();}});//设为后台线程,其他前台线程结束后后台线程也跟着结束t.setDaemon(true);//启动线程t.start();}}public void submit(Runnable task) throws InterruptedException {blockingQueue.put(task);}
}

我们先来看初始化线程池的这个方法,你肯定会说,里面有一个死循环的代码,但是我们这么设计是为了让线程去持续处理任务

我们就拿其中一个线程去举例
如果队列是空的,它会陷入到阻塞状态,此时只有当我们往线程中去添加任务的时候,线程才会执行,执行完毕后继续回到阻塞状态等待新的任务,循环往复
毕竟一个线程只能执行一个任务,当同时有多个任务进来的时候其他线程也会分配到任务然后去执行,执行完毕再等待新任务

通过中断机制再去优雅的关闭线程池

现在我们创建一个测试用例来看看

public static void main(String[] args) throws InterruptedException {MyThreadPool myThreadPool = new MyThreadPool(5);//我们给予两百个任务for (int i = 0; i < 200; i++) {int taskId = i;myThreadPool.submit(()->{Thread current = Thread.currentThread();System.out.println(current.getName()+"执行了任务"+taskId);});}//确保线程池中的线程有足够时间去执行Thread.sleep(1000);}

image-20251105144317255

我们可以看到不同线程执行了不同的任务,而且一个线程可以多次地去执行任务,这和我们刚刚说的一个线程执行完毕后再回到循环开头获取任务一样,体现了线程复用!!

13. 定时器

比如你去浏览一个网页,如果网页半天加载不出来,你肯定会选择刷新或者是退出,总不可能一直等待它刷新不出来吧,而且浏览器也会提醒页面连接超时

因此在我们Java官方的标准库中提供了一个Timer类,并且提供schedule方法描述延时多久毫秒后执行
我们来演示一下

public class Demo23 {public static void main(String[] args) throws InterruptedException {Timer timer = new Timer();timer.schedule(new TimerTask(){@Overridepublic void run() {System.out.println("hello");}},5000);Thread.sleep(8000);timer.cancel();}
}

好,我们来模拟实现下

1. 模拟实现定时器

首先,我们得有一个具体的任务类
这个类描述的是一个个延迟任务的具体内容,肯定要实现comparable接口,毕竟延迟时间小的队列要先执行

再者,我们要把一个个延迟任务存储起来,但是要用什么数据结构呢?毕竟我们是根据延迟时间在集合中排序的,明确谁先执行谁后执行
那我们就可以使用优先级队列,这样我们把任务根据延迟时间排好序后,就可以每次出队都是延迟时间小的任务,当然使用时间轮方案也可以

//实现接口,重写方法,把先添加的任务放在前面执行
//达到类似延迟执行的效果
class MyTimerTask implements Comparable<MyTimerTask>{private Runnable runnable;//判定是否到达了执行时间private long time;public MyTimerTask(Runnable runnable,long delay){//执行时间是当前系统时间+指定的延时执行时间delaythis.time = System.currentTimeMillis()+delay;this.runnable = runnable;}public long getTime(){return time;}public void run(){runnable.run();}@Overridepublic int compareTo(MyTimerTask o) {return (int)(this.time-o.time);}
}public class MyTimer {private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();public void schedule(Runnable runnable,long delay){MyTimerTask task = new MyTimerTask(runnable,delay);queue.add(task);}public MyTimer(){Thread t = new Thread(()->{while(true){//每次查看这个任务是否到了需要执行的时间MyTimerTask task = queue.peek();//处理空队列情况if (task == null) {continue;}long currentTime = System.currentTimeMillis();if(currentTime >= task.getTime()){//说明时间已经到了,需要执行task.run();//任务执行完毕后踢出队列queue.poll();}else{//说明时间还没有到,继续判断continue;}}});t.start();}public static void main(String[] args) {MyTimer timer = new MyTimer();timer.schedule(()->{System.out.println("1000");},1000);timer.schedule(()->{System.out.println("2000");},2000);timer.schedule(()->{System.out.println("3000");},3000);}
}

我们目前代码执行还是有问题,除了拿取队列任务是空的情况,还有一个最严重的问题,即忙等待和线程安全问题


什么是忙等待,在我们刚刚代码中,如果队列没有任务,我们就让那个线程一直循环等待,占用资源但是又没有拿到什么实质性的任务,就会造成其他线程一直等待也拿不到资源,就会造成线程饿死问题


线程安全问题就是我们main线程去把任务放入了任务队列中,但是我们在MyTimer之中又有一个线程在从队列中获取任务,此时就会造成冲突,因此我们手动加锁

因此我们代码改进如下

class MyTimerTask implements Comparable<MyTimerTask>{private Runnable runnable;//判定是否到达了执行时间private long time;public MyTimerTask(Runnable runnable,long delay){//执行时间是当前系统时间+指定的延时执行时间delaythis.time = System.currentTimeMillis()+delay;this.runnable = runnable;}public long getTime(){return time;}public void run(){runnable.run();}@Overridepublic int compareTo(MyTimerTask o) {return (int)(this.time-o.time);}
}public class MyTimer {private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();//引入锁对象private Object locker = new Object();public void schedule(Runnable runnable,long delay){synchronized (locker) {MyTimerTask task = new MyTimerTask(runnable, delay);queue.add(task);//此时任务队列就有新的任务类,我们执行唤醒操作locker.notify();}}public MyTimer(){Thread t = new Thread(()->{synchronized (locker) {while (true) {//每次查看这个任务是否到了需要执行的时间MyTimerTask task = queue.peek();//处理空队列情况while (task == null) {//如果任务队列是空的,我们就等待try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}//等待之后我们再看看是否任务队列还是空的task = queue.peek();}long currentTime = System.currentTimeMillis();if (currentTime >= task.getTime()) {//说明时间已经到了,需要执行task.run();//任务执行完毕后踢出队列queue.poll();} else {//我们加入wait使其进入阻塞状态//我们等多久呢,我们等到这个任务需要执行的时候即可try {locker.wait(task.getTime()-currentTime);} catch (InterruptedException e) {throw new RuntimeException(e);}}}}});t.start();}public static void main(String[] args) {MyTimer timer = new MyTimer();timer.schedule(()->{System.out.println("1000");},1000);timer.schedule(()->{System.out.println("2000");},2000);timer.schedule(()->{System.out.println("3000");},3000);}
}

2. 手动加锁原因

考虑一下代码场景
此时由于我们的任务队列是空的,获取不到锁,就陷入阻塞状态

private PriorityQueue<MyTimerTask> queue = new PriorityBlockingQueue<>();
// 危险代码示例(可能死锁)
synchronized(outerLock) {Mytimer task = queue.take();  // 内部会获取队列自己的锁// 如果两个线程互相持有对方需要的锁...
}

文章可能有错误欢迎指出,过几天更新关于多线程的八股文

Git码云仓库链接

END♪٩(´ω`)و♪`
http://www.dtcms.com/a/574463.html

相关文章:

  • 网站建设+三乐seo优化系统哪家好
  • 游戏网站建设收费明细自己怎么制作假山
  • 调用模型的两个参数 temperature 和 max_new_tokens 指什么
  • Deepseek在它擅长的AI数据处理领域还有是有低级错误【k折交叉验证中每折样本数计算】
  • 影刀RPA实时监控抖店DSR评分,AI预警异常波动,店铺权重稳如泰山![特殊字符]
  • 用html做的美食网站wordpress 自动空格
  • 网站防护找谁做wordpress创建搜索框
  • 【雷达跟踪滤波例程】3个雷达的三维目标跟踪滤波系统,目标匀速运动,滤波为扩展卡尔曼|雷达观测:斜距、俯仰角、方位角。MATLAB,附下载链接
  • Go红队开发—图形化界面
  • 测开百日计划——Day1
  • 一些工具的使用
  • MATLAB多子种群混沌自适应哈里斯鹰算法优化BP神经网络回归预测
  • 红外体温产品开发踩坑后,我发现谷德 0.05℃精度的红外体温芯片居然自带免费算法?
  • 周口师范做网站好的网站建设网
  • 无锡网站wordpress 3.8.1
  • 巫山做网站哪家强wordpress 升级提示
  • 探索 MCP 生态与边缘智能体部署的家常话
  • 商城网站建设推广手机网站特效代码
  • MIT-0-1背包问题
  • AI+近红外:实现粮食质量快速检测的智能化升级——从单指标到多指标同步预测的技术飞跃
  • 注册电气工程师报考条件网站优化 套站
  • 兰州大学网页与网站设计最好玩的网站
  • wap建站系统创意设计工作室
  • MCU单片机驱动WS2812,点亮RGB灯带各种效果
  • 公司的服务器能建设网站吗网站如何做移动适配
  • 嘉兴做网站优化多少钱网站搜索引擎友好性
  • 正规网站建设公司哪家好wordpress js被挂木马
  • 贵州省建设厅网站查合肥网站排名优化公司
  • 什么是接口测试?为什么要做接口测试?
  • 淘宝网站制作教程北京网站建设东轩seo