问题

下面代码是线程不安全的代码,请问为什么很难跑出不安全的样例?

回答
你的问题很有意思,问到了很多开发者在实际并发编程中遇到的一个普遍困惑:为什么我写的看似“有风险”的多线程代码,平时好像都没出问题?

这背后其实是并发编程“间歇性”和“条件性”的本质在作祟,再加上我们日常测试中的局限性。让我试着深入给你讲讲:

1. 线程调度的“随意性”:时间片轮转与不可预测性

首先,要理解我们为什么会觉得代码“可能”不安全。在多线程环境中,CPU并不会严格按照你代码写的顺序,让一个线程执行完所有操作再去执行下一个。相反,它采用的是时间片轮转的调度方式。

想象一下,CPU就像一个效率极高的服务员,手里拿着很多张订单(线程)。它不会一口气把第一个订单的点餐、上菜、结账都做完,而是会快速地在各个订单之间切换。给第一个订单倒一杯水,然后去第二个订单那里拿一下菜单,再回来给第一个订单送过去,接着又去第三个订单那里看看有没有什么急事。

这个切换的时机,也就是线程什么时候被暂停、什么时候又被唤醒,是操作系统调度器说了算。而调度器是相当“随意”的,它会根据很多因素来决定,比如线程的优先级、是否有I/O等待、系统的负载等等。这些因素在我们日常运行代码时,往往是动态变化且难以精确控制的。

为什么这导致难以重现不安全样例?

因为,你的“不安全”操作,往往是建立在某个线程在关键时刻被中断的基础上。比如,你有一个计数器 `count`,两个线程都在对它进行 `count++` 操作。`count++` 并不是一个原子性的操作,它实际上包含了三个步骤:

读取 `count` 的当前值。
将读取到的值加一。
将新值写回 `count`。

如果线程A在读取了 `count` 的值(假设是5)后,还没来得及写回新值(6),就被CPU切换走了,然后线程B执行了完整的 `count++`,它读取到的 `count` 也是5,然后写回6。之后CPU再切换回线程A,线程A会用它之前读取到的5来加一,然后写回6。

结果就是,两次 `count++` 操作,`count` 的值只增加了1,而不是预期的2。

问题来了:

时机非常关键: 这种“读改写”的过程被中断,恰好发生在线程A完成“读”但未完成“写”的那个微妙时刻。
难以预测的精确时间点: 操作系统调度器什么时候恰好在线程A执行到“读”完就切换走,然后让线程B执行,再切回来?这个时间点是高度不确定的。它可能在系统空闲时发生,也可能在系统忙碌时发生,甚至在你刚刚按下“运行”按钮的瞬间就发生了。
“幸运”的正常执行: 大多数时候,操作系统可能恰好允许线程A把整个 `count++` 操作执行完毕,或者允许线程B执行完毕,或者允许它们按顺序执行。在这些“幸运”的情况下,`count` 的值就会是正确的,你就看不到线程不安全的问题。

2. 缓存一致性、内存重排与“不可见”的副作用

除了线程调度的不确定性,现代CPU的运行方式也让事情更加复杂。

CPU缓存: 为了提高效率,CPU在访问内存时会使用缓存。当线程在操作一个变量时,它可能直接操作的是CPU缓存中的值,而不是直接操作主内存。这带来了缓存一致性的问题。如果线程A修改了一个变量,但这个修改还没有同步到主内存,而线程B恰好从主内存读取了这个变量,那么线程B读到的就是旧值。
指令重排: 编译器和CPU为了优化性能,会重新安排指令的执行顺序,只要不改变单个线程的“逻辑”结果。比如,`a = 1; b = 2;` 这两行代码,在CPU层面可能变成 `b = 2; a = 1;` 执行,只要这两个赋值操作本身没有依赖关系。

为什么这导致难以重现不安全样例?

“我看到的是我的副本”: 线程A可能在它的缓存里修改了某个共享变量,但在它将这个修改写回主内存之前,线程B读取的是主内存中的旧值。或者反过来,线程B修改了,但线程A读取的是它自己缓存中的旧值。这些缓存中的值对其他线程来说是“不可见”的。
“我的顺序和你的不一样”: 即使你写代码时是按照 `x.doSomething(); y.doSomethingElse();` 的顺序,CPU或编译器可能会为了效率,将 `y.doSomethingElse()` 提到 `x.doSomething()` 前面执行。如果 `y.doSomethingElse()` 的结果依赖于 `x.doSomething()` 之前的一个状态,或者 `x.doSomething()` 的结果依赖于 `y.doSomethingElse()` 之后的状态,那么就会出错。

