引子
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()