Java并发编程:为什么实际项目开发中,很少使用volatile

最近在学习并发编程,有很多东西,在实际项目开发中很少见到。比如volatile关键字,所以今天分析一下为什么这个关键字很少用。未完待续。。。

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

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等
屏障类型 指令示例 说明
LoadLoad Load(A) ->LoadLoad ->Load(B) 保证load(A)的读取操作在load(B)之前执行
StoreStore Store(A)-> StoreStore->Store(B) 保证在执行Store(B)之前,Store(A)的写操作已经刷新到主内存中
LoadStore Load(A)->LoadStore->Store(B) 保证在执行Store(B)之前,Load(A)已经读取结束
StoreLoad Store(A)->StoreLoad->Load(B) 保证Load(B)读操作之前,Store(A)的写操作已经刷新到主内存

禁止指令重排序的原理

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

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

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

java 复制代码
//双重校验锁,实现对象单例(线程安全)
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,但此时该对象还没有被初始化。

参考文章:

  1. 知乎:Java内存中的Volatile关键字到底是怎么解决重排序的?

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

示例:

java 复制代码
// 实现多线程的方法之一,继承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);
    }
}

上述代码运行结果:

java 复制代码
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。

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

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

  • 使用synchronized改进increaseFun()方法
java 复制代码
static synchronized void increaseFun() {
    // 要运行在多线程环境下的方法
    increase++;
}
  • 使用Lock改进increaseFun( )方法
java 复制代码
Lock lock = new ReentrantLock();
public void increaseFun() {
    lock.lock();
    try {
        increase++;
    } finally {
        lock.unlock();
    }
}
  • 使用AtomicInteger改进(见上述代码中的对照组)

以下内容待确认

另外,再说一句,单独的volatile 不能保证原子性,但是当它配合上CAS 之后,就能实现无锁的同步(乐观锁方式)

这种方式,在JUC中有很多很多的例子,很经典的就是AtomicIntegerLongAdder之类的原子类。

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

参考文章

  1. JavaGuide:volatile 可以保证原子性么?

使用场景

Java的一些类库:CopyOnWriteArrayList、ConcurrentHashMap

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

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

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

源码大致如下:

java 复制代码
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关键字不保证原子性,并发情况下,很难准确分析是不是会有问题,为了避免出错,所以大多数会直接使用锁。

参考文章

  1. 知乎:volatile原理和使用场景
  2. 彻底理解volatile关键字及应用场景,面试必问,小白都能看懂
  3. 知乎:关于Java并发编程Volatile 关键字讲解最好的一篇文章!
  4. 阿里云:Java 理论与实践: 正确使用 Volatile 变量(转)
相关推荐
罗超驿几秒前
SpringBoot 快速入门
java·spring boot·后端
卷无止境42 分钟前
除了开发api,FastAPI其实也可以配合jinja2模板写页面
后端·python·fastapi
newerp1 小时前
Redis 操作与缓存策略
后端·程序员·go
newerp1 小时前
GORM ORM 基础
后端·程序员·go
小岛前端1 小时前
AI Skills 已经封神,但新的问题却越来越严重!
前端·后端·github
拖孩1 小时前
一个人 + AI 做的小程序,上线 15 天赚了 10 块 5
前端·后端·微信小程序
newerp1 小时前
CRUD 操作与预处理语句
后端·程序员·go
wei_shuo1 小时前
KES 数据同步与ETL实战:数据集成、转换与实时同步方案
后端
乒乓狂魔14786739970001 小时前
LangGraph:用一张图,编排一群 AI 助手
后端
Gopher_HBo1 小时前
请求绑定 binding(binding/ 包)
后端