这种内存模型和指令重排带来的不安全,同样是非常微妙的,它依赖于CPU的内部工作机制以及编译器对代码的优化策略。这些因素的组合,使得在一次简单的运行中,它们恰好“出错”的概率非常低。

3. 测试的局限性:采样与概率

我们平常测试代码,通常是在一个相对“正常”的负载下进行。

短暂的运行: 你运行一次代码,可能只有几秒钟或者几分钟。在这短暂的时间里,线程调度器可能就恰好避开了那些会暴露问题的关键时刻。
非极致并发: 你可能只启动了两个或几个线程。当你启动成百上千个线程,并且让它们同时争抢资源时,冲突的概率才会急剧升高,更容易触发那些“千年等一回”的竞态条件。
硬件环境差异: 不同的CPU、不同的操作系统版本,甚至同一台机器上不同的运行负载,都可能影响线程调度和缓存行为,从而影响问题的复现。

总结一下:

你的代码之所以“看起来”不安全,是因为在并发环境下,线程之间对共享资源的访问顺序和时机变得不可控。不安全样例的难以出现,主要是因为:

1. 线程调度的不可预测性: 操作系统调度器什么时候暂停一个线程,什么时候唤醒另一个,充满了随机性,只有在非常特定的时间点上,一个线程的“读改写”操作才会被另一个线程的类似操作打断,从而导致错误。
2. 底层硬件和编译器的优化: CPU缓存、内存重排等机制,虽然提高了效率,但也引入了数据可见性和执行顺序的不确定性,这些不确定性使得错误条件更加隐蔽。
3. 测试本身的局限性: 短暂的测试时间、有限的线程数量,不足以模拟出高并发、高冲突的场景,从而大大降低了触发那些“概率性”错误的几率。

就好像你试图寻找一个非常罕见的彩票中奖号码,而你只买了很少几张彩票,并且只在晴朗的日子里购买。只有当你持续不断地、在各种条件下大量地进行测试,才有可能偶然撞上那个“不安全的”瞬间。

所以,如果你认为你的代码“可能”不安全,即使当下没有出现问题,也绝不能掉以轻心。最好的做法是始终使用成熟的并发控制机制(如锁、原子操作、并发集合等)来保护共享资源,这样才能从根本上避免这些潜在的、难以捉摸的错误。

网友意见

user avatar

首先题主请务必纠正一个误解:线程不安全指的是“不保证会得到预期安全的行为”,而不是“保证不会得到预期安全的行为”。这是个逻辑问题而不是Java、多线程问题。

当您观察到一次实验的结果与预期的“安全”行为一致时,并不代表它就是安全的了。“不保证会得到预期安全的行为”也允许有些时候可以运行得到预期的结果,只是“没有保证”而已。

然而没有保证就是头疼的地方了。您听说过因为没有正确同步而在大并发条件下在HashMap.get()里死循环的案例不?这就是“不保证安全”,然后很多时候似乎也运行得到了预期的结果,然后放上生产环境压一压就垮的案例。

@木女孩

说得对。这个并发量级太低了。

另外代码在被JIT编译过后更容易观察到各种有趣的结果。所以把题主的例子稍微调整了一下结构来帮助它预热一小下:

       public class UnsafeSequence {     private int value;      public UnsafeSequence(int value) {         this.value = value;     }      public int getValue(){         return value++;     }      private static class MyRunnable implements Runnable {         private final UnsafeSequence seq;          MyRunnable(UnsafeSequence seq) {           this.seq = seq;         }          @Override         public void run() {             try {                 Thread.sleep(100);                 for (int i = 0; i < 1000; i++) {                     System.out.println(Thread.currentThread().getName() + ":" + seq.getValue());                 }             } catch (InterruptedException ex) {             }         }     }      // warm up MyRunnable.run(), make sure MyRunnable.run() is JIT compiled     private static void warmUp() {         UnsafeSequence unsafeSequence = new UnsafeSequence(0);         MyRunnable r = new MyRunnable(unsafeSequence);         // warm up MyRunnable.run()         for (int i = 0; i < 100; i++) {           r.run();         }     }      public static void main(String[] args) {         warmUp();          UnsafeSequence unsafeSequence = new UnsafeSequence(0);         MyRunnable r = new MyRunnable(unsafeSequence);                  for (int i = 0; i < 5000; i++) {             new Thread(r).start();         }     } }      

截取一段输出给题主参考:

       Thread-1439:68297 Thread-1439:585663 Thread-1439:585664 Thread-1439:585665 Thread-1439:585666 Thread-1439:585667 Thread-1439:585668 Thread-1438:68295 Thread-1438:585670 Thread-1438:585671      

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有