极客时间-Java并发编程实战学习笔记

662 阅读6分钟

01|可见性、原子性和有序性问题:并发编程bug的源头

源头一:缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性

多核时代,每颗cpu都有自己的缓存,当多个线程在不同的cpu上执行时这些线程操作的是不同的cpu缓存,比如下图中,线程A操作的是cpu-1上的缓存,而线程b操作的是cpu-2上的缓存,线程A对变量v的操作对于线程B而言,就不具备可见性了.

以下代码可以很好的反应缓存可见性导致的并发问题:

public class Test {


    public long calc() throws InterruptedException {
        final long[] count = {0};
        final Test test = new Test();
        //创建两个线程,执行add()操作
        Thread th1 = new Thread(() -> {
            int idx = 0;
            while (idx++ < 1000000000) {
                count[0] += 1;
            }
        });

        Thread th2 = new Thread(() -> {
            int idx = 0;
            while (idx++ < 1000000000) {
                count[0] += 1;
            }
        });
        //启动两个线程
        th1.start();
        th2.start();
        //等待两个线程执行结束
        th1.join();
        th2.join();
        return count[0];
    }

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        System.out.println(test.calc());
    }
}

想通过calc()方法用两个线程计算count分别加1000000000次后的结果,期望结果是:2000000000,实际结果是:1007337040.

原因:假设线程A和线程B同时开始执行,第一次都会将count=0读到各自的cpu缓存中,执行完count+1之后,各自cpu缓存里的值都是1,同时写入内存后,内存中是1,而不是期望的2.之后由于各自的cpu缓存里都有了count的值,两个线程都是基于cpu缓存里的count值来计算,所以导致最终的count的值都是小于2000000000. 这就是缓存的可见性问题

源头二:线程切换带来的原子性问题

高级语言里一条语句往往需要多条cpu指令完成,例如上面代码中的count+=1,至少需要三条cpu指令.

  • 指令1:首先,需要把变量count从内存加载到cpu的寄存器;
  • 指令2:之后,在寄存器中执行+1操作;
  • 指令3:最后,将结果写入内存(缓存机制导致可能写入的是cpu缓存而不是内存)

我们把一个或者多个操作在cpu执行的过程中不被中断的特性称为原子性

源头三:编译优化带来的有序性问题

在获取实例getInstance()的方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class,并再次检查instance是否为空,如果还为空则创建Singleton的一个实例

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

问题出现在new Singleton()这里 这一行对cpu来讲,有三个指令:

  • 1、分配内存空间
  • 2、初始化对象
  • 3、instance引用指向内存空间

正常执行顺序1->2->3,但是cpu指令重排序可能为1->3->2,那么就有问题了:

  • 1、A、B线程同时进入第一个if判断
  • 2、A首先进入synchronized块,由于instance为null,所以执行instance = new Singleton();
  • 3、然后线程A执行1 JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance
  • 4、在还没有进行第三步(将instance引用指向内存空间)的时候,线程A离开了synchronized块
  • 5、线程B进入synchronized块,读取到了A线程返回的instance,此时这个instance并未进行物理地址指向,是一个空对象.

现在比较通用的做法是采用静态内部类的方式处理:

public class MySingleton {
    //内部类
    private static class MySingletonHandler {
        private static MySingleton instance = new MySingleton();
    }

    private MySingleton() {

    }

    public static MySingleton getInstance() {
        return MySingletonHandler.instance;
    }
}

32位的机器上对long变量进行加减操作存在并发隐患:线程切换带来的原子性问题,非volatile类型的long和double型变量是8字节64位的,32位机器读或写这个变量时得把64位的long类型分成两个32位操作,可能一个线程读了某个值的高32位,低32位已经被另一个线程改了.所以推荐最好把long/double变量声明成volatile或是加同步锁synchronized以避免并发问题.

02|java内存模型:看Java如何解决可见性和有序性问题

什么是Java内存模型

解决有序性最直接的办法:禁用缓存和编译优化

03|互斥锁(上):解决原子性问题

用synchronized解决count+=1问题

SafeCalc这个类有两个方法:一个是get()方法,用来获取value的值;另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰,那么我们使用的这两个方法有没有并发问题?

class SafeCalc{
    long value = 0;
    long get(){
        return value;
    }
    
    synchronized void addOne(){
        value+=1;
    }
}

被synchronized修改后,无论是单核cpu还是多核cpu,只有一个线程能够执行addOne()方法,所以一定能保证原子操作.管程中锁的规则:对一个锁的解锁happens-before于后续对这个锁的加锁,我们知道synchronized修饰的临界区是互斥的,而所谓“对一个锁解锁happens-before后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作是可见,综合happens-before的传递性规则,就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的.

锁和受保护资源的关系

上面的例子稍作改动:

class SafeCalc{
    static long value = 0;
    synchronized long get(){
        return value;
    }
    synchronized static void addOne(){}
    value += 1;
}

改动后我们发现使用两个锁包含一个资源,这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class,由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了.

思考题

下面的代码用synchronized修饰代码块来尝试解决并发问题,这段代码有问题吗?

class SafeCalc{
    long value = 0;
    long get(){
        synchronized (new Object()){
            return value;
        }
    }
    void addOne(){
        synchronized (new Object()){
            value += 1;
        }
    }
}

解答:

  • 加锁的本质是在锁对象的对象头中写入当前线程id,但是new Object每次在内存中都是新对象,所以加锁无效.
  • 经过jvm逃逸分析的优化后,这个sync 代码直接会被优化掉,所以在运行时该代码是无效的.