【Netty】- NIO基础2

阻塞模式

客户端代码

public class Client {public static void main(String[] args) throws IOException {SocketChannel sc = SocketChannel.open();sc.connect(new InetSocketAddress("localhost", 8080));// sc.write(Charset.defaultCharset().encode("helloworld")); // 可以在debug下发数据System.out.println("waiting...");}
}

服务器代码

@Slf4j
public class Server {public static void main(String[] args) throws IOException {// 使用nio来理解阻塞模式(单线程)// 0. ByteBufferByteBuffer buffer = ByteBuffer.allocate(16);// 1. 创建服务器ServerSocketChannel ssc = ServerSocketChannel.open();// 2. 绑定监听端口ssc.bind(new InetSocketAddress(8080));// 3. 连接集合List<SocketChannel> channels = new ArrayList<>();while(true) {// 4. accept建立与客户端连接,SocketChannel用来与客户端之间通信log.debug("connecting...");SocketChannel sc = ssc.accept(); // 阻塞方法,没有连接建立时,线程停止运行log.debug("connected...{}", sc);channels.add(sc);// 5. 接收客户端发送的数据for(SocketChannel channel : channels) {log.debug("before read..., {}", channel);channel.read(buffer); // 阻塞方法,没有数据发送时,线程停止运行buffer.flip(); // 切换为读模式debugRead(buffer); // 读取数据buffer.clear(); // 切换为写模式log.debug("after read..., {}", channel);}}}
}

ssc.accept() 和 channel.read(buffer) 都是阻塞方法,如果没有建立连接或者没有数据过来时,线程都会阻塞等待。
因此如果同一个客户端再发第二次数据,并不会收到第二次发送的数据,因为此时没有新的连接建立,代码已经被阻塞在ssc.accept()这里了。

非阻塞模式

@Slf4j
public class Server {public static void main(String[] args) throws IOException {// 0. ByteBufferByteBuffer buffer = ByteBuffer.allocate(16);// 1. 创建服务器ServerSocketChannel ssc = ServerSocketChannel.open();ssc.configureBlocking(false); // 设置ServerSocketChannel为非阻塞模式// 2. 绑定监听端口ssc.bind(new InetSocketAddress(8080));// 3. 连接集合List<SocketChannel> channels = new ArrayList<>();while(true) {// 4. accept建立与客户端连接,SocketChannel用来与客户端之间通信SocketChannel sc = ssc.accept(); // 非阻塞,线程还会继续运行,如果没有连接建立,sc是nullif(sc != null) {log.debug("connected...{}", sc);ssc.configureBlocking(false); // 设置SocketChannel为非阻塞模式channels.add(sc);}// 5. 接收客户端发送的数据for(SocketChannel channel : channels) {int read = channel.read(buffer);// 非阻塞,线程仍然会继续运行,如果没有读到数据,read()返回0if(read > 0) {buffer.flip(); // 切换为读模式debugRead(buffer); // 读取数据buffer.clear(); // 切换为写模式log.debug("after read..., {}", channel);}}}}
}

ssc.configureBlocking(false); // 变为非阻塞模式
非阻塞模式相当于轮询的在检查是否有新的数据、是否有新的连接,这样很消耗系统资源,一般情况下也不会使用非阻塞模式,而是使用selector

Selector(多路复用)

单线程可以配合Selector完成对多个Channel可读写事件的监控

常见的四种事件

