裸辞-疫情-闭关-复习-大厂offer(二)
引子
2022 年 3 月辞职,没多久上海爆发疫情,蜗居在家准备面试。在经历 1 个月的闭关和 40+ 场 Android 面试后,拿到一些 offer。
总体上说,有如下几种面试题型:
- 基础知识
- 算法题
- 项目经历
- 场景题
场景题,即“就业务场景给出解决方案”,考察运用知识解决问题的能力。这类题取决于临场应变、长期积累、运气。
项目经历题取决于对工作内容的总结提炼、拔高升华、运气:
- 争取到什么样的资源
- 安排了怎么样的分工
- 搭建了什么样的架构
- 运用了什么模式
- 做了什么样的取舍
- 采用了什么策略
- 做了什么样的优化
- 解决了什么问题
力争把默默无闻的“拧螺丝”说成惊天动地的“造火箭”。(这是一门技术活)
但也不可避免地会发生“有些人觉得这是高大上的火箭,有些人觉得不过是矮小下的零件”。面试就好比相亲,甲之蜜糖乙之砒霜是常有的事。除非你优秀到解决了某个业界的难题。
算法题取决于刷题,运气,相较于前两类题,算法题可“突击”的成分就更多了。只要刷题足够多,胜算就足够大。大量刷,反复刷。
基础知识题是所有题型中最能“突击”的,它取决于对“考纲”的整理复习、归纳总结、背诵、运气。Android 的知识体系是庞杂的,对于有限的个人精力来说,考纲是无穷大的。
这不是一篇面经,把面试题公布是不讲武德的。但可以分享整个复习稿,它是我按照自己划定的考纲整理出的全部答案。
整个复习稿分为如下几大部分:
- Android
- Java & Kotlin
- 设计模式 & 架构
- 多线程
- 网络
- OkHttp & Retrofit
- Glide
由于篇幅太长,决定把全部内容分成两篇分享给大家。其中,Android 和 Java & Kotlin 已经在第一篇分享过,这一篇的内容是剩下的加粗部分。
设计模式/原则 & 架构
设计原则
- 单一职责原则:关于内聚的原则。高内聚、低耦合的指导方针,类或者方法单纯,只做一件事情
- 接口隔离原则:关于内聚的原则。要求设计小而单纯的接口(将过大的接口拆分),或者说只暴露必要的接口
- 最少知识法则
- 关于耦合的原则。要求类不要和其他类发生太多关联,达到解耦的效果
- 从依赖者的角度来说,只依赖应该依赖的对象。
- 从被依赖者的角度说,只暴露应该暴露的方法。
- 对象方法的访问范围应该受到约束:
- 对象本身的方法
- 对象成员变量的方法
- 被当做参数传入对象的方法
- 在方法体内被创建对象的方法
- 不能调用从另一个调用返回的对象的方法
- 开闭原则:关于扩展的原则。对扩展开放对修改关闭,做合理的抽象就能达到增加新功能的时候不修改老代码(能用父类的地方都用父类,在运行时才确定用什么样的子类来替换父类),开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段
- 里氏替换原则
- 为了避免继承的副作用,若继承是为了复用,则子类不该改变父类行为,这样子类就可以无副作用地替换父类实例,若继承是为了多态,则因为将父类的实现抽象化,
- 依赖倒置原则:即是面向接口编程,面向抽象编程,高层模块不该依赖底层模块,而是依赖抽象(比萨店不应该依赖具体的至尊披萨,而应该依赖抽象的披萨接口,至尊披萨也应该依赖披萨接口)
单例模式
目的:在单进程内保证类唯一实例
- 静态内部类:虚拟机保证一个类的初始化操作是线程安全的,而且只有使用到的时候才会去初始化,缺点是没办法传递参数
- 双重校验:第一校验处于性能考虑,若对象存在直接返回,不需要加锁。第二次校验是为了防止重复构建对象。对象引用必须声明为 volatile,通过保证可见性和防止重排序,保证单例线程安全。因为
INSTANCE = new instance()
不是原子操作,由三个步骤实现1.分配内存2.初始化对象3.将INSTANCE指向新内存,当重排序为1,3,2时,可能让另一个线程在第一个判空处返回未经实例化的单例。
工厂模式
- 目的:解耦。将对象的使用和对象的构建分割开,使得和对象使用相关的代码不依赖于构建对象的细节
- 增加了一层“抽象”将“变化”封装起来,然后对“抽象”编程,并利用”多态“应对“变化”,对工厂模式来说,“变化”就是创建对象。
- 实现方式
- 简单工厂模式
- 将创建具体对象的代码移到工厂类中的静态方法。
- 实现了隐藏细节和封装变化,对变化没有弹性,当需要新增对象时需要修改工厂类
- 工厂方法模式
- 在父类定义一个创建对象的抽象方法,让子类决定实例化哪一个具体对象。
- 特点
- 只适用于构建一个对象
- 使用继承实现多态
- 抽象工厂模式
- 定义一个创建对象的接口,把多个对象的创建细节集中在一起
- 特点:使用组合实现多态
- 简单工厂模式
建造者模式
- 目的:简化对象的构建
- 它是一种构造复杂对象的方式,复杂对象有很多可选参数,如果将所有可选参数都作为构造函数的参数,则构造函数太长,建造者模式实现了分批设置可选参数。Builder模式增加了构造过程代码的可读性
- Dialog 用到了这个模式
观察者模式
目的:以解耦的方式进行通信。将被观察者和具体的观察行为解耦。
- 是一种一对多的通知方式,被观察者持有观察者的引用。
- ListView的BaseAdapter中有DataSetObservable,在设置适配器的时候会创建观察者并注册,调用notifydataSetChange时会通知观察者,观察者会requestLayout
策略模式
- 目的:将使用算法的客户和算法的实现解耦
- 手段:增加了一层“抽象”将“变化”封装起来,然后对“抽象”编程,并利用”多态“应对“变化”,对策略模式来说,“变化”就是一组算法。
- 实现方式:将算法抽象成接口,用组合的方式持有接口,通过依赖注入动态的修改算法
- setXXListener都是这种模式
装饰者模式
- 目的:用比继承更灵活的方式为现有类扩展功能
- 手段:具体对象持有超类型对象
- ~是继承的一种替代方案,避免了泛滥子类。
- ~增加了一层抽象,这层抽象在原有功能的基础上扩展新功能,为了复用原有功能,它持有原有对象。这层抽象本身是一个原有类型
- ~实现了开闭原则
外观模式
- 目的:隐藏细节,降低复杂度
- 手段:增加了一层抽象,这层抽象屏蔽了不需要关心的子系统调用细节
- 降低了子系统与客户端之间的耦合度,使得子系统的变化不会影响调用它的客户类。
- 对客户屏蔽了子系统组件,减少了客户处理的对象数目,并使得子系统使用起来更加容易。
- 实现方式:外观模式会通过组合的方式持有多个子系统的类,~提供更简单易用的接口(和适配器类似,不过这里是新建接口,而适配器是已有接口)
- 通过外观模式,可以让类更加符合最少知识原则
- ContextImpl是外观模式
适配器模式
- 意图: 将现有对象包装成另一个对象
- 手段:增加了一层抽象,这层抽象完成了对象的转换。(具体对象持有另一个而具体对象)
- 是一种将两个不兼容接口(源接口和目标接口)适配使他们能一起工作的方式,通过增加一个适配层来实现,最终通过使用适配层而不是直接使用源接口来达到目的。
代理模式
- 目的:限制对象的访问,或者隐藏访问的细节
- 手段:增加了一层抽象,这层抽象拦截了对对象的直接访问
- 实现方式:代理类通过组合持有委托对象(装饰者是直接传入对象,而代理通常是偷偷构建对象)
- 分类 :代理模式分为静态代理和动态代理
- 静态代理:在编译时已经生成代理类,代理类和委托类一一对应
- 动态代理:编译时还未生成代理类,只是定义了一种抽象行为(接口),只有当运行后才生成代理类,使用Proxy.newProxyInstance(),并传入invocationHandler
- Binder通信是代理模式,Retrofit运用动态代理构建请求。
模板方法模式
- 目的:复用算法
- 手段:新增了一层抽象(父类的抽象方法),这层抽象将算法的某些步骤泛化,让子类有不同的实现
- 实现方式:在方法(通常是父类方法)中定义算法的骨架,将其中的一些步骤延迟到子类实现,这样可以在不改变算法结构的情况下,重新定义某些步骤。这些步骤可以是抽象的(表示子类必须实现),也可以不是抽象的(表示子类可选实现,这种方式叫钩子)
- android触摸事件中的拦截事件是钩子
- android绘制中的onDraw()是钩子
命令模式
- 目的:将执行请求和请求细节解耦
- 手段:增加了一层“抽象”将“变化”封装起来,然后对“抽象”编程,并利用”多态“应对“变化”,对命令模式来说,“变化”就是请求细节。新增了一层抽象(命令)
- 这层抽象将请求细节封装起来,执行者和这层抽象打交道,就不需要了解执行的细节。因为请求都被统一成了一种样子,所以可以统一管理请求,实现撤销请求,请求队列
- 实现方式:将请求定义成命令接口,执行者持有命令接口
- java中的Runnable就是命令模式的一种实现
桥接模式
- 目的:提高系统扩展性
- 手段:抽象持有另一个抽象
- 是适配器模式的泛化模式
访问者模式
- 目的:动态地为一类对象提供消费它们的方法。
- 重载是静态绑定(方法名相同,参数不同),即在编译时已经绑定,方法的参数无法实现运行时多态
- 重写是动态绑定(继承),方法的调用者可实现运行时多态
- 双分派:
a.fun(b)
在a和b上都实现运行时多态,实现方法调用者和参数的运行时多态。 - 编译时注解使用了访问者模式,一类对象是Element,表示构成代码的元素(类,接口,字段,方法),他有一个accept方法传入一个Visitor对象
架构
关于 MVP,MVVM,MVI,Clean Architecture 的介绍可以点击如下文章:
如何把业务代码越写越复杂? | MVP - MVVM - Clean Architecture
MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(一)
MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)
MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(三)
“无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(一)
多线程
进程 & 线程
- 系统按进程分配除CPU以外的系统资源(主存 外设 文件), 系统按线程分配CPU资源
- Android系统进程叫system_server,默认情况下一个Android应用运行在一个进程中,进程名是应用包名,进程的主线程叫ActivityThread
- jvm会等待普通线程执行完毕,但不会等守护线程
- 若线程执行发生异常会释放锁
- 线程上下文切换:cpu控制权由一个运行态的线程转交给另一个就绪态线程的过程(需要从用户态到核心态转换)
- 一对一线程模型:java语言层面的线程会对应一个内核线程
- 抢占式的线程调度,即由系统决定每个线程可以被分配到多少执行时间
阻塞线程的方法
- sleep():使线程到阻塞态,但不释放锁,会触发线程调度。
- wait():使线程到阻塞态,释放锁(必须先获取锁)
- yield():使线程到就绪态,主动让出cpu,不会释放锁,发生一次线程调度,同优先级或者更高优先级的线程有机会执行
线程安全三要素
- 原子性:不会被线程调度器中断的操作。
- 可见性:一个线程中对共享变量的修改,在其他线程立即可见。
- 有序性:程序执行的顺序按照代码的顺序执行。
原子操作
- 除long和double之外的基本类型(int, byte, boolean, short, char, float)的赋值操作。
- 所有引用reference的赋值操作,不管是32位的机器还是64位的机器。
- java.concurrent.Atomic.* 包中所有类的原子操作。
死锁
四个必要条件
- 互斥访问资源
- 资源只能主动释放,不会被剥夺
- 持有资源并且还请求资源
- 循环等待 解决方案是:加锁顺序+超时放弃
线程生命周期
线程从新建状态到就绪状态,就绪态的线程如果获得了cpu执行权就变成了运行态,运行完变成死亡态,如果运行中产生等待锁的情况(sleep,wait),则会进入阻塞态,当阻塞态的进程被唤醒后进入就绪态,参与cpu时间片的竞争,执行完毕死亡态。
线程池
- 如果创建对象代价大,且对象可被重复利用。则用容器保存已创建对象,以减少重复创建开销,这个容器叫做池。线程的创建就是昂贵的,通过线程池来维护实例。
线程通信:等待通知机制
- 等待通知机制是一种线程间的通信机制,可以调整多个进程的执行顺序。
- 需要等待某个资源的线程可以调用 wait(),当某资源具备后,可以调用统一对象上的notify()
- notify():随机使一个线程进入就绪态,它需要和调用wait()是同一个对象(获得锁的线程才能调用)
- notifyAll():唤醒所有等待线程,让他们到就绪队列中
Condition
- 是多线程通信的机制,挂起一个线程,释放锁,直到另一个线程通知唤醒,提供了一种自动放弃锁的机制。
- await()挂起线程的同时释放锁(所以必须先获取锁,否则抛异常),signal 唤醒一个等待的线程
- 每个Condition对象只能唤醒调用自己的await()方法的那个线程
- 如果条件不用 Condition 实现,则线程可能不断地获取锁并释放锁,但因继续执行的条件不满足,cpu 负载打满。使用Condition 让等待线程不消耗cpu
- await() 通常配合 while(){await()} 因为被唤醒是从上次挂起的地方执行,还需要再次判断是否满足条件
- await()必须在拥有锁的情况下调用,以防止lost wake-up,即在await条件判断和await调用之间notify被调用了。当await条件满足后,还没来得及执行await时发生线程调度,另一个线程调用了notify()。然后才轮到await()执行,它将错过刚才的notify,因为notify在await之前执行。
interrupt()
- 不会真正中断正在执行的线程,只是通知它你应该被中断了,自己看着办吧。
- 若线程正运行,则中断标志会被置为true,并不影响正常运行
- 如果线程正处于阻塞态,则会收到InterruptedException,就可以在 catch中执行响应逻辑
- 若线程想响应中断,则需要经常检查中断标志位,并主动停止,或者是正确处理 InterruptedException
内存屏障
- 用于禁止重排序,它分为以下四种:
- LoadLoad Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
- StoreStore Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
- LoadStore Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
- StoreLoad Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
volatile
- 保证变量操作的有序性和可见性
- 在每一个volatile写操作前面插入一个StoreStore屏障,可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
- 在每一个volatile写操作后面插入一个StoreLoad屏障,避免volatile写与后面可能有的volatile读/写操作重排序。
- 在每一个volatile读操作后面插入一个LoadLoad屏障,禁止处理器把上面的volatile读与下面的普通读重排序。
- 在每一个volatile读操作后面插入一个LoadStore屏障,禁止处理器把上面的volatile读与下面的普通写重排序。
- volatile就是将共享变量在高速缓存中的副本无效化,这导致线程修改变量的值后需立刻同步到主存,读取共享变量都必须从主存读取
- 当volatile修饰数组时,表示数组首地址是volatile的而不是数组元素
CAS
- Compare and Swap
- 当前值,旧值,新值,只有当旧值和当前值相同的时候,才会将当前值更新为新值
- Unsafe将cas编译成一条cpu指令,没有函数调用
- aba问题:当前值可能是变为b后再变为a,此a非彼a,通过加版本号能解决
- 非阻塞同步:没有挂起唤醒操作,多个线程同时修改一个共享变量时,只有一个线程会成功,其余失败,它们可以选择轮询。
synchronized
-
隐式加锁释放锁
-
可修饰静态方法,实例方法,代码块
-
当修饰静态方法的时,锁定的是当前类的 Class 对象(就算该类有多个实例,使用的还是同一把锁)。
-
当修饰非静态方法的时,锁定的是当前实例对象 this。当 饰代码块时需要指定锁定的对象。
-
通过将对变量的修改强制刷新到内存,且下一个获取锁的线程必须从内存拿。保证了可见性
-
同一时间只有一个线程可以执行临界区,即所有线程是串行执行临界区的
-
happen-before 就是释放锁总是在获取锁之前发生。
-
synchronized特点
- 可重入:可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。线程可以再次进入已经获得锁的代码段,表现为monitor计数+1
- 不公平:synchronized 代码块不能够保证进入访问等待的线程的先后顺序
- 不灵活:synchronized 块必须被完整地包含在单个方法里。而一个 Lock 对象可以把它的 lock() 和 unlock() 方法的调用放在不同的方法里
- 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,synchronized是自旋锁。如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高
-
1.8 之后synchronized性能提升:
- 偏向锁:目的是消除无竞争状态下性能消耗,假定在无竞争,且只有一个线程使用锁的情况下,在 mark word中使用cas 记录线程id(Mark Word存储对象自身的运行数据,在对象存储结构的对象头中)此后只需简单判断下markword中记录的线程是否和当前线程一致,若发生竞争则膨胀为轻量级锁,只有第一个申请偏向锁的会成功,其他都会失败
- 轻量级锁:使用轻量级锁,不要申请互斥量,只需要用 CAS 方式修改 Mark word,若成功则防止了线程切换
- 自旋(一种轻量级锁):竞争失败的线程不再直接到阻塞态(一次线程切换,耗时),而是保持运行,通过轮询不断尝试获取锁(有一个轮询次数限制),规定次数后还是未获取则阻塞。进化版本是自适应自旋,自旋时间次数限制是动态调整的。
- 重量级锁:使用monitorEnter和monitorExit指令实现(底层是mutex lock),每个对象有一个monitor
- 锁膨胀是单向的,只能从偏向->轻量级->重量级
ReentrantLock
- 手动加锁手动释放:JVM会自动释放synchronized锁,但可重入锁需要手动加锁手动释放,要保证锁定一定会被释放,就必须将unLock()放到finally{}中。手动加锁并释放灵活性更高
-
- 可中断锁:lockInterruptibly(),未获取则阻塞,但可响应当前线程的interrupt()被调用
-
- 超时锁:tryLock(long timeout, TimeUnit unit) ,未获取则阻塞,但阻塞超时。
-
- 非阻塞获取锁:tryLock() ,未获取则直接返回
-
- 可重入:若已获取锁,无需再次竞争即可重新进入临界区执行,state +1,出来的时候需要释放两次锁 state -1
-
- 独占锁:同一时间只能被一个线程获取,其他竞争者得等待(AQS独占模式)
- 性能:竞争不激烈,Synchronized的性能优于ReetrantLock,激烈时,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态
- 是AQS的实现类,AQS中有一个Node节点组成双向链表,存放等待的线程对象(被包装成Node)
- 获取锁流程:
-
尝试获取锁,公平锁排队逻辑:判断锁是否空闲,若空闲还要判断队列中是否有排在更前面的等待线程,若无则尝试获取锁。若当前独占线程是自己,表示重入,则增加state值。非公平锁抢占逻辑:直接进行CAS state操作(从0到1),若成功则设置当前线程为锁独占线程。若失败会判断当前线程是否就是独占线程若是表示重入,state+1
-
获取失败则入AQS队列,然后在挂起线程:将线程对象包装成EXCLUSIVE模式的Node节点插入到AQS双向链表的尾部(cas值链尾的next结点+自旋保证一定成功),并不断尝试获取锁,或中断Thread.interrupted()
-
- 释放锁流程:
- 释放锁表现为将state减到0
- 调用unparkSuccessor()唤醒线程(非公平时如何唤醒)
ReentrantReadWriteLock
- 并发度比ReentrantLock高,因为有两个锁,使用AQS,读锁是共享模式,写锁是独占模式。读多写少的情况下,提供更大的并发度
- 可实现读读并发,读写互斥,写写互斥
- 使用一个int state记录了两个锁的数量,高16位是读锁,低16位是写锁
- 获取写锁过程:除了考虑写锁是否被占用,还要考虑读锁是否被占用,若是则获取锁失败,否则使用cas置state值,成功则置当前线程为独占线程。
- 读并发也有并发数限制,获取读锁时需验证,并使用ThreadLocal记录当前线程持有锁的数量
- 可能发生写饥饿,因为太多读
- 锁降级:当一个线程获取写锁后再获取读锁,然后释放写锁
- 不支持锁升级是为了保证可见性:多个线程获取读锁,其中任意线程获取写锁并更新数据,这个更新对其他读线程是不可见的
StampedLock
- 实现读读并发,读写并发。
- 在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作
- 用二进制位记录每一次获取写锁的记录
CountdownLatch
- 用作等待若干并发任务的结束
- 内部有一个计数器,当值为0时,在countdownLatch上await的线程就被唤醒
- 通过AQS实现,初始化是置AQS.state为n,countdow()通过自旋+cas将执行state--效果
CyclicBarrier
- 用于同步并发任务的执行进度
- 使用 ReentranntLock 保证count线程安全,每次调用await() count--,然后在condition上阻塞,当count为0时,会signalAll()
Semaphore
- 用于限制并发访问资源线程的个数
- 基于AQS,初始化是为state赋值最大并发数,调用acquire()时即是cas将state-1,当state小于零时,令牌不足,将线程包装成node结点会入队列,然后挂起
- 有公平和非公平两个构造方法
AbstractQueuedSynchronizer
- 实现了cas方式竞争共享资源时的线程阻塞等待唤醒机制
- AQS提供了两种资源共享方式1.独占:只有一个线程能获取资源(公平,不公平)2.共享:多个进程可获取资源
- AQS使用了模板方法模式,子类只需要实现tryAcquire()和tryRelease(),等待队列的维护不需要关心
- AQS使用了CLH 队列:包括sync queue和condition queue,后者只有使用condition的时候才会产生
- 持有一个volatile int state代表共享资源,state =1 表示被占用(提供了CAS 更新 state 的方法),其他线程来加锁会失败,加锁失败的线程会放入等待队列(被Unsafe.park()挂起)
- 等待队列的队头是独占资源的线程。队列是双向链表
AtomicInteger
- 线程安全的int值,为了避免在一个变量上使用锁实现同步,这样太重了
- 在高并发的情况下,每次值成功更新了都需要将值刷到主存
- 自增使用cas+自旋+volatile
public final int incrementAndGet() {
for (;;) {// 自旋
int current = get();
int next = current + 1;
if (compareAndSet(current, next))// cas
return next;
}
}
AtomicIntegerArray
- 线程安全整形数组
- 内部持有 final int[] array,保证了使用时已经初始化,并且引用不能改变
- 对数组的操作退化为对单个元素的操作,因为数组的内存模型是连续的,并且每个元素所占空间一样大,
- 使用Unsafe带有volatile的方法进行对单个元素赋值。
AtomicReference
- 提供对象引用非阻塞原子性并发读写
- 对象引用是一个4字节的数字,代表着堆内存中的一个地址
CopyOnWriteArrayList
- 实现了线程安全的读写并发,读读并发,但不能实现写写并发(上锁了,synchronized),因为他们操纵是不同的副本
- 使用不可变 Object[] 作为容器
- 写时复制数组,写入新数组,引用指向新数组。加锁防止一次写操作导致复制了多个数组副本
- 读操作就是普通的获取数组某元素,
- 适合读多写少,因为写需要复制数组,耗时
- 适合集合不大,因为写时要复制数组,耗时,耗内存
- 实时性要求不高,因为可能会读到旧数据。(对新数组写,对数组读)
- 采用快照遍历,即遍历发起时形成一张当前数组的快照,并且迭代器不允许删除,新增元素。不会发生 ConcurrentModificationException,但可能实时性不够。
- 适用于作为观察者的容器
ArrayBlockingQueue
- 大小固定的,线程安全的顺序队列,不能读读,读写,写写并发。
- 使用Object[]作为容器,环形数组。比复制拷贝效率高。
- 存取使用同一把锁 ReentrantLock 保证线程安全+2个condition(写操作可能在notFull条件上阻塞,读操作可能在notEmpty上阻塞)
- 遍历支持remove及并发读写。
- 适用于控制内存大小的生产者消费者模式,队列满,则阻塞生产者/有限等待,队列空则阻塞消费者/有限等待。
- 适用于做缓存,缓存有大小限制,缓存是生产者消费者模型,多线程场景下需考虑线程安全。
LinkedBlockingQueue
- 线程安全的链队列,实现读写并发,不能读读,写写并发
- 存取用两把不同的 ReentrantLock,适用于读写高并发场景。
- 可实现并行存取,速度比 ArrayBlockingQueue 快,但有额外的Node结点对象的创建和销毁,可能引发 gc,若消费速度远低于生产速度,则内存膨胀
SynchronousQueue
- 以非阻塞线程安全的方式将元素从一个生产线程递交给消费线程
- 适用于生产的内容总是可以被瞬间消费的场景,比如容量为 Int.MAX_VALUE 的线程池,即当新请求到来时,总是可以找到新得线程来执行请求,不管是老的空闲线程,还是新建线程。
- 存储结构是一个链,使用 cas + 自旋的方式实现线程安全
PriorityBlockingQueue
- 使用 Object[] 作为容器实现堆
- 使用 ReentrantLock 保证线程安全,读和取同一把锁
- 每次存取会引发排序,使用堆排序提高性能
- 每次写,都写到数组末尾,然后堆向上调整
- 每次读都读取数组头,并将数组末尾元素放到数组头。然后执行一次向下调整
ConcurrentLinkedQueue
- 是一个链式队列,使用非阻塞方式实现并发读写的线程安全,而是使用轮询+
CAS
保证了修改头尾指针的线程安全 - 存储结构是带头尾指针的单链表。
- 头尾指针和结点next域都使用 volatile 保证可见性。
- 出队时,通过 cas 保证结点 item 域置空的线程安全,更新头指针也使用了 cas。
- 入队时,通过 cas 保证结点 next 域指向新结点的线程安全,更新尾指针也使用了 cas。
- 出于性能考虑,头尾指针的更新都是延迟的。每插入两个结点,更新一下尾指针,每取出两个结点,更新一下头指针。
- 适用于生产者消费者场景
- 入队算法:总是从当前tail指向的尾部向后寻找真正的尾部(因为tail更新滞后,并且可能被另一个入队线程抢占),找到后通过cas值next域
ConcurrentHashMap
- 1.7 使用的是开散列表,数组+链表
- 1.7 Segment 数组,Segment 是一个 ReentrantLock,分段锁,并发数是 Segment 的数量。每个 Segment 持有一个 Entry 数组(桶)。(一个entry就是一条链)
- 1.7 定位一个元素需要两次hash,先定位到 Segment 再定位到元素所在链表头。
- 1.7 put()先尝试非阻塞的获取锁,失败则自旋重试,并计算出对应桶的位置,到达最大尝试次数后阻塞式的获取。
- 1.7 ConcurrentHashMap.get()不需要上锁是因为键值对实体中将value声明成了volatile,能够在线程之间保持可见性
- 1.7 如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再 hash 后插入到新的数组里
- 1.7 遍历链表是个相对耗时的操作
- 1.8 将重入锁改成synchronized,因为它被优化过了
- 1.8 也是开散列表,数组+链表(或者红黑树),当链表长度大于8时,则将链表转换为红黑树,增加查找效率
- 1.8 使用cas方式保证只有一个线程初始化成功
- 1.8 put操作:对key进行hash得到数组索引,若数组未初始化则初始化,如果索引位置为null 则直接cas写(失败则自旋保持成功),(后面的部分synchronize了)如果索引位置为链头指针,则进行链插入,往后遍历找到相同的key 则覆盖,否则链尾插入,若索引位置是红黑树,则进行红黑树插入。
- 1.8 锁的粒度更细了,一个桶一个锁。
- 1.8 Node.next 用volatile修饰
红黑树
- 二叉树是一个父节点有两个子节点的递归结构
- 二叉排序树是一种特殊的二叉树,它规定左孩子 < 父亲 < 右孩子,它解决了二叉树退化为单链表的情况(查找时间复杂度退化为O(n))
- 平衡二叉排序树是一种特殊的二叉排序树。它规定每一个结点的左右子树高度差不大于1
- 红黑树是没有那么严格的平衡二叉排序树。因为频繁的调整子树是耗时的。
- 二叉排序树是二分查找,最大查找次数为树高度
- 红黑树插入结点后通过变色和旋转来保持红黑树的平衡。保证了没有任何一条路径会比其他路径长出两倍。
ConcurrentModificationException
- 当遍历数组的同时删除其中元素就会发生这个异常,这叫fast-fail机制。
- 因为调用next()时会检查 modCount和expectModCount是否一致,不一致则抛这个异常。
- 但单线程下如何解决这个问题:使用iterator.remove,他会同步modCount和expectModeCount
ThreadPoolExecutor
- 这是java的线程池。
- ThreadPoolExecutor构造参数如下:
- 核心线程数:线程池中一直存活的线程数量,当新的任务请求到来时,如果线程池中线程数小于核心线程数,则会新建线程,默认情况下核心线程会一直存活,只有当allowCoreThreadTimeOut设置为true时且发生超时才会被销毁
- 最大线程数,线程池中线程的上限
- keepAlive:非核心线程允许空闲的最大时长,超过空闲时间则会被销毁(当池中线程数>=核心线程数时创建出来的线程都是非核心线程)
- ThreadPoolExecutor线程池管理策略
if 线程池中正在运行的线程数 < corePoolSize
{新建线程来处理任务(即使线程池中有线程处于空闲状态)}
else if 线程池中正在运行的线程数 >= corePoolSize
{
if 缓冲队列未满
任务被放入缓冲队列
else 缓冲队列满
if maximumPoolSize > 线程池中正在运行的线程数 > corePoolSize
新建线程来处理任务 此时的任务会被立即执行
else if 线程池中正在运行的线程数 = maximunPoolSize
通过handler所指定的策略来处理此任务
拒绝策略(丢弃策略)
ThreadPoolExecutor.AbortPolicy 悄悄地丢弃一个任务
ThreadPoolExecutor.DiscardOldestPolicy 丢弃最旧的任务,重新提交最新的
ThreadPoolExecutor.CallerRunsPolicy 在调用者的线程中执行被拒绝的任务
ThreadPoolExecutor.DiscardPolicy() 丢弃当前任务
}
网络
网络分层的好处是下层的可重用性,tcp不需要知道它传输的是http还是ftp亦或是SSH。
1. 物理层
- 二进制在物理媒体上传输
2. 数据链路层
在物理层的基础上提供差错校验。
3. 网络层(ip)
为数据包路由
4. 传输层(tcp,udp)
提供端到端接口
tcp
-
传输控制协议,是传输层协议,解决数据如何传输,是面向连接的,可靠的点到点传输协议。
-
tcp头包括 sequence number(32位) 用于标识报文中第一个字节在整个数据流中的序号,确保有序。
-
tcp头包括 ack number(32位),表示对上一个接收到的sequence number的确认,解决丢包。只有当ack位为1时才有效
-
tcp 头部包含滑动窗口大小
-
tcp 头部包含 tcp flag,有6个标志位 URG,ACK,PSH,RST,SYN,FIN
-
tcp 头部包含两个16位的端口号(源+目的)
-
tcp是基于字节流的
-
采用确认和超时重传策略保证可靠传输
- 确认:接收方检测出帧出错是不会返回确认帧并直接丢弃该帧
- 超时重传:发送方发送数据报后启动倒计时,若规定时间内未收到确认才重传数据报
-
提供拥塞控制和流量控制
- 采用大小可变的滑动窗口实现流量控制,窗口大小即是发送方发送但未收到确认的数据报数量
- 慢启动:每个rtt将滑动窗口翻倍。
- 拥塞控制对链接是独立的
- 但拥塞控制会导致tcp队头阻塞(tcp必须接收到完整正确顺序的数据包后才能提交给上层),使得单路 http/2 的速度没有多路 http/1 的快
-
TCP通信过程太复杂并且开销大,一次TCP交换需要9个包: 三个连接包,四个断开包,一个request包,一个响应包。
-
UDP通信过程简单,只需要一个查询包和一个响应包。
tcp三次握手建立连接
- 发送方请求建立连接Syn报文,syn位置1(表示链接建立请求) ack位置0,seq number =x
- 接收方确认请求 syn位置1,ack位置1,seq number = y ack number = x+1
- 发送方确认的确认 ack number = y+1
- 为啥不能两次:防止超时的连接请求报文到达服务器再次建立连接。
tcp四次挥手释放连接
- 4次挥手:发送方请求释放连接(Fin报文)-> 接收方确认(ACK置1)-> 接收方请求释放连接(Fin报文)-> 发送方确认-客户端等待 2MSL(报文最大生存时间) 的时间后依然没有收到回复(服务端没收到ack,则服务端会重新发送fin),则证明服务端已正常关闭,那么客户端也可以关闭连接了
- 为啥挥手要四次,因为TCP全双工,客户端请求释放连接时,只表示客户端没东西发了,但服务器还有数据要返回。
tcp粘包,tcp分包
- 半包:如果数据包太大,导致服务器没有接收完整的包
- 粘包:tcp基于字节流,不关心上层传输的具体内容,在一个tcp报文中可能存在多个http包(发送端粘包:http包太小,tcp为了提高效率,所以粘包,接收端粘包:接收端没有及时处理接收缓冲区的数据,读取时出现粘包)
- 分包:tcp基于字节流,tcp不关心上层传输的具体内容,一个大的http包可能被分在多个tcp报文上(发送http太大)
- 粘包分包解决方案:定长消息,用特殊字符标记消息边界,将消息长度写在消息头中
tcp心跳包
- 通信双方处于idle状态时确保长链接的有效性,需要发送的特殊数据包给对方(ping),接收方给予回复(pong)
- tcp自带心态机制SO_KEEPALIVE,但不够灵活,所以在应用层上实现心跳
- Netty 使用 IdleStateHandler 根据超时时间监听读写事件,若发生超时则会触发回调,这个时候可以发送心跳包
socket
- 套接字 = {传输层协议,源地址,源端口,目标地址,目标端口},其中协议可以是tcp或udp,是不同主机进程间通信的端点
udp
- 用户数据包协议
- UDP提供的是无连接 无确认 不可靠服务的点到多点传输协议
- udp是基于报文的
- 发送前无需握手,发送完无需释放连接,传输效率高
- 每个数据包独立发送,不同数据包可能传输路径可能不同
- 没有拥塞控制
- 有差错校验,对udp头部和数据段都进行校验,服务端通过校验和发现出错时直接丢弃
- udp 依赖网络层的ip,udp数据包被包在ip数据包外层
5. 应用层
https
- https 是安全的 http,它 = http + ssl(Secure Sockets Layer)
- 是应用层协议,解决如何封装数据
- 无状态协议,服务器对用户操作没有记忆
- http 1.1 开始有keep-alive,保持链接,网页打开后,客户端和服务器的连接不会断开而是保持一段时间,为了效率(Connection:keep-alive,请求头部的该字段决定了链接是否会复用)
- 明文通信,可能被窃听;不验证身份,可能被劫持;无法验证报文完整性,可能被篡改。
- HTTP协议使用默认80端口,HTTPS协议使用443端口
- http1.1 新增了pipeline,多个http资源可以并发地在一条tcp链接上发送(发送方不需要等待第一个资源确认了才发送第二个资源)。但接收方只能串行的处理响应,一个慢响应会阻塞所有快请求向上层提交(管道解决了请求的队头阻塞)
- 证书: 是服务器下发给客户端的,客户端用证书验证服务端身份。证书需要购买
- 证书包含:认证机构(CA)信息,公钥,域名,有效期,指纹(对证书进行hash运算,即证书摘要),指纹算法,数字签名(CA私钥加密的指纹)等
HTTP2.0
- 1.0 每个http请求都要重新建立一条tcp链接,结束时要关闭链接,临时链接。
- 1.0 不压缩header,且每次通信都要重复发送head
- 1.0 不支持请求优先级
- 1.0 必须串行的地完成地发送资源(造成队头阻塞)
- 1.1 允许持久链接,接收方只能串行地处理不同请求,两个请求生命周期不能重叠,因为接收方无法确认数据的开始和结束(有效负荷字段写在header中),这会造成队头阻塞,多个并行请求需建立多条 tcp链接,无法复用。关闭链接只要在头部带上Connection:Close
- 2.0 支持header压缩,通讯双方缓存一个 header field 表,避免重复 header 传输
- 2.0 多路复用,将数据流分解成更小的帧(通过在头部廷加stream id,和帧大小),不同数据流的帧可以交错在一条tcp连接上发送,再根据所属流重新组装,实现了多请求并行传输的效果(时间片),解决了http层的队头阻塞(减轻了服务端的压力,每个客户端只建立了一条链接,服务器可以给更多的客户端建立连接)
- 2.0 支持优先级
加密解密
加密算法分为两类:对称加密和非对称加密。
- 对称加密:加密和解密用的都是相同的秘钥,优点是速度快,缺点是安全性低。常见的对称加密算法有DES、AES等等。
- 非对称加密:非对称加密有一个秘钥对,分为公钥和私钥。一般来说,私钥自己持有,公钥可以公开给对方,优点是安全性比对称加密高,缺点是数据传输效率比对称加密低。采用公钥加密的信息只有对应的私钥可以解密。常见的非对称加密包括RSA等。
数字摘要
- 是明文摘要成128位密文的过程,比如MD5,SHA1
数字签名
- 是用于验证信息完整性的和身份验证。
- 发送方将内容摘要并用私钥加密并发送,接收方用公钥解密摘要,再对原文求摘要,比对两个摘要,若相同则未被篡改
数字证书
- 是为了解决公钥置信的问题、
TLS
- 是 ssl3.0 的后续版本
- 分为 tls记录和tls握手
- tls 实现了加密数据,验证数据完整性,认证身份
tls握手过程
是一个借助于数字证书协商出对称加密密钥的过程
- 客户端发出请求,说明支持的协议,客户端生成的随机数,支持的加密方法
- 服务端返回证书,服务端生成的随机数
- 客户端验证证书
- 客户端使用证书中的公钥加密另一个新得随机数。并发送给服务器
- 生成会话密钥:客户端和服务器分别用三个随机数生成相同的对称密钥
- 服务器通知握手结束,之后就通过对称密钥通信
- 验证过程:
- 客户端 TLS 解析证书
- 证书是否过期
- CA是否可靠(查询信任的本地根证书)
- 证书是否被篡改(用户使用CA根公钥解密签名得到原始指纹,再对证书使用指纹算法得到新指纹,两指纹若不一样,则被篡改)
- 服务器域名和证书上的域名是否匹配
QUIC
- quic建立在UDP之上,但实现了可靠传输,它更应是TCP 2.0,它包含tcp的所有特性 :可靠性,拥塞控制,流量控制。
- quic 将 http2的流和帧的概念下移到了传输层,给每个数据流一个stream id,以及跟踪该字节流的字节范围(比如包1是从0-200,包2是从201-300),这将不能保证数据包的有序性,单个资源流的有序,多个流的顺序无法保证(比如服务器发送资源1.1-1.2-2,接收方的顺序可能是2-1.1-1.2),
队头阻塞
- 一个大的(慢的)响应会阻塞其后面的响应。
- http1.0 通过多个http链接缓解该问题
- http2.0回到单个 TCP 连接,解决队头阻塞问题。这在 HTTP/1.1 中是不可能的,因为没有办法分辨一个块属于哪个资源,或者它在哪里结束,另一个块从哪里开始。HTTP/2 非常优雅地解决了这一问题,它在资源块之前添加了帧(frames)
- http2.0解决了http层的队头阻塞。但还有tcp队头阻塞,当发生丢包,tcp会先将失序数据存在缓冲区,待重传数据到来时才按照正确的顺序提交给上层,此时丢失的包会阻塞后续包提交给上层。
- quic 将http2 流和帧的概念下移到了传输层,解决了 tcp队头阻塞
- tls队头阻塞:tls加解密是整块进行的,tls记录可能分散在多个tcp包上,若tcp丢包则tls队头阻塞,quic的解决方案是将加解密分散处理,这样会拖慢加解密速度。
一次网络请求
- 请求dns服务器解析ip地址
- 三次握手建立TCP链接
- tls握手
- 请求内容封装成http报文---tcp分包 在链路上发送出去
- 服务器解析报文响应
- 关闭链接,四次握手
网络优化
- 请求预热:发送无body的head请求,提前建立好tcp,tls链接,省掉dns,tcp,tls时间
- 统一域:不同的业务的域名在客户端发出请求之前进行合并(因为若域名不同,请求不同业务时都需要dns解析,且都需要建立不同的tcp链接),使用统一的域,将请求不同的部分往后挪到接口部分,请求到达后端SLB后进行域名还原。okhttp链接复用最多保持5个空闲链接(通过调整最大空闲请求数,一个connection在内存中5k)
- 有了统一的域之后,可以进行网络嗅探,择优进行IP直连,app启动时,拉取域对应的ip列表,并对所有ip进行嗅探ping接口,选择其中最优的ip 最为后续请求的直连ip,不需要进行dns解析
- 对于可靠性要求高的请求,先入库,失败后重试
- 网络切换时,自动关闭缓存池中现有的链接(客户端网络地址发生变化,原先的链接失效)
- 减少数据传输量,protocolBuffer,图片压缩,webp,请求合适大小的图片
- 无网环境下, 添加强制缓存的拦截器,对请求添加cache-control:max-age:1年
OkHttp & Retrofit
Retrofit
-
Retrofit 是一个 RESTful 的 HTTP 网络请求框架的封装
-
Retrofit将Http请求抽象成预定义请求接口,使用运行时注解配置请求参数,通过动态代理生成请求对象 (调用Call.Factory生成OkHttp.call),并将response转换成业务数据
-
使用建造者模式,构建retrofit实例
-
使用工厂模式:Convert.Factory构建序列化/反序列化工厂,将ResponseBody转换成业务层数据,将请求转换成一个requestBody
-
使用装饰者模式:通过装饰者模式将响应回调抛到主线程,真正发起请求的是OkhttpCall,他外面又套了一层 ExecutorCallbackCall 扩展了该功能
-
使用了外观模式,create(),隐藏了动态代理生成接口实例,通过Call.Factory生成请求的细节
-
Retrofit.crate()将接口请求动态代理给了ServiceMethod的invoke方法(查找接口对应的ServiceMethod对象(没找到就当场使用反射遍历接口中的注解,并生成ServiceMethod对象对应一个业务接口,接口中的参数都会成为它的成员变量,存在ConcurrentHashMap中,键是Method)),在该方法中生成retrofit的call对象(内部会生成Okhttp3的call对象并发起同步或异步请求),并调用CallAdapter将call适配成response(CallAdapter,它负责将retrofit.call 转换成业务层喜欢的消费方式(比如 observable,suspend方法))
-
特点
- 链接池,HTTP/2复用连接
- 默认支持GZIP,告诉服务器支持gzip压缩格式,请求添加Accept-Encoding: gzip,响应中返回Content-Encoding: gzip(使用哈夫曼算法,重复度越高压缩效果越好)
- 响应缓存
- 方便添加拦截器
OkHttp 调度器 Dispatcher
- 在 OkHttpClient.build 中构建
- 维护三个队列和一个线程池来并发处理网络请求,分别是同步运行队列,正在运行的异步队列,等待请求异步队列
- 持有 ExecutorService ,核心线程数为0,表示不保留空闲线程。最大线程数为 Int.max,表示随时会新建线程,使用同步队列,使得请求的生产不会被阻塞
五大拦截器
0. 应用拦截器一定会被执行一次
1. RetryAndFollowUpInterceptor
重试重定向拦截器 这个拦截器是一个while(true)的循环,只有请求成功或者重试超过最大次数,没有路由供重试时才会退出
请求抛出异常并满足重试条件时才重试,收到3xx,需要重定向时会重新构建请求
2. BridgeInterceptor
- 将http request加工,添加header 头字段(Connection:keep-alive,Accept-Encoding:Gzip),再将http response 加工 去掉 header
3. CacheInterceptor
- 缓存拦截器
- 从DiskLruCache根据请求url获取缓存Response,然后根据一些http头约定的缓存策略决定是使用缓存响应还是发起新的请求。
- 只缓存 get 请求
- 缓存是空间换时间的方法,缓存需要页面置换算法(LRU,FIFO)
- 缓存减小服务器压力,客户端更快地显示数据,无网下显示数据
- 缓存分为强制缓存和对比缓存
- 客户端直接拿数据,若缓存未命中则请求网络并更新数据
- 客户端拿数据标识,总是查询网络判断数据是否有效
- 响应头中包含Cache-Control:max-age,表示缓存过期时间
- 响应头中有Last-Modified字段标识资源最后被修改时间,客户端下次发起请求时在请求头中会带上If-Modified-Since,服务器比对如果最后修改时间大于该值则返回200,否则304标识缓存有效。
- 除了用最后修改时间做判断,还可以用资源唯一标识来判断ETag/If-None-Match,响应头包含ETag,再次请求时带上If-None-Match,服务器比对标识是否相同,相同则304,否则200
- 缓存策略:先判断缓存是否过期,若未过期则直接使用,若过期则发起请求,请求头带唯一标识,服务器回200或304,如果没有唯一标识则请求头带上次修改时间,服务器200或304
- 在无网环境下即是缓存过期,依然使用缓存,要添加 应用拦截器,重构request修改cache-control字段为FORCE_CACHE:
public class ForceCacheInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request.Builder builder = chain.request().newBuilder();
if (!NetworkUtils.internetAvailable()) {
builder.cacheControl(CacheControl.FORCE_CACHE);
}
return chain.proceed(builder.build());
}
}
okHttpClient.addInterceptor(new ForceCacheInterceptor());
- 若服务器不支持header头缓存字段,则可以添加网络拦截器,在CacheInterceptor收到响应之前修改response的header
public class CacheInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
Response response1 = response.newBuilder()
.removeHeader("Pragma")
.removeHeader("Cache-Control")
//cache for 30 days
.header("Cache-Control", "max-age=" + 3600 * 24 * 30)
.build();
return response1;
}
}
4. ConnectInterceptor
- 连接拦截器
- 建立连接及连接上的流
- 维护连接池,以复用连接
- 一个物理链接上有多个流(逻辑上的请求响应对),一个物理链接上的多个流是并发的,但有数量限制,一个流上有多个分配,分配是并发的
- 获取连接流程:
- 复用已分配的连接(重定向再次请求)
- 无已分配链接,则从链接池那一个新得链接,通过主机名和端口(并不是池中的链接就能复用,除了host之外的字段要都相等,比如dns,协议,代理)
- 尝试其他路由再从连接池中获取连接,若找到则进行dns查询
- 如果缓存池中没有链接,则新建链接(tcp+tls握手,sockect.connect+connectTls),这是耗时的,过程中可能有连接池可能有新的可用连接 所以再次尝试从连接池获取连接,如果成功则释放刚建立的链接,否则把新建连接入池
连接复用
- tcp连接建立需要三次握手和四次挥手
- 连接池实现链接缓存,实现同一地址的链接复用
- 连接池以队列方式存储链接ArrayDeque,链接池中同一个地址最多维护5个空闲链接,空闲链接最多存活5分钟
连接清理
- 五分钟定时任务,每五分钟遍历所有链接,并找到其中空闲时间最长的,如果空闲时间超过keep-alive(5分钟),或者空闲链接超过了阈值(5个)则清除这个链接
4.x NetworkInterceptor
- 网络拦截器
- 在连接建立完成和发送请求之间
- 可能不被调用,比如缓存命中,或者多次调用重定向
5. CallServerInterceptor
- 请求拦截器
- 将请求和响应分装成 http2 的帧,通过Http2ExchangeCodec(内部通过okio实现io)
- 1 写入请求头 - 2 写入请求体 - 3 读取响应头 - 4 读取响应体 如果响应头中 Connection:close,则在当前链接上设置标志位,表示该链接不能再被复用
RealCall
- 如何检测重复请求:使用一个 AtomicBoolean 作为请求过的标志位,每次执行 execute之前就会检查
- 如何发起请求:
- 请求被封装成 RealCall 对象,异步请求会进一步会封装成一个 Runnable
- 同步请求直接将请求在拦截器责任链上传递(并加到同步请求队列汇总)
- 异步请求会缓存到一个准备请求队列中,并检查当前并发请求数(同一个域最多5个并发,不同域最多64个),若未超阈值,则将请求出队入线程池执行(将请求在责任链上传递) 同一链接上的最大并发数据流是Int.max
请求如何在责任链上传递
责任链持有一组拦截器和当前拦截器索引,通过每次复制一条新责任链且索引+1,实现传递 发起请求并获取响应就是在请求和响应在责任链上u型传递的过程
Glide
特点
- 会根据控件大小进行下采样,以解码出符合需求的大小,对内存更友好
- 内存缓存+磁盘缓存
- 感知生命周期,取消任务,防止内存泄漏
- 感知内存吃紧,进行回收
- BitmapPool,防止内存抖动的进行bitmap变换
- 定义请求优先级
手写一个图片库注意事项
- 获取资源:异步并发下载图片,最大化利用cpu资源
- 资源解码:按实际需求异步解码,多线程并发是否能加快解码速度
- 资源变换:使用资源池,复用变换的资源,避免内存抖动
- 缓存:磁盘缓存原始图片,或变换的资源。内存缓存刚使用过的资源,使用lru策略控制大小
- 感知生命周期:避免内存泄漏
- 感知内存吃紧:清理缓存
Glide 数据加载流程
- RequestBuilder 构建 Request和 Target,将请求委托给RequestManager,RequestManager触发Request.begin(),然后调用Engine.load()加载资源,若有内存缓存则返回,否则启动异步任务加载磁盘缓存,若无则从网络加载
- DecodeJob 负责加载数据(可能从磁盘,或网络,onDataFetcherReady),再进行数据解码(onDataFetcherReady),再进行数据变换(Transformation),写ActiveResource,(将变换后的数据回调给Target),将变换后的资源写文件(ResourceEncoder)
预加载
preload,加载到一个PreloadTarget,等资源加载好了,就调用clear,将资源从ActiveResource移除存到Lrucache中
感知内存吃紧
注册ComponentCallbacks2,实现细粒度内存管理:
- onLowMemory(){清除内存}
- onTrimMemory(){修剪内存}
memoryCache.trimMemory(level); // 内存缓存
bitmapPool.trimMemory(level); // bitmap池
arrayPool.trimMemory(level); // 字节数组池
可以设置在onTrimMemory时,取消所有正在进行的请求。
BitmapPool
- BitmatPool 是 Glide 维护了一个图片复用池,LruBitmapPool 使用 Lru 算法保留最近使用的尺寸的 Bitmap。
- api19 后使用bitmap的字节数和config作为key,而之前使用宽高和congif,所以19以后复用度更高
- 用类似LinkedHashMap存储,键值对中的值是一组Bitmap,相同字节数的Bitmap 存在一个List中(这样设计的目的是,将Lru策略运用在Bitmap大小上,而不是单个Bitmap上),控制BitmapPool大小通过删除数据组中最后一个Bitmap。
- BitmapPool 大部分用于Bitmap变换和gif加载时
ArrayPool
- 是一个采用Lru策略的数组池,用于解码时候的字节数组的复用。
- 清理内存意味着清理MemoryCache,BitmapPool,ArrayPool
缓存
默认情况下,Glide 会在开始一个新的图片请求之前检查以下多级的缓存:
- 活动资源 (Active Resources) - 现在是否有另一个 View 正在展示这张图片?
- 内存缓存 (Memory cache) - 该图片是否最近被加载过并仍存在于内存中?
- 资源类型(Resource) - 该图片是否之前曾被解码、转换并写入过磁盘缓存?
- 数据来源 (Data) - 构建这个图片的资源是否之前曾被写入过文件缓存?
在 Glide v4 里,所有缓存键都包含至少两个元素 活动资源,内存缓存,资源磁盘缓存的缓存键还包含一些其他数据,包括: 必选:Model 可选:签名 宽度和高度 可选的变换(Transformation) 额外添加的任何 选项(Options) 请求的数据类型 (Bitmap, GIF, 或其他)
磁盘缓存策略
- 如果缓存策略是AUTOMATIC(默认),对于网络图片只缓存原始数据,加载本地资源是存储变换过的数据,如果加载不同尺寸的图片,则会获取原始缓存并在此基础上做变换。
- 如果缓存策略是ALL,会缓存原始图片以及每个尺寸的副本,
- 如果缓存策略是SOURCE,只会缓存变换过的资源,如果另一个界面换一个尺寸显示图片,则会重新拉取网络 可通过自定义Key实现操控缓存命中策略(混入自己的值,比如修改时间)
内存缓存
- 内存缓存分为两级
- 活跃图片 ActiveResource
- 使用HashMap存储正在使用资源的弱引用
- 资源被包装成带引用计数的EngineResource,标记引用资源的次数(当引用数不为0时阻止被回收或降级,降级即是存储到LruCache中)
- 这一级缓存没有大小限制,所以使用了资源的弱引用
- 存:每当下载资源后会在onEngineJobComplete()中存入ActiveResource,或者LruCache命中后,将资源从中LruCache移除并存入ActiveResource。
- 取:每当资源释放时,会降级到LruCache中(请求对应的context onDestroy了或者被gc了)
- 开一个后台线程,监听ReferenceQueue,不停地从中获取被gc的资源,将其从ActiveResource中移除,并重新构建一个新资源将其降级为LruCache
- ActiveResource是为了缓解LruCache中缓存造成压力,因为LruCache中没有命中的缓存只有等到容量超限时才会被清除,强引用即使内存吃紧也不会被gc,现在当LruCache命中后移到ActiveResource,弱引用持有,当内存吃紧时能被回收。
- LruCache
- 使用 LinkedHashMap 存储从活跃图片降级的资源,使用Lru算法淘汰最近最少使用的
- 存:从活跃图片降级的资源(退出当前界面,或者ActiveResource资源被回收)
- 取:网络请求资源之前,从缓存中取,若命中则直接从LruCache中移除了。
- 活跃图片 ActiveResource
- 内存缓存只会缓存经过转换后的图片
- 内存缓存键根据10多个参数生成,url,宽高
磁盘缓存
- 会将源数据或经过变换的数据存储在磁盘,在内存中用LinkedHashMap记录一组Entry,Entry内部包含一组文件,文件名即是key,并且有开启后台线程执行删除文件操作以控制磁盘缓存大小。
- 写磁盘缓存即是触发Writer将数据写入磁盘,并在内存构建对应的File缓存在LinkedHashMap中
- 根据缓存策略的不同,可能存储源数据和经过变换的数据。
感知生命周期
- 构造RequestManager时传入context,可以是app的,activity的,或者是view的
- 向界面添加无界面Fragment(SupportRequestManagerFragment),Fragment把生命周期传递给Lifecycle,Fragment持有RequestManager,RequestManager监听Lifecycle,RequestManager向RequestTracker传递生命周期以暂停加载,RequestTracker遍历所有正在进行的请求,并暂停他们(移除回调resourceReady回调)
- 当绑定context destroy时,RequestManager会将该事件传递给RequestTracker,然后触发该请求Resource的clear,再调用Engine.release,将resource降级到LruCache
- 通过HashMap结构保存无界面Fragment以避免重复创建
取消请求
通过移除回调,设置取消标志位实现:无法取消已经发出的请求,会在DecodeJob的异步任务的run()方法中判断,如果cancel,则返回。移除各种回调,会传递到DataFetcher,httpUrlConnection 读取数据后会判断是否cancel,如果是则返回null。并没有断开链接
感知网络变化
- 通过 ConnectivityManager 监听网络变化,当网络恢复时,遍历请求列表,将没有完成的任务继续开始
Transformation
- 所有的BitmapTransformation 都是从BitmapPool 拿到一个bitmap,然后将在原有bitmap基础上应用一个matrix再画到新bitmap上。
- 变换也是一个key,用以在缓存的时候做区别
RecycleView图片错乱
- 异步任务+视图复用导致
- 解决方案:设置占位图+回收表项时取消图片加载(或者新得加载开始时取消旧的加载)+imageview加tag判断是否是自己的图片如果不是则先调用clear
Glide 缓存失效
- 是因为 Key 发生变化,Url是生成key的依据,Url可能发生变化比如把token追加在后面
- 自定义生成key的方式,继承GlideUrl重写getCacheKey()
自定义加载
- 定义一个Model类用于包装需要加载的数据
- 定义一个Key的实现类,用于实现第一步的Model中的数据的签名用于区分缓存
- 定义一个DataFetcher的实现类,用于告诉Glide音频封面如何加载,并把加载结果回调出去
- 定义一个ModelLoader的实现类用于包装DataFetcher
- 定义一个ModelLoaderFactory的实现类用于生成ModelLoader实例
- 将自定义加载配置到AppGlideModule中
Glide线程池
- 磁盘缓存线程池,一个核心线程:用于io图片编码
- 加载资源线程池,最多不超过4个核心线程数,用于处理网络请求,图片解码转码
- 动画线程池,最多不超过2个线程
- 磁盘缓存清理线程池
- ActiveResource 开启一个后台线程监听ReferenceQueue 所有线程池都默认采用优先级队列
加载Gif流程
读取流的前三个字节,若判断是gif,则会命中gif解码器-将资源解码成GifDrawable,它持有GifFrameLoader会将资源解码成一张张Bitmap并且传递给DelayTarget的对象,该对象每次资源加载完毕都会通过handler发送延迟消息回调 onFrameReady() 以触发GifDrawable.invalidataSelf()重绘。加载下一帧时会重新构建DelayTarget
请求优先级
通过给加载线程池配置优先级队列,加载任务DecodeJob 实现了 compareTo 方法,将priority相减
图片加载优化
- 服务器存多种尺寸的图片
- 自定义 AppGlideModule,按设备性能好坏设定MemoryCategory.HIGH,LOW,NORMAL,内存缓存和bitmapPool的大小系数,以及图片解码格式,ARGB_8888,RGB_565
- RecyclerView 在onViewRecycled 中调用clear ,因为recyclerView会默认会缓存5个同类表项,如果类型很多,内存中会持有表项,如果这些表项都包含图片,Glide 的ActiveResource会膨胀。导致gc
- 如果 RecyclerView 包含一个很长的itemView,超过一屏,其中包含很多照片,最好把长itemView拆成多个itemView
- 使用thumbnail,加载一个缩略图,最好是一个独立的链接,如果是本地的也不差
- 使用preload,将资源提前加载到内存中。
- 大部分情况下 RESOURCE ,即缓存经过变换的图片上是最好选择,节约内存和磁盘。对于gif资源只缓存原始资源DATA,因为gif是多张图每次编码解码反而耗时
- 使用Glide实现变换,因为有BitmapPool供复用
转载自:https://juejin.cn/post/7129306281650683935