51CTO给各位读者讲过《Java的8大技术优势》,很多开发者觉得自己懂Java编程,事实是大多数开发人员都只领会到了Java平台的皮毛,所学也只够应付工作。作者将深度挖掘Java平台的核心功能,揭示一些鲜为人知的事实,帮助您解决最棘手的编程困难。
当应用程序性能受到损害时,大多数开发人员都惊慌失措,这在情理之中。跟踪Java应用程序瓶颈来源一直以来都是很麻烦的,因为Java虚拟机有黑盒效应,而且Java平台分析工具一贯就有缺陷。
然而,随着Java5中JConsole的引入,一切都发生了改变。JConsole是一个内置Java性能分析器,可以从命令行或在GUIshell中运行。它不是***的,但是当尖头老板来问你关于性能的问题时,用它来应对还是绰绰有余的——这比查询PapaGoogle要好得多。
我们将向您展示5个方法,使您可以轻松地使用JConsole(或者,它更高端的“近亲”VisualVM)来监控Java应用程序性能和跟踪Java中的代码。
1.JDK附带分析器
许多开发人员没有意识到从Java 5开始JDK中包含了一个分析器。JConsole(或者Java平台***版本,VisualVM)是一个内置分析器,它同Java编译器一样容易启动。如果是从命令行启动,使JDK在PATH上,运行jconsole即可。如果从GUIshell启动,找到JDK安装路径,打开bin文件夹,双击jconsole。
当分析工具弹出时(取决于正在运行的Java版本以及正在运行的Java程序数量),可能会出现一个对话框,要求输入一个进程的URL来连接,也可能列出许多不同的本地Java进程(有时包含JConsole进程本身)来连接。
使用JConsole进行工作
在Java 5中,Java进程并不是被设置为默认分析的,而是通过一个命令行参数—-Dcom.sun.management.jmxremote——在启动时告诉Java 5 VM打开连接,以便分析器可以找到它们;当进程被JConsole捡起时,您只能双击它开始分析。
分析器有自己的开销,因此***的办法就是花点时间来弄清是什么开销。发现JConsole开销最简单的办法是,首先独自运行一个应用程序,然后在分析器下运行,并测量差异。(应用程序不能太大或者太小;我最喜欢使用JDK附带的SwingSet2样本。)因此,我使用-verbose:gc尝试运行SwingSet2来查看垃圾收集清理,然后运行同一个应用程序并将JConsole分析器连接到它。当JConsole连接好了之后,一个稳定的GC清理流出现,否则不会出现。这就是分析器的性能开销。
JConsole或VisualVM?
JConsole从Java 5开始就随着Java平台版本一起发布,而VisualVM是在NetBeans基础上升级的一个分析器,在Java 6的更新版12中***次发布。多数还没有更新到Java 6,因此这篇文章主要介绍JConsole。然而,多数技巧和这两个分析器都有关。
2.远程连接进程
因为Web应用程序分析工具假设通过一个套接字进行连通性分析,您只需要进行少许配置来设置JConsole(或者是基于JVMTI的分析器,就这点而言),监控/分析远程运行的应用程序。
如果Tomcat运行在一个名为“webserve”的机器上,且JVM已经启动了JMX并监听端口9004,从JConsole(或者任何JMX客户端)连接它需要一个JMX URL“service:jmx:rmi:///jndi/rmi://webserver:9004/jmxrmi”。
基本上,要分析一个运行在远程数据中心的应用程序服务器,您所需要的仅仅是一个JMX URL。
3.跟踪统计
JConsole有许多对收集统计数据有用的选项卡,包括:
◆Memory:在JVM垃圾收集器中针对各个堆跟踪活动。
◆Threads:在目标JVM中检查当前线程活动。
◆Classes:观察VM已加载类的总数。
这些选项卡(和相关的图表)都是由每个Java 5及更高版本VM在JMX服务器上注册的JMX对象提供的,是内置到JVM的。一个给定JVM中可用bean的完整清单在MBeans选项卡上列出,包括一些元数据和一个有限的用户界面来查看数据或执行操作。(然而,注册通知是在JConsole用户界面之外。)
使用统计数据
假设一个Tomcat进程死于OutOfMemoryError。如果您想要弄清楚发生了什么,打开JConsole,单击Classes选项卡,过一段时间查看一次类计数。如果数量稳定上升,您可以假设应用程序服务器或者您的代码某个地方有一个ClassLoader漏洞,不久之后将耗尽PermGen空间。如果需要更进一步的确认问题,请看Memory选项卡。
不要成为典型
发现应用程序代码中性能问题的常用响应多种多样,但也是可预测的。早期的Java编程人员对旧的IDE可能十分生气,并开始进行代码库中主要部分的代码复查,在源代码中寻找熟悉的“红色标志”,像异步块、对象配额等等。随着编程经验的增加,开发人员可能会仔细研究JVM支持的-X标志,寻找优化垃圾收集器的方法。当然,对于新手,直接去Google查询,希望有其他人发现了JVM的神奇的“make it go fast”转换,避免重写代码。
从本质上来说,这些方法没什么错,但都是有风险的。对于一个性能问题最有效的响应就是使用一个分析器——现在它们内置在Java平台,我们确实没有理由不这样做!
4.为离线分析创建一个堆转储
生产环境中一切都在快速地进行着,您可能没有时间花费在您的应用程序分析器上,相反地,您可以为Java环境中的每个事件照一个快照保存下来过后再看。在JConsole中您也可以这样做,在VisualVM中甚至会做得更好。
先找到MBeans选项卡,在其中打开com.sun.management节点,接着是HotSpotDiagnostic节点。现在,选择Operations,注意右边面板中的“dumpHeap”按钮。如果您在***个(“字符串”)输入框中向dumpHeap传递一个文件名来转储,它将为整个JVM堆照一个快照,并将其转储到那个文件。
稍后,您可以使用各种不同的商业分析器来分析文件,或者使用VisualVM分析快照。(记住,VisualVM是在Java 6中可用的,且是单独下载的。)
5.JConsole并不是高深莫测的
作为一个分析器实用工具,JConsole是极好的,但是还有更好的工具。一些分析插件附带分析器或者灵巧的用户界面,默认情况下比JConsole跟踪更多的数据。
JConsole真正吸引人的是整个程序是用“普通旧式Java”编写的,这意味着任何Java开发人员都可以编写这样一个实用工具。事实上,JDK其中甚至包括如何通过创建一个插件来定制JConsole的示例。建立在NetBeans顶部的VisualVM进一步延伸了插件概念。
如果JConsole(或者VisualVM,或者其他任何工具)不符合您的需求,或者不能跟踪您想要跟踪的,或者不能按照您的方式跟踪,您可以编写属于自己的工具。如果您觉得Java代码很麻烦,Groovy或JRuby或很多其他JVM语言都可以帮助您更快完成。
您真正需要的是一个快速而粗糙(quick-and-dirty)的由JVM连接的命令行工具,可以以您想要的方式确切地跟踪您感兴趣的数据。
#p#
5个命令行分析工具
全功能内置分析器,如JConsole和VisualVM的成本有时比它们的性能费用还要高—尤其是在生产软件上运行的系统中。因此,在聚焦Java性能监控的第2篇中,我将介绍5个命令行分析工具,使开发人员仅关注运行的Java进程的一个方面。
JDK包括很多命令行实用程序,可以用于监控和管理Java应用程序性能。虽然大多数这类应用程序都被标注为“实验型”,在技术上不受支持,但是它们很有用。
1.jps(sun.tools.jps)
很多命令行工具都要求您识别您希望监控的Java进程。这与监控本地操作系统进程、同样需要一个程序识别器的同类工具没有太大区别。
“VMID”识别器与本地操作系统进程识别器(“pid”)并不总是相同的,这就是我们需要JDKjps实用程序的原因。
在Java进程中使用jps
与配置JDK的大部分工具及本文中提及的所有工具一样,可执行jps通常是一个围绕Java类或执行大多数工作的类集的一个薄包装。在Windows®环境下,这些工具是.exe文件,使用JNIInvocationAPI直接调用上面提及的类;在UNIX®环境下,大多数工具是一个shell脚本的符号链接,该脚本采用指定的正确类名称开始一个普通启动程序。如果您希望在Java进程中使用jps(或者任何其他工具)的功能—Ant脚本—仅在每个工具的“主”类上调用main()相对容易。为了简化引用,类名称出现在每个工具名称之后的括号内。
jps—名称反映了在大多数UNIX系统上发现的ps实用程序—告诉我们运行Java应用程序的JVMID。顾名思义,jps返回指定机器上运行的所有已发现的Java进程的VMID。如果jps没有发现进程,并不意味着无法附加或研究Java进程,而只是意味着它并未宣传自己的可用性。
如果发现Java进程,jps将列出启用它的命令行。这种区分Java进程的方法非常重要,因为只要涉及操作系统,所有的Java进程都被统称为“java”。在大多数情况下,VMID是值得注意的重要数字。
使用分析器开始
使用分析实用程序开始的最简单方法是使用一个如在demo/jfc/SwingSet2中发现的SwingSet2演示一样的演示程序。这样就可以避免程序作为背景/监控程序运行时出现挂起的可能性。当您了解工具及其费用后,就可以在实际程序中进行试用。
加载演示应用程序后,运行jps并注意返回的vmid。为了获得更好的效果,采用-Dcom.sun.management.jmxremote属性集启动Java进程。如果没有使用该设置,部分下列工具收集的部分数据可能不可用。
2.jstat(sun.tools.jstat)
jstat实用程序可以用于收集各种各样不同的统计数据。jstat统计数据被分类到“选项”中,这些选项在命令行中被指定作为***参数。对于JDK 1.6来说,您可以通过采用命令-options运行jstat查看可用的选项清单。清单1中显示了部分选项:
清单1.jstat选项
- -class
- -compiler
- -gc
- -gccapacity
- -gccause
- -gcnew
- -gcnewcapacity
- -gcold
- -gcoldcapacity
- -gcpermcapacity
- -gcutil
- -printcompilation
实用程序的JDK记录将告诉您清单1中每个选项返回的内容,但是其中大多数用于收集垃圾的收集器或者其部件的性能信息。-class选项显示了加载及未加载的类(使其成为检测应用程序服务器或代码中ClassLoader泄露的重要实用程序,且-compiler和-printcompilation都显示了有关Hotspot JIT编译程序的信息。
默认情况下,jstat在您核对信息时显示信息。如果您希望每隔一定时间拍摄快照,请在-options指令后以毫秒为单位指定间隔时间。jstat将持续显示监控进程信息的快照。如果您希望jstat在终止前进行特定数量的快照,在间隔时间/时间值后指定该数字。
如果5756是几分钟前开始的运行SwingSet2程序的VMID,那么下列命令将告诉jstat每250毫秒为10个佚代执行一次gc快照转储,然后停止:
- jstat -gc 5756 250 10
请注意Sun(现在的Oracle)保留了在不进行任何预先通知的情况下更改各种选项的输出甚至是选项本身的权利。这是使用不受支持实用程序的缺点。请参看Javadocs了解jstat输出中每一列的全部细节。
3.jstack(sun.tools.jstack)
了解Java进程及其对应的执行线程内部发生的情况是一种常见的诊断挑战。例如,当一个应用程序突然停止进程时,很明显出现了资源耗尽,但是仅通过查看代码无法明确知道何处出现资源耗尽,且为什么会发生。
jstack是一个可以返回在应用程序上运行的各种各样线程的一个完整转储的实用程序,您可以使用它查明问题。
采用期望进程的VMID运行jstack会产生一个堆转储。就这一点而言,jstack与在控制台窗口内按Ctrl-Break键起同样的作用,在控制台窗口中,Java进程正在运行或调用VM内每个Thread对象上的Thread.getAllStackTraces()或Thread.dumpStack()。jstack调用也转储关于在VM内运行的非Java线程的信息,这些线程作为Thread对象并不总是可用的。
jstack的-l参数提供了一个较长的转储,包括关于每个Java线程持有锁的更多详细信息,因此发现(和squash)死锁或可伸缩性bug是极其重要的。
4.jmap(sun.tools.jmap)
有时,您正在处理的问题是一个对象泄露,如一个ArrayList(可能持有成千上万个对象)该释放时没有释放。另一个更普遍的问题是,看似从不会压缩的扩展堆,却有活跃的垃圾收集。
当您努力寻找一个对象泄露时,在指定时刻对堆及时进行拍照,然后审查其中内容非常有用。jmap通过对堆拍摄快照来提供该功能的***部分。然后您可以采用下一部分中描述的jhat实用程序分析堆数据。
与这里描述的其他所有实用程序一样,使用jmap非常简单。将jmap指向您希望拍快照的Java进程的VMID,然后给予它部分参数,用来描述产生的结果文件。您要传递给jmap的选项包括转储文件的名称以及是否使用一个文本文件或二进制文件。二进制文件是最有用的选项,但是只有当与某一种索引工具结合使用时—通过十六进制值的文本手动操作数百兆字节不是***的方法。
随意看一下Java堆的更多信息,jmap同样支持-histo选项。-histo产生一个对象文本柱状图,现在在堆中大量引用,由特定类型消耗的字节总数分类。它同样给出了特定类型的总示例数量,支持部分原始计算,并猜测每个实例的相对成本。
不幸的是,jmap没有像jstat一样的period-and-max-count选项,但是将jmap(或jmap.main())调用放入shell脚本或其他类的循环,周期性地拍摄快照相对简单。(事实上,这是加入jmap的一个好的扩展,不管是作为OpenJDK本身的源补丁,还是作为其他实用程序的扩展。)
5.jhat(com.sun.tools.hat.Main)
将堆转储至一个二进制文件后,您就可以使用jhat分析二进制堆转储文件。jhat创建一个HTTP/HTML服务器,该服务器可以在浏览器中被浏览,提供一个关于堆的object-by-object视图,及时冻结。根据对象引用草率处理堆可能会非常可笑,您可以通过对总体混乱进行某种自动分析而获得更好的服务。幸运的是,jhat支持OQL语法进行这样的分析。
例如,对所有含有超过100个字符的String运行OQL查询看起来如下:
- select s from java.lang.String s where s.count >= 100
结果作为对象链接显示,然后展示该对象的完整内容,字段引用作为可以解除引用的其他链接的其他对象。OQL查询同样可以调用对象的方法,将正则表达式作为查询的一部分,并使用内置查询工具。一种查询工具,referrers()函数,显示了引用指定类型对象的所有引用。下面是寻找所有参考File对象的查询:
- select referrers(f) from java.io.File f
您可以查找OQL的完整语法及其在jhat浏览器环境内“OQL Help”页面上的特性。将jhat与OQL相结合是对行为不当的堆进行对象调查的有效方法。
结束语
当您需要近距离观察Java进程内发生的事情时,JDK的分析扩展会非常有用。本文中介绍的所有工具都可以从命令行中由其自己使用。它们还可以与JConsole或VisualVM有力地结合使用。JConsole和VisualVM提供Java虚拟机的总体视图,jstat和jmap等有针对性的工具支持您对研究进行微调。
#p#
Java 平台上更简单的脚本编写方法
现在,许多 Java 开发人员都喜欢在 Java 平台中使用脚本语言,但是使用编译到 Java 字节码中的动态语言有时是不可行的。在某些情况中,直接编写一个 Java 应用程序的脚本 部分 或者在一个脚本中调用特定的 Java 对象是更快捷、更高效的方法。
这就是 javax.script 产生的原因了。Java Scripting API 是从 Java 6 开始引入的,它填补了便捷的小脚本语言和健壮的 Java 生态系统之间的鸿沟。通过使用 Java Scripting API,您就可以在您的 Java 代码中快速整合几乎所有的脚本语言,这使您能够在解决一些很小的问题时有更多可选择的方法。
1. 使用 jrunscript 执行 JavaScript
每一个新的 Java 平台发布都会带来新的命令行工具集,它们位于 JDK 的 bin 目录。Java 6 也一样,其中 jrunscript 便是 Java 平台工具集中的一个不小的补充。
设想一个编写命令行脚本进行性能监控的简单问题。这个工具将借用 jmap(见本系列文章 前一篇文章 中的介绍),每 5 秒钟运行一个 Java 进程,从而了解进程的运行状况。一般情况下,我们会使用命令行 shell 脚本来完成这样的工作,但是这里的服务器应用程序部署在一些差别很大的平台上,包括 Windows® 和 Linux®。系统管理员将会发现编写能够同时运行在两个平台的 shell 脚本是很痛苦的。通常的做法是编写一个 Windows 批处理文件和一个 UNIX® shell 脚本,同时保证这两个文件同步更新。
但是,任何阅读过 The Pragmatic Programmer 的人都知道,这严重违反了 DRY (Don't Repeat Yourself) 原则,而且会产生许多缺陷和问题。我们真正希望的是编写一种与操作系统无关的脚本,它能够在所有的平台上运行。
当然,Java 语言是平台无关的,但是这里并不是需要使用 “系统” 语言的情况。我们需要的是一种脚本语言 — 如,JavaScript。
清单 1 显示的是我们所需要的简单 shell 脚本:
清单 1. periodic.js
- while (true)
- {
- echo("Hello, world!");
- }
由于经常与 Web 浏览器打交道,许多 Java 开发人员已经知道了 JavaScript(或 ECMAScript;JavaScript 是由 Netscape 开发的一种 ECMAScript 语言)。问题是,系统管理员要如何运行这个脚本?
当然,解决方法是 JDK 所带的 jrunscript 实用程序,如清单 2 所示:
清单 2. jrunscript
- C:\developerWorks\5things-scripting\code\jssrc>jrunscript periodic.js
- Hello, world!
- Hello, world!
- Hello, world!
- Hello, world!
- Hello, world!
- Hello, world!
- Hello, world!
- ...
注意,您也可以使用 for 循环按照指定的次数来循环执行这个脚本,然后才退出。基本上,jrunscript 能够让您执行 JavaScript 的所有操作。惟一不同的是它的运行环境不是浏览器,所以运行中不会有 DOM。因此,最顶层的函数和对象稍微有些不同。
因为 Java 6 将 Rhino ECMAScript 引擎作为 JDK 的一部分,jrunscript 可以执行任何传递给它的 ECMAScript 代码,不管是一个文件(如此处所示)或是在更加交互式的 REPL(“Read-Evaluate-Print-Loop”)shell 环境。运行 jrunscript 就可以访问 REPL shell。
2. 从脚本访问 Java 对象
能够编写 JavaScript/ECMAScript 代码是非常好的,但是我们不希望被迫重新编译我们在 Java 语言中使用的所有代码 — 这是违背我们初衷的。幸好,所有使用 Java Scripting API 引擎的代码都完全能够访问整个 Java 生态系统,因为本质上一切代码都还是 Java 字节码。所以,回到我们之前的问题,我们可以在 Java 平台上使用传统的 Runtime.exec() 调用来启动进程,如清单 3 所示:
清单 3. Runtime.exec() 启动 jmap
- var p = java.lang.Runtime.getRuntime().exec("jmap", [ "-histo", arguments[0] ])
- p.waitFor()
数组 arguments 是指向传递到这个函数参数的 ECMAScript 标准内置引用。在最顶层的脚本环境中,则是传递给脚本本身的的参数数组(命令行参数)。所以,在清单 3 中,这个脚本预期接收一个参数,该参数包含要映射的 Java 进程的 VMID。
除此之外,我们可以利用本身为一个 Java 类的 jmap,然后直接调用它的 main() 方法,如清单 4 所示。有了这个方法,我们不需要 “传输” Process 对象的 in/out/err 流。
清单 4. JMap.main()
- var args = [ "-histo", arguments[0] ]
- Packages.sun.tools.jmap.JMap.main(args)
Packages 语法是一个 Rhino ECMAScript 标识,它指向已经 Rhino 内创建的位于核心 java.* 包之外的 Java 包。
3. 从 Java 代码调用脚本
从脚本调用 Java 对象仅仅完成了一半的工作:Java 脚本环境也提供了从 Java 代码调用脚本的功能。这只需要实例化一个 ScriptEngine 对象,然后加载和评估脚本,如清单 5 所示:
清单 5. Java 平台的脚本调用
- import java.io.*;
- import javax.script.*;
- public class App
- {
- public static void main(String[] args)
- {
- try
- {
- ScriptEngine engine =
- new ScriptEngineManager().getEngineByName("javascript");
- for (String arg : args)
- {
- FileReader fr = new FileReader(arg);
- engine.eval(fr);
- }
- }
- catch(IOException ioEx)
- {
- ioEx.printStackTrace();
- }
- catch(ScriptException scrEx)
- {
- scrEx.printStackTrace();
- }
- }
- }
eval() 方法也可以直接操作一个 String,所以这个脚本不一定必须是文件系统的一个文件 — 它可以来自于数据库、用户输入,或者甚至可以基于环境和用户操作在应用程序中生成。
4. 将 Java 对象绑定到脚本空间
仅仅调用一个脚本还不够:脚本通常会与 Java 环境中创建的对象进行交互。这时,Java 主机环境必须创建一些对象并将它们绑定,这样脚本就可以很容易找到和使用这些对象。这个过程是 ScriptContext 对象的任务,如清单 6 所示:
清单 6. 为脚本绑定对象
- import java.io.*;
- import javax.script.*;
- public class App
- {
- public static void main(String[] args)
- {
- try
- {
- ScriptEngine engine =
- new ScriptEngineManager().getEngineByName("javascript");
- for (String arg : args)
- {
- Bindings bindings = new SimpleBindings();
- bindings.put("author", new Person("Ted", "Neward", 39));
- bindings.put("title", "5 Things You Didn't Know");
- FileReader fr = new FileReader(arg);
- engine.eval(fr, bindings);
- }
- }
- catch(IOException ioEx)
- {
- ioEx.printStackTrace();
- }
- catch(ScriptException scrEx)
- {
- scrEx.printStackTrace();
- }
- }
- }
访问所绑定的对象很简单 — 所绑定对象的名称是作为全局命名空间引入到脚本的,所以在 Rhino 中使用 Person 很简单,如清单 7 所示:
清单 7.
- println("Hello from inside scripting!")
- println("author.firstName = " + author.firstName)
您可以看到,JavaBeans 样式的属性被简化为使用名称直接访问,这就好像它们是字段一样。
5. 编译频繁使用的脚本
脚本语言的缺点一直存在于性能方面。其中的原因是,大多数情况下脚本语言是 “即时” 解译的,因而它在执行时会损失一些解析和验证文本的时间和 CPU 周期。运行在 JVM 的许多脚本语言最终会将接收的代码转换为 Java 字节码,至少在脚本被***次解析和验证时进行转换;在 Java 程序关闭时,这些即时编译的代码会消失。将频繁使用的脚本保持为字节码形式可以帮助提升可观的性能。
我们可以以一种很自然和有意义的方法使用 Java Scripting API。如果返回的 ScriptEngine 实现了 Compilable 接口,那么这个接口所编译的方法可用于将脚本(以一个 String 或一个 Reader 传递过来的)编译为一个 CompiledScript 实例,然后它可用于在 eval() 方法中使用不同的绑定重复地处理编译后的代码,如清单 8 所示:
清单 8. 编译解译后的代码
- import java.io.*;
- import javax.script.*;
- public class App
- {
- public static void main(String[] args)
- {
- try
- {
- ScriptEngine engine =
- new ScriptEngineManager().getEngineByName("javascript");
- for (String arg : args)
- {
- Bindings bindings = new SimpleBindings();
- bindings.put("author", new Person("Ted", "Neward", 39));
- bindings.put("title", "5 Things You Didn't Know");
- FileReader fr = new FileReader(arg);
- if (engine instanceof Compilable)
- {
- System.out.println("Compiling....");
- Compilable compEngine = (Compilable)engine;
- CompiledScript cs = compEngine.compile(fr);
- cs.eval(bindings);
- }
- else
- engine.eval(fr, bindings);
- }
- }
- catch(IOException ioEx)
- {
- ioEx.printStackTrace();
- }
- catch(ScriptException scrEx)
- {
- scrEx.printStackTrace();
- }
- }
- }
在大多数情况中,CompiledScript 实例需要存储在一个长时间存储中(例如,servlet-context),这样才能避免一次次地重复编译相同的脚本。然而,如果脚本发生变化,您就需要创建一个新的 CompiledScript 来反映这个变化;一旦编译完成,CompiledScript 就不再执行原始的脚本文件内容。
结束语
Java Scripting API 在扩展 Java 程序的范围和功能方面前进了很大一步,并且它将脚本语言的编码效率的优势带到 Java 环境。jrunscript — 它显然不是很难编写的程序 — 以及 javax.script 给 Java 开发人员带来了诸如 Ruby (JRuby) 和 ECMAScript (Rhino) 等脚本语言的优势,同时还不会破坏 Java 环境的生态系统和可扩展性。
关于作者
Ted Neward是Neward&Associates的总裁,从事关于Java、.NET、XML Services以及其他平台方面的咨询、指导和演示等工作。他居住在华盛顿西雅图。
【编辑推荐】