四、架构师-高并发与多线程-volatile关键字

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

要深入了解volatile关键字的实现原理,需要先了解一下内存模型、并发编程等相关知识;接下来就逐步分析这些知识点。

计算机内存模型

在这里插入图片描述
如上图,CPU在读数据时会从上到下依次查找数据。
一个cup可以有多个核芯,每个核芯都有独立的L1、L2高速缓存,多个核芯共用一个L3;如果是多个CPU则有多个L3,而多个L3都是与主存进行交互。大概模型如下:
在这里插入图片描述
当一个线程执行的过程中,PC记录线程执行的位置,Register寄存器用来存放计算过程中所需的或所得的各种信息,ALU(逻辑控制单元)从Register获取数据,从PC中查询到线程执行的位置用来计算。

缓存数据的规则,系统对数据的访问频率有两种假设:

  1. 时间局部性
    时间局部性假设目前访问的数据在接下来也更有可能再次访问到,所以计算机会把刚刚访问过的数据放入缓存中;
  2. 空间局部性
    空间局部性假设与目前访问的数据相邻的那些数据接下来也更有可能访问到,所以会把当前数据周围的数据放入缓存中;一个编写良好的代码往往符合时间局部性和空间局部性。

超线程:在ALU进行计算的过程中,如果一个ALU分别对应一个Register和PC(“四核四线程”),在线程切换的时候就要把Register和PC的数据做个快照暂存起来;为了提高ALU的利用率和计算效率,改进:一个ALU分别对应两个Register和PC(“四核八线程”),每对Register和PC存放一个线程信息,此时在线程切换的时候只要切换Register和PC就行了(当然线程太多的时候切换的时候还是会保存线程快照)。这种一个ALU对应多个Register|PC的方式就是超线程。

Cache Line 缓存行

在这里插入图片描述
Cache Line可以简单的理解为CPU Cache中的最小缓存单位。目前主流的CPU Cache的Cache Line大小都是64Bytes。当从内存中取单元到cache中时,会一次取一个cacheline大小的内存区域到cache中,然后存进相应的cacheline中;因为空间局部性:临近的数据在将来被访问的可能性大。

用段代码验证一下:

    public static void main(String[] args) {
        int[][] array = new int[64 * 1024][1024];

        long l1 = System.currentTimeMillis();
        // 横向遍历
        for(int i = 0; i < 64 * 1024; i ++)
            for(int j = 0; j < 1024; j ++)
                array[i][j] ++;
        long l2 = System.currentTimeMillis();
        System.out.println(l2 - l1);

        // 纵向遍历
        for(int i = 0; i < 1024; i ++)
            for(int j = 0; j < 64 * 1024; j ++)
                array[j][i] ++;
        long l3 = System.currentTimeMillis();
        System.out.println(l3 - l2);
    }

结果:
在这里插入图片描述
横向遍历的 CPU cache 命中率高,所以它比纵向遍历约快这么多倍!

伪共享False Sharing

当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
在这里插入图片描述
X Y在同一个cache line中。若果X/Y都是被volatile关键字修饰的,那么Core1对X修改后就要强制刷新(下文会讲)Care2中对应的Cache Line,同样Core2对Y修改后就要强制刷新Care1中对应的Cache Line;这样以来就会就会导致缓存失效。。。。效率急速下降。
验证一下:

   private static class Padding{
        // 填充数据,使变量不在同一个cache Line中
        // public volatile long p1, p2, p3, p4, p5, p6, p7; // 看这里 padding代码
    }

    private static class T extends Padding{
        // x
        public volatile long x = 0L;
    }

    public static T[] arrT = new T[2];

    static {
        arrT[0] = new T();
        arrT[1] = new T();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (long i = 0L; i < 1000_0000L; i++) {
                arrT[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0L; i < 1000_0000L; i++) {
                arrT[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 1000_0000);
    }
}

加了padding代码的运行结果
在这里插入图片描述
不加padding的结果
在这里插入图片描述
可以看出两个结果不是一个数量级的。

来源于一款优秀的开源框架 Disruptor 中的一个数据结构 RingBuffer;就用到了这种Cache Line 填充来提高效率。

abstract class RingBufferPad {
    protected long p1, p2, p3, p4, p5, p6, p7;
}

abstract class RingBufferFields<E> extends RingBufferPad{}

缓存一致性协议

处理器上有一套完整的协议,来保证Cache一致性。比较经典的Cache一致性协议当属MESI协议,奔腾处理器有使用它,很多其他的处理器都是使用它的变种。
MESI中每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid)。

  • exclusive:代表干缓存行对应的内容只被改CPU缓存,其他CUP没有缓存改缓存行对应内存中的数据。这个状态的缓存行中的内容和内存中的内容是一致的。当有其他CPU读取了E状态的缓存行则变为S状态;其他CPU写了E状态缓存行则变为M状态。
  • modified:代表该缓存行的内容被修改了,并且该缓存行只被缓存在改CPU中。这个状态的缓存行中的数据和内存的不一样,在未来的某个时刻它会被写入到内存中(当其他CPU要读该行内容时,或者其他CPU要修改该缓存对应的内容时)。
  • shared:该状态表示数据不止存在本地CPU缓存中,还存在别的CPU缓存中。这个状态的数据和内存中的数据是一致的。当其中一个CPU修改该缓存行对应的内存的内容时会使该缓存行变成I状态。
  • invalid:代表缓存行中的内容是无效的。

