加入星计划,您可以享受以下权益:

  • 创作内容快速变现
  • 行业影响力扩散
  • 作品版权保护
  • 300W+ 专业用户
  • 1.5W+ 优质创作者
  • 5000+ 长期合作伙伴
立即加入
  • 正文
    • Java
    • Java创建线程的方式有哪些?
    • 场景
  • 相关推荐
申请入驻 产业图谱

携程薪资开了,还算满意,签了!

01/05 11:25
610
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

图解学习网站:https://xiaolincoding.com

大家好,我是小林。昨天公布了互联网公司 25 届校招薪资,底部有读者留言想看看携程的薪资和面经。

之前有同学刚参加完携程的线下面试,流程是一二面+HR面,当天下午就直接速通了,速通之后就等后面的 offer 录取通知了,如果是线上面试的话,有时候流程会需要走 2-3 周,整体还是比较慢,最快也需要一周,所以线下面试效率还是非常快的,快人一步拿 offer。

25 届携程开发岗位的校招薪资如下:

整体看,携程的年薪是有 30-40w的,薪资待遇还是不错的,跟一线大厂差不多了,训练营也有同学拿到了携程 offer,薪资开了 sp offer,还算满意,最后选择去携程。

那携程面试到底难度如何呢?

那么,这次来分享一位同学携程的Java 后端开发的面经,主要是考察了Java 集合、Java IO、Java 并发、SSM、场景题、系统设计方面的知识。一般来说,携程面试还是会出算法的,不过这个同学当时是没有手撕算法,面试时长大概 40 分钟。

大家觉得难度如何呢?

Java

Java 中常用集合有哪些?

List是有序的Collection,使用此接口能够精确的控制每个元素的插入位置,用户能根据索引访问List中元素。常用的实现List的类有LinkedList,ArrayList,Vector,Stack。

    ArrayList 是容量可变的非线程安全列表,其底层使用数组实现。当发生扩容时,会创建更大的数组,并把原数组复制到新数组。ArrayList支持对元素的快速随机访问,但插入与删除速度很慢。LinkedList本质是一个双向链表,与ArrayList相比,,其插入和删除速度更快,但随机访问速度更慢。Vector 与 ArrayList 类似,底层也是基于数组实现,特点是线程安全,但效率相对较低,因为其方法大多被 synchronized 修饰

Map 是一个键值对集合,存储键、值和之间的映射。Key 无序,唯一;value 不要求有序,允许重复。Map 没有继承于 Collection 接口,从 Map 集合中检索元素时,只要给出键对象,就会返回对应的值对象。主要实现有TreeMap、HashMap、HashTable、LinkedHashMap、ConcurrentHashMap

    HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突),JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。HashTable:数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的TreeMap:红黑树(自平衡的排序二叉树)ConcurrentHashMap:Node数组+链表+红黑树实现,线程安全的(jdk1.8以前Segment锁,1.8以后volatile + CAS 或者 synchronized)

Set不允许存在重复的元素,与List不同,set中的元素是无序的。常用的实现有HashSet,LinkedHashSet和TreeSet。

    HashSet通过HashMap实现,HashMap的Key即HashSet存储的元素,所有Key都是用相同的Value,一个名为PRESENT的Object类型常量。使用Key保证元素唯一性,但不保证有序性。由于HashSet是HashMap实现的,因此线程不安全。LinkedHashSet继承自HashSet,通过LinkedHashMap实现,使用双向链表维护元素插入顺序。TreeSet通过TreeMap实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。

HashMap 的实现原理?

在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。

所以在 JDK 1.8 版本的时候做了优化,当一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。

HashSet 的实现原理及使用原理?

HashSet 实现原理:

数据结构实现原理

    • :HashSet 是基于哈希表实现的,HashSet 内部使用一个 HashMap 来存储元素。实际上,HashSet 可以看作是对 HashMap 的简单封装,它只使用了 HashMap 的键(Key)来存储元素,而值(Value)部分被忽略(在 Java 的 HashMap 实现中,所有 HashSet 中的元素对应的 Value 是一个固定的 Object 对象,通常是一个名为 PRESENT 的静态常量)。

