【八股】暑期实习八股复盘(三月)
1. 计算机网络
1.1 为什么用 UDP 不用 TCP
如:视频会议场景中为什么用UDP而不用TCP?
TCP 是面向连接的协议,为了保证数据包的可靠传输,需要引入重传机制、拥塞控制等,会造成音视频不同步等延迟或者卡顿,影响用户体验。
1.1.1 超时重传
- 当TCP发送一个数据包时,会为该数据包启动一个重传计时器(Retransmission Timer),等待接收方的确认(ACK)。如果发送方在计时器超时之前收到接收方的确认(ACK),则认为数据包已成功传输,计时器停止。如果计时器超时仍未收到ACK,发送方认为数据包可能丢失或接收方未收到,于是重传该数据包。
- TCP使用RTO(Retransmission Timeout)作为超时时间,RTO的值基于RTT(Round-Trip Time,往返时间)动态计算。
- 如果网络延迟增加,RTO会相应增大;如果网络延迟减少,RTO会减小。
- 如果重传后仍未收到ACK,TCP会采用指数退避策略,即每次重传时将RTO加倍,避免在网络拥塞时进一步加剧问题。
- 除了超时重传,TCP还支持快速重传机制:如果发送方连续收到3个相同的重复ACK(Dup-ACK),则认为数据包丢失,立即重传,而不等待超时。
1.1.2 拥塞控制
TCP 的拥塞控制机制旨在防止网络过载,确保网络资源的公平使用和高效分配。其核心思想是通过动态调整发送速率来适应网络的当前状态。以下是 TCP 拥塞控制的主要机制:
- 加性增 - 慢开始:cwnd 从 1 开始,每个 RTT 翻倍增长,直到达到 sstthresh。若超过 ssthresh,cwnd会定到ssthresh
- 加性增 - 拥塞避免:cwnd 增长速度放缓,每个 RTT 增加一个 MSS(最大报文段长度)
- 乘性减 - 检测拥塞:长时间未收到 ACK,认为发生拥塞,ssthresh 设置为当前 cwmd 一半,cwnd 重新被设置为 1
- 乘性减 - 快重传:连续收到三次重复的 ACK,认为中途数据丢失,ssthresh 设置为当前 cwnd 一半
- TCP Tahoe:cwnd 设置为 1
- TCP Reno(快恢复):cwnd 设置为 ssthresh,即直接减半
1.1.3 TCP 首部
UDP 首部非常简单,仅包含 8 字节,记录以下信息:
- 源端口号(16 位)
- 目的端口号(16 位)
- 长度(16 位)
- 校验和(16 位)
相比之下,TCP 首部多记录了以下信息:
- 序列号和确认号:各32位,用于实现可靠传输,确保数据按顺序到达。
- 控制位(Flags):6位,用于管理连接状态(如建立、关闭连接)。如 SYN、ACK、RST、FIN 等。
- 窗口大小:用于流量控制,避免接收方缓冲区溢出。
- 紧急指针:支持紧急数据的传输。
- 选项字段:支持扩展功能,如 MSS、窗口缩放等。
1.2 从输入 URL 到页面展示
大多数八股资料上都是从顶向下进行阐述,那我也这样重新表述一遍吧。
- 明确目标 IP ,封装到端到端传输:
- HTTP:要想让浏览器展示网页,我们需要解析 URL 通过 HTTP 协议发 GET 请求来获取响应的 HTML。
- TCP:其中 HTTP 基于 TCP 实现,TCP 使用 ipv4 或 ipv6 进行通信。
- DNS:其中由域名转换为公网 ipv4 需要 DNS 解析协议。
- 切开端到端的传输过程(IP 到 IP):
- WAN 与外网通信:NAT
- LAN 内网通信:判断是否在同一子网,若不在同一子网,则访问数个网关,进行路由选择转发(RIP or OSPF)。
- 访问网关需要知道网关的 MAC 地址,需要有 ARP 协议
- 想要走 ARP,又需要先知道自己被分配的 IP 地址 和 网关 IP 地址,所以从校园网 DHCP 开始。
1.3 应用层负载均衡技术的单点故障
我们访问一个 ip 时,如果我们采用 Nginx 或者 SpringCloud GateWay 对应这个进程负责转发到集群进程时,如果负责转发这个进程(如 Nginx)需要高可用怎么办?
- 主备机制:通过 keepalived 等组件配置主备节点,当主节点故障时,备用节点会自动接管 VIP,确保服务不中断。
- CDN:使用 CDN 或全球负载均衡(GSLB)将流量分发到不同区域的负载均衡器。
- 传输层负载均衡:四层负载均衡工作在 OSI 模型的传输层(第四层),基于 IP 地址和端口号进行流量分发。四层负载均衡器不解析应用层内容,通过修改数据包的目标 IP 地址和端口号,将请求转发到不同的后端进程。常见的四层负载均衡器包括 LVS 和 F5。
1.4 TCP 三次握手四次挥手各个状态
2. 操作系统
2.1 虚拟内存
以下是整个框架的内容梳理:
2.1.1 地址空间与转换
- 地址空间与虚拟内存
- 早期机器的物理内存情况:操作系统和运行程序直接在物理内存中,没有太多抽象,用户要求少,如单片机开发直接操作物理地址。
- 多道程序系统和分时共享系统出现后,引入进程概念,但存在内存分配冲突、进程间相互干扰等问题。
- 引入地址空间和虚拟内存:
- 每个进程有自己的地址空间,如假设 16KB,包括代码段(静态空间)、栈(向下增长)、堆(向上增长)。
- 程序认为自己被加载到地址 0 开始的内存中,但实际上通过虚拟化内存机制加载到合适的物理地址。
- 虚拟化内存的目标:透明假象(程序不知内存被虚拟化)、效率(需硬件支持与 TLB)、保护(保证进程隔离)。
- 地址转换机制
- 假设:用户地址空间连续放物理内存中、地址空间小于物理内存大小、每个地址空间大小相同。
- 动态重定位实现:
- 通过基地址寄存器和界限寄存器(统称 MMU)实现。
- 特殊指令用于修改基址和界限寄存器,这些指令是特权指令,只能在内核模式下修改。
- 操作系统在动态重定位中的介入工作:进程创建时分配内存、进程终止时回收内存、上下文切换时保存和恢复寄存器内容、CPU 异常时提供异常处理程序。
2.1.2 内存管理机制
- 分段
- 需要考虑的问题:栈和堆之间存在未使用的“空闲”空间占用物理内存;剩余物理内存可能无法提供连续区域放置完整地址空间。
- 分段概念:在 MMU 中引入多个基址和界限寄存器对,每个逻辑段(代码、栈、堆)一对。分段机制避免了虚拟地址空间中未使用部分占用物理内存。
- 地址转换例子:代码段、堆、栈的虚拟地址到物理地址的转换过程,包括偏移量的计算和反向增长的处理。硬件通过虚拟地址的前两位确定段,后 12 位作为段内偏移,与基址寄存器相加得到物理地址。
- 分段的好处:代码共享,独立的代码段可被多个程序共享。
- 问题:
- 外部碎片(物理内存中存在许多小的空闲空间,难以分配给新段或扩大已有段);
- 管理物理内存空闲空间成本高;
- 分段不能很好地支持稀疏地址空间。
- 解决外部碎片的方法:紧凑物理内存,重新安排原有段,但成本高。
- 分页:
- 分页思路:将空间分割成固定长度的分片(页),物理内存看成定长槽块的阵列(页帧),每个页帧包含一个虚拟内存页。
- 页表:操作系统为每个进程保存的数据结构,记录虚拟页在物理内存中的位置。
- 虚拟地址转换:将虚拟地址分成虚拟页面号(VPN)和页内偏移量,通过 VPN 查找页表得到物理帧号(PFN),偏移量不变,得到最终物理地址。
- 优点:不会导致外部碎片,支持稀疏虚拟地址空间。
- 问题:额外的内存访问访问页表导致速度变慢,页表可能占用过多内存。
- 快速地址转换 TLB
- 问题:分页方法中,每个内存引用都需要额外的内存引用从页表中获取地址转换,工作量大。
- 解决办法:引入 TLB(地址转换旁路缓冲存储器),作为“缓存”存储当前 VPN 的页表项。
- TLB 未命中处理方式:
- 硬件处理(CISC 指令集):硬件遍历页表,找到正确页表项,更新 TLB,重试指令。
- 操作系统软件处理(RISC 指令集):硬件抛出异常,操作系统处理,查找页表中的转换映射,更新 TLB,从陷阱返回,硬件重试指令。
- TLB 插入新项时的缓存替换策略:LRU、随机策略等。
- 上下文切换问题:TLB 内容只对当前进程有效,上下文切换时需处理 TLB 内容。直接清空 TLB 有开销,一些系统通过硬件支持实现跨上下文切换的 TLB 共享,如添加 ASID。
- 实现较小页表的数据结构单级页表问题:
- 页表大小与 VPN 位数有关,降低 VPN 位数会导致内存碎片问题。
- 多级页表:
- 将 VPN 分成多个部分,每一部分对应一个页表层次。
- 以两级页表为例,第一级页表(页目录)索引第二级页表,第二级页表索引物理页帧。
- 优势:稀疏映射(节省未使用虚拟页的页表空间)、按需加载(只有部分页表需驻留内存)。
2.1.3 页面置换 Swap
- 页表可能太大无法一次装入内存,系统将页表放入内核虚拟内存,内存压力大时可将部分页表交换到磁盘。
- 缺页中断:定义:CPU 访问页面不在物理内存时产生,请求操作系统将所缺页调入物理内存。与一般中断的区别:产生和处理时间不同,返回执行位置不同。处理流程:发生缺页中断:硬件查找页表中 PTE,存在位无效则发出缺页中断请求。操作系统进行 I/O:根据 PTE 中的硬盘地址查找磁盘页面位置,将页面换入物理内存空闲页。操作系统更新页表:更新 PTE 标记页面存在,更新 PFN 字段记录新物理页位置。重试指令。
3. RocketMQ
3.1 RocketMQ 消息模型
3.2 一个 Broker 是如何保存数据的
RocketMQ 主要的存储文件包括 CommitLog 文件、ConsumeQueue 文件、Indexfile 文件。
3.2.1 存储机制
1. CommitLog 文件
- 作用:CommitLog 是 RocketMQ 的核心存储文件,所有消息(无论属于哪个 Topic 或 Queue)都按顺序追加写入到 CommitLog 文件中。
- 特点:
- 顺序写入:消息按照到达 Broker 的顺序写入 CommitLog,确保高性能。
- 持久化存储:CommitLog 是消息的最终存储位置,消息不会丢失。
- 不分 Topic 或 Queue:所有消息混在一起存储,通过其他文件(如 ConsumeQueue)来组织消息的索引。
- 存储结构:
- 每条消息包含 Topic、QueueId、消息体、消息属性等信息。
- 消息在 CommitLog 中的位置(偏移量)称为 物理偏移量(Physical Offset)。
2. ConsumeQueue 文件
- 作用:ConsumeQueue 是 CommitLog 的索引文件,用于将消息按 Topic 和 Queue 进行逻辑分组,方便消费者拉取消息。
- 特点:
- 按 Topic 和 Queue 组织:每个 Topic 的每个 Queue 对应一个 ConsumeQueue 文件。
- 逻辑偏移量:ConsumeQueue 中存储的是消息的 逻辑偏移量(Logical Offset),用于标识消息在 Queue 中的顺序。
- 快速定位:ConsumeQueue 记录了消息在 CommitLog 中的物理偏移量、消息大小等信息,方便快速定位消息。
- 存储结构:
- 每条记录包含:消息在 CommitLog 中的物理偏移量、消息大小、消息的 Tag 哈希值。
- ConsumeQueue 文件是定长的(20 字节),便于快速查找。
3. IndexFile 文件
- 作用:IndexFile 是 RocketMQ 的二级索引文件,用于支持基于消息 Key 或时间范围的消息查询。
- 特点:
- 按消息 Key 索引:IndexFile 记录了消息的 Key 和其在 CommitLog 中的物理偏移量。
- 支持快速查询:通过 Key 或时间范围,可以快速定位消息。
- 非必须:IndexFile 是可选的,主要用于消息回溯或查询场景。
- 存储结构:
- 每条记录包含:消息 Key 的哈希值、消息在 CommitLog 中的物理偏移量、消息存储时间戳。
3.2.2 Topic-MessageQueue-ConsumerGroup 模型的实现
- Topic:
- Topic 是消息的逻辑分类,生产者将消息发送到指定的 Topic。
- 在 RocketMQ 中,Topic 是一个逻辑概念,实际存储是通过 CommitLog 和 ConsumeQueue 实现的。
- MessageQueue:
- 每个 Topic 可以划分为多个 Queue(默认 4 个),Queue 是消息的并行单元。
- 消息按照 Queue 的顺序写入 CommitLog,并通过 ConsumeQueue 记录每个 Queue 的消息索引。
- 消费者按 Queue 拉取消息,确保消息的顺序性和并行消费。
- ConsumerGroup:
- ConsumerGroup 是消费者的逻辑分组,多个消费者可以属于同一个 ConsumerGroup。
- 同一个 ConsumerGroup 的消费者共享 Queue 的消费进度(Offset),实现负载均衡。
- 每个 Queue 只能被一个 ConsumerGroup 中的一个消费者消费,确保消息不会被重复消费。
3.2.3 工作流程
- 消息写入:
- 生产者将消息发送到 Broker,Broker 将消息按顺序追加写入 CommitLog。
- Broker 同时更新对应的 ConsumeQueue 文件,记录消息的物理偏移量和逻辑偏移量。
- 如果需要,Broker 还会更新 IndexFile 文件,记录消息的 Key 和物理偏移量。
- 消息读取:
- 消费者从 ConsumeQueue 中获取消息的逻辑偏移量。
- 根据逻辑偏移量,从 CommitLog 中读取消息的物理偏移量,并拉取消息内容。
- 如果需要按 Key 查询消息,可以通过 IndexFile 快速定位消息。
- 消息消费:
- 消费者按 Queue 拉取消息,并更新消费进度(Offset)。
- Broker 会记录每个 ConsumerGroup 的消费进度,确保消息不会重复消费。
3.3 RocketMQ 顺序消息
RocketMQ 如何保证消息顺序呢?
1. 顺序消息的分类
RocketMQ 支持两种级别的顺序消息:
- 分区顺序消息(Partitionally Ordered Message):保证同一个 MessageQueue 中的消息按照发送顺序被消费。适用于需要部分顺序的场景,例如同一个订单 ID 的消息需要按顺序处理。
- 全局顺序消息(Globally Ordered Message):保证整个 Topic 中的消息按照发送顺序被消费。适用于需要严格全局顺序的场景,但性能较低。
2. 实现顺序消息的关键机制
2.1 MessageQueue 的顺序性
- RocketMQ 的 Topic 被划分为多个 MessageQueue,每个 Queue 是消息的并行单元。
- 消息在同一个 Queue 中是严格按顺序存储的(写入 CommitLog 和 ConsumeQueue 的顺序)。
- 消费者按 Queue 拉取消息,确保同一个 Queue 中的消息按顺序消费。
2.2 队列锁定机制
- 为了保证顺序消费,RocketMQ 引入了 队列锁定机制:消费者在消费某个 Queue 时,会锁定该 Queue,确保同一时间只有一个消费者消费该 Queue。如果消费者宕机或超时,锁会被释放,其他消费者可以接管该 Queue。
- 这种机制确保了同一个 Queue 中的消息不会被多个消费者并发消费,从而保证顺序性。
2.3 顺序消息的生产
- 生产者发送顺序消息时,需要指定 消息选择器(MessageQueueSelector),将同一组消息发送到同一个 Queue。
- 例如,订单消息可以根据订单 ID 选择 Queue,确保同一个订单的消息进入同一个 Queue。
2.4 顺序消息的消费
- 消费者通过 顺序消费模式(MessageListenerOrderly) 消费消息。
- 在顺序消费模式下,消费者会按顺序逐个处理 Queue 中的消息,不会并发消费。
- 如果某条消息消费失败,消费者会重试该消息,直到成功或达到最大重试次数。
3.4 RocketMQ 事务消息
RocketMQ 事务消息是 Apache RocketMQ 提供的一种分布式事务解决方案,它能够确保消息发送与本地事务执行的最终一致性。
3.4.1 核心概念
- 半消息(Half Message):事务消息的第一阶段,消息会被发送到 Broker,但此时消费者不可见
- 本地事务执行:消息发送方执行本地业务逻辑
- 事务状态回查:Broker 定期向生产者询问事务状态
- 事务提交/回滚:根据本地事务执行结果决定消息是提交(对消费者可见)还是回滚(删除)
3.4.2 工作原理
- 发送半消息:生产者发送"准备就绪"状态的消息到 Broker
- 执行本地事务:生产者执行与消息相关的业务逻辑
- 提交/回滚事务:
- 成功:提交事务,消息变为可消费状态
- 失败:回滚事务,消息被丢弃
- 事务状态回查:如果生产者未明确提交或回滚,Broker 会定期询问事务状态
3.4.3 使用场景
- 订单创建与库存扣减的分布式事务
- 支付与账户余额变更的一致性保证
- 任何需要确保消息发送与业务操作一致性的场景
4. 设计模式
阿里面试官非常喜欢设计模式,面阿里之前一定要看一下。
4.1 工厂模式
核心思想:使用一个 XxxxFactory
接口,用它的实现来进行各种接口对象的实例化。
interface CreatorFactory { ProductA getProductA(); ProductB getProductB(); } // 可以有实现类 ProductA1 ProductA2 等 interface ProductA() { void operationA(); } interface ProductB() { void operationA(); }
4.2 单例模式
其实单例模式不只是实例化,有的时候它也可以延迟类加载的初始化部分。
4.2.1 JVM 类加载
在Java中,一个类的加载过程可以分为三个主要步骤,这些步骤是按照特定的顺序执行的:
- 加载:在这个阶段,JVM会为这个类创建一个java.lang.Class对象,这个对象代表了这个类在JVM中的一个引用。
- 链接:链接阶段可以进一步细分为三个子阶段:
- 验证(Verification):确保加载的类信息符合JVM规范,没有安全问题。
- 准备(Preparation):为类的静态变量(类变量)分配内存,并设置默认初始值。
- 解析(Resolution):将类、接口、字段和方法的符号引用转换为直接引用。这个过程涉及到常量池的解析,将常量池中的符号引用替换为指向内存中的直接引用。
- 初始化:
- 在这个阶段,JVM会执行类的构造器方法<clinit>(),这通常包含了类变量的赋值操作。对于Java代码来说,就是执行类中的静态初始化块和静态变量的赋值操作。
- 需要注意的是,只有在真正需要使用到类时,JVM才会对类进行初始化(可以实现后面我们所说的 Lazy 加载),即执行<clinit>()方法。
这三个步骤是类加载机制的核心,它们确保了类的正确加载和初始化。在实际的类加载过程中,JVM会遵循双亲委派模型来确定哪个类加载器负责加载特定的类。此外,类的加载和链接过程是被动的,只有在首次主动使用类时才会开始,而初始化则是主动的,由JVM在确定类被使用时触发。
- 类的加载过程是原子操作:当一个类被加载时,其他线程不能同时加载同一个类。JVM 会保证一个类在被加载时不会被其他线程加载,这避免了多个线程同时加载同一个类的问题。
- 类的链接和初始化是同步的:尽管类加载器可以并发工作,但是类的链接和初始化阶段是同步的。这意味着,一旦类被加载,链接和初始化过程会同步执行,确保类的状态在被使用前是正确的。
- 类的初始化是线程安全的:类的静态初始化器 <clinit>() 方法在类被初始化时执行,JVM 确保这个初始化过程是线程安全的,即在类的 <clinit>() 方法执行期间,其他线程不能进入这个类。
4.2.2 两种常见的实现方案
- 在多线程的情况下,保证一个类仅有一个实例,并提供一个访问它的全局访问点。
- 私有构造方法,使用 getInstance() 静态方法获取实例而非 new 对象,建议设为 final
- 要求第一次调用 getInstance() 静态方法才进行类的初始化,而非类加载就初始化。
双重校验锁:
public class Singleton { private volatile static Singleton singleton; private Singleton() {} public final static Singleton getInstance() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
静态内部类:
- 创建一个内部类 SingletonHolder ,里面装单例
- 内部类没有被主动使用,只有通过显式调用 getInstance 方法时,才会显式装载 SingletonHolder类,从而实例化 instance。实现Singleton类延迟加载。
public class Singleton { private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } private Singleton() {} public final static Singleton getInstance() { return SingletonHolder.INSTANCE; } }
4.3 策略模式
将不同的策略抽象成一个 XXXXStrategy 接口,放进上下文类中。
interface Strategy { void operation(); } class Context { private Strategy strategy; public Context(Strategy strategy) { this.strategy = strategy; } public void opration { strategy.operation(); } }
可以优化 if-else。
4.4 观察者模式
在 Subject 中使用 List 实现观察者接口(有response方法)列表,然后 Subject 可以遍历列表调用每个观察者 response 方法
// 抽象观察者接口 interface Observer { void response(); // 观察者的响应方法 } // 抽象目标角色 abstract class Subject { protected List<Observer> observers = new ArrayList<>(); // 观察者列表 // 添加观察者 public void add(Observer observer) { observers.add(observer); } // 移除观察者 public void remove(Observer observer) { observers.remove(observer); } // 通知所有观察者 public abstract void notifyObservers(); } // 具体目标角色 class ConcreteSubject extends Subject { @Override public void notifyObservers() { // 遍历观察者列表,调用每个观察者的响应方法 for (Observer observer : observers) { observer.response(); } } }
5. 并发
5.1 Redis 分布式乐观锁
乐观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
面试官:文档协作场景下,如何在分布式环境下实现乐观锁?
啊啊啊啊之前把乐观锁理解错了,理解成乐观锁自旋悲观锁阻塞了,结果还是“悲观锁”方案。
乐观锁的核心是,先修改,再判断能不能成功修改!
5.1.1 版本号机制
- 核心思想:为每个文档维护一个版本号,每次更新文档时检查版本号是否匹配。
- 实现步骤:每个文档有一个唯一的版本号(如 version 字段)。用户编辑文档时,先获取当前版本号。用户提交修改时,附带获取的版本号。服务器检查提交的版本号是否与当前版本号一致:如果一致,更新文档并递增版本号。如果不一致,拒绝更新并通知用户冲突。
- 优点:简单易实现,适用于大多数文档协作场景。
- 缺点:需要维护版本号,冲突时需要用户手动解决。
5.1.2 基于时间戳的乐观锁
- 核心思想:使用时间戳作为版本控制的依据,确保更新的顺序性。
- 实现步骤:每个文档保存一个最后更新时间戳(如 last_updated 字段)。用户编辑文档时,获取当前时间戳。用户提交修改时,附带获取的时间戳。服务器检查提交的时间戳是否与当前时间戳一致:如果一致,更新文档并更新时间戳。如果不一致,拒绝更新并通知用户冲突。
- 优点:无需额外维护版本号,时间戳天然具有顺序性。
- 缺点:时钟同步问题可能导致冲突检测不准确。
5.2 volatile 内存屏障
volatile 是一种类型修饰符,通常用于变量声明。它的主要作用是告诉编译器,这个变量的值可能会在程序的控制之外被修改(例如,硬件设备、其他线程等),因此编译器不应该对这个变量进行优化(如缓存到寄存器中)。
Java volatile 的的读写操作隐式包含了内存屏障,它的核心实现在 CPU 层面,依赖于 CPU 的指令集。
内存屏障的作用:
- 顺序性:内存屏障可以防止编译器和处理器对指令进行重排序。它确保在屏障之前的操作在屏障之后的操作之前完成。
- 可见性:内存屏障可以确保在屏障之前的所有内存操作对其他线程可见。
内存屏障的类型:
- Load Barrier:确保在屏障之前的读操作在屏障之后的读操作之前完成。
- Store Barrier:确保在屏障之前的写操作在屏障之后的写操作之前完成。
- Full Barrier:确保在屏障之前的所有内存操作(读和写)在屏障之后的所有内存操作之前完成。
6. Java 集合
6.1 ConcurrentHashMap
JDK 1.7 之前:将哈希表分成多个 段(Segment),每个段是一个独立的哈希表。每个段都有自己的锁(由 ReentrantLock 继承),不同段的操作可以并发执行。
JDK 1.8 之后:使用 CAS 操作 和 synchronized 关键字 对每个桶实现细粒度的锁。
我在看并发集合的时候收到了美团的 HR 电话,所以留着以后再背啦... 祝大家好运!!