多线程系列4-相关代码案例


前言

前面几篇博客主要介绍了多线程相关的基础内容,下面我们开始认识一下几个多线程的相关代码案例。


一、单例模式

1.1 认识单例模式

单例模式是设计模式中的一种。
在计算机圈子里,大家的代码水平参差不齐,那么计算机大佬们为了让我们的代码写的不要太差,也就发明了一种针对代码的 “棋谱”,称为设计模式。
针对一些典型的场景,给出一些典型的解决方案。只要我们熟悉设计模式,并且按照设计模式来开发,此时代码写的也就不会差到哪里去。


单例模式,针对的是单个实例。(对象)

在有些场景下,有些特性的类,只能创建出一个实例,不应该创建多个实例。
当使用了单例模式后,想要创建多个实例,都难。
单例模式就是针对上述场景,进行了更强制性的保证,通过巧用 java 语法,达成了某个类只能被创建出一个实例这样的效果。(当程序员不小心创建了多个实例,就会编译报错)

1.2 单例模式分类

在 java 中,单例模式的实现方式有很多种,我们主要学习两种即可。分别是:饿汉模式、懒汉模式。

1.2.1 饿汉模式

// 饿汉模式的 单例模式 的实现
// 此处保证 SingleTon 只能被实例化一次
class SingleTon {
    // 在此处先把这个实例创建出来
    private static SingleTon instance = new SingleTon();

    // 如果需要使用这个 instance 唯一实例,使用 SingleTon.getInstance() 方法来获取
    public static SingleTon getInstance() {
        return instance;
    }

    // 为了避免 SingleTon 类不小心被复制出多份
    // 构造方法使用 private 修饰,避免在类外实例化这个类
    private SingleTon() {
    }
}

由上面的饿汉模式代码可以看出,instance 实例在类 SingleTon 加载的时候,被创建出来的,这种效果给人一种很急切的感觉。


在这里,如果把 static 给去掉了,会有啥结果?

在这里插入图片描述
此时就意味着,这个 instance 实例的创建,就不知道是啥时候了,得在类外实例化对象才能知道,但是这个类的构造方法又禁用了。所以此处的 static 不能去掉。

1.static 保证这个类唯一。
2.static 保证这实例在一定的时机中被创建出来。

1.2.2 单例模式的懒汉模式

// 单例模式的 懒汉模式 的实现
class SingleTonLazy {
    private static SingleTonLazy instance = null;
    
    public static SingleTonLazy getInstance() {
        if(instance == null) {
            instance = new SingleTonLazy();
        }
        return instance;
    }
    
    private SingleTonLazy() {  
    }
}

上述代码中 instance 实例并非是在类加载的时候创建的,而是第一次真正使用的时候,才去创建。如果不调用 getInstance,这个实例就不使用。

1.3 单例模式的线程安全

上面写的 饿汉模式 和 懒汉模式 在多线程环境下,调用getInstance 方法是否是线程安全的?

1.饿汉模式:在多线程调用 getInstance 方法的时候,只涉及到了读操作,因此不会有线程安全问题。
2.懒汉模式:在多线程调用 getInstance ,涉及到了读和修改操作,可能会创建多个实例,而单例模式是不允许创建多个实例。

下面是两个线程执行懒汉模式。
注意!!! 下面的 new 操作本质上也是 CPU 多条指令,此处将他看成一个整体,不影响对程序的分析。
在这里插入图片描述

1.4 线程安全解决方案

我们可以分析一下懒汉模式出现线程不安全的原因,可以很容易想到,本质上是 load、cmp、new 这三个操作不是原子的,这就导致 t2 读到的值可能是 t1 还没提交的数据。(脏读)
此时,我们就需要对着三个操作进行加锁。如下图:
在这里插入图片描述
此处 t2 读到的值就是 t1 修改完提交的数据,那么 t2 读到的值就是非空的,因此 t2 就不会触发 if 条件,也不会创建新的实例了,也就满足了单例模式的规则。
下面是修改后的代码:

// synchronized 加锁
 public static  SingleTonLazy getInstance() {
        synchronized (SingleTonLazy.class) {
            if(instance == null) {
                instance = new SingleTonLazy();
            }
            return instance;
        }
    }

注意 代码写到这里还有问题。
如果直接在 getIInstance 方法上加锁,那么每个线程调用这个方法的时候,都需要加锁,我们知道,加锁的开销很大,真的需要每次都加锁吗?

仔细想想,这里的锁只是在 new 对象前加上,是有必要的,一旦 new 完了后续调用 getInstance 的时候,instance 实例就不为空了,那么后续的调用就没必要加锁了。

所以针对上面分析,对代码又做出了如下修改:

public static  SingleTonLazy getInstance() {
        if(instance == null) {
            synchronized (SingleTonLazy.class) {
                if(instance == null) {
                    instance = new SingleTonLazy();
                }
            }
        }
        return instance;
    }

此时,对于多个线程就不是 无脑加锁了。如果 instance 实例还没创建出来,就加锁,如果 instance 实例已经创建了,就不加锁。

如果上述两个 if 条件之间没有加锁,连续两个 if 是没有意义的。
但是有了加锁,就不一样了。加锁操作会引起线程的阻塞,当一个线程执行第一个 if 条件,可能会发生阻塞,当这个线程重新获取到锁的时候,这个之间可能已经过了很长一段时间,程序运行的内部状态,这些变量的值,都可能发生很大的改变。


是不是以为代码修改到这里,已经大功告成了,哈哈,你错了,程序到这,还有问题。
注意!! 假设有很多的线程都同时读取 getInstance,那么这个时候,是否会出现程序被 JVM 优化的风险呢?

比如,只有第一次读的时候,是读取的内存中 instance 实例,之后的读取全都是读取寄存器里的值,然而,第一次读取出的 instance 是 null,所以后续读取的全都为 空。

此时就会有 内存可见性问题。


另外,可能还会有 指令重排序问题。

instance = new SingleTonLazy();

这里被拆分成三个步骤:

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

正常情况下,程序是按照 123 这个顺序来执行的。但是编译器还有一手操作,指令重排序,为了提高程序运行效率,编译器可能就会调整代码结构,比如 123 调整为 132,(如果是单线程,123 和 132 没有什么区别)。
但是多线程下就有问题了。

假设线程 t1 按照 132 的顺序执行,t1 执行完 13 之后,被 CPU 切出去了,由 t2 来执行,但是由于 t1 中的 2 还没执行,t2 拿到的引用是一个非法的对象,还没有构造完的对象,如果此时 t2 想去尝试使用这个引用的其他属性,就会出现问题。

所以,针对上述的内存可见性 和 指令重排序,可以使用 volatile 解决!!!

 private static volatile SingleTonLazy instance = null;

二、阻塞队列

2.1 基本概念

阻塞队列也是特殊的队列,除了满足先进先出的规则,还带有特殊的功能。
理解阻塞:

1.如果队列为空,执行出队操作,就会阻塞,阻塞到另一个线程往队列中添加元素(队列不为空) 为止。
2.如果队列满,执行入队操作,也会阻塞,一直阻塞到另一个线程从队列中取走元素(队列不满) 为止。

2.2 生产消费者模型

2.2.1 啥是生产消费者模型

由于阻塞队列的特性,于是我们就可以实现 “生产消费者模型”。
此处,我们先来举个例子:
比如,过年的时候,我们都会包饺子。其中有两种典型的包法。
(1): 每个人先一起擀饺子皮,擀完后,再一起包饺子。
(2): 一个人负责擀饺子皮,另外三个人负责包饺子。


上述哪种方式更科学?
很显然,第二种更科学,因为第一种,擀面杖不够,就会出现有人阻塞等待,影响效率。此时第二种就是 “生产消费者模型”。

在上述的例子中,负责擀饺子皮的人就是生产者,负责包饺子的人就是消费者,放饺子皮的盖帘就是 阻塞队列。
如果负责擀饺子皮的快,盖帘满了,擀饺子皮的就得阻塞等待。
如果他们包饺子包的快,盖帘没了,包饺子的就得阻塞等待。

2.2.2 生产消费者模型好处

生产消费者模型,能给我们的程序带来两个好处。

2.2.1 发送方和接收方的解耦

