多方面解读Java中的volatile关键字
作者:lfsun666
介绍
volatile 是 Java 中的关键字,用于修饰变量。它的作用是强制对被修饰的变量的写操作立即刷新到主存中,并强制对该变量的读操作从主存中读取最新的值,而不是使用缓存中的值。
作用
保证变量的可见性:
可见性指的是多个线程之间对共享变量的修改能否被及时地通知到其他线程,也就是说,当一个线程修改了共享变量的值时,其他线程能够立即看到这个变化。如果共享变量的可见性不能得到保证,就可能出现数据不一致的情况。
当一个变量被 volatile 修饰时,任何对该变量的写操作都会立即刷新到主存中,而任何对该变量的读操作都会从主存中读取最新的值。这样可以保证多个线程之间对该变量的读写操作都是可见的。
禁止指令重排:
指令重排指的是编译器或处理器为了提高程序运行效率而对指令序列进行重新排序的过程。在多线程环境下,指令重排可能会导致程序的执行结果与预期结果不一致。因此,为了保证程序的正确性,需要使用 volatile 关键字或 synchronized 关键字来禁止指令重排。具体来说,当一个变量被volatile修饰时,编译器和处理器会插入一些内存屏障,保证指令的执行顺序不会被打乱。
内存屏障是一种特殊的CPU指令,它可以保证在执行到内存屏障之前的所有指令都已经执行完成,并且其结果已经被写入内存中。在执行内存屏障之后的指令,必须等待前面的指令执行完成后,才能开始执行。这样可以避免由于 CPU 的乱序执行导致的指令重排和内存操作的乱序问题。synchronized 和 volatile 在实现上都使用了内存屏障来保证线程安全性。synchronized 使用了一种特殊的内存屏障——“内存锁定”,它可以保证线程在获取锁之前,所有对共享变量的修改操作都已经被刷新到主内存中,同时在释放锁之后,所有对共享变量的修改操作都已经对其他线程可见。而volatile使用了“写屏障”和“读屏障”,它们分别保证写操作和读操作在指令执行时都不会受到指令重排的影响,从而保证了线程对共享变量的访问操作的可见性和禁止指令重排。
编译器和处理器为了提高程序的执行效率,可能会对指令进行重排。使用volatile关键字可以禁止这种优化,保证指令的执行顺序不会被打乱。
不能保证原子性
原子性指的是一个操作是不可被中断的,要么全部执行,要么全部不执行。在多线程环境下,如果某个操作不是原子性的,就可能会出现多个线程同时修改同一个变量的情况,从而导致数据不一致。
如果需要保证原子性,可以使用 synchronized 关键字或者juc.atomic包中的原子类。
可见性、有序性、原子性
可见性、有序性、原子性三个特性都是线程安全的必要条件,三个特性缺一不可。因此,保证其中任何一个特性都不能认为是线程安全的全部,而只有同时保证三个特性才能实现真正的线程安全。
synchronized 关键字是一种保证线程安全的机制,它可以实现原子性和有序性,同时也可以保证可见性,因此使用 synchronized 可以保证线程安全,但是会影响程序的性能。synchronized 通过独占锁来保证同步代码块的原子性和有序性,同时在获取和释放锁的过程中,会使用内存屏障来保证可见性和有序性。
在Java 5之后,JDK 引入了 volatile 关键字,它可以保证可见性和有序性,但是无法保证原子性。volatile 关键字只能保证共享变量的读写操作是原子的,但是不能保证复合操作的原子性,如递增操作。因此,在需要保证原子性的情况下,仍然需要使用 synchronized 关键字。
所以,使用 synchronized 可以保证线程安全,并且同时保证了可见性、有序性和原子性,而使用 volatile 只能保证可见性和有序性,无法保证原子性。
不会导致线程阻塞
线程阻塞指的是线程暂停执行,等待某个条件得到满足后再继续执行的一种状态。线程阻塞可以分为两种情况:
主动阻塞:主动阻塞是指线程在执行过程中,调用了某个方法或者操作,而该方法或者操作需要等待某个条件的满足才能继续执行。例如,线程调用了sleep方法,就会主动阻塞一段时间;线程调用了wait方法,就会主动阻塞,等待其他线程的唤醒。被动阻塞:被动阻塞是指线程在执行过程中,由于某些原因(如I/O操作、锁竞争等)而无法继续执行,进入阻塞状态。例如,线程等待某个资源的释放,就会被动阻塞。
无论是主动阻塞还是被动阻塞,都会导致线程暂停执行,直到某个条件得到满足或者等待时间到期,才会重新被唤醒并继续执行。因此,在多线程编程中需要注意线程的阻塞状态,避免出现死锁、饥饿等问题。
volatile 关键字不会阻塞线程,它只是保证对被修饰的变量的读写操作都从主存中进行,而不是使用缓存中的值。
使用场景
1、多个线程之间共享一个变量,并且其中一个线程对该变量的修改需要立即对其他线程可见时。
/** * 将myVariable变量声明为volatile类型。 * 在incrementmyVariable()方法中,我们对myVariable的值进行了修改,而不是使用++运算符。 * 这是因为++运算符不是一个原子操作,它实际上包含读取变量值、增加变量值和写回变量值这三个步骤, * 如果多个线程同时执行这些步骤,可能会导致值的不一致。 * 因此,使用了一个简单的加法操作来对myVariable进行修改,这样就可以确保线程安全。 * @author Administrator */ public class MyVolatile1 { private volatile int myVariable = 0; public void incrementMyVariable() { myVariable = myVariable + 1; } public int getMyVariable() { return myVariable; } }
2、多个线程之间共享一个变量,并且该变量的值可能会被多个线程同时修改时。
/** * 将myVariable变量声明为volatile类型。由于多个线程可能同时修改该变量的值, * 需要使用synchronized关键字来保护它。 * incrementMyVariable()、decrementMyVariable()、getMyVariable()方法中, * 都使用了synchronized关键字来确保对共享变量的访问是原子的。这样就可以确保线程安全。 * * * * 为什么myVariable = myVariable + 1;是线程安全;myVariable ++不是? * myVariable++操作实际上包含了三个步骤: * 1、读取myVariable的值、 * 2、将其增加1、 * 3、将结果写回到myVariable。 * 如果有多个线程同时执行myVariable++操作,就会出现竞态条件,导致myVariable的值不确定。 * * 例如,假设myVariable的初始值为0,有两个线程同时执行myVariable++操作,它们可能会执行以下步骤: * * 线程1读取myVariable的值为0。 * 线程2读取myVariable的值为0。 * 线程1将myVariable的值增加1,结果为1。 * 线程2将myVariable的值增加1,结果也为1。 * 线程1将结果1写回到myVariable。 * 线程2将结果1写回到myVariable。 * 最终,myVariable的值为1,而不是预期的2。这就是竞态条件导致的结果。 * * 相比之下,myVariable = myVariable + 1; * 操作只包含两个步骤:读取myVariable的值和将其增加1。由于这两个步骤是在一个原子操作中完成的,所以不会出现竞态条件,从而保证了线程安全。 * * 因此,当我们需要对共享变量进行增加操作时,建议使用myVariable = myVariable + 1;这种形式,而不是myVariable++操作,以确保线程安全。 * @author Administrator */ public class MyVolatile2 { private volatile int myVariable = 0; public synchronized void incrementMyVariable() { myVariable = myVariable + 1; } public synchronized void decrementMyVariable() { myVariable = myVariable - 1; } public synchronized int getMyVariable() { return myVariable; } }
实现原理
使用内存屏障:当一个变量被 volatile 修饰时,编译器和处理器会插入一些内存屏障,保证指令的执行顺序不会被打乱。
使用缓存一致性协议:当一个线程对一个 volatile 变量进行写操作时,处理器会发送一个信号通知其他处理器该变量的值已经被修改,其他处理器会强制将该变量的缓存行失效,从而保证其他线程读取该变量时能够读取到最新的值。
happens-before
happens-before,用于描述多线程程序中的事件顺序关系。如果一个事件 A happens-before 另一个事件B,那么A对共享变量的写入操作对B的读取操作是可见的,也就是说,B 可以看到 A 写入的值。happens-before 关系可以通过以下方式建立: 1、程序顺序规则:在一个线程中,按照程序顺序,前面的操作 happens-before 于后面的操作。 2、volatile变量规则:对一个 volatile 变量的写操作 happens-before 于后续对该变量的读操作。 3、锁定规则:一个解锁操作 happens-before 于后续的加锁操作。 4、传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
局限性
不能保证原子性:volatile关键字只能保证可见性和禁止指令重排,不能保证原子性。如果需要保证原子性,可以使用synchronized 关键字或者 juc.atomic包中的原子类。不能替代锁:volatile 关键字只能保证可见性,不能保证同步性。如果需要保证同步性,仍然需要使用 synchronized 关键字或者其他锁机制。 对于复合操作的保证:当多个变量之间存在依赖关系时,使用 volatile 关键字不能保证操作的原子性和一致性。此时仍然需要使用 synchronized 关键字或者其他锁机制来保证操作的一致性。
和 synchronized 关键字比较
volatile关键字只能修饰变量,而synchronized关键字可以修饰方法或代码块。 volatile关键字不能保证原子性,而synchronized关键字可以保证原子性。 volatile关键字不会阻塞线程,而synchronized关键字可能会导致线程阻塞。 volatile关键字只能保证可见性和禁止指令重排,而synchronized关键字可以保证同步性和原子性。 volatile关键字用于保证可见性和禁止指令重排,但是不能保证原子性,不能替代锁机制。 synchronized关键字用于保证同步性和原子性,但是不能保证可见性。synchronized关键字通过获取锁来保证同一时刻只有一个线程可以执行临界区代码,从而保证线程安全。
和 Atomic 类比较
volatile 关键字只能保证可见性和禁止指令重排,不能保证原子性。 Atomic 类可以保证可见性和原子性,但是不能替代锁机制。Atomic 类通过CAS操作实现原子性,当多个线程同时对一个变量进行修改时,只有一个线程能够成功执行 CAS 操作,从而保证操作的原子性。
和 final 关键字比较
volatile关键字用于保证可见性和禁止指令重排,但是不能保证原子性,不能替代锁机制。 final关键字用于定义常量,一旦定义就不能再修改它的值。final关键字可以保证线程安全,因为一个final变量的值一旦被初始化之后就不会再被修改,所以不会存在多线程之间的竞争问题。
和 ThreadLocal 关键字比较
volatile 关键字用于保证可见性,即多个线程之间能够正确地访问共享变量,避免出现数据不一致的情况。
ThreadLocal 用于实现线程本地变量,即每个线程都有自己独立的变量副本,线程之间不会相互干扰。ThreadLocal 主要解决的是多线程中数据的隔离问题,可以让每个线程都拥有自己独立的变量副本,从而避免出现数据不一致的情况。
失效场景
1、如果变量的值只会被单个线程修改,而其他线程只会读取这个值,那么就算不使用volatile关键字,这个程序也是线程安全的。 在这种情况下,即使不使用volatile关键字,多个线程之间也不会产生竞争条件,因为每个线程都是独立地读取和修改变量的值。因此,volatile关键字在这种情况下并不会产生任何影响。
// 场景1:如果变量的值只会被单个线程修改,那么即使不使用volatile关键字,程序也是线程安全的。 public static void testScenario1() throws InterruptedException { /** * 即使使用 newCachedThreadPool 创建的线程池,每个任务也只会被单个线程执行。 * 因为线程池会维护一定数量的线程,当有新任务时会从线程池中取出一个空闲的线程执行, * 当任务执行完毕后,该线程就会重新归还到线程池中等待下一个任务的到来。 * * 在这个例子中,虽然没有显式地指定线程数,但是线程池内部会根据需要自动创建和维护线程。 * 在执行任务时,线程池会选取一个空闲的线程去执行任务,而其他线程都会被阻塞等待。 * 因此,虽然任务可能被多个线程执行,但每个线程只会执行部分任务,任务的修改操作也只会被单个线程执行,因此这个程序是线程安全的。 */ //ExecutorService executorService = Executors.newCachedThreadPool(); ExecutorService executorService = Executors.newSingleThreadExecutor(); executorService.execute(() -> { for (int i = 0; i < 10000; i++) { count++; } }); executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.SECONDS); System.out.println("Scenario1: count=" + count); }
2、多个线程对变量进行复合操作,例如 i++ 操作,这种操作不是原子性操作,volatile关键字无法保证操作的一致性。
在这种情况下,如果多个线程同时对变量进行复合操作,例如i++操作,会导致竞争条件的产生,从而导致线程安全问题。虽然volatile关键字可以保证变量的可见性,但是它无法保证操作的原子性,因此使用volatile关键字并不能解决这种情况下的线程安全问题。
如果需要保证操作的原子性,可以使用synchronized关键字或者juc.atomic包中提供的原子类。
// 场景2:多个线程对变量进行复合操作,volatile关键字无法保证操作的一致性。 public static void testScenario2() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { executorService.execute(() -> { for (int j = 0; j < 1000; j++) { count++; } }); } executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.SECONDS); System.out.println("Scenario2: count=" + count); }
3、多个线程之间对变量的操作存在依赖关系,但是依赖关系没有被正确处理,例如线程A对变量 i 进行操作后,线程 B 读取了i的值,但是没有正确处理线程 A 对 i 的操作,导致线程 B 读取到了错误的值。
在这种情况下,volatile关键字可以保证变量的可见性,但是它无法保证多个线程之间的操作顺序。如果多个线程之间的操作存在依赖关系,那么需要使用同步机制,例如synchronized关键字或者 juc 包中提供的同步工具,来保证操作的顺序和一致性。 B 读取了i的值,但是没有正确处理线程 A 对 i 的操作,导致线程 B 读取到了错误的值。
// 场景3:多个线程之间对变量的操作存在依赖关系,但是依赖关系没有被正确处理,使用volatile关键字也无法保证正确性。 public static void testScenario3() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); executorService.execute(() -> { for (int i = 0; i < 1000; i++) { count++; } }); executorService.execute(() -> { for (int i = 0; i < 1000; i++) { if (count % 2 == 0) { count++; } } }); executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.SECONDS); System.out.println("Scenario3: count=" + count); }
测试
public class VolatileTest { private static volatile int count = 0; public static void main(String[] args) throws InterruptedException { // 测试场景1:单个线程修改变量值 testScenario1(); // 测试场景2:多个线程对变量进行复合操作 testScenario2(); // 测试场景3:多个线程之间存在依赖关系 testScenario3(); } }
结果
到此这篇关于多方面解读Java中的volatile关键字的文章就介绍到这了,更多相关Java中的volatile关键字内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!