Java面试题八股文(上)【!!!超全七万字归纳详解各种面试题型,值得收藏!!!】

文章目录

1.JVM内存结构

  • 程序计数器
  • Java 虚拟机栈
  • 本地方法栈
  • 方法区

程序计数器

  • 功能:每个线程都有一个程序计数器,它可以看作是当前线程正在执行的字节码指令的地址。由于JVM是多线程的,程序计数器是线程私有的,线程切换时不会影响它的内容。
  • 作用:程序计数器用于控制线程的执行流程,是线程上下文的一部分。每个线程都有一个独立的计数器。

java虚拟机栈

  • 功能:每个线程都有一个独立的虚拟机栈,用于存储方法的局部变量、操作数栈、动态链接、方法返回地址等信息。

  • 结构

    • 每个方法在调用时会创建一个栈帧,栈帧包括:局部变量表、传递的参数、操作数栈、方法返回地址等。
    • 局部变量表:用于存放方法的输入参数和局部变量(包括基本数据类型和引用类型)。局部变量表是按顺序分配的,每个局部变量都有一个索引位置。基本数据类型(int、float、long等)和对象引用()会存储在这里。
    • 操作数栈:用于执行字节码指令时存放的中间结果。
    • 方法返回地址:指向方法执行完毕后返回的地方。
  • 内存管理虚拟机栈是线程私有的,它的生命周期与线程的生命周期一致,栈的大小可以通过JVM参数进行调整。

本地方法栈

  • 功能:与虚拟机栈类似,但是它专门用于处理Java中的本地方法(native methods),即通过JNI(Java Native Interface)调用的C/C++方法。
  • 作用:存放本地方法的相关信息,如操作系统栈和C/C++代码的执行状态。

  • 功能:堆是JVM中最大的一块内存区域,用于存放所有的对象实例和数组。几乎所有的Java对象(包括数组)都分配在堆内存中。

  • 结构

    年轻代(Young Generation):存放新创建的对象,通常包括Eden区和两个Survivor区(S0和S1)。对象会在年轻代内存中经过多次垃圾回收后晋升到老年代。

    老年代(Old Generation):存放经过多次GC回收仍然存活的对象。通常是长期存在的对象,例如长时间存在的缓存、大对象等。

    永久代/元空间(Metaspace)存放JVM加载的类信息、方法信息等元数据。永久代在JDK 8之后被元空间取代,元空间是存储在本地内存中的,而不是堆内存。

方法区

  • 功能:存储类的结构信息,如类名、常量池、字段、方法等数据
  • 结构:方法区包括了类信息、静态变量、常量池等信息。方法区在JDK8之后已经被“元空间”(Metaspace)替代,元空间存储在本地内存中,不再是堆内存的一部分。

【额外】

jvm组成:

  • ClassLoader类加载器:加载class字节码文件中的内容到内存中
  • 运行时数据区域:负责管理jvm使用到的内存,如:创建对象、销毁对象
  • 执行引擎:将字节码文件中的指令解释成机器码,同时使用即时编译器优化性能
  • 本地接口:调用本地已经编译的方法,比如虚拟机中的c/c++的方法

堆和方法区是所有线程共有的,而虚拟机栈,本地方法栈和程序计数器则是线程私有的。

2.垃圾回收机制GC

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

是Java虚拟机(JVM)自动管理内存的一个重要部分。GC的目的是回收那些不再被引用的对象,从而释放内存,防止内存泄漏,并提高系统的性能。

GC触发的条件有两种:

  • 程序调用System.gc时可以触发;
  • 系统自身来决定GC触发的时机。

垃圾回收的过程

  • 标记阶段:从GC Roots开始,遍历所有可达的对象,标记这些对象为“活跃”。如果一个对象没有被标记为“活跃”,那么就认为它是垃圾对象。
  • 清除阶段:对于那些没有被标记为“活跃”的对象,可以将其销毁并释放内存。
  • 整理阶段(标记-整理算法):回收后,对内存中的存活对象进行整理,防止内存碎片。

垃圾回收算法

  • 标记-清除算法

    步骤

    1. 标记阶段:从GC Roots开始,遍历所有可以到达的对象,标记为“活跃”。
    2. 清除阶段:遍历堆中的所有对象,清除那些没有被标记为“活跃”的对象。

    优点

    • 实现简单,容易理解。

    缺点

    • 清理过程中会产生内存碎片,因为对象可能会分散存放在内存中,导致堆中的空闲区域不连续。
  • 复制算法

    步骤:将内存分为两个区域,每次只使用一个区域。对象从一个区域复制到另一个区域,存活的对象被复制到空闲区域。

    优点

    • 不会产生内存碎片,整个内存区域都是连续的。

    缺点

    • 内存使用率低,通常只有一半的内存空间被利用(需要两倍的内存区域来进行复制)。
    • 对于大对象的复制性能较差。
  • 标记-整理算法

    步骤

    1. 标记阶段:标记出所有存活的对象。
    2. 整理阶段:将存活的对象按顺序移动到堆的一端,并清理出剩余空间。

    优点

    • 不产生内存碎片,空间利用率较高。

    缺点

    • 需要移动对象,增加了内存回收的成本。

3.垃圾回收的调优方法

垃圾回收的效率和停顿时间会对应用性能产生很大影响。常见的调优方法包括:

  • 调整堆的大小和分配:通过-Xms和-Xmx来设置JVM堆的初始大小和最大大小。
  • 选择合适的垃圾回收器:根据应用需求(低延迟、高吞吐量)选择合适的垃圾回收器。
  • 监控GC日志:通过 -Xloggc 打开垃圾回收日志,分析垃圾回收的频率、时间等。
  • 调节GC策略:例如,通过-XX:+UseG1GC选择G1垃圾回收器,或者通过-XX:+UseConcMarkSweepGC选择CMS垃圾回收器。

调整堆的大小和分配

通过-Xms-Xmx来设置JVM堆的初始大小和最大大小。

选择合适的垃圾回收器

根据应用需求(低延迟、高吞吐量)选择合适的垃圾回收器。

  • 串行垃圾回收器 (Serial GC):适用于单核或内存小的环境,采用单线程进行回收,适合小型应用。
    • 启用方式:-XX:+UseSerialGC
  • 并行垃圾回收器 (Parallel GC):通过多个线程进行垃圾回收,适合多核机器上的大多数应用。
    • 启用方式:-XX:+UseParallelGC
  • 并发标记清除垃圾回收器 (CMS GC):通过多个线程并发执行回收,减少停顿时间,适用于低延迟要求的应用。
    • 启用方式:-XX:+UseConcMarkSweepGC
  • G1垃圾回收器 (G1 GC):适用于大内存应用,目标是实现可预测的停顿时间,适合高吞吐量和低延迟的应用。
    • 启用方式:-XX:+UseG1GC
  • ZGC (Z Garbage Collector):是一种低延迟垃圾回收器,旨在减少暂停时间,适用于大内存、高并发应用。
    • 启用方式:-XX:+UseZGC
  • Shenandoah GC:也是一个低延迟垃圾回收器,类似于ZGC,但适用于不同的环境。
    • 启用方式:-XX:+UseShenandoahGC

监控GC日志

通过 -Xloggc 打开垃圾回收日志,分析垃圾回收的频率、时间等。

  • 启用 GC 日志:-Xlog:gc*(适用于 Java 9 及以上版本)
  • 生成 GC 日志到文件:-Xlog:gc*:file=gc.log

调节GC策略

例如,通过-XX:+UseG1GC选择G1垃圾回收器,或者通过-XX:+UseConcMarkSweepGC选择CMS垃圾回收器。

4.堆和栈的区别

定义与用途

  • 堆(Heap):用于存储对象实例和数组。堆是动态分配内存的区域,所有在Java中通过new关键字创建的对象都会分配在堆中。堆的大小可以通过JVM参数进行设置。
  • 栈(Stack):用于存储方法调用时的局部变量和方法调用的状态信息。每个线程在运行时都会有一个自己的栈空间。栈中的内容包括局部变量、方法调用的返回地址、以及一些中间计算结果。

生命周期

  • 堆的生命周期:堆中的对象生命周期由垃圾回收机制(GC)控制。当对象不再被引用时,垃圾回收器会回收这些对象并释放内存。
  • 栈的生命周期:栈中的局部变量和方法调用信息由方法的调用和返回控制。当方法调用结束后,该方法对应的栈帧就会被销毁,局部变量的内存空间被回收。

内存管理

    • 堆内存的分配是动态的,需要垃圾回收机制来管理内存。
    • 堆内存的对象分配和释放较为复杂,涉及到GC(垃圾回收)的过程,可能会导致停顿。
    • 堆空间通常较大,可以通过JVM参数调整,如-Xms-Xmx来控制堆的初始大小和最大大小。
    • 堆空间的管理可能会导致内存碎片问题,特别是在频繁的内存分配和回收时。
    • 栈内存的分配是静态的,局部变量的空间由JVM自动分配并管理。
    • 栈空间的内存回收非常高效,因为栈的内存分配和释放是按顺序进行的。方法调用结束后,栈帧会被自动销毁。
    • 栈空间较小,通常在几百KB到几MB之间,过多的栈空间分配可能导致StackOverflowError异常。

内存分配方式

    • 堆的内存分配不依赖于栈的顺序,而是动态的,按需分配。
    • 通过new关键字创建的对象和数组会在堆上分配空间。
    • 堆内存的访问速度相对较慢,因为需要通过垃圾回收管理其生命周期。
    • 栈的内存分配是基于栈帧的,方法调用时会分配一个新的栈帧,方法返回时栈帧会被销毁。
    • 栈内存的访问速度非常快,因为栈的内存分配遵循LIFO(后进先出)原则,栈帧的分配和销毁非常高效。

数据存储类型

    • 存储的是Java中的对象(包括实例对象、数组等),这些对象可以在程序的不同方法之间共享。
    • 堆中的对象可以通过引用传递,这意味着对象的生命周期可以跨方法调用。
    • 存储的是局部变量方法调用信息方法的返回地址,这些数据通常只在当前方法的调用过程中存在。
    • 栈中的数据在方法执行完毕后就会被销毁。
    • 对于基本数据类型(如intfloat等),会直接存储在栈上;对于引用类型(如String、对象等),则会存储其引用(指针)。

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

