深度解析)
JCSprout 并发基石Java 多线程三大核心原子性、可见性、顺序性深度解析【免费下载链接】JCSprout Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout导读本篇文章以 JCSprout 项目中docs/thread/Threadcore.md为骨架系统讲解 Java 并发编程绕不开的三大核心特性——原子性Atomicity、可见性Visibility、顺序性Ordering并结合仓库src/main/java/com/crossoverjie/concurrent目录下的真实源码Singleton、Volatile、VolatileInc、StopThread等逐行印证volatile、synchronized、AtomicInteger的底层原理与实战用法。读完你将掌握为什么i不是原子操作、volatile到底能保证什么不能保证什么、双重检查锁单例为何必须加volatile以及如何正确使用标记变量优雅地停止线程。一、原子性一个操作要么全部成功要么全部失败1.1 什么是原子性Java中的原子性Atomicity与数据库事务的原子性类似一个操作要么全部执行成功要么全部执行失败不存在中间状态。JMMJava 内存模型只保证了基本的原子性即对基本数据类型变量的读取、赋值这类简单操作是原子的。1.2i为什么不是原子操作看似原子的i实际上在底层被拆分为三步获取i的值执行自增1将结果再赋值给i。这三步操作之间随时可能被线程调度打断因此多个线程并发执行i时最终结果往往小于预期。仓库中的 VolatileInc.java 正是用来演示这一问题的private static volatile int count 0 ; //使用 volatile 修饰基本数据内存不能保证原子性 Override public void run() { for (int i0;i10000 ;i){ count ; } }main方法中启动t1、t2两个线程加上main线程自身三个线程同时对count累加 10000 次最终打印的count几乎必然小于 30000。这正是因为count 的“取值-自增-赋值”三步无法原子完成即使count被volatile修饰保证可见性也无济于事。想要让i具备原子性通常有三种方案使用synchronized或Lock加锁处理同一时刻只允许一个线程进入临界区让多个线程串行执行退化为单线程无法发挥多线程优势使用AtomicInteger这类原子类其本质是利用 CPU 级别的CASCompare And Swap指令完成。1.3 AtomicInteger 与 CAS原子自增的正确姿势对于基础类型的自增操作AtomicInteger是最常用的原子类。其中使用频率最高的方法就是incrementAndGet()——以原子的方式自增并返回新值。其源码逻辑如下public final long incrementAndGet() { for (;;) { long current get(); long next current 1; if (compareAndSet(current, next)) return next; } }执行流程是先获取当前值current计算自增后的next current 1然后调用最核心的compareAndSet(current, next)进行原子更新。CAS 的源码如下public final boolean compareAndSet(long expect, long update) { return unsafe.compareAndSwapLong(this, valueOffset, expect, update); }其逻辑为判断当前内存中的值是否仍等于期望值current——如果相等说明期间没有被其他线程更新过于是将值原子地更新为next并返回true如果不相等则返回false外层for(;;)进入下一轮循环重新读取最新值并重试直到更新成功为止。这就是经典的自旋 CAS模式。值得强调的是AtomicInteger中的get()方法同样关键它返回的当前值用volatile修饰保证了内存可见性private volatile int value;也就是说原子类内部本身就是volatile 保证可见性 CAS 保证原子性的组合两者缺一不可。在 JCSprout 仓库中AtomicInteger被广泛用于并发组件CustomThreadPool.java 用AtomicInteger totalTask记录提交到线程池的任务总数并在任务提交时调用totalTask.incrementAndGet()自增计数MultipleThreadCountDownKit.java 用AtomicInteger counter实现多线程协作的倒计数工具LRUAbstractMap.java 用volatile AtomicInteger size记录 LRU Map 的大小并在插入元素时调用size.incrementAndGet()。这些源码印证了原子类在真实并发组件中的落地方式用 CAS 保证计数操作的原子性用 volatile 保证计数值对各线程可见。二、可见性一个线程的修改另一个线程何时能看见2.1 CPU 高速缓存带来的可见性问题现代计算机中CPU直接从主内存读取数据的效率不高因此每个CPU核心都配有高速缓存Cache。线程修改数据时首先更新到缓存之后才刷回主内存。如果数据尚未刷回主内存其他线程此时读取到的就是修改之前的数据。用JMM的语言描述所有变量都存放在主内存中每个线程拥有自己的工作内存可简单理解为线程栈对应的缓存区域。线程工作时需要把主内存中的变量拷贝到工作内存对变量的所有操作都基于工作内存完成之后再刷新回主内存。在这种模型下并发运行时线程 B 读到的很可能就是线程 A 更新之前的数据从而引发数据不一致。2.2 volatile 如何保证可见性volatile关键字就是用于解决可见性问题的当一个变量被volatile修饰时任何线程对它的写操作都会立即刷新到主内存并且强制使其他线程缓存中该变量的副本失效清空其他线程只能重新从主内存读取最新值。也就是说每次读取 volatile 变量都能拿到最新数据无论哪个线程修改它都会立即同步到主内存。需要澄清的是volatile修饰后并不是让线程直接从主内存获取数据线程依然会将该变量拷贝到工作内存只是每次读写都强制与主内存同步并保证其他线程缓存副本立即失效。2.3 synchronized 与锁也能保证可见性synchronized和加锁同样可以保证可见性其实现原理是在释放锁之前其他线程访问不到这个共享变量——进入临界区必须重新从主内存读取退出临界区则强制将修改刷回主内存。但与volatile相比加锁的线程阻塞与唤醒开销要大得多。volatile不涉及锁竞争属于轻量级同步机制因此适合单写多读的标记类场景。三、顺序性指令重排与 happen-before3.1 什么是指令重排看下面这段代码int a 100 ; //1 int b 200 ; //2 int c a b ; //3正常情况下执行顺序应该是1 → 2 → 3。但 JVM 为了提高整体执行效率可能会进行指令重排Instruction Reordering使实际执行顺序变为2 → 1 → 3。当然JVM 的重排不是任意的它遵循一个前提在保证单线程最终结果与代码顺序执行结果一致的情况下才允许重排as-if-serial 语义。3.2 重排带来的并发隐患指令重排在单线程中不会出现问题但在多线程场景下可能引发数据不一致。例如下面这段伪代码private static MapString,String value ; private static boolean flag false ; // 线程 A 中执行初始化 Map public void initMap(){ value getMapValue() ; //1 flag true ; //2 } // 线程 B 中执行等待 Map 初始化完成后使用 public void doSomeThing(){ while(!flag){ sleep() ; } doSomeThing(value); // 可能拿到尚未初始化的 value }如果flag没有用volatile修饰JVM 可能对语句 1 和 2 进行重排导致value还没初始化完成时线程 B 就已经跳出循环去使用它从而引发错误。加上volatile之后可以禁止这种重排保证业务的正确性。3.3 volatile、锁与 happen-before 原则Java 中可以用volatile保证顺序性synchronized和Lock也能保证有序性其原理与保证原子性一致——通过同一段时间只允许一个线程访问临界区从互斥的角度杜绝了重排导致的跨线程错误。除了volatile的显式保证之外JVM 还通过happen-before先行发生原则隐式地保证顺序性。其中与volatile直接相关的一条是对volatile变量的写操作一定先行发生于对该变量的读操作。这意味着任何线程读取 volatile 变量时拿到的必然是最新写入的值写读之间建立了跨线程的 happens-before 关系。四、volatile 的两大经典应用附仓库源码4.1 应用一双重检查锁的单例模式volatile最常见的应用场景之一就是双重检查锁Double-Checked Locking的单例模式。仓库中的 Singleton.java 即为完整实现public class Singleton { private static volatile Singleton singleton; private Singleton() { } public static Singleton getInstance() { if (singleton null) { synchronized (Singleton.class) { if (singleton null) { //防止指令重排 singleton new Singleton(); } } } return singleton; } }这里的volatile关键字主要是为了防止指令重排。如果不加volatilesingleton new Singleton();这行代码在底层其实会被拆成三步分配内存空间初始化对象执行构造函数将singleton引用指向分配的内存地址。问题在于JVM 可能把第 2 步和第 3 步重排先让singleton指向一块尚未完成初始化的内存。此时另一个线程通过第一重if (singleton null)检查发现引用非空直接返回了一个尚未初始化完成的单例对象使用时就可能抛异常。加上volatile后三步操作被强制按顺序执行彻底杜绝了拿到未初始化对象的风险。volatile在这里同时承担了两个职责保证单例引用对各线程可见防止拿到旧引用禁止对象发布时的指令重排防止拿到半初始化对象。4.2 应用二控制停止线程的标记volatile另一个经典应用是作为线程停止的标记位。仓库中的 Volatile.java 演示了完整用法public class Volatile implements Runnable{ private static volatile boolean flag true ; Override public void run() { while (flag){ } System.out.println(Thread.currentThread().getName() 执行完毕); } public static void main(String[] args) throws InterruptedException { Volatile aVolatile new Volatile(); new Thread(aVolatile,thread A).start(); // ... 主线程等待输入 1 后调用 stopThread() } private void stopThread(){ flag false ; } }核心思想thread A在while (flag)循环中持续工作当某个线程调用stopThread()将flag置为false后thread A观察到变化退出循环并结束。这里如果没有用volatile修饰flagstopThread()修改了flag的值却不会立即刷新到主内存其他线程thread A的工作内存中仍是旧值循环就不会立即停止甚至可能永远停不下来。该场景利用的正是 volatile 的内存可见性语义。对比阅读仓库中还有一个基于中断机制的 StopThread.java它通过Thread.currentThread().isInterrupted()配合thread.interrupt()实现线程停止。两种方式都遵循协作式停止理念区别在于 volatile 标记更简单直接而中断机制可以同时唤醒sleep/wait中的阻塞线程实际项目中可根据场景选用。五、volatile 的局限不能保证原子性总结volatile的能力边界volatile关键字只能保证可见性和顺序性不能保证原子性。回到 VolatileInc.java 的验证结果即使count被volatile修饰三个线程并发执行count 的最终结果依然小于 30000。原因正如前文所述——count 的三步操作中任何一步都可能被打断volatile 只解决了读到的值是否最新解决不了三步操作是否被打断。正确的修正是把count换成AtomicInteger源码中已注释的//private static AtomicInteger count new AtomicInteger();并调用count.incrementAndGet()利用 CAS 从底层保证自增的原子性。三者的选用建议需求推荐手段说明仅需标记/开关类变量的可见性volatile单写多读开销最小需要复合操作的原子性synchronized/Lock/Atomic*互斥或 CAS需要禁止指令重排volatile如双重检查锁单例需要线程间写读顺序volatile借助 happen-before 规则六、总结本篇文章完整梳理了 Java 多线程编程的三大核心原子性i并非原子操作需要synchronized、Lock或基于 CAS 的AtomicInteger来保证AtomicInteger.incrementAndGet()通过volatile 值 CAS 自旋实现线程安全的自增。可见性CPU 缓存导致线程间的修改不可见volatile通过写立即刷主存、读强制失效缓存解决synchronized/锁也能保证可见性但开销更大。顺序性JVM 可能指令重排volatile可禁止相关重排synchronized/锁通过互斥保证有序同时 JVM 通过 happen-before 原则含 volatile 写读规则隐式保障顺序。volatile在 Java 并发体系中应用极广Atomic包内部的值、AbstractQueuedLongSynchronizerAQS中的state等关键字段都被定义为volatile以保证内存可见性。把这三块理解透彻是编写健壮并发程序的基础。如果你想亲手运行验证可查看 VolatileInc.java观察并发自增结果小于预期、Volatile.java观察 volatile 标记停止线程与 Singleton.java双重检查锁单例更系统的 volatile 专题可见仓库中的 MD/concurrent/volatile.md 与 docs/jvm/volatile.md相关多线程常见问题上下文切换、死锁、资源限制可继续阅读 Thread-common-problem.md。【免费下载链接】JCSprout Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考