深入JAVA并发编程(二):JAVA内存模型

多线程并发编程

线程安全性问题

共享资源是指该资源被多个线程持有或者说多个线程都可以去访问该资源。

线程安全问题是指当多个线程同时读写一个共享资源时并且没有任何同步措施时,导致出现脏数据其他或不可预见结果的问题。当多个线程只是读取共享资源而不去修改的时候,是不会出现线程安全问题的。只有当至少一个线程修改共享资源时才会存在线程安全问题。

举个例子例如计数器。有一个共享变量count,在t1时刻时A线程读取count=0,t2时刻A线程递增count=1,B线程读取count=0,t3时刻A线程把递增后的值写入,B线程递增count=1,t4时刻B线程把递增后的值写入。我们发现,明明进行了两次计数,为什么结果还是1呢?这就是线程安全问题,如何解决?需要在线程访问共享变量时进行适当的同步。

JAVA中共享变量的内存可见性问题

我们在操作系统中学过,为了提高处理速度,CPU不是直接和内存进行通信的,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不是立马写入内存的。JAVA内存模型中,会将所有的变量放入主内存中,支持多个线程同时访问一个对象或者变量,但是当线程使用变量时,会把主内存里的变量复制到自己的工作空间,线程读写时操作的是自己工作空间的变量,处理完毕后再将变量值更新到主内存,这样做的目的是为了加速程序的执行,是现代多核处理器的一个显著特征,但是这就会引起一个问题,就是线程中看到的变量并不一定是最新的。这里的工作空间对应的就是操作系统的L1或者L2等缓存。

例如线程A和线程B同时处理一个变量,线程A首先获取共享变量X的值,它会先去自己的工作空间中找,如果没找到将主内存中X的值0加载到自己的工作空间,然后修改值为1写入到自己的工作空间(两级缓存中)并且更新到主内存。线程B获取X的值,首先从工作空间获取(先从一级缓存中找,未找到再从二级缓存找),由于二级缓存是所有核心共享的,所以在二级缓存中找到x为1,然后修改值为2放入缓存中并且更新到主内存。当A线程需要再次修改时,会先从工作空间找,从一级缓存找到的X是为1的,也就是线程B写入的值对线程A不可见,这就是内存可见性问题。而volatile关键字可以帮助我们解决这个问题。

JAVA中的volatile关键字

volatile是轻量级的synchronized,它所修饰的变量能够保证可见性。该关键字可以确保当前线程对一个共享变量的更新对其他线程马上可见。如果volatile使用恰当的话,会比synchronized使用成本更低,因为它不会引起上下文切换和线程调度的开销。

volatile会将当前修改的变量直接写回主内存,其他线程读取的时候会直接从主内存读取,而不会再从缓存中读。

JAVA中的synchronized关键字

synchronized块是Java提供的一种原子性内置锁,java中的每个对象都可以把它当作一个同步锁来使用,这些java内置的、使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入synchronized代码块前会自动获取监视器锁,这时候其他线程来访问该同步代码块时会被阻塞挂起。拿到监视器锁的线程会在正常退出同步代码块或者抛出异常或者在同步代码块调用该监视器资源的wait系统方法时释放该锁 。监视器锁是排他锁,其他线程必须等该线程释放后才能获取。

synchronized也可以解决内存可见性问题,前面讲到共享变量内存可见性问题主要是因为线程的工作空间导致的,而synchronized有一个内存语义,这个内存语义可以解决共享变量的内存可见性问题。进入synchronized块的内存语义是把在该块内使用到的变量从线程的工作空间中清除,这样在synchronized内使用到该变量时就不会再从线程的工作空间去获取,而是直接到主内存获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改写入主内存。synchronized关键字会引起线程上下文切换带来线程调度开销,性能不是很好。

我们来通过例子来看下synchronized和volatile的区别。

public class MemoryDemo {

    private int value;

    public synchronized int getValue() {
        return value;
    }

