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 变量(转)
相关推荐
码农派大星。2 分钟前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man42 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*43 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu44 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s1 小时前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子1 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王1 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构
customer082 小时前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
2402_857589362 小时前
SpringBoot框架:作业管理技术新解
java·spring boot·后端
一只爱打拳的程序猿2 小时前
【Spring】更加简单的将对象存入Spring中并使用
java·后端·spring