解耦,就是降低耦合的过程,在咱们写代码的时候,就是要追求 低耦合。
还是一样,举个例子,开发中的典型场景,服务器之间的相互调用。

客户端向 A服务器(入口服务器) 发了一个计费的请求,由于 A 服务器不具备处理业务的能力,A 把请求转给 B服务器处理,B处理完了,把结果返回给 A ,此时就可以视为 “A 调用了 B”。

在这里插入图片描述
在上述场景中,服务器A 和 服务器B 之间的耦合比较高的,A 要调用 B,首先 A 必须要知道 B 是否存在,如果 B 挂了,那么 A 就有可能出 bug 了。
另外,如果此时增加一个 C 服务器,也需要改动 A 服务器不少代码,。因此就需要针对 A 重新修改代码,重新测试,重新发布,比较麻烦!!


因此,为了解决上述 高耦合 问题,可以使用 生产消费者 模型。
在这里插入图片描述

此时,A 和 B 的耦合就降低了很多。
A 服务器是不知道 B服务器 的任何相关信息,A 只知道 队列。(A 的代码中没有任何一条和 B 相关),B 也不知道 A,B 也只知道队列。
如果 B 挂了,对 A 没有任何影响,因为队列还好着,A 还可以正常给队列插入元素。如果队列满了,就先阻塞等待就好了。
如果 A 挂了,对于 B 也没有任何影响,因为队列还好着,B 还可以正常从队列中获取元素。如果队列为空,就先阻塞等待好了。
此时,AB 任何一方挂了,都不会对对方造成影响。新增一个 C服务器,对 A 来说也是无感知的。

2.2.2 削峰填谷

可以保证系统的稳定性。
依旧举个例子:

如下图三峡大坝的位置。
如果上游水多了,三峡大坝,关闸蓄水。此时就相当于三峡大坝承担了上游的冲击,对下游起到了很好的保护作用。【削峰】
如果下游的水少了,三峡大坝,开闸放水。有效保证下游的用水,避免出现干旱灾害。【填谷】

在这里插入图片描述
咱们的服务器开发,也和上述的这个模型非常相似。
咱们的上游,就是用户发送的请求,下游就是,处理各种业务逻辑的服务器。用户发多少请求?不可控的,有时候多,有时候少。


说不定某个瞬间,有很多客户端发来请求,此时服务器没有做好准备,处理不了这么多请求,可能就挂了。使用生产消费者模型就是一个非常有效的手段!!!

2.2 阻塞队列的使用 与实现

前面是一些背景知识,下面我们要完成以下两点:
1.会使用标准库提供的阻塞队列。
2.会自己实现一个简单的阻塞队列。

2.2.1 阻塞队列的使用

我们使用一下标准库提供的阻塞队列。

// 这是标准库提供的阻塞队列
 BlockingDeque<String> blockingDeque = new LinkedBlockingDeque<>();

阻塞队列主要提供了两个方法:put 和 take,都是带有阻塞功能的。

 public static void main(String[] args) throws InterruptedException {
        // 创建一个阻塞队列
        BlockingDeque<String> blockingDeque = new LinkedBlockingDeque<>();
        blockingDeque.put("hello");
        String res = blockingDeque.take();
        System.out.println(res);
        String res1 = blockingDeque.take();
        System.out.println(res1);
    }

在这里插入图片描述
由上述结果可以看出,当阻塞队列为空时,再去元素,就会阻塞等待。


基于阻塞队列,我们还可以写一个生产消费者模型。

