JAVA面试题集(持续更新)

发布于:2025-08-03 ⋅ 阅读:(10) ⋅ 点赞:(0)

目录

JAVA基础

ArrayList和LinkedList区别及联系

HashMap和ConcurrentHashMap区别及联系

线程池核心参数有哪些

volatile关键字的核心作用

JAVA IO模型

谈谈java的类加载器

谈谈java类加载器的双亲委派机制

类加载有哪些时机,有何不同

说说JVM 内存设置参数及JDK自带查看JVM命令工具

JVM 堆内存是什么组成,发生GC时,回收策略是什么

说说java的4种引用及作用

说出几种获取线程返回值的方式

sleep方法和wait方法的区别

说说线程池的核心参数有哪些,项目中线程数量设置的依据是啥

谈谈对CountDownLatch的理解

说几个常用的系统性能评估指标

JAVA框架

mybatis中#和$区别

谈谈Spring的IOC和AOP   

谈谈Spring的声明式事务

说说SpringBoot启动流程

单节点服务下,如何做服务限流控制

为什么要实现微服务熔断,熔断的方案有哪些

Redis

Redis如何做数据持久化

Redis缓存淘汰策略

说说Redis缓存雪崩及解决办法

说说Redis缓存穿透及解决办法

说说Redis缓存数据和数据库数据不一致的原因和解决办法

消息队列

说说Kafka的架构及核心概念

如何编码实现Kakfa多线程消费消息

解释Kafka消息丢失原因及解决办法

解释Kafka消息重复消费原因及解决办法

数据库

说说mysql数据库的悲观锁、乐观锁及实现方式

谈谈mysql中的行锁、表锁

谈谈数据库中的脏读、不可重复读、幻读及数据库事务隔离级别

B+树索引和hash索引的区别

谈谈项目中是如何优化SQL及索引的

说说mysql中的binlog及应用场景

数据库数据量很大时,如何优化项目使其高效快速运行

实际项目对mysql有哪些方面的优化措施

网络

解释TCP三次握手

TCP和UDP的区别

浏览器输入网址,网络上经历了哪些流程

说说常见的HTTP状态码

说说HTTPS的连接过程

HTTP和HTTPS的区别


JAVA基础

  • ArrayList和LinkedList区别及联系

        联系:2者都实现了List接口

        区别:

        ArrayList内部以数组方式实现,查询速度快,时间复杂度O(1),删除更新速度慢,时间复杂度O(n);

        LinkedList内部以链表方式实现,插入删除等维护操作速度快,时间复杂度O(1),查询速度慢,时间复杂度O(n)

  • HashMap和ConcurrentHashMap区别及联系

        联系:HashMap和ConcurrentHashMap都采用了哈希表(Hash Table)的数据结构,通过计算键(Key)的哈希值来快速定位数据。

        区别:

        HashMap是非线程安全的,它在进行put、get、remove等操作时不会进行线程同步,因此多个线程同时访问HashMap时可能会引发数据不一致的问题。

        ConcurrentHashMap是线程安全的,它采用了分段锁(Segmentation Lock)技术,允许多个线程同时访问不同的段(Segment),从而提高了并发性能。

  • 线程池核心参数有哪些

        当要执行的任务多于核心线程数,任务进入等待队列,当等待队列满了后,增加新的线程直到达到最大线程数,当最大线程数满了后,执行拒绝策略,实际工作中往往采用丢弃抛异常或者调用线程处理的策略,当任务不多时,最大线程数大于核心线程数,等待keepAliveTime时间后,就开始销毁线程到核心线程的数量。 

  • volatile关键字的核心作用

        保证可见性‌:当变量被volatile修饰时,任何线程对该变量的修改都会立即同步到主内存,其他线程读取时也会直接从主内存获取最新值,避免因线程工作内存中的缓存导致数据不一致。‌‌

        ‌禁止指令重排序‌:编译器或处理器可能对指令进行优化重排,而volatile通过内存屏障(Memory Barrier)防止这种重排序,确保代码执行顺序符合预期。

  • JAVA IO模型

        BIO、NIO、AIO 是 Java 中三种不同的 I/O 模型,核心区别在于阻塞特性与编程模式:BIO 是同步阻塞,NIO 是同步非阻塞,AIO 是异步非阻塞。

        BIO:每个连接对应一个线程,线程在读写操作时会被阻塞,直到数据就绪。

        NIO:为了解决BIO单线程阻塞问题(读写阻塞,连接阻塞),实现单线程解决并发问题(多路复用)。多路复用器(Selector)轮询事件,单线程处理多个连接,核心是非阻塞和事件驱动。

        NIO模型中,每个连接(无论是客户端连接还是服务端监听)都对应一个Channel,将这些Channel注册到同一个Selector上,由Selector统一监控这些Channel的I/O事件(如读、写、连接等)。Selector的核心作用就是用一个线程管理多个Channel,实现高并发。

        AIO:异步回调或Future机制,由操作系统完成IO操作后通知应用,无需应用线程等待。 

        适用场景:

        BIO:适用于连接数较少且固定的场景,例如传统的单线程或少量线程处理数据库连接、文件 I/O 等操作。

        NIO:高并发短连接(如即时通讯、API 网关)、对延迟敏感的实时系统(如股票交易撮合)。

  • 谈谈java的类加载器

        在Java中,类加载器(Class Loader)是用来将类(.class文件)从文件系统或其他来源加载到JVM(Java虚拟机)中的组件。在Java中,主要有以下几种类加载器:

        1、启动类加载器(Bootstrap ClassLoader):这是最顶层的类加载器,由C++实现(即不是Java代码),它负责加载Java的核心库,如rt.jar中的类。

        2、扩展类加载器(ExtClassLoader):它负责加载Java的扩展目录jre/lib/ext或者由系统属性java.ext.dirs指定的目录下的JAR包中的类。

        3、应用类加载器AppClassLoader:负责加载classpath路径下的类库。

        可以通过继承java.lang.ClassLoader类来创建自定义的类加载器。下方示例自定义了一个类加载器,加载已经编译过的类文件。

