史上最全!押题率90%的 Android 中高级工程师面试复习大纲及真题答案整理(上篇)

在这里插入图片描述

缘起

转眼间2020就接近尾声了,年后有跳槽想法的小伙伴们心里应该也有自己的决定了。金三银四青铜五,总不能到跳槽的黄金期再开始复习吧。没办法,都是兄弟,宠着!2020年度Android中高级面试复习大全奉上。

篇幅过长,预计分三篇文章讲解,好兄弟们记得点个关注或者点赞Mark插个眼,后续不容错过哦

废话就懒得多说了,进入正题。(再插一句:点赞都是好兄弟,白嫖都是好妹妹)

正文

概述

一文搞定 Android 中高级工程师面试必问所有知识点,希望可以通过此文帮助一些想换工作的朋友更好的复习,准备面试。

以下只整理列出面试频率较高的题

1、Java、计算机网络相关

1.1 容器(HashMap、HashSet、LinkedList、ArrayList、数组等)

1.1.1说说HashMap的工作原理

简单来说:HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存Entry对象。当两个对象的hashcode相同时,它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry会存储在链表中,当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。

网上文章很多,可以去我的B站看视频讲解:HashMap原理解析

1.1.2、用过 ArrayList 吗 ?说一下它的底层实现 ?

有使用,它的底层是基于数组的数据结构, 默认第一次初始化长度为 10 ,由于 add ,put , size 没有处理线程安全,所以它是非线程安全的。

要不我手动画一下它的整体结构吧。如下图所示。

图解:

  • Index: ArrayList 的索引下标
  • elementData: ArrayList 的索引下标对应的数据
  • size: ArrayList 的大小

1.1.3、用过 LinkedList 吗?说一下它的底层实现 ?

有用过,它的底层数据结构是双向链表组成, 我还是画一下它的结构图吧。如下所示:

图解:

  • 链表每个节点我们叫做 Node,Node 有 prev 属性,代表前一个节点的位置,next 属性,代表后一个节点的位置;
  • first 是双向链表的头节点,它的前一个节点是 null。
  • last 是双向链表的尾节点,它的后一个节点是 null;
  • 当链表中没有数据时,first 和 last 是同一个节点,前后指向都是 null;

1.2 内存

1.2.1 JVM内存区域

JVM基本构成

从上图可知,JVM主要包括四个部分:

1.类加载器(ClassLoader):在JVM启动时或者在类运行将需要的class加载到JVM中。(下图表示了从java源文件到JVM的整个过程,可配合理解。

2.执行引擎:负责执行class文件中包含的字节码指令;

3.内存区(也叫运行时数据区):是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为5个区域,如图:

1.2.2 开线程影响哪块内存?

每当有线程被创建的时候,JVM就需要为其在内存中分配虚拟机栈和本地方法栈来记录调用方法的内容,分配程序计数器记录指令执行的位置,这样的内存消耗就是创建线程的内存代价。

1.2.3 JVM的内存模型的理解?

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。
Java线程之间的通信总是隐式进行,并且采用的是共享内存模型。这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
总之,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

1.3 垃圾回收算法(JVM)

1.3.1、描述一下GC的原理和回收策略?

提到垃圾回收,我们可以先思考一下,如果我们去做垃圾回收需要解决哪些问题?

一般说来,我们要解决三个问题:

1、回收哪些内存?

2、什么时候回收?

3、如何回收?

这些问题分别对应着引用管理和回收策略等方案。

提到引用,我们都知道Java中有四种引用类型:

  • 强引用:代码中普遍存在的,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象。
  • 软引用:SoftReference,用来描述还有用但是非必须的对象,当内存不足的时候会回收这类对象。
  • 弱引用:WeakReference,用来描述非必须对象,弱引用的对象只能生存到下一次GC发生时,当GC发生时,无论内存是否足够,都会回收该对象。
  • 虚引用:PhantomReference,一个对象是否有虚引用的存在,完全不会对其生存时间产生影响,也无法通过虚引用取得一个对象的引用,它存在的唯一目的是在这个对象被回收时可以收到一个系统通知。

不同的引用类型,在做GC时会区别对待,我们平时生成的Java对象,默认都是强引用,也就是说只要强引用还在,GC就不会回收,那么如何判断强引用是否存在呢?

一个简单的思路就是:引用计数法,有对这个对象的引用就+1,不再引用就-1,但是这种方式看起来简单美好,但它却不能解决循环引用计数的问题。

因此可达性分析算法登上历史舞台,用它来判断对象的引用是否存在。

可达性分析算法通过一系列称为GCRoots的对象作为起始点,从这些节点从上向下搜索,所走过的路径称为引用链,当一个对象没有任何引用链与GCRoots连接时就说明此对象不可用,也就是对象不可达。

GC Roots对象通常包括:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 方法中类的静态属性引用的对象
  • 方法区中常量引用的对象
  • Native方法引用的对象

可达性分析算法整个流程如下所示:

第一次标记:对象在经过可达性分析后发现没有与GC Roots有引用链,则进行第一次标记并进行一次筛选,筛选条件是:该对象是否有必要执行finalize()方法。没有覆盖finalize()方法或者finalize()方法已经被执行过都会被认为没有必要执行。 如果有必要执行:则该对象会被放在一个F-Queue队列,并稍后在由虚拟机建立的低优先级Finalizer线程中触发该对象的finalize()方法,但不保证一定等待它执行结束,因为如果这个对象的finalize()方法发生了死循环或者执行时间较长的情况,会阻塞F-Queue队列里的其他对象,影响GC。

第二次标记:GC对F-Queue队列里的对象进行第二次标记,如果在第二次标记时该对象又成功被引用,则会被移除即将回收的集合,否则会被回收。

总之,JVM在做垃圾回收的时候,会检查堆中的所有对象否会被这些根集对象引用,不能够被引用的对象就会被圾收集器回收。一般回收算法也有如下几种:

1).标记-清除(Mark-sweep)