  1. accept:在有连接请求时触发
  2. connect:客户端连接建立后触发
  3. read:可读事件
  4. write:可写事件
@Slf4j
public class Server {private static void split(ByteBuffer source) {source.flip();// 切换为读模式for(int i = 0; i < source.limit(); i++) {// 找到一条完整消息if(source.get(i) == '\n') { // get(i)不会改变position的位置int len = i - source.position() + 1;// 把这条消息存入新的ByteBuffer中ByteBuffer target = ByteBuffer.allocate(len);// 从source读,向target中写for(int j = 0; j < len; j++) {target.put(source.get());// get()会改变position的位置}debugAll(target);}}source.compact();}public static void main(String[] args) throws IOException {// 1. 创建selector,管理多个channelSelector selector = Selector.open();ServerSocketChannel ssc = ServerSocketChannel.open();ssc.configureBlocking(false);// 2. 建立selector和channel的联系(注册)SelectionKey sscKey = ssc.register(selector, 0, null);// 事件发生后,通过它可以知道事件和哪个channel的事件(管理ServerSocketChannel)sscKey.interestOps(SelectionKey.OP_ACCEPT); // 对哪个事件感兴趣(有四种事件)log.debug("register key:{}", sscKey);ssc.bind(new InetSocketAddress(8080));while(true) {// 3. select 方法/*selector():没有事件发生 - 线程阻塞有(感兴趣的)事件发生 - 线程会恢复运行*/selector.select();// 4. 处理事件,selectedKey内部包含了所有发生的事件Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); // accept,readwhile(iter.hasNext()) {SelectionKey key = iter.next();iter.remove(); // 处理key的时候,一定要从SelectionKeys集合中删除,否则下次处理就会有问题log.debug("key:{}", key);if (key.isAcceptable()) { // 如果是accept事件ServerSocketChannel channel = (ServerSocketChannel) key.channel();SocketChannel sc = channel.accept();sc.configureBlocking(false);ByteBuffer buffer = ByteBuffer.allocate(16);// 附件(attachment)SelectionKey scKey = sc.register(selector, 0, buffer); // 把buffer当成scKey的附属品注册倒scKey上(和channel对应)scKey.interestOps(SelectionKey.OP_READ);log.debug("{}", sc);}else if(key.isReadable()) { // 如果是读事件try {SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channelByteBuffer buffer = (ByteBuffer) key.attachment();// 获取scKey上关联的附件int read = channel.read(buffer);if(read == -1) {key.cancel(); // 如果正常断开,read返回值是-1}else {split(buffer);if(buffer.position() == buffer.limit()) { // 扩容ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2); // 扩容buffer.flip(); // 切换读模式newBuffer.put(buffer); // 把旧的buffer放入新的buffer中key.attach(newBuffer); // 重新关联新的buffer到key}}}catch (IOException e) {e.printStackTrace();key.cancel(); // 因为客户端断开了,所以需要将key取消(从selector集合中删除)}}}}}
}

如果selector已经处理过事件,那么下次再来事件时,selector就会认为上一次的事件已经处理过,就会处理新的事件。
但是如果selector没有处理该事件,selector会一直认为上一次的事件还没处理,就还会处理上一次的事件(表现:一直轮询处理上一次的事件)
如果selector不想处理这次事件,可以使用key.cancel()取消事件

事件发生之后,要么处理,要么取消

selector会在发生事件后,向selectionKeys中加入key,但是不会删除

事件如果被处理,我们应该手动移除

处理客户端断开

