并发问题的根源

并发问题的根源

CPU、内存、I/O设备运算速度的差异一直是计算机优化的课题,我们可以形象地将三者速度的差异比喻为:

  1. CPU是天上一天,内存是地上一年
  2. 内存是天上一天,I/O设备是地上十年

在这种情况下,CPU与内存、I/O设备相互协作时,CPU会花很多时间在等待上面,CPU是计算机的大脑,将很多时间浪费在等待上无疑会降低计算机整体的性能和效率。为此,很多前人做了非常多的努力,均衡三者速度的差异,主要有:

  1. CPU增加了高速缓存,均衡与内存的速度差异,如下图,增加了三级缓存

image.png

  1. 操作系统增加了进程、线程,分时复用CPU,均衡CPU与I/O设备的速度差异

  2. 编译程序优化指令执行顺序,使得缓存能够得到更合理的利用

凡事有利有弊,这些操作提高计算机整体的性能和效率,但是也带来了以下问题

高速缓存带来的内存可见性问题

先看一段代码示例,这段代码最终没有打印出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是不可见的

image.png

线程切换带来的原子性问题

先看一段代码示例,理论上打印出来的值是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;
    }
}
复制代码

image.png

主要的问题出在serialNumber += 1,serialNumber += 1实际经历了三步操作

  1. 将serialNumber从内存加载到CPU寄存器
  2. 在寄存器执行+1操作
  3. 将结果写入内存

那就有可能出现这样的情况,线程1从主存读取了为0的serialNumber,对它进行加1操作,此时,线程2获得CPU,从主存读取了为0的serialNumber,加1后写回主存,释放CPU,线程执行也写回主存的操作,虽然serialNumber经历了两次加的操作,但是最终的效果是只加了一次,如下图。注意,这个问题和内存可见性是两个问题,涉及到读写两步操作,即使没有内存可见性这个问题,依旧会发生

image.png

编译优化带来的有序性问题

有序性是指按照代码的先后顺序执行,但是编译器有时候为了优化性能,会改变代码的先后执行顺序,比如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;
            }

        }
    }

}
复制代码

实际的执行中会发现确实出现了指令重排序

image.png

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

image.png

锁的秘密就在markword中,markword中存储了对象的HashCode、GC信息、锁标志位,下图是markword的构成

image.png

在我们的刻板印象中,synchronized是一个非常重的锁,但是在Java1.6中已经对synchronized做了非常大的优化,锁的性能已经提高了很多,在很多框架中,比如spring,synchronized是较为常用的一个关键字,synchronized一共有4种状态,级别从低到高,分别是无锁、偏向锁、轻量级锁、重量级锁,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级为偏向锁

偏向锁

当一个线程访问同步代码块并获得锁时,会在对象头的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步代码块时只需要简单验证markword中是否存储指向当前线程的偏向锁即可,我们可以简单地理解为偏向锁就是租借一辆共享单车,在同一时刻,只有你一个人拥有这辆共享单车,扫码的过程就是存储线程ID的过程

为什么要设置偏向锁?

HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,反而总是同一个线程多次获得,既然如此,我们就在锁上贴一个标签,标志这辆共享单车是我的,这样就不用每次使用都扫码了

如果是另外一个线程访问这个synchronized方法,那么实际情况会如何呢?

偏向锁会被撤销

轻量级锁

当锁出现竞争时,锁就会升级为轻量级锁

如果锁已经是轻量级锁了,另外一个线程访问这个synchronized方法,那么实际情况会如何呢?

该线程会自旋获取锁资源

重量级锁

锁竞争非常激烈的情况下,会膨胀成重量级锁,线程从用户态进入内核态,获取锁失败的线程会进入阻塞队列中,如下图,这样做的好处是不会因为线程的自旋

image.png

为什么有自旋锁还需要重量级锁

自旋需要消耗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(无锁态)

image.png

偏向锁
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左右,虽然是偏向锁,但是并没有具体偏向哪个线程,是一个特殊状态的无锁

image.png

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());
        }
    }
}
复制代码

这个时候可以看到具体偏向某个线程了

image.png

轻量级锁
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());
        }

    }
}
复制代码

可以看到锁一开始是偏向锁,另外一个线程去竞争后,锁变成轻量级锁

image.png

重量级锁

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());
        }

    }
}
复制代码

锁竞争非常激烈的情况下,升级为重量级锁了

image.png

锁升级的过程可以简单概括如下图

image.png

再深入一点点

synchronized是基于进入与退出管程对象(Monitor)来实现的,每个对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建并销毁,Monitor对象是由C++来实现的。查看以上程序的字节码,可以看到monitorenter与monitorexit

image.png

volatile

原子性问题可以通过synchronized来解决,那可见性和有序性的问题怎么解决呢?这个时候就可以请出volatile了

volatile是轻量级的synchronized(说明synchronized也可以解决可见性和有序性问题,但是解决的方式不一样),在多线程的开发中主要有两个作用

  1. 实现共享变量的可见性
  2. 防止指令的重排序

使用方式

将共享变量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关键字的变量,自己写完,别人才能读,自己读完,别人才能写

image.png

参考资料

B站马士兵视频教程

Java并发编程的艺术

www.cnblogs.com/LemonFive/p…

极客时间Java并发编程

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享