线程与栈

  • 堆是共享的,所有线程都可以访问堆中的对象,因此它是多线程共享的内存区域。
  • :每个线程有自己独立的栈空间,因此栈是线程私有的。

内存大小

    • 堆的大小可以通过JVM参数调整,通常较大(几十MB到几GB)。
    • 对于大内存的对象,堆的大小可能会显得更大,尤其是当对象生命周期较长时。
    • 每个线程的栈空间较小,通常在几十KB到几MB之间。
    • 如果栈空间分配过大或递归调用过深,可能会导致StackOverflowError异常。

线程安全

  • :堆中的对象是共享的,不同线程可以访问相同的对象。因此,如果多个线程访问堆中的对象,通常需要通过同步机制(如synchronized)来保证线程安全。
  • :每个线程有自己的栈空间,因此栈中的数据是线程私有的,不会发生线程安全问题。

GC与栈的关系

  • :垃圾回收器负责管理堆内存中的对象,当对象不再被引用时,GC会回收它们,释放内存。
  • :栈内存的回收非常简单,方法执行完毕,栈帧就会被销毁,局部变量的内存也会被释放,JVM不需要进行特殊的回收操作。

总结

特性 堆(Heap) 栈(Stack)
用途 存储对象和数组 存储方法调用时的局部变量和方法调用信息
生命周期 由垃圾回收器管理 根据方法调用和返回自动管理
分配方式 动态分配 静态分配,遵循LIFO原则
内存管理 通过垃圾回收机制管理 自动管理,方法调用结束后栈帧销毁
存储内容 对象实例和数组 局部变量、方法返回地址等
线程共享 共享的,多个线程可以访问 每个线程都有独立的栈空间
内存大小 较大(几MB到几GB) 较小(几十KB到几MB)
访问速度 相对较慢(GC影响) 快(LIFO分配)
垃圾回收 有垃圾回收机制 无垃圾回收,栈帧自动销毁

5.JVM使用命令

-Xms 和 -Xmx

  • -Xms: 设置 JVM 启动时的初始堆内存大小(单位:字节)。

  • -Xmx: 设置 JVM 最大堆内存大小(单位:字节)。

    java -Xms512m -Xmx1024m MyClass
    

-Xss

  • 设置每个线程的堆栈大小。适用于多线程应用程序中,控制线程的内存使用量。

    java -Xss512k MyClass
    
    

-XX:+PrintGCDetails

  • 打印垃圾回收(GC)详细信息。这对于调试和优化垃圾回收行为非常有帮助。

    java -XX:+PrintGCDetails MyClass
    
    

调试相关命令

  • -Xdebug 和 -Xrunjdwp:启用 JVM 调试功能,这对于远程调试 Java 程序非常有用
java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 MyClass

JVM 监控和分析命令

  • jps:显示当前 Java 进程的信息,包括进程 ID 和启动的主类。
jps
  • jstat:显示 JVM 的性能统计信息,例如堆内存、垃圾回收等。
jstat -gc <pid>
  • jmap:显示或生成堆的转储信息。用于分析内存使用情况。
jmap -heap <pid>
  • jstack:打印当前 JVM 进程的线程堆栈。对于分析死锁和性能问题非常有用。
jstack <pid>
  • jconsole:启动 Java 控制台工具,用于监控和管理 Java 应用程序的运行时行为。
jconsole
  • jvisualvm:启动 VisualVM 工具,用于性能分析、内存分析、垃圾回收分析等。
jvisualvm

JVM 调优示例

java -Xms512m -Xmx2g -XX:+UseG1GC -XX:+PrintGCDetails -Dapp.config=/path/to/config.properties -jar myapp.jar

这个命令做了如下配置:

  • 设置堆内存初始大小为 512MB,最大堆内存为 2GB。

  • 启用 G1 垃圾回收器,并打印垃圾回收的详细信息。

  • 设置系统属性 app.config 为指定的配置文件路径。

  • 执行 myapp.jar 文件。

  • 设置每个线程的堆栈大小。适用于多线程应用程序中,控制线程的内存使用量。

6.class.forName和Classload区别

class.forName

Class.forName是Java反射API中的一个静态方法,用于加载指定名称的类。它会在运行时查找并加载该类,然后返回一个Class对象表示该类。Class.forName方法常用于动态加载类,尤其是在需要反射机制时。

  • 功能Class.forName根据传入的类的全限定名(即包名 + 类名)加载该类。
  • 加载时机Class.forName会立即加载类并初始化类。如果类实现了静态代码块,静态代码块也会在Class.forName调用时执行。
  • 返回值:返回一个Class对象,表示被加载的类。

特点:

  • Class.forName会触发类的加载和初始化,意味着如果类的初始化过程中有静态变量或静态代码块,都会被执行。
  • Class.forName常用于数据库驱动Spring框架Hibernate等需要动态加载类的场景。
  • 如果类没有被找到,ClassNotFoundException会被抛出。

ClassLoader

ClassLoader是Java中的一个类加载器,用于加载Java类。Java的类加载机制是通过ClassLoader来实现的。ClassLoader有不同的实现,比如URLClassLoaderClassLoader的子类等,通常用于从不同的来源(如文件系统、网络等)加载类。

  • 功能ClassLoader通过指定的类路径加载类,loadClass方法用于加载指定名称的类。
  • 加载时机ClassLoaderloadClass方法是延迟加载(懒加载)的,即在你实际使用该类时才会加载它,而不像Class.forName会立即加载类。
  • 返回值:返回一个Class对象,但不会初始化类(即不会执行静态代码块和静态变量的初始化)。

特点:

  • ClassLoader用于自定义类加载的场景,比如从JAR文件、网络或其他非标准路径加载类。
  • ClassLoader加载类时不会自动执行类的初始化。
  • 通常,loadClass方法只负责加载类本身,不会执行静态初始化。如果你想要初始化类,必须手动调用Class.forName

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

特性 Class.forName ClassLoader.loadClass
加载时机 立即加载并初始化类(包括静态代码块) 延迟加载,仅加载类但不初始化
初始化类 会执行类的静态初始化代码(包括静态块和静态变量) 不会执行类的静态初始化代码
常见用途 动态加载类,通常用于框架(如数据库驱动、反射) 自定义类加载器,加载类文件(如从JAR、网络加载)
方法的返回值 返回Class对象并触发初始化 返回Class对象,但不触发初始化
异常处理 如果类未找到,抛出ClassNotFoundException 如果类未找到,抛出ClassNotFoundException
使用场景 需要反射机制或者在运行时动态加载和初始化类 用于自定义类加载器,或从不同来源加载类

何时使用哪一个?

Class.forName

  • 当你需要动态加载并初始化类时,Class.forName是一个方便的选择。特别是当你知道类名并且希望立即加载并初始化该类时(例如数据库驱动类的加载)。

  • 例如,数据库驱动的加载:

    java
    
    复制代码
    Class.forName("com.mysql.cj.jdbc.Driver");
    

ClassLoader

  • 如果你有自定义的类加载需求(例如从非标准路径加载类,或者加载JAR包中的类),则可以使用ClassLoaderClassLoader适用于需要按需加载类,并且不一定要立即初始化类的场景。

  • 例如,从JAR文件中加载类:

    java复制代码
    ClassLoader classLoader = getClass().getClassLoader();
    
    Class<?> clazz = classLoader.loadClass("com.example.MyClass");
    

小结

  • Class.forName用于在运行时加载并初始化类,适用于你已经知道类名并且希望触发类的初始化(包括静态代码块和静态变量)时使用。
  • ClassLoader用于加载类的过程,通常用于自定义类加载器或按需加载类,且不会自动触发类的初始化。

7.谈谈jdk1.8特性有哪些?

jdk1.8新特性知识点:

  • Lambda表达式
  • 函数式接口
  • 方法引用(构造器调用)
  • Stream API
  • 接口中的默认方法和静态方法
  • 时间日期API

Lambda表达式

注意点:

  • Lambda表达式基本作用:可以简化匿名内部类的书写
  • Lambda表达式只能简化函数式接口的匿名内部类的写法
  • Lambda表达式使用前提:接口必须是匿名内部类,接口中只能有一个抽象方法。

格式:

( ) ->{
   
    
    }

():对应着方法的形参
->:固定格式
{
   
    
    }:方法体

代码示例:

Arrays.asList( "a", "b", "d" )
  .sort( ( e1, e2 ) -> e1.compareTo( e2 ) );

//上面等价于下面内名内部类形式
List<String> list = Arrays.asList("a", "b", "c");
Collections.sort(list, new Comparator<String>() {
   
    
    
    @Override
    public int compare(String o1, String o2) {
   
    
    
        return o1.compareTo(o2);
    }
});

函数式接口

  • 接口中只能有一个抽象方法,但是可以有多个非抽象方法的接口
  • 可以有静态方法和默认方法
  • 可以加 @FunctionalInterface 标记,也可以不加
  • 默认方法可以被覆写

可以通过 Lambda 表达式实例化,常见的函数式接口有:

  • Runnable:表示一个可以在不同线程中执行的任务。Runnable 主要用于线程的创建和执行。Runnable 接口不返回任何结果,它的 run() 方法是无返回值的。适用于不需要返回值的并发任务。

  • Callable:类似于 Runnable,但是它可以返回结果。Callable 通常用于执行并发任务时需要返回值或异常处理的场景。Callable 接口有一个抽象方法 call(),返回一个结果,类型由泛型指定。适用于需要返回结果或抛出异常的并发任务。

  • Comparator:用于对象排序的接口,可以定义自定义的排序规则。它允许对一个对象集合进行排序,并且可以按照不同的标准对对象进行比较。可以对不同的属性进行排序,并且一个对象可以有多个排序规则。

    Comparator 接口的 compare() 方法返回一个整数值(通常是负数、零或正数)来表示两个对象的顺序:

    • 如果返回负数,表示第一个对象小于第二个对象。
    • 如果返回零,表示两个对象相等。
    • 如果返回正数,表示第一个对象大于第二个对象。
  • ConsumerConsumer<T> T 作为输入 ,没有输出

  • FunctionFunction<T,R> T 作为输入,返回的 R 作为输出

  • PredicatePredicate<T> T 作为输入 ,返回 boolean 值的输出

方法引用

