抖音、腾讯、阿里、美团春招服务端开发岗位硬核面试(二)

共 16991字,需浏览 34分钟

 ·

2020-07-14 12:12

上一篇 文章中,我们分享了几大互联网公司面试的题目,本文就来详细分析面试题答案以及复习参考和整理的面试资料,小民同学的私藏珍品?。

首先是面试题答案公布,在讲解时我们主要分成如下几块:语言的基础知识、中间件、操作系统、计算机网络、手写算法、开放题和项目经历。对面试题和涉及的知识点进行整理,这样更容易让各位同学理解。不会按照提问的顺序进行讲解,还请见谅。

其次是 Java 复习参考和整理的面试资料。由于内容比较多,学习有  非常重要,我们介绍一下其中的要点和目录,完整文件可以参见笔者提供的 pdf 资料。

面试题解析

Java 的语言基础

Future 的缺陷?

Future 在异步编程中经常用到,Future 表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并获取计算的结果。

然而 Future 接口调用 get()方法取得处理的结果值时是阻塞性的,如果调用 Future 对象的 get()方法时,如果这个线程还没执行完成,就一直主线程main阻塞到此线程完成为止,就算和它同时进行的其它线程已经执行完了,也要等待这个耗时线程执行完才能获取结果,大大影响运行效率。那么使用多线程就没什么意义了。

CompletionService 在依赖任务之间是如何实现的?

接上一个问你题,鉴于 Future 的缺陷,JDK 1.8 并发包也提供了CompletionService接口可以解决这个问题,它的take()方法哪个线程先完成就先获取谁的 Futrue 对象。

volatile 怎么搞

出现 volatile,是因为多线程的场景下存在脏读。Java内存模型规定所有的变量都是存在主存当中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。变量的值何时从线程的工作内存写回主存,无法确定。

1df0096208fd3e2f5ba098451cb6a5b1.webp


volatile关键字的作用:保证了变量的可见性(visibility)。被volatile关键字修饰的变量,如果值发生了变更,其他线程立马可见,避免出现脏读的现象。如以下代码片段,isShutDown被置为true后,doWork方法仍有执行。如用volatile修饰isShutDown变量,可避免此问题。

volatile只能保证变量的可见性,不能保证对volatile变量操作的原子性。

类加载机制?

JVM将class文件字节码文件加载到内存中, 并将这些静态数据转换成方法区中的运行时数据结构,在堆(并不一定在堆中,HotSpot在方法区中)中生成一个代表这个类的java.lang.Class 对象,作为方法区类数据的访问入口。

c58c0c8f402944d57a1b35ee048bd898.webp

JVM类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程。其中加载、检验、准备、初始化和卸载这个五个阶段的顺序是固定的,而解析则未必。为了支持动态绑定,解析这个过程可以发生在初始化阶段之后。

  • 加载:加载过程主要完成三件事情,通过类的全限定名来获取定义此类的二进制字节流;将这个类字节流代表的静态存储结构转为方法区的运行时数据结构;在堆中生成一个代表此类的java.lang.Class对象,作为访问方法区这些数据结构的入口。

  • 验证:此阶段主要确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。文件格式验证:基于字节流验证;元数据验证:基于方法区的存储结构验证;字节码验证:基于方法区的存储结构验证;
    符号引用验证:基于方法区的存储结构验证。

  • 准备:为类变量分配内存,并将其初始化为默认值。(此时为默认值,在初始化的时候才会给变量赋值)即在方法区中分配这些变量所使用的内存空间。例如:

public static int value = 123;

此时在准备阶段过后的初始值为0而不是123。

  • 解析:把类型中的符号引用转换为直接引用。符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中;直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

  • 初始化:初始化阶段是执行类构造器方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证方法执行之前,父类的方法已经执行完毕。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法。
    java中,对于初始化阶段,有且只有以下五种情况才会对要求类立刻“初始化”(加载,验证,准备,自然需要在此之前开始):

    • 使用new关键字实例化对象、访问或者设置一个类的静态字段(被final修饰、编译器优化时已经放入常量池的例外)、调用类方法,都会初始化该静态字段或者静态方法所在的类。

    • 初始化类的时候,如果其父类没有被初始化过,则要先触发其父类初始化。

    • 使用java.lang.reflect包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化。

    • 虚拟机启动时,用户会先初始化要执行的主类(含有main)

    • jdk 1.7后,如果java.lang.invoke.MethodHandle的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化。

