随笔 - 231  文章 - 0  评论 - 64  阅读 - 88万

并发-Volatile关键字详解

参考:

https://www.cnblogs.com/zhengbin/p/5654805.html

https://juejin.im/post/6861885337568804871

https://zhuanlan.zhihu.com/p/139891314

 

 

 

 

 

Java中Volatile关键字详解

 

一、基本概念


先补充一下概念:Java 内存模型中的可见性、原子性和有序性。

可见性:

  可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

  可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。

  在 Java 中 volatile、synchronized 和 final 实现可见性。

原子性:

  原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

  在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。

有序性:

  Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

下面内容摘录自《Java Concurrency in Practice》:

  下面一段代码在多线程环境下,将存在问题。

复制代码
 1 /**
 2  * @author zhengbinMac
 3  */
 4 public class NoVisibility {
 5     private static boolean ready;
 6     private static int number;
 7     private static class ReaderThread extends Thread {
 8         @Override
 9         public void run() {
10             while(!ready) {
11                 Thread.yield();
12             }
13             System.out.println(number);
14         }
15     }
16     public static void main(String[] args) {
17         new ReaderThread().start();
18         number = 42;
19         ready = true;
20     }
21 }
复制代码
复制代码

  NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。甚至NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值,这种现象被称为“重排序”。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。

  在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行春旭进行判断,无法得到正确的结论。

  这个看上去像是一个失败的设计,但却能使JVM充分地利用现代多核处理器的强大性能。例如,在缺少同步的情况下,Java内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中。此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。

二、Volatile原理


  Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

  在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

  当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

  而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

当一个变量定义为 volatile 之后,将具备两种特性:

  1.保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。

  2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。

volatile 性能:

  volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

 
 
 
 
 
 
 
 

反制面试官 | 14张原理图 | 再也不怕被问 volatile!

 

絮叨

这一篇也算是Java并发编程的开篇,看了很多资料,但是轮到自己去整理去总结的时候,发现还是要多看几遍资料才能完全理解。还有一个很重要的点就是,画图是加深印象和检验自己是否理解的一个非常好的方法。

一、Volatile怎么念?

volatile怎么念

看到这个单词一直不知道怎么发音

英 [ˈvɒlətaɪl]  美 [ˈvɑːlətl]

adj. [化学] 挥发性的;不稳定的;爆炸性的;反复无常的
复制代码

那Java中volatile又是干啥的呢?

二、Java中volatile用来干啥?

  • Volatile是Java虚拟机提供的轻量级的同步机制(三大特性)
    • 保证可见性
    • 不保证原子性
    • 禁止指令重排

要理解三大特性,就必须知道Java内存模型(JMM),那JMM又是什么呢?

volatile怎么念

三、JMM又是啥?

这是一份精心总结的Java内存模型思维导图,拿去不谢。

拿走不谢

原理图1-Java内存模型

3.1 为什么需要Java内存模型?

Why:屏蔽各种硬件和操作系统的内存访问差异

JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

3.2 到底什么是Java内存模型?

  • 1.定义程序中各种变量的访问规则
  • 2.把变量值存储到内存的底层细节
  • 3.从内存中取出变量值的底层细节

3.3 Java内存模型的两大内存是啥?

原理图2-两大内存

  • 主内存
    • Java堆中对象实例数据部分
    • 对应于物理硬件的内存
  • 工作内存
    • Java栈中的部分区域
    • 优先存储于寄存器和高速缓存

3.4 Java内存模型是怎么做的?

Java内存模型的几个规范:

  • 1.所有变量存储在主内存

  • 2.主内存是虚拟机内存的一部分

  • 3.每条线程有自己的工作内存

  • 4.线程的工作内存保存变量的主内存副本

  • 5.线程对变量的操作必须在工作内存中进行

  • 6.不同线程之间无法直接访问对方工作内存中的变量

  • 7.线程间变量值的传递均需要通过主内存来完成

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:

原理图3-Java内存模型

3.5 Java内存模型的三大特性

  • 可见性(当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改)
  • 原子性(一个操作或一系列操作是不可分割的,要么同时成功,要么同时失败)
  • 有序性(变量赋值操作的顺序与程序代码中的执行顺序一致)

关于有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

四、能给个示例说下怎么用volatile的吗?

考虑一下这种场景:

有一个对象的字段number初始化值=0,另外这个对象有一个公共方法setNumberTo100()可以设置number = 100,当主线程通过子线程来调用setNumberTo100()后,主线程是否知道number值变了呢?

答案:如果没有使用volatile来定义number变量,则主线程不知道子线程更新了number的值。

(1)定义如上述所说的对象:ShareData

class ShareData {
    int number = 0;

    public void setNumberTo100() {
        this.number = 100;
    }
}
复制代码

(2)主线程中初始化一个子线程,名字叫做子线程

子线程先休眠3s,然后设置number=100。主线程不断检测的number值是否等于0,如果不等于0,则退出主线程。

