面向面试编程:并发编程三大特性

247 阅读9分钟
 面试官:看你用过多线程,那你来说说并发编程的三大特性吧 

背景

众所周知,CPU、内存、I/O设备三者间的速度差异天差地别。为了合理利用CPU的性能,平衡三者间的速度差异:CPU增加了高速缓存(L1,L2,L3)来平衡与内存的速度差异;操作系统增加了进程、线程,分时复用CPU均衡其与I/O设备的速度差异;编译程序优化指令执行次序从而更合理的利用CPU缓存。

这些成果在带给我们便利的同时也会给我们带来一系列的问题。

 面试官:说重点...

1.可见性

可见性问题就出现在CPU的位置,CPU处理速度非常快,相对CPU来说,去内存获取数据这个事情太慢了,CPU就提供了L1,L2,L3的三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率肯定会提升。

缓存导致的可见性问题

这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致数据不一致问题。

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (flag) {
            // ....
        }
        System.out.println("t1线程结束");
    });

    t1.start();
    Thread.sleep(10);
    flag = false;
    System.out.println("主线程将flag改为false");
}

解决可见性的方式

volatile

volatile是一个关键字,用来修饰成员变量。 如果属性被volatile修饰,相当于会告诉CPU,对当前属性的操作,不允许使用CPU的缓存,必须去和主内存操作。

面试官:volatile是如何做到的呢?

volatile的内存语义:

  • 当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中。
  • 当读一个volatile变量,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量。

加了volatile修饰的属性,会在转为汇编之后,追加一个lock的前缀,CPU执行这个指令时,如果带有lock前缀会做两个事情:

  • 将当前处理器缓存行的数据写回到主内存
  • 这个写回的数据,在其他的CPU内核的缓存中,直接无效。

synchronized

synchronized也是可以解决可见性问题的,synchronized的内存语义。

如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从CPU缓存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

Lock

Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。

如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。

final

final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。

final并不是说每次取数据从主内存读取,他没有这个必要,而且final和volatile是不允许同时修饰一个属性的

final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰。

2.原子性

早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

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

原子性指一个操作是不可分割的,不可中断的,一个线程在执行时,另一个线程不会影响到他。

private static int count;

public static void increment(){
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    count++;
}

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
           increment();
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            increment();
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);
}

上述程序中,count++操作一共分为三个部分:

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

操作系统做任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 t1 在指令 1 执行完后做线程切换,线程 t2 此时执行这三条指令,执行结束后线程 t1 执行后两条指令,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

原子性问题:多线程操作临界资源,预期的结果与最终结果一致。

保证并发编程的原子性

synchronized

可以在方法上追加synchronized关键字或者采用同步代码块的形式来保证原子性。

synchronized可以让避免多线程同时操作临界资源,同一时间点,只会有一个线程正在操作临界资源。

CAS

什么是CAS?compare and swap也就是比较和交换,他是一条CPU的并发原语。

他在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作。Java中基于Unsafe的类提供了对CAS的操作的方法,JVM会帮助我们将方法实现CAS汇编指令。

但是要清楚CAS只是比较和交换,在获取原值的这个操作上,需要你自己实现。Java并发包中的原子类就是基于CAS来实现的。

Lock锁

Lock锁的性能相比synchronized在JDK1.5的时期,性能好很多,但是在JDK1.6对synchronized优化之后,性能相差不大,但是如果涉及并发比较多时,推荐ReentrantLock锁,性能会更好。

ReentrantLock可以直接对比synchronized,在功能上来说都是锁,但是ReentrantLock的功能性相比synchronized更丰富。

ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作。

ThreadLocal

ThreadLocal保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据。

面试官:ThreadLocal的原理有了解过吗?

ThreadLocal实现原理:

  • 每个Thread中都存储着一个成员变量,ThreadLocalMap。

  • ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap。

  • ThreadLocalMap本身就是基于Entry[]实现的,因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式实现。

  • 每一个现有都自己独立的ThreadLocalMap,再基于ThreadLocal对象本身作为key,对value进行存取。

  • ThreadLocalMap的key是一个弱引用,弱引用的特点是,即便有弱引用,在GC时,也必须被回收。这里是为了在ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收。

    面试官:ThreadLocal内存泄漏问题你遇到过吗?
    

ThreadLocal内存泄漏问题:

  • 如果ThreadLocal引用丢失,key因为弱引用会被GC回收掉,如果同时线程还没有被回收,就会导致内存泄漏,内存中的value无法被回收,同时也无法被获取到。
  • 只需要在使用完毕ThreadLocal对象之后,及时的调用remove方法,移除Entry即可。

3.有序性

顾名思义,有序性指的是程序按照代码的先后顺序执行。

在Java中,.java文件中的内容会被编译,在执行前需要再次转为CPU可以识别的指令,CPU在执行这些指令时,为了提升执行效率,在不影响最终结果的前提下(满足一些要求),会对指令进行重排。

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

指令乱序执行的原因,是为了尽可能的发挥CPU的性能。Java中的程序是乱序执行的。

单例模式由于指令重排序可能会出现问题: 线程可能会拿到没有初始化的对象,导致在使用时,可能由于内部属性为默认值,导致出现一些不必要的问题。

private static MyTest test;

private MyTest(){}

public static MyTest getInstance(){
    // B
    if(test  == null){
        synchronized (MyTest.class){
            if(test == null){
                // A   开辟空间,test指向地址,初始化
                test = new MyTest();
            }
        }
    }
    return test;
}

指令重排的优化

Happens-Before 规则

  1. 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
  2. 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
  3. volatile的happen-before原则: 对一个volatile变量的写操作happen-before对此变量的任意操作。
  4. happen-before的传递性原则: 如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
  5. 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
  6. 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
  7. 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
  8. 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。

Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

volatile

如果需要让程序对某一个属性的操作不出现指令重排,除了满足happens-before原则之外,还可以基于volatile修饰属性,从而对这个属性的操作,就不会出现指令重排的问题了。

volatile是基于内存屏障实现的禁止指令重排。将内存屏障看成一条指令。会在两个操作之间,添加上一道指令,这个指令就可以避免上下执行的其他指令进行重排序。

final

volatile 为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是final 关键字

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。