package com.gingko.jvm.classloader;
import java.io.*;
public class MyClassLoader extends ClassLoader{

    private String path;//类文件路径
    public MyClassLoader(String path) {
        this.path = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        String fileName = name.replaceAll("\\.", "/");
        InputStream inputStream = null;
        ByteArrayOutputStream outputStream = null;
        try {
            inputStream = new FileInputStream(path + fileName + ".class");
            outputStream = new ByteArrayOutputStream();
            byte[] bytes = new byte[1024];//每次读取1k字节的数据
            int len = 0;//每次读取的字节的实际长度,最后一次大概率不是1024
            len = inputStream.read(bytes);
            for(;len != -1;len = inputStream.read(bytes)) {
                //写入文件
                outputStream.write(bytes,0,len);//从读取数组的0开始,一直到实际的读取长度结束
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if(outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return defineClass(name,outputStream.toByteArray(),0,outputStream.toByteArray().length);
    }

    public static void main(String[] args) throws Exception {
        String path = "D:\\common\\workspace\\Intellij\\learning\\jvm\\src\\main\\java\\";
        MyClassLoader myClassLoader = new MyClassLoader(path);
        String name = "com.gingko.jvm.classloader.ClassLoaderTest";
        Class<?> testClass = myClassLoader.findClass(name);
        Object instance = testClass.newInstance();
        System.out.println(instance.getClass().getClassLoader());
        System.out.println(testClass.getMethod("test").invoke(instance));
    }
}

        自定义类加载器的应用场景如下:

        1、通过自定义类加载器实现无需重启应用即可更新代码逻辑,例如框架插件热加载

        2、应用隔离:在多项目共存的环境中(如Tomcat部署多个应用),通过不同类加载器隔离不同项目,防止资源冲突。

        3、加密类文件保护:对核心代码进行加密,通过自定义加载器解密后加载,防止未授权访问。 

  • 谈谈java类加载器的双亲委派机制

        双亲委派机制是指当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,因此所有的类加载请求最终都会传送到顶层的启动类加载器。只有当父类加载器无法完成加载请求时,子加载器才会尝试自己去加载。

        假设系统中自定义了类加载器MyClassLoader,下方示例代码中加载【System】和【ClassLoaderTest】用到的类加载器分别null(Bootstrap ClassLoader)和AppClassLoader。

        为什么要使用双亲委派机制去加载类?

        避免加载多份相同的字节码,影响内存使用。比如A类和B类都用到了System类,如果不用双亲委派机制去加载,那么内存A类和B类都会去加载System类,内存中就存在2份相同的System类字节码。用了双亲委派机制加载后,只有Bootstrap ClassLoader加载一份System类字节码。

  • 类加载有哪些时机,有何不同

        1、显式的类加载,如new出对象,加载类并初始化

        2、隐式的类加载,通过对象的loadClass或Class.forName

        3、new和Class.forName都会执行加载类的静态代码块(loadClass不会)完成类初始化,比如加载mysql类驱动时,需要使用forName加载,因为在驱动类的静态代码块中初始化了驱动信息。

  • 说说JVM 内存设置参数及JDK自带查看JVM命令工具

        1、-Xms<size>: 设置 JVM 初始堆大小 (initial heap size)。
        2、-Xmx<size>: 设置 JVM 最大堆大小 (maximum heap size)。
        建议:将 -Xms 设置为与 -Xmx 相同的值,可以避免 JVM 在运行时动态调整堆大小,减少 GC 开销。
        3、-Xss<size>: 设置线程栈大小 (stack size) ,默认值:取决于操作系统和 JVM 版本(通常为 512KB 或 1MB)。

  • 通过jinfo -flags 进程号可以查看jvm设置的参数信息。

  • 通过jstack -l 进程号 查看jvm线程信息

  • 通过jmap -heap 进程号 查看jvm堆信息

  •  通过jmap -dump 导出堆文件

jmap -dump:format=b,file=heapDump.hprof pid

  • 实际项目中运行jar的同时,就会指定当系统发生oom问题时,自动导出dump文件,再通过jvisualvm工具导入dump文件进行分析,设置如下:

        java -Xms1024m-Xmx1024m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof -jar xxx.jar

  • JVM 堆内存是什么组成,发生GC时,回收策略是什么

        对于jdk8,jvm的堆内存分为:年轻代、老年代;其中年轻代包含:Eden区和Survivor区。

        内存分配比例:

        NewRatio:新生代与老年代的比值,JDK8的默认值为2

        SurvivorRatio:新生代2个Survivor区和Eden区的比值,默认值为8;即Eden区:From S0区:To S1区 = 8:1:1

        对于堆内存中没有外部引用的对象就可以标记为垃圾对象,GC可以回收。

        对于年轻代,大多数新对象在这里创建,生命周期短暂,垃圾回收策略采用复制算法(Copying Algorithm),将存活的对象复制到另一个区域,然后清除原区域。这样可以快速回收大部分不再使用的对象。

        对于老年代,通常放置大对象或者从新生代晋升过来的存活对象(存活时间长,参数MaxTenuringThreshould设置后,Minor GC 回收MaxTenuringThreshould次后都没有被回收,就会放到Old区)通常被分配到这里。垃圾回收策略采用标记-清除(Mark-Sweep)或标记-整理(Mark-Compact)算法。老年代的垃圾回收称为Major GC或Full GC,比新生代的GC(Minor GC)更耗时和资源密集。Full GC 通常会导致较长的停顿(STW,Stop-The-World),如果 Full GC 频繁发生,可能会影响应用的性能。

        触发Full GC的场景如下:

        1、老年代空间满了

        2、调用System.gc()

        3、元空间(Metaspace)容量耗尽

        通过命令:jstat -gcutil <pid> 1000 查看java进程每隔1000毫秒GC情况,示例如下:

        其中:S0, S1, E, O 分别表示幸存区(Survivor)、Eden区 和老年代(Old Generation)的使用百分比。

        M 表示元数据区的使用百分比。

        CCS 是压缩类空间的使用百分比(仅在启用了压缩类指针时显示)。

        YGC 和 FGC 分别表示年轻代垃圾回收次数和老年代垃圾回收次数。

        YGCT 和 FGCT 分别表示年轻代和老年代垃圾回收消耗的时间。

        GCT 是垃圾回收总消耗时间。

  • 说说java的4种引用及作用

        1、 强引用是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

        2、软引用只有内存空间不足时,GC会回收掉引用对象的内存。

        3、弱引用GC时会回收掉引用对象的内存。 

        4、虚引用存在于每一个对象里面,不会对对象的存活造成任何影响,用处是:能在对象被GC时收到系统通知。

        软引用和弱引用主要用于对象缓存。

  • 说出几种获取线程返回值的方式

        1、通过线程join方法,等待线程运行结束

        2、通过callable接口,结合FutureTask和Future实现

        示例代码如下:

  • sleep方法和wait方法的区别

        sleep()是Thread类的静态方法,不释放锁,需指定休眠时间自动唤醒;

        wait()是Object类的方法,必须配合synchronized使用,会释放锁,可通过notify()/notifyAll()或超时唤醒。

比较选项 sleep()方法 wait()方法
语法 属于Thread类,可直接调用,无需同步块 属于Object类,必须在synchronized代码块内调用
锁释放行为 休眠期间‌不释放‌持有的锁,其他线程无法获取该锁 主动释放锁,允许其他线程竞争锁资源。‌‌
唤醒机制 必须指定超时时间,时间结束后自动唤醒。‌‌ 依赖notify()/notifyAll()唤醒或超时自动唤醒
  • 说说线程池的核心参数有哪些,项目中线程数量设置的依据是啥

        线程池ThreadPoolExecutor包含7个核心参数,分别如下:

        1、corePoolSize 核心线程数

        2、maximumPoolSize 最大线程数,在核心线程数的基础上,额外增加线程数

        3、keepAliveTime 线程空闲时间,如果线程池当前的线程数多于corePoolSize,多余的线程空闲时间超过keepAliveTime,它们就会被终止

        4、unit 时间单位

        5、workQueue 阻塞任务队列

        常用的阻塞任务队列(队列为空时,获取队列要素时阻塞等待,队列满时,插入队列要素时阻塞等待)有:
        LinkedBlockingQueue:无界队列,队列任务可以多到Integer.MAX_VALUE
        SynchronousQueue:同步队列,即任务到来时,不放入队列等待,直接交给工作线程执行任务
        DelayedWorkQueue:延迟队列
        ArrayBlockingQueue:有界队列,队列容量大小确定
        PriorityBlockingQueue:优先级有界队列,队列容量大小确定

        6、threadFactory 线程工厂,当线程池需要新的线程时,通过它生成新的线程

        7、handler 拒绝策略,默认是AbortPolicy,会抛出异常。

        线程池中线程创建规则:

        1、若线程总数小于corePoolSize时,新任务到来时(即使有线程处于空闲状态)会创建一个新线程。

        2、若线程数等于corePoolSize,再来任务时,会把任务放入workQueue队列去等待。

        3、若workQueue队列满了,如果此时线程数小于maximumPoolSize,则会再创建新线程来执行任务。

        4、若workQueue队列已满,并且线程数已经扩大到等于maximumPoolSize时,再尝试添加任务时,则执行拒绝策略

        线程池线程数量设置建议:

        1、CPU密集型任务(大量计算):最佳线程数为CPU核心数的1-2倍左右。
        2、IO耗时型任务(读写数据库、文件、网络读写等):最佳线程数一般会大于CPU核心数很多倍。参考Brain Goetz推荐的计算方法,线程数=CPU核心数*( 1+平均等待时间/平均工作时间 )

  • 谈谈对CountDownLatch的理解

        CountDownLatch是Java并发编程中用于协调多个线程执行顺序的工具类‌,其核心机制是通过一个共享的计数器控制线程的阻塞与唤醒。它通过计数器来实现,初始值为线程的数量。每当一个线程完成了自己的任务,计数器的值就相应得减1。当计数器到达0时,表示所有的线程都已执行完毕,然后在等待的线程就可以恢复执行任务。CountDownLatch核心的API见如下:

API 说明
CountDownLatch(int count) count为计数器的初始值,通常为需要等待的线程数量
countDown() 线程调用一次,计数器值-1,直到count被减为0,代表所有线程全部执行完毕
await() 阻塞等待,直到计数器归零

        CountDownLatch典型的应用场景:并行任务的同步(当一个任务需要等待多个并行执行的任务全部完成才能继续时,可以使用CountDownLatch)

  • 说几个常用的系统性能评估指标

        1、吞吐量

        QPS:Query Per Second  系统每秒可以响应的请求读次数

        TPS:Transaction  Per Second 系统每秒可以响应的请求写次数

        2、响应耗时

        95线:95%的请求耗时在什么范围内

        99线:99%的请求耗时在什么范围内

    

JAVA框架

  • mybatis中#和$区别

        1、安全性
‌        #{}‌:采用预编译SQL机制(类似PreparedStatement),参数值被转换为占位符(如?),可防止SQL注入。 ‌
        ${}:直接将参数值拼接到SQL语句中,若参数包含恶意内容(如用户输入),可能导致注入风险。

        2、功能与场景
        ‌#{}‌:适用于普通参数替换(如条件查询、插入值),自动类型转换,推荐优先使用。 ‌
        ${}:用于动态表名、列名或需要拼接SQL片段的场景(如ORDER BY),需严格验证参数安全性。 ‌

  • 谈谈Spring的IOC和AOP   

        IOC:Inversion of Control 控制反转,是一种设计原则,spring 中通过DI(dependency Injection)来具体实现。比如原本对象的实例化,是通过程序主动New出来,IOC中的对象实例交给Spring框架来实例化,程序使用时直接通过spring获取即可。通过spring 容器ApplicationContext获取容器创建的bean,spring 默认创建的bean是单例的。

        AOP:Aspect Oriented Programming 面向切面编程。使用场景:将一些通用的功能封装成切面类,切面类作用在目标类方法的前后,并通过自动插拔实现目标类方法的前后逻辑。AOP实现需要如下组件:
        1、切面类(Aspect类)
        2、切点(Pointcut),即上图的各个目标方法,通过切点表达式(execution)实现
        3、连接点(JoinPoint) ,切面和切点之间的连接信息,可以理解为横切面和切点的交汇处
        4、通知(Advice) ,在目标类方法的之前、之后还是环绕执行切面逻辑

        AOP是基于代理模式实现切点方法的动态扩展。当切点目标类实现了接口,AOP通过JDK自带的动态代理扩展被代理对象方法的功能;当切点目标类未实现接口,Spring 通过CGLib组件实现扩展被代理对象方法功能。

  • 谈谈Spring的声明式事务

        Spring声明式事务,实现的原理就是AOP的环绕通知,在程序全部执行正常后,自动提交事务,在程序出现异常时,自动回滚事务。

        Spring事务传播方式如下:

        1、Spring事务的默认的事务传播特性是Required,即当前环境没有事务,则创建一个事务,如果已经在一个事务中,则加入这个事务中。
        2、requires_new 总是主动开启事务;如果存在外层事务,就将外层事务挂起
        3、supports 如果不存在外层事务,就不开启事务;否则使用外层事务
        4、not_supported 当前的方法不应该运行在事务中,如果当前有运行的事务,将它挂起
        5、mandatory 当前的方法必须运行在事务内部,如果没有正在运行的事务,就抛出异常
        6、never 当前的方法不应该运行在事务中,如果运行在事务中就抛出异常
        7、nested 如果有事务在运行,当前的方法就应该在这个事务的嵌套事务内运行,否则就启动一个新的事务,并在它自己的事务内运行

  • 说说SpringBoot启动流程

        启动入口:SpringApplication.run,启动后经历如下阶段:

        1、创建SpringApplication

        2、初始化构造器Initializer和监听器Listener

        3、环境准备,ConfigFileApplicationListener是用来加载properties文件

        4、打印Banner

        5、创建应用上下文  createApplicationContext()

        6、上下文预处理  this.prepareContext()

        7、刷新上下文 this.refreshContext,里面的onRefresh方法启动webserver

        8、刷新后处理  this.afterRefresh 

        9、listeners启动完成,打印启动时间

  • 单节点服务下,如何做服务限流控制

        限流包含限制并发数、限制TPS、QPS

        示例(限制并发数):假设某服务接口的并发数限制在10以内,通过AOP环绕此接口做计数器,当计数器>10时,返回调用者限流异常,伪代码如下:

AtomicInteger count = new AtomicInteger(0);
int maxConcurrence = 10;//服务接口最大能接收的并发数
if(count.get() < maxConcurrence ) {
	count.incrementAndGet(); //count++
	//doSomething 操作服务接口逻辑
	count.decrementAndGet(); //count--
}else {
	throw new RuntimeException("请求并发数超过最大值,请稍后尝试...");
}

        示例(限制QPS、TPS):假设某服务接口的QPS限制在10以内, 通过AOP环绕此接口做计数器递减,开启另外一个线程,每秒重置计数器=maxQPS,伪代码如下:

int maxQPS = 10;//服务接口每秒最大接收的查询数量
AtomicInteger count = new AtomicInteger(maxQPS);
if(count.get() > 0 ) {
	count.decrementAndGet(); //count--
	//doSomething 操作服务接口逻辑
}else {
	throw new RuntimeException("接口QPS超过最大值,请稍后尝试...");
}

        在集群模式下,限流实现思路和单点类似,将上述的计数器资源从单节点的jvm中转移到集中储存的Redis中。

  • 为什么要实现微服务熔断,熔断的方案有哪些

        服务熔断是分布式系统中关键的容错机制,其核心作用是通过阻断故障传播来防止系统级联崩溃。该机制通过持续监控服务调用状态,在异常请求达到预设阈值时自动触发熔断,快速拒绝后续请求以保护系统资源。实际项目中,服务熔断可以采用下述标准:

        1、失败率触发:一段时间内服务接口出错的概率,超过阈值则触发接口熔断

        2、失败次数触发:一段时间内服务接口出错次数总和,超过阈值则触发接口熔断

        微服务架构下,实现接口熔断的背景:在高并发的场景下,由于A服务不可用,导致B调用A一直等待,迟迟不能释放资源,进而导致B服务不可用,进而导致CD服务不可用,最终整个项目服务不可用,产生了服务崩塌。

        实现熔断方案主流的有:Sentinel与Hystrix两大工具。

        服务熔断后,间隔一段时间后,需要启动熔断恢复,主要有:全恢复和半转全。

        全恢复:一段时间后,恢复接口的全部能力,比如配置的限流QPS是10,恢复时就是10

        半转全:一段时间后,部分恢复接口的能力,假设设置恢复20%,接口没问题后,再全部恢复接口的服务能力,实际项目往往采用此种模式。

Redis

  • Redis如何做数据持久化

        方式一:通过RDB(快照)持久化,保持某个时间点全量数据快照

        方式二:AOF(Append-Only-File)持久化,记录除查询以外所有变更数据库状态的指令

        RDB和AOF比较:

RDB
优点 缺点
全量数据快照,二进制文件小,恢复快 最近一次快照之后的数据会丢失
AOF
优点 缺点
文本文件,可读性好,数据不容易丢失 文件体积大,恢复慢

        实际项目中redis数据持久化: RDB+AOF混合持久化方式保存数据,即RDB做全量数据持久化,增量数据持久化交给AOF。

  • Redis缓存淘汰策略

        需要缓存淘汰的原因:如果Redis服务器的内存已经满了,现在还需要向Redis中保存新的数据,就需要依据配置的缓存淘汰策略删除缓存,有以下策略:

        1、noeviction:不删除任何数据,当内存使用达到上限时,新的写入命令会报错,默认就是这种策略。

        2、volatile-lru(Least Recently Used):从设置了过期时间的键中,移除最久未使用的键。

        3、allkeys-lru:从所有键中,移除最久未使用的键。

        4、volatile-lfu(Least Frequently Used):从设置了过期时间的键中,移除最不常访问的键。

        5、allkeys-lfu:从所有键中,移除最不常访问的键。

        6、volatile-random:从设置了过期时间的键中,随机移除一些键。

        7、allkeys-random:从所有键中,随机移除一些键。

        8、volatile-ttl:移除那些在过期时间内将被删除的键(TTL,Time To Live)。

  • 说说Redis缓存雪崩及解决办法

        缓存雪崩:由于大量缓存同时过期或者redis故障导致外部请求集中请求到数据库,进而导致数据库瞬时压力过大。

        解决缓存雪崩的方案主要有2种:

        1、设置随机的缓存的过期时间,如在固定缓存过期时间比如30分钟的基础上+随机秒数

        2、采用分布式锁,当redis缓存同时失效,并且高并发的请求落到后台服务时,通过分布式锁(比如基于Redisson的分布式锁)只让一个请求访问数据库(其他请求等待),待获取信息后放入缓存,其他请求直接可以从缓存中获取。

  • 说说Redis缓存穿透及解决办法

        缓存穿透:前台大量请求后台的数据,在redis缓存中不存在,进而去大量查询数据库,而数据库中也不存在数据,导致每次请求都会到到数据库查询。

        解决方法:将数据库查询不到的数据也写入redis缓存,比如设置为-1等特定的值,这样下次请求时就直接从redis缓存中获取,进而不用访问数据库。注意要根据实际业务情况确认此数据在数据库中一定不存在。

  • 说说Redis缓存数据和数据库数据不一致的原因和解决办法

        Redis缓存数据和数据库数据不一致,很大原因来自于高并发场景下,并发请求同时更新数据库数据及缓存数据导致,下方示例演示了2种场景导致缓存数据和数据库数据不一致:

        场景一:2个并发请求先更新数据库,再更新缓存,最终数据库数据Y,缓存数据X,出现了不一致。

        场景二:2个并发请求先更新缓存,再更新数据库,最终缓存数据Y,数据库数据X,出现了不一致。 

        解决方法:

        1、缓存设置合理的过期时间(TTL, Time To Live),可以让数据在一段时间后自动从缓存中移除,迫使下一次访问时重新从数据库加载数据,使得数据不一致的时间缩短。

        2、使用定时任务,异步线程等同步数据库和缓存数据

        3、分布式锁,在高并发场景下,可以使用分布式锁(如基于Redisson分布式锁)来控制对共享资源的访问,确保同一时间只有一个操作在更新缓存和数据库。

消息队列

  • 说说Kafka的架构及核心概念

        Kafka是一个开源的分布式事件流平台(Event Streaming Platform),主要作用:消息异步、服务解耦等。

        Kafka有如下重要组件:

        1、Producer:kafka消息生产者

        2、Broker:kafka集群中的每个节点,这些节点数据被zookeeper管理

        3、Consumer:Kafka消息消费者

        4、Topic:kafka消息主题,是逻辑概念

        5、Partition:分区,具体存储kafka消息的地方,一个Topic有多个Partition

        6、Replica:副本,在kafka集群中,每个分区在不同的broker节点中有副本,Kafka 通过副本机制实现高可用。

  • 如何编码实现Kakfa多线程消费消息

        实际项目中,分区partition中的消息会快速积累很多,为了满足消息的及时性,项目需要及时可靠的将消息消费出去,下图示例演示了通过多个消费者并发的消费多个分区消息的思想实现消息快速消费。

package com.gingko.quickstart.kafka;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class Consumer {
    public static void main(String[] args) {
 
        Properties properties = new Properties();
        //集群地址
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,"192.168.136.128:9092");
        //KEY和Value的反序列化
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        /**
         * 消费者组,一个消费者组内有多个消费者,组内的消费者会按照kafka的均衡策略分别处理不同的分区消息,一个分区信息只会被组内的一个消费者消费;
         * 不同组内的消费者,都会消费分区信息,相当于广播
         */
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
        /**
         * 消费者从什么位置开始拉取消息,有三种方式:"latest","earliest","none'
         * none
         * latest 对于其他分组不同GROUP_ID_CONFIG的消费者,从一个分区的最后提交的offset开始拉取消息,默认值
         * earliest 对于其他分组不同GROUP_ID_CONFIG的消费者,从最开始的起始位置拉取消息
         */
        properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"latest");
        //消息手动提交
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
        //一次拉取最大数据量,默认为50M
        properties.put(ConsumerConfig.FETCH_MAX_BYTES_CONFIG, 52428800);
        //一次fetch请求,从一个partition中取得的records最大大小 默认1M
        properties.put(ConsumerConfig.MAX_PARTITION_FETCH_BYTES_CONFIG, 1048576);
        //Consumer每次调用poll()时取到的records的最大数默认为500条
        properties.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
 
        //订阅主题
        String topic = "test-kafka";
 
        //构造线程池,5个分区,用5个消费者线程来消费
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for(int i=0;i<5;i++) {
            //5个分区消息任务
            executorService.submit(new MutiThreadConsumer(properties,topic));
        }
        executorService.shutdown();//关闭线程池
    }
}
package com.gingko.quickstart.kafka;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;
import java.time.Duration;
import java.util.*;
 
/**
 * 多线程消费者
 */
public class MutiThreadConsumer implements Runnable{
 
    //消费者
    private KafkaConsumer<String,String> kafkaConsumer;
 
    public MutiThreadConsumer(Properties properties,String topic) {
        kafkaConsumer = new KafkaConsumer<String, String>(properties);
        //订阅主题
        kafkaConsumer.subscribe(Collections.singletonList(topic));
    }
 
    @Override
    public void run() {
        while (true) {
            try {
                //拉取获取消息,获取此主题的所有消息
                ConsumerRecords<String, String> consumerRecords = kafkaConsumer.poll(Duration.ofMillis(1000));
                //主题对应的分区列表
                Set<TopicPartition> topicPartitions = consumerRecords.partitions();
                //遍历每个分区
                topicPartitions.forEach(topicPartition -> {
                    //获取此分区的对应的消息列表
                    List<ConsumerRecord<String, String>> records = consumerRecords.records(topicPartition);
                    //遍历每条消息
                    records.forEach(record -> {
                        String key = record.key();
                        String value = record.value();
                        //消息在partition中的位置
                        long offset = record.offset();
                        String str = "收到的消息,key:" + key + ",value:" + value + ",offset:" + offset + ",partition:" + topicPartition
                                + ",被线程" + Thread.currentThread().getName() + "消费了";
                        System.out.println(str);
                        //对于消息可靠性有要求的,建议消息一条条提交
                        Map<TopicPartition, OffsetAndMetadata> offsetMap = new HashMap<>();
                        //手动提交,提交的offset,向后移动一位
                        long commitOffset = offset + 1;
                        offsetMap.put(topicPartition,new OffsetAndMetadata(commitOffset));
                        kafkaConsumer.commitSync(offsetMap);
                    });
                });
            }finally {
                if(null != kafkaConsumer) {
                    kafkaConsumer.close();
                }
            }
        }
    }
}
  • 解释Kafka消息丢失原因及解决办法

