文章目录
1. volatile
在多线程并发中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,volatile保证了共享变量的
可见性
(当一个线程修改共享变量时,另一个线程能比较后读取到变动后的值),它比synchronized的使用和执行成本更低(不会引起上线文切换和调度)。
1.1 volatile的定义与实现原理
Java允许线程访问共享变量,为了确保共享变量能准确和一致的更新,线程应该确保通过
排他锁
单独获得这个变量。
如果一个字段被声明成volatile
,Java线程内存模型确保所有线程看到这个变量的值是一致的。
CPU术语定义:
术 语 | 单 词 | 描 述 |
---|---|---|
内存屏障 | memory barriers | 保证指令执行的顺序,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | 缓存中可以分配的最小存储单位。 |
原子性操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从主存中读取操作数是可以缓存的,处理器读取整个缓存行到适当的缓存(L1,L2等) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否存在缓存行中,没如果存在一个有效的缓存行,则处理器将这个操作写回到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
使用volatile
声明的变量进行读写时会多出一些汇编代码,比如lock cmpxchg
与lock addl
,(查看编译后的汇编代码可以通过JVM插件hsdis.dll查看,具体使用步骤可以看看相关描述)。
lock
前缀的指令会引发哪些事情呢 ?
1.将当前处理器缓存行的数据写回到系统内存。
2.写回到内存的操作会使在其它CPU里缓存了该内存地址的数据无效。
为了提高处理速度,CPU不直接和内存进行通信,而是将系统内存的数据读取到内部缓存(L1,L2等)后在进行操作,但是操作完之后写回到内存的时间无法确定。
对声明了volatile
的变量进行写操作时,JVM就会向处理器发送一条lock
前缀的指令,将这个变量所在缓存行的数据写回到系统内存,同时其它CPU缓存的值还是旧的,所以在多个CPU下,为保证各CPU的缓存是一致的,就会实现缓存一致性协议,每个CPU通过嗅探总线上传播的数据来检查自己缓存的值是不是过期了,不一致,就会将当前CPU的缓存行设置成无效的状态,当前处理器对这个数据进行修改时,会重新从系统内存中把数据读取到处理器缓存里。
volatile的两条实现原则:
1.lock
前缀指令会引起CPU缓存回写到内存。
2.一个CPU的缓存回写到内存会导致其它CPU的缓存无效。
2. synchronized的实现原理与应用
用
synchronized
来实现同步,Java每一个对象都可以作为锁:
1.作用于普通方法,锁的是当前示例对象。
2.作用于静态方法,所得是当前类的Class对象。
3.作用于代码块,所得是synchronized(x){}的x对象。
synchonized
使用用JVM指令中monitorenter
和monitorexit
实现的,我们通过java -c
可以查看编译后的class文件,查看用synchronized声明的方法或代码块,我们可以发现这两个命令。
monitorenter
指令是在编译后插入到同步代码块的开始位置,而monitorexit
是插入到方法结束和异常处,JVM保证每个monitorenter
有与之相匹配的monitorexit
,任何对象都有一个monitor
与之关联,当一个monitor
被持有时,它将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象的monitor
的所有权,也就是常说的获取对象锁。
monitor
对象有三个属性:
1._owner 记录当前持有锁的线程;
2._EntryList 是一个队列,记录所有阻塞等待锁的线程;
3._WaitSet 也是一个队列,记录调用 wait() 方法并还未被通知的线程。
当线程持有锁时,线程ID等信息会拷贝进_owner字段,其余线程会进入阻塞队列_EntryList,当持有锁的线程执行wait(),会立即释放锁进入_WaitSet ,当线程释放锁的时候,_owner置空;公平锁条件下,_EntryList中的线程会竞争锁,竞争成功的线程ID等信息会写入_owner ,其余线程继续在_EntryList中等待。
当线程取访问同步代码块时,它必须持有当前锁定对象的对象锁,退出或抛出异常必须释放锁。
那么锁定的对象到底存在哪里?会存储什么信息?
2.1 Java对象头
synchronized
用的锁是存在Java对象头里面的。
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
对象头中的Mark Word默认存储对象的HashCode、分代年龄和锁标志位等信息。
同时Mark Word会随着锁的状态变化,如下表所示:
32位虚拟机Mark Word的状态变化 | |||||
---|---|---|---|---|---|
锁的状态 | 25bit | 4bit | 1bit | 2bit | |
2bit | 23bit | 是否偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | NULL | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
2.2 锁升级与不同锁的对比
锁一共有4中状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。锁可以升级但不能降级。
2.2.1 偏向锁
锁会存在多线程竞争,而且会有几率出现同一个线程多次获得。为了让线程获得锁的代价更低,引入了偏向锁。
当一个线程访问同步代码并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后有该线程在进入或退出同步代码时,不需要进行CAS操作来加锁和解锁,只需判断Mark Word里是否存储着指向当前线程的偏向锁。
如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
2.2.2 轻量级锁
(1) 轻量级锁加锁
线程执行同步代码之前,JVM会现在当前线程的栈帧中创建用户存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。成功则当前线程获取锁,失败则与其它线程竞争锁。
(2) 轻量级锁解锁
解锁时,会使用CAS将锁记录中的Mrak Word替换回到对象头,失败:则当前锁存在竞争,锁就会膨胀呈重量级锁。
因为自旋会消耗CPU,为了避免无用自旋,一旦锁升级成重量级锁,就不会恢复到轻量级锁的状态。
当锁处于重量级锁状态时,其它线程尝试获取锁都会被阻塞,持有锁的线程在处理完业务逻辑后,会唤醒这些线程,然后重新竞争锁。
2.2.3 锁的优缺点对比
锁的优缺点对比 | |||
---|---|---|---|
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,与执行非同步方法相比仅存在纳秒级的差距 | 若线程间存在锁竞争,会带来额外的锁撤销处理 | 适用于只有单线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序响应速度 | 始终得不到锁的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
3. 原子操作的实现原理
原子操作(atomic operation):不可被终端的一个或一系列操作
CPU术语定义 | ||
---|---|---|
比较并交换 | Compare and Swap | CAS操作需要输入两个数值,在操作期间先比较旧值有没有发生变化,没有发生变化则交换新值,否则不变 |
CPU流水线 | CPU pipeline | 将指令分解为多步,并让不同指令的各步操作重叠,从而实现几条指令并行处理,以加速程序运行过程的技术。指令的每步有各自独立的电路来处理,每完成一步,就进到下一步,而前一步则处理后续指令。 |
内存顺序冲突 | Memory order violation | 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,出现内存顺序冲突时,CPU必须清空流水线 |
3.1 处理器如何实现原子操作
3.1.1 使用总线锁保证原子性
如果多个CPU同时对共享变量进行读写操作时,比如i=1,进行两次i++,得到的值很有可能为2而不是3。原因可能是多个CPU同时从各自的缓存中读取变量i,分别进行+1操作,然后分别写入系统内存中。
想要保证读写共享变量的操作是原子的,就必须保证CPU读写共享变量的时候,其它CPU不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的,总线锁就是使用CPU提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其它CPU的请求将被阻塞住,那么该CPU可以独占共享内存。
3.1.2 使用缓存锁来保证原子性
总线锁定把CPU和内存之间的通信锁住了,在锁定期间,其它CPU不能操作其它内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定替代总线锁定来进行优化。
频繁使用的内存会缓存在CPU的L1、L2、L3高速缓存里,那么原子操作就可以在处理器内存缓存中进行,CPU可以使用缓存锁定
的方式来实现复杂的原子性。缓存锁定
是指内存区域如果被缓存在CPU的缓存行中,并且在LOCK操作期间被锁定,那么当它执行锁操作会写到内存时,CPU不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会组织同时修改由两个以上CPU缓存的内存区域数据,当其他处理器回写已被锁定的缓存行数据时,会使缓存无效。
3.2 Java如何实现原子操作
Java中可以通过锁和循环CAS的方式来实现原子操作。
3.2.1 使用循环CAS实现原子操作
JVM中的CAS操作就是利用了处理器提供的CMPCHG
指令实现的。自旋CAS实现的基本思路就是循环进行CAS(Compare and Swap)操作指导成功为止。
举个示例:
public class CASDemo {
private static AtomicInteger atomicCount = new AtomicInteger(0);
static int count = 0;
public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++;
for (;;){
int atomicCountIntValue = atomicCount.get();
if(atomicCount.compareAndSet(atomicCountIntValue,++atomicCountIntValue)){
break;
}
}
}
});
threadList.add(thread);
}
threadList.forEach(Thread::start);
Thread.sleep(1000);
System.out.println(count);
System.out.println(atomicCount);
}
}
输出信息如下:
99724
100000
3.2.2 CAS实现原子操作的三大问题
3.2.2.1 ABA问题
CAS在操作值的时候检查值有没有发生变化,变化则更新。但是如果原值是A,变成了B,又变回A,那么使用CAS进行检查时会出现它的值没有发生变化。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号在原来的基础上加一。
ABA代码示例:
public class ABADemo {
private static AtomicInteger atomicInteger = new AtomicInteger(50);
public static void main(String[] args) {
System.out.println("业务要求:张三和张三老婆同时取款,只能取出原存款金额50,被取完,就不能继续取。");
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Thread thread1 = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 张三原存款剩余:" + atomicInteger.get());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicInteger.compareAndSet(atomicInteger.get(), atomicInteger.get() - 50);
System.out.println(Thread.currentThread().getName() + " 张三在" + sdf.format(new Date()) + "取原存款50元,当前账户剩余" + atomicInteger.get() + "元");
});
thread1.setName("张三本人操作->");
Thread thread2 = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 张三原存款剩余:" + atomicInteger.get());
System.out.println("#####由于张三已经提前把钱取走,张三老婆取原存款应失败!#####");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean flag = atomicInteger.compareAndSet(atomicInteger.get(), atomicInteger.get() - 50);
if (flag) {
System.out.println(Thread.currentThread().getName() + " 张三老婆在" + sdf.format(new Date()) + "取款50元,当前账户剩余" + atomicInteger.get() + "元");
} else {
System.out.println(Thread.currentThread().getName() + "取款失败");
}
});
thread2.setName("张三老婆操作->");
Thread thread3 = new Thread(() -> {
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 张三当前的账户余额:" + atomicInteger.get());
atomicInteger.compareAndSet(atomicInteger.get(), atomicInteger.get() + 50);
System.out.println(Thread.currentThread().getName() + " 张三上司在" + sdf.format(new Date()) + "汇款50元,当前账户剩余" + atomicInteger.get() + "元");
});
thread3.setName("张三上司操作->");
thread1.start();
thread2.start();
thread3.start();
}
}
输出信息
业务要求:张三和张三老婆同时取款,只能取出原存款金额50,被取完,就不能继续取。
张三本人操作-> 张三原存款剩余:50
张三老婆操作-> 张三原存款剩余:50
#####由于张三已经提前把钱取走,张三老婆取原存款应失败!#####
张三本人操作-> 张三在2020-08-06 11:00:01取原存款50元,当前账户剩余0元
张三上司操作-> 张三当前的账户余额:0
张三上司操作-> 张三上司在2020-08-06 11:00:02汇款50元,当前账户剩余50元
张三老婆操作-> 张三老婆在2020-08-06 11:00:02取款50元,当前账户剩余0元
查看面的输出信息,显然不符合我们的业务要求,那么怎么区分原账户的50元,和汇入的50元呢,怎么感知变化呢?
JDK的Atomic提供了一个类AtomicStampedReference
来解决ABA问题,该类compareAndSet()
的作用是首先检查当前引用是否等于预期引用,并检查当前标志是否等于预期标志,若为true,则以原子方式将该引用及标志的值置为预期的值,源码如下所示:
/**
* 若当前引用是预期的引用,
* 以原子的方式则将当前标志置为预期标志,
* 值置为预期的值
*
* @param expectedReference 预期的引用
* @param newReference 更新后的引用
* @param expectedStamp 预期标志
* @param newStamp 更新后的标志
* @return {@code true} if successful
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
private boolean casPair(Pair<V> cmp, Pair<V> val) {
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
下面我们尝试使用AtomicStampedReference
来尝试解决这种特殊业务要求:
public class ABADemo {
private static AtomicStampedReference atomicStampedRef = new AtomicStampedReference(50, 0);
public static void main(String[] args) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Thread thread1 = new Thread(() -> {
int currentStamp = atomicStampedRef.getStamp();
System.out.println(Thread.currentThread().getName() + " 张三原存款剩余:" + Integer.parseInt(atomicStampedRef.getReference().toString()));
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedRef.compareAndSet(Integer.parseInt(atomicStampedRef.getReference().toString()), Integer.parseInt(atomicStampedRef.getReference().toString()) - 50, currentStamp, currentStamp + 1);
System.out.println(Thread.currentThread().getName() + " 张三在" + sdf.format(new Date()) + "取款50元,当前账户剩余" + Integer.parseInt(atomicStampedRef.getReference().toString()) + "元");
});
thread1.setName("张三本人操作->");
Thread thread2 = new Thread(() -> {
int currentStamp = atomicStampedRef.getStamp();
System.out.println(Thread.currentThread().getName() + " 张三原存款剩余:" + Integer.parseInt(atomicStampedRef.getReference().toString()));
System.out.println("#####由于张三已经提前把原存款取走,张三老婆原存款应失败!#####");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean flag = atomicStampedRef.compareAndSet(Integer.parseInt(atomicStampedRef.getReference().toString()), Integer.parseInt(atomicStampedRef.getReference().toString()) - 50, currentStamp, currentStamp + 1);
if (flag) {
System.out.println(Thread.currentThread().getName() + " 张三老婆在" + sdf.format(new Date()) + "取原存款50元,当前账户剩余" + Integer.parseInt(atomicStampedRef.getReference().toString()) + "元");
} else {
System.out.println(Thread.currentThread().getName() + " 张三老婆取原存款失败");
}
});
thread2.setName("张三老婆操作->");
Thread thread3 = new Thread(() -> {
try {
Thread.sleep(502);
} catch (InterruptedException e) {
e.printStackTrace();
}
int currentStamp = atomicStampedRef.getStamp();
System.out.println(Thread.currentThread().getName() + " 张三当前的账户余额:" + Integer.parseInt(atomicStampedRef.getReference().toString()));
atomicStampedRef.compareAndSet(Integer.parseInt(atomicStampedRef.getReference().toString()), Integer.parseInt(atomicStampedRef.getReference().toString()) + 50, currentStamp, currentStamp + 1);
System.out.println(Thread.currentThread().getName() + " 张三上司在" + sdf.format(new Date()) + "汇款50元,当前账户剩余" + Integer.parseInt(atomicStampedRef.getReference().toString()) + "元");
});
thread3.setName("张三上司操作->");
thread1.start();
thread2.start();
thread3.start();
}
}
张三本人操作-> 张三原存款剩余:50
张三老婆操作-> 张三原存款剩余:50
#####由于张三已经提前把原存款取走,张三老婆原存款应失败!#####
张三本人操作-> 张三在2020-08-06 10:55:00取款50元,当前账户剩余0元
张三上司操作-> 张三当前的账户余额:0
张三上司操作-> 张三上司在2020-08-06 10:55:00汇款50元,当前账户剩余50元
张三老婆操作-> 张三老婆取原存款失败
3.2.2.1 循环时间长开销大
自旋若长时间不成功,则会给CPU带来非常大的执行开销。
3.2.2.2 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们使用循环CAS的方式来保证原子操作。但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这时候就可以用锁。
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域,除了偏向锁,JVM实现锁的方式都用了循环CAS,当一个线程想要进入同步块的时候循环使用CAS的方式来获得锁,当退出同步块时使用循环CAS来释放锁。
参考文献
《JAVA并发编程的艺术》