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

主存、工作内存

        在了解什么是线程可见性前,我们先来简单了解下 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锁时,它会将主内存中的数据刷新到自己的工作内存中,这样其他线程就能够读取到最新的值。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
好的,我知道了。首先我需要解释一下 synchronized 关键字、volatileReentrantLock 类与阻塞队列 LinkedBlockingQueue<E> 的概念。 synchronized 关键字是 Java 中用于实现同步的关键字,可以用来修饰代码块、方法和静态方法,用于实现线程间的同步。 volatile 是一个关键字,用于修饰变量,用于实现多线程之间的可见,即当一个线程修改了一个 volatile 变量的值时,其他线程可以立即看到这个修改。 ReentrantLock 类是一个可重入的互斥锁,可以用来实现线程间的同步,与 synchronized 关键字相比,ReentrantLock 提供了更多的功能,如公平锁和可中断锁等。 阻塞队列 LinkedBlockingQueue<E> 是一个基于链表实现的阻塞队列,在多线程编程中可以用来实现线程间的同步。 接下来,我将使用这些同步方法实现主线程与子线程的同步,并写出实验小结。 代码如下: ``` import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.locks.ReentrantLock; public class Main { private volatile boolean flag = false; private final ReentrantLock lock = new ReentrantLock(); private final LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<>(); public static void main(String[] args) { Main main = new Main(); main.start(); } private void start() { Thread t = new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } flag = true; lock.lock(); try { queue.put(1); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } }); t.start(); lock.lock(); try { while (!flag) { Thread.sleep(100); } System.out.println(queue.take()); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } } ``` 在这个例子中,主线程和子线程都需要修改和访问共享变量 flag,因此需要使用 volatile 关键字来实现可见。 主线程需要等待子线程修改 flag 后才能继续执行,因此可以使用 while 循环不断检查 flag 是否被修改,这里使用了 Thread.sleep(100) 来减少 CPU 的占用率。 子线程需要将一个元素加入到队列中,并通知主线程可以继续执行,因此可以使用 ReentrantLock 和 LinkedBlockingQueue 来实现线程间的同步。 实验小结: 在多线程编程中,同步是一个非常重要的概念,可以用来避免线程间的竞争和冲突。在 Java 中,可以使用 synchronized 关键字、volatileReentrantLock 类和阻塞队列 LinkedBlockingQueue<E> 等方法来实现线程间的同步。在实际编程中,应根据情况选择不同的同步方法,以保证程序的正确和效率。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

学徒630

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

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

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

打赏作者

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

抵扣说明:

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

余额充值