0. 问题背景
在正式进入问题之前,我先简单描述一下背景。我司基于javaagent做了一套字节码插件平台。如果大家有了解过Skywalking的话,那基本上跟这个差不多,只不过我们这个字节码平台的特色就是:
- Easy Development
- Easy Management
基于此平台,我司构建了一整套声明式API,把API与实现分离,彻底解耦了业务研发与中间件研发,使得中间件研发可以独立迭代,不再依赖于业务方。
这个不是我们这次分享的重点,具体是如何做的,下次再分享。
1. 为什么就我本地不行?
我们的声明式API慢慢在公司内推广,我们遇到了各种诡异的问题。今天这个问题算是其中一个。下面我们简单来回顾一下这个问题的Everything
某天下午:
研发A:我本地开发的时候,你们的API貌似没有生效
我们【自信满满】:请发一下你的启动日志
研发A发完启动日志后
我们【自信满满】:你这明显没有使用我们的idea插件啊
研发A去安装完插件,又来找我们
研发A:还是不行啊,主要问题是我们dev环境上是没问题的
我们:那其他人本地开发有问题吗
研发A:其他人貌似没啥问题,就我本地开发的时候有问题
我们【重启工程师】:这个科学已经没法解释了,你要不你重启一下idea,或者重启一下电脑试下呢
过了10分钟后
研发A:大佬,还是不行啊
一般剧情走到这里的话,我们有两个选择:
- 1这人的环境有问题,或者这人有问题,反正不管我们的事,让他自己去搞吧
- 2我们API是不是有问题,得debug看看
本来我是想选第一种方案的,奈何我还没选择好,人家已经搬着电脑来找我了。
我本可以义正严词地告诉他,一定是你的电脑有问题,要不你换台电脑?还没等我说出口,想着为(dui)公(fang)司(shi)节(mei)省(zi)成本,算了,大概看一下原因吧。
2. 尴尬了:我也不知道为啥
既然为(dui)公(fang)司(shi)节(mei)省(zi)成本,那就debug吧。这不debug还好,一debug发现我们的实现的代码都没有走到断点处,我立马就开始怀疑人生了,这怎么可能呢,已经运行了这么长的时间了,怎么可能连功能都没有生效呢?
然后气氛立马就尴尬了起来,气氛就有点类似于理发店的场景:
徐志胜:这发型丑,也不能全怪他
理发师:这发型丑,也有可能是我手艺不精
研发A:这个不生效,也不一定是他的问题
我:这个问题有可能是我们的bug
然后,尴尬的气氛大概持续了几分钟后,那人有事先走了,把电脑留下了。
再然后,我突然之前强总貌似遇到过类似的一些问题,就轻描淡写地问了一句,然后他说,你把idea的那个运行参数修改成xxx试试看呢?
至于是啥,我们下面再说。
但是结果就是,我把idea的运行参数修改成他说的那个参数之后,确实就好了。
3. 到底修改idea的啥配置了?
那上面提到的idea参数到底是啥呢?
Look一下,就是下面的这个参数Shorten command line
我相信大多数人可能都没有注意到这个参数,一般都使用的是idea默认的选项,只有出现问题的时候,会百度一下看看咋搞。
如下面的最为经典的问题:Command line is too long,然后我们就会百度一下,找一篇文章看一下如何解决。比如这篇文章解决: Intellij IDEA 运行报错 Command line is too long
那在我们这个场景下,出问题的是哪一个选项呢?
Shorten command line:classpath file有问题,其他的选项都没有任何问题。
[图片上传失败...(image-4904ee-1649646754607)]
4. Shorten command line到底是个啥
那这玩意到底个啥呢?跟我们的问题又有什么关系呢?
啥关系我们先不聊,我们先来看看这到底是个啥玩意。然后我们再看是啥关系。
Shorten command line:缩短命令行
那到底缩短什么命令行,又为什么要缩短,每一个选项的具体含义是啥呢?
我们先去官网Look Look
idea help doc
我们随便看一个文档Run/Debug Configuration
那到底在说呢?大家可以自己用翻译软件翻译一下,这里我就不翻译了,大致意思如下:
classpath有可能很长。如果不缩短的话,那么JVM启动命令有可能因为classpath太长导致超过了操作系统的允许的最长命令参数长度,从而导致启动失败。所以这里有3种方式可以来缩短classpath的长度,进而缩短JVM启动的参数。
3种选项的意思如下:
-
none:不会缩短classptah。如果命令行参数长度超过了操作系统的限制,那么idea就不会运行应用,然后会显示一个提示语来建议你缩短命令行长度
-
JAR manifest:idea会把classpath写入临时生成的classpath.jar中的manifest文件中
-
classpath file: idea将把一个长classpath写入一个文本文件
第一种比较容易理解,就是不缩短命令行长度。但是第二种和第三种到底是个什么东东?就每一字都认识,但是就是不知道啥意思的感觉。
5. 深入理解Shorten command line
我们要想彻底明白Shorten command line几个选项的具体含义,还需要启动看一下,到底有什么不一样。
搞起来,我们先demo走起:如下,搞一个简单的maven工程,里面就一个依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>command-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<!--为了简单就引入一个依赖-->
<dependency>
<groupId>org.lz4</groupId>
<artifactId>lz4-java</artifactId>
<version>1.8.0</version>
</dependency>
</dependencies>
</project>
然后搞一个启动类:很简单就调用了一下依赖中的类库
package com.command.demo;
import net.jpountz.xxhash.XXHash32;
import net.jpountz.xxhash.XXHashFactory;
import java.nio.charset.Charset;
public class DemoApplication {
public static void main(String[] args) {
final XXHash32 xxHash32 = XXHashFactory.fastestInstance().hash32();
final byte[] originalBytes = "ABC".getBytes(Charset.forName("UTF-8"));
final int seed = 156894356;
xxHash32.hash(originalBytes, 0, originalBytes.length, seed);
}
}
下面,我们分别看一下每一种Shorten command line选项在启动参数上有什么不一样
5.1 none
如下:以默认的选项启动时,就直接把所有的classpath(包括jdk依赖路径,依赖的路径以及应用编译后的路径)放到了命令行中了
[图片上传失败...(image-6b6ad5-1649646754607)]
这个比较容易理解,也没有什么特别的操作,直接跳过。
5.2 JAR manifest
启动参数截图如下:
#${user}实际上是你自己电脑上的用户名
"C:\Program Files\Java\jdk1.8.0_161\bin\java.exe" "-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.4\lib\idea_rt.jar=33342:D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.4\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\${user}\AppData\Local\Temp\classpath1516630237.jar com.command.demo.DemoApplication
可以看到,命令行确实短了,其中的classpath参数指定了一个jar包
-classpath C:\Users\${user}\AppData\Local\Temp\classpath1516630237.jar
那这个classpath参数是个啥意思呢?
直接看官方文档
Adding Classes to the JAR File's Classpath
通过在manifest中使用Class-Path头部属性,可以避免在调用Java运行应用程序时必须指定长类路径标志。
那这个classpath1516630237.jar文件中到底包含了什么内容呢?
由于是临时文件,在运行到业务代码的时候,该文件已经被删除了,所以得有一些骚操作才能看到这个文件。
JVM中有一种远程debug的功能,当suspend=y的时候,是必须等待有远程机器debug上来的时候才会继续执行,这样的话,JVM启动后不会执行任何其他代码,此时生成的临时文件classptah.jar文件估计还没有被JVM读取到。
你说我是怎么知道这个骚操作的,这个全靠强总的指导!!!
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=40000
下面,我们来试一把,把这个临时的classpath.jar给捞出来
1.设置远程debug参数以及Shorten commadn line为JAR manifest
2.启动:
3.找到临时jar文件
4.打开临时jar文件
可以看到,这个jar包就这一个文件
image.png
里面的内容如下:
到这里有么有柳暗花明又一村的感觉,这个所谓的通过JAR manifest方式来缩短命令行参数,其实就是把原本放在Jvm参数中的classpath,放到了单独的jar文件中的manifest文件中的Class-Path属性中。
那么问题来了,为什么这么做就等效于JVM参数中的classpath参数了呢?
实际上,这个Class-Path属性是属于jar包规范的一部分。
规范链接如下:Adding Classes to the JAR File's Classpath
不想看规范的,我就简单截个图看一下吧
好吧,咱们也不用看懂,知道有这么个东西就可以了。
总结起来,Class-Path属性是jar包的规范,JVM必须遵循这个规范,解释Class-Path属性,这样就可以把Class-Path属性中指定的classpath放到自己应用的classpath中了。
5.3 classpath file
下面,我们来看一下有问题的Shorten command line选项:classpath file
- 1.设置debug参数以及Shorten command line选项为classpath file
- 2.跑起来看JVM启动参数
实际的文本启动命令如下:
"C:\Program Files\Java\jdk1.8.0_161\bin\java.exe" -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=40000 "-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.4\lib\idea_rt.jar=1167:D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.4\bin" -Dfile.encoding=UTF-8 -classpath "D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.4\lib\idea_rt.jar" com.intellij.rt.execution.CommandLineWrapper C:\Users\¥{user}\AppData\Local\Temp\idea_classpath1041469241 com.command.demo.DemoApplication
这里的JVM参数是有些奇怪的,main程序的入口变成了com.intellij.rt.execution.CommandLineWrapper
,而真正的main程序入口com.command.demo.DemoApplication
变成了main函数的参数。
至于这个CommandLineWrapper
到底是个啥,我们后面再分析
3.找到临时文本文件
4.查看临时文本文件内容
至此,我们大概知道了classpath实际是放在文本文件中的,然后通过另外一个程序入口加载这个文本文件中的classpath,从而idea认为这样也可以间接实现缩短命令行参数
那到底
com.intellij.rt.execution.CommandLineWrapper
是个啥呢,它又是怎么做的呢?
直接开源代码,这个实际上属于idea的代码了,我们直接知道对应的jar包,然后反编译。
这个类在我的电脑的路径为
D:\Program Files\JetBrains\IntelliJ IDEA 2020.2.4\lib\idea_rt.jar
有兴趣的骚年可以自己去反编译看一下,这里我就补贴大段的代码了,我们简单过一下逻辑:
或者直接看一下idea官方的github中的代码CommandLineWrapper.java
[图片上传失败...(image-ae7c51-1649646754607)]
arg[0]其实就是那个包含classpath的临时的文本文件的路径
看一下loadMainClassWithCustomLoader的逻辑:
- 1.读出classpath文本文件中的类路径
- 2.new
URLClassLoader
,然后指定路径为上面读出来的路径,并且指定parent classloader为null(这里是一个关键的节点,后面我们再看) - 3.用new出来的
URLClassLoader
加载实际的程序入口mainclass
然后反射调用URLClassLoader
加载的mainclass的main方法
我们debug验证一下,程序的入口classDemoApplication
是URLClassLoader
来加载的
而如果选择的另外两种方式,程序入口的ClassLoader
实际AppClassLoader
5.4 Shorten command line总结
- none(默认):不缩短JVM参数,直接把所有classpath作为JVM参数穿进去
- JAR manifest:把所有classpath写入到临时的claapath.jar包中的manifest文件中,在manifest文件的Class-Path属性中指定所有的classpath。然后JVM参数中指定classpath参数为claapath.jar所在的绝对路径
- classpath file:把所有的classpath写入到临时的文本文件中,然后把classpath设置到idea自定义的
URLClassLoader
中,然后使用自定义的URLClassLoader
加载程序入口,反射调用main方法。main方法所在的类的ClassLoader
就是idea自定义的URLClassLoader
6.那到底为什么classpath file选项不行呢?
首先,我们来看一下我们的javaagent的类加载的体系(在SpringBoot下):
agentclassloader层次
首先,agent.jar的类加载器默认是AppClassLoader
,这个也是JVM规范中提到的
有兴趣可以读一下:instrument说明
其次,各种插件的定义(PluginDefine
)由自定义的AgentClassLoader加载,此时增强逻辑(Advice
)并没有被加载。在程序的运行期间,用户空间的类被加载的时候,会给javaagent一个回调,使得javaagent可以有机会来增强所有的类的逻辑,我们这里使用Advice
来表示这种增强的逻辑。而这里的增强的逻辑类Advice
也是由自定义的AgentClassLoader
加载。只不过这里的自定义的AgentClassLoader
的parent是加载原有类的ClassLoader
。
因此,Springboot应用在使用了我们的agent之后,自定义的AgentClassLoader
的parent是LaunchedURLClassLoader
,而LaunchedURLClassLoader
的parent是AppClassLoader
。具体可以看一下上面的图。
但是,上面的类加载器的层次结构是实际在Linux上运行时的类加载器层次结构,但不是在本地开发的时候的层次结构。因为idea的Shorten command line会导致类加载器的变更。
那如果本地开发时,Shorten command line选择为none或者JAR mainfest时,类加载器的层次结构是怎么样的呢?
那如果本地开发时,Shorten command line选择为classpath file时,类加载器的层次结构是怎么样的呢?
agentclassloader层次-classpath.png
如上图,由于idea自定义的URLClassLoader
parent不是AppClassLoader
(实际parent=null,parent为null时,自己加载不到类的时候,会使用BootstrapClassLoader加载),所以自定义AgentClassLoader
在加载拦截逻辑Advice
类的实现类的时候,需要加载Advice
类。但是Advice
类实际是在AppClassLoader
中加载的。所以此时的自定义AgentClassLoader
是加载不到Advice
的类的定义的。所以拦截逻辑一个也没有生效。
7.有办法解决吗?
不要使用classpath file不就行了吗!!!
还有其他办法吗?
我们发现,其实我们的增强逻辑接口Advice
如果由Bootstrap ClassLoader来加载的话,那idea自定义的URLClassLoader
也是可以加载到Advice
的。
那具体应该怎么样才能把
Advice
等插件相关的类由Bootstrap ClassLoader加载呢?
这里提供三种方法:
- 1.指定-Xbootclasspath参数为agent.jar
- 2.使用
Instrumentation
#appendToBootstrapClassLoaderSearch()方法 - 3.打包agent.jar的时候指定指定Boot-Class-Path属性为自己
我使用了第三种方式,实验了一下,是没问题的。
其实,transmittable-thread-local的agent使用的就是第三种方式,老版本transmittable-thread-local使用的是第一种方式。
后来,我还听我龙哥讲到,Skywalking
使用的是更为少见的方式,大家有兴趣可以去看一下,这里就不分析了。
8.总结
使用idea进行本地开发的时候与实际Linux上运行Java程序的时候,类加载器会不一样。
正常情况下,idea的启动参数Shorten command line的不管选什么选项,都不会对应用程序造成什么影响。但是对于使用javaagent的程序来讲,由于类加载器变了,可能会导致类找不到的情况。
所以,应用程序在运营的时候Shorten command line最好选择none或者JAR mainfest方式。
而作为javaagent的开发者,最好能考虑到用于的一些非常规操作,把自己对外的API接口使用Bootstrap ClassLoader来加载,这样就可以避免因为本地开发导致类加载层级结构不一样导致加载不到类的问题。