【操作系统入门到成神系列 五】CPU 是如何执行任务的

2023-05-16

  • ??作者简介:大家好,我是,独角兽企业的Java开发工程师,Java领域新星创作者。
  • ??个人公众号:(回复 “技术书籍” 可获千本电子书籍)
  • ??系列专栏:Java设计模式、数据结构和算法、Kafka从入门到成神、Kafka从成神到升仙、操作系统从入门到成神
  • ??如果文章知识点有错误的地方,请指正!和大家一起学习,一起进步??
  • ??如果感觉博主的文章还不错的话,请??三连支持??一下博主哦
  • ??博主正在努力完成2022计划中:以梦为马,扬帆起航,2022追梦人

文章目录

    • CPU 是如何执行任务的
      • 一、引言
      • 二、CPU 如何读写数据的?
        • 1. 分析伪共享问题
        • 2. 避免伪共享的方法
      • 三、CPU 是如何选择线程的
        • 1. 调度类
        • 2. 完全公平调度
        • 3. CPU 运行队列
        • 4. 调整优先级
      • 四、总结

CPU 是如何执行任务的

首先,我们先提出几个问题:

  • 有了内存,为什么还需要 CPU Cache?
  • CPU 是怎么读写数据的?
  • 如何让 CPU 读写数据更快一点?
  • CPU 伪共享是如何发生的?又该如何避免?
  • CPU 的调度任务是如何进行的?

img

一、引言

本文参考 小林coding 的《图解操作系统》,也是我十分喜欢的一个公众号博主,为他打 call

老读者知道我之前再写 Kafka 的博文,为什么突然开始写操作系统的呢?

原因在于:

当我看到 Kafka 服务端的一些 IO 操作时,我发现我看不懂了,了解之后发现这里 Netty 的概念。

当我尝试了解 IO 时,我发现一些内存、磁盘的交换,搞的我焦头烂额,于是,想静下心来从头开始。

当我把 小林coding 的 《图解操作系统》看完之后,我发现对操作系统的理解更上一层楼。

用一段话,作为今天的开场白:

读书的根本目的,未必是解决现实问题,它更像一场心灵的抚慰。
一个喜欢读书的人,可能不会记得自己读过哪些书。
但是那些看过的故事、收获的感悟、浸染过的气质,就像一颗种子,会在你的身体里慢慢发芽长大,不断提升你的认知,打开你的视野。

二、CPU 如何读写数据的?

我们先来看下 CPU 的架构图:

img

我们可以看到,一个 CPU 通常有多个 CPU 核心,对于 L1 Cache(dCache、iCache)、L2 Cache 每个 CPU 核心都是独立的,而 L3 Cache 是多个核心共享的。

上面的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些共同组成了 CPU 的存储结构:

img

各存储设备的访问状态:

img

通过这张图,可以解决我们的第一个疑问:有了内存,为什么还需要 CPU Cache?

内存和CPU的交互十分的缓慢,又由于摩尔定律,我们不得不在 CPU 和内存之间添加一层缓存层(CPU Cache)

而我们的 CPU Cache 层级结构如下:

CPU Cache - L1 Cache - Cache Line(缓存块) - Tag(组) + Cache Block(缓存数据)

Cache Line 的大小为:64字节

所以,我们CPU Cache 和内存交互时,一次从内存 Load 的大小为 64 字节。

如果当前加载的是数组 int[] array 的话,我们读取 array[0] 实际会将 array[0] - array[15] 全部加载到 CPU Cache 中,方便下一次 CPU 进行读取

但如果当前加载的是变量的话,会产生伪共享问题,伪共享是性能杀手,我们应该去避免该问题的产生。

我们一起来看一下 伪共享是什么?怎么去避免伪共享?

假设我们当前有一个双核的 CPU,这两个 CPU 核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A 和 B,这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同一个 Cache Line 中,又因为 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中。

img

我们来思考一个问题,当我们A线程修改A的值,B线程修改B的值,会出现什么问题呢?

1. 分析伪共享问题

我们上一章讲述了CPU的缓存一致性协议:MESI,我们来分析下上述情况,会产生什么问题?

  • 最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。

在这里插入图片描述

  • 1 号核心读取变量 A,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。

在这里插入图片描述

  • 接着,2 号核心开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。
    在这里插入图片描述

  • 1 号核心需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心把 Cache 中对应的 Cache Line 标记为「已失效」状态,然后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A。

在这里插入图片描述

  • 之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,然后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。

