优泽俊 發表於 2025-11-7 09:19:00

一个经典案例深入剖析Java并发中的“可见性”陷阱

<blockquote>
<p><strong>“你以为程序按顺序执行,但CPU和JVM说:不,我们有自己的想法。”</strong></p>
</blockquote>
<p>一起来解剖一段看似简单、实则暗藏玄机的Java代码。它只有20行,却浓缩了多线程编程中最经典、最易被忽视的陷阱——<strong>可见性(Visibility)问题与指令重排序(Reordering)</strong>。</p>
<p>它来自《Java并发编程实战》(JCIP)的经典示例,也是无数面试题的源头。</p>
<h2 id="-代码原貌平静下的风暴">🔍 代码原貌:平静下的风暴</h2>
<pre><code class="language-java">public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
      @Override
      public void run() {
            while (!ready) {
                Thread.yield(); // 礼貌地让出CPU
            }
            System.out.println(number);
      }
    }

    public static void main(String[] args) {
      new ReaderThread().start(); // 启动读线程
      number = 42;                // 先赋值
      ready = true;               // 再“通知”
    }
}
</code></pre>
<h3 id="程序的预期逻辑很简单">程序的“预期”逻辑很简单:</h3>
<ol>
<li>启动一个线程 <code>ReaderThread</code>,它不断检查 <code>ready</code> 是否为 <code>true</code>;</li>
<li>主线程将 <code>number</code> 设为 42,再将 <code>ready</code> 设为 <code>true</code>,表示“数据已就绪”;</li>
<li>读线程看到 <code>ready == true</code> 后,打印 <code>number</code>,理应输出 <strong>42</strong>。</li>
</ol>
<h3 id="但现实呢">❓但现实呢?</h3>
<p>多次运行,你可能会看到:</p>
<ul>
<li><code>42</code> ✅(幸运时刻)</li>
<li><code>0</code> ⚠️(高频出现!)</li>
<li>或者……程序<strong>永远卡住不退出</strong>(需手动 <code>Ctrl+C</code>)💥</li>
</ul>
<blockquote>
<p>🤔 这段代码没有 <code>synchronized</code>,没有锁,没有异常——它“语法正确”,却“语义错误”。问题出在哪?</p>
</blockquote>
<hr>
<h2 id="️-问题根源java内存模型jmm的三重背叛">🌪️ 问题根源:Java内存模型(JMM)的三重“背叛”</h2>
<h3 id="1️⃣-缓存不一致可见性缺失">1️⃣ <strong>缓存不一致:可见性缺失</strong></h3>
<p>现代CPU为提升性能,每个线程都有自己的<strong>工作内存</strong>(高速缓存)。对共享变量的读写,可能只发生在本地缓存,<strong>不立即同步到主内存</strong>。</p>
<ul>
<li>主线程修改了 <code>ready = true</code>,但这个值可能还“躺”在它的缓存里;</li>
<li><code>ReaderThread</code> 的缓存里 <code>ready</code> 仍是 <code>false</code> → 无限循环;</li>
<li>即便它看到了 <code>ready == true</code>,它的缓存里 <code>number</code> 可能还是初始值 <code>0</code> → 打印 <code>0</code>。</li>
</ul>
<blockquote>
<p>⚠️ <code>Thread.yield()</code> <strong>只是建议线程让出CPU时间片,并不触发缓存刷新</strong>!它无法解决可见性问题。</p>
</blockquote>
<h3 id="2️⃣-编译器与cpu的自作聪明指令重排序">2️⃣ <strong>编译器与CPU的“自作聪明”:指令重排序</strong></h3>
<p>为优化性能,JVM 和 CPU 在<strong>不改变单线程语义</strong>的前提下,允许重排指令顺序:</p>
<pre><code class="language-java">// 你写的:
number = 42;
ready = true;

