Android进阶宝典 -- 并发编程之线程池

推荐文章:
# Android进阶宝典 -- 并发编程之JMM模型和锁机制

在上文的末尾,为了解决sychronized的阻塞性问题,引出了CAS算法,目的就是为了提高效率从而达到计算准确的目的。其实在Android开发中,一直追求的就是响应速度,因此CAS算法是首选。

那么在CAS算法中是如何保证计算的准确性呢?这里可以做一个总结:

首先CAS算法是会存储主内存中的旧值,然后完成计算之后并不会直接刷入主内存,而是会首先比较主内存和旧值是否一致,如果一致说明主内存的值没有被其他线程修改过,则直接刷入主内存;如果不一致,则工作内存缓存该地址的数据失效,需要重新获取,重新计算。

1 无锁并发与有锁并发

所以,既然加锁之后会影响效率,那么无锁(像CAS)就一定会提高效率吗?其实万物都不是绝对的。

对于sychronized加锁的场景下,因为线程在没有获取锁对象的时候会阻塞等待获取,导致释放CPU资源从而发生线程的上下文切换;而无锁的状态下,线程是一直在运行的,只不过会因为主内存中的值与旧值不一致导致重试,但是线程是一直在高速运行的。

那么CAS一定是适应所有的场景吗?其实不是,对于线程数量少的场景下,例如与CPU核数一致,这种情况下CAS一定是效率最高的;但是如果是10000个线程的场景下,CAS反而效率会降低,因为频繁地重试和重新读取,都会消耗额外的CPU资源

所以CAS是基于乐观锁的思想,我不怕别的线程来修改结果,因为CAS有重试机制能保证计算的准确性;但是sychronized是基于悲观锁的思想,在加锁后不允许其他线程修改内存值,只有当锁释放之后,才能修改。

那么如果创建的线程数超过CPU的核心数,那么CAS算法还会高效吗?其实有了线程池,就能够尽可能地限制最大线程数,保证CAS算法的高效。

2 核心线程数的考量

首先我们需要知道为什么会出现线程池这个东西?首先如果我们有10000个任务,那么肯定不会开辟10000个线程去处理,所以出现了线程池的概念,利用有限的线程数处理无限的任务。

2.1 线程饥饿

虽然线程池能够处理无限的任务,但是如果任务的类型是不一样的,例如有2000个任务,其中A任务类型有1000个,B任务类型有1000个,当前线程池中有10个工作线程。

java 复制代码
private static void testThreadPool(){
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    for (int i = 0; i < 10; i++) {
        executorService.submit(()->{
            System.out.println("上菜--");
        });
    }
    for (int i = 0; i < 10; i++) {
        executorService.submit(()->{
            System.out.println("做菜--");
        });
    }
}

假如工作线程全部都在执行A任务,B任务处理闲置状态,但是B任务会对A任务有调度,或者说有依赖项,可能会导致概念上的"死锁"。例如上面的例子,正常应该是先做菜完事后才能上菜,但是因为执行的先后顺序,导致上菜在等做菜完,但是做菜又不能先执行。因此如果这个时候有一个新的线程执行了做菜的操作,那么上菜就能正常执行了。

所以因为线程饥饿的问题,所以在使用线程池的时候,对于有互相关联的任务来说,不能放在同一线程池中处理,需要新开辟线程池处理。

2.2 核心线程数

当然线程池并不是代表着线程数越多,它的效率就越高,如果一股脑创建一堆线程真正运作的时候反而用不上,才是真正浪费系统资源,所以需要根据我们的业务场景来决定核心线程数的大小。

(1)CPU密集型任务

对于CPU密集型任务,例如for循环,通常采用CPU核数 + 1就能实现CPU的最优利用率,为什么要+1呢,就是因为可能存在系统故障导致线程停止,那么额外的这个线程就能够顶上去。

(2)IO密集型任务

这种一般发生在频繁地读写IO操作,或者从数据库读写数据,这个时候CPU会闲置下来,可以使用多线程来提高效率

因此这里总结一个公式:核心线程数 = CPU核数 * CPU利用率 * 计算时长百分比 / 等待时长百分比

例如发起一个网络请求,从发起请求到拿到结果中间是需要等待时长的,假设等待时长占比为20%,那么计算时长为80%,当前CPU核数为4核,那么核心线程数 = 4 * 100% * 80% / 20% = 16

3 自定义线程池

从上面小节中,大概了解了线程池的工作流程,其实就是一个生产者和消费者的设计思想,主线程通过生产任务,线程池来消费任务。