方法引用使得开发者可以直接引用现存的方法、Java类的构造方法或者实例对象。方法引用和Lambda表达式配合使用,使得java类的构造方法看起来紧凑而简洁,没有很多复杂的模板代码。

方法引用符:::

方法引用:就是把已经有的方法拿过来用,当作函数式接口中抽象方法的方法体。

注意点:

  • 需要有函数式接口
  • 被引用方法必须已经存在
  • 被引用方法的形参和返回值需要跟抽象方法保持一致
  • 被引用方法的功能要满足当前需求
public class Test {
   
    
     
  public static void main(String[] args) {
   
    
    

        //需求:创建一个数组,进行倒序排列
        Integer[] arr = {
   
    
    3, 5, 4, 1, 6, 2};

        //1.匿名内部类形式
        Arrays.sort(arr, new Comparator<Integer>() {
   
    
    
            @Override
            public int compare(Integer o1, Integer o2) {
   
    
    
                return o2-o1;
            }
        });

        //2.lambda表达式形式
        //因为第二个参数的类型Comparator是一个函数式接口
        Arrays.sort(arr,(o1 ,o2)->{
   
    
    
            return  o2- o1;
        });

        //3.lambda表达式简写形式
        Arrays.sort(arr,(o1 ,o2)-> o2- o1);

        // 4.方法引用
        //表示引用Test类里面的静态subtraction方法
        //把这个方法当做抽象方法的方法体
        Arrays.sort(arr,Test::subtraction);

      	//输出数组
        System.out.println(Arrays.toString(arr));
    }
    //静态subtraction方法
    public static int subtraction(int num1 ,int num2){
   
    
    
        return num2 - num1 ;
    }
  

Stream API

用于处理集合(如 List、Set 等)支持对集合的批量数据进行筛选、排序、映射、统计等操作。通过 Lambda 表达式配合 Stream API,可以写出简洁且易于理解的代码。

 public static void main(String[] args) {
   
    
    
//单列集合==default Stream<E> stream() == Collection中的默认方法

        // 创建单列集合
        ArrayList<String> list = new ArrayList<>();
        // 添加元素给集合
        Collections.addAll(list,"a","b","c");

        // // 1.获取Stream流
        // Stream<String> stream1 = list.stream();
        // // 循环打印Stream流的每个元素
        // stream1.forEach(new Consumer<String>() {
   
    
    
        //    @Override
        //    public void accept(String s) {
   
    
    
        //       s:依次表示流水线上的每一个数据
        //        System.out.println(s);
        //    }
        // });


    // 2.获取Stream流并循环打印每个元素
    list.stream().forEach(s -> System.out.print(s+" "));
    //a b c

    }

Stream流中间方法:

1.中间方法,返回新的Stream流,原来的Stream流只能使用一次,建议使用链式编程
2.修改Stream流中的数据,不会影响原来集合或者数组中的数据
方法名 说明
Stream filter(Predicate<? super T> predicate) 过滤
Stream limit(long maxSize) 获取前几个元素
Stream skip(long n) 跳过前几个元素
Stream distinct() 元素去重,依赖(hashCode和equals方法)
static Stream concat(Stream a,Stream b) 合并a和b两个流为一个流
Stream map(Function<super T, R> mapper) 转换流中的数据类型
map(Function mapper ) 将元素转换成其他形式或提取信息。
sorted() 对元素进行排序

Stream流终结方法:

方法名 说明
void forEach(Consumer action) 遍历
long count() 统计
toArray() 收集流中的数据,放到数组中
collection(Collection collection) 收集流中数据,放到集合中

collect(Collector collector) 收集流中的数据,放到集合中 (List 、Set、 Map)

如果我们要收集到Map集合当中,键不能重复,否则会报错

接口中的默认方法和静态方法

Java 8 允许接口中定义静态方法。静态方法可以通过接口名直接调用,而不需要通过实现类。接口中的静态方法通过 static 关键字定义。

Java 8 还引入了接口中的默认方法,这使得接口可以有方法实现,而不需要继承类。接口中的默认方法通过 default 关键字定义。默认方法允许我们在接口里添加新的方法,而不会破坏实现这个接口的已有类的兼容性,也就是说不会强迫实现接口的类实现默认方法。

时间日期API

Java 8 引入了全新的日期和时间 API(java.time 包),它提供了更强大且灵活的处理日期和时间的方式。相比于旧的 java.util.Datejava.util.Calendar,新 API 更加易用且线程安全。

几乎所有的时间对象都实现了 Temporal 接口,所以接口参数一般都是 Temporal

  • Instant: 表示时间线上的一个点,参考点是标准的Java纪元(epoch),即1970-01-01T00:00:00Z(1970年1月1日00:00 GMT)
  • LocalDate: 日期值对象如 2019-09-22
  • LocalTime: 时间值对象如 21:25:36
  • LocalDateTime: 日期 时间值对象
  • ZoneId: 时区
  • ZonedDateTime: 日期 时间 时区值对象
  • DateTimeFormatter: 用于日期时间的格式化
  • Period: 用于计算日期间隔
  • Duration: 用于计算时间间隔

8.反射获取类中的所有方法和获取类中的所有属性.

获取类中的所有方法

要获取类中的所有方法,使用 Class 类的 getDeclaredMethods()getMethods() 方法。

  • getDeclaredMethods():获取类中声明的所有方法(包括私有的、保护的和公共的)。
  • getMethods():获取类中公共的方法,包括继承自父类的公共方法。

获取类中的所有属性

获取类中的所有属性(字段),可以使用 Class 类的 getDeclaredFields()getFields() 方法。

  • getDeclaredFields():获取类中声明的所有属性(字段)包括私有的、保护的和公共的。
  • getFields():获取类中公共的属性(字段),包括继承自父类的公共字段。

9.懒汉和饿汉模式的区别?口述两种模式。

单例设计模式分类两种:

  • 饿汉式:类加载就会导致该单实例对象被创建
  • 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

饿汉式

  • 是立即加载的方式,无论是否会用到这个对象,都会加载。

  • 如果在构造方法里写了性能消耗较大,占时较久的代码,比如建立与数据库的连接,那么就会在启动的时候感觉稍微有些卡顿。

  • 由于类的加载和初始化是由 JVM 管理的,它天生是线程安全的。

  • 缺点:不管是否使用该对象,类加载时都会实例化对象,可能浪费资源,特别是当对象的创建比较复杂时。

懒汉式

  • 是延迟加载的方式,只有使用的时候才会加载。 并且有线程安全的考量。
  • 使用懒汉式,在启动的时候,会感觉到比饿汉式略快,因为并没有做对象的实例化。 但是在第一次调用的时候,会进行实例化操作,感觉上就略慢。
  • 线程安全问题:在多线程环境下,如果不加锁,可能会创建多个实例。需要通过同步来保证线程安全。
  • 缺点:如果没有加锁处理,可能会出现线程不安全的问题。加锁可能带来性能损耗,尤其在高并发的情况下。

懒汉模式与饿汉模式的区别总结

特性 懒汉模式 饿汉模式
实例化时机 延迟实例化,只有在第一次访问时才创建对象 类加载时立即实例化对象
线程安全 默认线程不安全(需要加锁来保证线程安全) 天生线程安全
性能 只有在需要时才创建对象,节省资源 无论是否使用都会创建对象,可能浪费资源
内存消耗 在实例化之前不会占用内存 在程序启动时就占用内存
使用场景 适用于需要延迟加载、实例化过程较为复杂的场景 适用于实例化过程简单且总是需要的单例类
实现方式 通过 if 判断是否需要创建实例(可能需要加锁) 通过静态初始化时创建实例,无需加锁

10.如何保证单例模式在多线程中的线程安全性

在 Java 中,单例模式在多线程环境下需要特别小心,以避免多个线程同时创建多个实例,导致违反单例模式的基本原则。为了保证单例模式的线程安全性,我们需要采取一些额外的措施

双重检查锁定

多线程环境下实现懒汉模式的一个常见方案。它通过减少同步的范围,既保证了线程安全,又避免了过多的性能开销。

工作原理

  • 第一次检查是否已经创建实例,如果已经创建,则直接返回实例(此时不需要加锁)。
  • 如果没有创建实例,才会进入同步块进行加锁。在同步块内,再次检查实例是否已经创建,确保只有一个线程能够创建实例。

关键点

  • 使用 volatile 关键字:确保多线程环境下对 instance 变量的可见性,防止发生指令重排可能会导致程序的执行顺序不符合预期。
  • 通过双重检查减少了同步锁的粒度,避免了每次访问 getInstance() 方法时都进行加锁,提升了性能
public class Singleton {
   
    
    
    private static volatile Singleton instance;

    private Singleton() {
   
    
    }

    public static Singleton getInstance() {
   
    
    
        // 第一次检查
        if (instance == null) {
   
    
    
            synchronized (Singleton.class) {
   
    
    
                // 第二次检查
                if (instance == null) {
   
    
    
                    instance = new Singleton();  // 创建实例
                }
            }
        }
        return instance;
    }
}

使用 synchronized 方法

通过将获取实例的方法设置为 synchronized,可以保证多线程环境下只有一个线程能够访问该方法,从而保证线程安全。

缺点

  • 每次调用 getInstance() 方法时都要加锁,性能较差,尤其在多线程高并发的情况下,因为每次访问 getInstance() 都需要进行同步。
public class Singleton {
   
    
    
    private static Singleton instance;

    private Singleton() {
   
    
    }

    public static synchronized Singleton getInstance() {
   
    
    
        if (instance == null) {
   
    
    
            instance = new Singleton();
        }
        return instance;
    }
}

静态内部类(最推荐方式)

利用 静态内部类来实现单例的方式。这种方式不仅线程安全,而且避免了同步带来的性能损耗。静态内部类在第一次被加载时才会实例化,因此它实现了懒加载,同时确保了线程安全。

静态属性由于被static修饰,保证只被实例化一次,并且严格保证实例化顺序。

它结合了 懒汉式饿汉式 的优点,同时提供了 线程安全延迟加载 的特点。

工作原理:

  • 静态内部类在外部类加载时不会被加载,只有在调用 getInstance() 方法时,内部类才会被加载,这样就保证了懒加载。
  • Java 的类加载机制可以保证对静态变量的初始化是线程安全的,因此无需手动加锁。

优点

