【并发编程】JMM以及happens-before原则

JMM

什么是JMM呢?
首先,我们都知道Java程序是运行在Java虚拟机上的,同时我们也知道,JVM是一个跨语言跨平台的实现,也就是Write Once、Run Anywhere。

那么JVM如何实现在不同平台上都能达到线程安全的目的呢?
这个时候JMM出来了,Java内存模型 (Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的, 保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中 保存了这个线程中用到的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。

不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
在这里插入图片描述

本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

JMM的整个模型实际上和CPU的高速缓存和内存交互的模型是一致的。这个抽象模型的意义在于,它可以针对不同平台来保证并发场景下的可见性问题

JMM定义了四种屏障类型,在不同的操作系统会有不同的实现
在这里插入图片描述
jvm的源码里定义了这样的接口

inline void OrderAccess::loadload() {
    
     acquire(); }
inline void OrderAccess::storestore() {
    
     release(); }
inline void OrderAccess::loadstore() {
    
     acquire(); }
inline void OrderAccess::storeload() {
    
     fence(); }

在不同的操作系统下其内存屏障指令会有不同的实现,比如linux_x86和linux_sparc的实现就是不一样的

orderAccess_linux_x86.inline

inline void OrderAccess::fence() {
    
    
	if (os::is_MP()) {
    
    
	// always use locked addl since mfence is sometimes expensive
	#ifdef AMD64
	__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
	#else
	__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
	#endif
	}
}
orderAccess_linux_sparc.inline

inline void OrderAccess::fence() {
    
    
__asm__ volatile ("membar #StoreLoad" : : :);
}

volatile保证可见性原理

我们给字段添加volatile关键字之后,通过javap -v命令查看,可以看到该变量增加了一个ACC_VOLATILE的flag
在这里插入图片描述
在这里插入图片描述
程序在修改stop变量的时候会调用putstatic方法,部分源码如下,jvm会判断该变量是否是volatile修饰的,如果是的话,会增加一个storeload的内存屏障指令,从而保证可见性
在这里插入图片描述
在这里插入图片描述

Happens-Before原则

但在实际工作中,我们并不需要对所有涉及到多线程的代码都添加volatile关键字。

也就是说在某些情况下,我们不需要增加volatile关键字,也能保证在多线程环境下的可见性和有序性。

这是因为从JDK1.5开始,引入了一个happens-before的概念来部分场景下多个线程操作共享变量的可见性问题。对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。

Happens-Before规则包含下面八大原则

【原则一】程序次序规则

在一个线程中,按照代码的顺序,前面的操作Happens-Before于后面的任意操作。

可以简单认为是as-if-serial。as-if-serial的意思是,不管怎么重排序,单线程的程序的执行结果不能改变。

  • 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序的执行结果。
  • 对于没有依赖关系的指令,即便是重排序,也不会改变在单线程环境下的执行结果
int a=2; //A
int b=2; //B
int c=a*b; //C

A和B允许重排序,但是C不允许重排,因为存在依赖关系。根据as-if-serial规则,在单线程环境下, 不管怎么重排序,最终执行的结果都不会发生变化

【原则二】volatile变量规则

对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作,这个是因为volatile底层通过内存屏障机制防止了指令重排

【原则三】传递规则

如果A Happens-Before B,并且B Happens-Before C,则A Happens-Before C。

public class VolatileExample {
    
    
	int a=0;
	volatile boolean flag=false;
	public void writer(){
    
    
		a=1; //1
		flag=true; //2
	}
	public void reader(){
    
    
		if(flag){
    
     //3
			int i=a; //4
		}
	}
}

1 happens before 2, 3 happens before 4, 这个是程序次序规则得出的结论。
2 happens before 3 是由volatile规则得出的。
所以1 happens before 4

这就又有个问题了,程序次序规则中,在单线程里,如果两个指令之间不存在依赖关系,是允许重排序的,也就是1 和 2的顺序可以重排,那么是不是意味着最终4输出的结果可能是0呢?

这里也是因为volatile修饰的重排序规则的存在,导致1和2是不允许重排序的,在volatile重排序规则表中,如果第一操作是普通变量的读/写,第二个操作是volatile的写,那么这两个操作之间不允许重排序。
在这里插入图片描述

【原则四】监视器锁规则

对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作。

int x=10;
synchronized (this) {
    
     // 加锁
	// x 是共享变量, 初始值 =10
	if (this.x < 12) {
    
    
		this.x = 12;
	}
} // 解锁

线程A修改x变量值为12后,第二个线程获得锁读取到的x的值一定是12

【原则五】start规则(线程启动)

如果线程A调用线程B的start()方法来启动线程B,则ThreadB.start()之前的操作Happens-Before于线程B中的任意操作。

int x=0;
Thread t1 = new Thread(()->{
    
    
// 主线程调用 t1.start() 之前的所有共享变量的修改,此处皆可见
// 此例中,x==100
});
x = 100;
// 主线程启动子线程
t1.start();

【原则六】join规则(线程终结)

线程A等待线程B完成(在线程A中调用线程B的join()方法实现),当线程B完成后(线程A调用线程B的join()方法返回),则线程A能够访问到线程B对共享变量的操作。

Thread t1 = new Thread(()->{
    
    
	// 此处对共享变量 x 修改
	x= 100;
});
t1.start();
t1.join()
// 子线程(1)所有对共享变量的修改在主线程调用 t1.join() 之后皆可见
// 此时x==100

【原则七】interrupt规则(线程中断)

对线程interrupt()方法的调用Happens-Before于被中断线程的代码检测到中断事件的发生。

在线程A中中断线程B之前,将共享变量x的值修改为100,则当线程B检测到中断事件时,访问到的x变量的值为100。

//在线程A中将x变量的值初始化为0
    private int x = 0;

    public void execute(){
    
    
        //在线程A中初始化线程B
        Thread threadB = new Thread(()->{
    
    
            //线程B检测自己是否被中断
            if (Thread.currentThread().isInterrupted()){
    
    
                //如果线程B被中断,则此时X的值为100
                System.out.println(x);
            }
        });
        //在线程A中启动线程B
        threadB.start();
        //在线程A中将共享变量X的值修改为100
        x = 100;
        //在线程A中中断线程B
        threadB.interrupt();
    }

【原则八】finalize原则(对象终结)

一个对象的初始化完成Happens-Before于它的finalize()方法的开始。

public class TestThread {
    
    

    public TestThread() throws InterruptedException {
    
    
        System.out.println("执行构造方法");
        Thread.sleep(3000);
        System.out.println("构造方法执行完成");
    }

    @Override
    protected void finalize() throws Throwable {
    
    
        System.out.println("执行对象销毁");
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        new TestThread();
        System.gc();
    }
}

在这里插入图片描述

为什么Happens-Before原则到底是如何解决变量间可见性问题的。
导致多线程间可见性问题的两个根本原因是CPU高速缓存和指令重排序。那么如果要保证多个线程间共享的变量的操作对每个线程都可见,最简单粗暴的做法就是禁止使用高速缓存和指令重排序。但这会极大影响CPU的处理性能,肯定是不行的。

我们可以用一种折中的方案,将整个程序用分割线划分成几个程序块,在每个程序块内部的指令是可以重排序的,但是当程序从一个执行块执行到另外一个执行块的时候,CPU必须将执行结果同步到主内存或从主内存读取最新的变量值。

Happens-Before规则就是定义了这些程序块的分割线

猜你喜欢

转载自blog.csdn.net/qq_35448165/article/details/130030169