Efficient Android Threading(第二章:Java中的多线程)

本书的这一部分涵盖了由Linux、java和Android提供的异步处理机制。您应该理解它们是如何工作的、各种技术的特性以及风险。本章将为您提供PartII部分所述技术的基础知识。

每一个Android应用程序应该遵循java语言的多线程编程模型。多线程编程提高了用户体验所需的性能和响应性,但同时也增加了程序的复杂性:
•处理Java中的并发编程模型
•在多线程环境中保持数据一致性
•建立任务执行策略

线程的基础知识

软件编程主要是指导硬件执行操作(例如,在显视器上显示图像、在文件系统上存储数据等)。指令是由被CPU以有序顺序处理的代码定义的,是对线程的高级定义。从应用的角度来看,线程沿着java语法的代码路径依次执行。在一个线程上顺序执行的代码路径称为一个任务、一个在线程上连贯执行的工作单元。线程可以按顺序执行一个或多个任务。

线程的执行

Android应用程序中的线程是由java.lang.thread实现。它是android中最基本的执行环境,它在启动时执行任务,在任务完成时终止;线程的存活时长由任务的长度决定。线程支持实现了接口java.lang.runnable的任务的执行。在run方法中定义任务:

private class MyTask implements Runnable {
    public void run() {
        int i = 0; // Stored on the thread local stack.
    }
}

直接或间接被方法run()调用的函数中的变量将存储在线程的本地存储栈内。任务的执行是通过实例化和启动一个线程开始:

Thread myThread = new Thread(new MyTask());
myThread.start();

在操作系统级别上,线程既有指令指针又有堆栈指针。指令指针指向下一个要处理的指令,堆栈指针指向一个不能被其它线程访问的私有内存区域,该区域用于存储线程本地数据。线程本地数据通常是Java方法中定义的变量。

CPU可以一次处理来自一个线程的指令,但是系统通常有多个线程需要同时进行处理,例如系统有多个同时运行的应用程序。为了让用户感知应用程序是并行运行,多个应用程序线程必须共享CPU的处理时间。CPU处理时间的共享由调度器来处理。它决定CPU应该处理哪个线程以及处理多长时间。调度策略可以以多种方式实现,但它主要基于线程优先级:高优先级线程比低优先级线程优先获得CPU分配,这给高优先级线程提供了更多的执行时间。java中线程的优先级可以从1(最低)设置到10(最高),但除非显式设置,否则默认优先级为5:

myThread.setPriority(8);

然而,如果调度仅仅是基于优先级的,那么低优先级线程可能没有得到足够的处理时间来执行它的任务,这就是线程饥饿。因此,调度器在切换线程时会考虑线程的处理时间。线程切换又称为context切换。context切换先存储线程的执行状态,以便将来可以重新恢复这个线程的执行,再让该线程进入等待状态。然后,调度器恢复另一个正在等待的线程使其进入执行状态。由单处理器执行的两个并行线程被分割到执行间隔,如图2-1所示:

Thread T1 = new Thread(new MyTask());
T1.start();

Thread T2 = new Thread(new MyTask());
T2.start();

这里写图片描述
图2-1:在一个CPU上执行的两个线程。用C表示context切换。
每个调度点都包含一个context切换,操作系统使用CPU来执行切换。一个这样的context切换在图中被标记为C。

单线程应用程序

每个应用程序至少有一个线程定义了代码的执行路径。如果应用程序只有一个线程,所有的代码都将沿着同一个代码路径依次执行,必须等前面的指令执行完后才能执行后面的指令。

单线程执行是一个具有确定性执行顺序的简单编程模型,但多数情况下它不是一个足够好的实现方法,因为后面的指令即使不依赖于前面的指令,也要等待前面的指令执行完了才能执行,这会使指令执行不及时。例如,用户按下设备的按钮后应该得到即时的视觉反馈,但在单线程环境下,UI事件可能因为前面的指令延迟,从而降低性能和响应速度。为了解决这个问题,应用程序需要将执行分成多个代码路径,即线程。

多线程应用程序

对于多个线程,应用程序代码可以拆分成多个代码路径,以便操作被同时执行。如果执行线程的数量超过了处理器的数量,则不能实现真正的并发执行,但调度器在要处理的线程之间进行快速切换,将每个线程分割成按顺序执行的执行间隔。多线程是应用程序必须的技术,但它在提升性能的同时也增加了程序的复杂度、增加了内存消耗、引起了执行顺序的不确定性。

