【Java并发】【volatile】适合初学者体质的volatile

👋hi,我不是一名外包公司的员工,也不会偷吃茶水间的零食,我的梦想是能写高端CRUD
🔥 2025本人正在沉淀中… 博客更新速度++
👍 欢迎点赞、收藏、关注,跟上我的更新节奏
📚欢迎订阅专栏,专栏别名《在2B工作中寻求并发是否搞错了什么》

前言

不太想上来就直说JMM啥的,我们为什么不从最简单的使用开始说起呢?这一期主播会把你当成新手教学。
我当年初学volatile的时候上来就是库库一顿原理分析,给幼小的我造成了不小的伤害,初学不需要 懂那么多🤭
具体的实现原理,我们放到下一期讲。期待的uu们,现在可以关注主播,跟上主播的节奏!!!

当你阅读dalao的框架源码的时候,你是否会见到这样一个关键字 - - - volatie,诶,你是否会好奇,为什么要加它?加了它有什么作用?
下图为bistouy使用到了volatile关键字地方:
在这里插入图片描述

我之前也阅读bistouy在线debug功能部分的源码输出过文章:
【Bistoury】Bistoury功能分析-在线debug

一、入门

什么是volatile关键字?

volatile 是 Java 中的一个关键字,用于修饰变量,主要作用是确保多线程环境下变量的可见性禁止指令重排序
它是 Java 内存模型(JMM, Java Memory Model)提供的一种轻量级同步机制。

volatile的作用(为什么要volatile关键字?)

  1. 保证可见性
    • 问题:在多线程环境中,每个线程都有自己的工作内存(线程栈),线程对变量的操作通常是在工作内存中进行的。如果没有同步机制,一个线程对变量的修改可能不会立即反映到主内存中,其他线程也就无法看到最新的值。
    • 解决:volatile 修饰的变量会强制线程每次读写都直接操作主内存,从而保证一个线程对变量的修改对其他线程立即可见。
  2. 禁止指令重排序
    • 问题:为了提高性能,JVM 和 CPU 可能会对指令进行重排序(Instruction Reordering)。重排序可能会导致多线程程序出现不可预期的行为。
    • 解决:volatile 修饰的变量会禁止 JVM 对其进行重排序,确保程序的执行顺序与代码的书写顺序一致。
  3. 轻量级同步
    • 问题:使用 synchronized 或锁机制会带来较大的性能开销,尤其是在只需要保证可见性和禁止重排序的场景中。
    • 解决:volatile 是一种轻量级的同步机制,性能开销较小,适用于简单的多线程同步场景。

volatie修饰位置(如何使用volatile关键字)

修饰字段:最常见的是修饰类的实例变量或静态变量,确保一个线程对变量的修改对其他线程立即可见。

public class Example {
    private volatile boolean flag = false;
}

(没有用但可以)修饰局部变量:虽然技术上可以修饰局部变量,但由于局部变量通常在线程栈中,每个线程有独立的副本,因此修饰局部变量没有实际意义。

public void method() {
    volatile int localVar = 10; // 无意义
}

修饰基本类型数组:volatile 可以修饰数组引用,但不能保证数组元素的可见性。

public class Example {
    private volatile int[] array;
}

修饰对象引用:可以修饰对象引用,确保引用的可见性,但不保证对象内部状态的可见性。

public class Example {
    private volatile MyObject obj;
}

这里,就会有好奇的小朋友要问了🙋‍,为什么修饰数组和对象的时候,不保证内部状态的可见性? 诶哟,你真的很聪明,能想到这点!!

那是因为volatile的作用范围仅限于它直接修饰的变量(在这里是数组/对象引用),而数组元素/对象字段是独立存储的,不受volatile的影响。
😄说人话就是,改引用别的线程知道,改里面的值,别的线程不知道。

public class Example {
    private volatile int[] array = new int[10];

    public void updateArray(int index, int value) {
        array[index] = value; // 对数组元素的修改不保证可见性
    }

