在我早期的文章当中,我使用过一个插件 vmlens 实现让 i++ 展现了百分百的线程不安全。在演示示例中,使用了两个线程并发执行 i++,然后就看到了线程不安全的全过程。

但是 vmlens 当时是个付费软件,作者给白嫖用户两周的体验期,虽然我我提了一个 BUG ,也没得到任何的优待。所以很快进行了简单的尝试之后,就放弃探索 vmlens

最近开始研究 Byteman 的官方文档过程中,当我看到了关于多线程管理的部分,原来可以控制多个故障的多线程同步,突然意识到有可能找到了 vmlens 一样的套路。如果我们可以控制访问一个变量的线程访问(读/写)顺序,那我们应该可以很容易模仿出线程不安全的场景。

既然如此,那我将重现一下 i++ 百分百线程不安全的远古神级。

i++ 为什么不安全

不安全

i++ 是线程不安全的,因为它不是一个原子操作。i++ 其实包含了三个步骤:

  1. 读取变量值:从内存中读取变量 i 的当前值。
  2. 自增操作:将读取的值加 1。
  3. 写回变量值:将更新后的值存回内存中。

在单线程环境下,这个过程不会有问题,但在多线程环境中,如果多个线程同时执行 i++,可能会发生竞态条件。例如,两个线程都读取了相同的初始值,但都还没来得及写回时,导致最终只会增加一次,而不是两次。

解决方法

  1. 使用同步机制:可以通过使用 synchronized 关键字来确保每次只有一个线程能够访问这个变量进行 i++ 操作。
synchronized(this) {
    i++;
}
  1. 使用原子类: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();
        }

    }

}

这个代码的逻辑可以简单梳理为以下几点:

  1. 静态变量 i:定义了一个静态变量 i,初始值为 0。这是所有线程共享的变量,用来记录每次的递增操作。
  2. test() 方法test() 方法的作用是对 i 进行自增操作,然后输出当前线程的名字和自增后的值。在原代码中,i++ 是线程不安全的,多个线程可能会在读取和写入 i 时发生冲突。
  3. main() 方法:在 main() 方法中,使用了一个循环创建了 两个线程,每个线程会进入一个无限循环(while (true))。每个线程在执行时,都会每隔 1 秒Thread.sleep(1000))调用一次 test() 方法,执行自增操作,并输出线程名称和当前的 i 值。
  4. 多线程执行:两个线程同时运行,不断对 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.FunTestertest 方法进行增强,借助 Byteman 的规则动态监控和修改线程执行时的行为。主要目标是通过 FunHelper 来记录和监控线程的名字,并在访问共享变量 i 时输出线程持有锁的信息。下面逐步解释每个规则的含义:

第一条规则:sync test

test() 方法执行时,无论什么情况下,都会调用 setThreadName(),可能用于记录每个线程的名称,以便后续跟踪哪个线程正在执行。

第二条规则:async test

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 相关技术话题。


↙↙↙阅读原文可查看相关链接,并与作者交流