资源消耗的增加

线程在内存和处理器使用方面开销很大。每个线程被分配一个私有内存区域,该区域主要用于在方法执行过程中存储方法局部变量和参数。私有内存区在线程被创建时分配,在线程终止后被销毁。即,只要线程是活动的,即使它是空闲或阻塞状态,它也能持有系统资源。线程创建和销毁、context切换都会有对处理器的开销。执行的线程越多,context切换就越频繁,性能就会越恶化。

复杂性的增加

分析单线程应用程序的执行相对简单,因为执行顺序是已知的。在多线程应用程序中,分析程序是如何执行的以及代码被处理的顺序要困难得多。线程间的执行顺序是不确定的,因为事先不知道调度器是如何分配各线程的执行时间。因此,多线程将在执行中引入不确定性。这种不确定性不仅增加调试代码的难度,而且也有引入线程同步问题的风险。

数据不一致性

在资源访问顺序不确定的情况下,多线程程序中出现了一组新问题。如果两个或多个线程使用共享资源,则不知道线程将以哪个顺序访问和处理资源。例如,如果线程T1和T2尝试修改成员变量sharedresource,则存取顺序是不确定的。sharedresource可能先递增,也可能先递减:

public class RaceCondition {
    int sharedResource = 0;
    public void startTwoThreads() {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                sharedResource++;
            }
        });
        t1.start();
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                sharedResource--;
            }
        });
        t2.start();
    }
}

由于程序每次运行时,代码的执行顺序可能不同,所以sharedresource处于竞争条件下。即无法保证线程T1总是在线程T2前执行。在这种情况下,不仅是执行顺序的问题,事实上还有加减操作是多字节代码指令(read, modify, write)的问题。context切换可能在字节码指令之间发生,sharedresource的最终结果可能是:0,-1或1。第一个结果发生在一个线程在另一个线程读取它之前写入值;最后两个结果发生在两个线程第一次读取初始值0,那么最后一个写入值决定最终结果。

因为当一个线程执行一段代码时,可能发生context切换,多个线程同时读写同一个数据会有上述示例的数据不一致问题,因此需要在代码中适当位置创建同步区域。同一时间内,同步区域内的代码指令总是在一个线程中按顺序执行,而不会穿插其他线程的访问。如果一个线程在一个同步区域执行,则其他线程将被阻塞,直到同步区域中没有线程执行。因此, java语言中的同步区被认为是互斥的,因为它只允许一个线程访问。可以以各种方式创建同步区域,但最基本的同步机制是synchronized关键字:

synchronized (this) {
    sharedResource++;
}

如果对共享资源的每个访问都是同步的,那么就能保证多线程访问下数据是一致的。本书中讨论的许多线程机制都是为了减少这种数据不一致的风险。

线程安全

多个线程访问同一个对象是线程快速通信的好方法(一个线程写入,另一个线程读取),但它可能引起错误。多个线程可以同时执行同一个对象实例,从而导致对共享内存中状态的并发访问。当一个对象在多线程访问时始终保持数据一致性,那么就是线程安全的。通过设置同步区域,实现对数据访问的控制,来保证线程的安全。应该在有多个线程同时read、write同一个变量的代码段添加同步区域,这种必须以原子方式执行,即同一时间只能有一个线程访问它。同步是通过使用一个锁机制来实现的,该机制检查当前是否存在一个在同步区域内执行的线程。如果有,则其它所有试图进入同步区的线程都会阻塞,直到线程完成了同步区内的执行。如果一个共享资源可以被多个线程访问,且不能保证其数据的一致性,那么对这个资源的每个访问都需要用同一个锁保护。

简言之,锁保证了其锁定的区域的原子执行。Android包括的锁机制有:
•对象锁
——synchronized关键字
•显式锁
——java.util.concurrent.locks.ReentrantLock
——java.util.concurrent.locks.ReentrantReadWriteLock

对象锁和Java Monitor

