最新要闻
- 观热点:纽约州进入紧急状态 美国暴风雪有多可怕?官方告诫市民别出门否则会冻死
- 快消息!稳住iPhone 14 Pro 富士康给员工发留任奖金:最高5000元
- 黑客携4亿推特用户数据勒索马斯克:想破财消灾还是被罚款?
- 世界看点:驾特斯拉Model X车祸 5个多月林志颖仍在康复:期待复工
- 一家三口发烧用洋葱退热成功 医生科普:低烧有用、高烧不建议
- 【全球新视野】工信部拟规定:APP应可便捷卸载 不得默认自动续订
- 当前关注:终于不用费劲算优惠了:天猫年货节取消跨店满减 直接降价
- 最新资讯:雷军刚投的激光雷达:120米以外就能探测到路面轮胎
- 环球讯息:李国庆称逃离北上广去二线商机无限 快反思降降价:看好杭州合肥长沙等
- 43mm大喇叭!荣耀亲选迪士尼便携蓝牙音箱上架 149元
- 环球观天下!更冷了!南方多地将有大到暴雪:湿冷“魔法攻击”来了
- 【世界独家】张艺谋新作 电影《满江红》官宣阵容:沈腾、易烊千玺双男主
- 每日热闻!专家:波司登万元羽绒服面料很低廉
- 焦点消息!文化输出网红要复出了?李子柒成子柒文化实控人:微念创始人退出公司
- 天天快讯:多语种录音转译!讯飞开放式办公耳机iFLYBUDS Air上市 899元
- 科特迪瓦是哪个国家?科特迪瓦特产有哪些?
手机
iphone11大小尺寸是多少?苹果iPhone11和iPhone13的区别是什么?
警方通报辅警执法直播中被撞飞:犯罪嫌疑人已投案
- iphone11大小尺寸是多少?苹果iPhone11和iPhone13的区别是什么?
- 警方通报辅警执法直播中被撞飞:犯罪嫌疑人已投案
- 男子被关545天申国赔:获赔18万多 驳回精神抚慰金
- 3天内26名本土感染者,辽宁确诊人数已超安徽
- 广西柳州一男子因纠纷杀害三人后自首
- 洱海坠机4名机组人员被批准为烈士 数千干部群众悼念
家电
天天短讯!Netty中8大组件详解(EventLoop、Channel、ChannelFuture、Future、 Promise、Handler 、 Pipeline、ByteBuf)
Netty 概述
1、什么是 Netty
Netty is an asynchronous event-driven network application frameworkfor rapid development of maintainable high performance protocol servers & clients.
Netty 是一个异步的、基于事件驱动的网络应用框架,用于快速开发可维护、高性能的网络服务器和客户端
注意:netty的异步还是基于多路复用的,并没有实现真正意义上的异步IO
2、Netty 的优势
如果使用传统 NIO,其工作量大,bug 多
【资料图】
- 需要自己构建协议
- 解决 TCP 传输问题,如粘包、半包
- 因为 bug 的存在,epoll 空轮询导致 CPU 100%
Netty 对 API 进行增强,使之更易用,如
- FastThreadLocal => ThreadLocal
- ByteBuf => ByteBuffer
3、入门案例
1、服务器端代码
public class HelloServer { public static void main(String[] args) { // 1、启动器,负责装配netty组件,启动服务器 new ServerBootstrap() // 2、创建 NioEventLoopGroup,可以简单理解为 线程池 + Selector .group(new NioEventLoopGroup()) // 3、选择服务器的 ServerSocketChannel 实现 .channel(NioServerSocketChannel.class) // 4、child 负责处理读写,该方法决定了 child 执行哪些操作 // ChannelInitializer 处理器(仅执行一次) // 它的作用是待客户端 SocketChannel 建立连接后,执行 initChannel 以便添加更多的处理器 .childHandler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception { // 5、SocketChannel的处理器,使用StringDecoder解码,ByteBuf=>String nioSocketChannel.pipeline().addLast(new StringDecoder()); // 6、SocketChannel的业务处理,使用上一个处理器的处理结果 nioSocketChannel.pipeline().addLast(new SimpleChannelInboundHandler() { @Override protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) throws Exception { System.out.println(s); } }); } // 7、ServerSocketChannel绑定8080端口 }).bind(8080); }}
2、客户端代码
public class HelloClient { public static void main(String[] args) throws InterruptedException { new Bootstrap() .group(new NioEventLoopGroup()) // 选择客户 Socket 实现类,NioSocketChannel 表示基于 NIO 的客户端实现 .channel(NioSocketChannel.class) // ChannelInitializer 处理器(仅执行一次) // 它的作用是待客户端SocketChannel建立连接后,执行initChannel以便添加更多的处理器 .handler(new ChannelInitializer() { @Override protected void initChannel(Channel channel) throws Exception { // 消息会经过通道 handler 处理,这里是将 String => ByteBuf 编码发出 channel.pipeline().addLast(new StringEncoder()); } }) // 指定要连接的服务器和端口 .connect(new InetSocketAddress("localhost", 8080)) // Netty 中很多方法都是异步的,如 connect // 这时需要使用 sync 方法等待 connect 建立连接完毕 .sync() // 获取 channel 对象,它即为通道抽象,可以进行数据读写操作 .channel() // 写入消息并清空缓冲区 .writeAndFlush("hello world"); }}
3、运行流程
左:客户端 右:服务器端
组件解释
- channel 可以理解为数据的通道
- msg 理解为流动的数据,最开始输入是 ByteBuf,但经过 pipeline 中的各个 handler 加工,会变成其它类型对象,最后输出又变成 ByteBuf
- handler 可以理解为数据的处理工序
工序有多道,合在一起就是 pipeline(传递途径),pipeline 负责发布事件(读、读取完成…)传播给每个 handler, handler 对自己感兴趣的事件进行处理(重写了相应事件处理方法)
- pipeline 中有多个 handler,处理时会依次调用其中的 handler
handler 分 Inbound 和 Outbound 两类
- Inbound 入站
Outbound 出站
- eventLoop 可以理解为处理数据的工人
- eventLoop 可以管理多个 channel 的 io 操作,并且一旦 eventLoop 负责了某个 channel,就会将其与 channel 进行绑定,以后该 channel 中的 io 操作都由该 eventLoop 负责
- eventLoop 既可以执行 io 操作,也可以进行任务处理,每个 eventLoop 有自己的任务队列,队列里可以堆放多个 channel 的待处理任务,任务分为普通任务、定时任务
- eventLoop 按照 pipeline 顺序,依次按照 handler 的规划(代码)处理数据,可以为每个 handler 指定不同的 eventLoop
1、EventLoop
事件循环对象EventLoop
EventLoop 本质是一个单线程执行器(同时维护了一个 Selector),里面有 run 方法处理一个或多个 Channel 上源源不断的 io 事件
它的继承关系如下
继承自 j.u.c.ScheduledExecutorService 因此包含了线程池中所有的方法
继承自 netty 自己的 OrderedEventExecutor
提供了 boolean inEventLoop (Thread thread) 方法判断一个线程是否属于此 EventLoop
提供了 EventLoopGroup parent () 方法来看看自己属于哪个 EventLoopGroup
事件循环组EventLoopGroup
EventLoopGroup 是一组 EventLoop,Channel 一般会调用 EventLoopGroup 的 register 方法来绑定其中一个 EventLoop,后续这个 Channel 上的 io 事件都由此 EventLoop 来处理(保证了 io 事件处理时的线程安全)
- 继承自 netty 自己的 EventExecutorGroup
- 实现了 Iterable 接口提供遍历 EventLoop 的能力
- 另有 next 方法获取集合中下一个 EventLoop
1.1 处理普通与定时任务
public class TestEventLoop { public static void main(String[] args) { // 创建拥有两个EventLoop的NioEventLoopGroup,对应两个线程 EventLoopGroup group = new NioEventLoopGroup(2); // 通过next方法可以获得下一个 EventLoop System.out.println(group.next()); System.out.println(group.next()); // 通过EventLoop执行普通任务 group.next().execute(()->{ System.out.println(Thread.currentThread().getName() + " hello"); }); // 通过EventLoop执行定时任务 group.next().scheduleAtFixedRate(()->{ System.out.println(Thread.currentThread().getName() + " hello2"); }, 0, 1, TimeUnit.SECONDS); // 优雅地关闭 group.shutdownGracefully(); }}
输出结果如下
io.netty.channel.nio.NioEventLoop@7bb11784io.netty.channel.nio.NioEventLoop@33a10788nioEventLoopGroup-2-1 hellonioEventLoopGroup-2-2 hello2nioEventLoopGroup-2-2 hello2nioEventLoopGroup-2-2 hello2
关闭 EventLoopGroup
优雅关闭 shutdownGracefully方法。该方法会首先切换 EventLoopGroup到关闭状态从而拒绝新的任务的加入,然后在任务队列的任务都处理完成后,停止线程的运行。从而确保整体应用是在正常有序的状态下退出的
1.2 处理 IO 任务
服务器代码
public class MyServer { public static void main(String[] args) { new ServerBootstrap() .group(new NioEventLoopGroup()) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println(Thread.currentThread().getName() + " " + buf.toString(StandardCharsets.UTF_8)); } }); } }) .bind(8080); }}
客户端代码
public class MyClient { public static void main(String[] args) throws IOException, InterruptedException { Channel channel = new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new StringEncoder()); } }) .connect(new InetSocketAddress("localhost", 8080)) .sync() .channel(); System.out.println(channel); // 此处打断点调试,调用 channel.writeAndFlush(...); System.in.read(); }}
1.3 分工
Bootstrap 的 group () 方法可以传入两个 EventLoopGroup 参数,分别负责处理不同的事件
public class MyServer { public static void main(String[] args) { new ServerBootstrap() // 两个Group,分别为Boss 负责Accept事件,Worker 负责读写事件 .group(new NioEventLoopGroup(1), new NioEventLoopGroup(2)) ... }}
多个客户端分别发送 hello 结果
nioEventLoopGroup-3-1 hello1nioEventLoopGroup-3-2 hello2nioEventLoopGroup-3-1 hello3nioEventLoopGroup-3-2 hello4nioEventLoopGroup-3-2 hello4
可以看出,一个 EventLoop 可以负责多个Channel,且 EventLoop 一旦与 Channel 绑定,则一直负责处理该 Channel 中的事件
增加自定义 EventLoopGroup
当有的任务需要较长的时间处理时,可以使用非 NioEventLoopGroup,避免同一个 NioEventLoop 中的其他 Channel 在较长的时间内都无法得到处理
public class MyServer { public static void main(String[] args) { // 增加自定义的非NioEventLoopGroup EventLoopGroup group = new DefaultEventLoopGroup(); new ServerBootstrap() .group(new NioEventLoopGroup(1), new NioEventLoopGroup(2)) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { // 增加两个handler,第一个使用NioEventLoopGroup处理,第二个使用自定义EventLoopGroup处理 socketChannel.pipeline().addLast("nioHandler",new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println(Thread.currentThread().getName() + " " + buf.toString(StandardCharsets.UTF_8)); // 调用下一个handler ctx.fireChannelRead(msg); } }) // 该handler绑定自定义的Group .addLast(group, "myHandler", new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; System.out.println(Thread.currentThread().getName() + " " + buf.toString(StandardCharsets.UTF_8)); } }); } }) .bind(8080); }}
启动四个客户端发送数据
nioEventLoopGroup-4-1 hello1defaultEventLoopGroup-2-1 hello1nioEventLoopGroup-4-2 hello2defaultEventLoopGroup-2-2 hello2nioEventLoopGroup-4-1 hello3defaultEventLoopGroup-2-3 hello3nioEventLoopGroup-4-2 hello4defaultEventLoopGroup-2-4 hello4
可以看出,客户端与服务器之间的事件,被 nioEventLoopGroup 和 defaultEventLoopGroup 分别处理
切换的实现
不同的 EventLoopGroup 切换的实现原理如下
由上面的图可以看出,当 handler 中绑定的 Group 不同时,需要切换 Group 来执行不同的任务
static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) { final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next); // 获得下一个EventLoop, excutor 即为 EventLoopGroup EventExecutor executor = next.executor(); // 如果下一个EventLoop 在当前的 EventLoopGroup中 if (executor.inEventLoop()) { // 使用当前 EventLoopGroup 中的 EventLoop 来处理任务 next.invokeChannelRead(m); } else { // 否则让另一个 EventLoopGroup 中的 EventLoop 来创建任务并执行 executor.execute(new Runnable() { public void run() { next.invokeChannelRead(m); } }); }}
- 如果两个 handler 绑定的是同一个 EventLoopGroup,那么就直接调用
- 否则,把要调用的代码封装为一个任务对象,由下一个 handler 的 EventLoopGroup 来调用
2、Channel
Channel 的常用方法
- close () 可以用来关闭 Channel
- closeFuture () 用来处理 Channel 的关闭
- sync 方法作用是同步等待 Channel 关闭
- 而 addListener 方法是异步等待 Channel 关闭
- pipeline () 方法用于添加处理器
- write () 方法将数据写入
- 因为缓冲机制,数据被写入到 Channel 中以后,不会立即被发送
- 只有当缓冲满了或者调用了 flush () 方法后,才会将数据通过 Channel 发送出去
- writeAndFlush () 方法将数据写入并立即发送(刷出)
2.1 ChannelFuture
连接问题
拆分客户端代码
public class MyClient { public static void main(String[] args) throws IOException, InterruptedException { ChannelFuture channelFuture = new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new StringEncoder()); } }) // 该方法为异步非阻塞方法,主线程调用后不会被阻塞,真正去执行连接操作的是NIO线程 // NIO线程:NioEventLoop 中的线程 .connect(new InetSocketAddress("localhost", 8080)); // 该方法用于等待连接真正建立 channelFuture.sync(); // 获取客户端-服务器之间的Channel对象 Channel channel = channelFuture.channel(); channel.writeAndFlush("hello world"); System.in.read(); }}
如果我们去掉 channelFuture.sync()方法,会服务器无法收到 hello world
这是因为建立连接 (connect) 的过程是 异步非阻塞的,若不通过 sync() 方法阻塞主线程,等待连接真正建立,这时通过 channelFuture.channel () 拿到的 Channel 对象,并不是真正与服务器建立好连接的 Channel,也就没法将信息正确的传输给服务器端
所以需要通过 channelFuture.sync() 方法,阻塞主线程,同步处理结果,等待连接真正建立好以后,再去获得 Channel 传递数据。使用该方法,获取 Channel 和发送数据的线程 都是主线程
下面还有一种方法,用于 异步获取建立连接后的 Channel 和发送数据,使得执行这些操作的线程是 NIO 线程(去执行 connect 操作的线程)
addListener 方法
通过这种方法可以在 NIO 线程中获取 Channel 并发送数据,而不是在主线程中执行这些操作
public class MyClient { public static void main(String[] args) throws IOException, InterruptedException { ChannelFuture channelFuture = new Bootstrap() .group(new NioEventLoopGroup()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new StringEncoder()); } }) // 该方法为异步非阻塞方法,主线程调用后不会被阻塞,真正去执行连接操作的是NIO线程 // NIO线程:NioEventLoop 中的线程 .connect(new InetSocketAddress("localhost", 8080)); // 当connect方法执行完毕后,也就是连接真正建立后 // 会在NIO线程中调用operationComplete方法 channelFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture channelFuture) throws Exception { Channel channel = channelFuture.channel(); channel.writeAndFlush("hello world"); } }); System.in.read(); }}
处理关闭
public class ReadClient { public static void main(String[] args) throws InterruptedException { // 创建EventLoopGroup,使用完毕后关闭 NioEventLoopGroup group = new NioEventLoopGroup(); ChannelFuture channelFuture = new Bootstrap() .group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline().addLast(new StringEncoder()); } }) .connect(new InetSocketAddress("localhost", 8080)); channelFuture.sync(); Channel channel = channelFuture.channel(); Scanner scanner = new Scanner(System.in); // 创建一个线程用于输入并向服务器发送 new Thread(()->{ while (true) { String msg = scanner.next(); if ("q".equals(msg)) { // 关闭操作是异步的,在NIO线程中执行 channel.close(); break; } channel.writeAndFlush(msg); } }, "inputThread").start(); // 获得closeFuture对象 ChannelFuture closeFuture = channel.closeFuture(); System.out.println("waiting close..."); // 同步等待NIO线程执行完close操作 closeFuture.sync(); // 关闭之后执行一些操作,可以保证执行的操作一定是在channel关闭以后执行的 System.out.println("关闭之后执行一些额外操作..."); // 关闭EventLoopGroup group.shutdownGracefully(); }}
关闭channel
当我们要关闭 channel 时,可以调用 channel.close () 方法进行关闭。但是该方法也是一个异步方法。真正的关闭操作并不是在调用该方法的线程中执行的,而是在 NIO 线程中执行真正的关闭操作
如果我们想在 channel 真正关闭以后,执行一些额外的操作,可以选择以下两种方法来实现
- 通过 channel.closeFuture () 方法获得对应的 ChannelFuture 对象,然后调用 sync () 方法阻塞执行操作的线程,等待 channel 真正关闭后,再执行其他操作
// 获得closeFuture对象ChannelFuture closeFuture = channel.closeFuture();// 同步等待NIO线程执行完close操作closeFuture.sync();
- 调用 closeFuture.addListener方法,添加 close 的后续操作
closeFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture channelFuture) throws Exception { // 等待channel关闭后才执行的操作 System.out.println("关闭之后执行一些额外操作..."); // 关闭EventLoopGroup group.shutdownGracefully(); }});
3、Future 与 Promise
3.1 概念
netty 中的 Future 与 jdk 中的 Future 同名,但是是两个接口
netty 的 Future 继承自 jdk 的 Future,而 Promise 又对 netty Future 进行了扩展
jdk Future 只能同步等待任务结束(或成功、或失败)才能得到结果
netty Future 可以同步等待任务结束得到结果,也可以异步方式得到结果,但都是要等任务结束
netty Promise 不仅有 netty Future 的功能,而且脱离了任务独立存在,只作为两个线程间传递结果的容器
3.2 JDK Future
public class JdkFuture { public static void main(String[] args) throws ExecutionException, InterruptedException { ThreadFactory factory = new ThreadFactory() { @Override public Thread newThread(Runnable r) { return new Thread(r, "JdkFuture"); } }; // 创建线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10,10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), factory); // 获得Future对象 Future future = executor.submit(new Callable() { @Override public Integer call() throws Exception { TimeUnit.SECONDS.sleep(1); return 50; } }); // 通过阻塞的方式,获得运行结果 System.out.println(future.get()); }}
3.3 Netty Future
public class NettyFuture { public static void main(String[] args) throws ExecutionException, InterruptedException { NioEventLoopGroup group = new NioEventLoopGroup(); // 获得 EventLoop 对象 EventLoop eventLoop = group.next(); Future future = eventLoop.submit(new Callable() { @Override public Integer call() throws Exception { return 50; } }); // 主线程中获取结果 System.out.println(Thread.currentThread().getName() + " 获取结果"); System.out.println("getNow " + future.getNow()); System.out.println("get " + future.get()); // NIO线程中异步获取结果 future.addListener(new GenericFutureListener>() { @Override public void operationComplete(Future super Integer> future) throws Exception { System.out.println(Thread.currentThread().getName() + " 获取结果"); System.out.println("getNow " + future.getNow()); } }); }}
运行结果
main 获取结果getNow nullget 50nioEventLoopGroup-2-1 获取结果getNow 50
Netty 中的 Future 对象,可以通过 EventLoop 的 sumbit () 方法得到
可以通过 Future 对象的 get 方法,阻塞地获取返回结果
也可以通过 getNow 方法,获取结果,若还没有结果,则返回 null,该方法是非阻塞的
还可以通过 future.addListener方法,在 Callable 方法执行的线程中,异步获取返回结果
3.4 Netty Promise
Promise 相当于一个容器,可以用于存放各个线程中的结果,然后让其他线程去获取该结果
public class NettyPromise { public static void main(String[] args) throws ExecutionException, InterruptedException { // 创建EventLoop NioEventLoopGroup group = new NioEventLoopGroup(); EventLoop eventLoop = group.next(); // 创建Promise对象,用于存放结果 DefaultPromise promise = new DefaultPromise<>(eventLoop); new Thread(()->{ try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } // 自定义线程向Promise中存放结果 promise.setSuccess(50); }).start(); // 主线程从Promise中获取结果 System.out.println(Thread.currentThread().getName() + " " + promise.get()); }}
4、Handler 与 Pipeline
4.1 Pipeline
public class PipeLineServer { public static void main(String[] args) { new ServerBootstrap() .group(new NioEventLoopGroup()) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { // 在socketChannel的pipeline中添加handler // pipeline中handler是带有head与tail节点的双向链表,的实际结构为 // head <-> handler1 <-> ... <-> handler4 <->tail // Inbound主要处理入站操作,一般为读操作,发生入站操作时会触发Inbound方法 // 入站时,handler是从head向后调用的 socketChannel.pipeline().addLast("handler1" ,new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { System.out.println(Thread.currentThread().getName() + " Inbound handler 1"); // 父类该方法内部会调用fireChannelRead // 将数据传递给下一个handler super.channelRead(ctx, msg); } }); socketChannel.pipeline().addLast("handler2", new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { System.out.println(Thread.currentThread().getName() + " Inbound handler 2"); // 执行write操作,使得Outbound的方法能够得到调用 socketChannel.writeAndFlush(ctx.alloc().buffer().writeBytes("Server...".getBytes(StandardCharsets.UTF_8))); super.channelRead(ctx, msg); } }); // Outbound主要处理出站操作,一般为写操作,发生出站操作时会触发Outbound方法 // 出站时,handler的调用是从tail向前调用的 socketChannel.pipeline().addLast("handler3" ,new ChannelOutboundHandlerAdapter(){ @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { System.out.println(Thread.currentThread().getName() + " Outbound handler 1"); super.write(ctx, msg, promise); } }); socketChannel.pipeline().addLast("handler4" ,new ChannelOutboundHandlerAdapter(){ @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { System.out.println(Thread.currentThread().getName() + " Outbound handler 2"); super.write(ctx, msg, promise); } }); } }) .bind(8080); }}
运行结果如下
nioEventLoopGroup-2-2 Inbound handler 1nioEventLoopGroup-2-2 Inbound handler 2nioEventLoopGroup-2-2 Outbound handler 2nioEventLoopGroup-2-2 Outbound handler 1
通过 channel.pipeline ().addLast (name, handler) 添加 handler 时,记得给 handler 取名字。这样可以调用 pipeline 的 addAfter、addBefore 等方法更灵活地向 pipeline 中添加 handler
handler 需要放入通道的 pipeline 中,才能根据放入顺序来使用 handler
- pipeline 是结构是一个带有 head 与 tail 指针的双向链表,其中的节点为 handler
- 要通过 ctx.fireChannelRead (msg) 等方法,将当前 handler 的处理结果传递给下一个 handler
- 当有 入站(Inbound)操作时,会从 head 开始向后调用 handler,直到 handler 不是处理 Inbound 操作为止
- 当有 出站(Outbound)操作时,会从 tail 开始向前调用 handler,直到 handler 不是处理 Outbound 操作为止
具体结构如下
调用顺序如下
4.2 OutboundHandler
socketChannel.writeAndFlush()
当 handler 中调用该方法进行写操作时,会触发 Outbound 操作,此时是从 tail 向前寻找 OutboundHandler
ctx.writeAndFlush()
当 handler 中调用该方法进行写操作时,会触发 Outbound 操作,此时是从当前 handler 向前寻找 OutboundHandler
4.3 EmbeddedChannel
EmbeddedChannel 可以用于测试各个 handler,通过其构造函数按顺序传入需要测试 handler,然后调用对应的 Inbound 和 Outbound 方法即可
public class TestEmbeddedChannel { public static void main(String[] args) { ChannelInboundHandlerAdapter h1 = new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { System.out.println("1"); super.channelRead(ctx, msg); } }; ChannelInboundHandlerAdapter h2 = new ChannelInboundHandlerAdapter() { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { System.out.println("2"); super.channelRead(ctx, msg); } }; ChannelOutboundHandlerAdapter h3 = new ChannelOutboundHandlerAdapter() { @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { System.out.println("3"); super.write(ctx, msg, promise); } }; ChannelOutboundHandlerAdapter h4 = new ChannelOutboundHandlerAdapter() { @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { System.out.println("4"); super.write(ctx, msg, promise); } }; // 用于测试Handler的Channel EmbeddedChannel channel = new EmbeddedChannel(h1, h2, h3, h4); // 执行Inbound操作 channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello".getBytes(StandardCharsets.UTF_8))); // 执行Outbound操作 channel.writeOutbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello".getBytes(StandardCharsets.UTF_8))); }}
5、ByteBuf
调试工具方法
private static void log(ByteBuf buffer) { int length = buffer.readableBytes(); int rows = length / 16 + (length % 15 == 0 ? 0 : 1) + 4; StringBuilder buf = new StringBuilder(rows * 80 * 2) .append("read index:").append(buffer.readerIndex()) .append(" write index:").append(buffer.writerIndex()) .append(" capacity:").append(buffer.capacity()) .append(NEWLINE); appendPrettyHexDump(buf, buffer); System.out.println(buf.toString());}
该方法可以帮助我们更为详细地查看 ByteBuf 中的内容
5.1 创建
public class ByteBufStudy { public static void main(String[] args) { // 创建ByteBuf ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16); ByteBufUtil.log(buffer); // 向buffer中写入数据 StringBuilder sb = new StringBuilder(); for(int i = 0; i < 20; i++) { sb.append("a"); } buffer.writeBytes(sb.toString().getBytes(StandardCharsets.UTF_8)); // 查看写入结果 ByteBufUtil.log(buffer); }}
运行结果
read index:0 write index:0 capacity:16read index:0 write index:20 capacity:64 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa||00000010| 61 61 61 61 |aaaa |+--------+-------------------------------------------------+----------------+
- ByteBuf 通过 ByteBufAllocator 选择 allocator 并调用对应的 buffer () 方法来创建的,默认使用 直接内存作为 ByteBuf,容量为 256 个字节,可以指定初始容量的大小
- 当 ByteBuf 的容量无法容纳所有数据时,ByteBuf 会进行扩容操作
- 如果在 handler 中创建 ByteBuf,建议使用 ChannelHandlerContext ctx.alloc ().buffer () 来创建
5.2 直接内存与堆内存
通过该方法创建的 ByteBuf,使用的是基于直接内存的 ByteBuf
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16);
可以使用下面的代码来创建池化 基于堆的 ByteBuf
ByteBuf buffer = ByteBufAllocator.DEFAULT.heapBuffer(16);
也可以使用下面的代码来创建池化基于直接内存的 ByteBuf
ByteBuf buffer = ByteBufAllocator.DEFAULT.directBuffer(16);
- 直接内存创建和销毁的代价昂贵,但读写性能高(少一次内存复制),适合配合池化功能一起用
- 直接内存对 GC 压力小,因为这部分内存不受 JVM 垃圾回收的管理,但也要注意及时主动释放
验证
public class ByteBufStudy { public static void main(String[] args) { ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16); System.out.println(buffer.getClass()); buffer = ByteBufAllocator.DEFAULT.heapBuffer(16); System.out.println(buffer.getClass()); buffer = ByteBufAllocator.DEFAULT.directBuffer(16); System.out.println(buffer.getClass()); }}// 使用池化的直接内存class io.netty.buffer.PooledUnsafeDirectByteBuf // 使用池化的堆内存 class io.netty.buffer.PooledUnsafeHeapByteBuf // 使用池化的直接内存 class io.netty.buffer.PooledUnsafeDirectByteBuf
5.3 池化与非池化
池化的最大意义在于可以重用ByteBuf,优点有
- 没有池化,则每次都得创建新的 ByteBuf 实例,这个操作对直接内存代价昂贵,就算是堆内存,也会增加 GC 压力
- 有了池化,则可以重用池中 ByteBuf 实例,并且采用了与 jemalloc 类似的内存分配算法提升分配效率
- 高并发时,池化功能更节约内存,减少内存溢出的可能
池化功能是否开启,可以通过下面的系统环境变量来设置
-Dio.netty.allocator.type={unpooled|pooled}
- 4.1 以后,非 Android 平台默认启用池化实现,Android 平台启用非池化实现
- 4.1 之前,池化功能还不成熟,默认是非池化实现
5.4 组成
ByteBuf 主要有以下几个组成部分
- 最大容量与当前容量
- 在构造 ByteBuf 时,可传入两个参数,分别代表初始容量和最大容量,若未传入第二个参数(最大容量),最大容量默认为 Integer.MAX_VALUE
- 当 ByteBuf 容量无法容纳所有数据时,会进行扩容操作,若超出最大容量,会抛出 java.lang.IndexOutOfBoundsException 异常
- 读写操作不同于 ByteBuffer 只用 position 进行控制,ByteBuf 分别由读指针和写指针两个指针控制。进行读写操作时,无需进行模式的切换
- 读指针前的部分被称为废弃部分,是已经读过的内容
- 读指针与写指针之间的空间称为可读部分
- 写指针与当前容量之间的空间称为可写部分
5.5 写入
常用方法如下
注意
- 这些方法的未指明返回值的,其返回值都是 ByteBuf,意味着可以链式调用来写入不同的数据
- 网络传输中,默认习惯是 Big Endian,使用 writeInt (int value)
使用方法
public class ByteBufStudy { public static void main(String[] args) { // 创建ByteBuf ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16, 20); ByteBufUtil.log(buffer); // 向buffer中写入数据 buffer.writeBytes(new byte[]{1, 2, 3, 4}); ByteBufUtil.log(buffer); buffer.writeInt(5); ByteBufUtil.log(buffer); buffer.writeIntLE(6); ByteBufUtil.log(buffer); buffer.writeLong(7); ByteBufUtil.log(buffer); }}
运行结果
read index:0 write index:0 capacity:16read index:0 write index:4 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 01 02 03 04 |.... |+--------+-------------------------------------------------+----------------+read index:0 write index:8 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 01 02 03 04 00 00 00 05 |........ |+--------+-------------------------------------------------+----------------+read index:0 write index:12 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 01 02 03 04 00 00 00 05 06 00 00 00 |............ |+--------+-------------------------------------------------+----------------+read index:0 write index:20 capacity:20 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 01 02 03 04 00 00 00 05 06 00 00 00 00 00 00 00 |................||00000010| 00 00 00 07 |.... |+--------+-------------------------------------------------+----------------+
还有一类方法是 set 开头的一系列方法,也可以写入数据,但不会改变写指针位置
5.6 扩容
当 ByteBuf 中的容量无法容纳写入的数据时,会进行扩容操作
buffer.writeLong(7);ByteBufUtil.log(buffer);// 扩容前read index:0 write index:12 capacity:16...// 扩容后read index:0 write index:20 capacity:20 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 01 02 03 04 00 00 00 05 06 00 00 00 00 00 00 00 |................||00000010| 00 00 00 07 |.... |+--------+-------------------------------------------------+----------------+
扩容规则
如何写入后数据大小未超过 512 字节,则选择下一个 16 的整数倍进行扩容
- 例如写入后大小为 12 字节,则扩容后 capacity 是 16 字节
如果写入后数据大小超过 512 字节,则选择下一个 2^n
例如写入后大小为 513 字节,则扩容后 capacity 是 210=1024 字节(29=512 已经不够了)
扩容不能超过maxCapacity,否则会抛出 java.lang.IndexOutOfBoundsException 异常
Exception in thread "main" java.lang.IndexOutOfBoundsException: writerIndex(20) + minWritableBytes(8) exceeds maxCapacity(20): PooledUnsafeDirectByteBuf(ridx: 0, widx: 20, cap: 20/20)
5.7 读取
读取主要是通过一系列 read 方法进行读取,读取时会根据读取数据的字节数移动读指针
如果需要 重复读取,需要调用 buffer.markReaderIndex() 对读指针进行标记,并通过 buffer.resetReaderIndex() 将读指针恢复到 mark 标记的位置
public class ByteBufStudy { public static void main(String[] args) { // 创建ByteBuf ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16, 20); // 向buffer中写入数据 buffer.writeBytes(new byte[]{1, 2, 3, 4}); buffer.writeInt(5); // 读取4个字节 System.out.println(buffer.readByte()); System.out.println(buffer.readByte()); System.out.println(buffer.readByte()); System.out.println(buffer.readByte()); ByteBufUtil.log(buffer); // 通过mark与reset实现重复读取 buffer.markReaderIndex(); System.out.println(buffer.readInt()); ByteBufUtil.log(buffer); // 恢复到mark标记处 buffer.resetReaderIndex(); ByteBufUtil.log(buffer); }}read index:4 write index:8 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 00 00 00 05 |.... |+--------+-------------------------------------------------+----------------+5read index:8 write index:8 capacity:16read index:4 write index:8 capacity:16 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 00 00 00 05 |.... |+--------+-------------------------------------------------+----------------+
还有以 get 开头的一系列方法,这些方法不会改变读指针的位置
5.8 释放
由于 Netty 中有堆外内存(直接内存)的 ByteBuf 实现,堆外内存最好是手动来释放,而不是等 GC 垃圾回收。
- UnpooledHeapByteBuf 使用的是 JVM 内存,只需等 GC 回收内存即可
- UnpooledDirectByteBuf 使用的就是直接内存了,需要特殊的方法来回收内存
- PooledByteBuf 和它的子类使用了池化机制,需要更复杂的规则来回收内存
Netty 这里采用了引用计数法来控制回收内存,每个 ByteBuf 都实现了 ReferenceCounted 接口
- 每个 ByteBuf 对象的初始计数为 1
- 调用 release 方法计数减 1,如果计数为 0,ByteBuf 内存被回收
- 调用 retain 方法计数加 1,表示调用者没用完之前,其它 handler 即使调用了 release 也不会造成回收
- 当计数为 0 时,底层内存会被回收,这时即使 ByteBuf 对象还在,其各个方法均无法正常使用
释放规则
因为 pipeline 的存在,一般需要将 ByteBuf 传递给下一个 ChannelHandler,如果在每个 ChannelHandler 中都去调用 release ,就失去了传递性(如果在这个 ChannelHandler 内这个 ByteBuf 已完成了它的使命,那么便无须再传递)
基本规则是,谁是最后使用者,谁负责 release
起点,对于 NIO 实现来讲,在 io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe.read 方法中首次创建 ByteBuf 放入 pipeline(line 163 pipeline.fireChannelRead (byteBuf))
入站 ByteBuf 处理原则
- 对原始 ByteBuf 不做处理,调用 ctx.fireChannelRead (msg) 向后传递,这时无须 release
- 将原始 ByteBuf 转换为其它类型的 Java 对象,这时 ByteBuf 就没用了,必须 release
- 如果不调用 ctx.fireChannelRead (msg) 向后传递,那么也必须 release
- 注意各种异常,如果 ByteBuf 没有成功传递到下一个 ChannelHandler,必须 release
- 假设消息一直向后传,那么 TailContext 会负责释放未处理消息(原始的 ByteBuf)
出站 ByteBuf 处理原则
- 出站消息最终都会转为 ByteBuf 输出,一直向前传,由 HeadContext flush 后 release
异常处理原则
- 有时候不清楚 ByteBuf 被引用了多少次,但又必须彻底释放,可以循环调用 release 直到返回 true
while (!buffer.release()) {}
当 ByteBuf 被传到了 pipeline 的 head 与 tail 时,ByteBuf 会被其中的方法彻底释放,但前提是 ByteBuf 被传递到了 head 与 tail 中
TailConext 中释放 ByteBuf 的源码
protected void onUnhandledInboundMessage(Object msg) { try { logger.debug("Discarded inbound message {} that reached at the tail of the pipeline. Please check your pipeline configuration.", msg); } finally { // 具体的释放方法 ReferenceCountUtil.release(msg); }}
判断传过来的是否为 ByteBuf,是的话才需要释放
public static boolean release(Object msg) {return msg instanceof ReferenceCounted ? ((ReferenceCounted)msg).release() : false;}
5.9 切片
ByteBuf 切片是【零拷贝】的体现之一,对原始 ByteBuf 进行切片成多个 ByteBuf,切片后的 ByteBuf 并没有发生内存复制,还是使用原始 ByteBuf 的内存,切片后的 ByteBuf 维护独立的 read,write 指针
得到分片后的 buffer 后,要调用其 retain 方法,使其内部的引用计数加一。避免原 ByteBuf 释放,导致切片 buffer 无法使用修改原 ByteBuf 中的值,也会影响切片后得到的 ByteBuf
public class TestSlice { public static void main(String[] args) { // 创建ByteBuf ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(16, 20); // 向buffer中写入数据 buffer.writeBytes(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); // 将buffer分成两部分 ByteBuf slice1 = buffer.slice(0, 5); ByteBuf slice2 = buffer.slice(5, 5); // 需要让分片的buffer引用计数加一 // 避免原Buffer释放导致分片buffer无法使用 slice1.retain(); slice2.retain(); ByteBufUtil.log(slice1); ByteBufUtil.log(slice2); // 更改原始buffer中的值 System.out.println("===========修改原buffer中的值==========="); buffer.setByte(0,5); System.out.println("===========打印slice1==========="); ByteBufUtil.log(slice1); }}
运行结果
read index:0 write index:5 capacity:5 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 01 02 03 04 05 |..... |+--------+-------------------------------------------------+----------------+read index:0 write index:5 capacity:5 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 06 07 08 09 0a |..... |+--------+-------------------------------------------------+----------------+===========修改原buffer中的值======================打印slice1===========read index:0 write index:5 capacity:5 +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f |+--------+-------------------------------------------------+----------------+|00000000| 05 02 03 04 05 |..... |+--------+-------------------------------------------------+----------------+
5.10 优势
- 池化思想 - 可以重用池中 ByteBuf 实例,更节约内存,减少内存溢出的可能
- 读写指针分离,不需要像 ByteBuffer 一样切换读写模式
- 可以自动扩容
- 支持链式调用,使用更流畅
- 很多地方体现零拷贝,例如
- slice、duplicate、CompositeByteBuf
本文由
传智教育博学谷
教研团队发布。如果本文对您有帮助,欢迎
关注
和点赞
;如果您有任何建议也可留言评论
或私信
,您的支持是我坚持创作的动力。转载请注明出处!
-
天天短讯!Netty中8大组件详解(EventLoop、Channel、ChannelFuture、Future、 Promise、Handler 、 Pipe
Netty概述1、什么是NettyNettyisanasynchronousevent-drivennetworkapplicationframeworkforrapidd
来源: -
在Windows中利用WSL2安装禅道17.7
在Windows中利用WSL2安装禅道17 7使用WSL2只是为了模拟Ubuntu22 04、PHP8、Apache2、MySQL8环境下源码...
来源: 天天短讯!Netty中8大组件详解(EventLoop、Channel、ChannelFuture、Future、 Promise、Handler 、 Pipe
全球热点!贯穿汽车用户全生命周期,火山引擎数智平台能帮车企做这些事!
全球播报:当项目经理看世界杯决赛时…
在Windows中利用WSL2安装禅道17.7
观热点:纽约州进入紧急状态 美国暴风雪有多可怕?官方告诫市民别出门否则会冻死
快消息!稳住iPhone 14 Pro 富士康给员工发留任奖金:最高5000元
黑客携4亿推特用户数据勒索马斯克:想破财消灾还是被罚款?
世界看点:驾特斯拉Model X车祸 5个多月林志颖仍在康复:期待复工
一家三口发烧用洋葱退热成功 医生科普:低烧有用、高烧不建议
【全球新视野】工信部拟规定:APP应可便捷卸载 不得默认自动续订
当前关注:终于不用费劲算优惠了:天猫年货节取消跨店满减 直接降价
最新资讯:雷军刚投的激光雷达:120米以外就能探测到路面轮胎
环球讯息:李国庆称逃离北上广去二线商机无限 快反思降降价:看好杭州合肥长沙等
43mm大喇叭!荣耀亲选迪士尼便携蓝牙音箱上架 149元
天天讯息:从发现SQL注入到ssh连接
学习下Redis内存模型
环球观天下!更冷了!南方多地将有大到暴雪:湿冷“魔法攻击”来了
【世界独家】张艺谋新作 电影《满江红》官宣阵容:沈腾、易烊千玺双男主
每日热闻!专家:波司登万元羽绒服面料很低廉
焦点消息!文化输出网红要复出了?李子柒成子柒文化实控人:微念创始人退出公司
天天快讯:多语种录音转译!讯飞开放式办公耳机iFLYBUDS Air上市 899元
HTC G18上市价格是多少?HTC G18手机参数
小米3什么时候出的?小米3后盖怎么打开?
win7系统怎么设置防火墙?win7系统一键还原按哪个键?
显示器驱动程序停止响应的原因有哪些?电脑显示驱动程序出现问题怎么办?
洗衣机怎么消毒?洗衣机消毒方法有哪些?
科特迪瓦是哪个国家?科特迪瓦特产有哪些?
起初婚礼放鞭炮是为了什么?起初婚礼上放鞭炮是什么原因?
权力的游戏布兰为什么是夜王?权力的游戏布兰的结局是什么?
世界热门:Dubbo 可扩展性设计
Web前端--HTML+Canvas+Js实现3D魔方小游戏
世界观点:微信Native支付(扫码支付)商户配置
魔兽世界里RN是什么意思?魔兽世界RN含义解释
天仙配电视剧大结局是什么?天仙配电视剧演员表
巧克力中的劳斯莱斯是什么牌子?巧克力中的劳斯莱斯多少钱?
淘宝金币有什么用处?淘宝金币怎么抵扣现金?
女生坐位体前屈满分多少厘米?女生坐位体前屈的技巧有哪些?
冯小宁战争三部曲是哪三部?冯小宁战争三部曲观看顺序
春眠不觉晓处处蚊子咬下一句是什么?春眠不觉晓改编打油诗
每日快报!年度必看神作!《阿凡达2》全球票房破9亿美元:内地破7.3亿
3排8座拉全家 路虎卫士130国内预售:119.8万起
新突破!我国建成首个年产500亿立方米特大型产气区
环球观天下!学AMD大涨价!Intel B760新一代平民主板来了 华硕等最快本周出货:涨幅不小
【快播报】教程干货!JNPF快速搭建库存管理与财务管理板块
天天热门:马斯克被曝架子大:访客等1个小时才能见人 还不能先开口
世界热点!“更适合国内路况”的CLTC新标准 为啥更坑“电动爹”了?
今日热搜:PC玩家狂喜:PS3模拟器已可运行所有PS3游戏
一瓶的钱现在买3件!杰威尔男士护肤套装大促:原价100多现在30包邮
环球今日讯!顾客吐槽看《阿凡达2》需30元购3D眼镜 凭啥买非常不合理:影院回应
【环球快播报】【爬虫+数据清洗+可视化分析】舆情分析哔哩哔哩"阳了"的评论
Python爬虫实战,requests+tqdm模块,爬取漫画之家漫画数据(附源码)
上海一车主至少5次倒车撞击后车:后车特斯拉全程不动 网友吐槽
热门:《三体》动画口碑直降!豆瓣跌破6分 网友发现人脸模型重复利用
讯息:5MHz处理器+128MB内存:成功运行Win7旗舰版
天天百事通!2022年度七大叫好又叫座游戏 看完手痒别怪我
观察:苹果中国开启新年福利:iPhone 13等降价优惠千元!
当前速看:Ubuntu下的NVIDIA显卡【驱动&CUDA 安装与卸载】
韩国报告首例“食脑虫”病例:一天即死亡、不会人传人
快报:葛优母亲施文心去世:享年94岁 从事电影文学编辑30年
【环球新视野】《西游记之七十二变》终极预告:孙悟空、牛魔王一起学法术
每日讯息!EPIC喜加一:国产之光3A游戏《暗影火炬城》免费送 省了108元
RTX 4070 Ti跑分偷跑:怎么灭掉了RTX 3090 Ti!
滚动:在虚幻引擎中使用蓝图实现简单的对话
每日视讯:Wi-Fi 6路由杀到169元 荣耀路由X4 Pro发布:三个千兆网口
世界新消息丨豆瓣2022年度电影榜单出炉:《阿凡达2》入榜
天天热资讯!虚假新闻检测(MAC)《Hierarchical Multi-head Attentive Network for Evidence-aware F
天天讯息:Atcoder Beginner Contest ABC 283 Ex Popcount Sum 题解 (类欧几里得算法)
环球微资讯!一群环保人士 为何想限制游戏发展?
热资讯!首发199元 荣耀手环7发布:1.47英寸大屏 支持全天候血氧监测
韩国人造肉需求激增:近一半韩国年轻人买过吃过人造肉
02年小伙3年打6份工买车 正能量感染网友:送过外卖、做过服务员等
首发5680元 汉王N10 Max墨水平板发布:13.3英寸大屏
微头条丨失业率最高的西班牙:开始薅中国羊毛
短讯!JMeter
第四章 --------------------XAML名称空间
环球观察:快2023年了还有友商拿8+128卡位凑数 一加11直接12G起步:欢迎友商跟进
天天新消息丨纯可可脂 怡浓55%-100%黑巧克力35元起
N多玩家插反PS5游戏盘!官方发文纠正
环球聚焦:功耗高、死机!AMD正在拼命优化RX 7900驱动
男子自曝无聊送外卖结果2小时挣150 网友质疑摆拍:根本没地方停车
天天消息!AcWing. 1146 新的开始
C919起飞验证!波音、中国商飞新合作:开发全新飞机材料
全球热点!20000转风扇看得见!红魔8 Pro+氘锋透明版图赏
全球头条:想堵车都难 福特车路协同系统落地国内六城
记录--vue.config.js 的完整配置(超详细)!
当前动态:双旦狂欢价:腾讯视频/优酷视频会员5折起
官宣油耗3.8L 实际7L以上!吉利帝豪L雷神Hi·P混动遭出租车司机集体维权
超越索尼PS4!任天堂Switch全球销量达1.18亿台:史上销量第四主机
环球观热点:东航C919今日起验证飞行:持续至2月中旬 商业载客不远了
全球观察:钱不好赚了?苹果今年第四季度净利或下降8%以上
Kubernetes监控手册04-监控Kube-Proxy
天天微头条丨Zabbix技术分享——snmp异常排查指南
对不起,你做的 A/B 实验都是错的——火山引擎 DataTester 科普
当前消息!与时代共命运:数智时代的到来意味着什么?
AcWing1144. 连接格点
每日快看:攀升年底大促!16GB轻薄本仅1698元
【天天报资讯】拆分数据库变相涨价!知网滥用市场支配地位被罚8760万元
焦点播报:神了!1919年一幅漫画预言了手机 这六个场景太真实
今日最新!蚊子变疫苗:中科院新研究可以从源头抑制新发传染病
视点!网友青岛莱西湖附近偶遇老虎 官方回应:已捉回、未造成伤害