Android进阶宝典 -- 并发编程之JMM模型和锁机制

语言: CN / TW / HK

在实际的开发中,尤其对于Android程序员,对于并发编程接触并不多,因为很少遇到需要并发的场景。但是像我们使用到的OKHttp,其实内部已经帮我们处理好了并发的场景,我们只是在应用层调用它们的API,所以在阅读源码时,我们肯定是能够看到多线程的处理,而且在面试中对于并发的考察并不少,所以这部分我们还是要熟悉的。

那么对于Android开发人员来说,并发的场景无非是:文件下载、多文件上传、数据库读取、网络请求等,适当地使用并发编程,避免我们的App出现卡顿

1 JMM内存模型

注意这里需要跟JVM内存模型做区分,这里的JMM内存模型指的是,在多线程的场景下Java的内存模型

image.png

在多线程并发的场景下,每个线程都会有自己的工作内存,所有的线程共享一块内存,如果某个线程需要修改内存中某个变量的值,可以将共享变量拷贝到工作内存,修改完成之后,刷新到主内存中。

1.1 JMM 8大原子性操作

```java public class JUCTest {

private static boolean flag = false;

public void test() {
    //线程1
    new Thread(new Runnable() {
        @Override
        public void run() {
            Log.e("TAG", "Thread start");
            while (!flag) {}
            Log.e("TAG","flag -- "+flag);
            Log.e("TAG", "Thread end");
        }
    }).start();
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    //线程2
    new Thread(new Runnable() {
        @Override
        public void run() {
            update();
        }
    }).start();
}

private void update() {
    Log.e("TAG", "Thread begin change flag");
    flag = true;
    Log.e("TAG", "Thread changed flag");
}

} ``` 接下来,我们通过上面这个示例来了解JMM中的8大原子性操作。

首先flag是一个静态变量,所有的线程都可以共享,因此属于JMM中的主内存;当线程1启动之后,需要获取flag的值(read),因此需要从主内存中读取数据,并将读取到的数据写入到主内存中(load),线程1就可以使用这个变量(use),并能够为变量赋值,这里线程1并没有做赋值操作。

image.png

而线程2做的操作比线程1要多,在线程2中,需要给flag赋值,然后写入到主内存中

image.png

通过上面的流程图,我们可以知道关于JMM的8大原子操作分别是什么了吧,我们总结一下:

(1)read:用于从主内存中读取共享数据到消息队列(总线)中;\ (2)load:用于将数据加载到线程的工作内存中;\ (3)use:从工作内存中取出数据来进行计算;\ (4)assign:将计算好的值重新赋值到工作内存中;\ (5)store:将工作内存数据存储到消息队列中;\ (6)write:将主内存中的变量重新赋值;

这里我们发现还缺少两个,剩下的两个就是跟线程同步锁相关的,分别是:

(7)lock:将主内存共享变量加锁;\ (8)unlock:将主内存共享变量解锁;

1.2 缓存一致性原则

所以,在多线程并发的场景下,如果某个线程修改了数据,其他线程(例如线程1)获取的还是旧数据,那么就会因为数据不一致导致计算错误。

而缓存一致性协议是什么意思呢?当一个CPU修改了缓存中的数据时,会立即通过store、write将新数据写入到主内存中

image.png其他CPU则是会通过总线嗅探机制,也就是图中的消息队列,感知数据是否发生了变化,如果发生了变化,那么在当前线程工作内存中的变量则会失效,会重新read、load将最新的数据刷新至高速缓存区。

java Thread start Thread begin change flag Thread changed flag 所以,当我们运行本小节开头的那一段代码时,会发现虽然线程2修改了主内存中flag的值,但是线程1并没有获取到最新修改的值,因此没有跳出循环,那么有什么方式能达到这种缓存一致的效果呢?那就是使用volatile关键字。

1.3 volatile的底层原理

当我们加上volatile关键字之后,

java Thread start Thread begin change flag Thread changed flag Thread end 我们看到线程1同步到了flag的最新值,跳出了while循环,所以volatile在底层干了什么事呢?首先,我们先看下volatile这段代码在执行的时候,指令集是什么样的?

java lock add dword ptr [rsp],0h ;*putstatic flag 我们可以看到,在volatile执行的时候,底层汇编指令添加了一个lock指令,那么这个lock指令的主要作用是什么呢?

其实lock指令的一个主要作用就是触发总线嗅探机制,在Intel架构软件中对于lock指令的解释就是:会将CPU高速缓冲区中修改的值重新写入到主内存中,同时其他CPU缓存了该地址的数据全部失效。

另外,lock指令的另一个作用就是禁止指令重排序。

1.4 指令重排序

什么是指令重排序呢?其实是编译器做的一次优化,当JIT编译器在解释执行字节码的时候,为了进行优化会将字节码的顺序做一次调整,我们看下面这个例子。