synchronized关键字作用于每个Java对象隐式持有的锁。对象锁是互斥的,这意味着同步区中的代码某一时段内只能有一个线程访问。其它想访问这个区域的线程会被阻塞并且暂停执行,直到区域的锁被释放。可把对象锁的作用域看作monitor(见图2-2),Java monitor可被模型化为三个状态:
•阻塞:线程在等待其他线程释放锁时被挂起。
•执行:持有锁的唯一线程正在运行同步区内的代码。
•等待:在执行完同步区代码之前主动释放锁的线程。这些线程等待其它线程发出notify信号,然后才能再次持有锁。
这里写图片描述
图2-2:Java monitor
一个线程执行一段有对象锁保护的代码时,会在三种状态中切换:
1、 进入monitor。线程试图访问由对象锁保护的代码。它进入monitor,但是如果锁已经被另一个线程持有,它将被阻塞。
2、 获得锁。如果没有其他线程持有锁,则阻塞线程可以获得同步区的锁并执行。如果有多个阻塞线程,那么由调度器决定要执行哪个线程。如果有多个阻塞线程等待进入执行状态,在阻塞线程之间没有先入先出的顺序,即进入monitor的第一个线程不一定是第一个被选中执行的线程。
3、 释放锁并等待。线程用Object.wait()来挂起自己,它需要等待某些条件实现后才继续执行。
4、 收到notify信号后重新获得锁。线程收到其它线程用Object.notify()或Object.notifyAll()发送信号,且被调度器选中,那么它就重新持有锁。获取锁时,等待线程没有比阻塞线程高的优先级。
5、 释放锁并退出monitor。运行到同步区域的结束位置后,线程退出monitor并释放锁,供其它线程获取锁。
把上面各状态的转换映射到相应的同步代码块:

synchronized (this) { // (1)
    // Execute code (2)
    wait(); // (3)
    // Execute code (4)
} // (5)

同步对共享资源的访问

可由多个线程访问和更改的共享可变状态需要同步策略,以便在并发执行期间保持数据一致。同步策略包括根据实际情况选择合适的锁,和设置正确的同步区域范围。

使用对象锁

根据如何使用synchronized关键字,对象锁可以有不同的方式保护共享的可变状态:
•基于当前对象的锁的方法级同步:

synchronized void changeState() {
    sharedResource++;
}

•基于当前对象的锁的代码块同步:

void changeState() {
    synchronized(this) {
        sharedResource++;
    }
}

•基于其它对象的锁的代码块同步:

private final Object mLock = new Object();
void changeState() {
    synchronized(mLock) {
        sharedResource++;
    }
}

•基于当前类的锁的方法级同步:

synchronized static void changeState() {
    staticSharedResource++;
}

•基于当前类的锁的代码块同步:

static void changeState() {
    synchronized(this.getClass()) {
        staticSharedResource++;
    }
}

基于当前对象锁的代码块同步和方法级同步使用的是相同的锁,但代码块同步能更精确的只覆盖你想保护的变量。不要创建比实际需要更大的同步区域,因为这可能在非必要时阻塞其他线程,导致应用程序执行较慢。

通过使用其他对象的锁,可以实现在同一个类中使用多个锁。应用程序应尽量用一个单独的锁来保护每个独立的状态。因此,如果一个类有多个独立的状态,那么应通过使用多个锁来提高性能。

synchronized关键字可以在不同的锁中工作。需要注意的是,静态方法上的同步操作是类对象的锁,而不是实例对象的锁。

使用显式锁机制

ReentrantLock和ReentrantReadWriteLock类可以实现比synchronized关键字更先进的锁机制。下面展示用显式锁定和解锁来保护关键代码:

int sharedResource;
private ReentrantLock mLock = new ReentrantLock();
public void changeState() {
    mLock.lock();
    try {
        sharedResource++;
    }
    finally {
        mLock.unlock();
    }
}

synchronized关键字和ReentrantLock有着相同的作用,如果另一个线程已经进入该区域,它们都阻止试图执行同步区的其它线程。这是一种假定所有并发访问都有问题的防御策略,但同时读取一个共享变量对多个线程没有害处。因此,synchronized和ReentrantLock都过度保护了。ReentrantReadWriteLock让读线程同步执行,但是阻塞同时读和写、同时写的线程:

int sharedResource;
private ReentrantReadWriteLock mLock = new ReentrantReadWriteLock();
public void changeState() {
    mLock.writeLock().lock();
    try {
        sharedResource++;
    }
    finally {
        mLock.writeLock().unlock();
    }
}
public int readState() {
    mLock.readLock().lock();
    try {
        return sharedResource;
    }
    finally {
        mLock.readLock().unlock();
    }
}

