JUC(十一)-线程池-ScheduledThreadPoolExecutor分析

news2024/9/25 13:25:37

ScheduledThreadPoolExecutor 分析

文章目录

  • ScheduledThreadPoolExecutor 分析
    • 一、ScheduledThreadPoolExecutor介绍
    • 二、ScheduledThreadPoolExecutor应用
      • 2.1 ScheduledThreadPoolExecutor 构造器
      • 2.2 ScheduledThreadPoolExecutor 应用代码
    • 三、ScheduledThreadPoolExecutor源码分析
      • 3.1 核心属性
      • 3.2 核心内部类 (和前边一样其实就是让任务具有延时性的类 实现Delayed接口)
      • 3.3 schedule 方法分析
        • 3.3.1 schedule 方法
        • 3.3.2 triggerTime 方法
        • 3.3.3 decorateTask
        • 3.3.4 **delayedExecute**
          • 3.3.4.1 ensurePrestart 正常执行延时任务方法
      • 3.4 scheduleAtFixedRate 方法分析
      • 3.5 scheduleWithFixedDelay 方法分析
      • 3.6 延时任务、周期任务,执行具体逻辑
        • 3.6.1 isPeriodic
        • 3.6.2 setNextRunTime
        • 3.6.3 reExecutePeriodic

一、ScheduledThreadPoolExecutor介绍

从名字就可以看出,当前线程池是用来执行定时任务的线程池

Java早期的定时任务操作是由Timer类提供的,但是Timer有很多问题
他是串行执行的不能像线程池这样搞个子线程去执行、而且可能会影响到其他的定时任务执行

ScheduledThreadPoolExecutor支持延迟执行 以及 周期执行的功能
周期性: 这里的周期性就是将执行完的任务 重新计算下次需要执行的时间,再次把任务扔到阻塞队列中

二、ScheduledThreadPoolExecutor应用

2.1 ScheduledThreadPoolExecutor 构造器

// ScheduledThreadPoolExecutor继承了ThreadPoolExecutor 因此其执行的流程还是ThreadPoolExecutor来的
public class ScheduledThreadPoolExecutor
        extends ThreadPoolExecutor
        implements ScheduledExecutorService {
    
}
/**
 * 调用父类的构造器 
 * 
 * ScheduledThreadPoolExecutor 允许我们最多设置三个参数
 *  - 核心线程数
 *  - 线程工厂
 *  - 拒绝策略
 *  
 *  不需要设置阻塞队列 , 默认提供给我们 DelayedWorkQueue , 类似优先级队列 是一个无界队列 
 *             当核心线程数满了之后 就添加到这个无界队列中 也就没有机会创建非核心线程了,因此不需要设置最大线程数量默认Integer.MAX_VALUE
 *  
 * 
 */
public ScheduledThreadPoolExecutor(int corePoolSize,
                                   ThreadFactory threadFactory,
                                   RejectedExecutionHandler handler) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue(), threadFactory, handler);
}

2.2 ScheduledThreadPoolExecutor 应用代码

public class TestScheduledThreadPoolExecutor {

    public static void main(String[] args) {
        // 1. 构建ScheduledThreadPoolExecutor
        ScheduledThreadPoolExecutor poolExecutor = new ScheduledThreadPoolExecutor(
                5,
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        Thread thread = new Thread(r);
                        return thread;
                    }
                },
                new ThreadPoolExecutor.AbortPolicy()
        );

        // 2.应用ScheduledThreadPoolExecutor

        // 正常的execute方法
        poolExecutor.execute(() -> {
            System.out.println("execute");
        });

        // 延迟两秒执行
        poolExecutor.schedule(() -> {
            System.out.println("schedule");
        },3 , TimeUnit.SECONDS);

        // 第一次延时三秒执行这个任务 后边每两秒执行一次
        // 开始执行时就确认了下次要执行的时间
        poolExecutor.scheduleAtFixedRate(() -> {
            System.out.println("scheduleAtFixedRate");
        },3,2,TimeUnit.SECONDS);

        // 和上边的区别就是 这个方式使用定时任务 是在任务执行完成后才计算的下次执行任务的时间的
        poolExecutor.scheduleWithFixedDelay(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("scheduleWithFixedDelay");
        },3,2,TimeUnit.SECONDS);
        
    }
}

