【RocketMQ】消息的拉取

在上一讲中,介绍了消息的存储,生产者向Broker发送消息之后,数据会写入到CommitLog中,这一讲,就来看一下消费者是如何从Broker拉取消息的。

RocketMQ消息的消费以组为单位,有两种消费模式:

广播模式:同一个消息队列可以分配给组内的每个消费者,每条消息可以被组内的消费者进行消费。

集群模式:同一个消费组下,一个消息队列同一时间只能分配给组内的一个消费者,也就是一条消息只能被组内的一个消费者进行消费。

通常使用集群模式的情况比较多,接下来以集群模式(Push模式)为例看一下消息的拉取过程。

消费者启动时处理

消费者在启动的时候主要做了以下几件事情:

  1. Topic订阅处理;
  2. MQClientInstance实例创建;
  3. 加载消费进度存储对象,里面存储了每个消息队列的消费进度;
  4. 从NameServer更新Topic路由信息;
  5. 向Broker进行注册;
  6. 触发负载均衡;

主题订阅处理

RocketMQ消费者以组为单位,启用消费者时,需要设置消费者组名称以及要订阅的Topic信息(需要知道要消费哪个Topic上面的消息):

@RunWith(MockitoJUnitRunner.class)
public class DefaultMQPushConsumerTest {@Mockprivate MQClientAPIImpl mQClientAPIImpl;private static DefaultMQPushConsumer pushConsumer;@Beforepublic void init() throws Exception {// ...// 消费者组名称String consumerGroup = "FooBarGroup";// 实例化DefaultMQPushConsumerpushConsumer = new DefaultMQPushConsumer(consumerGroup);pushConsumer.setNamesrvAddr("127.0.0.1:9876");// ...// 设置订阅的主题pushConsumer.subscribe("FooBar", "*");// 启动消费者pushConsumer.start();}
}

所以消费者启动的时候,首先会获取订阅的Topic信息,由于一个消费者可以订阅多个Topic,所以消费者使用一个Map存储订阅的Topic信息,KEY为Topic名称,VALUE为对应的表达式,之后会遍历每一个订阅的Topic,然后将其封装为SubscriptionData对象,并加入到负载均衡对象RebalanceImpl中,等待进行负载均衡。

MQClientInstance实例创建

MQClientInstance中有以下几个关键信息:

  • 消息拉取服务:对应实现类为PullMessageService,是用来从Broker拉取消息的服务;
  • 负载均衡服务:对应的实现类为RebalanceService,是用来进行负载均衡,为每个消费者分配对应的消费队列;
  • 消费者列表(consumerTable):记录该实例上的所有消费者信息,key为消费者组名称,value为消费者对应的MQConsumerInner对象,每一个消费者启动的时候会向这里注册,将自己加入到consumerTable中;

需要注意MQClientInstance实例是以clientId为单位创建的,相同的clientId共用一个MQClientInstance实例,clientId由以下信息进行拼装:
(1)服务器的IP;
(2)实例名称(instanceName);
(3)单元名称(unitName)(不为空的时候才拼接);
最终拼接的clientId字符串为:服务器IP + @ + 实例名称 + @ + 单元名称
所以在同一个服务器上,如果实例名称和单元名称也相同的话,所有的消费者会共同使用一个MQClientInstance实例。

MQClientInstance启动的时候会把消息拉取服务和负载均衡服务也启动(启动对应的线程)。

获取Topic路由信息

前面已经得知了当前消费者订阅的Topic信息,接下来需要知道这些Topic的分布情况,也就是分布在哪些Broker上,Topic的分布信息可以从NameServer中获取到,因为Broker会向NameServer进行注册,上报自己负责的Topic信息,所以这一步消费者向NameServer发送请求,从NameServer中拉取最新的Topic的路由信息缓存在本地。

加载消费进度

消费者在进行消费的时候,需要知道应该从哪个位置开始拉取消息,OffsetStore类中记录这些数据,不同的模式对应的实现类不同:

  • 集群模式:消息的消费进度保存在Broker中,由Broker记录每个消费队列的消费进度,对应实现类为RemoteBrokerOffsetStore
  • 广播模式:消息的消费进度保存在消费者端,对应实现类为LocalFileOffsetStore

这里关注集群模式,在集群模式下,加载消费进度时,会进入RemoteBrokerOffsetStore的load方法,load方法是从本地加载文件读取消费进度,因为集群模式下需要从Broker获取,所以load方法什么也没干,在负载均衡分配了消息队列,进行消息拉取的时候再向Broker发送请求获取消费进度。

向Broker进行注册

由于消费者增加或者减少会影响消息队列的分配,所以Broker需要感知消费者的上下线情况,消费者在启动时会向所有的Broker发送心跳包进行注册,通知Broker消费者上线。

Broker收到消费者发送的心跳包之后,会从请求中解析相关信息,将该消费者注册到Broker维护的消费者列表consumerTable中,其中KEY为消费者组名称,Value为该消费组的详细信息(ConsumerGroupInfo对象),里面记录了该消费组下所有消费者的Channel信息。

触发负载均衡

启动最后一步,会立即触发一次负载均衡,为消费者分配消息队列。

负载均衡

负载均衡是通过消费者启动时创建的MQClientInstance实例实现的(doRebalance方法),它的处理逻辑如下:

  1. MQClientInstance中有一个消费者列表consumerTable,存放了该实例上注册的所有消费者对象,Key为组名称,Value为消费者,所以会遍历所有的消费者,对该实例上注册的每一个消费者进行负载均衡;

  2. 对于每一个消费者,需要获取其订阅的所有Topic信息,然后再对每一个Topic进行负载均衡,前面可知消费者订阅的Topic信息被封装为了SubscriptionData对象,所以这里获取到所有的SubscriptionData对象进行遍历,开始为每一个消费者分配消息队列;

分配消息队列

这里我们关注集群模式下的分配,它的处理逻辑如下:

  1. 根据Topic获取该Topic下的所有消费队列(MessageQueue对象);

消费者在启动时向NameServer发送请求获取Topic的路由信息,从中解析中每个主题对应的消息队列,放入负载均衡对象的topicSubscribeInfoTable变量中,所以这一步直接从topicSubscribeInfoTable中获取主题对应的消息队列即可。

  1. 根据主题信息和消费者组名称,查找订阅了该主题的所有消费者的ID:
    (1)根据主题选取Broker:从NameServer中拉取的主题路信息中可以找到每个主题分布在哪些Broker上,从中随机选取一个Broker;
    (2)向Broker发送请求:根据上一步获取到的Broker,向其发送请求,查找订阅了该主题的所有消费者的ID(消费者会向Broker注册,所以可以通过Broker查找订阅了某个Topic的消费者);

  2. 如果主题对应的消息队列集合和获取到的消费者ID都不为空,对消息队列集合和消费ID集合进行排序;

  3. 获取分配策略,根据具体的分配策略,为当前的消费者分配对应的消费队列,RocketMQ默认提供了以下几种分配策略:

    • AllocateMessageQueueAveragely:平均分配策略,根据消息队列的数量和消费者的个数计算每个消费者分配的队列个数。

    • AllocateMessageQueueAveragelyByCircle:平均轮询分配策略,将消息队列逐个分发给每个消费者。

    • AllocateMessageQueueConsistentHash:根据一致性 hash进行分配。

    • AllocateMessageQueueByConfig:根据配置,为每一个消费者配置固定的消息队列 。

    • AllocateMessageQueueByMachineRoom:分配指定机房下的消息队列给消费者。

    • AllocateMachineRoomNearby:优先分配给同机房的消费者。

