首页 > 编程学习 > JVM 运行期优化 反射优化

JVM 运行期优化 反射优化

发布时间:2022/11/13 4:33:14

即时编译

分层编译

public class Test {
	// -XX:+PrintCompilation 会打印出java文件编译后的样子  -XX:-DoEscapeAnalysis 是否进行逃逸分析,“-”就是关掉。
	public static void main(String[] args) {
		for (int i = 0; i < 200; i ++) {
			long start = System.nanoTime();
			for(int j = 0; j < 1000; j ++) {
				new Object();
			}
			long end = System.nanoTime();
			System.out.printf("%d\t%d\n", i, (end - start));
		}
	}
}

这个例子分了两层循环,内层循环创建1000个Object对象,外层循环对内层循环进行计时统计,看每次创建1000个对象耗费多长时间。运行如下:

0	66560
1	49066
2	45227
3	49067
4	58880
5	58880
6	55040
7	52053
8	49920
9	46933
10	45226
...
68	18774
69	18346
...
145	61867
146	853
147	854
...

前面的是外层循环的次数,后面的数字是内层循环创建1000个对象耗费的时间。可以看到前面的有些循环是5开头甚至6开头,大约在第67次循环时,这个速度好像更快了,以2或1开头了,大约到146次时,速度一下子降到了三位数。这是因为,在运行期间,Java虚拟机会对我们的代码做一定的优化。

实际上,JVM把字节码执行的执行状态分成了5个层次:

  • 0层,解释执行(Interpreter)。即字节码被加载到虚拟机以后,会靠一个解释器去一个字节一个字节的去解释,解释器会把我们的字节码解释为真正的机器码,这样CPU就能识别并执行了。需要注意的是,当你的字节码被反复调用时,他认为你这种字节码是反复使用的,那么到达一定的阈值以后,他就会启用编译器对这个字节码进行编译执行。就是从0层会上升到1层。
  • 1层,使用C1即时编译器编译执行(不带profiling)。编译器分两种,一种是“C1即时编译器”,第二种是“C2即时编译器”,C1即时编译器占据了1~3层,C2即时编译器是第4层。那么即时编译器和解释器有什么不同呢?即时编译器就是把这些反复执行的一些代码编译成机器码,然后存储在一个code cache(代码缓存)当中,那么下次再遇到相同代码时,就不会把每个字节码再来解释为机器码,而是把编译好的机器码直接拿出来用,这样效率肯定比你逐行解释高。C1和C2的区别就是他们的优化程度不一样,C1你可以认为他制作一些最基本的优化,C2做一些更完全更彻底的优化,但C1比C2多了一个他就是要进行一些profiling这样的信息统计操作。profiling就是指在代码运行期间,他会收集这些字节码运行的状态的数据,比如你如果是方法的话那“这个方法调用了多少次”,如果是循环的话“循环了多少次”。这些都是在C1编译器里做一些基本的统计,统计发现你某个方法被频繁的调用了,那他就会针对这个方法再上升为C2编译器,让C2编译器对他进行更完全更彻底的优化。
  • 2层,使用C1即时编译器编译执行(带基本的profiling)。
  • 3层,使用C1即时编译器编译执行(带完全的profiling)。
  • 4层,使用C2即时编译器编译执行。

