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

1,336 阅读10分钟

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

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

1 JMM内存模型

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

image.png

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

1.1 JMM 8大原子性操作

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将最新的数据刷新至高速缓存区。

Thread start
Thread begin change flag
Thread changed flag

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

1.3 volatile的底层原理

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

Thread start
Thread begin change flag
Thread changed flag
Thread end

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

lock add dword ptr [rsp],0h  ;*putstatic flag

我们可以看到,在volatile执行的时候,底层汇编指令添加了一个lock指令,那么这个lock指令的主要作用是什么呢?

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

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

1.4 指令重排序

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

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的值的结果也可能有多种,但是单就于某个线程来说,例如线程1

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        //代码块的顺序改变,并不会影响最终的结果
        a = x;
        y = 1;
    }
});

其实就会发生指令重排序,目的就是提高代码执行的效率,但也仅仅对于单线程,多线程下是不会发生指令重排序的,因此会导致结果出现异常。

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

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

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

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对象,

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没有详细介绍,那么本小节就会从并发场景中了解锁的重要性。

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关键字就可以了吗?还是不行!其实造成现在这个问题的主要原因是线程上下文切换导致的,看下面的图。

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种解决方案:加锁或者使用原子变量。

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

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原理实现

 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对象

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