在这里插入图片描述

所以,可以发现如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 ④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。

因此,这种因为多个线程读写同一个 CPU Line 的不同变量时,而导致 CPU Cache 失效的现象称为:伪共享

2. 避免伪共享的方法

对于多个线程共享的热点数据(经常修改的),应该避免这些数据在同一个 CPU Line 中,否则就会出现伪共享的问题。

我们先看看内核中的避免方法:

在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。

img

打个比方。如果你定义一个结构体:

struct test {
    int a;
    int b;
}

ab 肯定位于同一缓存行,如下:

img

当我们添加上内核的宏定义后

struct test {
    int a;
    int b __cacheline_aligned_in_smp;
}

ab 的缓存信息如下:

img

所以,避免伪共享最好的方式就是用空间换时间的思想,浪费一部分的 CPU Cache,从而换来性能的提升。

我们看一下应用层面的规避方案,有一个 Java 的并发框架 Disruptor 使用 字节填充 + 继承 的方法,来避免伪共享的问题。

Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,代码如下:

img

我们都知道,CPU Cache 从内存读取数据的单位是 CPU Line,一般 64 位 CPU 的 CPU Line 的大小是 64 个字节,一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据。

根据 JVM 对象继承关系中父类成员和子类成员,内存地址是连续排列布局的,因此 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line 前置填充,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line 后置填充,这 14 个 long 变量没有任何实际用途,更不会对它们进行读写操作。

img

由于「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因此不会产生伪共享的问题

三、CPU 是如何选择线程的

我们上面讲述了 CPU 如何读取数据并且产生伪共享问题,后续我们来看一看,CPU 是如何选择当前要执行的线程

这里需要注意一点:在 Linux 内核中,进程和线程都是用 task_struct 进行表示的,区别在于线程的 task_struct 结构体中的部分资源共享了进程已创建的资源。比如:内存地址、代码段、文件描述符等等,所以 Linux 中的线程也被称为轻量级进程。

img

所以,在 Linux 系统中,调度对象就是 task_struct,具体实现我们后面单独出一章进行讲解。

1. 调度类

由于我们的任务存在优先级,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分以下几种:

img

