裸辞-疫情-闭关-复习-大厂offer(二)

20,107 阅读56分钟

引子

2022 年 3 月辞职,没多久上海爆发疫情,蜗居在家准备面试。在经历 1 个月的闭关和 40+ 场 Android 面试后,拿到一些 offer。

总体上说,有如下几种面试题型:

  1. 基础知识
  2. 算法题
  3. 项目经历
  4. 场景题

场景题,即“就业务场景给出解决方案”,考察运用知识解决问题的能力。这类题取决于临场应变、长期积累、运气。

项目经历题取决于对工作内容的总结提炼、拔高升华、运气

  1. 争取到什么样的资源
  2. 安排了怎么样的分工
  3. 搭建了什么样的架构
  4. 运用了什么模式
  5. 做了什么样的取舍
  6. 采用了什么策略
  7. 做了什么样的优化
  8. 解决了什么问题

力争把默默无闻的“拧螺丝”说成惊天动地的“造火箭”。(这是一门技术活)

但也不可避免地会发生“有些人觉得这是高大上的火箭,有些人觉得不过是矮小下的零件”。面试就好比相亲,甲之蜜糖乙之砒霜是常有的事。除非你优秀到解决了某个业界的难题。

算法题取决于刷题,运气,相较于前两类题,算法题可“突击”的成分就更多了。只要刷题足够多,胜算就足够大。大量刷,反复刷。

基础知识题是所有题型中最能“突击”的,它取决于对“考纲”的整理复习、归纳总结、背诵、运气。Android 的知识体系是庞杂的,对于有限的个人精力来说,考纲是无穷大的。

这不是一篇面经,把面试题公布是不讲武德的。但可以分享整个复习稿,它是我按照自己划定的考纲整理出的全部答案。

整个复习稿分为如下几大部分:

  1. Android
  2. Java & Kotlin
  3. 设计模式 & 架构
  4. 多线程
  5. 网络
  6. OkHttp & Retrofit
  7. Glide

由于篇幅太长,决定把全部内容分成两篇分享给大家。其中,Android 和 Java & Kotlin 已经在第一篇分享过,这一篇的内容是剩下的加粗部分。

设计模式/原则 & 架构

设计原则

  • 单一职责原则:关于内聚的原则。高内聚、低耦合的指导方针,类或者方法单纯,只做一件事情
  • 接口隔离原则:关于内聚的原则。要求设计小而单纯的接口(将过大的接口拆分),或者说只暴露必要的接口
  • 最少知识法则
    • 关于耦合的原则。要求类不要和其他类发生太多关联,达到解耦的效果
    • 从依赖者的角度来说,只依赖应该依赖的对象。
    • 从被依赖者的角度说,只暴露应该暴露的方法。
    • 对象方法的访问范围应该受到约束:
      1. 对象本身的方法
      2. 对象成员变量的方法
      3. 被当做参数传入对象的方法
      4. 在方法体内被创建对象的方法
      5. 不能调用从另一个调用返回的对象的方法
  • 开闭原则:关于扩展的原则。对扩展开放对修改关闭,做合理的抽象就能达到增加新功能的时候不修改老代码(能用父类的地方都用父类,在运行时才确定用什么样的子类来替换父类),开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段
  • 里氏替换原则
    • 为了避免继承的副作用,若继承是为了复用,则子类不该改变父类行为,这样子类就可以无副作用地替换父类实例,若继承是为了多态,则因为将父类的实现抽象化,
  • 依赖倒置原则:即是面向接口编程,面向抽象编程,高层模块不该依赖底层模块,而是依赖抽象(比萨店不应该依赖具体的至尊披萨,而应该依赖抽象的披萨接口,至尊披萨也应该依赖披萨接口)

单例模式

目的:在单进程内保证类唯一实例

  1. 静态内部类:虚拟机保证一个类的初始化操作是线程安全的,而且只有使用到的时候才会去初始化,缺点是没办法传递参数
  2. 双重校验:第一校验处于性能考虑,若对象存在直接返回,不需要加锁。第二次校验是为了防止重复构建对象。对象引用必须声明为 volatile,通过保证可见性和防止重排序,保证单例线程安全。因为INSTANCE = new instance()不是原子操作,由三个步骤实现1.分配内存2.初始化对象3.将INSTANCE指向新内存,当重排序为1,3,2时,可能让另一个线程在第一个判空处返回未经实例化的单例。