    public void replaceArray(int[] newArray) {
        array = newArray; // 对数组引用的修改保证可见性
    }
}

那怎么解决勒?
对象

// 1、将对象内部字段也声明为 volatile
class MyObject {
    volatile int value; // 使用 volatile 修饰字段
}

// 2、修改方法,使用 synchronized 修饰
public synchronized void updateValue(int newValue) {
    if (obj != null) {
        obj.value = newValue;
    }
}

// 3、使用JUC:例如 AtomicReference、AtomicInteger 等,这些类提供了原子操作和可见性保证。
class MyObject {
    AtomicInteger value = new AtomicInteger(0);
}

// 4、使用final字段
// 如果对象的状态在构造后不会改变,可以将字段声明为 final,
// 这样在构造完成后,其他线程可以看到正确的值。

数组

// 1、使用 AtomicIntegerArray 或 AtomicReferenceArray,
// 这些类提供了对数组元素的原子操作和可见性保证。
public class Example {
    private AtomicIntegerArray array = new AtomicIntegerArray(10);

    public void updateArray(int index, int value) {
        array.set(index, value); // 保证可见性和原子性
    }
}

// 2、使用 synchronized 同步
public class Example {
    private int[] array = new int[10];

    public synchronized void updateArray(int index, int value) {
        array[index] = value; // 保证可见性和原子性
    }
}

// 3、使用JUC:例如,使用 CopyOnWriteArrayList 或自定义的线程安全容器
public class Example {
    private CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();

    public void updateList(int index, int value) {
        list.set(index, value); // 保证线程安全
    }
}

// 4、将数组元素声明为 volatile
class Element {
    volatile int value;
}

public class Example {
    private Element[] array = new Element[10];
}

二、volatile特性

可见性

下面的代码,我们启动了2个线程,一个负责修改cnt,一个来观察cnt的变化。我们会发现,控制台不会输出任何东西,修改cnt的线程修改的数量,对于读cnt的线程是不可见的🤗
这时候,需要我们加上volatile就可以解决了,至于为什么volatile修饰的变量会变得对其他线程可见😋,咱们下期再细说。

public class QuickTest {

    static int cnt;		// 需要加上volatile修饰
    
    public static void main(String[] args) throws Exception {

        // 启动读取数量的线程
        new Thread(() -> {
            int localCnt = cnt;
            while (true) {
                if (localCnt != cnt) {
                    System.out.println("当前值" + localCnt
                            + ",读到了修改后的数量: " + cnt);
                    localCnt = cnt;
                }
            }
        }).start();

        // 暂停1s,模拟业务操作
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        // 启动修改数量的线程
        new Thread(() -> {
            int localCnt = cnt;
            while (true) {
                localCnt++;
                cnt = localCnt;
                // 暂停1s,模拟业务操作
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }).start();

    }
}

有序性

喜欢用单例模式的小朋友,你们好呀!我们很经典的双重检查(DCL),我们会对要创建的单例对象字段使用volatile修饰,聪明的你一定也注意到了吧。
想回忆单例设计模式,也欢迎看看这篇:【设计模式】【创建型模式】单例模式(Singleton)-优快云博客

我们重点getInstance方法。
new Singleton();,在字节码里,是三个指令,如果发生指令重排,如下面代码所示。

public class Singleton {
	private static volatile Singleton instance;

	public static Singleton getInstance() {
		if (instance == null) {
			synchronized (Singleton.class) {
				if (instance == null) {
					instance = new Singleton();
					// ====== 下面是发生指令重排的情况 ====== 
					// 1. 创建对象内存	Singleton instance; #3231
					// 3. 给instance设置引用指针	instance=#3231
					// 2. 初始化Singleton对象	new Singleton()
				}
			}
		}
		return instance;
	}