即时编译器(JIT)与解释器的区别:

  • 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释。
  • JIT是将一些热点字节码编译为机器码,并存入code cache,下次遇到相同的代码,直接执行,无序再编译。
  • 解释器是将字节码解释为针对所有平台都通用的机器码。
  • JIT会根据平台类型,生成平台特定的机器码。(因为代码都运行起来了,所以肯定确定了平台,所以他会进行更激进的更彻底的优化

需要注意的是,其实有的时候很多代码或只会调用一次,很多方法也只会调用一次,那如果把这些每次编译成机器码,其实这个编译本身也是要耗费时间的,所以JVM采用的是接下来的策略:对于占据大部分的不常用的代码,没必要耗费时间将其编译成机器码,因为这些代码执行的频率很少,所以采取解释执行的方式运行;另一方面,如果对于仅占据小部分的热点代码,比如方法调用次数或循环次数达到一定的阈值,我们则可以将其编译成机器码,以达到比较理想的运行速度。

运行效率上简单比较一下,Interpreter < C1 < C2,C1的效率可以提升到五倍左右,C2的执行效率可以提升到10~100倍,所以即时编译器总的目标就是去发现热点代码(这也是为什么oracle的虚拟机叫hotspot,hotspot就是热点的意思),并加以优化。

上面例子中使用的优化手段叫“逃逸分析”,所谓逃逸分析就是比如在上面代码中,他去分析new Object对象会不会在循环外面会用到,或会不会被其他方法所引用,结果发现不会(本例子中),因为它就是循环内的局部变量,所以他就采用了这种优化手段,即他发现创建对象的操作不会逃逸,即意味着外层不会用到这个对象,既然不会用到我干嘛创建你呢,所以可以看到刚才他的执行时间突然变短的原因是因为JIT进行了逃逸分析以后,当然这个逃逸分析是在C2编译器里做的优化,会把这个对象的创建的字节码干脆给你替换掉,也就是说C2编译器生成的机器码他已经可以把你原本的字节码改的面目全非,就干脆不会创建对象了(本例子),所以速度一下子提升了这么多。这种优化手段叫逃逸分析。

如果在上面代码中关掉逃逸分析(-XX:-DoEscapeAnalysis),那么加上这个虚拟机参数后运行的话,可以看到这200次循环的时间后面虽然比前面也少了点,但不会出现明显的变成3位数等现象,这说明Object对象仍然是被创建。即关闭逃逸分析的话,他就没办法进入最终的C2编译器阶段了。

方法内联

方法内联也是即时编译器的优化手段的一种,比如下面的例子:

private static int square(final int i) {
	return i * i;
}

该例子是求平方的函数,内部针对整数i做了平方。其实,我们去调用这个函数时:

System.out.println(square(9));

比如,他认为这个函数如果比较短,而且这种函数如果是热点代码时,那么他就会做一个叫“方法内联”。方法内联非常简单,他就是把函数内的代码拷贝到他的调用者的位置,即进行方法内联以后变成下面的样子:

System.out.println(9 * 9);

就是相当于把方法内的代码给他拿出来,放在方法调用者这里。并且他还能再做一个进一步的优化,因为每次调用的这个值(9)固定不变,就可以认为他是个常量,所以还可以做一个叫“常量折叠(constant folding)”的优化,直接把他算出来的结果就传给System.out.println:

System.out.println(81);

以上是方法内联的优化策略。下面通过实验去观察方法内联的效率上的提升。

public class Test {
	// -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:CompileCommand=dontinline,*JIT2.square
	// -XX:+PrintCompilation
	public static void main(String[] args) {
		int x = 0;
		for (int i = 0; i < 500; i ++) {
			long start = System.nanoTime();
			for (int j = 0; j < 1000; j ++) {
				x = square(9);
			}
			long end = System.nanoTime();
			System.out.printf("%d\t%d\t%d\n", i,x, (end - start));
		}
	}
	private static int square(final int i) {
		return i * i;
	}
}

运行如下:

0	81	298239
1	81	162560
3	81	33706
4	81	30293
...
70	81	6400
71	81	6400
...
220	81	0
221	81	0
...

第一列是循环的次数,第二列是打印求平方的结果,第三列是1000次方法调用的时间,可以看到刚开始都是5位数,然后从大约第六十几次开始变成4位数了,到了200多次的时候,已经大部分变成0了。这是因为,已经内联了。内联了以后,就根本没有方法调用,而且第一次计算出来是81,他就会认为第二(N)次计算还是81,也就是个常量,所以他会认为没必要再计算了,所以已经是0了。所以,这个效率的提升还是非常的明显的。

想要查看方法内联的更详细的信息,可以通过上面的几个JVM参数来控制。比如,-XX:+PrintInlining就是是否进行了内联,当然这个还得配合前面的-XX:+UnlockDiagnosticVMOptions参数一起用,就是两个一起用可以打印你针对哪些方法进行了内联,可以把这些信息打印出来(不光是我们自己写的方法,还有JDK带的一些方法也许也会执行这些方法内联,这里需要注意一点的是,有的方法内的代码比较长,那就不会进行内联的)。

把上面的输出语句都注释掉后,用这两个虚拟机参数再运行结果如下:

...(有一堆其他方法的内联信息省略)...

在这里插入图片描述
【图片取自网上的黑马】

...(有一堆其他方法的内联信息省略)...

还有一些JVM参数,他可以禁用某个方法的内联,禁用内联的参数是-XX:CompileCommand,因为我们不想把所有的内联都禁用,因为它不光是针对我求平方的方法,JDK还有其他的方法也会进行内联,那么假如我只想禁用求平方方法的内联,其中的CompileCommand=dontinline就是说明不进行内联,后面的 * Test.square是通配符,*是包名的匹配,即任意包,Test是类名,即Test类的square方法不想内联。如果加上这个JVM参数后,再打印出时间时,可以发现千次循环的时间数从五位数慢慢变成四位数,再也没有下降到0了,这是因为square方法没有进行内联也没进行常量折叠,所以他的运行效率不可能进一步再提升了。

字段优化

是针对成员变量或静态成员变量的读写操作的优化。

为了让测试结果更为准确,可以使用JMH基准测试工具:http://openjdk.java.net/projects/code-tools/jmh/

JMH即Java Microbenchmark Harness,是Java用来做基准测试的一个工具,该工具由OpenJDK提供并维护,测试结果可信度高。基准测试(Benchmark)是测量、评估软件性能指标的一种测试,对某个特定目标场景的某项性能指标进行定量的和可比的测试。

用法如下:
1)加入maven依赖(目前JMH的最新版本为1.23)