  4. 根据最新分配的消息队列,更新当前消费者负责的消息处理队列;

更新消息处理队列

每个消息队列(MessageQueue)对应一个处理队列(ProcessQueue),后续使用这个ProcessQueue记录的信息进行消息拉取:

分配给当前消费者的所有消息队列,由一个Map存储(processQueueTable),KEY为消息队列,value为对应的处理队列:

由于负载均衡之后,消费者负责的消息队列可能发生变化,所以这里需要更新当前消费者负责的消息队列,它主要是拿负载均衡后重新分配给当前消费的消息队列集合与上一次记录的分配信息做对比,有以下两种情况:

(1)某个消息队列之前分配给了当前消费者,但是这次没有,说明此队列不再由当前消费者消负责,需要进行删除,此时将该消息队列对应的处理队列中的dropped状态置为true即可;
(2)某个消费者之前未分配给当前消费者,但是本次负载均衡之后分配给了当前消费者,需要进行新增,会新建一个处理队列(ProcessQueue)加入到processQueueTable中;

对于情况2,由于是新增分配的消息队列,消费者还需要知道从哪个位置开始拉取消息,所以需要通过OffsetStore来获取存储的消费进度,也就是上次消费到哪条消息了,然后判断本次从哪条消息开始拉取。前面在消费者启动的提到集群模式下对应的实现类为RemoteBrokerOffsetStore,再进入到这一步的时候,才会向Broker发送请求,获取消息队列的消费进度,并更新到offsetTable中。

从Broker获取消费进度之后,有以下几种拉取策略:
(1)CONSUME_FROM_LAST_OFFSET(上次消费位置开始拉取):从OffsetStore获取消息队列对应的消费进度值lastOffset,判断是否大于等于0,如果大于0则返回lastOffset的值,从这个位置继续拉取;
(2)CONSUME_FROM_FIRST_OFFSET(第一个位置开始拉取):从OffsetStore获取消息队列对应的消费进度值lastOffset,如果大于等于0,依旧从这个位置继续拉取,否则才从第一条消息拉取,此时返回值为0;
(3)CONSUME_FROM_TIMESTAMP(根据时间戳拉取):从OffsetStore获取消息队列对应的消费进度值lastOffset,如果大于等于0,依旧从这个位置继续拉取,否则在不是重试TOPIC的情况下,根据消费者的启动时间查找应该从什么位置开始消费;

nextOffset拉取偏移量的值确定之后,会将ProcessQueue加入到processQueueTable中,并构建对应的消息拉取请求PullRequest,并设置以下信息:

  • consumerGroup:消费者组名称;
  • nextOffset:从哪条消息开始拉取,设置的是上面计算的消息拉取偏移量nextOffset的值;
  • MessageQueue:消息队列,从哪个消息队列上面消费消息;
  • ProcessQueue:处理队列,消息队列关联的处理队列;

PullRequest构建完毕之后会将其加入到消息拉取服务中的一个阻塞队列中,等待消息拉取服务进行处理。

消息拉取

消费者发送拉取请求

消息拉取服务中,使用了一个阻塞队列,阻塞队列中存放的是消息拉取请求PullRequest对象,如果有消息拉取请求到来,就会从阻塞队列中取出对应的请求进行处理,从Broker拉取消息,拉取消息的处理逻辑如下:

  1. 从拉取请求PullRequest中获取对应的处理队列ProcessQueue,先判断是否置为Dropped删除状态,如果处于删除状态不进行处理;
  2. 从处理队列中获取缓存的消息的数量及大小进行验证判断是否超过了设定的值,因为处理队列中之前可能已经拉取了消息还未处理完毕,为了不让消息堆积需要先处理之前的消息,所以会延迟50毫秒后重新加入到拉取请求队列中处理;
  3. 判断是否是顺序消费,这里先不讨论顺序消费,如果是非顺序消费,判断processQueue中队列最大偏移量和最小偏移量的间距是否超过ConsumeConcurrentlyMaxSpan的值,如果超过需要进行流量控制,延迟50毫秒后重新加入队列中进行处理;
  4. 向Broker发送拉取消息请求,从Broker拉取消息:
    (1)ProcessQueue关联了一个消息队列MessageQueue对象,消息队列对象中有其所在的Broker名称,根据名称再查找该Broker的详细信息;
    (2)根据第(1)步的查找结果,构建消息拉取请求,在请求中设置本次要拉取消息的Topic名称、消息队列ID等信息,然后向Broker发送请求;
  5. 消费者处理拉取请求返回结果,上一步向Broker发送请求的时候可以同步发送也可以异步发送请求,对于异步发送请求当请求返回成功之后,会有一个回调函数,在回调函数中处理消息拉取结果。

Broker处理消息拉取请求

ConsumeQueue
RocketMQ在消息存储的时候将消息顺序写入CommitLog文件,如果想根据Topic对消息进行查找,需要扫描所有CommitLog文件,这种方式性能低下,所以RocketMQ又设计了ConsumeQueue存储消息的逻辑索引,在RocketMQ的存储文件目录下,有一个consumequeue文件夹,里面又按Topic分组,每个Topic一个文件夹,Topic文件夹内是该Topic的所有消息队列,以消息队列ID命名文件夹,每个消息队列都有自己对应的ConsumeQueue文件:

ConsumeQueue中存储的每条数据大小是固定的,总共20个字节:

