还在无脑使用synchronized?volitale或许能更优雅的帮到你

本文从Java内存模型入手,介绍了volatile关键字在并发编程中的特性。它能保证可见性和禁止指令重排,但无法保证原子性。文中通过代码案例展示了其作用,还介绍了加锁和原子类保障线程安全的方法,最后说明了volatile的应用场景及与synchronized的区别。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

面试的时候是否被问过volitale关键字?多线程并发编程时是否直接怼synchronized?volitale到底有什么用?volitale和synchronized又有什么区别?可见性,指令重排,原子性又是怎么回事?volitale原理是什么?如果你有这样的疑问,那么先恭喜你看到了这篇宝藏文章!

本合集的《Java线程安全问题和解决方案》一文中,指出Java多线程在操作共享数据时会有线程安全问题,解决线程安全问题通常手段是加锁,通过 synchronized 关键字或者通过Lock接口实现。使用锁之后线程在执行程序时会去获取锁,在执行效率上会降低,所以在一些简单场景下,我们可以使用volatile关键字来代替注意不是所有的场景都可以使用,文中会根据理论和代码逐一介绍volatile的相关特性,在文章末尾总结了使用场景。

如果想了解 volatile 需要从Java内存模型【JMM】以及并发编程中的可见性、有序性、原子性入手

注意:判断是否支持并发的三要素:可见性、有序性、原子性,其实我们都知道volatile无法保证原子性,所以很多情况无法使用的,只有某些场景可以使用,所以开头就说了,某些简单的场景就可以使用这个volatile,毕竟volatile是轻量级的synchronized

Java内存模型

《Java虚拟机规范》中定义Java内存模型来屏蔽各个硬件和操作系统的内存访问差异。Java的内存模型(Java Memory Mode, JMM)指定了Java虚拟机如何与计算机的主存(RAM)进行工作。如下图所示:

在这里插入图片描述

Java内存模型规定了一个线程对共享变量的写入何时对其他线程可见,定义了线程和内存之间的抽象关系。具体如下:

  • 共享变量存储于主内存之中,每个线程都可以访问
  • 每个线程都有私有的工作内存或称为本地内存
  • 工作内存只存储该线程对共享变量的副本
  • 线程不能直接操作主内存,只有先操作了工作内存数据副本之后,才能将操作后的数据再写入主内存。
  • 工作内存和Java内存模型一样也是一个抽象的概念,它其实并不存在,根据不同的Java虚拟机实现具体的数据存储位置,如Hotspot虚拟机,根据寄存器、方法区、堆内存以及硬件等存储数据

可见性

比如下方代码:

  • 声明一个flag变量,在线程1中将其修改为false
  • 主线程中一个while循环,当flag为true时一直循环,当为false时跳出循环,执行结束语句
public class VolatileMain {
    // 运行标记
    private static boolean flag = true;
    
    public static void main(String[] args) {

        //创建第一个线程
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"开始执行时,flag = "+flag);
            // 睡眠3秒
            try{ Thread.sleep(3000);}catch (Exception e){e.printStackTrace();}
            // 将运行标记设置为false
            flag = false;
            System.out.println(Thread.currentThread().getName()+"执行add()方法之后,flag = "+flag);
        },"线程1").start();

        //第二个是main线程
        while (true){
            // 如果第二个main线程 可以监测到flag值的改变,就会跳出当前循环,执行后续程序。
            if(!flag) {
                break;
            }
        }
        System.out.println(Thread.currentThread().getName()+"程序结束!");
    }
}

运行结果:

当子线程修改flag值为false后,主线程的while循环并未停止,说明主线程并没有发现flag值被另外的线程修改

在这里插入图片描述
分析:

  • 基于Java8使用的HotSpot虚拟机实现来说,静态变量 flag 存储于方法区中,被所有线程共享
  • 当线程1启动时需要使用flag变量就会将其拷贝进线程1的工作内存,并且修改值为false
  • 主线程使用该变量也是拷贝进自己的工作内存,当拷贝进去时flag变量值都为true,线程1睡眠3秒之后修改了值,并将值刷新进主内存
  • 但是此时主线程循环使用的flag值并不是主内存中最新的,而是线程启动时就拷贝进来的值,所以循环并没有停止,也就是主线程并没有发现值被修改了,因为他没有去获取最新的值