EMSI状态转换图:
在这里插入图片描述

EMSI状态转换表:

当前状态事件行为下一个状态
I(invalid)local read1.如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变为E
2.如果其他处理器中有这份数据,且缓存行状态为M,则先把缓存行中的内容写回到内存。本地cache再从内存读取数据,这时两个cache的状态都变为S
3.如果其他缓存行中有这份数据,并且其他缓存行的状态为S或E,则本地cache从内存中取数据,并且这些缓存行的状态变为S
E或S
-local write1.先从内存中取数据,如果其他缓存中有这份数据,且状态为M,则先将数据更新到内存再读取(个人认为顺序是这样的,其他CPU的缓存内容更新到内存中并且被本地cache读取时,两个cache状态都变为S,然后再写时把其他CPU的状态变为I,自己的变为M)
2.如果其他缓存中有这份数据,且状态为E或S,那么其他缓存行的状态变为I
M
-remote readremote read不影响本地cache的状态I
-remote writeremote read不影响本地cache的状态I
E(exclusive)local read状态不变E
-local write状态变为MM
-remote read数据和其他核共享,状态变为SS
-remote write其他CPU修改了数据,状态变为II
S(shared)local read不影响状态S
-local write其他CPU的cache状态变为I,本地cache状态变为MM
-remote read不影响状态 S
-remote write本地cache状态变为I,修改内容的CPU的cache状态变为MI
M(modified)local read状态不变M
-local write状态不变M
-remote read先把cache中的数据写到内存中,其他CPU的cache再读取,状态都变为SS
-remote write先把cache中的数据写到内存中,其他CPU的cache再读取并修改后,本地cache状态变为I。修改的那个cache状态变为MI

并发编程基本概念

在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念(详细讲解就不写了):

原子性

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

可见性

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

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
1. 通过在总线加LOCK#锁的方式。
2. 通过缓存一致性协议EMSI协议。

这两种方式都是在硬件级别实现的。

JVM内存模型JMM

Java内存模型为我们提供了保证以及在java中提供了一些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
在这里插入图片描述

原子性

Java中的原子性操作包括:

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

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

可见性

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

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

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

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

另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 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()方法的开始。

volatile

volatile两个特性

一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的
  2. 禁止进行指令重排序

volatile线程可见性

public class Volatile1 {

   /* volatile */ private boolean running = true;

    void m(){
        System.out.println("start");
        while (running){}
        System.out.println("end");
    }

    public static void main(String[] args) {
        Volatile1 o = new Volatile1();

        new Thread(o::m,"1").start();
		TimeUnit.SECONDS.sleep(1);
        new Thread(() ->{o.running = false;}, "2").start();

    }
}

线程1先执行,线程2后执行,那么线程1将是一个死循环。因为每个线程运行过程中都有一个自己的工作内存,那么线程1在运行的时候,会把stop的值复制到自己的工作内存中;同样线程2也会复制一份到自己内存中。当线程2修改running的值后,即使刷回到了主存,此时线程1自己工作内存中的值也不会被刷新,线程1就会死循环。
但是用volition修饰running变量后情况就不同了,volatile关键字会强制将线程2修改的值立即写入主存,也会导致线程1的工作内存变量running的缓存无效;由于线程1的工作内存中缓存变量无效之后,线程1会再次从主存读取新值。然后线程1就会停止循环。

虽然volatile能保证可见性,但是并不保证原子性。
如:

public class Test {
    public volatile int inc = 0;
        
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                       inc++;
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

理想的运行结果应该是10000,但是运行结果往往是小于10000的。原因是inc ++不是原子操作,前面有说过自增操作不是原子操作,它包括读取变量值,进行+1操作,写回变量值三个步骤。假如inc = 10时,A线程读取了10,然后被阻塞;B线程开始运行,由于线程A只是读取并没有进行修改,所以不会刷入主存,所以B也读取了10;然后B进行+1操作后B被阻塞;A获得执行权,A进行+1操作,并把11写入主存,并把B工作内存中的inc=10失效;虽然B中inc已经失效,但是B再此之前已经进行+1操作,所以B往主存中写入的也是11。这样就导致了程序出错。
解决办法就是使用AtomicInteger类、synchronized或者Lock。

volatile禁止指令重排序

volatile关键字禁止指令重排序有两层意思:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的写操作肯定全部完成,且结果已经对其后面的操作可见;在其后面的操作肯定都没进行。
  2. 在指令优化时,不能将volatile变量前的的语句放在其后面,也不能将volatile变量后的语句放在其前面。

1是对数据的有序,2是对代码的有序。

如:

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 4 + 5;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

不会出现先执行语句4再执行语句2的情况。也不会将语句3放在1,2的前面或4,5的后面。但是1和2的顺序,4和5的顺序没有保证。
并且当执行4和5时,语句1和2已经执行完毕了,且1和2的执行结果对4和5可见。(编译器层面的可见性)

volatile的原理和实现机制

上面有说到在计算机模型中保证可见性有两种方式:1、通过缓存一致性协议,2、通过锁总线的方式。
另外在计算机中CPU的内存屏障是靠sfence,mfence,ifence等系统原语实现有序性。而在JVM虚拟机中是按照JVM虚拟机规范来解决指令重排序的。在Hotspot虚拟机实现中使用的是Lock总线的方式来实现指令重排序的;并没有使用CPU提供的内存屏障原语,原因是为了与宿主主机解耦,“一处编译,多处运行”。

JVM内存屏障

屏障两边的指令不可以重排序。

在JVM规范中内存屏障规范4种:

屏障类型意义
LoadLoad对于语句Load1; LoadLoad; Load2;在Load2以及后续的读取操作要读取的数据被访问前,要保证Load1要读取的数据被读取完毕。
StoreStore对于语句Store1; StoreStore; Store2; 在Store2以及后续写入操作执行前,要保证Store1的写操作对其他处理器可见。
LoadStore对于语句Load1; LoadStore; Store2;在Store2以及后续的写入操作在被执行前,要保证Loade1的读取的数据被读取完毕。
StoreLoad对于语句Store1; StoreLoad; Load2; 在Load2以及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

任何JVM虚拟机的实现都要满足:
在这里插入图片描述

在对volatile变量写操作前加了StoreStore屏障,写操作后加了StoreLoad屏障。即volatile写之前在前面的写操作都已经写完,在其后面的读操作会等volatile变量写好之后再读。对volatile的读操作类似。

hotsport虚拟机的内存屏障实现

在这里插入图片描述

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。

volatile关键字的应用场景

  1. 状态标记
volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}
  1. double check
class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                	// 防止这行代码指令重排序
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

为什么执行instance = new Singleton();这行代码的时候会发生指令重排序?
对象的创建过程:
源码:

class T {
	int m = 8;
}

T t = new T();  

T t = new T(); 这行代码对应的字节码(idea安装“jclasslib bytecode viewer”插件):
在这里插入图片描述
在这里插入图片描述

// 申请内存空间,根据对象的的大小分配内存空间,此时m = 0, 类似C的malloc();
0 new #2 <com/gu/sync/T>
// 调用构造方法进行初始化,进行变量初始化 m = 8,以及其他初始化操作。
4 invokespecial #3 <com/gu/sync/T.<init>>
// 将对象引用 t 与 对象new T() 进行关联,即将t指向new T()所在的内存
7 astore_1
8 return

** 那么问题就来了,第4行和第7行是可以进行指令重排序的,如果4和7进行了重排序,这时的状态是:t != null且对象没有初始化完成;那么if(instance==null)就不会满足,就会返回一个未初始化完成的对象(m = 0)。。。 **

本文参考:http://www.cnblogs.com/dolphin0520/p/3920373.html
https://blog.csdn.net/u010983881/article/details/82704733
https://blog.csdn.net/u012723673/article/details/80682208

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值