  • 消息在CommitLog文件的偏移量,占用8个字节;
  • 消息大小,占用4个字节;
  • 消息Tag的hashcode值,用于tag过滤,占用8个字节;

Broker在收到消费发送的拉取消息请求后,会根据拉取请求中的Topic名称和消息队列ID(queueId)查找对应的消费信息ConsumeQueue对象:
Broker中的consumeQueueTable中存储了每个Topic对应的消费队列信息,Key为Topic名称,Value为Topic对应的消费队列信息,它又是一个MAP,其中Key为消息队列ID(queueId),value为该消息队列的消费消费信息(ConsumeQueue对象)。

在获取到息ConsumeQueue之后,从中可以获取其中记录的最小偏移量minOffset和最大偏移量maxOffset,然后与拉取请求中携带的消息偏移量offset的值对比进行合法校验,校验通过才可以查找消息,对于消息查找结果大概有如下几种状态:

nextOffsetCorrection方法:用于校正消费者的拉取偏移量,不过需要注意,当前Broker是主节点或者开启了OffsetCheckInSlave校验时,才会对拉取偏移量进行纠正,所以以下几种状态中如果调用了此方法进行校正,前提是满足此条件。

  1. NO_MESSAGE_IN_QUEUE:如果CommitLog中的最大偏移量maxOffset值为0,说明当前消息队列中还没有消息,返回NO_MESSAGE_IN_QUEUE状态;
  2. OFFSET_TOO_SMALL:如果待拉取偏移量offset的值小于CommitLog文件的最小偏移量minOffset,说明拉取进度值过小,调用nextOffsetCorrection校正下一次的拉取偏移量为CommitLog文件的最小偏移量(需要满足校正的条件),并将这个偏移量放入nextBeginOffset变量;
  3. OFFSET_OVERFLOW_ONE:如果待拉取偏移量offset等于CommitLog文件的最大偏移量maxOffset,依旧调用nextOffsetCorrection方法进行校正(需要满足校正的条件),只不过校正的时候使用的还是offset的值,可以理解为这种情况什么也没干。
  4. OFFSET_OVERFLOW_BADLY:如果待拉取偏移量offset大于CommitLog文件最大偏移量maxOffset,说明拉取偏移量越界,此时有以下两种情况:
    • 如果最小偏移minOffset量为0,调用nextOffsetCorrection方法校正下一次拉取偏移量为minOffset的值(需要满足校正的条件),也就是告诉消费者,下次从偏移量为0的位置开始拉取消息;
    • 如果最小偏移量minOffset不为0,调用nextOffsetCorrection方法校正下一次拉取偏移量为maxOffset的值(需要满足校正的条件),将下一次拉取偏移量的值设置为最大偏移量;
  5. NO_MATCHED_LOGIC_QUEUE:如果根据主题未找到消息队列,返回此状态;
  6. FOUND:待拉取消息偏移量offset的值介于最大最小偏移量之间,此时可以正常查找消息;

需要注意以上是消息查找的结果状态,Broker并没有使用这个状态直接返回给消费者,而是又做了一次处理。

经过以上步骤后,除了查找到的消息内容,Broker还会在消息返回结果中设置以下信息:

  1. 查找结果状态;
  2. 下一次拉取的偏移量,也就是nextBeginOffset变量的值;
  3. CommitLog文件的最小偏移量minOffset和最大偏移量maxOffset

消费者对拉取结果的处理

消费者收到Broker返回的响应后,对响应结果进行处理:

  1. FOUND:消息拉取请求成功,此时从响应中获取Broker返回的下一次拉取偏移量的值,更新到拉取请求中,然后进行以下判断:
    • 如果拉取到的消息内容为空,将拉取请求放入到阻塞队列中再进行一次拉取;
    • 如果拉取到的消息内容不为空,将消息提交到ConsumeMessageService中进行消费(异步处理),然后判断拉取间隔的值是否大于0,如果大于0,会延迟一段时间进行下一次拉取,如果拉取间隔小于0表示需要立刻进行下一次拉取,此时将拉取请求加入阻塞队列中进行下一次拉取。
  2. NO_MATCHED_MSG:没有匹配的消息,使用Broker返回的下一次拉取偏移量的值作为新的拉取消息偏移量,然后将拉取请求加入阻塞队列中立刻进行下一次进行拉取。
  3. OFFSET_ILLEGAL:拉取偏移量不合法,此时使用Broker返回的下一次拉取偏移量的值,更新到消费者记录的消息拉取偏移量中(offsetStore),并持久化保存,然后将当前的拉取请求中的处理队列状态置为dorp并删除处理队列,等待下一次重新构建拉取请求进行处理。

RocketMQ消息拉取相关源码可参考:【RocketMQ】【源码】消息的拉取

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

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

实时显示当前文件夹下的文件大小,shell脚本实现

图片来源于网络,如果侵权请联系博主删除! 需求: 写一个shell终端命令,实时显示当前文件夹下的文件大小 实现: 您可以使用以下的Shell脚本命令来实时显示当前文件夹下的文件大小: while true; docleardu …

百度飞浆OCR识别表格入门python实践

1. 百度飞桨(PaddlePaddle) 百度飞桨(PaddlePaddle)是百度推出的一款深度学习平台,旨在为开发者提供强大的深度学习框架和工具。飞桨提供了包括OCR(光学字符识别)在内的多种功能,可…

《动手学深度学习 Pytorch版》 4.10 实战Kaggle比赛:预测比赛

4.10.1 下载和缓存数据集 import hashlib import os import tarfile import zipfile import requests#save DATA_HUB dict() DATA_URL http://d2l-data.s3-accelerate.amazonaws.com/def download(name, cache_diros.path.join(.., data)): #save"""下载一个…

【chromium】windows 获取源码到本地

从github的chromium 镜像git clone 到2.5G失败了官方说不能,要去 windows_build_instructions vs2017和19都是32位的 vs2022是x64的 vs2022_install You may also have to set variable vs2022_install to your installation path of Visual Studio 2022,

自定义Dynamics 365实施和发布业务解决方案 3. 开箱即用自定义

在本章中,您将开始开发SBMA会员应用程序。在开发的最初阶段,主要关注开箱即用的定制。在第2章中,我们讨论了如何创建基本解决方案的细节,在本章中,将创建作为解决方案补丁的基本自定义,并展示将解决方案添加到源代码管理和目标环境的步骤。 表单自定义 若要开始表单自定…

宠物行业如何进行软文营销

如今,宠物已经成为了人们生活中不可或缺的一部分,大众对于萌宠的喜爱与日俱增,随着“萌宠经济”升温,越来越多的商机开始出现,伴随着宠物市场竞争的日益激烈,宠物行业的营销光靠硬广告很难吸引受众&#xf…

使用内网端口映射方案,轻松实现U8用友ERP的本地部署异地远程访问——“cpolar内网穿透”

文章目录 前言1. 服务器本机安装U8并调试设置2. 用友U8借助cpolar实现企业远程办公2.1 在被控端电脑上,点击开始菜单栏,打开设置——系统2.2 找到远程桌面2.3 启用远程桌面 3. 安装cpolar内网穿透3.1 注册cpolar账号3.2 下载cpolar客户端 4. 获取远程桌面…

小美的数组操作2---牛客周赛 Round 11

注意给a[ 0 ]赋一个最小值 #include<bits/stdc.h> using namespace std; typedef long long ll; const int N1e55; int t,n,m,a[N],cnt[N]; int main(){scanf("%d",&t);while(t--){scanf("%d%d",&n,&m);for(int i1;i<n;i){scanf(&q…

Mann-Kendall 检验

一、M-K 趋势检验 Mann-Kendall 突变检验是一种非参数的假设检验方法&#xff0c;用于检验时间序列数据中的趋势性变化。该检验方法通过比较每个数据点与其之前数据点的大小&#xff0c;来检测时间序列数据中的单调趋势&#xff08;上升、下降或没有趋势&#xff09;。具体来说…

word转PDF文件变小,图片模糊

word论文29M&#xff0c;文件——另存为——只有1.5M左右&#xff0c;图片压缩严重&#xff0c;图片看不清。 word中很多大图&#xff0c;5M一张的图&#xff0c;所以word很大。 找了很多方法&#xff0c;转换后都在2M左右&#xff0c;勉强可以。 直到找到了这个&#xff0c…

Java-集合类

集合 Java集合是Java中用于存储和管理一组对象的工具。Java集合提供了相应的方法&#xff0c;用于用户对集合内数据的操作。 Java集合类提供了许多不同的数据结构&#xff0c;如列表、队列、栈、集合和映射&#xff0c;以满足不同类型的编程需求。 程序中如何存储大批量同类型…

C 编译原理

C 编译原理 目录 C 编译原理引入GCC 工具链介绍C运行库 编译准备工作编译过程1.预处理2.编译3.汇编4.链接 分析ELF文件1.ELF文件的段2.反汇编ELF C语言编译过程 - 摘录编译预处理编译、优化汇编链接过程 引入 大家肯定都知道计算机程序设计语言通常分为机器语言、汇编语言和高…

(2023 最新版)IntelliJ IDEA 下载安装及配置教程

IntelliJ IDEA下载安装教程&#xff08;图解&#xff09; IntelliJ IDEA 简称 IDEA&#xff0c;由 JetBrains 公司开发&#xff0c;是 Java 编程语言开发的集成环境&#xff0c;具有美观&#xff0c;高效等众多特点。在智能代码助手、代码自动提示、重构、J2EE 支持、各类版本…

深度学习面试八股文(2023.9.06)

一、优化器 1、SGD是什么&#xff1f; 批梯度下降&#xff08;Batch gradient descent&#xff09;&#xff1a;遍历全部数据集算一次损失函数&#xff0c;计算量开销大&#xff0c;计算速度慢&#xff0c;不支持在线学习。随机梯度下降&#xff08;Stochastic gradient desc…

计算机竞赛 机器视觉opencv答题卡识别系统

0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 &#x1f6a9; 答题卡识别系统 - opencv python 图像识别 该项目较为新颖&#xff0c;适合作为竞赛课题方向&#xff0c;学长非常推荐&#xff01; &#x1f947;学长这里给一个题目综合评分(每项满分5分…

电压放大器的应用范围有哪些

电压放大器是一种常见的电子设备&#xff0c;用于将输入信号的电压放大到更高的水平。它在各个领域中具有广泛的应用范围。本文将详细介绍电压放大器的应用。 音频放大器&#xff1a; 电压放大器在音频系统中起着重要作用&#xff0c;用于将来自音源&#xff08;如CD播放器、MP…

清华智能体宇宙火了;主流大语言模型的技术原理细节

&#x1f989; AI新闻 &#x1f680; 清华智能体宇宙火了 摘要&#xff1a;清华大学联合北邮、微信团队推出了AgentVerse&#xff0c;这是一个可以轻松模拟多智能体宇宙的环境。它专为大语言模型开发&#xff0c;智能体可以利用LLM能力完成任务。AgentVerse提供了几个示例环境…

Linux使用正则匹配配置文件有效内容

cat /etc/libvirt/libvirtd.conf|egrep -v "^#|^$" 这个正则表达式是在使用grep命令进行文本匹配过滤。下面是对该正则表达式的解释&#xff1a; ^#&#xff1a;^表示行的开头&#xff0c;#表示一个井号符号。这个部分匹配以井号开头的行。^$&#xff1a;$表示行的结…

聚精品,通全球 2024中国(杭州)国际电商物流包装产业展览会四月隆重开幕

2024中国&#xff08;杭州&#xff09;国际电商物流包装产业展览会 2024年4月12-14日 | 杭州国际博览中心 同期举办&#xff1a;2024长三角快递物流供应链与技术装备展览会&#xff08;杭州&#xff09; 2024中国&#xff08;杭州&#xff09;国际数字物流技术与应用展览会 展会…

Unity 课时 4 : No.4 模拟面试题

课时 4 : No.4 模拟面试题 C# 1. 请说明字符串中 string str null string str “” string str string.Empty 三者的区别 第一个未作初始化没有值, 第二个为空字符串, 答案&#xff1a; str null 在堆中没有分配内存地址 str "" 和 string.Empty 一样都是…