Deadline 和 Realtime 这两个调度类,都是应用于实时任务的

  • SCHED_DEADLINE:距离当前时间点最近的 deadline 的任务会被优先调用
  • SCHED_FIFO:优先级一致的情况下,实施先来先服务的原则
  • SCHED_RR:对于优先级相同的任务,轮询执行(时间片

而 Fair 调度类是应用于普通任务

  • SCHED_NORMAL:普通任务使用的调度策略
  • SCHED_BATCH:后台任务的调度策略

2. 完全公平调度

我们平常一般遇到的都是普通任务,对于普通任务来说,公平性最重要。

Linux 基于 CFS 的调度算法, 实现了一种 完全公平调度

虽说是完全公平,也与其优先级有关,具体公式如下图:

img

3. CPU 运行队列

一个系统肯定存在很多个任务,我们任务的数量会远超于我们的核心,这个时候需要排队。

每个 CPU 拥有自己的运行队列,用于描述此 CPU 上所运行的所有进程,一般我们只需要用到普通任务的,也就是 cfs_rq

任务排序的依据:vruntime

img

调度的优先级为:Deadline > Realtime > Fair,因此,实时任务总是会比普通任务优先被执行

4. 调整优先级

如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务。普通任务的调度类是 Fail,由 CFS 调度器来进行管理。实现任务运行的公平性。

权重值与 nice 值的关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高。

我们要想让某个普通任务拥有更多的执行时间,需要将此任务的 nice 值进行调低。

四、总结

理解 CPU 是如何读写数据的前提,是要理解 CPU 的架构,CPU 内部的多个 Cache + 外部的内存和磁盘都就构成了金字塔的存储器结构,在这个金字塔中,越往下,存储器的容量就越大,但访问速度就会小。

CPU 读写数据的时候,并不是按一个一个字节为单位来进行读写,而是以 CPU Line 大小为单位,CPU Line 大小一般是 64 个字节,也就意味着 CPU 读写数据的时候,每一次都是以 64 字节大小为一块进行操作。

因此,如果我们操作的数据是数组,那么访问数组元素的时候,按内存分布的地址顺序进行访问,这样能充分利用到 Cache,程序的性能得到提升。但如果操作的数据不是数组,而是普通的变量,并在多核 CPU 的情况下,我们还需要避免 Cache Line 伪共享的问题。

所谓的 Cache Line 伪共享问题就是,多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。那么对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,避免的方式一般有 Cache Line 大小字节对齐,以及字节填充等方法。

系统中需要运行的多线程数一般都会大于 CPU 核心,这样就会导致线程排队等待 CPU,这可能会产生一定的延时,如果我们的任务对延时容忍度很低,则可以通过一些人为手段干预 Linux 的默认调度策略和优先级。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

【操作系统入门到成神系列 五】CPU 是如何执行任务的 的相关文章

  • 处理器在等待主内存读取时做什么

    假设 L1 和 L2 缓存请求导致未命中 处理器是否会停止运行 直到访问主内存为止 我听说过切换到另一个线程的想法 如果是的话 用什么来唤醒停滞的线程 现代 CPU 中会同时发生很多很多事情 当然 任何需要内存访问结果的事情都无法进行 但可
  • 检测处理器的数量

    如何检测 net 中物理处理器 核心的数量 System Environment ProcessorCount 返回逻辑处理器的数量 http msdn microsoft com en us library system environm
  • 为什么负载不能绕过同一核心上的另一个线程从写入缓冲区写入的值?

    如果CPU核心使用写缓冲区 则负载可以从写缓冲区绕过最近的存储到引用的位置 而无需等到它出现在缓存中 但是 正如它所写的记忆一致性和连贯性入门 https lagunita stanford edu c4x Engineering CS31
  • vCPU 是否可以使用两台不同硬件计算机的不同 CPU

    我搜索过这个问题 但似乎没有得到公平的答案 假设我不想创建一个具有 vCPU 的虚拟机 并且该 vCPU 必须有 10 个核心 但我只有 2 台计算机 每台计算机有 5 个物理 CPU 核心 是否可以通过依赖这两个物理 CPU 来创建一个
  • 了解CPU寄存器

    我是汇编语言的初学者 并试图理解这些都是如何工作的 我的问题可能看起来很愚蠢 但无论如何 我不太清楚 考虑以下简单的程序 section text global start start mov eax text mov val eax mo
  • 基准测试 - 如何计算发送到 CPU 的指令数以查找消耗的 MIPS

    假设我有一个软件并想使用黑盒子 https en wikipedia org wiki Black box testing方法 我有一个 3 0GHz CPU 有 2 个插槽和 4 个核心 如您所知 为了找出每秒指令数 IPS 我们必须使用
  • linux内存初始化时内核CPU使用率高

    在服务器上引导我的 java 应用程序时 我遇到了 Linux 内核 CPU 消耗高的问题 此问题仅发生在生产中 在开发服务器上一切都是光速 upd9 关于这个问题 有两个疑问 如何修复它 名义动物建议同步并删除所有内容 这确实有帮助 su
  • 如何通过.NET Core查找物理CPU核心数(不是逻辑SMT超线程)?

    我想检测实际物理核心的数量 而不是逻辑核心的数量 因为当更多线程竞争私有每核心缓存时 工作负载会出现负扩展 和 或具有足够高的 IPC 每个核心运行多个逻辑线程不会吞吐量的增加超过线程开销的增加 特别是对于无法完美扩展到大量内核的问题 或者
  • XNA 的 CPU 使用率高

    我今天刚刚注意到 当我编译并运行一款新的 XNA 4 0 游戏时 其中一个 CPU 线程以 100 的速度运行 并且帧速率下降到 54 FPS 奇怪的是 有时它可以在 60 FPS 下运行 但随后就会下降到 54 FPS 我以前没有注意到这
  • 如何在Python中模拟CPU和内存压力

    我想知道是否有人用 python 编写了模拟 cpu 和内存压力的代码 我看到一段加载 cpu 的代码 但如何强制它们在 90 的使用率下工作 一个节点主要有 4 种经常使用的资源 有效内存 中央处理器周期 储存空间 网络负载 上传和下载
  • 多处理和并行处理之间的比较

    有人能告诉我多处理和并行处理之间的确切区别吗 我有点困惑 感谢您的帮助 多重处理 多重处理是使用两个或多个中央处理单元 单个计算机系统中的 CPU 该术语还指 系统支持多个处理器和 或的能力 在他们之间分配任务的能力 并行处理 在计算机中
  • 如何让Java使用机器上的所有CPU资源?

    我有时用 Java 编写代码 我注意到有时它在多核机器上使用超过 100 的 CPU 我现在正在一台具有 33 个 CPU 亚马逊的 EC2 的多核机器上运行一些代码 我想让我的 Java 进程使用所有可用的 CPU 这样它将具有非常高的机
  • 单核上的多线程有什么意义?

    我最近一直在研究 Linux 内核 并回顾了大学操作系统课程的时代 就像那时一样 我正在玩线程之类的东西 一直以来我一直假设线程是自动在多个核心上同时运行但我最近发现您实际上必须显式编写代码来处理多个核心 那么单核上的多线程有什么意义呢 我
  • CPU利用率和能耗之间有什么关系?

    描述 CPU 利用率和能源消耗 电 热方面 之间关系的函数是什么 我想知道它是否是线性 次线性 exp 等 我正在编写一个程序 可以降低其他程序的 CPU 利用率 负载 我主要关心的是我能在能源方面受益多少 此外 我的服务器主要用作数据中心
  • Django 开发服务器 CPU 密集型 - 如何分析?

    我注意到本地 windows7 机器上的 django 开发服务器 版本 1 1 1 正在使用大量 CPU 根据任务管理器的 python exe 条目 约为 30 即使处于空闲状态 即没有请求到来进 出 是否有一种既定的方法来分析可能造成
  • 将 CPU 频率指定为 Linux 启动时的内核 CMD_LINE 参数?

    我将笔记本电脑的i5 CPU更换为i7 CPU 这样它可以运行得更快 但由于i7的功率更大 温度也比以前更高 所以我的笔记本经常死机 所以 我使用cpupower来指定CPU的最大频率 它起作用了 现在 我的问题是 有没有办法在启动时将CP
  • 查看x86架构中的cpu缓存内容

    如何查看或转储基于 x86 的架构的 cpu 缓存内容 每次进行缓存刷新时 我如何才能看到刷新了什么 在哪里 你不能 真的 CPU 缓存被设计为对于 CPU 上运行的代码是透明的 它具有加快代码执行速度的效果 但 CPU 管理有关缓存的所有
  • python 进程占用 100% CPU

    我正在尝试运行 python 应用程序并根据指定的时间间隔执行操作 下面的代码持续消耗 100 的 CPU def action print print hello there interval 5 next run 0 while Tru
  • 普通的 x86 或 AMD PC 是直接从 ROM 运行启动/BIOS 代码,还是先将其复制到 RAM? [关闭]

    Closed 这个问题不符合堆栈溢出指南 help closed questions 目前不接受答案 我知道现代计算机已经修改了哈佛架构 它们可以从保存数据的地方以外的地方读取指令 这一事实是否允许它们直接从 ROM 芯片获取指令 他们是先
  • NodeJS CPU 一次飙升至 100%

    我有一个用 NodeJS 编写的 SOCKS5 代理服务器 我正在使用原生net and dgram打开 TCP 和 UDP 套接字的库 它可以正常工作大约 2 天 所有 CPU 的最大利用率约为 30 两天没有重新启动后 一个 CPU 峰

随机推荐

  • Tomcat服务器的启动及启动失败可能的原因

    一 如何启动Tomcat 找到Tomcat目录下的bin目录下的startup bat文件 双击 就可以启动Tomcat服务器 启动后可以 打开浏览器 在浏览器地址栏中输入以下地址测试 1 http localhost 8080 2 htt
  • Docker端口映射

    端口映射 容器中可以运行一些应用 xff0c 要让外部也可以访问这些应用 xff0c 可以通过 P 或 p 参数来指定端口映射 当使用大写的 P 标记时 xff0c Docker 会随机映射一个物理机的 49000 49900 之间的端口到
  • 进入docker容器命令行

    docker exec it containerid bin bash
  • 正点原子FreeRTOS手把手教学-基于STM32视频

    正点原子FreeRTOS手把手教学 基于STM32 哔哩哔哩 bilibili
  • 谷歌浏览器下载、安装、配置。(保姆级详细教程。)

    1 xff0c 首先找一个你电脑已经自带了的浏览器 然后打开浏览器 2 xff0c 在浏览器里面的搜索框输入谷歌浏览器 然后进行搜索 找到带有官方标志的网址点击进去 xff08 优先选官网下载 xff0c 因为非官网的有时候会带有捆绑软件或
  • 饿了么神级UI组件库——Element-UI使用指南

    1 Element UI介绍 element ui 是饿了么前端出品的基于 Vue js的 后台组件库 xff0c 方便程序员进行页面快速布局和构建 Element UI官方站点 xff1a https element eleme cn z
  • get请求和post请求的区别(全面讲解)

    1 get请求一般是去取获取数据 xff08 其实也可以提交 xff0c 但常见的是获取数据 xff09 xff1b post请求一般是去提交数据 2 get因为参数会放在url中 xff0c 所以隐私性 xff0c 安全性较差 xff0c
  • vscode 脑图插件mindmap

    在日常办公中 xff0c 我们经常使用脑图工具来说明一个复杂的 事情 xff0c 百度提供了一个在线的脑图工具 xff0c https naotu baidu com 今天 xff0c 我们来看下vscode中如何通过安装插件 xff0c
  • Android Studio 实现登录注册-源代码 (连接MySql数据库)

    Android Studio 实现登录注册 源代码 xff08 连接MySql数据库 xff09 Android Studio 实现登录注册 源代码 二 xff08 Servlet 43 连接MySql数据库 xff09 Android S
  • PX4无人机-Gazebo仿真实现移动物体的跟踪

    原文链接PX4无人机 Gazebo仿真实现移动物体的跟踪末尾有演示视频 这个学期我们有一个智能机器人系统的课设 我们组分配到的题目是 仿真环境下使用无人机及相机跟踪移动物体 本文主要记录完成该课设的步骤以及内容 我们采用的最终方案是PX4飞
  • Python贪吃蛇 (完整代码+详细注释+粘贴即食)

    文章目录 代码运行截图笔记补充参考博客 xff08 阿里嘎多 xff01 xff09 代码 usr bin env python coding utf 8 author xff1a Wangdali time 2021年1月20日16 08
  • 人工智能作业——python实现洗衣机模糊推理系统实验

    人工智能作业 python实现洗衣机模糊推理系统实验 实验环境实验要求代码实验结果 书中实验要求用Matlab实现 xff0c 但是Matlab包太大了 xff0c 且还需要重新学习Matlab的使用 发现python也可以实现 xff0c
  • 如何使用 Python 提取 JSON 中的数据?

    我们知道在爬虫的过程中我们对于爬取到的网页数据需要进行解析 因为大多数数据是不需要的 所以我们需要进行数据解析 常用的数据解析方式有正则表达式 xpath bs4 这次我们来介绍一下另一个数据解析库 jsonpath 在此之前我们需要先了解
  • Linux查看文件内容的几种方法

    文章目录 1 cat 显示文件内容2 less 向前或者向后查看文件内容3 tail 查看文件尾部的内容4 head 查看文件开头的内容5 more 分页显示文件内容 1 cat 显示文件内容 使用cat命令时 xff0c 如果文件内容过多
  • [野火]《FreeRTOS内核实现与应用开发实战指南》视频

    野火 FreeRTOS内核实现与应用开发实战指南 哔哩哔哩 bilibili 1 正点原子 FreeRTOS 视频教程 正点原子 FreeRTOS 视频教程 哔哩哔哩 bilibili 2 正点原子FreeRTOS手把手教学 基于STM32
  • FlinkSQL CDC实现同步oracle数据到mysql

    环境准备 1 flink 1 13 0 2 oracle 11g 3 flink connector oracle cdc 2 1 0 1 oracle环境配置 首先需要安装oracle环境 xff0c 参考 https blog csdn
  • MySQL窗口函数OVER()

    下面的讲解将基于这个employee2表 mysql gt SELECT FROM employee2 43 43 43 43 43 43 id name age salary dept id 43 43 43 43 43 43 3 小肖
  • ubuntu安装mysql详细过程

    1 安装mysql server sudo apt install mysql server 2 登录 sudo mysql u root p 两点要注意 添加sudo password中 任意密码都能登录 3 修改登录密码 ALTER U
  • 修改docker容器端口映射的方法

    大家都知道docker run可以指定端口映射 xff0c 但是容器一旦生成 xff0c 就没有一个命令可以直接修改 通常间接的办法是 xff0c 保存镜像 xff0c 再创建一个新的容器 xff0c 在创建时指定新的端口映射 有没有办法不
  • 【操作系统入门到成神系列 五】CPU 是如何执行任务的

    作者简介 xff1a 大家好 xff0c 我是 xff0c 独角兽企业的Java开发工程师 xff0c Java领域新星创作者 个人公众号 xff1a xff08 回复 技术书籍 可获千本电子书籍 xff09 系列专栏 xff1a Java