并发编程 · 基础篇(上) · android线程那些事

3,811 阅读1小时+

小木箱成长营并发编程系列教程(排期中~):

并发编程 · 基础篇(中) · 三大分析法分析Handler

并发编程 · 基础篇(下) · 三大分析法分析线程池

并发编程 · 提高篇(上) · Java并发关键字那些事

并发编程 · 提高篇(下) · Java锁安全性那些事

并发编程 · 高级篇(上) · Java内存模型那些事

并发编程 · 高级篇(下) · Java并发BATJ面试之谈

并发编程 · 实战篇 · android下载器实现

Tips: 关注微信公众号小木箱成长营,回复 "并发编程" 可免费获得并发编程思维导图

一、序言

Hello,我是小木箱,欢迎来到小木箱成长营并发编程系列教程,今天将分享并发编程 · 基础篇 · android线程那些事

android线程那些事主要分为三部分,第一部分是5W2H分析并发,第二部分是线程安全特性,第三部分是线程安全,最后一部分是结语。

其中,5W2H分析并发主要是针对并发提出了6个高价值的问题。

其中,线程基础主要分为五部分,第一部分是线程操作,第二部分是线程属性,第三部分是线程通信,第四部分是线程运行状态,最后一部分是生产者和消费者模型。

其中,线程安全主要分为五部分,第一部分是带着问题出发,第二部分是线程安全特性,第三部分是线程安全强度,第四部分是线程安全方案,最后一部分是UncaughtException兜底。

如果完全掌握小木箱成长营并发编程系列教程,那么任何人都能通过高并发相关的技术面试。

二、5W2H分析并发

首先我们聊聊并发基础的第一部分内容5W2H分析并发。我们根据5W2H法则按照What、Why、Where、How、How much五个维度提出了六个高价值问题

  • 并发是什么?

  • android为什么要用并发?

  • android哪些地方用到并发?

  • android如何实现多线程?

  • android合理使用并发有什么收益?

  • android盲目使用并发有什么风险?

下面,小木箱就带带着问题出发,带大家正式进入并发基础内容学习。

2.1 并发是什么?

传送门: Java Concurrency

首先我们聊一聊5W2H的What,Java Concurrency—并发。

并发、并行和串行

并发是指系统在同一时间段可同时处理多个任务,而同一时刻只有一个任务处于运行状态,和并发有两个接近的概念很容易被混淆,串行和并行,串行、并发和并行是相对于进程或多线程来说的。如下图是串行、并发和并行的执行时间图。

串行比较好理解,如上图所示串行是指线程A完成之后做线程B,以此类推,直到完成线程C,每个线程排队执行。下面我们着重看一下并发和并行。

并发是指一个或若干个 CPU 对多个进程或线程之间进行多路复用。简单说线程A先做Task,工作一段时间,线程B再做Task;

线程B执工作一段时间线程C再做Task,线程C工作一段时间,线程A重新执行Task。

以此类推,直到工作完成,看上去像是三个线程同时一起执行,但其实完全可以交给一个线程执行。

对于并发来说,线程A先执行一段时间,然后线程B再执行一段时间,接着线程C再执行一段时间。每个线程都轮流得到 CPU 的执行时间,并发只需要一个 CPU 即能够实现, 线程利用率最优。

并行则是指多个进程或线程同一时刻被执行,是真正意义上同时执行,并行必须要有多个 CPU 支持。

并行是A、B和C三个线程同时执行一个或多个Task,每线程负责一项Task,A、B和C三线程在同一时刻齐头并进地完成这些事情。

并行比串行和并发时间开销要小,但是由于线程A、线程B和线程C是同时执行的,需要三个 CPU 才能实现,一定程度影响机器性能。

用一句话总结就是:

串行是一个时间段内多个任务执行时,一个任务执行完才能执行另一个。

并行是指一个时间段内每个线程分配给独立的核心,线程同时运行。

而并发指的是一个时间段多个线程在单个核心运行,同一时间只能一个线程运行,系统不停切换线程,看起来像同时运行,实际上是线程不停切换。

同步和异步

除了串行、并行和并发以外,实际开发过程中,同学们经常将同步、异步混淆,下面简单对比一下同步和异步的区别。

如下图是同步和异步执行时间图,同步和异步与并发、并行、串行区别点在于同步和异步一般相对进程或多线程,而同步和异步一般是相对于线程而言的。

同步是指两个事物相互依赖,并且一个事物必须以依赖于另一事物的执行结果。比如在事物 A->B 事件模型中,你需要先完成事物 A 才能执行事物 B。

也就是说,同步调用在被调用者未处理完请求之前,调用不返回,调用者会一直等待结果的返回。

异步是指两个事物完全独立,一个事物的执行不需要等待另外一个事物的执行。也就是说,异步调用可以返回结果不需要等待结果返回,当结果返回的时候通过回调函数或者其他方式带着调用结果再做相关事情。

阻塞和非阻塞

除了同步、异步以外,实际开发过程中,同学们经常将阻塞和非阻塞混淆,下面简单对比一下阻塞和非阻塞的区别。如下图是阻塞和非阻塞执行图,

  • 所谓阻塞是发出一个请求不能立刻返回响应,要等所有的逻辑全处理完才能返回响应。简单来说就是等待。

  • 所谓非阻塞相反,发出一个请求立刻返回应答,不用等处理完所有逻辑。阻塞与非阻塞指的是单个线程内遇到同步等待时,是否在原地不做任何操作。

那么同步阻塞、同步非阻塞、异步阻塞异步非阻塞又有什么区别呢?

同步阻塞

同步阻塞是指在需要某资源时马上发起请求,并暂停本线程之后的程序,直至获得所需的资源。参考代码如下:

输出结果:

小木箱成长营

同步非阻塞

同步非阻塞是指在需要某资源时马上发起请求,且可以马上得到答复,然后继续执行之后的程序。但如果得到的不是完整的资源,之后将周期性地的请求。参考代码如下:

输出结果:

小木箱正在学习并发编程

小木箱正在学习设计模式

异步阻塞

异步阻塞是指在需要某资源时不马上发起请求,而安排一个以后的时间再发起请求。当到了那时发出请求时,将暂停本线程之后的程序,直至获得所需的资源。参考代码如下:

输出结果:

小木箱成长营说: 异步任务开始 ...

小木箱成长营说: 异步任务结束 ...

小木箱成长营说: 所有异步任务执行完毕,继续执行后续任务

异步非阻塞

异步非阻塞是指在需要某资源时不马上发起请求,而安排一个以后的时间再发起请求。当到了那时发出请求时,可以马上得到答复,然后继续执行之后的程序。

但如果得到的不是完整的资源,之后将周期性地的请求。参考代码如下:

输出结果:

This is an asynchronous non-blocking code.

至此,同步、异步、阻塞、非阻塞以及他们的组合使用,小木箱已经讲解完毕了,下面小木箱着重的聊一下android为什么要使用并发?

2.2 android为什么要用并发?

因为CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,充分利用系统资源,让多个线程在同时运行的过程中竞争资源,充分利用android操作系统处理能力,因此我们需要使用并发。

总结一下就是: 提高程序性能、改善用户体验和节省设备资源

2.3 android哪些地方用到并发?

android并发场景应用非常广泛。

如果你需要编写启动器进行启动任务管理,那么你需要了解并发。