标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

2).标记-整理(Mark-Compact)

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。该垃圾回收算法适用于对象存活率高的场景(老年代)。

3).复制(Copying)

复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。

4).分代收集算法

不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java堆内存一般可以分为新生代、老年代和永久代三个模块:

新生代:

1.所有新生成的对象首先都是放在新生代的。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。

2.新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。

3.当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

4.新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

老年代:

1.在老年代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。

2.内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC,即Full GC。Full GC发生频率比较低,老年代对象存活时间比较长。

永久代:

永久代主要存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如使用反射、动态代理、CGLib等bytecode框架时,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。

垃圾收集器

垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现:

  • Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;

  • Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;

  • ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;

  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。

  • Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

  • Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;

  • G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

内存分配和回收策略

JAVA自动内存管理:给对象分配内存 以及 回收分配给对象的内存。

1、对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。

2、大对象直接进入老年代。如很长的字符串以及数组。很长的字符串以及数组。

3、长期存活的对象将进入老年代。当对象在新生代中经历过一定次数(默认为15)的Minor GC后,就会被晋升到老年代中。

4、动态对象年龄判定。为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

1.4 类加载过程(需要多看看,重在理解,对于热修复和插件化比较重要)

类的加载器

大家都知道,一个Java程序都是由若干个.class文件组织而成的一个完整的Java应用程序,当程序在运行时,即会调用该程序的一个入口函数来调用系统的相关功能,而这些功能都被封装在不同的class文件当中,所以经常要从这个class文件中要调用另外一个class文件中的方法,如果另外一个文件不存在的话,则会引发系统异常。

而程序在启动的时候,并不会一次性加载程序所要用到的class文件,而是根据程序的需要,通过Java的类加载制(ClassLoader)来动态加载某个class文件到内存当的,从而只有class文件被载入到了内存之后,才能被其它class文件所引用。所以ClassLoader就是用来动态加载class件到内存当中用的。

双亲机制

类的加载就是虚拟机通过一个类的全限定名来获取描述此类的二进制字节流,而完成这个加载动作的就是类加载器。

类和类加载器息息相关,判定两个类是否相等,只有在这两个类被同一个类加载器加载的情况下才有意义,否则即便是两个类来自同一个Class文件,被不同类加载器加载,它们也是不相等的。

注:这里的相等性保函Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果以及Instance关键字对对象所属关系的判定结果等。

类加载器可以分为三类:

  • 启动类加载器(Bootstrap ClassLoader):负责加载<JAVA_HOME>\lib目录下或者被-Xbootclasspath参数所指定的路径的,并且是被虚拟机所识别的库到内存中。

  • 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录下或者被java.ext.dirs系统变量所指定的路径的所有类库到内存中。

  • 应用类加载器(Application ClassLoader):负责加载用户类路径上的指定类库,如果应用程序中没有实现自己的类加载器,一般就是这个类加载器去加载应用程序中的类库。

1、原理介绍

ClassLoader使用的是双亲委托模型来搜索类的,每个ClassLoader实例都有一个父类加载器的引用(不是继承的关系,是一个包含的关系),虚拟机内置的类加载器(Bootstrap ClassLoader)本身没有父类加载器,但可以用作其它lassLoader实例的的父类加载器。

当一个ClassLoader实例需要加载某个类时,它会在试图搜索某个类之前,先把这个任务委托给它的父类加载器,这个过程是由上至下依次检查的,首先由最顶层的类加载器Bootstrap ClassLoader试图加载,如果没加载到,则把任务转交给Extension ClassLoader试图加载,如果也没加载到,则转交给App ClassLoader 进行加载,如果它也没有加载得到的话,则返回给委托的发起者,由它到指定的文件系统或网络等待URL中加载该类。

如果它们都没有加载到这个类时,则抛出ClassNotFoundException异常。否则将这个找到的类生成一个类的定义,将它加载到内存当中,最后返回这个类在内存中的Class实例对象。

类加载机制:

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法去内,然后在堆区创建一个java.lang.Class对象,用来封装在方法区内的数据结构。类的加载最终是在堆区内的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载有三种方式:

1)命令行启动应用时候由JVM初始化加载

2)通过Class.forName()方法动态加载

3)通过ClassLoader.loadClass()方法动态加载

这么多类加载器,那么当类在加载的时候会使用哪个加载器呢?

这个时候就要提到类加载器的双亲委派模型,流程图如下所示:

双亲委派模型的整个工作流程非常的简单,如下所示:

如果一个类加载器收到了加载类的请求,它不会自己立去加载类,它会先去请求父类加载器,每个层次的类加器都是如此。层层传递,直到传递到最高层的类加载器只有当 父类加载器反馈自己无法加载这个类,才会有当子类加载器去加载该类。

2、为什么要使用双亲委托这种模型呢?

因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要让子ClassLoader再加载一次。

考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(BootstrcpClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

3、但是JVM在搜索类的时候,又是如何判定两个class是相同的呢?

JVM在判定两个class是否相同时,不仅要判断两个类名否相同,而且要判断是否由同一个类加载器实例加载的。

只有两者同时满足的情况下,JVM才认为这两个class是相同的。就算两个class是同一份class字节码,如果被两个不同的ClassLoader实例所加载,JVM也会认为它们是两个不同class。

比如网络上的一个Java类org.classloader.simple.NetClassLoaderSimple,javac编译之后生成字节码文件NetClasLoaderSimple.class,ClassLoaderA和ClassLoaderB这个类加载器并读取了NetClassLoaderSimple.class文件并分别定义出了java.lang.Class实例来表示这个类,对JVM来说,它们是两个不同的实例对象,但它们确实是一份字节码文件,如果试图将这个Class实例生成具体的对象进行转换时,就会抛运行时异常java.lang.ClassCastException,提示这是两个不同的类型。

1.5 反射

1.5.1、说说你对Java反射的理解?

答:Java 中的反射首先是能够获取到Java中要反射类的字节码, 获取字节码有三种方法:

1.Class.forName(className)

2.类名.class

3.this.getClass()。

然后将字节码中的方法,变量,构造函数等映射成相应的Method、Filed、Constructor等类,这些类提供了丰富的方法可以被我们所使用。

1.6 多线程和线程池

1.6.1、什么是线程池,如何使用?为什么要使用线程池?

答:线程池就是事先将多个线程对象放到一个容器中,使用的时候就不用new线程而是直接去池中拿线程即可,节 省了开辟子线程的时间,提高了代码执行效率。

1.6.2、Java中的线程池共有几种?

Java有四种线程池:

第一种:newCachedThreadPool

不固定线程数量,且支持最大为Integer.MAX_VALUE的线程数量:

public static ExecutorService newCachedThreadPool() {
    // 这个线程池corePoolSize为0,maximumPoolSize为Integer.MAX_VALUE
    // 意思也就是说来一个任务就创建一个woker,回收时间是60s
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                60L, TimeUnit.SECONDS,
                                new SynchronousQueue<Runnable>());
}

可缓存线程池:

1、线程数无限制。 2、有空闲线程则复用空闲线程,若无空闲线程则新建线程。 3、一定程序减少频繁创建/销毁线程,减少系统开销。

第二种:newFixedThreadPool

一个固定线程数量的线程池:

public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    // corePoolSize跟maximumPoolSize值一样,同时传入一个无界阻塞队列
    // 该线程池的线程会维持在指定线程数,不会进行回收
    return new ThreadPoolExecutor(nThreads, nThreads,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>(),
                                threadFactory);
}

定长线程池:

1、可控制线程最大并发数(同时执行的线程数)。 2、超出的线程会在队列中等待。

第三种:newSingleThreadExecutor

可以理解为线程数量为1的FixedThreadPool:

public static ExecutorService newSingleThreadExecutor() {
    // 线程池中只有一个线程进行任务执行,其他的都放入阻塞队列
    // 外面包装的FinalizableDelegatedExecutorService类实现了finalize方法,在JVM垃圾回收的时候会关闭线程池
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

单线程化的线程池:

1、有且仅有一个工作线程执行任务。 2、所有任务按照指定顺序执行,即遵循队列的入队出队规则。

第四种:newScheduledThreadPool。

支持定时以指定周期循环执行任务:

public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}

注意:前三种线程池是ThreadPoolExecutor不同配置的实例,最后一种是ScheduledThreadPoolExecutor的实例。

1.6.3、线程池原理?

从数据结构的角度来看,线程池主要使用了阻塞队列(BlockingQueue)和HashSet集合构成。 从任务提交的流程角度来看,对于使用线程池的外部来说,线程池的机制是这样的:

1、如果正在运行的线程数 < coreSize,马上创建核心线程执行该task,不排队等待;
2、如果正在运行的线程数 >= coreSize,把该task放入阻塞队列;
3、如果队列已满 && 正在运行的线程数 < maximumPoolSize,创建新的非核心线程执行该task;
4、如果队列已满 && 正在运行的线程数 >= maximumPoolSize,线程池调用handler的reject方法拒绝本次提交。

理解记忆:1-2-3-4对应(核心线程->阻塞队列->非核心线程->handler拒绝提交)。

线程池的线程复用:

这里就需要深入到源码addWorker():它是创建新线程的关键,也是线程复用的关键入口。最终会执行到runWoker,它取任务有两个方式:

  • firstTask:这是指定的第一个runnable可执行任务,它会在Woker这个工作线程中运行执行任务run。并且置空表示这个任务已经被执行。
  • getTask():这首先是一个死循环过程,工作线程循环直到能够取出Runnable对象或超时返回,这里的取的目标就是任务队列workQueue,对应刚才入队的操作,有入有出。

其实就是任务在并不只执行创建时指定的firstTask第一任务,还会从任务队列的中通过getTask()方法自己主动去取任务执行,而且是有/无时间限定的阻塞等待,保证线程的存活。

信号量

semaphore 可用于进程间同步也可用于同一个进程间的线程同步。

可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。

1.6.4、线程池都有哪几种工作队列?

1、ArrayBlockingQueue

是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。

2、LinkedBlockingQueue

一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()和Executors.newSingleThreadExecutor使用了这个队列。

3、SynchronousQueue

一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。

4、PriorityBlockingQueue

一个具有优先级的无限阻塞队列。

1.6.5、怎么理解无界队列和有界队列?

有界队列

1.初始的poolSize < corePoolSize,提交的runnable任务,会直接做为new一个Thread的参数,立马执行 。 2.当提交的任务数超过了corePoolSize,会将当前的runable提交到一个block queue中。 3.有界队列满了之后,如果poolSize < maximumPoolsize时,会尝试new 一个Thread的进行救急处理,立马执行对应的runnable任务。 4.如果3中也无法处理了,就会走到第四步执行reject操作。

无界队列

与有界队列相比,除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。当有新的任务到来,系统的线程数小于corePoolSize时,则新建线程执行任务。当达到corePoolSize后,就不会继续增加,若后续仍有新的任务加入,而没有空闲的线程资源,则任务直接进入队列等待。若任务创建和处理的速度差异很大,无界队列会保持快速增长,直到耗尽系统内存。 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略。

6、多线程中的安全队列一般通过什么实现?

Java提供的线程安全的Queue可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue.

对于BlockingQueue,想要实现阻塞功能,需要调用put(e) take() 方法。而ConcurrentLinkedQueue是基于链接节点的、无界的、线程安全的非阻塞队列。

1.7 HTTP、HTTPS、TCP/IP、Socket通信、三次握手四次挥手过程

1.7.1、HTTP与HTTPS有什么区别?

HTTPS是一种通过计算机网络进行安全通信的传输协议。HTTPS经由HTTP进行通信,但利用SSL/TLS来加密数据包。HTTPS开发的主要目的,是提供对网站服务器的身份 认证,保护交换数据的隐私与完整性。

HTPPS和HTTP的概念:

HTTPS(全称:Hypertext Transfer Protocol over Secure Socket Layer),是以安全为目标的HTTP通道,简单讲是HTTP的安全版。即HTTP下加入SSL层,HTTPS的安全基础是SSL,因此加密的详细内容就需要SSL。 它是一个URI scheme(抽象标识符体系),句法类同http:体系。用于安全的HTTP数据传输。https:URL表明它使用了HTTP,但HTTPS存在不同于HTTP的默认端口及一个加密/身份验证层(在HTTP与TCP之间)。这个系统的最初研发由网景公司进行,提供了身份验证与加密通讯方法,现在它被广泛用于万维网上安全敏感的通讯,例如交易支付方面。

超文本传输协议 (HTTP-Hypertext transfer protocol) 是一种详细规定了浏览器和万维网服务器之间互相通信的规则,通过因特网传送万维网文档的数据传送协议。

https协议需要到ca申请证书,一般免费证书很少,需要交费。http是超文本传输协议,信息是明文传输,https 则是具有安全性的ssl加密传输协议http和https使用的是完全不同的连接方式用的端口也不一样,前者是80,后者是443。http的连接很简单,是无状态的HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议 要比http协议安全HTTPS解决的问题:1 . 信任主机的问题. 采用https 的server 必须从CA 申请一个用于证明服务器用途类型的证书. 改证书只有用于对应的server 的时候,客户度才信任次主机2 . 防止通讯过程中的数据的泄密和被窜改

如下图所示,可以很明显的看出两个的区别:

注:TLS是SSL的升级替代版,具体发展历史可以参考传输层安全性协议。

HTTP与HTTPS在写法上的区别也是前缀的不同,客户端处理的方式也不同,具体说来:

如果URL的协议是HTTP,则客户端会打开一条到服务端端口80(默认)的连接,并向其发送老的HTTP请求。 如果URL的协议是HTTPS,则客户端会打开一条到服务端端口443(默认)的连接,然后与服务器握手,以二进制格式与服务器交换一些SSL的安全参数,附上加密的 HTTP请求。 所以你可以看到,HTTPS比HTTP多了一层与SSL的连接,这也就是客户端与服务端SSL握手的过程,整个过程主要完成以下工作:

交换协议版本号 选择一个两端都了解的密码 对两端的身份进行认证 生成临时的会话密钥,以便加密信道。 SSL握手是一个相对比较复杂的过程,更多关于SSL握手的过程细节可以参考TLS/SSL握手过程