类加载器?

把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性。系统自带的类加载器分为三种:

  • 启动类加载器。

  • 扩展类加载器。

  • 应用程序类加载器。

ce6441e28258b6b3f34e72ec22a676ed.webp


双亲委派机制工作过程:

如果一个类加载器收到了类加载器的请求.它首先不会自己去尝试加载这个类.而是把这个请求委派给父加载器去完成.每个层次的类加载器都是如此.因此所有的加载请求最终都会传送到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方法



2b1689cee8da37920d23968e349216a9.webp



策略模式 不同策略怎么转化?

策略模式是一种比较简单的模式,他的定义是:定义一组算法,将每个算法都封装起来,并且使他们之间可以互换。

772c0c72dce07ef23da8f738f44e5f20.webp

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注解,指明自定义注解是否可以被继承。但实现情况又可细分为多种。

f37833fc3c6a5aebba5fe4b0f731cc50.webp

@Inherited 只可控制 对类名上注解是否可以被继承。不能控制方法上的注解是否可以被继承。

java 内存结构

JAVA内存结构:堆、栈、方法区;堆:存放所有 new出来的东西(堆空间是所有线程共享,虚拟机动的时候建立);栈:存放局部变量(线程创建的时候 被创建);方法被虚拟机加载的类信息、常量、静态常量等。

9204802d2990b23aebcff9b81db908c0.webp

程序计数器

程序计数器可以看作是当前线程所执行的字节码的行号指示器,是一块线程隔离的内存空间。在虚拟机的概念模型中,字节码解释器通过改变程序计数器的值来选取下一条执行的字节码命令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要计数器完成。每个线程都有独立的程序计数器内存空间,它们之间相互隔离、互不影响。当线程上下文进行切换时,线程独占的程序计数器也会被加载。

当线程在执行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异常。

注意不要回答成内存模型!

jvm参数 为什么要配置?

堆空间主要组成部分:

  • 新生代(new generation),新生代又划分为3部分:

    • eden

    • From Survivor(s0区域)

    • To Survivor(s1区域)
      其中s0和s1区域大小相等

  • 老年代(tenured generation)

new出来的对象都会存放在堆内存中。新生代和老年代的存在主要用于垃圾回收机制,其中主要针对的是新生代,因为对象首先分配在eden区,在新生代回收后,如果对象还存活,则进入s0或s1区,之后每经过一次新生代回收,如果对象存活则它的年龄就加1,对象达到一定的年龄后,则进入老年代。

3b6310918b3be855636a7561eecdbff1.webp

在JVM启动参数中,可以设置跟内存、垃圾回收相关的一些参数设置,默认情况不做任何设置JVM会工作的很好,但对一些配置很好的Server和具体的应用必须仔细调优才能获得最佳性能。通过设置我们希望达到一些目标:

  • GC的时间足够的小

  • GC的次数足够的少

  • 发生Full GC(新生代和老年代)的周期足够的长

要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。

  • 针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值

  • 年轻代和年老代将根据默认的比例(1:2)分配堆内存,可以通过调节二者的比例为1:3或者1:4,当然也可以设置新生代的大小,原则上为堆空间的1/3或者1/4。

8G内存的机器 java进程最大配置多少?

增大堆内存(-Xms,-Xmx)会减少可创建的线程数量,增大线程栈内存(-Xss,32位系统中此参数值最小为60K)也会减少可创建的线程数量。

d89685aa0bf4c5497efe14d70c05cc38.webp

