三、Java并发编程:Java内存模型

一、Java内存模型的基础

1. 并发编程模型的两个关键问题

并发编程模型的两个关键问题:线程之间如何通信和如何同步

  1. 线程之间如何通信?
  • 命令式编程

线程之间的通过消息传递来进行显示通信

  • 共享内存并发模型

线程之间共享内存中的程序的公共状态进行隐式通信

  1. 线程之间如何同步?

线程之间同步是指控制线程之间代码执行的先后顺序

  • 命令式编程

命令式编程线程之间的同步是隐式进行的,

  • 共享内存并发模型

程序员显示指定代码的互斥部分,线程间顺序执行

2. Java内存模型的抽象结构

JVM运行时数据区划分如下:

在这里插入图片描述

由图可知,在程序运行时,只有方法区和堆可以由线程共享,只有共享的区域才会出现内存可见性问题;Java线程之间的通信由Java内存模型(JMM)控制;从cpu的结构来分析,传统的cpu读写数据需要和内存直接交互,但是cpu的读写速度远远高于内存的读写速度,所以新的cpu都会有一块缓存,用于存放频繁读写的数据;单线程时,cpu从内存中将数据copy到缓存中进行操作,操作完成后再将缓存中的数据刷新到内存中;但是多线程的情况下,如果两个线程需要操作同一个变量,由于缓存的存在,就会出现内存可见性问题

在这里插入图片描述

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

为了优化性能,在代码执行时,编译器处理器会对指令进行重排序操作;重排序分为三种:

  1. 编译器重排序

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

  1. 指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  1. 内存重排序

4. 并发编程模型的分类

5. happens-before规则

happens-before是JMM最核心的规则,规则如下:

  1. 程序执行顺序:单线程中,代码按照顺序依次执行
  2. 锁定规则:对于同一个锁来讲,解锁操作在上锁操作之前发生
  3. volatile变量规则:对于volatile变量来讲,写操作在读操作之前发生
  4. 传递规则:A操作在B操作之前发生,B操作在C操作之前发生,那么A操作一定在C操作之前发生
  5. start()规则:线程的启动操作在线程执行体中操作之前发生
  6. join()规则:A线程调用B线程的join()方法,B线程中的操作先于join()结束之前发生

二、重排序

为了优化性能,在代码执行时,编译器处理器会对指令进行重排序操作;重排序分为三种:

  1. 编译器重排序

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

  1. 指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  1. 内存重排序

多线程情况下,上述的重排序可能导致内存可见性问题

内存可见性问题

多个线程操作共享变量X,线程1对共享变量X进行了写操作,但是由于指令重排序缓存中数据没有及时刷新到主存中,线程2读取到X的旧数据,造成的错误操作,这种问题称为内存可见性问题

1. 数据依赖性

在单个处理器的情况下,两个操作访问同一个变量,如果其中一个操作为写操作,两个操作就存在数据依赖性;对於单处理器或者单线程情况下,重排序会遵守数据依赖性,不会改变两个操作的顺序;但是对于多线程,则不会遵循数据依赖性

举个栗子:a=1,b=a;这个分两个操作,操作1为写操作:a=1;操作2为读操作:b=a;共同变量为a,这两个操作就存在数据依赖性:操作2依赖于操作1;

2. as-if-serial语义

as-if-serial的意思是:在单处理器和单线程的情况下,不管怎么重排序,程序的执行结果都不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义

3. 重排序对多线程的影响

在多线程的情况下,没有数据依赖性,且遵守happens-before规则,对指令重排序,会造成内存可见性问题:线程1执行write()方法,线程2执行read()方法;正常情况下,程序执行完成后,a=1,flag=true
在这里插入图片描述

由于a和flag没有依赖关系,可能会被重排序,当指令1和2重排时,就有可能造成内存可见性问题:在程序执行完成后,可能会出现:a=0,flag=false

在这里插入图片描述

而这种问题,在多线程编程中是有可能遇到的,也是需要去避免的

三、顺序一致性

顺序一致性具有两个特点:

  1. 线程中的操作必须按照程序的顺序来执行
  2. 线程中的每一个操作必须具有原子性,且操作结果对所有线程可见

