参考 JAVA并发编程的艺术
Java代码的一生:
- 编译后变成Java字节码
- 字节码被类加载器加载到jvm
- jvm执行字节码,最终转换为汇编指令在CPU上运行
Java中使用的并发机制依赖于jvm的实现和CPU指令
1 volatile
1.1 volatile的应用
volatile是轻量级的synchronized. 在多处理器开发中保证共享变量的可见性。volatile不会引发线程上下文的切换,比synchronized成本更低。
可见性:一个线程修改共享变量后,另外一个线程能读到修改后的值
1.2 volatile的定义
Java提供了volatile,比排它锁更方便。如果一个字段被修饰成volatile,Java内存模型会保证所有线程看到的值都是一致的。
1.3 volatile的底层实现
volatile如何保证可见性:
修饰的变量在转换为汇编指令时,会有一个Lock前缀的指令
这个指令引发两件事:
1. 将当前处理器缓存行的数据写回到系统内存
2. 写回内存的操作会使在其他CPU里缓存了该内存地址的数据失效
x86处理器使用MESI(修改、独占、共享、无效)控制协议维护内部缓存和其他处理器缓存的一致性。
2 synchronized
synchronized实现同步的基础:
Java中的每一个对象都可以是锁。
- 普通同步方法: 锁是当前实例对象
- 静态同步方法: 锁是当前类的class对象
- 同步方法块: 锁是括号里配置的对象
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
2.1 实现原理
jvm规范中可以看到synchronized的实现原理:
- jvm通过进入和退出Monitor对象来实现方法同步和代码块同步,但实现细节不一样
- 代码块:使用monitorenter和monitorexit指令实现
- 方法同步: 另外一种,jvm规范中细节没有说明
monitorenter和monitorexit指令:
- monitorenter指令:在编译后期插入到同步代码块开始位置
- monitorexit指令: 插入到方法结束和异常处
- jvm保证每个monitorenter都有一个monitorexit对应。
- 任何一个对象都有一个monitor对象关联。并且一个monitor对象被持有后,将处于锁定状态
- 线程执行到monitorenter指令时,会尝试获取对象所对应的monitor的所有权,即尝试获取对象的锁
2.2 java对象头
synchronized用的锁是存在java对象头里的。
jvm用3个字宽存储数组类型对象头,其他类型用2个字宽。
32位的java虚拟机一个字宽是32位,即4个字节;
64位的java虚拟机一个字宽是64位,即8个字节
java对象头的长度说明:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | mark word | 存储对象的hashCode或锁信息等 |
32/64bit | class metadata address | 存储到对象类型的数据的指针 |
32/64bit | array length | 如果是数组类型表示数组长度 |
Java对象头里的Mark Word里默认存储对象的HashCode、分带年龄和锁标记位。
32位JVM的Mark Word的默认存储结构:
锁状态 | 25bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
运行期间,Mark Word里面存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4中数据:
锁状态 | 25bit[23bit + 2bit] | 4bit | 1bit[是否偏向锁] | 2bit [锁标志位] |
---|---|---|---|---|
轻量级锁 | 指向栈中锁记录的指针(包括后面5bit) | 00 | ||
重量级锁 | 指向互斥量(重量级锁)的指针(包括后面5bit) | 10 | ||
GC标记 | 空(包括后面5bit) | 11 | ||
偏向锁 | 线程ID + Epoch | 对象分带年龄 | 1 | 01 |
工具参考:
查看java对象头:http://openjdk.java.net/projects/code-tools/jol/
查看类的字节码:http://blog.csdn.net/qq_24489717/article/details/53837493
2.3 锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
在Java SE1.6中,锁一共有4中状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态
这几个状态会随着竞争情况逐渐升级。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
2.3.1 偏向锁
引入偏向锁的原因:
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
下图为偏向锁的获得和撤销流程:
偏向锁的获得
当一个线程访问同步块并获取锁时,会在对象头和线程栈帧中的锁记录里存储锁偏向的线程ID(通过最开始的一次CAS),以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(看看当前是否还处于偏向锁的层次,因为锁会升级的),如果设置了,则当前仍处于偏向锁层次只是还没有线程此刻占有锁,尝试使用CAS将对象头的偏向锁指向当前线程(释放锁时,会将对像头中纪录线程id的这个位置置空,以便其他线程获取该偏向锁);如果没有设置,表示当前可能已经升级到轻量锁甚至重量锁了,则使用CAS竞争锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。(偏向锁只能在单线程下起作用)
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。上图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
2.3.1 轻量锁
轻量级锁也是一种多线程优化,它与偏向锁的区别在于,轻量级锁是通过CAS来避免进入开销较大的互斥操作,而偏向锁是在无竞争场景下完全消除同步,连CAS也不执行(CAS本身仍旧是一种操作系统同步原语,始终要在JVM与OS之间来回,有一定的开销)。
轻量级锁(Lightweight Locking)本意是为了减少多线程进入互斥的几率,并不是要替代互斥。
它利用了CPU原语Compare-And-Swap(CAS,汇编指令CMPXCHG),尝试在进入互斥前,进行补救。
轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。上图是两个线程同时争夺锁,导致锁膨胀的流程图。
参考:http://blog.sina.com.cn/s/blog_c038e9930102v2ht.html
2.4 锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会被阻塞,提高了程序的响应速度 | 如果始终得不到锁的线程,使用自旋会消耗CPU | 追求响应时间 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程被阻塞,响应时间缓慢 | 追求吞吐量同步块执行时间较长 |
3 原子操作的实现
3.1 处理器实现原子操作
当处理器读取内存的一个字节时,其它处理器不能访问这个字节的内存地址,最新的处理器能自动保证处理器对同一缓存行里进行16/32/64位的操作是原子的。处理器提供总线锁定和缓存锁定的机制保证复杂内存操作的原子性。
1、总线锁保证原子性
使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞,那么该处理器就能独自共享内存。
2、缓存锁保证原子性
“缓存锁定”指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不需要在总线上声言LOCK#信号,而是修改内部的内存地址,通过缓存一致性机制保证操作的原子性。
例外:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,处理器会调用总线锁定。
缓存一致性
缓存一致性会阻止同时修改由两个以上处理器的内存区域数据,当其他处理器回写被锁定的缓存行数据时,会使其它处理器的缓存行无效。
3.2 Java原子操作实现
在Java中通过锁和循环CAS的方式实现原子操作。
3.2.1 使用循环CAS实现原子操作
jvm中的CAS操作是基于处理器的CMPXCHG指令实现的,java1.5开始并发包中提供了原子操作的类,如AtomicInteger、AtomicBoolean等,下面试一段代码:
package cas;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger count1 = new AtomicInteger(0);
private int count2 = 0;
public static void main(String[] args) {
final Counter counter = new Counter();
List<Thread> threadList = new ArrayList<Thread>(100);
for (int i = 0; i < 100; i++) {
Runnable runnable = new Runnable() {
public void run() {
for (int j = 0; j < 1000; j++) {
counter.countAtomic();
counter.count();
}
}
};
threadList.add(new Thread(runnable));
}
long start = System.currentTimeMillis();
for (Thread t : threadList) {
t.start();
}
for (Thread t : threadList) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("time:" + (System.currentTimeMillis() - start));
System.out.println("count1:" + counter.count1.get());
System.out.println("count2:" + counter.count2);
}
public void countAtomic() {
for (; ; ) {
int t = count1.get();
boolean success = count1.compareAndSet(t, t + 1);
if (success) {
break;
} else {
System.out.println("cas fail!");
}
}
}
public void count() {
count2++;
}
}
3.2.2 CAS存在三个问题
3.2.2.1 ABA问题
如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。
解决方法:
ABA问题解决方法就是加版本号。从java1.5开始,JDK的atomic包提供一个类AtomicStampedReference来解决ABA问题。
AtomicStampedReference内部不仅维护了对象值,还维护了一个版本号。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新版本号。当AtomicStampedReference设置对象值时,对象值以及版本号都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要版本号发生变化,就能防止不恰当的写入。
下面为AtomicStampedReference的实例代码:
package cas;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicStampedReference;
public class AtomicStampedReferenceDemo {
public static AtomicStampedReference<Integer> money = new AtomicStampedReference<Integer>(0, 0);
public static void main(String[] args) {
List<Thread> threadList = new ArrayList<Thread>();
for (int i = 0; i < 10; i++) {
Runnable runnable = new Runnable() {
public void run() {
for (int j = 0; j < 100; j++) {
int stamp = money.getStamp();
int m = money.getReference();
boolean success;
if (j % 2 == 0) {
success = money.compareAndSet(m, m + 10, stamp, stamp + 1);
if (!success){
System.out.println("recharge fail");
}
} else {
if (m<=0){
System.out.println("money is zero");
continue;
}
success = money.compareAndSet(m, m - 10, stamp, stamp + 1);
if (!success){
System.out.println("consume fail");
}
}
}
}
};
threadList.add(new Thread(runnable));
}
for (Thread t : threadList) {
t.start();
}
for (Thread t : threadList) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("money:"+money.getReference());
System.out.println("stamp:"+money.getStamp());
}
}
3.2.2.2 循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3.2.2.3 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。