元素存储过程

    • :当向 HashSet 中添加一个元素时,首先会计算该元素的哈希码,然后通过哈希函数得到桶的索引。接着检查该桶中是否已经存在元素。如果桶为空,则直接将元素插入到该桶中;如果桶不为空,则遍历桶中的链表(或红黑树),比较元素的哈希码和 equals 方法(在 Java 中,判断两个元素是否相等,先比较哈希码是否相同,若相同再比较 equals 方法是否返回 true)。如果没有找到相同的元素(即哈希码和 equals 方法都不匹配),则将元素添加到链表(或红黑树)中;如果找到相同的元素,则认为该元素已经存在于 HashSet 中,不会重复添加。

元素查找过程

    :查找一个元素是否在 HashSet 中也是类似的过程。先计算元素的哈希码,然后通过哈希函数得到桶的索引。接着在对应的桶中查找元素,通过比较哈希码和 equals 方法来判断元素是否存在。由于哈希函数能够快速定位到元素可能存在的桶,所以在理想情况下,HashSet 的查找操作时间复杂度可以接近常数时间 O (1),但在最坏情况下(所有元素都哈希到同一个桶),时间复杂度会退化为 O (n),其中 n 是 HashSet 中的元素个数。

HashSet 使用原理:

添加元素

    • :使用 add 方法可以将元素添加到 HashSet 中。例如,在 Java 中,HashSet<String> set = new HashSet<>(); set.add("example");
    • 就将字符串 “example” 添加到了 HashSet 中。

检查元素是否存在

    • :使用 contains 方法来检查一个元素是否存在于 HashSet 中。例如,set.contains("example")
    • 会返回 true,因为刚刚添加了这个元素。

删除元素

    • :通过 remove 方法删除元素。如set.remove("example");会将刚刚添加的元素从 HashSet 中删除。

ArrayList 和 LinkedList 有什么区别?

ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。

底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。

插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针。

随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。

空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。

使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。

线程安全:这两个集合都不是线程安全的,Vector是线程安全的

双亲委派策略是什么?

双亲委派模型是 Java 类加载器的一种层次化加载策略。在这种策略下,当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给它的父类加载器。只有当父类加载器无法完成加载任务时,才由自己来加载。

在 Java 中,类加载器主要有以下几种,并且存在层次关系。

启动类加载器(Bootstrap ClassLoader)

    • :它是最顶层的类加载器,主要负责加载 Java 的核心类库,例如存放在<JAVA_HOME>/lib
    • 目录下的rt.jar
    • 等核心库。它是由 C++ 编写的,是虚拟机的一部分,没有对应的 Java 类,在 Java 代码中无法直接引用它。

扩展类加载器(Extension ClassLoader)

    • :它的父加载器是启动类加载器。主要负责加载<JAVA_HOME>/lib/ext
    • 目录下的类库或者由java.ext.dirs
    • 系统属性指定路径中的类库。它是由 Java 编写的,对应的 Java 类是sun.misc.Launcher$ExtClassLoader。

应用程序类加载器(Application ClassLoader)

    • :也称为系统类加载器,它的父加载器是扩展类加载器。主要负责加载用户类路径(classpath
    • )上的类库,这是我们在日常编程中最常接触到的类加载器,对应的 Java 类是sun.misc.Launcher$AppClassLoader。

自定义类加载器(Custom Class Loader)

    :开发者可以根据需求定制类的加载方式,比如从网络加载class文件、数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展Java应用程序的灵活性和安全性,是Java动态性的一个重要体现。

当一个类加载请求到达应用程序类加载器时,它会先把请求委派给它的父加载器(扩展类加载器)。扩展类加载器收到请求后,也会先委派给它的父加载器(启动类加载器)。启动类加载器会尝试从自己负责的核心类库中加载这个类,如果能加载成功,就返回加载的类;如果不能加载,就把请求返回给扩展类加载器。扩展类加载器再尝试从自己负责的扩展类库中加载,如果成功就返回,否则将请求返回给应用程序类加载器。最后,应用程序类加载器从自己负责的类路径中加载这个类。

双亲委派模型优势是:

安全性

    • :通过双亲委派策略,保证了 Java 核心类库的安全性。例如,java.lang.Object
    • 这个类是由启动类加载器加载的。如果没有这种策略,用户可能会编写一个自己的java.lang.Object
    • 类,并且通过自定义的类加载器加载,这会导致整个 Java 类型系统的混乱。而双亲委派策略使得像java.lang.Object
    • 这样的核心类始终由启动类加载器加载,防止了用户代码对核心类库的恶意篡改。

