Java 多线程标志位的使用

在 Java 多线程编程中,标志位(Flag) 是一种常见的线程协作与控制手段,用于通知线程"是否继续运行"、"是否停止任务"等。但它的使用有严格的前提条件,否则会导致可见性问题、响应延迟甚至死循环。

标志位通常是一个 boolean 类型的共享变量,一个线程通过修改它来通知另一个线程改变行为。

以下提供 4 种代码,来体现使用标志位时的注意事项

错误代码一:程序卡住!"子线程结束" 永远不打印!

此时,几乎 100% 会死循环!因为 JVM 会优化这个循环:

  • 第一次读取 running 到寄存器

  • 后续直接使用寄存器中的 true,永不重新读主内存

为什么后续 JVM直接使用寄存器中的 true,永不重新读主内存:

步骤 1:JVM 分析代码

  • JVM 发现:在这个线程的执行路径中,没有任何地方修改 running

  • 而且 running 不是 volatile,也没有被 synchronized 保护。

  • 所以 JVM 合理推断:running 的值在本线程中永远不会改变。

步骤 2:激进优化 ------ "提升为常量" 或 "缓存到寄存器"

步骤 3:后果 ------ 多线程下失效

  • 主线程修改了 running = false,写入主内存。

  • 但子线程早已不再访问主内存,它只看寄存器或本地缓存中的旧值 true

  • 结果:死循环,永远看不到变化。

java 复制代码
public class Demo9_3 {
    private static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while(running) {
                //空循环
            }
            System.out.println("子线程结束");
        });

        t.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("主线程已设 running = false");

        //你会发现:程序卡住!"子线程结束" 永远不打印!

    }
}

错误代码二:表面上看,它"似乎能正常结束"------但这只是"偶然正确",本质上是存在严重并发 bug 的!

为什么 Thread.sleep() 有时能"救"回来?

虽然 sleep() 不是 JMM 规范定义的同步点(不像 volatile),但在实际 JVM 实现中:

  • Thread.sleep() 是一个 native 方法,会触发线程状态切换(RUNNABLE → TIMED_WAITING)。

  • 在进入/退出内核态、上下文切换时,CPU 缓存可能被刷新,或 JVM 保守地重新加载内存状态。

  • 所以从 sleep() 返回后,再次读取 running 时,可能碰巧读到主内存的新值。

⚠️ 但这不是规范保证的行为!不同 JVM、不同 OS、不同 CPU 架构下表现可能不同。

java 复制代码
public class Demo9_1 {
    private static boolean running = true;

    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while(running) {
                System.out.println("hello");
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("thread 线程结束");
        });

        t.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("输入任意内容, 控制 t 线程结束: ");
        scanner.next();
        running = false; 
    }
}

解决方案一:使用 volatile

保证可见性(Visibility)

  • 当一个线程写入 volatile 变量时,强制将该值刷新到主内存。

  • 当其他线程读取该 volatile 变量时,强制从主内存重新加载最新值,跳过 CPU 缓存和寄存器缓存。

类比理解(现实例子)

想象两个办公室:

  • 主内存 = 公司中央公告栏

  • CPU 缓存/寄存器 = 员工自己的笔记本

没有 volatile(无通知机制):

  • 老板(主线程)在公告栏贴了"今天下班"(running = false

  • 员工 A(子线程)早上抄了一条"今天上班"(running = true)到笔记本

  • 之后他再也不看公告栏,只看笔记本 → 一直加班到死 💀

volatile(强制看公告栏):

  • 老板贴通知时,广播:"所有人立刻看公告栏!"

  • 员工 A 下次看 running 时,必须去公告栏看最新内容 → 看到"下班",就走了 ✅

java 复制代码
public class Demo9_4 {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while(running) {
                //空循环
            }
            System.out.println("子线程结束");
        });

        t.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("主线程已设置 running = false");


    }
}

解决方法二:使用 AtomicBoolean(更现代)

使用 AtomicBoolean 能解决多线程中标志位的可见性与安全性问题,核心原因在于它内部通过 volatile + CAS(Compare-And-Swap)机制,既保证了内存可见性,又提供了原子操作语义。

一、为什么 AtomicBoolean 能解决问题?

1. 底层使用 volatile 修饰值

AtomicBoolean.get()set() 操作都作用于一个 volatile int,因此天然具备 内存可见性 ------ 主线程调用 set(false) 后,子线程调用 get() 一定能读到最新值。

2. 支持原子的"比较并设置"操作(CAS)

虽然你的场景只用到 get()set(),但 AtomicBoolean 还提供:

java 复制代码
public final boolean compareAndSet(boolean expect, boolean update)
  • 这个操作是 原子的(通过 CPU 的 CAS 指令实现)
  • 即使多个线程同时尝试修改,也能保证"读-改-写"不被干扰:当多个线程同时对同一个共享变量执行"先读取当前值 → 根据值做计算 → 写回新值"这一系列操作时,使用 CAS(如 AtomicBoolean.compareAndSet)能确保整个过程不会被其他线程打断,从而避免数据错误。

二、与 volatile boolean 对比

