注册

基础巩固——多线程

多线程编程

Android沿用了Java的线程模型,一个Android应用在创建时会开启一个线程,常称作主线程,也叫做UI线程,如果有请求网络等耗时操作时,就需要开启子线程去处理。因此,此文对多线程进行梳理总结


线程基础

1. 进程与线程

这两个的区分在我的另一篇文章# Android面向面试复习-操作系统+计网篇中已经提及,简单复习一下。进程是操作系统结构的基础,是程序在一个数据集合上运行的过程,是系统进行资源分配和调度的基本单位。进程可以看作程序的实体,也是线程的容器。线程是操作系统调度的最小单元,一个进程中可以创建多个线程。这些线程拥有各自的计数器,堆栈,局部变量等属性,并且能够访问共享的内存变量


2. 线程的状态

Java线程在运行的生命周期可能会处于六种不同的状态,如下

  • New:新创建状态,线程被创建,还没有调用start方法,运行之前还有一些基础工作
  • Runnable:可运行状态,一旦调用start方法,就会处于Runnable状态。处于这个状态的线程可能正在运行,也可能没有,取决于操作系统的调度
  • Blocked:阻塞状态,表示线程被锁阻塞,暂不能活动
  • Waiting:等待状态,线程暂时不活动,并且不运行任何代码,这消耗最少的资源,知道调度器重新激活这个线程
  • Timed Waiting:超时等待,可以在指定的时间自行返回
  • Terminated:终止状态,表示当前线程已经执行完毕,比如run方法正常执行退出,或者因为没有被捕获的异常而终止

3. 创建线程

  • 继承Thread类,重写run方法
  • 实现Runnable接口,重写run方法
  • 实现Callable接口,重写call方法

4. 理解中断

当线程的run方法执行完毕,或者方法里出现没有捕获的异常时,线程就要终止。早期Java版本中有stop方法可以终止线程,现在已经被弃用。现版本用interrupt来中断线程,当一个线程调用interrupt方法时,它的中断标志位将被置为true。线程会时不时的检测这个中断标记位,以判断线程是否应该被中断,要想知道线程是否被置位,可以调用isInterrupted方法查看返回值。还可以调用静态方法interrupted来对中断标志位进行复位。但是如果一个线程被阻塞,就无法检测中断状态。如果一个线程处于阻塞状态,那么线程在检查中断标志位时若发现中断标志位为true,就会在阻塞方法调用处抛出阻塞异常,并且在抛出异常前将线程中断标志位复位,即重新设置为false。需要注意的是被中断的线程不一定会终止,中断线程是为了引起线程的注意,被中断的线程可以决定如何响应中断。如果是比较重要的线程,则不会理会中断。而大部分情况是线程会将中断作为一个终止的请求。另外,不要在底层代码里捕获InterruptedException不做处理,这里介绍两种合适的处理方式

  1. 在catch子句中,调用Thread.currentThread().interrupt()来设置中断状态。因为在抛出异常后中断标志位会复位,让外界通过判断isInterrupted()来决定是终止还是继续下去
void test(){
try{
sleep(50);
}catch(InterruptedException e){
Thread.currentThread().interrupt();
}
}
  1. 更好的做法是直接抛出异常,方便调用者捕获
void test() throw InterruptedException{
sleep(50);
}

5. 安全的终止线程

上一点我们提到了中断,首先用中断来终止线程,如下

public class test{
public static void main(String[] args) throws InterruptedException {
MyRunner runner = new MyRunner();
Thread thread = new Thread(runner, "MyRunner");
thread.start();
TimeUnit.MILLISECONDS.sleep(10);
thread.interrupt();
}

public static class MyRunner implements Runnable{
private long i;

@Override
public void run() {
while (!Thread.currentThread().isInterrupted()){
i++;
System.out.println("i=" + i);
}
System.out.println("stop");
}
}
}

代码里用sleep方法使得main线程沉睡10ms,留给MyRunner足够的时间来感知中断从而结束,还可以采用boolean变量来控制是否需要停止线程,如下

public class test{
public static void main(String[] args) throws InterruptedException {
MyRunner runner = new MyRunner();
Thread thread = new Thread(runner, "MyRunner");
thread.start();
TimeUnit.MILLISECONDS.sleep(10);
runner.cancel();
}

public static class MyRunner implements Runnable{
private long i;
private volatile boolean on = true;
@Override
public void run() {
while (on){
i++;
System.out.println("i=" + i);
}
System.out.println("stop");
}

public void cancel(){
on = false;
}
}
}

结果如下,两段代码是类似的

image.png

此处说明线程执行到了run方法的末尾,即将终止