三、ScheduledThreadPoolExecutor源码分析

3.1 核心属性

// 这三个标记 是用于任务取消操作相关的判断
private volatile boolean continueExistingPeriodicTasksAfterShutdown;
private volatile boolean executeExistingDelayedTasksAfterShutdown = true;
private volatile boolean removeOnCancel = false;
// 计数器,如果两个任务的执行时间节点一样,那么就需要根据这个序列来判断谁先谁后
private static final AtomicLong sequencer = new AtomicLong();

3.2 核心内部类 (和前边一样其实就是让任务具有延时性的类 实现Delayed接口)

想要实现延时性 那么就需要实现Delayed接口定义好其 比较规则和延时时间到期的规则 然后才能使用延时队列进行添加任务

ScheduledFutureTask类结构图 在这里插入图片描述

private class ScheduledFutureTask<V>
            extends FutureTask<V> implements RunnableScheduledFuture<V> {
    // 当前任务的 全局唯一的 序列值
    // 如果两个任务时间一致 就根据当前属性判断
    private final long sequenceNumber;
    // 任务执行的时间,单位是纳秒
    private long time;
    /**
     * period == 0 : 说明是只执行一次的任务 不是周期性任务
     * period >  0 : 说明是执行 scheduleAtFixedRate , 以固定周期执行的任务
     * period <  0 : 说明是执行 scheduleWithFixedDelay , 加上任务的执行时间后再执行任务
     */
    private final long period;
    // 周期性执行时 需要将任务重新扔到阻塞队列中,基于当前属性 拿到这个任务
    RunnableScheduledFuture<V> outerTask = this;
    
    int heapIndex;

    /**
     * period == 0 构建的是schedule方法执行的任务
     */
    ScheduledFutureTask(Runnable r, V result, long ns) {
        super(r, result);
        this.time = ns;
        this.period = 0;
        this.sequenceNumber = sequencer.getAndIncrement();
    }

    /**
     * 构建周期性的任务
     */
    ScheduledFutureTask(Runnable r, V result, long ns, long period) {
        super(r, result);
        this.time = ns;
        this.period = period;
        this.sequenceNumber = sequencer.getAndIncrement();
    }
}

3.3 schedule 方法分析

execute 也是调用的schedule方法,只不过延时时间是0纳秒
将任务和时间封装在一起组成一个具有延时性的对象(ScheduleFutureTask) 然后扔到阻塞队列中进行执行

3.3.1 schedule 方法

/** 
 * 延迟任务执行的方法
 * command : 要执行的任务
 * delay : 延迟时间
 * unit  : 延时时间的单位
 */
public ScheduledFuture<?> schedule(Runnable command,long delay,TimeUnit unit) {
    if (command == null || unit == null)
        throw new NullPointerException();
    
    // 将任务和延时时间封装到一起(ScheduledFutureTask延时任务对象)
    // triggerTime方法: 将我们传入的延时时间和单位转换为该任务要执行的时间 
    //                 当前系统时间+设置的延时时间的结果 就是任务要执行的时间
    // ScheduledFutureTask有参构造: 将任务 延时时间封装在一起组成一个延时任务对象
    // decorateTask: 仅仅是进入了一下方法,利用多态返回父类类型
    RunnableScheduledFuture<?> t = 
        decorateTask(
                command,
                new ScheduledFutureTask<Void>(command, null,triggerTime(delay, unit))
        );
    // 封装好延时任务后 执行这个任务
    delayedExecute(t);
    // 返回了FutureTask对象
    return t;
}

3.3.2 triggerTime 方法

private long triggerTime(long delay, TimeUnit unit) {
    // 对延时时间做校验
    // 如果延时时间 < 0 直接设置为0 , 并把延时时间转换为纳秒 , 延时任务的时间<0没有意义
    return triggerTime(unit.toNanos((delay < 0) ? 0 : delay));
}
// 将延时时间+现在系统时间 --> 生成要执行的时间
// 后面的校验是为了避免延时时间超过Long的取值范围
long triggerTime(long delay) {
    return now() +
        ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay));
}
// ==================================
// ScheduledFutureTask有参构造
ScheduledFutureTask(Runnable r, V result, long ns) {
    super(r, result);
    this.time = ns;
    // period == 0 说明是延时执行而不是周期执行
    this.period = 0;
    // 基于AtomicLong生成的唯一序列
    this.sequenceNumber = sequencer.getAndIncrement();
}

