Android进阶宝典 -- 并发编程之JMM模型和锁机制
在实际的开发中,尤其对于Android程序员,对于并发编程接触并不多,因为很少遇到需要并发的场景。但是像我们使用到的OKHttp,其实内部已经帮我们处理好了并发的场景,我们只是在应用层调用它们的API,所以在阅读源码时,我们肯定是能够看到多线程的处理,而且在面试中对于并发的考察并不少,所以这部分我们还是要熟悉的。
那么对于Android开发人员来说,并发的场景无非是:文件下载、多文件上传、数据库读取、网络请求等,适当地使用并发编程,避免我们的App出现卡顿
1 JMM内存模型
注意这里需要跟JVM内存模型做区分,这里的JMM内存模型指的是,在多线程的场景下Java的内存模型
在多线程并发的场景下,每个线程都会有自己的工作内存,所有的线程共享一块内存,如果某个线程需要修改内存中某个变量的值,可以将共享变量拷贝到工作内存,修改完成之后,刷新到主内存中。
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并没有做赋值操作。
而线程2做的操作比线程1要多,在线程2中,需要给flag赋值,然后写入到主内存中
通过上面的流程图,我们可以知道关于JMM的8大原子操作分别是什么了吧,我们总结一下:
(1)read:用于从主内存中读取共享数据到消息队列(总线)中; (2)load:用于将数据加载到线程的工作内存中; (3)use:从工作内存中取出数据来进行计算; (4)assign:将计算好的值重新赋值到工作内存中; (5)store:将工作内存数据存储到消息队列中; (6)write:将主内存中的变量重新赋值;
这里我们发现还缺少两个,剩下的两个就是跟线程同步锁相关的,分别是:
(7)lock:将主内存共享变量加锁; (8)unlock:将主内存共享变量解锁;
1.2 缓存一致性原则
所以,在多线程并发的场景下,如果某个线程修改了数据,其他线程(例如线程1)获取的还是旧数据,那么就会因为数据不一致导致计算错误。
而缓存一致性协议是什么意思呢?当一个CPU修改了缓存中的数据时,会立即通过store、write将新数据写入到主内存中
而其他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个时间片,每个线程都会竞争获取时间片,当一个时间片结束之后,线程需要释放然后同其他线程再次竞争。
所以正是因为这个原因,导致了计算结果不如预期。所以,执行++操作这个过程并不是原子性的,因此从字节码指令中可以看到,执行++操作是分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()
我们看下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,主要作用就是同步主内存和工作内存的数据
那么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);
}
转载自:https://juejin.cn/post/7162532709112217614