<dependency>
	<groupId>org.openjdk.jmh</groupId>
	<artifactId>jmh-core</artifactId>
	<version>1.23</version>
</dependency>
<dependency>
	<groupId>org.openjdk.jmh</groupId>
	<artifactId>jmh-generator-annprocess</artifactId>
	<version>1.23</version>
	<scope>provided</scope>
</dependency>

2)编写基准测试代码

@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
public class Benchmark1 {
	// 创建整形数组,数组的长度是1000
	volatile int[] elements = randomInts(1_000);

	private static int[] randomInts(int size) {
		Random random = ThreadLocalRandom.current();
		int[] values = new int[size];
		for (int i = 0; i < size; i ++) {
			values[i] = random.nextInt();// 数组里面随机填写整数
		}
		return values;
	}

	// 想把这数组elements里面的值累加,这里用了三种方式:
	// 这三个方法都加了@Benchmark,说明将来要对比和测试的就是这三个方法。
	// 【通过JMH我们可以轻松的测试出某个接口的吞吐量、平均执行时间等指标的数据】

	@Benchmark// 声明一个public方法为基准测试方法
	public void test1() {
		// 第一种循环是常见的数组下标循环
		for(int i = 0; i < elements.length; i ++) {
			doSum(elements[i]);
		}
	}

	@Benchmark
	public void test2() {
		// 第二种方法是把成员变量数组赋值给了局部变量数组,然后循环局部变量数组,而不是直接去遍历成员变量数组
		int[] local = this.elements;
		for(int i = 0; i < local.length; i ++) {
			doSum(elements[i]);
		}
	}

	@Benchmark
	public void test3() {
		// 第三个方法是用了jdk5以后的foreach语法
		for(int element : elements) {
			doSum(element);
		}
	}

	static int sum = 0;
	
	// 这个注解可以控制将来我这个doSum方法在进行调用时,是不是要进行方法的内联,这里的CompilerControl.Mode.INLINE说明允许这个
	// 方法的内联。如果设置DONT_INLINE,那么将来方法运行时就禁止内联。
	@CompilerControl(CompilerControl.Mode.INLINE)
	static void doSum(int x) {
		sum += x;
	}

	public static void main(String[] args) throws RunnerException {
		// 这段代码在jmh的官网上有进一步说明,这里不详细写。
		Options opt = new OptionsBuilder()
			.include(Benchmark1.class.getSimpleName())
			.forks(1)
			.build();

		new Runner(opt).run();
	}

	
}

这里重点是两个注解“@Warmup@Measurement”,之前的两个例子演示时已经看出来可能你这程序刚开始跑的时速度比较慢,因为它还并不能监测到哪些方法或哪些循环是属于热点代码,所以最终测试的结果就有可能不准。