  • 线程安全:类的加载机制保证了线程安全性。
  • 延迟加载:只有在首次调用 getInstance() 时,SingletonHelper 类才会被加载,避免了不必要的内存浪费。
  • 性能高:没有同步开销。
public class Singleton {
   
    
    
    private Singleton() {
   
    
    }

    private static class SingletonHelper {
   
    
    
        // 静态初始化器
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
   
    
    
        return SingletonHelper.INSTANCE;
    }
}

枚举单例(推荐)

Java 枚举类型是实现单例的最佳方式之一。**枚举类本身由 JVM 保证线程安全,同时还能够防止反序列化重新创建实例对象的问题(这对于其他单例模式可能是一个潜在问题)。**在枚举类型中,单例实例是由 JVM 确保只会创建一次,并且能够保证反序列化时不会创建新的实例。

优点

  • 简洁且线程安全:枚举类型是由 JVM 自身保证线程安全和单例的。
  • 防止反序列化:枚举类型是唯一可以防止反序列化时破坏单例的方式,因为枚举类的反序列化机制保证了枚举实例的唯一性。
public enum Singleton {
   
    
    
    INSTANCE;

    public void someMethod() {
   
    
    
        // 这里可以实现其他方法
        System.out.println("Singleton method");
    }
}

总结

实现方式 线程安 全性 性能 适用场景 优缺点
双重检查锁定 线程安全 高效,减少锁开销 多线程高并发时 需要 volatile,实现复杂,但性能优异。
同步方法 线程安全 性能较低 线程不频繁访问 每次调用都需要加锁,性能差,适合访问较少的场景。
静态内部类 线程安全 性能优异 推荐使用 线程安全、懒加载、无需加锁,是最推荐的方式。
枚举单例 线程安全 性能优异 最推荐使用 代码简洁,线程安全,防止反序列化破坏,但不可继承。

11.Spring运用了什么设计模式?讲一下哪些部分用到了这些设计模式

  • 单例模式
  • 工厂模式
  • 代理模式‌
  • 观察者模式‌
  • 适配器模式‌
  • 装饰器模式‌
  • 模板方法模式
  • 策略模式‌

单例模式

  • 应用场景:Spring中的Bean的默认作用域就是单例。也就是说,当一个Bean被创建后,Spring容器会缓存它并在后续的请求中返回同一个实例。意味着在整个应用程序中只有一个Bean实例,由Spring容器负责管理
  • 实现方式:Spring使用单例模式管理所有的Bean实例,在Bean的作用域是单例的情况下,容器只会创建一个实例并重复使用。你可以通过@Scope("singleton")来显式指定Bean为单例(这是Spring的默认行为)。

工厂模式

工厂模式是一种创建型设计模式。Spring 使用工厂模式可以通过 BeanFactoryApplicationContext 创建 Bean 对象。Spring容器本质上是一个工厂,它通过ApplicationContext或者BeanFactory来管理Bean的生命周期、创建和依赖注入。隐藏了具体实例化的细节,使得应用程序更易于扩展和维护‌。

  • BeanFactory:延迟注入(使用到某个 Bean 的时候才会注入),相比于 ApplicationContext 来说会占用更少的内存,程序启动速度更快。
  • ApplicationContext:容器启动的时候,不管你用没用到,一次性创建所有 Bean。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory,除了有 BeanFactory 的功能还有额外更多功能,所以一般开发人员使用 AplicationContext 更多。

应用场景:Spring容器本质上是一个工厂,它通过ApplicationContext或者BeanFactory来管理Bean的生命周期、创建和依赖注入。

实现方式:Spring通过BeanFactory接口和ApplicationContext来实现工厂模式。BeanFactory负责根据配置文件或者注解创建Bean实例,并管理它们的生命周期。

代理模式‌

代理模式是一种结构型设计模式,它允许通过代理类控制对其他对象的访问。

能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如:事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可扩展性和可维护性。

  • 应用场景:Spring AOP(面向切面编程)使用代理模式来处理横切关注点(如日志记录、事务管理等)。在Spring AOP中,代理对象被用来拦截方法的调用,并在方法执行前后加入额外的处理逻辑。

    (AOP的核心思想是将横切关注点(如日志、事务管理、权限控制等)从业务逻辑中分离出来,通过“切面”(Aspect)来动态地增强目标对象的行为。)

  • 实现方式:Spring AOP的默认实现基于JDK动态代理或CGLIB代理。在JDK动态代理中,如果目标类实现了接口,则会创建一个实现接口的代理类;如果目标类没有实现接口,则会使用CGLIB动态生成目标类的子类。

观察者模式‌

应用场景:Spring事件驱动模型使用观察者模式。Spring的事件机制允许应用程序在某些事件发生时进行处理,多个监听器可以对同一事件做出反应。

实现方式ApplicationEventPublisherApplicationListener接口提供了事件发布和事件监听的机制。当某个事件发生时,所有注册的监听器会被通知并进行处理。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。

适配器模式‌

适配器模式用于将一个类的接口转换成客户端期望的另一个接口,从而使原本因接口不兼容而不能一起工作的类能够一起工作。在SpringMVC中,HandlerAdapter允许不同类型的 处理器 适配 到处理器接口,以实现统一的处理器调用‌。

实现过程:DispatcherServlet根据HandlerMapping返回的handler,向HandlerAdapter发起请求,处理handler。HandlerAdapter根据规则找到对应的Handler并让其执行,执行完毕后Handler会向HandlerAdapter返回一个ModelAndView,最后由HandlerAdapter向DispatcherServlet返回一个

  • 应用场景:Spring通过适配器模式,使得不同类型的对象可以通过统一的接口进行交互。比如,SpringMVC的HandlerAdapterViewResolver就是典型的适配器模式应用。
  • 实现方式HandlerAdapter接口允许不同的控制器(如SimpleControllerHandlerAdapterAnnotationMethodHandlerAdapter等)使用统一的方式来处理请求。

装饰器模式‌

装饰器模式允许在不改变原有对象的情况下,通过包装一个对象来增加新的功能。在Spring中,BeanWrapper允许在不修改原始Bean类的情况下添加额外的功能‌。

模板方法模式‌

模板方法模式定义了一个操作中的算法骨架,将一些步骤延迟到子类中实现。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。在Spring中,JdbcTemplate、HibernateTemplate等类使用了模板方法模式,提供了统一的接口同时允许用户根据自己的需求进行定制‌。

一般情况下,我们都是使用继承的方法来实现模板模式,但是 Spring 并没有使用这种方式,而是使用 Callback 模板与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。

策略模式‌

策略模式定义了一系列的算法,并将每一个算法封装起来,使它们可以相互替换。在Spring中,策略模式用于实现不同的算法或策略,例如任务调度策略‌

举例:Spring 框架的资源访问 Resource 接口。该接口提供了更强的资源访问能力,Spring 框架本身大量使用了 Resource 接口来访问底层资源。

12.说说你都知道哪些设计模式?最常用的有哪些?总共有几种设计模式?

设计模式总共有 23 种,可以分为创建型、结构型和行为型三类。

创建型(Creational)设计模式

这些模式主要关注如何实例化对象。它们提供了一个灵活的方式来创建对象,解决了对象创建的复杂性。

  • 单例模式(Singleton):确保一个类只有一个实例,并提供全局访问点。
  • 工厂方法模式(Factory Method):定义一个接口来创建对象,但由子类决定实例化哪一个类。
  • 抽象工厂模式(Abstract Factory):提供一个创建一系列相关或依赖对象的接口,而无需指定具体类。
  • 建造者模式(Builder):使用多个简单的对象一步步构建成一个复杂的对象。
  • 原型模式(Prototype):通过复制现有的实例来创建新对象,而不是通过类的构造方法来创建对象。

结构型(Structural)设计模式

这些模式主要关注如何将类和对象组合成更大的结构。它们通常关注的是如何通过继承和接口来更好地组织和结构化代码。

  • 适配器模式(Adapter):将一个类的接口转换成客户端希望的另一个接口。
  • 桥接模式(Bridge):将抽象部分与其实现部分分离,使得两者可以独立变化。
  • 组合模式(Composite):将对象组合成树形结构来表示“部分-整体”的层次结构。
  • 装饰器模式(Decorator):通过继承来动态地给一个对象添加额外的功能。
  • 外观模式(Facade):为子系统中的一组接口提供一个统一的高层接口。
  • 享元模式(Flyweight):通过共享小的对象来减少内存使用,提高性能。
  • 代理模式(Proxy):为其他对象提供一种代理以控制对这个对象的访问。

行为型(Behavioral)设计模式

这些模式主要关注对象之间的交互和职责分配。它们有助于改善对象之间的通信、协作和行为模式。

  • 责任链模式(Chain of Responsibility):通过多个处理者对象来处理请求,每个处理者负责处理请求的一部分。
  • 命令模式(Command):将请求封装成对象,从而使您能够使用不同的请求、队列或日志请求,以及支持可撤销操作。
  • 解释器模式(Interpreter):为语言中的每个符号定义一个解释器对象,通过上下文解释整个表达式。
  • 迭代器模式(Iterator):提供一种方法访问一个集合对象中的各个元素,而又不暴露该对象的内部表示。
  • 中介者模式(Mediator):定义一个对象来封装一组对象之间的交互,促进松耦合。
  • 备忘录模式(Memento):在不暴露对象内部结构的情况下,捕获对象的状态,以便以后恢复。
  • 观察者模式(Observer):定义对象之间的一对多依赖关系,使得一个对象状态变化时,其依赖的所有对象都会自动更新。
  • 状态模式(State):允许一个对象在其内部状态改变时改变其行为,看起来像是改变了其类。
  • 策略模式(Strategy):定义一系列算法,并将每个算法封装起来,使它们可以互换。
  • 模板方法模式(Template Method):定义一个操作中的算法的框架,将一些步骤延迟到子类中实现。
  • 访问者模式(Visitor):表示一个作用于某对象结构的操作,它可以在不改变对象结构的前提下定义新的操作。

最常用的设计模式