        生产者在发送数据时、kafka集群存储数据时、消费者消费消息时都有可能发生消息丢失,示意图如下:

        处理办法如下:

        【生产者在发送数据时消息丢失】由于网络故障等导致消息发送失败,解决办法有:

        1、设置自动重试及重试间隔

        2、同步发送send后,同步等待kafka返回信息,如果返回失败,手工编写失败逻辑(如再次发送等)

        【kafka集群存储数据时消息丢失】,解决方法:

        1、Kafka可以通过配置flush.messages和flush.ms来设置刷盘策略。flush.messages设置每多少条消息刷盘一次,而flush.ms设置每隔多少毫秒刷盘一次。

        2、配置多分区副本,实现数据高可靠

        【消费者消费消息时发生消息丢失】,由于配置了自动提交,消息还没处理完发生了异常,导致消息被提交了,解决方法:关闭自动提交,消息消费成功后手工提交offset

  • 解释Kafka消息重复消费原因及解决办法

        消息重复消费:消费端已经消费了数据,但是偏移量offset没有提交,导致下一个消费者会继续消费此条消息,重复消费的原因有如下:

        1、消费者服务消费消息,还未提交offset之前宕机重启等

        2、提交offset之前,系统增加了kafka分区或消费者,kafka发生了再均衡

        重复消费的解决办法:消费端实现消息消费接口的幂等性,即即使重复消费消息业务也可以持续准确运行。

数据库

  • 说说mysql数据库的悲观锁、乐观锁及实现方式

        悲观锁:悲观锁假定会发生冲突,因此在数据被访问之前就加锁。这通常通过数据库事务来实现,确保在事务执行期间,数据不会被其他事务修改。

        悲观锁的实现方式:在 MySQL 中,可以通过 SELECT ... FOR UPDATE 语句来锁定被选中的行,直到事务结束。示例图下:

        1、先关闭数据库自动提交(set autocommit = 'OFF')

        2、在一个session中执行:select * from user where id =1 for update,查询出数据,在另外一个session中查询此条数据,报【Lock wait timeout exceeded】

        3、更新数据并且手工提交后,另外一个session的查询语句查出结果。

        update user set name = 'wangwu' where id =1;
        commit ;

        悲观锁适用场景:悲观锁适用于高冲突的场景,当你预期多个事务会频繁修改同一资源时,它通过锁定资源来防止冲突,但可能会影响并发性能。

        乐观锁:乐观锁假设冲突不会发生,只在数据提交时检查是否有冲突。

        乐观锁实现方式:这通常通过在数据行中添加一个版本号(version number)或时间戳(timestamp)来实现。示例代码以版本号(Version Number)的方式实现乐观锁,在数据库表中添加一个版本号字段,每次更新数据时增加该版本号。在更新数据时检查版本号是否匹配,如果不匹配则说明数据已被其他事务修改。

