java并发编程-一章解读volatile

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和 CPU的指令。

volatile
简要(轻量级的synchronized)
对volatile变量的写操作与普通变量的主要区别有两点:

(1)修改volatile变量时会强制将修改后的值刷新的主内存中。

(2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。
可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。本 文将深入分析在硬件层面上Intel处理器是如何实现volatile的,通过深入分析帮助我们正确地使用volatile变量。

volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。

volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:
● 保持可见性
● 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)
● 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
● 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

可见性的定义
可见性的定义常见于各种并发场景中,以多线程为例:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。
从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值。

可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)。下文会讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。
可见性导致的伪·重排序
缓存同步顺序本质上是可见性问题。
假设程序顺序(program order)中先更新变量v1、再更新变量v2,不考虑真·重排序:
Core0和Core1是两个核心

  1. Core0先更新缓存中的v1,再更新缓存中的v2(位于两个缓存行,这样淘汰缓存行时不会一起写回内存)。
  2. Core0读取v1(假设使用LRU协议淘汰缓存)。
  3. Core0的缓存满,将最远使用的v2写回内存。
  4. Core1的缓存中本来存有v1,现在将v2加载入缓存。

此时,尽管“更新v1”的事件早于“更新v2”发生,但Core1只看到了v2的最新值,却看不到v1的最新值。这属于可见性导致的伪·重排序:虽然没有实际上没有重排序,但看起来发生了重排序。
可以看到,缓存可见性不仅仅导致可见性问题,还会导致伪·重排序。因此,只要解决了缓存上的可见性问题,也就解决了伪·重排序。

重排序
定义
重排序并没有严格的定义。整体上可以分为两种:
● 真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。
● 伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了。
重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。
问题来源
重排序问题无时无刻不在发生,源自三种场景:

  1. 编译器编译时的优化
  2. 处理器执行时的乱序优化
  3. 缓存同步顺序(导致可见性问题)
    场景1、2属于真·重排序;场景3属于伪·重排序

定义
Java编程语言允许线程访问共享变量,为了 确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

Java语言提供了volatile,在某些情况下比锁要更加方便。

如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

一些术语的解释

内存屏障 (Memory Barrior):

volatile如何实现可见性
instance = new Singleton(); // instance是volatile变量

执行这行代码的时候,cpu做了什么呢?
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架
构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情:
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

下面来具体讲解volatile的两条实现原则。
1)Lock前缀指令会引起处理器缓存回写到内存。
在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。(因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存)
相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。
例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理
器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理
器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
禁止指令重排序

  1. 什么是重排序呢?
    为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
  2. 源代码会经过什么重排序呢?

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标,而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。

JMM对底层尽量减少约束,使其能够发挥自身优势。

因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。
一般重排序可以分为如下三种:
● 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
(因为编译器只考虑单线程语义不变的情况来重排序代码,但是这不能保证在多线程的情况下会出什么问题)
● 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

● 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

这里还得提一个概念,as-if-serial。
什么是 as-if-serial呢?
答:
不管怎么重排序,单线程下的执行结果不能被改变。
编译器、runtime和处理器都必须遵守as-if-serial语义。

内存屏障指令(volatile来保证不重排序)
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。

重排序原则,为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。
如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。

从JDK5开始,提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。

happens-before
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

volatile域规则:对一个volatile域的写操作,happens-before(发生在)于任意线程后续对这个volatile域的读。

如果现在我的变了falg变成了false,那么后面的那个操作,一定要知道我变了。

MESI协议及RFO请求
加了volatile关键字的变量在读写的时候才会触发MESI协议。

典型的CPU微架构有3级缓存, 每个核都有自己私有的L1, L2缓存. 那么多线程编程时, 另外一个核的线程想要访问当前核内L1, L2 缓存行的数据, 该怎么办呢?

这里不得不提一提:带有高速缓存的CPU执行计算的流程

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU的高速缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

总线风暴
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。
目前流行的多级缓存结构
由于CPU的运算速度超越了1级缓存的数据I/O能力,CPU厂商又引入了多级的缓存结构。多级缓存结构示意图如下:

多核CPU的情况下有多个一级缓存,如果保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。

cache分类

● 前提:所有的cache共同缓存了主内存中的某一条数据。
● 本地cache:指当前cpu的cache。
● 触发cache:触发读写事件的cache。
● 其他cache:指既除了以上两种之外的cache。
● 注意:本地的事件触发 本地cache和触发cache为相同。

有人说可以通过第2个核直接访问第1个核的缓存行. 这是可行的, 但这种方法不够快. 跨核访问需要通过Memory Controller(见上一篇的示意图), 典型的情况是第2个核经常访问第1个核的这条数据, 那么每次都有跨核的消耗. 更糟的情况是, 有可能第2个核与第1个核不在一个插槽内.况且Memory Controller的总线带宽是有限的, 扛不住这么多数据传输. 所以, CPU设计者们更偏向于另一种办法: 如果第2个核需要这份数据, 由第1个核直接把数据内容发过去, 数据只需要传一次。

那么什么时候会发生缓存行的传输呢? 答案很简单: 当一个核需要读取另外一个核的脏缓存行时发生. 但是前者怎么判断后者的缓存行已经被弄脏(写)了呢?

下面将详细地解答以上问题. 首先我们需要谈到一个协议–MESI协议(链接). 现在主流的处理器都是用它来保证缓存的相干性和内存的相干性. M,E,S和I代表使用MESI协议时缓存行所处的四个状态:
CPU中每个缓存行(caceh line)使用4种状态进行标记(使用额外的两位(bit)表示):
M: 被修改(Modified)
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。
当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
E: 独享的(Exclusive)
该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。
同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。
S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。
I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU修改了该缓存行)。

