推荐文章:
# 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的时候,启异步请求方法中就使用到了高并发、高吞吐量的线程池,并配合阻塞队列一起使用,在实际的项目开发中,我们可能暂时用不到线程池,但是在一些框架源码中却是经常见到,这也有利于我们理解其中的思想。