@Warmup:为了数据准确,我们可能需要让程序(或某个方法)做下热身运动。如在方法中创建某个对象,预热可以避免首次创建某个对象时因多了类加载的耗时而导致测试结果不准确的情况。JVM使用JIT即时编译器,一定的预热次数让JIT对相关程序的调用链路完成编译,去掉解释执行对测试结果的影响。

@Measurement:是说你要进行几轮测试,这里配置iterations = 5,就进行五轮测试,最后再针对这5论测试看到他的平均值,这样也是
更为可靠。time = 2是每次测量的持续时间,比如这里的意思是每次测量持续2秒,2秒内执行该方法的次数是不固定的,由方法执行耗时和time决定。

运行如下:

...
# Benchmark: test.Benchmark1.test1

# Run progress: 0.00% complete, ETA 00:00:21
# Fork: 1 of 1
# Warmup Iteration	1: 1943121.932 ops/s
# Warmup Iteration	1: 2247873.988 ops/s
Iteration	1: 2437662.759 ops/s
Iteration	2: 2245822.102 ops/s
Iteration	3: 2285052.899 ops/s
Iteration	4: 2326505.910 ops/s
Iteration	5: 2375321.748 ops/s

...
# Benchmark: test.Benchmark1.test1

# Run progress: 33.33% complete, ETA 00:00:19
# Fork: 1 of 1
# Warmup Iteration	1: 2418512.034 ops/s
# Warmup Iteration	1: 2460798.032 ops/s
Iteration	1: 2339885.812 ops/s
Iteration	2: 2164841.238 ops/s
Iteration	3: 2313258.839 ops/s
Iteration	4: 2365938.465 ops/s
Iteration	5: 2373950.128 ops/s

...

# Run progress: 66.67% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration	1: 2474328.493 ops/s
# Warmup Iteration	1: 2461649.720 ops/s
Iteration	1: 2443686.848 ops/s
Iteration	2: 2449670.798 ops/s
Iteration	3: 2449339.191 ops/s
Iteration	4: 2446632.043 ops/s
Iteration	5: 2412051.726 ops/s

...

# Run complete. Total time: 00:00:27

Benchmark		Mode	Samples		Score		Score error	Units
t.Benchmark1.test1	thrpt	5		2334073.083	289955.978	ops/s
t.Benchmark1.test2	thrpt	5		2311574.896	328893.105	ops/s
t.Benchmark1.test3	thrpt	5		2440276.121	61461.104	ops/s

可以看到先测试的是test1方法,经过了两轮的热身,然后开始5论的测试。(test2~test3方法也一样)最后的是结果里面,最后的Units是单位,单位表示你这方法每秒钟能调用的吞吐量,这个指标越大越好,调用次数越高,说明这个方法执行效率更高,Score是最终的吞吐量的得分,Score error是他的误差,因为它经过了无论测试,那就会有一定的误差,这里主要看Score就好。

test1是直接去操作成员变量(elements),他的得分是2334073.083。test2是把成员变量(elements)先赋值给了局部变量(local),他的得分是2311574.896。test3是用了foreach语法去进行循环,他的得分是2440276.121,吞吐量好像是最高的。但是这三个从数量值上并没有看出太大的差异,也就是说这三种循环其实在效率上是差不多的。当然,这是它在内部调用doSum方法时加了INLINE,就是允许doSum方法内联。

如果把这个设置为DONT_INLINE,就是说不允许doSum方法内联,然后再运行测试的话:

...
(直接看结果)

# Run complete. Total time: 00:00:27

Benchmark		Mode	Samples		Score		Score error	Units
t.Benchmark1.test1	thrpt	5		284510.531	40174.858	ops/s
t.Benchmark1.test2	thrpt	5		362251.199	31535.191	ops/s
t.Benchmark1.test3	thrpt	5		355362.368	47831.278	ops/s

可以看到吞吐量相对于前面都有一定的下降,因为方法内联被禁用了,得分是test2最高,test3次之,得分最低的是直接去访问成员变量时。

那为何会有这样的结果呢?就是我们方法内联其实也会影响到我成员变量读取的优化操作,我们可以针对test1方法进行说明。