操作系统限制,系统最大可开线程数,主要受以下几个参数影响:

  • /proc/sys/kernel/thread-max:系统可以生成的最大线程数量

  • /proc/sys/kernel/pid_max:并不是threads-max,就能创建越多的线程,发现线程数量在达到一定数量以后不再增长。创建的线程数还受到系统可创建的最大pid数影响,默认值为32768。

  • max_user_process:64位Linux系统,这个参数还是会限制线程数量,可通过ulimit –a查看。

  • /proc/sys/vm/max_map_count:包含限制一个进程可以拥有的VMA(虚拟内存区域)的数量。

平衡树的种类

平衡树是一颗二叉搜索树,并且它的深度保持相对稳定,也就是不会退化成链的树。平衡树可以说是区间操作的数据结构中效率高的一种,它最大的用处自然是维护区间了。细分为:splay、有旋/无旋treap、AVL树、替罪羊树、二叉查找树(SBT)树等。

跳表和平衡树区别

Redis sortedset 使用的是跳表。跳表是一种可以替代平衡树的数据结构。跳表追求的是概率性平衡,而不是严格平衡。因此,跟平衡二叉树相比,跳表的插入和删除操作要简单得多,执行也更快。

二叉树可以用来实现字典和有序表等抽象数据结构。在元素随机插入的场景,二叉树可以很好应对。然而,在有序插入的情况下,二叉树就退化了(链表),性能非常差。如果有办法对待插入元素进行随机排列,二叉树大概率可以运行良好。大部分情况下,插入是在线进行的,因此随机排列并不具有可行性。平衡树在操作时对树结构进行调整以满足平衡条件,因此获得理想性能。

跳表是一种概率性可行的平衡二叉树替代数据结构。跳表通过一个随机数生成器实现平衡。虽然跳表最坏情况下(worst-case)性能也很差,但是没有任何输入序列必然会导致最坏情况发生(这点类似划分元素(pivot point)随机选定的快排)。跳表极度不平衡发生的概率非常低(一个包含250个元素的字典,一次查找需要花3倍期望时间的概率小于百万分之一)。跳表平衡概率跟随机插入的二叉树差不多,好处是插入顺序不要求随机。

实现概率性平衡比严格控制平衡要简单得多。对很多应用来说,跳表用起来比平衡树更自然,而且算法更简单。跳表算法简单性意味着更容易实现,而且与平衡树和自适应树相比有常数倍数的性能提升。跳表在空间上也比较高效。平均每个元素只需要额外耗费个2指针(甚至可以配置得更低),并不需要在每个节点上都存与平衡和优先级相关的数据。

volatile,内存重排序到底怎么避免的?

Volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。这是就需要内存屏障来保证可见性了。

内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。

  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;

  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;

  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

一个线程在内存中如何存储

从线程和进程的角度来说,进程是资源分配的最小单位,线程是独立调度的最小单位。

同一个进程中的多个线程之间可以并发执行,他们共享进程资源。
线程不拥有资源,线程可以访问隶属进程的资源,进程有自己的独立空间地址,线程没有自己的独立空间地址,但是线程有自己的堆栈和局部变量。

线程的栈、程序计数器、本地方法区也是存放在进程的地址空间上,只是这些栈、程序计数器、本地方法区都只能有某个特定的线程去访问、其他的线程访问不到。如果使用C/C++语言的话,数组越界后,很容易就访问到其他线程的栈了,以致有可能导致其他线程的异常。

线程池,堵塞队列为什么要用堵塞?

多线程环境中,通过队列可以很容易实现数据共享,比如经典的“生产者”和“消费者”模型中,通过队列可以很便利地实现两者之间的数据共享。假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。然而,在concurrent包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。好在此时,强大的concurrent包横空出世了,而他也给我们带来了强大的BlockingQueue。(在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤醒)。

中间件

Tomcat框架的 selevet

Tomcat是目前市场上主流Web服务器之一,是用Java语言开发的项目。Tomcat支持Servlet和JSP的规范,它由一组嵌套的层次和组件组成。所有组件都实现lifecycle生命周期方法,里面包含了init、start、stop、destroy等方法,用来控制生命周期。

Servlet是用Java编写的Server端程序,它与协议和平台无关。Servlet运行于Java-enabled Web Server中。Java Servlet可以动态地扩展Server的能力,并采用请求-响应模式提供Web服务。最早支持Servlet技术的是JavaSoft的Java Web Server。此后,一些其它的基于Java的Web Server开始支持标准的Servlet API。Servlet的主要功能在于交互式地浏览和修改数据,生成动态Web内容。