避免类的重复加载

    :由于类加载请求是由上到下进行委派的,当一个类已经被父类加载器加载后,子类加载器就不会再重复加载。例如,某个类在扩展类库中已经被加载,那么应用程序类加载器就不会再次加载这个类,从而提高了加载效率,节省了内存空间。

深拷贝和浅拷贝的区别?怎么实现?

    浅拷贝是指只复制对象本身和其内部的值类型字段,但不会复制对象内部的引用类型字段。换句话说,浅拷贝只是创建一个新的对象,然后将原对象的字段值复制到新对象中,但如果原对象内部有引用类型的字段,只是将引用复制到新对象中,两个对象指向的是同一个引用对象。深拷贝是指在复制对象的同时,将对象内部的所有引用类型字段的内容也复制一份,而不是共享引用。换句话说,深拷贝会递归复制对象内部所有引用类型的字段,生成一个全新的对象以及其内部的所有对象。

序列化和反序列化实现的是深拷贝还是浅拷贝?

Java创建线程的方式有哪些?

1.继承Thread类

这是最直接的一种方式,用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。

class MyThread extends Thread {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    MyThread t = new MyThread();
    t.start();
}

采用继承Thread类方式

    优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类

2.实现Runnable接口

如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    Thread t = new Thread(new MyRunnable());
    t.start();
}

采用实现Runnable接口方式:

    优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。

3. 实现Callable接口与FutureTask

java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        // 线程执行的代码,这里返回一个整型结果
        return 1;
    }
}

