在我早期的文章当中,我使用过一个插件 vmlens 实现让 i++
展现了百分百的线程不安全。在演示示例中,使用了两个线程并发执行 i++
,然后就看到了线程不安全的全过程。
但是 vmlens 当时是个付费软件,作者给白嫖用户两周的体验期,虽然我我提了一个 BUG ,也没得到任何的优待。所以很快进行了简单的尝试之后,就放弃探索 vmlens 。
最近开始研究 Byteman
的官方文档过程中,当我看到了关于多线程管理的部分,原来可以控制多个故障的多线程同步,突然意识到有可能找到了 vmlens
一样的套路。如果我们可以控制访问一个变量的线程访问(读/写)顺序,那我们应该可以很容易模仿出线程不安全的场景。
既然如此,那我将重现一下 i++
百分百线程不安全的远古神级。
i++ 为什么不安全
不安全
i++
是线程不安全的,因为它不是一个原子操作。i++
其实包含了三个步骤:
-
读取变量值:从内存中读取变量
i
的当前值。 - 自增操作:将读取的值加 1。
- 写回变量值:将更新后的值存回内存中。
在单线程环境下,这个过程不会有问题,但在多线程环境中,如果多个线程同时执行 i++
,可能会发生竞态条件。例如,两个线程都读取了相同的初始值,但都还没来得及写回时,导致最终只会增加一次,而不是两次。
解决方法
-
使用同步机制:可以通过使用
synchronized
关键字来确保每次只有一个线程能够访问这个变量进行i++
操作。
synchronized(this) {
i++;
}
-
使用原子类:Java 提供了
AtomicInteger
来处理类似的操作,它保证了i++
的原子性。
AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet(); // 相当于 i++
这样可以避免多个线程同时修改变量时导致的不一致性。
测试代码
下面是我的测试代码,逻辑非常简单。代码创建了两个线程,每个线程每隔一秒对共享变量 i
进行递增操作,并输出当前值。
package com.funtest.temp;
public class FunTester {
static int i = 0;
public static void test() {
i++;
System.out.println(Thread.currentThread().getName() + " " + i);
}
public static void main(String[] args) {
for (int j = 0; j < 2; j++) {
new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
test();
}
}).start();
}
}
}
这个代码的逻辑可以简单梳理为以下几点:
-
静态变量
i
:定义了一个静态变量i
,初始值为0
。这是所有线程共享的变量,用来记录每次的递增操作。 -
test()
方法:test()
方法的作用是对i
进行自增操作,然后输出当前线程的名字和自增后的值。在原代码中,i++
是线程不安全的,多个线程可能会在读取和写入i
时发生冲突。 -
main()
方法:在main()
方法中,使用了一个循环创建了 两个线程,每个线程会进入一个无限循环(while (true)
)。每个线程在执行时,都会每隔 1 秒(Thread.sleep(1000)
)调用一次test()
方法,执行自增操作,并输出线程名称和当前的i
值。 -
多线程执行:两个线程同时运行,不断对
i
进行递增操作,由于i++
不是原子操作,线程可能会发生数据竞争,导致递增结果不正确(输出值可能不连续或重复)。
总结:
代码创建了两个线程,每个线程每隔一秒对共享变量 i
进行递增操作,并输出当前值。然而,由于 i++
操作不是线程安全的,程序可能出现竞态条件,导致输出结果不符合预期。
Byteman rule 脚本
下面是我的 Byteman 脚本的内容:
RULE sync test
CLASS com.funtest.temp.FunTester
METHOD test
HELPER org.chaos_mesh.byteman.helper.FunHelper
AT ENTRY
IF TRUE
DO setThreadName()
ENDRULE
RULE async test
CLASS com.funtest.temp.FunTester
METHOD test
HELPER org.chaos_mesh.byteman.helper.FunHelper
AT WRITE i
IF checkThreadName()
DO System.out.println(Thread.currentThread().getName() + " 持有锁");
ENDRULE
这个 Byteman 脚本的作用是通过对类 com.funtest.temp.FunTester
的 test
方法进行增强,借助 Byteman 的规则动态监控和修改线程执行时的行为。主要目标是通过 FunHelper
来记录和监控线程的名字,并在访问共享变量 i
时输出线程持有锁的信息。下面逐步解释每个规则的含义:
第一条规则:sync test
-
RULE 名称:
sync test
,给这条规则命名为sync test
。 -
CLASS:目标类是
com.funtest.temp.FunTester
,这条规则作用于该类。 -
METHOD:这条规则针对
test()
方法,表示要拦截这个方法。 -
HELPER:指定了一个辅助类
org.chaos_mesh.byteman.helper.FunHelper
,其中定义了一些辅助方法来支持规则逻辑。 -
AT ENTRY:该规则触发的时机是在
test()
方法的入口处,也就是方法一开始执行时。 -
IF TRUE:条件始终为
TRUE
,意味着无条件执行。 -
DO setThreadName():在
test()
方法执行时,调用FunHelper
中的setThreadName()
方法。这通常是用于记录或设置当前线程的名称。
当 test()
方法执行时,无论什么情况下,都会调用 setThreadName()
,可能用于记录每个线程的名称,以便后续跟踪哪个线程正在执行。
第二条规则:async test
-
RULE 名称:
async test
,命名为async test
。 -
CLASS:同样作用于类
com.funtest.temp.FunTester
。 -
METHOD:针对
test()
方法。 -
HELPER:依然是
org.chaos_mesh.byteman.helper.FunHelper
,同样使用辅助类来提供额外功能。 -
AT WRITE i:表示该规则在变量
i
被写入时触发。也就是说,当i
的值发生改变时,规则会被执行(对应于i++
时的写操作)。 -
IF checkThreadName():该规则只有在辅助类中的
checkThreadName()
返回true
时才会触发。这个方法可能会根据线程名称来判断当前线程是否符合某种条件。 -
DO System.out.println(Thread.currentThread().getName() + " 持有锁"):如果条件为
true
,则会打印当前线程的名字,并显示"持有锁"
,表示当前线程正在执行对共享变量i
的修改操作。
当 test()
方法中的共享变量 i
被写入时(即 i++
发生时),Byteman 会检查当前线程的名字。如果线程名字满足 checkThreadName()
的条件,就会输出该线程已经持有锁的信息。这可以用于调试或监控,查看哪个线程正在修改共享变量 i
,避免竞态条件。
思路
通过这两个脚本,我们就可以在 i++
赋值的过程中,第一个线程等待第二个线程进来,就能模式两个线程同时完成 i+
计算,然后在分别开始执行赋值过程。我自己的思路就是在赋值之前做一个阻塞的设置,当一个线程到达,必须等另外一个线程过去,然后自己再执行。这个设计基本上可以保障后来的进程先赋值,因为我再等待的方法中加上了神迹 Thread.sleep(10);
。
实践效果
下面是注入 Byteman 脚本前后,控制台输出日志变化情况:
Thread-3 7
Thread-3 8
Thread-2 9
Thread-3 10
Thread-2 11
setThreadName Thread-2
setThreadName Thread-2
Thread-3 持有锁
Thread-3 12
setThreadName Thread-3
Thread-2 持有锁
Thread-2 12
setThreadName Thread-2
Thread-3 持有锁
Thread-3 13
setThreadName Thread-3
Thread-2 持有锁
Thread-2 13
setThreadName Thread-2
可以看出,注入前,看着似乎是线程安全的,但是注入之后,每个线程输出的值都是一样的,百分百线程不安全了。
关于 FunHelper
这里实现比较简单,而且粗糙,目前各种实践中积累一些好的设计和场景。打算从 Byteman 源码中再汲取一些营养。后面等我感觉代码成熟了,再来分享一篇文章。有兴趣的可以加好友一起交流一下 Byteman 相关技术话题。