3.3.3 decorateTask

// 这个方法直接返回了传入的延时任务 task
// 这个方法用户可根据自己的要求 拓展该方法 对任务进行修改
protected <V> RunnableScheduledFuture<V> decorateTask(
    Runnable runnable, RunnableScheduledFuture<V> task) {
    return task;
}

3.3.4 delayedExecute

// 执行延时任务
private void delayedExecute(RunnableScheduledFuture<?> task) {
    // 查看当前线程池状态 是否是RUNNING
    if (isShutdown())
        // 不是RUNNING状态不接受新任务 , 执行拒绝策略
        reject(task);
    else {
        // 是RUNNING状态
        
        // **这里是直接是把任务放到了延时阻塞队列中**
        // 这里和ThreadPoolExecutor不同的是 不会先创建核心线程 而是先将任务扔到队列中
        super.getQueue().add(task);
        /*
         * 放入任务后再次判断线程池状态
         * 如果加入任务后,线程池状态变为了SHUTDOWN状态,那么此时要针对这个任务来决定还要不要执行
         */
        if (isShutdown() &&
            // task.isPeriodic() 返回true 说明是周期性执行,但是这里periodic==0不是周期执行的 所以这里会返回false , 因此会继续执行下边的判断
            // canRunInCurrentRunState,判断这个周期任务能否执行,方法返回true说明可以执行 再加上! 则不进if逻辑
            // 默认情况下 SHUTDOWN状态下 延时任务可以执行 , 周期任务不能执行
            !canRunInCurrentRunState(task.isPeriodic()) &&
            remove(task))
            task.cancel(false);
        else
            // 线程池状态正常 , 任务可以执行
            ensurePrestart();
    }
}
// 线程池状态是SHUTDOWN时,判断任务是否可以执行的方法
// 延时执行: periodic==false
// 周期执行: periodic==true
// executeExistingDelayedTasksAfterShutdown: 延时任务 在SHUTDOWN状态下默认为true
// continueExistingPeriodicTasksAfterShutdown: 周期任务 在SHUTDOWN状态下默认为false
boolean canRunInCurrentRunState(boolean periodic) {
    return isRunningOrShutdown(periodic ?
                               continueExistingPeriodicTasksAfterShutdown :
                               executeExistingDelayedTasksAfterShutdown);
}
// 属性默认值
private volatile boolean continueExistingPeriodicTasksAfterShutdown;
private volatile boolean executeExistingDelayedTasksAfterShutdown = true;
// shutdownOK : SHUTDOWN下能否执行 这里是true
final boolean isRunningOrShutdown(boolean shutdownOK) {
    int rs = runStateOf(ctl.get());
    // 状态是RUNNING , 则返回true
    // 状态时SHUTDOWN , 最后就根据 shutdownOK 来决定
    return rs == RUNNING || (rs == SHUTDOWN && shutdownOK);
}
3.3.4.1 ensurePrestart 正常执行延时任务方法
// 线程池状态正常可以执行延时任务的方法
void ensurePrestart() {
    // 拿到工作线程数量
    int wc = workerCountOf(ctl.get());
    // 数量少于核心线程 添加核心工作线程 , 只不过不带任务 因为是先向队列中加的任务
    if (wc < corePoolSize)
        addWorker(null, true);
    else if (wc == 0)
        // 走到这说明 设置的核心线程数量是0个 工作线程也是0个 , 因此添加一个非核心线程去处理任务
        addWorker(null, false);
}

3.4 scheduleAtFixedRate 方法分析

将任务先延时执行设置的时间单位后执行,然后按照设置的固定时间间隔周期执行