// 实际执行的,可能是:
ready = true;   // 先执行!
number = 42;    // 后执行!
</code></pre>
<p>对主线程自己来说,结果一样;但对 <code>ReaderThread</code> 而言,它可能在 <code>ready</code> 变成 <code>true</code> 的瞬间跳出循环,此时 <code>number</code> 还没被写入——于是读到 <code>0</code>。</p>
<blockquote>
<p>📌 <strong>重排序是合法的</strong>,只要你没用同步机制“约束”它。</p>
</blockquote>
<h3 id="3️⃣-缺乏happens-before保证">3️⃣ <strong>缺乏“happens-before”保证</strong></h3>
<p>Java 内存模型用 <strong>happens-before</strong> 规则定义操作间的可见性顺序。若操作 A <strong>happens-before</strong> 操作 B,则 A 的结果对 B <strong>一定可见</strong>。</p>
<p>而上述代码中:</p>
<ul>
<li><code>number = 42</code> 与 <code>ready = true</code> 之间 <strong>没有 happens-before 关系</strong>;</li>
<li>主线程写 <code>ready</code> 与读线程读 <code>ready</code> 之间 <strong>也没有 happens-before 关系</strong>。</li>
</ul>
<p>结果就是:一切皆有可能(<code>0</code>、<code>42</code>、死循环)——典型的<strong>竞态条件(Race Condition)</strong>。</p>
<hr>
<h2 id="-正确解法建立因果律">✅ 正确解法:建立“因果律”</h2>
<p>要让 <code>ReaderThread</code> 在看到 <code>ready == true</code> 时 <strong>必然</strong> 看到 <code>number == 42</code>,我们必须建立明确的 <strong>happens-before</strong> 边界。</p>
<h3 id="-方案一volatile--最简洁优雅推荐">✅ 方案一:<code>volatile</code> —— 最简洁优雅(推荐!)</h3>
<pre><code class="language-java">private static volatile boolean ready; // ← 只需加在这里!
private static int number;             // number 可以不加 volatile
</code></pre>
<h4 id="为什么有效">为什么有效?</h4>
<p>Java 内存模型规定:</p>
<blockquote>
<p><strong>“对一个 volatile 变量的写操作 happens-before 后续对这个 volatile 变量的读操作。”</strong></p>
</blockquote>
<p>这意味着:</p>
<ol>
<li>主线程执行 <code>ready = true</code>(volatile 写);</li>
<li><code>ReaderThread</code> 执行 <code>if (!ready)</code>(volatile 读)并看到 <code>true</code>;</li>
<li>根据 happens-before 规则:<br>
<code>number = 42</code> →(程序顺序)→ <code>ready = true</code>(volatile写)<br>
→(volatile规则)→ <code>ready</code> 读取为 <code>true</code><br>
⇒ 所以 <code>number = 42</code> <strong>happens-before</strong> 读取 <code>number</code>!</li>
</ol>
<p>✅ <strong><code>number</code> 即便不是 <code>volatile</code>,也能被正确看到为 42!</strong></p>
<blockquote>
<p>🌟 这就是 <code>volatile</code> 的“<strong>内存可见性传递性</strong>”:一个 volatile 写,能“捎带”它之前所有普通写操作的可见性 。</p>
</blockquote>
<h3 id="-方案二synchronized--重量级但通用">✅ 方案二:<code>synchronized</code> —— 重量级但通用</h3>
<pre><code class="language-java">private static final Object lock = new Object();

// ReaderThread 中:
while (!ready) {
    synchronized (lock) { } // 空同步块,只为建立同步边
    Thread.yield();
}

// main 中:
synchronized (lock) {
    number = 42;
    ready = true;
}
</code></pre>
<p><code>synchronized</code> 天然提供:</p>
<ul>
<li>互斥访问(此处非必需);</li>
<li><strong>进入/退出同步块时的内存屏障</strong>,刷新缓存,禁止重排序;</li>
<li>明确的 <strong>happens-before</strong>:释放锁 happens-before 获取同一把锁。</li>
</ul>
<h3 id="-方案三atomicboolean--atomicinteger">✅ 方案三:<code>AtomicBoolean</code> / <code>AtomicInteger</code></h3>
<pre><code class="language-java">private static final AtomicBoolean ready = new AtomicBoolean(false);
private static final AtomicInteger number = new AtomicInteger(0);