SSL/TSL的常见开源实现是OpenSSL,OpenSSL是一个开放源代码的软件库包,应用程序可以使用这个包来进行安全通信,避免窃听,同时确认另一端连接者的身份。这个包广泛被应用在互联网的网页服务器上。 更多源于OpenSSL的技术细节可以参考OpenSSL。

1.7.2、谈谈对http缓存的了解。

HTTP的缓存机制也是依赖于请求和响应header里的参数类实现的,最终响应式从缓存中去,还是从服务端重新拉取,HTTP的缓存机制的流程如下所示:

HTTP的缓存可以分为两种:

强制缓存:需要服务端参与判断是否继续使用缓存,当客户端第一次请求数据是,服务端返回了缓存的过期时间(Expires与Cache-Control),没有过期就可以继续使用缓存,否则则不适用,无需再向服务端询问。 对比缓存:需要服务端参与判断是否继续使用缓存,当客户端第一次请求数据时,服务端会将缓存标识(Last-Modified/If-Modified-Since与Etag/If-None-Match)与数据一起返回给客户端,客户端将两者都备份到缓存中 ,再次请求数据时,客户端将上次备份的缓存 标识发送给服务端,服务端根据缓存标识进行判断,如果返回304,则表示通知客户端可以继续使用缓存。 强制缓存优先于对比缓存。

上面提到强制缓存使用的的两个标识:

Expires:Expires的值为服务端返回的到期时间,即下一次请求时,请求时间小于服务端返回的到期时间,直接使用缓存数据。到期时间是服务端生成的,客户端和服务端的时间可能有误差。 Cache-Control:Expires有个时间校验的问题,所有HTTP1.1采用Cache-Control替代Expires。 Cache-Control的取值有以下几种:

private: 客户端可以缓存。 public: 客户端和代理服务器都可缓存。 max-age=xxx: 缓存的内容将在 xxx 秒后失效 no-cache: 需要使用对比缓存来验证缓存数据。 no-store: 所有内容都不会缓存,强制缓存,对比缓存都不会触发。 我们再来看看对比缓存的两个标识:

Last-Modified/If-Modified-Since

Last-Modified 表示资源上次修改的时间。

当客户端发送第一次请求时,服务端返回资源上次修改的时间:

Last-Modified: Tue, 12 Jan 2016 09:31:27 GMT

客户端再次发送,会在header里携带If-Modified-Since。将上次服务端返回的资源时间上传给服务端。

If-Modified-Since: Tue, 12 Jan 2016 09:31:27 GMT 

服务端接收到客户端发来的资源修改时间,与自己当前的资源修改时间进行对比,如果自己的资源修改时间大于客户端发来的资源修改时间,则说明资源做过修改, 则返回200表示需要重新请求资源,否则返回304表示资源没有被修改,可以继续使用缓存。

上面是一种时间戳标记资源是否修改的方法,还有一种资源标识码ETag的方式来标记是否修改,如果标识码发生改变,则说明资源已经被修改,ETag优先级高于Last-Modified。

Etag/If-None-Match

ETag是资源文件的一种标识码,当客户端发送第一次请求时,服务端会返回当前资源的标识码:

ETag: "5694c7ef-24dc"

客户端再次发送,会在header里携带上次服务端返回的资源标识码:

If-None-Match:“5694c7ef-24dc” 服务端接收到客户端发来的资源标识码,则会与自己当前的资源吗进行比较,如果不同,则说明资源已经被修改,则返回200,如果相同则说明资源没有被修改,返回 304,客户端可以继续使用缓存。

1.7.3、Https加密原理。

加密算法的类型基本上分为了两种:

  • 对称加密,加密用的密钥和解密用的密钥是同一个,比较有代表性的就是 AES 加密算法;
  • 非对称加密,加密用的密钥称为公钥,解密用的密钥称为私钥,经常使用到的 RSA 加密算法就是非对称加密的;

此外,还有Hash加密算法

HASH算法:MD5, SHA1, SHA256

相比较对称加密而言,非对称加密安全性更高,但是加解密耗费的时间更长,速度慢。

想了解更多加密算法请点击这里

HTTPS = HTTP + SSL,HTTPS 的加密就是在 SSL 中完成的。

这就要从 CA 证书讲起了。CA 证书其实就是数字证书,是由 CA 机构颁发的。至于 CA 机构的权威性,那么是毋庸置疑的,所有人都是信任它的。CA 证书内一般会包含以下内容:

  • 证书的颁发机构、版本
  • 证书的使用者
  • 证书的公钥
  • 证书的有效时间
  • 证书的数字签名 Hash 值和签名 Hash 算法

客户端如何校验 CA 证书?

CA 证书中的 Hash 值,其实是用证书的私钥进行加密后的值(证书的私钥不在 CA 证书中)。然后客户端得到证书后,利用证书中的公钥去解密该 Hash 值,得到 Hash-a ;然后再利用证书内的签名 Hash 算法去生成一个 Hash-b 。最后比较 Hash-a 和 Hash-b 这两个的值。如果相等,那么证明了该证书是对的,服务端是可以被信任的;如果不相等,那么就说明该证书是错误的,可能被篡改了,浏览器会给出相关提示,无法建立起 HTTPS 连接。除此之外,还会校验 CA 证书的有效时间和域名匹配等。

