type
status
date
slug
summary
tags
category
icon
password
😀
这里写文章的前言: 一个简单的开头,简述这篇文章讨论的问题、目标、人物、背景是什么?并简述你给出的答案。
可以说说你的故事:阻碍、努力、结果成果,意外与转折。
 

📝 Java专项----面试题篇

JVM篇

JVM组成

什么是程序计数器

程序计数器:线程私有的,每个线程都有一份(不存在线程安全问题)内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。换一句话讲就是保存当前线程所代码的行号,可以用来恢复被夺走执行权后的执行

详细介绍一下堆

线程共享(有线程安全问题)的区域:主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常

Java8中的JVM内存结构:

主要包含虚拟机栈、本地方法栈、程序计数器、本地内存(直接内存、元空间)、堆(年轻代、老年代)
notion image

堆的内存结构(年轻(新生)代、老年代)

年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM的策略,当一个对象被创建会先存放在Eden区,假如这个对象在垃圾回收之后还存活就会被复制移动到幸存者区S0或S1,移动了几次后,仍然存货与Survivor的对象会被移动到老年代区间
老年代主要保存生命周期长的对象

元空间

元空间主要是保存类信息、静态变量、常量、编译后的代码和java1.7(包括)之前的永久代(堆中)是一样的,为什么1.8的堆没有了永久代/方法区?因为随着项目的动态类加载的情况越来越多,永久代的大小就变的不可控,为了防止OOM,java8就把它优化到了本地内存

什么是虚拟机栈

Java Virtual machine Stacks,每个线程运行时所需要的内存,称为虚拟机栈(线程安全),先进后出的特点。
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存(如参数、局部变量、返回地址),一个栈帧中可能有多个额外的栈帧,因为他里面可能调用了其他方法
但每个线程中只能有一个活动的栈帧,对应着当前正在执行的那个方法,方法里调用其他方法是按先进后出的顺序压栈和出栈

垃圾回收是否涉及栈内存?

垃圾回收主要指的是堆内存,当栈帧出栈(弹栈之后,内存就会释放)不需要垃圾回收器去回收

栈内存分配越大越好吗

未必,默认的栈内存通常为1024k
栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能获得的线程数为512个,如果改成2048k,那么能活动的栈帧就会减少一半

方法内的局部变量是否线程安全?

方法内的局部变量内存地址是存储在栈帧中的,而每次往栈里面压入一个栈帧执行完就会弹出一个栈帧,这时候栈帧内存就被释放了,操作1000次就创建释放一千次,不会被其他栈帧影响,也就是说方法内的局部变量这是每个线程独有的(独有的、或者不可变数据就是线程安全的,不会被其他因素改造)
如果是main方法的局部变量/成员变量的话就不是线程安全的了,因为成员变量一半是存放在堆里,main方法的局部变量能够被改变,被其他线程调用也能改变,因此可能会导致数据不一致的问题。
如果方法里的局部变量被返回了,那么它也不是线程安全的了,因为我们也可以在main方法里去调用这个方法去修改这个局部变量,反正只要能被公用的变量就不是线程安全的

总结

  • 如果方法内局部变量没有逃离方法的作用范围(不被其他线程使用),那么它是线程安全的
  • 如果是局部变量引用了对象,并逃离了方法的作用范围,则考虑线程安全

什么情况会导致栈内存溢出(爆栈)?

栈帧过多导致栈内存溢出,典型问题:递归调用方法
栈帧过大导致栈内存溢出,但一般不会出现,因为一个栈一般不会超过1M内存

堆和栈的区别是什么?

  • 栈内存一般会用来存储局部变量和方法的调用,但堆内存是用来存储Java对象和数组的,堆会GC垃圾回收,而栈不会
  • 栈内存是线程私有的,而堆内存不是线程共有的。
  • 两者异常错误不同,如果栈内存溢出是抛出stackOverFlowError,堆内存溢出是抛出OutOfMemoryError。

能不能解释一下方法区(元空间)?

  • 方法区(Method Area)是各个线程共享的内存区域
  • 主要存储类的信息,运行时常量池
  • 方法区在元空间,元空间在JDK1.7之前是在堆里面,1.8之后移到了本地内存,因为运行加载的类越来越多,避免堆内存溢出
  • 方法区在虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区的内存无法满足分配需求时,则会抛出OutOfMemoryError:Metaspace

常量池

可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息
notion image

运行时常量池

常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

你听过直接内存吗

直接内存:并不属于JVM中的内存结构,不由JVM进行管理,是虚拟机的系统内存,常见于NIO操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高
用常规IO和NIO去拷贝一个文件,很明显NIO的速度要比常规IO(BIO)快的多,这是因为Java本身不具备磁盘读写能力,要实现磁盘读写,就必须调用操作系统提供的函数(本地native 方法)
  • 常规IO的数据拷贝流程:
    • 首先会从Java的用户态切换成System的内核态,在这个过程,磁盘文件会先被系统分批次的读取在一个系统内存的缓冲区(不能说一次性把几百兆的内存读取在系统内存,万一放不下呢,内存就爆了),系统缓冲区Java代码是不能运行的,所以Java会在堆中分配一块Java缓冲区(new Byte[]),Java要想访问系统从磁盘读取到缓存区的IO流数据,就必须得把系统缓冲区的内容间接的读入到Java缓冲区,读取完后从内核态切换到用户态,在用户态调用输出流的写入操作,这样反复读写,就可以把一个文件拷贝。
    • notion image
    • 由于有两块缓冲区,在读取数据时就会涉及到数据必须要存两份,造成不必要的数据复制,所以常规IO效率不高
  • NIO数据拷贝流程
    • NIO和常规IO唯一的区别就是把两块缓冲区直接变成了一块直接内存,这块直接内存Java代码和系统都可以直接访问,那么数据从磁盘读取到直接内存后,Java代码就可以直接从直接内存获取到用户态用输出流写入。减少了一次数据复制操作
    • notion image

类加载器

什么是类加载器,类加载器有哪些

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
类加载器分了四种
  • 启动(引导)类加载器,BootStrap ClassLoader,load from JAVA_HOME/jre/lib,由C++编写实现,嵌套在Java内部,主要是用来加载Java的核心库的
  • 扩展类加载器,ExtClassLoader,load from JAVA_HOME/jre/lib/ext,主要就是加载对应目录的扩展内容
  • 应用类加载器起,AppClassLoader,load from CLASSPATH,加载开发者自己编写的Java类,是默认的类加载器,一般来说,Java应用的类都是它来加载
    • 在Java的日常应用开发中,基本有上述三种类加载器互相配合完成
  • 还有一种自定义类加载器,实现自定义类加载规则,不是很常用

什么是类加载器的双亲委派模型

加载某一个类,先委托上一级的加载器进行加载,如果上一级加载器也有上级,则继续向上委托,如果该类委托上级没有被加载,子加载器则尝试加载该类(向上委派,向下查找)
  • 对于用户自定义的Cat类(属于AppClassLoader,应用类加群器加载),应用类加载器上一级是扩展类加载器,会先委托扩展类加载器,扩展类加载器又有启动类加载器,所以又委托启动类加载器,但启动类加载器所加载的JAVA_HOME/jre/lib目录下没有这个类,所有就到下一级扩展类加载器加载,扩展类加载器../ext的目录下也没有找到这个Cat类,所以还是让应用加载器加载了
  • 然后对于Java.lang下的包如String类,首先是默认使用应用加载器加载,然后它有上一级,先委托ExtClassLoader,扩展类加载器也有上一级,再委托BootStrap ClassLoader,启动类加载器没有上一级了,则开始查找有没有这个String类,有就直接返回给应用类加载器使用,没有就往下一级查找。

那么JVM为什么要采用双亲委派机制呢?

  • 防止重复加载:通过双亲委派机制可以避免某一个类重复加载,当父类已经加载后无需重复加载,保证唯一性,比如String类一定是使用的启动类加载器JAVA_HOME/jre/lib目录下面的java.lang.String
  • 保证API的安全:如果不使用双亲委派机制,那么就会被有心之人去恶意篡改核心API方法,使其程序携带攻击性。而使用了双亲委派机制,用户去无法重写核心API库,就比如String方法,如果用户定义了一个类叫String,里面写了一个main方法,那么这个类是无法被执行的,因为在启动器加载器中的核心jre目录下有相同名字的类文件,但该类没有main方法,因此就会报找不到对应的方法错误,这样用户就只能使用核心API库而不能篡改,因为双亲委派是向上委派,向下查找,核心API库都在启动类加载器的目录下,因为双亲委派机制,这样能防止用户改写核心API

说一下类装载的过程

类从加载到虚拟机开始知道卸载为止,它的整个生命周期包括了:加载、连接(验证、准备、解析)、初始化、使用和卸载7个阶段。(加盐准备了西式菜)

加载阶段:

  • 通过类的全名,获取类的二进制数据流。
  • 解析类的二进制数据流为方法区的数据结构(Java类模型),也就是说把类的信息存入到方法区
  • 在堆中创建Java.lang.Class类的实例,表示该类型。作为访问该类在方法区的数据的入口
  • 总结一下过程:首先会获取这个类的数据结构存放在元空间里的方法区,然后在堆中开辟一个区域放这个类的对象,当创建这个类的实例时都会通过这个Class对象去反射到方法区的数据结构在堆中新建实例。
notion image

连接(验证、准备、解析)

  • 验证
    • 验证类是否符合JVM规范,安全性检查
      • 文件格式验证
      • 源数据验证
      • 字节码验证
        • 前三项都是格式检查,如文件格式是否错误、语法、字节码是否合规
      • 符合引用验证,Class文件在其常量池会通过字符串记录自己将要使用的其他类或方法
  • 准备
    • 为类变量(static修饰的变量)分配内存并设置类变量初始值
      • static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
        • static int b = 10
      • static变量是final的基本类型,以及值已确定的字符串常量,赋值在准备阶段完成
        • static final int c =20
        • static final String d = "hello"
      • static变量是final的引用类型,那么复制也会在初始化阶段完成
        • static final Object obj = new Object()
  • 解析
    • 把类中的符号引用转换为直接引用,比如方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法,也就是找到方法区里面对应的常量池去找到真正的内存地址(常量池变运行时常量池)

初始化,对类的静态变量,静态代码执行初始化操作

  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类,也就是子类调用自己的静态变量时,会先初始化执行父类的静态变量和静态代码。
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。也就是子类调用自己的静态变量时,如果有多个静态代码块,那么自上而下执行
  • 如果子类调用的是父类的静态变量,那么子类的静态代码块将不被执行,而父类的静态变量和静态代码将被初始化和执行

使用(JVM开始从入口方法开始执行用户的程序代码)

  • 调用静态类成员信息(比如:静态字段、静态方法)
  • 使用new关键字为其创建对象实例
卸载(
  • 当用户程序代码执行完毕之后,jvm就开始销毁创建的class对象,这时候就相当于把类给卸载了

口诀(加盐准备西式菜,加载、验证、准备、解析、初始化、使用、卸载)

垃圾回收

如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

堆空间的基本结构

Java的自动内存管理主要针对对象内存的回收和对象内存的分配。同时,Java自动内存管理最核心的功能是堆内存中对象的分配与回收
Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆(Garbage Collected Heap)

对象什么时候可以被回收?

如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收期回收
如果要定位什么是垃圾,可以用两种方法来确定,第一个是引用计数法,第二个是可达性分析算法

引用计数法

一个对象被引用了一次,在当前的对象头上递增一次引用次数,当被取消引用后,引用次数ref就会减少,如果这个对象的应用次数为0,代表这个对象可回收
但是引用计数法如果对象间出现了循环相互引用,那么引用计数法就会失效,就会引发内存溢出

可达性分析算法

现在的虚拟机采用的都是可达性分析算法来确定哪些内容是垃圾
有一个GC Roots,相当于一棵二叉树的树根,它从根中进行探索查看它的子节点是否有关联的对象,如果能关联到,不管是直接关联还是间接关联,只要找到的对象是存活的对象,那么这些对象就不会被垃圾回收,如果扫描堆中的对象过程不能沿着GC Root 对象为起点的引用链找到对象,那么就代表可以回收(X和Y)
notion image

哪些对象可以作为GC Root?(四种)

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  • 自己的理解
    • 在新版的IDEA中,对于变量(静态属性、常量)、DTO,VO,实体类里定义的属性、接口方法名等对象有提示被多少多少引用

JVM垃圾回收算法有哪些

  • 标记-清除算法(用的较少,下面两种用的较多)
    • 将垃圾回收分为两个阶段,分别是标记和清除
      • 根据根可达性分析算法得出的垃圾进行标记
      • 对这些标记可回收的内容进行垃圾回.
      • notion image
  • 复制算法(新生代一般使用这种算法)
    • 将整个内存分成两个大小相等的区域,标记阶段和其他两种一致,也是沟通过GC Root去标记存活和非存活对象,标记完后将存活的对象进行复制,复制到另外一块内存区,复制过程就会完成碎片的整理,随后把被复制区域整个清空
    • notion image
  • 标记-整理算法(很多老年代垃圾回收器使用这种算法)
    • 和标记-清除算法思路差不多,同样也是通过GC Root查找存活的对象,然后把不是存活的对象标记回收,之后整理,避免了内存碎片的问题,但是由于需要移动内存位置,效率因此收到了一些影响
    • notion image

说一下JVM中的分代回收

在java8中,堆被分为了两份:新生代和老年代【1:2】,也就是说新生代占了1/3的堆空间,老年代占了2/3的堆空间
其中,新生代又划分了3分区域,分别是Eden区(伊甸园区,新生的对象都分配到这里)、两个幸存者区(survivorfrom、survivorto)。三者比例是【8:1:1】

分代收集算法-工作机制

  • 新创建的对象,都会先分配到eden区
  • 当伊甸园区内存不足,就标记eden区和survivorfrom(最开始创建阶段没有)的存活对象
  • 采用复制算法,把伊甸园区和survivorfrom区的存活对象复制到survivorto区中并整理,清空 from和Eden区,此时又新增一批对象在eden区,弱eden区空间内存再次不足,则开始标记eden区和survivorto区的存活对象复制到survivor区中并整理,清空 to和Eden区,以此往复。
  • 此时新生代中的有些对象在survivorfrom区和to区移动次数达到一个阈值(最多15次)后就会移动到老年代(采用的是标记-整理算法),或者说我们的幸存者from或to区内存不足了,或者这个对象太大了,那么这个对象也会被移动到老年代

Minor GC、Mixed GC、Full GC分别是什么(新生代和老年代)

  • MinorGC(young GC),发生在新生代的垃圾回收,暂停时间(STW,Stop-The-World,暂停所有应用程序线程,等待垃圾回收完成)短,效率高
  • MixedGC,新生代和老年代部分区域的垃圾回收,G1收集器特有
  • FullGC,新生代和老年代完整垃圾回收,暂停时间(STW)长,应尽力避免
    • 在FullGC的时候,会去清空新生代的区域和老年代的区域所有的内存

说一下JVM有哪些垃圾回收器(四种)

  • 串行垃圾收集器
  • 并行垃圾收集器
  • CMS(并发)垃圾收集器
  • G1垃圾收集器

串行垃圾回收器

Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑,一般企业开发用的较少
  • Serial 作用于新生代,采用复制算法
  • Serial Old 作用于老年代,采用标记-整理算法
垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW,stop the world),等待垃圾回收的完成。

并行垃圾回收器

Parallel New 和 Paraller Old,JDK8默认使用此垃圾回收器,通常的项目使用这个垃圾回收器即可
  • New 作用于新生代,采用复制算法
  • Old 作用于老年代,采用标记-整理算法
垃圾回收时,多个线程去处理垃圾回收工作,并且java应用的所有线程都要暂停(STW),等待垃圾回收完成。

CMS(并发)垃圾回收器

CMS全称Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。
  • 但由于其特点是进行垃圾回收时程序能够正常运行,因此需要执行三次标记,初始标记时会短暂阻塞,并发标记时不会阻塞其他线程,最后在重新标记(防止并发标记过程GC Root新增或删除其他引用而误删)。最最后并发清理,其他线程可正常运行,STW(stop the word,线程暂停时间)短

G1垃圾收集器