想要解决这个问题有两种方案

  • 加锁
  • 保障变量修改后被其他线程可见

加锁

我们对flag的判断进行加锁处理

public class VolatileMain {
    private static boolean flag = true;
    public static void main(String[] args) {
        //创建第一个线程
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"开始执行时,flag = "+flag);
            try{ Thread.sleep(3000);}catch (Exception e){e.printStackTrace();}
            flag = false;
            System.out.println(Thread.currentThread().getName()+"执行add()方法之后,flag = "+flag);
        },"线程1").start();

        //第二个是main线程
        while (true){
            // 加锁
            synchronized (VolatileMain.class) {
                if(!flag) {
                    break;
                }
            }
        }
        System.out.println(Thread.currentThread().getName()+"程序结束!");
    }
}

运行结果:

在这里插入图片描述
**分析:**为什么加了锁就能获取到最新的值了呢?

因为线程进入 synchronized 代码块之后,它的执行过程如下:

  • 线程获取锁
  • 从主内存拷贝共享数据的最新值到工作内存中
  • 执行代码
  • 将修改后的值刷新到主内存
  • 线程释放锁

volatile实现可见性

加了volatile关键字修饰的变量,只要有一个线程将主内存中的变量值做了修改,其他线程都将马上收到通知,立即获得最新值。当写线程修改一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存。当读线程获取一个volatile变量时,JMM会把该线程对应的本地工作内存置为无效,线程将到主内存中重新读取共享变量

解决方案:我们对变量flag使用 volatile 修饰,就可以保障线程在使用该变量时会从主内存获取最新值

public class VolatileMain {
    private volatile static boolean flag = true;
    public static void main(String[] args) {
        //创建第一个线程
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"开始执行时,flag = "+flag);
            try{ Thread.sleep(3000);}catch (Exception e){e.printStackTrace();}
            flag = false;
            System.out.println(Thread.currentThread().getName()+"执行add()方法之后,flag = "+flag);
        },"线程1").start();

        //第二个是main线程
        while (true){
            if(!flag) {
                break;
            }
        }
        System.out.println(Thread.currentThread().getName()+"程序结束!");
    }
}

运行结果: 发现当修改了flag值之后,main线程也跳出了while循环

在这里插入图片描述
分析:

  • 线程1从主内存读取共享数据到工作内存
  • 睡眠3秒后,将值修改为false,此时共享数据被volatile修饰,就会强制将最新的值刷新回主内存
  • 当线程1将值刷新到主内存之后,其他线程的共享变量就会作废
  • 再次对共享变量操作时,就会读最新的值,放到工作内存中

volatile修饰的变量可以在多线程情况下,修改数据可以实现线程之间的可见性

有序性(禁止指令重排序)

**指令重排:**在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,软件和硬件都会为了:在不改变程序执行结果的前提下,尽可能提高执行效率,JMM对底层尽量减少束缚,使其能够发挥自身优势。因此:在程序运行时,为了提高性能,编译器和处理器常常会对指令进行重排。重排序一般分3种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

在这里插入图片描述

指令重排图解

我们知道Java执行会将代码转换为指令集,变量a和变量b并没有直接的依赖关系,左边为重排之前代码,需要加载和保存a变量两次,右侧为重排之后的代码,只需要加载和保存a变量一次,提高了执行效率

在这里插入图片描述

指令重排问题

根据以下来需求证明存在指令重排:

  • a,b,i,j四个变量初始值为0
  • 开启两个线程,分别对a和b赋值为1,再将b的值赋值给i,将a的值赋值给j,
  • 因为指令重排问题,i和j的值有四种情况
  • 分别输出第count次,i和j的值分别为多少,为了控制输出条数,声明result1,result2,result3,result4四个变量记录每一种情况
  • 当所有情况都出现之后跳出while循环,结束程序