实际应用
volatile只能保证对单次读/写的原子性。因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。

volatile只能保证可见性而不能保证原子性:
1.从主内存到工作内存<读>:每次使用变量前 先从主内存中刷新最新的值到工作内存,用于保证能看见其他现场对变量修改的最新值
2.从工作内存到主内存<写>:每次修改变量后必须立刻同步到主内存中,用于保证其他线程可以看到自己对变量的修改
3.指令重排序:保证代码的执行顺序和程序的执行顺序一致。(并发环境下 代码的执行顺序与程序的执行顺序有时并不一致,会出现串行的现象固有指令重排序优化一说。JAVA1.5之后彻底修复了这个BUG在用volatile变量的时)

public class StudyJucTest {
volatile int i;

@Test
void voTest() {
    for (int b=0; b< 10000; b++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                i++;
            }
        }).start();
    }
    try {
        Thread.sleep(1000);//等待10秒,保证上面程序执行完成
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    System.out.println("i的值为:" + i);


}

}
这段代码经过测试,i的值输出为9957,并不是10000.
原因:volatile只能保证对单次读/写的原子性。
因为i++实际上有两个操作,cpu要把i从内存中读出来,
再temp=i+1,再进行赋值操作,所以可能同时有多个线程拿到了相同的i值。

还要放重排序的作用:
public class VolatileSingleTest {

volatile static B b = null;

public synchronized void getB() {
    if (b == null) {

        synchronized (VolatileSingleTest.class) {
            if (null == b) {
                b = new B();
            }
        }

    }
}

class B {

}

}

b = new B();其实发生了三件事:
memory = allocate(); //1:为对象分配内存空间
ctorInstance(memory) /:2 :初始化对象
instance = memory;//3 : 设置instance指向刚分配的内存地址
其中,volatile担心2和3重排了

多线程下变量更新不同步问题

现在来看下面这一段代码:
当我把flag改成false的时候,程序并不会结束,而是一直运行。
当我在这个while语句块添加一个
while(flagtrue) {
System.out.println(“运行–”);}
的时候,这个程序再次运行起来的时候,就会停止了
public class Test {
boolean flag=true;
public static void main(String[] args){
Test test = new Test();
test.demo();
}
public void demo(){
Thread t1 = new Thread(){
@Override
public void run() //重写run方法
{
while(flag
true)
{

            }
            System.out.println("线程结束了。。。。。。。。");
        }
    };
    t1.start();
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
    flag=false;
}

}

CPU的运算速度是远远大于CPU和主存之间的数据读写速度的,CPU中存在高速缓存(可以有多级),CPU可以和高速缓存之间进行高速的数据读写操作。比如要执行i=i+1这个语句,CPU会先向主存中读取i变量的数值,存入自己的高速缓存中,然后对i变量进行加一操作,写到高速缓存,最后再刷新到主存中。当对运算的要求比较高的时候(就像我这个空while(flag==true){}就是疯狂用这个flag,所以cpu就一直往内存里面找这个flag,就算刷新了它也不知道,CPU会对高速缓存中的数据进行一系列操作后统一刷回主存,这就可以解释我们上面看到的现象了,为什么CPU中flag值没有被更新为false,在线程读取flag=true值到高速缓存中后,就在while循环中不断轮询这个变量值,因为我们这次没有加上打印语句(打印语句的执行时间是比较长的),为了保证读取的高效,CPU保持和其高速缓存进行交互,而没有重新去主存中读取新值(虽然主存中的值已经被修改了),循环就一直持续下去。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。
栗子:带volatile的单例模式
单例模式涉及八种,这里我只说这种涉及volatile的:
对象实际上创建对象要进过如下几个步骤:
● 分配内存空间。
● 调用构造器,初始化实例。
● 返回地址给引用
因为可能发生指令重排序(2跟3调换顺序),那有可能构造函数在对象初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。
但是别的线程去判断instance!=null,直接拿去用了,其实这个对象是个半成品,那就有空指针异常了。
public class Singleton {

/**
 * 单例对象
 */
private volatile static Singleton instance = null;

private Singleton(){}

public static Singleton getInstance() {
    // 第一重锁检查
    if (instance == null) {
        // 同步锁定代码块
        synchronized (Singleton.class) {
            // 第二重锁检查
            if (instance == null) {
                // new对象不是原子操作
                instance = new Singleton();
            }
        }
    }
    return instance;
}

}

volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。

volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
总结一下

  1. volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
  2. volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
  3. volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
  4. volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取。
  5. volatile提供了happens-before保证,对volatile变量v的写入happens-before(发生在)所有其他线程后续对v的读操作。
  6. volatile可以使得long和double的赋值是原子的。
  7. volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
    JMM(JAVA内存模型)
    Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
    JMM有以下规定
  8. 所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  9. 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
  10. 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  11. 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

具体流程解释:

lock (锁定) : 将主内存变量加锁,表示为线程独占状态,可以被线程进行read
read(读取) :线程从主内存读取数据
load(载入):将上一步线程从主内存中读取的数据,加载到工作内存中
use(使用):从工作内存中读取数据来进行我们所需要的逻辑计算
assign(复制):将计算后的数据赋值到工作内存中
store(存储):将工作内存的数据准备写入主内存
write(写入):将store过去的变量正式写入主内存
unlock(解锁):将主内存的变量解锁,解锁后其他线程可以锁定该变量

你可能感兴趣的:(java)