作用在新生代和老年代,JDK9之后默认使用
  • 其划分了多个区域,每个区域可以充当Eden,survivor,old,humongous,其中humongous专为大对象准备,没有了老年代、新生代区域的比例划分,四个区域哪里都能存
  • 采用的复制算法
  • 响应时间和吞吐率兼顾
  • 把垃圾回收过程分成了三个阶段(三个阶段并行循环执行)
    • 新生代回收(Young Collection,第一阶段,会设计STW,stop the world,暂停用户线程)
      • 初始时,所有区域都处于空闲状态
      • 创建了一些对象,调出一些空闲区域作为伊甸园区存储这些对象
      • 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
        • G1里的新生代内存占比不是固定的,在5%-6%之间波动,它自动的进行调整,所以会限制伊甸园区,我们就不能随便创特别多的新对象,当伊甸园区数据够了之后,就会触发一次伊甸园区的垃圾回收,采用的也是复制算法(利用根可达性分析算法标明哪些对象是存活下来的,没标记的就成了垃圾,这样它就会把幸存对象复制到幸存者区
      • 随着时间流逝,第一次使用复制算法回收伊甸园区后,它又快满了,就会把伊甸园区的幸存者对象,与第一次存放到幸存者区中的对象,合并到一块新的幸存者区,并进行整理和清除老伊甸园区和幸存者区。当然同样如果在某一个幸存者区中出现了一个交换次数阈值,那么它就会被移动到老年代中(需要长久存活的对象)
    • 并发标记(第二阶段,在重新标记过程会触发STW)
      • 有一个触发条件,当老年代的区域触发了一个阈值(45%)后就会触发并发标记,其实就是在老年代区间中去找到存活的对象,并标记,这个过程是并发执行的,并不会暂停用户的其他线程。它会进行一个重新的标记(防止在并发执行识别老年代存核对象过程GC Root进行了新增或删除,此时需要暂停其他用户现场)
    • 混合收集(Mixed Collection)
      • 前两阶段都完成后,就知道了老年代有哪些存活的对象,随后进入混合收集阶段,此时不会对所有老年代区域进行回收,而是根据暂停时间目标(会有一个预期的暂停时间,比如xxxms,可以设置),优先回收价值高(固定区域存活对象少,且在预期的暂停时间内)的各区域(Eden区,幸存者区,价值高的老年代区混合收集,这也是G1 Gabage First名称的由来)
      • 混合收集过程(新生代老年代混合收集)是把Eden区、幸存者区会复制到一个新的幸存者区,而老年代和幸存者区到了一定阈值的对象就会被复制到新的老年代中,收集完成之后,就会把被复制区域清除,混合收集可能会执行多次,因为并发标记是其他线程可执行的,因此会去重新标记一次并发过程漏筛的内容。
      • 在混合收集完成后,又循环回到了新生代回收、并发标记,准备开始新一轮回收
  • 如果并发失败(即回收速度赶不上创建新对象的速度),则会触发Full GC,但一般G1三个阶段都是并发执行的,用户线程暂停时间也比较短,所有一般不会触发Full GC,触发了就要STW(stop the world。暂停用户线程)久一点了
  • 还有一个问题,就是一个对象如果太大了,一个区域装不下的话,会存入到一个巨型区域(连续的区域)中。

强引用、软引用、弱引用、虚引用的区别

不同的引用,垃圾回收的情况不同

强引用

只有所有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
  • User user = new User(); 只要这个user被关联了,那么它就是强引用,不会被回收,即使抛出oom异常也不会回收这个强引用对象

软引用

新建的对象是通过其他对象创建的,在正常垃圾回收后,若内存还是不足时就会回收这个其他对象。
  • User user = new User(); SoftReference sr = new SoftReference(user);,第一次不会回收user对象,回收之后内存不足则回收new SoftReference(user)里的user对象

弱引用

和软引用类似,但它是只要有垃圾回收过程就会被回收
  • User user = new User(); WeakReference sr = new WeakReference (user);,下面这个super(k)就是弱引用,遇到回收就会被回收
notion image

虚引用

必须配合引用队列使用,在引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存
直接内存需要调用api释放,jvm不会主动释放

总结

强引用
  • 只要强引用存在(被使用),对象永远不会被回收
  • 例:Object obj = new Object()
  • 场景:日常开发中最常用的引用类型
软引用
  • 内存不足时才会被回收(GC前会检查内存是否足够)
  • 例:SoftReference<Object> softRef = new SoftReference<>(obj)
  • 场景:适合实现缓存(如图片缓存),内存不够时自动释放
弱引用
  • 只要发生GC就会被回收(不管内存足不足够)
  • 例:WeakReference<Object> weakRef = new WeakReference<>(obj)
  • 场景:实现临时性缓存(如 WeakHashMap),监听器清理
虚引用
  • 无法通过虚引用获取对象时,GC时会收到通知
  • 例:PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue)
  • 场景:管理堆外内存(如 NIO 的 DirectBuffer),精确控制资源释放
生活场景类比
  • 强引用:你手里的手机,只要拿着就不会掉
  • 软引用:口袋里的备用手机,没钱吃饭时才卖掉换钱
  • 弱引用:桌上的抽纸,打扫房间时就会被扔掉
  • 虚引用:监控摄像头,只能知道物品被拿走了,但拿不走物品本身

JVM调优的参数可以在哪里设置参数值

也就是问项目部署的时候参数设置
war包部署在tomcat中设置
  • tomcat中有个文件叫catalina.sh文件,它的位置在TOMCAT_HOME/bin/catalina.sh(linux下),如果是windows下,则为bat结尾
  • 在这个文件中,有jvm的参数值,名字叫JAVA_OPTS = "-Xms512m -Xmx1024m",这个意思就是对的初始大小是512MB,最大大小是1024MB。
jar包部署在启动参数设置
  • 通常在linux系统直接加参数启动springboot项目
    • nohub java -Xms512m -Xmx1024m -jar xxxxxx.jar --spring.profiles.active=prod &
    • nohub:用于系统后台不挂断地运行命令,退出终端不会影响程序的运行
    • 参数 &:让命令在后台执行,终端退出后命令仍旧执行。

JVM调优的参数都有哪些

对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。

设置堆空间大小

  • 设置堆的初始值大小和最大大小,防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间。
  • Xms:设置堆的初始化大小
  • Xmx:设置堆的最大大小
  • 不指定单位的话默认是字节,指定的话就按照指定的设置
  • 一般堆空间推荐设置大小是:最大大小的默认值是物理内存的1/4,初试大小是物理内存的1/64
    • 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,导致STW(stop the world)暂停用户线程
    • 堆内存大肯定是好,但是存在风险,假如发生了FullGC,它会扫描整个堆空间,暂停用户现场的时间长
虚拟机栈的设置
  • 每个线程默认会开启1M的内存,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统
  • Xss 对每个线程的stack大小进行设置,如 -Xss128k
年轻代中Eden区和两个Survivor区的大小比例
  • 设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小,来减少Young GC发生的次数,但优势我们发现,虽然次数减少了,但Eden区满的时候,由于占用空间较大,导致释放缓慢,进而导致STW的时间教程,因此需要按照程序情况区调优
  • XXSurvivorRatio=8,表示每一个Survivor区,Eden区的大小是其的8倍,幸存者永远站两份(from一份,to一份),eden占多少份就决定了总分数:survivor:eden = 2:8
年轻代晋升老年代阈值
  • XX:MaxTenuringThreshold = threshold
  • 默认为15
  • 取值范围0-15
设计垃圾回收收集器
  • 可以通过设置并行垃圾回收收集器增大吞吐量提高系统性能(jdk8默认垃圾回收收集器)
  • XX:+UseParallerGC
  • XX:+UseParallerOldGC
  • XX:UseG1GC

JVM调优的工具

命令工具

  • jps 进程状态信息
  • jstack 查看java进程内线程的堆栈信息
    • 先使用jps查看运行的进程pid
    • 然后再使用jstack pid 可以查看堆栈信息
    • 结合二者可以查找死锁
  • jmap 查看堆转信息
    • 使用 jmap -heap pid即可查看对应进程的堆栈信息
  • jhat 堆转储快照分析工具
  • jstat JVM统计监测工具

可视化工具

  • jconsole 用于对jvm的内存,线程,类的监控
  • VisualVM 能够监控线程,内存情况

Java内存溢出的排查思路

