共享模型-内存

82 阅读13分钟

共享模型之内存

Monitor主要关注的是访问共享变量时,保证临界区代码的原子性

那么如何保证共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题

Java内存模型

JMM即Java Memory Model,它定义了主存,工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。

JMM体现在以下几个方面

  • 原子性-保证指令不会受到线程上下文切换的影响
  • 可见性-保证指令不会受到cpu缓存的影响
  • 有序性-保证指令不会受cpu指令并行优化的影响

可见性

例如如下代码,main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:

@Slf4j(topic = "c.Main")
public class Main {
	static boolean run = true;
	public static void main(String[] args) throws InterruptedException {
		Thread t1 = new Thread(() -> {
			while (run) {
				int i = 1;
				i++;
			}
		});
		t1.start();
		Thread.sleep(1000);
		run	= false;
		t1.join();
	}
}

分析

  1. 初始状态,t线程刚开始从主存中读取了run的值到工作内存
  2. 因为t线程要频繁从主存中读取run的值,JIT编译期会将run的值缓存值自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率,底层关联了cpu的缓存
  3. 1秒之后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

解决方法

  1. volatile关键字
volatile static boolean run = true;

volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存

  1. synchronized
static boolean run = true;
final static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
	Thread t1 = new Thread(() -> {
		while (run) {
			synchronized (lock) {
				int i = 1;
				i++;
			}
		}
	});
	t1.start();
	Thread.sleep(1000);
	run	= false;
	t1.join();
}

可见性和原子性

前面的例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个线程读线程的情况:

上例从字节码理解如下:

getstatic run	//线程t获取run true
getstatic run	//线程t获取run true
getstatic run	//线程t获取run true
getstatic run	//线程t获取run true
putstatic run	//线程main修改run为 false,仅此一次
getstatic run	//线程t获取run false

对于两个线程,一个i++,一个i--,只能保证看到最新值,不能解决指令交错

注意

synchronized语句块既可以保证代码块的原子性,也同时保证代码块内的变量可见性。但缺点是synchronized是属于重量级操作,性能相对更低

终止模式-两阶段终止模式

在一个线程T1中如何“优雅”终止线程T2,这里的【优雅】指的是给T2一个料理后事的机会

错误思路

  1. 使用线程对象的stop()方法停止线程

    stop方法会真正的杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其他线程将会永远无法获取锁

  2. 使用System.exit(int)方法停止线程

    目的仅是停止一个线程,但这种做法会让整个程序都停止

正确思路

@Slf4j(topic = "c.Main")
public class Main {
	volatile static boolean flag = true;
	public static void main(String[] args) throws InterruptedException {
		Thread t1 = new Thread(() -> {
			while (true){
				//是否被打断
				if (flag){
					//没有打断正常运行
					int i = 0;
					i++;
				}else {
					//料理后事
					log.debug("线程将要退出");
					break;
				}
			}
		});
		t1.start();
		Thread.sleep(1000);
		flag = false;
		t1.join();
		log.debug("主线程结束");
	}
}

同步模式-Balking