public class volatileVisibility {
    public static void main(String[] args) {
        // 资源类
        ShareData shareData = new ShareData();

        // 子线程 实现了Runnable接口的,lambda表达式
        new Thread(() -> {

            System.out.println(Thread.currentThread().getName() + "\t come in");

            // 线程睡眠3秒,假设在进行运算
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改number的值
            myData.setNumberTo100();

            // 输出修改后的值
            System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);

        }, "子线程").start();

        while(myData.number == 0) {
            // main线程就一直在这里等待循环,直到number的值不等于零
        }

        // 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
        // 如果能输出这句话,说明子线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
        System.out.println(Thread.currentThread().getName() + "\t 主线程感知到了 number 不等于 0");

        /**
         * 最后输出结果:
         * 子线程     come in
         * 子线程     update number value:100
         * 最后线程没有停止,并行没有输出"主线程知道了 number 不等于0"这句话,说明没有用volatile修饰的变量,变量的更新是不可见的
         */
    }
}
复制代码

没有使用volatile

(3)我们用volatile修饰变量number

class ShareData {
    //volatile 修饰的关键字,是为了增加多个线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
    volatile int number = 0;

    public void setNumberTo100() {
        this.number = 100;
    }
}
复制代码

输出结果:

子线程  come in
子线程  update number value:100
main     主线程知道了 number 不等于 0

Process finished with exit code 0
复制代码

mark

小结:说明用volatile修饰的变量,当某线程更新变量后,其他线程也能感知到。

五、那为什么其他线程能感知到变量更新?

mark

其实这里就是用到了“窥探(snooping)”协议。在说“窥探(snooping)”协议之前,首先谈谈缓存一致性的问题。

5.1 缓存一致性

当多个CPU持有的缓存都来自同一个主内存的拷贝,当有其他CPU偷偷改了这个主内存数据后,其他CPU并不知道,那拷贝的内存将会和主内存不一致,这就是缓存不一致。那我们如何来保证缓存一致呢?这里就需要操作系统来共同制定一个同步规则来保证,而这个规则就有MESI协议。

如下图所示,CPU2 偷偷将num修改为2,内存中num也被修改为2,但是CPU1和CPU3并不知道num值变了。

原理图4-缓存一致性1

5.2 MESI

当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,系统会发出信号通知其它CPU将该内存变量的缓存行设置为无效。如下图所示,CPU1和CPU3 中num=1已经失效了。

原理图5-缓存一致性2

当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。

如下图所示,CPU1和CPU3发现缓存的num值失效了,就重新从内存读取,num值更新为2。

原理图6-缓存一致性3

5.3 总线嗅探

那其他CPU是怎么知道要将缓存更新为失效的呢?这里是用到了总线嗅探技术。

每个CPU不断嗅探总线上传播的数据来检查自己缓存值是否过期了,如果处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。

原理图7-缓存一致性4

5.4 总线风暴

总线嗅探技术有哪些缺点?

由于MESI缓存一致性协议,需要不断对主线进行内存嗅探,大量的交互会导致总线带宽达到峰值。因此不要滥用volatile,可以用锁来替代,看场景啦~

六、能演示下volatile为什么不保证原子性吗?

原子性:一个操作或一系列操作是不可分割的,要么同时成功,要么同时失败。

这个定义和volatile啥关系呀,完全不能理解呀?Show me the code!

考虑一下这种场景:

当20个线程同时给number自增1,执行1000次以后,number的值为多少呢?

在单线程的场景,答案是20000,如果是多线程的场景下呢?答案是可能是20000,但很多情况下都是小于20000。

示例代码:

package com.jackson0714.passjava.threads;

/**
 演示volatile 不保证原子性
 * @create: 2020-08-13 09:53
 */

public class VolatileAtomicity {
    public static volatile int number = 0;

    public static void increase() {
        number++;
    }

    public static void main(String[] args) {

        for (int i = 0; i < 50; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increase();
                }
            }, String.valueOf(i)).start();
        }

        // 当所有累加线程都结束
        while(Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(number);
    }
}
复制代码

执行结果:第一次19144,第二次20000,第三次19378。

volatile第一次执行结果

volatile第二次执行结果

volatile第三次执行结果

我们来分析一下increase()方法,通过反编译工具javap得到如下汇编代码:

  public static void increase();
    Code:
       0: getstatic     #2                  // Field number:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field number:I
       8: return
复制代码

number++其实执行了3条指令

getstatic:拿number的原始值 iadd:进行加1操作 putfield:把加1后的值写回

执行了getstatic指令number的值取到操作栈顶时,volatile关键字保证了number的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把number的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的number值同步回主内存之中。

总结如下:

在执行number++这行代码时,即使使用volatile修饰number变量,在执行期间,还是有可能被其他线程修改,没有保证原子性。

七、怎么保证输出结果是20000呢?

7.1 synchronized同步代码块