1. 数据竞争

在一个线程中写一个数据,另一个线程中读同一个数据,且写和读没有通过同步来排序,就会出现数据竞争的现象

2. 同步程序的顺序一致性效果

顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序,但是最终不会改变程序的执行结果

临界区

临界区:代码中可以访问临界资源的代码片段称为临界区(临界资源指的是一次仅允许一个线程访问的共享资源);Java中一般指synchronized修饰的区域或者lock加锁的区域

3. 未同步程序的执行特性

对于未同步的程序,JMM提供最小安全性保证:线程读取共享变量时,要么是前某个线程写入的值,要么是0,null,false;JMM不保证未同步程序的执行结果与顺序一致性模型的执行结果一致;顺序一致性模型与JMM差异如下:

  1. 顺序一致性模型保证程序中的代码按照程序的顺序执行;JMM中指令可能会重排序
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序
  3. 顺序一致性模型保证对所有的变量的操作都具有原子性;32位JVM中,JMM不保证long型,double型变量的原子性操作

32位中,对64位的long型,double型数据的读写是拆分成两个32位来操作的

四、volatile

volatile修饰变量,在多线程的情况下,操作volatile修饰的变量具有原子性(但不包含类似i++这种复合操作);

package com.lt.thread04;

import java.util.ArrayList;
import java.util.List;

/**
 * 验证volatile不能满足复杂操作的原子性:i++
 * @author lt
 * @date 2019年5月11日
 * @version v1.0
 */
public class Counter_2 {

	private volatile int m = 0;
	public void count(){
		m++;
	}
	public static void main(String[] args) throws Exception {
		Counter_2 c = new Counter_2();
		List<Thread> ts = new ArrayList<>();
		for(int i=0; i<1000; i++){
			Thread t = new Thread(new Runnable() {
				@Override
				public void run() {
					c.count();
				}
			}, "线程"+i);
			ts.add(t);
		}
		for(Thread t : ts){
			t.start();
		}
		//等待当前线程执行完毕
		for(Thread t : ts){
			t.join();
		}
		System.out.println(c.m);
	}
}

1. volatile的内存语义

  1. volatile写的内存语义:当写一个volatile变量时,JMM会将缓存中的数据刷新到主存中
  2. volatile读的内存语义:当读一个volatile变量时,JMM会将缓存中的数据置为无效,然后去主存中读取

2. volatile内存语义的实现

在这里插入图片描述

可以看到当第一个操作为volatile读时,不管第二个操作是什么都不能进行重排序;当第二个操作为volatile写时,不论第一个操作是什么都不能进行重排序;

volatile通过在指令序列中插入内存屏障的方法来实现内存语义,下面是基于保守策略的JMM内存屏障插入策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障
内存屏障

内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作;硬件层的内存屏障分为两种:Load BarrierStore Barrier即读屏障和写屏障,两种屏障两两组合形成四种内存屏障

  • LoadLoad屏障:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

五、锁(synchronized,lock)的内存语义

1. 锁(synchronized,lock)的内存语义

  • 获取锁:当获取锁时,线程会将缓存中的数据置为无效,临界区代码回去主存中获取数据(共享变量)
  • 释放锁:当释放锁时,线程会将缓存中的数据刷新到主存中去

2. 锁(synchronized,lock)内存语义的实现

  1. 利用volatile的读/写的内存语义
  2. 利用CAS所附带的volatile读和volatile写的内存语义

六、final的内存语义

1. final域的重排序规则

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

  • 写final域重排序规则

在构造函数内对一个final域的写入,随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序;JMM禁止将final域的写重排序到构造器外边;编译器会在final域写之后构造器return之前,插入StoreStore屏障,以避免final域写重排序到构造器外边

  • 读final域的重排序规则

初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序,编译器会在读final域操作之前插入LoadLoad屏障

2. final语义在处理器中的实现

final域的渡河写是通过插入内存屏障来阻止重排序,但是在X86处理器中,写-写、读-读操作并不会插入内存屏障,所以在X86处理器中,final域的读写是有可能重排序的

