并发基础二:Java内存模型

1. Java内存模型

1. 定义

  • Java内存模型规定所有变量都存储在主内存中(Main memory),每个线程还有自己的工作内存(working memory)。
  • 线程的工作内存中保存了被该线程使用到的变量的拷贝(从主内存中拷贝过来),线程对变量的所有操作都必须在工作内存中执行,而不能直接访问主内存中的变量。
  • 不同线程之间无法直接访问对方工作内存的变量,线程间变量值的传递都要通过主内存来完成。
  • 主内存主要对应Java堆中实例数据部分。工作内存对应于虚拟机栈中部分区域。
    这里写图片描述

2. 内存间交互工作

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把主内存的一个变量读取到工作内存中。
  • load(载入):作用于工作内存,把read操作读取到工作内存的变量载入到工作内存的变量副本中
  • use(使用):作用于工作内存的变量,把工作内存中的变量值传递给一个执行引擎。
  • assign(赋值):作用于工作内存的变量。把执行引擎接收到的值赋值给工作内存的变量。
  • store(存储):把工作内存的变量的值传递给主内存
  • write(写入):把store操作的值放入到主内存的变量中

2. 指令重排序

  • 概念:编译器或者处理器为了提高并发度,改变程序指令的执行顺序。(机器级的操作,汇编代码或者机器码的指令执行顺序与高级语言不一致)
  • JMM是语言级的内存模型,它通过禁止特定类型的编译器重排序和处理器重排序,确保在不同编译器和处理器平台上,为程序员提供一致的内存可见性。(Java编译器在生成特定类型的指令序列时,会插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序)
  • 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。(依赖:某段代码依赖于另外一段代码的结果,多线程线程的控制依赖会导致重排序改变程序执行结果)

3. 原子性、可见性、有序性

1.原子性

  • 一句话概括:一个操作或多个操作要么全部执行完成且执行过程不被中断,要么就不执行
  • 例如: a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。(为什么强调非long和double,后面再解释)
  • 再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。

2. 可见性

  • 一句话概括: 当一个线程修改了该变量的值,另外一个线程可以立刻得知
  • 原理:Java内存模型是通过将新值从该线程的工作内存同步到主内存,在变量读取前从主内存刷新本地工作内存来实现的。
  • 底层实现:加内存屏障:
    1. 写变量后加写屏障,保证CPU写缓冲区的值强制刷新回主内存
    2. 读变量之前加读屏障,使缓存失效,从而强制从主内存读取变量最新值

3. 有序性

  • 一句话概括:在本线程内观察,所有操作都是有序的;如果在一个线程中观察另外一个线程,所有操作都是无序的
  • 解释:前半句表示 “线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)”,后半句表示“指令重排序”现象以及“工作内存和主内存同步延迟”现象。
  • 通俗地讲,有序性指的是:数据不相关的变量在并发的情况下,实际执行的结果和单线程的执行结果是一样的,不会因为重排序的问题导致结果不可预知
  • volatile、synchronized、显示锁都能保证有序性
  • 除了以上几个关键字以外,Java语言中还有一个“先行发生”(Happen-Before)的原则可以保证有序性。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依赖。
  • 补充:先行发生原则:Happen-Before
  • 定义:指Java内存模型中定义的两项操作之间的依序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到
  • java内存模型中存在几个天然的线性发生原则:例如
    1. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这里的后面指时间上的先后顺序。
    2. 管程锁定规则 一个unlock操作先行发生于后面对同一个锁的lock操作。后面指时间上的先后顺序。
    3. ….

4. 同步的两种基础机制:volatile和内置锁

1.volatile

  • 当一个变量被声明为volatile之后,就保证了它对所有线程的可见性
  • 声明了Volatile的作用:
    1. 每次修改volatile变量都会同步到主存中
    2. 每次读取volatile变量的值都强制从主存读取最新的值(强制JVM不可优化volatile变量,如JVM优化后变量读取会使用cpu缓存而不从主存中读取)
  • 但是volatile不能保证原子性,如下图
    这里写图片描述
  • 以上代码说明了volatile不能保证原子性,每次运行的结果都是小于200000的数字。原因是因为在一个线程中当race值被取到栈顶时,volatile保证了此时的数据时最新的、正确的,但是执行自增的时候,主存中的race可能已经发生了变化,栈顶的race变为过期的数据,所以自增后的错误数据又被写回到主存之中。

2.内置锁

  • java提供了一种内置的锁机制来支持原子性:同步代码块。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象,一般不要这么做,这样会影响效率。
  • 每一个java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或者监视器锁或对象锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。Java的内置锁本质上都是互斥锁。
  • 内置锁具有可重入性:
    当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是调用。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。

3. 内置锁与volatile对比

  • 加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,但是volatile的开销比锁小(volatile的读操作与普通变量几乎没有差别,写操作会慢一些,因为要禁止本地重排序优化,插入许多内存屏障指令)

4. long、double非原子性协议

  • 一个64位数据没有volatile修饰,他的读写操作可以划分为2个32位操作进行。因此,虚拟机的实现可以不保证没有被volatile修饰的64位数据类型的读写操作的原子性。(虚拟机一般都把long、double的读写操作实现为原子性,因此,写代码的时候一般不用专门在long、double类型声明为volatile)