        update user set name = 'xxx' version_num = version_num +1 where id =1 and version_num = xxx

        乐观锁适用场景:适用于冲突较低的场景,当你预期数据在事务期间不会被频繁修改时。它通过事后检查冲突来避免锁定开销,但需要额外的逻辑来处理冲突。

        下图是悲观锁和乐观锁对于创建订单的操作区别示意图。

        特别注意:使用悲观锁时,锁的粒度控制在行级别,就需要where条件的字段是索引,如果是普通字段,行锁就会变成表锁,会使得访问这张表的功能都受限,务必避免。

  • 谈谈mysql中的行锁、表锁

        行锁是最细粒度的锁,它只锁定数据库表中的特定行。这意味着在事务中对某一行的操作不会影响到其他行的操作,从而提高了并发性能。

        示例:select * from user where id = 1 for update,通过user表的主键字段或唯一索引过滤的数据for update后,锁加在对应表的行记录上。

         表锁是较为粗粒度的锁,它锁定整个表。当一个事务对表加锁时,其他事务必须等待该锁释放后才能访问该表。

        示例:select * from user where name = '张三' for update,通过user表的非索引字段过滤的数据for update后,锁加在整张表上。如果name是普通索引(非唯一索引)字段,上述语句会加行锁以及以索引数据为区间的间隙锁(即索引数据上下对应的行记录也会被上锁)。