public class Thread17 {
    public static int count = 0;
    public static void main(String[] args) {
        // 创建一个阻塞队列,用于生产者生产放数据 和 消费者取数据
        BlockingDeque<Integer> blockingDeque = new LinkedBlockingDeque<>();
        // 创建两个线程,分别为 生产者 和 消费者
        // 生产者生产一个就放在阻塞队列中
        // 消费者每次去阻塞队列中取元素
        // 生产者
        Thread produce = new Thread(() -> {
            while (true) {
                System.out.println("生产者:" + count);
                try {
                    blockingDeque.put(count);
                    count++;
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        produce.start();

        // 消费者
        Thread custom = new Thread(() -> {
            while (true) {
                try {
                    Integer val = blockingDeque.take();
                    System.out.println("消费者:" + val);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        custom.start();
    }
}

在这里插入图片描述

2.2.2 阻塞队列的实现

我们的目的是通过编写阻塞队列的代码,来更好的理解多线程。
1.首先实现一个普通的队列。
可以基于数组实现(玄幻队列),也可以基于链表实现。基于链表很容易进行头插和尾删,只有在尾结点设置一个引用,那么插入和删除时间复杂度都为 O(1)。
在这里,我们使用循环队列实现一个阻塞队列。

// 普通队列
class MyBlockQueue {
    public int[] elem = new int[1000];
    public int head;
    public int tail;
    public int usedSize;

    // 入队列
    public void put(int value) {
        // 判断数组是否满
        if(isFull()) {
            // 满了
            return;
        }
        elem[tail] = value;
        tail = (tail +1) % elem.length;
        usedSize++;
    }
    private boolean isFull() {
        return usedSize == elem.length;
    }

    // 出队列
    public int take() {
        int result = 0;
        // 判断队列是否为空
        if(isEmpty()) {
            // 空了
            return result;
        }
         result = elem[head];
        head = (head + 1) % elem.length;
        usedSize--;
        return result;
    }
    private boolean isEmpty() {
        return usedSize == 0;
    }
}

当前已经完成了普通队列的实现,现在,要加上阻塞功能,称为 阻塞队列。意味着队列要在多线程情况下使用。

// 入队列
    public void put(int value) throws InterruptedException {
        synchronized (this) {
            // 判断数组是否满
            if (isFull()) {
                // 满了
                // 此处队列满了,要进行阻塞
                this.wait();
            }
            elem[tail] = value;
            tail = (tail + 1) % elem.length;
            usedSize++;
            // 入队列完成通知出队列
            this.notify();
        }
    }
    private boolean isFull() {
        return usedSize == elem.length;
    }

 // 出队列
    public int take() throws InterruptedException {
        int result = 0;
        synchronized (this) {
            // 判断队列是否为空
            if (isEmpty()) {
                // 空了
                this.wait();
            }
            result = elem[head];
            head = (head + 1) % elem.length;
            usedSize--;
            // 出队列完成,通知其他线程入队列
            this.notify();
        }
        return result;
    }
    private boolean isEmpty() {
        return usedSize == 0;
    }

注意!!! 这里有个疑问? 上述代码中 put 和 take 中的 wait 是否会同时触发?如果同时触发了,显然就不能相互唤醒了。
答案就是,肯定不会,因为同一个阻塞队列,在同一状态下,不可能既是空又是满。


写到这里,程序还有一个小问题。

 // 判断数组是否满
            if (isFull()) {
                // 满了
                // 此处队列满了,要进行阻塞
                this.wait();
            }

由上述代码,当 wait 被唤醒的时候,这里的 if 条件一定就不成立了吗?

具体来说,put 中的 wait 被唤醒,要求队列不满,但是当 wait 被唤醒后,队列一定是不满的吗?
注意,咱们这里的代码,不会出现这种情况。当前代码一定是取完元素之后,才被唤醒,每次取元素都会唤醒,但是稳妥起见,wait 之后,再次判断一下看此时的条件是否具备了。

这么写,进行二次判断了,但是并不合适,可能第二次 wait 被唤醒了,条件还不满足

 // 判断数组是否满
            if (isFull()) {
                // 满了
                // 此处队列满了,要进行阻塞
                this.wait();
                if(isFull()) {
                    this.wait();
                }
            }

因此,利用循环才是最好的解决办法,标准库推荐的就是这么写。

 // 判断数组是否满
            while (isFull()) {
                // 满了
                // 此处队列满了,要进行阻塞
                this.wait();
            }

三、定时器

3.1 啥是定时器

类似于我们日常生活中的闹钟,到特定时刻或者特定时间段之后,提醒我们。
咱们这里的定时器,不是进行提醒,而是在到了特定时间执行一个实现好的方法/代码。
定时器也是咱们开发中一个重要的组件,尤其是 网络编程 的时候,很容易出现卡了、连不上的情况,此时就可以使用定时器,来进行止损。

3.2 定时器的使用和实现

和阻塞队列相似,标准库给我们也提供了定时器。
可以自定义给任务执行执行的时间。

 public static void main(String[] args) {
        System.out.println("程序启动!");
        // 这个Timer 类就是标准库提供的
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时器任务1");
            }
        },3000);

        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时器任务2");
            }
        },2000);

        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时器任务3");
            }
        },1000);
    }

