(41-60)计算机 Java后端 实习 and 秋招 面试高频问题汇总

写在前面

我们这一届的秋招已经结束了,回想过去几个月,从暑期实习到秋招,我总计经历了上百场面试,也算是小有经验,也阅读了数千篇面经,受益匪浅。所以最终我想要做这样一份这样的汇总,是因为在我经历秋招最艰难、最迷茫的时候,正是众多前辈和同学们分享的宝贵经验与经历,陪伴并帮助我度过了那些焦虑而迷茫的时刻。对于他们的无私分享,我心怀深深的感激,也希望能够尽自己的一份绵薄之力,继续帮助更多后来的人。

在过去的两年里,我陆陆续续地积累了大约 30 万字的内容,涵盖了近 300 个后端高频面试问题(有些太过刁钻的我会做删除处理,不保证最终达到300个),如果之后时间允许,我会将它们逐步整理并分享出来。在可以预见的未来,如果没有意外的变动,这些内容都会免费地“开源”,因为我曾经无数次从别人的分享中获得过帮助,如果有机会,我愿意把这份温暖和善意传递下去

前文链接

1-20 https://www.nowcoder.com/discuss/731538952120696832

21 -40 https://www.nowcoder.com/discuss/734364149513220096

41.redis中分布式锁提前过期会怎么样

在Redis中使用分布式锁时,如果锁提前过期,可能会引发几个问题,主要取决于锁的使用场景和具体的实现方式。分布式锁通常用于确保在分布式系统中对共享资源的访问是同步的,以避免数据不一致、数据覆盖或其他并发问题。如果锁提前过期,可能会有以下影响:

  1. 安全性问题:如果一个进程(或线程)持有锁进行操作,而锁突然过期,另一个进程就可能获得锁并开始操作相同的资源。这可能导致数据不一致或数据损坏,因为两个进程可能会同时修改同一资源。
  2. 性能问题:锁提前过期可能导致更多的竞争和锁争夺,从而增加系统的负载和延迟。
  3. 死锁:虽然Redis的锁提前过期不直接导致死锁,但如果因为锁提前过期而引发的逻辑错误导致某些进程无法释放其他资源,可能间接导致系统出现死锁状态。

为了减少锁提前过期的风险和影响,可以采用以下策略:

  • 合理设置锁的过期时间:确保锁的过期时间足够长,以覆盖预期内的操作时间,同时避免设置过长的过期时间,以减少资源锁定的时间。
  • 使用续租机制:对于执行时间较长的操作,可以实现锁的续租机制,即在锁快要过期时,检查当前进程是否仍然需要锁,如果需要,则更新锁的过期时间。
  1. 锁的创建与过期时间设置:在Redis中,分布式锁通常使用SET命令加上NX(只在键不存在时设置键)和PX(设置键的过期时间,单位为毫秒)选项来实现。例如,SET lock_key unique_value NX PX 30000这个命令试图获取一个名为lock_key的锁,其中unique_value用于标识锁的拥有者,确保只有锁的持有者才能释放该锁。PX 30000表示这个锁的过期时间是30000毫秒(30秒)。
  2. 锁的续租:续租机制的实现需要客户端定期更新锁的过期时间,以防止在持有锁的操作未完成时锁过期。这可以通过重新设置过期时间来完成,比如使用PEXPIRE命令:PEXPIRE lock_key 30000,将lock_key的过期时间再次设置为30000毫秒。续租操作需要在一个独立的线程或者定时任务中执行,频率应该小于锁的过期时间,以确保锁不会因为超时而被误解。PEXPIRE 在英文中可以读作 "P-expire"
  3. 安全的续租实现:为了确保续租操作的安全性,即只有锁的持有者才能续租该锁,可以使用Lua脚本来原子性地检查unique_value并更新过期时间。这样做可以避免在检查和设置过期时间之间的时间差导致的安全问题。 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end 上述Lua脚本首先检查lock_key对应的值是否为unique_value,即确认当前客户端是否为锁的持有者,如果是,则更新锁的过期时间;如果不是,则不做任何操作。
  4. 锁的释放:锁的持有者在操作完成后应该立即释放锁,这同样可以通过一个Lua脚本安全地完成,确保只有锁的持有者才能释放锁。 luaCopy codeif redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

通过以上步骤,可以实现一个较为安全且具有续租机制的Redis分布式锁,有效防止因操作延时导致的锁自动释放问题,增强了分布式环境下资源访问的控制效率和安全性。然而,需要注意的是,Redis的锁机制并不是完全可靠的,它依赖于网络环境和Redis服务器的状态。在高要求的生产环境中,可能需要结合其他工具和策略来确保分布式锁的可靠性。 同时,一些开源的Redis客户端或框架也提供了现成的分布式锁实现,如Redisson,可以直接使用。 为了确保只有锁的持有者才能续租该锁,我们可以在获取锁时为锁关联一个唯一标识符(如UUID),然后在续租时验证续租请求是否携带了与锁关联的标识符。这样可以防止其他客户端恶意续租锁。 以下是实现步骤:

  1. 获取锁时,生成一个唯一标识符(如UUID),将其与锁的键一起存储到Redis中。
  2. 在续租锁时,客户端需要携带这个唯一标识符。
  3. Redis使用Lua脚本来验证续租请求的标识符与锁关联的标识符是否一致,只有一致时才允许续租。
  • 锁释放的幂等性:确保锁的释放操作是幂等的,即多次释放操作和一次释放操作的效果相同,这样即使在锁提前过期的情况下也能安全地释放锁。
  • 检查锁的拥有权:在执行操作之前和之后,检查当前进程是否真正拥有锁。这可以通过在Redis中存储一个唯一的锁标识符来实现,只有当进程知道正确的标识符时才能获得或释放锁。

通过上述措施,可以减轻Redis分布式锁提前过期带来的风险和影响,保障分布式系统的稳定性和一致性。

42.jvm内存模型 堆空间的结构?分配策略有哪些?