dacb5d3f624c3f6561dfd45158203a51.webp

Tomcat服务器本质是通过ServerSocket与客户端进行通信,要进行通信首先就要进行TCP连接,Tomcat有两个核心组件,Connecter和Container,Connecter将在某个指定的端口上侦听客户请求,接收浏览器的发过来的 tcp 连接请求,创建一个 Request 和 Response 对象分别用于和请求端交换数据,Request包含了用户的请求信息,Response负责记录了服务器的答复内容。然后会产生一个线程来处理这个请求并把产生的 Request 和 Response 对象传给Container处理。

Connector 最重要的功能就是接收连接请求然后分配线程让 Container 来处理这个请求,所以这必然是多线程的,多线程的处理是 Connector 设计的核心。

当Connector处理完后会调用Container的invoke()方法,你可以想象Container容器里有一条管道,管道上有很多阀门,每个阀门都会根据request进行一些操作,request和response请求会依次经过这些阀门,而Servlet就是该管道的最后一道阀门,之前的阀门就是filter。

Tomcat容器也分有上下层级关系如下图,Tomcat的四层容器不都是必须的,一般简单的容器只有Context和Wrapper两层,Contenxt负责管理多个Wrapper,负责将映射转发到对应Wrapper,当然期间还要经过filter过滤。Wrapper是最低层的容器,它只包裹着一个Servlet,Wrapper负责加载并管理调用Servlet服务。

mysql B+ B- B区别?

B树,即二叉搜索树,有如下特点:

  • 所有非叶子节点至多拥有两个儿子(Leaf和Right)

  • 左右结点存储一个关键字

  • 非叶子节点的左指针指向小于其关键字的子树,右指针指向大于其关键字的子树

B树的搜索,从根结点开始,如果查询的关键字与结点的关键字相等,那么就命中;否则,如果查询关键字比结点关键字小,就进入左儿子;如果比结点关键字大,就进入右儿子;如果左儿子或右儿子的指针为空,则报告找不到相应的关键字;如果B树的所有非叶子结点的左右子树的结点数目均保持差不多(平衡),那么B树的搜索性能逼近二分查找;但它比连续内存空间的二分查找的优点是,改变B树结构(插入与删除结点)不需要移动大段的内存数据,甚至通常是常数开销。

B-树,是一种多路搜索树(并不是二叉的):

B树和B-树是同一种树,只不过英语中B-tree被中国人翻译成了B-树,让人以为B树和B-树是两种树,实际上,两者就是同一种树。此处单从算法的角度进行了划分,区别于 B+ 树,可以参见:https://en.wikipedia.org/wiki/Binary_search_tree。

  • 关键字集合分布在整颗树中

  • 任何一个关键字出现且只出现在一个结点中

  • 搜素有可能在非叶子节点结束

  • 其搜索性能等价于在关键字全集内做一次二分查找

  • 自动层次控制

由于限制了除根结点以外的非叶子结点,至少含有M/2个儿子,确保了结点的至少利用率。所以B-树的性能总是等价于二分查找(与M值无关),也就没有B树平衡的问题,由于 M/2的限制,在插入结点时,如果结点已满,需要将结点分裂为两个各占 M/2 的结点,删除结点时,需将两个不足M/2的兄弟节点合并。B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果
命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点。

B+树,B+树是B-树的变体,也是一种多路搜索树,其定义基本与B-树同,除了:

  • 非叶子结点的子树指针与关键字个数相同;

  • 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间);

  • 为所有叶子结点增加一个链指针;

  • 所有关键字都在叶子结点出现

B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找;非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;更适合文件索引系统。

mysql 隔离级别?

数据库事务是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。事务拥有四个重要的特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),人们习惯称之为 ACID 特性。

