小蔡学Java

volatile为什么在实战中使用比较少

2023-09-09 20:18 710 0 Java基础 JavaJUCJVM

要了解这个关键字为什么很少用,就需要分析这个关键字的作用、实现原理、使用场景,这也是这篇文章的中心内容

volatile关键字的作用

概括来说,volatile关键字有以下作用:

  • volatile关键字仅用于修饰变量(无法用于修饰方法),保证变量的可见性(但不能保证操作的原子性)
  • 禁止JVM的指令重排序

实现上述功能的原理

保证变量的可见性

  • 线程写volatile变量的时候,改变线程工作内存中的变量副本的值之后,会立马将改变后的值从自己的工作内存刷新到主内存。cpu总线嗅探机制检测到主内存的变量发生变化之后,会将其他工作内存中的变量缓存置为无效。

  • 线程读volatile变量的时候,如果缓存被置为无效,则会直接从主内存读最新值到线程的工作内存中,然后再从工作内存中读volatile变量的副本,进行操作

整个过程中,不同线程都能看到变量的最新值,保证了变量的可见性。但无法保证原子性。

什么是原子性(补充内容)

定义: 即一个操作或者多个操作,要么全部执行并且不被打断,要么就都不执行。

  • 比如:从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证不出现一些意外的问题。

  • 再比如:i = i+1;其中就包括,读取i的值,计算i,写入i。这行代码在java中是不具备原子性的,如果多线程运行肯定会出问题。

综上可知,对变量的写操作不依赖于当前值才是原子级别的,在多线程环境中才可以不用考虑多并发问题。比如:n=n+1、n++ 就不行。n=m+1才是原子级别的,实在没把握就使用synchronized关键字来代替volatile关键字。

禁止JVM指令重排序

内存屏障(补充内存)

内存屏障是基于特定硬件的,具体展开来非常的复杂。简单来说,内存屏障分两种:读屏障和写屏障。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。 这里的缓存主要指的是CPU缓存,如L1,L2等

禁止指令重排序的原理

通过在指令序列中插入内存屏障来禁止volatile变量的指令重排序。

  • 在每个volatile变量的写操作前插入StoreStore屏障:保证普通写操作刷新到主内存之后,再进行volatile写;在每个volatile变量的写操作之后插入StoreLoad屏障:保证volatile写刷新到主内存之后,再执行后续的volatile变量读操作。
  • 在每个volatile变量的读操作后插入一个LoadLoad屏障:保证volatile变量的读操作完成之后,再进行后续所有的读操作;在每个volatile变量的读操作后再插入一个LoadStore屏障:保证volatile变量的读操作完成之后,在进行后续的所有写操作。

应用:双重校验锁,实现对象单例

	//双重校验锁,实现对象单例(线程安全)
	public class VolatileSingleton {
		private volatile static VolatileSingleton uniqueInstance;

		private VolatileSingleton(){

		}

		public static VolatileSingleton getUniqueInstance(){
			if (uniqueInstance == null) {
				synchronized (VolatileSingleton.class) {
					if (uniqueInstance == null) {
						uniqueInstance = new VolatileSingleton();
					}
				}
			}
			return uniqueInstance;
		}
	}

uniqueInstance采用volatile关键字修饰也是很有必要的,uniqueInstance=new VolatileSingleton()这句代码,不是原子操作,需要分三步执行:

  1. 为uniqueInstance分配空间
  2. 初始化uniqueInstance
  3. 将uniqueInstance指向分配的内存地址

如果不用volatile关键字修饰,JVM进行指令重排序,执行步骤有可能变为:1-3-2。单线程下,指令重排没有问题,但是多线程环境下可能会出现以下现象:

  • 线程T1执行了1和3,
  • 线程T2调用getUniqueInstance()之后发现uniqueInstance不为空,则会直接返回uniqueInstance,但此时该对象还没有被初始化。

volatile关键字无法保证原子性,会导致什么问题

示例:

	// 实现多线程的方法之一,继承Thread类,并重写run方法
	public class VolatileTest extends Thread{

		static volatile int increase = 0;
		static AtomicInteger aInteger = new AtomicInteger(); // 对照组

		static void increaseFun() {
			// 要运行在多线程环境下的方法
			increase++;
			aInteger.incrementAndGet();
		}

		//要让线程运行代码,必须要重写run方法;否则会调用父类的run方法,执行的是空方法。
		//重写run方法的方式来指定我们的线程任务(所以run方法里面就是线程要执行的任务)
		public void run() {
			// 线程要运行的代码
			int i = 0;
			while (i < 10000) {
				increaseFun();
				i++;
			}
		}

		public static void main(String[] args) {
			VolatileTest vt = new VolatileTest();
			//陆续启动10个线程同时执行
			int THREAD_NUM = 10;
			Thread[] threads = new Thread[THREAD_NUM];
			for (int i = 0; i < THREAD_NUM; i++) {
				threads[i] = new Thread(vt, "线程" + i);
				//启动线程
				threads[i].start();
			}

			//idea中会返回主线程和守护线程,
			while (Thread.activeCount() > 2) {
				Thread.yield();
			}
			System.out.println("volatile的值: "+increase);
			System.out.println("AtomicInteger的值: "+aInteger);
		}
	}

