Java:java学习笔记之volatile关键字的简单理解和使用

volatile关键字

在这里插入图片描述

1、定义

  • volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)volatile 变量
  • 相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。
  • 但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

2、并发编程的3个基本概念

原子性、可见性 & 有序性
在这里插入图片描述

2.1、原子性

  • 定义: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。
  • 简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。

例如 a=1是原子性操作,但是a++a +=1就不是原子性操作。Java中的原子性操作包括:

  • (1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。
  • (2)所有引用reference的赋值操作
  • (3)java.concurrent.Atomic.* 包中所有类的一切操作

2.2、可见性

定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

2.2.1、Java的内存模型JMM以及共享变量的可见性

Java虚拟机规范试图定义一种Java内存模型(JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。

  • 简单来说,由于CPU执行指令的速度是很快的,但是内存访问的速度就慢了很多,相差的不是一个数量级,所以搞处理器的那群大佬们又在CPU里加了好几层高速缓存。

JMM操作变量的时候不是直接在主存进行操作的,而是每个线程拥有自己的工作内存,在使用前,将该变量的值copy一份到自己的工作内存,读取时直接读取自己的工作内存中的值.写入操作时,先将修改后的值写入到自己的工作内存,再讲工作内存中的值刷新回主存.
在这里插入图片描述

JMM定义了线程和主内存之间的抽象关系:

  • 共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory)
  • 本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

对于普通的共享变量来讲:

  • 线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;
  • 而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。

解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile

需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存

2.2.1.1、举例说明

在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。 使用工作内存和主存,虽然加快的速度,但是也带来了一些问题。比如看下面一个例子:

i = i + 1;

假设i初值为0,当只有一个线程执行它时,结果肯定得到1,当两个线程执行时,会得到结果2吗?这倒不一定了。可能存在这种情况:

线程1: load i from 主存    // i = 0
        i + 1  // i = 1
线程2: load i from主存  // 因为线程1还没将i的值写回主存,所以i还是0
        i +  1 //i = 1
线程1:  save i to 主存
线程2: save i to 主存

如果两个线程按照上面的执行流程,那么i最后的值居然是1了。如果最后的写回生效的慢,你再读取i的值,都可能是0,这就是缓存不一致问题。

2.2.2、可见性

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。

当然,synchronizeLock都可以保证可见性。synchronizedLock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

2.3、有序性

定义:即程序执行的顺序按照代码的先后顺序执行。

Java内存模型中的有序性可以总结为:

  • 如果在本线程内观察,所有操作都是有序的;
  • 如果在一个线程中观察另一个线程,所有操作都是无序的。
  • 前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。

最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronizedLock来保证有序性,synchronizedLock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

3、锁的互斥和可见性

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)

(1)互斥性:互斥即一次只允许一个线程持有某个特定的锁,一次就只有一个线程能够使用该共享数据。

(2)可见性:它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。也即当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  • a.对变量的写操作不依赖于当前值。
  • b.该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

  • 事实上就是保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

4、volatile变量的特性

4.0、JMM八种操作指令

在这里插入图片描述
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。

注意Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,

  • 如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。
  • 除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
    在这里插入图片描述
    说明:
    在这里插入图片描述

4.1、保证可见性

一文搞懂volatile的可见性原理

具体描述

  • volatile修饰的属性保证每次读取都能读到最新的值

但不会 & 无法更新已经读了的值

原理

  • 线程A在工作内存中修改的共享属性值会立即刷新到主存,线程B/C/D每次通过读写栅栏来达到类似于直接从主存中读取属性值

1、只是类似,网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为

2、读写栅栏是一条CPU指令;插入一个读写栅栏 = 告诉CPU & 编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性)

3、读写栅栏另一个作用是强制更新一次不同CPU的缓存。例如,一个写栅栏会 把这个栅栏前写入的数据刷新到缓存,以此保证可见性

4.1.1、实例说明
package xyz.ring2.demo.test;

public class VolatileVisibilityTest {
    public static boolean flag = false;

