深入理解java(异常-反射)

异常处理

基本概念

  • 异常基本组件
    • throw:异常抛出
    • try-catch:异常捕获
  • 异常类型,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。第一个是 Error,涵盖程序不应捕获的异常。
    • Error:系统级别异常,用户无需处理实现。(无法捕获)
      • 当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。
    • Exception:由throw关键字抛出
      • 显示异常:CheckedException,代表:IOException,SQLException;各模块业务异常,一般用户自定义。
      • 隐式异常:RuntimeException,代表:空指针,数组越界,表示程序基本逻辑错误。
  • 一些问题
    • 异常性能问题:new 出一个异常非常耗费性能,因为他需要递归当前线程栈的所有栈帧,逐一的记录当前类,文件,代码行数,方法名等。
    • 不可缓存:异常堆栈信息是在异常new的那一刻记录,和throw语句无关。
    • 如果我们catch住某个异常,并重新抛出新的异常,会导致内存旧异常的堆栈消失。

try-catch块

针对try-catch块,也就是异常处理代码块。其核心逻辑如下:

  • try块:用来监控用户的代码是否抛出异常。
  • catch块:用来捕获try块的异常。
    • 一次能catch多个异常种类,并且可以多次生明catch块;
    • 上一层的catch块不能覆盖下面层级的catch块申明的异常;
    • catch块也会抛出异常,但不会被下面层级的异常捕获,会直接抛出。
  • finally块:执行必须执行的操作,如资源释放等。注意finally块代码在任何情况下都会执行。参考一下几种情况:
    • try{throw ex}->catch->finally
    • try{throw ex}->no catch match->finally->method throw
    • try{throw ex_a}->catch(ex_a){throw ex_b}->finally->method throw ex_b:即便catch抛出新的异常,finally也会执行。

虚拟机捕获异常

基本原理

虚拟机通过异常表来监控方法异常抛出和捕获(每个方法都会有一个异常表)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
try {
mayThrowException();
} catch (Exception e) {
e.printStackTrace();
}
}
// 对应的Java字节码
public static void main(java.lang.String[]);
Code:
0: invokestatic mayThrowException:()V
3: goto 11
6: astore_1
7: aload_1
8: invokevirtual java.lang.Exception.printStackTrace
11: return
Exception table:
from to target type
0 3 6 Class java/lang/Exception // 异常表条目

如上图,异常表包含

  • from:表达当前异常监控起始位置;
  • to:表达当前异常监控截至位置;
  • type:表达所匹配的异常类型;
  • target:如果触发当前异常条目,则跳转到target指向的代码。

具体执行流程:

  1. 方法抛出异常;
  2. 调用异常表逐一查看;
  3. 判断是否在from-to范围内;
  4. 判断是否匹配type异常类型;
  5. 获取target指向的代码行,执行跳转;
  6. 另外,如果无法找到匹配的异常type类型,会弹出当前方法堆栈,给到上一层调用者的异常表执行这个步骤。

关于finally块

finally块会比较特殊,在当前的java版本。

  • 正常流程:编译器会把finally块代码复制到try区域末尾和所有catch区域末尾,这样确保了正常流程均会执行finally块。
  • 抛出异常:编译器会额外生成一个异常表条目在末尾,监控整个try-catch块区域。
    • from-to:整个try-catch区域
    • type:为any匹配所有异常,
    • target:一个特殊的finally块,他会在最后重新抛出当前异常表匹配的异常。

如下所示:finally块有三分

  1. 在catch代码块末尾,用于处理正常catch的流程
  2. 在上一个finally块末尾(即22行开始),用于捕获上述代码未被捕获的异常和catch块抛出的新异常(即全局catch)
    • 注意这一份finally块末尾会抛出新的异常。
  3. 即第五行程序goto指向的30行,表示程序正常结束。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class Foo {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;

public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}


$ javap -c Foo
...
public void test();
Code:
0: aload_0
1: iconst_0
2: putfield #20 // Field tryBlock:I
5: goto 30
8: astore_1
9: aload_0
10: iconst_1
11: putfield #22 // Field catchBlock:I
14: aload_0
15: iconst_2
16: putfield #24 // Field finallyBlock:I
19: goto 35
22: astore_2
23: aload_0
24: iconst_2
25: putfield #24 // Field finallyBlock:I
28: aload_2
29: athrow
30: aload_0
31: iconst_2
32: putfield #24 // Field finallyBlock:I
35: aload_0
36: iconst_3
37: putfield #26 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 8 Class java/lang/Exception
0 14 22 any

...

Suppressed 异常和语法糖

异常Suppressed问题

在某些特殊情况下,异常catch,finally代码块中也会抛出新的异常。如果我们不做特殊处理,新的异常会覆盖我们原有的异常。

如何将这两种异常归并到一起呢?

catch块很好处理,因为它们获取到旧异常的指针,可以自定义归并,如下:

1
2
3
4
catch(Exception old){
...
throw new Exception(old);
}

finally块并没有这个指针,我们要么选择抛出新的异常,要么原地catch。是否对如下代码很常见?

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
InputStream stream = new ByteArrayInputStream(new byte[]{});
try {
} finally {
try {
stream.close();
} catch (IOException e) {
//简单处理异常
//to do noting
}
}
}

我们最多在内层的catch块打印一个新的日志用于排查。针对这个问题,我们需要用到try-with-resources 语法糖,它能自动帮助我们在字节码层面自动使用 Suppressed 异常(即将新的异常依附在原有异常之上)。

try-with-resources 语法糖

除了Suppressed异常外,该语法糖更多的用途是自动帮助我们处理资源回收等场景。只需要我们实现AutoClosable接口的内容即可;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Foo implements AutoCloseable {
private final String name;
public Foo(String name) { this.name = name; }

@Override
public void close() {
throw new RuntimeException(name);
}

public static void main(String[] args) {
try (Foo foo0 = new Foo("Foo0"); // try-with-resources
Foo foo1 = new Foo("Foo1");
Foo foo2 = new Foo("Foo2")) {
throw new RuntimeException("Initial");
}
}
}

// 运行结果:
Exception in thread "main" java.lang.RuntimeException: Initial
at Foo.main(Foo.java:18)
Suppressed: java.lang.RuntimeException: Foo2
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo1
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo0
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)

反射

反射的作用:

  1. 提供编辑期查询可用方法,如eclips
  2. 提供依据namespace+classname,创建对象,调用对象,如Spring-ioc框架

实现原理

其原理就是JVM依据反射的对象类型,和参数,和方法名,可以找到方法内存地址,最终执行方法调用,将参数传递进去即可。

如下时反射源码:

1
2
3
4
5
6
7
8
9
10
public final class Method extends Executable {
public Object invoke(Object obj, Object... args) {
MethodAccessor ma = methodAccessor;
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
}

可见最终会通过MethodAccessor的指针去指向一个具体的类型去调用反射,它是一个Interface,实现有如下几个:

  1. DelegatingMethodAccessorImpl:代理实现(基于委派模式)
  2. NativeMethodAccessorImpl:本地实现,由c++代码提供反射调用

上述DelegatingMethodAccessorImpl(委派实现)底层会最终调用NativeMethodAccessorImpl(本地实现)。而之所以还需要一个代理机制是因为JVM对反射进行了动态实现的优化,从而能够在本地实现以及动态实现中切换

  1. GeneratedMethodAccessor1(动态实现):由JVM自动生成字节码触发调用
    • 动态实现的调用性能更高,通常比NativeMethodAccessorImpl本地实现快20倍以上;
    • 反之,由于需要字节码生成,初始化过程较慢;仅仅调用一次的话反而本地实现快3-4倍。

java针对以上动态实现本地实现的优劣,进行了优化

  • 默认16次调用之后启用动态实现;
  • -Dsun.reflect.noInflation=true可以关闭本地实现直接使用动态实现,会直接使用动态实现不会触发本地实现和委派实现
  • -Dsun.reflect.inflationThreshold可以修改默认16次的动态实现触发阈值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
    static void test_invoke(int count){
try {
TestPrint testObj = new TestPrint();
String name = TestPrint.class.getName();
Class<?> cls = Class.forName(name);
Method method = cls.getMethod("test", int.class);
for (int i = 1; i < count; i++) {
try {
method.invoke(testObj, i);
} catch (Exception e) {
e.printStackTrace();
}
}
} catch (Exception e) {
}

}
static class TestPrint{
public void test(int count) throws Exception {
throw new Exception("my expection = " + count);
}

}

java.lang.reflect.InvocationTargetException
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
//第16次打印错误时,使用的还是本地实现
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.example.Main.test_invoke(Main.java:21)
at org.example.Main.main(Main.java:8)
Caused by: java.lang.Exception: my expection = 16
at org.example.Main$TestPrint.test(Main.java:32)
... 6 more
java.lang.reflect.InvocationTargetException
//十七次开始使用动态实现
at jdk.internal.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.example.Main.test_invoke(Main.java:21)
at org.example.Main.main(Main.java:8)
Caused by: java.lang.Exception: my expection = 17
at org.example.Main$TestPrint.test(Main.java:32)
... 5 more

反射调用开销

反射调用成本计算

  1. Class.forName():从JVM中根据命名空间查找类型class;
  2. Class.getMethod():从class类型中查找方法;
    • 此过程会遍历整个方法列表,如果本类没有回从父类等一次找;
    • 此过程会生成一个Method对象的副本返回,如果返回一整个方法列表,性能开销更大。
  3. 反射回重复校验访问权限,可以关闭method.setAccessible(true);

针对上述问题我们可以将Class和Method都缓存起来。

剩下的就只需要关注Method.Invoke的性能开销 了。

关于Invoke性能开销

以下代码用来观察反射调用直接调用的性能差距,我们取最后五次的结果。

v1_方法参数int:128

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Main {
public static void main(String[] args) throws Exception {
directInvoke(2_000_000_000);
reflectInvoke_v1(2_000_000_000);
}
static void directInvoke(int count) {
long before = System.currentTimeMillis();
for (int i = 1; i < count; i++) {
//每隔一个效目标打印一次耗时
if (i%100_000_000 == 0) {
long after = System.currentTimeMillis();
System.out.println(after-before);
before = after;
}
Test.test(128);
}
}

static void reflectInvoke_v1(int count) throws Exception {
long before = System.currentTimeMillis();
String name = Test.class.getName();
Class<?> cls = Class.forName(name);
Method method = cls.getMethod("test", int.class);
//避免校验权限也能提升部分性能
method.setAccessible(true);
for (int i = 1; i < count; i++) {
//每隔一个效目标打印一次耗时
if (i%100_000_000 == 0) {
long after = System.currentTimeMillis();
System.out.println(after-before);
before = after;
}
method.invoke(null, 128);
}
}
static class Test {
public static void test(int count) {
}
}
}

直接调用(毫秒) 反射调用(毫秒) 差距(倍数)
82 202 2.46
81 204 2.52
81 204 2.52
83 194 2.34
82 202 2.46

得出平均基准差距2.46倍

v2_方法缓存参数数组

  • 由于反射接受的参数是一个数组,如果我们只传入一个int值,那么它会自动构建一个数组去封装这个参数。我们可以提前构建数组:
    • 避免数组重复创建;
    • 避免Integer对象自动装配创建;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void reflectInvoke_v1(int count) throws Exception {
long before = System.currentTimeMillis();
String name = Test.class.getName();
Class<?> cls = Class.forName(name);
//避免校验权限也能提升部分性能
method.setAccessible(true);
Method method = cls.getMethod("test", int.class);
Object[] value = {128};
for (int i = 1; i < count; i++) {
//每隔一个效目标打印一次耗时
if (i%100_000_000 == 0) {
long after = System.currentTimeMillis();
System.out.println(after-before);
before = after;
}
method.invoke(null, value);
}
}

比基准慢2.18略有提升

V3_使用常量池

  • 如果上述方案替换使用常量池,只避免重复创建Integer对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) throws Exception {
long before = System.currentTimeMillis();
String name = Test.class.getName();
Class<?> cls = Class.forName(name);
Method method = cls.getMethod("test", int.class);
method.setAccessible(true);
for (int i = 1; i < 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long after = System.currentTimeMillis();
System.out.println(after - before);
before = after;
}
//改成了 127,能使用常量池
method.invoke(null, 127);
}
}
static class Test {
public static void test(int arg){}
}

是基准的1.5倍,快很多。

  • 当前优化比V2版本少了缓存Object[]对象,为什么频繁创建Object[]数组对象反而会性能更快?
    • 无GC触发:java会对方法内的栈变量进行逃逸分析,如果能确保其只在方法中使用便会将对象创建在栈空间内(实际更复杂)。从而避免了GC触发导致的性能损耗。
    • 即时编译器无法优化:V2版本由于数组在循环外面,编译器无法识别数组是否发生变更,因而无法优化对数组的访问操作。

V4_添加NoInflation参数

添加启动项参数,避免委派实现:java -XX:+PrintGC -Dsun.reflect.noInflation=true org.example.Main

1.2倍,很接近了!!

之所以反射调用能够变得这么快,主要是因为即时编译器中的方法内联。在关闭了 Inflation 的情况下,内联的瓶颈在于 Method.invoke 方法中对 MethodAccessor.invoke 方法的调用。

关于内联的瓶颈:原因在于MethodAccessor.invoke的调用点需要记录的类型可能有很多,即a.invoke,b.invoke,c.invoke…

无法内联的损耗

上述优化都使用到了方法内联,但如果在调用者种类过多的情况下会发生啥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class Main {
public static void main(String[] args) throws Exception {
long before = System.currentTimeMillis();
String name = Test.class.getName();
Class<?> cls = Class.forName(name);
Method method = cls.getMethod("test", int.class);
method.setAccessible(true);
polluteProfile();
for (int i = 1; i < 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long after = System.currentTimeMillis();
System.out.println(after - before);
before = after;
}
method.invoke(null, 127);
}
}

public static void polluteProfile() throws Exception {
Method method1 = Test.class.getMethod("test1", int.class);
Method method2 = Test.class.getMethod("test2", int.class);
for (int i = 0; i < 2000; i++) {
method1.invoke(null, 0);
method2.invoke(null, 0);
}
}

static class Test {
public static void test(int arg) {}
public static void test1(int i) {}
public static void test2(int i) {}
}

}
console结果
712
[GC (Allocation Failure) 276789K->821K(610304K), 0.0019333 secs]
[GC (Allocation Failure) 267573K->821K(601600K), 0.0010575 secs]
[GC (Allocation Failure) 258869K->821K(593408K), 0.0012190 secs]
[GC (Allocation Failure) 250677K->821K(585216K), 0.0015212 secs]
[GC (Allocation Failure) 242485K->821K(633856K), 0.0020997 secs]
[GC (Allocation Failure) 291125K->821K(624128K), 0.0013386 secs]
[GC (Allocation Failure) 281397K->821K(614912K), 0.0012273 secs]
[GC (Allocation Failure) 272181K->821K(606208K), 0.0014130 secs]
[GC (Allocation Failure) 263477K->821K(597504K), 0.0012301 secs]
728
[GC (Allocation Failure) 254773K->821K(589312K), 0.0009970 secs]
[GC (Allocation Failure) 246581K->821K(581632K), 0.0019493 secs]
[GC (Allocation Failure) 238901K->821K(629248K), 0.0021713 secs]
[GC (Allocation Failure) 286517K->821K(686592K), 0.0014060 secs]
[GC (Allocation Failure) 343861K->821K(755200K), 0.0017024 secs]
[GC (Allocation Failure) 412469K->821K(837632K), 0.0017687 secs]
[GC (Allocation Failure) 494901K->821K(818176K), 0.0009257 secs]
711
[GC (Allocation Failure) 475445K->821K(799744K), 0.0011703 secs]
[GC (Allocation Failure) 457013K->821K(782336K), 0.0010471 secs]
[GC (Allocation Failure) 439605K->821K(765440K), 0.0011883 secs]
[GC (Allocation Failure) 422709K->821K(749568K), 0.0011071 secs]
[GC (Allocation Failure) 406837K->821K(734208K), 0.0014714 secs]

8.59倍,差很多了

原因是:

  • JVM只会缓存部分调用者类型,超过两个则会放弃内联(XX:TypeProfileWidth=x可以修改);
  • 逃逸分析也不再生效,会触发若干GC。

优化

  • 用V2版本提前缓存,约提升至5.4倍

  • 使用TypeProfileWidth修改监控缓存,即:XX:TypeProfileWidth=3:


深入理解java(异常-反射)
https://andrewjiao.github.io/2022/09/14/深入理解JAVA/深入理解java(三)/
作者
Andrew_Jiao
发布于
2022年9月14日
许可协议