一、编译时与运行时
二、java几种锁的区别
问题简答
这里我们就需要详细解析一下AQS与公平锁、非公平锁的概念了
公平锁与非公平锁
公平锁:
多个线程按照申请锁的顺序去获得锁。线程会直接进入到队列去排队,永远都是队列第一位才能得到锁。
优点:所有的线程都能得到资源,不会饿死在队列中。
缺点:吞吐量会下降很多。队列里面除了第一个线程,其它的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
简单来说,缺点比优点大,所以ReentrantLock默认实现是非公平锁。
非公平锁:
多个线程去获取所的时候,会直接尝试获取。获取不到再进入等待队列,如果能获取到就直接获取锁。
优点:可以减少CPU唤醒线程的开销,整体的吞吐率会比非公平锁高。CPU也不用唤醒所有线程,会减少唤起线程的数量。
缺点:可能导致队列中间的线程一直获取不到所或者长时间获取不到锁导致饿死。
我们从ReentrantLock入手:
当我们new一个ReentrantLock的时候,可以看到:
可以看到在ReentrantLock的构造方方法里会创建Sync对象,fair默认为false,sync实现为NonfairSync(),fair为true的时候,sync实现为FairSync()。
NonfairSync与FairSync都继承自Sync对象。
Sync是ReentrantLock中的一个抽象静态内部类,其继承自
AbstractQueueSynchronizer
.这个AbstractQueueSynchronizer实际上就是我们常说的AQS我们先看一下NonfairSync和FairSync里公平锁与非公平锁如何实现的:
①NonfairSync
可以看到NonfairSync中并不会判断是否有阻塞队列的存在,而是直接调用UnSafe里的compareAndSwap(native层实现)也就是CAS去获取锁。
②FairSync
而FairSync会判断当前线程是否位于同步队列的首位,是返回true,如果通过CAS也能获取到锁,则当前线程拿到当前锁
公平锁与非公平锁 中锁的获取过程
我们再往里看看公平锁和非公平锁是如何给当前线程加锁的,先看一下非公平锁的加锁过程:
非公平锁获取锁过程
A线程准备获取锁,首先判断一下state状态,如果是0,CAS成功。将自己修改为持有锁的那个线程。
这个时候B线程也过来了,判断一下state状态,发现是1,那么CAS就失败了,只能去等待队列里等待唤醒。
A持有锁结束,准备释放掉锁。会修改state状态,抹掉持有锁线程的痕迹,准备去叫醒B。
也就是A线程调用了unLock方法:
这时候线程C进来了,发现state是0,果断CAS后修改state为1,将持有锁的线程修改为自己。
B线程被A唤醒准备去获取锁,结果发现state是1,CAS失败,直接继续去等待队列。
以上就是一个非公平锁中线程获取锁的过程。
公平锁获取锁过程
ReentrantLock构造参数中传true,改成公平锁,默认非公平锁。
①线程A想要获取锁,先判断一下state,发现是0;看了一下队列,自己是第一位,果断将锁持有线程改为自己。
②线程B过来了,判断一下state,是1,CAS失败,只能去排队。
③线程A释放锁之后,唤醒B,这时候线程C进来了,先判断一下state是0,以为有戏,但是发现自己不是等待队列中的第一位,作为良好市民,果断去排队了。
④线程B被A欢喜你后,去判断state,发现是0,且自己现在是队列中的第一位,那么获得了当前锁。
//TODO 需要将源码串联起来,根据代码梳理这些流程。
AQS
AQS详解
AQS详解二
AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch...。
AQS为一系列同步器依赖于一个单独的原子变量(state)的同步器提供了一个非常有用的基础。子类们必须定义改变state变量的protected方法,这些方法定义了state是如何被获取或释放的。鉴于此,本类中的其他方法执行所有的排队和阻塞机制。子类也可以维护其他的state变量,但是为了保证同步,必须原子地操作这些变量。
AbstractQueuedSynchronizer中对state的操作是原子的,且不能被继承。所有的同步机制的实现均依赖于对改变量的原子操作。为了实现不同的同步机制,我们需要创建一个非共有的(non-public internal)扩展了AQS类的内部辅助类来实现相应的同步逻辑。
AQS为一系列同步器依赖于一个单独的原子变量(state)的同步器提供了一个非常有用的基础。子类们必须定义改变state变量的protected方法,这些方法定义了state是如何被获取或释放的。鉴于此,本类中的其他方法执行所有的排队和阻塞机制。子类也可以维护其他的state变量,但是为了保证同步,必须原子地操作这些变量。
三、线程池相关
Java线程池入门
线程池面试问题总结--很nice,必须看看
美团技术博客--线程池
系统中有四个自带的线程池,java建议使用这个几个来建议线程池,但是阿里巴巴禁止用四个Executors创建的线程池!(为什么禁止我们后面说,先简单说一下这个四个线程池)
1.newSingleThreadExecutor
创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
2.newFixedThreadPool
创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程的最大容量。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
3.newCachedThreadPool
创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60s不执行任务)的线程,当任务数增加时,此线程池又可以智能的增加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖操作系统(或者说JVM,哈哈一个字不差,全是照着敲的)能够创建的最大线程大小
4.newScheduledThreadPool
创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。(定时任务,类似Timer和TimerTask)
前面说了阿里强制线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors返回的线程池对象的弊端如下:
①FixedThreadPool和SingleThreadPool(newSingleThreadExecutor):
允许的请求队列长度为Integer.MAX_VALUE,可能堆积大量的请求,从而导致OOM.
②CachedThreadPool和ScheduledThreadPool:
运行创建的线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。
Demo:
创建一个死循环塞进线程池线程,最终会报OOM异常。
Java中的 BlockingQueue主要有两种实现,分别是ArrayBlockingQueue 和 LinkedBlockingQueue。
ArrayBlockingQueue是一个用数组实现的有界阻塞队列,必须设置容量。
LinkedBlockingQueue是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。
这里的问题就出在:不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。也就是说,如果我们不设置LinkedBlockingQueue的容量的话,其默认容量将会是Integer.MAX_VALUE。
而newFixedThreadPool中创建LinkedBlockingQueue时,并未指定容量。此时,LinkedBlockingQueue就是一个无边界队列,对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题。
抛出异常的点为LinkedBlockingQueue.offer方法.我们先看一下这四个线程池都是怎么创建的:
可以看到这四个线程池底层都是ThreadPoolExecutors创建的。ThreadPoolExecutors构造参数,有七个。如下:
①corePoolSize:线程池中核心线程的数量(看源码注释:一直在线程池中的线程个数)
②maximumPoolSize:线程池允许的最大的线程数
③keepAliveTime:非核心空闲线程最大存活时间,一个线程如果处于空闲状态,且当前线程超过核心线程数,那么在这个指定时间后,线程会被销毁。
④TimeUnit:时间单位
**⑤workQueue:也就是我们常说的阻塞队列:BlockingQueue。jdk提供了四种工作队列:
1>ArrayBlockingQueue
基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经满了,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。
2>LinkedBlockingQueue
基于链表的无界阻塞队列。(最大容量为Integer.MAX_VALUE),按照FIFO排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务尽力啊,会一直存在该队列,而不会去创建新线程直到maxPoolSize(Integer.MAX_VALUE)因此使用该工作队列时,参数maxPoolSize其实是不起作用的(而SingleThreadPool和FixedThreadPool默认用的就是LinkedBlockingQueue,所以可能会导致OOM!!)。
3>SynchronousQueue
一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务。如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略.
4>PriorityBlockingQueue
具有优先级的无界阻塞队列,优先级通过参数Comparator实现。(优先级!!这个好使!!)
**
⑥threadFactory线程工厂
创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等。
⑦handler拒绝策略
当工作队列中的任务已达到最大限制,并且线程池中的线程数量也达到最大限制。这时如果有新任务提交进来时,该如何处理呢。这里的拒绝策略,这时解决这个问题的,jdk中提供了4种拒绝策略:
1>CallerRunsPolicy
该策略下,在调用者线程中执行执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。
2>AbortPolicy
该策略下,直接丢弃任务,并抛出RejectedExecutioinException异常。
3>DiscardPolicy
该策略下,直接丢弃任务,什么都不做。
4>DiscardOldestPolicy
该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列。
创建线程池的正确姿势
避免使用Executors创建线程池,主要是避免使用其中的默认实现,那么我们可以自己直接调用ThreadPoolExecutor的构造参数来自己创建线程池,给BlockingQueu指定容量就可以了。比如我们创建一个核心线程池,也就是newFixedThreadPool:
这种情况下,一旦提交的线程数超过当前可用线程数时,就会抛出java.untl.concurrent.RejectdExecutionException.异常(Exeception)总比错误(Error)好。
说到错误和异常,我们下面简单说一下程序中捕获异常和错误的问题。
java exception和 error
java 中error可不可以捕获?答案是可以的。
可以看到error和exception都继承自Throwable,我们完全可以在catch语句中捕获Throwable,也就是error可以捕获:
那为什么不该捕获Error呢?因为出现Error的情况会造成程序直接无法运行,所以捕获了也没有任何意义。
Throwable 是所有异常和错误的超类。你可以在 catch 子句中使用它,但是你永远不应该这样做!
如果在 catch 子句中使用 Throwable ,它不仅会捕获所有异常,也将捕获所有的错误。JVM 抛出错误,指出不应该由应用程序处理的严重问题。 典型的例子是 OutOfMemoryError 或者 StackOverflowError 。两者都是由应用程序控制之外的情况引起的,无法处理。
所以,最好不要捕获 Throwable ,除非你确定自己处于一种特殊的情况下能够处理错误
Runnable/Callable/Future/FutureTask
①Runnable是一个接口,只有一个run方法,没有返回值,不能抛出异常。
②Callable
参考资料
Callable也是一个接口,只有一个call方法。和Runnable差别在于它有返回的结果,而且可以抛出异常!一般配合ThreadPoolExecutor使用。[??这是真的吗]
Callable确实主要在线程池中使用,如下图,在ThreadPoolExecutor的父类AbstractExecutorService中有引用到Callable:
③Future也是一个接口,它可以对具体的Runnable或者Callable任务进行取消、判断任务是否已取消、查询任务是否完成、获取任务结果。如果Runnable的话返回的结果是null。(下面会剖析为什么Runnable的任务,Future还能返回结果)。接口里面有一以下几个方法。注意两个get方法都会阻塞当前调用get的线程,直到返回结果或者超时才会唤醒当前的线程。
使用demo:
④FutureTask
因为Future只是一个接口,所以无法创建使用,因此有了FutureTask.
FutureTask相当于继承了Runnable和Future。
因此它可以作为Runnable被线程执行,又可以有Future的那些操作。
Demo:
as we all know,线程池执行任务有两种方法,一是execute,而是submit。
如果们需要返回任务的执行结果就得调用submit方法而不是execute。
submit也不神秘,就是将任务封装成FutureTask再execute。
所以submit三个方法其实都是把task转成FutureTask,如果task是Callable,就直接赋值。如果是Runnable就转为Callable再赋值,只不过返回值是null。
此外,sumbit有个方法:
demo:
传入runnable以及result,可以拿到修改后的result。
简单总结:
①Callable可以获得任务结果和抛出异常;②Runnable没结果也无法抛出异常。
③Future可以很容易的获取异步执行的结果,并且对任务进行一些操控。并且get等待结果时会阻塞,所以当任务之间有依赖关系的时候,一个任务依赖另一个任务的结果,可以用Future的get来等待依赖的任务完成的结果。④FutureTask就是具体的实现类,有Runnable的特性又有Future的特性,内部包的是Callable,当然也有接受Runnable的构造器,只是会偷偷把Runnable转成Callable来实现能返回结果的方法。
线程池方法源码解析
当线程池调用该方法时,线程池的状态通过CAS会变成SHUTDOWN状态。此时,不能再往线程池中添加任务,否则会抛出RejectedExecutionException。
有几个相关问题由此产生:
1>shutdown()有什么功能?
阻止新来的任务提交,对已经提交了的任务不会产生任何影响。会将那些闲置的线程idleWorks进行中断。
2>如何组织新来的任务提交?
通过线程池的状态改成SHUTDOWN,当再执行execute提交任务时,如果测试到状态不为RUNNING,则抛出RejectedExecutionException。如下图:
3>为什么对运行中的任务不产生任何影响?
在调用中断任务的方法时,tryTerminate方法会检测workers中的线程,如果没有中断,并且是空闲线程,才会去中断这个线程。
这里判断空闲线程的方法也很简单,就是看是否从ReentrantLock中获取到锁,如果获取到了,说明不是在运行中的线程,为什么呢?
因为线程在运行之前会调用w.tryLock,会先拿到锁执行任务;那么这个中断的地方tryLock就会失败,也就拿不到锁,自然也就中断不了运行中的线程了。
这也就是shutdown为什么关闭不了运行中的任务的原因。
②shutDownNow
从最上层的调用可以看到,与shutdown不同的是通过CAS将状态改成STOP,shutdown是改成了SHUTDOWN.在组织新来的任务提交的同时,会中断当前正在运行的线程,及workes中的线程。另外将workQueue中的任务给移除,并将这些任务添加到列表中。
可以看到在interruptWorkers方法中,会加锁判断所有的Worker是否已经运行且没有被中断,如果满足上述条件,则调用interrupt方法中断所有运行的线程。
接着调用drainQueue,将阻塞队列也就是workQueue(BlockingQueue)中的任务给移除掉,并将这些任务添加到列表中返回。
然后调用tryTerminate,将空闲线程也给中断掉,CAS判断后终止线程池。
相关问题:
①如何阻止新来的任务提交?
通过将线程池的状态改成STOP,当再将执行execute提交任务时,如果测试到状态不为RUNNING,则抛出rejectedExecution,从而达到阻止新任务提交的目的。
②如果我提交的任务代码块中,正在等待某个资源,而这个资源没到,但此时执行shutdownNow(),会出现什么情况?
当执行shutdownNow()方法时,如遇已经激活的任务,并且处于阻塞状态时,shutdownNow()会执行1次中断阻塞的操作,此时对应的线程报InterruptedException,如果后续还要等待某个资源,则按正常逻辑等待某个资源的到达。例如,一个线程正在sleep状态中,此时执行shutdownNow(),它向该线程发起interrupt()请求,而sleep()方法遇到有interrupt()请求时,会抛出InterruptedException(),并继续往下执行。在这里要提醒注意的是,在激活的任务中,如果有多个sleep(),该方法只会中断第一个sleep(),而后面的仍然按照正常的执行逻辑进行。
下面看看线程池的一些问题:
(1)线程池是什么,有什么好处?
线程池是一种基于池化思想管理线程的工具。 好处如下:
①降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成损耗。
②提高响应速度:任务到达时,无需等待线程创建即可立即执行
③提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
④提供更多更加强大的功能:线程池具备可扩展性,允许开发人员向其中增加更多的功能。比如延时暂定线程池ScheduledThreadPoolExecutor,就允许任务延迟执行或者定期执行。
(2)线程池解决的问题是什么?
线程池解决的核心问题就是资源管理问题。在并发环境下,系统不确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:
1.频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大
2.对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
3.系统无法合理管理内部的资源分布,会降低系统的稳定性。
为了解决资源分配问题,线程池采用"池化"(Pooling)思想。池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。
池化思想在计算机中还有其他的应用:
①内存池:预先申请内存,提升申请内存速度,减少内存碎片。
②连接池:预先申请数据库连接,提升申请连接的速度,降低系统的开销
③实例池:循环使用对象,减少资源在初始化和释放时的昂贵损耗。
(3)线程池的核心设计是怎样的?
①ThreadPoolExecutor顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程、如何调度线程来执行任务,用户只需提供Runnable对象,将任务的执行逻辑提交到执行器Executor中,由Executor框架完成线程的调配和任务的执行部分。
②ExecutorService接口增加了一些能力:扩充执行任务的的能力,补充可以为一个活一批任务生成Future的方法;提供了管控线程池的方法,比如停止线程池的运行(shutDown/shutDownNow/isShutDown)
③AbstractExecutorService则是上层的抽象类,将执行任务的流程串联起来,保证下层的实现只需关注一个执行任务的方法接口。实现了submit相关方法。
④最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。
线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
(4)线程池有哪些状态?
RUNNING:接受新任务并处理排队的任务。
SHUTDOWN:不接受新任务,但处理排队的任务。
STOP:不接受新任务,不处理排队的任务,并中断正在进行的任务。
TIDYING:所有任务都已终止,workerCount 为零,线程转换到 TIDYING 状态将运行 terminated() 钩子方法。
TERMINATED:terminated() 已完成
(5)线程池里有个ctl,你知道它是如何设计的吗?
ctl是一个打包两个概念字段的AtomicInteger.
①workerCount:指示线程的有效数量
②runState:指示线程池的运行状态,有RUNNING/SHUTDOWN/STOP/TIDYING/TERMINATED状态
int类型占4字节有32位,其中ctl的低29位表示workerCount,高3位用于表示runState。
ctl为什么这么设计,有什么好处吗?
主要好处是将runState和workerCount的操作封装成一个原子操作。
runState和workerCount是线程池正常运转中的2个最重要的属性,线程池在某一时刻该做什么操作,取决于这2个属性的值。
无论是查询还是修改,我们必须保证对这2个属性的操作是属于"同一时刻"的,也就是原子操作,否则出现错乱的情况。如果使用2个变量分别存储,要保证原子性则需要额外进行加锁操作,这显然会带来额外的开销,而将这2个变量封装成1个AtomicInteger则不会带来额外的加锁开销,而且只需要使用简单的位操作就能分别得到runState和workerCount。
通过ctl得到runState,只需要通过位操作:ctl & ~CAPACITY。
通过 ctl 得到 workerCount 则更简单了,只需通过位操作:c & CAPACITY。
(6)任务调度流程
首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
这些问题多摘自上面美团技术博客和知乎专栏,更多的问题参考上面链接。
四、协程
枯燥的Kotlin协程三部曲(中)
真正的协程
①一种非抢占式/协作式 的任务调度模式,程序可主动挂起或恢复执行
②基于线程,相对于线程轻量很多,可理解为用户层模拟线程操作
③上下文切换由用户控制,避免大量中断参与,减少线程上下文切换
kotlin中的假协程
语言级别并没有实现一种同步机制(锁),还是依靠kotlin-jvm提供的java关键字,锁的实现还是交给线程处理,因此Kotlin的协程本质上只是一套【基于原生Java Thread API的封装】。只是这套API隐藏了异步实现细节,让我们可以用【同步的方法来写异步操作】罢了。
使用demo:
Kotlin-JVM的协程是假协程,只是对底层Thread的一次良好封装,通过在协程中使用
Thread.currentThread.name
可以在协程中打印出当前线程的名字,这里可以看到这个协程所用的线程名字为:DefaultDispatcher-worker-1。盲猜是线程池,毕竟高效的多线程调度基本离不开线程池。26行比21行先执行的原因是:创建线程池需要费点时间,所以协程里的代码没有主线程同步代码里的执行速度快。
delay()是一个人挂起函数,可在不堵塞线程的情况下延迟协程;Thread.sleep()则会堵塞当前线程。
这里的Thread.sleep(2000L)是不能去掉的,这里需要阻塞主线程来保证JVM存活。否则就会出现下面这种情况:
阻塞与非阻塞
delay()是非阻塞的;
Thread.sleep()是阻塞的;
runBlocking{}是阻塞的:
调用runBlocking的主线程会一直阻塞直到runBlocking内部的协程执行完毕。
runBlocking是一个全局函数,可在任意地方调用,不过 项目中用得不多,毕竟堵塞main线程意义不大,常用于单元测试防止JVM退出。
协程作用域:CoroutineScope
在 Android 环境中,通常每个界面(Activity、Fragment 等)启动的 Coroutine 只在该界面有意义,如果用户在等待 Coroutine 执行的时候退出了这个界面,则再继续执行这个 Coroutine 可能是没必要的。另外 Coroutine 也需要在适当的 context 中执行,否则会出现错误,比如在非 UI 线程去访问 View。 所以 Coroutine 在设计的时候,要求在一个范围(Scope)内执行,这样当这个 Scope 取消的时候,里面所有的子 Coroutine 也自动取消。所以要使用 Coroutine 必须要先创建一个对应的 CoroutineScope。
CoroutineScope是一个接口,其内部只定义了一个变量:coroutineContext ,也就是协程的上下文:
CoroutineScope只是定义了一个新的协程的作用域。每一个协程的builder(launch、async等)都是CoroutineScope的扩展方法,并且自动的继承了当前作用域的coroutineContext(协程上下文环境)和取消操作。
虽然CouroutineScope只有一个属性,但是却有很多扩展函数和扩展属性,比如launc/async/cancel等。
协程作用域可分为以下几种:
1.全局作用域:GlobalScope
可以看到GlobalScope是一个单例对象,生命周期贯穿整个JVM,使用的时候需要注意内存泄露。一般而言不会直接使用GlobalScope来创建协程。
2.自定义作用域
有两种方法:
①需要我们在Activity后者Fragment中实现CoroutineScope接口,实现协程上下文。同时需要在onDestroy中取消掉协程,防止内存泄露。
②使用MainScope()函数
为了在Android场景中更方便的使用,官方提供了MainScope()函数快速创建基于主线程协程作用域。
如下图(并不能运行哈,只是一个示例,没有上下文环境)
③使用coroutineScope()和supervisorScope()创建子作用域
作用域函数
创建协程函数
有两种创建协程的方式:
1.launch:
launch返回一个Job,用于协程监督与取消,用于无返回值的场景。
可以通过Job的start、cancel、join等方法来控制协程的启动和取消。
2.async:
async返回一个Job的子类Deferred,可通过await()获取完成时返回值。
如下:
挂起函数:suspend关键字
Kotlin协程提供了suspend关键字,用于定义一个挂起函数,它就是一个标记。
当我们写的普通函数需要在某些时刻挂起和恢复,加上这个关键字就行。
其真正作用是:
告知编译期,这个函数需要在协程中执行。编译器会将挂起函数用有限状态机转化为一种优化版的回调。
如上图,如果不加suspend,那么在这个方法中是不能使用delay方法的。
Job
调用launch函数会返回一个Job对象,代表一个协程的工作任务。
/**
* 协程状态
*/
isActive: Boolean //是否存活
isCancelled: Boolean //是否取消
isCompleted: Boolean //是否完成
children: Sequence<Job> // 所有子作业
/**
* 协程控制
*/
cancel() // 取消协程
join() // 堵塞当前线程直到协程执行完毕
cancelAndJoin() // 两者结合,取消并等待协程完成
cancelChildren() // 取消所有子协程,可传入CancellationException作为取消原因
attachChild(child: ChildJob) // 附加一个子协程到当前协程上
Job的生命周期包括一系列状态:
New(新创建)/Active(活跃)/Completing(完成中)
异常处理
Kotlin中异常处理有三种:
1.try-catch直接捕获作用域内异常:
无法使用try-catch捕获launch和async作用域的异常
2.全局异常处理(throw)
3.异常传播【关键点】
协程作用域中异常传播默认是双向的,其表现为:
①父协程发生异常,所有子协程都会取消
②子协程发生异常,会导致父协程取消,间接导致这个子协程的兄弟协程也取消。
有两种方式可以将异常传播变为单向,即子协程发生异常不会影响父协程及兄弟协程。其中一种方式是:
①用SupervisorJob代替Job
②另一种是使用自定义作用域函数supervisorScope
启动模式
launch&async第二个参数CoroutineStart,可以指定协程的启动模式:
协程的启动模式有四种:
// 默认,创建后立即开始调度,调度前被取消,直接进入取消响应状态。
DEFAULT,
// 懒加载,不会立即开始调度,需要手动调用start、join或await才会
// 开始调度,如果调度前就被取消,协程将直接进入异常结束状态。
LAZY,
// 和Default类似,立即开始调度,在执行到一个挂起函数前不响应取消。
// 涉及到cancle才有意义
@ExperimentalCoroutinesApi
ATOMIC,
// 直接在当前线程执行协程体,直到遇到第一个挂起函数,才会调度到
// 指定调度器所在的线程上执行
@ExperimentalCoroutinesApi
UNDISPATCHED;
协程调度器:CoroutineDispatcher
1.一种有四种:
- Default : 默认,线程池,适合处理后台计算,CPU密集型任务调度器
- IO : IO调度器,适合执行IO相关操作,IO密集型任务调度器
- MAIN : UI调度器,根据平台不同会初始化为对应UI线程的调度器,如Android的主线程
- Unconfined : 不指定线程,如果子协程切换线程,接下来的代码也在该线程继续
2.withContext
和launch/async/runBlocking不同的是,withContext不会创建新的协程,常用于切换代码执行所运行的【线程】。它也是一个挂起方法,直到结束返回结果。多个withContext是串行执行的,所以很适合那种一个任务依赖上一个任务返回结果的情况。
五、ThreadLocal
ThreadLocal 使用与原理
线程局部变量。ThreadLocal设计的目的就是为了能够在当前线程中有属于自己的变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
应用场景:
当很多线程需要多次使用同一个对象,并且需要该对象具有相同初始化值的时候最适合使用ThreadLocal。
ThreadLocal和synchronized的异同
①进行同步控制synchronized 效率降低 并发变同步(串行)
②使用ThreadLocal 本地线程 每个线程一个变量副本(各不相干,提升并发效率)
概括起来说,对于多线程资源共享的问题,同步机制采用了“以时间换空间”的方式,而ThreadLocal采用了“以空间换时间”的方式。前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响。
ThreadLocal原理解析
①每个Thread对象内部都维护了一个ThreadLocal的Map:ThreadLocalMap,这个map可以存放多个ThreadLocal。
②当我们调用get()方法时,先获取当前线程,然后获取当前线程的ThreadLocalMap对象。如果非空就取出ThreadLocal的value,否则进行初始化,初始化就是将initialValue的值set到ThreadLocal中。
③当我们调用set()方法时,就是拿到当前的线程对应的ThreadLocal,不为空,赋值。为空,创建ThreadLocalMap绑定Thread并赋值。
④ThreadLocalMap是以弱引用的ThreadLocal为key的,不是Thread!
⑤总结:当我们调用get方法的时候,其实每个当前线程中都有一个ThreadLocal。每次获取或者设置都是对该ThreadLocal进行的操作,是与其他线程分开的。
ThreadLocal内存泄漏
ThreaLocal内存泄漏
threadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉。
但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
永远无法回收,造成内存泄漏。
ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
ThreadLocal正确用法
①每次使用完ThreadLocal都调用它的remove()方法清除数据
②将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。【没get到。。。】
package com.dawn.zgstep.threads.objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TestThreadLocal {
private static int count = 0;
ThreadLocal<String> threadLocal = new ThreadLocal<>();
private void testThreadLocal() {
ExecutorService executorService = Executors.newFixedThreadPool(20);
for (int i = 0; i < 20; i++) {
int finalI = i;
executorService.submit(() -> {
threadLocal.set(Thread.currentThread().getName());
soutLocal();
});
}
}
private void soutLocal(){
System.out.println(threadLocal.get());
}
public static void main(String[] args) {
TestThreadLocal testThreadLocal = new TestThreadLocal();
testThreadLocal.testThreadLocal();
}
}
//结果:
pool-1-thread-1
pool-1-thread-3
pool-1-thread-5
pool-1-thread-2
pool-1-thread-4
pool-1-thread-6
pool-1-thread-7
pool-1-thread-8
pool-1-thread-9
pool-1-thread-10
pool-1-thread-11
pool-1-thread-12
pool-1-thread-13
pool-1-thread-14
pool-1-thread-15
pool-1-thread-16
pool-1-thread-17
pool-1-thread-18
pool-1-thread-19
pool-1-thread-20
总结:
①每个线程都有一个ThreadLocalMap 类型的 threadLocals 属性。
②ThreadLocalMap 类相当于一个Map,key 是 ThreadLocal 本身,value 就是我们的值。
③当我们通过 threadLocal.set(new Integer(123)); ,我们就会在这个线程中的 threadLocals 属性中放入一个键值对,key 是这个threadLocal.set(new Integer(123)) 的 threadlocal,value就是值new Integer(123)。
④当我们通过 threadlocal.get() 方法的时候,首先会根据这个线程得到这个线程的 threadLocals 属性,然后由于这个属性放的是键值对,我们就可以根据键 threadlocal 拿到值。 注意,这时候这个键 threadlocal 和 我们 set 方法的时候的那个键 threadlocal 是一样的,所以我们能够拿到相同的值。
⑤ThreadLocalMap 的get/set/remove方法跟HashMap的内部实现都基本一样,通过 "key.threadLocalHashCode & (table.length - 1)" 运算式计算得到我们想要找的索引位置,如果该索引位置的键值对不是我们要找的,则通过nextIndex方法计算下一个索引位置,直到找到目标键值对或者为空。
⑥hash冲突:在HashMap中相同索引位置的元素以链表形式保存在同一个索引位置;而在ThreadLocalMap中,没有使用链表的数据结构,而是将(当前的索引位置+1)对length取模的结果作为相同索引元素的位置:源码中的nextIndex方法,可以表达成如下公式:如果i为当前索引位置,则下一个索引位置 = (i + 1 < len) ? i + 1 : 0。
六、网络相关
必看网络面试1
这个挺详细,必须得看
网络相关查漏补缺
比如状态码等。
下面一些问题是上面提的不全面的,这里做一下简单总结
1.Http1.0、Http1.1、Http2.0区别
参考资料
HTTP1.0最早在网页中使用是在1996年,那个时候只是使用一些较为简单的网页上和网络请求上,而HTTP1.1则在1999年才开始广泛应用于现在的各大浏览器网络请求中,同时HTTP1.1也是当前使用最为广泛的HTTP协议。
主要区别:
①带宽优化及网络连接的使用:
Http1.1在请求头引入了range头域,允许只请求资源的某个部分,返回码是206.这是我们断点续传的基础!
②新增了24个错误状态响应码。比如409表示请求的资源与资源的当前状态发生冲突;410表示服务器上的某个资源被永久删除。
③ Host头处理;Http1.0中认为每台服务器都绑定一个唯一的ip。但随后的虚拟主机的发展,多个虚拟主机共享一个IP地址。HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会报告一个错误
④长连接。Http1.1支持长连接和请求的流水线处理。在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟。在Http1.1中默认开启Connection:keep-alive选项,一定程度上弥补了Http1.0每次请求都要创建连接的缺点。
HTTP2.0和HTTP1.X相比的新特性:
新的二进制格式:HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮,不同于HTTP1.x的解析是基于文本
多路复用:连接共享,即每一个request都是是用作连接共享机制的。一个request对应一个id,这样一个连接上可以有多个request。
服务端推送:服务器主动向客户端推送消息
2.HTTPS与HTTP的一些区别
HTTPS协议需要到CA申请证书,一般免费证书很少,需要交费。
HTTP协议运行在TCP之上,所有传输的内容都是明文,HTTPS运行在SSL/TLS之上,SSL/TLS运行在TCP之上,所有传输的内容都经过加密的。
HTTP和HTTPS使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
HTTPS可以有效的防止运营商劫持,解决了防劫持的一个大问题。
3.TCP和UDP
TCP传输控制协议:面向连接;使用全双工的可靠信道;提供可靠的服务,即无差错、不丢失、不重复且按序到达;拥塞控制、流量控制、超时重发、丢弃重复数据等等可靠性检测手段;面向字节流;每条TCP连接只能是点到点的;用于传输可靠性要求高的数据
UDP用户数据报协议:无连接;使用不可靠信道;尽最大努力交付,即不保证可靠交付;无拥塞控制等;面向报文;支持一对一、一对多、多对一和多对多的交互通信;用于传输可靠性要求不高的数据
TCP的三次握手、四次挥手
参考博客
TCP提供了一种可靠、面向连接、字节流、传输层的服务,采用三次握手建立一个连接。采用4次挥手来关闭一个连接。
三次握手
第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN(c)。此时客户端处于 SYN_SEND 状态。
首部的同步位SYN=1,初始序号seq=x,SYN=1的报文段不能携带数据,但要消耗掉一个序号。
第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且指定了自己的初始化序列号 ISN(s)。同时会把客户端的 ISN + 1 作为ACK 的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_RCVD 的状态。
在确认报文段中SYN=1,ACK=1,确认号ack=x+1,初始序号seq=y。
第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK 的值,表示已经收到了服务端的 SYN 报文,此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态,此时,双方已建立起了连接。
确认报文段ACK=1,确认号ack=y+1,序号seq=x+1(初始为seq=x,第二个报文段所以要+1),ACK报文段可以携带数据,不携带数据则不消耗序号。
问:为什么需要三次,两次不行吗?
假如现在客户端想向服务端进行握手,它发送了第一个连接的请求报文,但是由于网络信号差或者服务器负载过多,这个请求没有立即到达服务端,而是在某个网络节点中长时间的滞留了,以至于滞留到客户端连接释放以后的某个时间点才到达服务端,那么这就是一个失效的报文,但是服务端接收到这个失效的请求报文后,就误认为客户端又发了一次连接请求,服务端就会想向客户端发出确认的报文,表示同意建立连接。
假如不采用三次握手,那么只要服务端发出确认,表示新的建立就连接了。但是现在客户端并没有发出建立连接的请求,其实这个请求是失效的请求,一切都是服务端在自相情愿,因此客户端是不会理睬服务端的确认信息,也不会向服务端发送确认的请求,但是服务器却认为新的连接已经建立起来了,并一直等待客户端发来数据,这样的情况下,服务端的很多资源就没白白浪费掉了
四次握手
为什么建立连接是三次,断掉连接是四次呢?
我想可能是因为相见时难别亦难吧。
刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求:
第一次挥手:客户端发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN_WAIT1 状态。
即发出连接释放报文段(FIN=1,序号seq=u),并停止再发送数据,主动关闭TCP连接,进入FIN_WAIT1(终止等待1)状态,等待服务端的确认。
第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。
即服务端收到连接释放报文段后即发出确认报文段(ACK=1,确认号ack=u+1,序号seq=v),服务端进入CLOSE_WAIT(关闭等待)状态,此时的TCP处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入FIN_WAIT2(终止等待2)状态,等待服务端发出的连接释放报文段。
第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态。(中间多了这一步,所以是四次)
即服务端没有要向客户端发出的数据,服务端发出连接释放报文段(FIN=1,ACK=1,序号seq=w,确认号ack=u+1),服务端进入LAST_ACK(最后确认)状态,等待客户端的确认。
第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。
即客户端收到服务端的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),客户端进入TIME_WAIT(时间等待)状态。此时TCP未释放掉,需要经过时间等待计时器设置的时间2MSL后,客户端才进入CLOSED状态。
七、Glide源码解析
Glide源码解析参考一
Glide源码解析参考二,基于.9.0
截止目前为止:2021/5/17, Glide最新版本是4.12.0,本人以4.12.0进行分析。
Glide架构
可以看到,Glide采用五层架构设计,从高到低依次是:
Request-->Engine-->Get Data-->Data-->Resource
按照逻辑功能划分可以分为以下几种:
Glide 是单例类,通过 Glide#get(Context) 方法可以获取到实例。
Glide 类算是个全局的配置类,Encoder、Decoder、ModelLoader、Pool 等等都在这里设置,此外还提供了创建 RequestManager 的接口(Glide#with() 方法)
使用Glide时最先调用Glide#with()方法创建RequestManager,有五个重载方法;
如源码注释所说,RequestManager是Glide的请求管理类。
我们根据最上层的调用分析源码:
1.with方法
首先判断是否在主线程中调用;如果是在子线程中调用,则调用get(activity.getApplicationContext()),也就是使用Application的Context。
with方法总结:
通过RequestManagerRetriever的get获取RequestManagerRetriever单例对象
通过retriever.get(context)获取RequestManager,在get(context)方法中通过对context类型的判断做不同的处理:
context是Application,通过getApplicationManager(Context context) 创建并返回一个RequestManager对象
context是Activity,通过supportFragmentGet(activity, fm)在当前activity创建并添加一个没有界面的fragment,从而实现图片加载与activity的生命周期相绑定,之后创建并返回一个RequestManager对象
最终在RequestManager中完成对Glide对象的初始化Glide.get(context)
在supportFragmentGet方法中,会调用Glide#get方法,获取Glide实例,此方法中会检查glide字段是否为null,如果没有初始化,则调用initializeGlide方法初始化Glide。Glide#get方法里可以看到初始化逻辑是用的DCL单例来进行初始化的。
Glide的创建是通过GlideBuilder也就是建造者模式创建的:
这个GlideBuilder#build方法是Glide创建的核心方法,我们来仔细分析一下build方法:
可以看到build中首先新建了三个线程池:
1.sourceExecutor:负责处理内存缓存(??)
2.diskCacheExecutor:负责处理磁盘缓存
3.animationExecutor:负责加载Gif动图的每一帧图像
这三个线程池底层都是GlideExecutor的build方法中通过ThreadPoolExecutor创建的 corePoolSize和maximumPoolSize都不相同:
接着创建了Bitmap池,也就是LruBitmapPool和BitmapPoolAdapter;
以及数组池:LruArrayPool。
重头戏来了,创建的memoryCache就是面试中常被问的缓存策略。
可以看到Glide采用的是LruCache三级缓存。(这里注意这里的LruCache是Glide的类)
2.load()方法
创建完Glide对象接下来就是将图片加载进来了。load方法实际调用的就是RequestManager的load方法。返回一个RequestBuilder。
RequestBuilder用来构建请求,例如设置RequestOptions、缩略图、加载失败占位图等。
这里返回的RequestBuilder使用的是克隆模式,用了一次深拷贝。
深拷贝与浅拷贝的区别
3.into()方法
最重要的就是这个方法了。调用RequestBuilder的into方法:
into方法首先会检测是否在主线程,如果不是在主线程中调用的into方法,则直接抛出异常:IllegalArgumentException
接着构建requestOptions。
这两步完成后,调用into的重载方法传入创建好的ViewTarget以及主线程池(这是第四个线程池了,前面创建Glide的时候,创建了三个线程池)。
这个主线程池会将所有的任务都传到主线程中进行处理。
在其重载的into方法中:
会创建Request,也就是封装的请求,这是个接口,定义了对请求的开始、结束、状态获取、回收等操作。所以请求中不仅包含基本的信息,还负责管理请求。
Request的实现类有三个:
①SingleRequest
②ThumbnailRequestCoordinator
③ErrorRequestCoordinator
我们依次分析。
1.SingleRequest
这个类负责执行请求并将结果反映到Target上
当我们使用 Glide 加载图片时,会先根据 Target 类型创建不同的 Target(比如ImageView会创建ViewTarget),然后 RequestBuilder 将这个 target 当做参数创建 Request 对象,Request 与 Target 就是这样关联起来的。
虑到性能问题,可能会连续创建很多个 SingleRequest 对象,所以使用了对象池来做缓存。
into方法里会调用Request的begin方法
当我们调用Request#begin方法是并不会直接发起请求,而是等待ImageView初始化完成。对于ViewTarget及其子类来说,会注册View的onPreDrawListener事件,等待View初始化完成后调用SingleRequest#onSizeReady方法,这个方法里就是加载图片的入口。
添加监听的路径:
在SingleRequest#begin里:
初始化完成回调:
所以要看图片怎么加载的,还是要看onSizeReady方法里怎么处理的:
View初始化完成后,调用Engine#load方法加载图片:
Engine#load方法中会根据宽高、签名、request options等参数构建EngineKey,并以此为键去缓存中查找是否有文件缓存,调用的是#loadFromMemory方法。如果没有缓存,则使用现存或者开启新的任务来加载图片。
我们接下里分析一下Engine#loadFromMemory,也就是Glide的缓存处理:
可以看到这里有两级缓存,先是从#loadFromActiveResources中获取EngineResource(ActiveResource中有个HashMap,value是我们图片的key,value是ResourceWeakRefenerce,ResourceWeakReference中持有了我们的图片的Resource对象)。
如果支持内存緩存则存入缓存,否则为null。
如果不为空,则直接返回;若为空,再调用#loadFromCache获取磁盘缓存返回;若为空,则调用#waitForExistingOrStartNewJob通过网络去加载图片。当图片加载完成,调用onResourceReady方法分发给各个Target去设置图片。然后调用notifyLoadSuccess方法通知ThumbnailRequestCoordinator图片加载成功。
**Glide磁盘缓存策略分为四种,默认的是RESULT(默认值这一点网上很多文章都写错了,但是这一点很重要):
1.ALL:缓存原图(SOURCE)和处理图(RESULT)
2.NONE:什么都不缓存
3.SOURCE:只缓存原图(SOURCE)
4.RESULT:只缓存处理图(RESULT) —默认值**
我们来仔细分析一下【怎么通过网络进行图片加载的】:
#waitForExistingOrStartNewJob方法中,会封装一个DecodeJob,将DecodeJob放到EngineJob持有的GlideExecutor也就是Glide加载图片的线程池中运行。(其中DecodeJob是解码资源和转换的类,EngineJob是加载调度类,主要就是处理根中回调)DecodeJob实现了Runnable接口,那么我们就可以去其#run()方法中查看了:
拿到DataFetcher对象,调用runWrapped方法-->runGenerators()-->DataFetcherGenerator#startNext()--->SourceGenerator#startNextLoad():
最后调用到DataFetcher#loadData()方法
DataFetcher是一个接口,负责数据加载。其实现类如下:
其网络加载的实现类,基本上就可以确定是HttpUrlFetcher.
果不其然,看一下其#loadData方法:
调用#loadDataWithRedirects方法获取输入流,并调用
DataCallback#onDataReady回调,如果IO异常,调用onLoadFailed方法。
在loadDataWithRedirects方法中建立HttpURLConnection加载图片:
这就是Glide加载网络图片的简单分析!
2.ThumbnailRequestCoordinator
这个类就是协调两个请求,因为有的请求需要同时加载原图和缩略图,比如启动这两个请求,原图加载完成后缩略图就不用等待了等等,这些控制都由这个类控制。
3.ErrorRequestCoordinator
当加载失败时可能希望通过网络或者本地资源加载另一张错误占位图,就是通过此类协调ThumbnailRequestCoordinator以及error中的Request。
Target(Glide中很重要的概念!)
Target代表一个可被Glide加载并具有生命周期的资源。当我们调用RequestBuilder#into方法时,会根据传入参数创建对应类型的Target实现类。
其角色就是指加载完成的图片应该放在哪。是setImageDrawable还是setBackgroudDrawable等。
Target的实现很多:
我们这里只看几个常用到的:
1.CustomViewTarget & ViewTarget
抽象类,负责加载Bitmap、Drawable并且放到View上。
ViewTarget:所有View相关的Target都是继承ViewTarget,但是已经被标记为过期类,推荐将ViewTarget替换成CustomViewTarget。
2.ImageViewTarget
是加载到ImageView上的Target,继承自ViewTarget,同样也是个抽象类。
构造器中限定了必须传入ImageView或者其子类,图片数据加载完成后会回调其中的onResourceReady方法,第一步是将图片设置给ImageView,第二步是判断是否需要使用动画,需要的话就执行动画。
虽然目前有5个子类,但主要用于区分加载的资源是Bitmap还是Drawable类型。
3.RequestFutureTarget
用来同步加载图片的Target,调用RequestBuilder#sumbit将会返回一个FutureTarget,调用get方法即可获取到加载的资源对象。
4.AppWidgetTarget
用于将下载的 Bitmap 设置到 RemoteView 上。
其他的暂且不分析了。
还有几个重要的概念需要知道:
1.DataFetcher
是一个接口,最重要的方法还loadData,也就是加载数据
内部是通过HttpUrlConnection发起网络请求、打开文件或者使用AssetManager打开一个资源等等。
加载完成后通过DataFetcher$DataCallback接口回調。
此接口有两个方法
分别代表数据加载成功或者加载失败回调。
2.Encoder
也是一个接口。用来将给定的数据写入文件中
如注释所写,就是把data存入文件中。
数据加载完成后会先使用Encoder将数据存入本地磁盘缓存文件中。
缓存目录,我查看的,可能是下面这个:
Encoder对应的实现类是在Glide初始化时注册进去的。
3.ResourceDecoder
与Encoder对应,数据解码器,用来将原始数据解码成相应的数据类型。
针对不同的请求实现类都不同,例如通过网络请求最终获取到的是一个 InputStream,经过 ByteBufferBitmapDecoder 解码后再生成一个 Bitmap。
需要指出的是,这里解码时会根绝 option 以及图片大小(如果有的话)按需加载 Bitmap,防止内存的浪费。【按需加载在这里!!!】
与 Encoder 一样,Glide 初始化时会注册很多个类型的 ResourceDecoder 实现类,图片数据获取到之后会根据不同的类型使用对应的解码器对其解码。
4.Engine
执行引擎,算是整个 Glide 的核心发动机。
Engine 负责管理请求以及活动资源、缓存等。主要关注 load 方法,这个方法主要做了如下几件事:
通过请求构建 Key;
从活动资源中获取资源(详见缓存章节),获取到则返回;
从缓存中获取资源,获取到则直接返回;
判断当前请求是否正在执行,是则直接返回;
构建 EngineJob 与 DecodeJob 并执行。
Glide生命周期管理
Glide Bitmap复用机制
【Glide生命周期管理】
Glide的生命周期处理逻辑由RequestManagerRetriever#get方法出发:
此方法有五个重载方法:
我们主要分析传入FragmentActivity参数的#get方法
1.如果当前是在子线程中就是用Application的context,也就是生命周期和Application保持一致。
2.如果不是在子线程中,则先判断activity是否销毁。拿到当前Activity的FragmentManager,通过#supportFragmentGet方法创建一个Fragment。
可以看到调用RequestManagerRetriever#getSupportRequestManagerFragment方法创建一个Fragment.
3.从SupportRequestManagerFragment中获取RequestManager,如果为空,则直接创建RequestManager并绑定此Fragment上。
创建RequestManager对象过程中,会传入ActivityFragmentLifecycle。这样RequestManager也就是我们的请求管理类中也有了监控生命周期的能力。
如图一所标。
我们看一下RequestManager是怎么通过RequestManagerFactory#build创建的
通过默认的RequestManagerFactory: DEFAULT_FACTORY的build方法中直接new了一个RequestManager。【这个写法蛮有意思的,之前没这么写过,抄,必须抄】
看一下RequestManager构造方法里的逻辑处理:
将当前对象注册到ActivityFragmentLifecycle中,如果是子线程则通过Handler将当前对象注册到ActivityFragmentLifecycle中。
同时添加网络监听。
自然RequestManager实现了LifecycleListener接口,并实现了接口中的方法。Fragment生命周期变化时会主动通知lifecycle执行相关方法。
可以看到在onStart、onStop和onDestory生命周期中,RequestManager会对请求进行相应处理。(resumeRequest、pauseRequest等)
从上面几点的阐述可以看出,构造RequestManager的时候就将RequestManager的生命周期与Fragment关联起来了。
下面就是【简单的总结】:
Glide生命周期回调流程:
Activity/Fragment->RequestManagerFragment->ActivityFragmentLifecycle->RequestManager->根据生命周期变化做业务处理
** Glide.with(this)绑定了Activity的生命周期。在Activity内新建了一个无UI的Fragment,这个Fragment持有一个Lifecycle,通过Lifecycle在Fragment关键生命周期通知RequestManager进行相关从操作。在生命周期onStart时继续加载,onStop时暂停加载,onDestory时停止加载任务和清除操作。**
【灵魂发问】Glide为什么要新建一个无界面的Fragment来管理生命周期呢?
参考资料
图片的一般是异步的,异步经常面临的问题是内存泄露和异步加载回来view已经销毁导致的空指针问题。而Glide在使用的时候只要求传入当前加载的view或者context,且没有用setLifeCyclelistener什么的方法就实现了生命周期的管理。这就是Glide为什么要是用空白Fragment来管理生命周期了,这样我们上层使用的时候就不需要自己来管理图片加载的生命周期了。
空白Fragment的其他妙用就是:
动态申请权限,对于不是必要的权限不同意就退出app体验会很糟糕,我们就就可以将这些权限放在空白Fragment中,哪个地方需要申请就持有当前Fragment即可。
BitmapPool(Glide是如何实现Bitmap复用的?)
BitmapPool分析
首先明白两个概念:
池化思想
核心是复用
复用相同的资源,减少浪费,减少新建和销毁的成本;
减少单独管理的成本,统一交由"池";
集中管理,减少"碎片";
提高系统响应速度,因为池中有现成的资源,不用重新去创建;
池化思想在Android中的运用:
1.线程池
2.Handler Message池
3.Java内存池
inBitmap参数
在 Android 3.0 上面引入了 BitmapFactory.Options.inBitmap 字段。如果设置了此选项,那么采用 Options 对象的解码方法会在加载内容时尝试重复使用现有位图。这样可以复用现有的 Bitmap,减少对象创建,从而减少发生 GC 的概率。不过,inBitmap 的使用方式存在某些限制。特别是在 Android 4.4(API 级别 19)之前,系统仅支持大小相同的位图。在 Android 4.4 之后的版本,只要内存大小不小于需求的 Bitmap 都可以复用。
在 Android 3.0 上面引入了 BitmapFactory.Options.inBitmap 字段。如果设置了此选项,那么采用 Options 对象的解码方法会在加载内容时尝试重复使用现有位图。这样可以复用现有的 Bitmap,减少对象创建,从而减少发生 GC 的概率。不过,inBitmap 的使用方式存在某些限制。特别是在 Android 4.4(API 级别 19)之前,系统仅支持大小相同的位图。在 Android 4.4 之后的版本,只要内存大小不小于需求的 Bitmap 都可以复用。
BitmapPool是一个接口,有两个实现类:
1.BitmapPoolAdapter
2.LruBitmapPool
默认实现是LruBitmapPool。inBitmap 以 Android 4.4 为分水岭,之前和之后的版本在使用上存在版本差异,那么 BitmapPool 是如何处理这个差异的呢?答案是策略模式。Glide 定义了 LruPoolStrategy 接口,该接口内部定义了增删相关操作。真实的 Bitmap 数据根据尺寸和颜色等映射关系存储到 LruPoolStrategy 中。BitmapPool 的 get 和 put 也是通过 LruPoolStrategy 的 get 和 put 完成的。
LruPoolStrategy 默认提供了三个实现,分别是 AttributeStrategy、SizeConfigStrategy 和 SizeStrategy. 其中,AttributeStrategy 适用于 Android 4.4 以下的版本,SizeConfigStrategy 和 SizeStrategy 适用于 Android 4.4 及以上的版本。
AttributeStrategy 通过 Bitmap 的 width(图片宽度)、height(图片高度) 和 config(图片颜色空间,比如 ARGB_8888 等) 三个参数作为 Bitmap 的唯一标识。当获取 Bitmap 的时候只有这三个条件完全匹配才行。而 SizeConfigStrategy 使用 size(图片的像素总数) 和 config 作为唯一标识。当获取的时候会先找出 cofig 匹配的 Bitmap(一般就是 config 相同),然后保证该 Bitmap 的 size 大于我们期望的 size 并且小于期望 size 的 8 倍即可复用(可能是为了节省内存空间)。
所谓的 LRU 就是 BitmapPool 通过 LruPoolStrategy 实现的,具体操作是,在往 BitmapPool 中 put 数据之后会执行下面的操作调整空间大小
回顾一下Bitmap加载的一般流程:
// 设置 inJustDecodeBounds 为 true 来获取图片尺寸
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), options);
// 设置 inJustDecodeBounds 为 false 来真正加载
options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight);
options.inJustDecodeBounds = false;
BitmapFactory.decodeFile(file.getAbsolutePath(), options);
也就是说,首先通过设置 options.inJustDecodeBounds 为 true 来获取图片真实的尺寸,以便设置采样率。因为我们一般不会直接加载图片的所有的像素,而是采样之后再按需加载,以减少图片的内存占用。当真正需要加载的时候,设置 options.inJustDecodeBounds 为 false,再调用 decode 相关的方法即可。
那么 Bitmap 复用是如何使用的呢?很简单,只需要在加载的时候通过 options 的 inBitmap 参数指定一个 Bitmap 对象再 decode 即可:
options.inBitmap = bitmapPool.getDirty(width, height, expectedConfig);
Glide如何加载Bitmap
Glide在初始化的时候,在GlideBuilder#build中会创建BitmapPool,在Glide构造方法中,会创建Downsampler。Glide的Bitmap加载流程就位于Downsampler类中。
从其他渠道,比如网络或者磁盘中获取到一个输入流 InputStream 之后就可以进行图片加载了。执行流程在其#decodeFromWrappedStreams方法中:
首先通过设置inJustDecodeBounds读取图片的原始尺寸信息
根据要求计算需要记载的图片大小和config,计算结果直接设置给bitmap的options
根据图片的期望尺寸到BitmapPool获取一个Bitmap以复用。
然后执行decodeStream逻辑,获取到我们的目标Bitmap。
Bitmap池复用逻辑如上所述。
Glide 首先会通过设置 inBitmap 复用的方式加载图片。如果这个过程中出现了异常,因为此时 inBitmap 不为空,所以将会进入异常处理流程,此时会清理掉 inBitmap,再次调用 decodeStream 方法二次加载,这个时候就不是 Bitmap 复用的了。所以,Glide 内部会通过错误重试机制进行 Bitmap 复用,当复用并出现错误的时候,会降级为非复用的方式第二次进行加载。
为什么要进行三级缓存?
三级缓存策略,最实在的意义就是减少不必要的流量消耗,增加加载速度。
Bitmap 的创建非常消耗时间和内存,可能导致频繁GC。而使用缓存策略,会更加高效地加载 Bitmap,减少卡顿,从而减少读取时间。
而内存缓存的主要作用是防止应用重复将图片数据读取到内存当中,硬盘缓存则是防止应用重复从网络或其他地方重复下载和读取数据。
Glide是如何清理缓存的
Glide实现了ComponentCallbacks2接口,实现了onTrimMemory方法。此方法会在系统回收不需要的内存时调用:
其中调用了Glide的trimeMomery方法,此方法中会清理memoryCache、bitmapPool、arrayPool。
memoryCache会调用LruResourceCache的trimMemory,最终调用LruCache的trimToSize方法清除内存缓存。
LruCache原理解析
参考博客一
参考博客二
Lru -->Least Recently Used,最近最少使用.
LruCache是个泛型类,主要算法原理是把最近使用的对象用强引用(即我们平常使用的对象引用方式)存储在 LinkedHashMap 中。当缓存满时,把最近最少使用的对象从内存中移除,并提供了get和put方法来完成缓存的获取和添加操作。
LruCache里最重要的就是使用LinkedHashMap存储缓存数据,为什么使用LinkedHashMap,这个我们后面会说。
除了这个map,还有两个参数比较重要:
size:当前缓存已使用大小。
maxSize:LruCache能使用的内存的最大值。
先分析其get和put方法:
1.get()
通过key获取缓存的数据,如果通过这个方法得到了需要的元素,那么这个元素会被放到缓存队列的头部。可以理解成最近常用的元素,不会在缓存空间不足时被清理掉。
这里加了synchronized,同步代码块,所以LruCache的get是线程安全的。
如果通过key从缓存结合中获取不到缓存数据,就尝试使用create方法创造一个新数据并放入缓存中。当然需要我们重写create(key)方法,不重写默认为null。
2.put()
将创建的新元素添加到缓存队列,并添加成功后返回这个元素。 在同步代码块中size就是对象占用内存的大小(所有对象占用内存总和,所以是+=),所以我们在缓存图片时需要重写sizeOf方法,如果添加失败size将会减去图片内存大小。
因为加了synchronized,所以可以看出put也是线程安全的操作。
最后调用了trimToSize方法,修改缓存大小,使已经使用的缓存(size)不大于设置的缓存最大值(maxSize)
接下来我们说一下最重要的点:
【为什么使用LinkedHashMap进行缓存】?
参考博客
这个跟算法有关,LinkedHashMap刚好能提供LruCache所需要的最近最少使用算法。
LinkedHashMap内部本来就有个排序功能,当第三个参数是true的时候,数据在被访问的时候就会排序,这个排序的结果就是把最近访问的数据放到集合的最后面。
我们在LruCache的构造参数中new LinkedHashMap的时候,第三个参数accessOrder就是传的true。
LinkedHashMapEntry的定义
LinkedHashMap内部是使用双向循环链表来存储数据的。也就是每一个元素都持有它上一个元素地址和下一个元素地址,元素实体类就是LinkedHashMapEntry。
其定义如下:
当集合的get方法被调用时,会调用afterNodeAccess方法(jdk中是recordAccess)。如果accessOrder为true,就把这个元素放在集合的最末端。
LinkedHashMap#get()方法内容很简单:
就是判断accessOrder是否为true,如果为true,调用afterNodeAccess,然后返回节点Node的value。
排序过程:
①当LinkedHashMap初始化的时候会初始化一个头结点head;
这个头结点的前节点和后节点都指向自己。
②当获取一个节点的时候,会拿到的当前节点p的前节点b和后节点a,
首先将后节点置为null, 将后节点a的前节点指定为b;接着拿到最后一个节点tail,将当前节点p指定为tail的前节点,这样当前节点就为链表的最后一个元素。
这样就完成了一次Lru排序。
将最近访问的数据放在了链表的结尾,链表越靠前的越不常用,缓存空间不够就优先清除前面的。
LinkedHashMap还有一个方法eldest,返回最近最少使用的元素:
返回的就是链表的头结点head。
#timeToSize
这是LruCache核心方法之一了,get和put都可能会执行此方法。
这个方法会检查已用的缓存大小和设置的最大缓存大小。当发现需要进行删除数据来腾出缓存空间的时候,会调用LinkedHashMap的eldest()方法来删除头结点,也就是最近最少使用的节点。
这就是LruCache的大致工作原理。
手写三级缓存
19年写过,现在是21年5月10号,我就直接截图了:
这里LruCache大小为运行缓存的八分之一。