线程同步

在多线程应用中,两个或者两个以上的线程需要共享对同一个数据的存取,如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法,这种情况被称为竞态条件。此时如果不用同步,是无法保证数据原子性的,所以我们就需要用到锁


1. 重入锁与条件对象

synchronized关键字自动提供了锁以及相关条件。大多数需要显示锁的情况使用synchronized非常方便。但是等我们了解了重入锁和条件对象时,能更好的理解synchronized关键字。重入锁ReentrantLock是Java SE 5.0引入的,就是支持重进入的锁,表示该锁能够支持一个线程对资源的重复加锁。具体结构如下

Lock mLock = new ReentrantLock();
mLock.lock();
try {

}catch (){

}finally {
mLock.unlock();
}

这一结构确保任何时刻只有一个线程进入临界区,临界区就是同一时刻只有一个任务访问的代码区域。一旦一个线程封锁了锁对象,其他线程都无法进入。把解锁操作放到finally区域内是十分必要的,如果因为某些异常,锁资源是必须要释放的,否则其他资源将被永久阻塞。进入临界区时,却发现在某一个条件满足之后它才能执行,这时可以用一个条件对象来管理那些已经获得了一把锁但是却不能做有用工作的线程,条件对象又被称作条件变量。通过下面例子来说明为何需条件对象。假设一个场景需要用支付宝转账,我们先写支付宝类,它的构造方法需传入支付宝账户的数量和每个账户的账户金额。

public class Alipay{
private double[] accounts;
private Lock alipayLock;
public Alipay(int n, double money){
accounts = new double[n];
alipayLock = new ReentrantLock();
for(int i = 0; i < accounts.length; i++){
accounts[i] = money;
}
}
}

接下来实现转账,需要一个from转账方,和to接收方,amount是转账金额,如下

public void transfer(int from, int to, int amount){
alipayLock.lock();
try {
while (accounts[from] < amount){
//wait
}
}catch (){

}finally {
alipayLock.unlock();
}
}

有可能会出现转账方余额不足的情况,如果有其他线程给这个转账方再转足够的钱,就可以转账成功了,但是这个线程已经获取了锁,具有排他性,别的线程无法获取锁来进行存款操作,这时我们就需要引入对象锁。一个锁对象拥有多个相关条件对象,可以用new Condition方法获得一个条件对象,我们得到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁,相关代码如下

public class Alipay{
private double[] accounts;
private Lock alipayLock;
private Condition condition;
public Alipay(int n, double money){
accounts = new double[n];
alipayLock = new ReentrantLock();
condition = alipayLock.newCondition();
for(int i = 0; i < accounts.length; i++){
accounts[i] = money;
}
}

public void transfer(int from, int to, int amount) throws InterruptedException{
alipayLock.lock();
try {
while (accounts[from] < amount){
condition.await();
}
}catch (){

}finally {
alipayLock.unlock();
}
}
}

一旦一个线程调用await方法,就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同一个条件的signalAll()方法时为止。当另一个线程转账给我们此前的转账方时,只重复调用singnalAll()方法,就会重新激活因为这一条件而等待的所有线程,代码如下

public void transfer(int from, int to, int amount) throws InterruptedException{
alipayLock.lock();
try {
while (accounts[from] < amount){
condition.await();
}
accounts[from] = accounts[from] - amount;
accounts[to] = accounts[to] + amount;
condition.signalAll();
}catch (){

}finally {
alipayLock.unlock();
}
}

当调用了signalAll时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能够在当前线程退出同步方法后,通过竞态实现对对象的访问,还有个方法是signal,它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞,如果没有其他线程再次调用signal,那么系统就死锁了


2. 同步方法

Lock接口和Condition接口为程序设计提供了高度的锁定控制,然而大多数情况下并不需要那样的控制,并且可以使用一种嵌入到Java语言内部的机制。Java中每一个对象都有一个内部锁,如果一个方法用synchronized关键字修饰,那么对象的锁将保护整个方法,也就是说,要调用该方法,线程必须获得内部的对象锁,如下

public synchronized void method(){
···
}

这段代码等价于

Lock mLock = new ReentrantLock();
public void method(){
mLock.lock();
try{
···
}finally{
mLock.unlock();
}
}

对于上面转账的例子,可以将Alipay的transfer方法声明为synchronized,而不是使用一个显示的锁。内部对象锁只有一个相关条件,wait方法将一个线程添加到等待集中,使用notifyAll或notify方法解除等待线程的阻塞状态。也就是说wait相当于调用condition.await(),notifyAll等价于signalAll,所以前面例子里的transfer方法也可以这么写

