JUC并发编程之volatile详解

简介: Java内存模型是Java虚拟机(JVM)规范中定义的一组规则,用于屏蔽各种硬件和操作系统的内存访问差异,保证多线程情况下程序的正确执行。Java内存模型规定了线程之间如何交互以及线程和内存之间的关系。它主要解决的问题是可见性、原子性和有序性。

1. volatile


在并发编程中,多线程操作共享的变量时,可能会导致线程安全问题,如数据竞争、可见性问题等。为了解决这些问题,Java提供了JUC(java.util.concurrent)工具包,其中包含了很多用于处理并发编程的工具类和接口。在JUC中,volatile是一个关键字,它可以用于修饰变量,用来确保变量的可见性和禁止指令重排序,从而在一定程度上解决线程安全问题。


1.1 volatile关键字的作用


1.1.1 变量可见性


在多线程环境下,如果一个线程修改了共享变量的值,其他线程可能由于线程间的数据不一致性而看不到该变量的最新值。这种问题称为“变量不可见性”或“可见性问题”。


volatile关键字可以确保被修饰的变量对所有线程可见。当一个线程修改了volatile变量的值,其他线程立即能够看到修改后的最新值,而不会使用缓存中的旧值。


1.1.2 禁止指令重排序


在JVM(Java虚拟机)中,为了优化性能,编译器和处理器可能会对指令进行重排序。在单线程环境下,这种重排序不会影响程序的执行结果。然而,在多线程环境下,指令重排序可能会导致线程安全问题。


volatile关键字可以防止指令重排序,确保被修饰的变量按照代码中的顺序执行。


1.2 volatile可见性案例

public class VolatileExample {
    private static volatile boolean flag = false;
    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {
                System.out.println("Waiting for the flag to be true...");
            }
            System.out.println("Flag is now true. Exiting the thread.");
        }).start();
        try {
            Thread.sleep(1000); // 确保主线程在子线程之前执行
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Setting the flag to true...");
        flag = true;
    }
}


在上面的示例中,我们创建了一个VolatileExample类,并声明了一个volatile类型的flag变量。在主线程中,我们启动一个新的子线程,该子线程会不断地检查flag变量的值,直到flag变为true时,子线程退出。


在主线程中,我们将flag变量设置为true。由于flag变量被声明为volatile类型,子线程能够及时看到flag的最新值,从而退出循环,输出“Flag is now true. Exiting the thread.”。


这个示例演示了volatile关键字的作用,确保了flag变量的可见性。如果我们没有使用volatile关键字,子线程可能会一直循环下去,因为它看不到主线程对flag的修改。


1.3 volatile非原子性案例


public class Test {
    public static void main(String[] args) throws InterruptedException {
        VolatileAtomicityExample example = new VolatileAtomicityExample();
        for(int i=1;i<=100;i++){
            new Thread(()->{
                for(int j=1;j<=1000;j++)
                    example.increment();
            },String.valueOf(i)).start();
        }
        TimeUnit.SECONDS.sleep(2);
        System.out.println(example.getCount());
    }
}
class VolatileAtomicityExample {
    volatile int count = 0;
    public  void increment() {
        count++;
    }
    public int getCount() {
        return count;
    }
}


我们创建了一个VolatileAtomicityExample类,其中的成员变量count被声明为volatile类型。然后,我们创建了100个线程,每个线程分别执行1000次increment()操作,对count进行自增。最后,我们在主线程中打印count的最终值。以上示例中的输出结果可能会因为运行时的不确定性而有所不同。每次运行时可能得到不同的结果,但通常结果都小于100000。为了解决这个问题,我们需要使用synchronized关键字或其他线程安全机制来确保increment()方法的原子性。


这是什么原因呢?



对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存最新值,操作出现写丢失问题。即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。


1.4 volatile 禁止重排序


内存屏障是一种硬件指令或者编译器指令,用于控制内存操作的顺序,以确保多线程环境下的内存可见性和正确的执行顺序。


内存屏障分为两种类型:


内存读屏障(Load Barrier):它是一个特殊的硬件或者编译器指令,用于保证在内存读取操作之前,所有的先行写操作都已经完成,并且其结果对当前线程可见。也就是说,读屏障可以防止后续读取指令重排序到读屏障之前的位置。


内存写屏障(Store Barrier):它是一个特殊的硬件或者编译器指令,用于保证在内存写入操作之前,所有的先行写操作和写屏障之前的写操作都已经完成,并且其结果对其他线程可见。也就是说,写屏障可以防止前面的写入指令重排序到写屏障之后的位置。


