关于java:Netty-系列之-Netty-百万级推送服务设计要点

37次阅读

共计 10756 个字符,预计需要花费 27 分钟才能阅读完成。

1. 背景

1.1. 话题起源

最近很多从事挪动互联网和物联网开发的同学给我发邮件或者微博私信我,征询推送服务相干的问题。问题形形色色,在帮忙大家答疑解惑的过程中,我也对问题进行了总结,大略能够演绎为如下几类:

  1. Netty 是否能够做推送服务器?
  2. 如果应用 Netty 开发推送服务,一个服务器最多能够撑持多少个客户端?
  3. 应用 Netty 开发推送服务遇到的各种技术问题。

因为咨询者泛滥,关注点也比拟集中,我心愿通过本文的案例剖析和对推送服务设计要点的总结,帮忙大家在理论工作中少走弯路。

1.2. 推送服务

挪动互联网时代,推送 (Push) 服务成为 App 利用不可或缺的重要组成部分,推送服务能够晋升用户的活跃度和留存率。咱们的手机每天接管到各种各样的广告和提醒音讯等大多数都是通过推送服务实现的。

随着物联网的倒退,大多数的智能家居都反对挪动推送服务,将来所有接入物联网的智能设施都将是推送服务的客户端,这就意味着推送服务将来会面临海量的设施和终端接入。

1.3. 推送服务的特点

挪动推送服务的次要特点如下:

  1. 应用的网络次要是运营商的无线挪动网络,网络品质不稳固,例如在地铁上信号就很差,容易产生网络闪断;
  2. 海量的客户端接入,而且通常应用长连贯,无论是客户端还是服务端,资源耗费都十分大;
  3. 因为谷歌的推送框架无奈在国内应用,Android 的长连贯是由每个利用各自保护的,这就意味着每台安卓设施上会存在多个长连贯。即使没有音讯须要推送,长连贯自身的心跳音讯量也是十分微小的,这就会导致流量和耗电量的减少;
  4. 不稳固:音讯失落、反复推送、提早送达、过期推送时有发生;
  5. 垃圾音讯满天飞,不足对立的服务治理能力。

为了解决上述弊病,一些企业也给出了本人的解决方案,例如京东云推出的推送服务,能够实现多利用单服务单连贯模式,应用 AlarmManager 定时心跳节俭电量和流量。

2. 智能家居畛域的一个实在案例

2.1. 问题形容

智能家居 MQTT 音讯服务中间件,放弃 10 万用户在线长连贯,2 万用户并发做音讯申请。程序运行一段时间之后,发现内存泄露,狐疑是 Netty 的 Bug。其它相干信息如下:

  1. MQTT 音讯服务中间件服务器内存 16G,8 个外围 CPU;
  2. Netty 中 boss 线程池大小为 1,worker 线程池大小为 6,其余线程调配给业务应用。该调配形式起初调整为 worker 线程池大小为 11,问题仍旧;
  3. Netty 版本为 4.0.8.Final。

2.2. 问题定位

首先须要 dump 内存堆栈,对疑似内存泄露的对象和援用关系进行剖析,如下所示:

咱们发现 Netty 的 ScheduledFutureTask 减少了 9076%,达到 110W 个左右的实例,通过对业务代码的剖析发现用户应用 IdleStateHandler 用于在链路闲暇时进行业务逻辑解决,然而闲暇工夫设置的比拟大,为 15 分钟。

Netty 的 IdleStateHandler 会依据用户的应用场景,启动三类定时工作,别离是:ReaderIdleTimeoutTask、WriterIdleTimeoutTask 和 AllIdleTimeoutTask,它们都会被退出到 NioEventLoop 的 Task 队列中被调度和执行。

因为超时工夫过长,10W 个长链接链路会创立 10W 个 ScheduledFutureTask 对象,每个对象还保留有业务的成员变量,十分耗费内存。用户的长久代设置的比拟大,一些定时工作被老化到长久代中,没有被 JVM 垃圾回收掉,内存始终在增长,用户误认为存在内存泄露。

事实上,咱们进一步剖析发现,用户的超时工夫设置的十分不合理,15 分钟的超时达不到设计指标,从新设计之后将超时工夫设置为 45 秒,内存能够失常回收,问题解决。