public synchronized void transfer(int from, int to, int amount) throws InterruptedException{
while (accounts[from] < amount){
wait();
}
accounts[from] = accounts[from] - amount;
accounts[to] = accounts[to] + amount;
notifyAll();
}

在此可以看到,使用sychronized关键字来编码要简练很多,由该锁来管理那些试图进入synchronized方法的线程,由该锁中的条件来管理那些调用wait的线程


3. 同步代码块

除了调用同步方法来获得锁,还可以通过使用同步代码块,如下

synchronized(obj){
···
}

其获得了obj的锁,obj是一个对象,我们用同步代码块进行改写上面的例子

public class Alipay{
private double[] accounts;
private Object lock = new Object();
private Condition condition;
public Alipay(int n, double money){
accounts = new double[n];
for(int i = 0; i < accounts.length; i++){
accounts[i] = money;
}
}

public synchronized void transfer(int from, int to, int amount) throws InterruptedException{
synchronized (lock){
accounts[from] = accounts[from] - amount;
accounts[to] = accounts[to] + amount;
}
}
}

在这里创建了一个名为lock的Object类,为的是使用Object类所持有的锁。同步代码块是非常脆弱的,通常不推荐使用,一般实现同步最好用Java的并发包下的集合类,比如阻塞队列。如果同步方法适合自己的程序,尽量使用同步方法,这样可以减少编写代码的数量,减少出错的概率,如果特别需要使用Lock/Condition结构提供的独有特性时,才使用Lock/Condition


4. volatile

有时,仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大,而volatile关键字为实例域的同步访问提供了免锁机制。如果声明一个域为volatile的话,那么编译器和虚拟机知道该域是可能被另一个线程并发更新的。当一个共享变量被volatile关键字修饰后,就具备了两个含义,一个含义是线程修改了变量的值时,变量的新值对于其他线程是立即可见的。另一个含义是禁止使用指令重排序,分为编译期重排序和运行时重排序。先来看一段代码,假设线程1先执行,2后执行,如下

//线程1
boolean stop = false;
while(!stop){
//doSomething
}

//线程2
stop = true;

这是一个线程中断的代码,但是这段代码不一定会将线程中断,虽说无法中断线程这个情况出现的概率很小,但是一旦发生便是死循环。因为每个线程都有私有的工作内存,因此线程1运行时会拷贝一份stop的值放入私有工作内存中,当线程2更改了stop的变量值并返回后,线程2突然需要做其他操作,这时就无法将更改的stop变量写入主存中,这样线程1就不知道线程2对stop变量进行了更改,因此线程1会一直执行下去。当stop用volatile修饰,线程2修改stop值时,会强制将修改的值立刻写入主存,这样使得线程1的工作内存中的stop变量缓存无效,这样线程1在此读取变量stop的值时就会去主存读取

volatile不保证原子性

另外volatile不保证原子性,可看如下代码演示

class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}

public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(() -> {
for(int j=0;j<1000;j++)
test.increase();
}).start();
}
//保证前面的线程都执行完
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.inc);
}
}

这段代码每次运行的结果都不一致,因为自增操作是不具备原子性的。自增操作里包含了读取原始值、加1、写入工作内存这三个子操作,也就是说这三个子操作可能被割裂执行。

volatile保证有序性

volatile关键字能禁止指令重排序,因此能保证有序性。禁止指令重排序有两层含义,其一是指代码运行到volatile变量操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还未执行。其二是进行指令优化时,在volatile变量之前的语句不能在volatile变量之后执行

正确使用volatile关键字

synchronized关键字可防止多个线程同时执行一段代码,但是这会很影响程序的执行效率。volatile关键字在有些时候会优于synchronized关键字。但是要注意volatile关键字时无法替代synchronized关键字的,因为其无法保证原子性,通常来说,使用volatile关键字需要具备以下两个条件:

  1. 对变量的写操作不依赖于当前值
  2. 该变量没有包含在具有其他变量的不变式中

关于第一点,就是上面提到的自增自减操作。关于第二点,举个例子,包含一个不变式:下界总是小于或等于上界,代码如下

public class NumberRange {
private volatile int lower, upper;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}

这种方式定义的upper和lower并不能充分实现类的线程安全,如果两个线程在同一时间使用不一致的值执行setLower和setUpper的话,就会使范围处于不一致的状态。例如,如果初始状态是(0,5),同一时间内,两个线程分别调用setLower(4)和setUpper(3),虽然这两个交叉存入的值是不符合条件的,但是这两个线程都会通过用于保护不变式的检查,使得最后范围是(4,3),显然是不对的


0 个评论

要回复文章请先登录注册