写在前面
本文为方鹏飞 魏鹏 程晓明所著《Java并发编程的艺术》一书中第二章和第三章的读书笔记,其中观点可能有理解错误的地方,还请大佬指出。
第二章 JUC底层实现原理
volatile
volatile(a.不稳定的,易变的)在Java规范中定义如下:Java编程语言运行线程访问共享变量,为了确保共享变量能被准确一致的更新,线程应该确保通过排他锁单独获得这个变量。
如果一个变量被声明为volatile, Java线程内存模型将确保所有线程看到这个变量的值一致。
使用JIT看含有volatile的变量会编译成什么样子:
java
代码解读
复制代码
instance = new Singleton();
assemble
代码解读
复制代码
movb $0x0,0x1104800(%esi) lock addl $0x0,(%esp)
使用volatile时会多出一个Lock. lock在多核处理器下会引发两件事:
- 将当前处理器缓存行数据写回系统内存;
- 这个写回内存操作会使得在其它CPU里缓存了该内存地址的数据无效。
volatile还有一个使用优化,如LinkedTransferQueue类中,它会使用追加字节的方式,把类的共享内存追加到64字节。这样做的原因是,对于常见的CPU, 其缓存行很多是64字节;而如果队列的头尾节点都不足64字节,就会被放到同一个缓存行中,从而导致每次操作头节点并锁定该缓存行时,都会导致尾结点一起被锁定,无法同时操作,因此会降低效率。而如果大于64字节,那么头节点与尾节点就可以放入不同缓存行,从而使得两个节点可以并行操作。
synchronized
synchronized 是比volatile 更常用的关键字。再过去,它是重量级锁,在JDK1.6后又引入了偏向锁和轻量级锁。
synchronized:
- 对于普通同步方法,锁的是当前示例对象;
- 对于静态同步方法,锁的是类的Class对象;
- 对于同步方法快,锁的是synchronized括号里配置的对象。
在Java对象头里,有一段Mark Word数据。
- 当锁为偏向锁时,markword中存储线程ID、Epoch、对象分代年龄和是否是偏向锁的标记。这里面线程ID就是持有该锁的线程ID. 当线程进入和退出同步块时不需要进行加锁和解锁,只需要检查线程ID是否是自己;如果是就表明已获得锁,如果不是,就检查是不是偏向锁(有一个标记位),如果是,就把线程ID设置为指向自己来获取锁;如果没有,就使用竞争锁。
- 如果锁是轻量级锁,那么锁里面存的是指向栈中锁记录的指针。加锁时,先在当前线程栈桢中创建用于存储锁记录的空间,并把markword复制到锁记录中,为displace mark word. 然后线程尝试CAS(compare and swap, 先比较,只有在不同时才交换)将markword换位指向当前锁记录的指针。成功后则获取该锁,失败则自旋等待获取。解锁时,会把displace markword换回对象头。如果成功,则无竞争;如果失败,则表明存在竞争,锁会膨胀为重量级锁
- 重量级锁markword里存的是指向互斥量(重量级锁)的指针,此时获取锁失败的线程会被阻塞而不是自旋。
对于三种锁:
- 偏向锁加解锁不需要额外消耗,但如果存在竞争则会带来额外撤销锁的消耗,因此适合只有少数并发的场景;
- 轻量级锁不会阻塞进程,提高了响应速度,但是自旋会消耗cpu,适合追求响应时间的场景;
- 重量级锁不自旋,不消耗cpu,但是因为阻塞导致响应慢,适合追求吞吐量的场合。
原子操作实现原理
有两种办法,第一个是使用总线锁,当一个处理器输出LOCK# 信号时,其它处理器的请求都会被阻塞,从而该处理器可以独占内存;
第二种时使用缓存锁,处理器不在总线声言LOCK#, 而是修改内部的内存地址,并允许它的缓存一致性操作来保证操作原子性,因为其缓存一致性机制会阻止同时修改两个以上的处理器缓存的内存区域数据,当其它处理器诙谐已被锁定的缓存行的数据时,会使缓存行无效。
只锁定缓存行可以防止总线锁时会全部锁定的情况,但是有时候不能使用缓存行,例如操作数据不能缓存在处理器内部,或者数据跨多个缓存行,或者处理器不支持缓存锁定。
Intel处理器提供了很多带Lock前缀的指令来实现原子操作,如BTS、BTR、BTC、XADD、CMPXCHG等。JVM中的CAS操作就是使用CMPXCHG实现的。
从Java1.5开始,就提供了一些支持原子类的操作,如AtomicBoolean、AtomicInteger、AtmoicLong等。
CAS操作三大问题:
- ABA问题,即一个值为A, 改回了B, 又改回A, 此时CAS检查是没有变化的,然而实际有。解决方法是给其加上版本号,每次更新自增。
- 循环时间长时开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
- 只能保证一个共享变量原子操作。这里有一个取巧的办法,也就是用结构体来控制多个变量,再用CAS操作结构体。
第三章 Java内存模型
在Java内存模型(简称JMM)中,有线程、主内存(里面有共享变量)和各线程的本地内存(里面有主内存的共享变量的副本)。这里共享内存值实例域、静态域和数组元素。局部变量、方法定义参数和异常处理参数不会在线程间共享。
线程A要想与线程B通信,需要两个步骤:
- A把A本地内存中更新过的共享变量刷新到主内存;
- B从主内存读取A更新的共享变量。
这个过程看起来没有什么问题。然而实际上,在执行程序时,为了提高性能,编译器和处理器常常会重排序指令。重排序有三种:
- 编译器优化。这种优化只会在不改变程序语义的前提下执行。
- 指令级并行的重排序。现代处理器会使用指令级并行技术,如果不存在数据依赖则可以改变语句执行顺序。
- 内存系统重排序。由于处理器使用了缓存和io缓冲区,导致加载和存储操作可能看起来是乱序的。
我们来看一个例子。假设某处理器有写缓冲区,但没有读缓冲区。这样导致的结果是,处理器A本想先执行写入,再读取;结果写入的数据进入了缓冲区,再数据刷新进缓冲区前,读取指令由于没有缓存区,已经执行了;同时另一个处理器B也在发生同样的错误,结果就导致了非预期的错误。
出现这种指令不按顺序执行的原因是操作顺序被重排了。不同的处理器允许的重排序不一样,如X86由于有较强的内存处理模型,只允许对写-读重排序(也就是上面的例子发生的原因);而所有处理器都不允许数据依赖重排序。
数据依赖,包括写后读、写后写、读后写,任何交换执行顺序都会影响结果。
为了防止重排影响结果,JVM会在编译时,往操作前后加上屏障,如:
- LoadLoad Barriers:确保L1装载的数据先于L2级后续指令装载;
- LoadStore Barriers:确保L1装载先于S2对其它处理器可见(刷新到主内存)及后续指令;
- StoreStore Barriers:确保S1对其它处理器可见(刷新到主内存)先于S2;
- StoreLoad Barriers:确保S1刷新先于L1.
happens-before
happens-before是JDK5使用的JSR-133内存模型引入的,它是一致原则,与程序员密切相关的如下:
- 程序顺序规则:一个线程中的每个操作,happens-before与该线程的后续操作;
- 监视器锁规则:对一个锁的解锁,happens-before与随后对其的加锁;
- volatile变量规则:对一个volatile域的写,happens-before与后续对该域的读;
- 传递性:A happens-before B, B happens-before C, 则A happens-before C.
- start()规则:如果A执行ThreadB.start(),那么A的start操作优先与线程B的任意操作;
- join()规则:如果A执行ThreadB.join()并成功返回,那么B中任意操作先于join().
这里happens-before的顺序和中文理解是反的。另外happens-before并不是一个具体实现,只是约束的一个准则。
同时happens-before并不要求一个操作必在再另一个操作前执行,其仅表示前一个操作的结果对后一个操作的结果可见,且前一个操作按顺序排在第二个前。这是JMM对程序员的承诺。
happens-before还有一个定义,就是如果重排序后的结果与happens-before是一致的,那么允许重排序。这是对编译器和处理器的约束。因此happens-before本质上和as-if-serial(见下节)是一回事。区别在于asif保证单线程,happens-before保证多线程。
重排序一定不好吗
重排序主要是为了优化程序性能而执行的。
有一个语义叫as-if-serial语义,也就是不管怎么重排序都不会改变结果。JMM允许符合as-if-serial的重排序。
有时候多线程程序中重排序依旧会影响结果,如下:
java
代码解读
复制代码
int a=0; boolean flag = false; public void writer() { a=1; //1 flag=true; //2 } public void reader() { if(flag) { int i =a; } }
如果1和2发生了重排序,flag就会过早变成true,导致reader读取错误数据。
顺序一致性模型是一个理论参考模型,处理器和编程语言的内存模型设计时都以此为参照。Java内存模型对数据竞争的定义为:“在一个线程中写一个变量,在另一个线程读取变量,而且写和读没有通过同步排序。
顺序一致性模型有两大特性,第一是线程中所有操作必须按顺序执行,第二是所有线程只能看到一个单一的操作执行顺序,每个原子操作都必须原子执行且立刻对所有线程可见。
例如说,有两个程序,分别是A1->A2->A3、B1->B2->B3, 那么A1->A2->B1->B2->A3->B3就是符合顺序一致性的。
JMM实现的基本方针为:在不改变结果的前提下,尽可能方便编译器和处理器的优化。
但是上述只存在与同步程序(如加了synchonized), 如果是未同步程序,依然是可能出错的,JMM并不保证一致。
除此之外,对于32位处理器,如果要求其对操作64位数据有原子性,对性能开销比较大。因此JMM鼓励但不强求对其操作原子化。在这种情况下也可能出现不一致。在JDK5后,仅允许写操作不具有原子性,读操作一定有原子性。
volatile的内存语义
volatile的含义参考如下
java
代码解读
复制代码
volatile int a=0; public void getAndIncrement(){ a++ } //等价 public synchronized int get() { return v1; } public synchronized int set(int x) { v1=x; } public void getAndIncrement() { long temp = get(); temp++; set(temp); }
其具有如下特性:
- 可见性。对任意一个volatile变量的读,总是能看到其最新的数据。
- 原子性。对任意volatile变量的读写具有原子性,但volatile++这样的复合操作没有。
volatile写和读本质上是:
- A写了一个volatile变量,实际上是A向接下来要读该变量的线程发出了消息;
- B读变量本质上是接收了A发出的修改共享变量的消息;
- A写B读,本质上是线程A通过主内存向B发消息。
volatile实现靠的是插入屏障,如:
- 每个写操作前加入一个StoreStore屏障;
- 每个写后面插入一个StoreLoad;
- 每个读后面查一个LoadLoad;
- 每个读后面查一个LoadStore.
另外,对于有重复的或者没有用的屏障,编译时也会优化掉。
锁的内存语义
锁释放的内存语义和volatile写相同;锁获取和volatile读相同。
- A释放一个锁,实际上是A向接下来要获取该锁的线程发出了消息;
- B获得锁本质上是接收了A发出的修改共享变量的消息;
- A释放B获取,本质上是线程A通过主内存向B发消息。
锁内存语义的实现靠的是Java提供的一些类和方法,如ReentrantLock.lock()等。
final域的内存语义
对于final域,编译器和处理器有两个重排规则:
- 在构造函数内进行写入final域 以及 随后把该构造对象赋值给一个变量,该操作不能重排序;
- 初次读一个包含final域的对象的引用域随后初次读final域不能重排序。
写final域的重排序规则
- 禁止把final域的写重排序到构造函数外
- 在final域写后,构造函数return前,插入一个StoreStore屏障,目的是实现上一条。
这样做是为了防止读取到未初始化的final数据。
读final域的重排序规则
在一个线程中,初次读该对象引用与初次读该对象包含的final不能重排序。这个也是为了防止读取未写入的数据。
final域为引用类型
为了保证读取前已经正确初始化,还需要保证构造函数内,不能让这个被构造函数的引用为其它程序所见,也就是不能“逸出”。否则便可能出现,构造函数还未初始化完成,就有线程通过逸出读取到的情况(如构造函数中obj=this)。
final的实现
以X86为例,写final域要求在final域写入后,构造函数return前插入一个StoreStore;读final域要求在读前插入LoadLoad. 但是由于X86不会重排序写-写,也不会重排序存在间接依赖的操作,所以实际上不需要插入任何屏障,因此它们会被优化掉。
双重检查与延迟初始化
双重检查
有时候,对一些高开销的操作会实现用到再加载的操作,如
java
代码解读
复制代码
public class LazyClass { private static Instance instance; public static Instance getInstance() { if (instance==null) { instance = new Instance(); } return instance; } }
这里的问题在于,new实际上是三步操作:
java
代码解读
复制代码
memory=allocate(); //分配空间 ctorInstance(memory); //初始化对象 instance=memory; //设置instance指向分配的内存
这里的问题在于第二行和第三行是可以重排序的,而且不违反规则。这就导致if(instance==null) 会过早的失效,从而返回一个还没有初始化的对象。
解决方法有两种,第一个是禁止重排序,也就是使用volatile
java
代码解读
复制代码
public class LazyClass { private volatile static Instance instance; public static Instance getInstance() { if (instance==null) { synchronized(LazyClass.class) { if (instance==null) { instance = new Instance(); } } } return instance; } }
这个方法需要JDK5以上。
第二个办法是基于类初始化。JVM在类初始化阶段会获取一个锁,同步多个线程初始化一个类的过程:
java
代码解读
复制代码
public class InstanceFactory { private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { return InstanceHolder.instance; } }
在类初始化阶段,JVM会通过锁保证同时只能有一个线程来初始化一个类。
Java内存模型综述
总的来说,JMM是参照顺序一致性模型设计的。但是如果完全遵循,那么很多优化都会被禁止,对性能带来极大影响。
越是追求性能的处理器,内存模型设计的越弱,这样可以做尽可能的优化来提高性能。
JMM屏蔽了不同处理器内存模型的差异,保证不同处理器上展现给程序员的内存模型都是一致的。
对于未同步/未正确同步的多线程程序,Java也提供了最小安全性保证,也就是线程执行读取的值,要么是之前某个线程写入的,要么是默认值,如0/null/false.