public class OrderResetMain {
    // 共享变量a,b,i,j初始值都为0
    private static int a,b = 0;
    private static int i,j = 0;

    public static void main(String[] args) throws InterruptedException {
        // 记录每种情况是否已经出现
        boolean result1 = false;
        boolean result2 = false;
        boolean result3 = false;
        boolean result4 = false;
        // 执行次数
        int count = 0;
        // 不断循环开启线程
        while(true) {
            // 每次执行次数+1
            count++;
            // 每次重置四个变量
            a = 0;
            b = 0;
            i = 0;
            j = 0;
            Thread t1 = new Thread(() -> {
                // 交替赋值
                a = 1;
                i = b;
            }, "线程1");

            Thread t2 = new Thread(() -> {
                // 交替赋值
                b = 1;
                j = a;
            }, "线程2");
            // 启动线程
            t1.start();
            t2.start();
            // 插入线程,需要等待t1和t2线程运行结束才会执行main线程
            t1.join();
            t2.join();
            // 分别针对四种情况只做一次输出
            if(!result1 && i == 0 && j == 0) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result1 = true;
            }
            if(!result2 && i == 0 && j == 1) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result2 = true;
            }
            if(!result3 && i == 1 && j == 0) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result3 = true;
            }
            if(!result4 && i == 1 && j == 1) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result4 = true;
            }
            // 当所有情况都出现之后结束循环
            if(result1 && result2 && result3 && result4) {
                break;
            }
        }
    }
}

运行结果:这段程序的i和j的值有四种情况

在这里插入图片描述
分析:

出现这四中情况的原因是程序并没有同步加锁,导致线程切换执行,同时因为指令重排,同一个线程内部的程序在执行时调换了代码的顺序,按照之前的认识,线程内代码执行的顺序是不变的,也就是线程1的a = 1肯定在 i = b之前执行,第二个线程的b = 1在j = a之前执行

但是实际上线程1和线程2内部的两行代码的执行顺序和源代码中写的不一致,因为虚拟机在执行代码时发现i = b这行代码与上边的a = 1这行之间没有必然联系,它认为重排不会影响执行结果,线程1对线程2的代码是无知的,线程之间的代码是独立的,所以就出现了i = 0,j = 0 的情况。

如果没有指令重排,即保障线程中两行代码的执行顺序和编写顺序一样,那么就不会出现i = 0,j = 0的情况

public class OrderResetMain {
    // 使用 volatile修饰变量,保障用到该变量的代码是有序的
    private volatile static int a,b = 0;
    private volatile static int i,j = 0;

    public static void main(String[] args) throws InterruptedException {
        boolean result1 = false;
        boolean result2 = false;
        boolean result3 = false;
        boolean result4 = false;
        int count = 0;
        while(true) {
            count++;
            a = 0;
            b = 0;
            i = 0;
            j = 0;
            Thread t1 = new Thread(() -> {
                // 交替赋值
                a = 1;
                i = b;
            }, "线程1");

            Thread t2 = new Thread(() -> {
                // 交替赋值
                b = 1;
                j = a;
            }, "线程1");

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            if(!result1 && i == 0 && j == 0) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result1 = true;
            }
            if(!result2 && i == 0 && j == 1) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result2 = true;
            }
            if(!result3 && i == 1 && j == 0) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result3 = true;
            }
            if(!result4 && i == 1 && j == 1) {
                System.out.println("第" + count + "次,i = " + i + ",j = " + j);
                result4 = true;
            }

            if(result1 && result2 && result3 && result4) {
                break;
            }
        }
    }
}

原子性

所谓原子性是指在一次操作或多次操作中,要么所有的操作全部得到执行并且不受任何因素干扰而中断,要么所有的操作都不执行,而volatile不保障原子性

**案例:**开启100个线程每个线程对count值累加10000次,那么最后的正确结果应该是 1000000。

