volatile、ReentrantLock和synchronized保证线程可见性原理

本文介绍了Java内存模型中的主存和工作内存概念,通过实例阐述了线程可见性问题及其原因,重点讲解了volatile、Synchronized和ReentrantLock如何保证可见性,以及它们的原理和用法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

主存、工作内存

        在了解什么是线程可见性前,我们先来简单了解下 Java内存模型主存工作内存抽象概念

主存:        存储的是一些共享资源的存储位置(例如静态变量等)

工作内存: 每个线程对应的栈内存对应的私有局部资源的存储位置

我们来分析一个小案例:

static boolean run = true;
 public static void main(String[] args) throws InterruptedException {
     Thread t = new Thread(()->{
         while(run){
             // 。。。
         }
     });
     t.start();

     sleep(1);
     run = false; // 线程t不会如预想的停下来
}

 为什么会有这种情况呢?让我们结合 Java内存模型 来分析下底层的原理:

1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
2. 因为 t 线程要频繁从主内存中读取 run 的值, JIT 编译器 会将 run 的值缓存至自己工作内存中的 高速缓存 中, 减少对主存中 run 的访问,提高效率
3. 1 秒之后, main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

内存屏障

内存屏障主要用于解决线程的可见性有序性问题

代码案例:


什么是线程可见性?

        内存可见性(memory visibility)——是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了状态后,其他线程能够立即看到发生的状态变化。

        由于线程之间的交互都发生在主存中,但对于变量的修改又发生在自己的工作内存中,经常会造成读写共享变量的错误,我们也叫可见性错误。

可见性错误

        指当读操作与写操作在不同的线程中执行时,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。


解决方案:

        针对前面的代码例子讲解,我们发现,之所以会导致可见性问题是因为一个线程在自己的工作内存中更新了该共享变量的副本,但是没有同步到主存中,而在另一个线程中也没有在主存中拉去最新的状态来进行刷新自己工作内存中的共享变量的副本,因此我们的解决方案就是围绕着 “修改了共享资源的线程要将自己的更改刷新到主存中,并且让该共享资源在其他线程的工作内存中失效,强制要求其拉去主存中的最新状态来实现同步”。对此,有以下三种主要的解决方式:volatile关键字ReentrantLockSynchronized

volatile关键字

因此,正对共享的资源,我们通过添加 volatile关键字 就可以确保线程间的一致性

volatile static boolean run = true;
 public static void main(String[] args) throws InterruptedException {
     Thread t = new Thread(()->{
         while(run){
             // 。。。
         }
     });
     t.start();

     sleep(1);
     run = false; // 线程t会如预想的停下来
}

volatile保证可见性的原理

        当JVM将.class文件编译为具体的CPU执行指令(也就是机器码)后,观察这些指令,我们会发现只要是加了 volatile修饰的共享变量 ,都会在指令前面加上一个以lock为前缀的指令,lock 前缀的指令会引发两件事情:

  1. 任何线程对其进行操作都是在主内存中进行的,不会产生副本,从而保证共享变量的可见性。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存(工作内存)失效,要拉取最新

        第一点的实现:lock信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。如果访问的内存区域没有缓存在处理器内部,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

       第二点的实现:IA-32 CPU和 Intel 64 CPU使用 MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性,避免在总线加lock锁。CPU使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。具体解决思路为:

  当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会重新从内存中读取这个变量


Synchronized

        针对可见性问题,我们也可以通过Synchronized来进行解决,代码如下:

// 易变
static boolean run = true;

// 锁对象
final static Object Lock = new Object();

public static void main(Stringl] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(true){
            // 。。。。
            synchronized (Lock) {
                if(!run) {break;}
            }
        }
    });
    t.start();
    
    sleep(1);
    log.debug("停止 t");
    synchronized (lock){
        run = false;
    } 
}

Synchronized保证可见性的原理

如果线程A要和线程B通讯(发送数据),需要经过两个步骤

        首先A需要将副本写到主内存中去,B再去主内存中读取数据,就可以读取到A更新过的共享变量了。这个过程是由我们JVM去控制的。主内存是A和B沟通的桥梁

        JVM正是通过控制主内存与每个线程的本地内存之间的交互来为我们java程序员提供内存可见性的保证。 

        在了解了java内存模型之后呢,我们来学习下Synchronized是如何做到可见性的实现的?

在释放锁之前一定会将数据写回主内存:

        一旦一个代码块或者方法被Synchronized所修饰,那么它执行完毕之后,被锁住的对象所做的任何修改都要在释放之前,从线程内存写回到主内存。也就是说他不会存在线程内存和主内存内容不一致的情况。