  1. 单例模式(Singleton)
    这可能是最常用的设计模式,常用于确保某个类只有一个实例,并提供全局访问点。例如,数据库连接池、配置类等。
  2. 工厂模式(Factory Method)
    工厂模式可以有效地解耦客户端代码与具体的对象创建过程。它非常适用于需要创建不同类型对象的场景。
  3. 观察者模式(Observer)
    观察者模式用于实现事件驱动的机制,通常用于 GUI 编程、消息通知等场景。例如,当用户注册为某个事件的监听器时,系统可以自动推送相关数据给用户。
  4. 策略模式(Strategy)
    策略模式用于定义一系列算法,并且可以根据需求动态地选择不同的算法实现,避免了使用大量的条件语句。
  5. 代理模式(Proxy)
    代理模式广泛应用于懒加载、远程方法调用等场景。通过代理对象控制对目标对象的访问,从而实现对对象访问的控制。
  6. 装饰器模式(Decorator)
    装饰器模式可以在运行时动态地为对象增加功能,比继承更灵活,可以用于增强对象的功能,而无需修改其类。

13.redis可以存储哪几种数据类型?你的项目里都用redis存储哪些数据

Redis支持五种主要的数据类型,它们分别是:

  1. 字符串(String): 最简单的数据类型,可以存储任何类型数据,比如文本、数字等。String 是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。

  2. **哈希表(Hash):**键值对集合, 更适合对象的存储,类似于其他编程语言中的关联数组或者对象。每个哈希表可以存储多个键值对。常用于存储用户信息、商品信息等。

  3. 列表(List): 一个有序的字符串元素集合,可以存储重复元素,支持从两端压入和弹出元素,可以用作栈(stack)或队列(queue)。应用场景:消息队列、文章列表、历史记录、最新动态。

  4. 集合(Set): 无序的字符串元素集合,每个元素不能重复。可以执行集合操作,如并集、交集、差集等。应用场景:点赞、标签系统、共同关注、好友关系、唯一访客统计、抽奖活动

  5. 有序集合zset(Sorted Set): 类似于集合,元素不能重复。但每个元素都关联一个分数,根据分数进行排序。可以用于实现排行榜等功能。应用场景:排序场景,比如排行榜、时间线、范围查询、以及一些需要延迟队列的场景

14.redis有哪些常用操作?(命令)

据类型相关命令

  1. 字符串(String):

    • SET key value:设置指定键的值。
    • GET key:获取指定键的值。
    • INCR key:将指定键的值加1。
  2. 哈希表(Hash):

    • HSET key field value:设置哈希表中指定字段的值。
    • HGET key field:获取哈希表中指定字段的值。
    • HGETALL key:获取哈希表中所有字段和值。
  3. 列表(List):

    • LPUSH key value:将一个值插入到列表头部。
    • RPUSH key value:将一个值插入到列表尾部。
    • LPOP key:移除并返回列表的第一个元素。
  4. 集合(Set):

    • SADD key member:向集合中添加一个或多个成员。
    • SMEMBERS key:获取集合中的所有成员。
    • SINTER key1 key2:返回两个集合的交集。
  5. 有序集合(Sorted Set):

    • ZADD key score member:将一个成员的分数加到有序集合中。
    • ZRANGE key start stop:通过索引区间返回有序集合指定区间内的成员。

服务器管理命令

  1. 信息命令:

    • INFO:获取关于 Redis 服务器的各种信息和统计数值。
    • PING:检测服务器是否可用。
  2. 持久化:

    • SAVE:同步保存数据到硬盘。
    • BGSAVE:异步保存数据到硬盘。
  3. 复制:

    • SLAVEOF host port:将当前服务器设置为指定服务器的从服务器。

事务相关命令

  1. 事务:
    • MULTI:标记一个事务块的开始。
    • EXEC:执行所有事务块命令。
    • DISCARD:取消事务,放弃执行事务块内的所有命令。

其他常用命令

  1. 键操作:

    • DEL key:删除一个键。
    • EXISTS key:检查键是否存在。
    • KEYS pattern:查找所有符合给定模式的键。
  2. 过期时间:

    • EXPIRE key seconds:为键设置过期时间。
    • TTL key:获取键的剩余过期时间。
  3. 发布与订阅:

    • PUBLISH channel message:将消息发送到指定频道。
    • SUBSCRIBE channel:订阅一个或多个频道。

这只是 Redis 命令的一小部分,实际应用中可能会根据具体场景使用更多的命令。不同版本的 Redis 可能会有新增或废弃的命令,建议查阅官方文档获取详细信息。

15.Redis缓存和数据库怎么保持一致?

  • 更新缓存时同步数据库
  • 设置缓存过期时间,避免缓存中的数据长时间过期。
  • 删除缓存后重新加载,保证数据的一致性。
  • 双写一致性,保证在数据库写操作时同时更新缓存。
  • 使用异步更新缓存方式来减少操作的阻塞。
  • 使用消息队列、事件机制等手段来解耦数据库与缓存的更新过程。

更新缓存时同步数据库

最常见的方法是在更新数据库时同步更新缓存。在执行更新操作时,确保缓存与数据库保持一致。

  • 场景:当应用对数据进行增、删、改(写操作)时,除了操作数据库,还需要更新缓存。这样确保数据库和缓存的数据一致。
  • 常见操作
    1. 更新操作:数据更新时,先修改数据库,修改成功后更新缓存。
    2. 删除操作:删除数据库中的数据后,删除缓存中的数据。
    3. 新增操作:新增数据时,先插入数据库,再将数据插入缓存。

流程:

  1. 执行数据库的增、删、改操作。
  2. 数据库操作成功后,更新或删除Redis中的缓存数据。
  3. 如果Redis操作失败,可以通过重试机制或错误日志进行处理,保证数据最终一致性。

示例:假设我们有一个商品信息的缓存,更新商品信息时,既要更新数据库,也要更新Redis缓存。

public void updateProduct(Product product) {
   
    
    
    // 更新数据库
    productDao.updateProduct(product);

    // 更新缓存
    redisTemplate.opsForValue().set("product:" + product.getId(), product);
}

缓存过期策略

为了避免缓存与数据库数据的长时间不一致,通常使用缓存过期策略来保证缓存的数据不会长期存活。在数据过期后,再次请求时会重新加载数据到缓存中。

  • 场景:设置缓存的过期时间,通常与数据的更新频率相关,保证在数据更新时,缓存失效能被及时替换。
  • 实现方式
    1. 为缓存设置一个合理的过期时间(TTL,Time-To-Live),根据业务需求决定。
    2. 数据过期后,缓存将失效,下次请求时会从数据库中读取数据并重新更新缓存。

示例:

// 设置缓存过期时间为1小时
redisTemplate.opsForValue()
  .set("product:" + productId, product, 1, TimeUnit.HOURS);

删除缓存后重新加载

当数据库发生写操作时(比如更新、删除数据),可以选择直接删除缓存,然后等待下一次请求来重新加载缓存。这种方式有助于避免缓存与数据库数据的长时间不一致。

场景:每当数据库数据变动时,删除缓存数据,下一次请求时缓存会重新加载,保证缓存中数据的正确性。

实现方式

  1. 删除缓存:数据库修改后,删除缓存中的相关数据。
  2. 下一次请求:当缓存失效后,从数据库重新读取并加载到缓存中。

双写一致性(双写策略)

双写一致性指的是,在更新数据库时,确保同时更新Redis缓存。可以通过事务机制来确保双写操作的一致性。

  • 场景:进行数据库和缓存的双写时,避免在操作过程中出现缓存和数据库数据不一致的情况。
  • 实现方式
    1. 在更新数据库后,立即更新缓存,确保数据库和缓存的数据同步。
    2. 如果数据库更新成功,但缓存更新失败,需要有补偿机制(如重试)来确保缓存数据最终与数据库一致。

这种方式是最常见的保证一致性的方法,但它的缺点是可能出现“脏读”现象(在缓存更新之前就有请求进来读取到旧缓存数据)。

异步更新缓存

可以通过异步更新缓存来降低数据库与缓存操作的同步开销。在这种情况下,数据更新时,首先更新数据库,然后通过异步任务更新缓存。

  • 场景:当写入操作很频繁时,可以异步更新缓存,避免同步更新带来的性能瓶颈。
  • 实现方式
    1. 数据库更新:数据库数据更新后,立即返回。
    2. 异步更新缓存:通过消息队列(如Kafka、RabbitMQ)或者后台异步任务来更新缓存。

使用消息队列(异步一致性)

如果你想在保证缓存一致性的同时,又不影响系统性能,可以考虑通过消息队列来实现数据库和缓存的一致性。可以在写操作时将消息发送到消息队列,然后异步处理消息来更新缓存。

场景:将缓存更新操作推送到消息队列,通过异步消费消息来更新缓存,从而避免阻塞主流程。

实现方式

  1. 在更新数据库的同时,将“更新缓存”的任务推送到消息队列中。

  2. 消费者从队列中取出任务后,更新缓存。

    (然后有一个消费者从队列中读取消息并更新缓存。)

基于事件的机制(例如观察者模式)

利用事件机制,在数据库发生变更时(如数据增删改),可以发布事件,通知系统其他组件(如缓存更新模块)去更新缓存。这可以通过事件监听机制来实现。

  • 场景:每当数据库发生变动时,触发相应的事件,其他模块(如缓存模块)监听该事件并更新缓存。
  • 实现方式
    1. 数据库修改时发布事件。
    2. 监听该事件并更新缓存。

16.redis可以持久化么?如何持久化?

Redis可以持久化。Redis 提供了几种持久化机制,主要包括 RDB(Redis Database)AOF(Append-Only File),此外,还可以结合使用两者。

Redis支持两种主要的持久化方式,分别是:

  1. RDB(Redis DataBase): RDB 是一种快照(snapshot)持久化方式,通过将当前数据集的状态保存到磁盘上的二进制文件中。这个文件包含了某个时间点上所有键的值。RDB 是一种紧凑且经济的持久化方式,适用于数据备份和恢复。

    • 触发条件:管理员手动执行 SAVE 或者 BGSAVE 命令,或者根据配置文件中的自动保存规则(save 指令)进行定期触发。
    • 文件格式:默认以 dump.rdb 命名,可以通过配置文件指定其他名称。
  2. AOF(Append Only File): AOF 是一种追加日志文件方式,记录每个写操作的命令,以追加的形式写入到磁盘文件中。通过重放这些命令,可以恢复数据集的状态。AOF 提供了更好的持久化实时性,适用于要求更小数据丢失的场景。