JVM的内存模型主要分为以下几个区域:

  1. 方法区:这个区域用于存储已被加载的类信息,常量,静态变量,编译器编译后的代码等数据。
  2. 堆区:这是Java中最大的一块存储区域,几乎所有的对象都是在这里分配内存的。堆是被所有线程共享的一块区域,可被分为新生代和老年代。新创建的对象首先分配在新生代,经过一次 Minor GC 后,如果有存活的对象,会被移到老年代。
  3. 栈区:每个线程创建时都会创建一个虚拟机栈,每个方法被执行的时候都会动态创建一块栈帧用于存储局部变量表,操作数栈,方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  4. 程序计数器(PC寄存器):这是最小的存储区域,其容量只有几个字宽。可以看作是当前线程所执行的字节码的行号指示器。改变计数器的值来选取下一条需要执行的字节码指令。
  5. 本地方法栈:和虚拟机栈功能类似,他们之间的区别不过是虚拟机栈为虚拟机执行Java方法 (也就是字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。

在Java中,堆空间(Heap Space)是由JVM在启动时创建的内存区域,它用于存储运行时数据,主要用于存放对象实例和数组。了解Java堆空间的结构对于优化应用程序和垃圾收集(Garbage Collection, GC)有很大帮助。

堆空间的结构

Java堆空间的结构从JDK 7和JDK 8开始有所变化,这里主要介绍JDK 8及以后版本的结构:

  1. 年轻代 (Young Generation):
  • Eden空间: 大多数情况下,新创建的对象首先被分配到Eden区。
  • 幸存者区 (Survivor Spaces): 包括两个部分,Survivor from/to 1 (S1) 和 Survivor 2 (S2)。它们用来存放从Eden区经过第一次垃圾收集仍然存活的对象。
  1. 老年代 (Old Generation):
  • 存放长时间存活的对象。当对象在年轻代经过一定次数的垃圾收集后仍然存活,就会被移动到老年代。
  1. 永久代/元空间 (PermGen/Metaspace,取决于JDK版本):
  • JDK 7之前: 永久代(PermGen)用于存放Java类和方法信息。
  • JDK 8之后: 元空间(Metaspace)替代了永久代。Metaspace并不在虚拟机的堆内存中,而是使用本地内存。这一变化是为了避免永久代内存溢出(OutOfMemoryError)的问题。

分配策略

堆空间的分配策略指的是JVM如何在堆空间中分配内存给新创建的对象和数组。一些常见的分配策略包括:

  1. 对象优先在Eden分配: 新建对象通常会在Eden区分配。当Eden区填满时,会触发一次Minor GC。
  2. 大对象直接进入老年代: JVM有一个阈值来定义大对象。如果对象超过了这个大小,会直接被分配到老年代,以避免在Eden区和两个Survivor区之间复制产生的开销。
  3. 长期存活的对象将进入老年代: 每个对象都有一个年龄计数器,当对象在年轻代中存活足够的垃圾收集周期,并且超过了某个年龄阈值后,会被移动到老年代。
  4. 动态对象年龄判定: 如果Survivor空间中相同年龄所有对象大小的总和超过Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到阈值年龄。 至于为什么这个规则不适用于Eden空间,原因是Eden空间和Survivor空间的使用目标不同。Eden空间主要用于存放新创建的对象,而Survivor空间则用于存放已经存活的对象,因此,对于长时间存活的对象,我们希望它们能够尽快被移到老年区,以保持Survivor空间的流动性。
  5. 空间分配担保: 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于年轻代所有对象的总空间,如果不足,则进行Full GC来清理老年代的空间。

43.linux文件有哪些类型?各自的作用是什么?

在Linux操作系统中,文件是存储数据的基本单位。Linux中的文件类型多样,每种类型的文件都有其特定的作用和用途。以下是Linux中常见的几种文件类型及其作用:

  1. 普通文件(Regular Files):这是最常见的文件类型,用于存储数据,比如文本文件、程序脚本或二进制程序等。普通文件可以包含文本、源代码或程序数据。
  2. 目录(Directories):目录是一种特殊类型的文件,用于组织文件系统中的文件和其他目录。目录文件包含了它所包含的文件和目录的列表及其相关信息。
  3. 字符设备文件(Character Device Files):这种文件类型代表一种设备,比如键盘、鼠标或其他输入/输出设备,它们是以字符流的形式进行数据传输的。
  4. 块设备文件(Block Device Files):块设备文件也代表设备,但与字符设备不同,它们以数据块的形式存储数据,通常用于磁盘驱动器或其他存储设备。
  5. 链接(Links):链接是指向另一个文件的引用。有两种类型的链接:硬链接(hard link)和符号链接(symbolic link,又称软链接)。硬链接是对文件的另一个引用,它与原文件共享相同的数据。符号链接则是一个特殊的文件,包含了另一个文件的路径。
  6. 管道(Pipes):管道是一种特殊类型的文件,主要用于进程间通信。它允许一个进程和另一个进程之间的数据流动。
  7. 套接字(Sockets):套接字也是一种特殊类型的文件,用于在网络中的不同主机间或同一主机上的不同进程间进行通信。
  8. 设备文件(Device Files):设备文件是系统用来访问硬件设备的接口文件,包括字符设备文件和块设备文件。

每种文件类型在Linux系统中扮演着不同的角色,它们共同协作,使得Linux系统能够高效地管理数据和资源。

44.inode的作用?inode包含哪些内容?给出一个文件名,Linux是如何根据该文件名打开文件的?(文件名->inode->block)文件的访问时间是如何记录的?

在Linux文件系统中,inode(索引节点)是一个非常核心的概念,它用于存储文件的元数据(不包括文件名和文件实际内容)。每个文件或目录都有一个与之对应的inode,其中包含了该文件的几乎所有信息。

inode的作用

  • 存储文件元数据:inode存储了关于文件的所有基本信息,除了文件名和实际数据内容。
  • 文件系统操作:在执行文件系统操作如打开、读取、写入文件时,系统会使用inode中的信息来进行这些操作。

inode包含的内容

inode包含的信息大致可以分为以下几类:

  • 文件类型:文件是普通文件、目录还是链接文件等。
  • 权限和所有权:文件的访问权限(读、写、执行)以及文件的所有者和所属组。
  • 时间戳:文件的创建时间、最后访问时间、最后修改时间等。
  • 文件大小:文件内容的大小(字节为单位)。
  • 指向文件数据块的指针:这些指针实际上指向存储文件数据的磁盘块。

从文件名到打开文件的过程

当给出一个文件名时,Linux系统如何根据该文件名打开文件的过程通常遵循以下步骤:

  1. 查找目录项:系统首先在当前目录下查找文件名对应的目录项。目录本身也是一种文件,其内容是一系列的目录项,每个目录项将一个文件名映射到一个inode号。
  2. 获取inode:一旦找到文件名对应的目录项,系统就会读取该目录项中的inode号,并通过这个inode号在文件系统的inode表中找到对应的inode。
  3. 读取文件数据:系统根据inode中存储的文件数据块位置信息,找到文件数据存储在磁盘上的位置,进而可以读取、写入或修改文件数据。

45.零拷贝是什么?用来解决什么问题?有哪些应用场景?实现方式有哪些?

实现方式

零拷贝可以通过多种方式实现,常见的实现方式包括:

  1. 利用内存映射(mmap):通过内存映射,应用程序可以直接访问磁盘上的文件,而无需将文件内容读入到用户空间的缓冲区中,减少了一次数据复制。
  2. 直接I/O:直接I/O绕过了操作系统的缓存,允许磁盘I/O操作直接在用户空间缓冲区和磁盘之间传输数据,避免了数据在用户空间和内核空间之间的多次复制。
  3. sendfile系统调用:sendfile是Linux提供的一种高效的数据传输方式,它可以直接在文件描述符之间传输数据,绕过用户空间,减少数据复制。
  4. Linux的splice和tee系统调用:splice可以将数据从一个文件描述符移动到另一个文件描述符中,而不需要将数据复制到用户空间。tee用于在两个文件描述符之间复制数据,同时避免了数据复制到用户空间。

46.TCP 和 UDP 可以使用同一个端口吗?

是的,TCP和UDP可以使用同一个端口号,因为它们是两种不同的协议,操作在不同的网络层。在网络中,一个端口号的唯一性是由IP地址、端口号以及协议三者共同决定的。这意味着TCP和UDP各自可以绑定相同的端口号,而不会产生冲突,因为协议类型(TCP或UDP)作为区分。这种机制允许应用程序同时或分别监听相同的端口号上的TCP和UDP流量。

举例来说,DNS服务就是一个典型的例子,它通常同时在UDP和TCP的53端口上监听。对于大多数查询,DNS使用UDP协议,因为它更快,且DNS请求和响应通常都很小,适合UDP。然而,当DNS响应数据较大时,超过了UDP的限制,就会改用TCP协议,以确保数据的完整性和可靠性。

因此,在设计和部署网络应用程序时,可以根据需要,为同一端口号配置TCP和UDP协议,这不会引起端口冲突,只要保证同一协议类型下,端口号的唯一性即可。

47.mysql有什么引擎 这些引擎有什么区别 为什么inodb用最多

  1. InnoDB:
  • 支持事务处理,具有提交、回滚和崩溃恢复能力,保证数据的完整性。
  • 支持外键,确保数据之间的引用完整性。
  • 支持行级锁定,提高多用户并发操作的性能。
  • 默认的MySQL存储引擎(从MySQL 5.5.5开始)。
  1. MyISAM:MyISAM 读作 "My-Eye-Sam"。这个名字来自于MySQL的“My”和“ISAM”
  • 不支持事务处理。不支持外键
  • 提供高速存储、检索以及全文搜索能力。
  • 支持表级锁定。
  • 曾是MySQL的默认存储引擎,适用于只读数据集、数据仓库和Web应用等场景。
  1. MEMORY:
  • 将数据存储在内存中,提供快速的访问速度。
  • 支持表级锁定。
  • 适用于临时数据存储和快速数据访问的场景,如缓存。
  1. Archive:
  • 用于存储大量的归档数据,如日志信息。
  • 支持高压缩比,节省存储空间。
  • 只支持INSERT和SELECT操作。
  1. Federated:
  • 允许访问远程MySQL服务器上的表,就像访问本地数据库一样。
  • 适用于分布式数据库管理和数据整合的场景。
  1. CSV:
  • 将数据存储为逗号分隔值的文本文件,易于数据导入导出。
  • 支持表级锁定。
  1. BLACKHOLE:
  • 接受数据的写入操作但不存储数据,查询时返回空结果。
  • 适用于日志记录或复制配置的场景。

为什么InnoDB使用最多

  • 事务支持:InnoDB支持事务,这对于需要保证数据完整性和一致性的应用来说非常重要。
  • 恢复能力:InnoDB提供了强大的崩溃恢复能力,可以减少数据损坏的风险。
  • 并发性能:通过行级锁定,InnoDB能够在高并发条件下提供更好的性能。
  • 外键约束:外键约束帮助维持数据库的引用完整性,这对于复杂的数据库系统尤其重要。
  • 自动崩溃恢复:InnoDB表能够在MySQL崩溃后自动恢复到一致的状态。

由于这些特性,InnoDB成为了许多要求高可靠性、高性能和数据完整性保障的应用的首选存储引擎。

48.spring配置加载顺序

Spring Framework 在启动时加载配置文件的顺序遵循一定的规则,以确保配置的灵活性和可覆盖性。Spring Boot 进一步增强了这一机制,提供了一个多层次、有序的配置加载方式,允许从不同的来源读取配置,包括属性文件、YAML文件、环境变量和命令行参数等。以下是Spring Boot中配置加载的一般顺序,从高优先级到低优先级:

  1. 命令行参数:任何直接在命令行上传递的参数(例如,使用--name=value的形式)。
  2. 来自Java系统属性(System Properties)的配置:通过System.getProperties()设置的属性。
  3. 操作系统环境变量:系统级别的环境变量。
  4. JNDI属性:在Java命名和目录接口(JNDI)下的java:comp/env中查找的属性。
  5. Java配置类:通过@Configuration注解标注的类,内部使用@Bean定义的配置信息。
  6. Spring Boot应用程序属性位于application.properties或application.yml 文件中的配置,这些文件可以位于多个位置,优先级按照以下顺序:
  • file:./config/
  • file:./
  • classpath:/config/
  • classpath:/ 对于上述位置,优先加载位于./config/下的配置文件,其次是位于当前目录下的,然后是类路径/config/下的,最后是类路径根下的。
  1. 在配置服务器上的配置(如果使用Spring Cloud Config Server)。
  2. 打包在应用程序内的配置:应用内部的application.properties或application.yml文件。
  3. 通过@PropertySource注解指定的属性文件:在配置类中,可以使用@PropertySource注解指定要加载的属性文件。
  4. 默认属性:通过SpringApplication.setDefaultProperties指定的默认属性。

49.spring源码的设计模式有啥在哪用到了

Spring Framework 的设计和架构中广泛使用了设计模式,以提供灵活、高效和可扩展的开发框架。以下是一些在Spring源码中常见的设计模式及其应用示例:

  1. 单例模式(Singleton Pattern):
  • 用于Spring的Bean默认作用域,确保一个Bean定义对应一个实例。
  • 应用:BeanFactory中的Bean实例管理。
  1. 工厂模式(Factory Pattern):
  • 用于创建对象,特别是在创建Bean实例时,不需要指定具体类。
  • 应用:BeanFactory和ApplicationContext用于Bean的创建。
  1. 代理模式(Proxy Pattern):
  • 用于Spring AOP和Spring Security,为目标对象提供一个代理对象,管理访问。
  • 应用:通过JDK动态代理和CGLIB代理实现横切关注点,如事务管理、方法拦截。
  1. 观察者模式(Observer Pattern):
  • 用于事件监听机制,允许对象在状态变化时通知其他对象。
  • 应用:ApplicationEventPublisher和ApplicationListener用于事件发布和监听。
  1. 模板方法模式(Template Method Pattern):
  • 定义算法的骨架,将一些步骤的实现延迟到子类。
  • 应用:JdbcTemplate、HibernateTemplate等,用于执行数据库操作的共享方法。
  1. 策略模式(Strategy Pattern):
  • 定义一系列算法,使它们可以互换使用。
  • 应用:Resource接口的实现,比如用于不同类型资源的访问策略(文件、URL等)。
  1. 适配器模式(Adapter Pattern):
  • 允许不兼容的接口之间的通信。
  • 应用:Spring MVC中,将不同类型的控制器适配为处理器(HandlerAdapter)。
  1. 装饰器模式(Decorator Pattern):
  • 动态地给一个对象添加一些额外的职责。
  • 应用:在Spring中用于增强类(如通过代理增加事务管理)。io
  1. 责任链模式(Chain of Responsibility Pattern):
  • 使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。
  • 应用:Spring Security的过滤器链。
  1. 建造者模式(Builder Pattern):
  • 封装一个对象的构建过程,并允许按步骤构造。
  • 应用:BeanDefinitionBuilder用于构建BeanDefinition对象。

这些设计模式在Spring框架的设计和实现中发挥着关键作用,使得Spring能够提供一个既强大又灵活的编程模型。通过这些模式的应用,Spring能够实现低耦合、高内聚,并易于扩展和维护。

50.设计一个秒杀系统,请给出大致思路。如果有十万个请求同时访问你的系统该怎么办?超买超卖问题怎么解决?事务并行会有哪些问题?

设计一个高效的秒杀系统需要考虑多方面因素,包括但不限于系统的高并发处理、数据的一致性、网络延迟、安全问题等。以下是设计一个秒杀系统的大致思路:

2.1 前端限流

  • 按钮防抖:防止用户短时间内重复提交。

"按钮防抖"是一个在编程中常用的概念,主要用于解决用户多次快速点击按钮导致的问题。 在编程中,首次触发点击事件后,如果在设定的规定时间(例如,500毫秒)内再次触发点击,那么这次点击就会被视为无效,也就是"防抖"。 这样可以防止因连续快速点击一个按钮,导致预期功能触发多次的问题。这在很多场景中都很有用,比如提交表单、发送请求等。这样即便用户连续点击,也只会触发一次操作,避免了可能的冲突或错误。

  • 验证码:减轻后端压力,防止自动化脚本攻击。

2.2 应用层处理

  • 内存队列:使用消息队列(如RabbitMQ、Kafka)缓冲大量请求,异步处理秒杀逻辑。
  • 请求合并:批量处理请求,减少数据库压力。

2.3 业务逻辑处理

  • 库存预减:在Redis等高性能缓存系统中预先减少库存,减少数据库访问。
  • 异步处理订单:用户秒杀请求通过后,异步生成订单,提高系统响应速度。

2.4 数据库优化

  • 数据库优化:使用乐观锁避免超卖现象,减少数据库层面的写冲突。
  • 分库分表:减少单一数据库的压力。

2.5 安全性考虑

  • 接口限流:对秒杀接口进行限流,防止系统被过多请求打垮。
  • 隐藏秒杀接口:动态生成秒杀接口的URL,防止恶意用户提前知道接口地址。
  1. 技术选型
  • 缓存技术:Redis等,用于处理高并发请求和减少对数据库的访问。
  • 消息队列:RabbitMQ、Kafka等,用于异步处理订单生成等业务逻辑。
  • 数据库:选择高性能数据库,考虑使用分库分表策略。
  • 前端技术:使用现代前端框架支持高并发场景下的用户界面展示。
  1. 系统测试与优化
  • 压力测试:模拟高并发场景,测试系统的承载极限。
  • 安全测试:测试系统的安全性,防止SQL注入、XSS攻击等。
  • 优化调整:根据测试结果调整系统配置,优化代码逻辑。
  1. 部署与监控
  • 分布式部署:考虑使用云服务实现系统的弹性伸缩。
  • 监控告警:实时监控系统状态,对异常情况进行告警,快速响应。

处理十万个同时访问的请求,需要采用多种策略来确保系统的稳定性、响应性和可扩展性。以下是一些关键策略:

  • 使用异步处理机制,如事件驱动模型,减少阻塞。
  • 采用限流策略(如令牌桶、漏桶算法)防止系统过载。
  • 使用缓存(如Redis)存储热点数据,减少数据库访问压力。
  • 通过负载均衡器分散请求到多个服务器,避免单点压力。

数据库层面的优化

  • 分离读写操作,通过主从复制提升读操作的处理能力。
  • 采用分库分表减少单库压力,提高查询速度。

微服务架构

  • 使用微服务架构分解应用,提高系统整体的可维护性和扩展性。

消息队列

  • 使用消息队列(如Kafka、RabbitMQ)异步处理耗时操作,缓冲高峰流量。

使用云服务和容器技术

  • 利用云服务的自动扩展功能,按需增减资源。
  • 使用容器(如Docker)和容器编排(如Kubernetes)实现快速部署和弹性伸缩。
  • 部署DDoS防护措施,保护系统不被大流量攻击。
  • 实施全面的系统监控,及时发现并处理系统瓶颈。
  • 配置预警系统,实时监控关键指标,快速响应异常情况。

51.分布式ID的生成策略,有哪些优缺点?

分布式ID生成策略主要有以下几种:

  1. UUID:UUID算法可以保证在全球范围内生成的ID都是唯一的。
  • 优点:不要求服务器之间进行通讯,独立性强,生成简单,性能好。
  • 缺点:UUID一般是128位长度,占用空间大。不连续,无法做到趋势递增,查询效率慢。
  1. Twitter的Snowflake算法:生成的ID是一个64位整数,
  • 优点:生成的ID趋势递增,利于后期维护和排查问题;弱一致性,在分布式环境中也能实现ID有序,性能好。
  • 缺点:需要依赖机器时钟,如果服务器之间时间不一致,可能会导致ID冲突。
  1. 数据库自增ID:利用数据库自带的自动递增字段,每插入一条记录,自增字段的值就会加1。
  • 优点:简单,容易理解。
  • 缺点:不适用于分布式系统,因为分布式系统中有多个数据库,自增ID会发生重复。同时,如果单台服务器宕机,其他服务器不能顶替它生成ID。
  1. Redis生成ID:使用Redis的原子操作 INCR 和 INCRBY 实现分布式系统的ID生成。
  • 优点:趋势递增,生成简单。
  • 缺点:需要依赖Redis,一旦Redis服务挂掉,可能会影响业务。
  1. Zookeeper生成ID:利用Zookeeper的特性,创建持久顺序节点来实现分布式ID生成。
  • 优点:生成的ID是趋势递增的,利于后期维护和排查问题。
  • 缺点:需要依赖Zookeeper,一旦Zookeeper集群故障,会影响到ID的生成,且性能相对较差。

52.秒杀系统重复点击怎么解决?幂等性什么意思?

在秒杀系统中,重复点击问题可以通过以下方式来解决:

  1. 前端层面:在第一次点击后就进行禁用按钮,从而防止用户在短时间内多次点击同一个按钮。
  2. 缓存层面:为每个用户设置一个标记,当用户发起请求时,首先检查标记是否存在,如果存在说明已经发起过请求,就拒绝本次请求;如果不存在,就添加标记并继续处理请求。
  3. 数据库层面:利用数据库的原子操作或者事务来保证操作的原子性,防止并发操作导致的重复处理问题。

"幂等性"是指一个操作,无论执行多少次,最后结果都是一样的。比如说对一个数据库的插入操作,如果插入的数据已经存在,并且重复执行都不会进行任何操作,那么这个插入操作就具有幂等性。再如,一个删除操作,删除某个资源后,再次执行删除这个已经不存在的资源,也不会产生任何影响。

在秒杀系统中,如果加入"幂等性"设计,就可以保证一个用户无论点击多少次秒杀按钮,都只会被系统处理一次,可以很好地解决因重复提交而导致的一系列问题。

53.往set中存自定义对象需要注意什么?

往 Set 集合中存储自定义对象时,需要确保对象的唯一性。为了让 Set 正确地识别对象是否相同,需要特别注意以下几点:

  1. 重写 equals() 和 hashCode() 方法
  • equals() 方法:确定两个对象是否等价。如果不重写 equals() 方法,Set 会使用对象的引用地址来判断对象是否相同,这意味着即使两个对象的内容完全一样,它们也会被视为不同的对象。
  • hashCode() 方法:提供对象的哈希码,用于在哈希表中快速定位对象。如果两个对象通过 equals() 方法判断相等,那么它们的 hashCode() 方法也必须返回相同的整数值。如果不这样做,可能会违反 hashCode 的一般约定,导致对象无法正确地存储在基于哈希的 Set 实现(如 HashSet)中。
  1. 确保 hashCode() 方法的一致性
  • hashCode() 方法需要确保对象在一个应用执行期间多次调用时返回相同的值,前提是对象的信息没有被修改。即使在不同的应用执行过程中,对于相同的对象,hashCode() 也应该尽可能返回相同的值。
  1. 保持 equals() 和 hashCode() 之间的一致性
  • 如果两个对象通过 equals() 方法比较相等,则这两个对象的 hashCode() 方法必须返回相同的值。
  • 如果两个对象的 hashCode() 方法返回相同的值,并不要求这两个对象通过 equals() 方法比较也一定相等(即允许哈希碰撞)。
  1. 考虑 Comparator 或 Comparable 接口(对于 TreeSet)
  • 对于 TreeSet,对象的比较不仅仅依赖于 equals() 和 hashCode() 方法,还可能依赖于 Comparator 或 Comparable 接口的实现。这是因为 TreeSet 是基于红黑树(一种自平衡二叉查找树)实现的,它通过比较器(Comparator)或可比较性(Comparable)来维护元素的排序。
  • 如果使用 TreeSet 存储自定义对象,需要确保这些对象实现了 Comparable 接口,或者在创建 TreeSet 实例时提供了一个 Comparator。

实践建议

  • 在重写 equals() 方法时,确保它是对称的、传递的、一致的,并且对于任何非 null 的引用值 x,x.equals(null) 应该返回 false。
  • 在重写 hashCode() 方法时,尽量返回一个根据对象内部状态计算出的、分布均匀的哈希码,以提高哈希表的性能。
  • 考虑使用 Objects 类的 hash() 和 equals() 静态方法来简化 hashCode() 和 equals() 方法的实现。

54.K8S简单介绍一下? 介绍一下k8s有状态无状态服务

Kubernetes(K8s)是一个开源容器编排平台,用于自动化部署、扩展和管理容器化应用程序。它提供了一套丰富的功能,帮助用户在一组机器上以高效、可靠的方式运行分布式系统。Kubernetes 抽象了容器运行环境的许多复杂性,允许用户声明式配置和自动化部署应用。

在 Kubernetes 中,服务(或应用)可以大致分为有状态(Stateful)和无状态(Stateless)两种:

无状态服务(Stateless Services)

  • 无状态服务不保存任何状态。它们不会记录过去的信息或数据以供将来的请求使用。每个请求被视为全新的,不依赖之前的交互。
  • 扩展性:由于无状态服务之间没有依赖关系,它们可以很容易地进行水平扩展。增加更多的实例不会影响服务的运行。
  • 部署和管理:在 Kubernetes 中,无状态服务通常使用 Deployment 或 ReplicaSet 来部署和管理。这些资源确保指定数量的 Pod 副本始终在运行。
  • 例子:Web 服务器、RESTful APIs 等,这些应用不需要保存客户端的状态信息。

有状态服务(Stateful Services)

  • 有状态服务需要保存状态信息,例如数据库、消息队列或任何需要持久化数据的应用。这些服务在处理请求时依赖之前的交互或数据。
  • 管理复杂性:有状态服务的管理比无状态服务复杂,因为你需要处理数据的持久化、备份、恢复以及实例之间的数据同步等问题。
  • 部署和管理:在 Kubernetes 中,有状态服务通常使用 StatefulSet 来部署。StatefulSet 为每个 Pod 副本提供了一个持久的身份,并保证按照指定顺序进行部署和扩展。
  • 存储:有状态服务需要持久化存储,Kubernetes 通过 PersistentVolume 和 PersistentVolumeClaim 提供了存储解决方案,以保证数据的持久性。
  • 例子:数据库(如 MySQL、PostgreSQL)、消息队列(如 Kafka、RabbitMQ)等。

是的,一个Pod可以运行多个容器。在Kubernetes中,Pod是可以包含一个或者多个紧密相关的容器的基本调度单位。这些容器共享同一个网络命名空间,包括IP地址和端口空间,它们可以通过localhost互相通信。并且这些容器还可以共享存储卷。

虽然一个Pod可以包含多个容器,但是通常情况下,一个Pod只会包含一个应用容器。

当你使用Deployment部署在Kubernetes集群上时,每个创建的Pod都会有其自己的IP地址,这些IP地址在Pod内部和集群内其他Pod之间是可直接访问的。但是,如果你需要从集群外部访问这些由Deployment创建的Pod,你通常会使用Service或Ingress。

使用Service是最常见的做法,Kubernetes的Service是一种可以将流量路由到一组Pod的抽象,它提供一种单一、统一的访问入口。你可以创建一个类型为LoadBalancer或者NodePort的Service来暴露Deployment。创建Service后,Kubernetes会为Service分配一个固定的IP地址(内部或外部),所有发送到这个IP地址的请求会被Kubernetes自动分发到后端的Pod中。

对于更复杂的流量路由需求,常常会使用Ingress。Ingress是Kubernetes中的一个对象,它管理外部访问集群中的服务,通常通过HTTP。Ingress可以提供负载均衡,SSL终止和基于名称的虚拟托管等。

简单来说,你不需要直接管理或使用各个Pod的IP。在Kubernetes中,Service和Ingress是你暴露和访问Deployment的主要工具。

55.分布式定时任务多实例但只执行一次

要实现一个分布式定时任务系统,其中任务有多个实例但任务只执行一次,你需要采用一种机制确保任务的唯一性执行。这种需求常见于需要高可用性和容错性的分布式系统中,以避免同一个任务在多个节点上重复执行。以下是一些实现这一目标的策略:

  1. 集中式调度

使用一个集中式的调度器来决定哪个节点执行特定的任务。这个调度器可以是一个独立的服务,如Apache ZooKeeper,它能够确保跨多个节点的锁和同步。

  • 锁机制:当任务触发时,所有的实例都尝试从调度中心获取执行权限(即锁)。只有获得锁的实例才会执行任务,其他实例则不执行。
  • 心跳检测:调度中心通过心跳检测来监测执行节点的健康状态,以便在节点失败时迅速进行故障转移。
  1. 分布式锁

利用分布式锁来保证同一时刻只有一个实例执行任务。分布式锁可以通过多种方式实现,如基于Redis的RedLock算法、基于数据库的锁机制、或者使用ZooKeeper等。

  • 锁的获取与释放:当任务准备执行时,每个节点尝试获取分布式锁。只有获取到锁的节点才执行任务,执行完成后释放锁。
  • 锁的续期:为防止执行时间过长导致锁过期,执行任务的节点需要定期续期锁直至任务完成。
  1. 数据库标记

在数据库中使用标记(如状态字段或时间戳)来记录任务的执行状态。

  • 任务领取:每个节点在执行任务前,尝试在数据库中标记自己为该任务的执行者(例如,通过更新一个记录的状态)。
  • 乐观锁:通过乐观锁机制(如版本号或时间戳比较)防止多个节点同时标记成功。
  1. 基于消息队列的唯一性保证

使用消息队列(如Kafka、RabbitMQ)来分发任务,队列保证了消息的顺序和唯一性。

  • 单一消费者:配置消息队列,使得每个任务的消息只被单一消费者(即执行实例)消费。
  • 消费者组:如果使用Kafka,可以利用消费者组的概念,确保同一组内的消费者不会重复消费同一个消息。

56.100个G的整数,如何在4G内存进行排序

第一步:分割大文件

假设我们有100G的整数数据需要排序,但是我们只有4G的内存可用。首先,我们需要将这个大文件分割成多个小文件,每个文件小到足以被加载到内存中进行排序。以下是分割步骤的详细说明:

  1. 确定小文件的大小:基于内存限制,我们需要选择一个合适的大小作为每个小文件的大小。考虑到内存中还需要一些额外空间进行排序操作,我们可以选择每个文件大约为1G。
  2. 读取和排序:按顺序读取原始文件中的1G数据,加载到内存中。然后使用内部排序算法(比如快速排序或归并排序)对这些数据进行排序。
  3. 写入临时文件:将排序后的数据写入到一个新的临时文件中。重复这个过程,直到整个100G数据被处理完毕。这样,你会得到大约100个排序后的临时文件。

第二步:外部归并排序

接下来,我们需要将这些已排序的小文件合并成一个单一的、完全排序的大文件。由于内存限制,我们不能同时打开所有文件进行合并,因此我们采用外部归并排序算法。

  1. 多路归并:同时打开多个(假设10个)已排序的小文件,并分配一个小部分内存用于每个文件的输入缓冲区,以及一个输出缓冲区用于合并后的数据。
  2. 创建最小堆:从每个小文件的输入缓冲区中读取第一个元素(如果有的话),并将它们放入一个最小堆(或优先队列)中。最小堆允许我们快速找到当前所有打开文件中的最小值。
  3. 执行归并 :重复以下步骤,直到所有的文件都被完全处理完毕:
  • 从最小堆中取出最小元素,并将其写入输出缓冲区。
  • 如果输出缓冲区满了,就将它写入最终的输出文件,并清空缓冲区。
  • 从那个刚刚提供了最小元素的文件中读取下一个元素,将其加入到最小堆中。如果该文件已经读完,就关闭它,并从剩余文件中继续处理。
  1. 合并完成:当所有的文件都被处理完,且最小堆为空时,确保将输出缓冲区中剩余的数据写入到最终的输出文件中。此时,合并过程完成,最终的输出文件是完全排序的。

那么100个排序后的文件,归并后产生10个文件,是不是还要再归并一次

是的,如果你在第一轮归并后得到了10个排序后的文件,你确实需要进行进一步的归并步骤,直到所有的数据最终合并成一个单一的、完全排序的文件。这个过程通常是递归或迭代进行的,每次合并减少文件数量,直到达到最终的目标。

57.springboot的依赖注入方式

Spring Boot支持几种类型的依赖注入方式,让你能选择最符合你需求的方法:

基于构造函数的依赖注入:这是推荐的依赖注入方式,因为它可以确保你的Bean在被自动装载时是不可变的。通过构造函数,你可以在创建对象实例时注入依赖。这样做的一个优点是你可以使用final关键字来修饰字段。

 @Service
 public class MyService {
     private final AnotherService anotherService;
     
     @Autowired // 在Spring 4.3及以后的版本中,如果目标构造器只有一个且需要注入的依赖明确,则可以省略@Autowired注解
     public MyService(AnotherService anotherService) {
         this.anotherService = anotherService;
     }
 ​
 // 使用anotherService进行一些操作...
 }

基于Setter的依赖注入:这种方式允许你在对象实例化之后注入依赖。你会需要一个没有参数的构造函数,或者所有字段都被设置为可选。

 @Service
 public class MyService {
 ​
   private MyDependency myDependency;
 ​
   @Autowired
   public void setMyDependency(MyDependency myDependency) {
     this.myDependency = myDependency;
   }
 ​
   //...
 }

基于字段的依赖注入:你也可以直接在字段上使用@Autowired注解。但请注意,这种方式并不推荐,因为它会导致你的代码更难测试和理解。

  1. :来自于JSR-250规范,可以应用在字段和setter方法上,它有一个名称属性,可以通过名称来进行注入。
  2. :当有多个相同类型的Bean时,可以用@Qualifier("beanName")来指定注入哪一个Bean。
 @Service
 public class MyService {
 ​
   @Autowired
   private MyDependency myDependency;
 ​
   //...
 }

所有这些方法都可以正确的注入依赖项。但通常来说,优先选择基于构造函数的注入方法,因为它可以帮助你编写更易于测试和理解的代码。基于setter和字段的方法还是应该尽量避免。

58.有过JVM内存调优吗?

例子 1: 调整堆大小

为了减少垃圾收集的频率和提高应用性能,你可以调整JVM的初始堆大小(-Xms)和最大堆大小(-Xmx)。

 shellCopy code
 java -Xms512m -Xmx1024m -jar your-application.jar

这个例子设置了JVM的初始堆大小为512MB,最大堆大小为1024MB。合理设置这些值可以减少因为堆扩展而导致的延迟,并避免因为频繁的垃圾收集而影响性能。

例子 2: 选择和调整垃圾收集器

使用G1垃圾收集器

如果你的应用在JDK 9或更高版本上运行,并且要求较低的停顿时间,可以选择G1垃圾收集器,并通过设置最大停顿时间目标来优化性能。

 shellCopy code
 java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar your-application.jar

这个例子启用了G1垃圾收集器,并设置了最大垃圾收集停顿时间为200毫秒。

为什么G1垃圾收集器有可预测的停顿功能

G1(Garbage-First)垃圾收集器是一种在Java虚拟机(JVM)中使用的垃圾收集(GC)算法,它旨在为具有大量内存和多核处理器的系统提供高吞吐量同时保持尽可能低的停顿时间。G1垃圾收集器有可预测的停顿功能的主要原因包括:

  1. 分区收集策略:G1将堆内存划分为多个区域(Region),在GC执行过程中,它可以选择性地收集那些最有可能含有大量垃圾、回收效率最高的区域。这种方法减少了每次收集操作需要处理的数据量,从而减少了单次GC停顿的时间。
  2. 停顿时间目标:G1允许用户指定期望的停顿时间目标(例如,不超过50毫秒)。G1垃圾收集器会根据这个目标来调整其工作方式,尽可能地在不超过用户设定的停顿时间的前提下,完成内存的垃圾收集。这是通过动态选择要清理的区域数量和类型来实现的,以及调整背景GC线程的工作强度。
  3. 并发和并行处理:G1能够利用多核心处理器,并发和并行地执行许多垃圾收集任务。例如,G1在标记阶段可以并发于应用线程运行,减少了停顿时间。在实际的垃圾回收阶段,G1还会并行处理多个区域,进一步缩短停顿。
  4. 增量清理:G1通过增量方式执行清理工作,意味着它不需要一次性停止所有工作线程来处理整个堆,而是可以分步骤进行,这有助于控制和减少停顿时间。
  5. 回收优先级:G1收集器通过评估各个区域中的垃圾比例和容量,为它们设置优先级,优先清理垃圾最多的区域。这种策略使得G1可以更有效地利用每次停顿,提高垃圾收集的效率。

调整并行GC线程数

如果使用并行垃圾收集器,可以根据你的CPU核心数调整并行GC线程数,以优化垃圾收集过程。

 shellCopy code
 java -XX:+UseParallelGC -XX:ParallelGCThreads=8 -jar your-application.jar

这个例子启用了并行垃圾收集器,并设置了并行GC线程数为8。

例子 3: 调整新生代与老年代的比例

通过调整新生代(Young Generation)和老年代(Old Generation)的比例,可以优化垃圾收集的效率,减少Full GC的发生。

 shellCopy code
 java -XX:NewRatio=2 -jar your-application.jar

这个例子设置了新生代和老年代的大小比例为1:2,意味着老年代是新生代大小的两倍。这个比例的设置取决于应用的特性和垃圾收集行为。

例子 4: 调整元空间大小

对于使用Metaspace的Java 8及以上版本,可以通过调整元空间的初始大小和最大大小来避免频繁的垃圾收集。

 shellCopy code
 java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -jar your-application.jar

这个例子设置了元空间的初始大小为128MB,最大大小为256MB。合理调整元空间大小可以减少因为元空间不足而导致的Full GC。

59.场景题:如何找到 100亿字符串中 出现次数前10的字符串

处理如此大规模数据的问题时,单机内存和计算能力通常是不够的。因此,需要采用分布式处理或者大数据技术来解决。这里提供两种策略:一种是使用MapReduce模型(如Hadoop)进行分布式处理,另一种是通过局部内存处理和外部存储结合的方式。

如果没有分布式环境

hash取模分组(分组直到能够在当前机器内存里进行运算),用最小堆和hashmap找出每组里出现次数前10的,最后汇总里找前10

60.数据库里面的数据被删除之后是真的删除了吗?

当你在数据库中执行一个删除操作(如SQL的DELETE语句)时,被删除的数据确实会从数据库中删除。然而,是否能够恢复这些被删除的数据,取决于多个因素:

  1. 数据库的类型:在某些类型的数据库(如某些类型的NoSQL数据库)中,删除的数据可能会被标记为已删除,但实际数据并未被立即删除,只是在后续的清理过程中才会被真正删除。

在InnoDB数据库中,当执行删除操作时,并不会真正地将数据从磁盘上擦除。相反,MySQL使用了一种称为“逻辑删除”的机制。这意味着被删除的数据实际上并没有被物理移除,而是通过设置一个删除标记(deleted_flag)来标记这些数据为已删除,物理删除在后台由InnoDB的purge线程来完成。purge线程会定期扫描已删除的记录并真正从物理存储中移除,以释放空间。

  1. 事务日志:在许多关系型数据库(如MySQL,SQL Server等)中,所有的操作都会被记录在事务日志中。如果删除操作后立即在日志中进行恢复,那么有可能将数据恢复回来。
  2. 备份和恢复策略:如果数据库有定期备份,或者使用了例如二进制日志(binlog)等用于恢复的机制,即使数据从数据库中删除,也有可能从备份或者日志中恢复。
  3. 数据库的物理存储结构:此外,即使数据被删除,只要物理存储介质(如磁盘)上的数据没有被覆盖,理论上也有可能通过专业工具进行物理恢复。

写在后面

最近有一些学弟学妹找我帮忙看简历,我也很开心能帮到大家~加上这段时间我比较闲,后面可以更多跟大家交流嘿嘿。我建议还是以后端方向的学弟学妹为主,因为这方面我能给出更多具体建议,希望能为你们提供一些有价值的帮助!

如果需要,欢迎发给我你的学校和名字,我帮你看看,本科(你羊)和研究生(你南)的学弟学妹们优先

#牛客激励计划##牛客创作赏金赛#
实习/秋招面经 文章被收录于专栏

实习/秋招面经

全部评论
mark
点赞 回复 分享
发布于 04-08 00:35 浙江
mark!!
点赞 回复 分享
发布于 04-07 18:02 香港
mark
点赞 回复 分享
发布于 04-01 16:45 河南

相关推荐

评论
11
31
分享

创作者周榜

更多
牛客网
牛客企业服务