多线程案例 | 单例模式、阻塞队列、定时器、线程池

多线程案例

1、案例一:线程安全的单例模式

单例模式

单例模式是设计模式的一种

什么是设计模式?

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

软件开发中也有很多常见的 "问题场景",针对一些典型的场景,给出了一些典型的解决方案

有两个设计模式是非常常见的

其一是单例模式 ,其二是工厂模式

单例模式 => 单个 实例 (对象)

在有些场景中,有的特定的类,只能创建出一个实例,不应该创建多个实例

单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例,这种单例模式,在实际开发中是非常常见,也非常有用的,开发中的很多 "概念" 天然就是单例,JDBC,DataSource,这样的对象,就应该是单例的

Java 里实现单例模式的方式有很多,单例模式的两种典型实现:

  • 饿汉模式
  • 懒汉模式

举例:洗碗

1.中午这顿饭,使用了4个碗,吃完之后,立即把这4个碗给洗了~~[饿汉]

⒉中午这顿饭,使用了4个碗,吃完之后,先不洗。晚上这顿,只需要2个碗,然后就只洗2个即可~~[懒汉]

第二种是更加高效的操作,---般是褒义词 (在计算机中提高效率)

饿汉的单例模式,是比较着急地去进行创建实例

懒汉的单例模式,是不太着急地去创建实例,只是在用的时候,才真正创建


1.1、饿汉模式

java 复制代码
private static Singleton instance;

注意:

  1. 类里面使用 static 修饰的成员,应该叫做 "类成员" => "类属性 / 类方法",相当于这个属性对应的内存空间在类对象里面

    不加 static 修饰的成员,叫做 "实例成员" => "实例属性 / 实例方法"

    静态变量 属于类,存储在方法区,随着的类加载而加载,

    成员变量 属于对象,存储在堆中,随着对象的创建而创建

    • static 是让当前 instance 属性是类属性了

    • 一个类对象在一个 Java 进程中是唯一实例的 (JVM保证的),类属性是长在类对象上的,进一步的也就保证了类的 static 成员也是只有一份的

  2. 类对象 != 对象

    类:就相当于实例的模板,基于模板可以创建出很多的对象来

    对象(实例)

    • java 代码中的每个类,都会在编译完成后得到 .class文件,类对象,就是 .class 文件
      JVM 运行时就会加载这个 .class 文件读取其中的二进制指令,并解析,在内存中构造出对应的类对象 (类加载),形如 Singleton.class)
    • 类对象里就有 .class 文件中的一切信息
      包括:类名是啥,类里有哪些属性,每个属性叫啥名字,每个属性叫啥类型,每个属性是 public private...
      基于这些信息,才能实现反射
java 复制代码
// 通过 Singleton 这个类来实现单例模式,保证 Singleton 这个类只有唯一实例
class Singleton {
    // 1.使用 static 创建一个实例,并且立即进行实例化
    //   这个 instance 对应的实例,就是该类的唯一实例
    private static Singleton instance = new Singleton();
  
    // 2.提供一个方法,让外面能够拿到唯一实例
    public static Singleton getInstance() {
        return instance;
    }
    
    // 3.为了防止程序猿在其他地方不小心地 new 这个 Singleton,就可以把构造方法设为 private
    //   把构造方法设为 private.在类外面,就无法通过 new的方式来创建这个 Singleton实例了!
    private Singleton() {};
}

public class demo1 {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        Singleton instance2 = Singleton.getInstance();
        System.out.println(instance == instance2); // true 两个引用相同
    }
}

针对这个唯一实例的初始化,比较着急,类加载阶段,就会直接创建实例

(程序中用到了这个类,就会立即加载)

饿汉模式中 getlnstance,仅仅是读取了变量的内容

如果多个线程只是读同一个变量,不修改,此时仍然是线程安全的


1.2、懒汉模式 - 单线程

java 复制代码
class Singleton2 {
    // 1.就不是立即就初始化实例.
    private static Singleton2 instance = null;

    // 2.把构造方法设为 private
    private Singleton2() {}

    // 3.提供一个方法来获取到上述单例的实例
    //    只有当真正需要用到这个实例的时候,才会真正去创建这个实例
    public static Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }
        return instance;
    }
}

只有在真正使用到 getInstance 的时候才会真的创建实例

一个典型的案例:
notepad 这样的程序,在打开大文件的时候是很慢的 (你要打开一个1G大小的文件,此时 notepad 就会尝试把这 1G 的所有内容都读到内存中 ) [饿汉]

像一些其他的程序,在打开大文件的时候就有优化 (要打开 1G 的文件,但是只先加载这---个屏幕中能显示出来的部分) [懒汉]


1.3、懒汉模式 - 线程安全

真正要解决的问题,是实现一个线程安全的单例模式

线程安全不安全,具体指的是多线程环境下,并发的调用 getInstance 方法,是否可能存在 bug

------懒汉模式 与 饿汉模式 在多线程环境下,是否线程安全?

  • 饿汉模式这里,多线程调用,只是涉及到"读操作"

  • 懒汉模式中,包含读操作和修改操作,存在线程安全问题

上述罗列出了一种可能的排序情况,实际情况是有很多种

通过上述分析,就可以看出,当前这个代码中是存在bug,可能导致实例被创建出多份来

如何保证懒汉模式的线程安全呢?加锁

可不是说,代码中有了 synchronized 就---定线程安全,synchronized 加的位置也得正确,不能随便写

本质是读,比较,写,这三个操作不是原子的。这就导致了 t2 读到的值可能是 t1 还没来得及写的(脏读),导致多次 new;所以要把锁加在外面,此时才能保证 读操作 和 修改操作 是一个整体

使用这里的类对象作为锁对象

(类对象在一个程序中只有唯------份,就能保证多个线程调用 getInstance 的时候都是针对同一个对象进行的加锁)

java 复制代码
public static Singleton2 getInstance() {
    synchronized (Singleton.class) { // 类对象作为锁对象
        if (instance == null) {
            instance = new Singleton2();
        }
    }
    return instance;
}

1.4、懒汉模式 - 锁竞争

当前虽然加锁之后,线程安全问题得到解决了,但是又有了新的问题:

对于刚才这个懒汉模式的代码来说, 线程不安全,是发生在 instance 被初始化之前的 ,未初始化的时候,多线程调用 getinstance,就可能同时涉及到读和修改 ,但是一旦 instance 被初始化之后,后续调用 getlnstance,此时 instance 的值一定是非空的,if 判断不成立,也就线程安全了,因此就会直接触发 return,getlnstance 就只剩下两个读操作,相当于一个是比较操作,一个是返回操作,这两个都是读操作