工厂模式

  • 目的:解耦。将对象的使用和对象的构建分割开,使得和对象使用相关的代码不依赖于构建对象的细节
  • 增加了一层“抽象”将“变化”封装起来,然后对“抽象”编程,并利用”多态“应对“变化”,对工厂模式来说,“变化”就是创建对象。
  • 实现方式
    1. 简单工厂模式
      • 将创建具体对象的代码移到工厂类中的静态方法。
      • 实现了隐藏细节和封装变化,对变化没有弹性,当需要新增对象时需要修改工厂类
    2. 工厂方法模式
      • 在父类定义一个创建对象的抽象方法,让子类决定实例化哪一个具体对象。
      • 特点
        • 只适用于构建一个对象
        • 使用继承实现多态
    3. 抽象工厂模式
      • 定义一个创建对象的接口,把多个对象的创建细节集中在一起
      • 特点:使用组合实现多态

建造者模式

  • 目的:简化对象的构建
  • 它是一种构造复杂对象的方式,复杂对象有很多可选参数,如果将所有可选参数都作为构造函数的参数,则构造函数太长,建造者模式实现了分批设置可选参数。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语言层面的线程会对应一个内核线程
  • 抢占式的线程调度,即由系统决定每个线程可以被分配到多少执行时间

阻塞线程的方法

  1. sleep():使线程到阻塞态,但不释放锁,会触发线程调度。
  2. wait():使线程到阻塞态,释放锁(必须先获取锁)
  3. yield():使线程到就绪态,主动让出cpu,不会释放锁,发生一次线程调度,同优先级或者更高优先级的线程有机会执行

线程安全三要素

  • 原子性:不会被线程调度器中断的操作。
  • 可见性:一个线程中对共享变量的修改,在其他线程立即可见。
  • 有序性:程序执行的顺序按照代码的顺序执行。

原子操作

  1. 除long和double之外的基本类型(int, byte, boolean, short, char, float)的赋值操作。
  2. 所有引用reference的赋值操作,不管是32位的机器还是64位的机器。
  3. java.concurrent.Atomic.* 包中所有类的原子操作。

死锁

四个必要条件

  1. 互斥访问资源
  2. 资源只能主动释放,不会被剥夺
  3. 持有资源并且还请求资源
  4. 循环等待 解决方案是:加锁顺序+超时放弃

线程生命周期

线程从新建状态到就绪状态,就绪态的线程如果获得了cpu执行权就变成了运行态,运行完变成死亡态,如果运行中产生等待锁的情况(sleep,wait),则会进入阻塞态,当阻塞态的进程被唤醒后进入就绪态,参与cpu时间片的竞争,执行完毕死亡态。

线程池

  • 如果创建对象代价大,且对象可被重复利用。则用容器保存已创建对象,以减少重复创建开销,这个容器叫做池。线程的创建就是昂贵的,通过线程池来维护实例。

线程通信:等待通知机制

  • 等待通知机制是一种线程间的通信机制,可以调整多个进程的执行顺序。
  • 需要等待某个资源的线程可以调用 wait(),当某资源具备后,可以调用统一对象上的notify()
  1. notify():随机使一个线程进入就绪态,它需要和调用wait()是同一个对象(获得锁的线程才能调用)
  2. 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