上述代码运行结果:

volatile的值: 63326
AtomicInteger的值: 100000

出现上述情况的原因

这个程序我们跑了10个线程同时对volatile修饰的变量进行10000的自增操作(AtomicInteger实现了原子性,作为对照组),如果volatile变量是并发安全的话,运行结果应该为100000,可是多次运行后,每次的结果均小于预期值。显然上文的说法是有问题的。

volatile修饰的变量并不保证原子性,所以在上述的例子中,用volatile来保证线程安全不靠谱。我们用Javap对这段代码进行反编译,为什么不靠谱简直一目了然:

  • getstatic指令把increase的值拿到了操作栈的顶部,此时由于volatile的规则,该值是正确的。
  • iconst_1和iadd指令在执行的时候increase的值很有可能已经被其他线程加大,此时栈顶的值过期。
  • putstatic指令接着把过期的值同步回主存,导致了最终结果较小。

很多人会误认为自增操作 increase++ 是原子性的,实际上,increase++ 其实是一个复合操作,包括三步:

  1. 读取 increase 的值。
  2. 对 increase 加 1。
  3. 将 increase 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 increase 进行读取操作之后,还未对其进行修改。线程 2 又读取了 increase的值并对其进行修改(+1),再将increase 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 increase的值进行修改(+1),再将increase 的值写回内存。

这也就导致两个线程分别对 increase 进行了一次自增操作后,increase 实际上只增加了 1。

改进代码,使其具有原子性

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized、Lock或者AtomicInteger都可以。

  • 使用synchronized改进increaseFun()方法
		static synchronized void increaseFun() {
			// 要运行在多线程环境下的方法
			increase++;
		}
  • 使用Lock改进increaseFun( )方法
		Lock lock = new ReentrantLock();
		public void increaseFun() {
			lock.lock();
			try {
				increase++;
			} finally {
				lock.unlock();
			}
		}

  • 使用AtomicInteger改进(见上述代码中的对照组)

以下内容待确认

另外,再说一句,单独的volatile 不能保证原子性,但是当它配合上CAS 之后,就能实现无锁的同步(乐观锁方式) 这种方式,在JUC中有很多很多的例子,很经典的就是AtomicInteger、LongAdder之类的原子类。

	public class AtomicInteger {
		// ...
		  private static final Unsafe unsafe = Unsafe.getUnsafe();
	   // ...
	   private volatile int value;
	   // ...
	}

使用场景

Java的一些类库:CopyOnWriteArrayList、ConcurrentHashMap

有些地方会说 volatile 是一种轻量级的同步方式,实际上这里指的是它对于内存可见性的作用。如果要更准确的表达的话,volatile 应该成为是轻量级的线程操作可见方式。如果是在多写场景下的话,他并不能提供所谓的“同步”功能,还是会产生原子性的问题。

但是,如果是一写多读的场景,使用volatile 会变得十分的合适,在保证内存可见性的同时,不会像synchronized 那样会引起线程上下文的切换和调度(独占锁,会阻塞其他线程),相较起来使用和执行成本会更低。

典型的应用是 CopyOnWriteArrayList。它在修改数据时会把整个集合的数据全部复制出来, 对写操作加锁,修改完成后, 再用 setArray() 把 array 指向新的集合。使用 volatile 可以使读线程尽快地感知 array 的修改, 不进行指令重排,操作后即对其他线程可见。 源码大致如下:

	public class CopyOnWriteArrayList<E>
		implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
		private static final long serialVersionUID = 8673264195747942595L;

		/** The lock protecting all mutators */
		final transient ReentrantLock lock = new ReentrantLock();

		/** The array, accessed only via getArray/setArray. */
		private transient volatile Object[] array;

		public E set(int index, E element) {
			final ReentrantLock lock = this.lock;
			lock.lock();
			try {
				Object[] elements = getArray();
				E oldValue = get(elements, index);

				if (oldValue != element) {
					int len = elements.length;
					Object[] newElements = Arrays.copyOf(elements, len);
					newElements[index] = element;
					setArray(newElements);
				} else {
					// Not quite a no-op; ensures volatile write semantics
					setArray(elements);
				}
				return oldValue;
			} finally {
				lock.unlock();
			}
		}


		final void setArray(Object[] a) {
			array = a;
		}

因为volatile关键字不保证原子性,并发情况下,很难准确分析是不是会有问题,为了避免出错,所以大多数会直接使用锁。

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录