HTTPS 中的 SSL 握手建立过程

假设现在有客户端 A 和服务器 B :

  • 1、首先,客户端 A 访问服务器 B ,比如我们用浏览器打开一个网页 www.baidu.com ,这时,浏览器就是客户端 A ,百度的服务器就是服务器 B 了。这时候客户端 A 会生成一个随机数1,把随机数1 、自己支持的 SSL 版本号以及加密算法等这些信息告诉服务器 B 。
  • 2、服务器 B 知道这些信息后,然后确认一下双方的加密算法,然后服务端也生成一个随机数 B ,并将随机数 B 和 CA 颁发给自己的证书一同返回给客户端 A 。
  • 3、客户端 A 得到 CA 证书后,会去校验该 CA 证书的有效性,校验方法在上面已经说过了。校验通过后,客户端生成一个随机数3 ,然后用证书中的公钥加密随机数3 并传输给服务端 B 。
  • 4、服务端 B 得到加密后的随机数3,然后利用私钥进行解密,得到真正的随机数3。
  • 5、最后,客户端 A 和服务端 B 都有随机数1、随机数2、随机数3,然后双方利用这三个随机数生成一个对话密钥。之后传输内容就是利用对话密钥来进行加解密了。这时就是利用了对称加密,一般用的都是 AES 算法。
  • 6、客户端 A 通知服务端 B ,指明后面的通讯用对话密钥来完成,同时通知服务器 B 客户端 A 的握手过程结束。
  • 7、服务端 B 通知客户端 A,指明后面的通讯用对话密钥来完成,同时通知客户端 A 服务器 B 的握手过程结束。
  • 8、SSL 的握手部分结束,SSL 安全通道的数据通讯开始,客户端 A 和服务器 B 开始使用相同的对话密钥进行数据通讯。

简化如下:

  • 1、客户端和服务端建立 SSL 握手,客户端通过 CA 证书来确认服务端的身份;
  • 2、互相传递三个随机数,之后通过这随机数来生成一个密钥;
  • 3、互相确认密钥,然后握手结束;
  • 4、数据通讯开始,都使用同一个对话密钥来加解密;

可以发现,在 HTTPS 加密原理的过程中把对称加密和非对称加密都利用了起来。即利用了非对称加密安全性高的特点,又利用了对称加密速度快,效率高的好处。

需要更深的理解请点击这里

1.7.4、HTTPS 如何防范中间人攻击?

什么是中间人攻击?

当数据传输发生在一个设备(PC/手机)和网络服务器之间时,攻击者使用其技能和工具将自己置于两个端点之间并截获数据;尽管交谈的两方认为他们是在与对方交谈,但是实际上他们是在与干坏事的人交流,这便是中间人攻击。

有几种攻击方式?

  • 1、嗅探:

嗅探或数据包嗅探是一种用于捕获流进和流出系统/网络的数据包的技术。网络中的数据包嗅探就好像电话中的监听。

  • 2、数据包注入:

在这种技术中,攻击者会将恶意数据包注入常规数据中。这样用户便不会注意到文件/恶意软件,因为它们是合法通讯流的一部分。

  • 3、会话劫持:

在你登录进你的银行账户和退出登录这一段期间便称为一个会话。这些会话通常都是黑客的攻击目标,因为它们包含潜在的重要信息。在大多数案例中,黑客会潜伏在会话中,并最终控制它。

  • 4、SSL剥离:

在SSL剥离攻击中,攻击者使SSL/TLS连接剥落,随之协议便从安全的HTTPS变成了不安全的HTTP。

1.7.5、为什么tcp要经过三次握手,四次挥手?

重要标志位

ACK : TCP协议规定,只有ACK=1时有效,也规定连接建立后所有发送的报文的ACK必须为1

SYN(SYNchronization) : 在连接建立时用来同步序号。当SYN=1而ACK=0时,表明这是一个连接请求报文。对方若同意建立连接,则应在响应报文中使SYN=1和ACK=1. 因此, SYN置1就表示这是一个连接请求或连接接受报文。

FIN (finis)即完,终结的意思, 用来释放一个连接。当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放连接。

三次握手、四次挥手过程

三次握手:

第一次握手:建立连接。客户端发送连接请求报文段,将SYN位置为1,Sequence Number为x;然后,客户端进入SYN_SEND状态,等待服务器的确认;

第二次握手:服务器收到SYN报文段。服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1);同时,自己还要发送SYN请求信息,将SYN位置为1,Sequence Number为y;服务器端将上述所有信息放到一个报文段(即SYN+ACK报文段)中,一并发送给客户端,此时服务器进入SYN_RECV状态;

第三次握手:客户端收到服务器的SYN+ACK报文段。然后将Acknowledgment Number设置为y+1,向服务器发送ACK报文段,这个报文段发送完毕以后,客户端和服务器端都进入ESTABLISHED状态,完成TCP三次握手。