而按照上述的加锁方式,无论代码是初始化之后,还是初始化之前。加锁是有开销的,每次调用 getinstance 都会进行加锁 ,也就意味着即使是初始化之后 (已经线程安全了),但是仍然存在大量的锁竞争 加锁确实能让代码保证线程安全,也付出了代价 (程序的速度就慢了)

所以为啥不推荐使用 vector hashtable ?? 就是因为这俩类里面就是在无脑加锁

改进方案,在加锁这里再加上一层条件判定即可 ,对象还没创建,才进行加锁;对象创建过了,就不再加锁了,
条件就是当前是否已经初始化完成 (instance == null)

java 复制代码
class Singleton2 {
    // 1.就不是立即就初始化实例.
    private static Singleton2 instance = null;
    // 2.把构造方法设为 private
    private Singleton2() {}
    // 3.提供一个方法来获取到上述单例的实例
    //    只有当真正需要用到这个实例的时候,才会真正去创建这个实例
    public static Singleton2 getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) { // 类对象作为锁对象
                if (instance == null) {
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }
}

这俩条件---模一样,只是一个美丽的巧合而已,这俩条件起到的效果 / 预期的目的是完全不---样的

上面的条件判定的是是否要加锁

下面的条件判定的是是否要创建实例

碰巧这两个目的都是判定 instance 是否为 null

在这个代码中,看起来两个---样的 if 条件是相邻的,但是实际上这两个条件的执行时机 是差别很大的!
加锁可能导致线程阻塞,当执行到锁结束之后,执行到第二个 if 的时候,第二个 if 和第一个 if 之间可能已经隔了很久的时间,沧海桑田。程序的运行内部的状态,这些变量的值,都可能已经发生很大改变了。 如外层条件是 10:16 执行的,里层条件可能是 10:30 执行的,此时 instance 可能已经被其他线程给修改了。

如果去掉了里层的 if 就变成了刚才那个典型的错误代码,加锁没有把读+修改这操作进行打包

java 复制代码
	public static Singleton2 getInstance() {
        if (instance == null) { // 判定的是是否要加锁
            synchronized (Singleton.class) {
                instance = new Singleton2();
            }
        }
        return instance;
    }

1.5、懒汉模式 - 内存可见性 指令重排序

当前这个代码中还存在一个重要的问题

如果多个线程,都去调用这里的 getlnstance

就会造成大量的读 instance 内存的操作 => 可能会让编译器把这个读内存操作优化成读寄存器操作

---旦这里触发了优化,后续如果第一个线程已经完成了针对 instance 的修改,那么紧接着后面的线程都感知不到这个修改仍然把 instance 当成 null

另外,还会涉及到指令重排序问题!!

instance = new Singleton(); 拆分成三个步骤:

1.申请内存空间
2.调用构造方法,把这个内存空间初始化成一个合理的对象
3.把内存空间的地址赋值给 instance 引用

正常情况下,是按照 123 这个顺序来执行的
编译器还有一手操作,指令重排序:为了提高程序效率,调整代码执行顺序

123 这个顺序就可能变成 132

如果是单线程,123 和 132 没有本质区别

例如食堂阿姨打饭,1 是拿盘子,2 是装饭,3 是把盘子给我。此时,就是先把盘子给我,再装饭

但是多线程环境下,就会有问题了!!!

假设 t1 是按照 132 的步骤执行的
t1 执行到 13 之后,执行 2 之前,被切出 cpu,t2 来执行
(当 t1 执行完 3 之后,t2 看起来,此处的引用就非空了),此时此刻,t2 就相当于直接返回了 instance 引用,并且可能会尝试使用引用中的属性
但是由于 t1 中的 2(装饭) 操作还没执行完呢,t2 拿到的是非法的对象,还没构造完成的不完整的对象

解决方法:给 instance 加上 volatile 即可

java 复制代码
// 这个代码是完全体的线程安全单例模式
class Singleton2 {
    // 1.就不是立即就初始化实例.
    private static volatile Singleton2 instance = null;

    // 2.把构造方法设为 private
    private Singleton2() {}

