深入理解volatile

一、volatile概念介绍

在Java编程语言中,volatile是一个关键字,它用于修饰变量,以确保对该变量的读写操作都直接作用于主内存,而不是线程的工作内存。这使得volatile变量在多线程环境中具有可见性和有序性。

二、volatile的两大特性

  1. 保证可见性:当一个线程修改了一个被 volatile 修饰的变量后,新的值会立即反映到主内存中,并且任何线程读取这个变量都会从主内存中读取。因此,任何其他线程对该变量的读取都会看到这个最新的更新。这有助于解决一些基本的并发问题,使得变量的修改能够被其他线程及时感知。

  2. 保证有序性:volatile 变量的读写具有一定的内存屏障效果。它禁止了指令重排序(至少在某些方面),使得在读写 volatile 变量时不会发生重排序问题,从而保证了执行顺序的一致性。这意味着在写一个 volatile 变量前的指令不会被重排序到写操作之后,而在读一个 volatile 变量后的指令不会被重排序到读操作之前。

注意:volatile 是一种简单而有效的轻量级同步机制,尽管volatile保证了可见性和有序性,但它并不保证复合操作的原子性。例如,对于i++这样的操作,虽然i是volatile类型的,但是i++包含了读取、增加和写回三个步骤,这些步骤并不是原子的。因此在并发环境下仍然可能会出现竞态条件,导致数据不一致。所以还需要考虑使用更强大的同步手段如 synchronized 或者 Lock。

三、内存屏障

内存屏障(Memory Barrier),也称为内存栅栏,是一种同步机制,用于控制特定操作的执行顺序,确保在多处理器或多线程环境中内存操作的一致性和有序性。内存屏障的主要作用是防止编译器和处理器对指令序列进行重排序,从而保证内存操作的顺序性和可见性。

在 Java 中,我们不需要直接编写内存屏障的代码,我们只需要直接使用 volatile 去修饰变量便会自动带来内存屏障的效果。但是了解内存屏障的概念有助于更好地理解 volatile 和其他同步机制的工作原理。

在多线程编程中,内存屏障通常用于以下几种情况:

  1. 编译器重排序:编译器在不改变程序语义的前提下,可能会对指令进行重排序以优化性能。内存屏障可以防止这种重排序,确保指令按照程序员的意图执行。
  2. 处理器重排序:现代处理器为了提高执行效率,可能会对指令进行乱序执行。内存屏障可以确保在屏障前后的指令按照一定的顺序执行。
  3. 缓存一致性:在多核处理器系统中,每个核心可能有自己的缓存。内存屏障可以确保一个核心对内存的修改对其他核心可见,从而维护缓存的一致性。

内存屏障简分通常分为以下4种类型:

  1. Load Barrier(读屏障):确保所有在读屏障之前的读操作完成后,才能执行读屏障之后的读操作。
  2. Store Barrier(写屏障):确保所有在写屏障之前的写操作完成后,才能执行写屏障之后的写操作。
  3. Load-Store Barrier(读-写屏障):这种屏障结合了读屏障和写屏障的特性,确保在读-写屏障之前的读操作和写操作都完成后,才能执行屏障之后的读写操作。
  4. Store-Load Barrier(写-读屏障):写-读屏障是最强大的内存屏障,它确保在屏障之前的写操作对所有后续的读操作可见。这种屏障可以防止编译器和处理器对读写操作进行重排序,从而确保内存操作的顺序性和一致性。

四、案例代码

1、volatile体现可见性案例:

package com.fivefox.thread1;
import java.util.concurrent.TimeUnit;
public class VolatileTest {

    private static volatile boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + ",线程执行中....");
            while (flag) {}
            System.out.println("flag被修改为false,threadA线程退出。");
        }, "threadA").start();

        TimeUnit.SECONDS.sleep(2);
        flag = false;
        System.out.println("已将flag设置false主线程代码执行完毕!");
    }
}
  • 不加volatile关键字:
    这个时候flag变量是不满足可见性的,将会导致threadA一直在线程的工作内存中读取flag的值。从而导致程序一直处于while循环的状态。
  • 加了volatile关键字后:
    这个时候flag变量满足可见性了;当main线程修改flag变量后,新的值会立即写入到主内存中。并且任何线程对于flag变量的读取都会从主内存中读取。这个时候while检测到flag值变为false,将会跳出while,结束程序的执行。