所以根据上图的思想,设计一个线程池。

3.1 任务队列

任务队列,主要的作用就是存储异步任务提供给线程池调用任务,所以在其内部是维护了一个队列

kotlin 复制代码
//维护异步任务的队列
val dequeTask = ArrayDeque<T>()
val mLock = ReentrantLock()

//条件变量 队列满了
val fullWaitSet = mLock.newCondition()

//队列空了
val emptyWaitSet = mLock.newCondition()

//队列的容量 默认为4个
val dequeSize = AtomicInteger(4)

这个队列随时可能满,随时可能空,因此当队列满的时候,便不能添加任务,需要等到队列不满;当队列空的时候,不能取任务,需要等到队列不为空的时候,因此需要两个条件变量Condition。

kotlin 复制代码
interface IBlockingQueue<T> {

    fun addTask(t: T)
    fun removeTask(): T?
}

对于队列来说,主要有两个操作,一个是取操作,一个是添加操作。

kotlin 复制代码
override fun addTask(t: T) {

    //线程安全操作
    mLock.lock()
    try {

        //判断队列大小是否超限
        while (dequeTask.size >= dequeSize.get()) {
            //需要等到队列不满的时候
            fullWaitSet.await()
        }

        //如果没有超限
        dequeTask.add(t)
        //既然能添加数据,证明队列不是空的了
        emptyWaitSet.signal()
        
    } finally {
        mLock.unlock()
    }
}

override fun removeTask(): T? {

    mLock.lock()

    var target: T? = null
    try {

        //判断队列是否为空
        while (dequeTask.size == 0) {
            //如果为空,需要等到不为空的时候
            emptyWaitSet.await()
        }

        //如果队列不为空,那么可以取数据
        target = dequeTask.removeFirst()
        //既然取出了数据,那么队列一定不是满的,则可以添加数据,通知fullWaitSet可以跳出循环了
        fullWaitSet.signal()
    } finally {
        mLock.unlock()
    }
    return target
}

所以在添加或者获取的时候,依赖两个条件变量,当一方能够执行时,通知另一方执行,两者属于相互依赖,有消费就有添加

3.2 线程池

从小节开头的图中可以看到,除了任务队列之外,还有就是一个线程集合,当执行任务时,首先会分配给线程集合中的核心线程执行。

kotlin 复制代码
class MyThreadPool {

    //任务队列
    private val blockingQueue: MyBlockingQueue<Runnable> by lazy {
        MyBlockingQueue()
    }

    //线程集合
    private val works: HashSet<Work> by lazy {
        HashSet()
    }

    //注意这里可以动态配置,demo中暂时写死了
    private val coreSize = AtomicInteger(4)

    fun execute(task: Runnable) {

        //如果当前有空闲的核心线程可以使用
        if (works.size < coreSize.get()) {
            val work = Work(task)
            work.start()
            works.add(work)
        } else {
            //否则就往任务队列中塞
            blockingQueue.addTask(task)
        }

    }

    inner class Work(var task: Runnable?) : Thread() {

        override fun run() {
            super.run()
            while (task != null) {
                try {
                    task?.run()
                }finally {
                    task = blockingQueue.removeTask()
                }
            }
        }
    }
}

看下execute方法,当有空闲线程时,任务会交给线程立即执行,而如果没有空闲的线程,那么就会塞进任务队列中。

我们看下Work这个内部类,是继承自Thread,当执行run方法时,我们可以看到是有一个while循环的,会不断从任务队列中去任务执行。

其实对于线程池的使用,前面我们在介绍OkHttp的时候,启异步请求方法中就使用到了高并发、高吞吐量的线程池,并配合阻塞队列一起使用,在实际的项目开发中,我们可能暂时用不到线程池,但是在一些框架源码中却是经常见到,这也有利于我们理解其中的思想。

相关推荐
找藉口是失败者的习惯17 分钟前
从传统到未来:Android XML布局 与 Jetpack Compose的全面对比
android·xml
Jinkey1 小时前
FlutterBasic - GetBuilder、Obx、GetX<Controller>、GetxController 有啥区别
android·flutter·ios
大白要努力!3 小时前
Android opencv使用Core.hconcat 进行图像拼接
android·opencv
天空中的野鸟4 小时前
Android音频采集
android·音视频
小白也想学C5 小时前
Android 功耗分析(底层篇)
android·功耗
曙曙学编程5 小时前
初级数据结构——树
android·java·数据结构
闲暇部落8 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX10 小时前
Android 分区相关介绍
android
大白要努力!10 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee11 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip