封面《きまぐれテンプテーション2 ゆうやみ廻奇譚》

前言

在公司里做项目的时候有一个需要使用 groovy 来计算表达式结果的情景。但是在使用过程中经常碰到一些计算结果不符合预期的问题,最后查阅文档在公司里一篇 13 年的文章找到答案,因此来记录一下。

问题复现

场景描述

公司里面使用 groovy 的场景是一个对树做递归场景,先对表达式求值结果,然后根据表达式结果 dfs 求取下一个表达式。由于对树做递归不影响其他表达式求值,因此在递归的过程中做了并行流来优化速度。在此时出现了表达式求值结果与预期不符合的场景

问题代码复现

由于 Groovy 在解析脚本的时候耗时很大以及为了避免缓存过多 class 导致永久代内存 OOM,因此在项目中我们使用了 ConcurrentHashMap 来缓存解析后实例化的Script对象。在这之中发现了 Groovy 脚本在多线程中出现 binding 混乱导致表达式求值结果不符合预期。下面是一个简单的代码复现

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
import groovy.lang.Binding;
import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;

import java.util.ArrayList;
import java.util.List;


public class Main {

public static void main(String[] args){
// 执行内容
String exp = "return aVal";
try (GroovyClassLoader classLoader = new GroovyClassLoader()) {
// 加载类
Class scriptClass = classLoader.parseClass(exp);
Script script = (Script) scriptClass.newInstance();
runScript(script);
} catch (Exception e) {
throw new RuntimeException(e);
}
}


public static void runScript(Script script) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int i = 0;i<100;i++) {
final int index= i;
final boolean aVal = i % 2 == 0;
Thread thread = new Thread(() -> {
Binding binding = new Binding();
binding.setVariable("aVal",aVal);
script.setBinding(binding);
boolean result = (boolean) script.run();
if (result != aVal) {
System.out.printf("index=%d Expected %b but got %b%n",index, aVal, result);
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
}

在预想之中结果应该是 stdout 不会有任何输出,但是实际的输出结果如下

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
index=20 Expected true but got false
index=18 Expected true but got false
index=0 Expected true but got false
index=24 Expected true but got false
index=34 Expected true but got false
index=60 Expected true but got false
index=10 Expected true but got false
index=46 Expected true but got false
index=44 Expected true but got false
index=40 Expected true but got false
index=36 Expected true but got false
index=4 Expected true but got false
index=94 Expected true but got false
index=50 Expected true but got false
index=6 Expected true but got false
index=8 Expected true but got false
index=78 Expected true but got false
index=66 Expected true but got false
index=42 Expected true but got false
index=64 Expected true but got false
index=52 Expected true but got false
index=12 Expected true but got false
index=62 Expected true but got false
index=22 Expected true but got false
index=88 Expected true but got false
index=80 Expected true but got false
index=58 Expected true but got false
index=16 Expected true but got false
index=54 Expected true but got false
index=30 Expected true but got false
index=76 Expected true but got false
index=82 Expected true but got false
index=98 Expected true but got false
index=86 Expected true but got false
index=74 Expected true but got false
index=32 Expected true but got false
index=38 Expected true but got false
index=26 Expected true but got false
index=68 Expected true but got false
index=2 Expected true but got false
index=90 Expected true but got false
index=28 Expected true but got false
index=72 Expected true but got false
index=48 Expected true but got false
index=96 Expected true but got false
index=84 Expected true but got false
index=56 Expected true but got false
index=70 Expected true but got false
index=14 Expected true but got false
index=92 Expected true but got false

可以看到有很多结果并不符合预期,这主要是因为在高并发的情况下,第一个线程获取到 script 对象后进行参数绑定,但是还没有执行逻辑方法时,第二个进程进行参数绑定导致执行了第二个进程的参数。

解决方案

使用 synchronized 关键字

最简单的解决方案就是在执行脚本的地方加上 synchronized 关键字。代码如下

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

public class Main {

public static void main(String[] args){
// 执行内容
String exp = "return aVal";
try (GroovyClassLoader classLoader = new GroovyClassLoader()) {
// 加载类
Class scriptClass = classLoader.parseClass(exp);
Script script = (Script) scriptClass.newInstance();
runScript(script);
} catch (Exception e) {
throw new RuntimeException(e);
}
}


public static void runScript(Script script) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int i = 0;i<100;i++) {
final int index= i;
final boolean aVal = i % 2 == 0;
Thread thread = new Thread(() -> {
synchronized (script) {
Binding binding = new Binding();
binding.setVariable("aVal", aVal);
script.setBinding(binding);
boolean result = (boolean) script.run();
if (result != aVal) {
System.out.printf("index=%d Expected %b but got %b%n", index, aVal, result);
}
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
}

使用 synchronized 关键字后,stdout 没有任何输出,说明结果符合预期。

缓存 Script 的 Class 对象

由于 synchronized 关键字会影响并发性能,因此更好的解决方案是缓存Script的 Class 对象,在每个线程中使用 newInstance 方法创建独立的Script对象。代码如下。这样每个 Script 对象都是独立的,互不影响。

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
import groovy.lang.Binding;
import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;

import java.util.ArrayList;
import java.util.List;


public class Main {

public static void main(String[] args){
// 执行内容
String exp = "return aVal";
try (GroovyClassLoader classLoader = new GroovyClassLoader()) {
// 加载类
Class<?> scriptClass = classLoader.parseClass(exp);
runScript((Class<Script>) scriptClass);
} catch (Exception e) {
throw new RuntimeException(e);
}
}


public static void runScript(Class<Script> scriptClass) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int i = 0;i<100;i++) {
final int index= i;
final boolean aVal = i % 2 == 0;
Thread thread = new Thread(() -> {
Script script = null;
try {
script = scriptClass.newInstance();
} catch (InstantiationException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
Binding binding = new Binding();
binding.setVariable("aVal", aVal);
script.setBinding(binding);
boolean result = (boolean) script.run();
if (result != aVal) {
System.out.printf("index=%d Expected %b but got %b%n", index, aVal, result);
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
}

可以看到使用这种方式后,stdout 没有任何输出,说明结果符合预期。而且脚本也是并行执行,但需要注意解析出来的 class 没有被使用的时候可能无法被 JVM 回收,因此需要使用 new GroovyClassLoader () 的方式来加载 class,避免永久代内存溢出。

参考资料