```java private static int a = 0; private static int b = 0; private static int x = 0; private static int y = 0;

public static void testCodeSort(){ HashSet hashSet = new HashSet(); for (int i = 0; i < 1000000000; i++) {

    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            a = x;
            y = 1;
        }
    });

    Thread thread1 = new Thread(new Runnable() {
        @Override
        public void run() {
            b = y;
            x = 1;
        }
    });

    thread.start();
    thread1.start();
    try {
        thread.join();
        thread1.join();
    }catch (Exception e){

    }

    hashSet.add("a="+a+"b="+b);
    System.out.println(hashSet);
}

} 两个线程同时执行,因为每个线程执行快慢是未知的,因此最终得到a和b的值的结果也可能有多种,但是单就于某个线程来说,例如线程1java Thread thread = new Thread(new Runnable() { @Override public void run() { //代码块的顺序改变,并不会影响最终的结果 a = x; y = 1; } }); ``` 其实就会发生指令重排序,目的就是提高代码执行的效率,但也仅仅对于单线程,多线程下是不会发生指令重排序的,因此会导致结果出现异常。

1.5 指令重排序在单例模式中的惨案

```java public class Singleton {

private Singleton() {
}

private static Singleton mInstance = null;

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

} ``` 这是最常用的一种双检锁单例设计模式,看起来没什么问题,但是细细研究一下还是会发现有待优化之处的,看下字节码。

java 10 monitorenter 11 getstatic #2 <com/lay/mvi/jvm/Singleton.mInstance : Lcom/lay/mvi/jvm/Singleton;> 14 ifnonnull 27 (+13) 17 new #3 <com/lay/mvi/jvm/Singleton> 20 dup 21 invokespecial #4 <com/lay/mvi/jvm/Singleton.<init> : ()V> 24 putstatic #2 <com/lay/mvi/jvm/Singleton.mInstance : Lcom/lay/mvi/jvm/Singleton;> 27 aload_0 28 monitorexit

我们直接从加锁后的代码块看,当执行ifnonnull指令后,会创建一个Singleton对象,

java 21 invokespecial #4 <com/lay/mvi/jvm/Singleton.<init> : ()V> 24 putstatic #2 <com/lay/mvi/jvm/Singleton.mInstance : Lcom/lay/mvi/jvm/Singleton;> 关键看这两个JVM指令,当创建Singleton对象的时候会执行init方法,而给mInstance赋值则是赋值一个符号引用,因此这两段代码前后并没有关系,因此在JIT编译时可能会发生指令重排序,那这里问题就大了。

这个时候,Singleton如果没有初始化完成,就将拿到一个空的mInstance,发生空指针异常导致应用崩溃。因此可以将mInstance加上volatile关键字,从而禁止指令重排序。

2 并发中的锁机制

在介绍JMM中8大原子性的时候,其中2个lock和unlock没有详细介绍,那么本小节就会从并发场景中了解锁的重要性。

java private static int count = 0; public static void testAutomic() throws InterruptedException { Thread thread = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 1000000; i++) { count++; } } }); Thread thread1 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 1000000; i++) { count--; } } }); thread.start(); thread1.start(); thread.join(); thread1.join(); System.out.println(count); } 输出的结果会是0吗?肯定不是,而且结果不唯一,那么为什么会造成这样的结果?我们可以猜想一下,是上一节中JMM内存模型中常见的并发问题。因为某个线程在修改共享变量的时候,并没有通知其他的线程去刷新主存,导致其他线程还是在旧变量的基础上做修改,从而导致一些无效的操作。

2.1 ++操作字节码指令分析

那么加上volatile关键字就可以了吗?还是不行!其实造成现在这个问题的主要原因是线程上下文切换导致的,看下面的图。

java 0 getstatic #5 <com/lay/mvi/jvm/Singleton.a : I> 3 iconst_1 4 iadd 5 putstatic #5 <com/lay/mvi/jvm/Singleton.a : I> 首先我们需要知道当执行 ++ 操作时对应的字节码指令是什么样的。再者伙伴们是否了解CPU的时间片轮转机制,假设在1s时间内分成了30个时间片,每个线程都会竞争获取时间片,当一个时间片结束之后,线程需要释放然后同其他线程再次竞争。

image.png

所以正是因为这个原因,导致了计算结果不如预期。所以,执行++操作这个过程并不是原子性的,因此从字节码指令中可以看到,执行++操作是分4步完成的,并不是一蹴而就的,所以当存在时间片轮转机制时,可能导致最后一步刷入主内存的时候没有完成,就被其他线程抢占了时间片

2.2 原子性实现 - sychronized