    // 3.提供一个方法来获取到上述单例的实例
    //    只有当真正需要用到这个实例的时候,才会真正去创建这个实例
    public static Singleton2 getInstance() {
        if (instance == null) { // 判定的是是否要加锁
            synchronized (Singleton.class) { // 类对象作为锁对象
                if (instance == null) { // 判定的是是否要创建实例
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }
}

2、案例二:阻塞队列

2.1、生产者消费者模型

队列先进先出

阻塞队列同样也是一个符合先进先出 规则的特殊队列,相比于普通队列,阻塞队列又有一些其他方面的功能!
1、线程安全
2、产生阻塞效果

1). 如果队列为空 ,执行出队列 操作,就会出现阻塞,阻塞到另一个线程往队列里添加元素(队列不为空)为止

2). 如果队列为满 ,执行入队列 操作,也会出现阻塞,阻塞到另一个线程从队列里取走元素(队列不为满)为止

消息队列 ,也是特殊的队列,相当于是在阻塞队列的基础上,加上了个 "消息的类型",按照制定类别进行先进先出

此时咱们谈到的这个消息队列, 仍然是一个 "数据结构"

基于上述特性,就可以实现 "生产者消费者模型"


此处的阻塞队列就可以作为生产者消费者模型中的交易场所

生产者消费者模型,是实际开发中非常有用的一种多线程开发手段!尤其是在服务器开发的场景中

假设,有两个服务器, AB,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,来给A提供一些数据


优点1:解耦合

实现了发送发和接受方之间的解耦

------开发中典型的场景:服务器之间的相互调用

客户端发送一个充值请求给 A 服务器,此时 A 把请求转发给 B 处理,B 处理完了把结果反馈给 A,此时就可以视为是 "A 调用了 B",

如果不使用生产者消费者模型
上述场景中,A 和 B 之间的耦合性是比较高的! A 要调用 B,A 务必要知道 B的存在,如果 B 挂了,很容易引起 A 的 bug !!!(在开发 A 代码的时候就得充分了解到 B 提供的一些接口,开发 B 代码的时候也得充分了解到 A 是怎么调用的)

另外,如果要是再加一个 C 服务器,此时也需要对 A 修改不少代码

因此就需要针对 A 重新修改代码,重新测试,重新发布,重新部署,非常麻烦了

针对上述场景,使用生产者消费者模型,就可以有效的降低耦合

对于请求:A是生产者,B是消费者

对于响应:A是消费者,B是生产者

阻塞队列都是作为交易场所,队列是不变

A 不需要认识 B,只需要关注如何和队列交互 (A 的代码中,没有任何一行代码和 B 相关)
B 不需要认识 A,也只需要关注如何和队列交互 (B 的代码中,也没有任何一行代码和 A 相关)

如果 B 挂了,对于 A 没有任何影响 ,因为队列还好着,A 仍然可以给队列插入元素,如果队列满,就先阻塞就好了,

如果 A 挂了,也对于 B 没有影响,因为队列还好着,B 仍然可以从队列取元素,如果队列空,也就先阻塞就好了
A B 任何一方挂了不会对对方造成影响!!!

新增一个 C 来作为消费者,对于 A 来说,也完全感知不到...


优点2:削峰填谷

能够对于请求进行 "削峰填谷",保证系统的稳定性

------三峡大坝,起到的效果,就是 "削峰填谷"

到了雨季,水流量就会很大,三峡大坝关闸蓄水,承担了上游的冲击,保护下游水流量不是太大,不至于出现洪灾------削峰

到了早季,水流量很小,三峡大坝就开闸放水,给下游提供更充分的水源,避免出现干旱灾害------填谷

什么时候上游涨水,真的是难以预测,防患于未然

上游,就是用户发送的请求。下游就是一些执行具体业务的服务器。
用户发多少请求?不可控的,有的时候,请求多,有的时候请求少...

------未使用生产者消费者模型:

未使用生产者消费者模型的时候,如果请求量突然暴涨 (不可控)

A暴涨 => B暴涨

A 作为入口服务器 ,计算量很轻,请求暴涨,问题不大

B 作为应用服务器 ,计算量可能很大,需要的系统资源也更多,如果请求更多了,需要的资源进一步增加,如果主机的硬件不够,可能程序就挂了

------使用生产者消费者模型:

A 请求暴涨 => 阻塞队列的请求暴涨

由于阻塞队列没啥计算量,就只是单纯的存个数据,就能抗住更大的压力

B 这边仍然按照原来的速度来消费数据,不会因为A的暴涨而引起暴涨,B就被保护的很好,就不会因为这种请求的波动而引起崩溃

"削峰":这种峰值很多时候不是持续的 ,就一阵,过去了就又恢复了

"填谷":B 仍然是按照原有的频率来处理之前积压的数据

实际开发中使用到的 "阻塞队列" 并不是一个简单的数据结构了,而是一个 / 一组专门的服务器程序 ,并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能 (对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数.......)

这样的队列又起了个新的名字,"消息队列 " (未来开发中广泛使用到的组件)

kafka 就是业界一个比较主流的消息队列,消息队列的实现,有很多种,核心功能都差不多


2.2、实现阻塞队列

学会使用 Java 标准库中的阻塞队列 ,基于这个内置的阻塞队列,实现一个简单的生产者消费者模型

再自己**实现一个简单的阻塞队列 **(为了更好地理解阻塞队列的原理,多线程,尤其是锁操作)

标准库中的阻塞队列 BlockingQueue

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

  • BlockingQueue 是一个接口,真正实现的类是 LinkedBlockingQueue

  • Queue 提供的方法有三个:入队列 offer。出队列 poll。取队首元素 peek

    阻塞队列主要方法是两个:入队列 put,出队列 take

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

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

public class demo3 {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
        blockingQueue.put("hello");
        String s1 = blockingQueue.take();
        System.out.println(s1);
        blockingQueue.take();
        String s2 = blockingQueue.take();
        System.out.println(s2);
    }
}

取出 "hello",队列为空,此时再次取元素,就会进入阻塞,等待其他线程往队列中添加元素


生产者消费者模型

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


