- 👉《图解网络》 :500 张图 + 15 万字贯穿计算机网络重点知识,如HTTP、HTTPS、TCP、UDP、IP等协议
- 👉《图解系统》:400 张图 + 16 万字贯穿操作系统重点知识,如进程管理、内存管理、文件系统、网络系统等
- 👉《图解MySQL》:重点突击 MySQL 索引、存储引擎、事务、MVCC、锁、日志等面试高频知识
- 👉《图解Redis》:重点突击 Redis 数据结构、持久化、缓存淘汰、高可用、缓存数据一致性等面试高频知识
- 👉《Java后端面试题》:涵盖Java基础、Java并发、Java虚拟机、Spring、MySQL、Redis、计算机网络等企业面试题
- 👉《大厂真实面经》:涵盖互联网大厂、互联网中厂、手机厂、通信厂、新能源汽车厂、银行等企业真实面试题
大家好,我是小林。
众安保险这两年都有在招校招生,看到不少同学有拿到众安保险的 Java 开发和测试开发岗的 offer 的,这次我们来盘点一下众安保险的薪资和面经。
众安保险 25 届开发岗的校招薪资主要有下面这三档,年薪在 27w~33w:
- 18k x 15 = 27w,同学背景硕士 985,办公地点上海
- 20k x 15 = 30w,同学背景硕士 211,办公地点上海
- 22k x 15 = 33w,同学背景硕士 985,办公地点上海
众安保险面试流程主要是 3 面,2轮技术面+1 轮 hr 面,技术面也不能说算很简单,基本还是跟互联网中大厂的难度差不多的,八股+项目+算法都会考察。
之前有后端训练营校招同学拿到了众安保险 offer,开了 30w 年薪,应该是 20k x 15 这一档了,薪资这一块同学还是比较满意的,都有点心动了。
甚至也有其他同学面众安保险,面了 2 个小时,面完之后,汗都流完了。
这次就带大家看看一位面众安保险面了一个小时的 Java 校招面经,基本是八股盛宴拷打了,总共问了 20 多个八股问题,然后最后还有场景题(秒杀系统设计)+ 算法题。
JavaSE
String、StringBuilder、StringBuffer的区别?
1、可变性 :**String**
是不可变的(Immutable),一旦创建,内容无法修改,每次修改都会生成一个新的对象。**StringBuilder**
和 **StringBuffer**
是可变的(Mutable),可以直接对字符串内容进行修改而不会创建新对象。
2、线程安全性 :**String**
因为不可变,天然线程安全。**StringBuilder**
不是线程安全的,适用于单线程环境。**StringBuffer**
是线程安全的,其方法通过 **synchronized**
关键字实现同步,适用于多线程环境。
3、性能 :**String**
性能最低,尤其是在频繁修改字符串时会生成大量临时对象,增加内存开销和垃圾回收压力。**StringBuilder**
性能最高,因为它没有线程安全的开销,适合单线程下的字符串操作。**StringBuffer**
性能略低于 **StringBuilder**
,因为它的线程安全机制引入了同步开销。
4、使用场景 :如果字符串内容固定或不常变化,优先使用 **String**
。如果需要频繁修改字符串且在单线程环境下,使用 **StringBuilder**
。如果需要频繁修改字符串且在多线程环境下,使用 **StringBuffer**
。
对比总结如下:
特性 | String | StringBuilder | StringBuffer |
---|---|---|---|
不可变性 | 不可变 | 可变 | 可变 |
线程安全 | 是(因不可变) | 否 | 是(同步方法) |
性能 | 低(频繁修改时) | 高(单线程) | 中(多线程安全) |
适用场景 | 静态字符串 | 单线程动态字符串 | 多线程动态字符串 |
例子代码如下:
// String的不可变性
String str = "abc";
str = str + "def"; // 新建对象,str指向新对象
// StringBuilder(单线程高效)
StringBuilder sb = new StringBuilder();
sb.append("abc").append("def"); // 直接修改内部数组
// StringBuffer(多线程安全)
StringBuffer sbf = new StringBuffer();
sbf.append("abc").append("def"); // 同步方法保证线程安全
抽象类和接口的区别是什么?
抽象类和接口的主要区别如下:
- 定义 :抽象类是一个不能实例化的类,可以包含具体方法和抽象方法;接口是一个完全抽象的结构,主要用于定义行为规范。
- 继承与实现 :抽象类通过
extends
继承,只能单继承;接口通过implements
实现,可以多实现。 - 成员变量 :抽象类可以有普通变量和常量;接口只能有常量(
public static final
)。 - 方法 :抽象类可以有抽象方法和具体方法;接口从 JDK 8 开始支持默认方法和静态方法。
- 构造器 :抽象类可以有构造器,接口不能有构造器。
- 设计目的 :抽象类用于描述“是什么”,强调类的继承关系;接口用于描述“能做什么”,强调行为的规范。
接口的成员变量和方法的访问权限是怎样的?
成员类型 | 默认修饰符 | 访问权限说明 | 其他特性说明 |
---|---|---|---|
成员变量 | public static final | 可以被任何类访问,因为是 public ;通过接口名直接访问,因为是 static | 必须在定义时初始化,且初始化后值不能被修改,是常量 |
抽象方法(Java 8 之前接口中的普通方法) | public abstract | 任何实现该接口的类都要实现这些方法,可被外部类访问 | 只有方法声明,没有方法体,由实现类提供具体实现 |
default 方法(Java 8 及以后) | public | 实现类可直接使用,也可重写,外部类通过实现类对象调用 | 有方法体,为实现类提供默认行为 |
static 方法(Java 8 及以后) | public | 通过接口名直接调用 | 有方法体,属于接口本身,与接口的实例无关 |
private 方法(Java 9 及以后) | private | 只能在接口内部被其他 default 方法或 private static 方法调用 | 用于抽取接口中 default 方法或 static 方法的重复代码 |
private static 方法(Java 9 及以后) | private static | 只能在接口内部被其他 static 方法或 default 方法调用 |
接口中的成员变量默认是public
、static
和final
的。这意味着:
**public**
:可以被任何类访问。**static**
:它属于接口本身,而不是接口的实例。**final**
:变量的值在初始化后不能被修改,即它是一个常量。所以在接口中定义成员变量时,通常会同时进行赋值,例如public static final int MAX_COUNT = 100;
,并且实际编码中public static final
这些修饰符常常省略。
接口中的方法默认是public
和abstract
的。这表示:
**public**
:任何实现该接口的类都必须实现这些方法,并且可以被外部类访问。**abstract**
:这些方法只有声明,没有方法体,需要由实现接口的类来提供具体的实现逻辑。在 Java 8 及以后版本,接口中还可以有default
方法和static
方法,default
方法有方法体,为接口的实现类提供了一种默认的行为实现;static
方法属于接口自身,可通过接口名直接调用,它们的访问权限同样是public
。
内部类能否访问外部类的私有变量?
部类可以访问外部类的私有变量,这是因为:
- 内部类是外部类的一部分,可以直接访问外部类的所有成员,包括私有成员。
- 对于非静态内部类,它持有对外部类实例的隐式引用(
OuterClass.this
),因此可以访问外部类的非静态私有变量,比如下面的例子,展示内部类如何访问外部类的私有变量:
public class OuterClass {
private String outerField = "I am a private field of OuterClass";
// 非静态内部类
class InnerClass {
public void accessOuterField() {
// 内部类可以直接访问外部类的私有变量
System.out.println("Accessing outerField: " + outerField);
}
}
public static void main(String[] args) {
// 创建外部类实例
OuterClass outer = new OuterClass();
// 创建内部类实例
OuterClass.InnerClass inner = outer.new InnerClass();
// 调用内部类方法,访问外部类的私有变量
inner.accessOuterField();
}
}
//输出结果
Accessing outerField: I am a private field of OuterClass
- 对于静态内部类,它只能访问外部类的静态私有变量,因为它不依赖于外部类的实例。例子如下代码:
public class OuterClass {
private String outerField = "I am a private field of OuterClass";
private static String staticField = "I am a static private field";
// 静态内部类
static class StaticInnerClass {
public void accessStaticField() {
// 可以访问外部类的静态私有变量
System.out.println("Accessing staticField: " + staticField);
// 无法直接访问非静态私有变量
// System.out.println(outerField); // 编译错误
}
}
public static void main(String[] args) {
// 创建静态内部类实例
OuterClass.StaticInnerClass staticInner = new OuterClass.StaticInnerClass();
staticInner.accessStaticField();
}
}
//输出结果
Accessing staticField: I am a static private field
深拷贝和浅拷贝的区别是什么?
- 浅拷贝是指只复制对象本身和其内部的值类型字段,但不会复制对象内部的引用类型字段。换句话说,浅拷贝只是创建一个新的对象,然后将原对象的字段值复制到新对象中,但如果原对象内部有引用类型的字段,只是将引用复制到新对象中,两个对象指向的是同一个引用对象。
- 深拷贝是指在复制对象的同时,将对象内部的所有引用类型字段的内容也复制一份,而不是共享引用。换句话说,深拷贝会递归复制对象内部所有引用类型的字段,生成一个全新的对象以及其内部的所有对象。
HashMap如何处理哈希冲突?
在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。
所以在 JDK 1.8 版本的时候做了优化,当一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。
常见哈希冲突有哪些解决方法?
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
- 再哈希法:当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。
常见线程安全的集合你知道哪些?
在 java.util 包中的线程安全的类主要 2 个,其他都是非线程安全的。
- Vector:线程安全的动态数组,其内部方法基本都经过synchronized修饰,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
- Hashtable:线程安全的哈希表,HashTable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要保证线程安全的哈希表,可以用ConcurrentHashMap。
java.util.concurrent 包提供的都是线程安全的集合:
并发Map:
- ConcurrentHashMap:它与 HashTable 的主要区别是二者加锁粒度的不同,在JDK1.7,ConcurrentHashMap加的是分段锁,也就是Segment锁,每个Segment 含有整个 table 的一部分,这样不同分段之间的并发操作就互不影响。在JDK 1.8 ,它取消了Segment字段,直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。对于put操作,如果Key对应的数组元素为null,则通过CAS操作(Compare and Swap)将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率。
- ConcurrentSkipListMap:实现了一个基于SkipList(跳表)算法的可排序的并发集合,SkipList是一种可以在对数预期时间内完成搜索、插入、删除等操作的数据结构,通过维护多个指向其他元素的“跳跃”链接来实现高效查找。
并发Set:
- ConcurrentSkipListSet:是线程安全的有序的集合。底层是使用ConcurrentSkipListMap实现。
- CopyOnWriteArraySet:是线程安全的Set实现,它是线程安全的无序的集合,可以将它理解成线程安全的HashSet。有意思的是,CopyOnWriteArraySet和HashSet虽然都继承于共同的父类AbstractSet;但是,HashSet是通过“散列表”实现的,而CopyOnWriteArraySet则是通过“动态数组(CopyOnWriteArrayList)”实现的,并不是散列表。
并发List:
- CopyOnWriteArrayList:它是 ArrayList 的线程安全的变体,其中所有写操作(add,set等)都通过对底层数组进行全新复制来实现,允许存储 null 元素。即当对象进行写操作时,使用了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行的读操作,则直接返回结果,操作过程中不需要进行同步。
并发 Queue:
- ConcurrentLinkedQueue:是一个适用于高并发场景下的队列,它通过无锁的方式(CAS),实现了高并发状态下的高性能。通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue 。
- BlockingQueue:与 ConcurrentLinkedQueue 的使用场景不同,BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞。反之,如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待。
并发 Deque:
- LinkedBlockingDeque:是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点。LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作
- ConcurrentLinkedDeque:ConcurrentLinkedDeque是一种基于链接节点的无限并发链表。可以安全地并发执行插入、删除和访问操作。当许多线程同时访问一个公共集合时,ConcurrentLinkedDeque是一个合适的选择。
JVM
JVM 内存结构说一下
根据 JDK 8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。
JVM的内存结构主要分为以下几个部分:
- 元空间:元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- Java 虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫 “栈帧” 的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
- 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行 Java 方法,本地方法栈执行 native 方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
- 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为 “线程私有” 内存。
- 堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象实例和数组都在堆上分配,这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。堆是 JVM 内存占用最大、管理最复杂的一个区域。JDK 1.8 后,字符串常量池和运行时常量池从永久代中剥离出来,存放在堆中。
- 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。在 JDK 1.4 中新加入了 NIO 类,引入了一种基于通道 (Channel) 与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
堆和栈的区别是什么?
- 用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当你使用
new
关键字创建一个对象时,对象的实例就会在堆上分配空间。 - 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在垃圾回收机制(Garbage Collection, GC)检测到对象不再被引用时才被回收。
- 存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出(LIFO, Last In First Out)的原则,操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
- 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
- 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
方法区的作用是什么?
在 JDK 8 之前,它是一个独立的内存区域;从 JDK 8 开始,方法区的实现由永久代变为元空间,但概念和作用基本保持一致。其主要作用如下:
- 存储类的元数据:方法区会存储类的基本结构信息,包括类的全限定名、父类的全限定名、实现的接口列表等。例如,对于
java.util.ArrayList
类,方法区会记录它的完整类名、父类java.util.AbstractList
以及实现的接口如java.util.List
等信息。这些信息对于 JVM 进行类加载、类型检查和反射操作非常重要。类中定义的字段和方法的相关信息也会存储在方法区。对于字段,会记录字段的名称、类型、修饰符等;对于方法,会记录方法的名称、参数列表、返回类型、方法体的字节码指令等。例如,一个Person
类中有name
和age
两个字段,以及getName()
和setName()
等方法,这些字段和方法的详细信息都会存储在方法区。 - 存储常量池:在方法区中,有一个重要的组成部分是字符串常量池。它用于存储字符串常量,当代码中使用双引号创建字符串时,JVM 会先在字符串常量池中查找是否已经存在相同内容的字符串,如果存在则直接返回该字符串的引用,否则会在常量池中创建一个新的字符串对象。
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // 输出 true,因为 s1 和 s2 引用的是字符串常量池中的同一个对象
- 存储静态变量:类的静态变量也存储在方法区中。静态变量是属于类本身的,而不是属于某个对象,因此它们在类加载时被初始化,并且在整个应用程序的生命周期内都存在。在这个例子中,
staticVar
静态变量会被存储在方法区,所有该类的实例都共享这个静态变量,例如:
public class StaticVariableExample {
public static int staticVar = 10;
}
双亲委派机制介绍一下
双亲委派机制规定了 Java 类加载器在加载类时的层次关系和委托顺序。当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,直到委派到最顶层的启动类加载器。只有当父类加载器无法完成该加载请求(在它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。
在 Java 中,主要有以下几种类型的类加载器,它们构成了一个层次结构:
- 启动类加载器(Bootstrap Class Loader):它是最顶层的类加载器,由 C++ 实现,负责加载 Java 的核心类库,如
java.lang
、java.util
等包下的类,加载的路径通常是JRE/lib
目录。 - 扩展类加载器(Extension Class Loader):由 Java 语言实现,它的父加载器是启动类加载器,负责加载
JRE/lib/ext
目录下的扩展类库。 - 应用程序类加载器(Application Class Loader):也称为系统类加载器,同样由 Java 语言实现,父加载器是扩展类加载器,负责加载用户类路径(
classpath
)上所指定的类库,我们自己编写的 Java 类通常由它加载。 - 自定义类加载器(Custom Class Loader):用户可以根据需要自定义类加载器,它的父加载器通常是应用程序类加载器。
以下是双亲委派机制的具体工作流程:
- 当一个自定义类加载器收到类加载请求时,它会先将请求委派给其父加载器(通常是应用程序类加载器)。
- 应用程序类加载器收到请求后,会继续将请求委派给它的父加载器(扩展类加载器)。
- 扩展类加载器再将请求委派给启动类加载器。
- 启动类加载器会在其负责的加载路径(
JRE/lib
)中查找所需的类。如果找到,则直接加载该类;如果找不到,则将加载请求返回给扩展类加载器。 - 扩展类加载器在自己的加载路径(
JRE/lib/ext
)中查找类。若找到则加载,找不到就把请求返回给应用程序类加载器。 - 应用程序类加载器在用户类路径(
classpath
)中查找类。若找到则加载,找不到再由自定义类加载器尝试加载(如果存在自定义类加载器)。
代码如下,在下述代码中,当尝试加载 java.lang.String
类时,应用程序类加载器会将请求依次委派给扩展类加载器和启动类加载器,最终由启动类加载器完成加载,所以输出结果显示该类的类加载器为 null
(因为启动类加载器由 C++ 实现,在 Java 中表示为 null
)。
import java.net.URL;
import java.net.URLClassLoader;
public class ParentDelegationExample {
public static void main(String[] args) {
// 获取应用程序类加载器
ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
// 获取扩展类加载器
ClassLoader extClassLoader = appClassLoader.getParent();
// 获取启动类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println("应用程序类加载器: " + appClassLoader);
System.out.println("扩展类加载器: " + extClassLoader);
System.out.println("启动类加载器: " + bootstrapClassLoader);
try {
// 尝试加载一个类
Class<?> clazz = appClassLoader.loadClass("java.lang.String");
System.out.println("加载 " + clazz.getName() + " 的类加载器是: " + clazz.getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
双亲委派机制优点和缺点:
- 优点:避免类的重复加载:确保同一个类只会被加载一次,不同的类加载器加载的同一个类也被视为相同的类,保证了 Java 程序的稳定性和一致性。保证 Java 核心类库的安全性:由于核心类库由启动类加载器加载,用户无法通过自定义类加载器来加载和篡改核心类库中的类,防止了恶意代码替换核心类的情况发生。
- 缺点:灵活性不足:双亲委派机制的层级结构比较固定,在某些场景下可能无法满足动态加载类的需求,例如在 OSGi 等动态模块化系统中,就需要打破双亲委派机制来实现更灵活的类加载。
常见回收算法你知道哪些?
- 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
- 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
- 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
- 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。
垃圾回收器有哪些?
- Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
- ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
- Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
- Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
- Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
- G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代
MySQL
事务 ACID特性是什么?
- 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样,就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。
- 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。
- 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。
- 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
MySQL InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?
- 持久性是通过 redo log (重做日志)来保证的;
- 原子性是通过 undo log(回滚日志) 来保证的;
- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;
- 一致性则是通过持久性+原子性+隔离性来保证;
数据库三大范式清楚吗?
数据库三大范式是设计关系型数据库时遵循的重要规则,它们的主要目的是减少数据冗余、提高数据的一致性和可维护性,有助于设计出更高效、更合理的数据库结构。
- 第一范式(1NF):“第一范式要求数据库表的每一列都是不可再分的最小数据单元,也就是保证数据的原子性。例如,在设计学生信息表时,不能将多种信息混合在一列中,像‘联系方式’就应拆分为‘电话号码’和‘电子邮箱’等列。”
- 第二范式(2NF):“第二范式建立在第一范式的基础上,要求表中的每一个非主属性完全依赖于主键,而不是只依赖于主键的一部分。当表存在复合主键时,这一点尤为关键。比如在订单详情表中,复合主键为(订单 ID,商品 ID),‘商品单价’只依赖于‘商品 ID’,不满足第二范式,可将商品信息拆分为单独的商品表。”
- 第三范式(3NF):“第三范式在满足第二范式的前提下,要求每一个非主属性既不部分依赖于主键也不传递依赖于主键。以员工信息表为例,若包含‘员工 ID’‘部门 ID’‘部门名称’,‘部门名称’通过‘部门 ID’间接依赖于‘员工 ID’,存在传递依赖,可将部门信息单独拆分为部门表。”
虽然遵循三大范式能带来很多好处,但在实际应用中,并非所有情况都要严格遵循这些范式。有时为了提高查询性能,可能会适当增加数据冗余,采用反范式设计。例如,在一些高并发的查询场景中,适当的冗余数据可以减少表连接操作,提高查询效率。
mysql 的隔离级别有哪些?
- 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;
- 读提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;
- 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别;
- 串行化(serializable);会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;
按隔离水平高低排序如下:
针对不同的隔离级别,并发事务时可能发生的现象也会不同。
也就是说:
- 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
- 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
- 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏读和不可重复读现象;
- 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。
接下来,举个具体的例子来说明这四种隔离级别,有一张账户余额表,里面有一条账户余额为 100 万的记录。然后有两个并发的事务,事务 A 只负责查询余额,事务 B 则会将我的余额改成 200 万,下面是按照时间顺序执行两个事务的行为:
在不同隔离级别下,事务 A 执行过程中查询到的余额可能会不同:
- 在「读未提交」隔离级别下,事务 B 修改余额后,虽然没有提交事务,但是此时的余额已经可以被事务 A 看见了,于是事务 A 中余额 V1 查询的值是 200 万,余额 V2、V3 自然也是 200 万了;
- 在「读提交」隔离级别下,事务 B 修改余额后,因为没有提交事务,所以事务 A 中余额 V1 的值还是 100 万,等事务 B 提交完后,最新的余额数据才能被事务 A 看见,因此额 V2、V3 都是 200 万;
- 在「可重复读」隔离级别下,事务 A 只能看见启动事务时的数据,所以余额 V1、余额 V2 的值都是 100 万,当事务 A 提交事务后,就能看见最新的余额数据了,所以余额 V3 的值是 200 万;
- 在「串行化」隔离级别下,事务 B 在执行将余额 100 万修改为 200 万时,由于此前事务 A 执行了读操作,这样就发生了读写冲突,于是就会被锁住,直到事务 A 提交后,事务 B 才可以继续执行,所以从 A 的角度看,余额 V1、V2 的值是 100 万,余额 V3 的值是 200万。
这四种隔离级别具体是如何实现的呢?
- 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;
- 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问;
- 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View来实现的,它们的区别在于创建 Read View 的时机不同,「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View。
常见的索引有哪些?
- 唯一索引:要求索引列中的值必须唯一,允许为 NULL。确保数据的唯一性,如用户表中的用户名、邮箱字段可创建唯一索引。缺点是插入和更新数据时需要额外检查唯一性,可能影响性能。
- **主键索引:**特殊的唯一索引,索引列的值唯一且不能为 NULL,每个表只能有一个主键索引。唯一标识表中的每一行记录,加快数据的查找和关联操作。选择不当可能影响性能,如使用过长的主键会增加索引空间和查询成本。
- 联合索引:在多个列上创建的索引,索引列按指定顺序排序。当查询条件经常同时涉及多个列时,使用组合索引可提高查询效率。如果查询条件不按索引列顺序使用,可能无法有效利用索引。
唯一索引如何实现不可重复的?
数据库会在插入操作执行前对唯一索引进行扫描。以 B+ 树结构的唯一索引为例,数据库会根据插入值在 B+ 树中进行查找操作,若找到相同的值,插入操作将被拒绝,并返回唯一约束冲突的错误信息。例如在 MySQL 里,如果对用户表的 email
字段创建了唯一索引,当尝试插入一条 email
已经存在的记录时,会提示 Duplicate entry 'xxx' for key 'email'
错误。
索引的数据结构有哪些?
- B+Tree 索引:B+Tree 是 B Tree 的变体,非叶子节点只存储索引键,数据都存储在叶子节点,且叶子节点通过指针相连形成有序链表。特别适合范围查询,因为可以通过叶子节点的链表快速遍历数据。MySQL 的 InnoDB 存储引擎就使用 B+Tree 作为索引结构。优点是范围查询效率高,磁盘 I/O 次数少,缺点是插入和删除操作可能会导致节点分裂和合并,影响性能。
- Hash 索引:使用哈希函数将索引键映射到哈希表中,通过哈希值快速定位数据存储位置。适用于精确匹配查询,如使用
=
进行的查询。例如,Redis 广泛使用哈希索引实现高效数据查找。优点是查找速度极快,时间复杂度为 O (1),缺点是不支持范围查询,哈希冲突可能影响性能。 - 全文索引:对文本内容进行分词处理,创建倒排索引,记录每个词在哪些文档中出现。用于全文搜索,如论坛、新闻网站的文章关键词搜索。MySQL 从 5.6 版本开始支持 InnoDB 存储引擎的全文索引。优点是能高效处理文本搜索需求。缺点是创建和维护成本高,更新数据时需要重新构建索引。
mysql 锁的分类有哪些?
在 MySQL 里,根据加锁的范围,可以分为全局锁、表级锁和行锁三类。
-
全局锁:通过flush tables with read lock 语句会将整个数据库就处于只读状态了,这时其他线程执行以下操作,增删改或者表结构修改都会阻塞。全局锁主要应用于做全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。
-
表级锁:MySQL 里面表级别的锁有这几种:
-
表锁:通过lock tables 语句可以对表加表锁,表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。
-
元数据锁:当我们对数据库表进行操作时,会自动给这个表加上 MDL,对一张表进行 CRUD 操作时,加的是 MDL 读锁;对一张表做结构变更操作的时候,加的是 MDL 写锁;MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。
-
意向锁:当执行插入、更新、删除操作,需要先对表加上「意向独占锁」,然后对该记录加独占锁。意向锁的目的是为了快速判断表里是否有记录被加锁。
-
行级锁:InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁。
-
记录锁,锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的,满足读写互斥,写写互斥
-
间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
-
Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
update加锁后是否可以读?
在 mysql 默认的可重复读隔离级下,UPDATE
操作通常会对数据加排他锁(X 锁)。是否可以读取加锁的数据取决于读操作的类型:
-- 事务 A
START TRANSACTION;
UPDATE table_name SET column = 'new_value' WHERE id = 1;
-- 事务 B
SELECT * FROM table_name WHERE id = 1; -- 快照读,不会阻塞
SELECT * FROM table_name WHERE id = 1 FOR UPDATE; -- 当前读,会被阻塞
- 如果是快照读,比如普通的 select 查询,快照读是无锁的,不会和 update 冲突,那么数据还是可以读到的。
- 如果是当前读,比如 select … from update 方式查询,这时候是属于锁定读查询语句,是会加锁的,这时候就会与 update 发送锁冲突,就无法正常读到数据了。
操作系统
进程通信的方式?
常见进程通信方式对比
通信方式 | 特点 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
管道 | 半双工,字节流 | 简单易用 | 单向通信,无消息边界 | 父子进程间的简单通信 |
消息队列 | 异步通信,支持多对多 | 消息分类,可靠性高 | 消息大小有限 | 多进程间的异步通信 |
共享内存 | 最快的 IPC 方式 | 高效,适合大数据量 | 需要同步机制 | 高性能需求的通信 |
信号 | 异步通知机制 | 简单轻量 | 无法传递复杂数据 | 简单的事件通知 |
套接字 | 支持跨网络通信 | 跨平台、灵活性强 | 实现复杂 | 分布式系统、网络通信 |
信号量 | 同步机制 | 解决互斥与同步问题 | 不直接用于数据传输 | 进程间的同步与互斥 |
文件 | 通过文件读写通信 | 简单易用,支持持久化 | 性能低,需手动同步 | 数据需要持久化的场景 |
场景题
如何设计一个秒杀场景?
秒杀场景的核心特点是高并发、低库存、短时间爆发式访问 。因此,设计时需要解决以下几个问题:
-
高并发处理 :如何应对大量用户同时访问?
-
库存一致性 :如何保证库存不会超卖或少卖?
-
用户体验 :如何减少用户等待时间,避免页面崩溃?
-
防刷机制 :如何防止恶意用户利用脚本抢购商品?
面对上面这些问题,可以针对每一层做一些设计:
1、前端层:
- 静态资源分离 :将秒杀页面的静态资源(如HTML、CSS、JS)部署到CDN(内容分发网络),减轻服务器压力。
- 请求拦截:活动未开始时,前端按钮置灰;通过验证码、点击频率限制。
2、网关层:
- 流量拦截 :使用API网关对请求进行初步过滤,例如IP限流、黑名单拦截等。
- 身份验证 :通过Token或签名验证用户身份,防止未登录用户直接访问秒杀接口。
3、缓存层:
- Redis缓存库存 :将商品库存信息存储在Redis中,利用其高性能特性处理库存扣减操作。
- 预热数据 :在秒杀活动开始前,将商品信息和库存数据加载到Redis中,减少数据库压力。
4、消息队列:
- 削峰填谷 :使用消息队列(如Kafka)将用户的秒杀请求异步化,避免直接冲击后端服务。
- 订单处理 :将成功的秒杀请求放入队列,由后台服务异步生成订单,提高系统吞吐量。
5、数据库层
- 乐观锁:在库存扣减时,使用乐观锁确保库存一致性。
- 读写分离 :通过主从复制实现数据库的读写分离,提升查询性能。
关键的核心业务逻辑实现,**库存防超卖方案采用:**Redis原子操作 + 异步扣减数据库
具体流程如下:
- 秒杀请求达到:用户发起秒杀请求,系统接收到请求后,首先进行一些基础校验(如用户身份验证、活动是否开始等)。如果校验通过,进入库存扣减逻辑。
- **Redis库存扣减:**在Redis中检查商品库存是否充足。例如,使用
**GET**
命令获取当前库存数量。如果库存不足,直接返回失败,结束流程。如果库存充足,使用Redis的原子操作(如**DECR**
或Lua脚本)扣减库存。 - 异步更新数据库:如果Redis库存扣减成功,生成一个秒杀成功的消息,并将其放入消息队列
- **后台服务消费消息:**后台服务从消息队列中消费秒杀成功的消息,执行以下操作:1、为用户创建订单记录;2、使用乐观锁将数据库中的库存数量减少1;3、通过唯一标识(如用户ID+商品ID+时间戳)防止重复消费。
- **最终一致性校验:**在Redis库存扣减和数据库库存更新之间,可能会存在短暂的不一致状态。为了保证最终一致性,可以采取以下措施:1、定期将Redis中的库存数据与数据库进行同步。2、如果发现Redis和数据库库存不一致,触发补偿逻辑(如回滚订单或调整库存)。
算法
判断链表是否存在环
思路:可以使用**快慢指针,**分别从链表头部开始移动。**slow**
每次移动一步,**fast**
每次移动两步。如果链表中存在环,**fast**
和 **slow**
最终会在环内相遇,如果链表没有环,**fast**
会先到达链表末尾(**null**
)。
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
this.next = null;
}
}
public class LinkedListCycle {
// 判断链表是否有环
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false; // 空链表或只有一个节点,不可能有环
}
// 定义快慢指针
ListNode slow = head; // 慢指针每次走一步
ListNode fast = head; // 快指针每次走两步
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow == fast) { // 快慢指针相遇,说明有环
return true;
}
}
return false; // 快指针到达链表末尾,说明无环
}
}
这种方法的时间复杂度为 O(n) ,空间复杂度为 O(1)
- 👉《图解网络》 :500 张图 + 15 万字贯穿计算机网络重点知识,如HTTP、HTTPS、TCP、UDP、IP等协议
- 👉《图解系统》:400 张图 + 16 万字贯穿操作系统重点知识,如进程管理、内存管理、文件系统、网络系统等
- 👉《图解MySQL》:重点突击 MySQL 索引、存储引擎、事务、MVCC、锁、日志等面试高频知识
- 👉《图解Redis》:重点突击 Redis 数据结构、持久化、缓存淘汰、高可用、缓存数据一致性等面试高频知识
- 👉《Java后端面试题》:涵盖Java基础、Java并发、Java虚拟机、Spring、MySQL、Redis、计算机网络等企业面试题
- 👉《大厂真实面经》:涵盖互联网大厂、互联网中厂、手机厂、通信厂、新能源汽车厂、银行等企业真实面试题