现已全部整理完,其他两篇
并发整理(二)— Java线程与锁
并发整理(三)— 并发集合类与线程池
本篇主要是底层的东西。
Java内存模型/JMM
Java并发采用的是共享内存模型。线程的通信隐式进行,整个通信过程对程序员完全透明。所以要理解其中隐式的规则,否则会引起一些内存可见性问题。
java的堆内存是可以共享的,但是栈内存是私有的。
线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。
指令重排序
Happens-Before
这个关键字是JSR-133内存模型中用来阐述操作的内存可见性的。是JMM的核心概念。程序员要基于这个规则提供内存可见性保证来编程。
这个关系并不是说着前一个操作要在后一个操作之前,只是说前一个操作对后一个可见。
具体规则
- 单个线程的任意操作happens-Before后序操作
- volatile写happens-Before于volatile读,原因在后面
- 对一个锁解锁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属于处理器重排序。
JMM重排序规则对编译器是禁止特定类型的重排,对处理器而是采用内存屏障指令的方法。
不同的处理器都有不同的重排序规则,所以java有对应的4个内存屏障指令来禁止这些重排序。
- StoreLoad最强大,就是强制让写缓冲全部刷新到内存然后再读取,大部分关键字都靠这个实现
- 其他三个类似LoadLoad、StoreStore、LoadStore
比如:
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怎么保证刷回主存
在解析volatile变量写的时候,会多出一个lock汇编指令,该指令在多核处理器下会
- Lock前缀指令执行期间,以前的处理器会锁住总线来,但是开销有点大,所以现在处理器会锁处理部分的内存区域,用缓存一致性来阻止两个以上的处理器缓存修改内存区域
- 写回结束后会被其他处理器嗅探到,然后其他处理器会把该部分置为无效,重新刷新
正是因为会锁住内存,所以有的时候在高速缓存行是64位的处理器中,我们可以将volatile变量最加到64位来提高其并发的效率。
关于如何更好使用可以看这个
Final
final用于修饰常量代表不可变,也可以修饰方法和类
所以编译器和处理器处理的时候,要保证final的赋值规范
怎么做到的
- final写之后插入一个StoreStore屏障
- final读前面插入一个LoadLoad屏障
当然这些都是针对大部分处理器,不同情况也会不同。
效果
- 对象的 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没有详细说明。