    public synchronized  void setValue(int value) {
        this.value = value;
    }
}


public class MemoryDemo {

    private volatile int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

在这里使用两种方法是等价的,都解决了共享变量value的可见性问题。但是synchronized是独占锁,同时只能有一个线程调用getset方法,其他线程会被阻塞。而volatile是非阻塞的。

volatile虽然提供了内存可见性保证,但是不保证操作的原子性。
一般什么时候使用volatile呢?

  • 写入变量值不依赖变量的当前值时,因为如果依赖当前值,例如计数器案例,获取计算写入三步操作不是原子性的,会造成错误。
  • 读写变量值时没有加锁,因为加锁已经保证了内存可见性。

JAVA内存模型

并发编程中,有两个关键问题需要处理:线程之间如何通信以及线程之间如何同步。
通信是指线程之间以何种机制来交换信息。
同步是指程序中用于控制不同线程间操作发生的相对顺序的机制。

JAVA的并发采用的是共享内存模型。在共享内存的并发模型中,多个线程之间共享程序的公共状态,通过写-读内存中的公共状态来进行隐式通信。而同步是显式进行的,程序员必须显式指定某个方法或代码需要在线程之间互斥执行。

我们知道在JVM中,所有实例对象、静态对象和数组元素等都是存放在堆区,堆内存是线程间共享的。

JAVA线程之间的通信由JAVA内存模型(Java Memory Model,JMM)控制,JMM是java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。 JMM规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本。本地内存是JMM的抽象概念,并不真实存在。JAVA内存模型示意图如下所示:

在这里插入图片描述

从上图来看,如果线程A和线程B要进行通信的话,需要经历两个步骤

  • 线程A把本地内存中更新过的共享变量刷新到主内存中
  • 线程B从主内存中读取更新过的共享变量

在这里插入图片描述

假设初始时,这三个内存中x的值都为0,线程A在执行时,把更新后的x值临时放在本地内存。当线程A与线程B需要通信时,

  • 线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。
  • 线程B到主内存中读取线程A更新后的X值,此时线程B的本地内存x的值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发消息,而且这个通信过程必须经过主内存

JMM通过控制主内存与每个线程的本地内存之间的交互,提供内存可见性的保证。

从源代码到指令序列的重排序

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

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。(编译器重排序)
  • 指令级并行的重排序。现代处理采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应及其指令的执行顺序。(处理器重排序)
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。(处理器重排序)

从源代码到最终执行的指令序列,会经历以下重排序过程:

在这里插入图片描述

这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(并不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,通过内存屏障(Memory Barriers,后面会解释)指令来禁止特定类型的处理器重排序。

现在的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。

我们来看个例子:

public class ElevenDemo {
    public static void main(String[] args) throws Exception{
    	//创建内部类
        class Pointer {
            int a = 0;
            int b = 0;
            int x = 0;
            int y = 0;

            public void set1() {
                a = 1;
                x = b;
            }

            public void set2() {
                b = 1;
                y = 2;
            }
        }

        int i = 0;
        while (true) {
            Pointer counter = new Pointer();
            
            //执行set1方法
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    counter.set1();
                }
            });
            
			//执行set2方法
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    counter.set2();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("i="+(++i)+",x=" + counter.x + ", y=" + counter.y);
            if (counter.x == 0 && counter.y == 0) {
                break;
            }
        }
    }
}

在这里插入图片描述

如上所示,如果两个线程按照执行顺序一直并行执行上述代码,就可能会出现x=y=0的结果。为什么呢?如下图所示

在这里插入图片描述

在这里插入图片描述

处理A和处理B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(B1,B2),最后才把自己写入缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。

从内存实际操作发生的顺序来看,直到处理器A执行A3将数据刷新到主内存,a=1的写操作才算是真正执行了,虽然处理器A执行内存操作的顺序为A1->A2,但内存操作顺序被重排序了,实际却变成了A2->A1。也就是变成了先读后写,所以出现了x=y=0的情况

