前言
线程并发系列文章:
Java 线程基础
Java “优雅”地中断线程
Java 线程状态
真正理解Java Volatile的妙用
Java ThreadLocal你之前了解的可能有误
Java Unsafe/CAS/LockSupport 应用与原理
Java 并发"锁"的本质(一步步实现锁)
Java Synchronized实现互斥之应用与源码初探
Java 对象头分析与使用(Synchronized相关)
Java Synchronized 偏向锁/轻量级锁/重量级锁的演变过程
Java Synchronized 重量级锁原理深入剖析上(互斥篇)
Java Synchronized 重量级锁原理深入剖析下(同步篇)
Java并发之 AQS 深入解析(上)
Java并发之 AQS 深入解析(下)
Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 详解
Java 并发之 ReentrantLock 深入分析(与Synchronized区别)
Java 并发之 ReentrantReadWriteLock 深入分析
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(应用篇)
最详细的图文解析Java各种锁(终极篇)
熟练掌握线程原理与使用是程序员进阶的必经之路,网上很多关于Java线程的知识,比如多线程之间变量的可见性、操作的原子性,进而扩展出的Volatile、锁(CAS/Synchronized/Lock)、信号量等知识。有些文章只说笼统的概念、有些文章深入底层源码令人迷失其中、有些文章只说了其中某个点没有提及内在的联系。
基于以上原因,本系列文章尝试由浅入深、系统性地分析、总结Java线程相关知识,算是加深印象、夯实基础,也算是抛砖引玉。若是相关文章对各位看官有所帮助,幸甚至哉。
通过本篇文章,你将了解到:
1、进程与线程区别
2、开启/停止线程
3、线程的交互
1、进程与线程区别
程序与进程
平时所说的编写一个程序/软件,比如编写好一个APK,这个APK可以直接传送给另一个设备安装,这时候我们说发送给你一个程序/软件,是个静态的单个文件/多个文件的集合。
当安装好APK之后,运行该APK,该程序就被CPU执行了,这时候我们称这个进程在运行了。因此进程是程序的动态表现,也是CPU执行时间段的描述。
当然,程序与进程也不是一一对应关系,也就是说一个程序里可以fork()多个进程来执行任务。
进程与线程
CPU调度执行程序之前,需要准备好一些数据,如程序所在的内存区域,程序需要访问的外设资源等,程序运行过程中产生的一些中间变量需要临时存储在寄存器等。这些与进程本身关联的东西称之为进程上下文。
由此引发的问题:CPU在切换进程的过程中势必涉及到上下文的切换,切换的过程会占用CPU时间。
通俗点理解就是:进程1先被CPU调度执行,执行了一段时间后调度进程2执行,此时上下文就会切换成与进程2相关的。
再考虑另一种情形:一个程序里实现了A、B两个有关联的功能,两者在不同的进程实现,A进程需要与B进程交互,该过程就是个IPC(进程间通信)。我们知道,IPC需要共享内存或者陷入内核调用,这些操作代价比较大。
Android 进程间通信系列文章请移步:Android IPC 看了都懂系列
随着计算机硬件越来越强大,CPU频率越来越高,甚至还发展出多个CPU。为了充分利用CPU,线程应运而生。
进程被分为更小的粒度,原本一个进程要执行A、B、C三个任务,现在将这三个任务分别放在三个线程里执行。
可以看出,CPU调度的基本单位就是线程。
进程与线程关系
1、进程与线程均是CPU执行时间段的描述。
2、进程是资源分配的基本单位,线程是CPU调度的基本单位。
3、一个进程里至少有一个线程。
4、同一进程里的各个线程可以共享变量,它们之间的通信称之为线程间通信。
5、线程可以看作粒度更小的进程。
线程的优势
1、开启新线程远比开启新进程节约资源,并且更快速。
2、线程间通信比IPC简单、快捷易于理解。
3、符合POSIX规范的线程可以跨平台移植。
2、开启/停止线程
既然线程如此重要,那么来看看Java中如何开启与停止线程。
开启线程
查看Thread.java源码可知,Thread实现了Runnable接口,因此需要重写Runnable方法:run()。
#Thread.java
@Override
public void run() {
if (target != null) {
target.run();
}
}
而线程开启后执行任务的方法即是run()。
该方法里先判断target是否不为空,若是则执行target.run()。
#Thread.java
/* What will be run. */
private Runnable target;
target为Runnable类型,该引用可以通过Thread构造方法赋值。
由此看就比较明显了,要线程实现任务,要么直接重写run()方法,要么传入Runnable引用。
继承Thread
声明MyThread继承自Thread,并重写run()方法
static class MyThread extends Thread {
@Override
public void run() {
System.out.println("thread running by extends...");
}
}
private static void startThreadByExtends() {
MyThread t2 = new MyThread();
t2.start();
}
生成Thread引用后,调用start()方法开启线程。
实现Runnable
先构造Runnable,再将Runnable引用传递给Thread。
private static void startThreadByImplements() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("thread running by implements...");
}
};
Thread t1 = new Thread(runnable);
t1.start();
}
生成Thread引用后,调用start()方法开启线程。
停止线程
线程开启后,被CPU调度后执行run()方法,该方法执行完毕线程正常退出。当然也可以在run()方法执行途中退出该方法(设置标记位,满足条件即退出),该线程也将停止。若是run()方法里正在Thread.sleep(xx)、Object.wait()等方法,可以使用interrupt()方法中断线程。
private static void stopThread() {
MyThread t2 = new MyThread();
t2.start();
//中断线程
t2.interrupt();
//已废弃
t2.stop();
}
更加完整的测试说明请移步:Java “优雅”地中断线程
3、线程的交互
硬件层面
先来看看CPU和主存的交互:
CPU运算速度远远高于访问主存的速度,也就是说,当CPU需要计算如下表达式:
int a = a + 1;
首先从主存里拿到a的值,访问主存的过程中CPU是等待状态,当从主存拿到a的值后才进行运算。这个过程显然很浪费CPU的时间,因此在主存与CPU之间增加了高速缓存,顾名思义,当拿到a的值后,放到高速缓存,下次再次访问a的时候先去看看缓存里是否有,有的话直接拿到放到寄存器里,最后按照一定的规则将改变后的a的值刷新到主存里。
访问速度:寄存器-->高速缓存-->主存,CPU在寻找值的时候先找寄存器,再到高速缓存,最后到主存。
你可能已经发现问题了,如下代码:
int a = 1;
int a++;
线程A、线程B分别执行上述代码,假设线程A被CPU1调度,线程B被CPU2调度。线程A、B分别执行a = 1,此时CPU1、CPU2的高速缓存分别存放着a=1,当线程A执行a++时发现高速缓存有值于是直接拿出来计算,结果是:a=2。
当线程B执行时同样的从高速缓存获取值来计算,结果是:a=2。
最后高速缓存将修改后的值回写的主存,结果是a=2。
这样的结果不是我们愿意看到的,CPU针对此种情况设计了一套同步高速缓存+主存的机制:MESI(缓存一致性协议)
该协议约定了各个CPU的高速缓存间与主存的配合,尽量保证缓存数据是一致的。但是由于StoreBuffer/InvalidateQueue的存在,还需要配合Volatile使用。
有关Volatile详细解析请移步:真正理解Java Volatile的妙用
软件层面
由于寄存器、高速缓存的存在,让我们有种感觉:每个线程都拥有自己的本地内存。
实际上,JVM设计了JMM(Java Memory Model Java内存模型):
本地内存是个虚拟概念,如下代码:
static Integer integer = new Integer(0);
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
integer = 5;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
integer = 6;
}
});
t1.start();
t2.start();
}
integer 在主存中只有一份,可能还存在于寄存器、高速缓存等地方,这些地方对应的是本地内存。而不是每个线程又重新复制了一份数据。
再看看一段代码:
static boolean flag = false;
static int a = 0;
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1; //1
flag = true; //2
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
if (flag) { //3
a = 2; //4
}
}
});
t1.start();
t2.start();
}
若是线程1先执行完,线程2再执行,结果没问题。若是两个线程同时执行,由于//1 //2之间没有依赖关系,编译器/处理器 可能会对//1 //2交换位置,这就是指令重排。如此之后,有可能执行顺序是:2->3->4->1,还有可能是其它顺序,最终的结果是不可控的。
线程交互的核心
从上述的软件层面、硬件层面分析可知,线程1、线程2、线程3各自的本地内存对其它线程是不可见的;多个线程写入主存时可能会存在脏数据;指令重排导致结果不可控。
多线程交互需要解决上述三个问题,这三个问题也是线程并发的核心:
1、可见性
2、原子性
3、有序性
上述三者既是并发核心,也是基础,只有满足了三者,线程并发的共享变量结果才是可控的。
我们熟知的锁、Volatile等是针对三者中的某个或者全部提出的解决方案。
互斥与同步
互斥的由来
要满足并发的三个条件,想想该怎么做呢?
先来看看原子性,既然多线程同时访问共享变量容易出问题,那么想到的是大家排队来访问它,当其中一个线程(A)在访问时,其它线程不能访问,并排队等待A线程执行完毕后,等待中的线程再次尝试访问共享变量,我们把操作共享变量的代码所在的区域称为临界区,共享变量称为临界资源。
//临界区
{
a = 5;
b = 6;
c = a;
}
如上面的代码,多个线程不能同时访问临界区。
这种访问方式称为:互斥。
也就是说多个线程互斥地访问临界区可以实现操作的原子性。
同步的由来
临界区内的操作的共享变量在不同的线程可能有不一样的处理,如下代码:
//伪代码
int a = 0;
//线程1执行
private void add() {
while(true) {
if (a < 10)
a++;
}
}
//线程2执行
private void sub() {
while(true) {
if (a > 0)
a--;
}
}
线程1、线程2都对变量a进行了操作,两者都依赖a的值做一些操作。
线程1判断如果a<10,则a需要自增;线程2判断如果a>0,则a需要自减。
线程1、线程2分别不断地去检查a的值看是否满足条件再做进一步操作,这么做没问题,但是效率太低。如果线程1、线程2检查到不满足条件先停下来等待,当满足条件时由对方通知自己,这样子就不用傻乎乎地每次跑去问a是多少了,极大提升了效率。
因此,交互变成这样子:
//伪代码
int a = 0;
//线程1执行
private void add() {
while(true) {
if (a < 10)
a++;
else
//等待,并通知线程2
}
}
//线程2执行
private void sub() {
while(true) {
if (a > 0)
a--;
else
//等待,并通知线程1
}
}
这么说流程有点枯燥,我们用个小比喻类比一下:
用小明表示线程1、小刚表示线程2,小明要发一批集装箱,先把箱子拿到库房外的空地上,空地面积有限,最多只能放10个箱子,等待小刚过来拿货。
1、刚开始小刚发现空地没货,于是等待小明通知。小明发现没货,开始放货。
2、小明发现空地上还可以放箱子,于是继续放。
3、小明发现箱子已经放了10个,空地占满了,于是就休息下来不再放了,并打电话告诉小刚,我的货够了,你快点过来拿货吧。
4、小刚收到通知后,过来拿货,一直拿,当发现货拿完之后,就不再拿了,并打电话告诉小明,货拿完了,你快放货吧。
于是整个流程简述:小明放了10个箱子就等待小刚拿,小刚拿完之后通知小明继续放。值得注意的是:上述是批量放了箱子,再批量拿箱子,并没有拿一个放一个。关于这个问题,后面细说
又因为小明、小刚都依赖于箱子的个数做事,通过上面对互斥的分析,我们知道需要将这部分操作包裹在临界区里进行互斥访问。
我们把上面的交互过程称之为:同步
同步与互斥关系
可以看出,同步是在互斥的基础上增加了等待-通知机制,实现了对互斥资源的有序访问,因此同步本身已经实现了互斥。
同步是种复杂的互斥
互斥是种特殊的同步
解释了互斥、同步概念,那么该这么实现呢?
接下来系列文章将重点分析系统提供的机制是如何实现可见性、原子性、有序性的以及互斥、同步与三者的关系。
下篇文章:聊聊Unsafe的作用及其用法。
您若喜欢,请点赞、关注,您的鼓励是我前进的动力
持续更新中,和我一起步步为营系统、深入学习Android
1、Android各种Context的前世今生
2、Android DecorView 一窥全貌(上)
3、Android DecorView 一窥全貌(下)
4、Window/WindowManager 不可不知之事
5、View Measure/Layout/Draw 真明白了
6、Android事件分发全套服务
7、Android invalidate/postInvalidate/requestLayout 彻底厘清
8、Android Window 如何确定大小/onMeasure()多次执行原因
9、Android事件驱动Handler-Message-Looper解析
10、Android 键盘一招搞定
11、Android 各种坐标彻底明了
12、Android Activity/Window/View 的background
13、Android IPC 之Service 还可以这么理解
14、Android IPC 之Binder基础
15、Android IPC 之Binder应用
16、Android IPC 之AIDL应用(上)
17、Android IPC 之AIDL应用(下)
18、Android IPC 之Messenger 原理及应用
19、Android IPC 之获取服务(IBinder)
20、Android 存储基础
21、Android 10、11 存储完全适配(上)
22、Android 10、11 存储完全适配(下)