2.3. 问题总结

如果是 100 个长连贯,即使是长周期的定时工作,也不存在内存泄露问题,在新生代通过 minor GC 就能够实现内存回收。正是因为十万级的长连贯,导致小问题被放大,引出了后续的各种问题。

事实上,如果用户的确有长周期运行的定时工作,该如何解决?对于海量长连贯的推送服务,代码解决稍有不慎,就满盘皆输,上面咱们针对 Netty 的架构特点,介绍下如何应用 Netty 实现百万级客户端的推送服务。

3. Netty 海量推送服务设计要点

作为高性能的 NIO 框架,利用 Netty 开发高效的推送服务技术上是可行的,然而因为推送服务本身的复杂性,想要开发出稳固、高性能的推送服务并非易事,须要在设计阶段针对推送服务的特点进行正当设计。

3.1. 最大句柄数批改

百万长连贯接入,首先须要优化的就是 Linux 内核参数,其中 Linux 最大文件句柄数是最重要的调优参数之一,默认单过程关上的最大句柄数是 1024,通过 ulimit -a 能够查看相干参数,示例如下:

[root@lilinfeng ~]# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 256324
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024

...... 后续输入省略

当单个推送服务接管到的链接超过下限后,就会报“too many open files”,所有新的客户端接入将失败。

通过 vi /etc/security/limits.conf 增加如下配置参数:批改之后保留,登记以后用户,从新登录,通过 ulimit -a 查看批改的状态是否失效。

*  soft  nofile  1000000
*  hard  nofile  1000000

须要指出的是,只管咱们能够将单个过程关上的最大句柄数批改的十分大,然而当句柄数达到肯定数量级之后,解决效率将呈现显著降落,因而,须要依据服务器的硬件配置和解决能力进行正当设置。如果单个服务器性能不行也能够通过集群的形式实现。

3.2. 当心 CLOSE_WAIT

从事挪动推送服务开发的同学可能都有领会,挪动无线网络可靠性十分差,常常存在客户端重置连贯,网络闪断等。

在百万长连贯的推送零碎中,服务端须要可能正确处理这些网络异样,设计要点如下:

  1. 客户端的重连距离须要正当设置,避免连贯过于频繁导致的连贯失败(例如端口还没有被开释);
  2. 客户端反复登陆回绝机制;
  3. 服务端正确处理 I/O 异样和解码异样等,避免句柄泄露。

最初特地须要留神的一点就是 close_wait 过多问题,因为网络不稳固常常会导致客户端断连,如果服务端没有可能及时敞开 socket,就会导致处于 close_wait 状态的链路过多。close_wait 状态的链路并不开释句柄和内存等资源,如果积压过多可能会导致系统句柄耗尽,产生“Too many open files”异样,新的客户端无奈接入,波及创立或者关上句柄的操作都将失败。

上面对 close_wait 状态进行下简略介绍,被动敞开 TCP 连贯状态迁徙图如下所示:

图 3-1 被动敞开 TCP 连贯状态迁徙图

close_wait 是被动敞开连贯是造成的,依据 TCP 状态机,服务器端收到客户端发送的 FIN,TCP 协定栈会主动发送 ACK,链接进入 close_wait 状态。但如果服务器端不执行 socket 的 close() 操作,状态就不能由 close_wait 迁徙到 last_ack,则零碎中会存在很多 close_wait 状态的连贯。通常来说,一个 close_wait 会维持至多 2 个小时的工夫(零碎默认超时工夫的是 7200 秒,也就是 2 小时)。如果服务端程序因某个起因导致系统造成一堆 close_wait 耗费资源,那么通常是等不到开释那一刻,零碎就已解体。

导致 close_wait 过多的可能起因如下:

  1. 程序处理 Bug,导致接管到对方的 fin 之后没有及时敞开 socket,这可能是 Netty 的 Bug,也可能是业务层 Bug,须要具体问题具体分析;
  2. 敞开 socket 不及时:例如 I/O 线程被意外阻塞,或者 I/O 线程执行的用户自定义 Task 比例过高,导致 I/O 操作解决不及时,链路不能被及时开释。

