背景
最近在准备面试,结合之前的工作经验和近期在网上收集的一些面试资料,准备将Android开发岗位的知识点做一个系统的梳理,整理成一个系列:Android应用开发岗 面试汇总。本系列将分为以下几个大模块:
Java基础篇、Java进阶篇、常见设计模式
Android基础篇、Android进阶篇、性能优化
网络相关、数据结构与算法
常用开源库、Kotlin、Jetpack
注1:以上文章将陆续更新,直到我找到满意的工作为止,有跳转链接的表示已发表的文章。
注2:该系列属于个人的总结和网上东拼西凑的结果,每个知识点的内容并不一定完整,有不正确的地方欢迎批评指正。
注3:部分摘抄较多的段落或有注明出处。如有侵权,请联系本人进行删除。
一、多线程和线程同步
线程概念: 指进程中的一个按顺序执行的流程,一个进程中可以运行多个线程。线程总是属于某个进程,进程中的多个线程共享进程的内存
1、启动一个线程的方式
- 使用Thread类来定义工作(重写Thread的run方法)
Thread thread = new Thread() {
@Override
public void run() { //重写Thread的run方法
System.out.println("Thread started!");
}
};
thread.start();
}
- 使用Runnable来定义工作
原理:runnable传到Thread构造方法里,当Thread的run执行时,该方法内部执行runnable的run
作用:便于runnable的重用
static void runnable() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with Runnable started!");
}
};
Thread thread = new Thread(runnable);
thread.start();
}
- 工厂方法来定义工作,复用Thread的创建和复用runnable
static void threadFactory() {
final AtomicInteger count = new AtomicInteger(0); //原子类型
ThreadFactory factory = new ThreadFactory() {
@Override
public Thread newThread(Runnable runnable) {
return new Thread(runnable, "Thread-" + count.incrementAndGet());//++count
}
};
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " started!");
}
};
Thread thread = factory.newThread(runnable);
thread.start();
Thread thread2 = factory.newThread(runnable); //复用runnable
thread2.start();
}
- 通过线程池来定义
static void executor() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with executor started!");
}
};
Executor executor = Executors.newCachedThreadPool();
executor.execute(runnable);
executor.execute(runnable);//复用
}
- Callable 来定义(很少用)。有返回值的Runnable
static void callable() {
Callable callable = new Callable() {
@Override
public String call() throws Exception {
Thread.sleep(5000);//等待5秒模,拟耗时操作
return "Done!";
}
};
ExecutorService executorService = Executors.newCachedThreadPool();
Future future = executorService.submit(callable);
while (true) { //该循环的逻辑可以理解成加载网络转菊花的情况
//xx1() 处理其他事情
//xx2()
if (future.isDone()) { //判断任务是否执行完
try {
//卡主线程,阻塞式的方法,需要等5秒后(sleep的时长)取到值。用来获取任务执行完的时机
String result = future.get();
System.out.println("result:" + result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
break;
}
}
}
2、线程池的使用
几种不同类型的线程池
- Executors.newCachedThreadPool();
说明:核心线程数默认0,线程数上限Integer.MAX_VALUE,等待回收时间60秒 - Executors.newSingleThreadExecutor();
说明:默认1,上限1,0秒
使用场景:应用场景少,用来做功能的取消 - Executors.newFixedThreadPool(10); //nThreads=10
说明:nThreads为固定线程数, 默认和上限均为nThreads,0秒
使用场景:用来执行瞬时爆发性的任务,如批量处理多个bitmap - Executors.newScheduledThreadPool(10);
说明: 默认corePoolSize,上限Integer.MAX_VALUE,10毫秒
使用场景:做定时任务,功能类似于Timer。用来做定时处理的线程池,如每隔60秒检查一次网络
用到的类
Executor: 是一个接口,里面只有一个execute方法
Executors: 是一个工具类,用来创建不同类型的线程池:ThreadPoolExecutor,返回ExecutorService对象,ExecutorService为Executor的子接口
ExecutorService: 为Executor的子接口。该类中有shutdown()和shutdownNow(),用来结束executor,即关闭线程池
- shutdown() 保守的结束,指如果线程池中有正在执行的任务或正在排队的任务,则等他们执行完后再结束。不允许有新的任务加进来或者排队
- shutdownNow() 积极性的结束,指马上结束线程池中的所有任务,用到的是Thread的interrupt()方法
- ThreadPoolExecutor 线程池对象,包含各种不同的线程池类型,该对象的实例用ExecutorService接收。如:ExecutorService executor = Executors.newCachedThreadPool();
ThreadPoolExecutor构造方法的参数说明:
corePoolSize: 线程数的默认值(也称核心线程数),即当创建该线程池时,就自动创建默认数个线程,当线程被回收时,保留默认数个线程
maximumPoolSize: 线程数的最大值,当线程增加到最大数个后,线程就不能再增加
keepAliveTime: 线程等待被回收的时间,当线程执行完处于闲置状态时,线程不会被立即回收,需要等待设定的时候后再进行回收,便于复用(避免频繁创建线程损耗性能)
unit: 时间单位
workQueue: 线程池所使用的缓冲队列,达到核心线程数之后,新的线程放入该队列中
threadFactory: 线程池用于创建线程
handler: 线程池对拒绝任务的处理策略
面试题:自定义线程池需要注意什么,核心线程数是多少
分析下线程池处理的程序是CPU密集型,还是IO密集型
CPU密集型:核心线程数 = CPU核数 + 1
IO密集型:核心线程数 = CPU核数 * 2
CPU核数 = Runtime.getRuntime().availableProcessors()
如何确定是CPU密集型还是IO密集型?
- 计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力
- 涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)
具体程序是CPU密集型,还是IO密集型请参考链接
3、线程间通信
线程之间有两种通信的方式:消息传递和共享内存
- 共享内存:线程之间共享程序的公共状态,通过读/写修改公共状态进行隐式通信。如子线程中获取和修改主线程的变量,可直接获取和修改
- 消息传递:线程之间没有公共状态,必须通过发送消息来进行显式通信。如wait、notify等方法
Java内存模型:
Java内存模型中规定所有变量都存储在主内存(虚拟机内存的一部分)中,主要对应Java的堆内存。这里提到的变量实际上是指共享变量,存在线程间竞争的变量,如:实例变量、静态变量和构成数组对象的元素,而局部变量和方法参数因为是线程私有的,所以不存在线程间共享和竞争关系,所以也就不在前面提到的变量范围内。
线程的内存
每个线程有着自己独有的工作内存,工作内存中保存了被该线程使用到的变量,这些变量来自主内存变量的副本拷贝。线程对变量的所有读写操作都必须在工作内存中进行,不能直接读写主内存中的变量。而不同线程间的工作内存也是独立的,一个线程无法访问其他线程的工作内存中的变量。即子线程内的局部变量不能被其他线程共享。
线程间共享内存:
原理:
线程工作时,把需要的变量从主内存中拷贝到自己的工作内存,线程运行结束之后再将自己工作内存中的变量写回到主内存中,而多个线程间对变量的交互只能通过主内存来间接实现。具体的线程、工作内存、主内存的交互关系图如下:
思考:通过上面的图和前面的介绍,我们就很容易明白我们平常所说的多线程编程时遇到数据状态不一致的问题是怎么产生的。例如:线程1和线程2都需要操作主内存中的共享变量A,当线程1已经在工作内存中修改了共享变量A副本的值但是还没有写回主内存,这时线程2拷贝了主内存中共享变量A到自己的工作内存中,紧接着线程1将自己工作内存中修改过的共享变量A的副本写回到了主内存,很明显线程2加载的共享变量A是之前的旧状态的数据,这样就产生了数据状态不一致的问题(即线程同步问题)。
参考链接
4、线程同步
如何解决以上线程同步问题呢?Java中有如下几种方式:
保证变量的原子性
即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是temp = a + 1;a = temp,是可分割的,所以他不是一个原子操作。
非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。另,大致可以认为基础数据类型的访问和读写是具备原子性的。
使用volatile关键字
保证资源的同步性,即被volatile 修饰的变量,如int aaa = 0,会以最高的积极性去维护同步。当在其他线程中aaa被修改后,会立刻同步到主内存中。当其他线程读取aaa时,会先去主内存中读取,同步到子线程中,以防被其他线程修改。即保证了每次读写变量都从主内存中读。
使用synchronized关键字
该关键字作用在代码块、方法(一般方法、静态方法)上。由于可以使用不同的类型来作为锁,因此分成了类锁和对象锁。给代码块、方法加一个同步的monitor,被监视的地方具有同步性。被同一个monitor修饰的方法、代码块具有互斥性。
- 类锁:使用字节码文件(即.class)作为锁。如静态同步函数(使用本类的.class),同步代码块中使用.class。
- 对象锁:使用对象作为锁。如同步函数(使用本类实例,即 this),同步代码块中是用引用的对象。
public Object obj = new Object();
// 静态同步函数,使用本类字节码做类锁(即Demo.class)
public static synchronized void method1() {
}
public void method2() {
// 同步代码块,使用字节码做类锁
synchronized (Demo.class) {
}
}
// 同步函数,使用本类对象实例即this做对象锁
public synchronized void method3() {
}
// 同步代码块,使用本类对象实例即this做对象锁
public void method4() {
synchronized (this) {
}
}
public void method5() {
//同步代码块,使用共享数据obj实例做对象锁。
synchronized (obj) {
}
}
}
synchronized括号里的参数,可以看成是一个monitor。
ReentrantLock
对代码块进行加锁。ReentrantLock允许同一个线程多次调用lock接口获取锁,每调用一次计数便加一。因此在释放锁的时候必须调用相应多次数unlock才能释放锁:
mLock.lock();
System.out.println("ThreadOne: start");
try {
for (int m = 0; m < Integer.MAX_VALUE; m++) {
num++;
}
System.out.println("ThreadOne: over");
} finally { //需要在finally中进行解锁,避免被锁的代码块出现异常而无法释放锁的情况
mLock.unlock();
}
同时,可以使用ReentrantReadWriteLock分别获取读锁和写锁,当只进行读操作时,只用读锁即可。保证了多个线程同时调用被读锁锁住的资源,不会出现同步性问题。
ThreadLocal的作用
ThreadLocal是JDK包提供的,它提供线程本地变量,如果创建了ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个副本,在实际多线程操作的时候,操作的是自己本地内存中的变量,从而规避了线程安全问题
作用:
ThreadLocal是除了加锁这种同步方式之外的一种保证一种规避多线程访问出现线程不安全的方法,当我们在创建一个变量后,如果每个线程对其进行访问的时候访问的都是线程自己的变量这样就不会存在线程不安全问题。即实现了线程隔离,线程直接是不能共享内存的,即该对象不管在哪个线程中读取数据,读到的都是该线程中自己的数据,不跟其他线程同步。
5、线程间通信
结束线程的方式:
- Thread.stop() 已被弃用,因为调用该方法后,线程会被立即终止,无法确定程序运行到哪里了。
- Thread.interrupt() 当前流行的使用方式,配合isInterrupt()使用。调用该方法后,会把线程标记为被停止执行状态,在子线程中,程序员可通过isInterrupt()方法来决定在哪里终止操作(即可由程序员控制终止的地方)。比如在一个耗时方法执行前,使用该判断,如果为true,则不继续往下执行。
wait()、notifyAll()
- 被synchronized修饰的代码块A持有了锁,但是该代码块需要等待某个变量aaa满足条件后才能继续往下执行,而修改aaa的代码在另一个被同一个monitor监控的线程B里。则测试可调用wait()方法来暂时释放锁,让B执行,当B执行后,需要调用notifyAll()方法来唤醒其他等待中的线程,之后A就可以继续执行。
- wait()、notify()、notifyAll(),为Object的方法,在方法中调用时,是synchronized的monitor去做等待和唤醒操作
- notify唤醒正在等待中的一个线程(唤醒哪一个是没有顺序的,随机),notifyAll唤醒等待中的所有线程。
- wait()、notifyAll() 必须成对使用,且都是需要被synchronized的同一个monitor监控。
join()
功能和wait()、notify()方法一样,当某个线程threadA执行时,需要等待另一个线程threadB先执行完,则可以在线程A中调用threadB.join(),达到等待的效果。
yield()
该方法作用是把自己的线程时间线让出一下下,让给跟自己同优先级的其他线程。作用等价于wait(),更短时间的wait,自动wait然后恢复。
乐观锁与悲观锁
数据库相关的业务常用(高并发控制),安卓中不常用。
- 乐观锁:读数据时不加锁,写数据时先判断是否有更新,再加锁。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。
- 悲观锁:读数据时就上锁。总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)
死锁
多重锁容易导致死锁
synchronized (obj1) {
work();
synchronized (obj2) {
work();
}
}
sleep和wait区别
- sleep:是将当前线程阻塞,如:sleep(1000),阻塞1秒。sleep是Thread的静态方法,在哪个线程中调用,哪个线程就休眠。
- wait:当前被synchronized修饰的代码块A持有了锁,该代码中调用了wait()后,将monitor让出(即将A所在的线程挂起),给其他拥有同一个monitor的代码块B执行,直到代码块A被唤醒后才可继续执行(可用monitor.notifyAll()唤醒)。wait是Object的方法。
2、泛型
略
参考
3、Java 的 IO、NIO 和 Okio
io是输入输出流,它的作用就是对外部进行数据交互使用的,内部和外部分别表示的是内存以及内存以外的,外部包括手机文件,电脑文件和网络, 服务器等都称为外部 ,外部统称为文件和网络
详情略
参考链接
4、集合
概述:
Java集合里使用接口来定义功能,是一套完善的继承体系。Iterator是所有集合的总接口,其他所有接口都继承于它,该接口定义了集合的遍历操作,Collection接口继承于Iterator,是集合的次级接口(Map独立存在),定义了集合的一些通用操作。
参考链接
结构:
List:有序、可重复;索引查询速度快;插入、删除伴随数据移动,速度慢;
Set:无序,不可重复;
Map:键值对,键唯一,值多个;
面试题:
- ArrayList与LinkedList的区别和适用场景?
Arraylist:
优点:ArrayList是实现了基于动态数组的数据结构,因地址连续,一旦数据存储好了,查询操作效率会比较高(在内存里是连着放的)。
缺点:因为地址连续,ArrayList要移动数据,所以插入和删除操作效率比较低。
LinkedList:
优点:LinkedList基于链表的数据结构,地址是任意的,其在开辟内存空间的时候不需要等一个连续的地址,对新增和删除操作add和remove,LinedList比较占优势。LikedList 适用于要头尾操作或插入指定位置的场景。
缺点:因为LinkedList要移动指针,所以查询操作性能比较低。
适用场景分析:
当需要对数据进行对应访问的情况下选用ArrayList,当要对数据进行多次增加删除修改时采用LinkedList。
- ArrayList和LinkedList怎么动态扩容的?
ArrayList: ArrayList 的初始大小是0,然后,当add第一个元素的时候大小则变成10。并且,在后续扩容的时候会变成当前容量的1.5倍大小。
LinkedList: LinkedList 是一个双向链表,没有初始化大小,也没有扩容的机制,就是一直在前面或者后面新增就好。
- HashMap原理
- HashMap何时扩容
- Hashmap如何解决散列碰撞(必问)?
Java中HashMap是利用“拉链法”处理HashCode的碰撞问题。在调用HashMap的put方法或get方法时,都会首先调用hashcode方法,去查找相关的key,当有冲突时,再调用equals方法。hashMap基于hasing原理,我们通过put和get方法存取对象。当我们将键值对传递给put方法时,他调用键对象的hashCode()方法来计算hashCode,然后找到bucket(哈希桶)位置来存储对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当碰撞发生了,对象将会存储在链表的下一个节点中。hashMap在每个链表节点存储键值对对象。当两个不同的键却有相同的hashCode时,他们会存储在同一个bucket位置的链表中。键对象的equals()来找到键值对。
常见面试题
5、ClassLoader
概念:
Classloader负责将Class加载到JVM中,并且确定由那个ClassLoader来加载(父优先的等级加载机制)。还有一个任务就是将Class字节码重新解释为JVM统一要求的格式
类加载器的双亲委派模型
当一个类加载器收到一个类加载的请求,它首先会将该请求委派给父类加载器去加载,每一个层次的类加载器都是如此,因此所有的类加载请求最终都应该被传入到顶层的启动类加载器(Bootstrap ClassLoader)中,只有当父类加载器反馈无法完成这个列的加载请求时(它的搜索范围内不存在这个类),子类加载器才尝试加载。其层次结构示意图如下:
作用:
- 可以避免重复加载,父类已经加载了,子类就不需要再次加
- 更加安全,很好的解决了各个类加载器的基础类的统一问题,如果不使用该种方式,那么用户可以随意定义类加载器来加载核心api,会带来相关隐患。
参考链接
6、JVM
JVM基本构成
从上图可知,JVM主要包括四个部分:
-
1.类加载器(ClassLoader):在JVM启动时或者在类运行将需要的class加载到JVM中。(下图表示了从java源文件到JVM的整个过程,可配合理解。
- 2.执行引擎:负责执行class文件中包含的字节码指令
-
3.内存区(也叫运行时数据区):是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为5个区域,如图:
方法区(MethodArea):用于存储类结构信息的地方,包括常量池、静态常量、构造函数等。虽然JVM规范把方法区描述为堆的一个辑部分, 但它却有个别名non-heap(非堆),所以大家不要搞混淆了。方法区还包含一个运行时常量池。
java堆(Heap):存储java实例或者对象的地方。这块是GC的主要区域。从存储的内容我们可以很容易知道,方法和堆是被所有java线程共享的。
java栈(Stack):java栈总是和线程关联在一起,每当创一个线程时,JVM就会为这个线程创建一个对应的java栈在这个java栈中,其中又会包含多个栈帧,每运行一个方法就建一个栈帧,用于存储局部变量表、操作栈、方法返回等。每一个方法从调用直至执行完成的过程,就对应一栈帧在java栈中入栈到出栈的过程。所以java栈是现成有的。
程序计数器(PCRegister):用于保存当前线程执行的内存地址。由于JVM程序是多线程执行的(线程轮流切换),所以为了保证程切换回来后,还能恢复到原先状态,就需要一个独立计数器,记录之前中断的地方,可见程序计数器也是线程私有的。
本地方法栈(Native MethodStack):和java栈的作用差不多,只不过是为JVM使用到native方法服务的。
- 4.本地方法接口:主要是调用C或C++实现的本地方法及回调结果。
GC的原理和回收策略
引用管理
Java中使用可达性算法对对象进行是否可回收的标记算法。通过一系列称为GCRoots的对象作为起始点,从这些节点从上向下搜索,所走过的路径称为引用链,当一个对象没有任何引用链与GCRoots连接时就说明此对象不可用,也就是对象不可达。
回收算法
- 1.标记-清除(Mark-sweep)
标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。 - 2.标记-整理(Mark-Compact)
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高(耗时),但是却解决了内存碎片的问题。该垃圾回收算法适用于对象存活率高的场景(老年代)。 - 3.复制(Copying)
复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。 - 4.分代收集算法
不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java堆内存一般可以分为新生代、老年代和永久代三个模块。
参考链接