  • 触发条件:每个写操作都会被追加到 AOF 文件。
    • 文件格式:默认以 appendonly.aof 命名,可以通过配置文件指定其他名称。

RDB持久化

RDB 持久化是 Redis 默认的持久化机制,采用快照方式,将 Redis 数据的某一时刻保存为一个二进制文件。

RDB 的优缺点:

优点

  • 快照是一次性的全量保存,保存速度较快。
  • Redis 在持久化过程中是非阻塞的,因为它是在后台进行的。
  • RDB 文件比较紧凑,占用磁盘空间小。

缺点

  • **RDB 持久化的周期性保存可能会导致数据丢失。**例如,如果 Redis 在快照之间崩溃,则会丢失最近几秒或几分钟的操作数据。

RDB 持久化原理:

  • Redis 会根据配置的条件定期将内存中的数据保存为一个 RDB 文件,该文件通常位于磁盘的 /var/lib/redis/dump.rdb
  • 快照的生成是通过创建一个内存快照(即持久化到磁盘)实现的。
  • 可以通过 配置 save 规则,来决定什么条件下进行快照。

RDB 配置:

在 Redis 配置文件(redis.conf)中,save 选项控制了持久化的频率。save 后面跟着的是两个参数:secondschanges,意思是:在某个时间内,如果有多少次写操作发生,Redis 会进行 RDB 持久化。

例如,以下配置意味着:

  • 如果 900 秒内有至少 1 次写操作,生成一个 RDB 快照。
  • 如果 300 秒内有至少 10 次写操作,生成一个 RDB 快照。
  • 如果 60 秒内有至少 10000 次写操作,生成一个 RDB 快照。
save 900 1
save 300 10
save 60 10000

触发 RDB 持久化的操作:

  • 手动触发:使用 BGSAVE 命令进行后台保存,或者使用 SAVE 命令进行同步保存(会阻塞 Redis 直到保存完成)。
  • 配置触发:根据 save 配置的规则,Redis 会自动保存。

AOF持久化

AOF 的优缺点:

优点

  • AOF 可以更精确地保证数据的持久化。相比 RDB,AOF 丢失数据的可能性更小,因为每次写操作都会被记录。
  • 配置灵活,可以选择不同的同步策略,提供了不同的性能和数据安全性折中。

缺点

  • AOF 文件会不断增长,因为每个写操作都会被记录下来,可能导致 AOF 文件较大。
  • AOF 写入过程中会增加系统负担,尤其是在 appendfsync always 模式下,性能损失较大。
  • 启动时需要重新执行 AOF 中的所有命令,相比于 RDB,恢复时间较长。

AOF 持久化原理:

  • Redis 会将写命令以“追加”日志的方式保存到 AOF 文件中。每个命令都以可执行的格式记录,因此 Redis 启动时可以通过重放 AOF 文件来恢复数据。
  • AOF 文件默认存储路径是 appendonly.aof,通常位于 /var/lib/redis/ 目录。

AOF 配置:

redis.conf 配置文件中,可以配置 AOF 文件的持久化策略,主要是 同步策略,即在每次写入操作后,Redis 如何将命令写入到 AOF 文件。

AOF 持久化有三种不同的同步策略:

  • always:每次写操作后都同步写入 AOF 文件(最安全,但性能差)。
  • everysec:每秒同步一次,适中选择,性能和安全性折中。
  • no:从不自动同步,需要手动控制同步(最高性能,最不安全)。
appendonly yes          # 启用 AOF 持久化
appendfsync everysec    # 每秒同步一次

RDB 和 AOF 混合模式

Redis 允许同时开启 RDB 和 AOF 持久化,即同时使用这两种方式来保证数据的持久性。这样可以兼顾两者的优点,减少数据丢失的风险。

如何配置 RDB 和 AOF 混合使用:

  1. 在配置文件中启用 RDB 和 AOF:

    save 900 1
    save 300 10
    save 60 10000
    
    appendonly yes
    appendfsync everysec
    
  2. 使用 RDB 做周期性保存,同时通过 AOF 记录每个写操作的命令,以提高数据的持久性。

  3. AOF 重写(AOF Rewrite):当 AOF 文件过大时,Redis 会进行 AOF 重写,创建一个新的 AOF 文件,包含当前数据库状态的最小写操作。这可以通过 BGREWRITEAOF 命令手动触发,Redis 会在后台进行重写,而不影响当前操作。AOF 重写的好处是通过压缩文件,去掉冗余命令,减少 AOF 文件的大小。

    BGREWRITEAOF
    

持久化策略总结

持久化方式 特点 优点 缺点
RDB 快照持久化,通过生成时间点的全量数据保存到磁盘。 快速、占用磁盘少;适合备份数据。 存在数据丢失风险,恢复较慢。
AOF 将每个写命令追加到文件中,按顺序重放命令恢复数据。 数据丢失风险小,可灵活配置同步策略。 AOF 文件较大,写操作性能较差。
混合使用 同时使用 RDB 和 AOF。 数据安全性较高;可以灵活选择策略。 可能导致磁盘空间占用较大,性能折中。

选择建议

  • 如果你需要高性能且数据丢失可以容忍的情况,可以选择 RDB 持久化
  • 如果你要求严格的数据持久性,且希望减少数据丢失的概率,可以选择 AOF 持久化
  • 如果你既希望有较好的数据持久性,又希望能够快速恢复,可以同时启用 RDB 和 AOF 混合持久化

17.redis分布式锁怎么使用?

在分布式系统中,为了避免多个进程/线程并发访问共享资源导致的数据不一致问题,进而使用分布式锁来保证数据安全。Redis由于其高效的读写能力,常被用来实现分布式锁。

Redis的分布式锁通常使用SETNX命令(SET if Not eXists)来实现。具体步骤如下:

  1. 客户端尝试在Redis中设置一个锁键,使用SETNX命令。如果该键不存在,设置成功并返回1,表示获取锁成功。
  2. 如果该键已经存在,说明锁已被其他客户端占用,获取锁失败则返回0
  3. 锁通常会设置一个过期时间(比如5秒),防止死锁。
  4. 获取锁后,客户端完成任务,最后删除锁键。
Jedis jedis = new Jedis("localhost");

// 尝试获取锁
String lockKey = "lock_key";
String lockValue = UUID.randomUUID().toString(); // 唯一标识
long expireTime = 5; // 锁的过期时间,单位秒

// SETNX命令尝试获取锁
if (jedis.setnx(lockKey, lockValue) == 1) {
   
    
    
    // 设置过期时间
    jedis.expire(lockKey, expireTime);

    // 执行业务逻辑

    // 释放锁
    if (lockValue.equals(jedis.get(lockKey))) {
   
    
    
        jedis.del(lockKey); // 删除锁
    }
} else {
   
    
    
    // 获取锁失败,重试或返回失败信息
}

18.Redis缓存数据丢失怎么办?

如果Redis缓存数据丢失,可以通过以下方式应对:

  1. 持久化机制:开启Redis的持久化(RDB或AOF),定期将数据保存到磁盘,以避免数据丢失。

    如果提前进行了Redis持久化操作,并且以RDB快照方式持久化将数据写入内存磁盘中,在数据丢失时可以重新启动Redis加载快照文件,进而恢复数据。

    如果进行的是AOF以追加日志文件方式写入内存磁盘,在数据丢失后也可以重新启动Redis加载AOF文件,进行数据回复,但是由于AOF会记录每个写操作命令,所以回复时比较慢

  2. 备份:定期备份Redis数据快照(RDB文件)或AOF日志,确保数据可以恢复。

  3. 主从复制:设置Redis主从复制,主节点数据丢失时可以从从节点恢复。

  4. 使用外部存储:将重要数据存储在数据库中,Redis作为缓存层,避免依赖单一存储系统。

19.Redis如果崩溃了如何快速恢复?

Redis 崩溃后的快速恢复可以通过以下方式实现:

  1. 持久化设置:开启RDB(快照)或AOF(追加文件)持久化。RDB在周期性保存数据,AOF记录每个写操作。
  2. 启用AOF重写:定期重写AOF文件,避免文件过大。
  3. 启动时自动加载持久化数据:Redis启动时会自动加载RDB或AOF文件,恢复数据。
  4. 定期备份:定期将持久化文件备份到外部存储,防止数据丢失。
  5. 自动化恢复脚本:在生产环境中,可以编写自动化的恢复脚本,当 Redis 崩溃时,自动触发恢复操作。比如,当 Redis 服务崩溃时,自动重启 Redis 实例,并恢复 RDB 或 AOF 文件。

20.redis是如何部署的?是单个部署还是集群部署?为什么这么做?

单节点部署:

  • 单节点部署就是将 Redis 作为一个单独的实例运行,所有的 Redis 数据都存储在一个 Redis 服务器上。适用于数据量较小或负载不高的应用场景。

  • 适用场景

    • 数据量相对较小。

    • 高可用性和分布式的需求较低。

    • 单节点足以承载业务的并发请求。

    • 可以快速搭建原型,进行开发和测试。

    优点

    • 部署和配置简单,维护方便。
    • 性能非常高,因为只有一个 Redis 实例,所有数据存储在内存中,访问速度极快。
    • 适用于小规模的项目或对数据一致性要求较低的场景。

    缺点

    • 单点故障:如果 Redis 实例崩溃,数据会丢失或者服务不可用,除非启用了持久化。
    • 扩展性差:随着数据量的增长,单节点会变得性能瓶颈明显,无法满足大规模数据的需求。
    • 容错性差:只有一个实例,如果它出现故障,整个服务会中断。

主从复制(主从部署)

一个主节点和一个或多个从节点,主节点负责写操作,从节点负责读操作,实现读写分离,分担主节点的压力。

主从模式的优缺点

  • 优点:实现读写分离,分担主节点的压力。
  • 缺点:当主节点宕机时,需要手动切换或等待重启,不具备自动故障恢复功能。
  • 适用场景:适用于对数据一致性和高可用性要求不高的场景

==============================================================

一个主节点和一个或多个从节点,数据的写操作都由主节点处理,从节点负责同步主节点的数据。主从复制通常用于 读写分离,即主节点负责写操作,从节点负责读操作。

  • 主节点:接收所有写请求,修改数据,并将数据同步到从节点。
  • 从节点:从主节点复制数据,可以处理读请求,但不能处理写请求。

优点