我们可以通过使用synchronized同步代码块来保证原子性。从而使结果等于20000

public synchronized static void increase() {
   number++;
}
复制代码

synchronized同步代码块执行结果

但是使用synchronized太重了,会造成阻塞,只有一个线程能进入到这个方法。我们可以使用Java并发包(JUC)中的AtomicInterger工具包。

7.2 AtomicInterger原子性操作

我们来看看AtomicInterger原子自增的方法getAndIncrement()

AtomicInterger

public static AtomicInteger atomicInteger = new AtomicInteger();

public static void main(String[] args) {

    for (int i = 0; i < 20; i++) {
        new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                atomicInteger.getAndIncrement();
            }
        }, String.valueOf(i)).start();
    }

    // 当所有累加线程都结束
    while(Thread.activeCount() > 2) {
        Thread.yield();
    }

    System.out.println(atomicInteger);
}
复制代码

多次运行的结果都是20000。

getAndIncrement的执行结果

八、禁止指令重排又是啥?

说到指令重排就得知道为什么要重排,有哪几种重排。

如下图所示,指令执行顺序是按照1>2>3>4的顺序,经过重排后,执行顺序更新为指令3->4->2->1。

原理图8-指令重排

会不会感觉到重排把指令顺序都打乱了,这样好吗?

可以回想下小学时候的数学题:2+3-5=?,如果把运算顺序改为3-5+2=?,结果也是一样的。所以指令重排是要保证单线程下程序结果不变的情况下做重排。

8.1 为什么要重排

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

8.2 有哪几种重排

  • 1.编译器优化重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 2.指令级的并行重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

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

原理图9-三种重排

注意:

  • 单线程环境里面确保最终执行结果和代码顺序的结果一致

  • 处理器在进行重排序时,必须要考虑指令之间的数据依赖性

  • 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

8.3 举个例子来说说多线程中的指令重排?

设想一下这种场景:定义了变量num=0和变量flag=false,线程1调用初始化函数init()执行后,线程调用add()方法,当另外线程判断flag=true后,执行num+100操作,那么我们预期的结果是num会等于101,但因为有指令重排的可能,num=1和flag=true执行顺序可能会颠倒,以至于num可能等于100

public class VolatileResort {
    static int num = 0;
    static boolean flag = false;
    public static void init() {
        num= 1;
        flag = true;
    }
    public static void add() {
        if (flag) {
            num = num + 5;
            System.out.println("num:" + num);
        }
    }
    public static void main(String[] args) {
        init();
        new Thread(() -> {
            add();
        },"子线程").start();
    }
}

复制代码

先看线程1中指令重排:

num= 1;flag = true; 的执行顺序变为 flag=true;num = 1;,如下图所示的时序图

原理图10-线程1指令重排

如果线程2 num=num+5 在线程1设置num=1之前执行,那么线程2的num变量值为5。如下图所示的时序图。

原理图11-线程2在num=1之前执行

8.4 volatile怎么实现禁止指令重排?

我们使用volatile定义flag变量:

static volatile boolean flag = false;
复制代码

如何实现禁止指令重排:

原理:在volatile生成的指令序列前后插入内存屏障(Memory Barries)来禁止处理器重排序。

有如下四种内存屏障:

四种内存屏障

volatile写的场景如何插入内存屏障:

  • 在每个volatile写操作的前面插入一个StoreStore屏障(写-写 屏障)。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障(写-读 屏障)。

原理图12-volatile写的场景如何插入内存屏障

StoreStore屏障可以保证在volatile写(flag赋值操作flag=true)之前,其前面的所有普通写(num的赋值操作num=1) 操作已经对任意处理器可见了,保障所有普通写在volatile写之前刷新到主内存。

volatile读场景如何插入内存屏障:

  • 在每个volatile读操作的后面插入一个LoadLoad屏障(读-读 屏障)。

  • 在每个volatile读操作的后面插入一个LoadStore屏障(读-写 屏障)。

原理图13-volatile读场景如何插入内存屏障

LoadStore屏障可以保证其后面的所有普通写(num的赋值操作num=num+5) 操作必须在volatile读(if(flag))之后执行。

十、volatile常见应用

这里举一个应用,双重检测锁定的单例模式

package com.jackson0714.passjava.threads;
/**
 演示volatile 单例模式应用(双边检测)
 * @author: 悟空聊架构
 * @create: 2020-08-17
 */

class VolatileSingleton {
    private static VolatileSingleton instance = null;
    private VolatileSingleton() {
        System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
    }
    public static VolatileSingleton getInstance() {
        // 第一重检测
        if(instance == null) {
            // 锁定代码块
            synchronized (VolatileSingleton.class) {
                // 第二重检测
                if(instance == null) {
                    // 实例化对象
                    instance = new VolatileSingleton();
                }
            }
        }
        return instance;
    }
}
复制代码

代码看起来没有问题,但是 instance = new VolatileSingleton();其实可以看作三条伪代码:

memory = allocate(); // 1、分配对象内存空间
instance(memory); // 2、初始化对象
instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null
复制代码

步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

memory = allocate(); // 1、分配对象内存空间
instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
instance(memory); // 2、初始化对象
复制代码

如果另外一个线程执行:if(instance == null) 时,则返回刚刚分配的内存地址,但是对象还没有初始化完成,拿到的instance是个假的。如下图所示:

原理图14-双重检锁存在的并发问题

解决方案:定义instance为volatile变量

private static volatile VolatileSingleton instance = null;
复制代码

十一、volatile都不保证原子性,为啥我们还要用它?

奇怪的是,volatile都不保证原子性,为啥我们还要用它?

volatile是轻量级的同步机制,对性能的影响比synchronized小。

典型的用法:检查某个状态标记以判断是否退出循环。

比如线程试图通过类似于数绵羊的传统方法进入休眠状态,为了使这个示例能正确执行,asleep必须为volatile变量。否则,当asleep被另一个线程修改时,执行判断的线程却发现不了。

那为什么我们不直接用synchorized,lock锁?它们既可以保证可见性,又可以保证原子性为何不用呢?

因为synchorized和lock是排他锁(悲观锁),如果有多个线程需要访问这个变量,将会发生竞争,只有一个线程可以访问这个变量,其他线程被阻塞了,会影响程序的性能。

注意:当且仅当满足以下所有条件时,才应该用volatile变量

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他的状态一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

十二、volatile和synchronzied的区别

  • volatile只能修饰实例变量和类变量,synchronized可以修饰方法和代码块。
  • volatile不保证原子性,而synchronized保证原子性
  • volatile 不会造成阻塞,而synchronized可能会造成阻塞
  • volatile 轻量级锁,synchronized重量级锁
  • volatile 和synchronized都保证了可见性和有序性

十三、小结

  • volatile 保证了可见性:当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
  • volatile 保证了单线程下指令不重排:通过插入内存屏障保证指令执行顺序。
  • volatitle不保证原子性,如a++这种自增操作是有并发风险的,比如扣减库存、发放优惠券的场景。
  • volatile 类型的64位的long型和double型变量,对该变量的读/写具有原子性。
  • volatile 可以用在双重检锁的单例模式种,比synchronized性能更好。
  • volatile 可以用在检查某个状态标记以判断是否退出循环。

参考资料:

《深入理解Java虚拟机》

《Java并发编程的艺术》

《Java并发编程实战》


作者:悟空聊架构
链接:https://juejin.im/post/6861885337568804871
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
 
 
 
 
 
 
 
 
 
 

一个volatile跟面试官扯了半个小时

 

前言

volatile 应该算是Java 后端面试的必考题,因为多线程编程基本绕不开它,很适合作为并发编程的入门题。

开场

面试官:你先自我介绍一下吧!

安琪拉: 我是安琪拉,草丛三婊之一,最强中单(钟馗不服)!哦,不对,串场了,我是**,目前在–公司做–系统开发。

面试官: 看你简历上写熟悉并发编程,volatile 用过的吧?

安琪拉: 用过的。(还是熟悉的味道)

面试官: 那你跟我讲讲什么时候会用到 volatile ?

安琪拉: 如果需要保证多线程共享变量的可见性时,可以使用volatile 来修饰变量。

面试官: 什么是共享变量的可见性?

安琪拉: 多线程并发编程中主要围绕着三个特性实现。可见性是其中一种!

  • 可见性
    可见性是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。
  • 原子性
    原子性指的一个操作或一组操作要么全部执行,要么全部不执行。
  • 有序性
    有序性是指程序执行的顺序按照代码的先后顺序执行。

面试官: volatile 除了解决共享变量的可见性,还有别的作用吗?

安琪拉: volatile 除了让共享变量具有可见性,还具有有序性(禁止指令重排序)。

面试官: 你先跟我举几个实际volatile 实际项目中的例子?

安琪拉: 可以的。有个特别常见的例子:

  1. 状态标志
    比如我们工程中经常用一个变量标识程序是否启动、初始化完成、是否停止等,如下:

volatile 很适合只有一个线程修改,其他线程读取的情况。volatile 变量被修改之后,对其他线程立即可见。

面试官: 现在我们来看一下你的例子,如果不加volatile 修饰,会有什么后果?

安琪拉: 比如这是一个带前端交互的系统,有A、 B二个线程,用户点了停止应用按钮,A 线程调用shutdown() 方法,让变量shutdown 从false 变成 true,但是因为没有使用volatile 修饰, B 线程可能感知不到shutdown 的变化,而继续执行 doWork 内的循环,这样违背了程序的意愿:当shutdown 变量为true 时,代表应用该停下了,doWork函数应该跳出循环,不再执行。

面试官: volatile还有别的应用场景吗?

安琪拉: 懒汉式单例模式,我们常用的 double-check 的单例模式,如下所示:

使用volatile 修饰保证 singleton 的实例化能够对所有线程立即可见。

面试官: 我们再来看你的单例模式的例子,我有三个问题:

  1. 为什么使用volatile 修饰了singleton 引用还用synchronized 锁?
  2. 第一次检查singleton 为空后为什么内部还需要进行第二次检查?
  3. volatile 除了内存可见性,还有别的作用吗?

安琪拉: 【心里炸了,举单例模式例子简直给自己挖坑】这三个问题,我来一个个回答:

  1. 为什么使用volatile 修饰了singleton 引用还用synchronized 锁?
    volatile 只保证了共享变量 singleton 的可见性,但是 singleton = new Singleton(); 这个操作不是原子的,可以分为三步:
    步骤1:在堆内存申请一块内存空间;
    步骤2:初始化申请好的内存空间;
    步骤3:将内存空间的地址赋值给 singleton;
    所以singleton = new Singleton(); 是一个由三步操作组成的复合操作,多线程环境下A 线程执行了第一步、第二步之后发生线程切换,B 线程开始执行第一步、第二步、第三步(因为A 线程singleton 是还没有赋值的),所以为了保障这三个步骤不可中断,可以使用synchronized 在这段代码块上加锁。(synchronized 原理参考《安琪拉与面试官二三事》系列第二篇文章)
  2. 第一次检查singleton 为空后为什么内部还进行第二次检查?
    A 线程进行判空检查之后开始执行synchronized代码块时发生线程切换(线程切换可能发生在任何时候),B 线程也进行判空检查,B线程检查 singleton == null 结果为true,也开始执行synchronized代码块,虽然synchronized 会让二个线程串行执行,如果synchronized代码块内部不进行二次判空检查,singleton 可能会初始化二次。
  3. volatile 除了内存可见性,还有别的作用吗?
    volatile 修饰的变量除了可见性,还能防止指令重排序。
    指令重排序 是编译器和处理器为了优化程序执行的性能而对指令序列进行重排的一种手段。现象就是CPU 执行指令的顺序可能和程序代码的顺序不一致,例如 a = 1; b = 2; 可能 CPU 先执行b=2; 后执行a=1;
    singleton = new Singleton(); 由三步操作组合而成,如果不使用volatile 修饰,可能发生指令重排序。步骤3 在步骤2 之前执行,singleton 引用的是还没有被初始化的内存空间,别的线程调用单例的方法就会引发未被初始化的错误。
    指令重排序也遵循一定的规则:
  • 重排序不会对存在依赖关系的操作进行重排

    • 重排序目的是优化性能,不管怎样重排,单线程下的程序执行结果不会变

因此volatile 还有禁止指令重排序的作用。

面试官: 那为什么不加volatile ,A 线程对共享变量的修改,其他线程不可见呢?你知道volatile的底层原理吗?

安琪拉: 果然该来的还是来了,我要放大招了,您坐稳咯!

面试官: 我靠在椅子上,稳的很,请开始你的表演!

安琪拉: 先说结论,我们知道volatile可以实现内存的可见性和防止指令重排序,但是volatile 不保证操作的原子性。那么volatile是怎么实现可见性和有序性的呢?其实volatile的这些内存语意是通过内存屏障技术实现的。

面试官: 那你跟我讲讲内存屏障。

安琪拉: 讲内存屏障的话,这块内容会比较深,我以下面的顺序讲,这个整个知识成体系,不散:

  1. 现代CPU 架构的形成
  2. Java 内存模型(JMM)
  3. Java 通过 Java 内存模型(JMM )实现 volatile 平台无关

现代CPU 架构的形成

安琪拉: 一切要从盘古开天辟地说起,女娲补天! 咳咳,不好意思,扯远了! 一切从冯洛伊曼计算机体系开始说起!

面试官: 扯的是不是有点远!

安琪拉: 你就说要不要听?要听别打断我!

面试官: 得嘞!您请讲!

安琪拉: 下图就是经典的 冯洛伊曼体系结构,基本把计算机的组成模块都定义好了,现在的计算机都是以这个体系弄的,其中最核心的就是由运算器和控制器组成的中央处理器,就是我们常说的CPU。

面试官: 这个跟 volatile 有什么关系?

安琪拉: 不要着急嘛!理解技术不要死盯着技术的细枝末节,要思考这个技术产生的历史背景和原因,思考发明这个技术的人当时是遇到了什么问题? 而发明这个技术的。 这样即理解深刻,也让自己思考问题更宏观,更有深度!这叫从历史的角度看问题,站在巨人的肩膀上!

面试官: 来来来,今天你教我做人!

安琪拉: 刚才说到冯洛伊曼体系中的CPU,你应该听过摩尔定律吧! 就是英特尔创始人戈登·摩尔讲的:

集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。

面试官: 听过的,然后呢?