  1. 如果客户端异常断开,此时会抛出异常,需要catch去捕获异常。
  2. 如果客户端正常断开,read返回值是-1,此时不会抛出异常。
try {SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channelByteBuffer buffer = ByteBuffer.allocate(16);int read = channel.read(buffer);if(read == -1) {key.cancel(); // 如果正常断开,read返回值是-1}else {buffer.flip();debugRead(buffer);}}catch (IOException e) {e.printStackTrace();key.cancel(); // 因为客户端断开了,所以需要将key取消(从selector集合中删除)}

处理消息边界

在这里插入图片描述

ByteBuffer的大小分配:

  • 每个channel都要记录可能被切割的消息,因为ByteBuffer不能被多个channel使用,需要每个channel都维护一个独立的ByteBuffer(附件attachment的形式)
  • ByteBuffer不能太大,因此需要设计大小可变的ByteBuffer:分配一个比较小的ByteBuffer,如果数据不够,再扩容。
while(true) {// 3. select 方法/*selector():没有事件发生 - 线程阻塞有(感兴趣的)事件发生 - 线程会恢复运行*/selector.select();// 4. 处理事件,selectedKey内部包含了所有发生的事件Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); // accept,readwhile(iter.hasNext()) {SelectionKey key = iter.next();iter.remove(); // 处理key的时候,一定要从SelectionKeys集合中删除,否则下次处理就会有问题log.debug("key:{}", key);if (key.isAcceptable()) { // 如果是accept事件ServerSocketChannel channel = (ServerSocketChannel) key.channel();SocketChannel sc = channel.accept();sc.configureBlocking(false);ByteBuffer buffer = ByteBuffer.allocate(16);// 附件(attachment)SelectionKey scKey = sc.register(selector, 0, buffer); // 把buffer当成scKey的附属品注册倒scKey上(和channel对应)scKey.interestOps(SelectionKey.OP_READ);log.debug("{}", sc);}else if(key.isReadable()) { // 如果是读事件try {SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channelByteBuffer buffer = (ByteBuffer) key.attachment();// 获取scKey上关联的附件int read = channel.read(buffer);if(read == -1) {key.cancel(); // 如果正常断开,read返回值是-1}else {split(buffer);if(buffer.position() == buffer.limit()) { // 扩容ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2); // 扩容buffer.flip(); // 切换读模式newBuffer.put(buffer); // 把旧的buffer放入新的buffer中key.attach(newBuffer); // 重新关联新的buffer到key}}}catch (IOException e) {e.printStackTrace();key.cancel(); // 因为客户端断开了,所以需要将key取消(从selector集合中删除)}}}}

服务器写入过多内容处理

基础代码

服务器:

public class WriteServer {public static void main(String[] args) throws IOException {ServerSocketChannel ssc = ServerSocketChannel.open();ssc.configureBlocking(false);Selector selector = Selector.open();ssc.register(selector, SelectionKey.OP_ACCEPT);ssc.bind(new InetSocketAddress(8080));while(true) {selector.select();Iterator<SelectionKey> iter = selector.selectedKeys().iterator();while (iter.hasNext()) {SelectionKey key = iter.next();iter.remove();if(key.isAcceptable()) {SocketChannel sc = ssc.accept();sc.configureBlocking(false);// 1. 向客户端发送大量数据StringBuilder sb = new StringBuilder();for(int i = 0; i < 30000000; ++i) {sb.append("a");}ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());while(buffer.hasRemaining()) {int write = sc.write(buffer);// 返回实际写入次数System.out.println(write);}}}}}
}

客户端:

public class WriteClient {public static void main(String[] args) throws IOException {SocketChannel sc = SocketChannel.open();sc.connect(new InetSocketAddress("127.0.0.1", 8080));// 2. 接收数据int count = 0;while(true) {ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);count += sc.read(buffer);System.out.println(count);}}
}

这样虽然可以写大量的数据,但是效率并不高,因为发送端只要内容没发满,就会一直循环,相当于卡在当前的SocetChannel上。
改进】:发送缓冲区还没满的话,可以进行读操作;缓冲区满再写。

改进

public class WriteServer {public static void main(String[] args) throws IOException {ServerSocketChannel ssc = ServerSocketChannel.open();ssc.configureBlocking(false);Selector selector = Selector.open();ssc.register(selector, SelectionKey.OP_ACCEPT);ssc.bind(new InetSocketAddress(8080));while(true) {selector.select();Iterator<SelectionKey> iter = selector.selectedKeys().iterator();while (iter.hasNext()) {SelectionKey key = iter.next();iter.remove();if(key.isAcceptable()) {SocketChannel sc = ssc.accept();sc.configureBlocking(false);SelectionKey sckey = sc.register(selector, 0, null);sckey.interestOps(SelectionKey.OP_READ);// 1. 向客户端发送大量数据StringBuilder sb = new StringBuilder();for(int i = 0; i < 3000000; ++i) {sb.append("a");}ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());int write = sc.write(buffer);// 返回实际写入次数System.out.println(write);if(buffer.hasRemaining()) {// 2. 关注可写事件sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE);// 3. 把未写完的数据挂到sckey上sckey.attach(buffer);}}else if(key.isWritable()) {ByteBuffer buffer = (ByteBuffer) key.attachment();SocketChannel sc = (SocketChannel) key.channel();int write = sc.write(buffer);// 返回实际写入次数System.out.println(write);// 4. 清理操作if(!buffer.hasRemaining()) { // buffer为空key.attach(null); // 清除bufferkey.interestOps(key.interestOps() - SelectionKey.OP_WRITE); // 不去关注可写事件}}}}}
}

利用多线程优化

@Slf4j
public class MultiThreadServer {public static void main(String[] args) throws IOException {Thread.currentThread().setName("boss");ServerSocketChannel ssc = ServerSocketChannel.open();ssc.configureBlocking(false);Selector boss = Selector.open();SelectionKey bossKey = ssc.register(boss, 0, null);bossKey.interestOps(SelectionKey.OP_ACCEPT);ssc.bind(new InetSocketAddress(8080));// 1. 创建固定数量的worker并初始化Worker[] workers = new Worker[2];for(int i = 0; i < workers.length; i++) {workers[i] = new Worker("worker-" + i);}AtomicInteger idx = new AtomicInteger(0);while(true) {boss.select();Iterator<SelectionKey> iter = boss.selectedKeys().iterator();while(iter.hasNext()) {SelectionKey key = iter.next();iter.remove();if (key.isAcceptable()) {SocketChannel sc = ssc.accept();sc.configureBlocking(false);log.debug("connected...{}", sc.getRemoteAddress());// 2. 关联selectorlog.debug("before register...{}", sc.getRemoteAddress());// 负载均衡算法workers[idx.getAndIncrement() % workers.length].register(sc); // 初始化selector(boss调用)log.debug("after register...{}", sc.getRemoteAddress());}}}}static class Worker implements Runnable {private Thread thread;private Selector selector;private String name;private volatile boolean start = false; // 还未初始化private ConcurrentLinkedDeque<Runnable> queue = new ConcurrentLinkedDeque<>(); // 线程间传递队列public Worker(String name) {this.name = name;}// 初始化线程和selectorpublic void register(SocketChannel sc) throws IOException {if(!start) {thread = new Thread(this, name);selector = Selector.open();thread.start();}// 向队列中添加任务,但是任务并没有执行queue.add(()->{try {sc.register(selector, SelectionKey.OP_READ, null);} catch (ClosedChannelException e) {throw new RuntimeException(e);}});selector.wakeup(); // 唤醒selector}@Overridepublic void run() {while(true) {try {selector.select();Runnable task = queue.poll();if(task != null) {task.run(); // 执行任务里的代码}Iterator<SelectionKey> iter = selector.selectedKeys().iterator();while(iter.hasNext()) {SelectionKey key = iter.next();iter.remove();if (key.isReadable()) {ByteBuffer buffer = ByteBuffer.allocate(16);SocketChannel channel = (SocketChannel) key.channel();log.debug("read...{}", channel.getRemoteAddress());channel.read(buffer);buffer.flip();debugAll(buffer);}}} catch (IOException e) {throw new RuntimeException(e);}}}}
}

NIO vs BIO

stream vs channel

  • stream不会自动缓冲数据,channel会利用系统提供的发送缓冲区、接收缓冲区
  • stream只支持阻塞API,channel同时支持阻塞、非阻塞API,channel可以配合selector实现多路复用
  • 二者均为全双工,读写可以同时进行

IO模型

同步阻塞、同步非阻塞、多路复用(本质也是同步的)、异步阻塞、异步非阻塞

  • 同步:线程自己去获取结果(一个线程)
  • 异步:线程自己不去获取结果,由其他线程送结果(至少两个线程)

异步阻塞是错误的

AIO

AIO用来解决数据复制阶段的阻塞问题

@Slf4j
public class AioFileChannel {public static void main(String[] args) {try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(Paths.get("data.txt"), StandardOpenOption.READ)) {/*参数1:ByteBuffer参数2:读取的起始位置参数3:附件参数4:回调对象*/ByteBuffer buffer = ByteBuffer.allocate(16);log.debug("read begin...");channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {@Override // read成功public void completed(Integer result, ByteBuffer attachment) {log.debug("read completed...");attachment.flip();debugAll(attachment);}@Override // read失败public void failed(Throwable exc, ByteBuffer attachment) {exc.printStackTrace();}});log.debug("read end...");} catch (IOException e) {e.printStackTrace();}}
}

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

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

相关文章

【WebRTC】源码更改麦克风权限

WebRTC源码更改麦克风权限 仓库: https://webrtc.googlesource.com/src.git分支: guyl/m125节点: b09c2f83f85ec70614503d16e4c530484eb0ee4f

cocos creator使用jenkins打包微信小游戏,自动上传资源到cdn,windows版运行jenkins

cocos 版本2.4.11 在windows上jenkins的具体配置和部署&#xff0c;可参考上一篇文章cocos creator使用jenkins打包流程&#xff0c;打包webmobile_jenkins打包,发布,部署cocoscreator-CSDN博客 特别注意&#xff0c;windows上运行jenkins需要关闭windows自己的jenkins服务&a…

力扣刷题(第三十六天)

灵感来源 - 保持更新&#xff0c;努力学习 - python脚本学习 多数元素 解题思路 这道题是要找出数组中出现次数超过一半的元素。有几种不同的方法可以解决这个问题&#xff1a; 哈希表统计法&#xff1a;遍历数组&#xff0c;用哈希表统计每个元素的出现次数&#xff0c;…

关于读取CH584单片机的IO电平出现到的乌龙

本来是调用的库里的 uint8_t get_wake_up_sta (void) {return GPIOB_ReadPortPin(GPIO_Pin_10);//return cc_gpio_get_in_io (WAKUP_CH);} 然后读出来是0&#xff0c;我都配置上拉了。 搞不到原因。 最后是CH584单片机只有0和非零两种状态&#xff0c;读出来1024被转换成无…

Opencv常见学习链接(待分类补充)

文章目录 1.常见学习链接 1.常见学习链接 1.Opencv中文官方文档 2.Opencv C图像处理&#xff1a;矩阵Mat 随机数RNG 计算耗时 鼠标事件 3.Opencv C图像处理&#xff1a;亮度对比度饱和度高光暖色调阴影漫画效果白平衡浮雕羽化锐化颗粒感 4.OpenCV —— 频率域滤波&#xff…

anaconda、miniconda、conda的关系及miniconda安装

anaconda、miniconda、conda的关系及miniconda安装 文章目录 前言正文定义关系Linux安装miniconda新建一个python3.8环境 参考 前言 本文用于记录关于Anaconda、conda和Miniconda的定义及其关系的总结123&#xff1a; 正文 定义 conda 一个跨平台的开源包管理和环境管理工具…

2024-2025年AI领域重大事件深度解析:技术革命、产业重构与未来挑战

一、技术突破&#xff1a;从多模态到具身智能的跨越式演进 1. 生成式AI的“核爆级”升级 多模态融合&#xff1a;OpenAI的GPT-4o实现文本、图像、语音的实时交互&#xff0c;GPQA基准测试得分达87.7%&#xff0c;在科学推理和编程任务中表现卓越1。谷歌的Gemini 2.0 Flash支持…

城市地下“隐形卫士”:激光甲烷传感器如何保障燃气安全?

城市“生命线”面临的安全挑战 城市地下管网如同人体的“血管”和“神经”&#xff0c;承载着燃气、供水、电力、通信等重要功能&#xff0c;一旦发生泄漏或爆炸&#xff0c;将严重影响城市运行和居民安全。然而&#xff0c;由于管线老化、违规施工、监管困难等问题&#xff0…

融云 uni-app IMKit 上线,1 天集成,多端畅行

融云 uni-app IMKit 正式上线&#xff0c;支持一套代码同时运行在 iOS、Android、H5、小程序主流四端&#xff0c;集成仅需 1 天&#xff0c;并可确保多平台的一致性体验。 融云 uni-app IMKit 在 Vue 3 的高性能加持下开发实现&#xff0c;使用 Vue 3 Composition API&#x…

《Claude:人工智能界的璀璨新星》

一、Claude 登场&#xff1a;AI 新时代的震撼开篇 在科技飞速发展的今天&#xff0c;人工智能&#xff08;AI&#xff09;已经成为推动社会进步和创新的核心力量。从智能语音助手到自动驾驶汽车&#xff0c;从图像识别技术到自然语言处理&#xff0c;AI 正以惊人的速度渗透到我…

Python中tqdm进度条工具和enumerate函数的使用详解

tqdm进度条工具 tqdm 是 Python 中一个非常流行的 进度条显示工具库&#xff0c;常用于迭代操作的可视化&#xff0c;比如训练神经网络、批量数据处理等任务。 一、tqdm 是什么&#xff1f; tqdm 全称是 taqaddum&#xff08;阿拉伯语&#xff0c;意为“进展”&#xff09;&a…

yum命令常用选项

刷新仓库列表 sudo yum repolist清理 Yum 缓存并生成新的缓存 sudo yum clean all sudo yum makecache验证 EPEL 源是否已正确启用 sudo yum repolist enabled安装软件包 sudo yum install <package-name> -y更新软件包 sudo yum update -y仅更新指定的软件包。 su…

linux debug技术

Linux是当今应用最广泛的免费和开源操作系统&#xff0c;它是一个复杂的分布式操作系统。它的内核的强大和灵活性已成为引用它的原因之一。在掌握Linux内核的过程中&#xff0c;调试工具可以帮助开发人员获得更深入的反思和理解。下面有25种不可或缺的Linux内核调试工具&#x…

【LinkedList demo 内部类讲说】

LinkedList demo 内部类讲说 1. Node节点2.MyLinkedList3. LinkedListTest 测试类 1. Node节点 public class Node<T> {private Node<T> pre;private Node<T> next;private T data;public Node() {}public Node getPre() {return pre;}public void setPre(N…

html主题切换小demo

主题切换功能为网页和应用程序提供了多样化的视觉风格与使用体验。实现多主题切换的技术方案丰富多样&#xff0c;其中 CSS 变量和 JavaScript 样式控制是较为常见的实现方式。 以下是一个简洁的多主题切换示例&#xff0c;愿它能为您的编程之旅增添一份趣味。 代码展示 <…

【数据结构】

一、架构梳理 线性&#xff08;1:1) 线性表 顺序存储 –> arr 链式存储 –> 指针 &#xff08;有头&#xff0c;无头&#xff09; 有头是指有一个不存数据的头&#xff0c;始终作为这个链表的起点。 会更加简单&#xff0c;无头的话&#xff0c;更改首部节点会麻烦。 头…

UML学习指南:从零入门到实战应用

为零基础学习者设计的UML技术文档&#xff0c;旨在通过详细解释和实际案例&#xff0c;从零开始掌握UML。 UML学习指南&#xff1a;从零入门到实战应用 目录 引言&#xff1a;UML是什么&#xff1f;为什么学习UML&#xff1f; 1.1 什么是UML&#xff1f;1.2 为什么我们需要UM…

Matlab实战训练项目推荐

以下是一系列适合不同技能水平的 MATLAB 实战训练项目&#xff0c;涵盖基础编程、数据分析、信号处理、图像处理、控制系统、机器学习等领域。这些项目可帮助你巩固理论知识并提升实际应用能力。 一、基础项目&#xff08;适合初学者&#xff09; 矩阵运算与可视化 目标&#x…

从零开始:Python语言进阶之异常处理

一、认识异常&#xff1a;程序运行中的“意外事件” 在编写Python程序时&#xff0c;即使代码语法完全正确&#xff0c;运行过程中也可能遭遇各种意外情况。这些意外被称为异常&#xff0c;它们会打断程序的正常执行流程。例如&#xff0c;当我们尝试打开一个不存在的文件、用0…

Groovy:Java 的简洁版

Groovy 是一种 动态、面向对象的编程语言&#xff0c;运行在 Java 虚拟机&#xff08;JVM&#xff09;上&#xff0c;并与 Java 高度兼容。你可以把它理解为“Java 的简洁版 动态特性 脚本语言的灵活性”。 &#x1f9e0; Groovy 的核心特点 特性说明✅ 与 Java 高度兼容可以…