他在允许方法内联的情况下,伪代码如下(doSum方法里的那行代码跑到了for里面):

@Benchmark
public void test1() {
	// element.length 首次读取会缓存起来 -> int[] local
	for (int i = 0; i < elements.length; i ++) { // 后续999次 求长度 <- local
		sum += elements[i];// 1000次取下标 <- local
	}
}

可以看到他在字段读取时进行优化,他先去读elements字段,并且把他首次读到的结果也就是把这个数组缓存到我们看不到的局部变量(int[] local)当中,当然这都是机器码的级别上进行的处理,不是我们现在所理解的字节码了,缓存的好处是下次再进行第二次第三次循环(也就是后续999次),我还要求这个长度,以及后续的1000次去取下标i的元素时,那就不需要继续去访问成员变量了,可以在缓存的局部变量中找到他的长度和找到下标i的元素,相当于这个代码就可以节省1999次的查找成员变量的操作(Field读取操作)。但是如果doSum方法没有内联,则不会进行上面的优化。所以从这点上来看其实方法内联他会影响到我们成员变量的读取操作。

那为什么后两种(test2 和 test3)在禁止方法内联时的性能相差不大呢,对于第二种test2,其实就是刚才虚拟机优化的结果,只不过第二种是手动的做了优化(int[] local = this.elements;),所以成员变量的读取只读了一次(赋值时),把结果先放到了本地变量,在本地变量里去访问长度、内存地址(不用去class里读取内存地址了),根据下标去访问数组元素了。但是方法内联情况下,test1里面的代码也会由帮我们优化,效果跟手动优化的效果一样。

至于test3,以前分析过foreach循环的字节码,其实他的代码是跟test2是等价的,所以如果说test2是我们自己优化的,那么test3是编译期间优化的,还有一个是运行期间优化的(test1)。

这给我们的提示是,如果你自己想做一些优化,尽可能的使用局部变量,而不要使用成员变量和静态成员变量,当然如果你忘了这一点也没关系,只要你没有禁用方法内联,那JVM在运行期间他会帮你做这个优化(比如test1的代码)。

(方法的)反射优化

方法反射是在日常开发中经常用到的操作,所以了解他后台有哪些反射的优化机制对我们非常有帮助。

public class Reflect1 {
	public static void foo() {
		System.out.println("foo...");
	}

	public static void main(String[] args) throws Exception {
		// 用类对象,获得方法对象
		Method foo = Reflect1.class.getMethod("foo");

		for(int i = 0; i <= 16; i ++) {
			System.out.printf("%d\t", i);
			foo.invoke(null);// 执行反射调用。因为foo是静态方法,所以没有关联的实例对象,所以传null
		}
		System.in.read();
	}
}

运行的结论是,从下标0到下标15次为止调用,即前十六次调用的性能是相对比较低的,但是第17次调用开始这个性能一下子就会变高。

这是为什么呢?可以分析foo.invoke(null)的源码,invoke方法源码如下:

@CallerSensitive
public Object invoke(Object obj, Object... args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {
	if (!override) {
		if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
			Class<?> caller = Reflection.getCallerClass();
			checkAccess(caller, clazz, obj, modifiers);
		}
	}
	MethodAccessor ma = methodAccessor;
	if (ma == null) {
		ma = acquireMethodAccessor();
	}
	return ma.invoke(obj, args);
}

可以看到他最后调用的是方法访问器(MethodAccessor)对象的invoke方法。方法访问器是接口:

public interface MethodAccessor {
	Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException;
}

他的实现有三种,DelegatingMethodAccessorImpl、MethodAccessorImpl、NativeMethodAccessorImpl,其中MethodAccessorImpl是抽象的实现,不用管它,默认使用的是第一个实现 DelegatingMethodAccessorImpl,这个实现内部里面相当于什么事都没做,他只是去选择到底接下来该调用哪个具体的实现,当然,默认的是在DelegatingMethodAccessorImpl里再访问NativeMethodAccessorImpl本地方法访问器)。

NativeMethodAccessorImpl里面的源码如下:

class NativeMethodAccessorImpl extends MethodAccessorImpl {

