
负责7K+应用,100K+机器的Spring Boot微服务技术落地,关注开发体验,微服务,APM,应用诊断技术。Dubbo/Arthas开源。 github: https://github.com/hengyunabc
Arthas是Alibaba开源的Java诊断工具,深受开发者喜爱。Github: https://github.com/alibaba/arthas文档:https://arthas.aliyun.com/doc/Arthas里的条件表达式和结果表达式Arthas里的 watch/trace等命令支持条件表达式和结果表达式。比如下面的watch命令:watch demo.MathGame primeFactors "{params[0],target}" "params[0]<0"结果表达式是: {params[0],target},即把 参数1 和 this 对象组装为一个数组,再打印结果条件表达式是: params[0]<0 ,即当参数1小于0时,才会触发更多参考: https://arthas.aliyun.com/doc/watch使用verbose参数当我们执行完一个watch命令时,会挂起一直等待拦截到函数调用。但当我们使用了条件表达式时,面临一个困境:当一直没有打印结果时,是函数没有被调用,还是调用之后,条件表达式结果为false?这时可以使用-v参数,每次函数被调用时,都打印条件表达式的执行结果,例如:$ watch demo.MathGame primeFactors "{params[0],target}" "params[0]<0" -v Press Q or Ctrl+C to abort. Affect(class count: 1 , method count: 1) cost in 114 ms, listenerId: 1 Condition express: params[0]<0 , result: false Condition express: params[0]<0 , result: true method=demo.MathGame.primeFactors location=AtExceptionExit ts=2021-04-19 19:48:08; [cost=0.215565ms] result=@ArrayList[ @Integer[-93817], @MathGame[demo.MathGame@533ddba], ]怎样调试表达式?使用-v参数可以让我们知道条件表达式的执行结果,但对于复杂的表达式也无能为力。因此我们增加了下面的在线教程,直接调试ognl表达式。用户也可以把示例工程clone到本地来实践。https://arthas.aliyun.com/doc/arthas-tutorials.html?language=cn&id=case-ognl-practise下面我们在代码里模拟arthas watch命令执行过程。首先,把代码clone到本地:git clone https://github.com/hengyunabc/ognl-demo.git再打开src/main/java/com/example/ognl/Demo.java: /** * * <pre> * watch com.example.ognl.TestService test "{target, params}" "params[0] > 1" -b -x 3 * </pre> */ public static void atBefore(ClassLoader loader, Class<?> clazz, ArthasMethod method, Object target, Object[] params) { threadLocalWatch.start(); Advice advice = Advice.newForBefore(loader, clazz, method, target, params); Express express = ExpressFactory.threadLocalExpress(advice); String watchExpress = "{target, params}"; String conditionExpress = "params[0] > 1"; try { boolean conditionResult = express.is(conditionExpress); System.out.println( "AtEnter, conditionExpress: " + conditionExpress + ", conditionResult: " + conditionResult); if (conditionResult) { Object object = express.get(watchExpress); ObjectView objectView = new ObjectView(object, 3); String draw = objectView.draw(); System.out.println(draw); } } catch (ExpressException e) { e.printStackTrace(); } }最后,我们在命令行里执行mvn compile exec:java时,会打印出表达式执行结果:AtEnter, conditionExpress: params[0] > 1, conditionResult: true @ArrayList[ @TestService[ ], @Object[][ @Integer[1000], @String[hello], @Student[ id=@Long[1], name=@String[tom], ], ], ]类似在arthas里执行下面的watch命令:watch com.example.ognl.TestService test "{target, params, returnObj, #cost}" "params[0] > 1 && #cost > 0.1" -x 3用户可以自己修改代码里的表达式,然后多次执行调试。另外,执行下面的命令行会模拟抛出异常的情况:mvn compile exec:java -DexceptionCase=true类似在arthas里执行下面的watch命令:watch com.example.ognl.TestService test "{target, params, throwExp}" "params[0] > 1" -e -x 2题外话:为什么Arthas选择了ognl?ognl表达式基于反射,比较轻量groovy库太大,并且很容易有内存泄露问题JVM去掉了Nashorn JavaScript引擎实践下来,ognl的确比较稳定,没有出过大问题。总结watch wiki: https://arthas.aliyun.com/doc/watch.html调试ognl表达式的在线教程: https://arthas.aliyun.com/doc/arthas-tutorials.html?language=cn&id=case-ognl-practise
缘起 最近看到一个很流行的标题,《开源XX年,star XXX,我是如何坚持的》。 看到这样的标题,忽然发觉Arthas从2018年9月开源以来,刚好一年了,正好在这个秋高气爽的时节做下总结和回顾。 Arthas是Alibaba开源的Java诊断工具,深受开发者喜爱。 Github: https://github.com/alibaba/arthas 文档:https://alibaba.github.io/arthas 回顾Arthas Star数的历史,一直保持快速增长,目前已经突破160K。 感谢用户的支持,既是压力也是动力。在过去开源的一年里,Arthas发布了7个Release版本,我们一直坚持三点: 持续改进易用性 持续增加好用的命令 从开源社区中获取力量,回报社区 持续改进易用性 Arthas一直把易用性放在第一位,在开源之后,我们做了下面的改进: 开发arthas boot,支持Windows/Linux/Mac统一体验 丝滑的自动补全,参考了jshell的体验 高效的历史命令匹配,Up/Down直达 改进类搜索匹配功能,更好支持lambda和内部类 完善重定向机制 支持JDK 9/10/11 支持Docker 支持rpm/deb包安装 尽管我们在易用性下了很大的功夫,但是发现很多时候用户比较难入门,因此,我们参考了k8s的 Interactive Tutorial,推出了Arthas的在线教程: Arthas基础教程 Arthas进阶教程 通过基础教程,可以在交互终端里一步步入门,通过进阶教程可以深入理解Arthas排查问题的案例。 另外,为了方便用户大规模部署,我们实现了tunnel server和用户数据回报功能: 增加tunnel server,统一管理Agent连接 增加用户数据回报功能,方便做安全管控 持续增加好用的命令 Arthas号称是Java应用诊断利器,那么我们自己要对得起这个口号。在开源之后,Arthas持续增加了10多个命令。 ognl 命令任意代码执行 mc 线上内存编译器 redefine 命令线上热更新代码 logger 命令一键查看应用里的所有logger配置 sysprop 查看更新System Properties sysenv 查看环境变量 vmoption 查看更新VM option logger 查看logger配置,更新level mbean 查看JMX信息 heapdump 堆内存快照 下面重点介绍两个功能。 jad/mc/redefine 一条龙热更新线上代码 以 Arthas在线教程 里的UserController为例: 使用jad反编译代码 jad --source-only com.example.demo.arthas.user.UserController > /tmp/UserController.java 使用vim编译代码 当 user id 小于1时,也正常返回,不抛出异常: @GetMapping("/user/{id}") public User findUserById(@PathVariable Integer id) { logger.info("id: {}" , id); if (id != null && id < 1) { return new User(id, "name" + id); // throw new IllegalArgumentException("id < 1"); } else { return new User(id, "name" + id); } } 使用mc命令编译修改后的UserController.java $ mc /tmp/UserController.java -d /tmp Memory compiler output: /tmp/com/example/demo/arthas/user/UserController.class Affect(row-cnt:1) cost in 346 ms 使用redefine命令,因为可以热更新代码 $ redefine /tmp/com/example/demo/arthas/user/UserController.class redefine success, size: 1 通过logger命令查看配置,修改level 在网站压力大的时候(比如双11),有个缓解措施就是把应用的日志level修改为ERROR。 那么有两个问题: 复杂应用的日志系统可能会有多个,那么哪个日志系统配置真正生效了? 怎样在线上动态修改logger的level? 通过logger命令,可以查看应用里logger的详细配置信息,比如FileAppender输出的文件,AsyncAppender是否blocking。 [arthas@2062]$ logger name ROOT class ch.qos.logback.classic.Logger classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 level INFO effectiveLevel INFO additivity true codeSource file:/Users/hengyunabc/.m2/repository/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar appenders name CONSOLE class ch.qos.logback.core.ConsoleAppender classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 target System.out name APPLICATION class ch.qos.logback.core.rolling.RollingFileAppender classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 file app.log name ASYNC class ch.qos.logback.classic.AsyncAppender classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 blocking false appenderRef [APPLICATION] 也可以在线修改logger的level: [arthas@2062]$ logger --name ROOT --level debug update logger level success. 从开源社区中获取力量,回报社区 感谢67位Contributors Arthas开源以来,一共有67位 Contributors,感谢他们贡献的改进: 社区提交了一系列的改进,下面列出一些点(不完整): 翻译了大部分英文文档的 trace命令支持行号 打包格式支持rpm/deb 改进命令行提示符为 arthas@pid 改进对windows的支持 增加mbean命令 改进webconsole的体验 另外,有83个公司/组织登记了他们的使用信息,欢迎更多的用户来登记: 洐生项目 基于Arthas,还产生了一些洐生项目,下面是其中两个: Bistoury: 去哪儿网开源的集成了Arthas的项目 arthas-mvel: 一个使用MVEL脚本的fork 用户案例分享 广大用户在使用Arthas排查问题过程中,分享了很多排查过程和心得,欢迎大家来分享。 回馈开源 Arthas本身使用了很多开源项目的代码,在开源过程中,我们给netty, ognl, cfr等都贡献了改进代码,回馈上游。 后记 在做Arthas宣传小册子时,Arthas的宣传语是: “赠人玫瑰之手,经久犹有余香” 希望Arthas未来能帮助到更多的用户解决问题,也希望广大的开发者对Arthas提出更多的改进和建议。
Arthas是Alibaba开源的Java诊断工具,深受开发者喜爱。 Github: https://github.com/alibaba/arthas 文档:https://alibaba.github.io/arthas Arthas 3.1.2版本持续增加新特性,下面重点介绍: logger/heapdump/vmoption/stop命令 通过tunnel server连接不同网络的arthas,方便统一管控 易用性持续提升:提示符修改为arthas@pid形式,支持ctrl + k清屏快捷键 logger/heapdump/vmoption/stop命令 logger命令 查看logger信息,更新logger level https://alibaba.github.io/arthas/logger.html 查看所有logger信息 以下面的logback.xml为例: <?xml version="1.0" encoding="UTF-8"?> <configuration> <appender name="APPLICATION" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>mylog-%d{yyyy-MM-dd}.%i.txt</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>60</maxHistory> <totalSizeCap>2GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>%logger{35} - %msg%n</pattern> </encoder> </appender> <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="APPLICATION" /> </appender> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%-4relative [%thread] %-5level %logger{35} - %msg %n </pattern> <charset>utf8</charset> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE" /> <appender-ref ref="ASYNC" /> </root> </configuration> 使用logger命令打印的结果是: [arthas@2062]$ logger name ROOT class ch.qos.logback.classic.Logger classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 level INFO effectiveLevel INFO additivity true codeSource file:/Users/hengyunabc/.m2/repository/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar appenders name CONSOLE class ch.qos.logback.core.ConsoleAppender classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 target System.out name APPLICATION class ch.qos.logback.core.rolling.RollingFileAppender classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 file app.log name ASYNC class ch.qos.logback.classic.AsyncAppender classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 appenderRef [APPLICATION] 从appenders的信息里,可以看到 CONSOLE logger的target是System.out APPLICATION logger是RollingFileAppender,它的file是app.log ASYNC它的appenderRef是APPLICATION,即异步输出到文件里 查看指定名字的logger信息 [arthas@2062]$ logger -n org.springframework.web name org.springframework.web class ch.qos.logback.classic.Logger classLoader sun.misc.Launcher$AppClassLoader@2a139a55 classLoaderHash 2a139a55 level null effectiveLevel INFO additivity true codeSource file:/Users/hengyunabc/.m2/repository/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar 更新logger level [arthas@2062]$ logger --name ROOT --level debug update logger level success. heapdump命令 dump java heap, 类似jmap命令的heap dump功能。 https://alibaba.github.io/arthas/heapdump.html dump到指定文件 [arthas@58205]$ heapdump /tmp/dump.hprof Dumping heap to /tmp/dump.hprof... Heap dump file created 只dump live对象 [arthas@58205]$ heapdump --live /tmp/dump.hprof Dumping heap to /tmp/dump.hprof... Heap dump file created vmoption命令 查看,更新VM诊断相关的参数 https://alibaba.github.io/arthas/vmoption.html 查看所有的option [arthas@56963]$ vmoption KEY VALUE ORIGIN WRITEABLE --------------------------------------------------------------------------------------------- HeapDumpBeforeFullGC false DEFAULT true HeapDumpAfterFullGC false DEFAULT true HeapDumpOnOutOfMemory false DEFAULT true Error HeapDumpPath DEFAULT true CMSAbortablePrecleanW 100 DEFAULT true aitMillis CMSWaitDuration 2000 DEFAULT true CMSTriggerInterval -1 DEFAULT true PrintGC false DEFAULT true PrintGCDetails true MANAGEMENT true PrintGCDateStamps false DEFAULT true PrintGCTimeStamps false DEFAULT true PrintGCID false DEFAULT true PrintClassHistogramBe false DEFAULT true foreFullGC PrintClassHistogramAf false DEFAULT true terFullGC PrintClassHistogram false DEFAULT true MinHeapFreeRatio 0 DEFAULT true MaxHeapFreeRatio 100 DEFAULT true PrintConcurrentLocks false DEFAULT true 查看指定的option [arthas@56963]$ vmoption PrintGCDetails KEY VALUE ORIGIN WRITEABLE --------------------------------------------------------------------------------------------- PrintGCDetails false MANAGEMENT true 更新指定的option [arthas@56963]$ vmoption PrintGCDetails true Successfully updated the vm option. PrintGCDetails=true stop命令 之前有用户吐槽,不小心退出Arthas console之后,shutdown会关闭系统,因此增加了stop命令来退出arthas,功能和shutdown命令一致。 通过tunnel server连接不同网络的arthas https://alibaba.github.io/arthas/web-console.html 在新版本里,增加了arthas tunnel server的功能,用户可以通过tunnel server很方便连接不同网络里的arthas agent,适合做统一管控。 启动arthas时连接到tunnel server 在启动arthas,可以传递--tunnel-server参数,比如: as.sh --tunnel-server 'ws://47.75.156.201:7777/ws' 目前47.75.156.201是一个测试服务器,用户可以自己搭建arthas tunnel server 如果有特殊需求,可以通过--agent-id参数里指定agentId。默认情况下,会生成随机ID。 attach成功之后,会打印出agentId,比如: ,---. ,------. ,--------.,--. ,--. ,---. ,---. / O \ | .--. ''--. .--'| '--' | / O \ ' .-' | .-. || '--'.' | | | .--. || .-. |`. `-. | | | || |\ \ | | | | | || | | |.-' | `--' `--'`--' '--' `--' `--' `--'`--' `--'`-----' wiki https://alibaba.github.io/arthas tutorials https://alibaba.github.io/arthas/arthas-tutorials version 3.1.2 pid 86183 time 2019-08-30 15:40:53 id URJZ5L48RPBR2ALI5K4V 如果是启动时没有连接到 tunnel server,也可以在后续自动重连成功之后,通过 session命令来获取 agentId: [arthas@86183]$ session Name Value ----------------------------------------------------- JAVA_PID 86183 SESSION_ID f7273eb5-e7b0-4a00-bc5b-3fe55d741882 AGENT_ID URJZ5L48RPBR2ALI5K4V TUNNEL_SERVER ws://47.75.156.201:7777/ws 以上面的为例,在浏览器里访问 http://47.75.156.201:8080/ ,输入 agentId,就可以连接到本机上的arthas了。 Arthas tunnel server的工作原理 browser <-> arthas tunnel server <-> arthas tunnel client <-> arthas agent https://github.com/alibaba/arthas/blob/master/tunnel-server/README.md 易用性持续提升 提示符修改为arthas@pid形式,用户可以确定当前进程ID,避免多个进程时误操作 [arthas@86183]$ help 增加ctrl + k清屏快捷键 总结 总之,3.1.2版本的Arthas新增加了logger/heapdump/vmoption/stop命令,增加了tunnel server,方便统一管控。另外还有一些bug修复等,可以参考 Release Note: https://github.com/alibaba/arthas/releases/tag/3.1.2 最后,Arthas的在线教程考虑重新组织,欢迎大家参与,提出建议: https://github.com/alibaba/arthas/issues/847
Arthas是Alibaba开源的Java诊断工具,深受开发者喜爱。 从Arthas上个版本发布,已经过去两个多月了,Arthas 3.1.0版本不仅带来大家投票出来的新LOGO,还带来强大的新功能和更好的易用性,下面一一介绍。 Github: https://github.com/alibaba/arthas 文档:https://alibaba.github.io/arthas 在线教程 在新版本Arthas里,增加了在线教程,用户可以在线运行Demo,一步步学习Arthas的各种用法,推荐新手尝试: Arthas基础教程 Arthas进阶教程 非常欢迎大家来完善这些教程。 增加内存编绎器支持,在线编辑热更新代码 3.1.0版本里新增命令mc,不是方块游戏mc,而是Memory Compiler。 在之前版本里,增加了redefine命令,可以热更新字节码。但是有个不方便的地方:需要把.class文件上传到服务器上。 在3.1.0版本里,结合jad/mc/redefine 可以完美实现热更新代码。 以 Arthas在线教程 里的UserController为例: 使用jad反编绎代码 jad --source-only com.example.demo.arthas.user.UserController > /tmp/UserController.java 使用vim编绎代码 当 user id 小于1时,也正常返回,不抛出异常: @GetMapping("/user/{id}") public User findUserById(@PathVariable Integer id) { logger.info("id: {}" , id); if (id != null && id < 1) { return new User(id, "name" + id); // throw new IllegalArgumentException("id < 1"); } else { return new User(id, "name" + id); } } 使用mc命令编绎修改后的UserController.java $ mc /tmp/UserController.java -d /tmp Memory compiler output: /tmp/com/example/demo/arthas/user/UserController.class Affect(row-cnt:1) cost in 346 ms 使用redefine命令,因为可以热更新代码 $ redefine /tmp/com/example/demo/arthas/user/UserController.class redefine success, size: 1 丝滑的自动补全 在新版本里,改进了很多命令的自动补全,比如 watch/trace/tt/monitor/stack等。 下面是watch命令的第一个Tab补全结果,用户可以很方便的一步步补全类名,函数名: $ watch com. sun. javax. ch. io. demo. jdk. org. java. 另外,新增加了 jad/sc/sm/redefine 等命令的自动补全支持,多按Tab有惊喜。 新版本的Web console 新版本的Web Console切换到了xtermd.js,更好地支持现代浏览器。 支持Ctrl + C复制 支持全屏 Docker镜像支持 Arthas支持Docker镜像了 用户可以很方便地诊断Docker/k8s里的Java进程 也可以很方便地把Arthas加到自己的基础镜像里 参考: https://alibaba.github.io/arthas/docker.html 重定向重新设计 之前的版本里,Arthas的重定向是会放到一个~/logs/arthas-cache/目录里,违反直觉。 在新版本里,重定向和Linux下面的一致,>/>>的行为也和Linux下一致。 并且,增加了 cat/pwd命令,可以配置使用。 总结 总之,3.1.0版本的Arthas带了非常多的新功能,改进了很多的用户体验,欢迎大家使用反馈。 Arthas在线教程可以学到很多技巧 jad/mc/redefine 一条龙非常强大 丝滑的自动补全值得尝试 新版本的Web Console有惊奇 Release Note: https://github.com/alibaba/arthas/releases/tag/3.1.0
Arthas是Alibaba开源的Java诊断工具,深受开发者喜爱。从Arthas上个版本发布,已经过去两个多月了,Arthas 3.1.0版本不仅带来大家投票出来的新LOGO,还带来强大的新功能和更好的易用性,下面一一介绍。 Github: https://github.com/alibaba/arthas 文档:https://alibaba.github.io/arthas 在线教程 在新版本Arthas里,增加了在线教程,用户可以在线运行Demo,一步步学习Arthas的各种用法,推荐新手尝试: Arthas基础教程 Arthas进阶教程 非常欢迎大家来完善这些教程。 增加内存编译器支持,在线编辑热更新代码 3.1.0版本里新增命令mc,不是方块游戏mc,而是Memory Compiler。 在之前版本里,增加了redefine命令,可以热更新字节码。但是有个不方便的地方:需要把.class文件上传到服务器上。 在3.1.0版本里,结合jad/mc/redefine 可以完美实现热更新代码。 以 Arthas在线教程 里的UserController为例: 使用jad反编译代码 jad --source-only com.example.demo.arthas.user.UserController > /tmp/UserController.java 使用vim编译代码 当 user id 小于1时,也正常返回,不抛出异常: @GetMapping("/user/{id}") public User findUserById(@PathVariable Integer id) { logger.info("id: {}" , id); if (id != null && id < 1) { return new User(id, "name" + id); // throw new IllegalArgumentException("id < 1"); } else { return new User(id, "name" + id); } } 使用mc命令编译修改后的UserController.java $ mc /tmp/UserController.java -d /tmp Memory compiler output: /tmp/com/example/demo/arthas/user/UserController.class Affect(row-cnt:1) cost in 346 ms 使用redefine命令,因为可以热更新代码 $ redefine /tmp/com/example/demo/arthas/user/UserController.class redefine success, size: 1 丝滑的自动补全 在新版本里,改进了很多命令的自动补全,比如 watch/trace/tt/monitor/stack等。 下面是watch命令的第一个Tab补全结果,用户可以很方便的一步步补全类名,函数名: $ watch com. sun. javax. ch. io. demo. jdk. org. java. 另外,新增加了 jad/sc/sm/redefine 等命令的自动补全支持,多按Tab有惊喜。 新版本的Web console 新版本的Web Console切换到了xtermd.js,更好地支持现代浏览器。 支持Ctrl + C复制 支持全屏 Docker镜像支持 Arthas支持Docker镜像了 用户可以很方便地诊断Docker/k8s里的Java进程 也可以很方便地把Arthas加到自己的基础镜像里 参考: https://alibaba.github.io/arthas/docker.html 重定向重新设计 之前的版本里,Arthas的重定向是会放到一个~/logs/arthas-cache/目录里,违反直觉。 在新版本里,重定向和Linux下面的一致,>/>>的行为也和Linux下一致。 并且,增加了 cat/pwd命令,可以配置使用。 总结 总之,3.1.0版本的Arthas带了非常多的新功能,改进了很多的用户体验,欢迎大家使用反馈。 Arthas在线教程可以学到很多技巧 jad/mc/redefine 一条龙非常强大 丝滑的自动补全值得尝试 新版本的Web Console有惊奇 Release Note: https://github.com/alibaba/arthas/releases/tag/3.1.0
背景 Arthas 是Alibaba开源的Java诊断工具,深受开发者喜爱。 https://github.com/alibaba/arthas Arthas提供了非常丰富的关于调用拦截的命令,比如 trace/watch/monitor/tt 。但是很多时候我们在排查问题时,需要更多的线索,并不只是函数的参数和返回值。比如在一个spring应用里,想获取到spring context里的其它bean。如果能随意获取到spring bean,那就可以“为所欲为”了。 下面介绍如何利用Arthas获取到spring context。 Demo: https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-arthas-spring-boot Arthas快速开始:https://alibaba.github.io/arthas/quick-start.html 使用tt命令获取到spring context Demo是一个spring mvc应用,请求会经过一系列的spring bean处理,那么我们可以在spring mvc的类里拦截到一些请求。 启动Demo: mvn spring-boot:run 使用Arthas Attach成功之后,执行tt命令来记录RequestMappingHandlerAdapter#invokeHandlerMethod的请求 tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod 然后访问一个网页: http://localhost:8080/ 可以看到Arthas会拦截到这个调用,index是1000,并且打印出: $ watch com.example.demo.Test * 'params[0]@sss' $ tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod Press Ctrl+C to abort. Affect(class-cnt:1 , method-cnt:1) cost in 101 ms. INDEX TIMESTAMP COST(ms IS-RE IS-EX OBJECT CLASS METHOD ) T P ------------------------------------------------------------------------------------------------------------------ 1000 2019-01-27 16:31 3.66744 true false 0x4465cf70 RequestMappingHandlerAda invokeHandlerMethod :54 pter 那么怎样获取到spring context? 可以用tt命令的-i参数来指定index,并且用-w参数来执行ognl表达式来获取spring context: $ tt -i 1000 -w 'target.getApplicationContext()' @AnnotationConfigEmbeddedWebApplicationContext[ reader=@AnnotatedBeanDefinitionReader[org.springframework.context.annotation.AnnotatedBeanDefinitionReader@35dc90ec], scanner=@ClassPathBeanDefinitionScanner[org.springframework.context.annotation.ClassPathBeanDefinitionScanner@72078a14], annotatedClasses=null, basePackages=null, ] Affect(row-cnt:1) cost in 7 ms. 从spring context里获取任意bean 获取到spring context之后,就可以获取到任意的bean了,比如获取到helloWorldService,并调用getHelloMessage()函数: $ tt -i 1000 -w 'target.getApplicationContext().getBean("helloWorldService").getHelloMessage()' @String[Hello World] Affect(row-cnt:1) cost in 5 ms. 更多的思路 在很多代码里都有static函数或者static holder类,顺滕摸瓜,可以获取很多其它的对象。比如在Dubbo里通过SpringExtensionFactory获取spring context: $ ognl '#context=@com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory@contexts.iterator.next, #context.getBean("userServiceImpl").findUser(1)' @User[ id=@Integer[1], name=@String[Deanna Borer], ] 链接 Arthas: https://github.com/alibaba/arthas https://alibaba.github.io/arthas/tt.html https://alibaba.github.io/arthas/ognl.html
背景 在Java Web/Spring Boot开发时,很常见的问题是: 网页访问404了,为什么访问不到? 登陆失败了,请求返回401,到底是哪个Filter拦截了我的请求? 碰到这种问题时,通常很头痛,特别是在线上环境时。 本文介绍使用Alibaba开源的Java诊断利器Arthas,来快速定位这类Web请求404/401问题。 https://github.com/alibaba/arthas Java Web里一个请求被处理的流程 在进入正题之前,先温习下知识。一个普通的Java Web请求处理流程大概是这样子的: Request -> Filter1 -> Filter2 ... -> Servlet | Response <- Filter1 <- Filter2 ... <- Servlet Demo 本文的介绍基于一个很简单的Demo:https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-404-401 访问 http://localhost:8080/ ,返回200,正常打印Welconme信息 访问 http://localhost:8080/a.txt ,返回404 访问 http://localhost:8080/admin ,返回401 是哪个Servlet返回了404? Demo启动后,访问:http://localhost:8080/a.txt ,返回404: $ curl http://localhost:8080/a.txt {"timestamp":1546790485831,"status":404,"error":"Not Found","message":"No message available","path":"/a.txt"} 我们知道一个HTTP Request,大部分情况下都是由一个Servlet处理的,那么到底是哪个Servlet返回了404? 我们使用Arthas的trace命令来定位: $ trace javax.servlet.Servlet * Press Ctrl+C to abort. Affect(class-cnt:7 , method-cnt:185) cost in 1018 ms. 然后访问 http://localhost:8080/a.txt ,Arthas会打印出整个请求树,完整的输出太长,这里只截取关键的一输出: +---[13.087083ms] org.springframework.web.servlet.DispatcherServlet:resolveViewName() | `---[13.020984ms] org.springframework.web.servlet.DispatcherServlet:resolveViewName() | +---[0.002777ms] java.util.List:iterator() | +---[0.001955ms] java.util.Iterator:hasNext() | +---[0.001739ms] java.util.Iterator:next() | `---[12.891979ms] org.springframework.web.servlet.ViewResolver:resolveViewName() | +---[0.089811ms] javax.servlet.GenericServlet:<init>() | +---[min=0.037696ms,max=0.054478ms,total=0.092174ms,count=2] org.springframework.web.servlet.view.freemarker.FreeMarkerView$GenericServletAdapter:<init>() 可以看出请求经过Spring MVC的DispatcherServlet处理,最终由ViewResolver分派给FreeMarkerView$GenericServletAdapter处理。所以我们可以知道这个请求最终是被FreeMarker处理的。后面再排查FreeMarker的配置就可以了。 这个神奇的trace javax.servlet.Servlet *到底是怎样工作的呢? 实际上Arthas会匹配到JVM里所有实现了javax.servlet.Servlet的类,然后trace它们的所有函数,所以HTTP请求会被打印出来。 这里留一个小问题,为什么只访问了http://localhost:8080/a.txt,但Arthas的trace命令打印出了两个请求树? 是哪个Filter返回了401? 在Demo里,访问 http://localhost:8080/admin ,会返回401,即没有权限。那么是哪个Filter拦截了请求? $ curl http://localhost:8080/admin {"timestamp":1546794743674,"status":401,"error":"Unauthorized","message":"admin filter error.","path":"/admin"} 我们还是使用Arthas的trace命令来定位,不过这次trace的是javax.servlet.Filter: $ trace javax.servlet.Filter * Press Ctrl+C to abort. Affect(class-cnt:13 , method-cnt:75) cost in 278 ms. 再次访问admin,在Arthas里,把整个请求经过哪些Filter处理,都打印为树。这里截取关键部分: +---[0.704625ms] org.springframework.web.filter.OncePerRequestFilter:doFilterInternal() | `---[0.60387ms] org.springframework.web.filter.RequestContextFilter:doFilterInternal() | +---[0.022704ms] org.springframework.web.context.request.ServletRequestAttributes:<init>() | +---[0.217636ms] org.springframework.web.filter.RequestContextFilter:initContextHolders() | | `---[0.180323ms] org.springframework.web.filter.RequestContextFilter:initContextHolders() | | +---[0.034656ms] javax.servlet.http.HttpServletRequest:getLocale() | | +---[0.0311ms] org.springframework.context.i18n.LocaleContextHolder:setLocale() | | +---[0.008691ms] org.springframework.web.context.request.RequestContextHolder:setRequestAttributes() | | `---[0.014918ms] org.apache.commons.logging.Log:isDebugEnabled() | +---[0.215481ms] javax.servlet.FilterChain:doFilter() | | `---[0.072186ms] com.example.demo404401.AdminFilterConfig$AdminFilter:doFilter() | | `---[0.021945ms] javax.servlet.http.HttpServletResponse:sendError() 可以看到HTTP Request最终是被com.example.demo404401.AdminFilterConfig$AdminFilter处理的。 总结 通过trace Servlet/Filter,可以快速定位Java Web问题 trace是了解应用执行流程的利器,只要trace到关键的接口或者类上 仔细观察trace的结果,可以学习到Spring MVC是处理Web请求细节 链接 https://github.com/alibaba/arthas https://alibaba.github.io/arthas/trace.html
前言 对于一个简单的Spring boot应用,它的spring context是只会有一个。 非web spring boot应用,context是AnnotationConfigApplicationContext web spring boot应用,context是AnnotationConfigEmbeddedWebApplicationContext AnnotationConfigEmbeddedWebApplicationContext是spring boot里自己实现的一个context,主要功能是启动embedded servlet container,比如tomcat/jetty。 这个和传统的war包应用不一样,传统的war包应用有两个spring context。参考:http://hengyunabc.github.io/something-about-spring-mvc-webapplicationcontext/ 但是对于一个复杂点的spring boot应用,它的spring context可能会是多个,下面分析下各种情况。 Demo 这个Demo展示不同情况下的spring boot context的继承情况。 https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-classloader-context 配置spring boot actuator/endpoints独立端口时 spring boot actuator默认情况下和应用共用一个tomcat,这样子的话就会直接把应用的endpoints暴露出去,带来很大的安全隐患。 尽管 Spring boot后面默认把这个关掉,需要配置management.security.enabled=false才可以访问,但是这个还是太危险了。 所以通常都建议把endpoints开在另外一个独立的端口上,比如 management.port=8081。 可以增加-Dspring.cloud.bootstrap.enabled=false,来禁止spring cloud,然后启动Demo。比如 mvn spring-boot:run -Dspring.cloud.bootstrap.enabled=false 然后打开 http://localhost:8080/ 可以看到应用的spring context继承结构。 打开 http://localhost:8081/contexttree 可以看到Management Spring Contex的继承结构。 可以看到当配置management独立端口时,management context的parent是应用的spring context 相关的实现代码在 org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration 里 在sprig cloud环境下spring context的情况 在有spring cloud时(通常是引入 spring-cloud-starter),因为spring cloud有自己的一套配置初始化机制,所以它实际上是自己启动了一个Spring context,并把自己置为应用的context的parent。 spring cloud context的启动代码在org.springframework.cloud.bootstrap.BootstrapApplicationListener里。 spring cloud context实际上是一个特殊的spring boot context,它只扫描BootstrapConfiguration。 ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); // Use names and ensure unique to protect against duplicates List<String> names = SpringFactoriesLoader .loadFactoryNames(BootstrapConfiguration.class, classLoader); for (String name : StringUtils.commaDelimitedListToStringArray( environment.getProperty("spring.cloud.bootstrap.sources", ""))) { names.add(name); } // TODO: is it possible or sensible to share a ResourceLoader? SpringApplicationBuilder builder = new SpringApplicationBuilder() .profiles(environment.getActiveProfiles()).bannerMode(Mode.OFF) .environment(bootstrapEnvironment) .properties("spring.application.name:" + configName) .registerShutdownHook(false).logStartupInfo(false).web(false); List<Class<?>> sources = new ArrayList<>(); 最终会把这个ParentContextApplicationContextInitializer加到应用的spring context里,来把自己设置为应用的context的parent。 public class ParentContextApplicationContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered { private int order = Ordered.HIGHEST_PRECEDENCE; private final ApplicationContext parent; @Override public void initialize(ConfigurableApplicationContext applicationContext) { if (applicationContext != this.parent) { applicationContext.setParent(this.parent); applicationContext.addApplicationListener(EventPublisher.INSTANCE); } } 和上面一样,直接启动demo,不要配置-Dspring.cloud.bootstrap.enabled=false,然后访问对应的url,就可以看到spring context的继承情况。 如何在应用代码里获取到 Management Spring Context 如果应用代码想获取到Management Spring Context,可以通过这个bean:org.springframework.boot.actuate.autoconfigure.ManagementContextResolver spring boot在创建Management Spring Context时,就会保存到ManagementContextResolver里。 @Configuration @ConditionalOnClass({ Servlet.class, DispatcherServlet.class }) @ConditionalOnWebApplication @AutoConfigureAfter({ PropertyPlaceholderAutoConfiguration.class, EmbeddedServletContainerAutoConfiguration.class, WebMvcAutoConfiguration.class, ManagementServerPropertiesAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class, HypermediaAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class }) public class EndpointWebMvcAutoConfiguration implements ApplicationContextAware, BeanFactoryAware, SmartInitializingSingleton { @Bean public ManagementContextResolver managementContextResolver() { return new ManagementContextResolver(this.applicationContext); } @Bean public ManagementServletContext managementServletContext( final ManagementServerProperties properties) { return new ManagementServletContext() { @Override public String getContextPath() { return properties.getContextPath(); } }; } 如何在Endpoints代码里获取应用的Spring context spring boot本身没有提供方法,应用可以自己写一个@Configuration,保存应用的Spring context,然后在endpoints代码里再取出来。 ApplicationContext.setParent(ApplicationContext) 到底发生了什么 从spring的代码就可以看出来,主要是把parent的environment里的propertySources加到child里。这也就是spring cloud config可以生效的原因。 // org.springframework.context.support.AbstractApplicationContext.setParent(ApplicationContext) /** * Set the parent of this application context. * <p>The parent {@linkplain ApplicationContext#getEnvironment() environment} is * {@linkplain ConfigurableEnvironment#merge(ConfigurableEnvironment) merged} with * this (child) application context environment if the parent is non-{@code null} and * its environment is an instance of {@link ConfigurableEnvironment}. * @see ConfigurableEnvironment#merge(ConfigurableEnvironment) */ @Override public void setParent(ApplicationContext parent) { this.parent = parent; if (parent != null) { Environment parentEnvironment = parent.getEnvironment(); if (parentEnvironment instanceof ConfigurableEnvironment) { getEnvironment().merge((ConfigurableEnvironment) parentEnvironment); } } } // org.springframework.core.env.AbstractEnvironment.merge(ConfigurableEnvironment) @Override public void merge(ConfigurableEnvironment parent) { for (PropertySource<?> ps : parent.getPropertySources()) { if (!this.propertySources.contains(ps.getName())) { this.propertySources.addLast(ps); } } String[] parentActiveProfiles = parent.getActiveProfiles(); if (!ObjectUtils.isEmpty(parentActiveProfiles)) { synchronized (this.activeProfiles) { for (String profile : parentActiveProfiles) { this.activeProfiles.add(profile); } } } String[] parentDefaultProfiles = parent.getDefaultProfiles(); if (!ObjectUtils.isEmpty(parentDefaultProfiles)) { synchronized (this.defaultProfiles) { this.defaultProfiles.remove(RESERVED_DEFAULT_PROFILE_NAME); for (String profile : parentDefaultProfiles) { this.defaultProfiles.add(profile); } } } } 怎样在Spring Event里正确判断事件来源 默认情况下,Spring Child Context会收到Parent Context的Event。如果Bean依赖某个Event来做初始化,那么就要判断好Event是否Bean所在的Context发出的,否则有可能提前或者多次初始化。 正确的做法是实现ApplicationContextAware接口,先把context保存起来,在Event里判断相等时才处理。 public class MyBean implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware { private ApplicationContext context; @Override public void onApplicationEvent(ContextRefreshedEvent event) { if (event.getApplicationContext().equals(context)) { // do something } } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.context = applicationContext; } } 总结 当配置management.port 为独立端口时,Management Spring Context也会是独立的context,它的parent是应用的spring context 当启动spring cloud时,spring cloud自己会创建出一个spring context,并置为应用的context的parent ApplicationContext.setParent(ApplicationContext) 主要是把parent的environment里的propertySources加到child里 正确处理Spring Event,判断属于自己的Context和Event 理解的spring boot context的继承关系,能避免一些微妙的spring bean注入的问题,还有不当的spring context的问题
Apache Dubbo是Alibaba开源的高性能RPC框架,在国内有非常多的用户。 Github: https://github.com/apache/incubator-dubbo 文档:http://dubbo.incubator.apache.org/zh-cn/ Arthas是Alibaba开源的应用诊断利器,9月份开源以来,Github Star数三个月超过6000。 Github: https://github.com/alibaba/arthas 文档:https://alibaba.github.io/arthas/ Arthas开源交流QQ群: 916328269 Arthas开源交流钉钉群: 21965291 当Dubbo遇上Arthas,会碰撞出什么样的火花呢?下面来分享Arthas排查Dubbo问题的一些经验。 dubbo-arthas-demo 下面的排查分享基于这个dubbo-arthas-demo,非常简单的一个应用,浏览器请求从Spring MVC到Dubbo Client,再发送到Dubbo Server。 Demo里有两个spring boot应用,可以先启动server-demo,再启动client-demo。 https://github.com/hengyunabc/dubbo-arthas-demo /user/{id} -> UserService -> UserServiceImpl Browser Dubbo Client Dubbo Server Client端: @RestController public class UserController { @Reference(version = "1.0.0") private UserService userService; @GetMapping("/user/{id}") public User findUserById(@PathVariable Integer id) { return userService.findUser(id); } Server端: @Service(version = "1.0.0") public class UserServiceImpl implements UserService { @Override public User findUser(int id) { if (id < 1) { throw new IllegalArgumentException("user id < 1, id: " + id); } for (User user : users) { if (user.getId() == id) { return user; } } throw new RuntimeException("Can not find user, id: " + id); } Arthas快速开始 https://alibaba.github.io/arthas/install-detail.html $ wget https://alibaba.github.io/arthas/arthas-boot.jar $ java -jar arthas-boot.jar 启动后,会列出所有的java进程,选择1,然后回车,就会连接上ServerDemoApplication $ java -jar arthas-boot.jar * [1]: 43523 ServerDemoApplication [2]: 22342 [3]: 44108 ClientDemoApplication 1 [INFO] arthas home: /Users/hengyunabc/.arthas/lib/3.0.5/arthas [INFO] Try to attach process 43523 [INFO] Attach process 43523 success. [INFO] arthas-client connect 127.0.0.1 3658 ,---. ,------. ,--------.,--. ,--. ,---. ,---. / O \ | .--. ''--. .--'| '--' | / O \ ' .-' | .-. || '--'.' | | | .--. || .-. |`. `-. | | | || |\ \ | | | | | || | | |.-' | `--' `--'`--' '--' `--' `--' `--'`--' `--'`-----' wiki: https://alibaba.github.io/arthas version: 3.0.5 pid: 43523 time: 2018-12-05 16:23:52 $ Dubbo线上服务抛出异常,怎么获取调用参数? https://alibaba.github.io/arthas/watch.html 当线上服务抛出异常时,最着急的是什么参数导致了抛异常? 在demo里,访问 http://localhost:8080/user/0 ,UserServiceImpl就会抛出一个异常,因为user id不合法。 在Arthas里执行 watch com.example.UserService * -e -x 2 '{params,throwExp}' ,然后再次访问,就可以看到watch命令把参数和异常都打印出来了。 $ watch com.example.UserService * -e -x 2 '{params,throwExp}' Press Ctrl+C to abort. Affect(class-cnt:1 , method-cnt:4) cost in 230 ms. ts=2018-12-05 16:26:44; [cost=3.905523ms] result=@ArrayList[ @Object[][ @Integer[0], ], java.lang.IllegalArgumentException: user id < 1, id: 0 at com.example.UserServiceImpl.findUser(UserServiceImpl.java:24) at com.alibaba.dubbo.common.bytecode.Wrapper1.invokeMethod(Wrapper1.java) at com.alibaba.dubbo.rpc.proxy.javassist.JavassistProxyFactory$1.doInvoke(JavassistProxyFactory.java:45) at com.alibaba.dubbo.rpc.proxy.AbstractProxyInvoker.invoke(AbstractProxyInvoker.java:71) at com.alibaba.dubbo.config.invoker.DelegateProviderMetaDataInvoker.invoke(DelegateProviderMetaDataInvoker.java:48) at com.alibaba.dubbo.rpc.protocol.InvokerWrapper.invoke(InvokerWrapper.java:52) at com.alibaba.dubbo.rpc.filter.ExceptionFilter.invoke(ExceptionFilter.java:61) 怎样线上调试Dubbo服务代码? https://alibaba.github.io/arthas/redefine.html 在本地开发时,可能会用到热部署工具,直接改代码,不需要重启应用。但是在线上环境,有没有办法直接动态调试代码?比如增加日志。 在Arthas里,可以通过redefine命令来达到线上不重启,动态更新代码的效果。 比如我们修改下UserServiceImpl,用System.out打印出具体的User对象来: public User findUser(int id) { if (id < 1) { throw new IllegalArgumentException("user id < 1, id: " + id); } for (User user : users) { if (user.getId() == id) { System.out.println(user); return user; } } throw new RuntimeException("Can not find user, id: " + id); } 本地编绎后,把server-demo/target/classes/com/example/UserServiceImpl.class传到线上服务器,然后用redefine命令来更新代码: $ redefine -p /tmp/UserServiceImpl.class redefine success, size: 1 这样子更新成功之后,访问 http://localhost:8080/user/1 ,在ServerDemoApplication的控制台里就可以看到打印出了user信息。 怎样动态修改Dubbo的logger级别? https://alibaba.github.io/arthas/ognl.html https://alibaba.github.io/arthas/sc.html https://commons.apache.org/proper/commons-ognl/language-guide.html 在排查问题时,需要查看到更多的信息,如果可以把logger级别修改为DEBUG,就非常有帮助。 ognl是apache开源的一个轻量级表达式引擎。下面通过Arthas里的ognl命令来动态修改logger级别。 首先获取Dubbo里TraceFilter的一个logger对象,看下它的实现类,可以发现是log4j。 $ ognl '@com.alibaba.dubbo.rpc.protocol.dubbo.filter.TraceFilter@logger.logger' @Log4jLogger[ FQCN=@String[com.alibaba.dubbo.common.logger.support.FailsafeLogger], logger=@Logger[org.apache.log4j.Logger@2f19bdcf], ] 再用sc命令来查看具体从哪个jar包里加载的: $ sc -d org.apache.log4j.Logger class-info org.apache.log4j.Logger code-source /Users/hengyunabc/.m2/repository/org/slf4j/log4j-over-slf4j/1.7.25/log4j-over-slf4j-1.7.25.jar name org.apache.log4j.Logger isInterface false isAnnotation false isEnum false isAnonymousClass false isArray false isLocalClass false isMemberClass false isPrimitive false isSynthetic false simple-name Logger modifier public annotation interfaces super-class +-org.apache.log4j.Category +-java.lang.Object class-loader +-sun.misc.Launcher$AppClassLoader@5c647e05 +-sun.misc.Launcher$ExtClassLoader@59878d35 classLoaderHash 5c647e05 Affect(row-cnt:1) cost in 126 ms. 可以看到log4j是通过slf4j代理的。 那么通过org.slf4j.LoggerFactory获取root logger,再修改它的level: $ ognl '@org.slf4j.LoggerFactory@getLogger("root").setLevel(@ch.qos.logback.classic.Level@DEBUG)' null $ ognl '@org.slf4j.LoggerFactory@getLogger("root").getLevel().toString()' @String[DEBUG] 可以看到修改之后,root logger的level变为DEBUG。 怎样减少测试小姐姐重复发请求的麻烦? https://alibaba.github.io/arthas/tt.html 在平时开发时,可能需要测试小姐姐发请求过来联调,但是我们在debug时,可能不小心直接跳过去了。这样子就尴尬了,需要测试小姐姐再发请求过来。 Arthas里提供了tt命令,可以减少这种麻烦,可以直接重放请求。 $ tt -t com.example.UserServiceImpl findUser Press Ctrl+C to abort. Affect(class-cnt:1 , method-cnt:1) cost in 145 ms. INDEX TIMESTAMP COST(ms) IS-RET IS-EXP OBJECT CLASS METHOD ---------------------------------------------------------------------------------------------------------------- 1000 2018-12-05 17:47:52 1.56523 true false 0x3233483 UserServiceImpl findUser 1001 2018-12-05 17:48:03 0.286176 false true 0x3233483 UserServiceImpl findUser 1002 2018-12-05 17:48:11 90.324335 true false 0x3233483 UserServiceImpl findUser 上面的tt -t命令捕获到了3个请求。然后通过tt --play可以重放请求: $ tt --play -i 1000 RE-INDEX 1000 GMT-REPLAY 2018-12-05 17:55:50 OBJECT 0x3233483 CLASS com.example.UserServiceImpl METHOD findUser PARAMETERS[0] @Integer[1] IS-RETURN true IS-EXCEPTION false RETURN-OBJ @User[ id=@Integer[1], name=@String[Deanna Borer], ] Time fragment[1000] successfully replayed. Affect(row-cnt:1) cost in 4 ms. Dubbo运行时有哪些Filter? 耗时是多少? https://alibaba.github.io/arthas/trace.html Dubbo运行时会加载很多的Filter,那么一个请求会经过哪些Filter处理,Filter里的耗时又是多少呢? 通过Arthas的trace命令,可以很方便地知道Filter的信息,可以看到详细的调用栈和耗时。 $ trace com.alibaba.dubbo.rpc.Filter * Press Ctrl+C to abort. Affect(class-cnt:19 , method-cnt:59) cost in 1441 ms. `---ts=2018-12-05 19:07:26;thread_name=DubboServerHandler-30.5.125.152:20880-thread-10;id=3e;is_daemon=true;priority=5;TCCL=sun.misc.Launcher$AppClassLoader@5c647e05 `---[8.435844ms] com.alibaba.dubbo.rpc.filter.EchoFilter:invoke() +---[0.124572ms] com.alibaba.dubbo.rpc.Invocation:getMethodName() +---[0.065123ms] java.lang.String:equals() `---[7.762928ms] com.alibaba.dubbo.rpc.Invoker:invoke() `---[7.494124ms] com.alibaba.dubbo.rpc.filter.ClassLoaderFilter:invoke() +---[min=0.00355ms,max=0.049922ms,total=0.057637ms,count=3] java.lang.Thread:currentThread() +---[0.0126ms] java.lang.Thread:getContextClassLoader() +---[0.02188ms] com.alibaba.dubbo.rpc.Invoker:getInterface() +---[0.004115ms] java.lang.Class:getClassLoader() +---[min=0.003906ms,max=0.014058ms,total=0.017964ms,count=2] java.lang.Thread:setContextClassLoader() `---[7.033486ms] com.alibaba.dubbo.rpc.Invoker:invoke() `---[6.869488ms] com.alibaba.dubbo.rpc.filter.GenericFilter:invoke() +---[0.01481ms] com.alibaba.dubbo.rpc.Invocation:getMethodName() Dubbo动态代理是怎样实现的? https://alibaba.github.io/arthas/jad.html com.alibaba.dubbo.common.bytecode.Wrapper 通过Arthas的jad命令,可以看到Dubbo通过javaassist动态生成的Wrappr类的代码: $ jad com.alibaba.dubbo.common.bytecode.Wrapper1 ClassLoader: +-sun.misc.Launcher$AppClassLoader@5c647e05 +-sun.misc.Launcher$ExtClassLoader@59878d35 Location: /Users/hengyunabc/.m2/repository/com/alibaba/dubbo/2.5.10/dubbo-2.5.10.jar package com.alibaba.dubbo.common.bytecode; public class Wrapper1 extends Wrapper implements ClassGenerator.DC { public Object invokeMethod(Object object, String string, Class[] arrclass, Object[] arrobject) throws InvocationTargetException { UserServiceImpl userServiceImpl; try { userServiceImpl = (UserServiceImpl)object; } catch (Throwable throwable) { throw new IllegalArgumentException(throwable); } try { if ("findUser".equals(string) && arrclass.length == 1) { return userServiceImpl.findUser(((Number)arrobject[0]).intValue()); } if ("listUsers".equals(string) && arrclass.length == 0) { return userServiceImpl.listUsers(); } if ("findUserByName".equals(string) && arrclass.length == 1) { return userServiceImpl.findUserByName((String)arrobject[0]); } } 获取Spring context 除了上面介绍的一些排查技巧,下面分享一个获取Spring Context,然后“为所欲为”的例子。 在Dubbo里有一个扩展com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory,把Spring Context保存到了里面。因此,我们可以通过ognl命令获取到。 $ ognl '#context=@com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory@contexts.iterator.next, #context.getBean("userServiceImpl").findUser(1)' @User[ id=@Integer[1], name=@String[Deanna Borer], ] SpringExtensionFactory@contexts.iterator.next 获取到SpringExtensionFactory里保存的spring context对象 #context.getBean("userServiceImpl").findUser(1) 获取到userServiceImpl再执行一次调用 只要充分发挥想像力,组合Arthas里的各种命令,可以发挥出神奇的效果。 总结 本篇文章来自杭州Dubbo Meetup的分享《当DUBBO遇上Arthas - 排查问题的实践》,希望对大家线上排查Dubbo问题有帮助。
Arthas从9月份开源以来,受到广大Java开发者的支持,Github Star数三个月超过6000,非常感谢用户支持。同时用户给Arthas提出了很多建议,其中反映最多的是: Windows平台用户体验不好 Attach的进程和最终连接的进程不一致 某些环境下没有安装Telnet,不能连接到Arthas Server 本地启动,不需要下载远程(很多公司安全考虑) 下载速度慢(默认从maven central repository下载) 在Arthas 3.0.5版本里,我们在用户体验方面做了很多改进,下面逐一介绍。 Github: https://github.com/alibaba/arthas 文档:https://alibaba.github.io/arthas/ 全平台通用的arthas-boot arthas-boot是新增加的支持全平台的启动器,Windows/Mac/Linux下使用体验一致。下载后,直接java -jar命令启动: wget https://alibaba.github.io/arthas/arthas-boot.jar java -jar arthas-boot.jar arthas-boot的功能比以前的as.sh更强大。 比如下载速度比较慢,可以指定阿里云的镜像。 java -jar arthas-boot.jar --repo-mirror aliyun --use-http 比如可以通过session-timeout指定超时时间: java -jar arthas-boot.jar --session-timeout 3600 更多的功能可以通过java -jar arthas-boot.jar -h查看 arthas-boot在attach成功之后,会启动一个java telent client去连接Arthas Server,用户没有安装telnet的情况下也可以正常使用。 优先使用当前目录的Arthas 在新版本里,默认会从arthas-boot.jar和as.sh所在的目录下查找arthas home,这样子用户全量安装之后,不需要再从远程下载Arthas。 用户可以更方便地集成到自己的基础镜像,或者docker镜像里 对安全要求严格的公司,不用再担心从远程下载的问题 Attach之前先检测端口 在之前的版本里,用户困扰最多的是,明明选择了进程A,但是实际连接到的却是进程B。 原因是之前attach了进程B,没有执行shutdown,下次再执行时,还是连接到进程B。 在新版本里,做了改进: 在attach之前,检测使用3658端口的进程 在Attach时,如果端口和进程不匹配,会打印出ERROR信息 $ java -jar arthas-boot.jar [INFO] Process 1680 already using port 3658 [INFO] Process 1680 already using port 8563 * [1]: 1680 Demo [2]: 35542 [3]: 82334 Demo 3 [ERROR] Target process 82334 is not the process using port 3658, you will connect to an unexpected process. [ERROR] If you still want to attach target process 82334, Try to set a different telnet port by using --telnet-port argument. [ERROR] Or try to shutdown the process 1680 using the telnet port first. 更好的历史命令匹配功能 新版本对键盘Up/Down有了更好的匹配机制,试用有惊喜:) 比如执行了多次trace,但是在命令行输入 trace后,想不起来之前trace的具体类名,那么按`Up`,可以很轻松地匹配到之前的历史命令,不需要辛苦翻页。 新版本增加了history命令 改进Web Console的体验 改进对Windows的字体支持 之前Windows下面使用的是非等宽字体,看起来很难受。新版本里统一为等宽字体。 增大字体,不再伤害眼睛 新增sysenv命令 sysenv命令和sysprop类似,可以打印JVM的环境变量。 https://alibaba.github.io/arthas/sysenv.html 新增ognl命令 ognl命令提供了单独执行ognl脚本的功能。可以很方便调用各种代码。 比如执行多行表达式,赋值给临时变量,返回一个List: $ ognl '#value1=@System@getProperty("java.home"), #value2=@System@getProperty("java.runtime.name"), {#value1, #value2}' @ArrayList[ @String[/opt/java/8.0.181-zulu/jre], @String[OpenJDK Runtime Environment], ] https://alibaba.github.io/arthas/ognl.html watch命令打印耗时,更方便定位性能瓶颈 之前watch命令只支持打印入参返回值等,新版本同时打印出调用耗时,可以很方便定位性能瓶颈。 $ watch demo.MathGame primeFactors 'params[0]' Press Ctrl+C to abort. Affect(class-cnt:1 , method-cnt:1) cost in 22 ms. ts=2018-11-29 17:53:54; [cost=0.131383ms] result=@Integer[-387929024] ts=2018-11-29 17:53:55; [cost=0.132368ms] result=@Integer[-1318275764] ts=2018-11-29 17:53:56; [cost=0.496598ms] result=@Integer[76446257] ts=2018-11-29 17:53:57; [cost=4.9617ms] result=@Integer[1853966253] https://alibaba.github.io/arthas/watch.html 改进类搜索匹配功能,更好支持lambda和内部类 之前的版本里,在搜索lambda类时,或者反编绎lambda类有可能会失败。新版本做了修复。比如 $ jad Test $$ Lambda$1/1406718218 ClassLoader: +-sun.misc.Launcher$AppClassLoader@5c647e05 +-sun.misc.Launcher$ExtClassLoader@3c1491ce Location: /tmp/classes /* * Decompiled with CFR 0_132. * * Could not load the following classes: * Test * Test $$ Lambda$1 */ import java.lang.invoke.LambdaForm; import java.util.function.Consumer; final class Test $$ Lambda$1 implements Consumer { private Test $$ Lambda$1() { } @LambdaForm.Hidden public void accept(Object object) { Test.lambda$0((Integer)((Integer)object)); } } 更好的tab自动补全 改进了很多命令的tab自动补全功能,有停顿时,可以多按tab尝试下。 Release Note 详细的Release Note:https://github.com/alibaba/arthas/releases/tag/arthas-all-3.0.5
背景 随着应用越来越复杂,依赖越来越多,日志系统越来越混乱,有时会出现一些奇怪的日志,比如: [] [] [] No credential found 那么怎样排查这些奇怪的日志从哪里打印出来的呢?因为搞不清楚是什么logger打印出来的,所以想定位就比较头疼。 下面介绍用arthas的redefine命令快速定位奇怪日志来源。 Arthas: https://github.com/alibaba/arthas redefine命令:https://alibaba.github.io/arthas/redefine.html 修改StringBuilder 首先在java代码里,字符串拼接基本都是通过StringBuilder来实现的。比如下面的代码: public static String hello(String world) { return "hello " + world; } 实际上生成的字节码也是用StringBuilder来拼接的: public static java.lang.String hello(java.lang.String); descriptor: (Ljava/lang/String;)Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=1, args_size=1 0: new #22 // class java/lang/StringBuilder 3: dup 4: ldc #24 // String hello 6: invokespecial #26 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V 9: aload_0 10: invokevirtual #29 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 13: invokevirtual #33 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 16: areturn 在java的logger系统里,输出日志时通常也是StringBuilder来实现的,最终会调用StringBuilder.toString(),那么我们可以修改StringBuilder的代码来检测到日志来源。 StringBuilder.toString() 的原生实现是: @Override public String toString() { // Create a copy, don't share the array return new String(value, 0, count); } 修改为: @Override public String toString() { // Create a copy, don't share the array String result = new String(value, 0, count); if(result.contains("No credential found")) { System.err.println(result); new Throwable().printStackTrace(); } return result; } 增加的逻辑是:当String里包含No credential found时打印出当前栈,这样子就可以定位日志输出来源了。 编绎修改过的StringBuilder 其实很简单,在IDE里把StringBuilder的代码复制一份,然后贴到任意一个工程里,然后编绎即可。 也可以直接用javac来编绎: javac StringBuilder.java 启动应用,使用Arthas redefine修改过的StringBuilder 启动应用后,在奇怪日志输出之前,先使用arthas attach应用,再redefine StringBuilder: $ redefine -p /tmp/StringBuilder.class redefine success, size: 1 当执行到输出[] [] [] No credential found的logger代码时,会打印当前栈。实际运行结果是: [] [] [] No credential found java.lang.Throwable at java.lang.StringBuilder.toString(StringBuilder.java:410) at com.taobao.middleware.logger.util.MessageUtil.getMessage(MessageUtil.java:26) at com.taobao.middleware.logger.util.MessageUtil.getMessage(MessageUtil.java:15) at com.taobao.middleware.logger.slf4j.Slf4jLogger.info(Slf4jLogger.java:77) at com.taobao.spas.sdk.common.log.SpasLogger.info(SpasLogger.java:18) at com.taobao.spas.sdk.client.identity.CredentialWatcher.loadCredential(CredentialWatcher.java:128) at com.taobao.spas.sdk.client.identity.CredentialWatcher.access$200(CredentialWatcher.java:18) at com.taobao.spas.sdk.client.identity.CredentialWatcher$1.run(CredentialWatcher.java:58) at java.util.TimerThread.mainLoop(Timer.java:555) at java.util.TimerThread.run(Timer.java:505) 可以看到是spas.sdk打印出了[] [] [] No credential found的日志。 总结 logger最终会用StringBuilder来输出 修改StringBuilder来定位输出特定日志的地方 使用Arthas redefine命令来加载修改过的StringBuilder redefine命令实际上实现了任意代码线上debug的功能,可以随意本地修改代码重新编绎,然后线上redefine加载 redefine的功能过于强大,所以请小心使用:) Arthas实践系列 使用Arthas抽丝剥茧排查线上应用日志打满问题 深入Spring Boot:利用Arthas排查NoSuchMethodError
前文:思考gRPC :为什么是protobuf 背景 gRPC是google开源的高性能跨语言的RPC方案。gRPC的设计目标是在任何环境下运行,支持可插拔的负载均衡,跟踪,运行状况检查和身份验证。它不仅支持数据中心内部和跨数据中心的服务调用,它也适用于分布式计算的最后一公里,将设备,移动应用程序和浏览器连接到后端服务。 https://grpc.io/ https://github.com/grpc/grpc GRPC设计的动机和原则 https://grpc.io/blog/principles 个人觉得官方的文章令人印象深刻的点: 内部有Stubby的框架,但是它不是基于任何一个标准的 gRPC支持任意环境使用,支持物联网、手机、浏览器 gRPC支持stream和流控 HTTP/2是什么 在正式讨论gRPC为什么选择HTTP/2之前,我们先来简单了解下HTTP/2。 HTTP/2可以简单用一个图片来介绍: 来自:https://hpbn.co/ 可以看到: HTTP/1里的header对应HTTP/2里的 HEADERS frame HTTP/1里的payload对应HTTP/2里的 DATA frame 在Chrome浏览器里,打开chrome://net-internals/#http2,可以看到http2链接的信息。 目前很多网站都已经跑在HTTP/2上了,包括alibaba。 gRPC over HTTP/2 准确来说gRPC设计上是分层的,底层支持不同的协议,目前gRPC支持: gRPC over HTTP2 gRPC Web 但是大多数情况下,讨论都是基于gRPC over HTTP2。 下面从一个真实的gRPC SayHello请求,查看它在HTTP/2上是怎样实现的。用wireshark抓包: 可以看到下面这些Header: Header: :authority: localhost:50051 Header: :path: /helloworld.Greeter/SayHello Header: :method: POST Header: :scheme: http Header: content-type: application/grpc Header: user-agent: grpc-java-netty/1.11.0 然后请求的参数在DATA frame里: GRPC Message: /helloworld.Greeter/SayHello, Request 简而言之,gGRPC把元数据放到HTTP/2 Headers里,请求参数序列化之后放到 DATA frame里。 基于HTTP/2 协议的优点 HTTP/2 是一个公开的标准 Google本身把这个事情想清楚了,它并没有把内部的Stubby开源,而是选择重新做。现在技术越来越开放,私有协议的空间越来越小。 HTTP/2 是一个经过实践检验的标准 HTTP/2是先有实践再有标准,这个很重要。很多不成功的标准都是先有一大堆厂商讨论出标准后有实现,导致混乱而不可用,比如CORBA。HTTP/2的前身是Google的SPDY,没有Google的实践和推动,可能都不会有HTTP/2。 HTTP/2 天然支持物联网、手机、浏览器 实际上先用上HTTP/2的也是手机和手机浏览器。移动互联网推动了HTTP/2的发展和普及。 基于HTTP/2 多语言客户端实现容易 只讨论协议本身的实现,不考虑序列化。 每个流行的编程语言都会有成熟的HTTP/2 Client HTTP/2 Client是经过充分测试,可靠的 用Client发送HTTP/2请求的难度远低于用socket发送数据包/解析数据包 HTTP/2支持Stream和流控 在业界,有很多支持stream的方案,比如基于websocket的,或者rsocket。但是这些方案都不是通用的。 HTTP/2里的Stream还可以设置优先级,尽管在rpc里可能用的比较少,但是一些复杂的场景可能会用到。 基于HTTP/2 在Gateway/Proxy很容易支持 nginx对gRPC的支持:https://www.nginx.com/blog/nginx-1-13-10-grpc/ envoy对gRPC的支持:https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/grpc# HTTP/2 安全性有保证 HTTP/2 天然支持SSL,当然gRPC可以跑在clear text协议(即不加密)上。 很多私有协议的rpc可能自己包装了一层TLS支持,使用起来也非常复杂。开发者是否有足够的安全知识?使用者是否配置对了?运维者是否能正确理解? HTTP/2 在公有网络上的传输上有保证。比如这个CRIME攻击,私有协议很难保证没有这样子的漏洞。 HTTP/2 鉴权成熟 从HTTP/1发展起来的鉴权系统已经很成熟了,可以无缝用在HTTP/2上 可以从前端到后端完全打通的鉴权,不需要做任何转换适配 比如传统的rpc dubbo,需要写一个dubbo filter,还要考虑把鉴权相关的信息通过thread local传递进去。rpc协议本身也需要支持。总之,非常复杂。实际上绝大部分公司里的rpc都是没有鉴权的,可以随便调。 基于HTTP/2 的缺点 rpc的元数据的传输不够高效 尽管HPAC可以压缩HTTP Header,但是对于rpc来说,确定一个函数调用,可以简化为一个int,只要两端去协商过一次,后面直接查表就可以了,不需要像HPAC那样编码解码。 可以考虑专门对gRPC做一个优化过的HTTP/2解析器,减少一些通用的处理,感觉可以提升性能。 HTTP/2 里一次gRPC调用需要解码两次 一次是HEADERS frame,一次是DATA frame。 HTTP/2 标准本身是只有一个TCP连接,但是实际在gRPC里是会有多个TCP连接,使用时需要注意。 gRPC选择基于HTTP/2,那么它的性能肯定不会是最顶尖的。但是对于rpc来说中庸的qps可以接受,通用和兼容性才是最重要的事情。 官方的benchmark:https://grpc.io/docs/guides/benchmarking.html https://github.com/hank-whu/rpc-benchmark Google制定标准的能力 近10年来,Google制定标准的能力越来越强。下面列举一些标准: HTTP/2 WebP图片格式 WebRTC 网页即时通信 VP9/AV1 视频编码标准 Service Worker/PWA 当然google也并不都会成功,很多事情它想推也失败了,比如Chrome的Native Client。 gRPC目前是k8s生态里的事实标准。 gRPC是否会成为更多地方,更大领域的RPC标准? 为什么会出现gRPC 准确来说为什么会出现基于HTTP/2的RPC? 个人认为一个重要的原因是,在Cloud Native的潮流下,开放互通的需求必然会产生基于HTTP/2的RPC。即使没有gRPC,也会有其它基于HTTP/2的RPC。 gRPC在Google的内部也是先用在Google Cloud Platform和公开的API上:https://opensource.google.com/projects/grpc 尽管gRPC它可能替换不了内部的RPC实现,但是在开放互通的时代,不止在k8s上,gRPC会有越来越多的舞台可以施展。 链接 https://hpbn.co/ https://grpc.io/blog/loadbalancing https://http2.github.io/faq
现象 在应用的 service_stdout.log里一直输出下面的日志,直接把磁盘打满了: 23:07:34.441 [TAIRCLIENT-1-thread-1] DEBUG io.netty.channel.nio.NioEventLoop - Selector.select() returned prematurely 14 times in a row. 23:07:34.460 [TAIRCLIENT-1-thread-3] DEBUG io.netty.channel.nio.NioEventLoop - Selector.select() returned prematurely 3 times in a row. 23:07:34.461 [TAIRCLIENT-1-thread-4] DEBUG io.netty.channel.nio.NioEventLoop - Selector.select() returned prematurely 3 times in a row. 23:07:34.462 [TAIRCLIENT-1-thread-5] DEBUG io.netty.channel.nio.NioEventLoop - Selector.select() returned prematurely 3 times in a row. service_stdout.log是进程标准输出的重定向,可以初步判定是tair插件把日志输出到了stdout里。 尽管有了初步的判断,但是具体logger为什么会打到stdout里,还需要进一步排查,常见的方法可能是本地debug。 下面介绍利用arthas直接在线上定位问题的过程,主要使用sc和getstatic命令。 https://alibaba.github.io/arthas/sc.html https://alibaba.github.io/arthas/getstatic.html 定位logger的具体实现 日志是io.netty.channel.nio.NioEventLoop输出的,到netty的代码里查看,发现是DEBUG级别的输出: https://github.com/netty/netty/blob/netty-4.0.35.Final/transport/src/main/java/io/netty/channel/nio/NioEventLoop.java#L673 然后用arthas的sc命令来查看具体的io.netty.channel.nio.NioEventLoop是从哪里加载的。 class-info io.netty.channel.nio.NioEventLoop code-source file:/opt/app/plugins/tair-plugin/lib/netty-all-4.0.35.Final.jar!/ name io.netty.channel.nio.NioEventLoop isInterface false isAnnotation false isEnum false isAnonymousClass false isArray false isLocalClass false isMemberClass false isPrimitive false isSynthetic false simple-name NioEventLoop modifier final,public annotation interfaces super-class +-io.netty.channel.SingleThreadEventLoop +-io.netty.util.concurrent.SingleThreadEventExecutor +-io.netty.util.concurrent.AbstractScheduledEventExecutor +-io.netty.util.concurrent.AbstractEventExecutor +-java.util.concurrent.AbstractExecutorService +-java.lang.Object class-loader +-tair-plugin's ModuleClassLoader classLoaderHash 73ad2d6 可见,的确是从tair插件里加载的。 查看NioEventLoop的代码,可以发现它有一个logger的field: public final class NioEventLoop extends SingleThreadEventLoop { private static final InternalLogger logger = InternalLoggerFactory.getInstance(NioEventLoop.class); 使用arthas的getstatic命令来查看这个logger具体实现类是什么(使用-c参数指定classloader): $ getstatic -c 73ad2d6 io.netty.channel.nio.NioEventLoop logger 'getClass().getName()' field: logger @String[io.netty.util.internal.logging.Slf4JLogger] 可以发现是Slf4JLogger。 再查看io.netty.util.internal.logging.Slf4JLogger的实现,发现它内部有一个logger的field: package io.netty.util.internal.logging; import org.slf4j.Logger; /** * <a href="http://www.slf4j.org/">SLF4J</a> logger. */ class Slf4JLogger extends AbstractInternalLogger { private static final long serialVersionUID = 108038972685130825L; private final transient Logger logger; 那么使用arthas的getstatic命令来查看这个logger属性的值: $ getstatic -c 73ad2d6 io.netty.channel.nio.NioEventLoop logger 'logger' field: logger @Logger[ serialVersionUID=@Long[5454405123156820674], FQCN=@String[ch.qos.logback.classic.Logger], name=@String[io.netty.channel.nio.NioEventLoop], level=null, effectiveLevelInt=@Integer[10000], parent=@Logger[Logger[io.netty.channel.nio]], childrenList=null, aai=null, additive=@Boolean[true], loggerContext=@LoggerContext[ch.qos.logback.classic.LoggerContext[default]], ] 可见,logger的最本质实现类是:ch.qos.logback.classic.Logger。 再次用getstatic命令来确定jar包的location: $ getstatic -c 73ad2d6 io.netty.channel.nio.NioEventLoop logger 'logger.getClass().getProtectionDomain().getCodeSource().getLocation()' field: logger @URL[ BUILTIN_HANDLERS_PREFIX=@String[sun.net.www.protocol], serialVersionUID=@Long[-7627629688361524110], protocolPathProp=@String[java.protocol.handler.pkgs], protocol=@String[jar], host=@String[], port=@Integer[-1], file=@String[file:/opt/app/plugins/tair-plugin/lib/logback-classic-1.2.3.jar!/], query=null, authority=@String[], path=@String[file:/opt/app/plugins/tair-plugin/lib/logback-classic-1.2.3.jar!/], userInfo=null, ref=null, hostAddress=null, handler=@Handler[com.taobao.pandora.loader.jar.Handler@1a0c361e], hashCode=@Integer[126346621], tempState=null, factory=@TomcatURLStreamHandlerFactory[org.apache.catalina.webresources.TomcatURLStreamHandlerFactory@3edd7b7], handlers=@Hashtable[isEmpty=false;size=4], streamHandlerLock=@Object[java.lang.Object@488ccac9], serialPersistentFields=@ObjectStreamField[][isEmpty=false;size=7], ] 可见这个ch.qos.logback.classic.Logger的确是tair插件里加载的。 定位logger的level 上面已经定位logger的实现类是ch.qos.logback.classic.Logger,但是为什么它会输出DEBUG level的日志? 其实在上面的getstatic -c 73ad2d6 io.netty.channel.nio.NioEventLoop logger 'logger'输出里,已经打印出它的level是null了。如果对logger有所了解的话,可以知道当child logger的level为null时,它的level取决于parent logger的level。 我们再来看下ch.qos.logback.classic.Logger的代码,它有一个parent logger的属性: public final class Logger implements org.slf4j.Logger, LocationAwareLogger, AppenderAttachable<ILoggingEvent>, Serializable { /** * The parent of this category. All categories have at least one ancestor * which is the root category. */ transient private Logger parent; 所以,我们可以通过getstatic来获取到这个parent属性的内容。然后通过多个parent操作,可以发现level都是null,最终发现ROOT level是DEBUG 。 $ getstatic -c 73ad2d6 io.netty.channel.nio.NioEventLoop logger 'logger.parent.parent.parent.parent.parent' field: logger @Logger[ serialVersionUID=@Long[5454405123156820674], FQCN=@String[ch.qos.logback.classic.Logger], name=@String[ROOT], level=@Level[DEBUG], effectiveLevelInt=@Integer[10000], parent=null, childrenList=@CopyOnWriteArrayList[isEmpty=false;size=2], aai=@AppenderAttachableImpl[ch.qos.logback.core.spi.AppenderAttachableImpl@1ecf9bae], additive=@Boolean[true], loggerContext=@LoggerContext[ch.qos.logback.classic.LoggerContext[default]], ] 所以 io.netty.channel.nio.NioEventLoop的logger的level取的是ROOT logger的配置,即默认值DEBUG。 具体实现可以查看ch.qos.logback.classic.LoggerContext public LoggerContext() { super(); this.loggerCache = new ConcurrentHashMap<String, Logger>(); this.loggerContextRemoteView = new LoggerContextVO(this); this.root = new Logger(Logger.ROOT_LOGGER_NAME, null, this); this.root.setLevel(Level.DEBUG); loggerCache.put(Logger.ROOT_LOGGER_NAME, root); initEvaluatorMap(); size = 1; this.frameworkPackages = new ArrayList<String>(); } 为什么logback输出到了stdout里 上面我们得到结论 tair插件里的logback默认的level是DEBUG,导致netty里的日志可以被打印出来 那么我们可以猜测: tair里的logback没有特殊配置,或者只配置了tair自己的package,导致ROOT logger的日志直接输出到stdout里 那么实现上tair里是使用了logger-api,它通过LoggerFactory.getLogger函数获取到了自己package的logger,然后设置level为INFO,并设置了appender。 换而言之,tair插件里的logback没有设置ROOT logger,所以它的默认level是DEBUG,并且默认的appender会输出到stdout里。 所以tair插件可以增加设置ROOT logger level为INFO来修复这个问题。 private static com.taobao.middleware.logger.Logger logger = com.taobao.middleware.logger.LoggerFactory.getLogger("com.taobao.tair"); public static com.taobao.middleware.logger.Logger infolog = com.taobao.middleware.logger.LoggerFactory.getLogger("com.taobao.tair.custom-infolog"); public static int JM_LOG_RETAIN_COUNT = 3; public static String JM_LOG_FILE_SIZE = "200MB"; static { try { String tmp = System.getProperty("JM.LOG.RETAIN.COUNT", "3"); JM_LOG_RETAIN_COUNT = Integer.parseInt(tmp); } catch (NumberFormatException e) { } JM_LOG_FILE_SIZE = System.getProperty("JM.LOG.FILE.SIZE", "200MB"); logger.setLevel(Level.INFO); logger.activateAppenderWithSizeRolling("tair-client", "tair-client.log", "UTF-8", TairUtil.splitSize(JM_LOG_FILE_SIZE, 0.8 / (JM_LOG_RETAIN_COUNT + 1)), JM_LOG_RETAIN_COUNT); logger.setAdditivity(false); logger.activateAsync(500, 100); logger.info("JM_LOG_RETAIN_COUNT " + JM_LOG_RETAIN_COUNT + " JM_LOG_FILE_SIZE " + JM_LOG_FILE_SIZE); infolog.setLevel(Level.INFO); infolog.activateAppenderWithSizeRolling("tair-client", "tair-client-info.log", "UTF-8", "10MB", 1); infolog.setAdditivity(false); infolog.activateAsync(500, 100); 总结 tair插件里直接以api的方式设置了自己package下的logger tair插件里netty的logger的packge和tair并不一样,所以它最终取的是ROOT logger的配置 logback默认的ROOT logger level是DEBUG,输出是stdout 利用arthas的sc命令定位具体的类 利用arthas的getstatic获取static filed的值 利用logger parent层联的特性,可以向上一层层获取到ROOT logger的配置 链接 Arthas开源:https://github.com/alibaba/arthas
前言 有时spring boot应用会遇到java.lang.NoSuchMethodError的问题,下面以具体的demo来说明怎样利用arthas来排查。 Demo: https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-NoSuchMethodError 在应用的main函数里catch住异常,保证进程不退出 很多时候当应用抛出异常后,进程退出了,就比较难排查问题。可以先改下main函数,把异常catch住: public static void main(String[] args) throws IOException { try { SpringApplication.run(DemoNoSuchMethodErrorApplication.class, args); } catch (Throwable e) { e.printStackTrace(); } // block System.in.read(); } Demo启动之后,抛出的异常是: java.lang.NoSuchMethodError: org.springframework.core.annotation.AnnotationAwareOrderComparator.sort(Ljava/util/List;)V at org.springframework.boot.SpringApplication.getSpringFactoriesInstances(SpringApplication.java:394) at org.springframework.boot.SpringApplication.getSpringFactoriesInstances(SpringApplication.java:383) at org.springframework.boot.SpringApplication.initialize(SpringApplication.java:249) at org.springframework.boot.SpringApplication.<init>(SpringApplication.java:225) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1118) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1107) at com.example.demoNoSuchMethodError.DemoNoSuchMethodErrorApplication.main(DemoNoSuchMethodErrorApplication.java:13) 显然,异常的意思是AnnotationAwareOrderComparator缺少sort(Ljava/util/List;)V这个函数。 安装arthas 参考:https://alibaba.github.io/arthas/install-detail.html 使用sc命令查找类所在的jar包 应用需要抛出了异常,但是进程还没有退出,我们用arthas来attach上去。比如在mac下面: ./as.sh 然后选择com.example.demoNoSuchMethodError.DemoNoSuchMethodErrorApplication进程。 再执行sc命令来查找类: $ sc -d org.springframework.core.annotation.AnnotationAwareOrderComparator class-info org.springframework.core.annotation.AnnotationAwareOrderComparator code-source /Users/hengyunabc/.m2/repository/org/springframework/spring/2.5.6.SEC03/spring-2.5.6.SEC03.jar name org.springframework.core.annotation.AnnotationAwareOrderComparator isInterface false isAnnotation false isEnum false isAnonymousClass false isArray false isLocalClass false isMemberClass false isPrimitive false isSynthetic false simple-name AnnotationAwareOrderComparator modifier public annotation interfaces super-class +-org.springframework.core.OrderComparator +-java.lang.Object class-loader +-sun.misc.Launcher$AppClassLoader@5c647e05 +-sun.misc.Launcher$ExtClassLoader@689e3d07 classLoaderHash 5c647e05 Affect(row-cnt:1) cost in 41 ms. 可以看到AnnotationAwareOrderComparator是从spring-2.5.6.SEC03.jar里加载的。 使用jad查看反编绎的源代码 下面使用jad命令来查看AnnotationAwareOrderComparator的源代码 $ jad org.springframework.core.annotation.AnnotationAwareOrderComparator ClassLoader: +-sun.misc.Launcher$AppClassLoader@5c647e05 +-sun.misc.Launcher$ExtClassLoader@689e3d07 Location: /Users/hengyunabc/.m2/repository/org/springframework/spring/2.5.6.SEC03/spring-2.5.6.SEC03.jar /* * Decompiled with CFR 0_132. */ package org.springframework.core.annotation; import java.lang.annotation.Annotation; import org.springframework.core.OrderComparator; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; public class AnnotationAwareOrderComparator extends OrderComparator { protected int getOrder(Object obj) { Order order; if (obj instanceof Ordered) { return ((Ordered)obj).getOrder(); } if (obj != null && (order = obj.getClass().getAnnotation(Order.class)) != null) { return order.value(); } return Integer.MAX_VALUE; } } Affect(row-cnt:1) cost in 286 ms. 可见,AnnotationAwareOrderComparator的确没有sort(Ljava/util/List;)V函数。 排掉依赖,解决问题 从上面的排查里,可以确定 AnnotationAwareOrderComparator来自spring-2.5.6.SEC03.jar,的确没有sort(Ljava/util/List;)V函数。 所以,可以检查maven依赖,把spring 2的jar包排掉,这样子就可以解决问题了。 总结 仔细看NoSuchMethodError的异常信息,了解是什么类缺少了什么函数 利用arthas来查找类,反编绎源码,确认问题 链接 Arthas--Alibaba开源Java诊断利器
Kotlin里的Extension Functions Kotlin里有所谓的扩展函数(Extension Functions),支持给现有的java类增加函数。 https://kotlinlang.org/docs/reference/extensions.html 比如给String增加一个hello函数,可以这样子写: fun String.hello(world : String) : String { return "hello " + world + this.length; } fun main(args: Array<String>) { System.out.println("abc".hello("world")); } 可以看到在main函数里,直接可以在String上调用hello函数。 执行后,输出结果是: hello world3 可以看到在hello函数里的this引用的是"abc"。 刚开始看到这个语法还是比较新奇的,那么怎样实现的呢?如果不同的库都增加了同样的函数会不会冲突? 反编绎生成的字节码,结果是: @NotNull public static final String hello(@NotNull String $receiver, @NotNull String world) { return "hello " + world + $receiver.length(); } public static final void main(@NotNull String[] args) { System.out.println(hello("abc", "world")); } 可以看到,实际上是增加了一个static public final函数。 并且新增加的函数是在自己的类里的,并不是在String类里。即不同的库新增加的扩展函数都是自己类里的,不会冲突。 lombok 里的 @ExtensionMethod 实现 lombok里也提供了类似的@ExtensionMethod支持。 https://projectlombok.org/features/experimental/ExtensionMethod 和上面的例子一样,给String类增加一个hello函数: 需要定义一个class Extensions 再用@ExtensionMethod声明 class Extensions { public static String hello(String receiver, String world) { return "hello " + world + receiver.length(); } } @ExtensionMethod({ Extensions.class }) public class Test { public static void main(String[] args) { System.out.println("abc".hello("world")); } } 执行后,输出结果是: hello world3 可以看到在hello函数里,第一个参数String receiver就是"abc"本身。 和上面kotlin的例子不一样的是,kotlin里直接可以用this。 生成的字节码反编绎结果是: class Extensions { public static String hello(String receiver, String world) { return "hello " + world + receiver.length(); } } public class Test { public static void main(String[] args) { System.out.println(Extensions.hello("abc", "world")); } } 可以看到所谓的@ExtensionMethod实际上也是一个语法糖。 设计动机 https://kotlinlang.org/docs/reference/extensions.html#motivation 据kotlin的文档:各种FileUtils,StringUtils类很麻烦。 比如像下面处理List,在java里可以用java.util.Collections // Java Collections.swap(list, Collections.binarySearch(list, Collections.max(otherList)), Collections.max(list)); 简化下import,可以变为 // Java swap(list, binarySearch(list, max(otherList)), max(list)); 但是还不够清晰,各种import *也是比较烦的。利用扩展函数,可以直接这样子写: // Java list.swap(list.binarySearch(otherList.max()), list.max()); 总结 kotlin的Extension Functions和lombok的@ExtensionMethod实际上都是增加public static final函数 不同的库增加的同样的Extension Functions不会冲突 设计的动机是减少各种utils类。
前言 https://wiki.openjdk.java.net/display/CodeTools/asmtools 在OpenJDK里有一个AsmTools项目,用来生成正确的或者不正确的java .class文件,主要用来测试和验证。 我们知道直接修改.class文件是很麻烦的,虽然有一些图形界面的工具,但还是很麻烦。 以前我的办法是用ASMifier从.class文件生成asm java代码,再修改代码,生成新的.class文件,非常麻烦。 AsmTools引入了两种表示.class文件的语法: JASM 用类似java本身的语法来定义类和函数,字节码指令则很像传统的汇编。 JCOD 整个.class用容器的方式来表示,可以很清楚表示类文件的结构。 重要的是两种语法的文件都是可以和.class互相转换的。 构建AsmTools 官方文档: https://wiki.openjdk.java.net/display/CodeTools/How+to+build+AsmTools 需要有jdk8和ant。 clone代码 hg clone http://hg.openjdk.java.net/code-tools/asmtools 编绎 cd asmtools/build ant 打包出来的zip包里有一个asmtools.jar。 也可以在这里下载我构建的:https://github.com/hengyunabc/hengyunabc.github.io/files/2188258/asmtools-7.0.zip 测试简单的java类 public class Test { public static void main(String[] args) { System.out.println("hello"); } } 先用javac来编绎: javac Test.java 查看JASM语法结果 java -jar asmtools.jar jdis Test.class 结果: super public class Test version 52:0 { public Method "<init>":"()V" stack 1 locals 1 { aload_0; invokespecial Method java/lang/Object."<init>":"()V"; return; } public static Method main:"([Ljava/lang/String;)V" stack 2 locals 1 { getstatic Field java/lang/System.out:"Ljava/io/PrintStream;"; ldc String "hello"; invokevirtual Method java/io/PrintStream.println:"(Ljava/lang/String;)V"; return; } } // end Class Test 查看JCOD语法结果 java -jar asmtools.jar jdec Test.class 结果: class Test { 0xCAFEBABE; 0; // minor version 52; // version [] { // Constant Pool ; // first element is empty class #2; // #1 Utf8 "Test"; // #2 class #4; // #3 Utf8 "java/lang/Object"; // #4 Utf8 "<init>"; // #5 Utf8 "()V"; // #6 Utf8 "Code"; // #7 Method #3 #9; // #8 NameAndType #5 #6; // #9 Utf8 "LineNumberTable"; // #10 Utf8 "LocalVariableTable"; // #11 Utf8 "this"; // #12 Utf8 "LTest;"; // #13 Utf8 "main"; // #14 Utf8 "([Ljava/lang/String;)V"; // #15 Field #17 #19; // #16 class #18; // #17 Utf8 "java/lang/System"; // #18 NameAndType #20 #21; // #19 Utf8 "out"; // #20 Utf8 "Ljava/io/PrintStream;"; // #21 String #23; // #22 Utf8 "hello"; // #23 Method #25 #27; // #24 class #26; // #25 Utf8 "java/io/PrintStream"; // #26 NameAndType #28 #29; // #27 Utf8 "println"; // #28 Utf8 "(Ljava/lang/String;)V"; // #29 Utf8 "args"; // #30 Utf8 "[Ljava/lang/String;"; // #31 Utf8 "SourceFile"; // #32 Utf8 "Test.java"; // #33 } // Constant Pool 0x0021; // access #1;// this_cpx #3;// super_cpx [] { // Interfaces } // Interfaces [] { // fields } // fields [] { // methods { // Member 0x0001; // access #5; // name_cpx #6; // sig_cpx [] { // Attributes Attr(#7) { // Code 1; // max_stack 1; // max_locals Bytes[]{ 0x2AB70008B1; } [] { // Traps } // end Traps [] { // Attributes Attr(#10) { // LineNumberTable [] { // LineNumberTable 0 2; } } // end LineNumberTable ; Attr(#11) { // LocalVariableTable [] { // LocalVariableTable 0 5 12 13 0; } } // end LocalVariableTable } // Attributes } // end Code } // Attributes } // Member ; { // Member 0x0009; // access #14; // name_cpx #15; // sig_cpx [] { // Attributes Attr(#7) { // Code 2; // max_stack 1; // max_locals Bytes[]{ 0xB200101216B60018; 0xB1; } [] { // Traps } // end Traps [] { // Attributes Attr(#10) { // LineNumberTable [] { // LineNumberTable 0 5; 8 6; } } // end LineNumberTable ; Attr(#11) { // LocalVariableTable [] { // LocalVariableTable 0 9 30 31 0; } } // end LocalVariableTable } // Attributes } // end Code } // Attributes } // Member } // methods [] { // Attributes Attr(#32) { // SourceFile #33; } // end SourceFile } // Attributes } // end class Test 从JASM/JCOD语法文件生成类文件 因为是等价表达,可以从JASM生成.class文件: java -jar asmtools.jar jasm Test.jasm 同样可以从JCOD生成.class文件: java -jar asmtools.jar jcoder Test.jasm 更多使用方法参考: https://wiki.openjdk.java.net/display/CodeTools/Chapter+2#Chapter2-Jasm.1 链接 https://wiki.openjdk.java.net/display/CodeTools/Appendix+A JASM Syntax https://wiki.openjdk.java.net/display/CodeTools/Appendix+B JCOD Syntax
背景 谈到RPC,就避免不了序列化的话题。 gRPC默认的序列化方式是protobuf,原因很简单,因为两者都是google发明的,哈哈。 在当初Google开源protobuf时,很多人就期待是否能把RPC的实现也一起开源出来。没想到最终出来的是gRPC,终于补全了这一块。 跨语言的序列化方案 事实上的跨语言序列化方案只有三个: protobuf, thrift, json。 json体积太大,并且缺少类型信息,实际上只用在RESTful接口上,并没有看到RPC框架会默认选json做序列化的。 国内一些大公司的使用情况: protobuf ,腾迅,百度等 thrift,小米,美团等 hessian, 阿里用的是自己维护的版本,有js/cpp的实现,因为阿里主用java,更多是历史原因。 序列化里的类型信息 序列化就是把对象转换为二进制数据,反序列化就把二进制数据转换为对象。 各种序列化库层出不穷,其中有一个重要的区别:类型信息存放在哪? 可以分为三种: 不保存类型信息 典型的是各种json序列化库,优点是灵活,缺点是使用的双方都要知道类型是什么。当然有一些json库会提供一些扩展,偷偷把类型信息插入到json里。 类型信息保存到序列化结果里 比如java自带的序列化,hessian等。缺点是类型信息冗余。比如RPC里每一个request都要带上类型。因此有一种常见的RPC优化手段就是两端协商之后,后续的请求不需要再带上类型信息。 在生成代码里带上类型信息 通常是在IDL文件里写好package和类名,生成的代码直接就有了类型信息。比如protobuf, thrift。缺点是需要生成代码,双方都要知道IDL文件。 类型信息看起来是一个小事,但在安全上却会出大问题,后面会讨论。 实际使用中序列化有哪些问题 这里讨论的是没有IDL定义的序列化方案,比如java自带的序列化,hessian, 各种json库。 大小莫名增加,比如用户不小心向map里put了大对象。 对象之间互相引用,用户根本不清楚序列化到底会产生什么结果,可能新加一个field就不小心被序列化了 enum类新增加的不能识别,当两端的类版本不一致时就会出错 哪些字段应该跳过序列化 ,不同的库都有不同的 @Ignore ,没有通用的方案 很容易把一些奇怪的类传过来,然后对端报ClassNotFoundException 新版本jdk新增加的类不支持,需要序列化库不断升级,如果没人维护就悲剧了 库本身的代码质量不高,或者API设计不好容易出错,比如kryo gRPC是protobuf的一个插件 以gRPC官方的Demo为例: package helloworld; // The greeting service definition. service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; } 可以看到rpc的定义也是写在proto文件里的。实际上gRPC是protobuf的一个扩展,通过扩展生成gRPC相关的代码。 protobuf并不是完美解决方案 在protobuf出来以后,也不断出现新的方案。比如 https://github.com/capnproto/capnproto https://github.com/google/flatbuffers https://avro.apache.org/ protobuf的一些缺点: 缺少map/set的支持(proto3支持map) Varint编码会消耗CPU 会影响CPU缓存,比如比较大的int32从4字节用Varint表示是5字节就不对齐了 解码时要复制一份内存,不能做原地内存引用的优化 protobuf在google 2008年公开的,内部使用自然更早。当时带宽还比较昂贵,现在人们对速度的关注胜过带宽了。 protobuf需要生成代码的确有点麻烦,所以会有基于java annotation的方案: https://github.com/protostuff/protostuff 同样thrift有: https://github.com/facebookarchive/swift 序列化被人忽视的安全性问题 序列化漏洞危害很大 序列化漏洞通常比较严重,容易造成任意代码执行 序列化漏洞在很多语言里都会有,比如Python Pickle序列化漏洞。 很多程序员不理解为什么反序列化可以造成任意代码执行。 反序列化漏洞到底是怎么工作的呢?很难直接描述清楚,这些漏洞都有很精巧的设计,把多个地方的代码串联起来。可以参考这个demo,跑起来调试下就可以有直观的印象: https://github.com/hengyunabc/dubbo-apache-commons-collections-bug 这里有两个生成java序列化漏洞代码的工具: https://github.com/frohoff/ysoserial https://github.com/mbechler/marshalsec 常见的库怎样防止反序列化漏洞 下面来看下常见的序列化方案是怎么防止反序列化漏洞的: Java Serialization jdk里增加了一个filter机制 http://openjdk.java.net/jeps/290 ,这个一开始是出现在jdk9上的,后面移值回jdk6/7/8上,如果安装的jdk版本是比较新的,可以找到相关的类 Oracle打算废除java序列化:https://www.infoworld.com/article/3275924/java/oracle-plans-to-dump-risky-java-serialization.html jackson-databind jackson-databind里是过滤掉一些已知的类,参见SubTypeValidator.java jackson-databind的CVE issue列表 fastjson fastjson通过一个denyList来过滤掉一些危险类的package,参见ParserConfig.java fastjson在新版本里denyList改为通过hashcode来隐藏掉package信息,但通过这个DenyTest5可以知道还是过滤掉常见危险类的package fastjson在新版本里默认把autoType的功能禁止掉了 所以总结下来,要么白名单,要么黑名单。当然黑名单机制不能及时更新,业务方得不断升jar包,非常蛋疼。白名单是比较彻底的解决方案。 为什么protobuf没有序列化漏洞 这些序列化漏洞的根本原因是:没有控制序列化的类型范围 为什么在protobuf里并没有这些反序列化问题? protobuf在IDL里定义好了package范围 protobuf的代码都是自动生成的,怎么处理二进制数据都是固定的 protobuf把一切都框住了,少了灵活性,自然就少漏洞。 总结 应该重视反序列化漏洞,毕竟Oracle都不得不考虑把java序列化废弃了 序列化漏洞的根本原因是:没有控制序列化的类型范围 防止序列化漏洞,最好是使用白名单 protobuf通过IDL生成代码,严格控制了类型范围 protobuf不是完美的方案,但是作为跨语言的序列化事实方案之一,IDL生成代码比较麻烦也不是啥大问题 链接 https://github.com/protostuff/protostuff https://github.com/facebookarchive/swift http://openjdk.java.net/jeps/290 https://www.infoworld.com/article/3275924/java/oracle-plans-to-dump-risky-java-serialization.html
背景 gRPC是google开源的高性能跨语言的RPC方案。gRPC的设计目标是在任何环境下运行,支持可插拔的负载均衡,跟踪,运行状况检查和身份验证。它不仅支持数据中心内部和跨数据中心的服务调用,它也适用于分布式计算的最后一公里,将设备,移动应用程序和浏览器连接到后端服务。 https://grpc.io/ https://github.com/grpc/grpc GRPC设计的动机和原则 https://grpc.io/blog/principles 个人觉得官方的文章令人印象深刻的点: 内部有Stubby的框架,但是它不是基于任何一个标准的 支持任意环境使用,支持物联网、手机、浏览器 支持stream和流控 HTTP/2是什么 在正式讨论gRPC为什么选择HTTP/2之前,我们先来简单了解下HTTP/2。 HTTP/2可以简单用一个图片来介绍: 来自:https://hpbn.co/ 可以看到: * HTTP/1里的header对应HTTP/2里的 HEADERS frame * HTTP/1里的payload对应HTTP/2里的 DATA frame 在Chrome浏览器里,打开chrome://net-internals/#http2,可以看到http2链接的信息。 目前很多网站都已经跑在HTTP/2上了,包括alibaba。 gRPC over HTTP/2 准确来说gRPC设计上是分层的,底层支持不同的协议,目前gRPC支持: gRPC over HTTP2 gRPC Web 但是大多数情况下,讨论都是基于gRPC over HTTP2。 下面从一个真实的gRPC SayHello请求,查看它在HTTP/2上是怎样实现的。用wireshark抓包: 可以看到下面这些Header: Header: :authority: localhost:50051 Header: :path: /helloworld.Greeter/SayHello Header: :method: POST Header: :scheme: http Header: content-type: application/grpc Header: user-agent: grpc-java-netty/1.11.0 然后请求的参数在DATA frame里: GRPC Message: /helloworld.Greeter/SayHello, Request 简而言之,gGRPC把元数据放到HTTP/2 Headers里,请求参数序列化之后放到 DATA frame里。 基于HTTP/2 协议的优点 HTTP/2 是一个公开的标准 Google本身把这个事情想清楚了,它并没有把内部的Stubby开源,而是选择重新做。现在技术越来越开放,私有协议的空间越来越小。 HTTP/2 是一个经过实践检验的标准 HTTP/2是先有实践再有标准,这个很重要。很多不成功的标准都是先有一大堆厂商讨论出标准后有实现,导致混乱而不可用,比如CORBA。HTTP/2的前身是Google的SPDY,没有Google的实践和推动,可能都不会有HTTP/2。 HTTP/2 天然支持物联网、手机、浏览器 实际上先用上HTTP/2的也是手机和手机浏览器。移动互联网推动了HTTP/2的发展和普及。 基于HTTP/2 多语言的实现容易 只讨论协议本身的实现,不考虑序列化。 每个流行的编程语言都会有成熟的HTTP/2 Client HTTP/2 Client是经过充分测试,可靠的 用Client发送HTTP/2请求的难度远低于用socket发送数据包/解析数据包 HTTP/2支持Stream和流控 在业界,有很多支持stream的方案,比如基于websocket的,或者rsocket。但是这些方案都不是通用的。 HTTP/2里的Stream还可以设置优先级,尽管在rpc里可能用的比较少,但是一些复杂的场景可能会用到。 基于HTTP/2 在Gateway/Proxy很容易支持 nginx对gRPC的支持:https://www.nginx.com/blog/nginx-1-13-10-grpc/ envoy对gRPC的支持:https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/grpc# HTTP/2 安全性有保证 HTTP/2 天然支持SSL,当然gRPC可以跑在clear text协议(即不加密)上。 很多私有协议的rpc可能自己包装了一层TLS支持,使用起来也非常复杂。开发者是否有足够的安全知识?使用者是否配置对了?运维者是否能正确理解? HTTP/2 在公有网络上的传输上有保证。比如这个CRIME攻击,私有协议很难保证没有这样子的漏洞。 HTTP/2 鉴权成熟 从HTTP/1发展起来的鉴权系统已经很成熟了,可以无缝用在HTTP/2上 可以从前端到后端完全打通的鉴权,不需要做任何转换适配 比如传统的rpc dubbo,需要写一个dubbo filter,还要考虑把鉴权相关的信息通过thread local传递进去。rpc协议本身也需要支持。总之,非常复杂。实际上绝大部分公司里的rpc都是没有鉴权的,可以随便调。 基于HTTP/2 的缺点 rpc的元数据的传输不够高效 尽管HPAC可以压缩HTTP Header,但是对于rpc来说,确定一个函数调用,可以简化为一个int,只要两端去协商过一次,后面直接查表就可以了,不需要像HPAC那样编码解码。 可以考虑专门对gRPC做一个优化过的HTTP/2解析器,减少一些通用的处理,感觉可以提升性能。 HTTP/2 里一次gRPC调用需要解码两次 一次是HEADERS frame,一次是DATA frame。 HTTP/2 标准本身是只有一个TCP连接,但是实际在gRPC里是会有多个TCP连接,使用时需要注意。 gRPC选择基于HTTP/2,那么它的性能肯定不会是最顶尖的。但是对于rpc来说中庸的qps可以接受,通用和兼容性才是最重要的事情。 官方的benchmark:https://grpc.io/docs/guides/benchmarking.html https://github.com/hank-whu/rpc-benchmark Google制定标准的能力 近10年来,Google制定标准的能力越来越强。下面列举一些标准: HTTP/2 WebP图片格式 WebRTC 网页即时通信 VP9/AV1 视频编码标准 Service Worker/PWA 当然google也并不都会成功,很多事情它想推也失败了,比如Chrome的Native Client。 gRPC目前是k8s生态里的事实标准。 gRPC是否会成为更多地方,更大领域的RPC标准? 为什么会出现gRPC 准确来说为什么会出现基于HTTP/2的RPC? 个人认为一个重要的原因是,在Cloud Native的潮流下,开放互通的需求必然会产生基于HTTP/2的RPC。即使没有gRPC,也会有其它基于HTTP/2的RPC。 gRPC在Google的内部也是先用在Google Cloud Platform和公开的API上:https://opensource.google.com/projects/grpc 尽管gRPC它可能替换不了内部的RPC实现,但是在开放互通的时代,不止在k8s上,gRPC会有越来越多的舞台可以施展。 链接 https://hpbn.co/ https://grpc.io/blog/loadbalancing https://http2.github.io/faq
背景 最近排查一个文件没有关闭的问题,记录一下。 哪些文件没有关闭是比较容易找到的,查看进程的fd(File Descriptor)就可以。但是确定fd是在哪里被打开,在哪里被引用的就复杂点,特别是在没有重启应用的情况下。 在JVM里可以通过heap dump比较方便地反查对象的引用,从而找到泄露的代码。 以下面简单的demo为例,Demo会创建一个临时文件,并且没有close掉: import java.io.File; import java.io.FileInputStream; import java.io.IOException; public class Test { public static void main(String[] args) throws IOException { File tempFile = File.createTempFile("test", "ttt"); FileInputStream fi = new FileInputStream(tempFile); System.in.read(); } } 通过文件名查找对应的fd 进程打开的文件在OS里有对应的fd(File Descriptor),可以用lsof命令或者直接在linux下到/proc目录下查看。 以demo为例,可以找到test文件的fd是12: $ ls -alh /proc/11278/fd/ total 0 dr-x------ 2 admin users 0 Jun 30 18:20 . dr-xr-xr-x 8 admin users 0 Jun 30 18:20 .. lrwx------ 1 admin users 64 Jun 30 18:20 0 -> /dev/pts/0 lrwx------ 1 admin users 64 Jun 30 18:20 1 -> /dev/pts/0 lr-x------ 1 admin users 64 Jun 30 18:24 11 -> /dev/urandom lr-x------ 1 admin users 64 Jun 30 18:24 12 -> /tmp/test7607712940880692142ttt 对进程进行heap dump 使用jmap命令: jmap -dump:live,format=b,file=heap.bin 11278 通过OQL查询java.io.FileDescriptor对象 对于每一个打开的文件在JVM里都有一个java.io.FileDescriptor对象。查看下源码,可以发现FileDescriptor里有一个fd字段: public final class FileDescriptor { private int fd; 所以需要查找到fd等于12的FileDescriptor,QOL语句: select s from java.io.FileDescriptor s where s.fd == 12 使用VisualVM里的OQL控制台查询 在jdk8里自带VisualVM,jdk9之后可以单独下载:https://visualvm.github.io/ 把heap dump文件导入VisualVM里,然后在“OQL控制台”查询上面的语句,结果是: 再可以查询到parent,引用相关的对象。 使用jhat查询 除了VisualVM还有其它很多heap dump工具,在jdk里还自带一个jhat工具,尽管在jdk9之后移除掉了,但是个人还是比较喜欢这个工具,因为它是一个web接口的。 jhat -port 7000 heap.bin 访问 http://localhost:7000/oql/ ,可以在浏览器里查询OQL: 打开链接可以查看具体的信息 总结 先找出没有关闭文件的fd 从heap dump里据fd找出对应的java.io.FileDescriptor对象,再找到相关引用 链接 ViauslVM Object Query Language (OQL)
背景 Hystrix 旨在通过控制那些访问远程系统、服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。Hystrix具备拥有回退机制和断路器功能的线程和信号隔离,请求缓存和请求打包,以及监控和配置等功能。 Dubbo是Alibaba开源的,目前国内最流行的java rpc框架。 本文介绍在spring应用里,怎么把Dubbo和Hystrix结合起来使用。 https://github.com/Netflix/Hystrix https://github.com/apache/incubator-dubbo Spring Boot应用 Demo地址: https://github.com/dubbo/dubbo-samples/tree/master/dubbo-samples-spring-boot-hystrix 生成dubbo集成spring boot的应用 对于不熟悉dubbo 集成spring boot应用的同学,可以在这里直接生成dubbo + spring boot的工程: http://start.dubbo.io/ 配置spring-cloud-starter-netflix-hystrix spring boot官方提供了对hystrix的集成,直接在pom.xml里加入依赖: <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-hystrix</artifactId> <version>1.4.4.RELEASE</version> </dependency> 然后在Application类上增加@EnableHystrix来启用hystrix starter: @SpringBootApplication @EnableHystrix public class ProviderApplication { 配置Provider端 在Dubbo的Provider上增加@HystrixCommand配置,这样子调用就会经过Hystrix代理。 @Service(version = "1.0.0") public class HelloServiceImpl implements HelloService { @HystrixCommand(commandProperties = { @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"), @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "2000") }) @Override public String sayHello(String name) { // System.out.println("async provider received: " + name); // return "annotation: hello, " + name; throw new RuntimeException("Exception to show hystrix enabled."); } } 配置Consumer端 对于Consumer端,则可以增加一层method调用,并在method上配置@HystrixCommand。当调用出错时,会走到fallbackMethod = "reliable"的调用里。 @Reference(version = "1.0.0") private HelloService demoService; @HystrixCommand(fallbackMethod = "reliable") public String doSayHello(String name) { return demoService.sayHello(name); } public String reliable(String name) { return "hystrix fallback value"; } 通过上面的配置,很简单地就完成了Spring Boot里Dubbo + Hystrix的集成。 传统Spring Annotation应用 Demo地址: https://github.com/dubbo/dubbo-samples/tree/master/dubbo-samples-spring-hystrix 传统spring annotation应用的配置其实也很简单,和spring boot应用不同的是: 显式配置Spring AOP支持:@EnableAspectJAutoProxy 显式通过@Configuration配置HystrixCommandAspect Bean。 @Configuration @EnableDubbo(scanBasePackages = "com.alibaba.dubbo.samples.annotation.action") @PropertySource("classpath:/spring/dubbo-consumer.properties") @ComponentScan(value = {"com.alibaba.dubbo.samples.annotation.action"}) @EnableAspectJAutoProxy static public class ConsumerConfiguration { @Bean public HystrixCommandAspect hystrixCommandAspect() { return new HystrixCommandAspect(); } } Hystrix集成Spring AOP原理 在上面的例子里可以看到,Hystrix对Spring的集成是通过Spring AOP来实现的。下面简单分析下实现。 @Aspect public class HystrixCommandAspect { @Pointcut("@annotation(com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand)") public void hystrixCommandAnnotationPointcut() { } @Pointcut("@annotation(com.netflix.hystrix.contrib.javanica.annotation.HystrixCollapser)") public void hystrixCollapserAnnotationPointcut() { } @Around("hystrixCommandAnnotationPointcut() || hystrixCollapserAnnotationPointcut()") public Object methodsAnnotatedWithHystrixCommand(final ProceedingJoinPoint joinPoint) throws Throwable { Method method = getMethodFromTarget(joinPoint); Validate.notNull(method, "failed to get method from joinPoint: %s", joinPoint); if (method.isAnnotationPresent(HystrixCommand.class) && method.isAnnotationPresent(HystrixCollapser.class)) { throw new IllegalStateException("method cannot be annotated with HystrixCommand and HystrixCollapser " + "annotations at the same time"); } MetaHolderFactory metaHolderFactory = META_HOLDER_FACTORY_MAP.get(HystrixPointcutType.of(method)); MetaHolder metaHolder = metaHolderFactory.create(joinPoint); HystrixInvokable invokable = HystrixCommandFactory.getInstance().create(metaHolder); ExecutionType executionType = metaHolder.isCollapserAnnotationPresent() ? metaHolder.getCollapserExecutionType() : metaHolder.getExecutionType(); Object result; try { if (!metaHolder.isObservable()) { result = CommandExecutor.execute(invokable, executionType, metaHolder); } else { result = executeObservable(invokable, executionType, metaHolder); } } catch (HystrixBadRequestException e) { throw e.getCause() != null ? e.getCause() : e; } catch (HystrixRuntimeException e) { throw hystrixRuntimeExceptionToThrowable(metaHolder, e); } return result; } HystrixCommandAspect里定义了两个注解的AspectJ Pointcut:@HystrixCommand, @HystrixCollapser。所有带这两个注解的spring bean都会经过AOP处理 在@Around AOP处理函数里,可以看到Hystrix会创建出HystrixInvokable,再通过CommandExecutor来执行 spring-cloud-starter-netflix-hystrix的代码分析 @EnableHystrix 引入了@EnableCircuitBreaker,@EnableCircuitBreaker引入了EnableCircuitBreakerImportSelector @EnableCircuitBreaker public @interface EnableHystrix { } @Import(EnableCircuitBreakerImportSelector.class) public @interface EnableCircuitBreaker { } EnableCircuitBreakerImportSelector继承了SpringFactoryImportSelector<EnableCircuitBreaker>,使spring加载META-INF/spring.factories里的EnableCircuitBreaker声明的配置 在META-INF/spring.factories里可以找到下面的配置,也就是引入了HystrixCircuitBreakerConfiguration。 org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker=\ org.springframework.cloud.netflix.hystrix.HystrixCircuitBreakerConfiguration 在HystrixCircuitBreakerConfiguration里可以发现创建了HystrixCommandAspect @Configuration public class HystrixCircuitBreakerConfiguration { @Bean public HystrixCommandAspect hystrixCommandAspect() { return new HystrixCommandAspect(); } 可见spring-cloud-starter-netflix-hystrix实际上也是创建了HystrixCommandAspect来集成Hystrix。 另外spring-cloud-starter-netflix-hystrix里还有metrics, health, dashboard等集成。 总结 对于dubbo provider的@Service是一个spring bean,直接在上面配置@HystrixCommand即可 对于dubbo consumer的@Reference,可以通过加一层简单的spring method包装,配置@HystrixCommand即可 Hystrix本身提供HystrixCommandAspect来集成Spring AOP,配置了@HystrixCommand和@HystrixCollapser的spring method都会被Hystrix处理 链接 https://github.com/Netflix/Hystrix https://github.com/apache/incubator-dubbo http://start.dubbo.io/ https://cloud.spring.io/spring-cloud-netflix/single/spring-cloud-netflix.html#_circuit_breaker_hystrix_clients
分支预测 在stackoverflow上有一个非常有名的问题: 为什么处理有序数组要比非有序数组快,可见分支预测对代码运行效率有非常大的影响。 现代CPU都支持分支预测(branch prediction)和指令流水线(instruction pipeline),这两个结合可以极大提高CPU效率。对于像简单的if跳转,CPU是可以比较好地做分支预测的。但是对于switch跳转,CPU则没有太多的办法。switch本质上是据索引,从地址数组里取地址再跳转。 要提高代码执行效率,一个重要的原则就是尽量避免CPU把流水线清空,那么提高分支预测的成功率就非常重要。 那么对于代码里,如果某个switch分支概率很高,是否可以考虑代码层面帮CPU把判断提前,来提高代码执行效率呢? Dubbo里ChannelEventRunnable的switch判断 在ChannelEventRunnable里有一个switch来判断channel state,然后做对应的逻辑:查看 一个channel建立起来之后,超过99.9%情况它的state都是ChannelState.RECEIVED,那么可以考虑把这个判断提前。 benchmark验证 下面通过jmh来验证下: public class TestBenchMarks { public enum ChannelState { CONNECTED, DISCONNECTED, SENT, RECEIVED, CAUGHT } @State(Scope.Benchmark) public static class ExecutionPlan { @Param({ "1000000" }) public int size; public ChannelState[] states = null; @Setup public void setUp() { ChannelState[] values = ChannelState.values(); states = new ChannelState[size]; Random random = new Random(new Date().getTime()); for (int i = 0; i < size; i++) { int nextInt = random.nextInt(1000000); if (nextInt > 100) { states[i] = ChannelState.RECEIVED; } else { states[i] = values[nextInt % values.length]; } } } } @Fork(value = 5) @Benchmark @BenchmarkMode(Mode.Throughput) public void benchSiwtch(ExecutionPlan plan, Blackhole bh) { int result = 0; for (int i = 0; i < plan.size; ++i) { switch (plan.states[i]) { case CONNECTED: result += ChannelState.CONNECTED.ordinal(); break; case DISCONNECTED: result += ChannelState.DISCONNECTED.ordinal(); break; case SENT: result += ChannelState.SENT.ordinal(); break; case RECEIVED: result += ChannelState.RECEIVED.ordinal(); break; case CAUGHT: result += ChannelState.CAUGHT.ordinal(); break; } } bh.consume(result); } @Fork(value = 5) @Benchmark @BenchmarkMode(Mode.Throughput) public void benchIfAndSwitch(ExecutionPlan plan, Blackhole bh) { int result = 0; for (int i = 0; i < plan.size; ++i) { ChannelState state = plan.states[i]; if (state == ChannelState.RECEIVED) { result += ChannelState.RECEIVED.ordinal(); } else { switch (state) { case CONNECTED: result += ChannelState.CONNECTED.ordinal(); break; case SENT: result += ChannelState.SENT.ordinal(); break; case DISCONNECTED: result += ChannelState.DISCONNECTED.ordinal(); break; case CAUGHT: result += ChannelState.CAUGHT.ordinal(); break; } } } bh.consume(result); } } benchSiwtch里是纯switch判断 benchIfAndSwitch 里用一个if提前判断state是否ChannelState.RECEIVED benchmark结果是: Result "io.github.hengyunabc.jmh.TestBenchMarks.benchSiwtch": 576.745 ±(99.9%) 6.806 ops/s [Average] (min, avg, max) = (490.348, 576.745, 618.360), stdev = 20.066 CI (99.9%): [569.939, 583.550] (assumes normal distribution) # Run complete. Total time: 00:06:48 Benchmark (size) Mode Cnt Score Error Units TestBenchMarks.benchIfAndSwitch 1000000 thrpt 100 1535.867 ± 61.212 ops/s TestBenchMarks.benchSiwtch 1000000 thrpt 100 576.745 ± 6.806 ops/s 可以看到提前if判断的确提高了代码效率,这种技巧可以放在性能要求严格的地方。 Benchmark代码:https://github.com/hengyunabc/jmh-demo 总结 switch对于CPU来说难以做分支预测 某些switch条件如果概率比较高,可以考虑单独提前if判断,充分利用CPU的分支预测机制
spring boot 对于jsp支持的限制 对于jsp的支持,Spring Boot官方只支持了war的打包方式,不支持fat jar。参考官方文档: https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-developing-web-applications.html#boot-features-jsp-limitations 这里spring boot官方说是tomcat的问题,实际上是spring boot自己改变了打包格式引起的。参考之前的文章:http://hengyunabc.github.io/spring-boot-classloader/#spring-boot-1-3-%E5%92%8C-1-4-%E7%89%88%E6%9C%AC%E7%9A%84%E5%8C%BA%E5%88%AB 原来的结构之下,tomcat是可以扫描到fat jar里的META-INF/resources目录下面的资源的。在增加了BOOT-INF/classes之后,则tomcat扫描不到了。 那么怎么解决这个问题呢?下面给出一种方案,来实现对spring boot fat jar/exploded directory的jsp的支持。 个性化配置tomcat,把BOOT-INF/classes 加入tomcat的ResourceSet 在tomcat里,所有扫描到的资源都会放到所谓的ResourceSet里。比如servlet 3规范里的应用jar包的META-INF/resources就是一个ResourceSet。 现在需要想办法把spring boot打出来的fat jar的BOOT-INF/classes目录加到ResourceSet里。 下面通过实现tomcat的 LifecycleListener接口,在Lifecycle.CONFIGURE_START_EVENT事件里,获取到BOOT-INF/classes的URL,再把这个URL加入到WebResourceSet里。 /** * Add main class fat jar/exploded directory into tomcat ResourceSet. * * @author hengyunabc 2017-07-29 * */ public class StaticResourceConfigurer implements LifecycleListener { private final Context context; StaticResourceConfigurer(Context context) { this.context = context; } @Override public void lifecycleEvent(LifecycleEvent event) { if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) { URL location = this.getClass().getProtectionDomain().getCodeSource().getLocation(); if (ResourceUtils.isFileURL(location)) { // when run as exploded directory String rootFile = location.getFile(); if (rootFile.endsWith("/BOOT-INF/classes/")) { rootFile = rootFile.substring(0, rootFile.length() - "/BOOT-INF/classes/".length() + 1); } if (!new File(rootFile, "META-INF" + File.separator + "resources").isDirectory()) { return; } try { location = new File(rootFile).toURI().toURL(); } catch (MalformedURLException e) { throw new IllegalStateException("Can not add tomcat resources", e); } } String locationStr = location.toString(); if (locationStr.endsWith("/BOOT-INF/classes!/")) { // when run as fat jar locationStr = locationStr.substring(0, locationStr.length() - "/BOOT-INF/classes!/".length() + 1); try { location = new URL(locationStr); } catch (MalformedURLException e) { throw new IllegalStateException("Can not add tomcat resources", e); } } this.context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR, "/", location, "/META-INF/resources"); } } } 为了让spring boot embedded tomcat加载这个 StaticResourceConfigurer,还需要一个EmbeddedServletContainerCustomizer的配置: @Configuration @ConditionalOnProperty(name = "tomcat.staticResourceCustomizer.enabled", matchIfMissing = true) public class TomcatConfiguration { @Bean public EmbeddedServletContainerCustomizer staticResourceCustomizer() { return new EmbeddedServletContainerCustomizer() { @Override public void customize(ConfigurableEmbeddedServletContainer container) { if (container instanceof TomcatEmbeddedServletContainerFactory) { ((TomcatEmbeddedServletContainerFactory) container) .addContextCustomizers(new TomcatContextCustomizer() { @Override public void customize(Context context) { context.addLifecycleListener(new StaticResourceConfigurer(context)); } }); } } }; } } 这样子的话,spring boot就可以支持fat jar里的jsp资源了。 demo地址: https://github.com/hengyunabc/spring-boot-fat-jar-jsp-sample 总结 spring boot改变了打包结构,导致tomcat没有办法扫描到fat jar里的/BOOT-INF/classes 通过一个StaticResourceConfigurer把fat jar里的/BOOT-INF/classes加到tomcat的ResourceSet来解决问题
java.lang.ArrayStoreException 分析 这个demo来说明怎样排查一个spring boot 1应用升级到spring boot 2时可能出现的java.lang.ArrayStoreException。 demo地址:https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-ArrayStoreException demo里有两个模块,springboot1-starter和springboot2-demo。 在springboot1-starter模块里,是一个简单的HealthIndicator实现 public class MyHealthIndicator extends AbstractHealthIndicator { @Override protected void doHealthCheck(Builder builder) throws Exception { builder.status(Status.UP); builder.withDetail("hello", "world"); } } @Configuration @AutoConfigureBefore(EndpointAutoConfiguration.class) @AutoConfigureAfter(HealthIndicatorAutoConfiguration.class) @ConditionalOnClass(value = { HealthIndicator.class }) public class MyHealthIndicatorAutoConfiguration { @Bean @ConditionalOnMissingBean(MyHealthIndicator.class) @ConditionalOnEnabledHealthIndicator("my") public MyHealthIndicator myHealthIndicator() { return new MyHealthIndicator(); } } springboot2-demo则是一个简单的spring boot2应用,引用了springboot1-starter模块。 把工程导入IDE,执行springboot2-demo里的ArrayStoreExceptionDemoApplication,抛出的异常是 Caused by: java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy at sun.reflect.annotation.AnnotationParser.parseClassArray(AnnotationParser.java:724) ~[na:1.8.0_112] at sun.reflect.annotation.AnnotationParser.parseArray(AnnotationParser.java:531) ~[na:1.8.0_112] at sun.reflect.annotation.AnnotationParser.parseMemberValue(AnnotationParser.java:355) ~[na:1.8.0_112] at sun.reflect.annotation.AnnotationParser.parseAnnotation2(AnnotationParser.java:286) ~[na:1.8.0_112] at sun.reflect.annotation.AnnotationParser.parseAnnotations2(AnnotationParser.java:120) ~[na:1.8.0_112] at sun.reflect.annotation.AnnotationParser.parseAnnotations(AnnotationParser.java:72) ~[na:1.8.0_112] at java.lang.Class.createAnnotationData(Class.java:3521) ~[na:1.8.0_112] at java.lang.Class.annotationData(Class.java:3510) ~[na:1.8.0_112] at java.lang.Class.createAnnotationData(Class.java:3526) ~[na:1.8.0_112] at java.lang.Class.annotationData(Class.java:3510) ~[na:1.8.0_112] at java.lang.Class.getAnnotation(Class.java:3415) ~[na:1.8.0_112] at java.lang.reflect.AnnotatedElement.isAnnotationPresent(AnnotatedElement.java:258) ~[na:1.8.0_112] at java.lang.Class.isAnnotationPresent(Class.java:3425) ~[na:1.8.0_112] at org.springframework.core.annotation.AnnotatedElementUtils.hasAnnotation(AnnotatedElementUtils.java:575) ~[spring-core-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.isHandler(RequestMappingHandlerMapping.java:177) ~[spring-webmvc-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.initHandlerMethods(AbstractHandlerMethodMapping.java:217) ~[spring-webmvc-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.afterPropertiesSet(AbstractHandlerMethodMapping.java:188) ~[spring-webmvc-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping.afterPropertiesSet(RequestMappingHandlerMapping.java:129) ~[spring-webmvc-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1769) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1706) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] ... 16 common frames omitted 使用 Java Exception Breakpoint 下面来排查这个问题。 在IDE里,新建一个断点,类型是Java Exception Breakpoint(如果不清楚怎么添加,可以搜索对应IDE的使用文档),异常类是上面抛出来的java.lang.ArrayStoreException。 当断点起效时,查看AnnotationUtils.findAnnotation(Class<?>, Class<A>, Set<Annotation>) line: 686 函数的参数。 可以发现 clazz是 class com.example.springboot1starter.MyHealthIndicatorAutoConfiguration$$EnhancerBySpringCGLIB$$945c1f annotationType是 interface org.springframework.boot.actuate.endpoint.annotation.Endpoint 说明是尝试从MyHealthIndicatorAutoConfiguration里查找@Endpoint信息时出错的。 MyHealthIndicatorAutoConfiguration上的确没有@Endpoint,但是为什么抛出java.lang.ArrayStoreException? 尝试以简单例子复现异常 首先尝试直接 new MyHealthIndicatorAutoConfiguration : public static void main(String[] args) { MyHealthIndicatorAutoConfiguration cc = new MyHealthIndicatorAutoConfiguration(); } 本以为会抛出异常来,但是发现执行正常。 再仔细看异常栈,可以发现是在at java.lang.Class.getDeclaredAnnotation(Class.java:3458)抛出的异常,则再尝试下面的代码: public static void main(String[] args) { MyHealthIndicatorAutoConfiguration.class.getDeclaredAnnotation(Endpoint.class); } 发现可以复现异常了: Exception in thread "main" java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy at sun.reflect.annotation.AnnotationParser.parseClassArray(AnnotationParser.java:724) at sun.reflect.annotation.AnnotationParser.parseArray(AnnotationParser.java:531) at sun.reflect.annotation.AnnotationParser.parseMemberValue(AnnotationParser.java:355) at sun.reflect.annotation.AnnotationParser.parseAnnotation2(AnnotationParser.java:286) at sun.reflect.annotation.AnnotationParser.parseAnnotations2(AnnotationParser.java:120) at sun.reflect.annotation.AnnotationParser.parseAnnotations(AnnotationParser.java:72) at java.lang.Class.createAnnotationData(Class.java:3521) at java.lang.Class.annotationData(Class.java:3510) at java.lang.Class.getDeclaredAnnotation(Class.java:3458) 为什么会是java.lang.ArrayStoreException 再仔细看异常信息:java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy ArrayStoreException是一个数组越界的异常,它只有一个String信息,并没有cause。 那么我们尝试在 sun.reflect.annotation.TypeNotPresentExceptionProxy 的构造函数里打断点。 public class TypeNotPresentExceptionProxy extends ExceptionProxy { private static final long serialVersionUID = 5565925172427947573L; String typeName; Throwable cause; public TypeNotPresentExceptionProxy(String typeName, Throwable cause) { this.typeName = typeName; this.cause = cause; } 在断点里,我们可以发现: typeName是 org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration cause是 java.lang.ClassNotFoundException: org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration 终于真相大白了,是找不到org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration这个类。 那么它是怎么变成ArrayStoreException的呢? 仔细看下代码,可以发现AnnotationParser.parseClassValue把异常包装成为Object //sun.reflect.annotation.AnnotationParser.parseClassValue(ByteBuffer, ConstantPool, Class<?>) private static Object parseClassValue(ByteBuffer buf, ConstantPool constPool, Class<?> container) { int classIndex = buf.getShort() & 0xFFFF; try { try { String sig = constPool.getUTF8At(classIndex); return parseSig(sig, container); } catch (IllegalArgumentException ex) { // support obsolete early jsr175 format class files return constPool.getClassAt(classIndex); } } catch (NoClassDefFoundError e) { return new TypeNotPresentExceptionProxy("[unknown]", e); } catch (TypeNotPresentException e) { return new TypeNotPresentExceptionProxy(e.typeName(), e.getCause()); } } 然后在sun.reflect.annotation.AnnotationParser.parseClassArray(int, ByteBuffer, ConstantPool, Class<?>)里尝试直接设置到数组里 // sun.reflect.annotation.AnnotationParser.parseClassArray(int, ByteBuffer, ConstantPool, Class<?>) result[i] = parseClassValue(buf, constPool, container); 而这里数组越界了,ArrayStoreException只有越界的Object的类型信息,也就是上面的 java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy 解决问题 发现是java.lang.ClassNotFoundException: org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration,则加上@ConditionalOnClass的检查就可以了: @Configuration @AutoConfigureBefore(EndpointAutoConfiguration.class) @AutoConfigureAfter(HealthIndicatorAutoConfiguration.class) @ConditionalOnClass(value = {HealthIndicator.class, EndpointAutoConfiguration.class}) public class MyHealthIndicatorAutoConfiguration { 准确来说是spring boot2把一些类的package改了: spring boot 1里类名是: org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration spring boot 2里类名是: org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration 总结 当类加载时,并不会加载它的annotation的field所引用的Class<?>,当调用Class.getDeclaredAnnotation(Class<A>)里才会加载 以上面的例子来说,就是@AutoConfigureBefore(EndpointAutoConfiguration.class)里的EndpointAutoConfiguration并不会和MyHealthIndicatorAutoConfiguration一起被加载。 jdk内部的解析字节码的代码不合理,把ClassNotFoundException异常吃掉了 排查问题需要一步步深入调试
jdk9后加载lib/modules的方式 从jdk的代码里可以看出来,默认的实现加载lib/modules是用mmap来加载的。 class NativeImageBuffer { static { java.security.AccessController.doPrivileged( new java.security.PrivilegedAction<Void>() { public Void run() { System.loadLibrary("jimage"); return null; } }); } native static ByteBuffer getNativeMap(String imagePath); } 在jimage动态库里最终是一个cpp实现的ImageFileReader来读取的。它在64位os上使用的是mmap方式: https://github.com/dmlloyd/openjdk/blob/jdk/jdk10/src/java.base/share/native/libjimage/imageFile.cpp#L44 启动多个jvm时会有好处: 减少内存占用 加快启动速度 突然有个想法,怎么验证多个jvm的确共享了内存? 下面来验证一下,思路是: 先获取进程的mmap信息 获取jvm进程映射modules的虚拟地址 从虚拟地址转换为物理地址 启动两个jvm进程,计算它们映射modules是否物理地址是一样的 linux下查看进程的mmap信息 使用pmap -x $pid命令 直接查看 cat /proc/$pid/maps文件的内容 启动一个jshell之后,用pmap查看mmap信息,其中RSS(resident set size)列表示真实占用的内存。: $ pmap -x 24615 24615: jdk9/jdk-9.0.4/bin/jshell Address Kbytes RSS Dirty Mode Mapping 0000000000400000 4 4 0 r-x-- jshell 0000000000601000 4 4 4 rw--- jshell 000000000111b000 132 120 120 rw--- [ anon ] ... 00007f764192c000 88 64 0 r-x-- libnet.so 00007f7641942000 2048 0 0 ----- libnet.so 00007f7641b42000 4 4 4 rw--- libnet.so 00007f7641b43000 2496 588 588 rwx-- [ anon ] ... 00007f7650b43000 185076 9880 0 r--s- modules 00007f765c000000 5172 5124 5124 rw--- [ anon ] ---------------- ------- ------- ------- total kB 2554068 128756 106560 我们可以找到modules文件的信息: 00007f7650b43000 185076 9880 0 r--s- modules 它的文件映射大小是185076kb,实际使用内存大小是9880kb。 linux kernel关于pagemap的说明 上面我们获取到了modules的虚拟地址,但是还需要转换为物理地址。 正常来说一个进程是没有办法知道它自己的虚拟地址对应的是什么物理地址。不过我们用linux kernel提供的信息可以读取,转换为物理地址。 linux每个进程都有个/proc/$pid/pagemap文件,里面记录了内存页的信息: https://www.kernel.org/doc/Documentation/vm/pagemap.txt 简而言之,在pagemap里每一个virtual page都有一个对应的64 bit的信息: * Bits 0-54 page frame number (PFN) if present * Bits 0-4 swap type if swapped * Bits 5-54 swap offset if swapped * Bit 55 pte is soft-dirty (see Documentation/vm/soft-dirty.txt) * Bit 56 page exclusively mapped (since 4.2) * Bits 57-60 zero * Bit 61 page is file-page or shared-anon (since 3.5) * Bit 62 page swapped * Bit 63 page present 只要把虚拟地址转换为pagemap文件里的offset,就可以读取具体的virtual page信息。计算方法是: // getpagesize()是系统调用 // 64bit是8字节 long virtualPageIndex = virtualAddress / getpagesize() offset = virtualPageIndex * 8 从offset里读取出来的64bit里,可以获取到page frame number,如果想要得到真正的物理地址,还需要再转换: // pageFrameNumber * getpagesize() 获取page的开始地址 // virtualAddress % getpagesize() 获取到page里的偏移地址 long pageFrameNumber = // read from pagemap file physicalAddress = pageFrameNumber * getpagesize() + virtualAddress % getpagesize(); 虚拟地址转换物理地址的代码 参考这里的代码:https://github.com/cirosantilli/linux-kernel-module-cheat/blob/master/kernel_module/user/common.h 得到的一个从虚拟地址转换为物理地址的代码: #define _POSIX_C_SOURCE 200809L #include <fcntl.h> /* open */ #include <stdint.h> /* uint64_t */ #include <stdlib.h> /* size_t */ #include <unistd.h> /* pread, sysconf */ int BUFSIZ = 1024; typedef struct { uint64_t pfn : 54; unsigned int soft_dirty : 1; unsigned int file_page : 1; unsigned int swapped : 1; unsigned int present : 1; } PagemapEntry; /* Parse the pagemap entry for the given virtual address. * * @param[out] entry the parsed entry * @param[in] pagemap_fd file descriptor to an open /proc/pid/pagemap file * @param[in] vaddr virtual address to get entry for * @return 0 for success, 1 for failure */ int pagemap_get_entry(PagemapEntry *entry, int pagemap_fd, uintptr_t vaddr) { size_t nread; ssize_t ret; uint64_t data; nread = 0; while (nread < sizeof(data)) { ret = pread(pagemap_fd, &data, sizeof(data), (vaddr / sysconf(_SC_PAGE_SIZE)) * sizeof(data) + nread); nread += ret; if (ret <= 0) { return 1; } } entry->pfn = data & (((uint64_t)1 << 54) - 1); entry->soft_dirty = (data >> 54) & 1; entry->file_page = (data >> 61) & 1; entry->swapped = (data >> 62) & 1; entry->present = (data >> 63) & 1; return 0; } /* Convert the given virtual address to physical using /proc/PID/pagemap. * * @param[out] paddr physical address * @param[in] pid process to convert for * @param[in] vaddr virtual address to get entry for * @return 0 for success, 1 for failure */ int virt_to_phys_user(uintptr_t *paddr, pid_t pid, uintptr_t vaddr) { char pagemap_file[BUFSIZ]; int pagemap_fd; snprintf(pagemap_file, sizeof(pagemap_file), "/proc/%ju/pagemap", (uintmax_t)pid); pagemap_fd = open(pagemap_file, O_RDONLY); if (pagemap_fd < 0) { return 1; } PagemapEntry entry; if (pagemap_get_entry(&entry, pagemap_fd, vaddr)) { return 1; } close(pagemap_fd); *paddr = (entry.pfn * sysconf(_SC_PAGE_SIZE)) + (vaddr % sysconf(_SC_PAGE_SIZE)); return 0; } int main(int argc, char ** argv){ char *end; int pid; uintptr_t virt_addr; uintptr_t paddr; int return_code; pid = strtol(argv[1],&end, 10); virt_addr = strtol(argv[2], NULL, 16); return_code = virt_to_phys_user(&paddr, pid, virt_addr); if(return_code == 0) printf("Vaddr: 0x%lx, paddr: 0x%lx \n", virt_addr, paddr); else printf("error\n"); } 另外,收集到一些可以读取pagemap信息的工具: https://github.com/dwks/pagemap 检查两个jvm进程是否映射modules的物理地址一致 先启动两个jshell $ jps 25105 jdk.internal.jshell.tool.JShellToolProvider 25142 jdk.internal.jshell.tool.JShellToolProvider 把上面转换地址的代码保存为mymap.c,再编绎 gcc mymap.c -o mymap 获取两个jvm的modules的虚拟地址,并转换为物理地址 $ pmap -x 25105 | grep modules 00007f82b4b43000 185076 9880 0 r--s- modules $ sudo ./mymap 25105 00007f82b4b43000 Vaddr: 0x7f82b4b43000, paddr: 0x33598000 $ pmap -x 25142 | grep modules 00007ff220504000 185076 10064 0 r--s- modules $ sudo ./mymap 25142 00007ff220504000 Vaddr: 0x7ff220504000, paddr: 0x33598000 可以看到两个jvm进程映射modules的物理地址是一样的,证实了最开始的想法。 kernel 里的 page-types 工具 其实在kernel里自带有一个工具page-types可以输出一个page信息,可以通过下面的方式来获取内核源码,然后自己编绎: sudo apt-get source linux-image-$(uname -r) sudo apt-get build-dep linux-image-$(uname -r) 到tools/vm目录下面,可以直接sudo make编绎。 sudo ./page-types -p 25105 flags page-count MB symbolic-flags long-symbolic-flags 0x0000000000000000 2 0 ____________________________________ 0x0000000000400000 14819 57 ______________________t_____________ thp 0x0000000000000800 1 0 ___________M________________________ mmap 0x0000000000000828 33 0 ___U_l_____M________________________ uptodate,lru,mmap 0x000000000000086c 663 2 __RU_lA____M________________________ referenced,uptodate,lru,active,mmap 0x000000000000087c 2 0 __RUDlA____M________________________ referenced,uptodate,dirty,lru,active,mmap 0x0000000000005868 10415 40 ___U_lA____Ma_b_____________________ uptodate,lru,active,mmap,anonymous,swapbacked 0x0000000000405868 29 0 ___U_lA____Ma_b_______t_____________ uptodate,lru,active,mmap,anonymous,swapbacked,thp 0x000000000000586c 5 0 __RU_lA____Ma_b_____________________ referenced,uptodate,lru,active,mmap,anonymous,swapbacked 0x0000000000005878 356 1 ___UDlA____Ma_b_____________________ uptodate,dirty,lru,active,mmap,anonymous,swapbacked total 26325 102 jdk8及之前加载jar也是使用mmap的方式 在验证了jdk9加载lib/modules之后,随便检查了下jdk8的进程,发现在加载jar包时,也是使用mmap的方式。 一个tomcat进程的map信息如下: $ pmap -x 27226 | grep jar ... 00007f42c00d4000 16 16 0 r--s- tomcat-dbcp.jar 00007f42c09b7000 1892 1892 0 r--s- rt.jar 00007f42c45e5000 76 76 0 r--s- catalina.jar 00007f42c45f8000 12 12 0 r--s- tomcat-i18n-es.jar 00007f42c47da000 4 4 0 r--s- sunec.jar 00007f42c47db000 8 8 0 r--s- websocket-api.jar 00007f42c47dd000 4 4 0 r--s- tomcat-juli.jar 00007f42c47de000 4 4 0 r--s- commons-daemon.jar 00007f42c47df000 4 4 0 r--s- bootstrap.jar 可以发现一些有意思的点: 所有jar包的Kbytes 和 RSS(resident set size)是相等的,也就是说整个jar包都被加载到共享内存里了 从URLClassLoader的实现代码来看,它在加载资源时,需要扫描所有的jar包,所以会导致整个jar都要被加载到内存里 对比jdk9里的modules,它的RSS并不是很高,原因是JImage的格式设计合理。所以jdk9后,jvm占用真实内存会降低。 jdk8及之前的 sun.zip.disableMemoryMapping 参数 在jdk6里引入一个 sun.zip.disableMemoryMapping参数,禁止掉利用mmap来加载zip包。http://www.oracle.com/technetwork/java/javase/documentation/overview-156328.html#6u21-rev-b09 https://bugs.openjdk.java.net/browse/JDK-8175192 在jdk9里把这个参数去掉了。因为jdk9之后,jdk本身存在lib/modules 这个文件里了。 总结 linux下可以用pmap来获取进程mmap信息 通过读取/proc/$pid/pagemap可以获取到内存页的信息,并可以把虚拟地址转换为物理地址 jdk9把类都打包到lib/modules,也就是JImage格式,可以减少真实内存占用 jdk9多个jvm可以共用lib/modules映射的内存 默认情况下jdk8及以前是用mmap来加载jar包
写在前面 这个demo来说明怎么排查一个@Transactional引起的NullPointerException。 https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-Transactional-NullPointerException 定位 NullPointerException 的代码 Demo是一个简单的spring事务例子,提供了下面一个StudentDao,并用@Transactional来声明事务: @Component @Transactional public class StudentDao { @Autowired private SqlSession sqlSession; public Student selectStudentById(long id) { return sqlSession.selectOne("selectStudentById", id); } public final Student finalSelectStudentById(long id) { return sqlSession.selectOne("selectStudentById", id); } } 应用启动后,会依次调用selectStudentById和finalSelectStudentById: @PostConstruct public void init() { studentDao.selectStudentById(1); studentDao.finalSelectStudentById(1); } 用mvn spring-boot:run 或者把工程导入IDE里启动,抛出来的异常信息是: Caused by: java.lang.NullPointerException at sample.mybatis.dao.StudentDao.finalSelectStudentById(StudentDao.java:27) at com.example.demo.transactional.nullpointerexception.DemoNullPointerExceptionApplication.init(DemoNullPointerExceptionApplication.java:30) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleElement.invoke(InitDestroyAnnotationBeanPostProcessor.java:366) at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:311) 为什么应用代码里执行selectStudentById没有问题,而执行finalSelectStudentById就抛出NullPointerException? 同一个bean里,明明SqlSession sqlSession已经被注入了,在selectStudentById里它是非null的。为什么finalSelectStudentById函数里是null? 获取实际运行时的类名 当然,我们对比两个函数,可以知道是因为finalSelectStudentById的修饰符是final。但是具体原因是什么呢? 我们先在抛出异常的地方打上断点,调试代码,获取到具体运行时的class是什么: System.err.println(studentDao.getClass()); 打印的结果是: class sample.mybatis.dao.StudentDao$$EnhancerBySpringCGLIB$$210b005d 可以看出是一个被spring aop处理过的类,但是它的具体字节码内容是什么呢? dumpclass分析 我们使用dumpclass工具来把jvm里的类dump出来: https://github.com/hengyunabc/dumpclass wget http://search.maven.org/remotecontent?filepath=io/github/hengyunabc/dumpclass/0.0.1/dumpclass-0.0.1.jar -O dumpclass.jar 找到java进程pid: $ jps 5907 DemoNullPointerExceptionApplication 把相关的类都dump下来: sudo java -jar dumpclass.jar 5907 'sample.mybatis.dao.StudentDao*' /tmp/dumpresult 反汇编分析 用javap或者图形化工具jd-gui来反编绎sample.mybatis.dao.StudentDao$$EnhancerBySpringCGLIB$$210b005d。 反编绎后的结果是: class StudentDao$$EnhancerBySpringCGLIB$$210b005d extends StudentDao StudentDao$$EnhancerBySpringCGLIB$$210b005d里没有finalSelectStudentById相关的内容 selectStudentById实际调用的是this.CGLIB$CALLBACK_0,即MethodInterceptor tmp4_1,等下我们实际debug,看具体的类型 public final Student selectStudentById(long paramLong) { try { MethodInterceptor tmp4_1 = this.CGLIB$CALLBACK_0; if (tmp4_1 == null) { tmp4_1; CGLIB$BIND_CALLBACKS(this); } MethodInterceptor tmp17_14 = this.CGLIB$CALLBACK_0; if (tmp17_14 != null) { Object[] tmp29_26 = new Object[1]; Long tmp35_32 = new java/lang/Long; Long tmp36_35 = tmp35_32; tmp36_35; tmp36_35.<init>(paramLong); tmp29_26[0] = tmp35_32; return (Student)tmp17_14.intercept(this, CGLIB$selectStudentById$0$Method, tmp29_26, CGLIB$selectStudentById$0$Proxy); } return super.selectStudentById(paramLong); } catch (RuntimeException|Error localRuntimeException) { throw localRuntimeException; } catch (Throwable localThrowable) { throw new UndeclaredThrowableException(localThrowable); } } 再来实际debug,尽管StudentDao$$EnhancerBySpringCGLIB$$210b005d的代码不能直接看到,但是还是可以单步执行的。 在debug时,可以看到 StudentDao$$EnhancerBySpringCGLIB$$210b005d里的所有field都是null this.CGLIB$CALLBACK_0的实际类型是CglibAopProxy$DynamicAdvisedInterceptor,在这个Interceptor里实际保存了原始的target对象 CglibAopProxy$DynamicAdvisedInterceptor在经过TransactionInterceptor处理之后,最终会用反射调用自己保存的原始target对象 抛出异常的原因 所以整理下整个分析: 在使用了@Transactional之后,spring aop会生成一个cglib代理类,实际用户代码里@Autowired注入的StudentDao也是这个代理类的实例 cglib生成的代理类StudentDao$$EnhancerBySpringCGLIB$$210b005d继承自StudentDao StudentDao$$EnhancerBySpringCGLIB$$210b005d里的所有field都是null StudentDao$$EnhancerBySpringCGLIB$$210b005d在调用selectStudentById,实际上通过CglibAopProxy$DynamicAdvisedInterceptor,最终会用反射调用自己保存的原始target对象 所以selectStudentById函数的调用没有问题 那么为什么finalSelectStudentById函数里的SqlSession sqlSession会是null,然后抛出NullPointerException? StudentDao$$EnhancerBySpringCGLIB$$210b005d里的所有field都是null finalSelectStudentById函数的修饰符是final,cglib没有办法重写这个函数 当执行到finalSelectStudentById里,实际执行的是原始的StudentDao里的代码 但是对象是StudentDao$$EnhancerBySpringCGLIB$$210b005d的实例,它里面的所有field都是null,所以会抛出NullPointerException 解决问题办法 最简单的当然是把finalSelectStudentById函数的final修饰符去掉 还有一种办法,在StudentDao里不要直接使用sqlSession,而通过getSqlSession()函数,这样cglib也会处理getSqlSession(),返回原始的target对象 总结 排查问题多debug,看实际运行时的对象信息 对于cglib生成类的字节码,可以用dumpclass工具来dump,再反编绎分析
写在前面 这个demo来说明怎么排查一个常见的spring expected single matching bean but found 2的异常。 https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-expected-single 调试排查 expected single matching bean but found 2 的错误 把工程导入IDE里,直接启动应用,抛出来的异常信息是: Caused by: org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'javax.sql.DataSource' available: expected single matching bean but found 2: h2DataSource1,h2DataSource2 at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveNamedBean(DefaultListableBeanFactory.java:1041) ~[spring-beans-4.3.9.RELEASE.jar:4.3.9.RELEASE] at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:345) ~[spring-beans-4.3.9.RELEASE.jar:4.3.9.RELEASE] at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:340) ~[spring-beans-4.3.9.RELEASE.jar:4.3.9.RELEASE] at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1090) ~[spring-context-4.3.9.RELEASE.jar:4.3.9.RELEASE] at org.springframework.boot.autoconfigure.jdbc.DataSourceInitializer.init(DataSourceInitializer.java:71) ~[spring-boot-autoconfigure-1.4.7.RELEASE.jar:1.4.7.RELEASE] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_112] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_112] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_112] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_112] at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleElement.invoke(InitDestroyAnnotationBeanPostProcessor.java:366) ~[spring-beans-4.3.9.RELEASE.jar:4.3.9.RELEASE] at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(InitDestroyAnnotationBeanPostProcessor.java:311) ~[spring-beans-4.3.9.RELEASE.jar:4.3.9.RELEASE] at org.springframework.beans.factory.annotation.InitDestroyAnnotationBeanPostProcessor.postProcessBeforeInitialization(InitDestroyAnnotationBeanPostProcessor.java:134) ~[spring-beans-4.3.9.RELEASE.jar:4.3.9.RELEASE] ... 30 common frames omitted 很多人碰到这种错误时,就乱配置一通,找不到下手的办法。其实耐心排查下,是很简单的。 抛出异常的原因 异常信息写得很清楚了,在spring context里需要注入/获取到一个DataSource bean,但是现在spring context里出现了两个,它们的名字是:h2DataSource1,h2DataSource2 那么有两个问题: 应用是在哪里要注入/获取到一个DataSource bean? h2DataSource1,h2DataSource2 是在哪里定义的? 使用 Java Exception Breakpoint 在IDE里,新建一个断点,类型是Java Exception Breakpoint(如果不清楚怎么添加,可以搜索对应IDE的使用文档),异常类是上面抛出来的NoUniqueBeanDefinitionException。 当断点停住时,查看栈,可以很清楚地找到是在DataSourceInitializer.init() line: 71这里要获取DataSource: Thread [main] (Suspended (exception NoUniqueBeanDefinitionException)) owns: ConcurrentHashMap<K,V> (id=49) owns: Object (id=50) DefaultListableBeanFactory.resolveNamedBean(Class<T>, Object...) line: 1041 DefaultListableBeanFactory.getBean(Class<T>, Object...) line: 345 DefaultListableBeanFactory.getBean(Class<T>) line: 340 AnnotationConfigEmbeddedWebApplicationContext(AbstractApplicationContext).getBean(Class<T>) line: 1090 DataSourceInitializer.init() line: 71 NativeMethodAccessorImpl.invoke0(Method, Object, Object[]) line: not available [native method] NativeMethodAccessorImpl.invoke(Object, Object[]) line: 62 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 498 InitDestroyAnnotationBeanPostProcessor$LifecycleElement.invoke(Object) line: 366 InitDestroyAnnotationBeanPostProcessor$LifecycleMetadata.invokeInitMethods(Object, String) line: 311 CommonAnnotationBeanPostProcessor(InitDestroyAnnotationBeanPostProcessor).postProcessBeforeInitialization(Object, String) line: 134 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).applyBeanPostProcessorsBeforeInitialization(Object, String) line: 409 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).initializeBean(String, Object, RootBeanDefinition) line: 1620 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).doCreateBean(String, RootBeanDefinition, Object[]) line: 555 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).createBean(String, RootBeanDefinition, Object[]) line: 483 AbstractBeanFactory$1.getObject() line: 306 DefaultListableBeanFactory(DefaultSingletonBeanRegistry).getSingleton(String, ObjectFactory<?>) line: 230 DefaultListableBeanFactory(AbstractBeanFactory).doGetBean(String, Class<T>, Object[], boolean) line: 302 DefaultListableBeanFactory(AbstractBeanFactory).getBean(String, Class<T>, Object...) line: 220 DefaultListableBeanFactory.resolveNamedBean(Class<T>, Object...) line: 1018 DefaultListableBeanFactory.getBean(Class<T>, Object...) line: 345 DefaultListableBeanFactory.getBean(Class<T>) line: 340 DataSourceInitializerPostProcessor.postProcessAfterInitialization(Object, String) line: 62 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).applyBeanPostProcessorsAfterInitialization(Object, String) line: 423 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).initializeBean(String, Object, RootBeanDefinition) line: 1633 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).doCreateBean(String, RootBeanDefinition, Object[]) line: 555 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).createBean(String, RootBeanDefinition, Object[]) line: 483 AbstractBeanFactory$1.getObject() line: 306 DefaultListableBeanFactory(DefaultSingletonBeanRegistry).getSingleton(String, ObjectFactory<?>) line: 230 DefaultListableBeanFactory(AbstractBeanFactory).doGetBean(String, Class<T>, Object[], boolean) line: 302 DefaultListableBeanFactory(AbstractBeanFactory).getBean(String) line: 197 DefaultListableBeanFactory.preInstantiateSingletons() line: 761 AnnotationConfigEmbeddedWebApplicationContext(AbstractApplicationContext).finishBeanFactoryInitialization(ConfigurableListableBeanFactory) line: 867 AnnotationConfigEmbeddedWebApplicationContext(AbstractApplicationContext).refresh() line: 543 AnnotationConfigEmbeddedWebApplicationContext(EmbeddedWebApplicationContext).refresh() line: 122 SpringApplication.refresh(ApplicationContext) line: 762 SpringApplication.refreshContext(ConfigurableApplicationContext) line: 372 SpringApplication.run(String...) line: 316 SpringApplication.run(Object[], String[]) line: 1187 SpringApplication.run(Object, String...) line: 1176 DemoExpectedSingleApplication.main(String[]) line: 17 定位哪里要注入/使用DataSource 要获取DataSource具体的代码是: //org.springframework.boot.autoconfigure.jdbc.DataSourceInitializer.init() @PostConstruct public void init() { if (!this.properties.isInitialize()) { logger.debug("Initialization disabled (not running DDL scripts)"); return; } if (this.applicationContext.getBeanNamesForType(DataSource.class, false, false).length > 0) { this.dataSource = this.applicationContext.getBean(DataSource.class); } if (this.dataSource == null) { logger.debug("No DataSource found so not initializing"); return; } runSchemaScripts(); } this.applicationContext.getBean(DataSource.class); 要求spring context里只有一个DataSource的bean,但是应用里有两个,所以抛出了NoUniqueBeanDefinitionException。 从BeanDefinition获取bean具体定义的代码 我们再来看 h2DataSource1,h2DataSource2 是在哪里定义的? 上面进程断在了DefaultListableBeanFactory.resolveNamedBean(Class<T>, Object...) 函数里的 throw new NoUniqueBeanDefinitionException(requiredType, candidates.keySet()); 这一行。 那么我们在这里执行一下(如果不清楚,先搜索下IDE怎么在断点情况下执行代码): this.getBeanDefinition("h2DataSource1") 返回的信息是: Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=demoExpectedSingleApplication; factoryMethodName=h2DataSource1; initMethodName=null; destroyMethodName=(inferred); defined in com.example.demo.expected.single.DemoExpectedSingleApplication 可以很清楚地定位到h2DataSource1这个bean是在 com.example.demo.expected.single.DemoExpectedSingleApplication里定义的。 所以上面两个问题的答案是: 是spring boot代码里的DataSourceInitializer.init() line: 71这里要获取DataSource,并且只允许有一个DataSource实例 h2DataSource1,h2DataSource2 是在com.example.demo.expected.single.DemoExpectedSingleApplication里定义的 解决问题 上面排查到的原因是:应用定义了两个DataSource实例,但是spring boot却要求只有一个。那么有两种办法来解决: 使用@Primary来指定一个优先使用的DataSource,这样子spring boot里自动初始的代码会获取到@Primary的bean 把spring boot自动初始化DataSource相关的代码禁止掉,应用自己来控制所有的DataSource相关的bean 禁止的办法有两种: 在main函数上配置exclude @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class }) 在application.properties里配置: spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration 总结 排查spring初始化问题时,灵活使用Java Exception Breakpoint 从异常栈上,可以很容易找到哪里要注入/使用bean 从BeanDefinition可以找到bean是在哪里定义的(哪个Configuration类/xml)
写在前面 这个demo来说明怎么一步步排查一个常见的spring boot AutoConfiguration的错误。 https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-database-type-NONE 调试排查 Cannot determine embedded database driver class for database type NONE 的错误 把工程导入IDE里,直接启动应用,抛出来的异常信息是: Error starting ApplicationContext. To display the auto-configuration report re-run your application with 'debug' enabled. 2017-11-29 14:26:34.478 ERROR 29736 --- [ main] o.s.b.d.LoggingFailureAnalysisReporter : *************************** APPLICATION FAILED TO START *************************** Description: Cannot determine embedded database driver class for database type NONE Action: If you want an embedded database please put a supported one on the classpath. If you have database settings to be loaded from a particular profile you may need to active it (no profiles are currently active). 其实这时有两个思路,直接google搜索Cannot determine embedded database driver class for database type NONE,就可以找到解决办法。 第二种方式,仔细查看日志内容,可以发现有To display the auto-configuration report re-run your application with 'debug' enabled.。 搜索下这个,就可以在spring的官方网站上找到相关的信息:https://docs.spring.io/spring-boot/docs/current/reference/html/using-boot-auto-configuration.html 就是用户只要配置了debug这个开关,就会把auto-configuration 相关的信息打印出来。 熟悉spring的环境变量注入的话,就可以知道有几种打开这个的方式: 在args里增加--debug 在application.properties里增加debug=true 通过-Ddebug=true 增加debug开关之后的信息 增加debug开关之后,可以看到打印出了错误堆栈: 2017-11-29 14:33:08.776 DEBUG 29907 --- [ main] o.s.b.d.LoggingFailureAnalysisReporter : Application failed to start due to an exception org.springframework.boot.autoconfigure.jdbc.DataSourceProperties$DataSourceBeanCreationException: Cannot determine embedded database driver class for database type NONE. If you want an embedded database please put a supported one on the classpath. If you have database settings to be loaded from a particular profile you may need to active it (no profiles are currently active). at org.springframework.boot.autoconfigure.jdbc.DataSourceProperties.determineDriverClassName(DataSourceProperties.java:245) ~[spring-boot-autoconfigure-1.4.7.RELEASE.jar:1.4.7.RELEASE] at org.springframework.boot.autoconfigure.jdbc.DataSourceProperties.initializeDataSourceBuilder(DataSourceProperties.java:182) ~[spring-boot-autoconfigure-1.4.7.RELEASE.jar:1.4.7.RELEASE] at org.springframework.boot.autoconfigure.jdbc.DataSourceConfiguration.createDataSource(DataSourceConfiguration.java:42) ~[spring-boot-autoconfigure-1.4.7.RELEASE.jar:1.4.7.RELEASE] at org.springframework.boot.autoconfigure.jdbc.DataSourceConfiguration$Tomcat.dataSource(DataSourceConfiguration.java:53) ~[spring-boot-autoconfigure-1.4.7.RELEASE.jar:1.4.7.RELEASE] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_112] 抛出异常的代码是: /** * Determine the driver to use based on this configuration and the environment. * @return the driver to use * @since 1.4.0 */ public String determineDriverClassName() { if (StringUtils.hasText(this.driverClassName)) { Assert.state(driverClassIsLoadable(), "Cannot load driver class: " + this.driverClassName); return this.driverClassName; } String driverClassName = null; if (StringUtils.hasText(this.url)) { driverClassName = DatabaseDriver.fromJdbcUrl(this.url).getDriverClassName(); } if (!StringUtils.hasText(driverClassName)) { driverClassName = this.embeddedDatabaseConnection.getDriverClassName(); } if (!StringUtils.hasText(driverClassName)) { throw new DataSourceBeanCreationException(this.embeddedDatabaseConnection, this.environment, "driver class"); } return driverClassName; } 可以看出来是没有找到 DataSource 的driver class,然后抛出了 DataSourceBeanCreationException。 那么一种解决办法是,在maven依赖里加入一些 DataSource driver class。 但是应用自己的代码里并没有使用DataSource,哪里导致spring boot要创建一个DataSource对象? 哪里导致spring boot要创建DataSource 从异常栈上,可以找到DataSourceConfiguration$Tomcat 这个类,那么查找下它的引用,可以发现它是被org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.PooledDataSourceConfiguration import引入的。 @Configuration @Conditional(PooledDataSourceCondition.class) @ConditionalOnMissingBean({ DataSource.class, XADataSource.class }) @Import({ DataSourceConfiguration.Tomcat.class, DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Dbcp.class, DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.Generic.class }) protected static class PooledDataSourceConfiguration { } 那么 PooledDataSourceConfiguration 是怎么生效的呢?从代码上可以看到@Conditional(PooledDataSourceCondition.class)。 那么再看PooledDataSourceCondition的具体实现: /** * {@link AnyNestedCondition} that checks that either {@code spring.datasource.type} * is set or {@link PooledDataSourceAvailableCondition} applies. */ static class PooledDataSourceCondition extends AnyNestedCondition { PooledDataSourceCondition() { super(ConfigurationPhase.PARSE_CONFIGURATION); } @ConditionalOnProperty(prefix = "spring.datasource", name = "type") static class ExplicitType { } @Conditional(PooledDataSourceAvailableCondition.class) static class PooledDataSourceAvailable { } } PooledDataSourceCondition引入了@Conditional(PooledDataSourceAvailableCondition.class) : /** * {@link Condition} to test if a supported connection pool is available. */ static class PooledDataSourceAvailableCondition extends SpringBootCondition { @Override public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { ConditionMessage.Builder message = ConditionMessage .forCondition("PooledDataSource"); if (getDataSourceClassLoader(context) != null) { return ConditionOutcome .match(message.foundExactly("supported DataSource")); } return ConditionOutcome .noMatch(message.didNotFind("supported DataSource").atAll()); } /** * Returns the class loader for the {@link DataSource} class. Used to ensure that * the driver class can actually be loaded by the data source. * @param context the condition context * @return the class loader */ private ClassLoader getDataSourceClassLoader(ConditionContext context) { Class<?> dataSourceClass = new DataSourceBuilder(context.getClassLoader()) .findType(); return (dataSourceClass == null ? null : dataSourceClass.getClassLoader()); } } 从代码里,可以看到是尝试查找dataSourceClass,如果找到,条件就成立。那么debug下,可以发现查找到的dataSourceClass是:org.apache.tomcat.jdbc.pool.DataSource 。 那么再看下org.apache.tomcat.jdbc.pool.DataSource这个类是从哪里来的呢? 从maven依赖树可以看到,依赖是来自:spring-boot-starter-jdbc。所以是应用依赖了spring-boot-starter-jdbc,但是并没有配置DataSource引起的问题。 问题解决办法 有两种: 没有使用到DataSource,则可以把spring-boot-starter-jdbc的依赖去掉,这样就不会触发spring boot相关的代码 把spring boot自动初始化DataSource相关的代码禁止掉 禁止的办法有两种: 在main函数上配置exclude @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class }) 在application.properties里配置: spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration 总结 应用没有使用到DataSource,但是在pom.xml里引入了spring-boot-starter-jdbc spring-boot-starter-jdbc带入了tomcat-jdbc,它里面有org.apache.tomcat.jdbc.pool.DataSource spring boot里的PooledDataSourceConfiguration,判断classpath下面有DataSource的实现类,尝试去创建DataSource bean 在初始化DataSourceProperties时,尝试通过jdbc的url来探测driver class 因为应用并没有配置url,所以最终在DataSourceProperties.determineDriverClassName()里抛出Cannot determine embedded database driver class for database type NONE 最后: 排查spring boot的AutoConfiguration问题时,可以按异常栈,一层层排查Configuration是怎么引入的,再排查Condition具体的判断代码。
前言 对于一个简单的Spring boot应用,它的spring context是只会有一个。 非web spring boot应用,context是AnnotationConfigApplicationContext web spring boot应用,context是AnnotationConfigEmbeddedWebApplicationContext AnnotationConfigEmbeddedWebApplicationContext是spring boot里自己实现的一个context,主要功能是启动embedded servlet container,比如tomcat/jetty。 这个和传统的war包应用不一样,传统的war包应用有两个spring context。参考:http://hengyunabc.github.io/something-about-spring-mvc-webapplicationcontext/ 但是对于一个复杂点的spring boot应用,它的spring context可能会是多个,下面分析下各种情况。 Demo 这个Demo展示不同情况下的spring boot context的继承情况。 https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-classloader-context 配置spring boot actuator/endpoints独立端口时 spring boot actuator默认情况下和应用共用一个tomcat,这样子的话就会直接把应用的endpoints暴露出去,带来很大的安全隐患。 尽管 Spring boot后面默认把这个关掉,需要配置management.security.enabled=false才可以访问,但是这个还是太危险了。 所以通常都建议把endpoints开在另外一个独立的端口上,比如 management.port=8081。 可以增加-Dspring.cloud.bootstrap.enabled=false,来禁止spring cloud,然后启动Demo。比如 mvn spring-boot:run -Dspring.cloud.bootstrap.enabled=false 然后打开 http://localhost:8080/ 可以看到应用的spring context继承结构。 打开 http://localhost:8081/contexttree 可以看到Management Spring Contex的继承结构。 可以看到当配置management独立端口时,management context的parent是应用的spring context 相关的实现代码在 org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration 里 在sprig cloud环境下spring context的情况 在有spring cloud时(通常是引入 spring-cloud-starter),因为spring cloud有自己的一套配置初始化机制,所以它实际上是自己启动了一个Spring context,并把自己置为应用的context的parent。 spring cloud context的启动代码在org.springframework.cloud.bootstrap.BootstrapApplicationListener里。 spring cloud context实际上是一个特殊的spring boot context,它只扫描BootstrapConfiguration。 ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); // Use names and ensure unique to protect against duplicates List<String> names = SpringFactoriesLoader .loadFactoryNames(BootstrapConfiguration.class, classLoader); for (String name : StringUtils.commaDelimitedListToStringArray( environment.getProperty("spring.cloud.bootstrap.sources", ""))) { names.add(name); } // TODO: is it possible or sensible to share a ResourceLoader? SpringApplicationBuilder builder = new SpringApplicationBuilder() .profiles(environment.getActiveProfiles()).bannerMode(Mode.OFF) .environment(bootstrapEnvironment) .properties("spring.application.name:" + configName) .registerShutdownHook(false).logStartupInfo(false).web(false); List<Class<?>> sources = new ArrayList<>(); 最终会把这个ParentContextApplicationContextInitializer加到应用的spring context里,来把自己设置为应用的context的parent。 public class ParentContextApplicationContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered { private int order = Ordered.HIGHEST_PRECEDENCE; private final ApplicationContext parent; @Override public void initialize(ConfigurableApplicationContext applicationContext) { if (applicationContext != this.parent) { applicationContext.setParent(this.parent); applicationContext.addApplicationListener(EventPublisher.INSTANCE); } } 和上面一样,直接启动demo,不要配置-Dspring.cloud.bootstrap.enabled=false,然后访问对应的url,就可以看到spring context的继承情况。 如何在应用代码里获取到 Management Spring Context 如果应用代码想获取到Management Spring Context,可以通过这个bean:org.springframework.boot.actuate.autoconfigure.ManagementContextResolver spring boot在创建Management Spring Context时,就会保存到ManagementContextResolver里。 @Configuration @ConditionalOnClass({ Servlet.class, DispatcherServlet.class }) @ConditionalOnWebApplication @AutoConfigureAfter({ PropertyPlaceholderAutoConfiguration.class, EmbeddedServletContainerAutoConfiguration.class, WebMvcAutoConfiguration.class, ManagementServerPropertiesAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class, HypermediaAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class }) public class EndpointWebMvcAutoConfiguration implements ApplicationContextAware, BeanFactoryAware, SmartInitializingSingleton { @Bean public ManagementContextResolver managementContextResolver() { return new ManagementContextResolver(this.applicationContext); } @Bean public ManagementServletContext managementServletContext( final ManagementServerProperties properties) { return new ManagementServletContext() { @Override public String getContextPath() { return properties.getContextPath(); } }; } 如何在Endpoints代码里获取应用的Spring context spring boot本身没有提供方法,应用可以自己写一个@Configuration,保存应用的Spring context,然后在endpoints代码里再取出来。 ApplicationContext.setParent(ApplicationContext) 到底发生了什么 从spring的代码就可以看出来,主要是把parent的environment里的propertySources加到child里。这也就是spring cloud config可以生效的原因。 // org.springframework.context.support.AbstractApplicationContext.setParent(ApplicationContext) /** * Set the parent of this application context. * <p>The parent {@linkplain ApplicationContext#getEnvironment() environment} is * {@linkplain ConfigurableEnvironment#merge(ConfigurableEnvironment) merged} with * this (child) application context environment if the parent is non-{@code null} and * its environment is an instance of {@link ConfigurableEnvironment}. * @see ConfigurableEnvironment#merge(ConfigurableEnvironment) */ @Override public void setParent(ApplicationContext parent) { this.parent = parent; if (parent != null) { Environment parentEnvironment = parent.getEnvironment(); if (parentEnvironment instanceof ConfigurableEnvironment) { getEnvironment().merge((ConfigurableEnvironment) parentEnvironment); } } } // org.springframework.core.env.AbstractEnvironment.merge(ConfigurableEnvironment) @Override public void merge(ConfigurableEnvironment parent) { for (PropertySource<?> ps : parent.getPropertySources()) { if (!this.propertySources.contains(ps.getName())) { this.propertySources.addLast(ps); } } String[] parentActiveProfiles = parent.getActiveProfiles(); if (!ObjectUtils.isEmpty(parentActiveProfiles)) { synchronized (this.activeProfiles) { for (String profile : parentActiveProfiles) { this.activeProfiles.add(profile); } } } String[] parentDefaultProfiles = parent.getDefaultProfiles(); if (!ObjectUtils.isEmpty(parentDefaultProfiles)) { synchronized (this.defaultProfiles) { this.defaultProfiles.remove(RESERVED_DEFAULT_PROFILE_NAME); for (String profile : parentDefaultProfiles) { this.defaultProfiles.add(profile); } } } } 总结 当配置management.port 为独立端口时,Management Spring Context也会是独立的context,它的parent是应用的spring context 当启动spring cloud时,spring cloud自己会创建出一个spring context,并置为应用的context的parent ApplicationContext.setParent(ApplicationContext) 主要是把parent的environment里的propertySources加到child里 理解的spring boot context的继承关系,能避免一些微妙的spring bean注入的问题,还有不当的spring context的问题
前言 对spring boot本身启动原理的分析,请参考:http://hengyunabc.github.io/spring-boot-application-start-analysis/ Spring boot里的ClassLoader继承关系 可以运行下面提供的demo,分别在不同的场景下运行,可以知道不同场景下的Spring boot应用的ClassLoader继承关系。 https://github.com/hengyunabc/spring-boot-inside/tree/master/demo-classloader-context 分三种情况: 在IDE里,直接run main函数 则Spring的ClassLoader直接是SystemClassLoader。ClassLoader的urls包含全部的jar和自己的target/classes ========= Spring Boot Application ClassLoader Urls ============= ClassLoader urls: sun.misc.Launcher$AppClassLoader@2a139a55 file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/classes/ file:/Users/hengyunabc/.m2/repository/org/springframework/cloud/spring-cloud-starter/1.1.9.RELEASE/spring-cloud-starter-1.1.9.RELEASE.jar file:/Users/hengyunabc/.m2/repository/org/springframework/boot/spring-boot-starter/1.4.7.RELEASE/spring-boot-starter-1.4.7.RELEASE.jar ... 以fat jar运行 mvn clean package java -jar target/demo-classloader-context-0.0.1-SNAPSHOT.jar 执行应用的main函数的ClassLoader是LaunchedURLClassLoader,它的parent是SystemClassLoader。 ========= ClassLoader Tree============= org.springframework.boot.loader.LaunchedURLClassLoader@1218025c - sun.misc.Launcher$AppClassLoader@6bc7c054 -- sun.misc.Launcher$ExtClassLoader@85ede7b 并且LaunchedURLClassLoader的urls是 fat jar里的BOOT-INF/classes!/目录和BOOT-INF/lib里的所有jar。 ========= Spring Boot Application ClassLoader Urls ============= ClassLoader urls: org.springframework.boot.loader.LaunchedURLClassLoader@1218025c jar:file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo-classloader-context-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/ jar:file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo-classloader-context-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-1.4.7.RELEASE.jar!/ jar:file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo-classloader-context-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-web-4.3.9.RELEASE.jar!/ ... SystemClassLoader的urls是demo-classloader-context-0.0.1-SNAPSHOT.jar本身。 ========= System ClassLoader Urls ============= ClassLoader urls: sun.misc.Launcher$AppClassLoader@6bc7c054 file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo-classloader-context-0.0.1-SNAPSHOT.jar 以解压目录运行 mvn clean package cd target unzip demo-classloader-context-0.0.1-SNAPSHOT.jar -d demo cd demo java org.springframework.boot.loader.PropertiesLauncher 执行应用的main函数的ClassLoader是LaunchedURLClassLoader,它的parent是SystemClassLoader。 ========= ClassLoader Tree============= org.springframework.boot.loader.LaunchedURLClassLoader@4aa298b7 - sun.misc.Launcher$AppClassLoader@2a139a55 -- sun.misc.Launcher$ExtClassLoader@1b6d3586 LaunchedURLClassLoader的urls是解压目录里的BOOT-INF/classes/和/BOOT-INF/lib/下面的jar包。 ========= Spring Boot Application ClassLoader Urls ============= ClassLoader urls: org.springframework.boot.loader.LaunchedURLClassLoader@4aa298b7 file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo/BOOT-INF/classes/ jar:file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo/BOOT-INF/lib/bcpkix-jdk15on-1.55.jar!/ jar:file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo/BOOT-INF/lib/bcprov-jdk15on-1.55.jar!/ jar:file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo/BOOT-INF/lib/classmate-1.3.3.jar!/ SystemClassLoader的urls只有当前目录: ========= System ClassLoader Urls ============= ClassLoader urls: sun.misc.Launcher$AppClassLoader@2a139a55 file:/Users/hengyunabc/code/java/spring-boot-inside/demo-classloader-context/target/demo/ 其实还有两种运行方式:mvn spring-boot:run 和 mvn spring-boot:run -Dfork=true,但是比较少使用,不单独讨论。感觉兴趣的话可以自行跑下。 总结spring boot里ClassLoader的继承关系 在IDE里main函数执行时,只有一个ClassLoader,也就是SystemClassLoader 在以fat jar运行时,有一个LaunchedURLClassLoader,它的parent是SystemClassLoader LaunchedURLClassLoader的urls是fat jar里的BOOT-INF/classes和BOOT-INF/lib下的jar。SystemClassLoader的urls是fat jar本身。 在解压目录(exploded directory)运行时,和fat jar类似,不过url都是目录形式。目录形式会有更好的兼容性。 spring boot 1.3.* 和 1.4.* 版本的区别 在spring boot 1.3.* 版本里 应用的类和spring boot loader的类都是打包在一个fat jar里 应用依赖的jar放在fat jar里的/lib下面。 在spring boot 1.4.* 版本后 spring boot loader的类放在fat jar里 应用的类打包放在fat jar的BOOT-INF/classes目录里 应用依赖的jar放在fat jar里的/lib下面。 spring boot 1.4的打包结构改动是这个commit引入的 https://github.com/spring-projects/spring-boot/commit/87fe0b2adeef85c842c009bfeebac1c84af8a5d7 这个commit的本意是简化classloader的继承关系,以一种直观的parent优先的方式来实现LaunchedURLClassLoader,同时打包结构和传统的war包应用更接近。 但是这个改动引起了很多复杂的问题,从上面我们分析的ClassLoader继承关系就有点头晕了。 目前的ClassLoader继承关系带来的一些影响 有很多用户可能会发现,一些代码在IDE里跑得很好,但是在实际部署运行时不工作。很多时候就是ClassLoader的结构引起的,下面分析一些案例。 demo.jar!/BOOT-INF/classes!/ 这样子url不工作 因为spring boot是扩展了标准的jar协议,让它支持多层的jar in jar,还有directory in jar。参考spring boot应用启动原理分析 在spring boot 1.3的时候尽管会有jar in jar,但是一些比较健壮的代码可以处理这种情况,比如tomcat8自己就支持jar in jar。 但是绝大部分代码都不会支持像demo.jar!/BOOT-INF/classes!/ 这样子directory in jar的多重url,所以在spring boot1.4里,很多库的代码都会失效。 demo.jar!/META-INF/resources 下的资源问题 在servlet 3.0规范里,应用可以把静态资源放在META-INF/resources下面,servlet container会支持读取。但是从上面的继承结果,我们可以发现一个问题: 应用以fat jar来启动,启动embedded tomcat的ClassLoader是LaunchedURLClassLoader LaunchedURLClassLoader的urls并没有fat jar本身 应用的main函数所在的模块的src/main/resources/META-INF/resources目录被打包到了fat jar里,也就是demo.jar!/META-INF/resources 应用的fat jar是SystemClassLoader的url,也就是LaunchedURLClassLoader的parent 这样子就造成了一些奇怪的现象: 应用直接用自己的ClassLoader.getResources()是可以获取到META-INF/resources的资源的 但是embedded tomcat并没有把fat jar本身加入到它的 ResourcesSet 里,因为它在启动时ClassLoader是LaunchedURLClassLoader,它只扫描自己的ClassLoader的urls 应用把资源放在其它的jar包的META-INF/resources下可以访问到,把资源放在自己的main函数的src/main/resources/META-INF/resources下时,访问不到了 另外,spring boot的官方jsp的例子只支持war的打包格式,不支持fat jar,也是由这个引起的。 getResource("") 和 getResources("") 的返回值的问题 getResource("")的语义是返回ClassLoader的urls的第一个url,很多时候使用者以为这个就是它们自己的classes的目录,或者是jar的url。 但是实际上,因为ClassLoader加载urls列表时,有随机性,和OS低层实现有关,并不能保证urls的顺序都是一样的。所以getResource("")很多时候返回的结果并不一样。 但是很多库,或者应用依赖这个代码来定位扫描资源,这样子在spring boot下就不工作了。 另外,值得注意的是spring boot在三种不同形式下运行,getResources("")返回的结果也不一样。用户可以自己改下demo里的代码,打印下结果。 简而言之,不要依赖这两个API,最好自己放一个资源来定位。或者直接利用spring自身提供的资源扫描机制。 类似 classpath*:**-service.xml 的通配问题 用户有多个代码模块,在不同模块下都放了多个*-service.xml的spring配置文件。 用户如果使用类似classpath*:**-service.xml的通配符来加载资源的话,很有可能出现在IDE里跑时,可以正确加载,但是在fat jar下,却加载不到的问题。 从spring自己的文档可以看到相关的解析: https://docs.spring.io/spring/docs/4.3.9.RELEASE/javadoc-api/org/springframework/core/io/support/PathMatchingResourcePatternResolver.html WARNING: Note that “classpath*:” when combined with Ant-style patterns will only work reliably with at least one root directory before the pattern starts, unless the actual target files reside in the file system. This means that a pattern like “classpath*:*.xml” will not retrieve files from the root of jar files but rather only from the root of expanded directories. This originates from a limitation in the JDK’s ClassLoader.getResources() method which only returns file system locations for a passed-in empty String (indicating potential roots to search). This ResourcePatternResolver implementation is trying to mitigate the jar root lookup limitation through URLClassLoader introspection and “java.class.path” manifest evaluation; however, without portability guarantees. 就是说使用 classpath*来匹配其它的jar包时,需要有一层目录在前面,不然的话是匹配不到的,这个是ClassLoader.getResources() 函数导致的。 因为在IDE里跑时,应用所依赖的其它模块通常就是一个classes目录,所以通常没有问题。 但是当以fat jar来跑时,其它的模块都被打包为一个jar,放在BOOT-INF/lib下面,所以这时通配就会失败。 总结 这个新的BOOT-INF打包格式有它的明显好处:更清晰,更接近war包的格式。 spring boot的ClassLoader的结构修改带来的复杂问题,并非当初修改的人所能预见的 很多问题需要理解spring boot的ClassLoader结构,否则不能找到根本原因
Spring里的占位符 spring里的占位符通常表现的形式是: <bean id="dataSource" destroy-method="close" class="org.apache.commons.dbcp.BasicDataSource"> <property name="url" value="${jdbc.url}"/> </bean> 或者 @Configuration @ImportResource("classpath:/com/acme/properties-config.xml") public class AppConfig { @Value("${jdbc.url}") private String url; } Spring应用在有时会出现占位符配置没有注入,原因可能是多样的。 本文介绍两种比较复杂的情况。 占位符是在Spring生命周期的什么时候处理的 Spirng在生命周期里关于Bean的处理大概可以分为下面几步: 加载Bean定义(从xml或者从@Import等) 处理BeanFactoryPostProcessor 实例化Bean 处理Bean的property注入 处理BeanPostProcessor 当然这只是比较理想的状态,实际上因为Spring Context在构造时,也需要创建很多内部的Bean,应用在接口实现里也会做自己的各种逻辑,整个流程会非常复杂。 那么占位符(${}表达式)是在什么时候被处理的? 实际上是在org.springframework.context.support.PropertySourcesPlaceholderConfigurer里处理的,它会访问了每一个bean的BeanDefinition,然后做占位符的处理 PropertySourcesPlaceholderConfigurer实现了BeanFactoryPostProcessor接口 PropertySourcesPlaceholderConfigurer的 order是Ordered.LOWEST_PRECEDENCE,也就是最低优先级的 结合上面的Spring的生命周期,如果Bean的创建和使用在PropertySourcesPlaceholderConfigurer之前,那么就有可能出现占位符没有被处理的情况。 例子1:Mybatis 的 MapperScannerConfigurer引起的占位符没有处理 例子代码:mybatis-demo.zip 首先应用自己在代码里创建了一个DataSource,其中${db.user}是希望从application.properties里注入的。代码在运行时会打印出user的实际值。 @Configuration public class MyDataSourceConfig { @Bean(name = "dataSource1") public DataSource dataSource1(@Value("${db.user}") String user) { System.err.println("user: " + user); JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:˜/test"); ds.setUser(user); return ds; } } 然后应用用代码的方式来初始化mybatis相关的配置,依赖上面创建的DataSource对象 @Configuration public class MybatisConfig1 { @Bean(name = "sqlSessionFactory1") public SqlSessionFactory sqlSessionFactory1(DataSource dataSource1) throws Exception { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); org.apache.ibatis.session.Configuration ibatisConfiguration = new org.apache.ibatis.session.Configuration(); sqlSessionFactoryBean.setConfiguration(ibatisConfiguration); sqlSessionFactoryBean.setDataSource(dataSource1); sqlSessionFactoryBean.setTypeAliasesPackage("sample.mybatis.domain"); return sqlSessionFactoryBean.getObject(); } @Bean MapperScannerConfigurer mapperScannerConfigurer(SqlSessionFactory sqlSessionFactory1) { MapperScannerConfigurer mapperScannerConfigurer = new MapperScannerConfigurer(); mapperScannerConfigurer.setSqlSessionFactoryBeanName("sqlSessionFactory1"); mapperScannerConfigurer.setBasePackage("sample.mybatis.mapper"); return mapperScannerConfigurer; } } 当代码运行时,输出结果是: user: ${db.user} 为什么会user这个变量没有被注入? 分析下Bean定义,可以发现MapperScannerConfigurer它实现了BeanDefinitionRegistryPostProcessor。这个接口在是Spring扫描Bean定义时会回调的,远早于BeanFactoryPostProcessor。 所以原因是: MapperScannerConfigurer它实现了BeanDefinitionRegistryPostProcessor,所以它会Spring的早期会被创建 从bean的依赖关系来看,mapperScannerConfigurer依赖了sqlSessionFactory1,sqlSessionFactory1依赖了dataSource1 MyDataSourceConfig里的dataSource1被提前初始化,没有经过PropertySourcesPlaceholderConfigurer的处理,所以@Value("${db.user}") String user 里的占位符没有被处理 要解决这个问题,可以在代码里,显式来处理占位符: environment.resolvePlaceholders("${db.user}") 例子2:Spring boot自身实现问题,导致Bean被提前初始化 例子代码:demo.zip Spring Boot里提供了@ConditionalOnBean,这个方便用户在不同条件下来创建bean。里面提供了判断是否存在bean上有某个注解的功能。 @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented @Conditional(OnBeanCondition.class) public @interface ConditionalOnBean { /** * The annotation type decorating a bean that should be checked. The condition matches * when any of the annotations specified is defined on a bean in the * {@link ApplicationContext}. * @return the class-level annotation types to check */ Class<? extends Annotation>[] annotation() default {}; 比如用户自己定义了一个Annotation: @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface MyAnnotation { } 然后用下面的写法来创建abc这个bean,意思是当用户显式使用了@MyAnnotation(比如放在main class上),才会创建这个bean。 @Configuration public class MyAutoConfiguration { @Bean // if comment this line, it will be fine. @ConditionalOnBean(annotation = { MyAnnotation.class }) public String abc() { return "abc"; } } 这个功能很好,但是在spring boot 1.4.5 版本之前都有问题,会导致FactoryBean提前初始化。 在例子里,通过xml创建了javaVersion这个bean,想获取到java的版本号。这里使用的是spring提供的一个调用static函数创建bean的技巧。 <bean id="sysProps" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean"> <property name="targetClass" value="java.lang.System" /> <property name="targetMethod" value="getProperties" /> </bean> <bean id="javaVersion" class="org.springframework.beans.factory.config.MethodInvokingFactoryBean"> <property name="targetObject" ref="sysProps" /> <property name="targetMethod" value="getProperty" /> <property name="arguments" value="${java.version.key}" /> </bean> 我们在代码里获取到这个javaVersion,然后打印出来: @SpringBootApplication @ImportResource("classpath:/demo.xml") public class DemoApplication { public static void main(String[] args) { ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.class, args); System.err.println(context.getBean("javaVersion")); } } 在实际运行时,发现javaVersion的值是null。 这个其实是spring boot的锅,要搞清楚这个问题,先要看@ConditionalOnBean的实现。 @ConditionalOnBean实际上是在ConfigurationClassPostProcessor里被处理的,它实现了BeanDefinitionRegistryPostProcessor BeanDefinitionRegistryPostProcessor是在spring早期被处理的 @ConditionalOnBean的具体处理代码在org.springframework.boot.autoconfigure.condition.OnBeanCondition里 OnBeanCondition在获取bean的Annotation时,调用了beanFactory.getBeanNamesForAnnotation private String[] getBeanNamesForAnnotation( ConfigurableListableBeanFactory beanFactory, String type, ClassLoader classLoader, boolean considerHierarchy) throws LinkageError { String[] result = NO_BEANS; try { @SuppressWarnings("unchecked") Class<? extends Annotation> typeClass = (Class<? extends Annotation>) ClassUtils .forName(type, classLoader); result = beanFactory.getBeanNamesForAnnotation(typeClass); beanFactory.getBeanNamesForAnnotation 会导致FactoryBean提前初始化,创建出javaVersion里,传入的${java.version.key}没有被处理,值为null。 spring boot 1.4.5 修复了这个问题:https://github.com/spring-projects/spring-boot/issues/8269 实现spring boot starter要注意不能导致bean提前初始化 用户在实现spring boot starter时,通常会实现Spring的一些接口,比如BeanFactoryPostProcessor接口,在处理时,要注意不能调用类似beanFactory.getBeansOfType,beanFactory.getBeanNamesForAnnotation 这些函数,因为会导致一些bean提前初始化。 而上面有提到PropertySourcesPlaceholderConfigurer的order是最低优先级的,所以用户自己实现的BeanFactoryPostProcessor接口在被回调时很有可能占位符还没有被处理。 对于用户自己定义的@ConfigurationProperties对象的注入,可以用类似下面的代码: @ConfigurationProperties(prefix = "spring.my") public class MyProperties { String key; } public static MyProperties buildMyProperties(ConfigurableEnvironment environment) { MyProperties myProperties = new MyProperties(); if (environment != null) { MutablePropertySources propertySources = environment.getPropertySources(); new RelaxedDataBinder(myProperties, "spring.my").bind(new PropertySourcesPropertyValues(propertySources)); } return myProperties; } 总结 占位符(${}表达式)是在PropertySourcesPlaceholderConfigurer里处理的,也就是BeanFactoryPostProcessor接口 spring的生命周期是比较复杂的事情,在实现了一些早期的接口时要小心,不能导致spring bean提前初始化 在早期的接口实现里,如果想要处理占位符,可以利用spring自身的api,比如 environment.resolvePlaceholders("${db.user}")
问题 可重现的Demo代码:demo.zip 最近排查一个spring boot应用抛出hibernate.validator NoClassDefFoundError的问题,异常信息如下: Caused by: java.lang.NoClassDefFoundError: Could not initialize class org.hibernate.validator.internal.engine.ConfigurationImpl at org.hibernate.validator.HibernateValidator.createGenericConfiguration(HibernateValidator.java:33) ~[hibernate-validator-5.3.5.Final.jar:5.3.5.Final] at javax.validation.Validation$GenericBootstrapImpl.configure(Validation.java:276) ~[validation-api-1.1.0.Final.jar:na] at org.springframework.boot.validation.MessageInterpolatorFactory.getObject(MessageInterpolatorFactory.java:53) ~[spring-boot-1.5.3.RELEASE.jar:1.5.3.RELEASE] at org.springframework.boot.autoconfigure.validation.DefaultValidatorConfiguration.defaultValidator(DefaultValidatorConfiguration.java:43) ~[spring-boot-autoconfigure-1.5.3.RELEASE.jar:1.5.3.RELEASE] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_112] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_112] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_112] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_112] at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:162) ~[spring-beans-4.3.8.RELEASE.jar:4.3.8.RELEASE] ... 32 common frames omitted 这个错误信息表面上是NoClassDefFoundError,但是实际上ConfigurationImpl这个类是在hibernate-validator-5.3.5.Final.jar里的,不应该出现找不到类的情况。 那为什么应用里抛出这个NoClassDefFoundError ? 有经验的开发人员从Could not initialize class 这个信息就可以知道,实际上是一个类在初始化时抛出的异常,比如static的静态代码块,或者static字段初始化的异常。 谁初始化了 org.hibernate.validator.internal.engine.ConfigurationImpl 但是当我们在HibernateValidator 这个类,创建ConfigurationImpl的代码块里打断点时,发现有两个线程触发了断点: public class HibernateValidator implements ValidationProvider<HibernateValidatorConfiguration> { @Override public Configuration<?> createGenericConfiguration(BootstrapState state) { return new ConfigurationImpl( state ); } 其中一个线程的调用栈是: Thread [background-preinit] (Class load: ConfigurationImpl) HibernateValidator.createGenericConfiguration(BootstrapState) line: 33 Validation$GenericBootstrapImpl.configure() line: 276 BackgroundPreinitializer$ValidationInitializer.run() line: 107 BackgroundPreinitializer$1.runSafely(Runnable) line: 59 BackgroundPreinitializer$1.run() line: 52 Thread.run() line: 745 另外一个线程调用栈是: Thread [main] (Suspended (breakpoint at line 33 in HibernateValidator)) owns: ConcurrentHashMap<K,V> (id=52) owns: Object (id=53) HibernateValidator.createGenericConfiguration(BootstrapState) line: 33 Validation$GenericBootstrapImpl.configure() line: 276 MessageInterpolatorFactory.getObject() line: 53 DefaultValidatorConfiguration.defaultValidator() line: 43 NativeMethodAccessorImpl.invoke0(Method, Object, Object[]) line: not available [native method] NativeMethodAccessorImpl.invoke(Object, Object[]) line: 62 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 498 CglibSubclassingInstantiationStrategy(SimpleInstantiationStrategy).instantiate(RootBeanDefinition, String, BeanFactory, Object, Method, Object...) line: 162 ConstructorResolver.instantiateUsingFactoryMethod(String, RootBeanDefinition, Object[]) line: 588 DefaultListableBeanFactory(AbstractAutowireCapableBeanFactory).instantiateUsingFactoryMethod(String, RootBeanDefinition, Object[]) line: 1173 显然,这个线程的调用栈是常见的spring的初始化过程。 BackgroundPreinitializer 做了什么 那么重点来看下 BackgroundPreinitializer 线程做了哪些事情: @Order(LoggingApplicationListener.DEFAULT_ORDER + 1) public class BackgroundPreinitializer implements ApplicationListener<ApplicationEnvironmentPreparedEvent> { @Override public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { try { Thread thread = new Thread(new Runnable() { @Override public void run() { runSafely(new MessageConverterInitializer()); runSafely(new MBeanFactoryInitializer()); runSafely(new ValidationInitializer()); runSafely(new JacksonInitializer()); runSafely(new ConversionServiceInitializer()); } public void runSafely(Runnable runnable) { try { runnable.run(); } catch (Throwable ex) { // Ignore } } }, "background-preinit"); thread.start(); } 可以看到BackgroundPreinitializer类是spring boot为了加速应用的初始化,以一个独立的线程来加载hibernate validator这些组件。 这个 background-preinit 线程会吞掉所有的异常。 显然ConfigurationImpl 初始化的异常也被吞掉了,那么如何才能获取到最原始的信息? 获取到最原始的异常信息 在BackgroundPreinitializer的 run() 函数里打一个断点(注意是Suspend thread类型, 不是Suspend VM),让它先不要触发ConfigurationImpl的加载,让spring boot的正常流程去触发ConfigurationImpl的加载,就可以知道具体的信息了。 那么打出来的异常信息是: Caused by: java.lang.NoSuchMethodError: org.jboss.logging.Logger.getMessageLogger(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Object; at org.hibernate.validator.internal.util.logging.LoggerFactory.make(LoggerFactory.java:19) ~[hibernate-validator-5.3.5.Final.jar:5.3.5.Final] at org.hibernate.validator.internal.util.Version.<clinit>(Version.java:22) ~[hibernate-validator-5.3.5.Final.jar:5.3.5.Final] at org.hibernate.validator.internal.engine.ConfigurationImpl.<clinit>(ConfigurationImpl.java:71) ~[hibernate-validator-5.3.5.Final.jar:5.3.5.Final] at org.hibernate.validator.HibernateValidator.createGenericConfiguration(HibernateValidator.java:33) ~[hibernate-validator-5.3.5.Final.jar:5.3.5.Final] at javax.validation.Validation$GenericBootstrapImpl.configure(Validation.java:276) ~[validation-api-1.1.0.Final.jar:na] at org.springframework.boot.validation.MessageInterpolatorFactory.getObject(MessageInterpolatorFactory.java:53) ~[spring-boot-1.5.3.RELEASE.jar:1.5.3.RELEASE] 那么可以看出是 org.jboss.logging.Logger 这个类不兼容,少了getMessageLogger(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Object 这个函数。 那么检查下应用的依赖,可以发现org.jboss.logging.Logger 在jboss-common-1.2.1.GA.jar和jboss-logging-3.3.1.Final.jar里都有。 显然是jboss-common-1.2.1.GA.jar 这个依赖过时了,需要排除掉。 总结异常的发生流程 应用依赖了jboss-common-1.2.1.GA.jar,它里面的org.jboss.logging.Logger太老 spring boot启动时,BackgroundPreinitializer里的线程去尝试加载ConfigurationImpl,然后触发了org.jboss.logging.Logger的函数执行问题 BackgroundPreinitializer 吃掉了异常信息,jvm把ConfigurationImpl标记为不可用的 spring boot正常的流程去加载ConfigurationImpl,jvm发现ConfigurationImpl类是不可用,直接抛出NoClassDefFoundError “` Caused by: java.lang.NoClassDefFoundError: Could not initialize class org.hibernate.validator.internal.engine.ConfigurationImpl ““ 深入JVM 为什么第二次尝试加载ConfigurationImpl时,会直接抛出java.lang.NoClassDefFoundError: Could not initialize class ? 下面用一段简单的代码来重现这个问题: try { org.hibernate.validator.internal.util.Version.touch(); } catch (Throwable e) { e.printStackTrace(); } System.in.read(); try { org.hibernate.validator.internal.util.Version.touch(); } catch (Throwable e) { e.printStackTrace(); } 使用HSDB来确定类的状态 当抛出第一个异常时,尝试用HSDB来看下这个类的状态。 sudo java -classpath "$JAVA_HOME/lib/sa-jdi.jar" sun.jvm.hotspot.HSDB 然后在HSDB console里查找到Version的地址信息 hsdb> class org.hibernate.validator.internal.util.Version org/hibernate/validator/internal/util/Version @0x00000007c0060218 然后在Inspector查找到这个地址,发现_init_state是5。 再看下hotspot代码,可以发现5对应的定义是initialization_error: // /hotspot/src/share/vm/oops/instanceKlass.hpp // See "The Java Virtual Machine Specification" section 2.16.2-5 for a detailed description // of the class loading & initialization procedure, and the use of the states. enum ClassState { allocated, // allocated (but not yet linked) loaded, // loaded and inserted in class hierarchy (but not linked yet) linked, // successfully linked/verified (but not initialized yet) being_initialized, // currently running class initializer fully_initialized, // initialized (successfull final state) initialization_error // error happened during initialization }; JVM规范里关于Initialization的内容 http://docs.oracle.com/javase/specs/jvms/se7/html/jvms-5.html#jvms-5.5 从规范里可以看到初始一个类/接口有12步,比较重要的两步都用黑体标记出来了: If the Class object for C is in an erroneous state, then initialization is not possible. Release LC and throw a NoClassDefFoundError. Otherwise, the class or interface initialization method must have completed abruptly by throwing some exception E. If the class of E is not Error or one of its subclasses, then create a new instance of the class ExceptionInInitializerError with E as the argument, and use this object in place of E in the following step. 第一次尝试加载Version类时 当第一次尝试加载时,hotspot InterpreterRuntime在解析invokestatic指令时,尝试加载org.hibernate.validator.internal.util.Version类,InstanceKlass的_init_state先是标记为being_initialized,然后当加载失败时,被标记为initialization_error。 对应Initialization的11步。 // hotspot/src/share/vm/oops/instanceKlass.cpp // Step 10 and 11 Handle e(THREAD, PENDING_EXCEPTION); CLEAR_PENDING_EXCEPTION; // JVMTI has already reported the pending exception // JVMTI internal flag reset is needed in order to report ExceptionInInitializerError JvmtiExport::clear_detected_exception((JavaThread*)THREAD); { EXCEPTION_MARK; this_oop->set_initialization_state_and_notify(initialization_error, THREAD); CLEAR_PENDING_EXCEPTION; // ignore any exception thrown, class initialization error is thrown below // JVMTI has already reported the pending exception // JVMTI internal flag reset is needed in order to report ExceptionInInitializerError JvmtiExport::clear_detected_exception((JavaThread*)THREAD); } DTRACE_CLASSINIT_PROBE_WAIT(error, InstanceKlass::cast(this_oop()), -1,wait); if (e->is_a(SystemDictionary::Error_klass())) { THROW_OOP(e()); } else { JavaCallArguments args(e); THROW_ARG(vmSymbols::java_lang_ExceptionInInitializerError(), vmSymbols::throwable_void_signature(), &args); } 第二次尝试加载Version类时 当第二次尝试加载时,检查InstanceKlass的_init_state是initialization_error,则直接抛出NoClassDefFoundError: Could not initialize class. 对应Initialization的5步。 // hotspot/src/share/vm/oops/instanceKlass.cpp void InstanceKlass::initialize_impl(instanceKlassHandle this_oop, TRAPS) { // ... // Step 5 if (this_oop->is_in_error_state()) { DTRACE_CLASSINIT_PROBE_WAIT(erroneous, InstanceKlass::cast(this_oop()), -1,wait); ResourceMark rm(THREAD); const char* desc = "Could not initialize class "; const char* className = this_oop->external_name(); size_t msglen = strlen(desc) + strlen(className) + 1; char* message = NEW_RESOURCE_ARRAY(char, msglen); if (NULL == message) { // Out of memory: can't create detailed error message THROW_MSG(vmSymbols::java_lang_NoClassDefFoundError(), className); } else { jio_snprintf(message, msglen, "%s%s", desc, className); THROW_MSG(vmSymbols::java_lang_NoClassDefFoundError(), message); } } 总结 spring boot在BackgroundPreinitializer类里用一个独立的线程来加载validator,并吃掉了原始异常 第一次加载失败的类,在jvm里会被标记为initialization_error,再次加载时会直接抛出NoClassDefFoundError: Could not initialize class 当在代码里吞掉异常时要谨慎,否则排查问题带来很大的困难 http://docs.oracle.com/javase/specs/jvms/se7/html/jvms-5.html#jvms-5.5
背景 在使用spring时,有时候有会有一些自定义annotation的需求,比如一些Listener的回调函数。 比如: @Service public class MyService { @MyListener public void onMessage(Message msg){ } } 一开始的时候,我是在Spring的ContextRefreshedEvent事件里,通过context.getBeansWithAnnotation(Component.class) 来获取到所有的bean,然后再检查method是否有@MyListener的annotation。 后来发现这个方法有缺陷,当有一些spring bean的@Scope设置为session/request时,创建bean会失败。 参考: http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#beans-factory-scopes 在网上搜索了一些资料,发现不少人都是用context.getBeansWithAnnotation(Component.class),这样子来做的,但是这个方法并不对。 BeanPostProcessor接口 后来看了下spring jms里的@JmsListener的实现,发现实现BeanPostProcessor接口才是最合理的办法。 public interface BeanPostProcessor { /** * Apply this BeanPostProcessor to the given new bean instance <i>before</i> any bean * initialization callbacks (like InitializingBean's {@code afterPropertiesSet} * or a custom init-method). The bean will already be populated with property values. * The returned bean instance may be a wrapper around the original. * @param bean the new bean instance * @param beanName the name of the bean * @return the bean instance to use, either the original or a wrapped one; * if {@code null}, no subsequent BeanPostProcessors will be invoked * @throws org.springframework.beans.BeansException in case of errors * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet */ Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException; /** * Apply this BeanPostProcessor to the given new bean instance <i>after</i> any bean * initialization callbacks (like InitializingBean's {@code afterPropertiesSet} * or a custom init-method). The bean will already be populated with property values. * The returned bean instance may be a wrapper around the original. * <p>In case of a FactoryBean, this callback will be invoked for both the FactoryBean * instance and the objects created by the FactoryBean (as of Spring 2.0). The * post-processor can decide whether to apply to either the FactoryBean or created * objects or both through corresponding {@code bean instanceof FactoryBean} checks. * <p>This callback will also be invoked after a short-circuiting triggered by a * {@link InstantiationAwareBeanPostProcessor#postProcessBeforeInstantiation} method, * in contrast to all other BeanPostProcessor callbacks. * @param bean the new bean instance * @param beanName the name of the bean * @return the bean instance to use, either the original or a wrapped one; * if {@code null}, no subsequent BeanPostProcessors will be invoked * @throws org.springframework.beans.BeansException in case of errors * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet * @see org.springframework.beans.factory.FactoryBean */ Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException; } 所有的bean在创建完之后,都会回调postProcessAfterInitialization函数,这时就可以确定bean是已经创建好的了。 所以扫描自定义的annotation的代码大概是这个样子的: public class MyListenerProcessor implements BeanPostProcessor { @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { return bean; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { Method[] methods = ReflectionUtils.getAllDeclaredMethods(bean.getClass()); if (methods != null) { for (Method method : methods) { MyListener myListener = AnnotationUtils.findAnnotation(method, MyListener.class); // process } } return bean; } } SmartInitializingSingleton 接口 看spring jms的代码时,发现SmartInitializingSingleton 这个接口也比较有意思。 就是当所有的singleton的bean都初始化完了之后才会回调这个接口。不过要注意是 4.1 之后才出现的接口。 public interface SmartInitializingSingleton { /** * Invoked right at the end of the singleton pre-instantiation phase, * with a guarantee that all regular singleton beans have been created * already. {@link ListableBeanFactory#getBeansOfType} calls within * this method won't trigger accidental side effects during bootstrap. * <p><b>NOTE:</b> This callback won't be triggered for singleton beans * lazily initialized on demand after {@link BeanFactory} bootstrap, * and not for any other bean scope either. Carefully use it for beans * with the intended bootstrap semantics only. */ void afterSingletonsInstantiated(); } https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/beans/factory/SmartInitializingSingleton.html
先打一个广告。 greys是一个很不错的java诊断工具:https://github.com/oldmanpushcart/greys-anatomy 最近尝试用greys来实时统计jvm里的异常生成数量,在增强Throwable时,发现应用会抛出StackOverflowError。下面记录详细的分析过程。 在真正分析之前,先介绍JVM对反射方法调用的优化和greys的工作原理。 JVM对反射方法调用的优化 在JVM里对于反射方法调用Method.invoke,默认情况下,是通过NativeMethodAccessorImpl来调用到的。 调用栈如下: NativeMethodAccessorImpl.invoke0(Method, Object, Object[]) line: not available [native method] NativeMethodAccessorImpl.invoke(Object, Object[]) line: 62 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 497 当经过16次方法调用之后,NativeMethodAccessorImpl 会用MethodAccessorGenerator 动态生成一个MethodAccessorImpl(即下面的GeneratedMethodAccessor1) ,然后再设置到 DelegatingMethodAccessorImpl 里。然后调用栈就变成这个样子: GeneratedMethodAccessor1.invoke(Object, Object[]) line: not available DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 497 这个动态生成的GeneratedMethodAccessor1是如何加载到ClassLoader里的?实际上是通过 Unsafe.defineClass 来define,然后再调用 ClassLoader.loadClass(String) 来加载到的。 AgentLauncher$1(ClassLoader).loadClass(String) line: 357 Unsafe.defineClass(String, byte[], int, int, ClassLoader, ProtectionDomain) line: not available [native method] ClassDefiner.defineClass(String, byte[], int, int, ClassLoader) line: 63 更多反射调用优化的细节参考:http://rednaxelafx.iteye.com/blog/548536 简单总结下: jvm会对method反射调用优化 运行时动态生成反射调用代码,再define到classloader里 define到classloader时,会调用ClassLoader.loadClass(String) greys的工作原理 使用greys可以在运行时,对方法调用进行一些watch, monitor等的动作。那么这个是怎么实现的呢? 简单来说,是通过运行时修改字节码来实现的。比如下面这个函数: class xxx { public String abc(Student s) { return s.getName(); } } 被greys修改过后,变为 Spy.ON_BEFORE_METHOD.invoke(null, new Integer(0), xxx2.getClass().getClassLoader(), "xxx", "abc", "(LStudent;)Ljava/lang/String;", xxx2, {student}); try { void s; String string = s.getName(); Spy.ON_RETURN_METHOD.invoke(null, string); return string; } catch (Throwable v1) { Spy.ON_THROWS_METHOD.invoke(null, v1); throw v1; } 可以看到,greys在原来的method里插入很多钩子,所以greys可以获取到method被调用的参数,返回值等信息。 当使用greys对java.lang.Throwable来增强时,会抛出StackOverflowError 测试代码: public class ExceptionTest { public static void main(String[] args) throws Exception { for (int i = 0; i < 100000; i++) { RuntimeException exception = new RuntimeException(""); System.err.println(exception); Thread.sleep(1000); } } } 在命令行里attach到测试代码进程之后,在greys console里执行 options unsafe true monitor -c 1 java.lang.Throwable * 当用greys增强java.lang.Throwable之后,经过16秒之后,就会抛出StackOverflowError。 具体的异常栈很长,这里只贴出重点部分: Thread [main] (Suspended (exception StackOverflowError)) ClassLoader.checkCreateClassLoader() line: 272 ... ClassCircularityError(Throwable).<init>(String) line: 264 ClassCircularityError(Error).<init>(String) line: 70 ClassCircularityError(LinkageError).<init>(String) line: 55 ClassCircularityError.<init>(String) line: 53 Unsafe.defineClass(String, byte[], int, int, ClassLoader, ProtectionDomain) line: not available [native method] ClassDefiner.defineClass(String, byte[], int, int, ClassLoader) line: 63 MethodAccessorGenerator$1.run() line: 399 MethodAccessorGenerator$1.run() line: 394 AccessController.doPrivileged(PrivilegedAction<T>) line: not available [native method] MethodAccessorGenerator.generate(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int, boolean, boolean, Class<?>) line: 393 MethodAccessorGenerator.generateMethod(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int) line: 75 NativeMethodAccessorImpl.invoke(Object, Object[]) line: 53 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 497 ClassCircularityError(Throwable).<init>(String) line: 264 ClassCircularityError(Error).<init>(String) line: 70 ClassCircularityError(LinkageError).<init>(String) line: 55 ClassCircularityError.<init>(String) line: 53 Unsafe.defineClass(String, byte[], int, int, ClassLoader, ProtectionDomain) line: not available [native method] ClassDefiner.defineClass(String, byte[], int, int, ClassLoader) line: 63 MethodAccessorGenerator$1.run() line: 399 MethodAccessorGenerator$1.run() line: 394 AccessController.doPrivileged(PrivilegedAction<T>) line: not available [native method] MethodAccessorGenerator.generate(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int, boolean, boolean, Class<?>) line: 393 MethodAccessorGenerator.generateMethod(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int) line: 75 NativeMethodAccessorImpl.invoke(Object, Object[]) line: 53 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 497 ClassNotFoundException(Throwable).<init>(String, Throwable) line: 286 ClassNotFoundException(Exception).<init>(String, Throwable) line: 84 ClassNotFoundException(ReflectiveOperationException).<init>(String, Throwable) line: 75 ClassNotFoundException.<init>(String) line: 82 AgentLauncher$1(URLClassLoader).findClass(String) line: 381 AgentLauncher$1.loadClass(String, boolean) line: 55 AgentLauncher$1(ClassLoader).loadClass(String) line: 357 Unsafe.defineClass(String, byte[], int, int, ClassLoader, ProtectionDomain) line: not available [native method] ClassDefiner.defineClass(String, byte[], int, int, ClassLoader) line: 63 MethodAccessorGenerator$1.run() line: 399 MethodAccessorGenerator$1.run() line: 394 AccessController.doPrivileged(PrivilegedAction<T>) line: not available [native method] MethodAccessorGenerator.generate(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int, boolean, boolean, Class<?>) line: 393 MethodAccessorGenerator.generateMethod(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int) line: 75 NativeMethodAccessorImpl.invoke(Object, Object[]) line: 53 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 497 RuntimeException(Throwable).<init>(String) line: 264 RuntimeException(Exception).<init>(String) line: 66 RuntimeException.<init>(String) line: 62 ExceptionTest.main(String[]) line: 15 从异常栈可以看出,先出现了一个ClassNotFoundException,然后大量的ClassCircularityError,最终导致StackOverflowError。 下面具体分析原因。 被增强过后的Throwable的代码 当monitor -c 1 java.lang.Throwable *命令执行之后,Throwable的代码实际上变为这个样子: public class Throwable { public Throwable() { Spy.ON_BEFORE_METHOD.invoke(...); try { // Throwable <init> } catch (Throwable v1) { Spy.ON_THROWS_METHOD.invoke(null, v1); throw v1; } } } 这个Spy.ON_BEFORE_METHOD.invoke 是一个反射调用,那么当它被调用16次之后,jvm会生成优化的代码。从最开始的异常栈可以看到这些信息: Unsafe.defineClass(String, byte[], int, int, ClassLoader, ProtectionDomain) line: not available [native method] ClassDefiner.defineClass(String, byte[], int, int, ClassLoader) line: 63 MethodAccessorGenerator$1.run() line: 399 MethodAccessorGenerator$1.run() line: 394 AccessController.doPrivileged(PrivilegedAction<T>) line: not available [native method] MethodAccessorGenerator.generate(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int, boolean, boolean, Class<?>) line: 393 MethodAccessorGenerator.generateMethod(Class<?>, String, Class<?>[], Class<?>, Class<?>[], int) line: 75 NativeMethodAccessorImpl.invoke(Object, Object[]) line: 53 DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43 Method.invoke(Object, Object...) line: 497 RuntimeException(Throwable).<init>(String) line: 264 RuntimeException(Exception).<init>(String) line: 66 RuntimeException.<init>(String) line: 62 ExceptionTest.main(String[]) line: 15 这时,生成的反射调用优化类名字是sun/reflect/GeneratedMethodAccessor1。 ClassNotFoundException 怎么产生的 接着,代码抛出了一个ClassNotFoundException,这个ClassNotFoundException来自AgentLauncher$1(URLClassLoader)。这是AgentLauncher 里自定义的一个URLClassLoader。 这个自定义ClassLoader的逻辑很简单,优先从自己查找class,如果找不到则从parent里查找。这是一个常见的重写ClassLoader的逻辑。 classLoader = new URLClassLoader(new URL[]{new URL("file:" + agentJar)}) { @Override protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { final Class<?> loadedClass = findLoadedClass(name); if (loadedClass != null) { return loadedClass; } try { Class<?> aClass = findClass(name); if (resolve) { resolveClass(aClass); } return aClass; } catch (Exception e) { return super.loadClass(name, resolve); } } }; 这个ClassNotFoundException的具体信息是sun.reflect.MethodAccessorImpl。实际上是MethodAccessorGenerator在生成反射调用代码里要用到的,所以需要加载到ClassLoader里。因此自定义的URLClassLoader在findClass时抛出了一个ClassNotFoundException。 ClassCircularityError是怎么产生的 抛出的ClassNotFoundException是Throwable的一个子类,所以也会调用Throwable的构造函数,然后需要调用到Spy.ON_BEFORE_METHOD.invoke 。 注意,这时Spy.ON_BEFORE_METHOD.invoke的反射调用代码已经生成了,但是还没有置入到ClassLoader里,也没有放到DelegatingMethodAccessorImpl里。所以这时仍然调用的是NativeMethodAccessorImpl,然后再次生成反射调用类,name是sun/reflect/GeneratedMethodAccessor2。 生成GeneratedMethodAccessor2之后, 会调用Unsafe.define来define这个class。这里抛出了ClassCircularityError。 为什么会抛出ClassCircularityError 因为Unsafe.defineClass 是native实现,所以需要查看hotspot源码才能知道具体的细节。 SystemDictionary是jvm里加载的所有类的总管,所以在defineClass,会调用到这个函数 // systemDictionary.cpp Klass* SystemDictionary::resolve_instance_class_or_null(Symbol* name, Handle class_loader, Handle protection_domain, TRAPS) { 然后,在这里面会有一个判断循环的方法。防止循环依赖。如果是发现了循环,则会抛出ClassCircularityError。 // systemDictionary.cpp // only need check_seen_thread once, not on each loop // 6341374 java/lang/Instrument with -Xcomp if (oldprobe->check_seen_thread(THREAD, PlaceholderTable::LOAD_INSTANCE)) { throw_circularity_error = true; } ... if (throw_circularity_error) { ResourceMark rm(THREAD); THROW_MSG_NULL(vmSymbols::java_lang_ClassCircularityError(), child_name->as_C_string()); } 这个循环检测是怎么工作的呢? 实际上是把线程放到一个queue里,然后判断这个queue里的保存的前一个线程是不是一样的,如果是一样的,则会认为出现循环了。 // placeholders.cpp bool check_seen_thread(Thread* thread, PlaceholderTable::classloadAction action) { assert_lock_strong(SystemDictionary_lock); SeenThread* threadQ = actionToQueue(action); SeenThread* seen = threadQ; while (seen) { if (thread == seen->thread()) { return true; } seen = seen->next(); } return false; } SeenThread* actionToQueue(PlaceholderTable::classloadAction action) { SeenThread* queuehead; switch (action) { case PlaceholderTable::LOAD_INSTANCE: queuehead = _loadInstanceThreadQ; break; case PlaceholderTable::LOAD_SUPER: queuehead = _superThreadQ; break; case PlaceholderTable::DEFINE_CLASS: queuehead = _defineThreadQ; break; default: Unimplemented(); } return queuehead; } 就这个例子实际情况来说,就是同一个thread里,在defineClass时,再次defineClass,这样子就出现了循环。所以抛出了一个ClassCircularityError。 StackOverflowError怎么产生的 OK,搞明白ClassCircularityError这个异常是怎么产生的之后,回到原来的流程看下。 这个ClassCircularityError也是Throwable的一个子类,那么它也需要初始化,然后调用Spy.ON_BEFORE_METHOD.invoke …… 然后,接下来就生成一个sun/reflect/GeneratedMethodAccessor3 ,然后会被defindClass,然后就会检测到循环,然后再次抛出ClassCircularityError。 就这样子,最终一直到StackOverflowError 完整的异常产生流程 Throwable的构造函数被增强之后,需要调用Spy.ON_BEFORE_METHOD.invoke Spy.ON_BEFORE_METHOD.invoke经过16次调用之后,jvm会生成反射调用优化代码 反射调用优化类sun/reflect/GeneratedMethodAccessor1需要被自定义的ClassLoader加载 自定义的ClassLoader重写了loadClass函数,抛出了一个ClassNotFoundException ClassNotFoundException在构造时,调用了Throwable的构造函数,然后调用了Spy.ON_BEFORE_METHOD.invoke Spy.ON_BEFORE_METHOD.invoke 生成反射调用优化代码:sun/reflect/GeneratedMethodAccessor2 Unsafe在defineClass sun/reflect/GeneratedMethodAccessor2 时,检测到循环,抛出了ClassCircularityError ClassCircularityError在构造时,调用了Throwable的构造函数,然后调用了Spy.ON_BEFORE_METHOD.invoke 反射调用优化类sun/reflect/GeneratedMethodAccessor3 在defineClass时,检测到循环,抛出了ClassCircularityError …… 不断抛出ClassCircularityError,最终导致StackOverflowError 总结 这个问题的根源是在Throwable的构造函数里抛出了异常,这样子明显无解。 为了避免这个问题,需要保证增强过后的Throwable的构造函数里不能抛出任何异常。然而因为jvm的反射调用优化,导致ClassLoader在loadClass时抛出了异常。所以要避免在加载jvm生成反射优化类时抛出异常。 修改过后的自定义URLClassLoader代码: classLoader = new URLClassLoader(new URL[]{new URL("file:" + agentJar)}) { @Override protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { final Class<?> loadedClass = findLoadedClass(name); if (loadedClass != null) { return loadedClass; } // 优先从parent(SystemClassLoader)里加载系统类,避免抛出ClassNotFoundException if(name != null && (name.startsWith("sun.") || name.startsWith("java."))) { return super.loadClass(name, resolve); } try { Class<?> aClass = findClass(name); if (resolve) { resolveClass(aClass); } return aClass; } catch (Exception e) { // ignore } return super.loadClass(name, resolve); } };
Serviceability Agent 想要查看一些被增强过的类的字节码,或者一些AOP框架的生成类,就需要dump出运行时的java进程里的字节码。 从运行的java进程里dump出运行中的类的class文件的方法,所知道的有两种 用agent attatch 到进程,然后利用Instrumentation和ClassFileTransformer就可以获取 到类的字节码了。 使用sd-jdi.jar里的工具 sd-jdi.jar 里自带的的sun.jvm.hotspot.tools.jcore.ClassDump就可以把类的class内容dump到文件里。 ClassDump里可以设置两个System properties: sun.jvm.hotspot.tools.jcore.filter Filter的类名 sun.jvm.hotspot.tools.jcore.outputDir 输出的目录 sd-jdi.jar 里有一个sun.jvm.hotspot.tools.jcore.PackageNameFilter,可以指定Dump哪些包里的类。PackageNameFilter里有一个System property可以指定过滤哪些包:sun.jvm.hotspot.tools.jcore.PackageNameFilter.pkgList。 所以可以通过这样子的命令来使用: sudo java -classpath "$JAVA_HOME/lib/sa-jdi.jar" -Dsun.jvm.hotspot.tools.jcore.filter=sun.jvm.hotspot.tools.jcore.PackageNameFilter -Dsun.jvm.hotspot.tools.jcore.PackageNameFilter.pkgList=com.test sun.jvm.hotspot.tools.jcore.ClassDump 显然,这个使用起来太麻烦了,而且不能应对复杂的场景。 dumpclass dumpclass这个小工具做了一些增强,更加方便地使用。 支持? *的匹配 支持多个ClassLoader加载了同名类的情况。 比如多个classloader加载了多份的logger,如果不做区分,则dump出来时会被覆盖掉,也分析不出问题。 dumpclass可以在maven仓库里下载到: http://search.maven.org/#search%7Cga%7C1%7Cdumpclass dumpclass的用法很简单,比如: Usage: java -jar dumpclass.jar <pid> <pattern> [outputDir] <--classLoaderPrefix> Example: java -jar dumpclass.jar 4345 *StringUtils java -jar dumpclass.jar 4345 *StringUtils /tmp java -jar dumpclass.jar 4345 *StringUtils /tmp --classLoaderPrefix 对于多个ClassLoader的情况,可以使用--classLoaderPrefix,这样子在输出.class文件时,会为每一个ClasssLoader创建一个目录,比如:sun.jvm.hotspot.oops.Instance@955d26b8。并且会在目录下放一个classLoader.text文件,里面是ClassLoader.toString()的内容,方便查看具体ClassLoader是什么。 源码和文档: https://github.com/hengyunabc/dumpclass HSDB 在sa-jdi.jar里,还有一个图形化的工具HSDB,也可以用来查看运行的的字节码。 sudo java -classpath "$JAVA_HOME/lib/sa-jdi.jar" sun.jvm.hotspot.HSDB 参考 http://rednaxelafx.iteye.com/blog/727938 https://docs.oracle.com/javase/7/docs/api/java/lang/instrument/ClassFileTransformer.html http://openjdk.java.net/groups/hotspot/docs/Serviceability.html
需求 大量的微服务框架引起了一大波embeded tomcat,executable fat jar的潮流。显然spring boot是最出色的解决方案,但是spring boot有两个不足的地方: 不支持配置web.xml文件,对于旧应用迁移不方便 一些配置在web.xml里配置起来很直观,放到代码里配置就难搞清楚顺序了。比如一些filter的顺序关系。 spring boot的方案依赖spring,对于一些轻量级的应用不想引入依赖 基于这些考虑,这里提出一个基于embeded tomcat本身的解决方案。 代码地址 https://github.com/hengyunabc/executable-embeded-tomcat-sample 支持特性: 支持加载传统的web.xml配置 支持打包为fat jar方式运行 支持在IDE里直接运行 旧应用迁移步聚 旧应用迁移非常的简单 在pom.xml里增加embeded tomcat的依赖 把应用原来的src/main/webapp/WEB-INF 移动到 src/main/resources/WEB-INF下,把在src/main/webapp下面的所有文件移动到 src/main/META-INF/resources目录下 写一个main函数,把tomcat启动起来 非常的简单,完全支持旧应用的所有功能,不用做任何的代码改造。 工作原理 web.xml的读取 传统的Tomcat有两个目录,一个是baseDir,对应tomcat本身的目录,下面有conf, bin这些文件夹。一个是webapp的docBase目录,比如webapps/ROOT 这个目录。 docBase只能是一个目录,或者是一个.war结尾的文件(实际对应不解压war包运行的方式)。 tomcat里的一个webapp对应有一个Context,Context里有一个WebResourceRoot,应用里的资源都是从WebResourceRoot 里加载的。tomcat在初始化Context时,会把docBase目录加到WebResourceRoot里。 tomcat在加载应用的web.xml里,是通过ServletContext来加载的,而ServletContext实际上是通过WebResourceRoot来获取到资源的。 所以简而言之,需要在tomcat启动之前,web.xml放到Context的WebResourceRoot,这样子tomcat就可以读取到web.xml里。 静态资源的处理 在Servlet 3.0规范里,应用可以把静态的资源放在jar包里的/META-INF/classes目录,或者在WEB-INF/classes/META-INF/resources目录下。 所以,采取了把资源文件全都放到src/main/META-INF/resources目录的做法,这样子有天然符合Servlet的规范,而且在打包时,自然地打包到fat jar里。 Fat jar的支持 Fat jar的支持是通过spring-boot-maven-plugin来实现的,它提供了把应用打包为fat jar,并启动的能力。具体原理可以参考另外一篇博客:http://blog.csdn.net/hengyunabc/article/details/50120001 当然,也可以用其它的方式把依赖都打包到一起,比如maven-assembly-plugin/jar-with-dependencies ,但不推荐,毕竟spring boot的方案很优雅。 其它 http://home.apache.org/~markt/presentations/2010-11-04-Embedding-Tomcat.pdf 官方的Embedded Tomcat文档 http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/basic_app_embedded_tomcat/basic_app-tomcat-embedded.html oracle的技术文档里的一个方案,但是这个方案太简单了,不支持在IDE里启动,不支持加载web.xml文件。 https://github.com/kui/executable-war-sample 这个把依赖都打包进war包里,然后在初始化tomcat里,直接把这个war做为docBase。这样子可以加载到web.xml。但是有一个严重的安全问题,因为应用的.class文件直接在war的根目录下,而不是在/WEB-INF/classes目录下,所以可以直接通过http访问到应用的.class文件,即攻击者可以直接拿到应用的代码来逆向分析。这个方案并不推荐使用。 实际上spring boot应用以一个war包直接运行时,也是有这个安全问题的。只是spring boot泄露的只是spring boot loader的.class文件。
spring boot executable jar/war spring boot里其实不仅可以直接以 java -jar demo.jar的方式启动,还可以把jar/war变为一个可以执行的脚本来启动,比如./demo.jar。 把这个executable jar/war 链接到/etc/init.d下面,还可以变为linux下的一个service。 只要在spring boot maven plugin里配置: <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <executable>true</executable> </configuration> </plugin> 这样子打包出来的jar/war就是可执行的。更多详细的内容可以参考官方的文档。 http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#deployment-install zip格式里的magic number 生成的jar/war实际上是一个zip格式的文件,这个zip格式文件为什么可以在shell下面直接执行? 研究了下zip文件的格式。zip文件是由entry组成的,而每一个entry开头都有一个4个字节的magic number: Local file header signature = 0x04034b50 (read as a little-endian number) 即 PK\003\004 参考:https://en.wikipedia.org/wiki/Zip_(file_format) zip处理软件是读取到magic number才开始处理。所以在linux/unix下面,可以把一个bash文件直接写在一个zip文件的开头,这样子会被认为是一个bash script。 而zip处理软件在读取这个文件时,仍然可以正确地处理。 比如spring boot生成的executable jar/war,的开头是: #!/bin/bash # # . ____ _ __ _ _ # /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ # ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ # \\/ ___)| |_)| | | | | || (_| | ) ) ) ) # ' |____| .__|_| |_|_| |_\__, | / / / / # =========|_|==============|___/=/_/_/_/ # :: Spring Boot Startup Script :: # 在script内容结尾,可以看到zip entry的magic number: exit 0 PK^C^D spring boot的launch.script 实际上spring boot maven plugin是把下面这个script打包到fat jar的最前面部分。 https://github.com/spring-projects/spring-boot/blob/1ca9cdabf71f3f972a9c1fdbfe9a9f5fda561287/spring-boot-tools/spring-boot-loader-tools/src/main/resources/org/springframework/boot/loader/tools/launch.script 这个launch.script 支持很多变量设置。还可以自动识别是处于auto还是service不同mode中。 所谓的auto mode就是指直接运行jar/war: ./demo.jar 而service mode则是由操作系统在启动service的情况: service demo start/stop/restart/status 所以fat jar可以直接在普通的命令行里执行,./xxx.jar 或者link到/etc/init.d/下,变为一个service。
spring boot quick start 在spring boot里,很吸引人的一个特性是可以直接把应用打包成为一个jar/war,然后这个jar/war是可以直接启动的,不需要另外配置一个Web Server。 如果之前没有使用过spring boot可以通过下面的demo来感受下。 下面以这个工程为例,演示如何启动Spring boot项目: git clone git@github.com:hengyunabc/spring-boot-demo.git mvn spring-boot-demo java -jar target/demo-0.0.1-SNAPSHOT.jar 如果使用的IDE是spring sts或者idea,可以通过向导来创建spring boot项目。 也可以参考官方教程: http://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/htmlsingle/#getting-started-first-application 对spring boot的两个疑问 刚开始接触spring boot时,通常会有这些疑问 spring boot如何启动的? spring boot embed tomcat是如何工作的? 静态文件,jsp,网页模板这些是如何加载到的? 下面来分析spring boot是如何做到的。 打包为单个jar时,spring boot的启动方式 maven打包之后,会生成两个jar文件: demo-0.0.1-SNAPSHOT.jar demo-0.0.1-SNAPSHOT.jar.original 其中demo-0.0.1-SNAPSHOT.jar.original是默认的maven-jar-plugin生成的包。 demo-0.0.1-SNAPSHOT.jar是spring boot maven插件生成的jar包,里面包含了应用的依赖,以及spring boot相关的类。下面称之为fat jar。 先来查看spring boot打好的包的目录结构(不重要的省略掉): ├── META-INF │ ├── MANIFEST.MF ├── application.properties ├── com │ └── example │ └── SpringBootDemoApplication.class ├── lib │ ├── aopalliance-1.0.jar │ ├── spring-beans-4.2.3.RELEASE.jar │ ├── ... └── org └── springframework └── boot └── loader ├── ExecutableArchiveLauncher.class ├── JarLauncher.class ├── JavaAgentDetector.class ├── LaunchedURLClassLoader.class ├── Launcher.class ├── MainMethodRunner.class ├── ... 依次来看下这些内容。 MANIFEST.MF Manifest-Version: 1.0 Start-Class: com.example.SpringBootDemoApplication Implementation-Vendor-Id: com.example Spring-Boot-Version: 1.3.0.RELEASE Created-By: Apache Maven 3.3.3 Build-Jdk: 1.8.0_60 Implementation-Vendor: Pivotal Software, Inc. Main-Class: org.springframework.boot.loader.JarLauncher 可以看到有Main-Class是org.springframework.boot.loader.JarLauncher ,这个是jar启动的Main函数。 还有一个Start-Class是com.example.SpringBootDemoApplication,这个是我们应用自己的Main函数。 @SpringBootApplication public class SpringBootDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringBootDemoApplication.class, args); } } com/example 目录 这下面放的是应用的.class文件。 lib目录 这里存放的是应用的Maven依赖的jar包文件。 比如spring-beans,spring-mvc等jar。 org/springframework/boot/loader 目录 这下面存放的是Spring boot loader的.class文件。 Archive的概念 archive即归档文件,这个概念在linux下比较常见 通常就是一个tar/zip格式的压缩包 jar是zip格式 在spring boot里,抽象出了Archive的概念。 一个archive可以是一个jar(JarFileArchive),也可以是一个文件目录(ExplodedArchive)。可以理解为Spring boot抽象出来的统一访问资源的层。 上面的demo-0.0.1-SNAPSHOT.jar 是一个Archive,然后demo-0.0.1-SNAPSHOT.jar里的/lib目录下面的每一个Jar包,也是一个Archive。 public abstract class Archive { public abstract URL getUrl(); public String getMainClass(); public abstract Collection<Entry> getEntries(); public abstract List<Archive> getNestedArchives(EntryFilter filter); 可以看到Archive有一个自己的URL,比如: jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/ 还有一个getNestedArchives函数,这个实际返回的是demo-0.0.1-SNAPSHOT.jar/lib下面的jar的Archive列表。它们的URL是: jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/aopalliance-1.0.jar jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar JarLauncher 从MANIFEST.MF可以看到Main函数是JarLauncher,下面来分析它的工作流程。 JarLauncher类的继承结构是: class JarLauncher extends ExecutableArchiveLauncher class ExecutableArchiveLauncher extends Launcher 以demo-0.0.1-SNAPSHOT.jar创建一个Archive: JarLauncher先找到自己所在的jar,即demo-0.0.1-SNAPSHOT.jar的路径,然后创建了一个Archive。 下面的代码展示了如何从一个类找到它的加载的位置的技巧: protected final Archive createArchive() throws Exception { ProtectionDomain protectionDomain = getClass().getProtectionDomain(); CodeSource codeSource = protectionDomain.getCodeSource(); URI location = (codeSource == null ? null : codeSource.getLocation().toURI()); String path = (location == null ? null : location.getSchemeSpecificPart()); if (path == null) { throw new IllegalStateException("Unable to determine code source archive"); } File root = new File(path); if (!root.exists()) { throw new IllegalStateException( "Unable to determine code source archive from " + root); } return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); } 获取lib/下面的jar,并创建一个LaunchedURLClassLoader JarLauncher创建好Archive之后,通过getNestedArchives函数来获取到demo-0.0.1-SNAPSHOT.jar/lib下面的所有jar文件,并创建为List。 注意上面提到,Archive都是有自己的URL的。 获取到这些Archive的URL之后,也就获得了一个URL[]数组,用这个来构造一个自定义的ClassLoader:LaunchedURLClassLoader。 创建好ClassLoader之后,再从MANIFEST.MF里读取到Start-Class,即com.example.SpringBootDemoApplication,然后创建一个新的线程来启动应用的Main函数。 /** * Launch the application given the archive file and a fully configured classloader. */ protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception { Runnable runner = createMainMethodRunner(mainClass, args, classLoader); Thread runnerThread = new Thread(runner); runnerThread.setContextClassLoader(classLoader); runnerThread.setName(Thread.currentThread().getName()); runnerThread.start(); } /** * Create the {@code MainMethodRunner} used to launch the application. */ protected Runnable createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) throws Exception { Class<?> runnerClass = classLoader.loadClass(RUNNER_CLASS); Constructor<?> constructor = runnerClass.getConstructor(String.class, String[].class); return (Runnable) constructor.newInstance(mainClass, args); } LaunchedURLClassLoader LaunchedURLClassLoader和普通的URLClassLoader的不同之处是,它提供了从Archive里加载.class的能力。 结合Archive提供的getEntries函数,就可以获取到Archive里的Resource。当然里面的细节还是很多的,下面再描述。 spring boot应用启动流程总结 看到这里,可以总结下Spring Boot应用的启动流程: spring boot应用打包之后,生成一个fat jar,里面包含了应用依赖的jar包,还有Spring boot loader相关的类 Fat jar的启动Main函数是JarLauncher,它负责创建一个LaunchedURLClassLoader来加载/lib下面的jar,并以一个新线程启动应用的Main函数。 spring boot loader里的细节 代码地址:https://github.com/spring-projects/spring-boot/tree/master/spring-boot-tools/spring-boot-loader JarFile URL的扩展 Spring boot能做到以一个fat jar来启动,最重要的一点是它实现了jar in jar的加载方式。 JDK原始的JarFile URL的定义可以参考这里: http://docs.oracle.com/javase/7/docs/api/java/net/JarURLConnection.html 原始的JarFile URL是这样子的: jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/ jar包里的资源的URL: jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/com/example/SpringBootDemoApplication.class 可以看到对于Jar里的资源,定义以’!/’来分隔。原始的JarFile URL只支持一个’!/’。 Spring boot扩展了这个协议,让它支持多个’!/’,就可以表示jar in jar,jar in directory的资源了。 比如下面的URL表示demo-0.0.1-SNAPSHOT.jar这个jar里lib目录下面的spring-beans-4.2.3.RELEASE.jar里面的MANIFEST.MF: jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar!/META-INF/MANIFEST.MF 自定义URLStreamHandler,扩展JarFile和JarURLConnection 在构造一个URL时,可以传递一个Handler,而JDK自带有默认的Handler类,应用可以自己注册Handler来处理自定义的URL。 public URL(String protocol, String host, int port, String file, URLStreamHandler handler) throws MalformedURLException 参考: https://docs.oracle.com/javase/8/docs/api/java/net/URL.html#URL-java.lang.String-java.lang.String-int-java.lang.String- Spring boot通过注册了一个自定义的Handler类来处理多重jar in jar的逻辑。 这个Handler内部会用SoftReference来缓存所有打开过的JarFile。 在处理像下面这样的URL时,会循环处理’!/’分隔符,从最上层出发,先构造出demo-0.0.1-SNAPSHOT.jar这个JarFile,再构造出spring-beans-4.2.3.RELEASE.jar这个JarFile,然后再构造出指向MANIFEST.MF的JarURLConnection。 jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar!/META-INF/MANIFEST.MF //org.springframework.boot.loader.jar.Handler public class Handler extends URLStreamHandler { private static final String SEPARATOR = "!/"; private static SoftReference<Map<File, JarFile>> rootFileCache; @Override protected URLConnection openConnection(URL url) throws IOException { if (this.jarFile != null) { return new JarURLConnection(url, this.jarFile); } try { return new JarURLConnection(url, getRootJarFileFromUrl(url)); } catch (Exception ex) { return openFallbackConnection(url, ex); } } public JarFile getRootJarFileFromUrl(URL url) throws IOException { String spec = url.getFile(); int separatorIndex = spec.indexOf(SEPARATOR); if (separatorIndex == -1) { throw new MalformedURLException("Jar URL does not contain !/ separator"); } String name = spec.substring(0, separatorIndex); return getRootJarFile(name); } ClassLoader如何读取到Resource 对于一个ClassLoader,它需要哪些能力? 查找资源 读取资源 对应的API是: public URL findResource(String name) public InputStream getResourceAsStream(String name) 上面提到,Spring boot构造LaunchedURLClassLoader时,传递了一个URL[]数组。数组里是lib目录下面的jar的URL。 对于一个URL,JDK或者ClassLoader如何知道怎么读取到里面的内容的? 实际上流程是这样子的: LaunchedURLClassLoader.loadClass URL.getContent() URL.openConnection() Handler.openConnection(URL) 最终调用的是JarURLConnection的getInputStream()函数。 //org.springframework.boot.loader.jar.JarURLConnection @Override public InputStream getInputStream() throws IOException { connect(); if (this.jarEntryName.isEmpty()) { throw new IOException("no entry name specified"); } return this.jarEntryData.getInputStream(); } 从一个URL,到最终读取到URL里的内容,整个过程是比较复杂的,总结下: spring boot注册了一个Handler来处理”jar:”这种协议的URL spring boot扩展了JarFile和JarURLConnection,内部处理jar in jar的情况 在处理多重jar in jar的URL时,spring boot会循环处理,并缓存已经加载到的JarFile 对于多重jar in jar,实际上是解压到了临时目录来处理,可以参考JarFileArchive里的代码 在获取URL的InputStream时,最终获取到的是JarFile里的JarEntryData 这里面的细节很多,只列出比较重要的一些点。 然后,URLClassLoader是如何getResource的呢? URLClassLoader在构造时,有URL[]数组参数,它内部会用这个数组来构造一个URLClassPath: URLClassPath ucp = new URLClassPath(urls); 在 URLClassPath 内部会为这些URLS 都构造一个Loader,然后在getResource时,会从这些Loader里一个个去尝试获取。 如果获取成功的话,就像下面那样包装为一个Resource。 Resource getResource(final String name, boolean check) { final URL url; try { url = new URL(base, ParseUtil.encodePath(name, false)); } catch (MalformedURLException e) { throw new IllegalArgumentException("name"); } final URLConnection uc; try { if (check) { URLClassPath.check(url); } uc = url.openConnection(); InputStream in = uc.getInputStream(); if (uc instanceof JarURLConnection) { /* Need to remember the jar file so it can be closed * in a hurry. */ JarURLConnection juc = (JarURLConnection)uc; jarfile = JarLoader.checkJar(juc.getJarFile()); } } catch (Exception e) { return null; } return new Resource() { public String getName() { return name; } public URL getURL() { return url; } public URL getCodeSourceURL() { return base; } public InputStream getInputStream() throws IOException { return uc.getInputStream(); } public int getContentLength() throws IOException { return uc.getContentLength(); } }; } 从代码里可以看到,实际上是调用了url.openConnection()。这样完整的链条就可以连接起来了。 注意,URLClassPath这个类的代码在JDK里没有自带,在这里看到 http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/7u40-b43/sun/misc/URLClassPath.java#506 在IDE/开放目录启动Spring boot应用 在上面只提到在一个fat jar里启动Spring boot应用的过程,下面分析IDE里Spring boot是如何启动的。 在IDE里,直接运行的Main函数是应用自己的Main函数: @SpringBootApplication public class SpringBootDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringBootDemoApplication.class, args); } } 其实在IDE里启动Spring boot应用是最简单的一种情况,因为依赖的Jar都让IDE放到classpath里了,所以Spring boot直接启动就完事了。 还有一种情况是在一个开放目录下启动Spring boot启动。所谓的开放目录就是把fat jar解压,然后直接启动应用。 java org.springframework.boot.loader.JarLauncher 这时,Spring boot会判断当前是否在一个目录里,如果是的,则构造一个ExplodedArchive(前面在jar里时是JarFileArchive),后面的启动流程类似fat jar的。 Embead Tomcat的启动流程 判断是否在web环境 spring boot在启动时,先通过一个简单的查找Servlet类的方式来判断是不是在web环境: private static final String[] WEB_ENVIRONMENT_CLASSES = { "javax.servlet.Servlet", "org.springframework.web.context.ConfigurableWebApplicationContext" }; private boolean deduceWebEnvironment() { for (String className : WEB_ENVIRONMENT_CLASSES) { if (!ClassUtils.isPresent(className, null)) { return false; } } return true; } 如果是的话,则会创建AnnotationConfigEmbeddedWebApplicationContext,否则Spring context就是AnnotationConfigApplicationContext: //org.springframework.boot.SpringApplication protected ConfigurableApplicationContext createApplicationContext() { Class<?> contextClass = this.applicationContextClass; if (contextClass == null) { try { contextClass = Class.forName(this.webEnvironment ? DEFAULT_WEB_CONTEXT_CLASS : DEFAULT_CONTEXT_CLASS); } catch (ClassNotFoundException ex) { throw new IllegalStateException( "Unable create a default ApplicationContext, " + "please specify an ApplicationContextClass", ex); } } return (ConfigurableApplicationContext) BeanUtils.instantiate(contextClass); } 获取EmbeddedServletContainerFactory的实现类 spring boot通过获取EmbeddedServletContainerFactory来启动对应的web服务器。 常用的两个实现类是TomcatEmbeddedServletContainerFactory和JettyEmbeddedServletContainerFactory。 启动Tomcat的代码: //TomcatEmbeddedServletContainerFactory @Override public EmbeddedServletContainer getEmbeddedServletContainer( ServletContextInitializer... initializers) { Tomcat tomcat = new Tomcat(); File baseDir = (this.baseDirectory != null ? this.baseDirectory : createTempDir("tomcat")); tomcat.setBaseDir(baseDir.getAbsolutePath()); Connector connector = new Connector(this.protocol); tomcat.getService().addConnector(connector); customizeConnector(connector); tomcat.setConnector(connector); tomcat.getHost().setAutoDeploy(false); tomcat.getEngine().setBackgroundProcessorDelay(-1); for (Connector additionalConnector : this.additionalTomcatConnectors) { tomcat.getService().addConnector(additionalConnector); } prepareContext(tomcat.getHost(), initializers); return getTomcatEmbeddedServletContainer(tomcat); } 会为tomcat创建一个临时文件目录,如: /tmp/tomcat.2233614112516545210.8080,做为tomcat的basedir。里面会放tomcat的临时文件,比如work目录。 还会初始化Tomcat的一些Servlet,比如比较重要的default/jsp servlet: private void addDefaultServlet(Context context) { Wrapper defaultServlet = context.createWrapper(); defaultServlet.setName("default"); defaultServlet.setServletClass("org.apache.catalina.servlets.DefaultServlet"); defaultServlet.addInitParameter("debug", "0"); defaultServlet.addInitParameter("listings", "false"); defaultServlet.setLoadOnStartup(1); // Otherwise the default location of a Spring DispatcherServlet cannot be set defaultServlet.setOverridable(true); context.addChild(defaultServlet); context.addServletMapping("/", "default"); } private void addJspServlet(Context context) { Wrapper jspServlet = context.createWrapper(); jspServlet.setName("jsp"); jspServlet.setServletClass(getJspServletClassName()); jspServlet.addInitParameter("fork", "false"); jspServlet.setLoadOnStartup(3); context.addChild(jspServlet); context.addServletMapping("*.jsp", "jsp"); context.addServletMapping("*.jspx", "jsp"); } spring boot的web应用如何访问Resource 当spring boot应用被打包为一个fat jar时,是如何访问到web resource的? 实际上是通过Archive提供的URL,然后通过Classloader提供的访问classpath resource的能力来实现的。 index.html 比如需要配置一个index.html,这个可以直接放在代码里的src/main/resources/static目录下。 对于index.html欢迎页,spring boot在初始化时,就会创建一个ViewController来处理: //ResourceProperties public class ResourceProperties implements ResourceLoaderAware { private static final String[] SERVLET_RESOURCE_LOCATIONS = { "/" }; private static final String[] CLASSPATH_RESOURCE_LOCATIONS = { "classpath:/META-INF/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/" }; //WebMvcAutoConfigurationAdapter @Override public void addViewControllers(ViewControllerRegistry registry) { Resource page = this.resourceProperties.getWelcomePage(); if (page != null) { logger.info("Adding welcome page: " + page); registry.addViewController("/").setViewName("forward:index.html"); } } template 像页面模板文件可以放在src/main/resources/template目录下。但这个实际上是模板的实现类自己处理的。比如ThymeleafProperties类里的: public static final String DEFAULT_PREFIX = "classpath:/templates/"; jsp jsp页面和template类似。实际上是通过spring mvc内置的JstlView来处理的。 可以通过配置spring.view.prefix来设定jsp页面的目录: spring.view.prefix: /WEB-INF/jsp/ spring boot里统一的错误页面的处理 对于错误页面,Spring boot也是通过创建一个BasicErrorController来统一处理的。 @Controller @RequestMapping("${server.error.path:${error.path:/error}}") public class BasicErrorController extends AbstractErrorController 对应的View是一个简单的HTML提醒: @Configuration @ConditionalOnProperty(prefix = "server.error.whitelabel", name = "enabled", matchIfMissing = true) @Conditional(ErrorTemplateMissingCondition.class) protected static class WhitelabelErrorViewConfiguration { private final SpelView defaultErrorView = new SpelView( "<html><body><h1>Whitelabel Error Page</h1>" + "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>" + "<div id='created'>${timestamp}</div>" + "<div>There was an unexpected error (type=${error}, status=${status}).</div>" + "<div>${message}</div></body></html>"); @Bean(name = "error") @ConditionalOnMissingBean(name = "error") public View defaultErrorView() { return this.defaultErrorView; } spring boot的这个做法很好,避免了传统的web应用来出错时,默认抛出异常,容易泄密。 spring boot应用的maven打包过程 先通过maven-shade-plugin生成一个包含依赖的jar,再通过spring-boot-maven-plugin插件把spring boot loader相关的类,还有MANIFEST.MF打包到jar里。 spring boot里有颜色日志的实现 当在shell里启动spring boot应用时,会发现它的logger输出是有颜色的,这个特性很有意思。 可以通过这个设置来关闭: spring.output.ansi.enabled=false 原理是通过AnsiOutputApplicationListener ,这个来获取这个配置,然后设置logback在输出时,加了一个 ColorConverter,通过org.springframework.boot.ansi.AnsiOutput ,对一些字段进行了渲染。 一些代码小技巧 实现ClassLoader时,支持JDK7并行加载 可以参考LaunchedURLClassLoader里的LockProvider public class LaunchedURLClassLoader extends URLClassLoader { private static LockProvider LOCK_PROVIDER = setupLockProvider(); private static LockProvider setupLockProvider() { try { ClassLoader.registerAsParallelCapable(); return new Java7LockProvider(); } catch (NoSuchMethodError ex) { return new LockProvider(); } } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (LaunchedURLClassLoader.LOCK_PROVIDER.getLock(this, name)) { Class<?> loadedClass = findLoadedClass(name); if (loadedClass == null) { Handler.setUseFastConnectionExceptions(true); try { loadedClass = doLoadClass(name); } finally { Handler.setUseFastConnectionExceptions(false); } } if (resolve) { resolveClass(loadedClass); } return loadedClass; } } 检测jar包是否通过agent加载的 InputArgumentsJavaAgentDetector,原理是检测jar的URL是否有”-javaagent:”的前缀。 private static final String JAVA_AGENT_PREFIX = "-javaagent:"; 获取进程的PID ApplicationPid,可以获取PID。 private String getPid() { try { String jvmName = ManagementFactory.getRuntimeMXBean().getName(); return jvmName.split("@")[0]; } catch (Throwable ex) { return null; } } 包装Logger类 spring boot里自己包装了一套logger,支持java, log4j, log4j2, logback,以后有需要自己包装logger时,可以参考这个。 在org.springframework.boot.logging包下面。 获取原始启动的main函数 通过堆栈里获取的方式,判断main函数,找到原始启动的main函数。 private Class<?> deduceMainApplicationClass() { try { StackTraceElement[] stackTrace = new RuntimeException().getStackTrace(); for (StackTraceElement stackTraceElement : stackTrace) { if ("main".equals(stackTraceElement.getMethodName())) { return Class.forName(stackTraceElement.getClassName()); } } } catch (ClassNotFoundException ex) { // Swallow and continue } return null; } spirng boot的一些缺点: 当spring boot应用以一个fat jar方式运行时,会遇到一些问题。以下是个人看法: 日志不知道放哪,默认是输出到stdout的 数据目录不知道放哪, jenkinns的做法是放到 ${user.home}/.jenkins 下面 相对目录API不能使用,servletContext.getRealPath(“/”) 返回的是NULL spring boot应用喜欢把配置都写到代码里,有时会带来混乱。一些简单可以用xml来表达的配置可能会变得难读,而且凌乱。 总结 spring boot通过扩展了jar协议,抽象出Archive概念,和配套的JarFile,JarUrlConnection,LaunchedURLClassLoader,从而实现了上层应用无感知的all in one的开发体验。尽管Executable war并不是spring提出的概念,但spring boot让它发扬光大。 spring boot是一个惊人的项目,可以说是spring的第二春,spring-cloud-config, spring-session, metrics, remote shell等都是深爱开发者喜爱的项目、特性。几乎可以肯定设计者是有丰富的一线开发经验,深知开发人员的痛点。
update: 2015-11-16 新版apache commons collections 3.2.2修复漏洞 新版本的apache commons collections默认禁止了不安全的一些转换类。可以通过升级来修复漏洞。参考release说明:https://commons.apache.org/proper/commons-collections/release_3_2_2.html Dubbo rpc远程代码执行的例子 update: 2015-11-13 重新思考了下这个漏洞,给出一个dubbo rpc远程代码执行的例子。 https://github.com/hengyunabc/dubbo-apache-commons-collections-bug 可以说很多公司开放的rpc,只要协议里支持java序列化方式,classpath里有apache commons collections的jar包,都存在被远程代码执行的风险。 至于能不能通过http接口再调用dubbo rpc远程代码,貌似不太可行。因为信息难以传递。 Apache Commons Collections远程代码执行漏洞 最近出来一个比较严重的漏洞,在使用了Apache Commons Collections的Java应用,可以远程代码执行。包括最新版的WebLogic、WebSphere、JBoss、Jenkins、OpenNMS这些大名鼎鼎的Java应用。 这个漏洞的严重的地方在于,即使你的代码里没有使用到Apache Commons Collections里的类,只要Java应用的Classpath里有Apache Commons Collections的jar包,都可以远程代码执行。 参考: https://github.com/frohoff/ysoserial http://blog.chaitin.com/2015-11-11_java_unserialize_rce/ 这个漏洞的演示很简单,只要在maven依赖里增加 <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency> 再执行下面的java代码: Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }), new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }), new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { "calc" }) }; Transformer transformedChain = new ChainedTransformer(transformers); Map innerMap = new HashMap(); innerMap.put("value", "value"); Map outerMap = TransformedMap.decorate(innerMap, null, transformedChain); Map.Entry onlyElement = (Entry) outerMap.entrySet().iterator().next(); onlyElement.setValue("foobar"); 这个漏洞的根本问题并不是Java序列化的问题,而是Apache Commons Collections允许链式的任意的类函数反射调用。攻击者通过允许Java序列化协议的端口,把攻击代码上传到服务器上,再由Apache Commons Collections里的TransformedMap来执行。 这里不对这个漏洞多做展开,可以看上面的参考文章。 如何简单的防止Java程序调用外部命令? 从这个漏洞,引发了很久之前的一个念头:如何简单的防止Java程序调用外部命令? java相对来说安全性问题比较少。出现的一些问题大部分是利用反射,最终用Runtime.exec(String cmd)函数来执行外部命令的。如果可以禁止JVM执行外部命令,未知漏洞的危害性会大大降低,可以大大提高JVM的安全性。 换而言之,就是如何禁止Java执行Runtime.exec(String cmd)之类的函数。 在Java里有一套Security Policy,但是实际上用的人比较少。因为配置起来太麻烦了。 参考: http://docs.oracle.com/javase/8/docs/technotes/guides/security/PolicyFiles.html http://docs.oracle.com/javase/8/docs/technotes/guides/security/permissions.html http://docs.gigaspaces.com/xap102sec/java-security-policy-file.html 详细的权限列表可以参考这个文档 从文档里可以知道,Java里并没有直接禁止Rumtine.exec 函数执行的权限。 禁止文件执行的权限在java.io.FilePermission里。如果想禁止所有外部文件执行,可以在下面的配置文件中把execute 删除: grant { permission java.io.FilePermission "<<ALL FILES>>", "read, write, delete, execute"; }; 但是Java权限机制是白名单的,还有一大堆的权限要配置上去,非常复杂。 从tomcat的配置就知道了。http://tomcat.apache.org/tomcat-7.0-doc/security-manager-howto.html 所以Tomcat默认是没有启用Security Policy的,可以通过在命令加上-security参数来启用。 catalina.sh start -security 那么有没有简单的办法可以在代码里禁止Java执行外部命令? 研究了下,通过扩展SecurityManager可以简单实现: SecurityManager originalSecurityManager = System.getSecurityManager(); if (originalSecurityManager == null) { // 创建自己的SecurityManager SecurityManager sm = new SecurityManager() { private void check(Permission perm) { // 禁止exec if (perm instanceof java.io.FilePermission) { String actions = perm.getActions(); if (actions != null && actions.contains("execute")) { throw new SecurityException("execute denied!"); } } // 禁止设置新的SecurityManager,保护自己 if (perm instanceof java.lang.RuntimePermission) { String name = perm.getName(); if (name != null && name.contains("setSecurityManager")) { throw new SecurityException("System.setSecurityManager denied!"); } } } @Override public void checkPermission(Permission perm) { check(perm); } @Override public void checkPermission(Permission perm, Object context) { check(perm); } }; System.setSecurityManager(sm); } 只要在Java代码里简单加上面那一段,就可以禁止执行外部程序了。 Java序列化的本身的蛋疼之处 其实Java自身的序列化机制就比较蛋疼,可以参考Effective Java里的。 http://allenlsy.com/NOTES-of-Effective-Java-10/ 并非禁止外部程序执行,Java程序就安全了 要注意的是,如果可以任意执行Java代码,还可以做很多事情,比如写入ssh密钥,从而可以远程登陆,参考最近的Redis未授权访问漏洞:https://www.sebug.net/vuldb/ssvid-89715 总结 禁止JVM执行外部命令,是一个简单有效的提高JVM安全性的办法。但是以前没有见到有相关的内容,有点奇怪。 可以考虑在代码安全扫描时,加强对Runtime.exec相关代码的检测。 有些开源库喜欢用Runtime.exec来执行命令获取网卡mac等操作,个人表示相当的蛋疼,不会使用这样子的代码。
项目地址: https://github.com/hengyunabc/xdiamond 简介 全局配置中心,存储应用的配置项,解决配置混乱分散的问题。名字来源于淘宝的开源项目diamond,前面加上一个字母X以示区别。 wiki 设计思路 在线演示 地址:xdiamond.coding.io,登陆选择standard,用户名密码:admin/admin 特性 所见即所得,在管理界面上看到的所有配置即项目运行时的配置 支持groupId,artifactId,version,profile四个维度以应对复杂环境 支持公共组件的配置继承,client jar包配置继承 配置修改实时通知客户端 和spring集成,使用简单 完善的权限系统 集成LDAP登陆,支持同步LDAP组/用户 支持http RESTful api获取配置 以secret key防止非法获取配置 丰富的metrics, connection统计 工作原理 每个项目有groupId,artifactId,version,然后在不同的环境里对应不同的profile,比如:test, dev, product。 应用在启动时,通过网络连接到xdiamond配置中心,获取到最新的配置。如果没有获取到,从本地备份读取最后拉取的配置。 在Spring初始化时,把配置转为Properties,应用可以通过${}表达式或者@Value来获取配置。 如果配置有更新,可以通过Listener来通知应用。 每个项目都有一个base的profile,所有的profile都会继承base的配置。在base可以放一些公共的配置,比如某个服务的端口。 对于使用者,xdiamond提供的是一个Properties对象。用户可以结合Spring等来使用。 界面截图 项目管理: Profile管理: Config管理: 项目依赖关系图: Metrics信息: Connection信息:
缘起 之前看到有开源项目用了github来做maven仓库,寻思自己也做一个。研究了下,记录下。 简单来说,共有三步: deploy到本地目录 把本地目录提交到gtihub上 配置github地址为仓库地址 配置local file maven仓库 deploy到本地 maven可以通过http, ftp, ssh等deploy到远程服务器,也可以deploy到本地文件系统里。 例如把项目deploy到/home/hengyunabc/code/maven-repo/repository/目录下: <distributionManagement> <repository> <id>hengyunabc-mvn-repo</id> <url>file:/home/hengyunabc/code/maven-repo/repository/</url> </repository> </distributionManagement> 通过命令行则是: mvn deploy -DaltDeploymentRepository=hengyunabc-mvn-repo::default::file:/home/hengyunabc/code/maven-repo/repository/ 推荐使用命令行来deploy,避免在项目里显式配置。 https://maven.apache.org/plugins/maven-deploy-plugin/ https://maven.apache.org/plugins/maven-deploy-plugin/deploy-file-mojo.html 把本地仓库提交到github上 上面把项目deploy到本地目录home/hengyunabc/code/maven-repo/repository里,下面把这个目录提交到github上。 在Github上新建一个项目,然后把home/hengyunabc/code/maven-repo下的文件都提交到gtihub上。 cd /home/hengyunabc/code/maven-repo/ git init git add repository/* git commit -m 'deploy xxx' git remote add origin git@github.com:hengyunabc/maven-repo.git git push origin master 最终效果可以参考我的个人仓库: https://github.com/hengyunabc/maven-repo github maven仓库的使用 因为github使用了raw.githubusercontent.com这个域名用于raw文件下载。所以使用这个maven仓库,只要在pom.xml里增加: <repositories> <repository> <id>hengyunabc-maven-repo</id> <url>https://raw.githubusercontent.com/hengyunabc/maven-repo/master/repository</url> </repository> </repositories> 目录查看和搜索 值得注意的是,github因为安全原因,把raw文件下载和原来的github域名分开了,而raw.githubusercontent.com这个域名是不支持目录浏览的。所以,想要浏览文件目录,或者搜索的话,可以直接到github域名下的仓库去查看。 比如这个文件mybatis-ehcache-spring-0.0.1-20150804.095005-1.jar: 浏览器地址是: https://github.com/hengyunabc/maven-repo/blob/master/repository/io/github/hengyunabc/mybatis-ehcache-spring/0.0.1-SNAPSHOT/mybatis-ehcache-spring-0.0.1-20150804.095005-1.jar 它的maven仓库url是: https://raw.githubusercontent.com/hengyunabc/maven-repo/master/repository/io/github/hengyunabc/mybatis-ehcache-spring/0.0.1-SNAPSHOT/mybatis-ehcache-spring-0.0.1-20150804.095005-1.jar maven仓库工作的机制 下面介绍一些maven仓库工作的原理。典型的一个maven依赖下会有这三个文件: https://github.com/hengyunabc/maven-repo/tree/master/repository/io/github/hengyunabc/mybatis-ehcache-spring/0.0.1-SNAPSHOT maven-metadata.xml maven-metadata.xml.md5 maven-metadata.xml.sha1 maven-metadata.xml里面记录了最后deploy的版本和时间。 <?xml version="1.0" encoding="UTF-8"?> <metadata modelVersion="1.1.0"> <groupId>io.github.hengyunabc</groupId> <artifactId>mybatis-ehcache-spring</artifactId> <version>0.0.1-SNAPSHOT</version> <versioning> <snapshot> <timestamp>20150804.095005</timestamp> <buildNumber>1</buildNumber> </snapshot> <lastUpdated>20150804095005</lastUpdated> </versioning> </metadata> 其中md5, sha1校验文件是用来保证这个meta文件的完整性。 maven在编绎项目时,会先尝试请求maven-metadata.xml,如果没有找到,则会直接尝试请求到jar文件,在下载jar文件时也会尝试下载jar的md5, sha1文件。 maven-metadata.xml文件很重要,如果没有这个文件来指明最新的jar版本,那么即使远程仓库里的jar更新了版本,本地maven编绎时用上-U参数,也不会拉取到最新的jar! 所以并不能简单地把jar包放到github上就完事了,一定要先在本地Deploy,生成maven-metadata.xml文件,并上传到github上。 参考:http://maven.apache.org/ref/3.2.2/maven-repository-metadata/repository-metadata.html maven的仓库关系 https://maven.apache.org/repository/index.html 配置使用本地仓库 想要使用本地file仓库里,在项目的pom.xml里配置,如: <repositories> <repository> <id>hengyunabc-maven-repo</id> <url>file:/home/hengyunabc/code/maven-repo/repository/</url> </repository> </repositories> 注意事项 maven的repository并没有优先级的配置,也不能单独为某些依赖配置repository。所以如果项目配置了多个repository,在首次编绎时,会依次尝试下载依赖。如果没有找到,尝试下一个,整个流程会很长。 所以尽量多个依赖放同一个仓库,不要每个项目都有一个自己的仓库。 参考 http://stackoverflow.com/questions/14013644/hosting-a-maven-repository-on-github/14013645#14013645 http://cemerick.com/2010/08/24/hosting-maven-repos-on-github/
spring mvc里的root/child WebApplicationContext的继承关系 在传统的spring mvc程序里会有两个WebApplicationContext,一个是parent,从applicationContext.xml里加载的,一个是child,从servlet-context.xml里加载的。 两者是继承关系,child WebApplicationContext 可以通过getParent()函数获取到root WebApplicationContext。 简单地说child WebApplicationContext里的bean可以注入root WebApplicationContext里的bean,而parent WebApplicationContext的bean则不能注入child WebApplicationContext里的bean。 一个典型的web.xml的内容是: <!-- The definition of the Root Spring Container shared by all Servlets and Filters --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath*:/applicationContext.xml</param-value> </context-param> <!-- Creates the Spring Container shared by all Servlets and Filters --> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- Processes application requests --> <servlet> <servlet-name>appServlet</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/servlet-context.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> <async-supported>true</async-supported> </servlet> <servlet-mapping> <servlet-name>appServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> 其中root WebApplicationContext是通过listener初始化的,child WebApplicationContext是通过servlet初始化的。 而在applicationContext.xml里通常只component-scan非Controller的类,如: <context:component-scan base-package="io.github.test"> <context:exclude-filter expression="org.springframework.stereotype.Controller" type="annotation" /> <context:exclude-filter type="annotation" expression="org.springframework.web.bind.annotation.ControllerAdvice" /> </context:component-scan> 在servlet-context.xml里通常只component-scan Controller类,如: <context:component-scan base-package="io.github.test.web" use-default-filters="false"> <context:include-filter expression="org.springframework.stereotype.Controller" type="annotation" /> <context:include-filter type="annotation" expression="org.springframework.web.bind.annotation.ControllerAdvice" /> </context:component-scan> 如果不这样子分别component-scan的话,可能会出现Bean重复初始化的问题。 上面是Spring官方开始时推荐的做法。 root/child WebApplicationContext继承关系带来的麻烦 root WebApplicationContext里的bean可以在不同的child WebApplicationContext里共享,而不同的child WebApplicationContext里的bean区不干扰,这个本来是个很好的设计。 但是实际上有会不少的问题: * 不少开发者不知道Spring mvc里分有两个WebApplicationContext,导致各种重复构造bean,各种bean无法注入的问题。 * 有一些bean,比如全局的aop处理的类,如果先root WebApplicationContext里初始化了,那么child WebApplicationContext里的初始化的bean就没有处理到。如果在child WebApplicationContext里初始化,在root WebApplicationContext里的类就没有办法注入了。 * 区分哪些bean放在root/child很麻烦,不小心容易搞错,而且费心思。 一劳永逸的解决办法:bean都由root WebApplicationContext加载 在一次配置metrics-spring时,对配置@EnableMetrics配置在哪个WebApplicationContext里,感到很蛋疼。最终决定试下把所有的bean,包括Controller都移到root WebApplicationContext,即applicationContext.xml里加载,而servlet-context.xml里基本是空的。结果发现程序运行完全没问题。 后面在网上搜索了下,发现有一些相关的讨论: http://forum.spring.io/forum/spring-projects/container/89149-servlet-context-vs-application-context spring boot里的做法 在spring boot里默认情况下不需要component-scan的配置,于是猜测在Spring boot里是不是只有一个WebApplicationContext? 后面测试下了,发现在spring boot里默认情况下的确是只有一个WebApplicationContext:org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext,所以在spring boot里省事了很多。 总结 spring 的ApplicationContext继承机制是一个很好的设计,在很多其它地方都可以看到类似的思路,比如Java的class loader。但是在大部分spring web程序里,实际上只要一个WebApplicationContext就够了。如果分开rott/child WebApplicationContext会导致混乱,而没什么用。 所以推荐把所有的Service/Controller都移到root WebApplicationContext中初始化。
最近tomcat升级版本时,遇到了ssi解析的问题,记录下解决的过程,还有tomcat ssi配置的要点。 tomcat 配置SSI的两种方式 Tomcat有两种方式支持SSI:Servlet和Filter。 SSIServlet 通过Servlet,org.apache.catalina.ssi.SSIServlet,默认处理”*.shtml”的URL。 配置方式: 修改tomcat的 conf/web.xml文件,去掉下面配置的注释: <servlet> <servlet-name>ssi</servlet-name> <servlet-class> org.apache.catalina.ssi.SSIServlet </servlet-class> <init-param> <param-name>buffered</param-name> <param-value>1</param-value> </init-param> <init-param> <param-name>debug</param-name> <param-value>0</param-value> </init-param> <init-param> <param-name>expires</param-name> <param-value>666</param-value> </init-param> <init-param> <param-name>isVirtualWebappRelative</param-name> <param-value>false</param-value> </init-param> <load-on-startup>4</load-on-startup> </servlet> <servlet-mapping> <servlet-name>ssi</servlet-name> <url-pattern>*.shtml</url-pattern> </servlet-mapping> SSIFilter 通过Filter,org.apache.catalina.ssi.SSIFilter,默认处理”*.shtml”的URL。 配置方式: 修改tomcat的 conf/web.xml文件,打开去掉下面配置的注释: <filter> <filter-name>ssi</filter-name> <filter-class> org.apache.catalina.ssi.SSIFilter </filter-class> <init-param> <param-name>contentType</param-name> <param-value>text/x-server-parsed-html(;.*)?</param-value> </init-param> <init-param> <param-name>debug</param-name> <param-value>0</param-value> </init-param> <init-param> <param-name>expires</param-name> <param-value>666</param-value> </init-param> <init-param> <param-name>isVirtualWebappRelative</param-name> <param-value>false</param-value> </init-param> </filter> <filter-mapping> <filter-name>ssi</filter-name> <url-pattern>*.shtml</url-pattern> </filter-mapping> 注意事项 注意:两种配置方式最好不要同时打开,除非很清楚是怎样配置的。 另外,在Tomcat的conf/context.xml里要配置privileged=”true”,否则有些SSI特性不能生效。 <Context privileged="true"> 历史代码里处理SSI的办法 在公司的历史代码里,在一个公共的jar包里通过自定义一个EnhancedSSIServlet,继承了Tomcat的org.apache.catalina.ssi.SSIServlet来实现SSI功能的。 @WebServlet(name="ssi", initParams={@WebInitParam(name="buffered", value="1"), @WebInitParam(name="debug", value="0"), @WebInitParam(name="expires", value="666"), @WebInitParam(name="isVirtualWebappRelative", value="0"), @WebInitParam(name="inputEncoding", value="UTF-8"), @WebInitParam(name="outputEncoding", value="UTF-8") }, loadOnStartup=1, urlPatterns={"*.shtml"}, asyncSupported=true) public class EnhancedSSIServlet extends SSIServlet { 其中@WebServlet是Servlet3.0规范里的,所以使用到web-common的web项目的web.xml文件都要配置为3.0版本以上,例如: <?xml version="1.0" encoding="UTF-8"?> <web-app version="3.0" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"> </web-app> Tomcat是启动Web应用时,会扫描所有@WebServlet的类,并初始化。 所以在使用到历史代码的项目都只能使用Tomcat服务器,并且不能在tomcat的conf/web.xml里打开SSI相关的配置。 Tomcat版本升级的问题 Tomcat版本从7.0.57升级到7.0.59过程中,出现了无法解析SSI include指令的错误: SEVERE: #include--Couldn't include file: /pages/test/intelFilter.shtml java.io.IOException: Couldn't get context for path: /pages/test/intelFilter.shtml at org.apache.catalina.ssi.SSIServletExternalResolver.getServletContextAndPathFromVirtualPath(SSIServletExternalResolver.java:422) at org.apache.catalina.ssi.SSIServletExternalResolver.getServletContextAndPath(SSIServletExternalResolver.java:465) at org.apache.catalina.ssi.SSIServletExternalResolver.getFileText(SSIServletExternalResolver.java:522) at org.apache.catalina.ssi.SSIMediator.getFileText(SSIMediator.java:161) at org.apache.catalina.ssi.SSIInclude.process(SSIInclude.java:50) at org.apache.catalina.ssi.SSIProcessor.process(SSIProcessor.java:159) at com.test.webcommon.servlet.EnhancedSSIServlet.processSSI(EnhancedSSIServlet.java:72) at org.apache.catalina.ssi.SSIServlet.requestHandler(SSIServlet.java:181) at org.apache.catalina.ssi.SSIServlet.doPost(SSIServlet.java:137) at javax.servlet.http.HttpServlet.service(HttpServlet.java:646) at javax.servlet.http.HttpServlet.service(HttpServlet.java:727) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:303) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208) at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:748) at org.apache.catalina.core.ApplicationDispatcher.doInclude(ApplicationDispatcher.java:604) at org.apache.catalina.core.ApplicationDispatcher.include(ApplicationDispatcher.java:543) at org.apache.jasper.runtime.JspRuntimeLibrary.include(JspRuntimeLibrary.java:954) at org.apache.jsp.pages.lottery.jczq.index_jsp._jspService(index_jsp.java:107) at org.apache.jasper.runtime.HttpJspBase.service(HttpJspBase.java:70) at javax.servlet.http.HttpServlet.service(HttpServlet.java:727) at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:432) at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:395) at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:339) at javax.servlet.http.HttpServlet.service(HttpServlet.java:727) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:303) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208) 仔细查看源代码后,发现不能处理的include指令代码如下: <!--#include virtual="/pages/test/intelFilter.shtml"--> 经过对比调试Tomcat的代码,发现是在7.0.58版本时,改变了处理URL的方法,关键的处理函数是 org.apache.catalina.core.ApplicationContext.getContext( String uri) 在7.0.57版本前,Tomcat在处理处理像/pages/test/intelFilter.shtml这样的路径时,恰好循环处理了”/”字符,使得childContext等于StandardContext,最终由StandardContext处理了/pages/test/intelFilter.shtml的请求。 这个代码实际上是错误的,不过恰好处理了include virtual的情况。 在7.0.58版本修改了处理uri的代码,所以在升级Tomcat到7.0.59时出错了。 7.0.57版的代码: https://svn.apache.org/repos/asf/tomcat/tc7.0.x/tags/TOMCAT_7_0_57/java/org/apache/catalina/core/ApplicationContext.java /** * Return a <code>ServletContext</code> object that corresponds to a * specified URI on the server. This method allows servlets to gain * access to the context for various parts of the server, and as needed * obtain <code>RequestDispatcher</code> objects or resources from the * context. The given path must be absolute (beginning with a "/"), * and is interpreted based on our virtual host's document root. * * @param uri Absolute URI of a resource on the server */ @Override public ServletContext getContext(String uri) { // Validate the format of the specified argument if ((uri == null) || (!uri.startsWith("/"))) return (null); Context child = null; try { Host host = (Host) context.getParent(); String mapuri = uri; while (true) { child = (Context) host.findChild(mapuri); if (child != null) break; int slash = mapuri.lastIndexOf('/'); if (slash < 0) break; mapuri = mapuri.substring(0, slash); } } catch (Throwable t) { ExceptionUtils.handleThrowable(t); return (null); } if (child == null) return (null); if (context.getCrossContext()) { // If crossContext is enabled, can always return the context return child.getServletContext(); } else if (child == context) { // Can still return the current context return context.getServletContext(); } else { // Nothing to return return (null); } } 7.0.58的代码: https://svn.apache.org/repos/asf/tomcat/tc7.0.x/tags/TOMCAT_7_0_58/java/org/apache/catalina/core/ApplicationContext.java 那么正确的处理办法是怎样的? 仔细查看Tomcat的SSI配置的说明文档,发现有一个isVirtualWebappRelative的配置,而这个配置默认是false的。 isVirtualWebappRelative - Should "virtual" SSI directive paths be interpreted as relative to the context root, instead of the server root? Default false. **也就是说,如果要支持“#include virtual=”/b.shtml”绝对路径这种指令,就要配置isVirtualWebappRelative为true。 但是tomcat默认的SSI配置,以及上面的EnhancedSSIServlet类默认都配置isVirtualWebappRelative为false。** 因此,把EnhancedSSIServlet类里的isVirtualWebappRelative配置为true,重新测试,发现已经可以正常处理”#include virtual=”/b.shtml”指令了。 相关的逻辑处理的代码在org.apache.catalina.ssi.SSIServletExternalResolver.getServletContextAndPathFromVirtualPath( String virtualPath): protected ServletContextAndPath getServletContextAndPathFromVirtualPath( String virtualPath) throws IOException { if (!virtualPath.startsWith("/") && !virtualPath.startsWith("\\")) { return new ServletContextAndPath(context, getAbsolutePath(virtualPath)); } String normalized = RequestUtil.normalize(virtualPath); if (isVirtualWebappRelative) { return new ServletContextAndPath(context, normalized); } ServletContext normContext = context.getContext(normalized); if (normContext == null) { throw new IOException("Couldn't get context for path: " + normalized); } 总结 之前的EnhancedSSIServlet类的配置就不支持”#include virtual=”/b.shtml”,这种绝对路径的SSI指令,而以前版本的Tomcat因为恰好处理了”/test.shtml”这种以”/”开头的url,因此以前版本的Tomcat没有报错。而升级后的Tomcat修正了代码,不再处理这种不合理的绝对路径请求了,所以报“ Couldn’t get context for path”的异常。 把tomcat的ssi配置里的isVirtualWebappRelative设置为true就可以了。 最后,留一个小问题: tomcat是如何知道处理*.jsp请求的?是哪个servlet在起作用? 参考 https://tomcat.apache.org/tomcat-7.0-doc/ssi-howto.html
TCP keep-alive的三个参数 用man命令,可以查看linux的tcp的参数: man 7 tcp 其中keep-alive相关的参数有三个: tcp_keepalive_intvl (integer; default: 75; since Linux 2.4) The number of seconds between TCP keep-alive probes. tcp_keepalive_probes (integer; default: 9; since Linux 2.2) The maximum number of TCP keep-alive probes to send before giving up and killing the connection if no response is obtained from the other end. tcp_keepalive_time (integer; default: 7200; since Linux 2.2) The number of seconds a connection needs to be idle before TCP begins sending out keep-alive probes. Keep- alives are sent only when the SO_KEEPALIVE socket option is enabled. The default value is 7200 seconds (2 hours). An idle connection is terminated after approximately an additional 11 minutes (9 probes an interval of 75 seconds apart) when keep-alive is enabled. 这些的默认配置值在/proc/sys/net/ipv4 目录下可以找到。 可以直接用cat来查看文件的内容,就可以知道配置的值了。 也可以通过sysctl命令来查看和修改: # 查询 cat /proc/sys/net/ipv4/tcp_keepalive_time sysctl net.ipv4.tcp_keepalive_time #修改 sysctl net.ipv4.tcp_keepalive_time=3600 上面三个是系统级的配置,在编程时有三个参数对应,可以覆盖掉系统的配置: TCP_KEEPCNT 覆盖 tcp_keepalive_probes,默认9(次) TCP_KEEPIDLE 覆盖 tcp_keepalive_time,默认7200(秒) TCP_KEEPINTVL 覆盖 tcp_keepalive_intvl,默认75(秒) ``` ## tcp keep-alive的本质 ###TCP keep-alive probe 上面了解了tcp keep-alive的一些参数,下面来探究下其本质。 在远程机器192.168.66.123上,用nc启动一个TCP服务器: ```bash nc -l 9999 <div class="se-preview-section-delimiter"></div> 在本地机器上,用python创建一个socket去连接,并且用wireshark抓包分析 import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) s.setsockopt(socket.SOL_TCP, socket.TCP_KEEPIDLE, 20) s.setsockopt(socket.SOL_TCP, socket.TCP_KEEPINTVL, 1) s.connect(('192.168.66.123', 9999)) 上面的程序,设置了TCP_KEEPIDLE为20,TCP_KEEPINTVL为1,系统默认的tcp_keepalive_probes是9。 当网络正常,不做干扰时,wireshark抓包的数据是这样的(注意看第二列Time): 可以看到,当3次握手完成之后,每隔20秒之后66.120发送了一个TCP Keep-Alive的数据包,然后66.123回应了一个TCP Keep-Alive ACK包。这个就是TCP keep-alive的实现原理了。 当发送了第一个TCP Keep-Alive包之后,拨掉192.168.66.123的网线,然后数据包是这样子的: 可以看到,当远程服务器192.168.66.123网络失去连接之后,本地机器(192.168.66.120)每隔一秒重发了9次tcp keep-alive probe,最终认为这个TCP连接已经失效,发了一个RST包给192.168.66.123。 在本地机器上,用python创建一个socket去连接,并且用wireshark抓包分析 ```python import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) s.setsockopt(socket.SOL_TCP, socket.TCP_KEEPIDLE, 20) s.setsockopt(socket.SOL_TCP, socket.TCP_KEEPINTVL, 1) s.connect(('192.168.66.123', 9999)) 上面的程序,设置了TCP_KEEPIDLE为20,TCP_KEEPINTVL为1,系统默认的tcp_keepalive_probes是9。 当网络正常,不做干扰时,wireshark抓包的数据是这样的(注意看第二列Time): 可以看到,当3次握手完成之后,每隔20秒之后66.120发送了一个TCP Keep-Alive的数据包,然后66.123回应了一个TCP Keep-Alive ACK包。这个就是TCP keep-alive的实现原理了。 当发送了第一个TCP Keep-Alive包之后,拨掉192.168.66.123的网线,然后数据包是这样子的: 可以看到,当远程服务器192.168.66.123网络失去连接之后,本地机器(192.168.66.120)每隔一秒重发了9次tcp keep-alive probe,最终认为这个TCP连接已经失效,发了一个RST包给192.168.66.123。 为什么应用层需要heart beat/心跳包? 默认的tcp keep-alive超时时间太长 默认是7200秒,也就是2个小时。 socks proxy会让tcp keep-alive失效 socks协议只管转发TCP层具体的数据包,而不会转发TCP协议内的实现细节的包(也做不到),参考socks_proxy。 所以,一个应用如果使用了socks代理,那么tcp keep-alive机制就失效了,所以应用要自己有心跳包。 socks proxy只是一个例子,真实的网络很复杂,可能会有各种原因让tcp keep-alive失效。 移动网络需要信令保活 前两年,微信信令事件很火,搜索下“微信 信令”或者“移动网络 信令”可以查到很多相关文章。 这里附上一个链接:微信的大规模使用真的会过多占用信令,影响通讯稳定吗? 总结 TCP keep-alive是通过在空闲时发送TCP Keep-Alive数据包,然后对方回应TCP Keep-Alive ACK来实现的。 为什么需要heart beat/心跳包?因为tcp keep-alive不能满足人们的实时性的要求,就是这么简单。
项目地址 https://github.com/hengyunabc/redis-id-generator 基于redis的分布式ID生成器。 准备 首先,要知道redis的EVAL,EVALSHA命令: http://redis.readthedocs.org/en/latest/script/eval.html http://redis.readthedocs.org/en/latest/script/evalsha.html 原理 利用redis的lua脚本执行功能,在每个节点上通过lua脚本生成唯一ID。 生成的ID是64位的: 使用41 bit来存放时间,精确到毫秒,可以使用41年。 使用12 bit来存放逻辑分片ID,最大分片ID是4095 使用10 bit来存放自增长ID,意味着每个节点,每毫秒最多可以生成1024个ID 比如GTM时间 Fri Mar 13 10:00:00 CST 2015 ,它的距1970年的毫秒数是 1426212000000,假定分片ID是53,自增长序列是4,则生成的ID是: 5981966696448054276 = 1426212000000 << 22 + 53 << 10 + 4 redis提供了TIME命令,可以取得redis服务器上的秒数和微秒数。因些lua脚本返回的是一个四元组。 second, microSecond, partition, seq 客户端要自己处理,生成最终ID。 ((second * 1000 + microSecond / 1000) << (12 + 10)) + (shardId << 10) + seq; 集群实现原理 假定集群里有3个节点,则节点1返回的seq是: 0, 3, 6, 9, 12 ... 节点2返回的seq是 1, 4, 7, 10, 13 ... 节点3返回的seq是 2, 5, 8, 11, 14 ... 这样每个节点返回的数据都是唯一的。 单个节点部署 下载redis-script-node1.lua,并把它load到redis上。 cd redis-directory/ wget https://raw.githubusercontent.com/hengyunabc/redis-id-generator/master/redis-script-node1.lua ./redis-cli script load "$(cat redis-script-node1.lua)" 获取lua脚本的sha1值,可能是: fce3758b2e0af6cbf8fea4d42b379cd0dc374418 在代码里,通过EVALSHA命令,传递这个sha1值,就可以得到生成的ID。 比如,通过命令行执行: ./redis-cli EVALSHA fce3758b2e0af6cbf8fea4d42b379cd0dc374418 2 test 123456789 结果可能是: 1) (integer) 1426238286 2) (integer) 130532 3) (integer) 277 4) (integer) 4 集群部署 假定集群是3个节点,则分别对三个节点执行: ./redis-cli -host node1 -p 6379 script load "$(cat redis-script-node1.lua)" ./redis-cli -host node2 -p 7379 script load "$(cat redis-script-node2.lua)" ./redis-cli -host node3 -p 8379 script load "$(cat redis-script-node3.lua)" 性能 redis默认配置。 单节点,单线程: time:0:00:00.959 speed:10427.52867570386 单节点,20线程: time:0:00:06.710 speed:29806.259314456034 结论: - 单节点,qps约3w - 可以线性扩展,3个结点足以满足绝大部分的应用 java客户端封装 在redis-id-generator-java目录下,有example和benchmark代码。 在调用时,要传入两个参数 - tag,即为哪一类服务生成ID - shardId,即分片由哪个ID生成,比如一个用户的订单,则分片ID应该由userId来生成 public class Example { public static void main(String[] args) { String tab = "order"; long userId = 123456789; IdGenerator idGenerator = IdGenerator.builder() .addHost("127.0.0.1", 6379, "fce3758b2e0af6cbf8fea4d42b379cd0dc374418") // .addHost("127.0.0.1", 7379, "1abc55928f37176cb934fc7a65069bf32282d817") // .addHost("127.0.0.1", 8379, "b056d20feb3f89483b10c81027440cbf6920f74f") .build(); long id = idGenerator.next(tab, userId); System.out.println("id:" + id); List<Long> result = IdGenerator.parseId(id); System.out.println("miliSeconds:" + result.get(0) + ", partition:" + result.get(1) + ", seq:" + result.get(2)); } } 多语言客户端 只要支持redis evalsha命令就可以了。 其它 之前写的一个blog:分片(Sharding)的全局ID生成
新blog地址:http://hengyunabc.github.io/cookie-and-session-and-csrf/ 在线幻灯片地址: Cookie & Session & CSRF
新blog地址:http://hengyunabc.github.io/prevent-iframe-stealing/ 缘起 在看资料时,看到这样的防止iframe嵌套的代码: try { if (window.top != window.self) { var ref = document.referer; if (ref.substring(0, 2) === '//') { ref = 'http:' + ref; } else if (ref.split('://').length === 1) { ref = 'http://' + ref; } var url = ref.split('/'); var _l = {auth: ''}; var host = url[2].split('@'); if (host.length === 1) { host = host[0].split(':'); } else { _l.auth = host[0]; host = host[1].split(':'); } var parentHostName = host[0]; if (parentHostName.indexOf("test.com") == -1 && parentHostName.indexOf("test2.com") == -1) { top.location.href = "http://www.test.com"; } } } catch (e) { } 假定test.com,test2.com是自己的域名,当其它网站恶意嵌套本站的页面时,跳转回本站的首页。 上面的代码有两个问题: referer拼写错误,实际上应该是referrer 解析referrer的代码太复杂,还不一定正确 无论在任何语言里,都不建议手工写代码处理URL。因为url的复杂度超出一般人的想像。很多安全的问题就是因为解析URL不当引起的。比如防止CSRF时判断referrer。 URI的语法: http://en.wikipedia.org/wiki/URI_scheme#Generic_syntax 在javascript里解析url最好的办法 在javascript里解析url的最好办法是利用浏览器的js引擎,通过创建一个a标签: var getLocation = function(href) { var l = document.createElement("a"); l.href = href; return l; }; var l = getLocation("http://example.com/path"); console.debug(l.hostname) 简洁防iframe恶意嵌套的方法 下面给出一个简洁的防止iframe恶意嵌套的判断方法: if(window.top != window && document.referrer){ var a = document.createElement("a"); a.href = document.referrer; var host = a.hostname; var endsWith = function (str, suffix) { return str.indexOf(suffix, str.length - suffix.length) !== -1; } if(!endsWith(host, '.test.com') || !endsWith(host, '.test2.com')){ top.location.href = "http://www.test.com"; } } java里处理URL的方法 http://docs.oracle.com/javase/tutorial/networking/urls/urlInfo.html 用contain, indexOf, endWitch这些函数时都要小心。 public static void main(String[] args) throws Exception { URL aURL = new URL("http://example.com:80/docs/books/tutorial" + "/index.html?name=networking#DOWNLOADING"); System.out.println("protocol = " + aURL.getProtocol()); System.out.println("authority = " + aURL.getAuthority()); System.out.println("host = " + aURL.getHost()); System.out.println("port = " + aURL.getPort()); System.out.println("path = " + aURL.getPath()); System.out.println("query = " + aURL.getQuery()); System.out.println("filename = " + aURL.getFile()); System.out.println("ref = " + aURL.getRef()); } 参考 http://stackoverflow.com/questions/736513/how-do-i-parse-a-url-into-hostname-and-path-in-javascript http://stackoverflow.com/questions/5522097/prevent-iframe-stealing
新blog地址:http://hengyunabc.github.io/kafka-manager-install/ 项目信息 https://github.com/yahoo/kafka-manager 这个项目比 https://github.com/claudemamo/kafka-web-console 要好用一些,显示的信息更加丰富,kafka-manager本身可以是一个集群。 不过kafka-manager也没有权限管理功能。 Kafka web console的安装可以参考之前的blog: http://blog.csdn.net/hengyunabc/article/details/40431627 安装sbt sbt是scala的打包构建工具。 http://www.scala-sbt.org/download.html ubuntu下安装: echo "deb https://dl.bintray.com/sbt/debian /" | sudo tee -a /etc/apt/sources.list.d/sbt.list sudo apt-get update sudo apt-get install sbt 下载,编绎 编绎,生成发布包: git clone https://github.com/yahoo/kafka-manager cd kafka-manager sbt clean dist 生成的包会在kafka-manager/target/universal 下面。生成的包只需要java环境就可以运行了,在部署的机器上不需要安装sbt。 如果打包很慢的话,可以考虑配置代理。 部署 打好包好,在部署机器上解压,修改好配置文件,就可以运行了。 - 解压 unzip kafka-manager-1.0-SNAPSHOT.zip 修改conf/application.conf,把kafka-manager.zkhosts改为自己的zookeeper服务器地址 kafka-manager.zkhosts="localhost:2181" 启动 cd kafka-manager-1.0-SNAPSHOT/bin ./kafka-manager -Dconfig.file=../conf/application.conf 查看帮助 和 后台运行: ./kafka-manager -h nohup ./kafka-manager -Dconfig.file=../conf/application.conf >/dev/null 2>&1 & 默认http端口是9000,可以修改配置文件里的http.port的值,或者通过命令行参数传递: ./kafka-manager -Dhttp.port=9001 正常来说,play框架应该会自动加载conf/application.conf配置里的内容,但是貌似这个不起作用,要显式指定才行。 参考: https://github.com/yahoo/kafka-manager/issues/16 sbt 配置代理 sbt的配置http代理的参考文档: http://www.scala-sbt.org/0.12.1/docs/Detailed-Topics/Setup-Notes.html#http-proxy 通过-D设置叁数即可: java -Dhttp.proxyHost=myproxy -Dhttp.proxyPort=8080 -Dhttp.proxyUser=username -Dhttp.proxyPassword=mypassword 也可以用下面这种方式,设置一下SBT_OPTS的环境变量即可: export SBT_OPTS="$SBT_OPTS -Dhttp.proxyHost=myproxy -Dhttp.proxyPort=myport" 要注意的是,myproxy,这个值里不要带http前缀,也不要带端口号。 比如,你的代理是http://localhost:8123,那么应该这样配置: export SBT_OPTS="$SBT_OPTS -Dhttp.proxyHost=localhost -Dhttp.proxyPort=8123" 打好的一个包 如果打包有问题的小伙伴可以从这里下载: http://pan.baidu.com/s/1kTtFpGV md5: bde4f57c4a1ac09a0dc7f3f892ea9026
新blog地址:http://hengyunabc.github.io/about-metrics/ 想要实现的功能 应用可以用少量的代码,实现统计某类数据的功能 统计的数据可以很方便地展示 metrics metrics,按字面意思是度量,指标。 举具体的例子来说,一个web服务器: - 一分钟内请求多少次? - 平均请求耗时多长? - 最长请求时间? - 某个方法的被调用次数,时长? 以缓存为例: - 平均查询缓存时间? - 缓存获取不命中的次数/比例? 以jvm为例: - GC的次数? - Old Space的大小? 在一个应用里,需要收集的metrics数据是多种多样的,需求也是各不同的。需要一个统一的metrics收集,统计,展示平台。 流行的metrics的库 https://github.com/dropwizard/metrics java实现,很多开源项目用到,比如hadoop,kafka。下面称为dropwizard/metrics。 https://github.com/tumblr/colossus scala实现,把数据存到OpenTsdb上。 spring boot 项目里的metrics: http://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-metrics.html spring boot里的metrics很多都是参考dropwizard/metrics的。 metrics的种类 dropwizard/metrics 里主要把metrics分为下面几大类: https://dropwizard.github.io/metrics/3.1.0/getting-started/ Gauges gauge用于测量一个数值。比如队列的长度: public class QueueManager { private final Queue queue; public QueueManager(MetricRegistry metrics, String name) { this.queue = new Queue(); metrics.register(MetricRegistry.name(QueueManager.class, name, "size"), new Gauge<Integer>() { @Override public Integer getValue() { return queue.size(); } }); } } Counters counter是AtomicLong类型的gauge。比如可以统计阻塞在队列里的job的数量: private final Counter pendingJobs = metrics.counter(name(QueueManager.class, "pending-jobs")); public void addJob(Job job) { pendingJobs.inc(); queue.offer(job); } public Job takeJob() { pendingJobs.dec(); return queue.take(); } Histograms histogram统计数据的分布。比如最小值,最大值,中间值,还有中位数,75百分位, 90百分位, 95百分位, 98百分位, 99百分位, and 99.9百分位的值(percentiles)。 比如request的大小的分布: private final Histogram responseSizes = metrics.histogram(name(RequestHandler.class, "response-sizes")); public void handleRequest(Request request, Response response) { // etc responseSizes.update(response.getContent().length); } Timers timer正如其名,统计的是某部分代码/调用的运行时间。比如统计response的耗时: private final Timer responses = metrics.timer(name(RequestHandler.class, "responses")); public String handleRequest(Request request, Response response) { final Timer.Context context = responses.time(); try { // etc; return "OK"; } finally { context.stop(); } } Health Checks 这个实际上不是统计数据。是接口让用户可以自己判断系统的健康状态。如判断数据库是否连接正常: final HealthCheckRegistry healthChecks = new HealthCheckRegistry(); public class DatabaseHealthCheck extends HealthCheck { private final Database database; public DatabaseHealthCheck(Database database) { this.database = database; } @Override public HealthCheck.Result check() throws Exception { if (database.isConnected()) { return HealthCheck.Result.healthy(); } else { return HealthCheck.Result.unhealthy("Cannot connect to " + database.getUrl()); } } } Metrics Annotation 利用dropwizard/metrics 里的annotation,可以很简单的实现统计某个方法,某个值的数据。 如: /** * 统计调用的次数和时间 */ @Timed public void call() { } /** * 统计登陆的次数 */ @Counted public void userLogin(){ } 想要详细了解各种metrics的实际效果,简单的运行下测试代码,用ConsoleReporter输出就可以知道了。 metrics数据的传输和展示 dropwizard/metrics 里提供了reporter的接口,用户可以自己实现如何处理metrics数据。 dropwizard/metrics有不少现成的reporter: ConsoleReporter 输出到stdout JmxReporter 转化为MBean metrics-servlets 提供http接口,可以查询到metrics信息 CsvReporter 输出为CSV文件 Slf4jReporter 以log方式输出 GangliaReporter 上报到Ganglia GraphiteReporter 上报到Graphite 上面的各种reporter中,Ganglia开源多年,但缺少一些监控的功能,图形展示也很简陋。Graphite已经停止开发了。 而公司所用的监控系统是zabbix,而dropwizard/metrics没有现成的zabbix reporter。 zabbix的限制 zabbix上报数据通常用zabbix agent或者zabbix trapper。 用户自己上报的数据通常用zabbix trapper来上报。 zabbix上收集数据的叫item,每个item都有自己的key,而这些item不会自动创建。zabbix有Low-level discovery,可以自动创建item,但是也相当麻烦,而且key的命名非常奇怪。不如直接用template了。 https://www.zabbix.com/documentation/2.4/manual/discovery/low_level_discovery 假定zabbix上不同的应用的key都是相对固定的,那么就可以通过模板的方式,比较方便地统一创建item, graph了。 另外想要实现自动创建item,比较好的办法是通过zabbix api了。 但目前Java版没有实现,于是实现了一个简单的: https://github.com/hengyunabc/zabbix-api dropwizard/metrics zabbix reporter 基于上面的template的思路,实现了一个dropwizard/metrics 的zabbix reporter。 原理是,通过zabbix sender,把metrics数据直接发送到zabbix server上。 https://github.com/hengyunabc/zabbix-sender https://github.com/hengyunabc/metrics-zabbix dropwizard/metrics发送到kafka,再从kafka发到zabbix 上面的方案感觉还是不太理想: - 没有实现自动化,还要手动为每一个应用配置template,不够灵活 - 所有的数据都发送到一个zabbix server上,担心性能有瓶颈 于是,新的思路是,把metrics数据发送到kafka上,然后再从kafka上消费,再把数据传到zabbix server上。 这样的好处是: - kafka可以灵活扩容,不会有性能瓶颈 - 从kafka上消费metrics数据,可以灵活地用zabbix api来创建item, graph 于是实现了两个新项目: - https://github.com/hengyunabc/metrics-kafka - https://github.com/hengyunabc/kafka-zabbix Java程序先把metrics数据上报到kafka,然后kafka consumer从metrics数据里,提取出host, key信息,再用zabbix-api在zabbix server上创建item,最后把metrics数据上报给zabbix server。 自动创建的zabbix item的效果图: 在zabbix上显示的用户自定义的统计数据的图: 数据的聚合 比如,统计接口的访问次数,而这个接口部署在多台服务器上,那么如何展示聚合的数据? zabbix自带有聚合功能,参考: http://opsnotes.net/2014/10/24/zabbix_juhe/ 实战:Zabbix 聚合功能配置与应用 metrics的实现的探讨 从dropwizard/metrics里,我们可以看到一种简单直观的实现: - app内收集统计数据,计算好具体的key/value - 定时上报 另外,用分布式调用追踪(dapper/zipkin)的办法,也可以实现部分metrics的功能。 比如某个方法的调用次数,缓存命中次数等。 当然,两者只是部分功能有重合。 dropwizard/metrics 是一种轻量级的手段,用户可以随意增加自己想要的统计数据,代码也很灵活。有些简单直观的统计数据如果用分布式调用追踪的方式来做,显然会比较吃力,得不偿失。 总结 本文提出并实现了,利用dropwizard/metrics做数据统计,kafka做数据传输,zabbix做数据展示的完整流程。 对于开发者来说,不需要关心具体的实现,只需要按dropwizard/metrics的文档做统计,再配置上metrics-kafka reporter即可。
新blog地址:http://hengyunabc.github.io/deploy-system-build-with-jenkins-ansible-supervisor/ 一步一步用jenkins,ansible,supervisor打造一个web构建发布系统。 本来应该还有gitlab这一环节的,但是感觉加上,内容会增加很多。所以直接用github上的spring-mvc-showcase项目来做演示。 https://github.com/spring-projects/spring-mvc-showcase 本文的环境用docker来构建。当然也可以任意linux环境下搭建。 如果没有安装docker,可以参考官方的文档: https://docs.docker.com/installation/ubuntulinux/#ubuntu-trusty-1404-lts-64-bit 下面将要介绍的完整流程是: github作为源代码仓库 jenkins做为打包服务器,Web控制服务器 ansible把war包,发布到远程机器 安装python-pip 用pip安装supervisor 安装jdk 下载,部署tomcat 把tomcat交由supervisor托管 把jenkins生成的war包发布到远程服务器上 supervisor启动tomcat 在http端口等待tomcat启动成功 supervisor托管app进程,提供一个web界面可以查看进程状态,日志,控制重启等。 在文章的最后,会给出一个完整的docker镜像,大家可以自己运行查看实际效果。 安装jenkins 先用docker来启动一个名为“jenkins”的容器: sudo docker run -i -t -p 8080:8080 -p 8101:8101 -p 9001:9001 --name='jenkins' ubuntu /bin/bash 8080是jenkins的端口,8101是spring-mvc-showcase的端口,9001是supervisor的web界面端口 执行完之后,会得到一个container的shell。接着在这个shell里安装其它组件。 安装open jdk 和 git: sudo apt-get update sudo apt-get install openjdk-7-jdk git 下载配置tomcat: apt-get install wget mkdir /opt/jenkins cd /opt/jenkins wget http://apache.fayea.com/tomcat/tomcat-8/v8.0.18/bin/apache-tomcat-8.0.18.tar.gz tar xzf apache-tomcat-8.0.18.tar.gz 安装jenkins: cd /opt/jenkins/apache-tomcat-8.0.18/webapps wget http://mirrors.jenkins-ci.org/war/latest/jenkins.war rm -rf ROOT* mv jenkins.war ROOT.war 启动jenkins: /opt/jenkins/apache-tomcat-8.0.18/bin/startup.sh 然后在本机用浏览器访问:http://localhost:8080/ ,可以看到jenkins的界面了。 配置jenkins 安装git插件 安装git插件: https://wiki.jenkins-ci.org/display/JENKINS/Git+Plugin 在“系统管理”,“插件管理”,“可选插件”列表里,搜索“Git Plugin”,这样比较快可以找到。 因为jenkins用google来检查网络的连通性,所以可能在开始安装插件时会卡住一段时间。 配置maven, java 打开 http://localhost:8080/configure, 在jenkins的系统配置里,可以找到maven,git,java相关的配置,只要勾选了,在开时执行job时,会自动下载。 JDK可以选择刚才安装好的openjdk,也可以选择自动安装oracle jdk。 Git会自动配置好。 配置ssh服务 安装sshd服务: sudo apt-get install openssh-server sshpass 编辑 vi /etc/ssh/sshd_config 把 PermitRootLogin without-password 改为: PermitRootLogin yes 重启ssh服务: sudo /etc/init.d/ssh restart 为root用户配置密码,设置为12345: passwd 最后尝试登陆下: ssh root@127.0.0.1 安装ansible 在jenkins这个container里,继续安装ansible,用来做远程发布用。 先安装pip,再用pip安装ansible: sudo apt-get install python-pip python-dev build-essential git sudo pip install ansible 配置ansible playbook 把自动发布的ansible playbook clone到本地: https://github.com/hengyunabc/jenkins-ansible-supervisor-deploy mkdir -p /opt/ansible cd /opt/ansible git clone https://github.com/hengyunabc/jenkins-ansible-supervisor-deploy 在jenkins上建立deploy job 新建一个maven的项目/job,名为spring-mvc-showcase 在配置页面里,勾选“参数化构建过程”,再依次增加“String”类型的参数 共有这些参数: app 要发布的app的名字 http_port tomcat的http端口 https_port tomcat的https端口 server_port tomcat的server port JAVA_OPTS tomcat启动的Java参数 deploy_path tomcat的目录 target_host 要发布到哪台机器 war_path jenkins生成的war包的目录 “源码管理”,选择Git,再填入代码地址 https://github.com/spring-projects/spring-mvc-showcase.git 在“Post Steps”里,增加调用ansible playbook的shell命令 cd /opt/ansible/jenkins-ansible-supervisor-deploy ansible-playbook -i hosts site.yml --verbose --extra-vars "target_host=$target_host app=$app http_port=$http_port https_port=$https_port server_port=$server_port deploy_path=$deploy_path JAVA_HOME=/usr JAVA_OPTS=$JAVA_OPTS deploy_war_path=$WORKSPACE/$war_path" 最后,保存。 测试构建 一切都配置好之后,可以在jenkins界面上,在左边,选择“Build with Parameters”,“开始”来构建项目了。 如果构建成功的话,就可以打开 http://localhost:8101 ,就可以看到spring-mvc-showcase的界面了。 打开 http://localhost:9001 可以看到superviosr的控制网页,可以查看tomcat进程的状态,重启,查看日志等。 如果想要发布到其它机器上的话,只要在 /opt/ansible/jenkins-ansible-supervisor-deploy/hosts 文件里增加相应的host配置就可以了。 其它的一些东东 如果提示 to use the 'ssh' connection type with passwords, you must install the sshpass program 则安装: sudo apt-get install sshpass 演示的docker image 如果只是想查看实际运行效果,可以直接把 hengyunabc/jenkins-ansible-supervisor 这个image拉下来,运行即可。 docker run -it -p 8080:8080 -p 8101:8101 -p 9001:9001 --name='jenkins' hengyunabc/jenkins-ansible-supervisor 总结 jenkins提供了丰富的插件,可以定制自己的打包这过程,并可以提供完善的权限控制 ansible可以轻松实现远程部署,配置环境等工作,轻量简洁,功能强大 supervisor托管了tomcat进程,提供了web控制界面,所有运行的程序一目了然,很好用
新blog地址: http://hengyunabc.github.io/netstat-difference-proc-fd-socket-stat/ 最近,线上一个应用,发现socket数缓慢增长,并且不回收,超过警告线之后,被运维监控自动重启了。 首先到zabbix上观察JVM历史记录,发现JVM-Perm space最近两周没有数据,猜测是程序从JDK7切换到JDK8了。问过开发人员之后,程序已经很久没有重启了,最近才重新发布的。而在这期间,线上的Java运行环境已经从JDK7升级到JDK8了。 因为jdk8里没有Perm space了,换成了Metaspace。 netstat 到线上服务器上,用netstat来统计进程的connection数量。 netstat -antp | grep pid | wc -l 发现比zabbix上的统计socket数量要少100多,netstat统计只有100多,而zabbix上监控数据有300多。 于是到/proc/$pid/fd下统计socket类型的fd数量: cd /proc/$pid/fd ls -al | grep socket | wc -l 发现数据和zabbix上的数据一致。 netstat是怎么统计的 下载netstat的源代码 http://unix.stackexchange.com/questions/21503/source-code-of-netstat apt-get source net-tools 从netstat的代码里,大概可以看到是读取/proc/net/tcp里面的数据来获取统计信息的。 java和c版的简单netstat的实现 java版的 http://www.cs.earlham.edu/~jeremiah/LinuxSocket.java C版的: http://www.netmite.com/android/mydroid/system/core/toolbox/netstat.c 用starce跟踪netstat strace netstat -antp 可以发现netstat把/proc 下的很多数据都读取出来了。于是大致可以知道netstat是把/proc/pid/fd 下面的数据和/proc/net/下面的数据汇总,对照得到统计结果的。 哪些socket会没有被netstat统计到? 又在网上找了下,发现这里有说到socket如果创建了,没有bind或者connect,就不会被netstat统计到。 http://serverfault.com/questions/153983/sockets-found-by-lsof-but-not-by-netstat 实际上,也就是如果socket创建了,没有被使用,那么就只会在/proc/pid/fd下面有,而不会在/proc/net/下面有相关数据。 简单测试了下,的确是这样: int socket = socket(PF_INET,SOCK_STREAM,0); //不使用 另外,即使socket是使用过的,如果执行shutdown后,刚开始里,用netstat可以统计到socket的状态是FIN_WAIT1。过一段时间,netstat统计不到socket的信息的,但是在/proc/pid/fd下,还是可以找到。 中间的时候,自己写了个程序,把/proc/pid/fd 下的inode和/proc/net/下面的数据比较,发现的确有些socket的inode不会出现在/proc/net/下。 用lsof查看 用lsof查看socket inode: 触发GC,回收socket 于是尝试触发GC,看下socket会不会被回收: jmap -histo:live <pid> 结果,发现socket都被回收了。 再看下AbstractPlainSocketImpl的finalize方法: /** * Cleans up if the user forgets to close it. */ protected void finalize() throws IOException { close(); } 可以看到socket是会在GC时,被close掉的。 写个程序来测试下: public class TestServer { public static void main(String[] args) throws IOException, InterruptedException { for(int i = 0; i < 10; ++i){ ServerSocket socket = new ServerSocket(i + 10000); System.err.println(socket); } System.in.read(); } } 先执行,查看/proc/pid/fd,可以发现有相关的socket fd,再触发GC,可以发现socket被回收掉了。 其它的东东 anon_inode:[eventpoll] ls -al /proc/pid/fd 可以看到有像这样的输出: 661 -> anon_inode:[eventpoll] 这种类型的inode,是epoll创建的。 再扯远一点,linux下java里的selector实现是epoll结合一个pipe来实现事件通知功能的。所以在NIO程序里,会有anon_inode:[eventpoll]和pipe类型的fd。 为什么tail -f /proc/$pid/fd/1 不能读取到stdout的数据 http://unix.stackexchange.com/questions/152773/why-cant-i-tail-f-proc-pid-fd-1 总结 原因是jdk升级之后,GC的工作方式有变化,FullGC执行的时间变长了,导致有些空闲的socket没有被回收。 本文比较乱,记录下一些工具和技巧。
2019年09月