public class ThreadDemo1 {
    public static void main(String[] args) {
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();

        // 创建两个线程,作为生产者和消费者
        Thread customer = new Thread(() -> {
           while (true) {
               try {
                   Integer result = blockingQueue.take();
                   System.out.println("消费元素:" + result);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        customer.start();

        Thread producer = new Thread(() -> {
            int count = 0;
            while (true) {
                try {
                    blockingQueue.put(count);
                    System.out.println("生产元素:" + count);
                    count++;
                    Thread.sleep(500); // 每500毫秒生产一个
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        producer.start();
    }
}

阻塞队列 - 单线程

要实现一个阻塞队列,需要先写一个普通的队列,再加上线程安全,再加上阻塞

队列可以基于数组 实现,也可以基于链表实现

------链表:很容易进行头删 / 尾插

链表的头删操作,时间复杂度是 O(1)

链表的尾插操作,时间复杂度是 "可以是 O(1)"

用一个额外的引用,记录当前的尾结点

------数组:循环队列

[head, tail) 都指向下标为 0
入队列 ,把新元素放到 tail 位置上,并且 tail++
出队列 ,把 head 位置的元素返回出去,并且 head++

当 head / tail 到达数组末尾 之后,就需要从头开始,重新循环

实现循环队列的时候,有一个重要的问题,如何区分,是空队列还是满队列?

如果不加额外限制,此时队列空或者满都是 head 和 tail 重合

  1. 浪费一个格子,head == tail 认为是空

    head == tail+1 认为是满

  2. 额外创建一个变量,size 记录元素的个数,size == 0 空

    size == arr.length 满

java 复制代码
class MyBlockingQueue {
    // 保存数据的本体
    private int[] items = new int[1000];
    // 队首下标
    private int head = 0;
    // 队尾下标
    private int tail = 0;
    // 有效元素个数
    private int size = 0;

    // 入队列
    public void put(int value) {
        // 1、
        if (size == items.length) {
            // 队列满了,暂时先直接返回
            return;
        }
        // 2、把新的元素放入 tail 位置
        items[tail] = value;
        tail++;
        // 3、处理 tail 到达数组末尾的情况
        if (tail >= items.length) { // 判定 + 赋值 (虽然是两个操作,两个操作都是高效操作)
            tail = 0;
        }
        // tail = tail % data.length; // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
        // 4、插入完成,修改元素个数
        size++;
    }

    // 出队列
    public Integer take() {
        // 1、
        if (size == 0) {
            // 如果队列为空,返回一个非法值
            return null;
        }
        // 2、取出 head 位置的元素
        int ret = items[head];
        head++;
        // 3、head 到末尾 重新等于 0
        if (head >= items.length) {
            head = 0;
        }
        // 4、数组元素个数--
        size--;
        return ret;
    }
}

public class TestDemo {
    public static void main(String[] args) {
        MyBlockingQueue queue = new MyBlockingQueue();
        queue.put(1);
        queue.put(2);
        queue.put(3);
        queue.put(4);
        System.out.println(queue.take()); // 1
        System.out.println(queue.take()); // 2
        System.out.println(queue.take()); // 3
        System.out.println(queue.take()); // 4
    }
}

阻塞队列 - 线程安全

当前已经完成了普通队列的实现,加上阻塞功能 ,阻塞功能意味着,队列要在多线程环境下使用 。保证多线程环境下,调用这里的 put 和 take 没有问题的,

put 和 take 里面的每一行代码都是在操作公共的变量。既然如此,直接就给整个方法加锁即可

(加上 synchronized 已经是线程安全的了)

java 复制代码
// 入队列
public void put(int value) {
    // 此处是把 synchronized 包裹了方法里的所有代码,其实 synchronized 加到方法上,也是一样的效果
    synchronized (this) { // 针对同一个 MyBlockingQueue,进行 put,take 操作时,会产生锁竞争
        if (size == items.length) {
            return;
        }
        items[tail] = value;
        tail++;
        if (tail >= items.length) {
            tail = 0;
        }
        size++;
    }
}

// 出队列
public Integer take() {
    int ret = 0;
    synchronized (this) {
        if (size == 0) {
            return null;
        }
        ret = items[head];
        head++;
        if (head >= items.length) {
            head = 0;
        }
        size--;
    }
    return ret;
}

阻塞队列 - 阻塞

接下来,实现阻塞 效果

关键要点,使用 wait 和 notify 机制

对于 put 来说,阻塞条件,就是队列为满,对于 take 来说,阻塞条件,就是队列为空

针对哪个对象加锁就使用哪个对象 wait, 如果是针对 this 加锁,就 this.wait

put 中的 wait 要由 take 来唤醒,只要 take 成功了一个元素,就队列不满了,就可以进行唤醒了

对于 take 中的等待,条件是队列为空,队列不为空,也就是 put 成功之后,就来唤醒

当前代码中,put 和 take 两种操作不会同时 wait (等待条件是截然不同的,一个是为空,一个是为满)

如果有人在等待,notify 能唤醒,如果没人等待,notify 没有任何副作用

notify 只能唤醒随机的一个等待的线程,不能做到精准

要想精准,就必须使用不同的锁对象

想唤醒 t1,就 o1.notify,让 t1 进行 o1.wait。想唤醒 t2,就 o2.notify,让 t2 进行 o2.wait

当 wait 被唤醒的时候,此时 if 的条件,一定就不成立了嘛?? 具体来说,put 中的 wait 被唤醒,要求,队列不满

但是 wait 被唤醒了之后,队列一定是不满的嘛?

注意,咱们当前代码中,确实不会出现这种情况,当前代码一定是取元素成功才唤醒,每次取元素都会唤醒

但是稳妥起见,最好的办法,是 wait 返回之后再次判定一下,看此时的条件是不是具备了!!

将 if 改为 while,标准库就是建议这么写的

java 复制代码
while (size == items.length) {
    // 队列满了,暂时先直接返回
    // return;
    this.wait();
} 

while (size == 0) {
    // 如果队列为空,返回一个非法值
    // return null;
    this.wait();
}

代码:

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

// 自己写的阻塞队列,此处不考虑泛型,直接使用 int 来表示元素类型了
class MyBlockingQueue {
    // 保存数据的本体
    private int[] items = new int[1000];
    // 队首下标
    private int head = 0;
    // 队尾下标
    private int tail = 0;
    // 有效元素个数
    private int size = 0;

    // 入队列
    public void put(int value) throws InterruptedException {
        synchronized (this) { // 针对同一个 MyBlockingQueue,进行 put,take 操作时,会产生锁竞争
            while (size == items.length) {
                // 队列满了,暂时先直接返回
                // return;
                this.wait();
            }
            // 2、把新的元素放入 tail 位置
            items[tail] = value;
            tail++;
            // 3、处理 tail 到达数组末尾的情况
            if (tail >= items.length) { // 判定 + 赋值 (虽然是两个操作,两个操作都是高效操作)
                tail = 0;
            }
            // tail = tail % data.length; // 代码可读性差,除法速度不如比较,不利于开发效率也不利于运行效率
            // 4、插入完成,修改元素个数
            size++;

            // 如果入队列成功,则队列非空,唤醒 take 中的 wait
            this.notify();
        }
    }

    // 出队列
    public Integer take() throws InterruptedException {
        int ret = 0;
        synchronized (this) {
            while (size == 0) {
                // 如果队列为空,返回一个非法值
                // return null;
                this.wait();
            }
            // 2、取出 head 位置的元素
            ret = items[head];
            head++;
            // 3、head 到末尾 重新等于 0
            if (head >= items.length) {
                head = 0;
            }
            // 4、数组元素个数--
            size--;

            // take 成后,唤醒 put 中的 wait
            this.notify();
        }
        return ret;
    }
}

public class ThreadDemo2 {
    public static void main(String[] args) {
        // 生产者消费者模型
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();

        // 创建两个线程,作为生产者和消费者
        Thread customer = new Thread(() -> {
            while (true) {
                try {
                    Integer result = blockingQueue.take();
                    System.out.println("消费元素:" + result);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        customer.start();

        Thread producer = new Thread(() -> {
            int count = 0;
            while (true) {
                try {
                    blockingQueue.put(count);
                    System.out.println("生产元素:" + count);
                    count++;
                    Thread.sleep(500); // 每500毫秒生产一个
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        producer.start();
    }
}

3、案例三:定时器

3.1、标准库中的定时器 Timer

定时器也是软件开发中的一个重要组件,类似于一个 "闹钟",达到一个设定的时间之后,就唤醒并执行之前设定好的任务

生活中闹钟,有两种风格:1.指定特定时刻,提醒。2.指定特定时间段之后,提醒

这里的定时器,不是提醒,是执行一个实现准备好的方法/代码

定时器是一种实际开发中非常常用的组件

比如网络通信中,很容易出现 "连不上" 的情况,不能一直等,就可以使用定时器来进行 "止损",如果对方 500ms 内没有返回数据,则断开连接尝试重连

比如一个 Map,希望里面的某个 key 在 3s 之后过期(自动删除)

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

join (指定超时时间),sleep (休眠指定时间,是基于系统内部的定时器,来实现的)

先介绍标准库的定时器用法,然后再看看如何自己实现一个定时器

标准库中提供了一个 Timer 类,Timer 类的核心方法为 schedule (安排),这个方法的效果是给定时器,注册一个任务,任务不会立即执行,而是在指定时间进行执行
schedule 包含两个参数 ,第一个参数指定即将要执行的任务代码 (Runnable),第二个参数指定多长时间之后执行 (单位为毫秒)

java 复制代码
import java.util.Timer;
import java.util.TimerTask;

public class demo5 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello time");
            }
        }, 3000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello time2");
            }
        }, 2000);
        System.out.println("main");
    }
}

运行结果:

首先打印:main

几秒后 打印:hello time2

然后打印:hello time

但是程序没有结束

Timer 内部是有专门的线程,来负责执行注册的任务的

Timer 内部都需要:

  1. 管理很多的任务
  2. 执行时间到了的任务

自己实现一个定时器:一个定时器是可以注册 N 个任务的,N 个任务会按照最初约定的时间,按顺序执行

1). 有一个扫描线程,负责判定时间到/执行任务 (单独在定时器内部,搞个线程,让这个线程周期性地扫描,判定任务是否是到时间了,如果到时间了,就执行,没到时间就再等等)

2). 还要有一个数据结构(优先级队列),来保存所有被注册的任务


3.2、描述任务

创建一个专门的类来表示一个定时器中的任务 (TimerTask)

队列中存放的任务就是 Runnable,Runnable 只是描述了任务内容,还需要描述任务什么时候被执行

java 复制代码
// 创建一个类,表示一个任务
class MyTask {
    // 任务具体要做什么
    private Runnable runnable;
    // 任务什么时候执行 (任务要执行的毫秒级时间戳)
    private long time;

    public MyTask(Runnable runnable, long time) {
        this.runnable = runnable;
        this.time = time;
    }
    
    // 获取当前任务时间
    public long getTime() {
        return time;
    }

    // 执行任务
    public void run() {
        runnable.run();
    }
}

3.2、组织任务

使用一定的数据结构把一些任务给放到一起,通过一定的数据结构来组织

假设现在有多个任务过来了---个小时之后,去做作业,三个小时之后,去上课,10分钟之后,去休息---会

安排任务的时候,这些任务的顺序是无序的,但是执行任务的时候,这就不是无序的了,按照时间先后来执行!

咱们的需求就是,能够快速找到所有任务中,时间最小的任务

此时我们发现可以用 ,在标准库中,有一个专门的数据结构 PriorityQueue

咱们这里的每个任务都是带个"时间"多久之后执行,一定是时间越靠前,就先执行

按照时间小的,作为优先级高

此时队首元素 ,就是整个队列中,最先 要执行的任务

虽然队列中的元素顺序,不能完全确定,但是可以知道,队首元素,一定是时间最靠前的

此时,扫描线程,只需要扫一下队首元素即可,不必遍历整个队列

private PriorityQueue<> queue = new PriorityQueue<>();

但是此处的优先级队列要在多线程环境下使用,要考虑到线程安全问题 ,可能在多个线程里进行注册任务,同时还有一个专门的线程来取任务执行,此处的队列就需要注意线程安全问题

所以我们得使用 PriorityBlockingQueue既带有优先级又带有阻塞队列

private PriorityBlockingQueue<> queue = new PriorityBlockingQueue<>();

java 复制代码
// 自己写个简单的定时器
class MyTimer {
    // 扫描线程
    private Thread t = null;

    // 定时器内部要能够存放多个任务 阻塞优先级队列保存任务
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    public MyTimer() {
        // TODO
    }
    
    /** 定时器提供一个 schedule 方法,注册任务
     * @param runnable 要执行的任务
     * @param after 多长时间(毫秒)之后执行
     */
    public void schedule(Runnable runnable, long after) {
        MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);
        queue.put(task); // 任务放入堆
    }
}

------执行时间到了的任务:

需要先执行时间最考前的任务

就需要有一个扫描线程,不停地去检查当前优先队列的队首元素,看看当前最靠前的这个任务是不是时间到了

在定时器构造方法中 创建线程进行扫描

阻塞队列,只能先把元素出队列才好判定,不满足还得放回去

这不像普通队列,可以直接取队首元素判定的

java 复制代码
public MyTimer() {
    t = new Thread(() -> {
        while (true) {
            try {
                // 取出队首元素,再比较这个任务有没有到时间
                MyTask myTask = queue.take();
                long curTime = System.currentTimeMillis();
                if (curTime < (myTask).getTime()) { // 1.没到时间,任务放回堆
                    queue.put(myTask);
                } else { // 2.时间到了,执行任务
                    myTask.run();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.start();
}

3.3、两个缺陷

上述代码中存在两个非常严重的问题:

第---个缺陷: MyTask 没有指定比较规则

像刚才咱们实现的 MyTask 这个类的比较规则,并不是默认就存在的 ,这个需要咱们手动指定,按照时间大小来比较的

标准库中的集合类,很多都是有一定的约束限制的,不是随便拿个类都能放到这些集合类里面去的

------测试:

java 复制代码
public class ThreadDemo {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务1");
            }
        }, 1000);
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务2");
            }
        }, 2000);
    }
}

让 MyTask 类实现 Comparable接口,另外也可以使用 Comparator单独写个比较器

修改:

java 复制代码
class MyTask implements Comparable<MyTask> {        
    @Override
    public int compareTo(MyTask o) {
        return (int) (this.time - o.time);
    }
}

第二个缺陷: 如果不加任何限制,这个循环就会执行的非常快

while (true) 转的太快了, 造成了无意义的 CPU 浪费

如果队列中的任务是空着的,就还好,这个线程就再这里阻塞了 (没问题)

就怕队列中的任务不空,并且任务时间还没到

上述操作,称为 "忙等" ,等确实是等了,但是又没闲着。既没有实质性的工作产出,同时又没有进行休息

等待是要释放 CPU 资源的。让 CPU 干别的事情。但是忙等。既进行了等待。又占用着CPU资源,忙等这种操作是非常浪费 CPU 的。

既然是指定一个等待时间,为啥不直接用 sleep ,而是要再用一下 wait 呢
sleep 不能被中途唤醒的,wait 能够被中途唤醒
在等待过程中,可能要插入新的任务! 新的任务是可能出现在之前所有任务的最前面的,使用 sleep 可能会错过新任务的执行时间

可以基于 wait 这样的机制来实现
wait 有一个版本,指定等待时间 (不需要 notify,时间到了自然唤醒),计算出当前时间和任务的目标之间的时间差,就等待这么长时间即可

schedule 操作中,就需要加上一个 notify 操作。使用 wait 等待,每次有新任务来了 (有人调用 schedule),就 notify 一下,重新检查下时间,重新计算要等待的时间

这样扫描线程既可以指定时间等待,也可以随时唤醒。让等待不占用 CPU,同时不错过新任务

修改:


3.4、问题三:notify 空

代码写到这里,还有个很严重的问题,这个问题,还是和线程安全 / 随机调度密切相关的

考虑一个极端情况:

假设代码执行到 put 这一行,这个线程就从 cpu 调度走了...

当线程回来之后,接下来就要进行 wait 操作,此时 wait 的时间已经是算好了的

比如 curTime 是 13:00,任务 getTime 是 14:00 即将要 wait 1小时 (此时还没执行 wait,因为线程在 put 就被调走了)

此时,另一个线程调用了 schedule 添加新任务,新任务是 13:30 执行

此处调用 schedule 会执行 notify,通知 wait 唤醒

由于扫描线程 wait 还没执行呢!

所以,此处的 notify 不会产生任何的唤醒操作! 此时此刻,新的任务虽然已经插入了队列,新的任务也是在队首紧接着扫描线程回到 cpu了,此时等待时间仍然是 1小时

因此,13:30 新的任务,就被错过了!

了解了上述问题之后,就不难发现,问题出现的原因,是因为当前 take 操作,和 wait 操作,并非是原子的

如果在 take 和 wait 之间加上锁,保证在这个过程中,不会有新的任务过来 ,问题自然解决

(换句话说,只要保证每次 notify 时,确实都正在 wait)

修改:

此处只需要把锁的范围放大,放大之后,此时就可以保证执行 notify 的时候,wait 是确实已经执行完了

就可以预防出现 notify 的时候还没有准备好,wait这样的情况了

代码:

java 复制代码
import java.util.concurrent.PriorityBlockingQueue;

// 创建一个类,表示一个任务
class MyTask implements Comparable<MyTask> {
    // 任务具体要做什么
    private Runnable runnable;
    // 任务什么时候执行 (任务要执行的毫秒级时间戳)
    private long time;

    public MyTask(Runnable runnable, long time) {
        this.runnable = runnable;
        this.time = time;
    }

    // 获取当前任务时间
    public long getTime() {
        return time;
    }

    // 执行任务
    public void run() {
        runnable.run();
    }

    @Override
    public int compareTo(MyTask o) {
        return (int) (this.time - o.time);
    }
}

// 自己写个简单的定时器
class MyTimer {
    // 扫描线程
    private Thread t = null;

    // 定时器内部要能够存放多个任务 阻塞优先级队列保存任务
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    // 扫描线程
    public MyTimer() {
        t = new Thread(() -> {
            while (true) {
                try {
                    synchronized (this) {
                        // 取出队首元素,再比较这个任务有没有到时间
                        MyTask myTask = queue.take();
                        long curTime = System.currentTimeMillis();
                        if (curTime < (myTask).getTime()) { // 1.没到时间,任务放回堆
                            queue.put(myTask);
                            // 在 put 后 wait
                            this.wait(myTask.getTime() - curTime);
                        } else { // 2.时间到了,执行任务
                            myTask.run();
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }

    /** 定时器提供一个 schedule 方法,注册任务
     * @param runnable 要执行的任务
     * @param after 多长时间(毫秒)之后执行
     */
    public void schedule(Runnable runnable, long after) {
        // 注意换算,time 是一个时间戳,不是绝对的时间戳的值
        MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);
        queue.put(task); // 任务放入堆
        // 有新任务加入 notify
        synchronized (this) {
            this.notify();
        }
    }
}

public class ThreadDemo {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务1");
            }
        }, 1000);
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务2");
            }
        }, 2000);
    }
}

运行结果:

java 复制代码
任务1
任务2

总结:

  1. 描述---个任务: runnable + time
  2. 使用优先阻塞队列来组织若干个任务,PriorityBlockingQueue
  3. 实现 schedule 方法来注册任务到队列中
  4. 创建一个扫描线程这个扫描线程不停地获取到队首元素,并且判定时间是否到达
  5. 注意:让 MyTask 类能够支持比较,注意解决这里的忙等问题,notity 时 wait 没有执行问题

4、案例四:线程池

4.1、用户态 / 内核态

进程 ,比较重,频繁创建销毁,开销大

解决方案:进程池 or 线程

线程 (轻量级进程),虽然比进程轻了,创建线程比创建进程更高效;销毁线程比销毁进程更高效;调度线程比调度进程更高效...但是如果创建销毁的频率进一步增加,仍然会发现开销还是有的

解决方案:线程池 or 协程/纤程 (还没有被加入 Java 标准库。Go 内置了协程,因此使用 Go 开发并发编程程序是有一定优势的)

使用线程池,来降低创建/销毁线程的开销

把线程提前创建好,放到池子里

1.后面需要用线程,直接从池子里取,就不必从系统这边申请了。线程用完了,也不是还给系统,而是2.放回池子里,以备下次再用

这两个动作比创建/销毁更高效的

------为森么线程放在池子里,就比从系统这边申请释放来的更快呢?

程序中的"用户态",
用户态执行的是程序猿自己写的代码 ,就在最上面的应用程序这一层来运行的。这里的代码都称为 "用户态" 运行的代码。

程序中的"内核态",
内核会给程序提供一些 API,称为系统调用,有些代码,需要调用操作系统的 API,进一步的逻辑就会在内核中执行,内核态进行的操作都是在操作系统内核中完成的。

例如,调用一个 System.out.println。本质上要经过 write 系统调用,进入到内核中,内核执行---堆逻辑,控制显示器输出字符串...

在内核中运行的代码 ,称为 "内核态" 运行的代码。

创建/销毁线程,需要操作系统内核完成 (创建线程本质是在内核中搞个PCB,加到链表里)

调用的 Thread.start 其实归根结底,也是要进入内核态来运行。

此时你不清楚内核身上背负着多少任务 (内核不是只给你一个应用程序服务,给所有的程序都要提供服务)

因此,当使用系统调用,执行内核代码的时候,无法确定内核都要做哪些工作,整体过程 "不可控" 的

而把创建好的线程放到" 池子里",由于池子就是用户态实现的

这个放到池子 / 从池子取,这个过程不需要涉及到内核态,就是纯粹的用户态代码就能完成

一般认为,纯用户态的操作,效率要比经过内核态处理的操作,要效率更高。

例如:滑稽老铁去银行处理业务,柜员说需要省份证复印件

1、滑稽老铁,自己来到大厅的复印机这里进行复印。纯用户态的操作。(完全自己完成的,整体的过程可控)

2、滑稽老铁,把身份证给柜员,让柜员去帮他复印,这个过程就相当于交给了内核态完成一些工作。(不是自己完成的,整体不可控的)

咱们也不知道柜员身上有多少任务。可能从柜台消失之后,是给你复印去了。

但是他可能还会顺手做一些其他的事情。数一下钱 / 清点一下票据 / 上个厕所 / 回个消息...

认为内核态效率低,倒不是说---定就真的低。而是代码进入了内核态,就不可控了。

内核什么时候给你把活干完,把结果给你。(有的时候快,有的时候慢)


4.2、标准库中的线程池 ThreadPoolExecutor

ThreadPoolExecutor

先学习---下 Java 标准库中,线程池的使用,然后再自己实现一个线程池

标准库的线程池叫做 ThreadPoolExecutor 这个东西用起来有点麻烦

java.util.concurrent (concurrent 并发) 下,

Java 中很多和多线程相关的组件都在这个 concurrent 包里

------构造方法:

(针对 ThreadPoolExecutor 这里的构造方法参数的解释,是高频考点,重点掌握!!!)

java 复制代码
ThreadPoolExecutor(int corePoolSize,
                   int maximumPoolSize, 
                   long keepAliveTime, 
                   TimeUnit unit, 
                   BlockingQueue<Runnable> workQueue, 
                   ThreadFactory threadFactory, 
                   RejectedExecutionHandler handler)
    Creates a new ThreadPoolExecutor with the given initial parameters.
    // 创建一个新 ThreadPoolExecutor 给定的初始参数

int corePoolSize 核心线程数 (正式员工的数量)

int maximumPoolSize 最大线程数 (正式员工 + 临时工)

把一个线程池,想象成是一个"公司",公司里有很多员工在干活

把线程(员工)分成两类:

1、正式员工(核心线程),正式员工允许摸鱼

2、临时工,临时工不允许摸鱼

开始的时候,假设公司要完成的工作不多,正式员工完全就能搞定,就不需要临时工。

如果公司的任务突然猛增了,正式员工加班也搞不定了,就需要雇佣一批临时工 (更多的线程)

但是一个程序任务不一定始终都很多,过了一段时间,工作量又降低了,现在的活正式员工也就能搞定了,甚至还有富裕 (正式员工可以摸鱼了) 临时工就更摸鱼了,就需要对现有的线程(临时工)进行一定的淘汰

整体的策略,正式员工保底,临时工动态调节

long keepAliveTime 允许临时工摸鱼的时间

TimeUnit unit 时间的单位 (s, ms, us...)

BlockingQueue<Runnable> workQueue, 任务队列

线程池会提供一个 submit 方法让程序猿把任务注册到线程池中,加到这个任务队列中

每个工作线程都是再不停尝试 take 的,如果有任务,take 成功,没有,就阻塞。

ThreadFactory threadFactory , 线程工厂类,用于创建线程,线程池是需要创建线程的

RejectedExecutionHandler handler 描述了线程池的 拒绝策略,也是一个特殊的对象,描述了当线程池任务队列满了,如果继续添加任务会有什么样的行为...

以下是标准库提供的四个拒绝策略:

  1. 直接抛异常 RejectedExecutionException
  2. 多出来的任务,谁加的,谁负责执行
  3. 直接丢弃最老的任务
  4. 丢弃最新的任务

比如我现在有很多任务要完成,突然有人给我来了个新的活,但是我已经非常忙,任务队列已经满了,导致我 CPU 烧了,新的活干不了 (1)

我说,我没空,你自己干吧 (2)

放下手里的工作,去做新的活 (3)

拒绝新的活,还是做原有的工作 (4)


线程池中线程的个数:

虽然线程池的参数这么多,但是使用的时候最最重要的参数,还是第一组参数,线程池中线程的个数

------有一个程序,这个程序要 并发的/多线程的 来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适? [不仅仅是面试题,也是工作中需要思考的话题]

针对这个问题,网上的很多说法,是不正确的!

网上一种典型的回答:假设机器有 N 核CPU,线程池的线程数目,就设为 N(CPU 的核数),N + 1,1.2N,1.5N, 2N...
只要能回答出一个具体的数字,都---定是错的!

不同的程序特点不同,此时要设置的线程数也是不同的,

考虑两个极端情况:

  1. CPU 密集型

    每个线程要执行的任务都是狂转 CPU (进行一系列算术运算)

    此时线程池线程数,最多也不应该超过 CPU 核数

    此时如果你设置的更大,也没用

    CPU 密集型任务,要一直占用 CPU,搞那么多线程,但是 CPU 的坑不够了...

  2. IO 密集型

    每个线程干的工作就是等待 IO (读写硬盘,读写网卡,等待用户输入) ------不吃CPU

    此时这样的线程处于阻塞状态,不参与 CPU 调度...

    这个时候多搞一些线程都无所谓, 不再受制于 CPU 核数了

    理论上来说你线程数设置成无穷大都可以 (实际上当然是不行的)

然而,我们实际开发中并没有程序符合这两种理想模型... 真实的程序,往往一部分要吃 CPU,一部分要等待 IO

具体这个程序几成工作量是吃 CPU 的,几成工作量是等待 IO,不确定...

实践中确定线程数量:通过性能测试的方式,找到合适的值

例如,写一个服务器程序,服务器里通过线程池,多线程的处理用户请求,就可以对这个服务器进行性能测试,

比如构造一些请求,发送给服务器,要测试性能,这里的请求就需要构造很多,比如每秒发送 500 / 1000 / 2000...根据实际的业务场景,构造一个合适的值

根据这里不同的线程池的线程数,来观察,程序处理任务的速度,程序持有的 CPU 的占用率,
当线程数多了,整体的速度是会变快,但是 CPU 占用率也会高
当线程数少了,整体的速度是会变慢,但是 CPU 占用率也会下降

需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点

不同类型的程序,因为单个任务,里面 CPU 上计算的时间和阻塞的时间是分布不相同的

因此随意想出来一个数字往往是不靠谱

搞了多线程,就是为了让程序跑的更快嘛,为啥要考虑不让CPU占用率太高呢?
对于线上服务器来说,要留有一定的冗余!随时应对一些可能的突发情况! (例如请求突然暴涨)

如果本身已经把 CPU 快占完了,这时候突然来---波请求的峰值,此时服务器可能直接就挂了


Executors

ThreadPoolExecutor 这个线程池用起来更麻烦一点(提供的功能更强大),所以才提供了工厂类,让我们用着更简单

标准库中提供了一个简化版本的线程池 Executors

本质是针对 ThreadPoolExecutor 进行了封装,提供了一些默认参数

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class demo6 {
    public static void main(String[] args) {
        // 创建一个固定的线程数目的线程池,参数指定了线程的个数
        ExecutorService pool = Executors.newFixedThreadPool(10);
        // 创建一个自动扩扩容的线程池,线程数量动态变化,会根据任务量自动扩容
        Executors.newCachedThreadPool();
        // 创建一个只有一个线程的线程池
        Executors.newSingleThreadExecutor();
        // 创建一个带有定时器功能的线程池,类似于 Timer,只不过执行的时候不是由扫描线程自己执行,而是由单独的线程池来执行
        Executors.newScheduledThreadPool(10);
    }
}

------使用 Executors:

构造出一个 10 个线程的线程池

线程池提供了一个重要的方法 submit 可以给线程池提交若干个任务

把 Runnable 描述的任务提交到线程池里,此时 run 方法不是主线程调用,是由线程池中的 10 个线程中的一个调用

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class demo6 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);
        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello threadPool!");
            }
        });
    }
}