Balking(犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事情,那么本线程就无需再做了,直接结束返回

例如:

@Slf4j(topic = "c.Main")
public class Main {
	//用来表示是否已经有线程已经再执行启动
	private volatile boolean starting;
	public void start() {
		log.info("尝试启动线程");
		synchronized (this) {
			if (starting) {
				return;
			}
			starting = false;
		}
		//真正的启动线程
	}
}

当多次调用start方法时,不会多次创建线程,实现思想比较像单例模式中(懒汉式)的双重检查锁机制

它还经常用来实现线程安全的单例

有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序

static int i;
static int j;
//在某个线程内执行如下赋值操作
i = ...;
j = ...;

可以看到,至于是先执行i还是先执行j最终的结果不会产生影响。所以上面代码真正执行时,既可以是

i = ...;
j = ...;

也可以是

j = ...;
i = ...;

这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性。为什么要有重排指令这项优化,从CPU执行指令的原理解释如下

CPU完成一条指令可以分为许多阶段,简单举例,把指令的处理过程分为取指令和执行指令两个阶段,一般来说,取指令和执行指令是周而复始地重复出现,各条指令按顺序串行执行

进一步分析发现,这种顺序执行虽然控制简单,但执行中各部件的利用率不高,如指令部件工作时,执行部件基本空闲,而执行部件工作时,而执行部件工作时,指令部件基本空闲。如果指令阶段不访问主存,则完全可以利用这段时间取下一条指令,这样就使取下一条指令的操作和执行当前指令的操作同时进行。

为了进一步提高处理速度,可将指令的处理过程分解为更细的几个阶段

  • 取指(FI):从存储器取出一条指令并暂时存入指令部件的缓冲区
  • 指令译码(DI):确定操作性质和操作数地址的形成方式
  • 计算操作数地址(CO):计算操作数的有效地址,涉及寄存器间接寻址、间接寻址、变址、基址、相对寻址等各种地址计算方式
  • 取操作数(FO):从存储器中取操作数(若操作数在寄存器中,则无须此阶段)
  • 执行指令(EI):执行指令所需的操作,并将结果存于目的位置(寄存器中)
  • 写操作数(WO):将结果存入存储器

一般为了方便讨论,会将上述的六种指令流水简化成五段

  • IF:取指
  • ID:译码
  • EX:执行
  • MEM:内存访问
  • WB:结果写回
t1t2t3t4t5t6t7t8
IFIDEXMEMWB
IFIDEXMEMWB
IFIDEXMEMWB
IFIDEXMEMWB

现代CPU支持多级指令流水线,例如支持同时执行取指令-指令译码-内存访问-数据写回的处理器,就可以称之为五级指令流水线。这时CPU可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC=1,本质上,流水线技术并不能缩短单挑指令的执行时间,但它变相地提高了指令地吞吐率

此时,为了减少流水线阻塞,CPU会在不影响最终结果的情况下,将指令进行重排序,目的是为了减少流水线阻塞

在不改变程序运行结果地前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行,这一技术在80年代中叶到90年代中叶占据了计算机架构的重要地位

分阶段,分工是提升效率的关键

异常的结果

int num = 0;
boolean ready = false;
//线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
    	r.r1 = num + num;  
    } else {
    	r.r1 = 1;
    }
}
//线程2 执行此方法
public void actor2(I_Result r) {
    num = 2;
    ready = true;
}

I_Result是一个对象,有一个属性r1用来保存结果,问,可能的结果有几种?

一共会有1、4、0三种情况,其中1、4均为正常情况,但在发生指令重排序后会出现0这种特殊情况

解决方法为在ready前加关键字volatile

volatile原理

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对volatile变量的写指令后会加入写屏障
  • 对volatile变量的读指令之前会加入读屏障

保证可见性

写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

public void actor2(I_Result r) {
    num = 2;
    ready = true;	//ready是volatile赋值带写屏障
    //写屏障
}

写屏障之前的所有的改动(赋值)同步到主存当中

而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