ReentrantReadWriteLock相对复杂,会导致性能损失,因为评估某个线程需要执行还是阻塞的耗时比synchronized和ReentrantLock的长。因此,需要在多个线程同时读取共享资源带来的性能提升和复杂性带来的性能损失之间做好权衡。当有许多读线程和很少的写线程时是使用ReentrantReadWriteLock的典型场景。

例子:消费者和生产者

一个常见的线程协作场景就是“消费者——生产者”模式,即一个线程产生数据和一个线程消耗数据。线程通过它们之间共享的列表进行协作。当列表未满时,生产者线程将item添加到列表中,而消费者线程则在列表不空时移除item。如果列表是满的,则生产者线程应该阻塞;如果列表为空,则消费者线程被阻塞。

ConsumerProducer类包含一个共享的资源LinkedList和两个方法(produce()用于添加item,consume()用于移除item):

public class ConsumerProducer {
    private LinkedList<Integer> list = new LinkedList<Integer>();
    private final int LIMIT = 10;
    private Object lock = new Object();
    public void produce() throws InterruptedException {
        int value = 0;
        while (true) {
            synchronized (lock) {
                while(list.size() == LIMIT) {
                    lock.wait();
                }
                list.add(value++);
                lock.notify();
            }
        }
    }
    public void consume() throws InterruptedException {
        while (true) {
            synchronized (lock) {
                while(list.size() == 0) {
                    lock.wait();
                }
                int value = list.removeFirst();
                lock.notify();
            }
        }
    }
}

生产者和消费者都使用相同的对象锁来保护共享列表。只要其它线程持有锁,那么试图访问共享列表的线程就被阻塞,但是当列表已满时生产者线程用wait()放弃执行,当列表为空时消费者线程用wait()放弃执行。

当item被添加或移除时,用notify()来通知其他等待的线程重新开始执行。消费者线程notify生产者线程,反之亦然。下面的代码显示了执行生产和消费操作的两个线程:

final ConsumerProducer cp = new ConsumerProducer();
Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        try {
            cp.produce();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}).start();
Thread t2 = new Thread(new Runnable() {
    @Override
    public void run() {
        try {
            cp.consume();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}).start();

任务的执行策略

为了确保正确地使用多线程来创建响应式应用程序,应该在设计应用程序时融入线程创建和任务执行的思想。下面是两个不理想的极端设计:
•用一个线程完成所有任务
——所有任务都在同一线程上执行。这通常导致没有充分利用处理器,应用程序不响应。
•每个任务一个线程
——每个任务总是用一个新线程来执行,每个线程都只为一个任务启动和终止。如果任务频繁创建和生命周期较短,线程的创建和销毁的开销就会降低性能。

这些极端设计代表了顺序和并发执行可能出现的极端情况:
•顺序执行
——任务在一个序列中依次执行,前一个任务完成后才能执行后一个任务。因此,任务的执行间隔不重叠。
这种设计的优点是:
1、 它本质上是线程安全的。
2、 可以在一个线程上执行,它比多个线程消耗更少的内存。
这种设计的缺点是:
1、 较低的吞吐量。
2、 下一个任务的开始取决于前一个任务的结束,导致任务的开始延迟甚至无限延迟。
•并行执行
——任务是并行和交错执行的。其优点是CPU利用率更高,而缺点是设计本身并不是线程安全的,因此需要使用同步技术。

一个有效的多线程设计根据执行的任务来选择用顺序执行还是并发执行。多个独立的任务可以同时执行以增加吞吐量,但对需要顺序执行的任务和需要共享未同步公共资源的任务,应该使用顺序执行。

并发执行的设计

并发执行可以用多种方法实现,因此设计必须考虑如何管理执行线程的数量及其关系。基本原则包括:
•尽量用重用线程来代替新建线程,这样有利于减少线程创建和销毁带来的资源消耗。
•不要创建非必要的线程。使用的线程越多,消耗的内存和处理器时间就越多。

总结

Android应用程序应该设计为多线程的,以提高单处理器平台和多处理器平台上的运行性能。线程可以在单个处理器上共享执行,可以在多个处理器上实现真正的并发执行。性能的提高是以增加复杂性为代价的,同时也要注意保护共享资源和保持数据一致。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值