当前位置: 首页 > 科技观察

深入理解Volatile关键字_0

时间:2023-03-17 12:13:15 科技观察

volatile关键字是Java语言的一个高级特性,但是要理解它是如何工作的就需要了解Java的内存模型。要开始使用volatile关键字,我们需要弄清楚它的真正含义。总的来说,它有两个含义,即:GuaranteedvisibilityprohibitsinstructionreorderingGuaranteedvisibilityGuaranteedvisibility的意思是:当一个线程修改了一个变量时,所有其他线程都知道这个变量被修改了。由于volatile可以保证可见性,Java现在可以保证在读取volatile变量时,线程读取的值是准确的。但这并不意味着对volatile变量的操作是线程安全的,因为有可能其他线程在读取变量后修改该变量。为了说明这个问题,我们可以举一个简单的例子。下面代码启动20个线程,每个线程对race变量进行10000次自增操作。如果这段代码能够正确并发执行,最终输出应该是200,000。但实际上每次输出的结果都不一样,都是小于20万的数字。为什么?这是因为当一个线程获取到race变量的值然后自增时,其他线程可能会对race变量进行自增操作,然后写回主存。当当前线程将数据写回主存时,就会发生数据覆盖。因此,就会出现数据不一致的问题。防止volatile变量出现并发安全问题,只需要遵守以下两条规则:运算结果不依赖于变量的当前值,或者保证只有单线程修改变量的值多变的。变量不需要与其他状态变量一起参与不变性约束。第一条规则更容易理解。比如上面例子中race变量的结果依赖于变量的当前值,所以不符合第一条规则,所以会存在线程安全问题。但是如果race++变成race=1;那么race的值不依赖于变量的当前值,所以不会有线程安全问题。第二条规则有点模糊。意思是变量不能和其他变量一起判断,不管其他变量是否是volatile类型的变量。比如if(a&&b)的判断不能满足volatile的第二条规则,就会出现线程安全问题,即使两个变量都是volatile类型的变量。关于第二条规则的描述,为什么不能和其他变量一起保证线程安全呢?要回答这个问题,我们不妨假设各种可能的情景。我们假设变量a和b的初始值都为真,并且都是volatile类型的变量。场景一:线程A执行if(a&&b)判断,先判断变量a,发现为真,再继续判断变量b。变量b也被发现为真,所以整个表达式为真。场景二:线程A执行if(a&&b)判断,先判断变量a,发现为真。这时,线程B将变量b的值修改为false。然后线程A继续判断变量b的值,发现变量b的值为false。然后整个表达式的计算结果为false。通过上面的例子,我们发现同一个表达式在不同的并发场景下会有不同的结果,这显然是线程不安全的。因为线程安全的代码,单线程和多线程下的结果应该是一样的。禁止指令重排序指令重排序是指为了在硬件层面加快执行速度,可能会调整指令的执行顺序,导致不按照代码顺序执行。例如,在下面的代码中,我们将flag变量初始化为false,然后将flag变量设置为true。但是这样的代码在并发执行的时候,有可能先把flag位置设置为true,然后再把flag改成false,造成线程安全问题。布尔标志=假;标志=真;我们说volatile变量禁止指令重排序,其实就是说volatile修饰的变量的执行顺序是不能重排序的。禁止重新排序的实现是使用了一种叫做“内存屏障”的东西。简单的说,内存屏障的作用就是当指令重新排序时,后面的指令不能重新排序到内存屏障之前的位置。前面我们说了可见性的来源:当一个被volatile修饰的变量被修改时,其他变量可以立即获取到它的变化。但是这种可见性的来源在哪里?为什么能达到这样的知名度?实际上,volatile的这些功能来源于Java内存模型中为volatile变量定义的特殊规则。假设T代表一个线程,V和W分别代表两个volatile变量。Java内存模型规定,在执行read、load、use、assign、store、write操作时需要满足以下规则:只有当线程T对变量V执行的前一个动作为load时,线程T才能对变量执行V使用动作。并且,只有当线程T对变量V执行的最后一个动作是use时,线程T才能对变量V执行加载动作。只有当线程T对变量V执行的前一个动作是assign时,线程T才能对变量执行存储动作五;只有当线程T对变量V执行的下一个动作是store时,线程T才能对变量V执行store动作。变量V执行assign动作。假设动作A是由线程T对变量V实现的使用或分配动作,假设动作F是与动作A关联的加载或存储动作,并假设动作P是对变量V的读取或写入动作,对应于动作F。类似地,假设动作B是由线程T对变量W实现的使用或分配动作,假设动作G是与动作B相关联的加载或存储动作,并假设动作Q是对变量W对应的读或写动作行动G行动。如果A先于B,则P先于Q。以上三个规则有点复杂,下面一一解释。首先,让我们看一下第一条规则。仅当线程T对变量V执行的先前操作是加载时,线程T才能对变量V执行使用操作。加载动作是指将从主内存中获取的变量值放入工作内存的变量副本中。使用动作是指将工作内存的变量值传递给执行引擎。那么这几句组合起来的意思就可以理解为:在使用变量V之前,必须先从主存中读取变量V。并且,只有当线程T对变量V执行的最后一个动作被使用时,线程T才能对变量V执行加载动作。这句话的意思可以理解为:读取主存的变量值并放入工作内存的变量副本,必须使用它。总的来说,这条规则的意思是:线程对变量V的use动作必须与read和load动作联系在一起,即read->load->use必须同时出现。这个规则要求在工作内存中,每次使用V之前,必须从主内存中刷新最新的值,以保证其他线程对变量V所做的修改值是可见的。让我们继续第二条规则。只有当线程T对变量V执行的前一个动作被赋值后,线程T才能对变量V执行存储动作。赋值动作是指将执行引擎的值赋值给工作内存的变量。存储动作是指将工作内存中的一个变量转移到主存中,以便后续写回主存。那么这些语句的组合意义可以理解为:要将工作内存中的变量写回主存,必须由执行引擎对工作内存中的变量进行赋值。而且,只有当线程T对变量V执行的最后一个动作是store时,线程T才能对变量V执行assign动作。这句话的意思可以理解为:将执行引擎接收到的值赋值给变量工作内存,工作内存变量的值必须写回主内存。总的来说,这条规则的意思是:线程对变量V的赋值动作必须与store和write联系在一起,即:assign->store->write必须同时出现。这条规则要求在工作内存中,V每次修改后,必须立即同步回主内存,以保证其他线程可以看到自己对变量V所做的修改。我们继续第三条规则。假设动作A是线程T对变量V实现的使用或分配动作,假设动作F是与动作A相关联的加载或存储动作,并假设动作P是对与动作F对应的变量V的读取或写入动作。这句话的意思比较简单。use和assign动作是将变量从工作内存传递到执行引擎,将变量从执行引擎传递到工作内存。加载和存储动作分别是将数据从主内存加载到工作内存,以及将数据从工作内存写入主内存。读写动作分别是将数据读入工作内存和将数据写回主内存。我们假设这是一个写主存的动作。如果将它们组合起来,则为:A->F->P(赋值->存储->写入)。类似地,假设动作B是线程T对变量W实现的使用或分配动作,假设动作G是与动作B关联的加载或存储动作,并假设动作Q是对变量W对应的读或写动作行动G行动。和上面类似,如果是写主存的动作,如果把这些组合起来,那么就是:B->G->Q(assign->store->write)。如果A先于B,则P先于Q。这意味着如果A动作早于B动作发生,那么A动作对应的P动作(写动作)将早于Q动作(写动作)。这个规则要求被volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序和程序的顺序一致。因此,volatile变量的可见性和禁止重新排序的语义实际上来自于Java内存模型中volatile变量的定义。总结这篇文章,我们介绍了volatile的两个语义:VisibilityprohibitsreorderingVisibility是指volatile类型的变量。一旦变量值被修改,其他线程可以立即感知到。禁止重排序是指被volatile修饰的变量,其执行顺序不能重排序。在我们的日常使用中,如果要防止volatile变量出现线程安全问题,只需要遵循以下两条规则即可。操作的结果不依赖于变量的当前值,或者可以保证只有单个线程修改变量的值。变量不需要与其他状态变量一起参与不变性约束。最后,我们进一步探究了volatile可见性和禁止重排序的根源,其实就是Java内存模型中volatile变量的定义。