安琪拉:所以你看到我们电脑CPU 的性能越来越强劲,英特尔CPU 从Intel Core 一直到 Intel Core i7,前些年单核CPU 的晶体管数量确实符合摩尔定律,看下面这张图。

横轴为新CPU发明的年份,纵轴为可容纳晶体管的对数。所有的点近似成一条直线,这意味着晶体管数目随年份呈指数变化,大概每两年翻一番。

面试官: 后来呢? 这和今天说的 volatile,以及内存屏障有什么关系?

安琪拉:别着急啊!后来摩尔定律越来越撑不住了,但是更新换代的程序对电脑性能的期望和要求还在不断上涨,就出现了下面的剧情。

他为其Pentium 4新一代芯片取消上市而道歉, 近几年来,英特尔不断地在增加其处理器的运行速度。当前最快的一款,其速度已达3.4GHz,虽然强化处理器的运行速度,也增强了芯片运作效能,但速度提升却使得芯片的能源消耗量增加,并衍生出冷却芯片的问题。
因此,英特尔摒弃将心力集中在提升运行速度的做法,在未来几年,将其芯片转为以多模核心(multi-core)的方式设计等其他方式,来提升芯片的表现。多模核心的设计法是将多模核心置入单一芯片中。如此一来,这些核心芯片即能以较缓慢的速度运转,除了可减少运转消耗的能量,也能减少运转生成的热量。此外,集众核心芯片之力,可提供较单一核心芯片更大的处理能力。 —《经济学人》

安琪拉:当然上面贝瑞特当然只是在开玩笑,眼看摩尔定律撑不住了,后来怎么处理的呢?一颗CPU 不行,我们多来几颗嘛!这就是现在我们常见的多核CPU,四核8G 听着熟悉不熟悉?当然完全依据冯洛伊曼体系设计的计算机也是有缺陷的!

面试官: 什么缺陷? 说说看。

安琪拉: CPU 运算器的运算速度远比内存读写速度快,所以CPU 大部分时间都在等数据从内存读取,运算完数据写回内存。

面试官: 那怎么解决?

安琪拉: 因为CPU 运行速度实在太快,主存(就是内存)的数据读取速度和CPU 运算速度差了有几个数量级,因此现代计算机系统通过在CPU 和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲,这样缓存提前从主存获取数据,CPU 不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。同时CPU 内还有寄存器,一些计算的中间结果临时放在寄存器内。

面试官: 既然你提到缓存,那我问你一个问题,CPU 从缓存读取数据和从内存读取数据除了读取速度的差异?有什么本质的区别吗?不都是读数据写数据,而且加缓存会让整个体系结构变得更加复杂。

安琪拉:缓存和主存不仅仅是读取写入数据速度上的差异,还有另外更大的区别:研究人员发现了程序80%的时间在运行20% 的代码,所以缓存本质上只要把20%的常用数据和指令放进来就可以了(是不是和Redis 存放热点数据很像),另外CPU 访问主存数据时存在二个局部性现象:

  1. 时间局部性现象
    如果一个主存数据正在被访问,那么在近期它被再次访问的概率非常大。想想你程序大部分时间是不是在运行主流程。
  2. 空间局部性现象
    CPU使用到某块内存区域数据,这块内存区域后面临近的数据很大概率立即会被使用到。这个很好解释,我们程序经常用的数组、集合(本质也是数组)经常会顺序访问(内存地址连续或邻近)。

因为这二个局部性现象的存在使得缓存的存在可以很大程度上缓解CPU 饥饿的问题。

面试官: 讲的是那么回事,那能给我画一下现在CPU、缓存、主存的关系图吗?

安琪拉:可以。我们来看下现在主流的多核CPU的硬件架构,如下图所示。