内存屏障

  • 用于禁止重排序,它分为以下四种:
  1. LoadLoad Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
  2. StoreStore Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
  3. LoadStore Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
  4. 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特点

    1. 可重入:可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。线程可以再次进入已经获得锁的代码段,表现为monitor计数+1
    2. 不公平:synchronized 代码块不能够保证进入访问等待的线程的先后顺序
    3. 不灵活:synchronized 块必须被完整地包含在单个方法里。而一个 Lock 对象可以把它的 lock() 和 unlock() 方法的调用放在不同的方法里
    4. 自旋锁(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{}中。手动加锁并释放灵活性更高
    1. 可中断锁:lockInterruptibly(),未获取则阻塞,但可响应当前线程的interrupt()被调用
    1. 超时锁:tryLock(long timeout, TimeUnit unit) ,未获取则阻塞,但阻塞超时。
    1. 非阻塞获取锁:tryLock() ,未获取则直接返回
    1. 可重入:若已获取锁,无需再次竞争即可重新进入临界区执行,state +1,出来的时候需要释放两次锁 state -1
    1. 独占锁:同一时间只能被一个线程获取,其他竞争者得等待(AQS独占模式)
  • 性能:竞争不激烈,Synchronized的性能优于ReetrantLock,激烈时,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态
  • 是AQS的实现类,AQS中有一个Node节点组成双向链表,存放等待的线程对象(被包装成Node)
  • 获取锁流程:
    1. 尝试获取锁,公平锁排队逻辑:判断锁是否空闲,若空闲还要判断队列中是否有排在更前面的等待线程,若无则尝试获取锁。若当前独占线程是自己,表示重入,则增加state值。非公平锁抢占逻辑:直接进行CAS state操作(从0到1),若成功则设置当前线程为锁独占线程。若失败会判断当前线程是否就是独占线程若是表示重入,state+1

    2. 获取失败则入AQS队列,然后在挂起线程:将线程对象包装成EXCLUSIVE模式的Node节点插入到AQS双向链表的尾部(cas值链尾的next结点+自旋保证一定成功),并不断尝试获取锁,或中断Thread.interrupted()

  • 释放锁流程:
    1. 释放锁表现为将state减到0
    2. 调用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 保证线程安全,读和取同一把锁
  • 每次存取会引发排序,使用堆排序提高性能
    1. 每次写,都写到数组末尾,然后堆向上调整
    2. 每次读都读取数组头,并将数组末尾元素放到数组头。然后执行一次向下调整

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构造参数如下:
  1. 核心线程数:线程池中一直存活的线程数量,当新的任务请求到来时,如果线程池中线程数小于核心线程数,则会新建线程,默认情况下核心线程会一直存活,只有当allowCoreThreadTimeOut设置为true时且发生超时才会被销毁
  2. 最大线程数,线程池中线程的上限
  3. 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三次握手建立连接

  1. 发送方请求建立连接Syn报文,syn位置1(表示链接建立请求) ack位置0,seq number =x
  2. 接收方确认请求 syn位置1,ack位置1,seq number = y ack number = x+1
  3. 发送方确认的确认 ack number = y+1
  • 为啥不能两次:防止超时的连接请求报文到达服务器再次建立连接。

tcp四次挥手释放连接

  • 4次挥手:发送方请求释放连接(Fin报文)-> 接收方确认(ACK置1)-> 接收方请求释放连接(Fin报文)-> 发送方确认-客户端等待 2MSL(报文最大生存时间) 的时间后依然没有收到回复(服务端没收到ack,则服务端会重新发送fin),则证明服务端已正常关闭,那么客户端也可以关闭连接了
  • 为啥挥手要四次,因为TCP全双工,客户端请求释放连接时,只表示客户端没东西发了,但服务器还有数据要返回。

tcp粘包,tcp分包

  1. 半包:如果数据包太大,导致服务器没有接收完整的包
  2. 粘包:tcp基于字节流,不关心上层传输的具体内容,在一个tcp报文中可能存在多个http包(发送端粘包:http包太小,tcp为了提高效率,所以粘包,接收端粘包:接收端没有及时处理接收缓冲区的数据,读取时出现粘包)
  3. 分包: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握手过程

是一个借助于数字证书协商出对称加密密钥的过程

  1. 客户端发出请求,说明支持的协议,客户端生成的随机数,支持的加密方法
  2. 服务端返回证书,服务端生成的随机数
  3. 客户端验证证书
  4. 客户端使用证书中的公钥加密另一个新得随机数。并发送给服务器
  5. 生成会话密钥:客户端和服务器分别用三个随机数生成相同的对称密钥
  6. 服务器通知握手结束,之后就通过对称密钥通信
  • 验证过程:
  1. 客户端 TLS 解析证书
  2. 证书是否过期
  3. CA是否可靠(查询信任的本地根证书)
  4. 证书是否被篡改(用户使用CA根公钥解密签名得到原始指纹,再对证书使用指纹算法得到新指纹,两指纹若不一样,则被篡改)
  5. 服务器域名和证书上的域名是否匹配

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的解决方案是将加解密分散处理,这样会拖慢加解密速度。

一次网络请求

  1. 请求dns服务器解析ip地址
  2. 三次握手建立TCP链接
  3. tls握手
  4. 请求内容封装成http报文---tcp分包 在链路上发送出去
  5. 服务器解析报文响应
  6. 关闭链接,四次握手

网络优化

  1. 请求预热:发送无body的head请求,提前建立好tcp,tls链接,省掉dns,tcp,tls时间
  2. 统一域:不同的业务的域名在客户端发出请求之前进行合并(因为若域名不同,请求不同业务时都需要dns解析,且都需要建立不同的tcp链接),使用统一的域,将请求不同的部分往后挪到接口部分,请求到达后端SLB后进行域名还原。okhttp链接复用最多保持5个空闲链接(通过调整最大空闲请求数,一个connection在内存中5k)
  3. 有了统一的域之后,可以进行网络嗅探,择优进行IP直连,app启动时,拉取域对应的ip列表,并对所有ip进行嗅探ping接口,选择其中最优的ip 最为后续请求的直连ip,不需要进行dns解析
  4. 对于可靠性要求高的请求,先入库,失败后重试
  5. 网络切换时,自动关闭缓存池中现有的链接(客户端网络地址发生变化,原先的链接失效)
  6. 减少数据传输量,protocolBuffer,图片压缩,webp,请求合适大小的图片
  7. 无网环境下, 添加强制缓存的拦截器,对请求添加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方法))

  • 特点

  1. 链接池,HTTP/2复用连接
  2. 默认支持GZIP,告诉服务器支持gzip压缩格式,请求添加Accept-Encoding: gzip,响应中返回Content-Encoding: gzip(使用哈夫曼算法,重复度越高压缩效果越好)
  3. 响应缓存
  4. 方便添加拦截器

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)
  • 缓存减小服务器压力,客户端更快地显示数据,无网下显示数据
  • 缓存分为强制缓存和对比缓存
    1. 客户端直接拿数据,若缓存未命中则请求网络并更新数据
    2. 客户端拿数据标识,总是查询网络判断数据是否有效
  • 响应头中包含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

  • 连接拦截器
  • 建立连接及连接上的流
  • 维护连接池,以复用连接
  • 一个物理链接上有多个流(逻辑上的请求响应对),一个物理链接上的多个流是并发的,但有数量限制,一个流上有多个分配,分配是并发的
  • 获取连接流程:
  1. 复用已分配的连接(重定向再次请求)
  2. 无已分配链接,则从链接池那一个新得链接,通过主机名和端口(并不是池中的链接就能复用,除了host之外的字段要都相等,比如dns,协议,代理)
  3. 尝试其他路由再从连接池中获取连接,若找到则进行dns查询
  4. 如果缓存池中没有链接,则新建链接(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之前就会检查
  • 如何发起请求:
  1. 请求被封装成 RealCall 对象,异步请求会进一步会封装成一个 Runnable
  2. 同步请求直接将请求在拦截器责任链上传递(并加到同步请求队列汇总)
  3. 异步请求会缓存到一个准备请求队列中,并检查当前并发请求数(同一个域最多5个并发,不同域最多64个),若未超阈值,则将请求出队入线程池执行(将请求在责任链上传递) 同一链接上的最大并发数据流是Int.max

请求如何在责任链上传递

责任链持有一组拦截器和当前拦截器索引,通过每次复制一条新责任链且索引+1,实现传递 发起请求并获取响应就是在请求和响应在责任链上u型传递的过程

Glide

特点

  1. 会根据控件大小进行下采样,以解码出符合需求的大小,对内存更友好
  2. 内存缓存+磁盘缓存
  3. 感知生命周期,取消任务,防止内存泄漏
  4. 感知内存吃紧,进行回收
  5. BitmapPool,防止内存抖动的进行bitmap变换
  6. 定义请求优先级

手写一个图片库注意事项

  1. 获取资源:异步并发下载图片,最大化利用cpu资源
  2. 资源解码:按实际需求异步解码,多线程并发是否能加快解码速度
  3. 资源变换:使用资源池,复用变换的资源,避免内存抖动
  4. 缓存:磁盘缓存原始图片,或变换的资源。内存缓存刚使用过的资源,使用lru策略控制大小
  5. 感知生命周期:避免内存泄漏
  6. 感知内存吃紧:清理缓存

Glide 数据加载流程

  • RequestBuilder 构建 Request和 Target,将请求委托给RequestManager,RequestManager触发Request.begin(),然后调用Engine.load()加载资源,若有内存缓存则返回,否则启动异步任务加载磁盘缓存,若无则从网络加载
  • DecodeJob 负责加载数据(可能从磁盘,或网络,onDataFetcherReady),再进行数据解码(onDataFetcherReady),再进行数据变换(Transformation),写ActiveResource,(将变换后的数据回调给Target),将变换后的资源写文件(ResourceEncoder)

预加载

preload,加载到一个PreloadTarget,等资源加载好了,就调用clear,将资源从ActiveResource移除存到Lrucache中

感知内存吃紧

注册ComponentCallbacks2,实现细粒度内存管理:

  1. onLowMemory(){清除内存}
  2. 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实现操控缓存命中策略(混入自己的值,比如修改时间)

内存缓存

  • 内存缓存分为两级
    1. 活跃图片 ActiveResource
      • 使用HashMap存储正在使用资源的弱引用
      • 资源被包装成带引用计数的EngineResource,标记引用资源的次数(当引用数不为0时阻止被回收或降级,降级即是存储到LruCache中)
      • 这一级缓存没有大小限制,所以使用了资源的弱引用
      • 存:每当下载资源后会在onEngineJobComplete()中存入ActiveResource,或者LruCache命中后,将资源从中LruCache移除并存入ActiveResource。
      • 取:每当资源释放时,会降级到LruCache中(请求对应的context onDestroy了或者被gc了)
      • 开一个后台线程,监听ReferenceQueue,不停地从中获取被gc的资源,将其从ActiveResource中移除,并重新构建一个新资源将其降级为LruCache
      • ActiveResource是为了缓解LruCache中缓存造成压力,因为LruCache中没有命中的缓存只有等到容量超限时才会被清除,强引用即使内存吃紧也不会被gc,现在当LruCache命中后移到ActiveResource,弱引用持有,当内存吃紧时能被回收。
    2. LruCache
      • 使用 LinkedHashMap 存储从活跃图片降级的资源,使用Lru算法淘汰最近最少使用的
      • 存:从活跃图片降级的资源(退出当前界面,或者ActiveResource资源被回收)
      • 取:网络请求资源之前,从缓存中取,若命中则直接从LruCache中移除了。
  • 内存缓存只会缓存经过转换后的图片
  • 内存缓存键根据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线程池

  1. 磁盘缓存线程池,一个核心线程:用于io图片编码
  2. 加载资源线程池,最多不超过4个核心线程数,用于处理网络请求,图片解码转码
  3. 动画线程池,最多不超过2个线程
  4. 磁盘缓存清理线程池
  5. ActiveResource 开启一个后台线程监听ReferenceQueue 所有线程池都默认采用优先级队列

加载Gif流程

读取流的前三个字节,若判断是gif,则会命中gif解码器-将资源解码成GifDrawable,它持有GifFrameLoader会将资源解码成一张张Bitmap并且传递给DelayTarget的对象,该对象每次资源加载完毕都会通过handler发送延迟消息回调 onFrameReady() 以触发GifDrawable.invalidataSelf()重绘。加载下一帧时会重新构建DelayTarget

请求优先级

通过给加载线程池配置优先级队列,加载任务DecodeJob 实现了 compareTo 方法,将priority相减

图片加载优化

  1. 服务器存多种尺寸的图片
  2. 自定义 AppGlideModule,按设备性能好坏设定MemoryCategory.HIGH,LOW,NORMAL,内存缓存和bitmapPool的大小系数,以及图片解码格式,ARGB_8888,RGB_565
  3. RecyclerView 在onViewRecycled 中调用clear ,因为recyclerView会默认会缓存5个同类表项,如果类型很多,内存中会持有表项,如果这些表项都包含图片,Glide 的ActiveResource会膨胀。导致gc
  4. 如果 RecyclerView 包含一个很长的itemView,超过一屏,其中包含很多照片,最好把长itemView拆成多个itemView
  5. 使用thumbnail,加载一个缩略图,最好是一个独立的链接,如果是本地的也不差
  6. 使用preload,将资源提前加载到内存中。
  7. 大部分情况下 RESOURCE ,即缓存经过变换的图片上是最好选择,节约内存和磁盘。对于gif资源只缓存原始资源DATA,因为gif是多张图每次编码解码反而耗时
  8. 使用Glide实现变换,因为有BitmapPool供复用