	private finalMethod method;
	private DelegatingMethodAccessorImpl parent;
	private int numInvocations;

	NativeMethodAccessorImpl(Method var1) {this.method = var1};

	public Object invoke(Obejct var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
		if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
			/*
			用new MethodAccessorGenerator()来生成一个新的方法访问器类
			this.method.getDeclaringClass():方法的声明类
			this.method.getName():方法的名称
			this.method.getParameterTypes():方法的参数类型
			this.method.getReturnType():方法的返回值类型
			this.method.getExceptionTypes():方法的异常类型
			this.method.getModifiers():方法的修饰符
			*/
			MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(),this.method.getExceptionTypes(), this.method.getModifiers());
			this.parent.setDelegate(var3);
		}
		return invoke0(this.method, var1, var2);
	}

	void setParent(DelegatingMethodAccessorImpl var1) {this.parent = var1;}

	private static native Object invoke0(Method var0, Object var1, Object[] var2);

}

刚开始if条件是不成立的,所以他就会调用invoke0(),他是Native方法,即由c++实现的本地方法,Java这边只是对他进行调用,而本地方法的性能调用起来相对来说是比较低的,所以我们前0到15次调用的都是本地方法,这也是为什么这个实现类叫NativeMethodAccessorImpl的由来。

接下来看看if条件,这里有个变量是numInvocations,这就是方法的调用次数,即每调用一次invoke他就会加一,然后他会把这个调用次数跟一个值ReflectionFactory.inflationThreshold()进行比较,如果大于这个值他就会执行if语句里面的代码,ReflectionFactory.inflationThreshold() 翻译过来就是膨胀阈值,他默认是15(可以点进去inflationThreshold方法看),即当我的本地方法的invoke执行了15次以后,他就会进行
一个替换,下面的长长代码的意思是,把本地方法访问器替换为一个运行期间动态生成的一个新的方法访问器,这个新的方法访问器是怎么生成的呢,他会根据当前调用方法的一些信息生成这个新的方法访问器,即根据他的方法的声明类方法的名称方法的参数类型方法的返回值类型方法的异常类型方法的修饰符生成一个新的方法访问器类,用new MethodAccessorGenerator()来生成一个新的方法访问器类,需要注意的是这个new MethodAccessorGenerator()类没有源代码,因为它是在运行期间动态生成的一段字节码,他采用字节码把新的方法访问器类生成以后赋值给抽象父类(MethodAccessorImpl),然后替换(setDelegate(var3))掉原本NativeMethodAccessor,这样他的性能就得到了很大的提升。

那为什么这个性能会提升呢,可以分析他生成的这个新的实现类的字节码的角度(进入generateMethod函数)来看看他到底为什么性能会有提升。

那么问题就是如何能查阅我们在运行期间动态生成的类的字节码呢?当然,这里有多种办法,不过这里选择的是一种功能比较强大,而且是非常方便的方法,就是利用了阿里巴巴的开源工具,叫arthas-boot.jar,用它就能够看到我们在这个程序的运行期间动态生成的那些类的字节码。

那还要做一个准备工作,因为你就算是用那个强大的工具,你也得知道那个新生成的类他的名字是什么,所以可以在foo.invoke(null);这里加一个断点,用debug模式先让他跑起来,然后在if里面的this.parent.setDelegate(var3);这里加断点,前面的foo.invoke(null);这里的断点可以去掉,走的话就会停在this.parent.setDelegate(var3);这里。然后看一下当前的类名是什么,var3就是由new MethodAccessorGenerator()动态生成的那个类,可以看到他生成的类的名称是class.sun.reflect.GeneratedMethodAccessor1,当然运行到这里this.numInvocations已经到16了。

确认类名后,就可以停止程序。因为待会用arthas-boot.jar工具去调试的话不能是以debug模式调试,只能是以正常方式去调试。所以,以正常方式运行程序。

运行后,在正常方式下,System.in.read();让程序停了下来,停下来前,由于进行了16次循环,所以控制台已经输出了16个foo...,当然这里的下标16已经是第17次调用了,在第17次的时候,那个动态类已经生成了,这时,我们可以打开工具,在命令行下执行(比如该jar在桌面):

cd Desktop
java -jar arthas-boot.jar