  • 负载均衡:通过将读请求分配到从节点,主节点的负载得到减轻,提高整体性能。
  • 数据备份:从节点可以作为主节点的备份,主节点崩溃时,可以快速切换到从节点,减少停机时间。
  • 高可用性:通过增加从节点,可以提高 Redis 系统的可用性和容错性。

缺点

  • 数据一致性主节点写入的数据需要通过复制同步到从节点,这会导致主从节点之间可能会有一定的延迟,因此会存在短暂的数据不一致问题。
  • 故障转移:主节点崩溃时,必须手动或通过其他机制(如 Redis Sentinel)进行主从切换。
  • 当主节点宕机时,需要手动切换或等待重启,不具备自动故障恢复功能。

适用场景

  • 需要高可用、容错和负载均衡的场景。
  • 读操作较多,写操作相对较少的应用(如缓存数据库)。
  • 用于主节点的备份和高可用性需求。
  • (适用于对数据一致性和高可用性要求不高的场景。)

哨兵模式

哨兵系统用于监控多个Redis服务器,当主服务器出现问题时,哨兵会自动进行故障迁移,选举一个新的主服务器。

哨兵模式的优缺点

  • 优点:自动故障迁移,实现高可用性。
  • 缺点:配置复杂,需要部署哨兵系统。
  • 适用场景:适用于对高可用性有较高要求的场景。

集群部署

Redis Cluster 是 Redis 官方提供的分布式部署方案,支持数据的自动分片。它可以将数据分散到多个节点上,同时提供高可用性和自动故障转移。

Redis Cluster将数据分片存储在多个节点上,实现自动分片和负载均衡。

Redis Cluster通过将键空间分割为16384个槽位,每个主节点负责一部分槽位,实现数据的分布式存储和高可用性

优点

  • 高可用性:Redis Cluster 自动进行故障转移和数据备份。
  • 水平扩展:支持数据分片,可以根据需要增加更多的节点以扩展容量。
  • 分布式存储:数据分布在多个节点上,提高了存储能力和性能。

缺点

  • 配置复杂:集群配置相对复杂,需要配置多个节点、管理数据分片和故障转移机制。
  • 数据一致性问题由于数据分布在多个节点上,可能会面临短暂的数据不一致和网络分区问题。

适用场景

  • 适用于大规模、高并发、高可用的场景。

  • 数据量非常大,单节点无法满足需求。

  • 高并发、高可用的分布式场景,如大规模缓存、分布式数据库等。

  • 需要对 Redis 进行水平扩展的场景。

部署方式 描述 优点 缺点 适用场景
单机部署 Redis运行在单个实例上,数据存储在该实例中。 简单易部署,适合小型项目,低成本。 单点故障,扩展性差,容灾能力差。 小型应用,缓存需求较低的场景。
主从复制 一个主节点提供写操作,从节点同步主节点数据,支持读操作。 提高读取性能,避免单点故障。 写操作仍然集中在主节点,主节点压力大。 读多写少的场景,提高读性能。
哨兵部署 使用Redis Sentinel进行监控,自动故障转移。 提高高可用性,支持自动故障切换。 配置和管理复杂,需要多台机器。 对高可用性有需求的场景。
集群部署 Redis Cluster将数据分片存储在多个节点上,实现自动分片和负载均衡。 水平扩展性强,支持大规模数据存储。 配置复杂,数据迁移和管理较为复杂。 大规模分布式应用,需要高吞吐量的场景。

总结:

  • 单机部署适合小规模、低并发场景。
  • 主从复制适合读多写少的场景,提高读取性能。
  • 哨兵部署提供高可用性,适合对可靠性要求高的场景。
  • 集群部署适合大规模分布式应用,提供水平扩展性和高吞吐量。

21.什么是缓存穿透、缓存击穿、缓存雪崩?如何解决?

雪崩,穿透,击穿,都是高并发场景下,大量请求查询,为找到redis存的数据,直接去查询了数据库,造成数据库压力大大量请求

雪崩:redis大量缓存同时过期,同时请求数据库,造成数据库压力大 解决方案:随机设置缓存的失效时间

击穿:热点数据缓存刚过期,大量请求绕过缓存请求数据库,造成数据库压力大 解决方案: 缓存预热,key永不过期或者使用期间内不过期

穿透:查询缓存和数据库都不存在的数据,导致数据库压力大, 解决方案:缓存空对象,布隆过滤器

问题类型 描述 解决方案
缓存穿透 请求的数据在缓存和数据库中都不存在,导致每次都访问数据库。 1. 使用布隆过滤器:快速判断数据是否存在,避免无效请求访问数据库。 2. 缓存空值:对于不存在的数据,缓存一个空值,设置短期过期时间。
缓存击穿 缓存中的某个热点数据在高并发时失效,多个请求同时访问数据库。 1. 互斥锁:多个请求同时请求数据时,只有一个请求去查询数据库,其他请求等待缓存结果。 2. 使用分布式锁:避免多个请求同时访问数据库。
缓存雪崩 缓存中的大量数据同时过期或失效,导致大量请求同时访问数据库。 1. 缓存过期时间错峰:避免缓存数据在同一时间过期。 2. 使用多级缓存:设置不同层级的缓存,减少对数据库的直接访问。 3. 加随机过期时间:避免大批缓存数据在同一时刻失效。

详细解释:

  1. 缓存穿透:指请求的数据既不在缓存中也不在数据库中,导致每次都直接查询数据库。可以通过布隆过滤器来快速判断请求数据是否存在,如果不存在则直接返回,避免无意义的数据库查询。
  2. 缓存击穿:指缓存中的某个热点数据在高并发下刚好失效,多个请求同时查询数据库,导致数据库压力增大。解决方法是通过互斥锁或分布式锁,确保同一时刻只有一个请求查询数据库,其他请求等待缓存结果。
  3. 缓存雪崩:指缓存中大量数据同时失效,导致大量请求同时访问数据库,造成数据库压力过大。可以通过设置不同的过期时间、错峰过期策略、增加缓存层级和随机化缓存过期时间来避免。

22.List和set和map的区别?

ListSetMap 是 Java 中常用的集合类型,它们的区别主要体现在以下几个方面:

特 性 List Set Map
存储结构 有序集合,允许重复元素。 无序集合,不允许重复元素。 键值对(key-value)集合,键唯一,值可以重复。
元素顺序 保持插入顺序,可以通过索引访问元素。 不保证元素顺序(HashSet)或有序(TreeSet)。 键有序(LinkedHashMap、TreeMap),值无序。
允许重复 允许重复元素。 不允许重复元素。 键不能重复,值可以重复。
访问方式 通过索引访问元素。 无法通过索引访问,只能通过迭代器遍历。 通过键(key)访问对应的值(value)。
常用实现 ArrayListLinkedList HashSetTreeSetLinkedHashSet HashMapTreeMapLinkedHashMap
线程安全 默认不安全(CopyOnWriteArrayList是线程安全的)。 默认不安全(CopyOnWriteArraySet是线程安全的)。 默认不安全(ConcurrentHashMap是线程安全的)。
性能特点 支持随机访问,查找元素效率高,但插入、删除可能较慢。 查找、插入、删除操作效率高,尤其是HashSet 基于哈希表,插入、查找、删除操作效率高。

详细说明:

  1. List

    • 是一个有序的集合,元素按照插入顺序排序。

    • 允许重复元素,元素可以根据索引位置进行访问。

    • 常用实现:ArrayList(支持快速随机访问)、LinkedList(支持高效插入和删除)。

    • 有序:存和取的顺序是一致的

      有索引:可以通过索引操作元素

      可重复:存储的元素可以重复

    • 适用场景:当你需要按照顺序存储元素,并且可以有重复元素时使用List。如果你需要频繁地根据索引位置访问元素,ArrayList(基于数组实现)可能是一个好的选择。

  2. Set

    • 是一个无序集合,不允许重复元素。

    • 不保证元素的顺序(HashSet),也可以保证元素的顺序(LinkedHashSet),或者按自然顺序排序、自定义排序(TreeSet)。

    • 常用于去重操作。

    • Set系列集合的实现类

      HashSet:无序、不重复、无索引

      LinkedHashSet:有序、不重复、无索引

      TreeSet:可排序、不重复、无索引

    • 适用场景:当你需要保证集合中的元素不重复,且不关心元素的顺序时使用Set。如果需要根据元素的自然顺序排序,可以使用TreeSet

  3. Map

    • 是一个键值对集合,每个元素包含一个键和一个值。

    • 键(key)必须唯一,值(value)可以重复。

    • Map的实现不保证键值对的顺序,具体顺序取决于实现类

    • 常用实现:HashMap(无序)、TreeMap(按键排序)、LinkedHashMap(保证插入顺序,存储和取出元素的顺序是一致的。)。

    • 双列集合一次需要存一对数据,分别为键和值

      键不能重复,值可以重复

      键和值一一对应,每个键只能找自己的对应的值

      键+值这个整体我们成为“键值对”或者“键值对对象”,在Java中叫“Entry对象

    • 适用场景:当你需要关联键和值,并且每个键只对应一个值时使用Map。例如,用Map存储学生ID和学生姓名的对应关系。

总结:

  • List 适用于需要保持元素顺序并允许重复的场景。
  • Set 适用于需要去重并且不关心元素顺序的场景。
  • Map 适用于需要通过键来映射值的场景,键是唯一的,但值可以重复。

23.Arrarylist,Linkedlist;Arraylist内部扩容机制是怎样的?

ArrayListLinkedList 是 Java 中实现 List 接口的两种常见集合,它们的内部结构和扩容机制有显著区别。下面是它们的对比及 ArrayList 的扩容机制详细说明:

ArrayList vs LinkedList

特性 ArrayList LinkedList
存储结构 基于动态数组 基于双向链表
访问方式 支持通过索引快速随机访问。 访问元素需要遍历链表,效率较低。
插入/删除效率 在末尾插入删除效率高,其他位置的插入/删除较慢 插入/删除操作在链表两端高效;在中间低效。
内存占用 内存消耗较少,只需存储元素。 内存消耗多,每个元素需要额外存储前后指针。
线程安全 默认不安全,可以使用 CopyOnWriteArrayList 实现线程安全。 默认不安全,可以使用 CopyOnWriteArrayList 实现线程安全。
扩容机制 动态数组扩容(具体机制见下文)。 不涉及扩容,链表按需动态分配内存。

总结

对比点 ArrayList LinkedList
底层实现 动态数组 双向链表
访问效率 随机访问快 随机访问慢
插入/删除效率 尾部操作快,中间操作慢 首尾操作快,中间操作较慢
内存消耗 相对较少 较高
适用场景 读多写少,需随机访问场景 写多读少,动态操作频繁场景

使用 ArrayList 的场景