下面,我们自己实现一个定时器。
1.让被注册的任务在指定的时间,被执行。
2.一个定时器可以注册多个任务,N 个任务会按照事先约定的时间,按照顺序依次执行。
在这里插入图片描述
咱们的定时器里面核心:
1.有一个扫描线程,扫描当前任务是否到了执行时间。
2.还有一个数据结构,优先级阻塞队列,用来存放所有注册的任务。
注意!! 这里的优先级队列会在多线程下使用,很显然,调用 schedule 是一个线程,扫描是另外一个线程,所以要注意线程安全问题。
此外,标准库提供的 PriorityBlockingQueue 是线程安全的。

class MyTask {
    // 要执行的任务内容
    public Runnable task;
    // 任务啥时候执行
    private long time;

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

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

    // 执行当前任务
    public void run() {
        task.run();
    }
}

class MyTimer {
    // 优先级队列,存放被注册的任务
    PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
    // 扫描线程
    Thread t = null;
    public MyTimer() {
        t = new Thread(() -> {
            while (true) {
                try {
                    MyTask myTask = queue.take();
                    if(System.currentTimeMillis() < myTask.getTime()) {
                        // 还没到执行的时间
                        // 重新放入优先级队列中
                        queue.put(myTask);
                        }
                    }else {
                        // 执行时间到了,立即执行
                        myTask.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }

    // 创建注册任务,并放入优先级队列中
    public void schedule(Runnable runnable,long time) {
        MyTask myTask = new MyTask(runnable,System.currentTimeMillis() + time);
        queue.put(myTask);
    }
}

public class Thread19 {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("任务1:");
            }
        },3000);

        myTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("任务2:");
            }
        },2000);

        myTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("任务3:");
            }
        },1000);
    }

上述代码还存在两个致命的问题!!!
1.当前优先级队列保存数据,以什么为优先级。
在这里插入图片描述
仔细想想,不难发现,肯定要以任务执行的时间为优先级。所以,任务的类要实现 Comparable 接口,或者定义一个 Comparator 比较器。

class MyTask implements Comparable<MyTask>{
    // 要执行的任务内容
    public Runnable task;
    // 任务啥时候执行
    private long time;

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

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

    // 执行当前任务
    public void run() {
        task.run();
    }

    // 以当前任务的时间作为优先级
    @Override
    public int compareTo(MyTask o) {
        return (int) (this.time - o.time);
    }
}