运行结果:

java 复制代码
hello threadPool!

运行程序之后发现,main 线程结束了,但是整个进程没结束,线程池中的线程都是前台线程,此时会阻止进程结束 (前面定时器 Timer 也是同理)

------循环提交 1000 个任务:

java 复制代码
public class ThreadDemo2 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            int n = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello pool! " + n);
                }
            });
        }
    }
}

此处要注意,当前是往线程池里放了 1000 个任务

1000 个任务就是由这 10 个线程来平均分配一下,差不多是一人执行 100 个,但是注意这里并非是严格的平均,可能有的多一个有的少一个,都正常 (随机调度)

(每个线程都执行完一个任务之后,再立即取下一个任务... 由于每个任务执行时间都差不多,因此每个线程做的任务数量就差不多)

进一步的可以认为,这 1000 个任务,就在一个队列中排队呢

这 10 个线程,就依次来取队列中的任务,取一个就执行一个,执行完了之后再执行下一个


工厂模式

ExecutorService pool = Executors.newFixedThreadPool(10);

此处 new 是方法名字的一部分,不是 new 关键字

这个操作,使用某个类的某个静态方法,直接构造出一个对象来 (相当于是把 new 操作,给隐藏到这样的方法后面了)

像这样的方法,就称为"工厂方法"
提供这个工厂方法的类,也就称为"工厂类",此处这个代码就使用了"工厂模式",这种设计模式