    public static void changeCondition(){
        flag = true;
    }

    public static  void main(String[] args) throws InterruptedException {
        System.out.println("working and waiting for change...");
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (!flag){
                    System.out.println("hello");
                }
            }
        }).start();

        Thread.sleep(200);

       new Thread(new Runnable() {
            @Override
            public void run() {
                changeCondition();
                System.out.println("condition has changed.");
            }
        }).start();

        Thread.sleep(200);
        System.out.println("work done.");

    }

}

在该程序中有一个共享变量flag,

  • 第一个线程运行时等待别的线程改变flag的值使其跳出循环
  • 第二个线程是去改变共享变量flag的值。

在我们看来,第一个线程只需要等待第二个线程改变了flag的即可跳出循环。以下是程序运行结果:
在这里插入图片描述
可以看到当“work done”打印出来时程序还没有停止,此时我们可以得出结论。两个线程对共享变量的操作是互相不可见的。此时我们很自然的想到了通过加synchronizedJava内置锁来解决。

通过在while循环外添加synchronized(this)同步块确实能解决这种问题,但是在这种仅仅只需要保证一个共享变量可见的情况下采用synchronized锁来保证同步代价太大,此时我们应该采用Java所

提供的volatile关键字来保证变量的可见性。使用上通过在flag前加上volatile关键字即可。

public static volatile boolean flag = false;

在这里插入图片描述
正常的使程序结束了,线程一成功的感知到了线程二对flag变量的改变。

那么volatile关键字使如何保证多线程下共享变量线程间可见的呢?

4.1.2、保证可见行具体说明

JMM中的数据原子操作:
在这里插入图片描述
Java中的volatile关键字是通过调用C语言实现的,而在更底层的实现上,即汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。

具体的实现原理是在硬件层面上通过:

  • MESI缓存一致性协议:多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。

IA32架构软件开发者手册对lock前缀指令的解释:

  • 1.会将当前处理器缓存行的数据立即回写到系统内存中,
  • 2.这个写回内存的操作会引起其他cpu里缓存了该内存地址的数据失效(MESI协议)
    在这里插入图片描述

4.2、保证有序性

具体描述

  • 当对volatile修饰的属性进行读/写操作时,其前面的代码必须已执行完成 & 结果对后续的操作可见

原理

  • 重排序时,以volatile修饰属性的读/写操作代码行为分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。由此保证有序性
4.2.1、指令重排

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

(1)重排序操作不会对存在数据依赖关系的操作进行重排序。

比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

(2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3

int a = 0;
bool flag = false;

public void write() {
    a = 2;              //1
    flag = true;        //2
}

public void multiply() {
    if (flag) {         //3
        int ret = a * a;//4
    }
}

假如有两个线程执行上述代码段,线程1先执行write,随后线程2再执行multiply,最后ret的值一定是4吗?结果不一定

在这里插入图片描述
例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程Aa=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2

4.2.1.1、happens-before原则

JMM有一些内在的规律性,也就是说,没有任何方法可以保证有序,这通常称为发生在原则之前。

<<JSR-133:Java Memory Model and Thread Specification>>定义了如下happens-before规则:

在这里插入图片描述

  • 第1条规则(程序顺序规则):在一个线程里,所有的操作都是按顺序的,但是在JMM里其实只要执行结果一样,是允许重排序的,这边的happens-before强调的重点也是单线程执行结果的正确性,但是无法保证多线程也是如此。
  • 第2条规则(监视器规则):就是在加锁之前,确定这个锁之前已经被释放了,才能继续加锁。
  • 第3条规则:如果一个线程先去写一个变量,另外一个线程再去读,那么写入操作一定在读操作之前
  • 第4条规则,就是happens-before传递性

后面几条就不再一一赘述了。

4.2.1.2、volatile变量的禁止指令重排序:内存屏障

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,下面是基于保守策略的JMM内存平展插入策略。

在这里插入图片描述
JVM的实现会在volatile读写前后均加上内存屏障,在一定程度上保证有序性。如下所示:

LoadLoadBarrier
volatile 读操作
LoadStoreBarrier

StoreStoreBarrier
volatile 写操作
StoreLoadBarrier

volatile在写操作前后插入了内存屏障后生成的指令序列示意图如下:
在这里插入图片描述
volatile在读操作后面插入了内存屏障后生成的指令序列示意图如下:
在这里插入图片描述

4.2.1.3、解决方法:volatile关键字

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:

  • 即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

继续拿上面的一段代码举例:

int a = 0;
volatile bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
}

