简介:Apache Tomcat 8.0.47 是一款面向 Java Web 开发者的轻量级开源 Servlet/JSP 容器,适合需要部署调试 Java Web 应用、学习 Servlet 与 JSP 运行机制的初中级开发者及运维人员。压缩包共 628 个文件、约 9.75MB,以 html 文档、class 编译文件、java 源码、jsp 页面为主体,配合 jar 依赖包、xml 配置、properties 配置以及 sh、bat 启停脚本,另含少量图片、字体与证书类资源,基本覆盖容器运行所需的目录结构。资源包含 Catalina 容器、Jasper JSP 引擎、Coyote 连接器等核心模块,并附 manager、admin 管理应用与日志目录,便于读者直接部署 war 包、调整 server.xml 端口与虚拟主机、配置 HTTPS 安全约束,并在出现启动或部署异常时对照日志定位问题。目前已有 122 人学习下载,适合作为 Java Web 入门实践与中小项目部署的基础环境。
1. 别急着解压:apache-tomcat-8.0.47 装错 JDK,一下午就没了
上周给一个模拟项目X 恢复运行环境,war 包丢进新装的 Tomcat 9,控制台干干净净,页面直接 404;换回 apache-tomcat-8.0.47,同一个包一路顺利跑通。这属于版本分支对不上的常见问题,和配置出错无关。8.0.x 是 Servlet 3.1 那条线里偏老的一支,老项目里的 web.xml 写法、自定义 Valve、还有一堆 2015 年前后打的 JAR,在 8.5 和 9 上会因为类加载和属性校验变严直接失败。所以这篇记录的是 apache-tomcat-8.0.47 从装 JDK、解压规划、改 server.xml 到部署 war 包验证的整套操作,而不是「Tomcat 怎么用」。手里压着老系统、需要一份能跑起来的 8.0.x 环境的人对着抄就行;从零起新项目的,直接上 8.5 或 9,别在这条线上耗时间。
2. 环境清单与 JDK 选型:8.0.47 该吃什么版本的 Java
2.1 一张对照表,先把规范和 JDK 定下来
装 Tomcat 之前最该做的一件事,是翻一遍手里的 war 包,确认它当初是用哪个 JDK 编的。8.0.x 这条线对 Java 的容忍度比想象中窄,装高了不一定报错,但会在某个 JSP 编译或者反射调用上直接中断。
| 项目 | apache-tomcat-8.0.47 的要求 | 我这边落的值 |
|---|---|---|
| Servlet 规范 | 3.1 | 由容器提供,war 里不要再打 servlet-api |
| JSP 规范 | 2.3 | 同上,jsp-api 也不能进 WEB-INF/lib |
| WebSocket | 1.1 | 老项目一般用不到 |
| 最低 JDK | 7 | — |
| 建议 JDK | 8(8u 任意小版本) | /opt/app/java软链到jdk1.8.0_x |
| 默认端口 | 8080 / 8005 / 8009 | 只保留 8080,其余按需 |
| 默认 URIEncoding | 8.0 起已是 UTF-8 | 仍然显式写死,防止被人从旧配置抄回去 |
不建议上 JDK 11 及以上的原因很实在。JDK 9 之后模块化把tools.jar拿掉了,而不少老项目的 JSP 编译、老版字节码增强库还惦记着它,跑起来就是NoClassDefFoundError;再加上模块系统默认不开反射,cglib 早期版本要额外加一串--add-opens才能启动。这些坑不是不能填,但为了跑一个 8.0.x 的容器去填它们,投入产出比很低。JDK 8 是这条线上最省事的落点。
有个技巧我每次接管环境都用:不猜,直接看字节码版本号。
# 从 war 包里抽一个 class,读头 8 个字节 # CAFEBABE 是魔数,后面 4 字节是 minor + major # major=52 对应 JDK 8,51 对应 JDK 7,50 对应 JDK 6,49 对应 JDK 5 unzip -p demo.war WEB-INF/classes/com/example/Demo.class | od -An -tu1 -N8输出形如202 254 186 190 0 0 0 52,最后那个 52 就是主版本号。这一步的作用是把「该装哪个 JDK」从猜测变成确认:主版本 52 就必须 JDK 8 及以上,装 JDK 7 会在启动阶段抛UnsupportedClassVersionError,容器甚至还没开始加载你的应用就已经结束了。
2.2 目录规划:CATALINA_HOME 和 CATALINA_BASE 分开放
绝大多数人装 Tomcat 的方式是把压缩包解到/usr/local下,然后所有东西——配置、日志、部署的 war——全塞在一个目录里。这样做的后果是升级或重装时,配置和业务包得先手工备份一遍,稍不留神就丢了。我更习惯按官方支持的CATALINA_HOME/CATALINA_BASE拆分来规划:
# CATALINA_HOME:只放程序本体,升级时整体替换,不做任何本地修改 mkdir -p /opt/app # CATALINA_BASE:放实例相关的一切,conf、logs、webapps、temp、work mkdir -p /opt/data/tomcat-base/{conf,logs,temp,work,webapps} # 解压后把默认 conf 拷一份到实例目录,作为配置起点 tar -zxvf apache-tomcat-8.0.47.tar.gz -C /opt/app/ cp -a /opt/app/apache-tomcat-8.0.47/conf/. /opt/data/tomcat-base/conf/拆开之后每个目录的职责非常清楚:/opt/app/apache-tomcat-8.0.47是只读的,谁都不许在里面改server.xml;/opt/data/tomcat-base是可写的,日志、临时文件、war 包全在这里。以后要升到 8.0.x 的下一个小版本,只需要换掉CATALINA_HOME指向的目录,conf和webapps原封不动——这就是提前为升级准备的方案,而不是出事后才后悔。
CATALINA_BASE下这几个目录是必须存在的,缺一个启动时的表现都不一样:缺conf会提示找不到server.xml,缺logs时catalina.out根本不会生成,缺temp会在上传文件或 JSP 编译阶段突然失败。所以我上面用一次mkdir -p全部建齐,省得后面一个个补。
2.3 装 JDK 并把 JAVA_HOME 写死
JDK 建议放在/opt/app下面,用一个软链指向具体版本,这样以后换小版本只改软链,环境变量一行都不用动:
# 解压到 /opt/app,然后建一个不带版本号的软链 tar -zxvf jdk-8u*-linux-x64.tar.gz -C /opt/app/ ln -s /opt/app/jdk1.8.0_<小版本> /opt/app/java # 验证软链是否指对了 /opt/app/java/bin/java -version环境变量统一写进/etc/profile.d/tomcat.sh,而不是直接改~/.bashrc。原因是运维脚本、cron、systemd 这些场景经常以别的用户身份跑,用户级的 profile 根本不会被加载,这是后面排错章节里最常见的一个坑:
# /etc/profile.d/tomcat.sh export JAVA_HOME=/opt/app/java export JRE_HOME=$JAVA_HOME/jre export CATALINA_HOME=/opt/app/apache-tomcat-8.0.47 export CATALINA_BASE=/opt/data/tomcat-base export PATH=$JAVA_HOME/bin:$PATH关于JAVA_HOME的写法,有几个细节值得较真。它指向的是 JDK 的根目录,不是bin目录,也不是jre目录;JRE_HOME在 JDK 8 里是$JAVA_HOME/jre,Tomcat 的启动脚本会先找JAVA_HOME、找不到才退回JRE_HOME,两个都不存在就直接拒绝启动。另外PATH里把$JAVA_HOME/bin放前面,是为了让java -version用的是我们指定的这个 JDK,而不是系统自带的 OpenJDK——一台机器上装了三四个 JDK 的时候,这个顺序决定了你调的到底是哪一个。
写完执行source /etc/profile.d/tomcat.sh,然后用echo $JAVA_HOME和$JAVA_HOME/bin/java -version双确认。别嫌这一步啰嗦,我在三台机器上见过JAVA_HOME指向/opt/app/java/bin这种写法,结果 Tomcat 启动时报「The JRE_HOME environment variable is not defined」,排查了半小时才发现是路径多写了一层——这种错误不会有任何提示,只会告诉你变量没定义。
3. 解压、授权与首次启动:让 8.0.47 顺利启动
3.1 专用用户与可执行权限
Tomcat 不要跑在 root 下,这是底线要求而不是洁癖。容器一旦被拿下,root 权限意味着整台机器拱手相让。所以先建一个不能登录的系统用户,再把两个目录的属主都改过去:
# -r 建系统用户,-s /sbin/nologin 禁止登录 shell useradd -r -s /sbin/nologin tomcat # 两个目录都要改属主:程序目录和实例目录 chown -R tomcat:tomcat /opt/app/apache-tomcat-8.0.47 /opt/data/tomcat-base # 压缩包解压出来的 sh 有时不带可执行位,统一补上 chmod +x /opt/app/apache-tomcat-8.0.47/bin/*.shchmod +x这一步很多人会漏。从某些渠道下载的 tar 包,或者从 Windows 中转过的文件,bin/*.sh的权限位可能是 644,执行时提示Permission denied,而ls -l一眼看过去文件都在,很容易误判成路径问题。补权限的时候注意只给.sh,bootstrap.jar、tomcat-juli.jar这些是 JAR,不需要可执行位。
3.2 目录结构:哪些能删、哪些碰不得
解压出来的目录看着多,真正要记住的就七个,我在部署文档里一般会给新人列这么一张表:
| 目录 | 作用 | 能不能动 |
|---|---|---|
bin/ | 启动停止脚本、bootstrap.jar | 只加setenv.sh,其余别改 |
conf/ | server.xml、web.xml、tomcat-users.xml | 要改,但改之前先备份 |
lib/ | 容器自身的 JAR | 别手动加业务 JAR |
logs/ | catalina.out、localhost 日志、访问日志 | 可清,注意别删正在写的 |
temp/ | 临时文件 | 停机时可清空 |
webapps/ | 默认的应用基目录 | 按部署方式决定 |
work/ | JSP 编译产物、会话序列化 | 清缓存就删这里 |
work/是最容易被当成垃圾目录删掉、又最容易被遗忘的地方。JSP 编译出来的 Servlet 就存在work/Catalina/localhost/<应用名>/下面,改了 JSP 不生效、或者页面报一些对不上号的编译错误,第一反应应该是清这里的缓存,而不是重启整台机器。
lib/里放业务 JAR 是另一个高频误操作。有人图省事把 JDBC 驱动扔进lib/,结果所有应用共享同一份驱动,版本冲突时谁都跑不起来,而且这个错误在应用日志里看起来像是业务代码的问题。业务依赖一律走WEB-INF/lib。
3.3 前台启动和后台启动,第一次一定要用前台
bin/下面有三个脚本容易搞混,它们的实际关系比名字看起来简单:
# 前台启动:日志直接打到终端,Ctrl+C 结束进程 # 第一次启动、改完配置验证,一律用这个 $CATALINA_HOME/bin/catalina.sh run # 后台启动:本质是 catalina.sh start,输出重定向到 logs/catalina.out $CATALINA_HOME/bin/startup.sh # 停止:读 server.xml 里的 shutdown 端口发关闭指令 $CATALINA_HOME/bin/shutdown.shstartup.sh只是catalina.sh start的一层包装,它的日志是异步写进catalina.out的。问题在于,如果启动过程中直接抛异常退出(比如端口被占、server.xml语法错、JAVA_HOME 没配好),你在终端上只会看到一句「Tomcat started」,然后去翻catalina.out才发现里面只有几行残缺的堆栈,甚至因为缓冲区还没刷盘而空空如也。
用catalina.sh run就不一样,日志同步输出到终端,异常堆栈完整可见,Ctrl+C就能干净退出,不用管进程号。所以我的习惯是:任何一次配置变更之后,先用run起一遍,看到Server startup in xxx ms再切后台。这条习惯帮我省掉的排查时间,比任何监控都多。
3.4 setenv.sh:内存参数该写在哪
catalina.sh启动时会自动 source 一个叫setenv.sh的文件,这是官方留的口子,也是唯一一个不用改官方脚本就能注入 JVM 参数的地方。文件要放在$CATALINA_BASE/bin/下,并且要有可执行位:
# $CATALINA_BASE/bin/setenv.sh # CATALINA_OPTS 只在 start 时生效,适合放堆内存这类启动参数 export CATALINA_OPTS="-Xms1024m -Xmx1024m \ -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m \ -Dfile.encoding=UTF-8 \ -Djava.security.egd=file:/dev/./urandom" # JAVA_OPTS 在 stop 时也会带上,别往里塞大内存参数 export JAVA_OPTS="-server"几个参数值得逐个说清楚。-Xms和-Xmx设成一样,是为了避免运行期堆反复扩容收缩带来的抖动,小内存机器上这个差别很明显。JDK 8 里永久代已经被元空间取代,所以写-XX:MaxPermSize不但无效,还会打印一行「ignoring option MaxPermSize」的警告——这个警告经常被新人当成启动失败的原因,其实是虚惊一场,正确的写法是MetaspaceSize和MaxMetaspaceSize。
-Djava.security.egd=file:/dev/./urandom是解决「启动卡住几十秒」的关键。某些 Linux 环境下/dev/random熵不足,Tomcat 初始化 SecureRandom 时会阻塞,表现是日志停在某个位置不动,没有任何报错,第一次遇到会以为是磁盘或者 CPU 的问题。换成urandom之后启动时间立刻恢复正常。
CATALINA_OPTS和JAVA_OPTS的区别也值得记住:前者只在 start 时生效,后者 start 和 stop 都会带。所以堆内存、GC 参数放CATALINA_OPTS,等shutdown.sh的时候进程只需要很少的内存,没必要按-Xmx1024m去申请。
4. server.xml 配置:端口、连接器和部署方式
4.1 端口三件套:8080、8005、8009
conf/server.xml里有三个端口,功能完全不同,混起来会出大问题:
<!-- 8005 是关闭端口,shutdown.sh 靠它给进程发指令,不是业务端口 --> <Server port="8005" shutdown="SHUTDOWN"> <!-- 8080 才是业务入口,protocol 显式指定 NIO 实现 --> <Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol" connectionTimeout="20000" redirectPort="8443" /> <!-- 8009 是 AJP 端口,纯 HTTP 场景直接注释掉 --> <!-- <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> --> </Server>8005其实不算一个漏洞,因为默认只监听回环地址,但它确实会带来一个典型事故:一台机器上跑两个 Tomcat 实例,两个server.xml都写着 8005。你在实例 A 上执行shutdown.sh,结果关掉的是实例 B,实例 A 还在跑,端口还占着,然后再启动就报Address already in use。解决方式很简单,每个实例给一个独立的关闭端口,或者干脆设成port="-1"关掉这个机制,改用系统服务管理进程。
8009这个 AJP 端口如果不用,就把整行注释掉。它不仅多开一个监听,还会在日志里制造一堆无意义的探测记录。
4.2 Connector 参数:线程、队列和编码
Connector是整个server.xml里最值得花时间的一节,默认值够用,但一旦有并发就会暴露出各种表现。下面这份是我在中小型项目里常用的配置:
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol" connectionTimeout="20000" maxThreads="400" minSpareThreads="20" acceptCount="200" maxConnections="10000" URIEncoding="UTF-8" compression="on" compressionMinSize="2048" compressableMimeType="text/html,text/xml,text/plain,text/css,application/javascript,application/json" redirectPort="8443" />maxThreads是同时处理请求的线程上限,默认 200。它不等于「最大并发数」,因为 NIO 模式下连接可以挂在maxConnections上等着,真正决定吞吐的是「线程数 + 队列长度」这一组合。acceptCount是队列长度,默认 100,队列满了之后新连接会被直接拒绝,客户端看到的是连接重置而不是超时——这个区别在排查时很好用,能快速判断是线程池打满还是后端响应慢。
connectionTimeout单位是毫秒,20000 表示 20 秒。这个值调太小会在弱网环境下出现莫名其妙的连接断开,调太大则会让慢连接一直占着资源,中小项目 20 到 30 秒之间是个舒服的区间。
URIEncoding="UTF-8"这条在 8.0.x 里其实已经是默认值了,但我仍然坚持显式写出来。原因是从 Tomcat 7 甚至更早版本迁移过来的server.xml,经常原样保留着URIEncoding="ISO-8859-1",一旦抄进 8.0.47,GET 请求里的中文参数就会变成问号,而后端代码怎么改都没用——因为参数在进入应用之前就已经被解错码了。
compression相关的三项是一组:on开启压缩,compressionMinSize是触发阈值,compressableMimeType决定哪些类型参与压缩。JSON 接口多的项目把application/json加进去,响应体能小一大截;但图片、视频这类已经是压缩格式的类型不要加,压了反而更大。
4.3 部署方式:appBase 和 Context 的取舍
最简单的部署方式是把 war 包丢进webapps/,让容器自动解压:
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> </Host>unpackWARs="true"表示解压后以目录形式运行,autoDeploy="true"表示定期扫描新包。这种方式上手快,但有两个隐患:一是 war 包和同名目录同时存在时,容器到底加载哪一份取决于时间戳,表现是「明明换了包,页面还是旧的」;二是webapps/和程序本体放在一起,升级时容易误删。
所以生产环境我更倾向用外部目录加独立 Context 文件,war 包和程序目录彻底分离:
<!-- $CATALINA_BASE/conf/Catalina/localhost/demo.xml --> <!-- 文件名 demo 就是访问路径 /demo,不需要在 server.xml 里写 path --> <Context docBase="/opt/data/apps/demo" reloadable="false" />这种写法的好处是:部署新版本只要替换/opt/data/apps/demo目录,server.xml一个字都不用改,也就不用重启整个容器;reloadable="false"是必须的,打开它意味着容器会监控WEB-INF/classes和lib下的每一个文件,任何改动都触发全量重扫,生产环境上这是纯粹的性能损耗。
4.4 访问日志:加一个耗时字段
默认的AccessLogValve只在localhost_access_log里记 IP、时间、状态码,够用但不够查。我一般把耗时加上:
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" prefix="localhost_access_log" suffix=".txt" pattern="%h %l %u %t "%r" %s %b %D" />%D是处理耗时,单位毫秒。有了它,「哪个接口慢」这个问题从翻应用日志变成一条awk就能出结果,尤其是在还没接 APM 的环境里,这个字段的性价比极高。%b是响应字节数,配合%s状态码,能快速看出是返回了大对象还是频繁 500。
日志目录记得配轮转。catalina.out是不会自动切割的,一个跑几个月没重启的实例,这个文件能涨到几个 G,最后磁盘满了导致应用写日志失败,进而整个卡死。这个坑我在两台机器上踩过,现在建实例第一件事就是配 logrotate。
5. 避坑与排查:五个最容易翻车的现场
5.1 报 JAVA_HOME 未定义,但终端里明明有
现象:手工执行startup.sh一切正常,配成 systemd 服务后启动失败,catalina.out里只有一行Neither the JAVA_HOME nor the JRE_HOME environment variable is defined。
原因:/etc/profile.d/下的脚本只在交互式登录 shell 里被 source,systemd 启动的服务不经过登录流程,环境变量是空的。
解决:要么在 unit 文件里用EnvironmentFile=/etc/profile.d/tomcat.sh显式引入,要么在setenv.sh里再写一遍JAVA_HOME——setenv.sh是catalina.sh自己 source 的,任何启动方式都覆盖得到。我一般两个都做,冗余一点但省事。
5.2 UnsupportedClassVersionError:装的 JDK 比编译的低
现象:应用启动时抛java.lang.UnsupportedClassVersionError: com/example/Demo : Unsupported major.minor version 52.0,容器本身起来了,但应用加载失败。
原因:war 包里的 class 是用 JDK 8 编译的(major 52),运行环境的 JDK 是 7(最高支持 51)。容器和应用的 JDK 是同一个,所以容器能启动不代表应用能加载。
解决:先用 2.1 里那个od命令确认字节码版本,再决定是升级运行环境还是让开发方降级编译。注意-source和-target只改语法和字节码版本,如果代码里用了 JDK 8 的新 API,降级编译能过但运行时会NoSuchMethodError,这个更隐蔽。
5.3 中文乱码:改代码没用,问题在容器
现象:GET 请求里的中文参数到后端变成乱码,或者?,POST 表单反而正常。
原因:server.xml里的Connector保留了旧配置URIEncoding="ISO-8859-1"。Tomcat 8.0 默认已经是 UTF-8,但从 7 迁移过来的配置文件不会自动改,参数在进入应用之前就被解错了。
解决:把URIEncoding="UTF-8"显式写上并重启。同时检查 JVM 的-Dfile.encoding=UTF-8是否在setenv.sh里,否则日志文件里的中文一样是乱的。POST 体的编码是另一回事,需要request.setCharacterEncoding("UTF-8")或者一个 CharacterEncodingFilter,两者别混为一谈。
5.4 Address already in use,关掉的还是别人的实例
现象:启动报java.net.BindException: Address already in use,但ps看当前实例明明已经停了;或者执行shutdown.sh之后,自己的实例还在跑。
原因:多个实例共用server.xml里的 8005 关闭端口,shutdown.sh连上去发SHUTDOWN指令,被关掉的是先占住那个端口的实例。
解决:每个实例分配独立的 8005,或者设port="-1"彻底关掉关闭端口,用系统服务管理进程;同时8080也要错开。查占用用ss -lntp | grep 8080,比netstat快且信息更全。
5.5 换了 war 包,页面还是旧版本
现象:war 包已经替换,时间戳也是新的,访问还是老页面;或者删了webapps/ROOT之后首页直接 404。
原因有两个。一是work/Catalina/localhost/<应用名>/下的 JSP 编译产物没清,容器认为缓存还有效;二是webapps/下 war 包和同名目录并存,容器加载了旧目录。ROOT 那个更直接——webapps/ROOT是默认首页,删了就没有应用响应/了。
解决:停实例,删work/Catalina/localhost/<应用名>和同名旧目录,再启动。生产上我倾向于用 4.3 里的外部Context方式部署,docBase指向独立目录,替换时整目录替换,不会出现 war 和目录打架的情况。
6. 装完怎么验证:从首页到 war 包跑通的三步检查
环境装完不算完,得有一套能重复执行的验证动作,否则下次换台机器又得凭感觉。我一般分三步走,每步都有明确的判据。
第一步,确认容器自身活着。用curl看响应头和状态码,比在浏览器里刷页面可靠得多:
# -I 只取响应头,-s 静默,-m 5 五秒超时 curl -sI -m 5 http://127.0.0.1:8080/ | head -n 6期望是HTTP/1.1 200,并且有Server: Apache-Coyote/1.1这一行。如果返回 404,说明容器起来了但webapps/ROOT不完整;如果连不上,回去看catalina.out最后 50 行,八成是端口或 JDK 的问题。
第二步,确认应用真的加载成功,而不是只有容器在跑。部署完demo.war之后,访问一个确定存在的路径,同时盯日志里的SEVERE:
# 访问应用的具体路径,带一个中文参数顺便验编码 curl -s -m 5 "http://127.0.0.1:8080/demo/hello?name=%E6%B5%8B%E8%AF%95" # 启动日志里出现 SEVERE 就说明有应用级错误,Deployment of web application 才是成功标志 grep -E "SEVERE|Deployment of web application" $CATALINA_BASE/logs/catalina.out | tail -n 20这一步的关键是别只看首页。首页 200 只证明容器活着,应用可能因为缺依赖、web.xml解析失败根本没部署起来。日志里出现Deployment of web application archive ... has finished in xxx ms才算数。
第三步,把运行时信息打出来核对,避免「以为装对了」。我习惯放一个临时 JSP 或者用启动参数确认,下面这张表是我每次交付前都会过一遍的清单:
| 检查项 | 命令或位置 | 期望结果 |
|---|---|---|
| JDK 版本 | $JAVA_HOME/bin/java -version | 1.8.0_x,与 war 的字节码版本匹配 |
| 堆内存生效 | ps -ef | grep catalina看启动参数 | 含-Xmx1024m,不是默认值 |
| 关闭端口 | ss -lntp | grep 8005 | 只被当前实例占用 |
| 文件编码 | 启动日志或 JSP 打印file.encoding | UTF-8 |
| 应用部署 | catalina.out里的 Deployment 行 | 有对应应用名且无 SEVERE |
| 访问日志 | logs/localhost_access_log.*.txt | 有本次请求记录,%D字段有值 |
这套动作跑完,才算真正把 apache-tomcat-8.0.47 落地了,而不是「解压完看起来能访问首页」。血泪经验是:曾经有一次首页 200、访问日志正常,结果业务接口全部 500,原因只是lib/里被人塞了个版本冲突的 JAR——从那次之后,我每次装完都强制走一遍这三步,尤其第二步的 grep,宁可多等三十秒也不省。
希望帮到你。
本文还有配套的精品资源,点击获取