特性 volatile boolean AtomicBoolean
可见性 ✅(靠 volatile ✅(内部用 volatile
原子性(单次读/写) ✅(读/写本身是原子的)
复合操作原子性(如"如果为 true 则设为 false") ✅(用 compareAndSet
语义清晰度 一般 ✅(明确表达"这是个原子布尔标志")
可扩展性 ✅(支持更多原子操作)

三、为什么它能避免"死循环"问题?

✅ 关键点:

  • running.get() → 读取 volatile int value → 强制从主内存加载

  • running.set(false) → 写入 volatile int value → 立即刷新到主内存

  • 因此,子线程下一次调用 get() 就能看到 false,循环退出

🚫 不会出现"缓存旧值导致死循环"的问题!

总结:为什么 AtomicBoolean 能解决问题?

原因 说明
1. 内部使用 volatile 保证 get()/set() 的内存可见性,解决"看不到修改"的问题
2. 对象引用在线程间共享 作为堆对象,所有线程访问的是同一个实例(不像局部变量被捕获副本)
3. 语义明确、不易误用 明确表达"这是一个线程安全的布尔状态"
4. 支持未来扩展 如需原子条件更新,直接用 compareAndSet,无需重构
java 复制代码
import java.util.Scanner;
import java.util.concurrent.atomic.AtomicBoolean;

public class Demo9_3 {
    public static void main(String[] args) {
        AtomicBoolean running = new AtomicBoolean(true);

        Thread t = new Thread(() -> {
            while (running.get()) {
                System.out.println("hello thread");
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
            System.out.println("thread 结束");
        });

        t.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("输入任意内容, 控制 t 线程结束: ");
        scanner.next();
        running.set(false); // 原子操作,线程安全
    }
}

总结

使用标志位并不能唤醒等待,要是想唤醒等待,必须使用 t.interrupt(); 来终止线程

使用标志位注意事项:

✅ 1. 必须保证内存可见性

问题 :一个线程修改标志位,另一个线程可能永远看不到更新。
原因:CPU 缓存、JVM 优化导致变量值未同步到主内存。

✅ 正确做法(任选其一):
  • 使用 volatile 修饰标志位:

    java 复制代码
    private static volatile boolean running = true;
  • 使用 AtomicBoolean

    java 复制代码
    private final AtomicBoolean running = new AtomicBoolean(true);
为什么要加 final:

✅ 1. 防止引用被意外修改(保证"对象不变性")

  • final 表示:running 这个引用变量不能再指向其他 AtomicBoolean 对象。

  • 你仍然可以调用 running.set(false) 修改其内部状态(因为 AtomicBoolean 本身是可变的)。

  • 但你不能做:

    java 复制代码
    running = new AtomicBoolean(false); // ❌ 编译错误!

为什么这很重要?

  • 如果允许多个线程重新赋值 running 引用,会导致:

    • 不同线程看到不同的 AtomicBoolean 实例

    • 原来的原子状态丢失

    • 线程间失去同步基础 → 严重并发 bug

java 复制代码
public class UnsafeTask {
    // ❌ 没有 final,引用可变!
    private AtomicBoolean running = new AtomicBoolean(true);

    public void startWorker() {
        Thread t1 = new Thread(() -> {
            // t1 拿到当前 running 引用(指向 instance A)
            AtomicBoolean localRef = this.running;
            while (localRef.get()) {
                System.out.println("T1: running = " + localRef.get());
                try { Thread.sleep(1000); } catch (InterruptedException e) { break; }
            }
        });

        Thread t2 = new Thread(() -> {
            try { Thread.sleep(2000); } catch (InterruptedException e) {}
            // ⚠️ 危险操作:创建新实例,并赋值给 running!
            this.running = new AtomicBoolean(false); // ← 指向 instance B
            System.out.println("T2: 已替换 running 为新对象");
        });

        t1.start(); t2.start();
    }
}

❌ 结果:

  • t1 在启动时捕获了 this.running 的引用(假设叫 instance A)。

  • t2 后来执行 this.running = new AtomicBoolean(false),让 running 指向 instance B。

  • t1 仍然在检查 instance A 的值(它一直是 true!)。

  • t1 永远不会退出循环!死锁式 bug!

💥 这就是"不同线程看到不同 AtomicBoolean 实例"的真实场景!
🔑 final 保证所有线程操作的是同一个 AtomicBoolean 对象,这是线程安全的前提。

✅ 2. 表达设计意图:这是一个不可变的引用

  • final 向阅读代码的人(包括未来的你)明确传达:

    "这个字段在对象创建后就不会再变了,所有线程都共享同一个状态容器。"

  • 这符合 "不可变引用 + 可变状态" 的经典并发设计模式:

    • 引用不可变(final)→ 安全共享

    • 内部状态可变(AtomicBoolean)→ 支持并发更新

✅3. 避免低级错误

想象你不小心写了:

java 复制代码
private AtomicBoolean running = new AtomicBoolean(true);

public void reset() {
    running = new AtomicBoolean(true); // 重置?看似合理...
}

但问题来了:

  • 如果有线程正在使用旧的 running 对象,它们永远不会收到新对象的状态变化。

  • 旧对象可能还在被某些线程使用,导致逻辑混乱。

而如果 runningfinal,这种错误在编译期就被阻止了。

相关推荐
talenteddriver2 小时前
java: Java8以后hashmap扩容后根据高位确定元素新位置
java·算法·哈希算法
云泽8082 小时前
STL容器性能探秘:stack、queue、deque的实现与CPU缓存命中率优化
java·c++·缓存
APItesterCris2 小时前
高并发场景下的挑战:1688 商品 API 的流量控制、缓存策略与异步处理方案
大数据·开发语言·数据库·缓存
yyy(十一月限定版)2 小时前
c语言——栈和队列
java·开发语言·数据结构
feeday2 小时前
Python 删除重复图片 优化版
开发语言·python
本地运行没问题2 小时前
基于Java注解、反射与动态代理:打造简易ORM框架
java
.格子衫.2 小时前
JS原型链总结
开发语言·javascript·原型模式
ss2732 小时前
Java线程池全解:工作原理、参数调优
java·linux·python
麦麦鸡腿堡2 小时前
Java_MySQL介绍
java·开发语言·mysql