2、volatile不满足原子性案例:

public class VolatileCounter {
    private volatile int count = 0;

    public void increment() {
        count++;
    }
    public int getCount() {
        return count;
    }
}
package com.fivefox.thread1;

public class VolatileCounterTest {
    public static void main(String[] args) throws InterruptedException {
        final VolatileCounter counter = new VolatileCounter();
        final int threadCount = 100;

        // 创建线程并启动
        Thread[] threads = new Thread[threadCount];
        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }
        // 等待所有线程结束
        for (Thread t : threads) {
            t.join();
        }
        // 输出最终的计数值
        System.out.println("Final count: " + counter.getCount());
    }
}

在这个例子中,我们创建了 1000 个线程,每个线程都会对 count 进行 1000 次递增操作。理论上,最终的计数值应该是 100 * 100 = 10000。然而,由于 count++ 是一个复合操作,它包括读取当前值、计算新值和写回新值三个步骤,因此在多线程环境下可能会出现竞态条件,导致最终计数值小于预期。

这里的关键在于 count++ 并不是一个原子操作。即使 count 被声明为 volatile,这也只能保证每次读取 count 时都会得到最新的值,但无法保证整个 count++ 操作的原子性。因此,当多个线程几乎同时执行 count++ 时,可能会导致某些线程读取相同的 count 值,然后各自进行递增并写回,从而导致实际的计数值比预期的小。

确保 count++ 的原子性的方法:

  1. 可以使用 java.util.concurrent.atomic.AtomicInteger 类来替代普通的 int 变量。AtomicInteger 内部使用了 CAS (Compare and Swap) 操作来确保复合操作的原子性。
public class VolatileCounter {
// private volatile int count = 0;
private AtomicInteger count = new AtomicInteger(0);
    public void increment() {
        count++;
    }
    public int getCount() {
        return count;
    }
}

 public void increment() {
        count.incrementAndGet();
 }
  1. 通过引入锁机制(ReentrantLock),可以确保 increment() 方法中的 count++ 操作在一个互斥的上下文中执行,从而保证了该操作的原子性。这种方法非常适合用于复合操作,因为锁可以确保在同一时刻只有一个线程能够执行被锁定的代码段。
package com.fivefox.thread1;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class VolatileCounter {
    private Lock lock = new ReentrantLock();
    private volatile int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
    public int getCount() {
        return count;
    }
}

3、DCL 单例模式

双重检查锁定(Double-Checked Locking, DCL)在单例模式中的应用。双重检查锁定是一种常用的线程安全技术,用于确保在多线程环境下单例对象只能被创建一次,并且每次请求都能得到同一个实例。

在实际开发中,双重检查锁定通常用于高并发场景下的单例模式实现。它既能保证线程安全,又能提高性能。

public class Singleton {
    // 使用 volatile 关键字确保可见性和有序性
    private static volatile Singleton instance;

    // 私有构造器,防止外部实例化
    private Singleton() {}

    // 提供一个静态公共方法来获取单例对象
    public static Singleton getInstance() {
        // 第一次检查:如果实例不存在,则进入同步代码块
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次检查:确保在多线程环境下只有一个实例被创建
                if (instance == null) {
                    // 实例化单例对象
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  1. 为什么要使用 DCL?
    直接使用同步方法或同步代码块会导致性能问题,因为每次访问都需要加锁,即使已经创建了实例也是如此。双重检查锁定通过只在必要时才进行同步来提高效率。
  2. 为什么需要两次检查?
    第一次检查:如果 instance 已经被创建,那么不需要再进入同步代码块,这样可以避免不必要的同步开销。
    第二次检查:确保即使多个线程同时进入第一次检查,也只会有一个线程成功创建实例。
  3. 为什么需要 volatile?
    可见性:volatile 确保当一个线程修改了 instance 变量后,其他线程能够立即看到这个修改。
    有序性:volatile 防止编译器和处理器重排序,确保 instance 的写操作不会与构造函数中的其他操作重排。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容