Java EE 多线程之多线程案例

文章目录

  • [1. 多线程案例](#1. 多线程案例)
    • [1.1 单例模式](#1.1 单例模式)
      • [1.1.1 饿汉模式](#1.1.1 饿汉模式)
      • [1.1.2 懒汉模式](#1.1.2 懒汉模式)
      • [1.1.3 多线程下的单例模式](#1.1.3 多线程下的单例模式)
    • [1.2 阻塞队列](#1.2 阻塞队列)
      • [1.2.1 阻塞队列定义](#1.2.1 阻塞队列定义)
      • [1.2.2 生产者消费者模型的意义](#1.2.2 生产者消费者模型的意义)
      • [1.2.4 标准库中的阻塞队列](#1.2.4 标准库中的阻塞队列)
      • [1.2.5 实现阻塞队列](#1.2.5 实现阻塞队列)
      • [1.2.6 用阻塞队列实现生产者消费者模型](#1.2.6 用阻塞队列实现生产者消费者模型)
    • [1.3 实现定时器](#1.3 实现定时器)
      • [1.3.1 标准库中的定时器](#1.3.1 标准库中的定时器)
      • [1.3.2 自己实现定时器](#1.3.2 自己实现定时器)
    • [1.4 线程池](#1.4 线程池)
      • [1.4.1 什么是线程池](#1.4.1 什么是线程池)
      • [1.4.2 标准库中的线程池](#1.4.2 标准库中的线程池)
      • [1.4.3 自己实现简答的线程池](#1.4.3 自己实现简答的线程池)

1. 多线程案例

1.1 单例模式

单例模式是校招中最常考的设计模式之⼀

那什么是设计模式呢?

设计模式好⽐象棋中的"棋谱",软件开发中也有很多常⻅的"问题场景".

针对这些问题场景,⼤佬们总结出了⼀些固定的套路,按照这个套路来实现代码,也不会吃亏

设计模式是一种软性规定,遵守设计模式,代码的下限就被兜住了

单例模式就是单个实例(对象)

摸各类,在一个进程中,只应该创建出一个实例,使用单例模式,既可以对代码进行一个更严格的校验和检查

此处介绍两种最基本的实现方式

(1)饿汉模式

(2)懒汉模式

1.1.1 饿汉模式

"饿汉模式"单例模式中一种简单的写法

所谓 "饿" 形容 "非常追切"

实例是在类加载的时候就创建了,创建时机非常早,相当于程序一启动,实例就创建了

就使用 "饿汉" 形容 "创建实例非常迫切,非常早"

java 复制代码
//就期望这个类只有唯一一个实例(一个进程中)
class Singleten {
    private static Singleten instance = new Singleten();
    //这个引用,就是我们期望创建出的唯一的实例引用

    public static Singleten getSingleten() {
        return instance;
    }

    private Singleten() {}
}

public class ThreadDemo26 {
    public static void main(String[] args) {
        Singleten s = Singleten.getSingleten();
        Singleten s2 = Singleten.getSingleten();
        System.out.println(s == s2);
    }
}


其他代码想要使用这个类的实力,就需要通过getSingleten 进行获取,不应该在其他代码中重新 new这个对象,而是使用这个方法获取发哦线程的对象

这个时候其他方法就没法 new ,只能使用 getSingleten

1.1.2 懒汉模式

懒汉模式和饿汉模式相比,创建的时机不太一样

创建实例的时机会更晚,直到第一次使用的时候,才会创建实例

在计算机中,这种懒汉模式很有意义,因为在实际中,加载一个数据可能会很大,但是懒汉模式会只加载一小部分,这样的话会节省不少内存


在创建懒汉模式的时候,先不初始化,先吧初始化设为 null

在下面进入 if 之后,如果是首次调用 getInstance 实例是null,就会创建新的实例

如果是后续再次调用,那么 instance 不是 null,这样就不会创建新的实例

这样设置,也会保证实例只有一个,同时创建实例的时机就是第一次调用 getInstance

java 复制代码
//懒汉模式实现单例模式
class SingletenLazy {
    private static SingletenLazy instance = null;

    public static SingletenLazy getInstance() {
        if (instance == null) {
            instance = new SingletenLazy();
        }
        return instance;
    }

    private SingletenLazy(){}
}

public class ThreadDemo27 {
    public static void main(String[] args) {
        SingletenLazy s1 = SingletenLazy.getInstance();
        SingletenLazy s2 = SingletenLazy.getInstance();
        System.out.println(s1 == s2);
    }
}

1.1.3 多线程下的单例模式

在上面的懒汉模式和饿汉模式里面,哪个是线程安全的呢?

在饿汉模式中,getInstance 直接返回了 Instance 实例,其实只是一个读操作,这是线程安全的

然而在懒汉模式中,在 if 里面读,在new 里面写,这很明显不是线程安全的


懒汉模式详解:

如图所示,在 t1 线程走到 if 的时候,很有可能 t2 线程也开始,并且也进入到了 t2,那么就会进入 t2 new 一个新的实例,然后接着执行,又 new 了一个对象

这个时候,就有了两个对象,因此会是线程不安全的


那么我们如何让懒汉模式变成线程安全的呢?

这里我们就要用到前面所学的 synchronized

但是要注意,synchronized 必须要写到 if 的外面,因为我们需要把 if 和 new 进行打包,这样在 new 新的实例的时候,才能保证县城安全

这样就可以保证,一定是 t1 执行完 new 操作,执行完修改 instance 之后,再回到 t2 执行 if 条件

t2 的 if 条件就不会成立了,t2 直接返回


但是上面的代码还是有问题,因为在创建完第一个实例,后面再次调用 getInstance 就是重复操作,线程本身是不会有现成安全问题的

这个时候每次调用,会让效率很低,因为线程阻塞会导致性能下降,会有很大的影响

这个时候,我们可以在外面套上一层 if ,用来判断我是否需要加锁

这样两个 if 的代码在多线程中是很重要的,由于线程的可调度性,如果不加if,线程可能会出现不同的结果

第一层 if 判断的是是否要加锁

第二层 if 判断的是是否要创建对象

这样就可以保证线程安全和执行效率了

这样的代码就被称为"双重校验锁"


但是,这个代码还是有问题的

这就是"指令重排序",引起的线程问题

什么是"指令重排序"呢?

指令重排序,也是编译器优化的一种方式

就是调整原有的执行顺序,保证逻辑不变的前提下,提高程序的效率

在上述代码中,最容易出现指令重排序的就是 new

这条指令其实包含三个步骤:

  1. 申请一段内存空间
  2. 在这个内存上调用构造方法,常见出这个实例
  3. 把这个内存地址赋给 Instance 引用变量
    正常情况下是 1 2 3 来执行,但是编译器可能会优化为 1 3 2 的循序来执行
    但是如果在多线程就可能会出现问题

    在上图中,t1 只执行了两步,刚给Instance 赋值,这个时候 instance 就已经不是 null 了,但是这个对象依然没有初始化
    所以 进入t2 并不会触发 if 进行加锁,也不会进行堵塞,这样就会直接 return
    但是由于 t1 并没有初始化结束,这个时候使用 instance 里面的属性或者方法,就会出错,导致代码的逻辑出行问题

如果先执行 2 后执行 3,这样的错误就不会出现

那么解决上述问题,我们需要 volatile

volatile 有两个功能:

  1. 保证内存可见性,每次访问变量必须都要重新读取内存,而不会优化到寄存器/缓存中
  2. 禁止指令重排序,针对这个被 volatile 修饰的变量的读写操作相关的只i选哪个,是不能被重排序的

    这个时候这个变量的读写操作,就不会进行重排序了

1.2 阻塞队列

1.2.1 阻塞队列定义

阻塞队列,就是基于普通队列做出的扩展

1、阻塞队列是线程安全的

2、阻塞队列具有阻塞特性

(1)如果针对一个已经满了的队列进行入队列,此时入队列操作就会阻塞,一直阻塞到队列不满(其他线程出队列元素)之后

(2)如果针对一个已经空了的队列进行出队列,此时出队列操作就会阻塞,一直阻塞到队列不空(其他线程入队列元素)之后

阻塞队列的用处很大,基于阻塞队列,就可以实现"生产者消费者模型"

那什么是"生产者消费者模型"呢?

生产者消费者模型描述的是一种多线程编程的方法

比如三个人 a b c 分工协作,a 负责生产,通过服务器传给 b 和 c ,b 和 c 拿 a 生产的东西进行再加工,那么 a 就是生产者,b 和 c 就是消费者,服务器就相当于"阻塞队列"

假如,a 的生产速度很快,b 和 c 很慢,那么这个时候 a 就需要等待 b 和 c,反之亦然,这个特性就是阻塞队列

1.2.2 生产者消费者模型的意义

生产者消费者模型在实际开发中的意义

1、引入生产者消费者模型,就可以更好的做到"解耦合"

在实际开发中,经常会使用到"分布式系统",并且通过服务器之间的网络通信,最终完成整个功能

有的时候入口服务器和用户服务器 和 商品服务器关系太密切,就会导致一处崩溃处处崩溃

这个时候我们就需要使用生产者消费者模型,使用阻塞队列,来降低耦合

当我们引入阻塞队列,最明显的代价,就是余姚增加机器,引入更多的硬件资源

1)上述的阻塞队列,并非是简单的数据结构,而是基于这个数据结构实现的服务器程序,又被部署到单独的主机上了

2)会导致整个系统的结构更加复杂,需要维护的服务器更多了

3)引入了阻塞队列,经过队列的转发,中间是有一定的开销的,会导致性能下降

2、削峰填谷

当服务器遇到了类似像 "三峡大坝遇到降雨量骤增的" 这样的请求骤增的时候,就会进行"削峰填谷"

这里的"峰"和"谷"都不是长时间持续的,而是短时间出现的


如果外网的请求突然骤增,那么入口服务器 A 的请求数量就会增加很多,压力就会变大,那么 B 和 C 的压力也会很大

那么为什么,请求多的时候,服务器就会挂掉?

因为,服务器处理每个请求,都是需要消耗硬件资源的(包括不限于,cpu,内存,硬盘,网络带宽)

即使一个请求消耗的资源比较少,但是请求暴增,总的消耗也会急剧增多,这样服务器就无法反应了


当我们引入阻塞队列/消息队列的时候,情况就会发生改变

阻塞队列:是一种数据结构

消息队列:基于阻塞队列实现服务器程序

这个时候,即使外界请求出现峰值,也是由队列承担请求,后面的依然会按照原来的速度取请求

由于队列只是存储数据,抗压能力是比较强的

但是如果请求不断增加,还是可能会挂的

1.2.4 标准库中的阻塞队列

在 Java 标准库中内置了阻塞队列

• BlockingQueue 是⼀个接⼝,真正实现的类是 LinkedBlockingQueue

• BlockingQueue 下有以下之中类,ArrayBlockingQueue,LinkedBlockingQueue,PriorityBlockingQueue

• put ⽅法⽤于阻塞式的⼊队列,take ⽤于阻塞式的出队列

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


 put 入队列

使用 put 和 offer 一样都是入队列,但是 put 是带有阻塞功能,offer 没有带阻塞(队列满了会返回结果)

 take 出队列

take 方法用来出队列,也是带有阻塞功能的

java 复制代码
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new ArrayBlockingQueue<>(100);
        queue.put("aaa");
        String elem = queue.take();
        System.out.println(elem);
        elem = queue.take();
        System.out.println(elem);
    }

由于 take 是带阻塞的队列,如果队列中没有值就会阻塞,如上述所示,代码打印一行结汇阻塞

1.2.5 实现阻塞队列

步骤:

1、先实现普通队列

2、再加上线程安全

3、再加上阻塞功能

对于第一步,我们使用数组来实现一个环形队列

这个时候我们要注意什么时候队列空 和 队列满

(1)浪费一个给子,tail 最多走到 head 的前一个位置

(2)引入 size 变量(常用)

java 复制代码
class MyBlockingQueue {
    private String[] elems = null;

    private int head = 0;
    private int tail = 0;
    private int size = 0;

    public MyBlockingQueue(int capacity) {
        elems = new String[capacity];
    }

    public void put(String elem) {
        if (size >= elems.length) {
            //队列满了
            //后续需要让这个代码能够阻塞
            return;
        }
        //新的元素放到 tail 指向的位置上
        elems[tail] = elem;
        tail++;
        if (tail >= elems.length) {
            tail = 0;
        }
        size++;
    }

    public String take() {
        if (size == 0) {
            //队列空了
            //后续也要让这个代码阻塞
            return null;
        }
        //取出 head 位置的元素并返回
        String elem = elems[head];
        head++;
        if (head >= elems.length) {
            head = 0;
        }
        size--;
        return elem;
    }
}

public class ThreadDemo29 {
    public static void main(String[] args) {
        MyBlockingQueue queue = new MyBlockingQueue(1000);
        queue.put("aaa");
        queue.put("bbb");
        queue.put("ccc");
        queue.put("ddd");

        String elem = "";
        elem = queue.take();
        System.out.println("elem: " + elem);
        elem = queue.take();
        System.out.println("elem: " + elem);
        elem = queue.take();
        System.out.println("elem: " + elem);
        elem = queue.take();
        System.out.println("elem: " + elem);
    }
}

这个时候我们最基础的队列就写完了


接下来,我们就要引入锁,解决线程安全问题

在 put 里面,if 下面的操作都是"写"操作,必须要用锁包裹起来

上面的 if 操作也是需要写到锁里面的,如果不写,就会导致队列中多加一个

java 复制代码
	public void put(String elem) {
        //锁加到这里和加到方法上本质是一样的,加到方法上是给 this 加锁,此处是给 locker 加锁
        synchronized (locker) {
            if (size >= elems.length) {
                //队列满了
                //后续需要让这个代码能够阻塞
                return;
            }
            //新的元素放到 tail 指向的位置上
            elems[tail] = elem;
            tail++;
            if (tail >= elems.length) {
                tail = 0;
            }
            size++;
        }
    }

    public String take() {
        String elem = null;
        synchronized (locker) {
            if (size == 0) {
                //队列空了
                //后续也要让这个代码阻塞
                return null;
            }
            //取出 head 位置的元素并返回
            elem = elems[head];
            head++;
            if (head >= elems.length) {
                head = 0;
            }
            size--;
            return elem;
        }
    }
}

接下来,我们来考虑如何阻塞

java 复制代码
class MyBlockingQueue {
    private String[] elems = null;

    private int head = 0;
    private int tail = 0;
    private int size = 0;

    //准备一个锁对象
    private Object locker = new Object();

    public MyBlockingQueue(int capacity) {
        elems = new String[capacity];
    }

    public void put(String elem) throws InterruptedException {
        //锁加到这里和加到方法上本质是一样的,加到方法上是给 this 加锁,此处是给 locker 加锁
        synchronized (locker) {
            if (size >= elems.length) {
                //队列满了
                //后续需要让这个代码能够阻塞
                locker.wait();
            }
            //新的元素放到 tail 指向的位置上
            elems[tail] = elem;
            tail++;
            if (tail >= elems.length) {
                tail = 0;
            }
            size++;

            //入队列成功之后进行唤醒
            locker.notify();
        }
    }

    public String take() throws InterruptedException {
        String elem = null;
        synchronized (locker) {
            if (size == 0) {
                //队列空了
                //后续需要让这个代码阻塞
                locker.wait();
            }
            //取出 head 位置的元素并返回
            elem = elems[head];
            head++;
            if (head >= elems.length) {
                head = 0;
            }
            size--;

            //元素出队列之后,进行唤醒
            locker.notify();
            return elem;
        }
    }
}

wait 要加入到 if 中,也要在 synchronized 里面

队列不满地时候,唤醒,也就是在出队列之后,进行唤醒

队列空了,再出队列,同样也需要阻塞,同样是在另一个队列成功后的线程中唤醒

我们的队列,一定是空或者是满的,不能即空又满


但是,上述代码里面依然存在问题,当 A 线程执行 put ,到了 wait 等待

这个时候 B 线程也执行 put ,到了wait 等待接下来 take 一个数,执行到了 notify

这个时候 A 被唤醒,接着往下走

但是 B 很有可能会被 A 代码下面的 notify 给唤醒

这样就出现了错误

这个时候,我们仅需把 if 改成 while 即可

if 只能判断一次

一旦程序进入阻塞,再次被唤醒,中间的时间会非常长,会出现变故

这个时候有了变故之后,就难以保证,你的条件是否仍然满足

如果改成 while 之后,意味着,wait 唤醒之后,再判断一次条件

wait 之前判定一次,唤醒之后再判定一次,相当于多做了一步确认操作

如果再次确认,发现队列还是满的,就继续等待

java 复制代码
class MyBlockingQueue {
    private String[] elems = null;

    private int head = 0;
    private int tail = 0;
    private int size = 0;

    //准备一个锁对象
    private Object locker = new Object();

    public MyBlockingQueue(int capacity) {
        elems = new String[capacity];
    }

    public void put(String elem) throws InterruptedException {
        //锁加到这里和加到方法上本质是一样的,加到方法上是给 this 加锁,此处是给 locker 加锁
        synchronized (locker) {
            while (size >= elems.length) {
                //队列满了
                //后续需要让这个代码能够阻塞
                locker.wait();
            }
            //新的元素放到 tail 指向的位置上
            elems[tail] = elem;
            tail++;
            if (tail >= elems.length) {
                tail = 0;
            }
            size++;

            //入队列成功之后进行唤醒
            locker.notify();
        }
    }

    public String take() throws InterruptedException {
        String elem = null;
        synchronized (locker) {
            while (size == 0) {
                //队列空了
                //后续需要让这个代码阻塞
                locker.wait();
            }
            //取出 head 位置的元素并返回
            elem = elems[head];
            head++;
            if (head >= elems.length) {
                head = 0;
            }
            size--;

            //元素出队列之后,进行唤醒
            locker.notify();
            return elem;
        }
    }
}

这个时候就是正确的阻塞队列代码了

1.2.6 用阻塞队列实现生产者消费者模型

java 复制代码
public static void main(String[] args) {
        MyBlockingQueue queue = new MyBlockingQueue(1000);

        //生产者
        Thread t1 = new Thread(() -> {
            int n = 1;
            while (true) {
                try {
                    queue.put(n + "");
                    System.out.println("生产元素 " + n);
                    n++;
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        //消费者
        Thread t2 = new Thread(() -> {
            while (true) {
                try {
                    String n = queue.take();
                    System.out.println("消费元素 " + n);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();
    }

这里就是一个最简单的生产者模型

在以后得实际开发中,往往还是有多个生产者多个消费者,可有可能这

不简简单单是一个线程,也可能是独立的服务器程序,甚至是一组服务器程序

但是其最核心的还是阻塞队列,使用 synchronized 和 wait/notify 达到线程安全

1.3 实现定时器

定时器是日常开发中常用的组件工具,类似于"闹钟"

设定一个时间,当时间到了的时候,定时器自动的去执行某个逻辑


1.3.1 标准库中的定时器

在我们 java 标准库中是提供了定时器的

在实现定时器之前,我们先来看一下,java 中提供的定时器做了什么

java 复制代码
public class ThreadDemo30 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        //用来添加任务
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                //时间到了之后,要实行的代码
                System.out.println("hello timer 3000");
            }
        },3000);

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

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

        System.out.println("hello main");
    }
}

这里定义一个 timer 添加多个任务,每个任务同时会带有一个时间

这个进程并没有结束,因为 Timer 里内置了前台线程

使用 timer.cancel(); 可以让线程结束


1.3.2 自己实现定时器

这个时候我们想要实现 Timer,里面需要什么内容呢?

1、需要一个线程,负责计算时间,等任务到大合适的时间,这个线程就负责执行

2、需要一个队列/数组,能够保存所有 schedule 进来的任务

这个时候们就要不断的去扫描上述队列的每个元素,到时间再执行

但是如果队列很长,这个开销就会很大

这个时候,我们实现优先级队列会更好

由于每个任务都是有时间的,用优先级队列,就不需要遍历,只看队首元素就可以了

就可以使用标准库提供的 PriorityQueue (线程不安全)

标准库也提供了 PriorityBlockingQueue (线程安全)

推荐使用 PriorityQueue,这个时候可以手动加锁


首先我们先创建一个类

在这个类里面有一点小小的问题

问题就是,这里面没有比较呀,我们需要在这里实现equal 方法

不过本身 Object 提供了这俩方法的实现,但是有些时候,为了让 hash 表更搞笑,需要重写 equals 和 hashCode


这个时候加上了比较方法,但是怎么比较呢

这个时候,最好的方法是试一试


接下来,我们创建好了主线程来进行添加

但是上面还有一个线程进行扫描

这个时候就会导致线程安全问题,我们需要对线程进行加锁

这个时候我们就加上了锁

但是这里我们思考一个问题,是否能把synchronized 放到 while 的外面呢?

是不可以的,因为在主线程中new 一个 MyTumer 的时候就进入了构造方法,进来如果直接加上锁,while又是死循环,这样就永远也解不了锁了


这个时候我们通过加锁解决了线程安全问题,但是我们还有一个问题"线程饿死"

这里代码执行速度非常快,在解锁之后又会重新加锁,那么就会导致其他线程通过 schedule 想要加锁,但是加不上,就导致了线程饿死

这个时候就要引入 wait

首先我们看队列为空的时候,这个时候我们就需要等待,在添加完元素之后就可以唤醒了

接下来,我们再看如果要求唤醒的时间没有到,这个时候也是需要等待的

因为如果没有等待,这个循化会一直执行到时间到,这种代码被称为"忙等 "(虽然是在等待,但是cpu一直在运算)

为了让 cpu 资源可以在别的线程运行的时候可以使用,这个时候我们就可以用 wait

  1. wait 的过程中,有新的任务来了,wait 就会被唤醒,schedule 有 notify 的
    这个时候需要根据新的任务,重新计算等待的时间,因为不知道这个新的任务是不是最早的任务
  2. wait 过程中,没有新的任务,时间到了,执行之前的这个最早的任务即可

这样我们的代码就可以正常运行了

java 复制代码
import java.util.PriorityQueue;

//通过这个类,来描述一个任务
class MyTimerTask implements Comparable<MyTimerTask>{
    //在什么时间点来执行这个任务
    //此处约定这里是一个 ms 级别的时间戳
    private long time;
    //实际任务要执行的代码
    private Runnable runnable;

    public long getTime() {
        return time;
    }

    //delay 期望是一个"相对时间"
    public MyTimerTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        //计算一下真正要执行任务的绝对时间(使用绝对时间,方便判定任务是否到达时间)
        this.time = System.currentTimeMillis() + delay;
    }

    public void run() {
        runnable.run();
    }

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

//通过这个类来表示一个定时器
class MyTimer {
    //负责扫描任务队列,执行任务的线程
    protected Thread t = null;
    //任务队列
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();

    //创建一个锁对象,此处使用this也可以
    private Object locker = new Object();

    public void schedule(Runnable runnable, long delay) {
        synchronized (locker) {
            MyTimerTask task = new MyTimerTask(runnable, delay);
            queue.offer(task);
            //添加新的元素之后,就可以唤醒扫描线程的 wait 了
            locker.notify();
        }
    }

    public void cancel() {
        //结束 t 线程
    }

    //构造方法,创建扫描线程,让扫描线程来完成判定和执行
    public MyTimer() {
        t = new Thread(() -> {
            //扫描线程就需要循环的反复的扫描队首元素,然后判断队首元素是不是时间到了
            //如果时间没到,等待
            //如果时间到了,就执行这个任务并且把这个任务从队列中删掉
            while (true) {
                try {
                    synchronized (locker) {
                        while (queue.isEmpty()) {
                            //暂不处理
                            locker.wait();
                        }
                        MyTimerTask task = queue.peek();
                        //获取到当前的时间
                        long curTime = System.currentTimeMillis();
                        if (curTime >= task.getTime()) {
                            //当前时间已经达到了任务时间,可以执行任务了
                            queue.poll();
                            task.run();
                        } else {
                            //当前时间还没到,暂时不执行
                            //这里不能使用 sleep,一方面是不能释放锁,另一方面是会错过别的任务
                            locker.wait(task.getTime() - curTime);
                        }
                    }//解锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

public class ThreadDemo31 {
    public static void main(String[] args) {
        MyTimer timer = new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        },3000);

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

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

1.4 线程池

1.4.1 什么是线程池

池 是一个非常重要的概念,我们常见的有:常量池、数据库连接池、线程池、进程池、内存池...

其核心内容有两点:

1、提前把要用的对象准备好

2、把用完的对象也不要立即释放,先留着以备下次使用

这样是为了提高效率

进程能够解决并发编程的问题,但是冰饭创建销毁进程,成本太高了,引入了轻量级进程,这就是"线程"

但是如果创建销毁线程的频率进一步提高,这个时候我们就要想办法来优化了

解决方案有两种:

  1. 引入轻量级 线程 -> 也称为纤程/协程
    协程本质,是程序员在用户态代码中进行调度,不是靠内核的调度器调度的,这样就节省了很多调度上的开销
  2. 使用线程池
    把使用的线程提前创建好,用完不直接释放,而是存放起来下次使用,这样就节省了创建/销毁线程的开销
    在这个使用的过程中,并没有真的频繁创建销毁,而只是从线程池里,取线程使用,用完了还给线程池

1.4.2 标准库中的线程池

ThreadPoolExecutor 是标准库中提供的线程池

这个类构造方法有很多参数

  • corePoolSize 核心线程数
    一个线程池中最少有多少线程(相当于正式员工)
  • maximumPoolSize 最大线程数
    一个线程池中最多有多少线程(相当于正式员工+临时工)
  • keepAliveTime 临时工允许的空闲时间
    如果临时工线程空闲,这个时候会等待一段时间,凡是出现下一时刻任务暴增
  • workQueue 传递任务的阻塞队列
    线程池的内部可以持有很多任务,可以通过阻塞队列进行组织
    传入 BlockingQueue,也可传入 PriorityBlockingQueue(这个带有优先级)
  • threadFactory 创建线程的⼯⼚,参与具体的创建线程⼯作
    这里就要提到工厂模式
    工厂模式,也是一种常见的设计模式,通过专门的"工厂类" / "工厂对象"来创建指定的对象
    在 java 中,有的时候我们想创建两种不同的方法来做一种事情,这个时候我们会用到重载,但有的时候重载也会无能为力

    为了解决这种问题,我们就引入了"工厂模式",使用普通的方法来创建对象,就是把构造方法封装了一层

    threadFactory 类里面提供的方法,让方法去封装 new Thread 的操作,并且同时给 Thread 设置一些属性,这样的操作就构成了 threadFactory 线程工厂
  • RejectedExecutionHandler 拒绝策略 (最重要!!!)
    在任务池中,有一个阻塞队列,能够容纳的元素有上限
    当任务队列中已经满了,如果继续往队列中添加任务,那任务池会如何做呢?
    1、AbortPolicy():超过负荷,直接抛出异常
    (新任务、旧任务都不执行)
    2、CallerRunsPolicy():调⽤者负责处理多出来的任务
    (新的任务,由添加任务的线程执行)
    3、DiscardOldestPolicy():丢弃队列中最⽼的任务
    4、DiscardPolicy():丢弃新来的任务
    ThreadPollExceutor 本身用起来比较复杂,因此标准库还提供了另一个版本(Executor),把 ThreadPollExceutor 给封装了起来
    Executor 工厂类,通过这个类创建出来不同的线程对象(在内部把 ThreadPollExceutor 创建好了并且设置了不同的参数)
java 复制代码
public class ThreadDemo32 {
    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(4);
        service.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        });
    }
}


那什么时候使用 Executor(简单使用),什么时候使用 ThreadPollExceutor(高级定制化) 呢?

由于程序的复杂性,很难直接对线程池的线程数量进行估算,更适合的方式就是通过测试的方式找到合适的线程数目,还是要具体情况具体分析

1.4.3 自己实现简答的线程池

写一个固定数量的线程池

  1. 提供一个构造方法,指定创建多少个线程
  2. 在构造方法中,把这些线程都创建好
  3. 有一个阻塞队列,能够持有要执行的任务
  4. 提供 submit 方法,可以添加新的任务

当我们的代码写到最后,发现报错了,这里的 i 为什么会错误呢?

这是因为变量捕获,new Runnable 是一个匿名内部类,而run 是一个回调函数,回调函数访问当前外部作用域的变量就是变量捕获

变量捕获的变量不能是一个变化的变量,需要是一个 final 或者事实 final

这里就需要把代码进行改变

此处的 n 就是一个"事实final" 变量

每循环,都是一个新的 n , n 本身没有改变,这样就可以捕获

java 复制代码
class MyThreadPoolExecutor {
    private List<Thread> threadList = new ArrayList<>();

    //这就是一个用来保存任务的队列
    private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);

    //通过 n 指定创建多少个线程
    public MyThreadPoolExecutor(int n) {
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(() -> {
                //线程要做的事情就是把任务队列中的任务不停的取出来,并且进行执行
                while (true) {
                    try {
                        //此处的 take 带有阻塞功能
                        //如果队列为空,此处的 take 就会阻塞
                        Runnable runnable = queue.take();
                        //取出一个任务就执行一个任务
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t.start();
            threadList.add(t);
        }
    }

    public void submit(Runnable runnable) throws  InterruptedException {
        queue.put(runnable);
    }
}

public class ThreadDemo33 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPoolExecutor executor = new MyThreadPoolExecutor(4);
        for (int i = 0; i < 1000; i++) {
            int n = i;
            executor.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("执行任务" + n + ",当前任务为:" + Thread.currentThread().getName());
                }
            });
        }
    }
}

这里可以看出,多个线程的执行顺序是不确定的,某个线程去到某个任务了,但是并非立即执行,这个过程中可能另一个线程就插到前面了

相关推荐
IT古董3 分钟前
【人工智能】Python在机器学习与人工智能中的应用
开发语言·人工智能·python·机器学习
繁依Fanyi7 分钟前
简易安卓句分器实现
java·服务器·开发语言·算法·eclipse
慧都小妮子18 分钟前
Spire.PDF for .NET【页面设置】演示:打开 PDF 时自动显示书签或缩略图
java·pdf·.net
m512722 分钟前
LinuxC语言
java·服务器·前端
IU宝27 分钟前
C/C++内存管理
java·c语言·c++
湫ccc27 分钟前
《Python基础》之pip换国内镜像源
开发语言·python·pip
瓜牛_gn28 分钟前
依赖注入注解
java·后端·spring
fhvyxyci28 分钟前
【C++之STL】摸清 string 的模拟实现(下)
开发语言·c++·string
hakesashou29 分钟前
Python中常用的函数介绍
java·网络·python
qq_4597300330 分钟前
C 语言面向对象
c语言·开发语言