在实际的开发中,尤其关于Android程序员,关于并发编程接触并不多,由于很少遇到需求并发的场景。可是像咱们运用到的OKHttp,其实内部已经帮咱们处理好了并发的场景,咱们只是在应用层调用它们的API,所以在阅读源码时,咱们肯定是能够看到多线程的处理,并且在面试中关于并发的调查并不少,所以这部分咱们仍是要熟悉的。

那么关于Android开发人员来说,并发的场景无非是:文件下载、多文件上传、数据库读取、网络恳求等,适当地运用并发编程,防止咱们的App呈现卡顿

1 JMM内存模型

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

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

在多线程并发的场景下,每个线程都会有自己的作业内存,一切的线程同享一块内存,假如某个线程需求修正内存中某个变量的值,能够将同享变量拷贝到作业内存,修正完结之后,刷新到主内存中。

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并没有做赋值操作。

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

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

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

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

(1)read:用于从主内存中读取同享数据到音讯队列(总线)中;
(2)load:用于将数据加载到线程的作业内存中;
(3)use:从作业内存中取出数据来进行核算;
(4)assign:将核算好的值从头赋值到作业内存中;
(5)store:将作业内存数据存储到音讯队列中;
(6)write:将主内存中的变量从头赋值;

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

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

1.2 缓存共同性准则

所以,在多线程并发的场景下,假如某个线程修正了数据,其他线程(例如线程1)获取的仍是旧数据,那么就会由于数据不共同导致核算错误。

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

Android进阶宝典 -- 并发编程之JMM模型和锁机制
其他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个时刻片,每个线程都会竞赛获取时刻片,当一个时刻片结束之后,线程需求开释然后同其他线程再次竞赛。

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

所以正是由于这个原因,导致了核算成果不如预期。所以,履行++操作这个过程并不是原子性的,因而从字节码指令中能够看到,履行++操作是分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()

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

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

Owner,用于存储当时获取这把锁的线程;
entryList是一个线程队列,代表等候获取这把锁的线程调集,当Owner中线程开释锁之后,Thead1将会持有这把锁(关于公正锁和非公正锁,便是在这儿的差异);
waitSet存储休眠的线程,当线程被唤醒之后,就会加到entryList调集中。

2.2.2 锁的等级区分

咱们能够看到上图中是在多线程的场景下,需求3个容器存储线程;可是假如在单线程的场景下,其实并不需求entryList和waitSet,而是只需求一个Owner,这样其实也是为了防止资源浪费;所以在此场景下,呈现了锁的等级区分。

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

所以当线程履行时,第一次碰到sychronized时,会标记当时锁为偏向锁;第2次碰到sychronized的时分,就会标记为轻量级锁;以此类推,尔后每次碰到sychronized都是重量级锁,会需求请出Monitor来帮助了。

所以,所谓的锁膨胀,便是在线程拓荒的过程中,处理计划的改动

2.2 原子性完成 — CAS

由于sychronized归于堵塞性的完成计划,会影响程序履行的速度,那么还有什么计划要比sychronized的功率更高呢?那便是CPU的CAS指令,能够提高运算功能。

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

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

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