抖音、腾讯、阿里、美团春招服务端开发岗位硬核面试(二)
在上一篇 我们中,我们分享了几大互联网公司面试的题目,本文就来详细分析面试题答案以及复习参考和整理的面试资料,小民同学的私藏珍品🐶。
首先是面试题答案公布,在讲解时我们主要分成如下几块:语言的基础知识、中间件、操作系统、计算机网络、手写算法、开放题和项目经历。对面试题和涉及的知识点进行整理,这样更容易让各位同学理解。不会按照提问的顺序进行讲解,还请见谅。
其次是 Java 复习参考和整理的面试资料。由于内容比较多,学习有 道
非常重要,我们介绍一下其中的要点和目录,完整文件可以参见笔者提供的 pdf 资料。
面试题解析
Java 的语言基础
Future 的缺陷?
Future 在异步编程中经常用到,Future 表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并获取计算的结果。
然而 Future 接口调用 get()方法取得处理的结果值时是阻塞性的,如果调用 Future 对象的 get()方法时,如果这个线程还没执行完成,就一直主线程main阻塞到此线程完成为止,就算和它同时进行的其它线程已经执行完了,也要等待这个耗时线程执行完才能获取结果,大大影响运行效率。那么使用多线程就没什么意义了。
CompletionService 在依赖任务之间是如何实现的?
接上一个问你题,鉴于 Future 的缺陷,JDK 1.8 并发包也提供了CompletionService接口可以解决这个问题,它的take()方法哪个线程先完成就先获取谁的 Futrue 对象。
volatile 怎么搞
出现 volatile,是因为多线程的场景下存在脏读。Java内存模型规定所有的变量都是存在主存当中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。变量的值何时从线程的工作内存写回主存,无法确定。
volatile关键字的作用:保证了变量的可见性(visibility)。被volatile关键字修饰的变量,如果值发生了变更,其他线程立马可见,避免出现脏读的现象。如以下代码片段,isShutDown被置为true后,doWork方法仍有执行。如用volatile修饰isShutDown变量,可避免此问题。
volatile只能保证变量的可见性,不能保证对volatile变量操作的原子性。
类加载机制?
JVM将class文件字节码文件加载到内存中, 并将这些静态数据转换成方法区中的运行时数据结构,在堆(并不一定在堆中,HotSpot在方法区中)中生成一个代表这个类的java.lang.Class 对象,作为方法区类数据的访问入口。
JVM类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程。其中加载、检验、准备、初始化和卸载这个五个阶段的顺序是固定的,而解析则未必。为了支持动态绑定,解析这个过程可以发生在初始化阶段之后。
- 加载:加载过程主要完成三件事情,通过类的全限定名来获取定义此类的二进制字节流;将这个类字节流代表的静态存储结构转为方法区的运行时数据结构;在堆中生成一个代表此类的java.lang.Class对象,作为访问方法区这些数据结构的入口。
- 验证:此阶段主要确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。文件格式验证:基于字节流验证;元数据验证:基于方法区的存储结构验证;字节码验证:基于方法区的存储结构验证;
符号引用验证:基于方法区的存储结构验证。 - 准备:为类变量分配内存,并将其初始化为默认值。(此时为默认值,在初始化的时候才会给变量赋值)即在方法区中分配这些变量所使用的内存空间。例如:
public static int value = 123;
此时在准备阶段过后的初始值为0而不是123。
-
解析:把类型中的符号引用转换为直接引用。符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中;直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
-
初始化:初始化阶段是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<client>方法执行之前,父类的<client>方法已经执行完毕。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。
java中,对于初始化阶段,有且只有以下五种情况才会对要求类立刻“初始化”(加载,验证,准备,自然需要在此之前开始):- 使用new关键字实例化对象、访问或者设置一个类的静态字段(被final修饰、编译器优化时已经放入常量池的例外)、调用类方法,都会初始化该静态字段或者静态方法所在的类。
- 初始化类的时候,如果其父类没有被初始化过,则要先触发其父类初始化。
- 使用java.lang.reflect包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化。
- 虚拟机启动时,用户会先初始化要执行的主类(含有main)
- jdk 1.7后,如果java.lang.invoke.MethodHandle的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化。
类加载器?
把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性。系统自带的类加载器分为三种:
- 启动类加载器。
- 扩展类加载器。
- 应用程序类加载器。
双亲委派机制工作过程:
如果一个类加载器收到了类加载器的请求.它首先不会自己去尝试加载这个类.而是把这个请求委派给父加载器去完成.每个层次的类加载器都是如此.因此所有的加载请求最终都会传送到Bootstrap类加载器(启动类加载器)中.只有父类加载反馈自己无法加载这个请求(它的搜索范围中没有找到所需的类)时.子加载器才会尝试自己去加载。
双亲委派模型的优点:java类随着它的加载器一起具备了一种带有优先级的层次关系.
例如类java.lang.Object,它存放在rt.jart之中.无论哪一个类加载器都要加载这个类.最终都是双亲委派模型最顶端的Bootstrap类加载器去加载.因此Object类在程序的各种类加载器环境中都是同一个类.相反.如果没有使用双亲委派模型.由各个类加载器自行去加载的话.如果用户编写了一个称为“java.lang.Object”的类.并存放在程序的ClassPath中.那系统中将会出现多个不同的Object类.java类型体系中最基础的行为也就无法保证.应用程序也将会一片混乱。
JDBC 加载机制?SPI 与双亲委派?
JDBC 加载机制:SPI ,全称为(Service Provider Interface) ,是JDK内置的一种服务提供发现机制;主要被框架的开发人员使用,比如java.sql.Driver接口,数据库厂商实现此接口即可,当然要想让系统知道具体实现类的存在,还需要使用固定的存放规则,需要在classpath下的META-INF/services/目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。
SPI 服务机制破坏了双亲委派模型。可以看出双亲委派机制是一种至下而上的加载方式,那么SPI是如何打破这种关系?
以JDBC加载驱动为例:在JDBC4.0之后支持SPI方式加载java.sql.Driver的实现类。SPI实现方式为,通过ServiceLoader.load(Driver.class)方法,去各自实现Driver接口的lib的META-INF/services/java.sql.Driver文件里找到实现类的名字,通过Thread.currentThread().getContextClassLoader()类加载器加载实现类并返回实例。
驱动加载的过程大致如上,那么是在什么地方打破了双亲委派模型呢?
先看下如果不用Thread.currentThread().getContextClassLoader()加载器加载,整个流程会怎么样。
- 从META-INF/services/java.sql.Driver文件得到实现类名字DriverA
Class.forName(“xx.xx.DriverA”)来加载实现类 - Class.forName()方法默认使用当前类的ClassLoader,JDBC是在D riverManager类里调用Driver的,当前类也就是DriverManager,它的加载器是BootstrapClassLoader。
- 用BootstrapClassLoader去加载非rt.jar包里的类xx.xx.DriverA,就会找不到。
- 要加载xx.xx.DriverA需要用到AppClassLoader或其他自定义ClassLoader
最终矛盾出现在,要在BootstrapClassLoader加载的类里,调用AppClassLoader去加载实现类。
因此在父加载器加载的类中,去调用子加载器去加载类:
- jdk提供了两种方式,Thread.currentThread().getContextClassLoader()和ClassLoader.getSystemClassLoader()一般都指向AppClassLoader,他们能加载classpath中的类
- SPI 则用 Thread.currentThread().getContextClassLoader() 来加载实现类,实现在核心包里的基础类调用用户代码
面向对象的原则
- 单一原则。一个类应该有且只有一个变化的原因。单一职责原则将不同的职责分离到单独的类,每一个职责都是一个变化的中心。需求变化时,将通过更改职责相关的类来体现。如果一个类拥有多于一个的职责,则多个职责耦合在一起,会有多于一个原因来导致这个类发生变化。一个职责的变化可能会影响到其他的职责,另外,把多个职责耦合在一起,影响复用性。
- 里氏替换原则,就是要求继承是严格的is-a关系。所有引用基类的地方必须能透明地使用其子类的对象。在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
- 依赖倒置原则。依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。低层模块尽量都要有抽象类或接口,或者两者都有。变量的声明类型尽量是抽象类或接口。
- 接口分离原则。一个类对另一个类的依赖应该建立在最小的接口上,通俗的讲就是需要什么就提供什么,不需要的就不要提供。接口中的方法应该尽量少,不要使接口过于臃肿,不要有很多不相关的逻辑方法。
- 多用组合(has-a),少用继承(is-a)。如果新对象的某些功能在别的已经创建好的对象里面已经实现,那么应当尽量使用别的对象提供的功能,使之成为新对象的一部分,而不要再重新创建。可以降低类与类之间的耦合程度。
- 开闭原则。对修改关闭,对扩展开放。在软件的生命周期内,因为变化,升级和维护等原因需要对软件原有代码进行修改,可能会给旧代码引入错误,也有可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。解决方案:当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现。不过这要求,我们要对需求的变更有前瞻性和预见性。其实只要遵循前面5种设计模式,设计出来的软件就是符合开闭原则的。
对象创建过程
一个对象在可以被使用之前必须要被正确地实例化。在Java代码中,有很多行为可以引起对象的创建,最为直观的一种就是使用new关键字来调用一个类的构造函数显式地创建对象,这种方式在Java规范中被称为:由执行类实例创建表达式而引起的对象创建。除此之外,我们还可以使用反射机制(Class类的newInstance方法、使用Constructor类的newInstance方法)、使用Clone方法、使用反序列化等方式创建对象。
当一个对象被创建时,虚拟机就会为其分配内存来存放对象自己的实例变量及其从父类继承过来的实例变量(即使这些从超类继承过来的实例变量有可能被隐藏也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值(零值)。在内存分配完成之后,Java虚拟机就会开始对新创建的对象按照开发人员的意志进行初始化。
类加载检查-->分配内存-->初始化零值-->设置对象头-->执行init方法
策略模式 不同策略怎么转化?
策略模式是一种比较简单的模式,他的定义是:定义一组算法,将每个算法都封装起来,并且使他们之间可以互换。
Context封装角色,也叫做上下文角色,起承上启下封装作用,屏蔽高层模块对策略、算法的直接访问,封装可能存在的变化。
Spring AOP 如何实现及应用?
基于代理思想,对原来目标对象,创建代理对象,在不修改原对象代码情况下,通过代理对象,调用增强功能的代码,从而对原有业务方法进行增强 !Spring中AOP的有两种实现方式:JDK动态代理以及Cglib动态代理。
使用场景: 记录日志、监控方法运行时间 (监控性能)、权限控制、缓存优化 (第一次调用查询数据库,将查询结果放入内存对象, 第二次调用, 直接从内存对象返回,不需要查询数据库 )、事务管理 (调用方法前开启事务, 调用方法后提交关闭事务 )
你项目中如何捕获业务异常以及记录日志的?
AOP 思想,Spring 统一异常处理有 3 种方式,分别为:
- 使用 @ ExceptionHandler 注解
- 实现 HandlerExceptionResolver 接口
- 使用 @controlleradvice 注解
编译时异常和运行时异常RuntimeException,前者通过捕获异常从而获取异常信息,后者主要通过规范代码开发、测试通过手段减少运行时异常的发生。在开发中,不管是dao层、service层还是controller层,都有可能抛出异常,在springmvc中,能将所有类型的异常处理从各处理过程解耦出来,既保证了相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。
java 枚举类型是否可以继承 (final)?
enum类是无法被继承的,编译器会自动把枚举用继承enum类来表示,但这一过程是由编译器完成的,枚举也不过是个语法糖。
如果一个类的实例是有限且确定的,那么可以使用枚举类。比如:季节类,只有春夏秋冬四个实例。
enum类默认被final修饰的情况下,是无法有子类的。enum本身不存在final、abstract的说法。就是不能被继承。运行时生成的class才有final、abstract的说法。
注解是否可以继承?
我们知道在编写自定义注解时,可以通过指定@Inherited注解,指明自定义注解是否可以被继承。但实现情况又可细分为多种。
@Inherited 只可控制 对类名上注解是否可以被继承。不能控制方法上的注解是否可以被继承。
java 内存结构
JAVA内存结构:堆、栈、方法区;堆:存放所有 new出来的东西(堆空间是所有线程共享,虚拟机气动的时候建立);栈:存放局部变量(线程创建的时候 被创建);方法区:被虚拟机加载的类信息、常量、静态常量等。
程序计数器
程序计数器可以看作是当前线程所执行的字节码的行号指示器,是一块线程隔离的内存空间。在虚拟机的概念模型中,字节码解释器通过改变程序计数器的值来选取下一条执行的字节码命令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要计数器完成。每个线程都有独立的程序计数器内存空间,它们之间相互隔离、互不影响。当线程上下文进行切换时,线程独占的程序计数器也会被加载。
当线程在执行Java方法时,计数器中记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native方法,计数器的值为空。程序计数器在Java虚拟机规范中没有规定如何的OutOfMemoryError情况的区域。
Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等。每一个方法从调用到执行完成的过程,对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
局部变量表中存放了编译期可知的各种基本的数据类型,对象引用和returnAddress类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,方法运行期间不会改变局部变量表的大小。
当线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,将会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈描述虚拟机使用到的Native方法执行的内存模型,其作用与Java虚拟机栈类似,同样可能抛出StackOverflowError和OutOfMemoryError异常。
Java堆
Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。Java堆作为运行时数据区域,存放着所有的类实例和数组,这是Java虚拟机规范中的规定。但是JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换等优化技术使得所有对象都在堆上分配变得不那么绝对。
Java堆是垃圾收集器管理的主要区域。从内存回收的角度来讲,现在的收集器基本都是采取分代收集算法,所以Java堆可以细分为新生代和老年代,再细致一点新生代中有Eden空间、From Survivor空间、 To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。
进一步划分的是为了更好地回收内存或者更快地分配内存。
如果在Java堆中没有内存完成实例分配,并且堆无法再扩展,将会抛出OutOfMemoryError异常。
方法区
方法区作为所有线程共享的内存区域,存储了被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。
在HotSpot 1.8之前,HotSpot通过永久代的方式实现了方法区,GC分代收集的方式扩展到了方法区,减少了专门管理方法区内存管理代码的编写。在HotSpot 1.8中,方法区通过元数据区实现,永久代被废弃,在1.7时字符串常量池已经被迁移到堆空间中。
方法区中的内存回收目标主要是针对常量池的回收和对类型的卸载。
当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。
运行时常量池
运行时常量池作为方法区的一部分存在。Class文件中的常量池用于存放编译期间生成的各种字面量和符号引用,在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,即在运行时也可以将新的常量放入池中(String#intern方法)。
直接内存
直接内存并不是虚拟机运行时数据区的一部分。在JDK 1.4中新加入的NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能够在一些场景中避免Java堆和Native堆中来回复制数据,提高性能。
一般来讲,本机直接内存的分配不会收到Java堆大小的限制,但是总会受到本机物理内存以及寻址空间的限制。如果各个内存区域的总和大于物理内存限制,容易导致动态扩展时出现OutOfMemoryError异常。
注意不要回答成内存模型!
未完待续…
推荐阅读
- 点赞
- 收藏
- 关注作者
评论(0)