SQL 标准定义的四种隔离级别:

  • READ UNCOMMITTED(读未提交):该隔离级别的事务会读到其它未提交事务的数据,此现象也称之为脏读。

  • READ COMMITTED(读提交):
    一个事务可以读取另一个已提交的事务,多次读取会造成不一样的结果,此现象称为不可重复读问题,Oracle 和 SQL Server 的默认隔离级别。

  • REPEATABLE READ(可重复读):
    该隔离级别是 MySQL 默认的隔离级别,在同一个事务里,select 的结果是事务开始时时间点的状态,因此,同样的 select 操作读到的结果会是一致的,但是,会有幻读现象。MySQL 的 InnoDB 引擎可以通过 next-key locks 机制来避免幻读。

  • SERIALIZABLE(序列化):
    在该隔离级别下事务都是串行顺序执行的,MySQL 数据库的 InnoDB 引擎会给读操作隐式加一把读共享锁,从而避免了脏读、不可重读复读和幻读问题。

8dd56f1552820fbf103b73611fbb50e1.webp
MVCC如何保证可重复读?

MySQL的innodb引擎是如何实现MVCC的。innodb会为每一行添加两个字段,分别表示该行创建的版本和删除的版本,填入的是事务的版本号,这个版本号随着事务的创建不断递增。在repeated read的隔离级别下,具体各种数据库操作的实现:

事务开始,第一次不加锁SELECT时,InnoDB从全局事务链表中,筛选所有活动事务(事务trx_id严格递增),生成当前一致性视图。

根据当前一致性视图高低水位,计算事务可见性。

根据可见事务redo log,逆向算出历史版本。SELECT快照读,读之前版本数据。SELECT FOR UPDATE 或 UPDATE 当前读,加行锁读当前值,不会创建一致性视图,有其它事务更新时,等待其它事务提交。(2PL更新时加写锁,事务提交时才会释放)

间隙锁怎么使用?

InnoDB 行级锁是通过给索引上的索引项加锁来实现的,InnoDB行级锁只有通过索引条件检索数据,才使用行级锁;否则,InnoDB使用表锁。

在不通过索引(主键)条件查询的时候,InnoDB是表锁而不是行锁。也就是说,在没有使用索引的情况下,使用的就是表锁。

间隙锁可以理解为是对于一定范围内的数据进行锁定,如果说这个区间没有这条数据的话也是会锁住的;主要是解决幻读的问题,如果没有添加间隙锁。如果其他事务中添加 id 在 1 到 100 之间的某条记录,此时会发生幻读;另一方面,视为了满足其恢复和赋值的需求(幻读的概念在上面有提到)。

默认情况下,innodb_locks_unsafe_for_binlog是0(禁用),这意味着启用了间隙锁定:InnoDB使用下一个键锁进行搜索和索引扫描。若要启用该变量,请将其设置为1。这将导致禁用间隙锁定:InnoDB只使用索引记录锁进行搜索和索引扫描。

innodb自动使用间隙锁的条件:

  • 必须在RR级别下

  • 检索条件必须有索引(没有索引的话,mysql会全表扫描,那样会锁定整张表所有的记录,包括不存在的记录,此时其他事务不能修改不能删除不能添加)

间隙锁的目的是为了防止幻读,其主要通过两个方面实现这个目的:

  • 防止间隙内有新数据被插入

  • 防止已存在的数据,更新成间隙内的数据(例如防止 number=3 的记录通过update变成 number=5)

mysql hash索引使用场景?

hash索引的特点:

  • hash索引是基于hash表实现的,只有查询条件精确匹配hash索引中的所有列的时候,才能用到hash索引。

  • 对于hash索引中的所有列,存储引擎都会为每一行计算一个hash码,hash索引中存储的就是hash码。

  • hash索引包括键值、hash码和指针 。

