并发整理(一)— Java并发底层原理

现已全部整理完,其他两篇
并发整理(二)— Java线程与锁
并发整理(三)— 并发集合类与线程池

本篇主要是底层的东西。

Java内存模型/JMM

Java并发采用的是共享内存模型。线程的通信隐式进行,整个通信过程对程序员完全透明。所以要理解其中隐式的规则,否则会引起一些内存可见性问题。

java的堆内存是可以共享的,但是栈内存是私有的。

线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

并发整理(一)— Java并发底层原理_第1张图片
image

JMM分析

指令重排序

Happens-Before

这个关键字是JSR-133内存模型中用来阐述操作的内存可见性的。是JMM的核心概念。程序员要基于这个规则提供内存可见性保证来编程。

这个关系并不是说着前一个操作要在后一个操作之前,只是说前一个操作对后一个可见。

具体规则

  • 单个线程的任意操作happens-Before后序操作
  • volatilehappens-Beforevolatile读,原因在后面
  • 对一个锁解锁happens-Before对一个锁加锁,原因也在后面
  • 线程start一定happens-Before线程中任意操作
  • 线程join成功之后一定happens-Before与返回操作
  • 满足传递性

这个关系就是让JMM来对编译器与处理器的重排序做约束

JMM保证

因为有编译器与处理器优化的存在,所以有重排序存在的必要性。

但是JMM保证,在正确同步的情况下不改变程序的执行结果,尽可能让编译器与处理器优化。

也可以说在满足程序员定义的happens-Before规则来执行的结果与优化的结果肯定一致。

有一点要注意,JMM不保证64位的long/double变量写的原子性,因为32位处理器要执行64位数据的指令需要拆分成两个单独执行。jdk5以前的JMM,64的读/写都是分开的,jdk5以后只有写会拆分。

数据依赖性

只要两个操作访问同一个变量,并且有一个写操作,那么就说这俩是数据依赖关系。

写-读写-写读-写都是。

但是只对单个线程的操作和单个处理器执行的指令有效。

重排序

编译器会对指令序列做优化,并不会按照我们写的顺序执行

  1. 编译器在不改变单线程中语义前提进行重排序
  2. 处理器可以改变不存在数据依赖性的语句重排序
  3. 由于读/写缓冲区,内存系统进行的重排序

1是编译器重排序,2、3属于处理器重排序。

JMM重排序规则对编译器是禁止特定类型的重排,对处理器而是采用内存屏障指令的方法。

不同的处理器都有不同的重排序规则,所以java有对应的4个内存屏障指令来禁止这些重排序。

  • StoreLoad最强大,就是强制让写缓冲全部刷新到内存然后再读取,大部分关键字都靠这个实现
  • 其他三个类似LoadLoadStoreStoreLoadStore

比如:

class Test{
  int a=0;
  boolean flag=false;
  
  public void writer(){
    a=1;//1
    flag=true;//2
  }
  
  public void reader(){
    if(flag) //3
      int i=a*a;//4
  }
}
//A线程先执行writer,B线程执行reader
//1和2不存在数据依赖,可以重排
//3和4不存在数据依赖,可以重排(处理器可以把指令拆分,让a*a提前读,3成立再赋值,所以3、4中的指令可以重排序)
//1和4在多线程中不考虑数据依赖,所以结果会不一样

并发原语

Volatile

会java的都知道volatile的特点

  • 可见性:只要修饰变量,就对所有线程可见,看到其最后写入
  • 原子性:任意单个volatile变量读/写都具有原子性

JMM怎么做到的

具体做法:
  • 每个volatile写操作前插入一个StoreStore屏障
  • 每个volatile写操作后插入一个StoreLoad屏障
  • 每个volatile读操作后插入一个LoadLoad屏障
  • 每个volatile读操作后插入一个LoadStore屏障

举例来说:StoreStore屏障的意义在于volatile写之前,所有普通写操作已经对任意处理器可见。保证这个屏障之前的写已经刷到主存。后面再加一个StoreLoad,就是防止与后面普通读重排序。volatile读类似。

效果

最终形成的可重排序效果:

第一个操作 第二个操作
是否能重排序 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

然后再看上面的例子就理解了

class VolatileTest{
  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*a;//4
  }
}
//A线程先执行writer,B线程执行reader
//现在2 happens-before 3,又因为单线程中1 happens-before 2同理3、4
//根据传递性现在1 happens-before 4,保证结果单一

:我们说的volatile的原子性是指它单一的读和写,像++这样的复合操作不具有原子性

为什么volatile不能保证原子性而Atomic可以

那volatile怎么保证刷回主存

在解析volatile变量写的时候,会多出一个lock汇编指令,该指令在多核处理器下会

  • Lock前缀指令执行期间,以前的处理器会锁住总线来,但是开销有点大,所以现在处理器会锁处理部分的内存区域,用缓存一致性来阻止两个以上的处理器缓存修改内存区域
  • 写回结束后会被其他处理器嗅探到,然后其他处理器会把该部分置为无效,重新刷新

正是因为会锁住内存,所以有的时候在高速缓存行是64位的处理器中,我们可以将volatile变量最加到64位来提高其并发的效率。

关于如何更好使用可以看这个

正确使用 Volatile 变量

Final

final用于修饰常量代表不可变,也可以修饰方法和类

所以编译器和处理器处理的时候,要保证final的赋值规范

怎么做到的

  • final写之后插入一个StoreStore屏障
  • final读前面插入一个LoadLoad屏障

当然这些都是针对大部分处理器,不同情况也会不同。

效果

  • 对象的 final 域已经被正确初始化过了之后,才会对其他线程可见,final写也不会重排序到构造函数之外
  • 在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用

极客并发教程-final语义

锁的内容非常多,下一篇单独整理。这里写概述。

显式锁Lock

ReentrantLock都是基于volatile关键字来实现的。

通过一个volatile变量Status来控制同步的状态,使那些没有获得的线程自旋或者阻塞来实现效果。

  • 公平锁获取的时候会读volatile,所以具有volatile语义
  • 非公平锁获取时会先读,然后用CAS来更新,所以同时具有volatile写和读的语义
  • 锁释放的时候都会写volatile写语义

隐式锁synchronized

synchronized之所以会叫隐式锁是因为编译器自动帮我们通过一个monitor的对象来完成。

Java中每个对象都可以作为锁,所以synchronized存在Java的对象头里。

对于synchronized代码块,JVM的实现是插入monitorenter和monitorexit指令来实现的。

方法的同步也可以用这种方式,但是JVM没有详细说明。

synchronized原理

你可能感兴趣的:(并发整理(一)— Java并发底层原理)