public class VolatileAtomic {
    // 共享变量
    private static int count = 0;
    public static void main(String[] args) {
        // 开启100个线程
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 线程中累加数据
                for (int j = 0; j < 10000; j++) {
                    count++;
                    System.out.println(Thread.currentThread().getName() + "count====>" + count);
                }
            },"第" + i + "个线程").start();
        }
    }
}

运行结果:发现多次运行结果并没有累加到1000000,当然也可能加到正确的结果,这里我的运气比较差

在这里插入图片描述

结果分析:

以上的问题出现在count++上,这个操作其实是分为了三个步骤:

  • 从主内存中将count变量加载工作内存,可以记作iload
  • 在工作内存中对数据进行累加,可以记作iadd
  • 再将累加后的值写回到主内存,可以记作istore

count++不是一个原子操作,也就是在某一个时刻对某一个指令操作时,可能被其他线程打断

在这里插入图片描述

  • 如果此时count的值为100,线程1需要对变量自增,首先需要从主内存中将变量count读取到线程的工作内存,此时因为不是原子操作,CPU时间片发生切换,线程2运行,此时线程1变为就绪状态,线程2变为运行状态
  • 线程2也需要对count进行自增操作,同样的将count的值从主内存读取到线程2的工作内存,此时count值还未被修改,仍然为100
  • 线程2对count进行了+1操作,线程2的工作内存的值变为了101,但是没有被刷新到主内存
  • 此时,CPU放弃执行线程2,转而执行线程1,由于此时线程2的并未被刷新进主内存,因此线程1工作内存的count值仍然为100,线程1进行了+1操作,工作内存中的值变为101
  • 然后线程2执行,将101刷新会主内存,线程1也执行,也是将101刷新进主内存,此时就会出现两个线程累加,但是只对值修改了一次

volatile原子性测试:

如下图,通过对变量count添加volatile发现并没有解决多线程count的累加问题,多次运行仍然累加不到1000000。

在这里插入图片描述
那是因为volatile不保障原子性,也就是count++还是被分割为三个操作,iload,iadd和istore。只保障线程使用值时获取到的是别的线程修改后的最新值,并不能保障一个操作的原子性,如下图:

在这里插入图片描述

  • iload数据时并没有任何一个线程修改数据,所以获取到的还是100
  • 因为被volatile修饰,所以当线程执行add之后,就会将最新的值刷新进主内存,并将其他线程获取到的旧值作废
  • 如果CPU是单核,此时其实可以解决累加问题,但是此时,我们CPU是多核,可以同时执行多个线程,线程1和 线程2如果同时执行add,就不会获取最新的值,仍然会出现少加情况

小结

volatile关键字可以保证可见性和禁止指令重排,但是不能保证对数据操作的原子性,所以在多线程并发编程的情况下如果对共享数据进行计算,使用volatile仍然是不安全的,我们可以通过加锁或者使用原子类保障线程安全

加锁保障线程安全

通过synchronized将操作count共享变量的代码同步起来

加锁方式1:

这里我将线程中的for循环直接同步了,锁的范围有点大,但是可以减少获取锁的次数,如果在线程的10000循环内加锁的话,线程内部每次循环都需要重新获取锁,反而影响性能

public class VolatileAtomic {
    private volatile static int count = 0;
    public static void main(String[] args) {
        // 开启100个线程
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 加锁
                synchronized (VolatileAtomic.class) {
                    // 线程中累加数据
                    for (int j = 0; j < 10000; j++) {
                        count++;
                        System.out.println(Thread.currentThread().getName() + "count====>" + count);
                    }
                }
            },"第" + i + "个线程").start();
        }
    }
}

加锁方式2:

一般都是建议减小锁粒度,即只锁住操作共享数据的代码,也就是只锁住count++就行了,但是线程内有循环,这样每次循环都需要再获取一次锁,虽然synchronized是可重入锁,虽然不用判断是否被占用,可以直接获取到锁,但是还是仍然会执行 monitorentermonitorexit指令,多少还是影响性能,不建议此种写法

执行结果:

加锁之后,就可以保障线程安全,可以获取到正确的结果,当然我们也可以使用Lock加锁在本合集的《Java线程安全问题和解决方案》一文中有介绍,在这就不多赘述