七、happens-before

A happens-before B,即A在B之前发生,程序中用happens-before表示代码执行的先后顺序以及依赖关系;JMM允许在不改变程序执行结果的情况下进行重排序;as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变

1. happens-before规则

  1. 程序执行顺序:单线程中,代码按照顺序依次执行
  2. 锁定规则:对于同一个锁来讲,解锁操作在上锁操作之前发生
  3. volatile变量规则:对于volatile变量来讲,写操作在读操作之前发生
  4. 传递规则:A操作在B操作之前发生,B操作在C操作之前发生,那么A操作一定在C操作之前发生
  5. start()规则:线程的启动操作在线程执行体中操作之前发生
  6. join()规则:A线程调用B线程的join()方法,B线程中的操作先于join()结束之前发生

八、多线程下花式创建单例

1. 单例模式(懒汉模式)

多线程情况下,为了降低创建对象的开销,采用延迟初始化进行单例创建,如下:

public class Singleton_1 {
	private static Singleton_1 instance = new Singleton_1();
    private Singleton_1 (){}
    public static Singleton_1 getInstance() {
      return instance;
    }
}

2. synchronized同步单例方法

但是,在多线程情况下,有可能会出现:多个线程在同时执行new Singleton(),这样,就会创建多个实例;所以进一步改进:

public class Singleton_2 {
	private static Singleton_2 instance = new Singleton_2();
    private Singleton_2 (){}
    public synchronized static Singleton_2 getInstance() {
      return instance;
    }
}

3. synchronized同步单例代码块

这样做可以保证创建出的实例只有一个,但是,多线程情况下,频繁调用getInstance()会造成线程阻塞,降低效率,所以还需要改进:

public class Singleton_3 {
	private static Singleton_3 instance;
    private Singleton_3 (){}
	public static Singleton_3 getInstance() {
		if(instance==null){
			synchronized(Singleton_3.class){
				instance = new Singleton_3();
			}
		}
		return instance;
    }
}

这样做似乎就很完美了,但是还是有缺陷:线程1在执行到instance = new Singleton_3();时,实际上需要三步来完成:

  1. 分配对象的内存空间
  2. 初始化对象
  3. instance指向刚分配的内存地址
    但是处理器可能会重排序:
  4. 分配对象的内存空间
  5. instance指向刚分配的内存地址
  6. 初始化对象
    这样的排序是允许的!因为在单线程中,2和3没有happens-before关系,将2和3互换后,可以提高CPU性能,但是多线程情形下,这样的互换很有可能导致空指针异常,看下面的时序图:

在这里插入图片描述

线程1执行完①③操作,线程2来获取实例,判断instance不为null(但实际上是null),线程2使用instance调用方法时,便会报空指针异常;所以,还得优化呀!看下面:

4. 基于volatile创建单例(推荐)

public class Singleton_4 {
	private volatile static Singleton_4 instance;
    private Singleton_4 (){}
	public static Singleton_4 getInstance() {
		if(instance==null){
			synchronized(Singleton_4.class){
				instance = new Singleton_4();
			}
		}
		return instance;
    }
}

5. 基于CAS创建单例

看完上面的例子,只是使用volatile修饰instance,在多线程情形下便可以禁止①③操作的重排序;看到这儿,加个餐,不利用锁去创建单例:

public class Singleton_5 {
	private static AtomicReference<Singleton_5> atomic = new AtomicReference<>();
    private Singleton_5 (){}
	public static Singleton_5 getInstance() {
		while(true){
			boolean flag = atomic.compareAndSet(null, new Singleton_5());
			if(flag) break;
		}
		return atomic.get();
    }
}

6. 基于类初始化创建单例

JVM在类初始化阶段会获取锁,用于同步多个线程对同一个类的初始化操作;鉴于此,可以创建单例:

public class Singleton_6 {
	private static class Handler{
		private final static Singleton_6 INSTANCE = new Singleton_6();
	}
    private Singleton_6 (){}
	public static Singleton_6 getInstance() {
		return Singleton_6.Handler.INSTANCE;
    }
}

参考

【1】Java并发编程的艺术
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章