上面咱们联合 Netty 的原理,对潜在的故障点进行剖析。

设计要点 1:不要在 Netty 的 I/O 线程上解决业务(心跳发送和检测除外)。Why? 对于 Java 过程,线程不能有限增长,这就意味着 Netty 的 Reactor 线程数必须收敛。Netty 的默认值是 CPU 核数 2,通常状况下,I/O 密集型利用倡议线程数尽量设置大些,但这次要是针对传统同步 I/O 而言,对于非阻塞 I/O,线程数并不倡议设置太大,只管没有最优值,然而 I/O 线程数经验值是 [CPU 核数 + 1,CPU 核数 2 ] 之间。

如果单个服务器撑持 100 万个长连贯,服务器内核数为 32,则单个 I/O 线程解决的链接数 L = 100/(32 * 2) = 15625。如果每 5S 有一次音讯交互(新音讯推送、心跳音讯和其它治理音讯),则均匀 CAPS = 15625 / 5 = 3125 条 / 秒。这个数值相比于 Netty 的解决性能而言压力并不大,然而在理论业务解决中,常常会有一些额定的简单逻辑解决,例如性能统计、记录接口日志等,这些业务操作性能开销也比拟大,如果在 I/O 线程上间接做业务逻辑解决,可能会阻塞 I/O 线程,影响对其它链路的读写操作,这就会导致被动敞开的链路不能及时敞开,造成 close_wait 沉积。

设计要点 2:在 I/O 线程上执行自定义 Task 要当心。Netty 的 I/O 解决线程 NioEventLoop 反对两种自定义 Task 的执行:

  1. 一般的 Runnable: 通过调用 NioEventLoop 的 execute(Runnable task) 办法执行;
  2. 定时工作 ScheduledFutureTask: 通过调用 NioEventLoop 的 schedule(Runnable command, long delay, TimeUnit unit) 系列接口执行。

为什么 NioEventLoop 要反对用户自定义 Runnable 和 ScheduledFutureTask 的执行,并不是本文要探讨的重点,后续会有专题文章进行介绍。本文重点对它们的影响进行剖析。

在 NioEventLoop 中执行 Runnable 和 ScheduledFutureTask,意味着容许用户在 NioEventLoop 中执行非 I/O 操作类的业务逻辑,这些业务逻辑通常用消息报文的解决和协定治理相干。它们的执行会抢占 NioEventLoop I/O 读写的 CPU 工夫,如果用户自定义 Task 过多,或者单个 Task 执行周期过长,会导致 I/O 读写操作被阻塞,这样也间接导致 close_wait 沉积。

所以,如果用户在代码中应用到了 Runnable 和 ScheduledFutureTask,请正当设置 ioRatio 的比例,通过 NioEventLoop 的 setIoRatio(int ioRatio) 办法能够设置该值,默认值为 50,即 I/O 操作和用户自定义工作的执行工夫比为 1:1。

我的倡议是当服务端解决海量客户端长连贯的时候,不要在 NioEventLoop 中执行自定义 Task,或者非心跳类的定时工作。

设计要点 3:IdleStateHandler 应用要当心。很多用户会应用 IdleStateHandler 做心跳发送和检测,这种用法值得提倡。相比于本人启定时工作发送心跳,这种形式更高效。然而在理论开发中须要留神的是,在心跳的业务逻辑解决中,无论是失常还是异样场景,解决时延要可控,避免时延不可控导致的 NioEventLoop 被意外阻塞。例如,心跳超时或者产生 I/O 异样时,业务调用 Email 发送接口告警,因为 Email 服务端解决超时,导致邮件发送客户端被阻塞,级联引起 IdleStateHandler 的 AllIdleTimeoutTask 工作被阻塞,最终 NioEventLoop 多路复用器上其它的链路读写被阻塞。

对于 ReadTimeoutHandler 和 WriteTimeoutHandler,束缚同样存在。

3.3. 正当的心跳周期

百万级的推送服务,意味着会存在百万个长连贯,每个长连贯都须要靠和 App 之间的心跳来维持链路。正当设置心跳周期是十分重要的工作,推送服务的心跳周期设置须要思考挪动无线网络的特点。

