Java多线程初阶-线程安全与同步关键字

Java多线程初阶:深入线程安全与同步关键字

文章简述
大家好!在上一篇《Java多线程初阶:从核心概念到线程操作》中,我们一起了解了线程的基本概念和操作。现在,我们将进入多线程编程中最核心、最重要的:线程安全。这篇笔记会深入探讨线程不安全问题的根源,并详细学习Java提供的两个关键武器——synchronizedvolatile——是如何帮助我们驯服并发猛兽,编写出健壮、可靠的多线程程序的。

1. 多线程的潜在风险:线程安全问题

当我们踏入多线程的世界,享受并发带来的效率提升时,也必须警惕其伴随而来的风险。其中,线程安全问题是每一位开发者都必须面对和解决的核心挑战。

1.1 一个线程不安全的实例

让我们从一个直观的例子开始。下面的代码创建了两个线程,它们共同对一个共享的计数器 counter 进行累加操作,每个线程各执行 50,000 次 increase 方法。

public class UnsafeThread {
    static class Counter {
        public int count = 0;
        void increase() {
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        
        // 等待两个线程都执行完毕
        t1.join();
        t2.join();
        
        // 理论上,最终结果应该是 100000
        System.out.println(counter.count); // 但实际运行结果往往小于 100000
    }
}

尝试多次运行这段代码,会发现最终打印出的 counter.count 值几乎每次都不同,而且总是小于预期的 100,000。这个现象就是典型的线程不安全。那么,问题究竟出在哪里呢?

1.2 理解线程安全

在深入探究原因之前,先给“线程安全”一个清晰的定义。虽然学术上的定义可能很复杂,但可以这样通俗地理解:

如果一段代码在多线程环境中执行的结果,与预期它在单线程环境中顺序执行的结果完全一致,那么就可以称这段代码是线程安全的。

反之,如果执行结果出现了偏差,就说明存在线程安全问题。

1.3 探究线程不安全的根源

线程安全问题通常由三个核心因素共同导致:原子性可见性有序性。在我们的例子中,这几个因素都有体现。

1.3.1 前提:存在修改共享数据

首先,线程不安全问题只会在“多个线程修改同一个共享变量”的场景下才会发生。在上面的例子中,counter 对象被 t1t2 两个线程共享,而 counter.count 变量被两个线程同时进行修改操作。

在这里插入图片描述

小思考:数据为何能被共享?

new Counter() 创建的对象实例是存放在**堆内存(Heap)**中的。堆是所有线程共享的内存区域。因此,t1t2 两个线程都可以通过 counter 这个引用访问到堆上的同一个 Counter 实例,从而修改其中的 count 字段。

在这里插入图片描述

如果多个线程只是读取共享数据,或者各自修改的是不同的数据,通常是不会产生线程安全问题的。

1.3.2 问题一:操作不具备原子性

原子性(Atomicity) 指的是一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。它就像一个不可分割的整体。

我们可以把一段需要保证原子性的代码想象成一个房间。正常情况下,一个线程(人)进入房间后,应该能不被打扰地做完自己的事再出来。但如果没有加锁机制,当线程 A 正在房间里做事时,线程 B 可能随时会闯进来,打断 A 的操作。这就破坏了原子性。

为了保证原子性,需要给房间加上一把锁。当一个线程进去后,立刻锁上门,其他线程就只能在门外等待,直到里面的线程出来并解锁。这种机制,通常称为“同步互斥”。

回到 count++ 操作。这行代码看起来简单,但在 CPU 层面,它并非一个单一指令,而是通常由三个独立的步骤组成:

  1. Load: 从内存中读取 count 的当前值到 CPU 的寄存器中。
  2. Add: 在寄存器中对这个值执行 +1 操作。
  3. Save: 将寄存器中计算出的新值写回到内存中。

思路分析:count++ 的执行过程

我们可以将这三条指令的执行过程想象成这样:

  1. load 指令:将内存中的 count 值加载到 CPU 寄存器。
  2. add 指令:将寄存器中的值加 1。
  3. save 指令:将寄存器中的新值写回内存。

由于操作系统的抢占式调度,线程的切换可能发生在任何一条指令执行完毕之后。

假设 count 初始值为 0,t1t2 两个线程并发执行 count++