在MySQL的存储引擎中,MyISAM 不支持哈希索引,而 InnoDB 中的hash索引是存储引擎根据B-Tree索引自建的。因为hash索引本身只需要存储对应的hash值,所以索引的结构十分紧凑,这也让hash索引查找的速度非常快。然而,哈希索引也有限制,如下:

  • 哈希索引只包含哈希值和行指针,而不存储字段值,所以不能使用索引中的值来避免读取行(即不能使用哈希索引来做覆盖索引扫描),不过,访问内存中的行的速度很快(因为memory引擎的数据都保存在内存里),所以大部分情况下这一点对性能的影响并不明显。

  • 哈希索引数据并不是按照索引列的值顺序存储的,所以也就无法用于排序
    哈希索引也不支持部分索引列匹配查找,因为哈希索引始终是使用索引的全部列值内容来计算哈希值的。如:数据列(a,b)上建立哈希索引,如果只查询数据列a,则无法使用该索引。

  • 哈希索引只支持等值比较查询,如:=,in(),<=>(注意,<>和<=>是不同的操作),不支持任何范围查询(必须给定具体的where条件值来计算hash值,所以不支持范围查询)。

  • 访问哈希索引的数据非常快,除非有很多哈希冲突,当出现哈希冲突的时候,存储引擎必须遍历链表中所有的行指针,逐行进行比较,直到找到所有符合条件的行。

  • 如果哈希冲突很多的话,一些索引维护操作的代价也很高,如:如果在某个选择性很低的列上建立哈希索引(即很多重复值的列),那么当从表中删除一行时,存储引擎需要遍历对应哈希值的链表中的每一行,找到并删除对应的引用,冲突越多,代价越大。

redis 为什么快?

完全基于内存,绝大多数请求是纯粹的内存操作,非常快速。数据存储在内存中,类似于HashMap,具备较快的查找和操作的时间复杂度O(1)。

数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的。
采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用考虑各种锁的问题,不存在加锁释放锁(上下文的切换),没有因为可能出现死锁而导致的性能消耗。
使用多路I/O复用模型,非阻塞IO。

使用底层模型不同,它们之间底层实现方式以及与客户端之间的通信的应用协议不一样,Redis直接自己构建了VM机制,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求(用户态和内核态之间的切换)。

未完待续

本来本文的标题是 抖音、腾讯、阿里、美团春招服务端开发岗位硬核面试(下),然题目比较多,限于篇幅,只能改成 ,下篇我们继续。

这是笔者对题目的解答,读者如发现答案有问题,欢迎留言指出,谢谢。

另外帮忙插播一个内推岗位,有兴趣的同学可以投简历或者后台和我私聊。

阿里云智能事业群-ECS资深Java工程师/专家-北京/杭州

大量HC,欢迎来聊。

  • 团队组合:一群有情有义有梦想的工程师和云计算行业技术大牛

  • 产品是啥:全球第三中国第一的公有云服务平台,负责阿里云 ECS 的资源管理、售卖、资源调度、资源供给服务,构建全球计算力的基础设施

岗位职责:

  1. 参与建设大规模的资源调度系统,承载每天百万次的 ECS 调度决策,为每台 ECS 选择最佳资源供给

  2. 运用数据挖掘、数据分析和智能算法,构建用户画像与资源画像,预测未来各个区域不同产品的购买行为和趋势,为ECS资源供给提供最佳决策,打造云计算的弹性能力与性价比,实现成本与售卖的双赢

  3. 构建资源管理系统,从宏观的物理机管理,到微观的虚拟资源管理,让每一份资源物尽其用

  4. 参与基于 ECS 的产品研发,打造后端能力的新玩法,实现技术能力的变现,物尽其用,让技术产生更多社会红利价值

职位要求:

  1. 至少熟悉Java/Python语言的一种或多种,理解该语言涉及的基础框架,对您使用过的框架能够了解到它的原理和机制

  2. 熟悉linux操作系统、常用工具和命令,熟悉mysql数据库

  3. 熟练掌握多线程等高并发系统编程和优化技能;熟悉分布式系统的设计和应用,熟悉分布式、缓存、消息等机制;能对分布式常用技术进行合理应用,解决问题

  4. 具备快速学习能力,较强的团队沟通和协作能力,较强的自我驱动能力

  5. 熟悉OpenStack/Kubernetes/Mesos/Borg等平台经验者优先,有大规模调度系统、资源管理系统的实际建设经验者优先

推荐阅读

面试合集

订阅最新文章,欢迎关注我的公众号

微信公众号A


浏览 41
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报