所以,如何保证操作的原子性呢?首先我们需要了解这个概念,其实这个概念出自于数据库事务,就是一个操作或者多个操作,要么就一次执行完成中间不能被外界干扰,要么就不执行。而++操作,因为底层字节码指令可能因为时间片轮转导致4步无法一次执行完,不具备原子性,因此Java中提供了2种解决方案:加锁或者使用原子变量。

加锁属于阻塞性的实现方案,当一个线程抢占了对象锁之后,其他线程如果想要获取锁下资源就会阻塞等待,不需要关心线程上下文的切换。

```java private static volatile int count = 0;

public static void testAutomic() throws InterruptedException {

Object mLock = new Object();

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            synchronized (mLock){
                count++;
            }
        }
    }
});
Thread thread1 = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            synchronized (mLock){
                count--;
            }
        }
    }
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println(count);

} ```

这个时候,再运行这段代码,结果始终就是0;因为两个线程持有同一把锁对象,是竞争关系,只有当一个线程完全执行++或者--操作之后,才会释放这把锁

2.2.1 sychronized原理实现

java 5 monitorenter 6 getstatic #5 <com/lay/mvi/jvm/Singleton.a : I> 9 iconst_1 10 iadd 11 putstatic #5 <com/lay/mvi/jvm/Singleton.a : I> 14 aload_1 15 monitorexit 再来看字节码指令,当执行到sychronized代码块的时候,我们可以看到首先执行了monitorenter,这里引出一个概念Monitor。

当代码执行到sychronized代码块时,JVM会创建一个Monitor对象

java Mobitor monitor = new Monitor()

image.png

我们看下Monitor的数据接口,其中有3个容器,分别是:

Owner,用于存储当前获取这把锁的线程;\ entryList是一个线程队列,代表等待获取这把锁的线程集合,当Owner中线程释放锁之后,Thead1将会持有这把锁(对于公平锁和非公平锁,就是在这里的区别);\ waitSet存储休眠的线程,当线程被唤醒之后,就会加到entryList集合中。

2.2.2 锁的等级划分

我们可以看到上图中是在多线程的场景下,需要3个容器存储线程;但是如果在单线程的场景下,其实并不需要entryList和waitSet,而是只需要一个Owner,这样其实也是为了避免资源浪费;所以在此场景下,出现了锁的等级划分。

偏向锁:只在单线程的场景下,本质上只有一把锁,直接应用markword解决识别问题(保存在对象头中,不需要创建Monitor对象);\ 轻量级锁:只在两个线程的场景下,通过栈区结构存储线程ID,是存储在栈帧中的;\ 重量级锁:在两个线程以上的场景下,采用Monitor来存储线程ID不同。

所以当线程执行时,第一次碰到sychronized时,会标记当前锁为偏向锁;第二次碰到sychronized的时候,就会标记为轻量级锁;以此类推,此后每次碰到sychronized都是重量级锁,会需要请出Monitor来帮忙了。

所以,所谓的锁膨胀,就是在线程开辟的过程中,处理方案的变更

2.2 原子性实现 -- CAS

因为sychronized属于阻塞性的实现方案,会影响程序执行的速度,那么还有什么方案要比sychronized的效率更高呢?那就是CPU的CAS指令,能够提高运算性能。

CAS全称是Campare And Swap,主要作用就是同步主内存和工作内存的数据

image.png

那么CAS算法是如何工作的呢?首先CAS是不关心切换线程上下文的,这就比sychronized要有优势。其次当线程2切换到线程1的时候,线程1准备调用putstatic指令将a = 1写入主内存。

此时如果采用了CAS算法,那么会做一次比较,比较主内存中的值与getstatic获取到的值是否一致,也就是说在putstatic之前,主内存中的值是否发生过改变;如果没有发生改变那么就直接赋值,如果发生改变,那么就会将当前值丢弃,重新从主内存中读取新值,重新计算。

所以在JUC的并发工具包中,有很多根据CAS思想设计的类,例如AtomicInteger,与int不同的是,AtomicInteger实现了原子性操作 ``` private static volatile AtomicInteger count = new AtomicInteger(0);

public static void testAutomic() throws InterruptedException {

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            count.incrementAndGet();
        }
    }
});
Thread thread1 = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            count.decrementAndGet();
        }
    }
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println(count);

} 所以使用AtomicInteger代替int就能够实现加锁的效果,除此之外,还有ReentrantLock,不需要通过阻塞的方式,能够将 int++ 变为原子性的操作。 private static int count = 0; private static ReentrantLock reentrantLock = new ReentrantLock();

public static void testAutomic() throws InterruptedException {

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            reentrantLock.lock();
            try {
                count++;
            }finally {
                reentrantLock.unlock();
            }
        }
    }
});
Thread thread1 = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            reentrantLock.lock();
            try {
                count--;
            }finally {
                reentrantLock.unlock();
            }
        }
    }
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println(count);

} ```