  • 需要频繁访问元素(随机访问场景,如根据索引读取)。
  • 插入和删除操作较少(特别是中间位置的操作)。
  • 需要更低的内存占用

使用 LinkedList 的场景

  • 需要频繁插入和删除元素(尤其是在列表的首尾操作)。
  • 元素数量动态变化较大,不确定具体容量,避免数组扩容的开销。
  • 操作顺序数据(如队列或栈的实现)。

ArrayList 内部扩容机制

ArrayList 使用动态数组作为底层数据结构。当 ArrayList 中的元素数量超过当前数组的容量时,ArrayList 会进行扩容,扩容的过程如下:

扩容是通过 创建一个新的、更大的数组 实现的,而不是直接对原数组扩容。Java 数组的大小是固定的,不能动态调整,因此:无法直接对原数组扩容

  1. 初始容量
    • 默认初始容量是 10。如果创建时没有指定容量,ArrayList 会使用默认容量。
    • 可以通过构造函数设置初始容量:new ArrayList<>(initialCapacity)
  2. 扩容过程
    • ArrayList 的元素个数超过当前数组的容量时,会进行扩容。
    • 扩容的规则是:当前容量的 1.5 倍。
    • 比如,初始容量为 10,当元素个数达到 10 时,会扩容到 15;再当元素个数达到 15 时,会扩容到 22(即 15 * 1.5 ≈ 22)。
  3. 扩容后的数据拷贝
    • 扩容时,ArrayList 会创建一个新的数组,将原数组中的元素复制到新数组中(Arrays.copyof()),替换原数组引用,使其指向新数组。
    • 这个过程是 O(n),即随着元素数量的增加,扩容的开销也会增大。
  4. 扩容的代价
    • 扩容过程虽然可以保证 ArrayList 的容量随元素增加而动态增长,但每次扩容时都需要进行数组的复制,因此在频繁扩容的情况下可能会对性能产生影响。
    • 过度扩容也会导致内存浪费,因此在创建 ArrayList 时如果能预估元素的数量,建议显式指定初始容量。

24.HashMap扩容机制,HashMap的底层原理

  • 创建一个默认长度16,默认加载因子为0.75的数组,数组名table

    16*0.75 = 12,如果存入的数据达到12,则数组自动扩容为原来的2倍

  • 根据元素的哈希值跟数组的长度计算出元素应存入的位置

    int index = (数组长度-1) & 哈希值;

  • 如果位置为null,直接存入

  • 如果位置不为null 表示有元素,则调用equals方法比较属性值

    一样:不存 不一样:存入数组,形成链表

    jdk8以前:如果在数组中的同一个位置插入不同的元素的话,新元素存入数组,老元素挂在新元素下面,形成链表

    jdk8以后:如果在数组中的同一个位置插入不同的元素的话,新元素直接挂在老元素下面

  • jdk8以后,当链表长度超过8,且数组长度大于等于64时,链表自动转换为红黑树。

  • 如果集合中存储的时自定义对象,必须重写hashCode方法和equals方法。

HashMap 扩容机制

HashMap 是 Java 中一个非常常用的键值对集合,它基于哈希表(Hash Table)实现,具有很高的查找、插入效率。HashMap 的扩容机制主要包括以下几个关键点:

  1. 初始容量(Initial Capacity)
    • 默认初始容量为 16,但可以通过构造函数设置。
    • 构造函数:new HashMap<>(initialCapacity),其中 initialCapacity 是初始容量。
  2. 负载因子(Load Factor)
    • 负载因子决定了 HashMap 的何时扩容。默认负载因子为 0.75,表示当 HashMap 中的元素个数超过 容量 * 负载因子 时,就会触发扩容。
    • 例如,初始容量为 16,负载因子为 0.75,那么当元素个数超过 12(16 * 0.75)时,HashMap 会扩容。
  3. 扩容机制
    • 扩容时,HashMap 会将容量扩大为原来的 2 倍,并重新计算所有元素的哈希值并重新分配到新的桶中。
    • 这意味着,扩容后,HashMap 的容量将变为 容量 * 2
    • 扩容的代价是 O(n),因为需要重新计算每个元素的哈希值并将它们重新放入新的数组中。
  4. 扩容触发条件
    • 当元素个数大于 容量 * 负载因子 时触发扩容。
    • 举例来说,如果初始容量为 16,负载因子为 0.75,当元素个数超过 12 时触发扩容(16 * 0.75 = 12)。
  5. 扩容时的性能问题
    • 由于扩容时需要将所有元素重新计算哈希位置并放入新的数组,因此扩容是一个较为耗时的操作,尤其是当元素非常多时。
    • 为了避免频繁扩容,通常可以根据预期元素数量来调整初始容量。

HashMap 的底层原理

HashMap 底层是基于数组和链表/红黑树(JDK 8及以后)实现的,具有以下几个重要组件和工作机制:

  1. 数组 + 链表/红黑树
    • HashMap 内部维护了一个数组(称为桶数组),每个桶可以存储多个键值对。当发生哈希冲突时,多个元素会被存储在同一个桶中,通常通过链表(JDK 7及以前)或红黑树(JDK 8及以后)来解决冲突。
  2. 哈希桶(Bucket)
    • HashMap 使用哈希函数计算键的哈希值,然后将键值对放入相应的桶中。桶的下标是通过 hash(key) % array.length 计算得到的。
    • 如果两个键的哈希值相同,它们会被放入同一个桶,这就是哈希冲突。
  3. 链表(在哈希冲突时)
    • 在 JDK 7 及以前,如果两个元素的哈希值相同,它们会被放在同一个桶的链表中。
    • 查找时,链表的长度决定了查找的效率,链表越长,查找的时间复杂度越高。
  4. 红黑树(JDK 8及以后)
    • 从 JDK 8 开始,如果某个桶中的元素个数超过 8 且数组的大小超过 64,HashMap 会将链表转换为红黑树。
    • 红黑树是一种自平衡的二叉查找树,它可以保证最坏情况下的查找时间复杂度为 O(log n),大大提高了性能。
  5. 哈希函数
    • 哈希函数是 HashMap 关键的性能因素。Java 使用了 hashCode() 方法和扰动函数来计算键的哈希值,从而将元素均匀地分布到不同的桶中。
    • HashMap 使用扰动函数(如 hash ^ (hash >>> 16))来减少哈希冲突,确保哈希值分布更均匀。

hashmap的底层是面试的重点详细请参考:https://blog.csdn.net/weixin_47084555/article/details/122351835

25.Hashmap的底层用什么存储的?线程是否安全?

HashMap 的底层是基于哈希表的结构来实现的,jdk8以前哈希表由数组+链表组成,jdk8以后哈希表由数组+链表+红黑树组成。

HashMap 的底层是基于 数组 + 链表 + 红黑树 的结构实现的。

  1. 数组
    • HashMap 底层维护了一个数组,称为 哈希桶(bucket)
    • 每个数组的元素是一个链表或红黑树的头结点,存储着键值对(EntryNode 对象)。
  2. 链表
    • 当两个或多个键计算出的哈希值相同时,它们会被存储在同一个桶中,形成链表。
    • 链表中每个节点存储一个键值对。
  3. 红黑树(从 JDK 8 开始)
    • 当链表的长度超过 8 且数组容量大于 64 时,链表会转换为红黑树。
    • 红黑树的查找效率为 O(log n),相比链表(O(n))效率更高。

线程安全性

  • HashMap 是非线程安全的

    • HashMap 在多线程环境下可能会导致数据不一致,例如在并发修改时会产生竞态条件。
    • 在扩容过程中,可能会导致循环引用(JDK 7 中的死循环问题),从而引发程序崩溃。
  • 解决线程安全问题的方法

    1. **使用 ****Collections.synchronizedMap()**

      • 可以通过 Collections.synchronizedMap() 方法包装一个 HashMap,使其线程安全。

        该方法返回一个线程安全的 Map,它通过 同步 的方式来保证线程安全。

Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
2. 使用ConcurrentHashMap:
    ConcurrentHashMap 是 HashMap的线程安全版本,支持高效的并发操作。
     它通过分段锁(JDK 7)或 CAS 和细粒度锁(JDK 8)实现了线程安全。
ConcurrentHashMap<String, String> concurrentMap = new ConcurrentHashMap<>();
3. 手动同步:
    在访问 HashMap 时手动添加同步代码块。
synchronized (hashMap) {
    hashMap.put("key", "value");
}

总结

特性 描述
底层结构 数组 + 链表 + 红黑树
线程安全性 非线程安全
线程安全解决方案 使用 Collections.synchronizedMap()ConcurrentHashMap

26.final和finally和finalize的区别

finalfinallyfinalize 是 Java 中三个不同的概念,尽管它们有相似的词根,但各自有不同的用途。

final

  • 用途final 是一个修饰符,用于定义常量、方法、类和局部变量。
  • 适用场景
    • 常量:声明常量(即不可修改的变量):
final int MAX_VALUE = 100;

  • 方法:表示方法不能被重写(不可覆写的方法):
public final void myMethod() {
    // 方法内容
}
  • :表示类不能被继承:
public final class MyClass {
    // 类内容
}
  • 局部变量:声明局部变量为常量(赋值后不可修改):
final int x = 10;

finally

  • 用途finally 是用于异常处理的关键字,表示一块无论是否发生异常都会执行的代码块。
  • 适用场景
    • finally 用于 try-catch 语句中,保证无论是否有异常发生,finally 中的代码都会执行,常用于资源释放(例如关闭文件流)。
try {
    // 代码块
} catch (Exception e) {
    // 异常处理
} finally {
    // 无论如何都会执行的代码块(例如:资源释放)
    System.out.println("This is always executed.");
}

finalize

  • 用途finalizeObject 类中的一个方法,用于垃圾回收器回收对象之前进行清理工作。该方法是 自动调用 的,但并不能保证一定会在对象被回收时被调用。
  • 适用场景
    • finalize 方法在对象被垃圾回收之前自动调用,通常用来释放资源或进行其他清理工作。
    • 注意:finalize