工厂模式:---句话表示,使用普通的方法,来代替构造方法,创建对象

为啥要代替?构造方法有坑!!!

坑就体现在,只构造一种对象,好办

如果要构造多种不同情况的对象,就难搞了...

------举个栗子:

有个类,用多种方法构造平面上的一个点

java 复制代码
class Point {
    // 使用笛卡尔坐标系提供的坐标,来构造点
    public Point(double x, double y) {}
	
    // 使用极坐标,来构造点
    public Point(double r, double a) {}
}

很明显,这个代码有问题!!! 正常来说,多个构造方法

是通过"重载"的方式来提供的

重载要求的是,方法名相同,参数的个数或者类型不相同

而上述两个方法,方法名相同,参数个数相同,参数类型相同,无法构成重载,在 Java 上无法正确编译

为了解决这个问题,就可以使用工厂模式:

java 复制代码
class PointFactory {
    public static Point makePointByXY(double x, double y) {}

    public static Point makePointByRA(double r, double a) {}
}
java 复制代码
Point p = PointFactory.makePointByXY(10,20);

普通方法,方法名字没有限制的

因此有多种方式构造,就可以直接使用不同的方法名即可,此时,方法的参数是否要区分,已经不重要了

很多时候,设计模式,是在规避编程语言语法上的坑

