JAVA SE 多线程(上)

文章目录

    • [📕1. Thread类及常见方法](#📕1. Thread类及常见方法)
        • [✏️1.1 创建线程](#✏️1.1 创建线程)
        • [✏️1.2 Thread 的常见构造方法](#✏️1.2 Thread 的常见构造方法)
        • [✏️1.3 Thread 的几个常见属性](#✏️1.3 Thread 的几个常见属性)
        • [✏️1.4 启动一个线程---start()](#✏️1.4 启动一个线程---start())
        • [✏️1.5 中断一个线程---interrupt()](#✏️1.5 中断一个线程---interrupt())
        • [✏️1.6 等待一个线程---join()](#✏️1.6 等待一个线程---join())
        • [✏️1.7 获取当前线程引用](#✏️1.7 获取当前线程引用)
        • [✏️1.8 休眠当前线程](#✏️1.8 休眠当前线程)
    • [📕2. 线程的状态](#📕2. 线程的状态)
    • [📕3. 线程安全](#📕3. 线程安全)
        • [✏️3.1 线程不安全案例](#✏️3.1 线程不安全案例)
        • [✏️3.2 线程不安全的原因](#✏️3.2 线程不安全的原因)
    • [📕4. synchronized 关键字](#📕4. synchronized 关键字)
        • [✏️4.1 synchronized的特性](#✏️4.1 synchronized的特性)
        • [✏️4.2 synchronized 使用示例](#✏️4.2 synchronized 使用示例)
    • [📕5. volatile关键字](#📕5. volatile关键字)
    • [📕6. wait与notify](#📕6. wait与notify)
        • [✏️6.1 wait()方法](#✏️6.1 wait()方法)
        • [✏️6.2 notify()方法](#✏️6.2 notify()方法)
        • [✏️6.3 notifyAll()方法](#✏️6.3 notifyAll()方法)
    • [📕7. 单例模式](#📕7. 单例模式)
    • [📕8. 阻塞队列](#📕8. 阻塞队列)
        • [✏️8.1 生产者消费者模型](#✏️8.1 生产者消费者模型)
        • [✏️8.2 标准库中的阻塞队列](#✏️8.2 标准库中的阻塞队列)
        • [✏️8.3 阻塞队列实现](#✏️8.3 阻塞队列实现)
    • [📕9. 定时器](#📕9. 定时器)
    • 📕10.线程池
        • [✏️10.1 ExecutorService 和 Executors](#✏️10.1 ExecutorService 和 Executors)
        • [✏️10.2 ThreadPoolExecutor](#✏️10.2 ThreadPoolExecutor)
        • [✏️10.3 线程池的工作流程](#✏️10.3 线程池的工作流程)

🌰首先,我们设想以下的一个场景:当一家公司去银行办理业务,既要进行财务转账,⼜要进行福利发放,还得进行缴社保。如果只有张三一个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三又找来两位同事李四、王五⼀起来帮助他,三个人分别负责一个事情,分别申请一个号码进行排队,自此就有了三个执行流共同完成任务,但本质上他们都是为了办理同一家公司的业务。

此时,我们就把这种情况称为多线程,将一个大任务分解成不同小任务,交给不同执行流就分别排队执行。其中李四、王五都是张三叫来的,所以张三⼀般被称为主线程(Main Thread)。

为什么要有线程呢?

  1. 首先, "并发编程" 成为 "刚需" ,

    单核 CPU 的发展遇到了瓶颈. 要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU资源 . 有些任务场景需要 "等待 IO", 为了让等待 IO 的时间能够去做⼀些其他的⼯作, 也需要用到并发编程.

  2. 其次, 虽然多进程也能实现并发编程, 但是线程比进程更轻量

    创建线程比创建进程更快.

    销毁线程比销毁进程更快.

    调度线程比调度进程更快

  3. 最后, 线程虽然比进程轻量, 但是人们还不满足 , 于是又有了 "线程池"(ThreadPool) 和 "协程"

    (Coroutine)

进程和线程的区别?

  1. 进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
  2. 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
  3. 进程是系统分配资源的最小单位,线程是系统调度的最小单位。
  4. 一个进程挂了一般不会影响到其他进程. 但是一个线程挂了, 可能把同进程内的其他线程一起带走(整个进程崩溃).

Java 的线程 和 操作系统线程 的关系?

线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对用户层提供了一些 API 供用户

使用 , Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装.

📕1. Thread类及常见方法

Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的Thread 对象与之关联。

用我们上面的例子来看,每个执行流,也需要有一个对象来描述,类似下图所示,而 Thread 类的对象就是用来描述一个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理。

✏️1.1 创建线程
  1. 继承Thread类
java 复制代码
//继承Thread来创建一个线程类
class MyThread extends Thread{
    @Override
    //重新run方法,run方法中是该线程具体要做的任务
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(i);
        }
    }
}
public class Test {
    public static void main(String[] args) {
        //实例化线程类对象
        MyThread t = new MyThread();
        //通过start()方法启动线程
        t.start();
    }
}
  1. 实现 Runnable 接口
java 复制代码
class MyRunnable implements Runnable{
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(i);
        }
    }
}
public class Test {
    public static void main(String[] args){
        //创建 Thread 类实例, 调⽤ Thread 的构造⽅法时将 Runnable 对象作为 target 参数
        Thread t = new Thread(new MyRunnable());
        t.start();
    }
}
  1. 匿名内部类创建 Thread 子类对象
java 复制代码
public class Test {
    public static void main(String[] args) {
        //匿名内部类创建Thread的子类
        Thread t = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.println(i);
                }
            }
        };
        t.start();
    }
}
  1. 匿名内部类创建 Runnable 子类对象
java 复制代码
public class Test {
    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.println(i);
                }
            }
        });
        t.start();
    }
}
  1. lambda 表达式创建 Runnable 子类对象
java 复制代码
public class Test {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            System.out.println("这是一个用lambda表达式创建的线程");
        });
        t.start();
    }
}

强烈推荐!!!

✏️1.2 Thread 的常见构造方法
方法 说明
Thread() 创建线程对象
Thread(String name) 创建线程对象并命名
Thread(Runnable target , String name) 使用Runnable对象创建线程对象并命名
Thread(Runnable target) 使用Runnable对象创建线程对象
✏️1.3 Thread 的几个常见属性

• ID 是线程的唯一标识,不同线程不会重复

• 名称是什么无所谓,不影响运行,是为了方便调试

• 状态表示线程当前所处的一个情况

• 优先级高的线程理论上来说更容易被调度到

• 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。

• 是否存活,即简单的理解,为 run 方法是否运行结束了

✏️1.4 启动一个线程---start()

我们现在已经知道如何通过覆写 run 方法创建⼀个线程对象,但线程对象被创建出来并不意味着线程就开始运行了。

• 覆写 run 方法是提供给线程要做的事情的指令清单

• 线程对象可以认为是把 李四、王五叫过来了

• 而调用 start() 方法,就是喊一声:"行动起来!",线程才真正独立去执行了。

调用 start 方法, 才真的在操作系统的底层创建出一个线程.

✏️1.5 中断一个线程---interrupt()
  1. 通过一个变量进行标记
java 复制代码
public class Test {
    public static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(){
            @Override
            public void run() {
                while(flag){
                    System.out.println("hello thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t.start();
        System.out.println("hello main");
        Thread.sleep(3000);
        flag = false;
        System.out.println("让线程中断");
    }
}
  1. 调用 interrupt() 方法
java 复制代码
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            // 由于这个 currentThread 方法, 是在后续 t start 之后, 才执行的.
            // 并且是在 t 线程中执行的. 返回的结果就是指向 t 线程对象的引用了.
            while(!Thread.currentThread().isInterrupted()){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        });
        t.start();
        Thread.sleep(2000);
        //调用这个方法,就是把标志位由false改为true
        t.interrupt();
    }
}
//使用interrupt()方法的时候
//1. t线程没有进行sleep()阻塞时,t的isInterrupted()方法返回true,通过循环条件结束循环
//2. t线程进行sleep()阻塞时,t的isInterrupted()方法还是返回true,但是sleep()方法如果被提前唤醒,抛出InterruptedException异常,同时会把isInterrupted()方法设为false,此时就要手动决定是否要结束线程了
✏️1.6 等待一个线程---join()

有时,我们需要等待一个线程完成它的工作后,才能进行自己的下⼀步工作。例如,张三只有等李四转账成功,才决定是否存钱,这时我们需要一个方法明确等待线程的结束。

java 复制代码
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            for (int i = 0; i < 4; i++) {
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        System.out.println("main线程开始了");
        t.join();
        System.out.println("main线程等t线程结束了");
    }
}
✏️1.7 获取当前线程引用
✏️1.8 休眠当前线程
方法 解释
public static native void sleep(long millis) throws InterruptedException; 休眠当前线程 , 以毫米为单位

📕2. 线程的状态

线程的状态是一个枚举类型:

• NEW: 安排了工作, 还未开始行动

• RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作

• BLOCKED: 由于加锁产生的阻塞

• WAITING: 无超时时间的阻塞

• TIMED_WAITING:有超时时间的阻塞

• TERMINATED: 工作完成了

📕3. 线程安全

✏️3.1 线程不安全案例

请大家观察下述代码:

java 复制代码
public class Test {
    private static int count;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50_000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50_000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

大家认为count最终的值会是100_000吗? 不是的,count最终的值是一个小于100_000的随机数.那为什么呢?

线程不安全的概念?

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

✏️3.2 线程不安全的原因
  1. 线程调度是随机的

  2. 修改共享数据

    即多个线程同时修改一个数据

  3. 原子性

什么是原子性?

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的⼈。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。

有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

一条 java 语句不一定是原子的,也不一定只是一条指令

上述代码中的count++对应着3条指令:

  1. load : 从内存把数据读到 CPU
  2. add : 进行数据更新
  3. save : 把数据写回到 CPU

上述三条指令在多线程中就是有问题的指令.如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

将3种指令执行顺序枚举出我们发现:只有第一种和第二种是正确的

  1. 内存可见性

这里主要个大家介绍一下JMM模型,关于可见性内容请大家查阅目录找volatile关键字

Java 内存模型 (JMM---Java Memory Model):

Java虚拟机规范中定义了Java内存模型 , 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

• 线程之间的共享变量存在 主内存 (Main Memory)

• 每一个线程都有自己的 "工作内存" (Working Memory)

• 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.

• 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

因为每个线程有自己的工作内存, 这些工作内存中的内容相当于同⼀个共享变量的 "副本". 这就导致了此时修改线程1 的工作内存中的值, 线程2 的工作内存不⼀定会及时变化.

初始情况下, 两个线程的工作内存内容一致.

一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不⼀定能及时同步

此时就引入了三个问题:

1.为什么要整这么多内存呢?

实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.

所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.

CPU的寄存器和缓存统称为工作内存,越往上,速度越快,空间越小,成本越高

2.为啥要这么麻烦的拷来拷去?

因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级,也就是几千倍, 上万倍).

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了.效率就大大提高了.

3.那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??

答案就是⼀个字: 贵 , 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度⼜远远快于硬盘.

对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜.

  1. 指令重排序

一段代码是这样的:

  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题的,可以少跑⼀次前台。这种叫做指令重排序.

关于指令重排序引发的线程不安全问题请查询目录到单例模式!!!

📕4. synchronized 关键字

✏️4.1 synchronized的特性
  1. 互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同⼀个对象 synchronized 就会阻塞等待.

synchronized用的锁是存在Java对象头里的。

可以粗略理解成, 每个对象在内存中存储的时候, 都存有⼀块内存表示当前的 "锁定" 状态(类似于厕所的 "有人/无人").

如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.

如果当前是 "有人" 状态, 那么其他⼈无法使用, 只能排队

什么是阻塞等待呢?

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程,再来获取到这个锁.

假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁,而是和 C 重新竞争, 并不遵守先来后到的规则.

  1. 可重入

synchronized 同步块对同⼀条线程来说是可重入的,不会出现自己把自己锁死的问题;

什么是自己把自己锁死?

java 复制代码
//第一次加锁,命名为锁1
synchronized (locker){
//第二次尝试加锁,命名为锁2,但是此时加锁要等到锁1释放锁
	synchronized (locker){
		count++;
	}
}
//锁1释放锁的条件锁2中的代码要执行完,这就是自己把自己锁死了

//理解一下这个场景,车钥匙在家里,家门钥匙在车里

但Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.

🌰举个例子:加入我追x姑娘,此时x姑娘处于未加锁状态 , 我可以表白成功 , 其他人也可以表白成功 . 但是如果我表白成功了, 意味着x姑娘就处于加锁状态了 , 其他人在想表白是不可能成功的 , 但是我无论想在表白多少次 , x姑娘都会同意

在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息:

• 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.

• 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

✏️4.2 synchronized 使用示例
  1. 修饰代码块
java 复制代码
public class SynchronizedDemo {
	private Object locker = new Object();
 
 	public void method() {
 		synchronized (locker) {
 
 		}
 	}
}
  1. 直接修饰普通方法
java 复制代码
public class SynchronizedDemo {

 		public synchronized void methond() {
	 	}
}
  1. 修饰静态方法
java 复制代码
public class SynchronizedDemo {

 		public synchronized static void method() {
		}
}

📕5. volatile关键字

  1. 内存可见性
java 复制代码
import java.util.Scanner;

 class Counter {
    public int flag = 0;
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counter count = new Counter();
        Thread t1 = new Thread(()->{
           while (count.flag == 0){
               System.out.println("it is t1 main thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });

        Scanner scanner = new Scanner(System.in);
        Thread t2 = new Thread(()->{
            System.out.println("please input a number");
            count.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

    }
}

在这个代码中:

• 创建两个线程 t1 和 t2

• t1 中包含⼀个循环, 这个循环以 flag == 0 为循环条件.

• t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.

• 预期当用户输入非 0 的值的时候, t1 线程结束

结果发现输入任意一个数字后线程t1并没有停止(这就是一个bug)

这是因编译器自身优化导致的bug,当编译器发现我们频繁load的flag是一个值得时候,就会把flag方法工作内存上,就不再上主内存load了,但是我们突然修改flag的值,主内存修改了,但是t1线程的工作内存并没有修改

代码在写入 volatile 修饰的变量的时候:

• 改变线程工作内存中volatile变量副本的值

• 将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候:

• 从主内存中读取volatile变量的最新值到线程的工作内存中

• 从工作内存中读取volatile变量的副本

前⾯我们讨论JMM模型时说了, 线程直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不一致的情况. 加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.

  1. volatile不能保证原子性

虽然volatile解决了内存可见性,但是volatile不是原子的,我们想解决原子性问题还要synchronized锁,volatile和synchronized是两个不同维度的问题

📕6. wait与notify

因为线程都是抢占式进行的,并没有固定的顺序,是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

例如一场篮球赛 : 我们要让A球员传球 , B球员拿到球后进行投篮

完成这个协调工作, 主要涉及到以下的方法:

• wait() / wait(long timeout): 让当前线程进入等待状态.

• notify() / notifyAll(): 唤醒在当前对象上等待的线程.

注意: wait, notify, notifyAll 都是 Object 类的方法

✏️6.1 wait()方法

wait 做的事情:

• 使当前执行代码的线程进行等待. (把线程放到等待队列中)

• 释放当前的锁

• 满足一定条件时被唤醒, 重新尝试获取这个锁.

注意 : wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.

wait 结束等待的条件:

• 其他线程调用该对象的 notify 方法.

• wait 等待时间超时 (wait 方法提供⼀个带有 timeout 参数的版本, 来指定等待时间).

• 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

✏️6.2 notify()方法

notify 方法是唤醒等待的线程.

• 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。

• 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")

• 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

✏️6.3 notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程

notifyAll 一下全都唤醒, 需要这些线程重新竞争锁

📕7. 单例模式

首先我们要知道 , 什么是设计模式?

设计模式好比象棋中的 "棋谱". 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一些固定的套路. 按照套路来走局势就不会吃亏.

软件开发中也有很多常见的 "问题场景". 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏.

单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例.

单例模式具体的实现方式有很多. 最常见的是 "饿汉" 和 "懒汉" 两种.

  1. 饿汉模式
java 复制代码
//类加载的同时创建实例
class Singleton{
    private Singleton instance = new Singleton();
    private Singleton(){};
    public Singleton getInstance(){
        return instance;
    }
}
  1. 懒汉模式---单线程版
java 复制代码
//类加载的时候不创建实例. 第一次使⽤的时候才创建实例
class Singleton{
    private static Singleton instence = null;
    private Singleton(){};
    public static Singleton getInstance(){
        if (instence == null){
            return new Singleton();
        }
        return instence;
    }
}
  1. 懒汉模式---多线程版❗❗❗
java 复制代码
//使⽤双重 if 判定, 降低锁竞争的频率.
//给 instance 加上了 volatile.
class Singleton{
    private static Object locker = new Object();
    private static volatile Singleton instence = null;
    private Singleton(){};
    public static Singleton getInstance(){
        
        if (instence==null){
            synchronized (locker){
                if (instence == null){
                    return new Singleton();
                }
            }
        }

        return instence;
    }
}

理解双重 if 判定:

加锁 / 解锁是一件开销比较高的事情. 而懒汉模式的线程不安全只是发生在首次创建实例的时候. 因此后续使用的时候, 不必再进行加锁了. 外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了.

当多线程首次调⽤ getInstance, 大家可能都发现 instance 为 null, 于是又继续往下执行来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作.当这个实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住了. 也就不会继续创建其他实例.

📕8. 阻塞队列

什么是阻塞队列?

阻塞队列是一种特殊的队列. 也遵守 "先进先出" 的原则.

阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:

当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.

当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.

阻塞队列的⼀个典型应用场景就是 "生产者消费者模型". 这是一种非常典型的开发模型.

✏️8.1 生产者消费者模型

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

生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.

  1. 阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力. (削峰填谷)
  2. 阻塞队列也能使生产者和消费者之间 解耦.
✏️8.2 标准库中的阻塞队列

在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可

  1. BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.

  2. put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.

  3. BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性.

生产者消费者模型:

java 复制代码
import java.util.Random;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Test {
    public static void main(String[] args) {
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
        Thread producer = new Thread(()->{
            Random random = new Random();
           while (true){
               try {
                   int value = random.nextInt(1000);
                   blockingQueue.put(value);
                   System.out.println("生产了:"+value);
                   Thread.sleep(5000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });

        Thread consumer = new Thread(()->{
           while (true){
               try {
                   int value = blockingQueue.take();
                   System.out.println("消费了:"+value);
                   Thread.sleep(6000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });

        producer.start();
        consumer.start();

    }
}
✏️8.3 阻塞队列实现

• 通过 "循环队列" 的方式来实现.

• 使用 synchronized 进行加锁控制.

• put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一定队列就不满了, 因为同时可能是唤醒了多个线程).

• take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)

java 复制代码
public class BlockingQueue {
 	private int[] items = new int[1000];
 	private volatile int size = 0;
 	private volatile int head = 0;
	 private volatile int tail = 0;
	 
 	public void put(int value) throws InterruptedException {
 		synchronized (this) {
 		// 此处最好使⽤ while.
 		// 否则 notifyAll 的时候, 该线程从 wait 中被唤醒,
 		// 但是紧接着并未抢占到锁. 当锁被抢占的时候, 可能⼜已经队列满了就只能继续等待
 			while (size == items.length) {
 				wait();
 			}
 			items[tail] = value;
 			tail = (tail + 1) % items.length;
 			size++;
 			notifyAll();
		}
	}
	
	public int take() throws InterruptedException {
		int ret = 0;
		synchronized (this) {
			while (size == 0) {
				wait();
			}
			ret = items[head];
			head = (head + 1) % items.length;
			size--;
			notifyAll();
		}
		return ret;
	}

	public synchronized int size() {
		return size;
	}
}

📕9. 定时器

定时器也是软件开发中的一个重要组件. 类似于一个 "闹钟". 达到一个设定的时间之后, 就执行某个指定好的代码.

比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连.

比如⼀个 Map, 希望⾥⾯的某个 key 在 3s 之后过期(自动删除).

类似于这样的场景就需要用到定时器.

标准库中的定时器:

标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule , schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).

java 复制代码
Timer timer = new Timer();

timer.schedule(new TimerTask() {
 	@Override
	public void run() {
 		System.out.println("hello");
 	}
}, 3000);

📕10.线程池

虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效.线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到⼀个 "池子" 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.

✏️10.1 ExecutorService 和 Executors

ExecutorService 表示一个线程池实例.

Executors 是一个工厂类, 能够创建出几种不同风格的线程池.

ExecutorService 的 submit 方法能够向线程池中提交若干个任务.

java 复制代码
 ExecutorService service = Executors.newFixedThreadPool(1);
        service.submit(()->{
            System.out.println("this is a service");
        });

Executors 创建线程池的几种方式:

newFixedThreadPool: 创建固定线程数的线程池

newCachedThreadPool: 创建线程数目动态增长的线程池.

newSingleThreadExecutor: 创建只包含单个线程的线程池

newScheduledThreadPool: 设定延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.

Executors 本质上是 ThreadPoolExecutor 类的封装

✏️10.2 ThreadPoolExecutor

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.

把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)

maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).

keepAliveTime: 临时工允许的空闲时间.

unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.

workQueue: 传递任务的阻塞队列

threadFactory: 创建线程的工厂, 参与具体的创建线程⼯作

RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.

◦ AbortPolicy(): 超过负荷, 直接抛出异常.

◦ CallerRunsPolicy(): 调用者负责处理

◦ DiscardOldestPolicy(): 丢弃队列中最老的任务.

◦ DiscardPolicy(): 丢弃新来的任务.

✏️10.3 线程池的工作流程
相关推荐
坐吃山猪2 小时前
SpringBoot01-配置文件
java·开发语言
我叫汪枫2 小时前
《Java餐厅的待客之道:BIO, NIO, AIO三种服务模式的进化》
java·开发语言·nio
yaoxtao2 小时前
java.nio.file.InvalidPathException异常
java·linux·ubuntu
Swift社区4 小时前
从 JDK 1.8 切换到 JDK 21 时遇到 NoProviderFoundException 该如何解决?
java·开发语言
DKPT5 小时前
JVM中如何调优新生代和老生代?
java·jvm·笔记·学习·spring
phltxy5 小时前
JVM——Java虚拟机学习
java·jvm·学习
seabirdssss6 小时前
使用Spring Boot DevTools快速重启功能
java·spring boot·后端
喂完待续6 小时前
【序列晋升】29 Spring Cloud Task 微服务架构下的轻量级任务调度框架
java·spring·spring cloud·云原生·架构·big data·序列晋升
benben0447 小时前
ReAct模式解读
java·ai