如果你需要对大文件进行多线程下载,那么你需要了解并发。

如果你用 AsyncTask进行调度任务,那么你需要了解并发。

如果你看Handler底层源码ThreadLocal实现,那么你需要了解并发。

如果你想编写一个合规的线程池,那么你需要了解并发。

......

所以说并发对android开发来说无影随形,既然并发对android程序员来说这么重要,那么我们该如何高效率使用并发呢?

2.4 android如何实现多线程?

android实现多线程的方式大概分为四种,第一种方式是HandlerThread,第二种方式是AsyncTask,第三种方式是IntentService,第四种方式是ExecutorService

2.4.1 HandlerThread

HandlerThread定义

首先,我们聊聊第一种方式HandlerThread,android的HandlerThread是一种特殊的Thread,HandlerThread提供了一个Looper,可以用来处理消息和处理程序。

HandlerThread优势在于可以将耗时的任务分发到后台线程,从而避免UI线程的阻塞和提高应用程序的性能和流畅性。

HandlerThread底层原理

android的HandlerThread底层原理:

  1. 创建一个继承自Thread类的HandlerThread,并重写run方法;
  2. 在run方法中创建一个Looper对象,并调用Looper.prepare方法;
  3. 在Looper.prepare方法中创建一个MessageQueue,并将MessageQueue赋值给Looper对象;
  4. 调用Looper.loop方法,HandlerThread会一直从MessageQueue中取出Message,并交给Handler处理;
  5. 如果HandlerThread调用了quit方法,那么Looper.loop方法就会停止,从而结束HandlerThread的运行。

因为,HandlerThread可以使用Handler来发送和处理消息并且HandlerThread可以创建多个Handler,每个Handler可以拥有自己的线程。所以,HandlerThread可以实现多线程。

因为,HandlerThread还提供了一种机制来管理线程,所以,线程可以在合适的时候被暂停或者恢复。

HandlerThread实现方式

下面,小木箱利用HandlerThread带大家实现一下android多线程:

2.4.2 AsyncTask

然后,我们聊聊第二种方式AsyncTask,android的AsyncTask通常用于执行一些短暂的耗时操作,比如从网络获取数据,在UI线程中执行简单的计算,或者更新UI等。

AsyncTask定义

android的AsyncTask是android提供的用于实现多线程的类,AsyncTask可以实现多线程协作,异步执行后台任务,并且可以通过主线程更新UI。

AsyncTask底层原理

android的AsyncTask实现多线程底层原理是:AsyncTask创建一个新的工作线程,在工作线程中调用doInBackground方法执行后台任务,同时在主线程中调用onProgressUpdate方法更新UI界面。

当doInBackground执行完毕后,会回调onPostExecute方法,在onPostExecute方法中可以更新UI界面。

AsyncTask使用方式

下面,小木箱利用android的AsyncTask带大家实现一下android多线程:

2.4.3 IntentService

IntentService定义

接着,我们聊聊第三种方式IntentServiceIntentService是android提供的一种用于执行异步任务的服务,IntentService是一种特殊的Service,可以在单独的工作线程中处理耗时任务,并在完成后自动停止。

IntentService可以处理多个异步任务,每个任务都会在一个单独的线程中处理,因此不会阻塞UI线程,而且可以在任务完成后自动停止。

IntentService底层原理

IntentService底层原理是IntentService利用HandlerThread类来处理任务,HandlerThread内部有一个Looper,Looper会循环从消息队列中取出消息,每取出一条消息就会执行一次handleMessage方法。

在IntentService中,handleMessage方法会调用onHandleIntent方法,onHandleIntent方法就是我们要实现的任务,当任务执行完毕后,IntentService会自动停止。

IntentService使用方式

下面,小木箱带大家看一下IntentService多线程代码实现:

  • 在androidManifest.xml文件中声明一个IntentService:

  • 创建IntentService

  • 调用IntentService

2.4.4 ExecutorService

ExecutorService定义

最后,我们聊聊第四种方式ExecutorService,ExecutorService是一个接口,ExecutorService提供了一种机制,可以将任务提交给Executor,然后由Executor在后台执行任务,从而提供并发性。

ExecutorService还提供了一种机制,可以管理运行中的任务和完成的任务,以及检查任务的执行状态。

ExecutorService底层原理

ExecutorService底层原理是使用了一个线程池来管理多个线程,并且可以控制线程的数量,提供了一系列的API来提交任务,并且可以控制任务的执行,比如可以提交一个任务,可以提交一个任务序列,可以提交一个可以控制任务执行时间的任务,也可以提交一个定时任务,实现了对任务的管理和控制。

ExecutorService的工作原理是,当调用其中的submit方法时,会将任务提交到线程池中,线程池会负责将任务分配给线程,然后线程池会控制线程的数量,如果线程数量超出了限制,则会把任务放到队列中,等待空闲的线程来执行任务, 如果没有空闲的线程,则会新建一个线程来执行任务,当线程完成任务时,会从队列中取出下一个任务来执行,直到所有的任务都完成,ExecutorService才会结束。

ExecutorService使用方式

下面,小木箱带大家看一下ExecutorService多线程代码实现:

最后,小木箱对HandlerThread、AsyncTask、IntentService和ExecutorService使用场景和优缺点做一下简单的归纳总结:

类型使用场景优点缺点
HandlerThread需要在后台运行一个持续的线程,可以在线程中处理消息队列中的消息可以实现消息的传递和处理,可以定义不同的消息处理程序容易出现内存泄漏,资源消耗大
AsyncTask异步处理耗时操作操作简单,实现快捷,可以很方便的在主线程和子线程之间传递消息容易出现内存泄漏,资源消耗大,不能处理复杂的任务
IntentService后台处理长时间任务,处理结束后自动停止可以处理复杂的任务,可以实现消息的传递和处理资源消耗大,不能处理频繁的任务
ExecutorService异步处理耗时操作操作简单,可以实现消息的传递和处理,可以处理复杂的任务资源消耗大,不能处理频繁的任务

2.5 android合理使用并发有什么收益?

那么,android合理使用并发有什么收益?

当我们在使用多线程处理文件下载过程中,不颠覆原有线程池使用方式的基础之上,从降低线程池参数修改的成本以及多维度监控这两个方面可以降低故障发生的概率。

总结一下就是: 提高应用程序性能、改善用户体验、提高应用程序的可维护性和改善应用程序的可扩展性

2.6 android盲目使用并发有什么风险?

因为线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;

另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。

如果盲目使用并发会导致如下三个问题:

  1. 频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。

  2. 对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。

  3. 系统无法合理管理内部的资源分布,会降低系统的稳定性。

总结一下就是: 内存泄漏、线程安全和数据不一致。

三、线程基础

3.1 线程操作

  3.1.1 线程使用方式

创建线程有四种方式,第一种是直接 new Thread 重写Thread的run方法。

第二种是实现Runnable接口,将Runnable接口传给Thread。无论是继承Thread还是Runnable接口都无法获取任务执行结果。

如果需要获取任务执行结果,就需要使用第三种方式使用Callable和Future接口

因为历史设计的原因,Thread只接受Runnable而不接受Callable,而FutureTask是Runnable和Callable的包装,FutureTask本身是继承Runnable的,所以FutureTask可以直接传给Thread,FutureTask调用get方法就可以获取到线程执行结果。