在获取锁之后一定从主内存中读取数据:

        同样的,线程在进入代码块得到锁之后,被锁定的对象的数据也是直接从主内存中读取出来的,由于上一个线程在释放的时候会把修改好的内容回写到主内存,所以线程从主内存中读取到数据一定是最新的
        就是通过这样的原理,Synchronized关键字保证了我们每一次的执行都是可靠的,它保证了可见性。


Reentrantlock

        synchronized 和 ReentrantLock(包括AQS的其他Lock),都能保证线程间的可见性,但实现方式有区别,以下是Reentrantlock保证线程可见性的示例代码:

public class Main {
    private static boolean run = true;
    private static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                lock.lock();
                try {
                    if (!run) {
                        break;
                    }
                    // 其他操作...
                } finally {
                    lock.unlock();
                }
            }
        });
        t.start();

        Thread.sleep(1);
        lock.lock();
        try {
            run = false;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock保证可见性的原理

        在 lock.lock() lock.unlock() 时,都会操作 AbstractQueuedSynchronizer类 中的一个变量 state,这个变量是 volatile 修饰的,volatile变量的语句对应的汇编码指令中会多加一行lock addl $0x0, (%esp),这一行的作用是:

(1)将工作内存修改了的缓存(不仅仅是该变量的缓存)都强制刷新回主内存

(2)把其他CPU对应缓存行标记为invalid状态,那么在读取这一部分缓存时,必须回主内存读取。这样也就保证了线程间的可见性

        具体来说,当一个线程获取ReentrantLock锁时,它会将自己工作内存中的数据刷新到主内存中,这样其他线程就能够看到最新的值。而当一个线程释放ReentrantLock锁时,它会将主内存中的数据刷新到自己的工作内存中,这样其他线程就能够读取到最新的值。

<think>好的,我现在需要回答用户关于“如果不用volatile,JVM怎么保证数据的可见性?”的问题。首先,我得回忆一下JMM(Java内存模型)相关的知识,特别是关于可见性的部分。用户可能已经知道volatile的作用,现在想了解其他方法。 用户之前的对话里讨论了equalshashCode的重写,现在转向并发问题,可能是在学习多线程编程。我需要先明确可见性的概念:即一个线程修改共享变量后,其他线程能立即看到修改后的值。而volatile通过内存屏障来保证可见性禁止指令重排序,但用户想知道不用volatile时如何实现。 首先,想到的是synchronized关键字。synchronized同步块或方法在进入时会获取锁,导致线程的工作内存失效,从而从主内存重新读取变量。退出时会将变量刷新回主内存,这样其他线程就能看到更新。所以,使用synchronized可以保证可见性。需要解释这个过程,并给出示例代码。 其次,Lock接口的实现类,比如ReentrantLock,在lockunlock时也会类似synchronized,有内存屏障的效果。这同样能保证可见性。可以对比synchronized,说明其机制类似,但需要显式调用lockunlock。 另外,原子类如AtomicInteger,它们内部使用volatile变量CAS操作,可能用户不直接使用volatile,但原子类间接依赖了volatile。不过,用户的问题可能允许这种情况,因为用户自己没显式用volatile,而是通过原子类的方法来保证。这点需要澄清,可能应该提到原子类,但指出其内部机制。 还有,final字段的可见性。当一个对象被正确构造后,final字段的初始化值对其他线程是可见的,这属于JMM的保证。不过这可能属于特殊情况,用户的问题可能更多指普通变量,所以可能作为补充点提及。 另外,线程的启动终止,比如Thread.start()Thread.join()也会带来内存可见性保证。例如,主线程启动子线程前修改的变量,子线程能看到;子线程结束后的状态变化,主线程调用join后也能看到。这也是JMM的规则,但用户可能不知道,需要解释。 然后需要考虑这些方法各自的适用场景,以及volatile的对比。比如synchronizedLock除了可见性还提供互斥,而volatile只解决可见性有序性,不保证原子性。所以在需要同步的时候,用锁更合适,而仅需要可见性时可能用volatile更轻量。 可能的误区是用户认为这些方法可以完全替代volatile,但实际上它们各自有不同的使用场景。例如,如果仅需要可见性而不需要锁,那么使用volatile更高效。但用户的问题是不使用volatile的情况下如何保证,所以需要列举其他方法。 总结下来,回答的结构应该是先说明可见性的重要性,然后分点讲解synchronized、Lock、原子类、final、线程操作等方法,并分别举例说明。同时比较它们的异同,强调正确同步的重要性,以及为什么在某些情况下必须用volatile或其他机制。 需要确保用词准确,比如“happens-before”原则,JMM的规定等,但用中文解释清楚。例如,synchronized块的进入退出会建立happens-before关系,从而保证变量的可见性。同样,Lock的加锁解锁也有类似的效果。 另外,用户可能的疑问是为什么这些方法可以替代volatile,所以需要解释它们的内存语义,比如锁的获取释放会触发内存屏障,强制刷新工作内存到主存,从而让其他线程可见。而volatile则是每次访问都直接从主存读写,但锁的机制在进入退出时做同样的事情,只是粒度更大。 最后,提醒用户如果没有正确同步(即不使用任何机制),可能会导致可见性问题,如脏读等,所以正确选择同步机制很重要。可能还需要提到其他机制如ThreadLocal,但ThreadLocal是避免共享,所以不适用此问题。 检查是否有遗漏的方法,比如volatile变量的写操作对其他线程的读可见,而锁的释放获取同样有这种效果。所以,虽然问题是不用volatile,但其他同步手段实际上也达到了同样的可见性保证,只是方式不同。 现在需要组织语言,用清晰的结构分点回答,每个方法给出简要说明示例,帮助用户理解不同场景下的解决方案。</think>在 Java 中,如果不使用 `volatile`,可以通过以下方法保证数据的可见性。这些方法均基于 **Java 内存模型(JMM)** 的规则 **happens-before 原则**,确保多线程间的数据同步。 --- ### 1. **使用 `synchronized` 同步块** **机制**: `JVM` 规定,线程在进入 `synchronized` 块时会清空本地内存(工作内存),从主内存重新加载共享变量;退出 `synchronized` 块时会将修改后的变量刷新回主内存。 **示例**: ```java public class Counter { private int count = 0; public synchronized void increment() { count++; // 线程安全的操作 } public synchronized int getCount() { return count; // 保证读取最新值 } } ``` **特点**: - 通过锁的获取与释放实现可见性原子性。 - 适用于需要同时保证可见性原子性的场景。 --- ### 2. **使用显式锁(`Lock` 接口)** **机制**: 显式锁(如 `ReentrantLock`)的 `lock()` `unlock()` 操作与 `synchronized` 类似,会触发内存屏障,强制将工作内存的修改刷新到主内存,并清空其他线程的本地内存。 **示例**: ```java public class Counter { private int count = 0; private final Lock lock = new ReentrantLock(); public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } } ``` **特点**: - 更灵活的锁机制,但需手动管理锁的释放。 - 同样保证可见性原子性。 --- ### 3. **使用原子类(`Atomic` 系列)** **机制**: 原子类(如 `AtomicInteger`)通过底层 `CAS(Compare-And-Swap)` 操作 `volatile` 变量实现可见性与原子性。虽然用户未显式使用 `volatile`,但原子类内部依赖了 `volatile` 的语义。 **示例**: ```java public class Counter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 线程安全且可见 } } ``` **特点**: - 仅需关注原子操作,无需手动同步。 - 适用于简单变量(如计数器)。 --- ### 4. **通过线程启动与终止的规则** **机制**: JMM 规定以下操作会保证可见性: - **线程启动**:`Thread.start()` 前的修改对子线程可见。 - **线程终止**:子线程结束后的修改对调用 `Thread.join()` 的线程可见。 **示例**: ```java public class VisibilityExample { private int value = 0; public static void main(String[] args) throws InterruptedException { VisibilityExample example = new VisibilityExample(); Thread thread = new Thread(() -> { System.out.println(example.value); // 一定能看到主线程修改后的 value }); example.value = 42; thread.start(); thread.join(); } } ``` --- ### 5. **`final` 关键字** **机制**: `final` 字段的初始化值在对象正确构造后(即构造函数结束前),对其他线程是可见的。这是 JMM 对 `final` 的强制保证。 **示例**: ```java public class FinalExample { private final int finalValue; // 构造完成后,finalValue 对所有线程可见 public FinalExample(int value) { this.finalValue = value; } } ``` --- ### 总结 | 方法 | 适用场景 | 可见性保证机制 | |---------------------|----------------------------|----------------------------------| | `synchronized` | 需要同步的代码块/方法 | 锁的获取与释放触发内存屏障 | | 显式锁(`Lock`) | 需要灵活控制的同步 | `lock()`/`unlock()` 触发内存屏障 | | 原子类(`Atomic`) | 简单变量的原子操作 | 内部 `volatile` + CAS | | 线程启动/终止 | 主线程与子线程间的数据传递 | JMM 的 happens-before 规则 | | `final` 关键字 | 不可变对象的初始化 | JMM 对 `final` 的特殊保证 | **注意事项**: - 若未使用上述任何机制,多线程间可能出现 **脏读**(读取到过期的数据)或 **可见性问题**。 - `volatile` 的轻量级特性使其适用于仅需可见性(无需原子性)的场景(如标志位),而其他方法通常需要配合锁或原子操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学徒630

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值