/**
 * command:任务
 * initialDelay: 第一次执行的延时时间
 * period: 按照此值 周期执行
 * unit: 上边两个时间的单位
 */
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                              long initialDelay,
                                              long period,
                                              TimeUnit unit) {
    if (command == null || unit == null)
        throw new NullPointerException();
    // 周期时间 <=0 没有意义
    if (period <= 0)
        throw new IllegalArgumentException();
    ScheduledFutureTask<Void> sft =
        // 将任务和 第一次执行的延时时间 和后续的周期时间封装在一提起 转换为新的对象
        new ScheduledFutureTask<Void>(command,
                                      null,
                                      triggerTime(initialDelay, unit),
                                      // 这里的时间是 > 0 的
                                      unit.toNanos(period));
    RunnableScheduledFuture<Void> t = decorateTask(command, sft);
    // 周期性任务,需要在任务执行完成之后重新把当前任务扔到阻塞队列,为了拿到任务 将outerTask设置为自己
    sft.outerTask = t;
    // 此时任务是周期任务
    // 如果任务扔到阻塞队列后,线程池状态就变为了SHUTDOWN状态,那么默认情况下周期任务是不能够执行的需要 remove掉此任务
    delayedExecute(t);
    return t;
}

3.5 scheduleWithFixedDelay 方法分析

这个方法和上边at方法的区别 就是传入的周期时间这里转换为了负数,所以核心还是在delayedExecute方法内部来区别这两个方法逻辑

public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                 long initialDelay,
                                                 long delay,
                                                 TimeUnit unit) {
    if (command == null || unit == null)
        throw new NullPointerException();
    if (delay <= 0)
        throw new IllegalArgumentException();
    ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
                                      null,
                                      triggerTime(initialDelay, unit),
                                      // 和At方法相比只有这里不同
                                      unit.toNanos(-delay));
    RunnableScheduledFuture<Void> t = decorateTask(command, sft);
    sft.outerTask = t;
    delayedExecute(t);
    return t;
}

3.6 延时任务、周期任务,执行具体逻辑

ScheduledThreadPoolExecutor中存放的任务都是其内部类 ScheduledFutureTask类型的任务,所以执行ScheduledFutureTask的run方法

// 延时任务和周期任务的执行方法 , 执行到这个方法说明线程池中的工作线程已经把这个任务从阻塞队列中取出来了(延时时间到了)
public void run() {
    // 判断当前任务是否是周期任务 , 此时this指向的就是正在执行的任务
    boolean periodic = isPeriodic();
    // 任务执行前,再次判断线程池状态 能否执行当前任务
    if (!canRunInCurrentRunState(periodic))
        cancel(false);
    // 可以正常执行
    else if (!periodic)
        // 不是周期任务(一次性延时任务) , 让工作线程执行command的逻辑
        ScheduledFutureTask.super.run();
    else if (ScheduledFutureTask.super.runAndReset()) {
        // 是周期任务
        // 设置下次任务的执行时间
        setNextRunTime();
        // 周期任务,将任务重新扔到阻塞队列中
        reExecutePeriodic(outerTask);
    }
}

3.6.1 isPeriodic

// 延时任务 periodic==0
// 周期任务 periodic!=0
public boolean isPeriodic() {
    return period != 0;
}

3.6.2 setNextRunTime

private void setNextRunTime() {
    // 拿到当前任务的period属性 , period>0:执行At方法 , period<0:执行With方法
    long p = period;
    // 执行At方法
    if (p > 0)
        // At方法设置任务下次执行时间为 本次执行时间+周期时间
        time += p;
    // 反之执行With方法
    else
        // With方法则设置当前任务下次执行时间为 当前时间now() + -p(p设置时为负数此时-p为正数) , 即重新计算任务时间 
        time = triggerTime(-p);
}

3.6.3 reExecutePeriodic