这段代码不仅仅受到重排序的困扰,即使1、2没有重排序。3也不会那么顺利的执行的:

  • 假设还是线程1先执行write操作,线程2再执行multiply操作
  • 由于线程1是在工作内存里把flag赋值为true,不一定立刻写回主存

所以线程2执行时,multiply再从主存读flag值,仍然可能为false,那么括号里的语句将不会执行。 如果改成下面这样:

int a = 0;
volatile bool flag = false;

public void write() {
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
   if (flag) {         //3
       int ret = a * a;//4
   }
}

那么线程1先执行write,线程2再执行multiply。根据happens-before原则,这个过程会满足以下3类规则:

(1)程序顺序规则:

  • 1 happens-before 2;
  • 3 happens-before 4; (volatile限制了指令重排序,所以1 在2 之前执行)

(2)volatile规则:

  • 2 happens-before 3

(3)传递性规则:

  • 1 happens-before 4

写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存

读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

4.3、不保证原子性

具体描述

  • volatile修饰的属性若在修改前已读取了值,那么修改后,无法改变已经复制到工作内存的值

即无法阻止并发的情况

// 变量a 被volatile修饰 
volatile static int a=0;
a++;
// 包含了2步操作:1 = 读取a、2= 执行a+1 & 将a+1结果赋值给a
// 设:线程A、B同时执行以下语句,线程A执行完第1步后被挂起、线程B执行了a++,那么主存中a的值为1
// 但线程A的工作内存中还是0,由于线程A之前已读取了a的值 = 0,执行a++后再次将a的值刷新到主存 = 1
// 即 a++执行了2次,但2次都是从0变为1,故a的值最终为1