哪里会产生内存泄露的问题?(堆(OutOfMemory:java heap space,最常见)、栈(StackOverFlowError,递归导致)、方法区(OutofMemoryError:Metaspace,动态加载的类过多导致)
主要也是排查堆空间溢出的问题
对于部署在服务器上的项目(通常有多个模块,如订单模块、物流模块、商品模块、支付模块等等),这些模块有可能根本没运行起来,也有可能运行一段时间后宕机
这时候就可以
  • 获取堆内存快照dump
    • 通过jmap指定打印他的内存快照dump(Dump文件是进程的内存镜像。可以把程序的执行状态通过调试器保存到dump文件中)
      • jmap命令获取运行中程序的dump文件:jmap -dump:format=b,file=heap.hprof pid
        • dump是特定指令后面跟着的是二进制文件
        • file=heap.hprof,设置当前文件存储的路径
        • pid是需要查看堆内存快照的进程id
    • 但jmap只能在项目运行时使用,所以使用vm参数获取dump文件
      • vm参数获取dump文件:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/app/dumps
  • VisualVM去分析dump文件
  • 通过查看堆信息的情况,定位内存溢出的问题

CPU飙高排查和解决方案

首先在服务器终端使用 top 命令查看占用CPU的情况
然后记住占用CPU较高的那个进程PID
接着使用 PS H -eo pid,tid,%cpu | grep 40940 查看进程中所有的线程信息。
  • HH 参数用于显示进程的线程信息。
  • eoeoeo 参数的缩写,用于指定输出的格式。eo 后面跟着的字段表示要显示的列。
  • |:管道符,用于将前一个命令的输出作为后一个命令的输入。
  • grep 40940grep 是一个用于搜索文本的命令,40940 是你要查找的进程 ID 或线程 ID。grep 会过滤出包含 40940 的行。
  • 这时候就能看到列举的pid对应的线程id了,那么就找到了问题所在,拿到线程id
再根据线程id,使用jstack pid去查看这个进程的堆栈执行情况
  • 但由于jstack pid 打印出来的线程id显示的方式是十六进制,因此需要借助 printf "%x\n" tid 来打印线程id的十六进制,与jstack pid打印出来的nid内容对应

Redis使用场景

notion image

常见场景

  • 缓存(三兄弟:穿透、击穿、雪崩)、双写一致、持久化、数据过期策略、数据淘汰策略
  • 分布式锁 setnx、redisson
  • 消息队列、延迟队列 何种数据类型

缓存(缓存三兄弟、双写一致、持久化、数据过期/淘汰策略)

缓存穿透

缓存的流程

  • 一个get请求:api/news/getById/1,根据id查询文章
  • 先去redis查找(缓存),找到则返回结果,未找到则查DB
  • DB查询到后返回结果(返回前数据会存储到redis)

概念

  • 查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库(常被用于被知晓请求路径后的恶意攻击,导致宕机)

解决方案

  • 方案一:缓存空数据,查询返回的数据为空,仍然把这个空结果缓存,缓解后续请求对DB的压力
    • 优点:简单
    • 缺点:消耗内存, 可能会发生后续添加数据后,DB与Redis的数据不一致的问题
  • 方案二:布隆过滤器,在请求发送前,先经过布隆过滤器(拦截不存在的数据),如果不存在,直接返回,如果存在就放行,查询redis。(在进行缓存热点数据(预热)时,往布隆过滤器中添加数据)
    • 优点:内存占用较少,没有多余的key
    • 缺点:实现复杂,存在误判

面试问答

notion image

缓存击穿

概念

  • 给某一个key设置了过期时间,当key过期的时候,恰好这个时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮

原由

  • 通常发生在缓存重建(可能存入缓存的数据是多个表分组汇总的结果)过程中,这时候有大量请求过来就会直接访问DB

解决方案

  • 方案一:互斥锁,在查询缓存中热点数据不存在时,利用redis的setnx设置一个互斥锁(分布式锁),随后查询数据库进行缓存数据的重建,在重建过程如果有另外一个线程来访问热点数据未命中,同样也去尝试设置和获取互斥锁,但此时后者会失败,因为已经有一个线程把锁占用了(正在重建)。此时后者会休眠一会后重试,直到重建成功,释放锁后,直接从redis取数据 。
    • 优点:只能由一个线程获取互斥锁,强一致性,适用于金钱相关业务
    • 缺点:性能差
  • 方案二:逻辑过期,不设置过期时间,在缓存数据里设置一个过期字段,用来描述这条数据从新增到缓存时到过期的时间,在查询请求发现逻辑时间过期时,利用redis的setnx设置一个互斥锁(只允许一个线程去重建缓存,防止乱套),然后新开一个线程去重建缓存,前者线程的返回数据不受影响,只是返回的是过期的数据,后者线程重建完缓存,释放锁后就是更新后的数据(包括过期时间),之后的线程如果在互斥锁释放前访问都是过期数据,如果在释放锁后访问就是更新后的数据,大大减小DB压力。
    • 优点:高可用,性能优,适用于互联网行业,用户体验号
    • 缺点:不能保证数据一致,会有部分请求的是过期数据,也有更新后的数据。
    • notion image

面试问答

notion image

缓存雪崩

概念

  • 在同一时间段大量的缓存Key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

原由

  • 大量key同一时间过期
  • Redis服务宕机

解决方案

  • 对于大量Key同一时间过期,可采用给不同的key的TTL(逻辑过期时间)添加随机值 1-5分钟,分散过期时间段,或给业务添加多级缓存(利用Guava/Caffeine设置一级缓存,redis作为二级缓存)
  • 对于Redis服务宕机,可采用Redis集群提高服务的可用性(哨兵模式,集群模式),或给在设计缓存业务时添加降级限流策略(在nginx或者Spring Cloud gateway网关中设置限流规则,可作为系统的保底策略,适用于穿透、击穿、雪崩)

面试问答

notion image

缓存记忆打油诗

《缓存三兄弟》
穿透无中生有key,布隆过滤null隔离
缓存击穿过期key,锁与非期解难题
雪崩大量过期key,过期时间要随机
面试必考三兄弟,可用限流来保底。

双写一致

redis作为缓存,mysql的数据如何与redis同步呢

  • 先介绍业务背景,再根据情况来回答,有两种情况
    • 一致性要求高
    • 允许延迟一致
  • 写操作(延时双删,降低脏数据出现的风险)
    • 删除缓存——》修改数据库—延时—》删除缓存
  • 强一致性解决双写一致的方案:
    • 分布式锁
  • 强一致性+性能优化解决双写一致的方案
    • 读写锁
      • 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作。
      • 排他锁:独占锁writeLock,加锁之后阻塞其他线程读写操作
      • notion image
  • 代码
notion image

面试问答

notion image

Redis持久化

在Redis提供了两种数据持久化的方式:1、RDB 2、AOF

RDB(Redis Database Backup file,Redis数据备份文件)

也叫Redis数据快照,简答来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。

指令(save、bgsave)

  • save,由Redis主进程来执行RDB,会阻塞所有命令
  • bgsave,由子进程来执行RDB,避免主进程受影响(推荐)
  • Redis内部有默认触发RDB的机制,一般执行的是bgsave
notion image

RDB原理

notion image

AOF(Append Only File,追加文件)

Redis处理每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

notion image

AOF默认关闭,需要去conf配置文件开启

  • appendonly yes(默认是no)
  • AOF文件名称 appendonly.aof
  • AOF的命令刷新频率有三个等级 always everysec no
notion image

注意

  • 可以用bgrewriteof命令让AOF执行重写功能,这样对于相同命令的写数据操作,只有最后一次写操作才有意义,用记录最少的命令达到相同的效果
  • Redis也会在触发命令阈值时自动执行重写AOF文件,阈值可在conf设置
notion image

RDB与AOF对比

notion image

RDB特点

  • 持久化方式:定时对整个内存做快照
  • 数据完整性:不完整,两次备份之间会有丢失
  • 文件大小:会有压缩,文件体积小
  • 宕机回复速度:很快
  • 数据恢复优先级:低,因为数据完整性不如AOF
  • 系统资源占用:高,大量CPU和内存消耗
  • 使用场景:可以容忍数分钟的数据丢失,追求更快的启动速度

AOF特点

  • 持久化方式:记录每一次执行的命令
  • 数据完整性:相对完整,取决于刷盘策略
  • 文件大小:记录命令,文件体积大
  • 宕机回复速度:慢
  • 数据恢复优先级:高,因为数据完整性更高
  • 系统资源占用:低,主要是磁盘IO资源,但AOF重写时会占用大量CPU和内存资源
  • 使用场景:对数据安全性要求较高常见。

面试问答

notion image

Redis过期策略

假如redis的key过期后,会立即删除吗

Redis对数据设置有效时间,过期后,就需要将数据从内存中删除,可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略(数据过期策略,有两种:惰性删除,定期删除)

惰性删除

设置key过期时间后,不去管他,当需要key时,我们在检查其是否过期,如果过期,就删除它,反之返回key

定期删除

每隔一段时间,对一些key进行检查,删除里面过期的key(从一定数量的数据库取出一定数量的随机key进行检查,并删除其中的过期key)
  • SLOW模式,定时任务,执行频率默认为10hz,每次不超过25ms,可以通过修改conf的hz选项来调整这个次数
  • FAST模式,执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
优点
  • 可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的映像。另外定期删除,也能有效释放过期键占用的内存。
缺点
  • 难以确定删除操作的时长和频率。

Redis的过期策略:惰性删除+定期删除配合

面试问答

notion image

数据淘汰策略

假如缓存过多,内存是有限的,被占满了怎么办

当Redis内存不够用时,向Redis中添加新的key,那么redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略

Redis支持8种不同的策略来选择要删除的key

  • noeviction:不淘汰任何key,内存满时不允许写入新数据,默认是这种策略
  • volatile-ttl:对设置了TTL(过期时间)的key,比较key的剩余TTL值,越小越先被淘汰。
  • allkeys-random:对全体key,随机进行淘汰
  • volatile-random:对设置了TTL的key,随机进行淘汰
  • allkeys-lru:对全体key,基于LRU算法淘汰
    • LRU:Least Recently Used,最近最少使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高
  • volatile-lru:对设置了TTL的key,基于LRU算法淘汰
  • allkeys-lfu:对全体key,基于LFU算法淘汰
    • LFU:Least Frequently Used,最少频率使用,会统计同一时间段内key的访问次数,值越小淘汰优先级越高
  • volatile-lfu:对设置了TTL的key,基于LFU算法淘汰

使用建议

  • 如果业务无任何要求,优先使用allkeys-lru策略,充分利用LRU最近最少使用算法的优势,把最近最常访问的数据留在缓存,或者业务有明显的冷热数据区分,建议使用
  • 如果业务中数据访问频率差别不大,没有明显的冷热数据区分,建议使用allkeys-random,随机选择淘汰
  • 如果业务中有置顶的需求,可以使用volatile-lru策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据
  • 如果业务中有短时高频访问的数据,可以使用allkeys-lfu或volatile-lfu策略

面试问答

notion image
数据库有1000万条数据,但是Redis只能缓存20w数据,如何保证redis中都是热点数据?
  • 答:使用allkeys-lru策略,删除最近最少使用的keys,保留常访问的数据。
Redis的内存用完了会发生什么?
  • 答:这个要看过期策略是什么,如果是noeviction,那么内存用完了就会报错

分布式锁(setnx、redisson)

redis分布式锁是如何实现的?
  • 结合项目中的业务进行回答,通常情况下分布式锁的应用场景:
    • 集群情况下的定时任务、抢单、幂等性场景
  • redisson实现的分布式锁,底层是setnx和lua脚本(保证redis执行多条命令时的原子性)
为什么需要分布式锁
  • 通常一个业务不是只有本地去访问的,synchronized是JVM自带的本地锁,无法互斥其他服务器的线程访问,所以就需要setnx、redisson分布式锁(外部锁)

抢券执行流程(采用分布式锁)

notion image

Redis分布式锁实现原理(setnx、redisson)

主要利用的是Redis的setnx命令(SET if not exists)
  • 获取锁
    • SET lock value NX EX 10
    • 添加锁,lock是锁名称(key),value是key的值,NX是互斥,EX是超时时间(不设置可能会导致因宕机而无法自动释放锁的死锁
redis实现的分布式锁如何合理的控制锁的有效时长?
  • 利用现有的市面技术redisson给锁续期
  • redisson的分布式锁中,提供了一个WatchDog看门狗机制,一个线程获取锁成功以后,看门狗会给持有锁的线程续期(默认是每隔10s续期一次)

redisson实现的分布式锁

原理还是setnx,在这个基础上的增强优化版本

加锁和释放锁的流程

通常的Redis加锁过程就是一个线程去访问一个资源,成功后就给其上锁然后操作redis
  • redisson的加锁是在加锁成功的同时设置一个Watch dog(每隔releaseTime/3的时间做一次续期,releaseTime默认30s)
  • redisson的释放锁需要手动加上代码,在执行到该代码时,会通知Watch dog key已经被删除。
假如来了一个新的线程想要访问资源,看看能否加上锁(锁名一致)
  • 加锁成功,与上面的redisson加锁流程一致
  • 加锁失败,普通的处理结果是中断,而Redisson有重试机制,会一直while重试获取锁到一个阈值后中断,重试机制让高并发情况可以很大程度增加分布式锁的使用性能

加锁代码(加锁、设置过期时间等操作都是基于lua脚本完成,保证redis执行多条命令的原子性)

notion image

锁的可重入(同一线程重复获取锁,一个方法获取锁,这个方法里面的方法也尝试去获取锁)

  • redis实现的分布式锁不可重入
  • redisson实现的分布式锁可重入
    • 实现原理:每一个线程都有一个线程ID,redisson根据这个线程id判断是否是同一线程。利用hash结构记录线程id和重入次数
    • notion image
  • KEY是锁名称,VALUE里面有两个值,一个是线程ID,另一个重入次数,当重入次数为0时释放(tryLock的时候+1,unlock的时候-1)
redisson实现的分布式锁能保证主从数据的一致吗?
  • 红锁(RedLock):在多个redis实例上创建锁(n/2+1,大于等于一半),但实现复杂,性能差,运维繁琐
    • 拓展:关于分布式锁,redis是AP思想(强调高可用性),可想办法保证最终的一致。主从数据的一致可以用zookeeper实现的分布式锁,zookeeper是CP思想(强一致性)

面试问答

notion image

拓展——Redis其他面试题

Redis主从复制,主从同步流程(解决高可用问题)

redis集群有哪些方案知道吗
  • 主从复制
  • 哨兵模式
  • 分片集群
...

Redis哨兵模式(解决高并发读的问题)

实现主从集群的自动故障恢复,当主节点宕机后,会选出一个从节点来当主节点来继承写操作
...

Redis分片集群结构(解决海量数据存储和高并发写的问题)

...

其他

redis是单线程的,为什么还那么快
  • Redis是从内存操作,执行速度非常快
  • 采用单线程,避免不必要的上下文切换可竞争条件,多线程要考虑线程安全的问题
  • 使用I/O多路复用模型,并非阻塞IO
    • 能否解释一下I/O多路复用模型?
    • redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,I/O多路复用模型主要就是实现了高效的网络请求
    • 涉及概念:
      • 用户空间和内核空间
      • 常见的IO模型:阻塞IO(Blocking IO)、非阻塞IO(Nonblocking IO)、IO多路复用(IO Multiplexing)
      • Redis网络模型

用户空间和内核空间

  • Linux系统中的一个进程使用的内存情况分为两部分:内核空间、用户空间
  • 用户空间只执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口访问
  • 内核空间可以执行特权命令(Ring0),调用一切系统资源
notion image
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区。
  • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备。
  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。
因此想要提高IO效率,从两点出发
  • 想办法减少用户空间无效的数据等待
  • 减少用户空间与内核空间数据的拷贝

IO模型(为了提高IO效率)

阻塞IO(BIO,Blocking IO)

顾名思义,就是两个 阶段必须阻塞等待
阶段一
  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态
阶段二
  • 数据到达,拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据
notion image
可以看到,BIO模型中,用户进程在两个阶段都是阻塞状态,性能不高

非阻塞IO(NIO,Nonblocking IO)

顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一
  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程拿到error后,再次尝试读取
  • 循环往复,直到数据就绪
阶段二
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据
notion image
可以看到,NIO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态,虽然是非阻塞,但是性能并没得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用(IO Multiplexing,性能比前两者好,redis底层使用这个)

利用单个线程同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阶段一
  • 用户进程调用select,指定要监听的Socket集合
  • 内核监听对应的多个socket
  • 任意一个或多个socket数据就绪则返回readable
  • 此过程用户进程阻塞
阶段二
  • 用户进程找到就绪的socket
  • 依次调用recvfrom读取数据
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据
notion image

面试问答

notion image

Mysql篇

mysql的优化
  • 定位慢查询
  • SQL执行计划
  • 索引
    • 存储引擎
    • 索引底层数据结构
    • 聚簇和非聚簇索引
    • 索引创建原则
    • 索引失效场景
  • SQL优化经验
其他面试题
  • 事物相关
    • 事务特性、隔离级别、MVCC
  • 主从同步原理
  • 分库分表

如何定位慢查询(结合SQL优化)

在MySQL中,如何定位慢查询?
通常慢查询包含:
  • 聚合查询
  • 多表查询
  • 表数据量过大查询
  • 深度分页查询
表象:页面加载国漫,接口压测响应时间过长(超过1s)
通常定位慢查询方式有两种
  • 方案一:开源工具
    • 调试工具:Arthas(跟踪上线项目方法的执行时间)
    • 运维工具:Prometheus、Skywalking(实时查看接口响应情况)
  • 方案二:MySQL自带的慢日志
    • 慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志,如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置如下信息
      • 开启MySQL慢日志查询开关:slow_query_log=1
      • 设置慢日志记录的时间为超过2秒,long_query_time=2

面试问答

在mysql中开起了慢日志查询,设置的值是2S,一但sql执行超过2秒就会计入到日志中(调试阶段)
notion image

SQL语句执行很慢,如何分析(优化)?

如果是SQL语句的原因,那么优化SQL语句
  • 聚合查询
    • 新增一个临时表解决
  • 多表查询
    • 优化SQL语句的结构
  • 表数据量过大查询
    • 添加索引
    • 如果添加索引依然很慢,就分析SQL的执行计划
  • 深度分页查询

SQL语句的执行计划分析

可以使用EXPLAIN 或者 DESC命令获取MySQL如何执行SELECT语句的信息
语法
  • 直接在select语句之前加关键字 explain / desc
  • 得到的内容是这条sql的执行情况
notion image
需要关心的字段:
  • possible_key 当前sql可能会使用到的索引
  • key 当前sql实际命中的索引
  • key_len 索引占用的大小
    • 通过上面两个字段判断是否可能会命中索引
  • Extra 额外的优化建议
    • Using where;Using Index 查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据
    • Using Index condition 查找使用了索引,但是需要回表查询数据(这个情况有优化空间)
  • type 这条sql的连接的类型,性能由好到差为:NULL、system、const、eq_ref、range、index、all
    • system:查询系统中的表
    • const:根据主键查询(用到较多)
    • eq_ref:主键索引查询或唯一索引查询(一条数据)
    • ref:索引查询(多条数据)
    • range:索引,但范围查询(最低要求,下面两个情况都需要优化)
    • index:索引树(全索引)查询
    • all:全盘查询
因此,通过MySQL自带的分析工具EXPLAIN分析SQL语句的可优化情况
  • 通过key和key_len检查是否命中了索引(也能判断索引本身存在是否有效的情况,?举个例子)
  • 通过type字段查看sql是否有进一步优化的空间,如果有全索引查询或者全盘扫描,则可优化
  • 通过Extra建议判断,如果出现了回表的情况,尝试添加索引或者修改返回字段来修复

面试问答

notion image

索引

了解过索引吗?
  • 索引(index)是帮助mysql高效获取数据的数据结构(有序)。在数据之外,数据库系统还维护着满足特定查找算法的数据结构(B+树),这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

数据结构对比(B+树、二叉树、红黑树、B树)

MySQL默认使用的索引底层数据结构是B+树
  • 二叉树(普通情况复杂度是O(logn),最坏情况(一边倒,从小到大/从大到小,O(n)),其时间复杂度不稳定
notion image
  • 红黑树(自平衡二叉树,时间复杂度相对稳定,O(logn),但因其本身也是二叉树,每个节点只有两个子节点,如果数据量巨大,红黑树会变得特别高,查找效率也就降低)
  • B树(多叉路衡查找树,相对于二叉树,B树每个节点可以有多个分支,即多叉。以一个最大度数为5(5阶,每个节点最多有5个子节点)的b-tree为例,B树每个节点最多存储4个key。
notion image
  • 上图,蓝色为key,存储的是数据,绿色为key对应的数据,灰色为指针,指向子节点的数据(一般都是比节点小的数据)

B+树(B+Tree)

是BTree基础上的优化,使其更适合实现外存储索引结构,InnoDB存储索引就是用B+Tree实现其索引结构
  • 与B树相似,属于多阶(矮胖树),不同在于其非叶子节点只存储指针(用于导航找到叶子节点存储的数据),只有在叶子节点才真正存储数据。
  • B树与B+树对比
    • 磁盘读写代价B+树更低(只在叶子节点写数据,非叶子节点只起导航作用);
    • 查询效率B+树更加稳定(查找路径都是从根节点到叶子节点);
    • B+树便于扫库和区间查询(叶子结点之间利用双向指针连接,在范围查询时更加方便);
因此对于什么是索引
  • 索引是帮助MySQL高效获取数据的数据结构(有序)
  • 提高数据检索的效率,降低数据库的IO成本(不需要全表扫描)
  • 通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗。
索引的底层结构了解过吗
  • MySQL的InnoDB引擎采用的是B+树的数据结构来存储索引
  • 阶数(度数/子节点个数)更多,路径更短
  • 磁盘读写代价B+树更低,非叶子节点只存储指针,叶子节点存储数据
  • B+树便于扫库和区间查询,叶子节点是一个双向链表。

面试问答

notion image

聚簇索引(存整行)与非聚簇索引(存主键)

什么是聚簇索引(聚集索引)、非聚簇索引(二级索引)?

什么是回表查询(与上面二者有关)?
  • 聚集索引(Clustered Index):将数据存储与索引放到一块,索引结构的叶子节点保存了行数据
    • 特点:必须有,而且只有一个
  • 二级索引(Secondary Index):将数据域与索引分开存储,索引结构的叶子节点关联的是对应的主键值
    • 特点:可以存在多个

聚簇索引选取规则:

  • 如果存在主键,主键索引就是聚集索引
notion image
  • 如果不存在主键,那么使用第一个唯一(UNIQUE)索引作为聚集索引。
  • 如果既没有主键,也没唯一索引,那么InnoDB会自动生成一个rowid作为隐藏的聚簇索引

二级索引

  • 如果选取name字段作为一个索引,那么它的叶子节点就是对应的主键值,这个就是非聚簇索引
notion image

回表查询(先非聚簇后聚簇取整行)

select * from user where name = 'Arm'
当查询条件走的是二级索引时,会触发一次根据主键走聚簇索引回表获取整行信息
notion image

面试问答

什么是聚簇索引/非聚簇索引?
  • 聚簇索引(聚集索引):数据与索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个
  • 非聚簇索引(二级索引):数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个。(单独给非主键字段创建的索引一般都是二级索引)
什么是回表查询?
  • 先解释聚簇索引和非聚簇索引
  • 通过二级索引找到对应的主键值,再到聚集索引中查找整行数据,这个过程是回表。
notion image

覆盖索引与超大分页优化

什么是覆盖索引

  • 指查询用了索引,并且需要返回的列在该索引中已经全部能够找到(根据该索引一次性把需要数据都找到,不涉及回表查询)
  • 使用id查询,直接走聚集索引查询,一次索引扫描,直接返回数据,性能高。
  • 如果返回的列中没有创建索引,可能会触发回表查询,尽量避免使用select *

MySQL超大分页怎么处理(覆盖索引解决)

在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。
select * from tb_sku limit 0,10; 花费0.00sec
select * from tb_sku limit 9000000,10; 花费11.05sec
为什么第二条花时这么久?
  • 第二条的分页查询返回第9000001条数据开始的10条数据,Mysql需要排序前9000010条记录,仅仅返回9000000 - 9000010 的记录,其他记录丢弃,查询排序的代价非常大
解决方法:利用覆盖索引(索引默认有序,无需重新排序)
  • 如需返回所有字段,通过覆盖索引+子查询+等价连接(以小数据集驱动大数据集/列匹配行)的形式进行优化。
select *
from tb_sku t,
(select id from tb_sku order by id limit 9000000,10) a
where t.id = a.id; 花费7.17sec

面试问答

notion image

索引的创建原则

索引有哪些?
  • 主键索引(优先)
  • 唯一索引
  • 根据业务创建的索引(复合索引)

适用场景/原则有哪些

  • 针对数据量较大(单表超过10万的数据),且查询比较频繁的表建立索引。
  • 针对常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引(防止回表,增加覆盖索引的概率)
  • 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用的索引效率就越高(城市字段有很多重复,区分度不高,不建议设索引)
  • 如果是字符串类型的字段,字段长度较长,可以针对字段特点,建立前缀索引(截取一部分内容做索引)。
  • 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。
  • 控制索引数量,索引越多维护索引结构的代价越大,影响增删改的效率
  • 如果索引不能存NULL,清在创建表时使用NOT NULL约束它,当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用户查询。

面试问答

notion image

索引失效

什么情况下索引会失效?
  • 思考一下,不要张口得得得(就是背),尽量说自己遇到过的
例子:给tb_seller创建联合索引,字段顺序:name,status,address
notion image
  • 使用explain关键字快速判断索引是否失效

失效情况:

  • 违反了最左前缀法则(可以少,不能跳,少的话是部分失效,跳的话只有最左生效,后面是不生效)
    • 索引了多列,就要遵守最左前缀法则,查询从索引的最左前列开始,并且不跳过索引中的列。只有匹配这个法则才走索引
    • notion image
上图索引生效
notion image
上图索引失效
notion image
上图索引匹配最左前缀生效,跳了的不生效
notion image
多个索引key_len的值不一样
  • 范围查询中,右边的列不能使用索引(范围字段在建立索引时,其建立顺序应该在最右边)
notion image
上图中Extra为using index condition 代表有回表查询。可优化(尽量走覆盖索引)
  • 不要在索引列上进行运算操作,否则索引失效
notion image
  • 字符串不加单引号,索引会失效
notion image
  • 头部模糊查询导致索引失效(以%开头的Like模糊查询),如果仅仅是尾部模糊查询,索引不失效(因为B+树是通过计算第一个字段的第一个字符的ascii码来排序的)
notion image

面试问答

notion image

SQL优化

表的设计优化

主要是参考阿里开发手册《嵩(黄)山版》
  • 比如设置何时的数值(tinyint int bigint),要根据实际情况(内容长度)选择。
  • 比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低。

索引优化(参考优化创建原则和索引失效)

SQL语句优化

  • SELECT语句务必指明字段名称(避免直接使用select * 导致回表,效率低,多覆盖索引)
  • SQL语句要避免索引失效的写法(违反最左前缀法则、遇到范围查询后,右边列索引失效、模糊查询的%在前面、在索引上进行了运算操作、类型转换。
  • 尽量用union all 代替union,union会多一次过滤,效率低(union all会吧两条语句的结果一起展示,包含重复数据。union会吧重复数据过滤)
  • 避免在where子句中对字段进行表达式操作(避免索引失效,不要在条件进行表达式操作)
  • Join优化,能用inner join(内连接)就不用左右连接(外连接),要用的话要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,大表放里边,左右连接不调整顺序。(把小表放在驱动方(左连接放左方)可以减少数据库连接次数)

主从复制、读写分离

如果数据库的使用场景的读操作比较多,为了避免写的操作造成的性能影响,可以采用读写分离的架构。读写分离解决的是由于数据库的写入影响的查询效率。(主库负责写操作,从库负责读操作,Master写完再同步到Slave)

面试问答

notion image

其他面试题

事务相关内容

事务的特性是什么?可以详细说一下吗(ACID)

  • 事务是一组操作的集合,不可分割的工作单位,事务会吧所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。
  • 事务的特性:ACID
    • 原子性(Automicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
    • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
    • 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
    • 持久性(Durability):事务一但提交或回滚,它对数据库中的数据的改变是永久的。

面试问答

notion image

并发事务带来哪些问题?怎么解决这些问题?Mysql默认隔离级别是?

并发事务问题:脏读、不可重复读、幻读(对事务进行隔离解决)
  • 脏读:一个事务读到另一个事务还未提交的数据
  • 不可重复读:一个事务先后读取同一条记录,但两次读取的数据不同
  • 幻读:一个事务按照条件查询数据时,没有对应的数据行,但是在插入数据时,又发现这行数据已存在
隔离级别:读未提交、读已提交、可重复读、串行化
  • Read uncommitted 未提交读,不可解决并行事务问题(一般不用这个隔离级别)
  • Read committed 读已提交,可解决脏读,不可解决不可重复读 、幻读问题
  • Repeatable Read 可重复读,可解决脏读、不可重复读(Mysql默认级别),不可解决幻读问题(重点)
  • Seriallzable 串行化,可解决脏读、不可重复读、幻读,放弃了并发事务,效率低,实际开发很少用
以上事务级别越往下级别越高,数据越安全,但是性能越低。

面试问答

notion image

undo log 和 redo log的区别

首先知道数据库的两个概念
  • 缓冲池(buffer pool):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度。
  • 数据页(page):是InnoDB存储引擎磁盘管理的最小单元,每个页的大小默认为16KB。页中存储的是行数据

redo log 重做日志

记录的是事务提交时数据页的物理修改,是用来实现事务的持久性的
  • 该日志文件由两部分组成:重做日志缓冲(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者在磁盘中,当事务提交之后会把所有修改信息都存到该日志文件中,用于刷新脏页到磁盘,以及发生错误时,进行数据恢复使用。
  • 涉及机制:WAL(Write-Ahead Logging,先写日志)
    • 当内存中的缓冲池发生CRUD等操作时,会在redo log buffer(内存) 中记录并同步更新到redo log file(磁盘)中,这样可以大大提升内存到磁盘失败后数据恢复的性能(对磁盘IO是顺序存取,不使用redo log恢复,直接同步恢复的话太慢了,因为对磁盘IO是随机的)以及实现持久性。
    • 但当脏页数据可正常同步到磁盘时,WAL机制就没有作用,redo log 每隔一段时间就会把redo log file(有两份,循环写) 清理

undo log 回滚日志

用于记录数据被修改前的信息,作用包含两个:提供回滚(ctrl+z)和MVCC(多版本并发控制)。undo log 和 redo log 记录物理日志不一样,undo log是逻辑日志(不写入磁盘?)
  • 可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之依然。
  • 当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log 中的逻辑记录读取到相应的内容并回滚。
  • undo log 可以实现事务的一致性和原子性。

面试问答

notion image

事务的隔离性是如何保证的?

锁:排他锁(如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的排他锁)
MVCC:多版本并发控制

MVCC(Multi-Version Concurrency Control)

多版本并发控制,指维护一个数据的多个版本,使得读写操作没有冲突
notion image
上图想要知道事务5的两次查询分别是哪个版本的记录,就需要用到MVCC
  • MVCC的具体实现,主要依赖于数据库记录中的隐式字段、undo log日志、readView
    • 隐式字段
      • 在每行数据有三个字段是隐藏的,DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID
      • DB_TRX_ID:最近修改事务ID,记录插入这一条记录或最后一次修改该记录的事务ID,修改会自动+1
      • DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本,用于配合undo log,指向上一个版本
      • DB_ROW_ID:隐藏主键,如果表结构没有指定主键,那么该隐藏字段就会生成
    • readView(读视图)
      • 快照图SQL执行时MVCC提取数据的一句,记录并维护系统当前活跃的事务(未提交的)id。
        • 快照读
          • 简单的select(不加锁)就是快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。
            • Read Committed(读已提交):每次select,都生成一个快照读
            • Repeatable Read(可重复读):开启事务后第一个select语句才是快照读的地方。
        • 当前读
          • 读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们的日常的操作,如:select ... lock in share mode(共享锁),select ... for update、update、insert、delete(排他锁)都是一种当前读
      • ReadView中包含了四个核心字段
        • m_ids:当前活跃(未提交)事务ID集合
        • min_trx_id:最小活跃事务ID
        • max_trx_id:预分配事务ID,当前最大事务ID+1 (因为事务ID是自增的)
        • creator_trx_id:ReadView创建者的事务ID
      • 对于快照读读取的版本就需要结合这四个字段的值+访问规则(满足其中一个可以访问的版本即可)了
      • notion image
      • 例子(RC级别,读已提交,可解决脏读。每次查询重新生成ReadView)
      • notion image
      • 例子(RR级别,可重复读,可解决脏读、可重复读,mysql默认隔离级别,只有第一次执行快照读时生成ReadView,后面复用)
      • notion image
    • undo log(回滚日志)
      • 在insert、update、delete的时候产生的便于数据回滚的日志。
      • 当insert的时候,产生的undo log只在回滚时需要,在事务提交后,可被立即删除
      • 当update、delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除。
      • undo log 版本链,当第一次update/delete操作后,DB_TRX_ID会+1,DB_ROLL_PTR变为指向版本链中第一次被改之前的版本的地址(最初版本),第二次更改同理。(不同的事务或相同事务对同一条记录进行修改,会导致该记录的undolog生成一条记录版本链表,链表的头部是最新的旧记录,链表的尾部是最早的旧记录)
      • notion image

面试问答

事务中的隔离性如何保证的呢?(解释一下MVCC)
  • mysql 中的多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突
  • MVCC分了三块内容:隐藏字段、undolog、readView
    • 隐藏字段
      • trx_id(事务id),记录每一次操作的事务id,是自增的
      • roll_pointer(回滚指针),指向上一个版本的事务版本记录地址。
    • undo log
      • 回滚日志,存储的是老版本数据
      • 版本链:多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表
    • readView解决的是一个事务查询选择版本的问题。
      • 根据readView 的匹配规则和当前的一些事务id判断该访问哪个版本的数据
      • 不同隔离级别快照是不一样的,最终访问的结果不一样
        • RC(读已提交):每一次执行快照读时生成ReadVIew
        • RR(可重复读):仅在事务中第一次执行快照读时生成 ReadVIew,后续复用
        • notion image

Mysql主从同步原理

如何进行同步,同步的原理是什么?

  • Mysql主从复制的核心是一个二进制日志
    • 二进制文件(BINLOG)记录了所有的DDL(数据定义语言)语句和DML(数据操纵语言),但不包括数据查询(SELECT、SHOW)语句。
  • 过程
    • Slave从节点的IOthread会去读取Master主节点的binlog文件(里面记录的是索引的DDL和DML语言),读取完后写入到自己的relay log中继日志中,再由SQLthread读取relay log中的操作并执行
    • 一:Master主库在事务提交时,会吧数据变更记录在二进制文件Binlog中
    • 二:从库读取主库二进制日志文件Binlog,写入中继日志Relay Log
    • 三:Slave从库重做中继日志的事件,将改变反应它自己的数据。

面试问答

notion image

分库分表

主从复制分担了访问压力,但不能解决海量存储数据的存储问题

分库分表的时机:

  • 前提,项目业务数据逐渐增多,或业务发展比较迅速(单表数据达到1000W,或超过20G)
  • 优化已解决不了性能问题
  • IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

拆分策略

  • 垂直拆分
    • 垂直分库:以表为依据,根据业务将不同表拆分到不同库中。
    • notion image
    • 特点
      • 按业务对数据进行分级管理、维护、监控、扩展
      • 在高并发下,提高磁盘IO和数据量连接数
    • 垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同的表中。
      • 拆分规则(一般是拆在同一个数据库下)
        • 把不常用的字段单独放在一张表
        • 把text,blob等大字段拆分出来放在另一张表。
        • notion image
      • 特点
        • 冷热数据分离
        • 减少IO过度争抢,两表互不影响
  • 水平拆分
    • 水平分库
    • 水平分表

框架篇

主要包含Spring和Mybatis
notion image
Spring
  • Spring
    • Bean线程安全问题
    • AOP、事务原理、事务失效
    • Bean的生命周期
    • 循环依赖
  • SpringMVC
    • 执行流程
  • SpringBoot
    • 自动配置原理
Mybatis
  • 执行流程、延迟加载、一二级缓存

Spring

Spring中的单例Bean是线程安全的吗?(答案:不是线程安全的)

成员方法需要考虑线程安全
  • 但对于Spring bean并没有可变的状态的类型(如Service类和DAO类),他们是线程安全的
    • 也就是说无状态(不可变/不能被修改)的变量和方法是线程安全的
    • 但如果在bean中定义了可修改的成员变量,那么就要考虑线程安全(一个线程在另一个线程改变这个数据过程读取就会产生脏数据)问题,可以使用加锁来解决
Spring框架中的bean是单例的
  • singleton:bean在每个Spring IoC容器中只有一个实例
  • prototype:一个bean的定义可以有多个实例。

面试问答

notion image

什么是AOP,你们项目有没有使用到AOP?(对AOP的理解,有没有真的用过)

AOP(Aspect-Oriented Programming)称为面向切面编程,用于讲那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为"切面"(Aspect),减少系统重的重复代码,降低了模块间的耦合度,同时提高了系统的稳定性。
常见的AOP使用场景
  • Spring的事务管理
  • 记录操作日志
    • 把记录操作日志的逻辑代码抽取出来封装,供多个服务使用
    • 例子:
      • 当用户去使用新增用户功能时,我们需要记录日志,就可以使用AOP提供的环绕通知@Around("pointcut()"),在里面做一个切面(通用代码的封装),在用户请求遇到了使用切面的时候,就会去找切点表达式pointcut()上面的内容:@Pointcut("@annotation(com.itheima.annotation.Log)")
        • @Pointcut是切点表达式,用来表示进入切面的途径方法
        • @annotation(com.itheima.annotation.Log)就是注解的意思,括号里面的内容是一个自定义的注解,Log注解里面只定义了一个name,用来区分调用切面增强了业务的模块
        • 如果某一个类方法加了@Log注解,那么就会去执行对应的切面方法进行业务增强。
  • 缓存处理
    • 给业务加缓存也有耦合的情况,也可以抽取出来封装,然后使用AOP的切面,拦截需要添加缓存的业务方法

Spring中的事务是如何实现的?

也是使用AOP去实现的,有两种方式
  • 编程式事务控制:需使用TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用
  • 声明式事务管理:声明式事务管理建立在AOP之上的,其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后再根据执行情况提交或者回滚事务。

Spring中事务失效的场景有哪些?

考察对spring框架的深入理解和复杂业务(调用复杂,业务可能会失效)的编码经验
三种场景失效场景:
  • 异常捕获处理
  • 抛出检查异常
  • 非public方法

异常捕获处理

原因
  • 事务通知只有捕捉到了目标跑出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知无法知悉
解决方法
  • 在catch块添加throw new RuntimeException("转账失败")抛出
  • 同时也可以在catch块加一些其他的信息来辅助错误顶我

抛出检查异常

原因
  • Spring默认只会回滚非检查异常,而检查异常(如FileNotFoundException不会自行回滚)
  • 延伸问题:什么是检查异常,什么是非检查异常?
解决方法
  • 在需要更新数据的操作配置一个rollbackFor属性:@Transactional(rollbackFor=Exception.class),只要有异常都会进行回滚。

非public方法导致的事务失效

没有使用public修饰方法,因为底层用aop的cglib来实现,要要创建子类,而非public方法无法重写要实现回滚需要实现的方法

Spring的bean的生命周期(方便调试和解决如spring的循环依赖问题)

Bean创建的流程

BeanDefinition

Spring容器在实例化时,会将xml配置的<bean>的信息封装成一个BeanDefinition对象,Spring根据BeanDefinition来创建Bean对象,里面有很多属性来描述Bean 比如id,class,lazy-init,property

流程

一、调用BeanDefinition的构造函数,实例化当前bean的对象
二、依赖注入,像一些Autowired标明的属性或者是@Value的属性会被注入进IoC容器中
三、Aware接口:BeanNameAware、BeanFactoryAware、ApplicationContextAware,如果Bean实现了这些接口,就要重写里面的方法,从名字就可以看出来
四、BeanPostProcessor#before
Bean后置处理器,一共有两个,这个是在bean初始化方法之前调用
五、初始化方法
有两个
  • InitializingBean
  • 自定义init方法
六、BeanPostProcessor#after
在初始化方法之后调用,进行类增强(如AOP)
  • AOP的底层是动态代理,动态代理有两层
    • JDK动态代理
    • CGLIB动态代理
然后bean对象就被创立了,除了第一步,所有的步骤都是在做初始化赋值(也就是说Bean的创建和初始化赋值是分开的)
七、销毁bean

Spring中的循环引用

notion image
在创建A对象的同时需要使用到B对象,在创建B对象的同时需要使用到A对象

什么是Spring的循环依赖?

在Spring注册Bean时
  • 首先实例化A,在堆中开启内存空间,此时是半成品A(因为bean的生命周期分为创建和初始化赋值)
  • 然后初始化A,设置b属性,b是B类型的对象,需需要到Spring容器查找B对象,判断容器是否存在b,如果存在则直接赋值并返回
    • 但很显然是不存在的,所以要去实例化B,同样在堆中开辟内存空间,生成一个半成品的B
  • 接着初始化B,初始化过程中,B要设置a属性,a是A类型的对象,需要到Spring容器中去查找a,但由于A的对象还是半成品,所以a还不存在,因此又去实例化A,由此死循环...,这就叫循环依赖,或者叫循环引用

如何解决?

三级缓存解决循环依赖
  • Spring解决循环依赖是通过三级缓存,对应的三级缓存如下所示:
notion image
  • 一级缓存,源码名称是singletonObjects,作用是单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
    • 限制bean在beanFactory中只存一份,即实现singleton scope,解决不了循环依赖。
  • 二级缓存,earlySingletonObjects,缓存早期的bean对象(生命周期还没走完)
    • 结合一级和二级缓存仅能解决部分循环依赖问题(因为二级缓存存储的是半成品bean对象,如果遇到了有增强的代理对象,那么被增强的方法无法被初始化使用)
  • 三级缓存,singletonFactories,缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的
    • 结合一级和二级和三级缓存可以解决大部分循环依赖问题,因为三级缓存缓存的是一个抽象工厂,它可以帮助创建bean的代理对象(含增强了的方法)和普通对象,然后使用代理对象去依赖注入生成自己的bean对象,并被其他对象拿去注入生成bean对象。
notion image

构造方法出现了循环依赖如何解决?(三级缓存无法解决)

三级缓存能够解决set方法注入(@Autowired注入,包含bean对象初始化过程)的循环依赖
构造方法出现了循环依赖(主要是在bean的最开始创建过程):
notion image
此时会有一个报错信息:error:Is there an unresolvable circular reference?
解决方法很简单:
  • 直接在构造函数的形参对象前加一个@Lazy(什么时候需要这个bean对象我再去创建,而不是在bean创建的第一步(实例化)就去注入进来)
  • 也就是说,什么时候用,什么时候注入,就能够解决构造方法的循环依赖问题

SpringMVC的执行流程

Spring框架的最核心内容
  • 视图阶段(老旧JSP等)
notion image
  • 前后端分离阶段(接口开发,异步)
notion image

Springboot的自动配置原理

Spring框架最核心的思想

关键注解@SpringBootApplication

自动配置主要依赖的就是这个注解,其包含了另外三个注解:
  • @SpringBootConfiguration
    • 该注解与@Configuration注解作用相同,用来声明当前也是一个配置类
  • @EnableAutoConfiguration
    • SpringBoot实现自动化配置的核心注解
  • @ComponentScan( excludeFilters = {@Filter( type = FilterType.CUSTOM, classes = {TypeExculudeFilter.class}),@Filter( type = FilterType.CUSTOM, classes = {AutoConfigurationExcludeFilter.class})})
    • 组件扫描,默认扫描当前引导类所在包和其子包

面试问答

SpringBoot的自动装配原理是什么?
  • 在SpringBoot羡慕中的引导类上有一个@SpringBootApplication注解,这个注解对三个注解进行了封装,分别是@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan
  • 其中@EnableAutoConfiguration是实现自动化配置的核心注解,该注解通过@Import注解导入对应的配置选择器。内部就是读取了该项目和该项目引用的Jar包的classpath路径下META-INF/spring.factories(里面大概有100多个配置类)】文件中的所配置的类的全类名。在这些配置类中所定义的Bean会根据条件注解所制定的条件来决定是否需要将其导入到Spring的容器中。
    • 例如RedisAutoConfiguration类
    • notion image
    • 其中@Configuration代表这是一个配置类,且不是代理bean对象(没有增强方法)
    • @ConditionalOnClass({类})判断是否存在对应字节码(通过jar包,也就是起步依赖,如果有那么就自动装配下面的Bean对象进Spring容器)
    • @Bean就是要加载进SpringIoC容器的对象注解
    • @ContionalOnMissingBean( name = {"redisTemplate"})判断容器中有没有对应的bean,有就不重复注入了。

Spring框架常见注解

spring的常见注解

@Component、@Controller、@Service、@Repository
  • 使用在类上用户实例化bean
@Autowired
  • 使用在字段上用于根据类型依赖注入
@Qualifier
  • 结合@Autowired一起使用用于根据名称进行依赖注入
@Scope
  • 标注Bean的作用范围
@Configuration
  • 指定当前类是一个Spring配置类,当创建容器时会从该类上加载注解
@ComponentScan
  • 指定Spring在初始化容器时要扫描的包
@Bean
  • 使用在方法上,标注将该方法的返回值存储到Spring容器中
@Import
  • 使用@Import导入的类会被Spring加载到IoC容器中
@Aspect、@Before、@After、@Around、@Pointcut
  • 用于切面编程(AOP)

springMVC常见的注解

@RequestMapping
  • 用于映射请求路径,可以定义在类上和方法上。用于类上,则表示类中的所有方法都是以改地址作为父路径
@RequestBody
  • 注解实现接收http请求的json数据,将json转换为java对象
@RequestParam
  • 指定请求参数的名称
@PathViriable
  • 从请求路径下中获取请求参数(/user/{id}),传递给方法的形式参数
@ResponseBody
  • 注解实现将controller方法返回对象转化为json对象响应给客户端
@RequestHeader
  • 获取指定的请求头数据
@RestController
  • @Controller+@ResponseBody

springboot常见注解

@SpringBootConfiguration
  • 组合了-@Configuration注解,实现配置文件的功能
@EnableAutoConfiguration
  • 打开了自动配置的功能,也可以关闭某个自动配置的选项
@ComponentScan
  • Spring组件扫描

Mybatis

mybatis的执行流程是什么?

理解执行流程可以帮助理解各个组件之间的关系和Sql的执行过程(参数映射,sql解析,执行和结果处理)
  • 首先读取mybatis的配置文件.xml,这里面有两块比较重要
    • 当前的环境配置,比如连接的数据库信息
    • 加载的映射文件,指定映射文件有两种
      • 一种直接指定映射文件的路径
      • 另一种指定映射文件的包名
  • 然后构建会话工厂(SqlSessionFactory,全局只有一个,可以批量去生产sqlSession), 通过sqlSession对象去操作数据库
  • 接着创建会话SqlSession,项目与数据库的会话,包含了执行sql语句的所有方法,每次操作一次回话,有多个
  • Executor执行器,真正执行数据库操作的接口,也负责查询缓存(一级缓存,二级缓存)的维护
  • 读取MapperStatement对象,里面主要包含resource(来自哪个mapper接口的xml)、id(userMapper接口的方法)、sql(sql语句)、resultMaps(返回的结果集)
notion image
  • 最后还要把输入参数和输出结果进行java和数据库类型之间的转换

mybatis是否支持延迟加载

支持,但默认没有开启

什么是延迟加载?

假设有一张用户表和一张订单表,它们之间是1:n的关系,其中用户表有一个List<Order> orderList对象,订单表有一个User user对象
  • 查询用户的时候,把用户所属的订单数据也查询出来,这个是立即加载
  • 查询用户的时候,暂时不查询订单数据,当需要订单的时候,再查询订单,这个就是延迟加载(按需加载)。
延迟加载开启方式:直接在局部的xml文件加上fetchType="lazy",或者在全局配置中,把lazyLoadingEnabled改为true

实现原理

主要使用的是CGLIB创建目标的代理对象完成的。当调用目标方法user.getOrderList()时,进入拦截器invoke方法,发现user.getOrderList()是null值,执行sql查询order列表,把order查询上来然后调用user.setOrderList(List<Order> orderList),接着完成user.getOrderList()方法的调用

mybatis的一级和二级缓存

本地缓存:基于PerpetualCache,本质是一个HashMap
一级缓存:作用域是session级别
二级缓存:作用域是namespace和mapper的作用域,不依赖于session
区别在于不同的作用域,保存数据的时机不一样

一级缓存

基于PerpetualCache的HashMap本地缓存,其存储作用域为Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开的是一级缓存
  • 一级缓存默认打开
  • 作用,也就是说当用户在同一个sqlsession对象存在的时候进行的相同sql语句会只执行一次,后面查询的都是这个对象在一级缓存中保存的数据,减少数据库压力

二级缓存

基于namespace和mapper的作用域起作用的,不是依赖于SqlSession对象,默认也是采用的PerpetualCache HashMap存储
  • 二级缓存默认是关闭的,开启方式有两种
    • 一种是在全局配置中:cacheEnable 改为 true
    • 另一种在映射文件(.xml)中随便一行加上<cache/> 标签让当前mapper.xml生效二级缓存
  • 作用:会把namespace和mapper的作用域保存起来,只要有mapper的包名路径被加载了,那么就会被保存在二级缓存里,后续即使这个sqlSession对象被关闭,只要mapper或者namespace存在,就不会去重复与数据库建立连接和重复执行sql语句
注意事项:
  • 对于缓存数据的更新机制,当某一个作用域(一级缓存Session/二级缓存Namespaces)进行了新增、修改、删除操作后,默认该作用域下所有select中的缓存将被clear
  • 二级缓存需要缓存的数据实现Serializable接口
  • 只有会话提交或者关闭后,一级缓存中的数据才会转移到二级缓存中
mybatis的缓存其实就是用来缓解数据库压力的,一级缓存作用域是sqlsession对象,只要这个对象还存活,那么它所执行的重复sql只执行一次;二级缓存作用域是namespace和mapper包名路径,需手动开启,在全局配置cacheEnabled=true或者在mapper.xml文件中任一行加<cache/>标签,只要有包名路径(也就是sqlsession被创建),那么即使这个sqlsession对象不存在了,它对应的xml方法也只会执行一次和连接数据库一次。但某一个作用域进行了增删改操作,那么查的缓存就都会消失。项目数据访问量不多就没开过二级缓存。

集合篇

集合框架
  • Collection,单列集合
    • List 有序,可重复
      • Vector,数组结构,线程安全(加了锁,一次只能被一个线程访问,降低脏数据风险)
      • ArrayList,数组结构,非线程安全
      • LinkedList,链表结构,非线程安全
    • Set 无序,唯一
      • HashSet 哈希表结构 —— LinkedHashSet 哈希表和链表结构
      • TresSet 红黑树结构
  • Map,双列集合
    • HashTable 哈希表结构,线程安全 —— Properties
    • HashMap 哈希表结构,非线程安全 —— LinkedHashMap 哈希表和链表结构
    • ConcurrentHashMap 哈希表结构,线程安全
    • TreeMap 和HashMap的区别就是有序,红黑树结构

数据结构

数据、链表、二叉树、红黑树、散列表

算法复杂度分析

可以指导自己写出性能更优的代码和评判别人写的代码的好坏

时间复杂度分析,来评估代码的执行耗时的

notion image
假设每行代码的执行耗时都一样是1ms
分析这段代码总执行多少行:3n+3
代码耗时总时间 T(n) = (3n+3)x1ms
notion image
性能速记口诀:常对幂指阶

空间复杂度分析(目前个人计算机的内存都很大了,更不用说服务器的,常规情况下说的复杂度都是说时间复杂度)

全称是渐进空间复杂度,表示算法占用的额外存储空间与数据规模之间的增长关系

List相关

数据结构-数组,ArrayList源码分析,ArrayList底层的实现原理是什么,ArrayList list = new ArrayList(10)种的list扩容几次,如何实现数组和List之间的转换,ArrayList和LinkedList的区别是什么?

数组

是一种用连续的内存空间存储相同数据类型数据的线性数据结构
一般对象(包括数组)的内存开辟都是在堆中,栈中一般保留的是基础数据类型、对象的引用(指针)、方法的参数和局部变量以及调用,堆中保存的是引用数据类型(对象)、静态变量、new创建的对象和关键字

数组如何获取其他元素的地址值?

  • 通过一个寻址公式:a[i] = baseAddress + i * dataTypeSize
    • baseAddress:数组的首地址
    • dataTypeSize:代表数组中元素类型的大小,int型的数据,dataTypeSize=4个字节

为什么数组从0开始,从1开始不行吗?

因为如果从1开始,寻址公式就要变成 a[i] = baseAddress + (i-1) * dataTypeSize
这样CPU就要多一次减法操作,从0开始相当于是一种优化,这样子对于上亿次的计算优化效果是很好的

操作数组的时间复杂度(查找)

随机查询(根据索引查询)
  • 数据元素的访问是通过下标来访问的,计算机通过数组的首地址和寻址公式能够很快速的找到想要访问的元素
  • 效率最高,时间复杂度O(1)
未知索引查询(不知道索引),查找数组内的元素
  • 不排序,直接遍历找,时间复杂度O(n)
  • 排序,利用二分查找,时间复杂度O(logn)

操作数组的时间复杂度(插入、删除)

数组是一段连续的内存空间,因此为了保证数组的连续性,就会使得数组的插入和删除的效率变的很低。
  • 最好的情况下是O(1)的,最坏的情况是O(n)的,平均情况下的时间复杂度是O(n)。

ArrayList源码分析

基于jdk8

成员变量

用的最多的属性就是 transient Object[] elementData;(元素数组) 和 private int size;(元素数量),DEFAULT_CAPACITY = 10(初始容量是10)

构造方法(构造函数)

三个
  • ①ArrayList(int initialCapacity)、②ArrayList()、③ArrayList(Collection<? extends E> c)
    • ①带初始化容量的构造函数
    • ②无参够惨函数,默认创建空集合,在添加数据的时候会有容量
    • ③将collection对象转换成数组,然后将数组的地址的赋给elementData
    • notion image

添加和扩容操作(第一次添加数据)

notion image
第一次添加数据,走add(E e)方法
  • 其中add方法的第一行是确保内部容量,ensureCapacityInternal(int minCapacity),minCapacity会把当前集合的大小+1传入去与初始化的默认长度10进行对比取最大值(如果minCapacity的值比10大那么取的值就是minCapacity,反之则是10赋值给minCapacity)
    • 确保和计算完容量后,如果minCapacity(最小可行值)- 元素长度 > 0,则说明容量不够,需要扩容。这个minCapacity又会被扩容后的长度(增加原来容量的1.5倍,newCapacity = oldCapacity + (oldCapacity >>1),元素长度+元素长度二进制右移一位(除以2))减去,也就是newCapacity - minCapacity,如果<0,也就是扩容后仍小于初始化长度,那么第一次添加的容量就是minCapacity,接着就是把初始化容量后的长度赋值给elementData,也就是数组拷贝。

添加和扩容操作(第二次至十次添加数据)

第2至10次添加操作,由于第一次添加操作已经把元素长度的最小可行值变为了10,那么后续添加操作的最小可行值 - 元素长度都是>0的,因此就直接添加,无需扩容。

添加和扩容操作(第十一次添加数据)

第11次操作,由于数组长度是10,当进行第11次添加时,minCapacity = size + 1 = 11,因此最小可行值 - 元素长度>0 ,需要进行扩容,首先扩容是增加原来容量的1.5倍,因此元素长度就由10变为了15,然后最小可行值11与元素长度相比取更大值,取完后就把长度更大的数组赋值给原来的数组(数组拷贝)。

ArrayList底层的实现原理是什么?

ArrayList底层是使用一个动态数组实现的
ArrayList初始化容量为0,第一次添加数据的时候才会初始化容量为10
ArrayList进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
ArrayList进行添加数据的时候:
  • 要确保数组已使用长度size + 1(最小可行值minCapacity)足够存下下一个数据
  • 计算数组的容量,如果最小可行值 - 数组长度 > 0(也就是最小可行值大于数组长度),则调用grow 方法进行扩容(1.5倍)
  • 确保新增的数据有地方存储之后,则将心元素添加到位于size的位置上
  • 返回添加成功布尔值

ArrayList list = new ArrayList(10) 中的list扩容几次?

该语句只是声明和实例了一个ArrayList,指定了容量为10,为扩容(如果不为10也只是声明和实例,指定容量不扩容,只有添加操作在确保容量不够时才扩容)

如何实现数组和List之间的转换

数组转List

只需要调用JDK种java.utils.Arrays工具类的静态方法Arrays.asList(字符串数组)

List转数组

也只需要调用List对象的方法list.toArray(new String[list.size]),需要注意的是toArray的参数是需要实例一个List它的对象的元素类型的数组,参数是list的长度。
  • 也就是说无参toArray方法返回的是Object数组,传入初始化长度的数组对象,返回该对象数组

追问,1.如果用Array.asList转List后,如果修改了数组内容,list会瘦影响吗 | 2.如果List用toArray转数组后,如果修改了List内容,数组会受影响吗?

对于1,数组转List,由于Arrays.asList方法的核心代码a = Objects.requireNonNull(array)是直接把引用地址给了List(如果为空报错,不为空把对象的引用给a),而不去重新为List开辟一个空间创建对象,因此数组转list当数组内容改变时,List也会跟着受影响,主要就是因为转换是使用的引用地址(指针)
对于2,List转数组,由于List对象的方法list.toArray核心代码是System.arraycopy(源元素,源开始位置,目标元素,目标开始位置,拷贝大小),是直接拷贝一个新的数组出来,也就是说他们是两块不一样的堆区域,不相互影响

链表

首先了解LinkedList,它的底层不是数组,而是链表,链表分为单向链表和双向链表

单向链表

链表中的每一个元素称之为结点(Node)
物理存储单元上,非连续、非顺序的存储结构
单向链表:每个结点包括两个部分,一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域,记录下一个结点地址的指针叫作后继指针next。
  • 如果要获取链表中的某个结点B的下一个结点C,直接使用B.next
notion image

单向链表时间复杂度分析

查询操作,只有在查询头结点时候不需要遍历链表,时间复杂度是O(1),查询其他结点需要遍历链表,时间复杂度是O(n),整体时间复杂度是O(n)
新增和删除,只有添加和删除头结点的时候不需要遍历链表,时间复杂度是O(1),添加删除其他结点的时间复杂度是O(n),因为你要先定位到其位置才能添加和删除,定位就需要去遍历链表查询,总体时间复杂度是O(n)

双向链表

顾名思义,支持两个方向
  • 每个结点不止有一个后继指针next执行后面的结点
  • 还有一个前驱指针prev指向前面的结点
notion image

对比单链表

双向链表需要额外的两个空间去存储后继结点和前驱结点的地址(更加占用空间)
支持双向遍历,这样也带来了双向链表操作的灵活性(LinkedList底层就是双向链表)

双向链表时间复杂度分析

查询操作
  • 查询头尾结点的时间复杂度是O(1)
  • 平均的查询时间复杂度是O(n)
  • 给定节点找前驱节点的时间复杂度为O(1)
增删操作
  • 头尾节点增删的时间复杂度为O(1)
  • 其他节点的增删时间复杂度是O(n)
  • 给定节点增删的时间复杂度为O(1)

ArrayList和LinkedList的区别是?*

①底层数据结构、②效率、③空间、④线程是否安全

①底层数据结构

ArrayList是动态数组的数据结构实现
LinkedList是双向链表的数据结构实现

②操作数据的效率

ArrayList按照下标查询的时间复杂度是O(1)【内存是连续的,根据寻址公式】,LinkedList不支持下标查询
查找(未知索引):ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)
新增和删除
  • ArrayList尾部插入和删除,时间复杂度都是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
  • LinkedList头尾节点增删时间复杂度都是O(1);其他都需要遍历链表,时间复杂度都是O(n)

③内存空间占用

ArrayList底层是数据,内存连续,节省内存
LinkedList是双向链表需要存储数据,和两个指针,更占用内存。

④线程安全

ArrayList和LinkedList都不是线程安全的
如果需要保证线程安全,一般有两种方案:
  • 在方法内使用,局部变量则是线程安全的
  • 使用线程安全的ArrayList和LinkedList
    • 如使用Collections.synchronizedList()包装它们

HashMap相关

底层数据结构:二叉树、红黑树、散列表(hash table,哈希表)

二叉树

每个节点最多有两个子节点,不过二叉树并不要求每个节点都要有两个子节点,可以只有单个左子节点或者右子节点
Java中有两个方式实现二叉树:数组存储、链式存储

基于链式存储的二叉树:

notion image

二叉树的分类

满二叉树、完全二叉树、二叉搜索树、红黑树

二叉搜索树,Binary Search Tree,BST

又名二叉查找树,有序二叉树或排序二叉树,是二叉树中比较常用的一种类型
二叉搜索树要求在树中的任意一个节点,其左子树中的每个节点的值都要小于这个节点的值,右子树节点的值都要大于这个节点的值

二叉搜索树时间复杂度分析

二叉树查找都要根据根节点处开始查找,其查找次数和当前层数有关,第一层2的0次,第二层2的1次,第三层2的2次...每次要查找只需要查找当前层的指数,例如查找第二层的数据就只需要找1次,因此时间复杂度是O(logn),插入删除也要先查找,因此也是O(logn),但是对于极端情况(全是在左子树或右子树),那就变成了链表,链表查找的时间复杂度O(n)

红黑树,Red Black Tree,*

也叫一种自平衡的二叉搜索树(BST),之前叫平衡二叉B树(Symmetric Binary B-Tree)

红黑树的特质

1.节点也么是红色,要么是黑色
2.根节点是黑色
3.叶子节点都是黑色的空节点
4.红黑树中红色节点的子节点都是黑色
5.从任一节点到叶子节点的所有路径都包含相同树木的黑色节点
  • 这样,在去添加或删除节点的时候,如果不符合这些性质就会发生旋转,以达到所有的性质(也就是保证平衡)

口诀:左根右,根叶黑,不红红,黑路同

红黑树的时间复杂度

查找,红黑树也是一颗BST(二叉搜索树),查找操作的时间复杂度是O(logn)
添加,添加先要从根节点开始找到元素添加的位置,时间复杂度为O(logn),添加完成后涉及到复杂度O(1)的旋转调整操作
  • 整体复杂度为O(logn)
删除,首先从根节点开始找到被删除元素的位置,时间复杂度为O(logn),删除完成后涉及到复杂度为O(1)的旋转调整操作
  • 整体复杂度为O(logn)

散列表(Hash Table,哈希表)

在HashMap底层最重要的一个数据结构就是散列表,散列表使用到了红黑树和链表
散列表(Hash Table)又名哈希表,是根据键(key)直接访问在内存储存位置值(value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性
  • 将键(key)映射为数组下标的函数叫作散列函数。可以表示为:hashValue = hash(key)
    • 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标
    • 如果key1=key2,那么经过hash后得到的哈希值也必定相同,即:hash(key1) == hash(key2)
    • 如果key1!=key2,那么经过hash后得到的哈希值也必定不相同,即:hash(key1) != hash(key2)
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一千块,这就是哈希冲突(散列冲突、哈希碰撞,就是指多个key映射到同一个数组下标位置,也就是有误码率)
notion image
  • 解决这个哈希冲突的办法:拉链(链表)法,在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)都会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
notion image

哈希冲突-拉链法(链表)- 时间复杂度

插入操作,通过哈希函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度是O(1)
查找、删除,同样通过哈希(散列)函数计算出对应的槽(桶),然后遍历链表查找或者删除
  • 平均情况下基于链表法解决冲突时查询的时间复杂度为O(1)
  • 如果一个槽下面的数据量够多,哈希表可能会退化成链表,查询时间就从O(1)退化成了O(n)
  • 将链表法中的链表改造为其他高校的动态数据结构如红黑树,查询的时间复杂度就是O(logn)
    • 而且将链表改成红黑树后还能够防止ddos攻击,链表长度大于8之后就会转为红黑树
      • DDos攻击,分布式拒绝服务攻击(Distributed Denial of Service),指处于不同位置的多个攻击者同时向一个或多个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个

HashMap的实现原理

HashTable的优化,有数组变成链表数组从而解决哈希冲突,链表的数据结构是红黑树
底层使用的是hash表数据结构,即数组和链表,当链表长度大于8时会转化成红黑树,优化时间复杂度(O(n)—》O(logn))同时防止DDos攻击
  • 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
  • 存储时,如果出现hash值相同的key,此时有两种情况
    • 如果key相同,则覆盖原始值
    • 如果key不同(出现冲突),则当前key-value放入链表或红黑树(当链表长度>8并且数组长度大于等于64时)中,扩容resize()时当红黑树拆分的数的节点数小于等于临界值6个,则会退化成链表
  • 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值

HashMap的put方法的具体流程

HashMap源码分析 - 常见属性

DEFAULT_INITIAL_CAPACITY,默认初始容量(1<<4,也就是16)
DEFAULT_LOAD_FACTOR,默认的加载因子(0.75)
  • 扩容阈值 == 数组容量 * 加载因子,16 * 0.75 = 12
HashMap.Node<K,V>[] table,Node它是HashMap中的一个内部类,它里面有hash(相当于索引)、key(键)、value(值)、next(转化为红黑树后的下一个节点地址),键值对是泛型(可以为多种包装类型)

构造函数

  • HashMap是懒惰加载,在创建对象时并没有初始化数组
  • 在无参的构造函数中,设置了默认的加载因子是0.75

添加数据流程图

notion image

put方法的具体流程

判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
根据键值key计算hash值得到数组索引
判断table[i] == null , 条件成立,直接新建节点添加
如果table[i] == null,不成立
  • 判断table[i]的首个元素是否和key一样,相同则直接覆盖value
  • 判断table[i]是否为treeNode,即table[i] 是否为红黑树,如果是红黑树,则直接在树中插入键值对
  • 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转化为红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在则直接覆盖value
插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,则进行扩容。

讲一件HashMap的扩容机制

扩容的流程

第一次扩容是HashMap懒加载添加数据时,判断旧容量是否大于0,第一次肯定是0,因此执行初始化容量操作(设置数组容量大小为16,阈值是*0.75,12),新建数组,完成。
后续添加(已经初始化完成),扩容方法再被调用就是要满足大于阈值,就会在原来的容量基础上乘2,赋值新容量给新数组,接着遍历旧数组,把旧数组中的数据拷贝至新数组,拷贝过程有讲究
  • 先是判断e.next是否是null,如果是null,则直接把当前的数据添加到新数组中(添加方式:被添加的节点拿到它的哈希值,对新数组长度取模,就是它在新数组的下标位置,这是没有链表和没有红黑树的情况,且当前某一个下标只有一个元素)
  • 如果不是null,则判断一下是否是红黑树,如果是的话,则走红黑树的添加逻辑,如果不是红黑树,则取遍历链表,取出链表中的每一个元素进行判断(拆分链表)
    • 拆分链表:被添加的节点先拿到它的哈希值和旧数组的容量是否是0,如果旧数组容量为0,取出旧数组的数据,直接添加在新数组,如果旧容量不等于0(也就是链表中存在数据),那么它就添加在新数组的下标加上老的容量值的位置
    • notion image

HashMap扩容机制

在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度*0.75)
每次扩容的时候,都是扩容之前容量的2倍;
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
  • 没有hash冲突的节点,则直接使用e.hash&(newCap - 1)计算新数组的索引位置
  • 如果是红黑树,走红黑树的添加
  • 如果是链表,则需要遍历链表,可能需要拆分链表,判断旧数组是否有容量,如果没有(第一次扩容)则元素的位置就停留在原始位置,如果有就移动到旧数组的容量+新数组的下标位置上

HashMap的运算动画演示:hash-demo,黑马课程资料

需要在jdk14以上的环境,在jar包目录,执行下面的命令:
notion image

HashMap的寻址算法

当我们调put方法时,会先通过key去计算一个hash值,然后通过hash值就能找到数组的索引(但是有hash冲突,用链表法+红黑树解决),这个hash值是怎么来的呢?
  • 其实在计算hash值的时候,它在计算过程又调用了一个哈希方法(二次哈希)
  • hash值计算方法会先去调用一个key.hashCode()方法,此时就能得到一个int类型的哈希值,在获取到int类型的哈希值后又去右移了16位,接着又与原哈希值做了一个异或运算,为什么这么麻烦呢?
    • 最大的作用就是让哈希值足够均匀,减少hash冲突(扰动算法),如果没有这个那就会只存在于几个下标里,那这样比如1000个数组,下标0是400多个下标1是600多个元素,那么这个红黑树就老长了
在计算完hash值和传递key,value等参数后,就会调用putVal方法
注意事项:数组的长度必须是2的n次幂(也就是为什么hashMap懒加载第一次是16,然后是以2的倍数扩容),否则按位与运算无法计算出正确的填充下标(取模可以,但是效率没有按位与好)
  • 不仅是按位与(只能计算2的n次幂)比取模运算效率更高,还有扩容时重新计算索引效率更高,因为在扩容的时候,列表的节点拷贝需要通过hash & oldCap == 0(按位与运算) 判断,如果为真,则元素留在原来的位置,否则新位置就会等于旧位置 + 旧容量

总结

1.hashMap的寻址算法
  • 计算对象的hashCode()
  • 再进行调用hash()方法进行二次哈希,hashCode值右移16位再异或运算,让哈希分布更均匀。
  • 最后(capacity - 1) & hash 得到索引
2.为何HashMap的数组长度一定是2的次幂?
  • 计算索引时效率更高:2的n次幂可以使用按位与运算代替取模
  • 扩容时重新计算索引效率更高:hash & oldCap == 0 的元素留在原来的位置,否则判断新位置 = 旧位置 + 旧容量

多线程相关

线程的基础知识

  • 线程与进程的区别、并行与并发的区别、线程的创建方式有哪些、runnable和callable有什么区别、线程包括哪些状态,状态之间是如何变化的、在java 中wait和sleep方法的不同、新建三个,如何保证它们按顺序执行、notify()和notifyAll()有什么区别、线程的run()和start()有什么区别、如何停止一个正在运行的线程
线程中并发安全
  • synchronized关键字的底层原理、谈谈JMM(java内存模型)、CAS你知道吗、什么是AQS、ReentrantLock的实现原理、synchronized和Lock有什么区别、死锁产生的条件是什么、如何进行死锁诊断、请谈谈你对volatile的理解、聊一下ConcurrentHashMap、导致并发程序出现问题的根本原因是什么
线程池
  • 说一下线程池的核心参数(线程池的执行原理知道吗)、线程池中有哪些常见的阻塞队列、如何确定核心线程数、线程池的种类有哪些、为什么不建议用Executors创建线程池
使用场景
  • 线程池使用场景(你们项目中哪些使用到了线程池)、如何控制某个方法运行并发访问线程的数量、谈谈你对ThreadLocal的理解

线程与进程的区别

进程:程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载到CPU,数据加载至内存,在指令运行过程中还需要用到网络、磁盘等设备,进程就是用来加载指令、管理内存、管理IO的。
  • 如打开一个谷歌浏览器或者文本文档,这就是一个进程(当一个程序被允许,从磁盘加载到程序的代码至内存,就开启了一个进程)
    • 上面讲到的都是多实例进程,此外还有单实例进程(也就是只能打开一个的客户端、如企微)
线程:就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行,一个进程之内可以分为多个线程
notion image

二者对比

进程是正在运行程序的实例,进程包含了线程,每个线程执行不同的任务
不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
线程更轻量,线程上下文切换成本一般要比进程上下文切换成本更低(上下文切换是指从一个线程切换到另一个线程)

并行和并发的区别

分单核CPU执行和多核CPU执行

单核CPU

线程实际还是串行执行的,也就是说对于都分配了时间片的线程1、2、3,它们只能被依次执行(一次执行一个)
操作系统中有一个组件叫作任务调度器,它将CPU的时间片(windows下时间片最小约为15毫秒)分给不同的程序使用,只是由于CPU在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。
总结一句话就是,微观串行,宏观(感觉上)并行
一般将这种线程轮流使用CPU的做法称为并发(concurrent)

多核CPU

这时候每个核(core)都可以调度运行线程,真正做到并行,也就是说对于都分配了时间片的线程1、2、3,核心1可以去执行1、3,同时核心2可以去进行线程2这就是并行

区别

并发(concurrent)是同一时间应对多件事情的能力
并行(parallel)是同一时间动手做多件事情的能力

创建线程的方式有哪些

四种方式:继承Thread类、实现runnable接口、实现callable接口、线程池创建线程

继承Thread类

写一个类的同时继承Thread类,重写run()方法(非必须),在主类去实例化写的类就可以调用start方法启动线程

实现runnable接口

写一个类的同时实现Runnable接口,必须重写run方法,在主类去实例化Thread类的同时把写的类的实例化对象当做参数传递,就可以调用start()方法启动线程了。

实现Callable接口

与runnable接口的创建方式类似,但多了泛型、FutureTask等,适合需要再执行完线程后就去拿结果的场景
写一个类的同时实现Callable接口,需在Callable<String>携带泛型,必须重写call方法(有返回值,返回值就是泛型的类型,前面两创建方式的run方法都无返回值),接着实例化一个FutureTask<String>的同时把写的类的实例化对象当做参数传递,然后再把这个FutureTask<String>的对象传递给new Thread(futrueTask),就可以调用start方法了,线程的返回结果通过futureTask.get()获取。注意泛型都需要匹配。

线程池创建线程

和runnable接口的创建方式类似,唯一的区别就是只需要提交任务就可以执行这个线程。
写一个类的同时实现runnable接口,必须重写run方法,在主类就直接实例化ExecutorService threadPool = Executors.newFixedThreadPool(3); 接着调用threadPool.submit(new 实现了runnable的类)提交线程方法,把写的类的实例化对象当做参数传递,就可以执行线程了。注意需要关闭线程池threadPool.shutdown()

使用runnable和callable都可以创建线程,区别是什么?

Runnable接口run方法没有返回值
Callable接口call方法有返回值,是个泛型,和Future、FutureTask搭配可以用来获取异步执行的结果
Callable接口的call()方法运行抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不继续往外抛(但可以内部使用try catch处理)

在启动线程时可以使用run方法吗?run()和start()有什么区别?

start()方法才是真正的去开启线程,通过该线程调用run方法执行其定义的逻辑代码,且start方法如果是同一线程只能被调用一次,不能同时调用多次
run()方法封装了要被线程执行的方法,使用它的话就是执行方法,不启动线程,可以同时被调用多次

线程包括哪些状态,状态之间如何变化的

状态类型可以参考JDK中的Thread类中的枚举State,其中有new(尚未启动,创建)、RUNNABLE(可运行,就绪)、BLOCKED(阻塞等待监视器锁)、WAITING(等待)、TIMED_WAITING(具有指定等待时间的等待)、TERMINATED(死亡)

变化过程

创建线程对象(实例化NEW过程,新建)——》有执行资格但没有执行权(等待调度,就绪)——》抢到CPU执行权,有执行资格和执行权(运行)——》运行完成,线程死亡变成垃圾(死亡),其中就绪和运行这两个阶段是可执行状态(RUNNABLE)。
  • 其中还有阻塞状态是在将运行过程发现已有线程在运行且加了锁,那么它就是有执行资格,没有执行权(无法获得锁、阻塞),在获得锁之后就变成就绪
  • 还有一种就是等待状态,在线程运行过程遇到了wait()方法,线程就会进入到等待状态,直到其他线程有调用notify()方法,这样就会把等待线程给唤醒,变成就绪状态
  • 最后一种就是时间阻塞状态,就是当线程的方法调用了sleep方法,睡眠时间完成后就变成了就绪状态
notion image

新建T1、T2、T3三个线程,如何保证它们按顺序执行?

可以使用线程中的join方法解决,join()方法作用是等待线程运行结束,如在t2的run方法里调用了t1.join,t2线程的状态就变成timed_waiting,直到线程t1执行完成后,线程才继续执行,join也就是插队

notify()和notifyAll()有什么区别

notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个wait线程

Java中wait和sleep方法的不同

共同点

wait(),wait(long)和sleep(long)的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态

不同点

方法的归属不同
  • sleep(long) 是Thread的静态方法
  • 而wait(),wait(long)都是Object的成员方法,每个对象都有
醒来的时机不同
  • 执行sleep(long)和wait(long)的线程都会在等待相应毫秒后醒来
  • wait(long)和wait()还可以被notify唤醒,wait()如果不被唤醒就一直等待下去
  • 它们都可以被打断唤醒
锁特性不同(重点)
  • wait方法的调用必须先获取wait对象的锁(必须搭配synchronized使用),而sleep则无此限制
  • wait方法执行后会释放对象锁,允许其他线程获得(我放弃CPU,你们可以用了),
  • 而sleep如果synchronized代码块中执行,并不会释放对象锁(我放弃CPU,但你们还不能用)

如何停止一个正在运行的线程

三种方式
  • 使用退出标志(while+flag标志),使线程正常退出,也就是当run方法完成后把flag改成true给while循环信号终止
  • 使用stop方法强行终止,但此方法已作废,不推荐
  • 使用interrupt方法中断线程
    • 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出InterruptException异常。
    • 打断正常的线程,当调用interrupt方法时,会把isInterrupted的布尔值改为true,这样就可以根据打断状态来标记是否退出线程

线程中并发安全的问题

synchronized关键字的底层原理

首先synchronized都是对象锁,最经典的抢票案例,如果不加锁,可能会有多个用户(线程)同时来抢最后一张票,而且成功,就导致了超卖的问题,加上锁,每次只能允许一个用户来抢票,其他来抢票的用户会被阻塞,就能解决超卖问题

底层原理

其实synchronized底层就是一个Monitor(监视器),想要查看到有关Monitor的代码信息,直接在加锁代码是看不出的,需要去查看加锁代码对应的字节码文件,可以使用javap -v xx.class,然后我们就可以看到这个class文件对应的汇编信息
  • 汇编是一个机器语言,它可以更加形象的看到Java代码执行过程,在汇编信息中有一个monitorenter,它是加锁开始的地方,也就是上锁,下面有一个monitorexit,它是对象锁结束的地方,也就是解锁,它们之间就是被锁住的代码(只允许一个线程访问),再下面还有一个monitorexit,为什么要释放两次锁呢?
    • 因为在synchronized底层它使用了一个隐式的try finally,以防在上锁和解锁之间的正常代码抛了异常,不走第一个解锁代码,所以在finally留了一手释放锁代码

Monitor,监视器

由jvm提供,C++语言实现,那么它是如何工作的呢?
首先了解Monitor的结构,它有三层,分别是WaitSet、EntryList、Owner
  • 因为lock是一个对象锁,monitor首先会让这个lock对象锁和monitor关联,判断monitor的Owner是否为空,如果为null,则让当前这个线程直接拥有这个lock(这个Owner只能关联一个对象锁),若此时再来一个lock(线程2),由于线程1的lock已经与Owner绑定了,所有Owner不为空,后面来的lock(线程2)就去EntryList等待。
    • 只要Owner判断出来不为空,那么后面来的线程锁都需要在EntryList进行等待,此时他们的状态都是阻塞状态
    • 当第一个线程执行完,lock被释放,也就是Owner为空了,那么在EntryList阻塞状态的锁就会争夺Owner(CPU),谁关联了Owner,谁就拿到了锁,就可以执行
    • 当一个线程被调用了wait方法,就会进入到WaitSet中处于等待状态
    • notion image

总结

Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
它的底层由monitor实现的,monitor是jvm级别的对象(C++实现),线程获得锁需要使用对象(锁)关联monitor
在monitor内部有三个属性,分别是owner、entryList、waitSet
其中owner是关联的获得锁的线程,并且只能关联一个线程;entryList关联的是出于阻塞状态的线程;waitSet关联的是出于Waiting状态的线程

synchronized底层原理-进阶,Monitor实现的锁是重量级锁,你了解过锁升级吗?

monitor实现的锁是重量级锁(性能不高),因为monitor是C++实现的,Java对象需要关联monitor,就涉及到了用户态(自己的Java代码,资源权限较低)和内核态(CPU层面的东西,权限较高)的切换,进程的上下文切换,CPU成本较高,性能比较低
  • 在JDK1.6之后引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入就是为了解决在没有多线程竞争或基本没有竞争的场景下使用传统锁机制带来的性能开销问题。(没有多线程竞争就可以用偏向锁或轻量级锁,如果有那就必须使用monitor重量级锁)

lock对象是如何关联上重量级锁monitor的?

首先需要熟悉一下java对象的内存结构
  • 在HotSpot虚拟机中,我们创建的对象都在堆中,对象在内存中的存储布局可分为三块区域:对象头(Header)、实例数据(Instance Data)和对其填充
    • 对象头又包括MarkWord、Klass Word;MarkWord里面描述了lock对象是如何与monitor关联的,Klass Word描述的是对象实例的具体类型
    • 实例数据存储的就是成员变量的数据
    • 对齐填充,如果(对象头+实例变量)不是8(字节)的整数倍,则通过对其填充一些无意义的数据补齐,因为hotspot虚拟机要求内存的起始地址必须是八的倍数

lock对象关联重量级锁Monitor的关键就是其在堆内存所占三区域之一的对象头中的MarkWord(剩下两个区域是实例数据和对齐填充,对象头中还有一个Klass Word,描述的就是当前对象的类型,User类,Order类等等)

因为Jvm虚拟机分为32位和64位,在32位的虚拟机中,Mark Word的锁占位是这样分布的:
notion image
第一行描述的是一个普通的对象(没有上锁的对象),每个对象都有一个hashCode值,它在mark word中占用25个bit位,age就是对象的分代回收年龄,在堆内存中,对象又分为了新生代和老年代,这个位置就是对象在新生代中挪动的次数,如果挪动超过了一定阈值(4次),就会挪去老年代。
  • biased_lock是偏向锁的标识位,0代表不开启偏向锁,1代表开启,最后两位01代表锁的标识,占两位,如果后面三位bit位是001,表示无锁状态,如果是101则表示偏向锁
第二行是一个偏向锁,它的第一列是线程id,占23位,第二列是epoch,表示偏向锁的时间戳占两位,age是和上面一样的
第三行是一个轻量级锁,最后两位是00标识,前面的30位都是轻量锁的记录
第四行是一个重量级锁,最后两位是10标识,前面的30位是一个指针,它指向的就是重量级锁需要关联的monitor,也就是说它和monitor关联是因为这个对象的对象头markword中记录了monitor的地址

轻量级锁

在很多情况下,java程序运行时,同步块中的代码都是不存在竞争的,不同线程的交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念
  • 例如某线程访问的方法1和方法2使用的是同一把锁,而访问的方法1又调用了方法2,这就会出现锁重入(同一把锁上两次),这时候就没必要使用重量级锁了
首先在使用轻量级锁时,我们先看一下锁对象的内存结构(和上面一样,对象头、实例数据、对齐填充数据)
  • 例如锁obj,当没有线程执行代码的时候,它就是一个无锁状态,就有对象头(markword(hashcode age 0 01),klass word(Object类型)),实例数据(object body),对齐填充数据(不展示)
notion image
  • 假如来了一个线程,它需要去执行method1方法,在线程执行的时候,就会先创建一个锁记录,叫lock record,在每个线程的栈中都包含了一个锁记录的结构(包含两部分:锁记录的内存地址和锁对象的地址,由object reference去指向锁对象),有什么作用呢?
    • 当其他线程来持有锁时,这个线程就要去修改obj这个对象的markword,这里主要用到CAS对锁记录的数据和markword数据进行交换
      • CAS可以理解为保证在修改数据的时候,它是一个原子操作
    • 如果交换成功了,那么对象头的markword就存储了锁记录的地址和状态00,表示是由该线程拥有了这个对象锁
      • 如果交换失败,有两种情况
        • 可能会有多个线程竞争,这时候就不能使用轻量锁了,就会直接自动升级为重量级锁(一但发生竞争,就都会升级成重量级锁)
        • 还有一种情况就是当前锁重入了,就会在这个线程的栈中再添加一条lock record作为重入的计数,lock record有几个就代表这个锁重入了几次
          • 注意每重入一次就需要进行CAS操作,CAS操作也就是比较并替换,线程在更新lock record值之前会先和预期值比较,如果比较结果不一样则进行替换。由于第一次已经把lock record添加到了锁对象中,所以第二次只需添加一条记录即可(锁地址为null),但是它依然需要CAS操作
      • 解锁的情况,在解锁时,需要先判断当前的锁记录地址是否位null,如果有null代表有重入,那么我们就需要去重置当前的锁记录的地址(也就是释放重复的记录地址,然后计数-1),在执行到lock record的锁记录地址不为null时,它会再来一次CAS操作,把锁对象的hashcode地址 age 0 01和Object的 lock record 地址 00 替换,就释放了锁

总结

轻量级锁的加锁流程:
  • 在线程栈中创建一个lock record,将其Object reference字段指向锁对象地址
  • 通过CAS指令将Lock record的地址存储在对象头中的mark word中(交换),如果对象处于无锁状态则修改成功,代表该线程获取了轻量级锁
  • 如果在线程持有该锁的情况下重复去执行带有该锁对象的方法,那么这就是一次锁重入,锁重入会继续添加一个lock record,但是其内存地址为null,次数+1.
  • 如果CAS修改失败,说明发生了竞争,则膨胀为重量级锁,这就是jdk1.6之后新增的轻量级锁特性
解锁流程:
  • 遍历线程栈,找到所有object reference地址指向是当前锁对象的lock record
  • 如果lock record的mark word为null,代表这是一次重入,将这个对象设置为null后continue
  • 如果lock record的mark word不为null,则利用CAS指令将对象头的mark word恢复成无锁状态,如果失败则膨胀成重量级锁

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然要执行CAS操作。
而偏向锁就解决了这个重复做CAS操作的问题,只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程id是自己就表示不是竞争,就不重新CAS,但在JDK18之后偏向锁被废弃

总结

重量级锁就是需要额外执行用户态(C++对象转Java对象的代码交互)和内核态(CPU层的操作)以及进程的上下文切换问题,性能较低但能着实保证数据只背被一个线程获取,所以jdk6之后引入了轻量级锁(可以锁重入,对没有竞争的锁对象方法只根据CAS操作把锁地址记录和对象的对象头中的mark word关联保护数据,但只要有竞争,就会膨胀成重量级锁)和偏向锁(对同一锁对象的方法重复执行只执行一次CAS(比较和交换的原子性操作)操作)

JMM(Java Memory Model,java内存模型)知道吗

定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
JMM把内存分成了两块,一块是工作内存,另一块是主内存
  • 每个线程创建时都会给它分配一个工作内存,并且每个线程只能访问自己的,线程A无法去访问线程B的工作内存,就算他们执行的是同样的代码;工作内存中的数据是对每个线程私有的,线程之间是没有办法相互访问的,此时也可以拿到一个结论:工作内存直接的数据是线程安全的
  • 主内存里面有很多共享变量,共享变量里面有java的实例对象,成员变量,数组等,每个线程都能够对共享变量进行访问。因此主内存存储的共享变量可能会有线程安全问题
    • 多个线程想要同步数据怎么实现?
      • 只能通过主内存去进行同步(相当于一个线程的数据改变,另一个线程想要获取,就必须通过主内存的共享变量来同步)
      • notion image

CAS你知道吗?

CAS全称是Compare And Swap,比较再交换,它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
  • 乐观锁和悲观锁
    • 乐观锁(网购秒杀):默认大家不会抢同一件商品,你点击购买会在付款时检查库存是否足够。例如100个人同时抢购最后一件商品,系统会先让所有人进入购买页面,但最终只有第一个付款的人能成功,其他人会显示库存不足,
      • 实现:数据库用版本号(version字段),java中用 AtomicInteger 原子类(底层是CAS,比较再交换机制)
    • 悲观锁(像上厕所排队):默认一定会有人和你抢,你进去后就锁门,其他人只能排队等
      • 实现:数据库的 SELECT FOR UPDATE,Java中的 synchronizedReentrantLock
    • 对比总结
      • 乐观锁:先操作,发现冲突再处理;适合读多写少(如点赞功能)
      • 悲观锁:先上锁,再操作;适合写多读少(如银行转账)
      • 网购抢单不锁单(乐观锁),厕所排队必须等(悲观锁)

CAS数据交换流程

还是上面那张图一个当前内存值V(int a=9),旧的预期值A(int a=9),即将更新的值B(a++),当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true(CAS,比较再替换),否则什么都不做,并返回false,如果其他线程CAS操作失败,将通过自旋的方式等待并再次重试,直到成功。
什么是自旋?
  • 伪代码:while(true){ int 旧值A = 共享变量V; int 结果B = 旧值 + 1; if(compareAndSwap(旧值,结果)){ //成功,退出循环 } }
  • 也就是如果某线程在修改内存中的数据失败时,会开始自旋,把内存中的数据线读取之后再进行操作并对比,这个也叫自旋锁
    • 自旋没有加锁,线程不会进入阻塞,效率较高
    • 但如果竞争激烈,重试频繁发生,效率就会受影响,所以有时候就会设置阈值,重试了几次后就放弃

CAS底层实现

依赖于一个Unsafe类来直接调用操作系统底层的CAS指令
这个类提供了3个native(本地方法,都是有系统提供的,不是Java实现的,是有C或C++实现的)方法完成CAS操作,
CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,即使改了也没关系,我吃点亏再重新尝试几次(自旋)
Synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们别想改,我改完了解开锁你们采有机会。

谈谈你对volatile的理解

volatile是一个关键字,它可以修饰主内存中的共享变量(类的成员变量、类的静态成员变量),修饰后具备两层语义
  • 保证线程间的可见性
    • 用volatile修饰共享变量,能够防止JIT即时编译器去优化代码,让一个线程对共享变量的修改对另一个线程可见(通过内存屏障实现可见,另一个线程撞到墙就知道前面有人在换衣服了)
    • 也就是说,对于一个线程需要看到另一个线程对共享变量的修改,就在前者变量前加上volatile,这样就能够防止JIT(即时编译器)优化把变成优化成true等情况。
  • 禁止进行指令重排序
    • 用volatile修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
    • 指令重排序最初是为了提高程序的执行效率,但没想到多线程情况下会对最终结果有部分影响,所以要利用volatile添加在变量前,防止指令重排序,就不会出现错误的数据。
      • 写操作加的屏障是阻止上方其他写操作越过屏障排到volatile变量写之下
      • 读操作加的屏障是阻止下方其他读操作越过屏障排到volatile变量读之上

volatile使用技巧

  • 写变量让volatile修饰的变量在代码的最后位置
  • 读变量让volatile修饰的变量在代码的最开始位置
注意,如果加错位置可能导致volatile的第二个作用禁止指令重排序无效(第一个是阻止JIT即时编译器优化)
notion image
注意2:volatile不保证原子性,只保证可见性和有序性。
如果需要原子性,应使用java.util.concurrent.atomic包中的原子类,如AtomicInteger,或使用synchronized关键字。

什么是AQS

AQS全称AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架。(JUC中提供的一种锁机制)
Synchronized与AQS对比
  • Synchronized
    • 关键字,C++语言实现
    • 悲观锁,自动释放锁
    • 锁竞争激烈都是重量级锁,性能差
  • AQS
    • java语言实现
    • 悲观锁,手动开启和关闭
    • 锁竞争激烈情况下,提供了多种解决方案
AQS常见的实现类
  • ReentrantLock 阻塞式锁
  • Semaphore 信号量
  • CountDownLatch 倒计时锁

AQS-基本工作机制

首先有一个变量private volatile int state; (volatile修饰,保证了多个线程直接的可见性),0表示无锁,1表示有锁
一个线程1想要获取锁,首先要把state由默认值0改为1,若此时另一个线程2来获取这把锁,但由于该锁的state是1(被获取),线程2就会进入到AQS的FIFO队列等待,同时线程3也来获取锁,同样去FIFO队列等待
  • AQS的FIFO队列(First In First Out,先进先出的双向队列,内部是一个双向链表)。在AQS还有两个属性
    • 一个是执行队列的头部head,另一个是执行尾部的tail
    • 当线程1执行完成,它就会把state改成0,同时会唤醒队列中的head元素,让它去持有锁,这就是一个简单的排队效果
和力扣LRU题目类似(手搓LRU是一个面试题)

多个线程共同去抢这个资源如何保证原子性呢?

对于同时进来的多个线程抢夺一个资源,到底谁能够抢成功,AQS内部是怎么处理的?
答案是CAS(CompareAndSwap,比较再交换)设置state状态,保证操作的原子性
  • 假如线程0和线程4同时来抢一个资源,此时AQS的FIFO队列中还有三个线程再等待,线程0利用CAS设置state后,线程4就采用CAS的自旋机制修改旧值然后丢进FIFO队列中,同时tail指针指向它

AQS是公平锁还是非公平锁?

可以实现公平锁也可以实现非公平锁,在 ReentrantLock 中,默认是非公平锁。这是因为非公平锁的性能通常更高,可以减少线程切换的开销。
  • 新的线程与队列中的线程共同来抢资源,是非公平锁(插队锁)
  • 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁(自觉排队)

总结

  • AQS 本身不定义公平性,具体行为由子类实现。
  • ReentrantLock 中,默认是非公平锁,但可以通过构造函数选择公平锁。
  • 非公平锁的性能通常更高,但公平锁可以避免线程饥饿问题。

ReentrantLock的实现原理

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
  • 可中断、可以设置超时时间、可以设置公平锁、支持多个条件变量、和synchronized(非公平锁)一样,都支持重入

底层原理

ReentrantLock主要利用CAS+AQS队列来实现。支持公平锁和非公平锁(默认非公平锁,因为线程切换少,性能高)
通过构造方法接受一个可选的公平参数,当设置为true时,表示公平锁,否则为非公平锁。在许多线程访问的去看下,公平锁表现出较低的吞吐量

NonfairSync,非公平锁

notion image
继承自AQS
  • 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
  • 假如修改状态失败,则会进入到双向队列中等待,head指向队列头部,tail尾部
  • 当exclusiveOwnerThread为null时,则会唤醒在双向队列中等待的线程
  • 公平锁则体现在按照先后顺序获取锁,非公平锁体现在新来的线程可以插队抢锁

Synchronized和Lock有什么区别?

语法层面

  • synchronized属于关键字,源码在jvm中,用c++语言实现
  • Lock是接口,源码由jdk提供,用java语言实现
  • 使用synchronized时,退出同步代码块会自动释放,而使用Lock时,需要收到调用unlock方法释放锁

功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
  • Lock提供了喜多synchronized不具备的功能,例如公平锁(ReentrantLock的构造函数传一个true就可以开启公平锁,默认是非公平锁)、可打断(获取锁对象是通过lockInterruptibly()方法的,可以使用interrupt()方法去打断获取锁)、可超时(通过调用tryLock(时间,时间单位)方法获取锁可以设置超时时间)、多条件变量(和notify()、notifyAll()对应,先创建condition对象(可多个,如c1,c2),然后再用c1.await()方法去走条件,如果使用了c1.signal(),就唤醒了c1的条件锁,如果在线程3调用了两次c1.signal,那么他就和notify一样了,随机唤醒一个signalAll()唤醒所有 和notifyAll一致
  • Lock有适合不同场景的实现,如ReentrantLock,RentrantReadWriteLock(读写锁,读一般不需要加锁,写操作需要,这个的并发量更高)

性能层面

  • 在没有竞争时,synchronized作何很多优化,如偏向锁、轻量级锁、性能不赖
  • 在竞争激烈时,Lock的实现通常会提供更好的性能

死锁产生的条件是什么?

死锁:一个线程需要同时获取多把锁,这时候就容易发生死锁,如线程t1持有A锁等待B锁,线程t2持有B锁等待A锁,这时程序没有结束,这种现象就是死锁现象

如何进行死锁的诊断?

当程序出现了死锁现象,我们可以用jdk自带的工具:jps和jstack检查
  • jps:输出JVM中运行的进程状态信息,直接在控制台输入jps,可以查看java所有的进程信息,第一列进程id,第二列进程描述
  • jstack:查看java进程内线程的堆栈信息,直接在控制台输入jstack -l 进程id,这样就可以在最后面看到Found 1 deadlock,其上面有描述死锁信息,可能是代码逻辑出问题,就去改代码逻辑
其他解决工具,可视化工具:
  • jconsole,用于对jvm的内存,线程,类的监控,是一个基于jmx的GUI性能监控工具
    • 打开方式:java安装目录bin目录下直接启动jconsole.exe就行
  • VisualVM,故障处理工具,能够监控线程,内存状况,查看方法的CPU时间和内存中的对象,已被GC的对象,反向查看分配的堆栈
    • 打开方式,java安装目录bin目录下直接启动jvisualvm.exe就行

聊一下ConcurrentHashMap

HashMap线程不安全,ConcurrentHashMap线程安全
ConcurrentHashMap是一种线程安全的高效Map集合
底层数据结构:
  • JDK1.7底层采用分段的数据+链表实现
  • JDK1.8之后采用的数据结构跟HashMap1.8的结果一样,是数据+链表(当链表>8 数组>=64时转换成红黑树)
    • 其放弃了1.7中Segment臃肿的设计,数据结构跟hashMap的数据结构一样:数组+红黑树+链表,采用CAS+Synchronized来保证并发安全进行实现
      • 其采用CAS添加新节点,谁先比较成功谁就交换,后比较的进入一个双向队列,失败的线程会先进行自旋,CAS一定次数还不成功后就放弃,且采用了synchronized锁定链表或红黑树的首节点

导致并发程序出现问题的根本原因是什么?/Java程序中怎么保证多线程的执行安全

Java并发和并行的区别

并发(Concrrent)是指同一时刻应对多种情况的能力
并行(parallel)是指同一时刻动手做多件事的能力

Java并发编程三大特性(保证多线程的执行安全)

  • 原子性
    • 一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不执行失败
    • 实现方式:
      • synchronized:同步加锁
      • JUC里面的lock:加锁
  • 可见性
    • 让一个线程对共享变量的修改对另一个线程可见
    • 实现方式
      • 加锁(性能不高)
      • 在共享变量前使用volatile关键字(解决根源问题JIT(即时编译器)优化
  • 有序性
    • 指令重拍:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序的结果是一致的
    • 实现方式
      • 在共享变量前加一个volatile即可阻止指令重排
      • 读变量让volatile修饰的放在最前面
      • 写变量让volatile修饰的放在最后面

线程池相关

说一下线程池的核心参数/线程池的执行原理

一个有七个核心参数(参考ThreadPoolExecutor类的构造方法)
  • corePoolSize,核心线程数目,线程池中主要执行任务的数量
  • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目),救急线程(临时线程)数目等于最大线程 - 核心线程数
  • keepAliveTime 生存时间 - 救急线程的生存时间(也就是救急线程的生存时间),生存时间内没有新任务,此线程资源会释放;也就是说,如果没有活跃任务,救急线程的生存时间超过了生存时间,那么救急线程就会被释放,但是核心线程数一直不会被释放
  • unit 前者参数的时间单位,如秒、毫秒等
  • workQueue 阻塞队列,当没有空闲核心线程时,新来任务(线程)会加入到此队列排队,队列满会创建救急线程执行任务
  • threadFactory 线程工厂,可以定制线程对象的创建、设置线程名字、是否是守护线程等
  • handler 拒接策略,当所有线程都在繁忙,workQueue也满时,会触发拒绝策略

执行原理

线程来,判断核心线程是否已满,没满就添加到工作线程并执行,已满则进入到阻塞队列判断是否已满,如果阻塞队列没满,则添加任务到阻塞队列中等待,已满则判断线程数是否大于最大线程数,没大于则创建救急线程执行任务,如果大于了则走拒绝策略处理
  • 如果核心或者临时(救急)线程执行完成任务后会检查阻塞队列中是有需要执行的线程,如果有,则使用核心线程或救急线程去(检查者)执行任务
  • 拒绝策略有四种
    • AbortPolicy:直接抛出异常,默认策略
    • CallerRunsPolicy:用调用者所在的线程来执行任务,用主线程去执行当前的任务;
    • DiscardOldestPolicy:丢弃阻塞队列中来的最久的任务,并进入到阻塞队列中;
    • DiscardPolicy:直接丢弃任务。

线程池中有哪些常见的阻塞队列

workQueue 阻塞队列,当没有空闲的核心线程时,新来的任务(线程)会加入到此排队,队列满后会创建救急线程执行任务,常见的阻塞对应有:
  • ArrayBlockingQueue:基于数组结构的有界(在创建队列时可以给一个容量的大小)阻塞队列,FIFO。
  • LinkedBlockingQueue:基于链表(单向链表)结构的阻塞队列,FIFO。
  • DelayedWorkQueue:是一个优先级队列,它可以保证每次出队任务都是当前队列中执行时间最靠前的
  • SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移除操作。

ArrayBlockingQueue和LinkedBlockingQueue的区别

ArrayBlockingQueue
  • 强制有界
  • 底层是数组
  • 提前初始化Node数组
  • Node需要是提前创建好的
  • 一把锁(入队和出队公用一把锁,效率低一点)
LinkedBlockingQueue
  • 默认无界(不给容量大小默认取Integer的最大值),支持有界
  • 底层是链表
  • 是懒惰的,创建节点时添加数据
  • 入队会生成新的Node
  • 两把锁(头尾,入队和出队各一把锁,互不影响,效率高一点)

如何确定核心的线程数

当前 应用程序可以分为两种类型
IO密集型任务,核心线程数大小设置为2N+1,N是当前CPU的核数
  • 一般来说:文件读写、DB读写、网络请求等
CPU密集型任务,核心线程数大小设置为N+1,因为计算型任务需要占用大量CPU,尽量减少线程间的切换来增加效率
  • 一般来说:计算型代码、Bitmap转换、Gson转换等

面试问答

  • 如果当前项目是一个高并发、执行时间短的类型,那么就认为它是CPU密集型认为,核心线程数设置为CPU核数+1,减少线程上下文的切换,提高效率
  • 如果当前项目是一个并发不高、任务执行时间长的类型,同样考虑两种情况,一是IO密集型任务,核心线程数设置为(CPU核数*2 +1),二是计算密集型任务,核心线程数设置为(CPU核数+1)
  • 如果当前项目是一个并发高、业务执行时间还长,那么解决这种类型的任务关键不在于线程池而在于整体架构的设计了,看看这些业务里面某些数据是否能够做缓存是第一步,增加服务器是第二步或者去对业务解耦优化,再考虑线程池中核心线程数的设置,参考上面的

线程池的种类有哪些

在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见有四种
  • 创建使用固定线程数的线程池
    • public static ExecutorService newFixedThreadPool(int nThreads) {return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()):}
    • 对于上面这段代码,核心线程数与最大线程数一样,没有救急线程
    • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
      • 适用于任务量已知,相对耗时的任务
  • 单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
    • public static ExecutorService newSingleThreadExecutor() {return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()):}
    • 核心线程数和最大线程数都是1,
    • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
      • 适用于按照顺序执行的任务
  • 可缓存线程池
    • public static ExecutorService newCachedThreadPool() {return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>()):}
    • 核心线程数为0
    • 最大线程数是Integer.MAX_VALUE
    • 阻塞队列为SynchronousQueue:不存储元素的阻塞队列*一个一个的出队入队),每个插入操作都必须等待一个移除操作。
      • 适用于任务数比较密集,但每个任务执行时间较短的情况
  • 提供了“延迟”和“周期执行”功能的ThreadPoolExecutor,通过调用Excetors.newScheduledThreadPool(核心线程数)对象的schedule(线程对象,延迟时间,时间单位)

为什么不建议使用Executors创建线程池

参考阿里开发手册《Java开发手册》
线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
  • Executors返回的线程池对象的弊端如下:
    • FixedThreadPool和SingleThreadPool 允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量请求,从而导致OOM(Out Of Memory,内存溢出)
    • CachedThreadPool允许创建的线程数量为Integer.MAX_VALUE,可能会堆积大量请求,从而导致OOM(Out Of Memory,内存溢出)

多线程的使用场景

你们项目哪里用到了多线程?(CountDownLatch、Future)

CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
  • 其中构造参数用来初始化等待计数值
  • await()用来等待计数归0
  • countDown()用来让计数减一

多线程使用场景1(es数据批量导入)

项目上线之前,需要吧数据库中的数据一次性同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(OOM异常),就可以使用线程池的方式导入和利用CountDownLatch来控制,就能避免一次性加载过多,防止OOM(内存溢出)
notion image

多线程使用场景2(数据汇总)

在一个电商网站中,用户下单后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
  • 在实际开发的工程,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能

多线程使用场景3(异步调用)

为了避免下一级方法影响上一级方法(性能考虑),可以使用异步线程调用下一个方法(不需要下一级方法返回值),这样可以提升方法响应时间,比如搜索的同时去另外开一个线程去添加搜索记录

谈谈你对ThreadLocal的理解

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程脚本从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享
案例:
  • 使用JDBC操作数据库的时候,会将每个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的Connection上进行数据库的操作,避免A线程关闭了B线程的连接,使多个线程间互相隔离

底层实现

ThreadLocal本质就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离。ThreadLocal下面有一个ThreadLocalMap实现类,每个线程都有这个对象,它里面为每个线程维护了一个Entry数组table(存储数据,大小默认16,和hashMap一样)
对于set()方法
  • 首先拿到当前线程,然后去获取其中的ThreadLocalMap对象(里面就包含了ThreadLocal存储的数据(entry数组对象)),如果map存在,就不创建键值了,直接把值进行覆盖存储。如果不存在则创建ThreadLocalMap,并存放值
createMap()方法
  • 调用构造函数创建的ThreadLocalMap对象,t是线程类Thread对象。这里的this是ThreadLocal对象,因为这个方法是在ThreadLocal类里面定义的

你知道ThreadLocal的内存泄漏问题吗?

Java对象中的四种引用类型:强引用、弱引用、软引用、虚引用

  • 强引用:最为普遍的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收,如 User user = new User() 就是强引用
  • 弱引用:表示一个对象处于可能有用且非必须的状态,在GC线程扫描内存区域时,一但发现弱引用,就会回收到弱引用相关联的对象,对于弱引用的回收,无关内存区域是否足够,一但发现则会被回收,如 User user = new User(); WeakReference weakReference = new WeakReference(user); 后者被发现就会被回收
总结:强引用在GCRoot引用链属于不回收的部分,弱引用是GC扫描到就回收,软引用是内存不够就回收

ThreadLocal内存泄露问题

每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference,其对象创建方式的第一个形参key就是使用弱引用的ThreadLocal实例,value是线程变量的副本。new ThreadLocalMap(ThreadLocal<?> firstKey,Object firstValue)
  • 但其中的value是强引用,由于强引用在GCRoot引用链属于不回收的部分,而弱引用ThreadLocalMap对象在GC扫描内存空间就会被回收,长久以来,value越积越多,就会泄露
  • 防止内存泄露的解决方法也很简单,在调用完ThreadLocal方法存储给某个变量后,就使用remove方法,把强引用对象和弱引用对象都清掉

面试问答

notion image
 
相关文章
实习专项----超体日记
Lazy loaded image
软考专项:下午题 #1 试题一 数据流图
Lazy loaded image
软考专项:上午题 #8 设计模式
Lazy loaded image
软考专项:上午题 #7 面向对象
Lazy loaded image
软考专项:上午题 #6 数据库
Lazy loaded image
软考专项:上午题 #5 知识产权
Lazy loaded image
实习专项----超体日记软考专项:下午题 #1 试题一 数据流图
Loading...
目录
0%
poze624
poze624
天行健,君子以自强不息
公告
🎉Poze小站已经上线🎉
--- 哈喽 ---
👏欢迎浏览阅读👏
待补充(欢迎关注我的公众号)
notion image
 
目录
0%