	private Singleton() {}
}

假设2个线程都在调用getInstance方法,线程1执行到,3.给instance设置引用指针 instance=#3231此时instance就不为null,这个时候,线程2进来,直接返回没有初始话的instance对象。这样的对象,只是一个空壳,内存空间而已,对象里面的数据还没有塞进去。

三、volatile在框架源码中运用

这里就拿我们之前说过的线程池源码来举例吧!
之前我们学习过的线程池:
【Java并发】【线程池】带你从0-1入门线程池
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码

在线程池中,我们传入的参数,如核心现程数、最大现程数… 都有用volatile修饰
在这里插入图片描述
因为线程池开放了这些参数的修改方法,如下所示:

// 设置核心线程数
public void setCorePoolSize(int corePoolSize) {
	if (corePoolSize < 0)
		throw new IllegalArgumentException();
	int delta = corePoolSize - this.corePoolSize;
	this.corePoolSize = corePoolSize;
	if (workerCountOf(ctl.get()) > corePoolSize)
		interruptIdleWorkers();
	else if (delta > 0) {
		int k = Math.min(delta, workQueue.size());
		while (k-- > 0 && addWorker(null, true)) {
			if (workQueue.isEmpty())
				break;
		}
	}
}
// 设置最大线程数
public void setMaximumPoolSize(int maximumPoolSize) {
	if (maximumPoolSize <= 0 || maximumPoolSize < corePoolSize)
		throw new IllegalArgumentException();
	this.maximumPoolSize = maximumPoolSize;
	if (workerCountOf(ctl.get()) > maximumPoolSize)
		interruptIdleWorkers();
}
// 其他参数类似

当这些参数被修改的时候,我们希望所有的Worker类线程都可以感知到它的值变化。
例如,在getTask()方法中,当线程数超过corePoolSize时,会通过poll(keepAliveTime)尝试回收线程。
我们需要保证,当corePoolSize的值,被1个线程修改后,对其他线程可见。所以要加上volatile修饰。

 if (workerCount > corePoolSize) {
	 workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS);
 }

聪明的你一定会好奇:
为什么要用volatile而不是原子类(AtomicInteger)
那是因为,corePoolSize这样的参数修改频率较低(通常仅在配置变更时),在此场景中非必需,避免不必要的性能开销。
那为什么不是synchronized?
那是因为
1、synchronized,每次读写corePoolSize都需要加锁/解锁,虽然能保证原子性,但会引入额外性能开销。
2、因为这个修改仅需保证变量的可见性(如corePoolSize的值修改后其他线程立即可见),而无需保证复合操作的原子性(例如i++这类非原子操作)。线程池中corePoolSize的更新通常为直接赋值,如setCorePoolSize()

四、总结

简单状态用volatile,原子操作靠Atomic,复合逻辑需加锁,单例模式防重排。

volatile适用场景

  1. 简单状态标志(如线程启停)。
  2. 一次性发布或双重检查锁(单例模式)。
  3. 独立观察(如配置热更新)。
  4. 需要禁止指令重排序的场景。

volatile不适用场景

  1. 复合操作(如 i++):
    需要原子性时,改用 AtomicIntegersynchronized
  2. 多变量依赖的原子操作:
    例如 if (a && b),需用锁机制保证原子性。
  3. 复杂同步逻辑:
    涉及多个步骤的线程协调时,需用 synchronizedLock

实际开发中的判断逻辑

  1. 是否需要可见性?
    • 如果一个线程修改了变量,其他线程是否需要 立即看到最新值?
    • 是 → 考虑volatile。
  2. 是否涉及指令重排序?
    • 是否存在代码执行顺序依赖(如单例模式中的对象初始化)?
    • 是 → 必须用volatile禁止重排序。
  3. 操作是否原子?
    • 是否是单一读/写操作(如boolean赋值)?
    • 是 → volatile适用。
    • 否(如i++)→ 需要锁或原子类。

后话

什么这么快结束了?你是否觉得volatile就这?
👆关注点上,下一期带你上难度,邀请与您探索Java底层深渊。
在这里插入图片描述

参考

volatile有哪些引用场景?
【IT老齐668】十分钟大白话volatile关键字

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值