5、volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  • (1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • (2)它会强制将对缓存的修改操作立即写入主存;
  • (3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

6、volatile的使用

  • 由于volatile保证可见性和有序性,被volatile修饰的共享属性一般并发读/写没有问题,可看做是一种轻量级的synchronized实现
  • volatile只可以用来修饰变量,不可以修饰方法以及类

这里举几个比较经典的场景:

  • 状态标记量,就是前面例子中的使用.
  • 一次性安全发布.双重检查锁定问题(单例模式的双重检查).
  • 独立观察.如果系统需要使用最后登录的人员的名字,这个场景就很适合.
  • 开销较低的“读-写锁”策略.当读操作远远大于写操作,可以结合使用锁和volatile来提升性能.

注意事项

  • volatile并不能保证操作的原子性,想要保证原子性请使用synchronized关键字加锁.

6.1、volatile不适用的场景

1.volatile不适合复合操作

例如,inc++不是一个原子性操作,可以由读取、加、赋值3步组成,所以结果并不能达到10000。

  • inc++是个复合操作,包括读取inc的值,对其自增,然后再写回主存。

在这里插入图片描述
按道理来说结果是10000,但是运行下很可能是个小于10000的值。

问题1、有人可能会说volatile不是保证了可见性啊,一个线程对inc的修改,另外一个线程应该立刻看到啊!

  • 可是这里的操作inc++是个复合操作啊,包括读取inc的值,对其自增,然后再写回主存。
  • 假设线程A,读取了inc的值为10,这时候被阻塞了,因为没有对变量进行修改,触发不了volatile规则。
  • 线程B此时也读读inc的值,主存里inc的值依旧为10,做自增,然后立刻就被写回主存了,为11。
  • 此时又轮到线程A执行,由于工作内存里保存的是10,所以继续做自增,再写回主存,11又被写了一遍。
  • 所以虽然两个线程执行了两次increase(),结果却只加了一次。

问题2、有人说,volatile不是会使缓存行无效的吗?

  • 但是这里线程A读取到线程B也进行操作之前,并没有修改inc值,所以线程B读取的时候,还是读的10。

问题3、又有人说,线程B将11写回主存,不会把线程A的缓存行设为无效吗?

  • 但是线程A的读取操作已经做过了啊,只有在做读取操作时,发现自己缓存行无效,才会去读主存的值,所以这里线程A只能继续做自增了。

综上所述,在这种复合操作的情景下,原子性的功能是维持不了了。但是volatile在上面那种设置flag值的例子里,由于对flag的读/写操作都是单步的,所以还是能保证原子性的。

要想保证原子性,只能借助于synchronized,Lock以及并发包下的atomic的原子操作类

  • 即对基本数据类型的自增(加1操作)自减(减1操作)、以及加法操作(加一个数)减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

2.解决方法

1)采用synchronized
在这里插入图片描述
(2)采用Lock

在这里插入图片描述
(3)采用java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的
在这里插入图片描述

6.2、volatile适用的场景距举例

6.2.1、单例模式(懒汉式)的双重锁要加volatile
public class TestInstance{
	private volatile static TestInstance instance;
	
	public static TestInstance getInstance(){        //1
		if(instance == null){                        //2
			synchronized(TestInstance.class){        //3
				if(instance == null){                //4
					instance = new TestInstance();   //5
				}
			}
		}
		return instance;                             //6
	}
}

需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,在第5行会出现问题。instance = new TestInstance();可以分解为3行伪代码

a. memory = allocate() //分配内存
 
b. ctorInstanc(memory) //初始化对象
 
c. instance = memory //设置instance指向刚分配的地址

上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题:

  • 当线程A在执行第5行代码时,B线程进来执行到第2行代码。
  • 假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。
  • 那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象
6.2.2、状态量标记

就如上面对flag的标记

int a = 0;
volatile bool flag = false;

public void write() {
    a = 2;              //1
    flag = true;        //2
}

public void multiply() {
    if (flag) {         //3
        int ret = a * a;//4
    }
}

这种对变量的读写操作,标记为volatile可以保证修改对线程立刻可见。比synchronized,Lock有一定的效率提升。

7、总结

在这里插入图片描述

参考

1、鲜为人知的关键字volatile
2、Java volatile关键字最全总结:原理剖析与实例讲解(简单易懂)
3、Java面试官最爱问的volatile关键字

### PyCharm 打开文件显示全的解决方案 当遇到PyCharm打开文件显示全的情况时,可以尝试以下几种方法来解决问题。 #### 方法一:清理缓存并重启IDE 有时IDE内部缓存可能导致文件加载异常。通过清除缓存再启动程序能够有效改善此状况。具体操作路径为`File -> Invalidate Caches / Restart...`,之后按照提示完成相应动作即可[^1]。 #### 方法二:调整编辑器字体设置 如果是因为字体原因造成的内容显示问题,则可以通过修改编辑区内的文字样式来进行修复。进入`Settings/Preferences | Editor | Font`选项卡内更改合适的字号大小以及启用抗锯齿功能等参数配置[^2]。 #### 方法三:检查项目结构配置 对于某些特定场景下的源码视图缺失现象,可能是由于当前工作空间未能正确识别全部模块所引起。此时应该核查Project Structure的Content Roots设定项是否涵盖了整个工程根目录;必要时可手动添加遗漏部分,并保存变更生效[^3]。 ```python # 示例代码用于展示如何获取当前项目的根路径,在实际应用中可根据需求调用该函数辅助排查问题 import os def get_project_root(): current_file = os.path.abspath(__file__) project_dir = os.path.dirname(current_file) while not os.path.exists(os.path.join(project_dir, '.idea')): parent_dir = os.path.dirname(project_dir) if parent_dir == project_dir: break project_dir = parent_dir return project_dir print(f"Current Project Root Directory is {get_project_root()}") ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值