如果FutureTask任务没有找执行完,那么FutureTask无参get会一直阻塞,FutureTask可以使用超时get,超过一定时间就返回null。

第四种方式是线程池方式,本文简单入门一下,后文会着重讲解。

  继承Thread类

输出结果:

小木箱说,当前运行的线程名为: CrazyCodingBoyThreadTest1

小木箱说,当前运行的线程名为: CrazyCodingBoyThreadTest2

  实现Runnable接口

输出结果:

小木箱说,当前运行的线程名为: CrazyCodingBoyRunnable1

小木箱说,当前运行的线程名为: CrazyCodingBoyRunnable2

  使用Callable和Future接口

输出结果:

小木箱说: 主线程在执行任务

小木箱说: Callable子线程开始计算

小木箱说: task运行结果4950

小木箱说: 所有任务执行完毕

  使用Executors类

输出结果:

index:2

index:0

index:1

  使用线程池

Executor管理多个异步任务的执行,是无需显式的管理线程的生命周期的。

输出结果:

pool-1-thread-4 Start. Command = 3

pool-1-thread-2 Start. Command = 1

pool-1-thread-3 Start. Command = 2

pool-1-thread-5 Start. Command = 4

pool-1-thread-1 Start. Command = 0

  3.1.2 启动线程

启动线程的方式有两种,第一种是start,第二种是run,其中start才是启动线程的方法,run是一个普通方法。

   3.1.2.1 start

start的线程处于就绪状态,当得到CPU的时间片后就会执行其中的run方法,具体可以看一下图示例代码,因为当执行到此处,创建了一个新的线程t并处于就绪状态,代码继续执行,打印出”ping”。此时,执行完毕。线程t得到CPU的时间片,开始执行,调用pong方法打印出”pong”。

3.1.2.2 run

通过run方法启动线程其实就是调用一个类中的方法。无需等待run方法中的代码执行完毕,就可以接着执行下面的代码。并没有创建一个线程,程序中依旧只有一个主线程,必须等到run方法里面的代码执行完毕,才会继续执行下面的代码,这样就没有达到写线程的目的。具体可以参考如下示例代码,因为t.run实际上就是等待执行new Thread里面的run方法调用pong完毕后,再继续打印”ping”。

思考1: 一个线程两次调用start方法会出现什么情况?为什么?

思考2: 既然 start 方法会调用 run 方法,为什么我们选择调用 start 方法,而不是直接调用 run 方法呢?

3.1.3 线程中断

说完启动线程,我们说一下线程中断。

什么是线程中断?当需求做到一半产品说要下线,就相当于线程中断。

什么是线程中断不了?当需求做到一半产品说要下线,但是你觉的产品SB,要继续做完,就相当于线程中断不了。

正常情况下线程执行完成自动结束。

如果运行时异常,会调用一个线程的interrupt方法来中断该线程。

如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程,中断会提前结束。

下面小木箱说一下线程人为中断的两种方式: stop和interrupt。

3.1.3.1 危险中断(不推荐)❌
  • stop

因为stop方法线程中断很危险的,如果stop方法强行线程中断,那么会使一些清理工作得不到完成,导致资源泄露。

如果线程调用stop方法后导致线程持有的锁突然释放,那么数据会呈现不一致性,对象的内部状态因此被破坏。

stop方法不会保证线程立即终止,使用stop方法可能会导致线程死锁问题。

3.1.3.2 安全中断(推荐)✔️
interrupt线程中断原理

interrupt方法是一个标识位,interrupt只是对线程打了一个“中断”的标记,并不是真正的停止线程。当线程进入到阻塞状态时,就会检查这个标记,如果被设置了,就会抛出InterruptedException,从而提前结束被阻塞状态。 如果线程处于正常活动状态时,如果检查到这个interrupt标记被设置了,那么线程将不会抛出InterruptedException,而是继续正常运行,除非线程在代码中去检查interrupt标记,然后自行决定如何处理。

interrupt线程中断实现

下面,小木箱带大家实现一下线程中断的逻辑

线程中断面试题

关于线程或线程池的中断有两个问题小木箱需要让大家思考一下。

问题一: interrupt、interrupted和isInterrupted有什么区别呢?

我们一般使用interrupted方法可以判断线程是否被中断,可以在循环体中使用interrupted方法判断条件,使用interrupt方法来提前中断线程。

问题二: 线程池是怎样中断的?

Executor的中断操作有两种: 第一种是通过shutdown方法实现。第二种是通过shutdownNow方法实现。

shutdown方法会等全部wait线程都执行完毕之后再关闭。

shutdownNow,相当于调用每个thread的interrupt方法。

3.1.4 线程切换

如果当前线程已经完成,那么我们可以利用yield切换到其他线程去执行

3.2 线程属性

线程操作小木箱说完了,接下来小木箱说一下线程属性,线程属性有三个,第一个是线程Id,第二个是线程名字,第三个是守护线程,第四个是线程优先级

首先我们看一下测试代码,分析一下线程属性:

输出结果:

true

main Thread's name is main

sub Thread's name is Thread-0

sub Thread is 22

main Thread id is 1

通过以上测试代码,我们可以得出结论:

3.2.1 线程ID

线程ID可以用来在线程之间传递消息,线程ID可以用来检查线程的状态,线程ID可以检查线程是否完成某些任务

3.2.2 线程名字

线程默认名字是0,Java中的线程名字是由Thread类的getName方法获取的,该方法返回一个字符串,表示线程的名字。

在创建线程时,可以使用Thread的构造函数来指定线程的名字,如果不指定,则系统会自动生成一个名字,格式为Thread-x,其中x是一个正整数。

3.2.3 守护线程

守护线程是程序运行在后台时提供Service的线程,当所有非守护线程结束时,程序终止,同时杀死所有的守护线程,守护线程不会占用太多的系统资源,通常会在后台运行。写测试类的时候main方法属于非守护线程,使用setDaemon方法可以将一个线程设置为守护线程 与非守护线程相比,守护线程拥有更低的优先级,并且在用户线程结束时自动结束。守护线程不能独立运行,而是需要依赖用户线程来执行任务,因此守护线程不能执行实际的任务,而只能为非守护线程提供服务。

3.2.4 线程优先级

线程优先级是指线程在多线程环境下的调度优先级,线程优先级决定了系统在多个线程之间进行调度时,哪个线程先执行,哪个线程后执行。线程优先级越高,越容易被调度,即被执行的概率越大,线程的优先级默认是5。

3.3 线程通信

实现线程协作的方式主要有四种,第一种是wait/notify/notifyAll方法。第二种是join方法。第三种是await/singal/singalAll方法。最后一种是CountDownLatch。

3.3.1 wait/notify/notifyAll

wait/ nofity/notifyAll方法是Object三个方法,详细可以参考API介绍Object有哪些公用方法?文章介绍,根据继承特性,所有Object子类都可以使用这wait/ nofity/notifyAll方法。

wait/ nofity/notifyAll方法只能在synchronized的同步代码块中使用,否则会抛出异常。

wait方法表示在其他线程调用此对象的 notify方法前,导致当前线程等待。

notify方法表示唤醒在此对象Monitor上等待的单个线程。

notifyAll方法表示唤醒在此对象Monitor上等待的所有线程。

Monitor是一种控制多线程同步互斥的机制,每一个Object实例都有一个Monitor与之相关联,每一个Monitor都有一个等待队列,