由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现在的处理器都会允许对写 - 读操作进行重排序。下图是常见处理器允许的重排序类型的列表。N表示不允许两个操作之间重排序,Y表示可以。
在这里插入图片描述

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如下表

在这里插入图片描述StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理大多支持该屏障(其他类型屏障不一定支持)。执行该屏障开销会很昂贵,因为当前处理通常要把写缓冲区的数据全部刷新到内存中。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
并不是所有指令都会进行重排序,重排序需要遵循以下几个特性:

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个是写操作,此时这两个操作之间就存在数据依赖性。数据依赖性分为下列三种类型:

在这里插入图片描述

上述的三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器是不会改变存在数据依赖关系的两个操作的执行顺序的。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

遵循as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。看如下例子

		double pi=3.14     //A操作
		double r=1.0		//B操作
		double area=pi*r*r  //C操作

从代码中可知,A操作和C操作之间有数据依赖关系,B操作和C操作之间有数据依赖关系。因此,在最终执行的指令序列中,C不能被重排序到A和B前面,但A和B之间没有数据依赖关系,编译器和处理器可以重排序AB的执行顺序,那么就会有两种执行顺序,但是都不影响结果。

在这里插入图片描述

as-if-serial语句使单线程程序无需担心重排序会影响到程序结果,也无需担心内存可见性问题。

但是注意这里仅仅指的是单线程,我们上面也说过了多线程之间的数据依赖性是不被考虑的,我们来看一个多线程的例子。

public class TwelveDemo {
    int a=0;
    int sum=0;
    boolean flag=false;

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

    public void reader(){
        if(flag){			//3
            sum=a*a;		//4
        }
    }

    public static void main(String[] args)throws Exception {
        while (true){
            TwelveDemo twelveDemo=new TwelveDemo();
            Thread thread1=new Thread(new Runnable() {
                @Override
                public void run() {
                    twelveDemo.writer();
                }
            });

            Thread thread2=new Thread(new Runnable() {
                @Override
                public void run() {
                    twelveDemo.reader();
                }
            });

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("sum="+twelveDemo.sum);
            if(twelveDemo.sum==0){
                break;
            }
        }
    }
}

这个代码中,flag为标记,用来标识变量a是否被写入,sum的值则依赖于a。
现在我们开启两个线程,一个执行writer方法,一个执行reader方法,我们来看看结果。

在这里插入图片描述

我们看到,sum出现了0的情况,那么为什么会这样呢?通过前面的学习,我们应该知道是重排序的问题。

首先在一个线程中,操作1和操作2没有数据依赖关系,同样的操作3和操作4也同样没有数据依赖关系。操作1和操作4是有数据依赖关系的,但是多线程之间是无效的,所以就可能出现以下情况。

在这里插入图片描述

通过这个例子,可以看到多线程的语义被重排序给破坏了。

顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编译器的内存模型都会以顺序一致性内存模型作为参照。

数据竞争与顺序一致性

当程序未正确同步时,就可能存在数据竞争。Java内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量
  • 在另一个线程读同一个变量
  • 而且写和读没有通过同步来排序

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(譬如重排序案例)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性-----即程序的执行结果与该程序在顺序一致性内存模型中的执行的结果相同。

顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。它有两大特性:

  • (1)一个线程中的所有操作必须按照程序的顺序来执行

  • (2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型的视图:

在这里插入图片描述

在理论概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任何一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点,只有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的读/写操作串行化。

但是在JMM中是没有这个保证的,未同步的程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作顺序也可能不一致,比如,当前线程把写过的数据缓存到工作空间,在没有刷新到主内存时,这个写操作只对当前线程可见。

happens-before

从JDK5开始,JAVA使用新的JSR-133内存模型,JSR-133使用happens-before阐述操作之间的内存可见性。happens-before是JMM最核心的概念,所以理解happens-before是理解JMM的关键。

在JMM中,如果一个操作执行的结果需要对另一个线程可见,那么这两个操作之间必须存在happens-before关系。这里的两个操作可以是同个线程,也可以是多个线程之间。

两个操作之间具有happens-before关系,并不意味着前一个操作必须在后一个操作之前执行,happens-before仅仅要求前一个操作的执行结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

A happens-before B并不是保证A一定在B之前执行,而是如果A在B之前执行了,那么JMM会保证A的操作对B可见。

如下图所示,一个happens-before规则对应一个或者多个编译器和处理器的重排序规则,对于程序员来说,happens-before规则简单易懂,避免程序员为了理解JMM提供的程序可见性保证而去学习复杂的重排序规则以及这些规则 的实现方法。

在这里插入图片描述

JMM的设计

JMM设计者的角度,设计JMM时,需要考虑两个关键因素

  • 需要考虑程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程,希望基于一个强内存模型来编写代码。
  • 需要考虑编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素相互矛盾,所以设计的时候核心要找到一个良好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。

看个例子:

		double pi=3.14     //A操作
		double r=1.0		//B操作
		double area=pi*r*r  //C操作

在这段代码中,存在三个happens-before关系

A happens-before B
B happens-before C
A happens-before C

第二个和第三个是必须的,第一个却不是必要的,因此,JMM把happens-before要求禁止的重排序分为下面两类,并采取了不同的策略:

  • 会改变程序执行结果的重排序,对于这种JMM要求编译器和处理器必须禁止这种重排序。
  • 不会改变程序执行结果的重排序,对于这种JMM对编译器和处理器不作要求(JMM允许这种重排序)

下面是JMM的设计示意图:

在这里插入图片描述

从上图可以看出两点:

  • JMM提供的happens-before规则能满足程序员的要求:它不仅简单易懂,而且提供了足够强的内存可见性保证。
  • JMM对编译器和处理器的束缚已经尽可能少。从上图来看,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指单线程或正确同步的多线程),编译器和处理器怎么优化都行。例如:如果编译器经过细致的分析后,认定一个锁只会被单线程访问,那么这个锁可以被消除。如果一个volatile变量只会被单个线程访问,那么编译器可以将其当作普通变量来对待,这些优化既不会改变程序的结果,又能提高程序的运行效率

happens-before的定义

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系提供跨线程的内存可见性保证(如果A线程的写操作和B线程的读操作存在happens-before关系,JMM保证A线程的写操作对B线程的读操作可见)

JSR-133对happens-before关系的定义如下:

  • (1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • (2)两个操作之间存在happens-before关系,并不意味者Java平台的具体实现必须要按照happens-before关系执行的顺序来执行。如果重排序之后的执行结果与按happens-before关系来执行的结果一致,那么这种重排序并不非法,也就是说JMM允许这种重排序。

上面的第一点是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B, 那么Java内存模型将向程序员保证–A操作的结果将对B可见,且A的执行顺序排在B之前。注意这是Java内存模型做出的保证。

上面的第二点是JMM对编译器和处理器重排序的约束规则。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,关心的是程序执行时的语义不能被改变即执行结果不能被改变。因此,happens-before关系本质上和前面说的as-if-serial语义是一回事。

  • a) as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

  • b) as-if-serial语义给编写单线程程序的创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

这两者都是为了在不改变执行结果的前提下,尽可能地提供程序执行的并行度。

happens-before规则

JSR-133定义了如下happens-before规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。(通俗的说:单线程中前面的动作发生在后面的动作之前)
  • 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。(通俗的说:解锁操作发生在加锁操作之后)
  • volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读。(通俗的说:对volatile变量的写发生在读之前)
  • 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C.
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作 happens-before 于线程B中的任意操作
  • join()规则:如果线程A执行操作Thread.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join操作成功返回。

我们来看个例子,下图是volatile写-读建立的happens-before 关系图

