ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java IO核心全解析:流模型、缓冲机制、NIO与序列化

Java IO核心全解析:流模型、缓冲机制、NIO与序列化 Java 的输入输出这门课我相信每个 Java 开发者都觉得自己会。但真到了实际项目里十个人里有七八个会在 IO 上栽跟头——不是乱码就是性能卡死要么就是莫名其妙少写了两行数据。尤其是那些写过两年 CRUD 的兄弟突然被调去处理一个超大文件、对接一套需要严格控制字符编码的第三方接口瞬间就露馅了。这篇文章不打算按教科书的方式来我就从实战视角把 Java IO 这条线完整捋一遍。从最基础的流模型讲起到文件读写、缓冲机制、序列化、NIO再到面试里高频出现的 IO 细节题把“是什么”和“为什么这样”全部说透。不管是刚入门想打个好底子的新手还是准备跳槽想复习 IO 的 Java 工程师这篇文章都能给你省下不少瞎折腾的时间。1. Java IO 的地基先从流模型开始1.1 为什么 Java 把输入输出抽象成“流”Java 里凡是做输入输出必然是围绕“流”这个概念转的。很多初学者一开始想不通什么叫流其实把水龙头打开水从管子流到盆里——水流就是数据流。水从水源地流向水龙头这叫输入流水从水龙头流向下水道这叫输出流。程序就是中间那截水管读进来的数据往里进要输出的数据往外走。抽象成流有巨大的好处无论你是从键盘读、从文件读、从网络端口读、还是从内存数组读对上层来讲都是同一个 InputStream处理方式都一模一样。这就是面向抽象编程最典型的一课。我把一段从文件里读数据的代码写好哪天需求改成从网络接口读只要换一个构造方法就行中间处理数据的逻辑不用动。Java 里这套体系的根是四个抽象类InputStream字节输入、OutputStream字节输出、Reader字符输入、Writer字符输出。所有具体的流类比如 FileInputStream、BufferedReader、PrintWriter统统是这四个根的子类。你把这四个名字记清楚整个 IO 家族的家谱就理顺了一半。1.2 字节流与字符流选错就是乱码的根源字节流处理的单位是 byte一个字节一个字节地搬运数据。文件、图片、音频、视频这些都是字节数据读写它们必须用字节流。字符流处理的单位是 char本质上是先把字节按某种字符集解码成文字再以字符为单位操作。纯文本文件、配置文件、日志输出这些用字符流更合适。很多新手最容易犯的错误是拿字符流去读二进制文件或者拿字节流去读文本文件再手动转字符串。前者会得到一堆乱码后者倒是能用但在转码过程里如果字符集没写对照样乱码。我见过不少项目代码里直接写 new String(bytes) 连编码参数都不传这个坑我后面会详细讲。还有一点必须说清楚字符流在底层仍然用字节流只不过内置了解码/编码动作。比如 FileReader 底层就是用 FileInputStream加上 StreamDecoder 做字符解码。所以字符流比字节流多了一层转码的开销但换来了按字符操作的便利。解决乱码的思路永远只有一条数据是什么编码读取时就按什么编码解码写出时按目标端要求的编码编码。2. 输入这条路从键盘到文件再到网络2.1 读取标准输入的正确姿势很多人学 Java 第一行代码就会用 System.out.println 往屏幕输出但 System.in 这个标准输入流见过的人就少很多了。想想你上一次写一个真正需要从控制台读用户输入的命令行程序是什么时候日常开发里控制台输入用得少但写算法题、写小工具、参加蓝桥杯这类编程比赛System.in 就是吃饭的家伙。最简单的读法是直接用 System.in.read()一个字节一个字节地读回车符和换行符全得自己处理体验极差。实际开发中从来没人这么干。主流方案有两条一是用 Scanner 包一层需要读整数、浮点数、单词时特别方便二是用 BufferedReader 包一层纯文本按行读取很爽性能也更好。Scanner scanner new Scanner(System.in); int n scanner.nextInt(); scanner.nextLine(); // 吃到 nextInt 之后残留的回车符 String line scanner.nextLine();有个细节必须提醒nextInt、nextDouble 这类方法不会消费行尾的回车符如果你在 nextInt 之后马上用 nextLine拿到的会是一个空字符串。很多人第一次写这种代码都会被卡一下。解决方式就是上面代码里的那种先 nextLine 把残留的回车吃掉。这不算 bug但算典型的 API 陷阱。2.2 文件读取为什么 BufferedReader 是王道读文件大概是整个 Java IO 里出场率最高的操作。基础姿势是用 FileInputStream 或 FileReader但我要直接给结论如果是读文本文件务必在 FileReader 外面再包一层 BufferedReader如果是读二进制文件务必在 FileInputStream 外面再包一层 BufferedInputStream。这不是可选项而是性能的关键。原因在于不包缓冲的流每次 read 都会触发一次系统调用。你自己数数一个 10MB 的文本文件按行读要几十万次 read每次 read 都到操作系统那儿走一趟这开销相当可观。加了缓冲之后BufferedReader 会一次性从底层读入一大块数据默认 8192 字符到内部的 char 数组里你每次 readLine 消费的是内存等内存里的读完了再整块补充。系统调用次数从几十万次直接降到几百次量级的差距。写代码时还有个小习惯我强烈建议你养成——把流声明用 try-with-resources。Java 7 之后就支持 try (InputStream in Files.newInputStream(path)) { } 这种写法资源在 try 块结束自动关闭不用手动写 finally 和 close。以前那种到处 close、finally 里还要判断非空的代码又啰嗦又容易漏而且一旦在 close 之前抛异常资源就不释放了。try (BufferedReader br new BufferedReader(new FileReader(input.txt))) { String line; while ((line br.readLine()) ! null) { // 按行处理 } } catch (IOException e) { // 处理异常 }再说一个进阶技巧如果要按行读一个 GB 级别的日志文件上面的写法已经够用且内存安全因为 readLine 每次只返回一行字符串不会把整个文件载入内存。一句话总结——面对大文件时BufferedReader.readLine 循环就是你最趁手的工具。2.3 不同场景的输入选型速查我整理了一个选型表基本覆盖了日常开发与竞赛场景的输入需求场景推荐方案理由控制台读简单类型Scanner自带解析整数、浮点的 API代码短控制台按行读文本BufferedReader InputStreamReader性能更高行处理方便读小文本文件(几MB)Files.readString(path, charset)一行代码读完适合配置类文件读大文本文件BufferedReader 循环 readLine内存安全按行处理读二进制文件BufferedInputStream保留字节语义不受编码影响从网络流读数据BufferedInputStream/Reader网络 IO 更需要缓冲减少次数Files 工具类是 Java NIO 包提供的后面我会更详细地提到但它读写小文件实在方便一行代码就能把整个文件内容变成字符串。我在处理临时分析需求时经常用它比如验证某个配置文件的 JSON 格式是否正确Files.readString 直接完事。不过大文件千万别这么干String 是能在内存里撑爆你的。3. 输出这条路写入背后的缓冲机制3.1 System.out 与 PrintStream 的那些事System.out 看起来是个很普通的对象用起来也只是 println 一下但我告诉你它的真实身份是 PrintStream 你可能就慎重多了。PrintStream 的 println、print 方法有好几个重载版本你传什么类型它就按什么类型输出最后统一加上换行。这里有没有隐患有而且很常见——PrintStream 的异常是内部吞掉的。它的方法不抛出 IOException而是把异常记录在内部一个布尔标志上你调用时根本感知不到写失败了。这就引出一个判断System.out 适合写什么适合写日志、写控制台提示这种丢了也无所谓的场景。但如果你要写的是订单数据、计费记录、半结构化报表绝对不能拿 PrintStream 的 print 方法裸写因为一旦中间某个写操作失败你会完全蒙在鼓里。另一种输出到控制台的姿势是用 System.out.printf它支持格式化字符串跟 C 语言的 printf 同款套路System.out.printf(用户 %s 的年龄是 %d, name, age)。我在处理需要对齐输出的命令行工具时经常用比如打印带缩进和占位符的表格。3.2 文件写入别再裸用 FileWriter 了文件写入的坑比读取更多因为写入涉及缓冲、编码、Flush、覆盖与追加这四重问题。先说基础写文本文件的标准姿势是 FileWriter 外包一层 BufferedWriter但如果你要频繁地写内容并自动换行直接上 PrintWriter 更方便它构造时可以套 BufferedWriter还可以指定自动 flush。try (PrintWriter pw new PrintWriter(new BufferedWriter(new FileWriter(output.txt)))) { pw.println(第一行); pw.println(第二行); }FileWriter 默认使用平台的默认编码写文件。这句话本身没毛病但问题也出在这里——如果你在中文 Windows 上默认编码是 GBK你的同事在 macOS 上默认是 UTF-8。同一套代码在不同环境生成的文本文件编码不一样。你说这算不算坑当然算。解决办法就是在构造 FileWriter 时显式传字符集或者干脆用 Files.newBufferedWriter(path, charset) 来写try (BufferedWriter bw Files.newBufferedWriter(Paths.get(output.txt), StandardCharsets.UTF_8)) { bw.write(中文内容不会乱码); bw.newLine(); }关键是记住一句话凡涉及跨平台、跨系统、跨环境的数据交换永远显式指定 UTF-8永远别用默认编码。3.3 缓冲与 flush一个决定数据是否“落盘”的细节这个知识点面试里问得极多但真正理解的人少BufferedWriter 的 write 方法真的把数据写进文件了吗不一定。它先把数据存在内部 8192 字符的缓冲数组里只有缓冲满了或者你调用了 flush/close才真正把数据交给操作系统写盘。所以如果你写了数据不 close 也不 flush程序崩溃时会丢失一部分数据。更隐蔽的场景是你在一个长事务里处理数据每处理一条写一行最后才统一提交。如果没在写满之前手动控制缓冲可能出现内存里攒了很多数据但磁盘上迟迟没有。这时候 flush 就是救命的。日常开发的一个常见需求是写日志希望每条日志实时落盘以便排查问题这时有两种选择一种是在每次 println 后手动 pw.flush()另一种是在构造 PrintWriter 时设置 autoFlush 为 truenew PrintWriter(new BufferedWriter(...), true)。设置了 autoFlush 后每次 println 结束都会自动 flush代价是频繁 flush 会让性能明显下降。所以在实时性和性能之间怎么取舍要结合业务场景想清楚。我曾经做过一个数据采集程序因为少了一个 flush程序异常退出导致最后几百条采集数据全部丢失被运维同事追着排查了半天。那之后我写代码就养成了习惯凡是最终要落磁盘的关键数据close 之前必定 flush并且时时提醒自己 flush 并不是“可选的”。4. 实战实现一个带格式校验的文本复制工具4.1 需求拆解与方案选择理论讲了这么多必须落一次地。我们来做一个小工具读取一个文本文件要求每一行都是合法数字把所有数字累加最后把结果写入另一个文件。这个任务很简单但涵盖了输入、输出、解析异常、格式校验、资源管理等内容很适合串一遍知识点。方案选择我是这么考虑的既然按行读取且需要解析数字BufferedReader.readLine 是首选Scanner 也可以但 BufferedReader 的异常抛出方式对 IO 错误更明确。输出用 BufferedWriter 或 PrintWriter这里我选 BufferedWriter因为只需要写一次结果。编码统一用 UTF-8因为要处理的文件大概率是跨系统产生的不显式指定编码就等着乱码。读取文件如果文件本身不存在FileReader 构造时会直接抛 FileNotFoundException我把它捕获并转换成更友好的提示。4.2 代码实现与关键说明public class SumNumbersFromFile { public static void main(String[] args) { Path inputPath Paths.get(numbers.txt); Path outputPath Paths.get(sum.txt); try { BigDecimal total process(inputPath); writeResult(outputPath, total); System.out.println(处理完成总和为 total); } catch (IOException e) { System.err.println(文件读写失败 e.getMessage()); } catch (NumberFormatException e) { System.err.println(文件包含非数字内容 e.getMessage()); } } private static BigDecimal process(Path path) throws IOException { BigDecimal sum BigDecimal.ZERO; try (BufferedReader br Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; int lineNum 0; while ((line br.readLine()) ! null) { lineNum; String trimmed line.trim(); if (trimmed.isEmpty()) { continue; } try { sum sum.add(new BigDecimal(trimmed)); } catch (NumberFormatException e) { throw new NumberFormatException(第 lineNum 行内容 \ trimmed \ 不是有效数字); } } } return sum; } private static void writeResult(Path path, BigDecimal value) throws IOException { try (BufferedWriter bw Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { bw.write(value.toPlainString()); bw.newLine(); } } }几个关键点我要单独拎出来说。用 BigDecimal 而不用 double是因为金额或精度敏感的累加场景double 的浮点误差会累积出严重偏差。你可以跑一下 0.1 加 0.2 这个例子看看 double 给你的结果是什么。这里用 BigDecimal 是规范姿势。空行被直接跳过因为空行不该被当成格式错误。trim 的作用是去掉用户文件里可能存在的行首行尾空格很多来源的文本文件行尾会带 \r 或者不可见字符不 trim 的话 new BigDecimal 解析会失败。捕获 NumberFormatException 后重新包装异常信息把第几行、内容是什么、为什么失败都写清楚。这种细节在实际工程里非常重要——排障时能看到“第 10086 行有问题”和看到“格式不对”完全是两种体验。程序入口只 catch 两种异常分别给出不同提示。需要注意的是 Files.newBufferedReader 抛出的 IOException 在 process 方法声明里用 throws 往上抛而 NumberFormatException 因为是 RuntimeException可以不用声明但为了给用户更友好的信息我在 process 里捕获并重新包装的时候把它转换成了携带行号的新 NumberFormatException这属于“既能向上抛、又带足上下文”的做法。4.3 测试与边界情况处理写完了不能直接交给用户得自己先操作一遍。我准备了一个测试文件内容是这样的100 200 300 (这一段是空行) abc期望的行为是程序在读到 abc 时报错提示第 5 行取决于空行数量不是有效数字。实际跑下来前三个数字能正确累加读到 abc 时退出并报告具体行号。之后再建一个全是合法数字的文件跑一遍看 sum.txt 里输出的结果是否正确。这个测试过程看上去简单但把输入校验、异常处理、资源关闭都验证了。实际项目里这种工具类的小程序往往还要考虑超大数字溢出、文件中包含科学计数法表示的数字比如 1e3、文件没有读权限这些情况。科学计数法这里 BigDecimal 是支持的但如果你用 Integer.parseInt 就会炸所以选准数据类型同样重要。5. 进阶NIO 到底解决了什么问题5.1 从 BIO 到 NIO阻塞与非阻塞在 Java 1.4 引入 NIONew IO之前Java 的高并发 IO 是比较吃力的。传统 IO 是阻塞式一个线程发起 read 操作之后必须等数据到达才能返回。如果同时有一万个连接在等数据你就得开一万个线程每个线程大部分时间都在睡眠。线程是有开销的光虚拟内存就要分配 1MB 栈空间一万个线程系统根本吃不住。NIO 给的核心思路是“一个线程管所有连接”。它把连接注册到一个 Selector 上然后线程去轮询哪些连接有数据可读了哪些连接可以写了只处理那些真正就绪的连接。这就是我们经常听到的多路复用。一轮轮询可能处理几十上百个活跃连接整个系统只需要几个线程。这里不展开讲 Reactor 模型你只需要建立起一个印象BIO 是一对一服务一个线程伺候一个连接NIO 是一对多服务一个线程轮流伺候所有连接。等到你的项目确实遇到“高并发 IO 密集”的问题时再回来看这段会有更深的体会。5.2 NIO 核心三件套Buffer、Channel、SelectorNIO 最核心的三个组件是 Buffer、Channel、Selector。理解它们之间的关系比记住 API 更重要。Buffer 是数据的容器你从 Channel 读取数据实际上是读进 Buffer你往 Channel 写数据实际上也是从 Buffer 拿数据。Buffer 最反直觉的地方在于它有 position、limit、capacity 三个内部标记读写之间需要调用 flip、clear 这些方法切换状态。很多新手在这个阶段就开始懵其实是没理解Buffer 像一块可读写指针的停车场读的时候从头读到 limit写的时候从 position 写到 capacity切换时指针位置不同。Channel 是连接数据源或目标的通道类似老 IO 里的流。文件用 FileChannel网络用 SocketChannel、ServerSocketChannel。跟老 IO 最大的不同是 Channel 支持双向一个 FileChannel 既能读也能写而老 IO 里 InputStream 和 OutputStream 是分开的。Selector 是 NIO 多路复用的核心。它负责监视多个 Channel 的事件比如 OP_READ、OP_WRITE、OP_CONNECT。一个线程 select 之后就知道哪些 Channel 有事情干然后逐个处理。这个机制在 Netty 里被包装得更为优雅但内核还是 Selector 那一套。5.3 什么时候真正需要用 NIO我的判断标准很简单如果你的 IO 场景是“少量连接 大量数据传输”比如下载大文件、批量导入导出老 IO 完全够用NIO 的复杂度不划算FileChannel 也不算有决定性的优势。如果你的场景是“海量连接 少量数据交互”比如 C1000K 的聊天服务器、实时弹幕系统、网关转发这时候 NIO 几乎就是唯一选择。还有一部分人以为 NIO 就等于 FileChannel 的大文件拷贝性能会翻倍。其实 Java NIO 的 FileChannel.transferTo 在 Linux 下会用上 sendfile 零拷贝确实比老 IO 手工循环快不少。但日常业务里这种场景不多多数是文件服务器或者 Spring Boot 的静态资源处理。你要是想感受一下它的强可以自己写一个 FileChannel.transferTo 复制大文件的测试对比一下传统的循环拷贝时间那个差距相当直观。6. 序列化把对象写成字节流6.1 Serializable 与 serialVersionUID 的前世今生Java 里把一个对象变成字节流叫序列化把字节流还原成对象叫反序列化。工具是 ObjectOutputStream 和 ObjectInputStream。使用姿势不复杂让要序列化的类实现 Serializable 接口加一个 serialVersionUID 常量然后try (ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(obj.dat))) { oos.writeObject(user); } catch (IOException e) { e.printStackTrace(); } try (ObjectInputStream ois new ObjectInputStream(new FileInputStream(obj.dat))) { User user (User) ois.readObject(); }serialVersionUID 这个东西平时不写也能编译通过但强烈建议都显式声明一个 final long。为什么因为 JVM 在反序列化时会校验当前类的 serialVersionUID 和序列化数据里的值是否一致不一致直接抛 InvalidClassException。如果你没声明编译器会根据类的结构自动生成一个——只要你以后改了类的任何成员变量这个自动生成的值就变了旧数据反序列化直接爆炸。所以显式声明 serialVersionUID 是给自己的未来上保险。我接手过一个老系统运维把线上数据对象加了一个字段之后重新部署再加载旧数据文件时直接反序列化失败排查了半天才发现是 serialVersionUID 变了。这种低级事故其实完全可以通过显式声明来避免。6.2 序列化的安全隐患要心里有数序列化不是只有优点。它天然的隐患是反序列化时如果数据来源不可信攻击者可以构造恶意字节流让程序执行危险操作。这就是著名的反序列化攻击。不要以为这是安全团队的事实际编码时你就应该养成习惯不要反序列化来自不可信来源的数据。如果必须要至少用 ObjectInputFilter 限制类白名单。另外一个性能问题Java 原生序列化是把类的完整结构信息也写进字节流的所以序列化后的数据体积很大而且序列化过程有大量反射操作性能不高。很多互联网项目早就改用 JSON 或者 Protobuf 来做对象持久化和传输了Java 原生序列化基本只剩下面试和某些老接口在使用。你可以知道它但别主动拥抱它除非项目成熟稳定、没有性能瓶颈。7. 面试官真的会怎么问IO 高频题梳理7.1 必背的 IO 概念题结合我在工作中带新人的经历以及和一些面试官朋友聊下来的情况Java IO 这一块的高频题基本是固定的。比如讲讲 BIO、NIO、AIO 的区别。这种题考验的是你理解到什么层次能讲清楚阻塞 vs 非阻塞 vs 异步再加一个实际应用场景基本稳了。再比如怎么理解 IO 里面的“流”这种基础题反而容易答歪。关键点是“流”是一个数据的载体与传输通道有方向和目的地。你从输入流读到输出流写最终数据在程序内外搬运。如果能顺势讲一下装饰器模式就是 IO 类体系最典型的体现——BufferedReader 包在 FileReader 外面本质就是用一个装饰类增强另一个类的功能。这道题答出来面试官会觉得你不仅会用还理解设计。7.2 代码题手写一个文件拷贝面试官如果让你现场写文件拷贝多数人第一反应是 FileInputStream while 循环读一个字节写一个字节。写法没错但一定不是他想看到的加分答案。加分答案是public static void copyFile(File src, File dst) throws IOException { try (InputStream in new FileInputStream(src); OutputStream out new FileOutputStream(dst)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } }注意 buffer 数组的妙处一次读一批字节而不是一个字节性能提升巨大。再加 try-with-resources 自动关闭流代码简洁且不泄漏资源。最后可以补一句如果追求极致性能还可以用 FileChannel.transferTo 走零拷贝。这一套下来面试官想不给你加分都难。7.3 那些让人翻车的细节题有些细节题看着简单现场答基本上张口就错。比如 InputStream 的 read() 和 read(byte[]) 的区别是什么前者每次读一个字节并且返回的是字节的 int 值后者一次读一批字节返回的是实际读到的字节数。空着 buffer 导致了上次残留数据的场景也常有面试官拿来问。还有什么问题值得提前过一遍我总结了几道read() 方法返回 -1 代表什么代表流已到达末尾。你如何判断 BufferedReader 读到了文件结尾readLine() 返回 null。为什么不推荐用一个字节一个字节的方式读大文件因为每次 read 都是系统调用性能差且涉及用户态内核态切换。字符流按字符读字节流按字节读如何选择文本优先字符流二进制必用字节流。flush 是什么把缓冲中的数据强制刷到目标设备确保数据落盘不让数据残留在内存缓冲里。try-with-resources 的原理是什么它要求资源实现 AutoCloseable编译后自动生成 finally 关闭逻辑不用你手写。这些点全是实战和面试交叉验证过的你要是全答上来IO 这关基本就过了。8. 那些年我踩过的 IO 坑一份避坑清单8.1 编码不统一的连锁反应踩过最痛的坑是接手一个旧系统日志里全中文乱码。排查到最后发现是写入时用了 FileWriter 默认编码GBK读取时另一端用 UTF-8 解码两边不一致导致乱码。乱码不是丢了文字而是同一串字节被两套编码规则解读成不同的字符。后来我给自己定了铁律所有涉及文件读写的代码显式传 StandardCharsets.UTF_8不走默认编码。宁可多敲几个字母不能留隐患。8.2 大文件读入内存导致 OOM有次我处理一个 2GB 的 CSV 文件图省事直接 Files.readAllLines 想一次全读进来。程序启动还没多久JVM 先受不了了内存暴涨到 OOM。后来才知道readAllLines 会把每一行都变成 String 放进 List2GB 的文件产生了十几 GB 的 Java 堆内存占用。改成 BufferedReader 按行处理之后问题瞬间消失。以后凡是大文件我脑子里第一反应就是“流式处理”绝不让数据整个集聚在内存里。8.3 忘了关闭流导致句柄泄漏早年间写代码不重视资源释放循环创建流不关闭。某天上线后发现 PID 的文件句柄数飙升系统报“Too many open files”服务直接挂掉。那时才意识到流不关闭不只是浪费内存是占用操作系统资源。文件句柄是稀缺资源泄漏多了系统就崩了。Java 7 之后 try-with-resources 大大降低出问题的概率但如果你还在维护老代码惰性找一找那些 new FileInputSstream 后没有 close 的地方该修的赶紧修。8.4 误用 PrintWriter 吞异常之前用过 System.out 那个思路写文件用 PrintWriter 时也没太重视它的吞异常特性直到有一次写文件失败但程序完全无感知数据丢了一批。PrintWriter 把 IOException 藏在内部 checkError 里要主动调 checkError() 才能知道。所以我的经验是文件写入这类可靠性和可追踪性要求高的场景优先用 BufferedWriter 而不是 PrintWriter。Console 输出才用 PrintWriter 的多行格式优势。这份避坑清单每条都是真金白银换来的。我常说 Java IO 坑多但绝大多数坑都有规则可循显式字符集、加缓冲、按行/分块处理、及时 flush、用 try-with-resources、可靠场景不要吞异常。这六条记牢了Java IO 就没有跨不过去的坎。
返回列表