  • 谈谈数据库中的脏读、不可重复读、幻读及数据库事务隔离级别

        脏读:一个事务读取到另一个未提交事务修改的数据,以转账为例说明脏读,如下:

时间顺序   事务1 事务2
1 开启事务
2 开启事务
3 查询账户余额为1000
4 充值2000,当前余额3000
5 查询此时账户余额为 3000 
6 业务异常,回滚充值,余额回到1000
7 消费1000,余额为2000
8 提交事务
9 正确余额应该为 0 ,因为产生了脏读,导致事务 2 读取到了事务 1 未提交的数据,因此最后剩余 2000 元

        不可重复读:一个事务内多次读取同一数据时,结果不一致(因其他事务修改并提交了数据)。 

时间顺序   事务1 事务2
1 开启事务
2 开启事务
3 查询账户余额为3000
4 消费 2000 ,当前余额为 1000 
5 提交事务
6 第二次查询账户余额为 1000 
7
8 两次查询的账户余额应该一致,目前出现了不一致

        幻读:一个事务按条件查询数据时,两次查询结果集不同(因其他事务插入/删除了符合条件的新数据)。

时间顺序   事务1 事务2
1 开启事务
2 开启事务
3 查询表记录数100
4 删除1条记录
5 提交事务
6 查询表记录数99
7
8 两次查询的表记录数不一致

        数据库通过事务隔离级别控制不同事务间的可见性,本质是在性能和数据一致性间做权衡,隔离级别如下:

        READ UNCOMMITTED(读未提交):允许脏读、不可重复读、幻读,性能最高(锁少),低隔离级别。

        READ COMMITTED(读已提交):避免脏读,但允许不可重复读和幻读,中等隔离级别。

        REPEATABLE READ(可重复读):可能发生幻读问题,但是不可以发生脏读和不可重复读的问题,高等隔离级别。MySQL的默认隔离级别。

        SERIALIZABLE(串行化):脏读、不可重复读、幻读都不出现,最高隔离级别。

  • B+树索引和hash索引的区别

        在数据库系统中,索引是用于提高数据检索速度的一种数据结构。常见的索引类型包括B树索引(B-tree index)和哈希索引(Hash index)。

        B+索引:支持高效的范围查询和排序操作,能够保持数据的顺序,适合需要按顺序访问数据的场景。

        Hash索引:使用哈希表来实现,通过哈希函数将键值映射到表中的位置。对于等值查询,哈希索引通常提供非常快的访问速度。

        Hash索引不支持范围查询,由于项目中无法预知索引是否要支持范围查询,所以建立索引时往往采用B+树索引

  • 谈谈项目中是如何优化SQL及索引的

        首先通过explain检测sql的执行计划,优化后sql的连接类型type至少满足range级别。

        system:表中仅有一行(系统表)这是const联结类型的一个特例。
        const:针对主键或唯一索引的等值查询扫描,最多只返回一行数据.const 查询速度非常快,因为它仅仅读取一次即可。
        eq_ref:唯一性索引扫描,对于每个索引键,表中只有一条记录与之匹配。常见于唯一索引或者主键扫描。
        ref:非唯一性索引扫描,返回匹配某个单独值的所有行,本质上也是一种索引访问,它返回所有匹配某个单独值的行,可能会找多个符合条件的行,属于查找和扫描的混合体。
        range:只检索给定范围的行,使用一个索引来选择行。key列显示使用了哪个索引,一般就是where语句中出现了between,in等范围的查询。这种范围扫描索引扫描比全表扫描要好,因为它开始于索引的某一个点,而结束另一个点,不用全表扫描。
        index:index 与all区别为index类型只遍历索引树,通常比all快,因为索引文件比数据文件小很多。
        all:遍历全表以找到匹配的行。

        项目中给表添加索引时满足如下原则:

        1、经常被查询并且区分度高的字段建立索引,区分度不高的不适合建立索引,比如状态,性别等字典项。

        2、建立的索引符合最左原则,防止索引失效(MySQL数据库调优-CSDN博客 介绍了索引失效及解决办法)

        3、使用覆盖索引,select 的字段尽量少,最好select后面只有索引字段

        4、join查询时,建议小表驱动大表 

  • 说说mysql中的binlog及应用场景

        MySQL binlog日志作用是用来记录mysql内部增删改等对mysql数据库有更新的内容的记录(对数据库的改动),对数据库的查询select和show等操作不会被binlog日志记录。

        MySQL binlog有3种记录模式,分别是Statement(每一条被修改数据的sql都会记录到master的bin-log中) 、Row(日志中会记录每一行数据被修改的情况) 、Mixed(混合了Statement+Row),项目中应用的比较多的是Row。

        binlog的应用场景,从数据库层面看,主从复制和数据恢复都依赖binlog。从业务角度看,实际项目中往往需要实时采集数据库的变化,下方示意图通过Maxwell采集binlog信息并发送到kafka。

  • 数据库数据量很大时,如何优化项目使其高效快速运行