void reExecutePeriodic(RunnableScheduledFuture<?> task) {
    // 判断当前周期任务是否可以执行
    if (canRunInCurrentRunState(true)) {
        // 可以执行把 task 扔到阻塞队列中
        super.getQueue().add(task);
        // 再次判断任务能否执行
        if (!canRunInCurrentRunState(true) && remove(task))
            task.cancel(false);
        else
            // 添加工作线程 执行任务
            ensurePrestart();
    }
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/131484.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

10多个LearnDash示例和演示:从这些在线教育网站获得灵感!

正在寻找真实的LearnDash示例来激发您自己的电子学习网站的灵感&#xff1f; LearnDash 是最受欢迎的 WordPress LMS 插件之一&#xff0c;约翰霍普金斯大学和 Collibra 等大型组织以及 Yoast 和 ProBlogger 等小型品牌都在使用它。 LearnDash在线教育网站定制 LearnDash是最…

基于FireBeetle 2 ESP32-E开发板的LVGL移植及传感器显示(Arduino+TFT_eSPI+LVGL)

目录项目介绍硬件介绍硬件结构说明LVGL移植综合实现功能展示项目总结&#x1f449; 【Funpack2-3】基于FireBeetle 2 ESP32-E开发板的LVGL移植及传感器显示 &#x1f449; CSDN-工程源文件 &#x1f449; Github-KafCoppelia/FireBeetle2_lvgl_sensors 项目介绍 本项目基于Fir…

蓝桥杯寒假集训第三天《灌溉》

没有白走的路&#xff0c;每一步都算数&#x1f388;&#x1f388;&#x1f388; 题目描述&#xff1a; 一个长方形块&#xff0c;在方形块的中间位置有给定的水管&#xff0c;这些水管在单位时间会往上下左右四个方向进行灌溉。问在给定的方块中&#xff0c;一定时间后&#…

PyCharm 发布了新版,支持最新 Python 3.11 和 PyScript 框架

通常而言&#xff0c;使用新潮的或者快速发展的技术&#xff0c;可能会挺有挑战性&#xff0c;你可能得经常阅读文档&#xff0c;才能熟悉新的语法、API 和协议。 PyCharm 2022.2 通过提供对 Python 3.11 的语言特性和新的 PyScript 框架的支持&#xff0c;能够帮助你完成这一…

代码随想录算法训练营day59|503.下一个更大元素II,42. 接雨水

503.下一个更大元素II 503. 下一个更大元素 II - 力扣&#xff08;LeetCode&#xff09; 思路&#xff1a;单调栈 1. 单调递增栈&#xff1b;在遍历的过程中模拟走两边nums&#xff1b; class Solution {public int[] nextGreaterElements(int[] nums) {if(numsnull || num…

【MySQL】八,角色管理

创建角色 引入角色的目的是方便管理拥有相同权限的用户。恰当的权限设定&#xff0c;可以确保数据的安全性。 语法 CREATE ROLE role_name[host_name] [,role_name[host_name]]...创建一个经理的角色 create role managerlocalhost;给角色赋予权限 创建角色之后&#xff0…

使用 npm link 测试本地编写的 node 模块 / 引入全局安装的 node 模块

目录 1. npm install VS npm install -g 2. npm install -g 的本质&#xff1f;映射脚本的作用&#xff1f; 3. 如何测试使用未发布的 npm 包&#xff1f;npm link 原理&#xff1f; 4. link 到项目 4.1 全局 link 4.2 解除 link 4.3 link 到项目有两种情况&#xff08;…

ansible 第二天

要求&#xff1a; 安装并且配置ansible 1)安装和配置ansible以及ansible控制节点server.example.com如下&#xff1a; 2)创建一个名为/home/student/ansible/inventory的静态库存文件如下所示&#xff1a; 2.1)node1 是dev主机组的成员 2.2)node2是test主机组的成员 2.3)node1和…

什么是 Loader、手写 Webpack Loader

目录 1. 什么是 Loader 1.1 Loader 工作原理 1.2 Loader 执行顺序 1.3 内联 Loader 前缀​​​​​​​ 2. 如何开发 Loader 2.1 Loader 长什么样子 2.2 配置本地 Loader 的四种方法 2.2.1 在配置 rules 时&#xff0c;指定 Loader 的绝对路径 2.2.2 在 resolveLoader…

Windows配置万德(Wind)量化接口

原理&#xff1a;wind会在python的第三方库中安装一个属于wind的库 文章目录步骤1:确定python的路径步骤2:配置wind的接口步骤3:检查配置步骤4:使用python提取任意的wind数据步骤1:确定python的路径 如果是默认安装&#xff0c;一般路径是&#xff1a;C:\Users\用户名\Anacond…

磨金石教育摄影技能干货分享|优秀作品欣赏—技巧十足的艺术摄影

想要赏析艺术类的摄影&#xff0c;就得立足于画面身后的意蕴&#xff0c;想作者所想&#xff0c;思作者所思。 这有一定的难度&#xff0c;但也不乏趣味。 今天我们就再来看一组艺术类摄影作品&#xff0c;看看作者如何用高明的技巧表达自己心中的感受吧。 1、江苏省-李玉龙-《…

表白墙 -- 前后端代码详解

表白墙 -- 前后端代码详解一、前端二、后端实现2.1 需求2.2 创建项目及初始化2.3 实现提交数据 (存档)2.3.1 实现 doPost2.3.2 构造请求 (修改 html 文件)2.3.3 验证2.4 实现获取数据 (读档)2.4.1 实现 doGet2.4.2 构造请求 (修改 html 文件)2.4.3 验证三、JDBC 版本 (MySQL)3.…

回味2022

回味20221.前言2.过去的十二个月3.我期望的20231.前言 2021年写给自己的总结&#xff1a;回味2021 一年又一年飞逝的光阴&#xff0c;我想唯有时间留给人的印象最为深刻吧。春去秋来&#xff0c;四季轮回间都是时光的印记。2022年12月30日&#xff0c;25岁的我依旧在这间写下2…

从socket开始讲解网络模式

从socket开始讲解网络模式 windows采用IOCP网络模型&#xff0c;而linux采用epoll网络模型&#xff08;Linux得以实现高并发&#xff0c;并被作为服务器首选的重要原因&#xff09;&#xff0c;接下来讲下epoll模型对网络编程高并发的作用 简单的socket连接 socket连接交互的…

LaoCat带你认识容器与镜像(一)

准备更新一个容器与镜像相关的系列&#xff0c;从Docker到K8s的入门再到实际项目进阶应用&#xff0c;这里感谢好朋友泽鹏&#xff0c;是他让我结识容器与镜像&#xff1b;也感谢上家公司菲恩曼&#xff0c;是它给了我去学习、实践的机会&#xff1b;最后感谢翼哥&#xff0c;一…

Linux系统下at任务调度机制

Linux系统下at任务调度机制 基本介绍 at命令是一次性定时计划任务&#xff0c;at 的守护进程 atd 会以后台模式运行&#xff0c;检查作业队列来运行。默认情况下&#xff0c;atd 守护进程每60秒检查作业队列&#xff0c;有作业时&#xff0c;会检查作业运行时间&#xff0c;如果…

深入理解计算机系统_可重定位目标文件的格式---elf格式

本篇笔记记录可重定位目标文件的格式— elf格式&#xff0c;也是《深入理解计算机系统》第7章的内容。了解这些内容&#xff0c;对我们很有帮助&#xff0c;比如代码排错&#xff0c;可以深入了解C/C 实现原理。 分别介绍如何得到可重定位目标文件及其格式。 2.1 如何得到可重…

操作系统~Linux~线程的互斥,mutex互斥锁的使用及其原理

1.一些基本概念 1&#xff0e;临界资源&#xff1a;凡是被线程共享访问的资源都是临界资源&#xff08;多线程、多进程打印数据到显示器&#xff0c;显示器就是临界资源&#xff09; 2&#xff0e;临界区&#xff1a;代码中访问临界资源的代码&#xff08;在代码中&#xff0c;…

kotlin学习笔记之注解与反射

一、声明并应用注解 一个注解允许你把额外的元数据关联到一个声明上。然后元数据就可以被相关的源代码工具访问&#xff0c;通过编译好的类文件或是在运行时&#xff0c;取决于这个注解是如何配置的。 1、应用注解 在kotlin中使用注解的方法和java一样。要应用一个注解&#xf…

如何通过3个月自学成为网络安全工程师!

前言&#xff1a; 趁着今天下班&#xff0c;我花了几个小时整理了下&#xff0c;非常不易&#xff0c;希望大家可以点赞收藏支持一波&#xff0c;谢谢。 我的经历&#xff1a; 我 19 年毕业&#xff0c;大学专业是物联网工程&#xff0c;我相信很多人在象牙塔里都很迷茫&…