不同的语言,语法规则不一样,因此在不同的语言上,能够使用的设计模式,可能会不同,有的设计模式,已经被融合在语言的语法内部了...

咱们日常谈到的设计模式,主要是基于 C++/Java/C# 这样语言来展开的,这里所说的设计模式不一定适合其他语言

像工厂模式,对于 Python 来说没什么价值,Python 构造方法,不像C++/Java 的这么坑,可以直接在构造方法中通过其他手段来做出不同版本的区分

------不能直接使用 i 的原因:

Lambda 变量捕获

很明显,此处的 run 方法属于 Runnable,这个方法的执行时机,不是立刻马上

而是在未来的某个节点 (后续在线程池的队列中,排到他了,就让对应的线程去执行)

fori 循环中的 i,这是主线程里的局部变量 (在主线程的栈上),随着主线程这里的代码块执行结束就销毁了

很可能主线程这里 for 执行完了,当前 run 的任务在线程池里还没排到呢,此时 i 就已经要销毁了

为了避免作用域的差异,导致后续执行 run 的时候 i 已经销毁,

于是就有了变量捕获,也就是让 run 方法把刚才主线程的 i 给往当前 run 的栈上拷贝一份...

(在定义 run 的时候,偷偷把 i 当前的值记住

后续执行 run 的时候,就创建一个也叫做 i 的局部变量,并且把这个值赋值过去...)