安琪拉: 现代操作系统一般会有多级缓存(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的电脑缓存信息,一共4核,三级缓存,L1 缓存(在CPU核心内)这里没有显示出来,这里L2 缓存后面括号标识了是每个核都有L2 缓存,而L3 缓存没有标识,是因为L3 缓存是4个核共享的缓存:

面试官: 那你能跟我简单讲讲程序运行时,数据是怎么在主存、缓存、CPU寄存器之间流转的吗?

安琪拉: 可以。比如以 i = i + 2; 为例, 当线程执行到这条语句时,会先从主存中读取i 的值,然后复制一份到缓存中,CPU 读取缓存数据(取数指令),进行 i + 2 操作(中间数据放寄存器),然后把结果写入缓存,最后将缓存中i最新的值刷新到主存当中(写回时间不确定)。

面试官: 这个数据操作逻辑在单线程环境和多线程环境下有什么区别?

安琪拉: 比如i 如果是共享变量(例如对象的成员变量),单线程运行没有任何问题,但是多线程中运行就有可能出问题。例如:有A、B二个线程,在不同的CPU 上运行,因为每个线程运行的CPU 都有自己的缓存,A 线程从内存读取i 的值存入缓存,B 线程此时也读取i 的值存入自己的缓存,A 线程对i 进行+1操作,i变成了1,B线程缓存中的变量 i 还是0,B线程也对i 进行+1操作,最后A、B线程先后将缓存数据写入内存,内存预期正确的结果应该是2,但是实际是1。这个就是非常著名的缓存一致性问题。

说明:单核CPU 的多线程也会出现上面的线程不安全的问题,只是产生原因不是多核CPU缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的。

执行过程如下图:

面试官: 那CPU 怎么解决缓存一致性问题呢?

安琪拉:早期的一些CPU 设计中,是通过锁总线(总线访问加Lock# 锁)的方式解决的。看下CPU 体系结构图,如下:

因为CPU 都是通过总线来读取主存中的数据,因此对总线加Lock# 锁的话,其他CPU 访问主存就被阻塞了,这样防止了对共享变量的竞争。但是锁总线对CPU的性能损耗非常大,把多核CPU 并行的优势直接给干没了!

后面研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel 的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。

面试官: 那讲讲 **MESI **协议呗!

安琪拉: (MESI这部分内容可以只了解大概思想,不用深究,因为东西多到可以单独成一篇文章了)

MESI 协议的核心思想:

  • 定义了缓存中的数据状态只有四种,MESI 是四种状态的首字母。
  • 当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
  • 当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取。
缓存中数据都是以缓存行(Cache Line)为单位存储;MESI 各个状态描述如下表所示:

面试官: 那我问你MESI 协议和volatile实现的内存可见性时什么关系?

安琪拉: volatile 和MESI 中间差了好几层抽象,中间会经历java编译器,java虚拟机和JIT,操作系统,CPU核心。

volatile 是Java 中标识变量可见性的关键字,说直接点:使用volatile 修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU 是如何实现内存可见的,我的语法规定就是volatile 修饰的变量必须是具有可见性的。

CPU 有X86(复杂指令集)、ARM(精简指令集)等体系架构,版本类型也有很多种,CPU 可能通过锁总线、MESI 协议实现多核心缓存的一致性。因为有硬件的差异以及编译器和处理器的指令重排优化的存在,所以Java 需要一种协议来规避硬件平台的差异,保障同一段代表在所有平台运行效果一致,这个协议叫做Java 内存模型(Java Memory Model)。

Java 内存模型(JMM)

面试官: 你能详细讲讲Java 内存模型吗?

安琪拉: JMM 全称 Java Memory Model, 是 Java 中非常重要的一个概念,是Java 并发编程的核心和基础。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果。

协议这个词都不会陌生,HTTP 协议、TCP 协议等。JMM 协议就是一套规范,具体的内容为:

所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存的拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

面试官: 你刚才提到每个线程都有自己的工作内存,问个深入一点的问题,线程的工作内存在主存还是缓存中?

安琪拉: 这个问题非常棒!JMM 中定义的每个线程私有的工作内存是抽象的规范,实际上工作内存和真实的CPU 内存架构如下所示,Java 内存模型和真实硬件内存架构是不同的:

JMM 是内存模型,是抽象的协议。首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此!

面试官: 能具体讲讲JMM 内存模型规范吗?

安琪拉: 可以。前面已经讲了线程本地内存和物理真实内存之间的关系,说的详细些:

  • 初始变量首先存储在主内存中;
  • 线程操作变量需要从主内存拷贝到线程本地内存中;
  • 线程的本地工作内存是一个抽象概念,包括了缓存、store buffer(后面会讲到)、寄存器等。

面试官: 那JMM 模型中多线程如何通过共享变量通信呢?

安琪拉: 线程间通信必须要经过主内存。

线程A与线程B之间要通信的话,必须要经历下面2个步骤:

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

我们编译一段Java code 看一下。

代码和字节码指令分别为:

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store 和write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断。
  • 不允许read和load、store和write操作之一单独出现,必须成对出现。
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

面试官: 听下来 Java 内存模型真的内容很多,那Java 内存模型是如何保障你上面说的这些规则的呢?

安琪拉: 这就是接下来要说的底层实现原理了,上面叨逼叨说了一堆概念和规范,需要慢慢消化。

Java 通过 Java 内存模型(JMM )实现 volatile 平台无关

安琪拉: 我们前面说 并发编程实际就是围绕三个特性的实现展开的:

  • 可见性
  • 有序性
  • 原子性

面试官: 对的。前面已经说过了。我怎么感觉我想是捧哏。

安琪拉: 前面我们已经说过共享变量不可见的问题,讲完Java 内存模型,理解的应该更深刻了,如下图所示:

1. 可见性问题:如果对象obj 没有使用volatile 修饰,A 线程在将对象count读取到本地内存,从1修改为2,B 线程也把obj 读取到本地内存,因为A 线程的修改对B 线程不可见,这是从Java 内存模型层面看可见性问题(前面从物理内存结构分析的)。

2. 有序性问题:重排序发生的地方有很多,编译器优化、CPU 因为指令流水批处理而重排序、内存因为缓存以及store buffer 而显得乱序执行。如下图所示:

附一张带store buffer (写缓冲)的CPU 架构图,希望详细了解store buffer 可以看文章最后面的扩展阅读。

每个处理器上的Store Buffer(写缓冲区),仅仅对它所在的处理器可见。这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序:

下图是各种CPU 架构允许的指令重排序的情况。

3. 原子性问题:例如多线程并发执行 i = i +1。 i 是共享变量,看完Java 内存模型,知道这个操作不是原子的,可以分为+1 操作和赋值操作。因此多线程并发访问时,可能发生线程切换,造成不是预期结果。

针对上面的三个问题,Java 中提供了一些关键字来解决。

  1. 可见性 & 有序性 问题解决
    volatile 可以让共享变量实现可见性,同时禁止共享变量的指令重排,保障可见性。从JSR-333 规范 和 实现原理讲:
  • JSR-333 规范:JDK 5定义的内存模型规范,
    在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

    1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
    2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
    • 实现原理:上面说的happens-before原则保障可见性,禁止指令重排保证有序性,如何实现的呢?
      Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性。
      内存屏障指令:写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性。

JVM中提供了四类内存屏障指令:

JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如下:

Java volatile 例子:

以下是区分各个CPU体系支持的内存屏障(也叫内存栅栏),由JVM 实现平台无关(volatile所有平台表现一致)

synchronized 也可以实现有序性和可见性,但是是通过锁让并发串行化实现有序,内存屏障实现可见。原理可以看《安琪拉与面试官二三事》系列的synchronized 篇。

    • 一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
    • 在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。
  1. 原子性问题解决
    原子性主要通过JUC Atomic***包实现,如下图所示,内部使用CAS 指令实现原子性,各个CPU架构有些区别。

扩展阅读

Java如何实现跨平台

作为Java 程序员的我们只需要写一堆 ***.java 文件,编译器把 .java 文件编译成 .class 字节码文件,后面的事就都交给Java 虚拟机(JVM)做了。如下图所示, Java虚拟机是区分平台的,虚拟机来进行 .class 字节码指令翻译成平台相关的机器码。

所以 Java 是跨平台的,Java 虚拟机(JVM)不是跨平台的,JVM 是平台相关的。 大家可以看 Hostpot1.8 源码文件夹,JVM 每个系统都有单独的实现,如下图所示:

As-if-serial

As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。

并发&并行

现代操作系统,现代操作系统都是按时间片调度执行的,最小的调度执行单元是线程,多任务和并行处理能力是衡量一台计算机处理器的非常重要的指标。这里有个概念要说一下:

  • 并发:多个程序可能同时运行的现象,例如刷微博和听歌同时进行,可能你电脑只有一颗CPU,但是通过时间片轮转的方式让你感觉在同时进行。
  • 并行:多核CPU,每个CPU 内运行自己的线程,是真正的同时进行的,叫并行。

内存屏障

JSR-133 对应规则需要的规则

另外 final 关键字需要 StoreStore 屏障

x.finalField = v; StoreStore; sharedRef = x;

MESI 协议运作模式

MESI 协议运作的具体流程,举个实例

第一列是操作序列号,第二列是执行操作的CPU,第三列是具体执行哪一种操作,第四列描述了各个cpu local cache中的cacheline的状态(用meory address/状态表示),最后一列描述了内存在0地址和8地址的数据内容的状态:V表示是最新的,和cache一致,I表示不是最新的内容,最新的内容保存在cache中。

总结篇

Java内存模型

Java 内存模型(JSR-133)屏蔽了硬件、操作系统的差异,实现让Java程序在各种平台下都能达到一致的并发效果,规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量,JMM使用内存屏障提供了java程序运行时统一的内存模型。

volatile的实现原理

volatile可以实现内存的可见性和防止指令重排序。

通过内存屏障技术实现的。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障指令,内存屏障效果有:

  • 禁止volatile 修饰变量指令的重排序
  • 写入数据强制刷新到主存
  • 读取数据强制从主存读取

volatile使用总结

  • volatile 是Java 提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),常用于
    状态标志、双重检查的单例等场景。使用原则:
    • 对变量的写操作不依赖于当前值。例如 i++ 这种就不适用。
    • 该变量没有包含在具有其他变量的不变式中。

volatile的使用场景不是很多,使用时需要仔细考虑下是否适用volatile,注意满足上面的二个原则。

    • 单个的共享变量的读/写(比如a=1)具有原子性,但是像num++或者a=b+1;这种复合操作,volatile无法保证其原子性;

 

 
 
 

 

posted on   秦羽的思考  阅读(723)  评论(0编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· Ollama——大语言模型本地部署的极速利器
· 使用C#创建一个MCP客户端
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· Windows编程----内核对象竟然如此简单?
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

点击右上角即可分享
微信分享提示