public void actor1(I_Result r) {
    //读屏障
    //ready是volatile读取值带读屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

读屏障之后的代码,读取到的都是主存中的数据,不会读取工作内存中的数据

如何保证有序性

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

public void actor2(I_Result r) {
    num = 2;
    ready = true;	//ready是volatile赋值带写屏障
    //写屏障
}

写屏障保证了之前的代码进行指令重排

读屏障会确保指令重排序时,不会讲读屏障之后的代码排在读屏障之前

public void actor1(I_Result r) {
    //读屏障
    //ready是volatile读取值带读屏障
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

读屏障防止读屏障下的,对变量的读取出现在读屏障之上

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

双重检查锁模式

public final class Singleton {
    private Singleton(){}
    private static Singleton INSTANCE = null;
    public static Singleton getInstance(){
        if(INSTANCE == null) {
            synchronized(Singleton.class) {
                if(INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用getInstance()才使用synchronized加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量,是在同步代码块之外

但在多线程环境下,上面的代码是有问题的,getInstance方法对应的字节码为:

 0 getstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
 3 ifnonnull 37 (+34)
 6 ldc #3 <org/example/test18/Singleton>
 8 dup
 9 astore_0
10 monitorenter
11 getstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
14 ifnonnull 27 (+13)
17 new #3 <org/example/test18/Singleton>
20 dup
21 invokespecial #4 <org/example/test18/Singleton.<init> : ()V>
24 putstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
40 areturn

其中

  • 17表示创建对象,将对象引用入栈
  • 20表示复制一份对象引用
  • 21表示利用一个对象引用,调用构造方法
  • 24表示利用一个对象引用,赋值给static INSTANCE

jvm会优化为:先执行24,再执行21。如果两个线程t1,t2按如下时间序列执行

  1. t1线程:new
  2. t1线程:dup
  3. t1线程:putstatic(给INSTANCE赋值)
  4. t2线程:getstatic(获取INSTANCE引用)
  5. t2线程:ifnonull 37(判断不为空跳转37行)
  6. t2线程:getstatic(获取INSTANCE引用)
  7. t2线程:areturn(返回)
  8. t2线程:使用对象
  9. t1线程:invokespecial(调用构造方法)

如果按照上述顺序执行,则t2线程将使用值为null的INSTANCE对象,发生错误

由此看来synchronized代码块内部还是存在指令重排序的问题,synchronized并不能阻止重排序,但是,如果共享变量完全被synchronized保护,是不会存在有序性问题,上述操作是没有将INSTANCE完全保护起来

synchronized只是保证了保护段的代码单线程执行,也就是串行执行,而指令重排序的前提是,CPU在不影响最终结果的情况下,将指令重排序,synchronized保证了单线程串行执行,所以被保护的代码块运行的最终结果即使发生指令重排序也是不影响最终结果的

为了解决上述的问题,可将代码更改为如下示例:

public final class Singleton {
    private Singleton(){}
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance(){
        if(INSTANCE == null) {
            synchronized(Singleton.class) {
                if(INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

主要更改的是该行代码private static volatile Singleton INSTANCE = null;

字节码上看不出来volatile指令的效果,手动加入屏障示例如下:

 //---------------------------------------------------->加入对INSTANCE变量的读屏障
 0 getstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
 3 ifnonnull 37 (+34)
 6 ldc #3 <org/example/test18/Singleton>
 8 dup
 9 astore_0
10 monitorenter --------------------------------------->保证原子性、可见性
11 getstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
14 ifnonnull 27 (+13)
17 new #3 <org/example/test18/Singleton>
20 dup
21 invokespecial #4 <org/example/test18/Singleton.<init> : ()V>
24 putstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
//----------------------------------------------------->加入对INSTANCE变量的写屏障
27 aload_0
28 monitorexit----------------------------------------->保证原子性、可见性
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <org/example/test18/Singleton.INSTANCE : Lorg/example/test18/Singleton;>
40 areturn

happens-before

happens-before规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见

  • 线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见

    static int x;
    static Object m = new Object();
    
    new Thread(() -> {
        synchronized(m) {
            x = 10;
        }
    },"t1").start();
    
    new Thread(() -> {
        synchronized(m) {
            System.out.println(x);
        }
    },"t2").start();
    
  • 线程对volatile变量的缩写,对接下来其他线程对该变量的读可见

    volatile static int x;
    
    new Thread(() -> {
        x = 10;
    },"t1").start();
    
    new Thread(() -> {
        System.out.println(x);
    },"t2").start();
    
  • 线程start前对变量的写,对该线程开始后对该变量的读可见

    static int x;
    
    x = 10;
    
    new Thread(() -> {
        System.out.println(x);
    },"t2").start();
    
  • 线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其他线程调用t1.isAlive()或t1.join()等待它结束)

    static int x;
    
    Thread t1 = new Thread(() -> {
        x = 10;
    },"t1");
    t1.start();
    
    t1.join();
    System.out.println(x);
    
  • 线程t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interrupted或t2.isInterrupted)

    static int x;
    
    public static void main(String[] args) {
        Thread t2 = new Thread(() -> {
            while(true) {
                if(Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    bread;
                }
            }
        },"t2");
        t2.start();
        
        new Thread(() -> {
            sleep(1);
            x = 10;
            t2.interrupt();
        },"t1").start();
        while(!t2.isInterrupted()) {
            Thread.yield();
        }
        System.out.println(x);
    }
    
  • 对变量默认值(0,false,null)的写,对其他线程对该变量的读可见

  • 具有传递性,如果x hb -> y 并且 y hb -> z 那么有x hb -> z,配合volatile的防指令重排,有下面的例子

    volatile static int x;
    static int y;
    
    new Thread(() -> {
        y = 10;
        x = 20;
    },"t1").start();
    
    new Thread(() -> {
        //x = 20 对t2可见,同时y=10也对t2可见
        System.out.println(x);
    },"t2").start();