在 Java 中,对于变量捕获,做了一些额外的要求

在 JDK 1.8 之前,要求变量捕获,只能捕获 final 修饰的变量,后来发现,这么搞太麻烦了

在 1.8 开始,放松了一点标准,要求不一定非得带 final 关键字,只要代码中没有修改这个变量,也可以捕获

此处,i 是有修改的,不能捕获的

而n是没有修改的,虽然没有 final 修饰,但是也能捕获了

C++, JS 也有类似的变量捕获的语法,但是没有上述限制...


4.3、实现一个线程池

线程池里面有:

  1. 先能够描述任务 (直接使用 Runnable)
  2. 需要组织任务 (直接使用 BlockingQueue)
  3. 能够描述工作线程
  4. 还需要组织这些线程
  5. 需要实现,往线程池里添加任务
java 复制代码
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

// 实现一个固定线程数的线程池
class MyThreadPool {
    // 1、描述一个任务,不像定时器涉及"时间",直接用 Runnable,不需要额外类
    // 2、使用一个数据结构(阻塞队列)来组织若干个任务
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    // 在构造方法中,创建若干个线程 (n 表示线程的数量)
    public MyThreadPool(int n) {
        // 在这里创建线程
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(() -> {
                while (true) { // 从队列中循环地取任务
                    try {
                        // 循环地获取任务队列中的任务,然后执行
                        // 队列为空,直接阻塞。队列非空,就获取内容
                        Runnable runnable = queue.take(); // 获取任务
                        runnable.run(); // 执行任务
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t.start();
        }
    }

    // 创建一个方法,能够允许程序员放任务到线程池中
    // 注册任务给线程池,由这 10 个线程执行
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class TestDemo {
    public static void main(String[] args) {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            int n  = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello " + n);
                }
            });
        }
    }
}

相关推荐
ProtonBase几秒前
如何从 0 到 1 ,打造全新一代分布式数据架构
java·网络·数据库·数据仓库·分布式·云原生·架构
乐之者v7 分钟前
leetCode43.字符串相乘
java·数据结构·算法
suweijie7683 小时前
SpringCloudAlibaba | Sentinel从基础到进阶
java·大数据·sentinel
公贵买其鹿4 小时前
List深拷贝后,数据还是被串改
java
xlsw_7 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹8 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭8 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫9 小时前
泛型(2)
java
超爱吃士力架9 小时前
邀请逻辑
java·linux·后端
南宫生9 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论