在这里插入图片描述

  • 1 happens-before 2和 3 happens-before 4由程序顺序规则产生,由于编译器和处理器都要遵守as-if-serial语义,也就是说,as-if-serial保证了程序顺序规则。
  • 2 happens-before 3是由volatile规则产生的。前面提到过,对一个volatile变量的读,总是能看到(任意线程)之前对这个volatile变量最后的写入。
  • 1 happens-before 4是由传递性规则产生的。

接下来我们来看下start规则。

在这里插入图片描述

  • 1 happens-before 2是程序顺序规则产生。2 happens-before 4是由start规则产生,根据传递性,则1 happens-before 4.这就意味着,线程A在执行ThreadB.start之前对共享变量所作的修改,在线程B执行后确保对线程B可见。

接下来看join规则

在这里插入图片描述

2 happens-before 4由join规则产生,4 happens-before 5由程序顺序规则产生,根据传递性,则2 happens-before 5,这意味着,线程A执行ThreadB.join并成功返回后,线程B的任意操作都对线程A可见。

volatile内存语义

volatile的特性

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 对任意单个volatile变量的读/写具有原子性。但是类型于a++这种复合操作不具有原子性。

理解volatile特性的一个很好的方法,就是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。

volatile写-读的内存语义

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

volatile内存语义的实现

为了实现volatile内存语义,JMM会限制编译器和处理器的重排序。以下是JMM针对编译器指定的volatile重排序规则表。

在这里插入图片描述

例如第三行最后一个单元格的意思就是,在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略,下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)。

上述内存屏障插入策略非常保守,但它可以保证在任意处理平台,任意的程序中都能得到正确的volatile语义。

下面是保守策略下,volatile 写操作 插入内存屏障后生成的指令序列示意图:

在这里插入图片描述

上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了(因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存)。另外volatile写后面的StoreLoad屏障,此屏障的作用是避免volatile写与后面可能有的读或写操作进行重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)为了保证能正确实现volatile的内存语义,JMM采取了保守策略:在每个volatile写的后面插入一个StoreLoad屏障。

因为volatile写-读内存语义的常见模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里也可看出JMM在实现上的一个特点:首先确保正确性,然后再去追求效率。

下面是在保守策略下,volatile读插入内存屏障后生产的指令序列示意图:

在这里插入图片描述

上图的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况忽略不必要的屏障。我们来看一个例子:

	public class ThirtyDemo {
	    int a;
	    volatile int v1=1;
	    volatile int v2=2;
	
	    void readAndWrite(){
	        int i=v1;       //第一个volatile读
	        int j=v2;       //第二个volatile读
	        a=i+j;          //普通写
	        v1=i+1;         //第一个volatile写
	        v2=j*2;         //第二个volatile写
	    }
	}

针对上面的readAndWrite方法,编译器在生成字节码时可以做如下的优化。

在这里插入图片描述

最后的StoreLoad屏障不能省略,因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。

上面的优化针对任意处理器平台,但是内存屏障的插入还可以根据不同的处理器内存模型继续优化。例如X86处理器,除了最后的StoreLoad屏障,其他的屏障都会被省略。

锁的内存语义

锁的释放-获取建立的happens-before关系

锁是JAVA并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

我们来看个例子。

public class FourteenDemo {
    int a=0;

    public synchronized void writer(){        		//1
        a++;                                      	//2
    }                                              	//3

    public synchronized void reader(){        	  //4
        int i=a;                                  //5
    }                                             //6
}
 

假设线程A先执行writer()方法,随后线程B执行reader()方法(为什么要假设?因为并不是一定按这种顺序发生)。根据happens-before规则,这个过程包含的happens-before关系可以分为3类:

  • 根据程序次序规则:1 happens-before 2, 2 happens-before 3, 4 happens-before 5, 5 happens-before 6;
  • 根据监视器锁规则:3 happens-before 4;
  • 根据传递性规则,2 happens-before 5。

上述happens-before关系的图形化表现形式如下:

在这里插入图片描述

锁的释放和获取的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。上面的例子中,线程1释放锁后,共享数据的状态示意图如下:

在这里插入图片描述

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

在这里插入图片描述

对比锁释放-读取的内存语义与volatile写-读的内存语义可以看出,锁释放与volatile写具有相同的内存语义;锁获取与volatile读具有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结。

  • 线程1释放一个锁,实质上是线程1向接下来将要获取这个锁的某个线程发出了(线程1对共享变量所做修改的)消息。
  • 线程2获取一个锁,实质上是线程2接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程1锁释放,随后线程2获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

final域的内存语义

final是JAVA的关键字之一,有被final修饰的类不能继承,被final修饰的方法不能被重写,被final修饰的变量不能被改变等等作用。
今天我们来说说final域在JMM中的内存语义。

final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则:

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这个两个操作不能被重排序。
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

我们通过一些例子来说明这个规则。

		public class FifteenDemo {
		    int i;					//普通变量
		    final int j;			//final变量
		    static  FifteenDemo obj;
		    public FifteenDemo(){		//构造函数
		        i=1;					//写普通域
		        j=2;					//写final域
		    }
		
		    public static void writer(){			
		        obj=new FifteenDemo();
		    }
		
		    public static void reader(){
		        FifteenDemo fifteenDemo=obj;	//读对象引用
		        int a=fifteenDemo.i;			//读普通域
		        int b=fifteenDemo.j;			//读final域
		    }
		
		}

假设线程A执行writer方法,线程B执行reader方法。

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面两个方面:

  • JMM禁止编译器吧final域的写重排序到构造函数之外
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

我们来分析一下writer方法,writer方法中只有一行代码:obj=new FifteenDemo();这行代码包含两个动作:

  • 构造一个FifteenDemo对象
  • 把这个对象的引用赋值给引用变量obj

假设线程B读对象引用和读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),那么就可能出现以下的执行顺序

在这里插入图片描述

在上图中,写普通域的操作被编译器重排序到了构造函数之外,线程B错误的读到了普通变量初始化之前的值。而写final域的操作,被写final域的重排序规则限定在了构造函数之内,线程B则正确的读到了final变量初始化后的值。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域则不具有这个保障。以上图为例,线程B看到对象引用obj时,很可能obj对象还没有构造完成(对普通域的写操作被重排序到了构造函数外,此时初始值1还没有写入普通域)。

读final域的重排序规则是:

  • 在一个线程中,初次读这个对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

上面我们的例子是final域是基础数据类型,如果final域是引用类型,会是什么情况呢?

		public class FinalExample {
		    final int[] array;						//引用类型
		    static FinalExample finalExample;		
		
		    public FinalExample(){
		        array=new int[1];				//1
		        array[0]=1;						//2
		    }
		
		    public static void writerOne(){			//线程A执行
		        finalExample=new FinalExample();	//3
		    }
		    public static void writerTwo(){			//线程B执行
		        finalExample.array[0]=2;			//4
		    }
		
		    public static void reader(){			//线程C执行
		        if(finalExample!=null){				//5
		            int temp=finalExample.array[0]; //6
		        }
		    }
		
		}

这个例子中final域为引用类型,对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。

假设线程A先执行writeOne方法,执行结束后线程B执行writeTwo方法,执行完后线程C执行reader方法。1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了1和3不能重排序外,2和3也不能重排序。

JAVA内存模型总结

顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺序一致性内存模型为参照。但是在设计时,JMM和处理器内存模型会对顺序一致性模型做一些宽松处理,因为如果完全按照顺序一致性模型来实现的话,很多的处理器和编译器优化都会被禁止,对执行性能有很大的影响。

JMM屏蔽了不同处理器内存模型的差异,在不同的处理器平台上为JAVA程序员提供了一个一致的内存模型。

在这里插入图片描述

参考书籍:《JAVA并发编程的艺术》

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值