        可以考虑分区、分库、分表的方案应对业务数据的快速增长。

        分区:使用mysql自带分区功能实现。

        分库、分表采用ShardingSphere实现,参考:分库分表-ShardingSphere-CSDN博客  

        对于读多写少的场景,可以采用读写分离, ShardingSphere也可以实现读写分离。

        在实际项目中,分库分表分多少合适,需要根据实际业务情况评估表数据量级,比如t_user表预估会到1亿数据量,单表控制不超过1000万的数据量,则需要分10张表,通过hash(user_id主键)%10就可以确定记录水平分配到具体哪张表中。 

  • 实际项目对mysql有哪些方面的优化措施

        实际项目中,大部分mysql采用InnoDB引擎,可以从以下3个方面对单机-mysql进行优化:

        1、写优化

        对数据库的循环写操作改成批量写操作,即for each {insert into table values (xxx)}  改成 insert into table values (1),(2),(3),(4)....

        2、查询优化

        1)通过explain查看sql查询计划,分析出慢查询

        2)索引优化

        3)尽量避免全表扫描和文件排序

        4)查询优化理想目标为:主键查询-->千万条记录 1-10ms ;唯一索引-->千万条记录 10-100ms;非唯一索引-->千万条记录 100-1000ms;无索引-->百万条记录 1000ms+(尽量避免无索引查询)

        3、数据库本身优化(主要InnoDB引擎)

        1)根据数据库配置设置合理连接数 :max connection=xxx 增加最大链接数,默认为100

        2)设置表和索引文件存储:建议innodb_file_per_table=1 ,每个innodb表和他的索引存储在自己的文件中

        3)设置缓冲池大小:建议innodb_buffer_pool size=xxx 设置为当前数据库服务内存的50%-80%

        4)设置日志文件大小 :建议innodb_log_file_size=256M  ,一般256M可以兼顾性能和recovery的速度

        5)设置日志缓冲大小:建议innodb_log_buffer_size=16M,该参数确保有足够大的日志缓冲区来保存脏数据在被写入到日志文件之前可以继续mysql事务操作

        6)设置InnoDB日志刷新到磁盘的策略:建议innodb_flush_log_at_trx_commit = 2

        0:在这种模式下,事务提交时,InnoDB会先将日志写入到操作系统的缓冲区,然后由操作系统决定何时将缓冲区中的数据刷新到磁盘。这种方式下,如果在操作系统层面发生崩溃,可能会导致最近的事务丢失。

        1:在这种模式下,事务提交时,InnoDB会在事务提交的瞬间将日志刷新到磁盘。这是最安全的设置,可以最大限度地保证事务的持久性,但可能会降低性能,因为每次提交都需要进行磁盘I/O操作。

        2:在这种模式下,事务提交时,InnoDB会先将日志写入到操作系统的缓冲区,然后在大约每秒一次的频率将缓冲区中的数据刷新到磁盘。这种方式结合了性能和安全性,既减少了磁盘I/O操作,又提供了较好的数据安全性。

        当项目需要满足高并发的场景,单节点数据库已不能满足要求,通过mysql读写分离或集群增加TPS、QPS的能力。

        当项目数据库数据量很大时,可以通过分库分表的方式提高项目的运行速度。

网络

  • 解释TCP三次握手

        TCP:面向连接的、可靠的、基于字节流的传输层通信协议。TCP提供可靠的连接服务,采用三次握手建立一个连接。

        第一次握手:客户端向服务器端发送SYN包(seq=x),客户端进入SYN-SENT状态,等待服务器确认。

        第二次握手:服务器接收SYN包,确认客户的SYN包(ack=x+1),同时发送一个SYN包(seq=y),即SYN+ACK包,服务器进入SYB_RECV状态。

        第三次握手:客户端接收SYN+ACK包,向服务器端发送ACK确认包(ack=y+1),此后客户端和服务器进入建立连接状态ESTABLISHED,完成3次握手。

        下方示例通过抓包工具抓取地址:30天高温高湿挑战 济南迎近十年最短“三伏天”-新华网,观察3次握手。从下图看出,客户端(本机ip:192.168.124.24)发送SYN包(seq=0)到服务器114.66.248.58,服务器收到后,向客户端发送SYN(seq=0)+ACK(ack=0+1)包到客户端,客户端收到后,发送ACK包(seq=0+1,ack=0+1)给服务器,最终2者建立连接。

  • TCP和UDP的区别

比较项目 TCP UDP
面向连接 面向连接 无连接
数据可靠性 有连接,消息可靠 消息不可靠
数据有序性 数据有序 不保证有序
适用场景 需要保证数据的完整性和顺序性的场景 实时多媒体应用:如实时音频、视频流传输,游戏数据传输等,需要低延迟和高实时性。
  • 浏览器输入网址,网络上经历了哪些流程

        1、DNS解析网址到对应的IP地址

        2、浏览器本机和远程IP主机建立TCP连接(3次握手)

        3、连接建立后,浏览器发送HTTP请求到服务器

        4、服务器处理客户端请求,并返回HTTP报文到浏览器

        5、浏览器解析内容并渲染页面

        6、连接结束,释放TCP连接

  • 说说常见的HTTP状态码

        1、200 OK 服务器正常返回信息

        2、401 Unauthorized:请求没有授权

        3、403 Forbidden:服务器收到客户端请求,但拒绝提供服务

        4、404 Not Found:请求资源不存在,往往URL地址错误

        5、500 Internal Server Error:服务器发生了错误

  • 说说HTTPS的连接过程

  • HTTP和HTTPS的区别

比较项 HTTP HTTPS
是否需要申请证书 否  需要申请CA证书
明文/密文传输 明文传输 密文传输
默认端口 80 443
安全 相对不安全 相对安全
HTTPS=HTTP+加密+认证