public static void main(String[] args) {
    MyCallable task = new MyCallable();
    FutureTask<Integer> futureTask = new FutureTask<>(task);
    Thread t = new Thread(futureTask);
    t.start();

    try {
        Integer result = futureTask.get();  // 获取线程执行结果
        System.out.println("Result: " + result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
}

采用实现Callable接口方式:

    • 缺点:编程稍微复杂,如果需要访问当前线程,必须调用

Thread.currentThread()

    方法。优点:线程只是实现Runnable或实现Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。

4. 使用线程池(Executor框架)

从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。

class Task implements Runnable {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(10);  // 创建固定大小的线程池
    for (int i = 0; i < 100; i++) {
        executor.submit(new Task());  // 提交任务到线程池执行
    }
    executor.shutdown();  // 关闭线程池
}

采用线程池方式:

    缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。

线程池使用的时候应该注意哪些问题?

线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗,线程池的工作原理如下图:

线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。

线程池使用的时候应该注意以下问题。

1.线程池大小的合理设置性

核心线程数(Core Pool Size)

    • :核心线程数是线程池在没有任务时保持的线程数量。如果设置得太小,当有大量任务突然到达时,线程池可能无法及时处理,导致任务在队列中等待时间过长。例如,对于一个 CPU 密集型的任务,核心线程数一般可以设置为 CPU 核心数加 1,这样可以充分利用 CPU 资源,同时避免过多的上下文切换。如果是 I/O 密集型任务,由于线程大部分时间在等待 I/O 操作完成,核心线程数可以设置得相对大一些,通常可以根据 I/O 设备的性能和任务的 I/O 等待时间来估算,比如可以设置为 CPU 核心数的两倍。

最大线程数(Maximum Pool Size)

    • :最大线程数决定了线程池能够同时处理任务的上限。如果设置得过大,可能会导致系统资源耗尽,如内存不足或者 CPU 过度切换上下文而导致性能下降。设置最大线程数时需要考虑系统的资源限制,包括 CPU、内存等。并且,最大线程数与任务队列的大小也有关系,当任务队列满了之后,线程池会创建新的线程,直到达到最大线程数。

阻塞队列(Blocking Queue)容量

    :阻塞队列用于存储等待执行的任务。如果队列容量设置得过小,可能无法容纳足够的任务,导致任务被拒绝;而如果设置得过大,可能会导致任务在队列中等待时间过长,增加响应时间。对于有优先级的任务队列,还需要考虑如何合理地设置优先级,以确保高优先级的任务能够及时得到处理。

2.线程池的生命周期管理

正确的启动和关闭顺序

    • :要确保线程池在正确的时机启动和关闭。在启动线程池后,才能提交任务给它执行;在系统关闭或者不再需要线程池时,需要正确地关闭线程池。关闭线程池可以使用shutdown
    • 或者shutdownNow
    • 方法。

shutdown

    • 方法会等待正在执行的任务完成后再关闭线程池,而shutdownNow
    • 方法会尝试中断正在执行的任务,并立即关闭线程池,返回尚未执行的任务列表。

避免重复提交任务

    :在某些情况下,可能会出现重复提交任务的情况。比如在网络不稳定的情况下,客户端可能会多次发送相同的请求,导致任务被多次提交到线程池。这可能会导致任务的重复执行或者资源的浪费。可以通过在任务提交端进行去重处理,或者在任务本身的逻辑中设置标志位来判断任务是否已经在执行,避免重复执行。

3.线程安全性和资源管理

共享资源访问控制

    • :线程池中的线程会并发地执行任务,如果任务涉及到共享资源的访问,如共享变量、数据库连接等,需要采取适当的同步措施,如使用synchronized
    • 关键字或者ReentrantLock
    • 等锁机制,以避免数据不一致或者资源竞争的问题。

资源的释放和清理

    • :线程执行任务可能会占用各种资源,如文件句柄、网络连接等。在任务执行完成后,需要确保这些资源得到正确的释放和清理,避免资源泄漏。可以在任务的run
    • 方法或者call
    方法的最后进行资源的清理工作,如关闭文件流、释放数据库连接等。

BIO、NIO、AIO 区别是什么?

    BIO(blocking IO):就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。优点是代码比较简单、直观;缺点是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。NIO(non-blocking IO) :Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层高性能的数据操作方式。AIO(Asynchronous IO) :是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

三级缓存解决循环依赖方式是?

环依赖指的是两个类中的属性相互依赖对方:例如 A 类中有 B 属性,B 类中有 A属性,从而形成了一个依赖闭环,如下图。

循环依赖问题在Spring中主要有三种情况:

    第一种:通过构造方法进行依赖注入时产生的循环依赖问题。第二种:通过setter方法进行依赖注入且是在多例(原型)模式下产生的循环依赖问题。第三种:通过setter方法进行依赖注入且是在单例模式下产生的循环依赖问题。

只有【第三种方式】的循环依赖问题被 Spring 解决了,其他两种方式在遇到循环依赖问题时,Spring都会产生异常。

Spring 解决单例模式下的setter循环依赖问题的主要方式是通过三级缓存解决循环依赖。三级缓存指的是 Spring 在创建 Bean 的过程中,通过三级缓存来缓存正在创建的 Bean,以及已经创建完成的 Bean 实例。具体步骤如下:

实例化 Bean:Spring 在实例化 Bean 时,会先创建一个空的 Bean 对象,并将其放入一级缓存中。

属性赋值:Spring 开始对 Bean 进行属性赋值,如果发现循环依赖,会将当前 Bean 对象提前暴露给后续需要依赖的 Bean(通过提前暴露的方式解决循环依赖)。

初始化 Bean:完成属性赋值后,Spring 将 Bean 进行初始化,并将其放入二级缓存中。

注入依赖:Spring 继续对 Bean 进行依赖注入,如果发现循环依赖,会从二级缓存中获取已经完成初始化的 Bean 实例。

通过三级缓存的机制,Spring 能够在处理循环依赖时,确保及时暴露正在创建的 Bean 对象,并能够正确地注入已经初始化的 Bean 实例,从而解决循环依赖问题,保证应用程序的正常运行。

Java 21 新特性知道哪些?

新新语言特性:

Switch 语句的模式匹配:该功能在 Java 21 中也得到了增强。它允许在switch

    • 的case
    • 标签中使用模式匹配,使操作更加灵活和类型安全,减少了样板代码和潜在错误。例如,对于不同类型的账户类,可以在switch
      • 语句中直接根据账户类型的模式来获取相应的余额,如

    case savingsAccount sa -> result = sa.getSavings();数组模式

    • :将模式匹配扩展到数组中,使开发者能够在条件语句中更高效地解构和检查数组内容。例如,if (arr instanceof int[] {1, 2, 3})
    • ,可以直接判断数组arr
    • 是否匹配指定的模式。

字符串模板(预览版)

    • :提供了一种更可读、更易维护的方式来构建复杂字符串,支持在字符串字面量中直接嵌入表达式。例如,以前可能需要使用"hello " + name + ", welcome to the geeksforgeeks!"
    • 这样的方式来拼接字符串,在 Java 21 中可以使用hello {name}, welcome to the geeksforgeeks!
    这种更简洁的写法

新并发特性方面:

虚拟线程:这是 Java 21 引入的一种轻量级并发的新选择。它通过共享堆栈的方式,大大降低了内存消耗,同时提高了应用程序的吞吐量和响应速度。可以使用静态构建方法、构建器或ExecutorService来创建和使用虚拟线程。

Scoped Values(范围值):提供了一种在线程间共享不可变数据的新方式,避免使用传统的线程局部存储,促进了更好的封装性和线程安全,可用于在不通过方法参数传递的情况下,传递上下文信息,如用户会话或配置设置。

SpringBoot 的核心注解有哪些?

Bean 相关:

@Component:将一个类标识为 Spring 组件(Bean),可以被 Spring 容器自动检测和注册。通用注解,适用于任何层次的组件。

@ComponentScan:自动扫描指定包及其子包中的 Spring 组件。

@Controller:标识控制层组件,实际上是 @Component 的一个特化,用于表示 Web 控制器。处理 HTTP 请求并返回视图或响应数据。

@RestController:是 @Controller 和 @ResponseBody 的结合,返回的对象会自动序列化为 JSON 或 XML,并写入 HTTP 响应体中。

@Repository:标识持久层组件(DAO 层),实际上是 @Component 的一个特化,用于表示数据访问组件。常用于与数据库交互。

@Bean:方法注解,用于修饰方法,主要功能是将修饰方法的返回对象添加到 Spring 容器中,使得其他组件可以通过依赖注入的方式使用这个对象。

依赖注入:

@Autowired:用于自动注入依赖对象,Spring 框架提供的注解。

@Resource:按名称自动注入依赖对象(也可以按类型,但默认按名称),JDK 提供注解。

@Qualifier:与 @Autowired 一起使用,用于指定要注入的 Bean 的名称。当存在多个相同类型的 Bean 时,可以使用 @Qualifier 来指定注入哪一个。

读取配置:

@Value:用于注入属性值,通常从配置文件中获取。标注在字段上,并指定属性值的来源(如配置文件中的某个属性)。

@ConfigurationProperties:用于将配置属性绑定到一个实体类上。通常用于从配置文件中读取属性值并绑定到类的字段上。

Web相关:

@RequestMapping:用于映射 HTTP 请求到处理方法上,支持 GET、POST、PUT、DELETE 等请求方法。可以标注在类或方法上。标注在类上时,表示类中的所有响应请求的方法都是以该类路径为父路径。

@GetMapping、@PostMapping、@PutMapping、@DeleteMapping:分别用于映射 HTTP GET、POST、PUT、DELETE 请求到处理方法上。它们是 @RequestMapping 的特化,分别对应不同的 HTTP 请求方法。

其他常用注解:

@Transactional:声明事务管理。标注在类或方法上,指定事务的传播行为、隔离级别等。

@Scheduled:声明一个方法需要定时执行。标注在方法上,并指定定时执行的规则(如每隔一定时间执行一次)。

场景

高并发的场景下保证数据库和缓存一致性?

对于读数据,我会选择旁路缓存策略,如果 cache 不命中,会从 db 加载数据到 cache。对于写数据,我会选择更新 db 后,再删除缓存。

缓存是通过牺牲强一致性来提高性能的。这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。所以,如果需要数据库和缓存数据保持强一致,就不适合使用缓存。

所以使用缓存提升性能,就是会有数据更新的延迟。这需要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期时间,这个时间太短、或者太长都不好:

    太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势。太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。

但是,通过一些方案优化处理,是可以最终一致性的。

针对删除缓存异常的情况,可以使用 2 个方案避免:

    删除缓存重试策略(消息队列)订阅 binlog,再删除缓存(Canal+消息队列)

消息队列方案

我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

      • 如果应用

    删除缓存失败

      • ,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是

    重试机制

      • 。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。如果

    删除缓存成功

    ,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

举个例子,来说明重试机制的过程。

重试删除缓存机制还可以,就是会造成好多业务代码入侵

订阅 MySQL binlog,再操作缓存「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

下图是 Canal 的工作原理:

将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性

使用消息队列还应该注意哪些问题?

需要考虑消息可靠性和顺序性方面的问题。

消息队列的可靠性、顺序性怎么保证?

消息可靠性可以通过下面这些方式来保证

消息持久化:确保消息队列能够持久化消息是非常关键的。在系统崩溃、重启或者网络故障等情况下,未处理的消息不应丢失。例如,像 RabbitMQ 可以通过配置将消息持久化到磁盘,通过将队列和消息都设置为持久化的方式(设置durable = true),这样在服务器重启后,消息依然可以被重新读取和处理。

消息确认机制:消费者在成功处理消息后,应该向消息队列发送确认(acknowledgment)。消息队列只有收到确认后,才会将消息从队列中移除。如果没有收到确认,消息队列可能会在一定时间后重新发送消息给其他消费者或者再次发送给同一个消费者。以 Kafka 为例,消费者通过commitSync或者commitAsync方法来提交偏移量(offset),从而确认消息的消费。

消息重试策略:当消费者处理消息失败时,需要有合理的重试策略。可以设置重试次数和重试间隔时间。例如,在第一次处理失败后,等待一段时间(如 5 秒)后进行第二次重试,如果重试多次(如 3 次)后仍然失败,可以将消息发送到死信队列,以便后续人工排查或者采取其他特殊处理。

消息顺序性保证的方式如下:

有序消息处理场景识别:首先需要明确业务场景中哪些消息是需要保证顺序的。例如,在金融交易系统中,对于同用户的转账操作顺序是不能打乱的。对于需要顺序处理的消息,要确保消息队列和消费者能够按照特定的顺序进行处理。

消息队列对顺序性的支持:部分消息队列本身提供了顺序性保证的功能。比如 Kafka 可以通过将消息划分到同一个分区(Partition)来保证消息在分区内是有序的,消费者按照分区顺序读取消息就可以保证消息顺序。但这也可能会限制消息的并行处理程度,需要在顺序性和吞吐量之间进行权衡。

消费者顺序处理策略:消费者在处理顺序消息时,应该避免并发处理可能导致顺序打乱的情况。例如,可以通过单线程或者使用线程池并对顺序消息进行串行化处理等方式,确保消息按照正确的顺序被消费。

系统设计

秒杀系统设计如何做?

系统架构分层设计如下。

前端层:

页面静态化:将商品展示页面等静态内容进行缓存,用户请求时可以直接从缓存中获取,减少服务器的渲染压力。例如,使用内容分发网络(CDN)缓存商品图片、详情介绍等静态资源。

防刷机制:通过验证码、限制用户请求频率等方式防止恶意刷请求。例如,在秒杀开始前要求用户输入验证码,并且在一定时间内限制单个用户的请求次数,如每秒最多允许 3 次请求。

应用层

负载均衡:采用负载均衡器将用户请求均匀地分配到多个后端服务器,避免单点服务器过载。如使用 Nginx 作为负载均衡器,根据服务器的负载情况和性能动态分配请求。

服务拆分与微服务化:将秒杀系统的不同功能模块拆分成独立的微服务,如用户服务、商品服务、订单服务等。这样可以独立部署和扩展各个模块,提高系统的灵活性和可维护性。

缓存策略:在应用层使用缓存来提高系统性能。例如,使用 Redis 缓存商品库存信息,用户下单前先从 Redis 中查询库存,减少对数据库的直接访问。

数据层:

数据库优化:对数据库进行性能优化,如数据库索引优化、SQL 语句优化等。对于库存表,可以为库存字段添加索引,加快库存查询和更新的速度。

数据库集群与读写分离

    :采用数据库集群来提高数据库的处理能力,同时进行读写分离。将读操作(如查询商品信息)和写操作(如库存扣减、订单生成)分布到不同的数据库节点上,提高系统的并发处理能力。

高并发场景下扣减库存的方式:

预扣库存:在用户下单时,先预扣库存,将库存数量在缓存(如 Redis)中进行减 1 操作。同时设置一个较短的过期时间,如 1 - 2 分钟。如果用户在过期时间内完成支付,正式扣减库存;如果未完成支付,库存自动回补。

异步更新数据库:通过 Redis 判断之后,去更新数据库的请求都是必要的请求,这些请求数据库必须要处理,但是如果数据库还是处理不过来这些请求怎么办呢?这个时候就可以考虑削峰填谷操作了,削峰填谷最好的实践就是 MQ 了。经过 Redis 库存扣减判断之后,我们已经确保这次请求需要生成订单,我们就可以通过异步的形式通知订单服务生成订单并扣减库存。

数据库乐观锁防止超卖

    • :更新数据库减库存的时候,采用乐观锁方式,进行库存限制条件,update goods set stock = stock - 1 where goods_id = ? and stock >0

相关推荐