  • t1 执行 load,读取到 count 为 0。
  • t1 执行 add,寄存器中的值变为 1。
  • 此时发生线程切换! t2 开始执行。
  • t2 执行 load,由于 t1 还没来得及 savet2 从内存中读取到的 count 仍然是 0
  • t2 执行 add,其寄存器中的值也变为 1。
  • t2 执行 save,将 1 写回内存。此时 count 变为 1。
  • 线程切换回来! t1 继续执行。
  • t1 执行 save,将它自己寄存器中的值 1 写回内存。count 最终还是 1。

可以看到,虽然两个线程都执行了 count++,但结果只增加了 1,而不是期望的 2。这就是因为 count++ 这个操作不具备原子性,在执行过程中被中断,导致了数据错误。

关于原子性的小结

  • 如果一个操作不具备原子性,在多线程环境下就可能因为执行中途被切换而导致意想不到的结果。
  • 在 Java 中,像 = 这样的简单赋值操作通常是原子的(对于 longdouble 的非 volatile 变量除外)。但复合操作如 count++ 则不是。
  • 解决原子性问题的核心思路就是加锁,确保同一时间只有一个线程能执行这段代码。
1.3.3 问题二:内存可见性

可见性(Visibility) 指的是当一个线程修改了共享变量的值,这个新值能够被其他线程立即得知。如果不能,就说明存在可见性问题。

为了理解可见性问题,需要先了解一下 Java 内存模型(Java Memory Model, JMM)。JMM 是 Java 虚拟机规范中定义的一套标准,它屏蔽了底层不同硬件和操作系统的内存访问差异,旨在让 Java 程序在各种平台上都能表现出一致的并发行为。

在这里插入图片描述

JMM 规定:

  • 所有线程共享的变量都存储在**主内存(Main Memory)**中。
  • 每个线程都有自己独立的工作内存(Working Memory)
  • 当线程需要操作一个共享变量时,它会先将该变量从主内存拷贝一份副本到自己的工作内存中,然后对这个副本进行操作。
  • 操作完成后,再将修改后的副本值同步回主内存

这种机制会导致一个问题:当线程 A 修改了自己工作内存中的变量副本后,如果还没来得及同步回主内存,线程 B 是看不到这个变化的。线程 B 的工作内存中仍然是旧的、未被修改过的值。

  1. 初始状态下,主内存与两个线程的工作内存中,变量 a 的值都是一致的。
    在这里插入图片描述

  2. 当线程 1 修改了 a 的值,它的工作内存中的副本被更新了。但此时,这个更新可能没有被立刻写回主内存,因此线程 2 的工作内存中的值依然是旧的。
    在这里插入图片描述

这就产生了可见性问题,线程 2 基于一个过时的数据进行操作,很可能导致错误。

追根溯源:为什么需要如此复杂的 JMM?

看到这里,可能会产生两个疑问:为什么要有这么复杂的内存模型?为什么数据要这样拷贝来拷贝去?

  1. JMM 并非真实存在的内存划分
    需要澄清一个概念:JMM 中的“主内存”和“工作内存”是抽象概念,并非指物理上真的有这么多块内存。实际上,“主内存”主要对应我们计算机的物理内存(RAM),而“工作内存”则对应了 CPU 的高速缓存(Cache)寄存器(Register)

  2. 拷贝操作是为了极致的性能
    这么做的根本原因在于 CPU 的运算速度和内存的读写速度之间存在着巨大的鸿沟。CPU 访问寄存器和高速缓存的速度,要比访问主内存快上成千上万倍。

    如果一个变量需要被连续读取 10 次,每次都从主内存读取会非常慢。而 JMM 的机制允许 CPU 第一次从主内存读取后,将数据缓存到自己的高速缓存中,后续 9 次直接从缓存读取,效率将得到极大的提升。

    那么,既然高速缓存这么快,为什么不全部用它来做内存呢?答案很简单:成本。存储设备的规律通常是:速度越快,单位容量的价格就越昂贵。

在这里插入图片描述

值得一提的是,快慢都是相对的。CPU 访问寄存器的速度远快于内存,而内存的访问速度又远快于硬盘。与此对应,CPU 的价格最贵,内存次之,硬盘最便宜。

因此,JMM 的设计正是在追求极致性能与硬件成本之间做出的权衡。但这种优化也带来了副作用,那就是可见性问题。
1.3.4 问题三:指令重排序

有序性(Ordering) 指的是程序代码在执行时的顺序,与我们编写它时所期望的顺序一致。但在现代计算机体系中,为了提升性能,编译器和处理器有时会对指令进行重排序(Reordering)

来看一个生活中的例子,假设有三个步骤:

  1. 去前台取 U 盘。
  2. 回座位写 10 分钟作业。
  3. 去前台取快递。

在单人(单线程)场景下,为了提高效率,很自然地会优化执行顺序为 1 -> 3 -> 2,这样只需要跑一趟前台。这种优化在不改变最终结果的前提下是完全合理的。

编译器和 CPU 也会做类似的优化。然而,这种优化的前提是“不改变单线程环境下的程序语义”。在多线程环境中,情况就变得复杂了。编译器和 CPU 很难在编译期或运行时准确预测多线程交互的复杂情况,一个看似无害的重排序,可能就会在并发场景下引发致命的逻辑错误。

重排序是一个涉及编译器优化和 CPU 底层执行原理的复杂话题,此处仅作概念性了解,知道它也是导致线程不安全的一个因素即可。

1.4 如何解决线程不安全问题

了解了问题的根源后,就可以对症下药了。让我们回到最初的 UnsafeThread 例子,看看如何修复它。这里先展示一种解决方案,其背后的原理将在下一节详细解释。

只需要给 increase 方法加上 synchronized 关键字,就能解决问题。

public class SafeThread {
    static class Counter {
        public int count = 0;
        // 使用 synchronized 关键字修饰方法
        synchronized void increase() {
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count); // 结果稳定为 100000
    }
}

再次运行代码,会发现无论执行多少次,最终的结果都稳定地输出为 100,000。这说明 synchronized 关键字有效地保证了线程安全。接下来,将深入学习它的工作机制。

2. synchronized:内置的监视器锁

在解决了线程不安全问题的 SafeThread 示例中,仅仅通过添加一个 synchronized 关键字,就让程序的行为恢复了正常。这个关键字是 Java 内置的、用于解决并发问题最核心的工具之一。它也被称为监视器锁(Monitor Lock)

2.1 synchronized 的核心特性

synchronized 关键字之所以能保证线程安全,是因为它同时具备了我们之前提到的三大特性:原子性、可见性和有序性。

1. 互斥性:保证操作的原子性

synchronized 的首要作用是提供互斥(Mutual Exclusion)。当一个线程进入由 synchronized 修饰的代码块或方法时,它会尝试获取一个与特定对象关联的锁。

  • 加锁:成功获取锁的线程可以执行同步代码块内的逻辑。
  • 阻塞:此时,如果其他线程也尝试进入同一个对象synchronized 区域,它们将会被阻塞(Block),进入等待状态,直到锁被释放。
  • 解锁:当持有锁的线程执行完毕并退出同步代码块时,它会自动释放锁。

在这里插入图片描述

小思考:锁存在哪里?

synchronized 使用的锁并非凭空产生,它实际上是存储在每个 Java 对象头(Object Header) 中的一部分信息。我们可以粗略地将其理解为,每个对象都自带一个“锁定”状态标记(类似于洗手间的“有人/无人”指示牌)。

  • 当状态为“无人”时,线程可以获取锁,并将状态置为“有人”。
  • 当状态为“有人”时,其他线程只能在门外排队等待。

在这里插入图片描述

小分享:正确使用 synchronized 的关键

要想真正解决线程安全问题,仅仅写下 synchronized 是不够的,必须确保:

  1. 同步代码块范围要合适:加锁的范围需要完整地包裹所有对共享资源进行修改的操作。范围太小,可能漏掉某些操作导致线程不安全;范围太大,则会影响程序性能。
  2. 锁定的对象要合适:多个线程必须竞争同一把锁,互斥效果才会生效。如果不同线程锁定了不同的对象,它们之间将不会产生竞争,也就无法保证线程安全。

在这里插入图片描述
聊聊“非公平锁”:为什么 synchronized 不讲究“先来后到”?

针对每一把锁,操作系统内部都会维护一个等待队列。当一个线程持有锁时,其他尝试获取该锁的线程就会进入这个队列中阻塞等待。

当锁被释放时,操作系统会从等待队列中唤醒一个线程来获取锁。但这里有一个重要的特性:synchronized 是一种非公平锁(Non-fair Lock)。这意味着,等待队列中的线程被唤醒的顺序,并不严格遵循“先来后到”的原则。一个后来的线程完全有可能比一个等待已久的线程更早地获取到锁。

synchronized 的底层通常是基于操作系统的 mutex lock(互斥锁)原语来实现的。

2. 内存刷新:保证内存的可见性

除了保证原子性,synchronized 还能确保内存可见性。JMM 保证了 synchronized 的完整工作流程如下:

  1. 加锁:线程获得互斥锁。
  2. 清空工作内存:清空当前线程工作内存中关于共享变量的副本。
  3. 从主内存加载:从主内存中拷贝共享变量的最新版本到工作内存中。
  4. 执行代码:在工作内存中执行同步代码块的逻辑。
  5. 刷新到主内存:将修改后共享变量的值从工作内存刷新回主内存。
  6. 解锁:释放互斥锁。

这个过程确保了,一旦一个线程在 synchronized 块内修改了数据,这个修改对于后续任何一个获取到同一个锁的线程来说,都是可见的。

3. 可重入性:避免死锁

synchronized 是一种可重入锁(Reentrant Lock)。这意味着,一个已经持有锁的线程,可以再次成功获取同一个锁,而不会被自己阻塞。

一个有趣的场景:“把自己锁死”

想象一下,如果一个锁是不可重入的,会发生什么?

// 假设 lock() 是一个不可重入的锁
// 第一次加锁,成功
lock();
// 第二次尝试加锁,发现锁已被占用(被自己占用),于是进入阻塞等待...
lock();

在这里插入图片描述

线程会因为等待一个永远不会被释放的锁(因为只有它自己能释放,但它已经阻塞了)而陷入永久的等待,这就是一种死锁

在实际开发中,方法之间的调用层次可能很深,很容易在无意中形成嵌套加锁的场景。例如:

class Counter {
    // add() 方法对 this 加锁
    public synchronized void add() {
        // ...
    }
}

public void someMethod() {
    Counter counter = new Counter();
    // 外部代码也对 counter 对象加锁
    synchronized (counter) {
        // 在持有锁的情况下,调用另一个同样需要该锁的方法
        counter.add(); // 如果 synchronized 不是可重入的,这里就会发生死锁
    }
}

在这里插入图片描述

在这里插入图片描述

幸运的是,Java 中的 synchronized 是可重入的,因此我们不必担心上述问题。

小分享:可重入锁的实现原理

可重入锁的内部通常维护着两个关键信息:当前持有锁的线程一个计数器

  • 当一个线程尝试加锁时,如果发现锁已被占用,它会检查持有者是否是自己。
  • 如果是自己,那么它依然可以成功获取锁,并将计数器加 1。
  • 每次解锁时,计数器会减 1。
  • 只有当计数器减到 0 时,锁才会被真正释放,其他线程才有机会获取它。
4. 死锁:多线程编程的噩梦

虽然 synchronized 的可重入性避免了“自己锁死自己”的问题,但在涉及多把锁的场景下,仍然可能遭遇最经典的并发问题——死锁(Deadlock)

一个生动的死锁比喻:厨房里的僵局

死锁听起来很吓人,但我们可以用一个厨房里的场景来轻松理解它。想象一下,你和一位朋友要在厨房里合作做两道菜:红烧肉和酱油炒饭。

  • 资源:厨房里只有一口(锁A)和一瓶酱油(锁B)。
  • 任务:你需要先用锅,再用酱油;而你的朋友需要先用酱油,再用锅。

现在,僵局开始了:

  1. 你手速飞快,先把拿到了手里(持有了锁A)。
  2. 几乎在同一时间,你的朋友也启动了,先把酱油拿到了手里(持有了锁B)。
  3. 接下来,你准备炒肉,发现酱油在朋友那里,于是你只能伸着手等待他用完。
  4. 而你的朋友准备炒饭,也发现锅在你手里,于是他也只能等着你用完。

此时,你占着锅,等着朋友的酱油;朋友占着酱油,等着你的锅。你们俩都持有对方需要的资源,又都在等待对方先放手,谁也不肯让步。这个互相等待的循环,就是死锁。最终的结果就是,两道菜都做不成,大家一起饿肚子。

一个经典的死锁示例:

public static void main(String[] args) throws InterruptedException {
    Object locker1 = new Object();
    Object locker2 = new Object();

    Thread t1 = new Thread(() -> {
        synchronized (locker1) { // t1 获取 locker1
            System.out.println("t1 持有 locker1,尝试获取 locker2...");
            try {
                Thread.sleep(1000); // 确保 t2 有机会获取 locker2
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (locker2) { // 尝试获取 locker2,但此时 locker2 可能已被 t2 持有
                System.out.println("t1 成功获取两把锁");
            }
        }
    });

    Thread t2 = new Thread(() -> {
        synchronized (locker2) { // t2 获取 locker2
            System.out.println("t2 持有 locker2,尝试获取 locker1...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (locker1) { // 尝试获取 locker1,但此时 locker1 已被 t1 持有
                System.out.println("t2 成功获取两把锁");
            }
        }
    });

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

在这个例子中,t1 持有 locker1 等待 locker2,而 t2 持有 locker2 等待 locker1,双方僵持不下,程序将永远挂起。

可以使用 jconsole 等工具来检测和分析死锁。

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

小思考:去掉 sleep 会怎样?

如果去掉代码中的 sleep,死锁还会发生吗?答案是:不一定,但可能性依然存在。这完全取决于操作系统的线程调度。sleep 的作用是人为地创造一个时间窗口,让死锁的条件更容易稳定复现。如果没有 sleep,很可能 t1 会在 t2 启动前就连续获取了两把锁,从而避免了死锁。但绝不能依赖这种不确定性来编写代码。

小分享:构成死锁的四个必要条件(高频面试题)

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源,在未使用完之前,不能被强行剥夺。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

要避免死锁,必须破坏上述四个条件中的至少一个。最常见的做法是破坏“请求与保持”或“循环等待”条件,例如:

  • 一次性申请所有需要的资源。
  • 规定所有线程都按相同的顺序来获取锁。

2.2 synchronized 的使用方式

synchronized 关键字可以灵活地应用在不同位置,但其本质始终是锁定一个对象

1. 修饰实例方法
synchronized 修饰一个普通的实例方法时,它锁定的对象是当前实例对象 this

public class SynchronizedDemo {
    // 等价于 synchronized(this) { ... }
    public synchronized void method() {
        // 同步代码
    }
}

2. 修饰静态方法
synchronized 修饰一个静态方法时,它锁定的对象是当前类的 Class 对象(例如 SynchronizedDemo.class)。因为静态方法不与任何实例关联,所以只能锁定类对象。

public class SynchronizedDemo {
    // 等价于 synchronized(SynchronizedDemo.class) { ... }
    public synchronized static void method() {
        // 同步代码
    }
}

3. 修饰代码块
这是最灵活的方式,可以明确指定要锁定的对象。

  • 锁定当前实例 this
    public class SynchronizedDemo {
        public void method() {
            synchronized (this) {
                // 同步代码
            }
        }
    }
    
  • 锁定类对象
    public class SynchronizedDemo {
        public void method() {
            synchronized (SynchronizedDemo.class) {
                // 同步代码
            }
        }
    }
    
  • 锁定任意指定对象
    public class SynchronizedDemo {
        private final Object lock = new Object();
        public void method() {
            synchronized (lock) {
                // 同步代码
            }
        }
    }
    

核心原则回顾

无论使用哪种方式,都必须牢记:只有当多个线程竞争同一把锁时,才会发生阻塞和同步。如果它们各自获取了不同的锁对象,那么 synchronized 将形同虚设。

在这里插入图片描述

例如,在下面的错误示例中,每个线程都锁定了自己的 Thread 对象(cur),而不是共享的资源,因此无法解决线程安全问题。

// 错误示例:锁定了错误的对象
public class Demo16 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
           Thread cur = Thread.currentThread();
            for (int i = 0; i < 50000; i++) {
                synchronized (cur) { // 锁的是 t1 对象
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            Thread cur = Thread.currentThread();
            for (int i = 0; i < 50000; i++) {
                synchronized (cur) { // 锁的是 t2 对象
                    count++;
                }
            }
        });
        // ...
    }
}

正确的做法是让它们锁定一个共享的对象,例如 Demo16.class 或者一个专门的 lock 对象。

2.3 Java 标准库中的线程安全类

在 Java 标准库中,许多常用的集合类默认都是线程不安全的,因为它们在设计时优先考虑了单线程环境下的性能,没有内置任何加锁措施。

  • ArrayList, LinkedList
  • HashMap, TreeMap
  • HashSet, TreeSet
  • StringBuilder

但标准库也提供了一些线程安全的替代品或专门为并发设计的类:

  • Vector, Hashtable:这些是早期的线程安全集合,它们的几乎每个方法都由 synchronized 修饰。但由于锁的粒度太大(锁整个集合),性能较差,现在已不推荐使用
  • StringBuffer:与 StringBuilder 功能类似,但其核心方法是同步的,因此是线程安全的。
  • ConcurrentHashMapjava.util.concurrent 包下的明星类,是 Hashtable 的高度优化版本,通过更精细的锁技术(如分段锁、CAS)大幅提升了并发性能。
  • String:虽然不加锁,但由于其不可变性(Immutability),它天然就是线程安全的。

性能的权衡

需要认识到,加锁是有性能代价的。锁的竞争可能导致线程阻塞,从而降低程序的执行效率。因此,在没有并发需求时,应优先选择非线程安全的类(如 ArrayList, StringBuilder)以获得更好的性能。只有在确实存在多线程共享数据的情况下,才需要考虑使用线程安全的类或手动加锁。

3. volatile:轻量级的同步利器

synchronized 提供了重量级的、全面的并发解决方案之后,再来认识一个更轻量级的关键字:volatile。它专注于解决一个特定的问题——内存可见性

3.1 volatile 如何保证内存可见性

来看一个经典的可见性问题场景。下面的代码中启动了两个线程:

  • t1 线程在一个循环中持续检查共享变量 counter.flag 的值,只有当 flag 不为 0 时才会退出。
  • t2 线程负责接收用户的输入,并将输入的值赋给 flag

按照直觉,当在 t2 中输入一个非零整数后,t1 的循环应该会立即结束。但实际情况可能并非如此。

import java.util.Scanner;

public class VolatileVisibility {
    static class Counter {
        // 未使用 volatile 修饰
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            // 为了极致的性能,t1 线程可能会将 counter.flag 的值缓存起来
            while (counter.flag == 0) {
                // 这个循环体是空的,执行速度极快
            }
            System.out.println("循环结束!");
        });
        
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            // t2 修改了 flag,但这个修改可能只存在于 t2 的工作内存中
            counter.flag = scanner.nextInt();
        });
        
        t1.start();
        t2.start();
    }
}

当我们运行这段代码并输入一个非零值后,大概率会发现 t1 线程的循环并不会结束

思路分析:问题出在哪里?

这正是 Java 内存模型(JMM)中“工作内存”导致的可见性问题。

  1. 性能优化引发的问题t1 线程中的 while (counter.flag == 0) 循环执行得非常快。为了追求极致的性能,JIT(即时)编译器会进行优化。它发现循环体内没有修改 flag 的操作,就可能做出一个“聪明”的决定:将 flag 的值从主内存读一次,然后缓存到自己的高速工作内存(甚至直接缓存到 CPU 寄存器)中。之后,t1 的每次循环都直接从这个高速缓存中读取 flag 的值,而不再访问主内存。
  2. 数据不一致:当 t2 线程修改 flag 的值时,它首先更新的是自己工作内存中的副本,然后才会(在某个不确定的时间点)将新值写回主内存。

结果就是,t1 一直在读自己的旧缓存,对 t2 在主内存中的更新一无所知,从而陷入了死循环。

小分享:为什么加入 Thread.sleep(1) 似乎能“解决”问题?

有时会发现,如果在 while 循环中加入一行 Thread.sleep(1),程序似乎又能正常工作了。这并不是因为 sleep 有什么神奇的魔力。sleep 方法会引发线程上下文切换,这给了操作系统一个机会去处理其他任务,其中就可能包括将工作内存的数据同步回主内存。

但这是一种非常不可靠的、带有偶然性的行为。绝对不能依赖 sleep 来解决内存可见性问题。

要从根本上解决这个问题,就需要用到 volatile 关键字。它就像一个“圣旨”,向编译器和 CPU 发出明确指令:

  • 写操作:当一个线程修改 volatile 变量时,JMM 会强制将这个修改后的值立即刷新回主内存。
  • 读操作:当一个线程读取 volatile 变量时,JMM 会强制它每次都从主内存中获取最新值,使工作内存中的缓存失效。

在这里插入图片描述

通过这种方式,volatile 牺牲了一部分缓存带来的性能优势,换取了多线程之间数据的强一致性。

只需做如下修改:

static class Counter {
    // 使用 volatile 关键字修饰
    public volatile int flag = 0;
}

再次运行程序,无论何时输入非零值,t1 线程都能立即感知到变化并结束循环。

3.2 volatile 无法保证原子性

volatile 虽然强大,但它并非万能。它只能保证可见性(和一定程度的有序性),却无法保证原子性

让我们回到最初那个经典的计数器例子。这次,去掉 synchronized,并给 count 变量加上 volatile

public class VolatileAtomicity {
    static class Counter {
        // volatile 保证了 count 的修改对其他线程立即可见
        public volatile int count = 0;
        
        // 但 count++ 这个操作本身不是原子的
        void increase() {
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        
        // 结果仍然不确定,大概率小于 100000
        System.out.println(counter.count);
    }
}

运行后会发现,结果依然是错误的。volatile 确实保证了每次 t1 修改 count 后,t2 都能立刻看到最新值。但它无法阻止 count++ 这个非原子操作(读-改-写)被中途打断。当两个线程同时读取到相同的值(例如 99),然后各自加一,最后都把 100 写回内存时,数据丢失的问题依然会发生。

3.3 synchronized 与 volatile 的对比

synchronized 作为一个“全能型选手”,它在保证原子性的同时,也提供了强大的内存可见性保证。

JMM 规定,在对一个对象**解锁(exit monitor)之前,线程必须把其工作内存中修改过的共享变量同步到主内存中。而在加锁(enter monitor)**时,会清空工作内存,强制从主内存重新加载共享变量的最新值。

因此,只要多个线程锁定的是同一个对象,synchronized 就能确保线程间的可见性。

synchronized 来改写最初的可见性示例:

import java.util.Scanner;

public class SynchronizedVisibility {
    static class Counter {
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        
        Thread t1 = new Thread(() -> {
            while (true) {
                // t1 在循环中不断地加锁、检查、解锁
                synchronized (counter) {
                    if (counter.flag != 0) {
                        break;
                    }
                }
            }
            System.out.println("循环结束!");
        });
        
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个整数:");
            // t2 在修改前也必须获取同一把锁
            synchronized (counter) {
                counter.flag = scanner.nextInt();
            }
            // 解锁时,flag 的修改会被刷新到主内存
        });
        
        t1.start();
        t2.start();
    }
}

总结一下 synchronizedvolatile 的关键区别:

特性volatilesynchronized
原子性❌ 不保证✅ 保证
可见性✅ 保证✅ 保证
有序性✅ 保证(禁止指令重排)✅ 保证
使用方式修饰变量修饰代码块、方法
性能开销较小(不涉及线程阻塞)较大(可能引起线程阻塞和上下文切换)
本质轻量级同步机制,JVM层面实现重量级锁,依赖操作系统互斥量

如何选择?

  • 当只需要保证共享变量的可见性时,volatile 是更轻量、更高效的选择。
  • 当需要保证一个代码块的原子性(通常是“读-改-写”这类复合操作)时,必须使用 synchronized
  • synchronized 可以完全替代 volatile 的功能,但反之不行。

本章小结 (Key Takeaways)

  • 理解线程安全:线程安全的核心,是保证多线程环境下的运行结果与单线程顺序执行的结果完全一致。任何偏离预期的行为,都标志着线程安全问题的存在。

  • 线程不安全的根源:问题的出现并非偶然,它通常由三个因素共同导致:

    1. 前提:多个线程对 共享数据 进行了 修改 操作。
    2. 直接原因:代码操作(如 count++)不具备 原子性,执行过程可能被中断。
    3. 深层原因:JMM 导致的 内存可见性 问题,以及编译器/CPU 的 指令重排序
  • 核心解决方案 synchronized:这是一个“重量级”但功能全面的内置锁。它通过 互斥 来保证代码块的 原子性,同时通过刷新内存的机制确保了 可见性,能一站式解决大部分线程安全问题。

  • 轻量级武器 volatile:它专注于解决 内存可见性 问题,确保一个线程对变量的修改能被其他线程立刻看到。但请务必牢记,volatile 无法保证原子性,不能用于 count++ 这类复合操作。
    需要保证共享变量的可见性时,volatile 是更轻量、更高效的选择。

  • 当需要保证一个代码块的原子性(通常是“读-改-写”这类复合操作)时,必须使用 synchronized
  • synchronized 可以完全替代 volatile 的功能,但反之不行。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值