并发问题的根源
CPU、内存、I/O设备运算速度的差异一直是计算机优化的课题,我们可以形象地将三者速度的差异比喻为:
- CPU是天上一天,内存是地上一年
- 内存是天上一天,I/O设备是地上十年
在这种情况下,CPU与内存、I/O设备相互协作时,CPU会花很多时间在等待上面,CPU是计算机的大脑,将很多时间浪费在等待上无疑会降低计算机整体的性能和效率。为此,很多前人做了非常多的努力,均衡三者速度的差异,主要有:
- CPU增加了高速缓存,均衡与内存的速度差异,如下图,增加了三级缓存
-
操作系统增加了进程、线程,分时复用CPU,均衡CPU与I/O设备的速度差异
-
编译程序优化指令执行顺序,使得缓存能够得到更合理的利用
凡事有利有弊,这些操作提高计算机整体的性能和效率,但是也带来了以下问题
高速缓存带来的内存可见性问题
先看一段代码示例,这段代码最终没有打印出flag 为 true
,退出循环
public class VolatileTest {
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
Thread thread = new Thread(volatileDemo, "A线程");
thread.start();
while (true) {
// 如果flag为true
if (volatileDemo.isFlag()) {
// 会打印这句话,并且退出死循环
System.out.println("flag 为 true");
break;
}
}
}
}
class VolatileDemo implements Runnable {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 这里将flag设置为true
flag = true;
}
public boolean isFlag() {
return flag;
}
}
复制代码
每个CPU都有自己的缓存,当CPU的缓存没有及时更新到主存上,这些缓存数据对于其它CPU就是不可见的了,如下图,CPU-1的缓存对于CPU-2是不可见的
线程切换带来的原子性问题
先看一段代码示例,理论上打印出来的值是0~9,但最终打印出来的值有重复
public class AtomicTest {
public static void main(String[] args) {
AtomicDemo atomicDemo = new AtomicDemo();
for (int i = 0; i < 10; i ++) {
new Thread(atomicDemo).start();
}
}
}
class AtomicDemo implements Runnable {
private int serialNumber = 0;
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getName() + " : " + getSerialNumber());
}
public int getSerialNumber() {
return serialNumber += 1;
}
}
复制代码
主要的问题出在serialNumber += 1,serialNumber += 1实际经历了三步操作
- 将serialNumber从内存加载到CPU寄存器
- 在寄存器执行+1操作
- 将结果写入内存
那就有可能出现这样的情况,线程1从主存读取了为0的serialNumber,对它进行加1操作,此时,线程2获得CPU,从主存读取了为0的serialNumber,加1后写回主存,释放CPU,线程执行也写回主存的操作,虽然serialNumber经历了两次加的操作,但是最终的效果是只加了一次,如下图。注意,这个问题和内存可见性是两个问题,涉及到读写两步操作,即使没有内存可见性这个问题,依旧会发生
编译优化带来的有序性问题
有序性是指按照代码的先后顺序执行,但是编译器有时候为了优化性能,会改变代码的先后执行顺序,比如a=8; b=7;
会变成b=7;a=8;
来看一段代码,以下的代码两个线程会分别对a、b、x、y进行赋值,因为线程的执行顺序问题,会有各种排列组合,但是理论上不会出现x=y=0的情况,因为x=y=0情况发生的条件是x = b,y = a这两行代码跑到a = 1,b = 1之前执行了
public class ReOrderTest {
private static int x = 0;
private static int y = 0;
private static int a = 0;
private static int b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (;;) {
i ++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(() -> {
a = 1;
x = b;
});
Thread two = new Thread(() -> {
b = 1;
y = a;
});
one.start();
two.start();
one.join();
two.join();
String result = "第" + i + "次, " + "x = (" + x + "), y = (" + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
}
}
}
}
复制代码
实际的执行中会发现确实出现了指令重排序
Synchronized
原子性问题的根源是线程切换,那我们只要不让线程切换就可以解决问题了,也就是说,我们要做到同一时刻只有一个线程运行
,这个条件我们称之为互斥
,Java提供的Synchronized关键字就是实现互斥的锁
使用方式
修饰代码块,锁是Synchronized括号中配置的对象
public class SynchronizedCodeBlock {
public static void main(String[] args) {
SynchronizedCodeBlockThread s1 = new SynchronizedCodeBlockThread();
Thread a = new Thread(s1, "A线程");
Thread b = new Thread(s1, "B线程");
a.start();
b.start();
}
}
class SynchronizedCodeBlockThread implements Runnable {
public static int count = 0;
public void method() throws InterruptedException {
synchronized (this) {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " : " + (count ++));
Thread.sleep(1000);
}
}
}
@Override
public void run() {
try {
method();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
复制代码
修饰代码块,锁是当前类的Class对象
public class SynchronizedStaticTest {
public static void main(String[] args) {
SyncThread s1 = new SyncThread();
SyncThread s2 = new SyncThread();
Thread a = new Thread(s1, "A线程");
Thread b = new Thread(s2, "B线程");
a.start();
b.start();
}
}
class SyncThread implements Runnable {
public static int count = 0;
public synchronized static void method() throws InterruptedException {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " : " + (count ++));
Thread.sleep(1000);
}
}
@Override
public void run() {
try {
method();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
复制代码
修饰普通同步方法,锁是当前实例对象
public class SynchronizedTest {
public static void main(String[] args) {
SynchronizedMethodThread s1 = new SynchronizedMethodThread();
Thread a = new Thread(s1, "A线程");
Thread b = new Thread(s1, "B线程");
a.start();
b.start();
}
}
class SynchronizedMethodThread implements Runnable {
public static int count = 0;
public synchronized void method() throws InterruptedException {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " : " + (count ++));
Thread.sleep(1000);
}
}
@Override
public void run() {
try {
method();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
复制代码
如何加锁
当一个线程访问同步代码块时,必须先得到锁,退出或抛出异常时必须释放锁,那么锁到底存在哪里?玄机就在对象头中
对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充
对象头又分为markword、klass pointer
锁的秘密就在markword中,markword中存储了对象的HashCode、GC信息、锁标志位,下图是markword的构成
在我们的刻板印象中,synchronized是一个非常重的锁,但是在Java1.6中已经对synchronized做了非常大的优化,锁的性能已经提高了很多,在很多框架中,比如spring,synchronized是较为常用的一个关键字,synchronized一共有4种状态,级别从低到高,分别是无锁、偏向锁、轻量级锁、重量级锁,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级为偏向锁
偏向锁
当一个线程访问同步代码块并获得锁时,会在对象头的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步代码块时只需要简单验证markword中是否存储指向当前线程的偏向锁即可,我们可以简单地理解为偏向锁就是租借一辆共享单车,在同一时刻,只有你一个人拥有这辆共享单车,扫码的过程就是存储线程ID的过程
为什么要设置偏向锁?
HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,反而总是同一个线程多次获得,既然如此,我们就在锁上贴一个标签,标志这辆共享单车是我的,这样就不用每次使用都扫码了
如果是另外一个线程访问这个synchronized方法,那么实际情况会如何呢?
偏向锁会被撤销
轻量级锁
当锁出现竞争时,锁就会升级为轻量级锁
如果锁已经是轻量级锁了,另外一个线程访问这个synchronized方法,那么实际情况会如何呢?
该线程会自旋获取锁资源
重量级锁
锁竞争非常激烈的情况下,会膨胀成重量级锁,线程从用户态进入内核态,获取锁失败的线程会进入阻塞队列中,如下图,这样做的好处是不会因为线程的自旋
为什么有自旋锁还需要重量级锁
自旋需要消耗CPU,在锁的时间长,锁竞争非常激烈的情况下,CPU会被大量消耗,重量级锁有等待队列,在拿不到锁的情况下,线程会进入等待队列中,不需要消耗CPU资源
偏向锁效率一定比自旋锁高吗
不一定,在明确有多线程竞争的情况下,偏向锁会涉及到锁撤销,频繁加锁解锁效率反而更低,JVM启动过程中,明确会有多线程竞争锁,这时候不打开偏向锁,过一段时间再打开
锁长什么样子
准备环境
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
复制代码
无锁
public class ObjectHeaderTest {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
复制代码
注意:jol打印出来的对象信息是倒序的,我们可以清楚看到锁标志位是001(无锁态)
偏向锁
public class ObjectHeaderTest {
public static void main(String[] args) throws InterruptedException {
// 线程沉睡5秒
Thread.sleep(5000);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
复制代码
一个非常有趣的现象,线程沉睡5秒后,对象由无锁态变成偏向锁状态,这是为什么呢?
我们上文说过,如果一开始就可以预料到某个锁竞争会非常激烈,有偏向锁的机制反而会变得低效,因为偏向锁有锁撤销的操作,而JVM刚开始启动的时候,有大量的系统类初始化,这个时候会使用synchronized对对象加锁,锁竞争会较为激烈,不宜有偏向锁,所以JVM会默认延迟加载偏向锁,延时时长大概为4s左右,虽然是偏向锁,但是并没有具体偏向哪个线程,是一个特殊状态的无锁
public class ObjectHeaderTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
复制代码
这个时候可以看到具体偏向某个线程了
轻量级锁
public class ObjectHeaderTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
// 锁已经偏向某个线程
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
// 另外一个线程去竞争锁
for (int i = 0; i < 1; i ++) {
Thread t = new Thread(() -> {
print(o);
});
t.start();
}
}
public static void print(Object o) {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
复制代码
可以看到锁一开始是偏向锁,另外一个线程去竞争后,锁变成轻量级锁
重量级锁
public class ObjectHeaderTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
// 使用10个线程去竞争锁
for (int i = 0; i < 10; i ++) {
Thread t = new Thread(() -> {
print(o);
});
t.start();
}
}
public static void print(Object o) {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
复制代码
锁竞争非常激烈的情况下,升级为重量级锁了
锁升级的过程可以简单概括如下图
再深入一点点
synchronized是基于进入与退出管程对象(Monitor)来实现的,每个对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建并销毁,Monitor对象是由C++来实现的。查看以上程序的字节码,可以看到monitorenter与monitorexit
volatile
原子性问题可以通过synchronized来解决,那可见性和有序性的问题怎么解决呢?这个时候就可以请出volatile了
volatile是轻量级的synchronized(说明synchronized也可以解决可见性和有序性问题,但是解决的方式不一样),在多线程的开发中主要有两个作用
- 实现共享变量的可见性
- 防止指令的重排序
使用方式
将共享变量flag使用volatile修饰,程序就可以正常输出flag 为 true
public class VolatileTest {
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
Thread thread = new Thread(volatileDemo, "A线程");
thread.start();
while (true) {
if (volatileDemo.isFlag()) {
System.out.println("flag 为 true");
break;
}
}
}
}
class VolatileDemo implements Runnable {
// flag使用volatile修饰
private volatile boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
}
public boolean isFlag() {
return flag;
}
}
复制代码
一些概念
happen-before
某个操作的结果happen-before后续的任意操作,那么这个结果对于后续的任意操作都是可见的
as if serial
不管如何重排序,单线程执行结果不会改变
如何实现
以上都是一些关于volatile的概念或者规范,那么如何实现volatile的语义呢?
缓存一致性协议(解决可见性问题)
在计算机有多个CPU的情况下,操作共享变量时,如果其它CPU的缓存中存在该变量的副本,会发出信号通知其它CPU将该变量的缓存行置为无效状态,缓存行无效后,其它CPU会从内存再次读取该变量,保证了共享变量的可见性
内存屏障(解决指令重排序问题)
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,如下图,简单来说,加了volatile关键字的变量,自己写完,别人才能读,自己读完,别人才能写
参考资料
B站马士兵视频教程
Java并发编程的艺术
极客时间Java并发编程