2.要执行的任务,时间还没到,一直重复循环等。

 while (true) {
                try {
                    MyTask myTask = queue.take();
                    if(System.currentTimeMillis() < myTask.getTime()) {
                        // 还没到执行的时间
                        // 重新放入优先级队列中
                        queue.put(myTask);
                        }
                    }else {
                        // 执行时间到了,立即执行
                        myTask.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

假设,现在是13:00,队首元素的任务是 14:00,显然此时取出的元素是不能执行的,在这个时间段内,可能这个循环就执行数十亿次。
注意!!! 咱们的队列是优先级队列(堆),put 会触发优先级调整(堆的调整),调整之后,mytask 又回到了队首位置,下次循环取出来的还是这个任务。


针对上述问题,不要进行盲等了,而要进行阻塞式等待。针对扫描线程,做出修改:

    public MyTimer() {
        t = new Thread(() -> {
            while (true) {
                try {
                    MyTask myTask = queue.take();
                    if(System.currentTimeMillis() < myTask.getTime()) {
                        // 还没到执行的时间
                        // 重新放入优先级队列中
                        queue.put(myTask);
                        // 阻塞等待 到 执行
                        synchronized (this) {
                            this.wait(myTask.getTime() - System.currentTimeMillis());
                        }
                    }else {
                        // 执行时间到了,立即执行
                        myTask.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }

在 schedule 里面进行通知操作。

    // 创建注册任务,并放入优先级队列中
    public void schedule(Runnable runnable,long time) {
        MyTask myTask = new MyTask(runnable,System.currentTimeMillis() + time);
        queue.put(myTask);
        // 新创建个任务,都要进行通知
        synchronized (this) {
            this.notify();
        }
    }

代码写到这里,还有问题。还是和 线程安全/随机调度 有关。
在这里插入图片描述
了解了这个问题之后,就不难发现,问题的出现原因是,因为当前的 take 操作 和 wait 操作,并原子的,如果在 take 和 wait 之前加上锁,保证在这个过程中,不会有其他新任务过来,问题就自然解决了。(话句话说,就是每次 notify 的时候,保证已经 wait)

    public MyTimer() {
        t = new Thread(() -> {
            while (true) {
                try {
                    synchronized (this) {
                        long curTime = System.currentTimeMillis();
                        MyTask myTask = queue.take();
                        if(curTime < myTask.getTime()) {
                            // 还没到执行的时间
                            // 重新放入优先级队列中
                            queue.put(myTask);
                            // 阻塞等待 到 执行
                            this.wait(myTask.getTime() - curTime);
                        }else {
                            // 执行时间到了,立即执行
                            myTask.run();
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }

此处只需要把锁的范围放大,方法之后,此时就可以保证说,执行 notify 的时候,wait 确实已经执行完了,可以预防出 notify 的时候,wait 还没执行的情况。

四.线程池

4.1 理解线程池

前面我们讲述了,线程存在的意义是:使用进程来并发编程,太重了(创建/销毁进程)。此时,我们引入线程,线程也被称为 “轻量线程”,创建、销毁
、调度线程比进程速度要快,因此,使用多线程就可以在很多时候替代多进程实现并发编程了。


但是随着并发的程度提高,我们对程序执行的性能要求的提高,咱们买发现,创建线程也没那么轻量了。当我们频繁的创建和销毁线程的时候,开销其实还是很大的。
要想进一步提高这里的效率,想出了两种办法:
1.搞一个 “更轻量级线程” ⇒ 协程
2.使用线程池,来降低创建/销毁线程的开销。
这里,我们是用第二种,线程池的方法。

我们事先把需要创建的线程创建好,放到 “池” 中,后面需要的时候,再去池子里去取,当用完后,再还给池子。
这个操作,比额外创建/销毁线程更高效,创建/销毁线程是操作系统内核完成的。
从池子里获取/还给池子,这是用户代码就能实现的,不必交给操作系统。

举个如下的例子,用户比如去银行办理业务:
在这里插入图片描述

4.2 线程池的使用

下面代码描述的是,创建了一个线程池,里面有10个线程。

ExecutorService service = Executors.newFixedThreadPool(10);

像上述这个操作,使用某个类的静态方法,直接构造出一个对象来(相当于把 new 操作隐藏到这个类方法后面了)。像这样的方法,就称为 “工厂方法”,提供这个工厂方法的类就叫做 “工厂类”,此处的代码就使用了 “工厂模式” 这样的设计方法。

工厂模式:简单来说,就是使用普通方法代替构造方法创建对象。那么为啥要代替构造方法呢?
答案就是,构造方法有坑,坑就体现在,如果只想构造一种对象,那好办,如果构造多种对象,使用构造方法就难搞了。


举个例子,假设有个类,分别想使用 笛卡尔积提供的坐标来构造点 和 使用极坐标来构造点。
在这里插入图片描述
很显然,这个代码有问题,这两个构造方法,正常情况下,是通过 重载 来实现的,重载的要求是:方法名相同,参数的类型,个数或者顺序不同。

为了解决这个问题,就可以使用 工厂模式 来解决。
在这里插入图片描述
此处的普通方法名字没有限制,因此有很多方式构造,就可以使用不同的方法名构造,此时参数是否需要区分,已经不重要了。


上面已经创建好了 10 个线程的线程池,接下来,就可以安排这些线程给我们干活了。
线程池中,提供了一个重要的方法,submit,可以给线程池提交若干个任务。

public static void main(String[] args) {
        // 创建一个 10 个线程的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        // 给线程池中的每个线程安排任务
        for (int i = 0; i < 100; i++) {
            int n = i;
            service.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello" + n);
                }
            });
        }
    }

在这里插入图片描述

运行程序后发现,main 线程结束了,但是整个进程还没结束,因为线程池中的线程都是前台线程,此时会阻止进程结束。(定时器也是同理)
注意!!! 此处我们提交了 100个任务给线程池,100个任务并非是线程池中的10个线程平均分配,每个线程10 个,有可能有的线程多点,有的线程少点。进一步可以认为,这100个任务在队列中排着队,线程池的线程一个个去取任务,取一个就执行一个,执行完了再去取下一个。


此处的代码还涉及到一个 变量捕获 的细节。
在这里插入图片描述

4.2 线程池的构造方法

线程池,本质上都是包装 ThreadPoolExecutor 类来实现的。
下面介绍一下 ThreadPoolExecutor 类的构造方法:
在这里插入图片描述
1.int corePoolSize : 核心线程数。
2.int maximumPoolSize : 最大线程数。

ThreadPoolExecutor 类把线程池的线程分为两类:核心线程(正式员工) 和 非正式核心线程(临时工)。这两类加起来就是最大线程数。

这有一个问题?实际开发中,线程池中的线程数,设定多少合适?

不同的程序特点不同,设置的线程数也不同。
考虑两个极端情况:
1.CPU 密集型,每个要执行的任务都在狂转 CPU,要进行一系列的算术运算,此时线程数最多不超过 CPU 核心数,设置更大,也没啥用。
2.IO 密集型,每个线程要干的工作就是等待IO(读写硬盘,读写网卡,等待用户输入…),不吃 CPU,此时这样的线程处于阻塞状态,不参与 CPU 调度,这个时候,多搞一些线程也无所谓,线程的数量,不再受限于 CPU 核数。

然而,实际开发中的程序并没有符合这两种理想情况,真实的程序,都是一部分线程池 CPU,一部分等待 IO,具体这个任务几成吃Cpu,几成等待IO,不确定的。
因此开发中,确定线程池的线程数量,通过实验的方式。

3.long keepAliveTime : 允许非核心线程(临时工)可以摸鱼的最大时间。

允许核心线程摸鱼(正式员工)摸鱼,不允许非核心线程(临时工)长时间摸鱼,否则这个非核心线程就会被销毁(开除)。
如果任务多,显然需要更多的线程(人手),此时多搞一些线程没问题。但是一个程序不可能始终都很多任务,如果任务少了,线程多,就非常不合适,此时就需要对一些线程进行淘汰。
整体策略:保留核心线程(正式员工),非核心线程(临时工)动态调节。

4.TimeUnit unit : 时间单位(s、ms、分钟…)
5.BlockingQueue workQueue : 线程池中的阻塞任务队列。
6.ThreadFactory threadFactory : 用于创建线程池中的线程。
7.RejectedExecutionHandler handler : 描述线程池的拒绝策略。也是一个有特殊的对象,描述了当前线程池的任务队列满了,如果继续添加任务,会有啥样的行为。


下面是标准库中提供的四个拒绝策略。

在这里插入图片描述
从上往下一次是:
7.1 如果任务太多,队列满了,就直接抛出异常。
7.2 如果任务队列满了,多出来的任务,谁加的就谁执行。
7.3 如果任务满了,丢弃最早的任务。
7.4 如果任务满了,丢弃最新的任务。

4.4 线程池的实现

前面介绍了线程池的背景理论知识,下面自己写代码实现一个固定线程数的线程池。


一个线程池,里面至少包含两个大的部分。
1.阻塞队列,保存任务。
2.若干个工作线程。

class MyThreadPool {
    // 此处不涉及到"时间",此处只有任务,就直接使用 Runnable 即可~~
    private BlockingQueue<Runnable> queue = new LinkedBlockingDeque<>();
    // 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();
        }
    }

    // 注册任务给线程
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

总结

好了,有关于多线程初阶的内容,说到这就结束了,有喜欢小编的内容,或者觉得小编的内容对你有帮助的小伙伴,希望能够点点关注,支持一下小编,小编也会继续再接再厉,给大家带来新的额内容。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值