在这里插入图片描述

原子类解决线程安全

Java5开始提供了
java.util.concurrent.atomic简称【Atomic包】,这个包中的原子操作类提供了一种用法简单,性能高效,线程安全的操作变量的方式
在这里插入图片描述

AtomicInteger

原子型Integer,可以实现整型原子修改操作

在这里插入图片描述
通过原子类改造:

  • 声明 AtomicInteger 类型的原子类共享数据
  • 通过incrementAndGet方法自增后返回新值
  • 线程中没有加锁,仍然实现了线程安全
import java.util.concurrent.atomic.AtomicInteger;

public class VolatileAtomic {
    // 声明原子类整型,不传参默认为0
    private static AtomicInteger atomicInteger = new AtomicInteger();

    public static void main(String[] args) {
        // 开启100个线程
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 线程中累加数据
                for (int j = 0; j < 10000; j++) {
                    // 累加1并返回新值
                    System.out.println(Thread.currentThread().getName() + "atomicInteger====>" + atomicInteger.incrementAndGet());
                }
            }, "第" + i + "个线程").start();
        }
    }
}

运行结果:

多次运行发现都是正确的结果,实现了线程安全

在这里插入图片描述

原子类源码

原子类中的值通过volatile修饰,保障数据可见性

在这里插入图片描述
incrementAndGet方法源码:

  • 累加后获取值的方法调用了 unsafe 对象的getAndAddInt方法
  • 在getAndAddInt方法中有一个do…while循环,判断条件调用了 this.compareAndSwapInt()方法
  • 其实是通过CAS实现的原子操作
public final int incrementAndGet() {
  // 调用方法
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
  // 循环判断和比较值是否是上一次修改的结果
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
volatile写读建立的happens-before关系

上边我们说为了提高运算速度,JVM会编译优化,也就是进行指令重排,并发编程下指令重排会带来安全隐患:如指令重排导致的多个线程之间的不可见性,如果让程序员再去了解这些底层的实现规则,那就太难太卷了,严重影响并发编程效率

从Java5开始,提出了happens-before【发生之前】的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这两个操作既可以是在一个线程之内,也可以是在不同线程之间。 所以为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它瞎优化!

简单来说: happens-before 应该翻译成: 前一个操作的结果可以被后续的操作获取。就是前面一个操作变量a赋值为1,那后面一个操作肯定能知道a已经变成了1。这就是volatile修饰变量可见性的原因

由此可见:使用volatile修饰绝不是花活而是科学的必要

volatile应用场景

因为volatile并不能保障原子性,所以如果多线程对共享数据进行计算的场景下还是需要加锁,使用volatile并不能保障线程安全,volatile适用于:

  • 单纯的赋值,比如将flag的值改为true或者false,不适用于count++这样的非原子操作
  • 监视数据变化,比如检测到flag的值变为true,就退出循环等操作,当温度超过40度就报警等

比如:我们上边的可见性问题的案例
所以volatile应用场景并不是非常广泛,主要是为了解决同步加锁太重的问题,在某些场景下可以使用volatile解决部分线程安全问题

volatile与synchronized

  • volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
  • volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他 (互斥) 的机制。
  • volatile用于禁止指令重排序: 可以解决单例双重检查对象初始化代码执行乱序问题
  • volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

总结

  • volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值。比如boolean flag ,或者监视数据变化,实现轻量级同步

单纯的赋值,比如将flag的值改为true或者false,不适用于count++这样的非原子操作
监视数据变化,比如检测到flag的值变为true,就退出循环等操作,当温度超过40度就报警等

  • volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性,因为无锁不需要花费时间在获取锁和释放锁上,所以说它是低成本的
  • volatile只能作用于变量上,我们用volatile修饰实例变量和类变量,这样编译器就不会对这个属性做指令重排序
  • volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取
  • volatile提供了happens-before保证,对volatile变量的写入,happens-before保障所有其他线程对变量的读操作都是可知的
  • volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值