四次挥手:

第一次分手:主机1(可以使客户端,也可以是服务器端),设置Sequence Number和Acknowledgment Number,向主机2发送一个FIN报文段;此时,主机1进入FIN_WAIT_1状态;这表示主机1没有数据要发送给主机2了;

第二次分手:主机2收到了主机1发送的FIN报文段,向主机1回一个ACK报文段,Acknowledgment Number为Sequence Number加1;主机1进入FIN_WAIT_2状态;主机2告诉主机1,我“同意”你的关闭请求;

第三次分手:主机2向主机1发送FIN报文段,请求关闭连接,同时主机2进入LAST_ACK状态;

第四次分手:主机1收到主机2发送的FIN报文段,向主机2发送ACK报文段,然后主机1进入TIME_WAIT状态;主机2收到主机1的ACK报文段以后,就关闭连接;此时,主机1等待2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,主机1也可以关闭连接了。

“三次握手”的目的是“为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误”。主要目的防止server端一直等待,浪费资源。换句话说,即是为了保证服务端能收接受到客户端的信息并能做出正确的应答而进行前两次(第一次和第二次)握手,为了保证客户端能够接收到服务端的信息并能做出正确的应答而进行后两次(第二次和第三次)握手。

“四次挥手”原因是因为tcp是全双工模式,接收到FIN时意味将没有数据再发来,但是还是可以继续发送数据。

1.7.6、TCP可靠传输原理实现(滑动窗口)。

确认和重传:接收方收到报文后就会进行确认,发送方一段时间没有收到确认就会重传。

数据校验。

数据合理分片与排序,TCP会对数据进行分片,接收方会缓存为按序到达的数据,重新排序后再提交给应用层。

流程控制:当接收方来不及接收发送的数据时,则会提示发送方降低发送的速度,防止包丢失。

拥塞控制:当网络发生拥塞时,减少数据的发送。

关于滑动窗口、流量控制、拥塞控制实现原理请点击这里

1.7.7、Tcp和Udp的区别?

1、基于连接与无连接;

2、对系统资源的要求(TCP较多,UDP少);

3、UDP程序结构较简单;

4、流模式与数据报模式 ;

5、TCP保证数据正确性,UDP可能丢包;

6、TCP保证数据顺序,UDP不保证。

1.7.8、如何设计在 UDP 上层保证 UDP 的可靠性传输?

传输层无法保证数据的可靠传输,只能通过应用层来实现了。实现的方式可以参照tcp可靠性传输的方式。如不考虑拥塞处理,可靠UDP的简单设计如下:

  • 1、添加seq/ack机制,确保数据发送到对端
  • 2、添加发送和接收缓冲区,主要是用户超时重传。
  • 3、添加超时重传机制。

具体过程即是:送端发送数据时,生成一个随机seq=x,然后每一片按照数据大小分配seq。数据到达接收端后接收端放入缓存,并发送一个ack=x的包,表示对方已经收到了数据。发送端收到了ack包后,删除缓冲区对应的数据。时间到后,定时任务检查是否需要重传数据。

目前有如下开源程序利用udp实现了可靠的数据传输。分别为RUDP、RTP、UDT:

1、RUDP(Reliable User Datagram Protocol)

RUDP 提供一组数据服务质量增强机制,如拥塞控制的改进、重发机制及淡化服务器算法等。

2、RTP(Real Time Protocol)

RTP为数据提供了具有实时特征的端对端传送服务,如在组播或单播网络服务下的交互式视频音频或模拟数据。

3、UDT(UDP-based Data Transfer Protocol)

UDT的主要目的是支持高速广域网上的海量数据传输。

1.7.9、socket断线重连怎么实现,心跳机制又是怎样实现?

socket概念

套接字(socket)是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议,本地主机的IP地址,本地进程的协议端口,远地主机的IP地址,远地进程的协议端口。

为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与TCP/IP协议交互提供了套接字(Socket)接口。应 用层可以和传输层通过Socket接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。

建立socket连接

建立Socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket 。

套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。

  • 服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。
  • 客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端- - 套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发 给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

SOCKET连接与TCP连接

创建Socket连接时,可以指定使用的传输层协议,Socket可以支持不同的传输层协议(TCP或UDP),当使用TCP协议进行连接时,该Socket连接就是一个TCP连接。

Socket连接与HTTP连接

由于通常情况下Socket连接就是TCP连接,因此Socket连接一旦建立,通信双方即可开始相互发送数据内容,直到双方连接断开。但在实际网 络应用中,客户端到服务器之间的通信往往需要穿越多个中间节点,例如路由器、网关、防火墙等,大部分防火墙默认会关闭长时间处于非活跃状态的连接而导致 Socket 连接断连,因此需要通过轮询告诉网络,该连接处于活跃状态。

而HTTP连接使用的是“请求—响应”的方式,不仅在请求时需要先建立连接,而且需要客户端向服务器发出请求后,服务器端才能回复数据。