volatile关键字通过内存屏障来保证变量的读写操作不会被重排序。具体来说,对于volatile变量的写操作,在写入变量之后会插入写屏障,这样可以防止其他指令重排序到写屏障之前。类似地,对于volatile变量的读操作,在读取变量之前会插入读屏障,这样可以防止其他指令重排序到读屏障之前。


通过这种方式,volatile关键字确保了对变量的读写操作具有一定的有序性,从而保证了多线程环境下的内存可见性和正确的执行顺序。


1.5 volatile 日常使用场景


状态标志:当一个线程修改了某个状态标志,其他线程需要立即看到最新的状态。这时可以使用volatile关键字修饰状态标志,保证其在多线程之间的可见性。例如:


public class Task implements Runnable {
    private volatile boolean isRunning = true;
    @Override
    public void run() {
        while (isRunning) {
            // 执行任务逻辑
        }
    }
    public void stop() {
        isRunning = false;
    }
}


双重检查锁定(Double-Checked Locking):在多线程环境下,当需要延迟初始化一个对象时,为了避免重复初始化,常常使用双重检查锁定。在这种情况下,需要使用volatile关键字来确保对象在多线程环境中的可见性。例如:


public class Singleton {
    private volatile static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}


在上述代码中,我们实现了一个简单的单例模式。在getInstance()方法中,我们使用双重检查锁定来实现延迟初始化,确保只有在instance为空时才创建新的Singleton实例。


然而,在多线程环境下,由于指令重排序的存在,可能会导致以下问题:


对象引用不为空但尚未初始化:在线程A执行完instance = new Singleton();这一行之前,可能发生指令重排序,导致instance的引用不为空,但是Singleton实例的初始化还未完成。这样,线程B在执行return instance;时就会得到一个尚未初始化的对象,导致错误。


可见性问题:指令重排序也可能导致线程B无法及时看到线程A的初始化操作。例如,线程A对instance的赋值可能被重排到线程A的后面执行,从而线程B在读取instance时得到一个旧的引用,无法感知线程A的初始化操作。


为了解决这个问题,需要在创建Singleton实例时使用volatile关键字来保证对象的可见性和禁止指令重排序。

相关文章
|
5月前
|
缓存 Java 编译器
JUC 并发编程之JMM
Java内存模型是Java虚拟机(JVM)规范中定义的一组规则,用于屏蔽各种硬件和操作系统的内存访问差异,保证多线程情况下程序的正确执行。Java内存模型规定了线程之间如何交互以及线程和内存之间的关系。它主要解决的问题是可见性、原子性和有序性。
|
2月前
|
存储 Java
JUC(8)JMM
这篇文章介绍了Java内存模型(JMM),解释了volatile关键字的作用,包括确保变量的可见性、禁止指令重排但不保证操作的原子性,并探讨了单例模式的实现方式,包括饿汉式和懒汉式单例模式的示例代码。
JUC(8)JMM
|
2月前
|
安全 Java
Java并发编程实战:使用synchronized和ReentrantLock实现线程安全
【8月更文挑战第31天】在Java并发编程中,保证线程安全是至关重要的。本文将通过对比synchronized和ReentrantLock两种锁机制,深入探讨它们在实现线程安全方面的优缺点,并通过代码示例展示如何使用这两种锁来保护共享资源。
|
5月前
|
安全 Java
JUC并发编程之原子类
并发编程是现代计算机应用中不可或缺的一部分,而在并发编程中,处理共享资源的并发访问是一个重要的问题。为了避免多线程访问共享资源时出现竞态条件(Race Condition)等问题,Java提供了一组原子类(Atomic Classes)来支持线程安全的操作。
|
5月前
|
缓存 算法 Java
JUC并发编程之CAS
CAS,即Compare and Swap,是一种并发编程中用于实现多线程环境下的原子操作的技术。它是一种无锁算法,用于解决多线程环境下的数据同步问题。CAS操作包含三个操作数:内存位置V,旧的预期值A和即将要写入的新值B。只有当内存位置的值与旧的预期值A相等时,才会将新值B写入内存位置V,否则不执行任何操作。CAS操作是原子的,保证了多线程环境下的数据一致性和线程安全性。
|
存储 缓存 安全
并发编程——synchronized
原子性、有序性、可见性
39 0
|
缓存 监控 安全
JUC并发编程之线程锁(一)
1.ReentrantLock(互斥锁)、2.ReentRantReaderWriterLock(互斥读写锁)、3.StampedLock(无障碍锁)、4.Condition(自定义锁)、5.LockSupport
71 0
|
Java 开发者
并发编程(五)synchronized
并发编程(五)synchronized
142 0
【并发编程】原子类
【并发编程】原子类
|
缓存 Java
Java并发编程系列2 - volatile
主要讲解volatile的相关知识,以及容易遇到的坑。
156 1
Java并发编程系列2 - volatile