当调用wait方法时,当前线程就会进入到Monitor的等待队列中,等待被唤醒,当调用notify/notifyAll方法时,就会从Monitor的等待队列中唤醒一个或多个线程,使它们可以继续执行。

notify/notifyAll方法用于唤醒正在等待线程Monitor的线程,而Monitor则是一种控制多线程同步的机制,Monitor允许一个线程在其他线程执行操作之前或之后获得控制权,然后等待线程等到重新获得对Monitor控制权后才能继续执行。

那么线程如何成为该线程对象Monitor的控制者呢?一共有三种方法

  • 使用synchronized关键字,当一个线程获得了某个对象的锁,该线程就成为了该对象的Monitor的控制者,直到它释放了该对象的锁。
  • 使用Object.wait方法,当一个线程调用了某个对象的wait方法,该线程就成为了该对象的Monitor的控制者,直到它被唤醒或超时。
  • 使用Lock接口,当一个线程获得了某个Lock实例的锁,该线程就成为了该Lock实例的Monitor的控制者,直到它释放了该Lock实例的锁。

注意: Monitor对象是共享的。Monitor对象可以保证在同一时间只有一个线程可以访问该资源,从而避免了多线程访问该资源时可能出现的竞争条件

下面用wait 、 notify 和 notifyAll方法简单的实现一下线程协作:

输出结果:

Thread[#22,waitThreadA,5,main] wait !

Thread[#24,waitThreadC,5,main] wait !

Thread[#23,waitThreadB,5,main] wait !

Thread[#1,main,5,main] notify !

Thread[#1,main,5,main] notifyAll !

Thread[#24,waitThreadC,5,main] wait !

通过上述代码我们可以看到, 当线程A调用对象的wait方法时,线程A就会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。 当线程A调用对象的notify方法时,线程A就会唤醒等待此对象的线程B,线程B会进入对象锁定池准备获取对象锁进入运行状态。 当线程A调用对象的notifyAll方法时,线程A就会唤醒所有等待此对象的线程,这些线程都会进入对象锁定池准备获取对象锁进入运行状态。

当然wait和notify也是生产者-消费者的实现模型,具体事项细节可以参考 #5.1

3.3.2 join

然后说说第二种join方法,主线程需要获得子线程的执行结果,join方法的主要作用是等待调用该方法的线程终止。

当一个线程调用另一个线程的join方法时,调用线程将被阻塞,直到被调用的join方法所属的线程终止。

调用线程才继续执行,使所有线程都等待被join的线程终止,这样,才能确保某个线程在另一个线程之前终止。

假设有这样一个场景: 在主线程中启动了一个子线程做耗时工作,主线程会先于子线程结束, 如何主线程中获得子线程的结果?

我们可能会想到用sleep可以让主线程休眠,等子线程执行完了,再继续主线程的执行,但是休眠多久这是完全不知道的。而且sleep不会释放锁,可能会抛出InterruptedException,Future、CutDownLaunch和join都可以很方便地实现这个功能。

下面,小木箱用join方法等待线程终止去实现这个功能:

输出结果:

小木箱说: 子线程开始运行

小木箱说: 子线程运行结束

小木箱说: 主线程继续运行

join三个线程协作,小木箱用代码实现一下:

输出结果:

小木箱成长营的产品经理规划新需求

小木箱开发新需求功能

小木箱成长营的测试测试新功能

3.3.3 await/singal/singalAll

接着说说第三种是await/singal/singalAll ,在Java中,除了Object的waitnotify/notify方法可以实现等待通知机制。

java.util.concurrent类中提供的ConditionLock配合同样可以完成等待通知机制,Condition能够更加精确地控制多线程之间的协调与通信。

Condition对象关联一个锁对象,只有在获得与之关联的锁时,才能够调用Condition实例的await方法使线程等待,或者调用signal/signalAll方法发出通知唤醒等待的线程。

当一个线程调用Condition实例的await方法时,可以指定等待的条件,Condition就会释放与之关联的锁,同时进入等待状态,直到其它线程调用Condition实例的signal/signalAll方法时,该线程才会从等待状态中唤醒,并重新获得与之关联的锁。

下面用Condition实现三个线程依次打印ABC逻辑:

输出结果:

ABC

ABC

ABC

3.3.4 CountDownLatch

最后说说第三种CountDownLatch,CountDownLatch底层原理是利用可重入锁ReentrantLock和条件变量Condition,同时还有一个计数器count。

当count的值大于0时,表示还有任务没有完成,await方法会被阻塞;当count的值等于0时,表示所有任务已经完成,await方法会返回。

countDown方法会将count减1,当count减至0时,会唤醒await方法返回。

CountDownLatch适用场景,是用来进行多个线程的同步管理,线程调用了countDownLatch.await 之后,需要等待countDownLatch的信号countDownLatch.countDown ,在收到信号前,CountDownLatch不会往下执行。 下面,小木箱用代码实现三个线程依次打印ABC

输出结果:

小木箱成长营A

小木箱成长营B

小木箱成长营C

3.4 线程运行状态

线程运行状态图

线程通信小木箱说完了,接下来我们聊一下线程运行状态,线程运行状态可以参考以下线程运行状态图以及相关参数定义

线程运行状态

  • Time waiting(睡眠)

    • Thread.sleep
  • Waiting(等待)

    • 定义

      • 等待其他thread显式的唤醒,否则不会被分配CPU时间进入方法
    • 形式

      • object.wait
      • Thread.join
      • LockSupport.park
  • Blocked(阻塞)

    • 等待获取一个排它锁,如果其他thread释放了lock就会结束此状态

线程挂起/恢复

挂起线程是指把正在运行的线程暂停,线程暂停后,线程处于阻塞状态,不会消耗CPU资源,但是线程的状态仍然是RUNNABLE,只是没有被调度到CPU上执行。

恢复线程是指把挂起的线程重新调度到CPU上执行,线程恢复后,线程处于就绪状态,可以被调度到CPU上执行,消耗CPU资源。

线程挂起/恢复方法有: join与sleep,wait与notify两组方法。

首先我们来说一下join与sleep,join线程是指用线程对象调用,如果在一个线程A中调用另一个线程B的join方法,那么线程A将会等待线程B执行完毕后再执行。

如果在A线程的代码中调用了join,那么线程A会被挂起直至线程b运行完为止才会继续运行。

我们有序调用notify和wait,先执行wait,再执行notify,就不会像suspend和resume一样产生死锁:

3.5 生产者消费者模型

线程运行状态小木箱说完了,接下来我们聊一下生产者消费者模型,生产者消费者模式是通过一个线程容器来解决生产者和消费者的强耦合问题。

常见的方式有wait / notify方法、 await / signal方法 、 BlockingQueue阻塞队列方法和Semaphore方法

3.5.1 wait/notify

wait/ notify实现底层原理解析参照3.1

下面,小木箱用wait/ notify实现一下生产者消费者模型代码:

输出结果:

小燕子 --> 女

小木箱 --> 男

小燕子 --> 女

小木箱 --> 男

3.5.2 ReentrantLock

ReentrantLock是锁的另一种表现形式,因为JVM天生就支持synchronized,ReentrantLock不是所有JDK版本都支持,而且synchronized不用担心没有释放锁导致死锁问题,JVM会确保锁的释放,因此除非下列情况建议使用ReentrantLock,否则我们一律使用synchronized实现线程同步

  • ① 如果你想更好的处理死锁,那么ReentrantLock提供了可中断的锁申请
  • ② 如果你想实现更复杂的线程同步,更好控制notify哪个线程,那么ReentrantLock提供了wait/notify/signal更多的方法,并结合Condition对ReentrantLock高级应用,支持多个条件变量
  • ③ 如果你想实现更精确的线程控制,例如每个到来的线程都将排队等候,那么ReentrantLock具有公平锁功能可以帮助到你
  • ④如果你想更好的实现多层线程同步,那么建议你利用ReentrantLock可重入锁能力

下面,小木箱用ReentrantLock实现一下生产者消费者模型代码:

ReentrantLock(实现lock接口)相对于synchronized多了三个高级功能:

高级功能1: 等待可中断

ReentrantLock第一个高级功能是ReentrantLock等待可中断,ReentrantLock类提供了一个lockInterruptibly方法,lockInterruptibly方法可以让一个线程在等待锁的过程中响应中断。

ReentrantLock等待可中断实现代码如下:

高级功能2: 公平锁

ReentrantLock第二个高级功能是ReentrantLock具有公平锁,公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到的原则,只有等前面的线程释放了锁,后面的线程才能获取到锁。 非公平锁是指多个线程获取锁的顺序没有任何规则,任何一个线程都有可能获得锁,和先来后到没有任何关系,这样可能导致某些线程一直拿不到锁,结果也就是不公平的了。

synchronized只能是非公平锁,而ReentrantLock既支持公平锁也支持非公平锁。

ReentrantLock非公平锁

ReentrantLock公平锁

高级功能3: ReentrantLock + Condition

ReentrantLock第三个高级功能是ReentrantLock可以绑定多个Condition通过多次newCondition可以获得多个Condition对象,简单的实现复杂的线程同步

3.5.4 BlockingQueue

BlockingQueue实现主要用于生产者-消费者队列,但BlockingQueue另外还支持 Collection 接口。

BlockingQueue是线程安全的,所有排队方法都可以使用内部锁或其他形式的并发控制来自动达到排队方法的目的。

BlockingQueue以四种形式出现,对于不能立即满足但可能在将来某一时刻可以满足的操作,BlockingQueue的四种形式处理方式不同:第一种是抛出一个异常,第二种是返回一个特殊值(null 或 false,具体取决于操作),第三种是在操作可以成功前,无限期地阻塞当前线程,第四种是在放弃前只给定最大时间限制内阻塞。

下面,小木箱用BlockingQueue实现一下生产者消费者模型代码:

3.5.5 Semaphore

Semaphore底层原理是基于信号量底层原理,Semaphore是一种用于控制进程或线程访问共享资源的系统调用。

Semaphore通过计数器来统计可以访问共享资源的进程或线程的数量,当计数器的值大于0时,表示有可用的资源,允许进程或线程访问共享资源;

当计数器的值等于0时,表示没有可用的资源,不允许进程或线程访问共享资源。

Semaphore提供了P(Proberen)和V(Verhogen)两种操作,P操作使计数器减1,V操作使计数器加1。

下面,小木箱用Semaphore实现一下生产者消费者模型代码:

3.5.6 PipedInputStream / PipedOutputStream

PipedInputStream / PipedOutputStream两个类位于java.io包中,PipedInputStream / PipedOutputStream是解决同步问题的最简单的办法,一个线程将数据写入管道,另一个线程从管道读取数据,PipedInputStream / PipedOutputStream便构成了一种生产者/消费者的缓冲区编程模式。

PipedInputStream/PipedOutputStream只能用于多线程模式,PipedInputStream / PipedOutputStream用于单线程下可能会引发死锁。

在生产者和消费者之间建立一个管道,从结果上看出也可以实现同步,但一般不使用,因为缓冲区不易控制、数据不易封装和传输。

下面,小木箱用PipedInputStream/PipedOutputStream实现一个生产者和消费者模型:

四、线程安全

说完线程基础,我们聊一聊线程安全,线程安全首先有六个问题需要大家一起思考

4.1 带着问题出发

4.1.1 什么是线程安全?

第一个问题是什么是线程安全?

《Java Concurrency In Practice》的作者Brian Goetz说过,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的

通俗一点来说: 线程安全是指多线程访问同一个资源时,不会因为线程交叉执行而导致资源混乱,从而保证程序的正确性。

4.1.2 你知道有哪些线程不安全的情况?

第二个问题是你知道有哪些线程不安全的情况?

小木箱从android、容器和线程池三个方面举例说明线程不安全的情况。

首先,在android多线程编程中,如果多个线程同时访问同一个Activity里的资源,也可能导致线程不安全的情况,因为Activity是一个单例,它的资源可能被多个线程同时访问。

上面的代码中,MyActivity类中定义了一个counter变量,并且在onCreate方法中启动了两个线程,两个线程都会更新counter变量,但是由于没有进行同步操作,这两个线程可能会同时访问counter变量,从而导致线程不安全的问题,从而可能引发Java异常。

为了解决这个问题,可以在更新counter变量时使用同步操作,例如使用synchronized关键字:

然后,在android UI操作并不是线程安全的,并且这些操作必须在UI线程执行,子线程是无法更新UI的,具体实现思路如下:

但这并非绝对的,子线程其实也是可以更新UI的

Toast本质是通过window显示和绘制的,而子线程不能更新UI在于ViewRootImpl的checkThread方法无法在Activity维护View树的行为。

然后,HashMap的putVal方法添加元素不是线程安全的,因此可通过Collections类的静态方法synchronizedMap获得线程安全的HashMap

最后,在ThreadPoolExecutor中,addWorker为什么需要持有mainLock,本质原因是workers是HashSet类型的,不能保证线程安全。

4.1.3 怎么避免线程安全问题?

第三个问题怎么避免线程安全问题?当存在多个线程协作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,保证线程安全的方式有三种。

第一种是使用线程安全的类,如AtomicInteger类;

第二种是加锁排队执行,如synchronized和ReentrantLock等使用;

第三种是使用线程本地变量,如ThreadLocal来处理。

后文会详细讲解实现过程和原理。

4.1.4 多线程会带来哪些线程安全问题?

多线程会带来哪些线程安全问题?多线程会带来四个线程安全问题。第一个问题是原子性问题,第二个问题是竞争条件,第三个问题是死锁,第四个问题是丢失更新。

首先我们来说说第一个问题原子性问题,某些操作不能被中断,比如赋值操作,如果多线程同时对同一变量进行赋值操作,会导致变量的值不正确。比如多线程同时访问 i++ 的场景:

如下图异常代码所示,输出结果可能不是按顺序输出打印的,因为多线程同时对count变量进行赋值操作,可能会出现线程安全问题。为了解决这个问题,可以使用同步代码块,保证变量的操作是原子性的

image.png

输出结果:

小木箱:1

小木箱:3

CrazyCodingBoy:2

小木箱:4

CrazyCodingBoy:5

小木箱:6

CrazyCodingBoy:7

CrazyCodingBoy:8

那么正确的编码是怎样的呢,我们应该使用Atomic原子类,可以保证count变量的操作是原子性的

import java.util.concurrent.atomic.AtomicInteger;

//正常代码
public class AtomicDataThread implements Runnable{
    private static AtomicInteger count = new AtomicInteger(0);
    @Override
    public void run(){
        for(int i=0; i<4; i++){
            count.incrementAndGet();
            System.out.println(Thread.currentThread().getName() + ":" + count);
        }
    }

    public static void main(String[] args){
        AtomicDataThread atomicDataThread = new AtomicDataThread();
        Thread thread1 =  new Thread(atomicDataThread,"小木箱");
        Thread thread2 =  new Thread(atomicDataThread,"CrazyCodingBoy");
        thread1.start();
        thread2.start();
    }
}

输出结果:

20

第二个问题是竞争条件,当多个线程同时访问某个变量时,某个线程修改了变量的值,但是其他线程没有读取到修改后的值,导致程序出现错误,如下面的代码所示:

输出结果:

Thread-1 : 2

Thread-3 : 4

Thread-0 : 1

Thread-4 : 5

Thread-2 : 3

上述代码中,5个线程同时访问count变量,并且在run方法中将count变量加1,但是由于多线程的存在,有可能某个线程修改了count变量的值,而其他线程还没有读取到修改后的值,这就可能导致程序出现错误。

为了解决这个问题,可以使用synchronized关键字来对count变量进行同步操作:

输出结果:

Thread-0:1

Thread-4:2

Thread-3:3

Thread-2:4

Thread-1:5

第三个问题是死锁,多个线程互相等待对方释放某个资源,导致程序无法继续执行。

假设有两个小木箱线程P1和小木箱线程P2,它们分别需要资源A和资源B。

但是它们同时只能获得一个资源,当小木箱线程P1获得资源A时,P2获得资源B,然后小木箱线程P1等待资源B,小木箱P2等待资源A。

但是由于资源A和资源B只有一个,所以小木箱线程P1和小木箱线程P2都无法获得自己想要的资源,这就是死锁。

死锁的原因是由于小木箱线程P1和小木箱线程P2同时请求资源A和资源B,而资源A和资源B只有一个。

所以小木箱线程P1和小木箱线程P2都无法获得自己想要的资源,从而导致死锁的发生。

输出结果:

Thread[#23,小木箱线程P2,5,main]get ResB

Thread[#22,小木箱线程P1,5,main]get ResA

Thread[#23,小木箱线程P2,5,main]waiting get ResA

Thread[#22,小木箱线程P1,5,main]waiting get ResB

最后一个问题是丢失更新。

丢失更新是指当多个线程同时访问某个变量时,某个线程修改了变量的值。

但是其他线程没有读取到修改后的值,导致程序出现错误。

主要原因是多个线程同时访问某个变量,而该变量没有被同步,导致其他线程读取到的值不是最新的值。

输出结果:

count=10000

解决方案有三种,第一种是使用synchronized关键字,使用synchronized关键字可以解决多线程访问变量时出现的线程安全问题。第二种是使用Atomic类,Atomic类提供了一种原子操作,可以解决多线程访问变量时出现的线程安全问题。第三种是使用volatile关键字,volatile关键字可以保证多线程访问变量时,可以读取到最新的值。

4.1.5 线程安全问题体现是怎样的?

线程编程会带来性能问题呢?主要有两个方面,第一个方面是线程调度,第二个方面是线程协作。

首先,我们说说第一个方面,线程调度具体体现是缓存失效带来性能问题。下面代码存在线程不安全的问题,因为多个线程同时对SUM变量进行操作,可能会出现脏读、脏写等问题。

输出结果:

199269

可以使用synchronized关键字加锁来解决。

输出结果:

200000

由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。

可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,因为CPU读写缓存速度低于内存读写缓存速度,原有缓存很可能失效,需要重新读写缓存新数据,造成一定的开销。

因此,线程调度器为了避免频繁地发生上下文切换,有四种解决方式,分别是减少线程的数量、 调整线程优先级、 整线程优先级、调整时间片大小和使用预取策略。

减少线程的数量方面,我们可以减少线程的数量,可以减少上下文切换的发生次数,这样可以提高效率。

调整线程优先级方面,我们在线程调度器中,可以通过调整线程的优先级来控制上下文切换次数,使计算机系统能够更高效地运行。

调整时间片大小方面,我们在线程调度器可以通过调整时间片大小来控制上下文切换的发生次数,以提高系统效率。

使用预取策略方面,我们在线程调度器可以使用预取策略来减少上下文切换次数,以提高系统效率。

首先,我们说说第二个方面线程协作导致线程不安全的情况。

因为主线程在设置running变量为false之前,另一个线程可能已经读取了running变量的值并将它设置为true。这样,循环就会一直运行,导致线程不安全的情况。

为了解决这个问题,可以使用synchronized关键字来确保变量running的原子操作:

因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中。

这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,间接降低了性能。

4.1.6 怎样获取子线程的结果?

第五个问题为怎样获取子线程的结果?获取子线程的结果有两种方式,第一种方式是FutureCallable,第二种方式是Callable接口。

因为Runnable没有具体返回值,也不能抛出checked Exception。

如果我们想要拿到线程执行结果,那么建议使用Future和Callable方式。

Future是一个存储器,Future存储了call0这个任务的结果,而这个任务的执行时间是无法提前确定的。

因为这完全取决于call方法执行的情况,通过Future.get来获Callable取接口返回的执行结果。

输出结果:

如果我们想取消任务的执行,我们可以调用cancel方法。

  1. 如果这个任务还没有开始执行,那么这种情况最简单,任务会被正常的取消,未来也不会被执行,方法返回true。
  2. 如果任务已完成,或者已取消,那么cancel方法会执行失败,方法返回false。
  3. 如果这个任务已经开始执行了,那么这个取消方法将不会直接取消该任务,而是会根据我们填的参数。 mayInterruptIfRunning。

使用Future的注意点有两个。

第一个是当for循环批future量获取的结果时,容易发生一部分线程很慢的情况,get方法调用timeout时应使用限制。

第二个是生命周期只能前进,不能后退。就和线程池的生命周期一样,一旦完全完成了任务,Future就永久停在了“已完成”的 状态,不能重头再来。


Callable比较简单了,类似于Runnable,被其它线程执行的任务,实现call方法,有返回值

输出结果:

小木箱说: 我是call的返回值

4.1.7 什么是多线程的上下文切换?

第六个问题什么是多线程的上下文切换?在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核等等,但线程数可能达到成百上千个。

这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。

而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。

但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。

那么什么情况会导致密集的上下文切换呢?如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么程序就可能需要更多的上下文切换,上下文切换导致了更大的开销,我们应该尽量避免这种情况的发生。

4.2 线程安全特性

带着问题出发小木箱说完了,接下来我们聊一下线程安全特性,无论是缓存失效、还是上下文切换带来的时序性问题和线程调度引发的数据准确性问题。

在深入理解Java虚拟机那本书统一归纳总结为线程安全的三大特性: 原子性、有序性和可见性。

4.2.1 可见性

当线程 A在CPU1上执行,线程 B在CPU2上执行,共享变量param=0,线程 A给共享变量param赋值时,会把param的初始值加载到CPU1的高速cache中,然后赋值2,线程 B给共享变量param赋值时,会把param的初始值加载到CPU2的高速cache中。此时param的值在CPU2中是0而不是2。线程 A在CPU1中修改了param但是CPU2中的线程 B却没有拿到。

保证线程可见性的方式有三种,第一种是提供volatile关键字保证可见性。

当一个变量被volatile修饰时,保证修改的值会被立刻更新到主内存中,当其他线程读取时,会去主内存中读取新值。

第二种是synchronized。第三种是Lock。

lock和synchronized因为同一时间只有一个线程执行,锁释放之前会把变量的修改刷新到主内存中.

使用volatile关键字可以确保变量可见性。当一个线程对volatile变量进行写操作时,会导致其他线程对该变量的读操作立即可见

使用synchronized关键字可以确保变量可见性。当一个线程访问一个对象的synchronized方法或者synchronized块时,其他线程对该对象的其他synchronized方法或者synchronized块的访问将被阻塞,直到访问线程离开synchronized方法或者synchronized块时,其他线程才可以访问该对象的其他synchronized方法或者synchronized块。

这样一来,synchronized关键字可以保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,从而也就保证了操作的可见性,即一个线程修改了某个共享变量的值,这新值对其他线程来说是可见的。

ReentrantLock通过使用内部的可重入锁来保证可见性。当线程获得锁时,它会同步内存,确保所有线程都能看到最新的状态。当线程释放锁时,ReentrantLock会将最新的状态写回主内存,确保其他线程能够看到最新的状态。

那么保证线程可见性的本质是什么呢? Happens-Before原则 ,什么是Happens-Before原则呢? JMM向程序员提供了足够强内存可见性保证,不影响程序执行结果情况,可见性保证并一定存在,比如下面的程序,A Happens-Before B 并不保证,因为其不影响程序执行结果;

JMM为了满足编译器和处理器的约束尽可能少

Happens-Before遵循的规则是:只要不改变程序的执行结果,编译器和处理器想怎么优化就怎么优化。

Happens-Before核心思想是: 前一个操作的结果对后续操作时可见的

Happens-Before目的是: 为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

4.2.2 有序性

JMM (Java Memory Model) 有序性问题是指在多线程编程中,由于编译器和处理器优化,导致程序执行顺序与代码顺序不一致的问题。JMM 为程序员提供了一种可靠的机制来确保程序的正确性,从而避免出现不可预料的结果。

因为JMM定义了一种内存模型,该模型定义了线程之间的内存访问顺序,但是由于处理器的指令重排序,导致线程之间的内存访问顺序可能会发生改变。

这就导致了线程之间存在有序性问题,从而出现了程序的不一致性。

JMM有序性问题可以通过使用使用synchronized关键字、使用Lock锁和使用Atomic类来解决: 使用synchronized关键字可以确保每次对变量的读写操作都是从主内存中读取最新的值,从而保证线程间变量的一致性。

当一个线程访问一个对象的synchronized代码块时,其他线程便不能访问该对象的其他synchronized代码块。

Lock锁可以确保每次对变量的读写操作都是从主内存中读取最新的值,从而保证线程间变量的一致性。当多个线程访问同一个ReentrantLock对象时,多个线程会按照获取锁的顺序依次获取锁,而不会发生竞争,从而保证线程安全。

ReentrantLock使用一个可重入的锁来确保线程按照顺序访问共享资源。ReentrantLock使用FIFO(先进先出)的锁队列来管理等待的线程,从而保证线程按照它们发出请求的顺序来访问共享资源。

Atomic类提供了一种可以实现原子操作的方法,从而保证线程间变量的一致性。Atomic还可以通过使用内存屏障来保证操作的有序性,内存屏障可以确保操作在特定时间点完成,从而保证操作的有序性。

在实际开发过程中,Android中存在多个进程,多个进程间通过IPC进行数据交互,如果没有同步机制,会出现JMM有序性问题。

4.2.3 原子性

JVM原子性是指Java虚拟机中的指令是原子的,也就是说,它们不能被其他线程中断或改变。这意味着,当一个线程正在执行某个指令时,其他线程就不能改变它。这确保了程序的正确性,避免了线程之间的竞争条件。

JVM通过使用synchronized关键字、使用Atomic类、使用Lock锁和使用CAS算法来确保原子性。

使用Synchronized关键字可以保证在同一时刻只有一个线程可以执行某个方法或某个代码块,Synchronized可以保证操作的原子性,即操作过程不可被中断,Synchronized使用的是互斥锁机制,能够保证同一时刻只有一个线程可以访问某个资源,从而保证了操作的原子性。

使用Atomic类的方法可以保证操作的原子性,即每次操作都是不可中断的,也就是说在一个线程进行操作的过程中,其他线程不能中断或插入,只有当前线程完成操作后,其他线程才能进行操作。

使用ReentrantLock可以保证原子性,因为ReentrantLock使用了一个可重入的锁,ReentrantLock可以保证操作的原子性,即一个操作必须在另一个操作完成之前完成。ReentrantLock还可以通过使用比较和交换(CAS)技术来确保原子性,从而保证操作的一致性和正确性。

使用CAS算法可以通过比较并交换操作来确保原子性。当多个线程同时尝试修改某个变量时,CAS算法会检查变量当前值是否与预期值相等,如果相等,就修改变量的值,否则就不做任何修改。这种方式可以确保多个线程同时修改变量时,只有一个线程的修改能够成功,从而保证了原子性。

简单总结一下:

如果多线程访问同一块资源时候,你想要保证资源的可见性,那么小木箱建议你使用volatile、synchronized、ReentrantLock和Atomic

如果多线程访问同一块资源时候,你想要保证资源的有序性,那么小木箱建议你使用synchronized和ReentrantLock

如果多线程访问同一块资源时候,你想要保证资源的原子性,那么小木箱建议你使用synchronized、Atomic、ReentrantLock和CAS算法

4.3 线程安全强度

线程安全特性小木箱说完了,接下来我们聊一下线程安全强度,线程安全强度有三大特征: 第一,线程不可变。第二,绝对线程安全。第三,相对线程安全。第四,线程隔离。

线程不可变

线程不可变是指一个线程实例的状态在它被创建之后不能被改变的概念。也就是说,一旦一个线程被创建,它的属性例如它的优先级,名字等都不能被改变。

不可变的object一定是线程安全的,因为线程的状态是由操作系统内核控制的,操作系统内核不允许线程的状态发生变化。

使用final基础数据类型、string、枚举类型、Number部分子类和集合类型Collections.unmodifiableXXX()获取不可变集合都可以保证线程不可变

绝对线程安全

绝对线程安全是指在多线程环境下,任何时刻都不会出现数据不一致的情况,也就是说不管多少个线程同时对同一个数据进行操作,最终结果都是一致的,一个线程对数据的改变,其他线程都能看到,也就是说绝对线程安全是指多个线程同时对数据进行操作时,数据的一致性是绝对保证的。

绝对线程安全的实现,可以降低系统出现线程安全问题的可能性,提高系统的稳定性和可靠性。

不管运行环境如何,调用者都不需要任何额外的sync操作,比如:ATM取钱,怎么去的和取完之后怎么拿走不影响取钱这个业务安全

相对线程安全

相对线程安全是指在多线程环境下,有一定的约束条件下,不会出现数据不一致的情况,也就是说在满足一定的条件下,多个线程同时对数据进行操作时,数据的一致性是相对保证的。

保证对这个object单独的操作是thread安全的;但是对一些特定顺序的连续调用,需要额外的手段来保证,java中的大部分thread安全类都属于这种类型,比如:Vector、HashTable、Collections、synchronizedCollection()方法包装的集合

线程隔离

线程隔离指对象本身不是thread安全的,但是可以在调用端采用正确的同步手段来保证对象在并发环境中安全的使用。

为了提高系统的并发性能,减少线程之间的竞争。 为了提高系统的安全性,防止一个线程对其他线程造成损害。为了提高系统的稳定性,防止一个线程中断其他线程的执行。为了提高系统的可维护性,防止一个线程影响其他线程的运行。JVM对各个线程进行独立隔离

线程对立

无法在多线程环境中并发使用的代码,java中几乎没有,因为会导致一种不可控制的状态,从而使系统处于不稳定的状态。

线程对立的目的是为了保护多个线程之间的共享资源,避免不同线程之间的数据竞争,从而防止程序出现数据不一致的问题。

4.4 线程安全方案

线程安全强度小木箱说完了,接下来我们聊一下线程安全方案,小木箱将上述的安全策略归纳总结为四大类,互斥阻塞同步、非阻塞同步、无同步方案和控制并发流程

4.4.1 互斥阻塞同步

阻塞线程执行达到同步的目的

  synchronized

按照锁的使用位置,synchronized锁的类型有四种: 第一种是对象锁,第二种是方法锁,第三种是类锁,第四种是静态方法锁

第一种是对象锁,synchronized(this),同步一个代码块,只作用于一个对象,如果调用两个对象的同步代码块方法,不会同步,可以用来保护一个对象的实例方法,使得多个线程可以同时访问,但是同一时刻只能有一个线程可以执行该方法,以避免多线程环境中数据出现错误。

第二种是类锁,synchronized(Student.class),同步一个类,对这个类的所有对象同步,可以用来保护一个类的静态方法,使得多个线程可以同时访问,但是同一时刻只能有一个线程可以执行该方法,以避免多线程环境中数据出现错误。

第三种是方法锁,synchronized method,同步一个方法,作用同上,只作用于一个对象,当多个线程访问同一个对象的实例方法时,它们会被同步,以保证线程安全。

第四种是静态方法锁,synchronized static method,同步静态方法,作用同步一个类,当多个线程访问同一个类的静态方法时,它们会被同步,以保证线程安全。   新版本jvm对synchronized进行了很多优化,例如自旋锁等,synchronized的性能略微比ReentrantLock差一点

  独占锁Reentrantlock、共享锁CountDownLatch、CyclicBarrier、Phaser、Semaphore等
4.4.2 非阻塞同步

非阻塞同步主要解决线程等待、切换带来的性能问题。基于冲突检测的乐观并发策略:先操作,如果没有其他线程竞争就直接成功;否则采取补偿措施不断重试,直到成功;底层需要硬件指令集支持。

CAS(compare and swap) 和原子类AtomicInteger的方法compareAndsSet()、getAndIncrement()都是非阻塞同步表现形式

4.4.3 无同步方案

 所谓的无同步方案就是控制并发流程

  控制并发流程定义

控制并发流程是指保证线程安全,不一定要同步,如果一个method不涉及共享数据,就无须同步。

控制并发流程特征
  • 特征一: 栈封闭

多个thread访问同一个method的局部变量时,不会出现thread安全问题,因为局部变量存储在虚拟机栈中,属于thread私有的

  • 特征二: 线程本地存储(Thread Local Storage)

如果一段代码中的数据必须与其他线程共享,那就保证共享数据代码在同一个thread里面执行,无须同步也能保证thread不会出现数据争用的问题

可以使用ThreadLocal类实现thread本地存储功能

  • 特征三: 可重入代码

可以在代码执行的任何时刻中断,转而执行另外一段代码,原来的程序不会出现任何错误

怎样控制并发流程

Java控制并发可以使用Cyclicbarrier和RxJava来控制并发流程。下面着重来讲解Cyclicbarrier,Cyclicbarrier是一种同步工具类,Cyclicbarrier允许一组线程相互等待,直到到达某个公共屏障点(common barrier point)。

当所有线程都到达屏障点时,屏障点才会打开,所有线程才能继续执行。 Cyclicbarrier可以用来实现多线程之间的协作,比如说,玩家在游戏中到达某个关卡时,所有玩家都要到达某个位置,然后才能继续游戏,这时候就可以使用Cyclicbarrier了。 Cyclicbarrier底层使用AQS(AbstractQueuedSynchronizer)实现,AQS是一种基于FIFO队列实现的锁机制,AQS通过一个int变量来表示同步状态,当状态为0时,表示无锁状态,当状态大于0时,表示有锁状态。

Cyclicbarrier在内部维护了一个计数器,每当一个线程到达屏障点时,计数器的值就会减1,当计数器的值减为0时,表示所有线程都已经到达屏障点,此时就会打开屏障,所有线程继续执行。

Cyclicbarrier优点有两个,第一个是Cyclicbarrier可以实现让一组线程等待至某个状态之后再全部同时执行。 第二个是Cyclicbarrier可以复用,当线程到达屏障点后,计数器会重置为初始值,这样就可以多次使用Cyclicbarrier了。

Cyclicbarrier缺点有两个,第一个是当某个线程超时或者被中断时,整个系统都会受到影响,因为其他线程都会被阻塞。第二个是如果线程太多,可能会导致计数器溢出。

简单总结一下就是:

如果你想保证线程不可变,那么小木箱建议你使用String、Integer 、volatile和 ConcurrentHashMap

如果你想保证线程相对安全,那么小木箱建议你使用mutex、semaphore、lock、局部变量、可重入函数、非阻塞算法和Vector等

如果你想保证线程处于隔离状态,那么小木箱建议你在Linux使用Namespace机制、使用锁和ThreadLocal

如果你想确保绝对的线程对立,那么小木箱建议你使用原子操作、线程池访问权限管控、同步锁、volatile关键字和信号量

如果你想确保绝对的线程安全,那么可以使用原子操作、同步锁、volatile关键字和ConcurrentHashMap

4.5 UncaughtException兜底

最后,我们尝试回答一个问题: 线程的未捕获异常UncaughtException应该如何处理?

当线程抛UncaughtException,我们可以利用UncaughtExceptionHandler处理,因为主线程可以轻松发现异常,子线程却不行。

在子线程抛出了异常会被主线程覆盖,子线程异常无法用传统方法捕获子线程抛出异常。

主线程try catch没用,只能捕获当前线程的异常,不能直接捕获所有异常,因此UncaughtExceptionHandler提高了代码健壮性。

输出结果: 小木箱成长营捕获了Thread-0线程的异常

这样,我们可以全局为不健康的线程进行兜底管控。

五、结语

并发编程 · 基础篇(上) · android线程那些事课程就此结束了,对于每一个Android开发者来说,线程知识重要性不言而喻,国内为什么老八股喜欢考线程知识,因为如果你不具备这方面扎实的线程安全和线程基础知识,那么应对高性能下载组件实现还是处理启动和卡顿优化等工作都非常棘手。

下一节,小木箱将带大家学习并发编程 · 基础篇(中) · 三大分析法分析Handler。

我是小木箱,如果大家对我的文章感兴趣,那么欢迎关注小木箱的公众号小木箱成长营。小木箱成长营,一个专注移动端分享的互联网成长社区。

参考资料