当一台智能手机连上挪动网络时,其实并没有真正连贯上 Internet,运营商调配给手机的 IP 其实是运营商的内网 IP,手机终端要连贯上 Internet 还必须通过运营商的网关进行 IP 地址的转换,这个网关简称为 NAT(NetWork Address Translation),简略来说就是手机终端连贯 Internet 其实就是挪动内网 IP,端口,外网 IP 之间互相映射。

GGSN(GateWay GPRS Support Note) 模块就实现了 NAT 性能,因为大部分的挪动无线网络运营商为了缩小网关 NAT 映射表的负荷,如果一个链路有一段时间没有通信时就会删除其对应表,造成链路中断,正是这种刻意缩短闲暇连贯的开释超时,本来是想节俭信道资源的作用,没想到让互联网的利用不得以远高于失常频率发送心跳来保护推送的长连贯。以中挪动的 2.5G 网络为例,大概 5 分钟左右的基带闲暇,连贯就会被开释。

因为挪动无线网络的特点,推送服务的心跳周期并不能设置的太长,否则长连贯会被开释,造成频繁的客户端重连,然而也不能设置太短,否则在以后不足对立心跳框架的机制下很容易导致信令风暴(例如微信念跳信令风暴问题)。具体的心跳周期并没有对立的规范,180S 兴许是个不错的抉择,微信为 300S。

在 Netty 中,能够通过在 ChannelPipeline 中减少 IdleStateHandler 的形式实现心跳检测,在构造函数中指定链路闲暇工夫,而后实现闲暇回调接口,实现心跳的发送和检测,代码如下:

public void initChannel({@link Channel} channel) {channel.pipeline().addLast("idleStateHandler", new {@link   IdleStateHandler}(0, 0, 180));
 channel.pipeline().addLast("myHandler", new MyHandler());
}
拦挡链路闲暇事件并解决心跳:public class MyHandler extends {@link ChannelHandlerAdapter} {{@code @Override}
      public void userEventTriggered({@link ChannelHandlerContext} ctx, {@link Object} evt) throws {@link Exception} {if (evt instanceof {@link IdleStateEvent}} {// 心跳解决}
      }
  }

3.4. 正当设置接管和发送缓冲区容量

对于长链接,每个链路都须要保护本人的音讯接管和发送缓冲区,JDK 原生的 NIO 类库应用的是 java.nio.ByteBuffer, 它理论是一个长度固定的 Byte 数组,咱们都晓得数组无奈动静扩容,ByteBuffer 也有这个限度,相干代码如下:

public abstract class ByteBuffer
    extends Buffer
    implements Comparable
{final byte[] hb; // Non-null only for heap buffers
    final int offset;
    boolean isReadOnly;

容量无奈动静扩大会给用户带来一些麻烦,例如因为无奈预测每条消息报文的长度,可能须要预调配一个比拟大的 ByteBuffer,这通常也没有问题。然而在海量推送服务零碎中,这会给服务端带来惨重的内存累赘。假如单条推送音讯最大下限为 10K,音讯均匀大小为 5K,为了满足 10K 音讯的解决,ByteBuffer 的容量被设置为 10K,这样每条链路实际上多耗费了 5K 内存,如果长链接链路数为 100 万,每个链路都独立持有 ByteBuffer 接收缓冲区,则额定损耗的总内存 Total(M) = 1000000 * 5K = 4882M。内存耗费过大,不仅仅减少了硬件老本,而且大内存容易导致长时间的 Full GC,对系统稳定性会造成比拟大的冲击。

实际上,最灵便的解决形式就是可能动静调整内存,即接收缓冲区能够依据以往接管的音讯进行计算,动静调整内存,利用 CPU 资源来换内存资源,具体的策略如下:

  1. ByteBuffer 反对容量的扩大和膨胀,能够按需灵便调整,以节约内存;
  2. 接管音讯的时候,能够依照指定的算法对之前接管的音讯大小进行剖析,并预测将来的音讯大小,依照预测值灵便调整缓冲区容量,以做到最小的资源损耗满足程序失常性能。

侥幸的是,Netty 提供的 ByteBuf 反对容量动静调整,对于接收缓冲区的内存分配器,Netty 提供了两种:

  1. FixedRecvByteBufAllocator:固定长度的接收缓冲区分配器,由它调配的 ByteBuf 长度都是固定大小的,并不会依据理论数据报的大小动静膨胀。然而,如果容量有余,反对动静扩大。动静扩大是 Netty ByteBuf 的一项基本功能,与 ByteBuf 分配器的实现没有关系;
  2. AdaptiveRecvByteBufAllocator:容量动静调整的接收缓冲区分配器,它会依据之前 Channel 接管到的数据报大小进行计算,如果间断填充斥接收缓冲区的可写空间,则动静扩大容量。如果间断 2 次接管到的数据报都小于指定值,则膨胀以后的容量,以节约内存。

绝对于 FixedRecvByteBufAllocator,应用 AdaptiveRecvByteBufAllocator 更为正当,能够在创立客户端或者服务端的时候指定 RecvByteBufAllocator,代码如下:

 Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT)

如果默认没有设置,则应用 AdaptiveRecvByteBufAllocator。

另外值得注意的是,无论是接收缓冲区还是发送缓冲区,缓冲区的大小倡议设置为音讯的均匀大小,不要设置成最大音讯的下限,这会导致额定的内存节约。通过如下形式能够设置接收缓冲区的初始大小:

/**
     * Creates a new predictor with the specified parameters.
     * 
     * @param minimum
     *            the inclusive lower bound of the expected buffer size
     * @param initial
     *            the initial buffer size when no feed back was received
     * @param maximum
     *            the inclusive upper bound of the expected buffer size
     */
    public AdaptiveRecvByteBufAllocator(int minimum, int initial, int maximum) 

对于音讯发送,通常须要用户本人结构 ByteBuf 并编码,例如通过如下工具类创立音讯发送缓冲区:

图 3-2 结构指定容量的缓冲区

3.5. 内存池

推送服务器承载了海量的长链接,每个长链接理论就是一个会话。如果每个会话都持有心跳数据、接收缓冲区、指令集等数据结构,而且这些实例随着音讯的解决朝生夕灭,这就会给服务器带来惨重的 GC 压力,同时耗费大量的内存。

最无效的解决策略就是应用内存池,每个 NioEventLoop 线程解决 N 个链路,在线程外部,链路的解决时串行的。如果 A 链路首先被解决,它会创立接收缓冲区等对象,待解码实现之后,结构的 POJO 对象被封装成 Task 后投递到后盾的线程池中执行,而后接收缓冲区会被开释,每条音讯的接管和解决都会反复接收缓冲区的创立和开释。如果应用内存池,则当 A 链路接管到新的数据报之后,从 NioEventLoop 的内存池中申请闲暇的 ByteBuf,解码实现之后,调用 release 将 ByteBuf 开释到内存池中,供后续 B 链路持续应用。

应用内存池优化之后,单个 NioEventLoop 的 ByteBuf 申请和 GC 次数从原来的 N = 1000000/64 = 15625 次缩小为起码 0 次(假如每次申请都有可用的内存)。

上面咱们以推特应用 Netty4 的 PooledByteBufAllocator 进行 GC 优化作为案例,对内存池的成果进行评估,后果如下:

垃圾生成速度是原来的 1/5,而垃圾清理速度快了 5 倍。应用新的内存池机制,简直能够把网络带宽压满。

Netty4 之前的版本问题如下:每当收到新信息或者用户发送信息到近程端,Netty 3 均会创立一个新的堆缓冲区。这意味着,对应每一个新的缓冲区,都会有一个 new byte[capacity]。这些缓冲区会导致 GC 压力,并耗费内存带宽。为了平安起见,新的字节数组调配时会用零填充,这会耗费内存带宽。然而,用零填充的数组很可能会再次用理论的数据填充,这又会耗费同样的内存带宽。如果 Java 虚拟机(JVM)提供了创立新字节数组而又无需用零填充的形式,那么咱们原本就能够将内存带宽耗费缩小 50%,然而目前没有那样一种形式。

在 Netty 4 中实现了一个新的 ByteBuf 内存池,它是一个纯 Java 版本的 jemalloc(Facebook 也在用)。当初,Netty 不会再因为用零填充缓冲区而节约内存带宽了。不过,因为它不依赖于 GC,开发人员须要小心内存透露。如果遗记在处理程序中开释缓冲区,那么内存使用率会有限地增长。

Netty 默认不应用内存池,须要在创立客户端或者服务端的时候进行指定,代码如下:

Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)