很多情况下,需要服务器端主动向客户端推送数据,保持客户端与服务器数据的实时与同步。此时若双方建立的是Socket连接,服务器就可以直接将数 据传送给客户端;若双方建立的是HTTP连接,则服务器需要等到客户端发送一次请求后才能将数据传回给客户端,因此,客户端定时向服务器端发送连接请求, 不仅可以保持在线,同时也是在“询问”服务器是否有新的数据,如果有就将数据传给客户端。TCP(Transmission Control Protocol) 传输控制协议

socket断线重连实现

正常连接断开客户端会给服务端发送一个fin包,服务端收到fin包后才会知道连接断开。 而断网断电时客户端无法发送fin包给服务端,所以服务端没办法检测到客户端已经短线。 为了缓解这个问题,服务端需要有个心跳逻辑,就是服务端检测到某个客户端多久没发送任何数据过来就认为客户端已经断开, 这需要客户端定时向服务端发送心跳数据维持连接。

心跳机制实现

长连接的实现:心跳机制,应用层协议大多都有HeartBeat机制,通常是客户端每隔一小段时间向服务器发送一个数据包,通知服务器自己仍然在线。并传输一些可能必要的数据。使用心跳包的典型协议是IM,比如QQ/MSN/飞信等协议

1、在TCP的机制里面,本身是存在有心跳包的机制的,也就是TCP的选项:SO_KEEPALIVE。 系统默认是设置的2小时的心跳频率。但是它检查不到机器断电、网线拔出、防火墙这些断线。 而且逻辑层处理断线可能也不是那么好处理。一般,如果只是用于保活还是可以的。通过使用TCP的KeepAlive机制(修改那个time参数),可以让连接每隔一小段时间就产生一些ack包,以降低被踢掉的风险,当然,这样的代价是额外的网络和CPU负担。

2、应用层心跳机制实现。

Cookie与Session的作用和原理。

  • Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中。
  • Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。
Session:

由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识具体的用户,这个机制就是Session.典型的场景比如购物车,当你点击下单按钮时,由于HTTP协议无状态,所以并不知道是哪个用户操作的,所以服务端要为特定的用户创建了特定的Session,用用于标识这个用户,并且跟踪用户,这样才知道购物车里面有几本书。这个Session是保存在服务端的,有一个唯一标识。在服务端保存Session的方法很多,内存、数据库、文件都有。集群的时候也要考虑Session的转移,在大型的网站,一般会有专门的Session服务器集群,用来保存用户会话,这个时候 Session 信息都是放在内存的。

具体到Web中的Session指的就是用户在浏览某个网站时,从进入网站到浏览器关闭所经过的这段时间,也就是用户浏览这个网站所花费的时间。因此从上述的定义中我们可以看到,Session实际上是一个特定的时间概念。

当客户端访问服务器时,服务器根据需求设置Session,将会话信息保存在服务器上,同时将标示Session的SessionId传递给客户端浏览器,

浏览器将这个SessionId保存在内存中,我们称之为无过期时间的Cookie。浏览器关闭后,这个Cookie就会被清掉,它不会存在于用户的Cookie临时文件。

以后浏览器每次请求都会额外加上这个参数值,服务器会根据这个SessionId,就能取得客户端的数据信息。

如果客户端浏览器意外关闭,服务器保存的Session数据不是立即释放,此时数据还会存在,只要我们知道那个SessionId,就可以继续通过请求获得此Session的信息,因为此时后台的Session还存在,当然我们可以设置一个Session超时时间,一旦超过规定时间没有客户端请求时,服务器就会清除对应SessionId的Session信息。

Cookie

Cookie是由服务器端生成,发送给User-Agent(一般是web浏览器),浏览器会将Cookie的key/value保存到某个目录下的文本文件内,下次请求同一网站时就发送该Cookie给服务器(前提是浏览器设置为启用Cookie)。Cookie名称和值可以由服务器端开发自己定义,对于JSP而言也可以直接写入Sessionid,这样服务器可以知道该用户是否合法用户以及是否需要重新登录等。

面试资料PDF电子书

资料已经上传在我的GitHub;快速入手通道:(点这里)免费下载!诚意满满!!!

Java、计算机网络部分电子书(242页)

Android部分电子书(1294页)

资料已经上传在我的GitHub;快速入手通道:(点这里)免费下载!诚意满满!!!

听说一键三连的粉丝都面试成功了?如果本篇博客对你有帮助,请支持下小编哦

image

快速入手通道:(点这里)免费下载!诚意满满!!!

Android高级面试精选题、架构师进阶实战文档传送门:我的GitHub

很感谢您阅读这篇文章,希望您能将它分享给您的朋友或技术群,这对我意义重大。

整理不易,觉得有帮助的朋友可以帮忙点赞分享支持一下小编~

你的支持,我的动力;祝各位前程似锦,offer不断!!!

参考

https://juejin.cn/post/6844904079169159175

https://juejin.cn/post/6905226221357891592

https://juejin.cn/post/6888222422760488974

https://juejin.cn/post/6844904155153170439

https://juejin.cn/post/6844904087566155784

猜你喜欢

转载自blog.csdn.net/zzz777qqq/article/details/111560088