// main:
number.set(42);
ready.set(true);

// ReaderThread:
while (!ready.get()) {
    Thread.yield();
}
System.out.println(number.get());
</code></pre>
<p><code>AtomicXxx</code> 的 <code>get()</code>/<code>set()</code> 默认具有 <code>volatile</code> 语义(除 <code>lazySet</code>),同样满足 happens-before 。</p>
<hr>
<h2 id="-实验验证眼见为实">🧪 实验验证:眼见为实</h2>
<p>你可以在本地反复运行原版代码:</p>
<pre><code class="language-bash">for i in {1..10}; do java NoVisibility; done
# 很可能混杂着 0 和 42,甚至卡住
</code></pre>
<p>再运行修复版(加 <code>volatile</code>):</p>
<pre><code class="language-bash">for i in {1..10}; do java FixedNoVisibility; done
# 稳定输出 42!
</code></pre>
<blockquote>
<p>💡 提示:在服务器模式(<code>-server</code> JVM)或某些CPU架构(如ARM)上,问题更容易复现。</p>
</blockquote>
<hr>
<h2 id="-深层思考由此学到了什么">📚 深层思考:由此学到了什么?</h2>
<table>
<thead>
<tr>
<th>误区</th>
<th>真相</th>
</tr>
</thead>
<tbody>
<tr>
<td>“变量赋值是原子的,所以没问题”</td>
<td>原子性 ≠ 可见性。<code>boolean</code>/<code>int</code> 赋值是原子的,但<strong>其他线程看不到</strong>!</td>
</tr>
<tr>
<td>“<code>Thread.yield()</code> 能让线程‘同步’”</td>
<td><code>yield()</code> 是线程调度提示,<strong>无内存语义</strong>,不能替代同步。</td>
</tr>
<tr>
<td>“代码顺序 = 执行顺序”</td>
<td>编译器、CPU、JIT 都会重排序——除非你用 <code>volatile</code>/<code>synchronized</code> 禁止。</td>
</tr>
<tr>
<td>“单核CPU不会有这问题”</td>
<td>单核也可能缓存不一致!且现代基本都是多核。</td>
</tr>
</tbody>
</table>
<h3 id="-关键总结">🎯 关键总结:</h3>
<ol>
<li><strong>共享可变状态</strong> 必须考虑线程安全;</li>
<li><code>volatile</code> 不只是“防重排序”,更是建立 <strong>happens-before</strong> 的轻量级工具;</li>
<li><strong>一个 <code>volatile</code> flag,可带动一批普通变量的可见性</strong>——这是高效并发设计的基石;</li>
<li>测试多线程bug不能靠“跑几次没事”,而要靠<strong>理论保证</strong>。</li>
</ol>
<hr>
<h2 id="-延伸阅读">📖 延伸阅读</h2>
<ul>
<li>📘 《Java Concurrency in Practice》第3章 “Sharing Objects”</li>
<li>📜 JSR-133: Java Memory Model and Thread Specification</li>
<li>🌐 Java Language Specification §17.4.5. Happens-before Order</li>
</ul>


</div>
<div id="MySignature" role="contentinfo">
    <center><big>欢迎关注我的公众号,共同学习</big></center><br/>
<center><img src="https://images.cnblogs.com/cnblogs_com/kohler21/1908624/t_220825103141_%E5%85%AC%E4%BC%97%E5%8F%B7%E5%90%8D%E7%89%87.png" alt="" align="center" ></center><br><br>
来源:https://www.cnblogs.com/kohler21/p/19198535
頁: [1]
查看完整版本: 一个经典案例深入剖析Java并发中的“可见性”陷阱