应用内存池之后,内存的申请和开释必须成对呈现,即 retain() 和 release() 要成对呈现,否则会导致内存泄露。

值得注意的是,如果应用内存池,实现 ByteBuf 的解码工作之后必须显式的调用 ReferenceCountUtil.release(msg) 对接收缓冲区 ByteBuf 进行内存开释,否则它会被认为依然在应用中,这样会导致内存泄露。

3.6. 当心“日志隐形杀手”

通常状况下,大家都晓得不能在 Netty 的 I/O 线程上做执行工夫不可控的操作,例如拜访数据库、发送 Email 等。然而有个罕用然而十分危险的操作却容易被疏忽,那便是记录日志。

通常,在生产环境中,须要实时打印接口日志,其它日志处于 ERROR 级别,当推送服务产生 I/O 异样之后,会记录异样日志。如果以后磁盘的 WIO 比拟高,可能会产生写日志文件操作被同步阻塞,阻塞工夫无奈预测。这就会导致 Netty 的 NioEventLoop 线程被阻塞,Socket 链路无奈被及时敞开、其它的链路也无奈进行读写操作等。

以最罕用的 log4j 为例,只管它反对异步写日志(AsyncAppender),然而当日志队列满之后,它会同步阻塞业务线程,直到日志队列有闲暇地位可用,相干代码如下:

 synchronized (this.buffer) {while (true) {int previousSize = this.buffer.size();
        if (previousSize < this.bufferSize) {this.buffer.add(event);
          if (previousSize != 0) break;
          this.buffer.notifyAll(); break;}
        boolean discard = true;
        if ((this.blocking) && (!Thread.interrupted()) && (Thread.currentThread() != this.dispatcher)) // 判断是业务线程 
        {
          try
          {this.buffer.wait();// 阻塞业务线程 
            discard = false;
          }
          catch (InterruptedException e)
          {Thread.currentThread().interrupt();}

        }

相似这类 BUG 具备极强的隐蔽性,往往 WIO 高的工夫继续十分短,或者是偶现的,在测试环境中很难模仿此类故障,问题定位难度十分大。这就要求读者在平时写代码的时候肯定要当心,留神那些隐性地雷。

3.7. TCP 参数优化

罕用的 TCP 参数,例如 TCP 层面的接管和发送缓冲区大小设置,在 Netty 中别离对应 ChannelOption 的 SO_SNDBUF 和 SO_RCVBUF,须要依据推送音讯的大小,正当设置,对于海量长连贯,通常 32K 是个不错的抉择。

另外一个比拟罕用的优化伎俩就是软中断,如图所示:如果所有的软中断都运行在 CPU0 相应网卡的硬件中断上,那么始终都是 cpu0 在解决软中断,而此时其它 CPU 资源就被节约了,因为无奈并行的执行多个软中断。

图 3-3 中断信息

大于等于 2.6.35 版本的 Linux kernel 内核,开启 RPS,网络通信性能晋升 20% 之上。RPS 的基本原理:依据数据包的源地址,目标地址以及目标和源端口,计算出一个 hash 值,而后依据这个 hash 值来抉择软中断运行的 cpu。从下层来看,也就是说将每个连贯和 cpu 绑定,并通过这个 hash 值,来平衡软中断运行在多个 cpu 上,从而晋升通信性能。

3.8. JVM 参数

最重要的参数调整有两个:

  • -Xmx:JVM 最大内存须要依据内存模型进行计算并得出绝对正当的值;
  • GC 相干的参数: 例如新生代和老生代、永恒代的比例,GC 的策略,新生代各区的比例等,须要依据具体的场景进行设置和测试,并一直的优化,尽量将 Full GC 的频率降到最低。

起源:https://www.infoq.cn/article/…
欢送关注公众号【码农开花】一起学习成长
我会始终分享 Java 干货,也会分享收费的学习材料课程和面试宝典
回复:【计算机】【设计模式】【面试】有惊喜哦

正文完
 0