回车后就执行该jar,这个工具执行后,他会把所有Java进程ID列出来,所以我们不用特意去执行查进程ID的命令。比如如下:

[INFO] arthas-boot version: 3.1.1
[INFO] Found existing java process, please choose one and hit RETURN.
* [1]: 15520 org.jetbrains.jps.cmdline.Launcher
  [2]: 12600 com.intellij.idea.Main
  [3]: 12632 org.jetbrains.idea.maven.server.RemoteMavenServer
  [4]: 5756 com.cnm.Reflect1
  [5]: 6172 org.jetbrans.kotlin.daemon.KotlinCompileDaemon

可以看到我们刚才运行的程序Relect1的进程ID是5756,我也不用去输入这个进程号,直接输入他面前的数字就行,即:

4

然后回车。

稍等一会儿后,就会连上去了,如下:

[INFO] arthas home: C:\User\lenovo\.arthas\lib\3.1.1\arthas
[INFO] Try to attach process 5756
[INFO] Attach process 5756 success.
[INFO] arthas-client connect 127.0.0.1 3658
...(ARTHA图案省略)
wiki		https://alibaba.github.io/arthas
tutorials	https://alibaba.github.io/arthas/arthas-tutorials
version		3.1.1
pid		5756
time		2022-11-12 09:24:01

$

可以执行help命令可以查看所有可以使用的命令,其中可以看到有个jad命令,这个就是可以用来反编译类。即执行上面运行期动态生成的类:

$ jad sun.reflect.GeneratedMethodAccessor1

执行后就能看到这个类的源码:

package sun.relect;

import com.cnm.Reflect1;
import java.lang.reflect.InvocationTargetException;
import sun.reflect.MethodAccessImpl;

public class GeneratedMethodAccessor1 extends MethodAccessorImpl{
	public Object invoke(Object object, Object[] arrobject) thorws InvocationTargetException {
		block4 : {
			if (arrobject == null || arrobject.length == 0) break block4;
			throw new IllegalArgumentException();
		}
		try {
			Relect1.foo();
			return null;
		}catch (Throwable throwable) {
			throw new InvocationTargetException(throwable);
		}catch (ClassCastException | NullPointerException runtimeException) {
			throw new IllegalArgumentException(Object.super.toString());
		}
	}
}

可以看到他也继承了MethodAccessorImpl父类,当然因此也间接实现了MethodAccessor接口。这里面的invoke方法里可以看到,在我们的以往理解中invoke是方法反射调用,但实际在他生成的invoke代码里面,已经变成了Relect1.foo(),因为foo是静态方法,所以他是通过类名.静态方法名调用的,那这还是不是反射调用呢,很明显,已经不是了。
所以,我们从第17次开始,Java虚拟机他已经把我们的反射方法调用转换成了正常方法调用,性能可以说跟你直接调用foo是相差微乎其微了。因为foo是没有返回值的,所以invock直接return null了,如果有返回值,就会return 返回值

上面的block4 : {...}部分是比较奇葩的做法,如果有参数,那么抛非法参数异常。

需要注意的是,膨胀阈值是可以进行设置的,即他是取了环境变量System.getProperty("sun.reflect.inflationThreshold");的值,所以可以自己去指定。

另外还可以通过另一个变量System.getProperty("sun.reflect.noInflation");noInflation是不要膨胀,如果给他设置为ture,就相当于他会一上来就直接使用这个生成后的MethodAccessor,就不会使用那个本地MethodAccessor了。(在jdk1.8.9_91\jre\lib\rt.jar!\sun\reflect\ReflectionFactory.class源码中可以看到这些设置

当然我们也可以不用设,因为你虽然可以用noInflaction来禁用膨胀,但是首次生成GeneratedMethodAccessor1是比较耗时的,如果你这个方法(比如 foo)只是反射调用一次,那感觉就会有些不太划算。(这个就跟我们那个热点方法的规则有点像,他认为你只有掉了15次以上,我才认为你这个为你这个方法生成新的MethodAccessoer的类的必要,如果你调用次数较少,我就还是用本地实现就完了。

Copyright © 2010-2022 dgrt.cn 版权所有 |关于我们| 联系方式