面试总结

Java 基础

1、Java的基本数据类型有哪些,占几个字节

八种基本类型。六种数字类型(四个整数型,两个浮点型),一种字符类型,还有一种布尔型。

  1. 整型:byte、short、int、long
  2. 浮点型:float、double
  3. 字符型:char
  4. 布尔型:boolean
序号 数据类型 位数 字节 默认值 取值范围 举例说明
1 byte(位) 8 1 0 -2^7 - 2^7-1 byte e = 10;
2 short(短整数) 16 2 0 -2^15 - 2^15-1 short s = 10;
3 int(整数) 32 4 0 -2^31 - 2^31-1 int i = 10;
4 long(长整数) 64 8 0 -2^63 - 2^63-1 long l = 10l;
5 float(单精度) 32 4 0.0 -2^31 - 2^31-1 float f = 10.0f;
6 double(双精度) 64 8 0.0 -2^63 - 2^63-1 double d = 10.0d;
7 char(字符) 16 2 null 0 - 2^16-1 char e = ‘c’;
8 boolean(布尔值) 8 1 flase true、false boolean b = true;

2、String类型比较

string a=“string”

string b=“string

string c=new string(“string”);

a == b,a == c,结果分别是什么,为什么

true,false

ab 为 true 是因为直接 String a= “string”;这样创建 String 类型的字符串时,它是在字符串常量池中先查找是否有某个地址空间存在”string”这个 String 类型的对象,如果没有就创建”string”这样的对象,然后将其所在的地址复制一份交给 String 类型的变量空间 a 中,在申请创建 String 类型的变量 b 时,会优先再次去字符串常量池去查看是否存在某个地址空间存在”string”这个 String 类型的对象,因为之前已经创建过了,所以直接将对象”string”的地址直接复制一份交给 b。

ac为false是因为 String c= new String(“string”); 这里采用new关键字创建了String类型的对象内value属性数组中存储的是s,t,r,i,n,g这6个字符。new关键字一出,说明这里是在堆内存中申请创建的对象空间。所以地址空间肯定不一样.

3、AOP是什么,有用过哪里

AOP 是面向切面编程,可以在原有方法的基础上增加增强方法,比较常用的日志记录,在接口或者方法上面用注解的形式增加日志记录功能,记录调用方法的时间、操作内容、操作人信息、ip 等信息。
还有动态切换数据源,有些数据有地区之分,需要动态的去切换地区,使用对应的数据源。

4、HashMap 原理,线程安全吗

1.HashMap原理

HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。

HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个

JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

JDK1.8 以后HashMap 在解决哈希冲突时有了较大的变化,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,当链表长度大于等于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。

哈希冲突:哈希表是有限的,而键的数量可能是无限的,因此不同的键经过哈希函数计算后可能会映射到相同的哈希桶(数组的位置)。

HashMap如果数组小于长度小于64则会扩容,HashMap初始大小为16,扩容为2的幂次方,在链表长度大于8,且数组长度大于64时将链表转换为红黑树。

2.HashMap扩容机制

  1. 当哈希表的负载因子(load factor)超过阈值时,即元素数量超过容量的乘积与负载因子的乘积,HashMap 将触发扩容操作。默认情况下,负载因子的阈值为 0.75。
  2. 扩容时,HashMap 会创建一个新的数组,其容量为原数组的两倍。
  3. 遍历原数组中的每个非空桶,将其中的键值对重新计算哈希值,并根据新的数组长度定位到新的位置,然后存储到新的数组中。
  4. 扩容操作完成后,新数组中的桶链(如果存在冲突)的顺序可能发生改变,但是哈希表的结构保持不变。

HashMap的put方法:

  1. 首先判断数组是否为空,如果是,则进行初始化。
  2. 其次,根据**(n - 1) & hash**求出要添加对象所在的索引位置,判断此索引的内容是否为空,如果是,则直接存储,
  3. 如果不是,则判断索引位置的对象和要存储的对象是否相同,首先判断hash值知否相等,在判断key是否相等。(1.两个对象的hash值不同,一定不是同一个对象。2.hash值相同,两个对象也不一定相等)。如果是同一个对象,则直接进行覆盖,返回原值。
  4. 如果不是,则判断是否为树节点对象,如果是,直接添加
  5. 当既不是相同对象,又不是树节点,直接将其插入到链表的尾部。在进行判断是否需要进行树化。
  6. 最后,判断hashmap的size是否达到阈值,进行扩容resize()处理。

3.ConcurrentHashMap底层原理

使用synchronized锁加CAS机制,Node数组+链表/红黑树,node是一个类似于一个hashentry的结构。它的冲突在达到一定大小时会转化成红黑树,在冲突小于一定数量时又会退回链表。

put方法

  1. 如果没有初始化就先调用initTable()方法来进行初始化过程
  2. 如果没有哈希冲突就直接CAS插入
  3. 如果还在进行扩容操作就先进行扩容
  4. 如果存在哈希冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
  5. 最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
  6. 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

4.ConcurrentHashMap和HashMap

  1. 安全性

    HashMap是线程不安全的,ConcurrentHashMap是线程安全的,ConcurrentHashMap通过synchronized和CAS实现的线程安全。

  2. 数据结构

    数组+链表/红黑树

5.HashMap和HashTable的区别

  1. 对Null key 和Null value的支持

    HashMap支持null的键(key)和值(value),HashTable不支持null,会报空指针

  2. 线程安全

    HashMap线程不安全,HashTable因为有synchronized修饰所以线程安全

  3. 效率

    因为线程安全性,单线程下HashMap比HashTable效率高

  4. 初始容量

    HashMap初始大小为16,扩容为2的幂次方。

    HashTable初始为11,扩容为2n+1。

  5. 底层结构

    HashMap会将链表长度大于阈值是转化为红黑树(会先判断当前数组的长度是否小于 64,是则扩容,而不转化),将链表转化为红黑树,以减少搜索时间。

    Hashtable 没有这样的机制。

6.hashtable和concurrenthashmap

hashtable的线程安全是因为使用单锁,这极大的影响了性能

concurrenthashtable放弃了单锁,用的锁分离,synchronized+CAS

7.CountDownLatch

运行count个线程阻塞在一个地方,直到线程的任务都执行完

使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑

5、ArrayList和LinkedList区别

  1. 数据结构不同

    ArrayList是Array(动态数组)的数据结构,LinkedList是Link(链表)的数据结构。

  2. 效率不同

    当随机访问List(get和set操作)时,ArrayList比LinkedList的效率更高,因为ArrayList可以根据下标进行查找,LinkedList是线性的数据存储方式,所以需要移动指针从前往后依次查找。

    当对数据进行增加和删除的操作(add和remove操作)时,LinkedList比ArrayList的效率更高,因为ArrayList在进行增删操作时,会对操作数据的下标索引造成影响,需要对操作数据附近的数据进行移动。

  3. 容量性质不同

    ArrayList有一个初始容量,刚创建ArrayList对象时不会定义底层数组长度,第一次调用add方法时会初始化长度为10,之后调用add方法会先调用ensure方法判断够不够,不够就会调用grow方法扩容,长度变为原来的1.5倍。

    LinkedList能够动态的随数据量的变化而变化。

  4. 主要控件开销不同

    ArrayList主要控件开销在于需要在List列表预留一定空间;

    LinkedList主要控件开销在于需要存储结点信息以及结点指针信息。

6、@Transactional(rollback=Exception. class),有没有可能会失效,不加 rollback 会怎么样

@Transactional 失效的 4 种情况:

  1. @Transaction 应用在非 public 修饰的方法上

    因为 @Transactional 是基于动态代理实现的

  2. @Transactional 注解属性 propagation 设置错误

    这种失效是由于配置错误,若是错误的配置以下三种 propagation,事务将不会发生回滚。

    1. TransactionDefinition.PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
    2. TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起。
    3. TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,如果当前存在事务,则抛出异常。
  3. @Transactional 注解属性 rollbackFor 设置错误

    rollbackFor 可以指定能够触发事务回滚的异常类型。Spring 默认抛出了未检查 unchecked 异常(继承自 RuntimeException 的异常)或者 Error 才回滚事务;其他异常不会触发回滚事务。如果在事务中抛出其他类型的异常,但却期望 Spring 能够回滚事务,就需要指定 rollbackFor 属性。

  4. 同一个类中方法调用,导致 @Transactional 失效

    开发中避免不了会对同一个类里面的方法调用,比如有一个类 Test,它的一个方法 A,A 再调用本类的方法 B(不论方法 B 是用 public 还是 private 修饰),但方法 A 没有声明注解事务,而 B 方法有。则外部调用方法 A 之后,方法 B 的事务是不会起作用的。这也是经常犯错误的一个地方。

7、SpringMVC 流程

img

  1. 用户通过浏览器发起 request 请求到前端控制器(DispatcherServlet)
  2. 前端控制器(DispatcherServlet)将请求发送到处理器映射器(HandlerMapping)
  3. 处理器映射器(HandlerMapping)根据请求找到对应的处理器(Controller),封装返回前端控制器(DispatcherServlet)
  4. 前端控制器(DispatcherServlet)会根据返回的处理器找到对应的处理器适配器(HandlerAdaptor)
  5. 处理器适配器(HandlerAdaptor)会调用对应的 Controller
  6. Controller 将处理结果和跳转视图封装到 ModelAndView 返回到处理器适配器(HandlerAdaptor)
  7. 处理器适配器(HandlerAdaptor)将 ModelAndView 返回到前端控制器(DispatcherServlet)
  8. 前端控制器(DispatcherServlet)调用视图解析器(ViewResolver)对 ModelAndView 解析
  9. 视图解析器(ViewResolver)将解析出来的视图(View)封装成视图对象返回前端控制器(DispatcherServlet)
  10. 前端控制器(DispatcherServlet)调用视图对象进行视图渲染(将数据模型< model >填充到视图< view >中), 形成 response
  11. 前端控制器(DispatcherServlet)返回 response 到浏览器,展示在页面上

8、BIO、NIO、AIO

https://blog.csdn.net/qq_40378034/article/details/119710529

  • BIO:同步并阻塞
  • NIO:同步非阻塞
  • AIO:异步非阻塞

高并发

1、线程的生命周期和状态

https://www.jianshu.com/p/dca1f3f4588d

1. 线程的生命周期包含 5 个阶段:新建就绪运行阻塞销毁

  1. 新建: 刚使用 new 方法创建出来的线程;

  2. 就绪: 调用线程的 start ()方法后,线程处于等待 CPU 分配资源阶段,当线程获取到 CPU 资源后开始执行;

  3. 运行: 当就绪的线程被调度并获得 CPU 资源时,便会进入运行状态,run ()方法定义了线程的操作和功能;

  4. 阻塞: 在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,如 sleep ()、wait ()之后,线程就会处于阻塞状态。这个时候需要其他机制将处于阻塞状态的线程唤醒,如 notify ()、notifyAll ()方法。被唤醒的线程不会立即执行 run 方法,会回到就绪阶段,再次等待 CPU 分配资源进入运行状态。

  5. 销毁: 如果线程正常执行完毕或被提前强制终止或出现异常导致结束,那么线程就会被销毁并释放资源。

2. 线程的 6 个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start() 等待运行的状态。
  • BLOCKED :阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

3. 阻塞的三种情况:等待阻塞同步阻塞其他阻塞

  • 等待阻塞: 正在运行中的线程执行 wait ()方法时,JVM 会把该线程放入等待队列中。
  • 同步阻塞: 运行的线程在获取对象的同步锁时,若该同步锁被其他线程占用,则 JVM 会把该线程放入锁池中。
  • 其他阻塞: 运行的线程执行 sleep ()、join ()方法,或者发出了 IO 请求时,JVM 会把该线程置为阻塞状态。

sleep 和 wait 的区别:

sleep 和 wait 的区别在于这两个方法来自不同的类分别是 Thread 和 Object,sleep 方法没有释放锁,而 wait 方法释放了锁,使得其他线程可以使用同步控制块或者方法。

sleep 是线程被调用时,占着 cpu 去睡觉,其他线程不能占用 cpu,os 认为该线程正在工作,不会让出系统资源,wait 是进入等待池等待,让出系统资源,其他线程可以占用 cpu,一般 wait 不会加时间限制。

4. 线程死亡的三种情况:

  • 正常结束: run ()或 call ()方法执行完成,线程正常结束;

  • 异常结束: 线程执行过程中抛出一个未捕获的异常导致结束;

  • 强制结束: 调用线程终止方法强制结束线程:

    https://blog.csdn.net/k_young1997/article/details/106970529

    • 使用退出标志

      定义一个volatile修饰的 boolean 型的标志位,在线程的 run 方法中根据这个标志位是为 true 还是为 false 来判断是否终止,这种情况多用于 while 循环中。

      (使用 volatile 目的是保证可见性,一处修改了标志,处处都要去主存读取新的值,而不是使用缓存)

    • Interrupt 方法

      使用 interrupt 方法中断线程有两种情况

      • 线程处于阻塞状态

        如使用了 sleep,同步锁的 wait, socket 中的 receiver, accept 等方法时,会使线程处于阻塞状态。当调用线程的 interrupt() 方法时,会抛出 InterruptException 异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后 break 跳出循环状态,从而让我们有机会结束这个线程的执行。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        try {
        threadDemo.start();
        //阻塞线程
        threadDemo.wait();
        //中断线程
        threadDemo.interrupt();
        }catch (InterruptedException e){
        //抛出异常,强制跳出,线程中断
        e.printStackTrace();
        }
      • 线程未处于阻塞状态

        使用 isInterrupted() 判断线程的中断标志来退出循环。当使用 interrupt ()方法时,中断标志就会置 true,和使用自定义的标志来控制循环是一样的道理。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        ThreadDemo threadDemo = new ThreadDemo();
        threadDemo.start();
        //中断线程
        threadDemo.interrupt();
        //通过while循环不断确认线程是否已经终止
        while (threadDemo.isInterrupted()) {

        }
        System.out.println("线程中断");
    • stop 方法

      调用 stop ()方法,该方法不安全,容易导致死锁

      • 调用 stop 方法会立刻终止 run ()方法中剩余的全部任务,包括 catch 或 finally 中的任务,并且抛出 ThreadDeath 异常,因此可能会导致任务执行失败。
      • 调用 stop 方法会立刻释放改线程所持有的所有锁,导致数据无法完成同步,出现数据不一致的问题。

2、创建线程的三种方式,线程池的好处

1. 创建线程的三种方式:1 个继承,两个实现

  • 继承 Thread 类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class MainDemo {

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

    public static class ThreadDemo extends Thread {
    @Override
    public void run() {
    System.out.println("ThreadDemo");
    }
    }
    }
  • 实现 Runnable 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class MainDemo {

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

    //不用担心单继承,没有返回值
    public static class RunnableDemo implements Runnable {

    @Override
    public void run() {
    System.out.println("RunnableDemo");
    }
    }
    }
  • 实现 Callable 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class MainDemo {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    FutureTask<String> futureTask = new FutureTask<>(new CallableDemo());
    new Thread(futureTask).start();
    //get()方法获取返回值
    System.out.println(futureTask.get());
    }

    //有返回值也可以抛出异常
    public static class CallableDemo implements Callable<String> {
    @Override
    public String call() throws Exception {
    System.out.println("CallableDemo");
    return "return CallableDemo";
    }
    }
    }

区别:

  • Thread 只能单继承,Runnable 和 Callable 可以多实现
  • Thread 和 Runnable 不能得到返回值,Callable 可以获取返回值及捕获异常

2. 使用线程池的好处

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

3、如何创建线程池

1. 通过构造方法实现

ThreadPoolExecutor构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);
//核心线程:5;最大线程200;空闲线程存活时间:10;单位:秒;阻塞队列:类型linked,容量10000;线程工程:默认;饱和策略:抛出 RejectedExecutionException来拒绝新任务的处理。
ThreadPoolExecutor executor = new ThreadPoolExecutor(5,
200,
10,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());

ThreadPoolExecutor 7 个参数:

  1. 核心线程数 corePoolSize 核心线程数定义了最小可以同时运行的线程数量。

  2. 最大线程数 maximumPoolSize 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。

  3. 阻塞队列 workQueue 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

  4. 存活时间 keepAliveTime 当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁;

  5. 存活时间单位 unit keepAliveTime 参数的时间单位。

  6. 线程工厂 threadFactory executor 创建新线程的时候会用到。

  7. 饱和策略 handler 如果当前同时运行的线程数量达到最大线程数量并且阻塞队列也已经被放满了任务时,会根据饱和策略来处理多余的任务。

    常见饱和策略:

    • ThreadPoolExecutor.AbortPolicy 抛出 RejectedExecutionException 异常来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy 调用执行自己的线程运行任务,也就是直接在调用 execute 方法的线程中运行 (run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
    • ThreadPoolExecutor.DiscardPolicy 不处理新任务,直接丢弃掉。
    • ThreadPoolExecutor.DiscardOldestPolicy 此策略将丢弃最早的未处理的任务请求。

线程池运行流程:

  1. 线程池创建,准备好 core 数量的核心线程,准备接受任务
  2. 新的任务进来,用 core 准备好的空闲线程执行。
  3. 核心线程 core 满了,就将再进来的任务放入阻塞队列中。空闲的 core 就会自己去阻塞队列获取任务执行
  4. 阻塞队列满了,就直接开新线程执行,最大只能开到 max 指定的数量
  5. 如果线程数开到了 max 的数量,还有新任务进来,就会使用 RejectedExecutionHandler 指定的拒绝策略拒绝任务
  6. max 都执行完成,有很多空闲。在指定 keepAliveTime 后,会释放 Max-core 数量空闲的线程。最终保持到 core 大小。new LinkedBlockingQueue<>()默认是 integer 的最大值,内存不够
  7. 所有的线程创建都是由指定的 factory 创建的

总结:核心线程 -> 阻塞队列 -> 新线程 -> 拒绝策略 -> 自动释放空闲核心线程

2. 通过 Executor 框架的工具类 Executors 来实现

4 种常见线程池:

  • CachedThreadPool

    1
    2
    3
    4
    5
    6
    /**
    * 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,
    * 若无可回收,则新建线程。
    * 没有核心线程,所有线程都可以回收。
    */
    Executors.newCachedThreadPool();
  • FixedThreadPool

    1
    2
    3
    4
    5
    6
    /**
    * 创建一个定长线程池,可控制线程最大并发数,
    * 超出的线程会在队列中等待。
    * 核心线程数和最大线程数相同,固定线程数大小,所有线程都不可以回收。
    */
    Executors.newFixedThreadPool(10);
  • ScheduledThreadPool

    1
    2
    3
    4
    5
    /**
    * 创建一个定长线程池,支持定时及周期性任务执行。
    * 可以指定多长时间以后执行任务。定时任务线程池。
    */
    Executors.newScheduledThreadPool(10);
  • SingleThreadExecutor

    1
    2
    3
    4
    5
    6
    7
    /**
    * 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,
    * 保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
    * 单线程的线程池,核心和最大线程数都为 1。
    * 后台从队列中取一个执行一个,相当于后台用单线程执行任务。
    */
    Executors.newSingleThreadExecutor();

4、submit 和 execute 的区别

  1. execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  2. submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Futureget() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

5、Java 并发包下常用的类库

  • CountDownLatch

  • LockSupport

  • BlockingQueue

  • Executors

  • ArrayBlockingQueue

  • FutureTask

  • CompletableFuture

6、Nacos、ZooKeeper、Eureka 的选择,各种优缺点

ZooKeeper 实现了 CP

Eureka 实现了 AP

Nacos 是根据配置实现 CP 和 AP

7、AQS

1. AQS 核心思想

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。

2. CLH

CLH 是指 Craig, Landin, and Hagersten,是一种经典的自旋锁算法,用于解决并发环境下的互斥访问问题。CLH 自旋锁是一种基于链表的软件锁算法,采用了无忙等待的自旋方式,避免了传统自旋锁的缺点。

CLH 自旋锁的核心思想是使用一个链表来表示所有等待获取锁的线程,链表中的每个节点代表一个竞争线程。每个节点都维护一个标志位,用来表示前一个节点是否已经释放了锁。

CLH 自旋锁的获取和释放过程如下:

  1. 获取锁:当线程需要获取锁时,它会创建一个新的节点,并将自己加入到链表的末尾。然后它会自旋等待,不断检查前一个节点的标志位,直到前一个节点释放了锁。
  2. 释放锁:当线程释放锁时,它会将自己所对应的节点的标志位置为已释放,并将其从链表中移除。这样后续等待的线程就可以通过自旋获取锁。

CLH 自旋锁的特点包括:

  1. 无忙等待:CLH 自旋锁通过自旋等待的方式来获取锁,避免了传统自旋锁在竞争激烈情况下的忙等待问题,减少了对 CPU 的占用,提高了性能。
  2. FIFO 队列:CLH 自旋锁的链表是按照线程请求锁的顺序排队的,保证了公平性,避免了饥饿现象的发生。
  3. 缓存友好性:CLH 自旋锁的节点通过自旋等待获取锁,而不是忙等待,这样可以减少对共享变量的频繁访问,提高了缓存的命中率。

需要注意的是,CLH 自旋锁在多处理器系统上的性能通常比较好,但在单处理器系统上可能存在性能问题,因为它会导致线程频繁地进行上下文切换。在实际使用时,需要根据具体的环境和需求来选择合适的锁算法。

CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

3. 什么是钩子方法

钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。

8、CAS

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 操作是原子性的,即在执行期间不会被其他线程中断,从而确保了线程安全。它实际上是利用了现代处理器提供的原子指令(比如 x 86 的 CMPXCHG 指令)来实现的。

CAS 操作由三个参数组成:

  • V:要更新的变量值 (Var)
  • E:预期值 (Expected)
  • N:拟写入的新值 (New)

它的执行过程如下:

  1. 检查变量值是否等于预期值。
  2. 如果相等,则将该地址上的值更新为新值。
  3. 如果不相等,则表示其他线程已经修改了该值,操作失败。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等,则说明没被其他线程修改,可以被设置为 6 。
  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

CAS 的优点在于它避免了使用锁带来的线程切换和上下文切换的开销,从而减少了系统开销和提高了并发性能。然而,CAS 也存在一些限制和问题,主要包括:

ABA 问题:如果变量的值在操作过程中被其他线程从预期值变为又变回预期值,CAS 操作可能会误判,无法感知到变量的变化。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

为了解决 CAS 的限制,Java 提供了 Atomic 包下的一系列原子类,如 AtomicInteger、AtomicLong、AtomicReference 等,它们封装了 CAS 操作,提供了更便捷和安全的方式来进行原子操作。

9、BlockingQueue

阻塞队列

ArrayBlockingQueue 是 Java 中的一个线程安全(线程同步)的固定大小的阻塞队列,它是基于数组实现的。它是在 java. util. concurrent 包下的一个类。

ArrayBlockingQueue 的主要特点有:

  1. 有界性:ArrayBlockingQueue 的容量是固定的,即在创建队列时必须指定队列的容量大小。
  2. 先进先出(FIFO):ArrayBlockingQueue 严格按照元素的插入顺序进行操作,保证先进来的元素先被获取。
  3. 线程安全:ArrayBlockingQueue 使用内部锁(ReentrantLock)实现了线程安全。它对插入和删除操作进行了同步,以确保多线程环境下的并发操作不会出现问题。
  4. 阻塞操作:ArrayBlockingQueue 支持阻塞操作,当队列为空时,从队列中获取元素的操作会被阻塞,直到有元素可用;当队列已满时,插入元素的操作会被阻塞,直到队列有空闲位置可用。
  5. 支持可选的公平性:可以通过构造函数设置公平性参数,如果设置为 true,则等待时间更长的线程会优先获得锁。

使用 ArrayBlockingQueue 可以方便地实现生产者-消费者模式,生产者线程将元素插入队列,消费者线程从队列中获取元素。当队列为空时,消费者线程会被阻塞,直到队列中有元素可用;当队列已满时,生产者线程会被阻塞,直到队列有空闲位置可用。

需要注意的是,在 ArrayBlockingQueue 中,当队列已满时尝试插入元素会导致线程被阻塞,当队列为空时尝试获取元素也会导致线程被阻塞。因此,在使用 ArrayBlockingQueue 时要合理处理阻塞操作可能引发的线程安全和性能问题。

10、lock

1. 锁(Lock)

在并发编程中,锁是用于控制对共享资源的访问的一种同步机制。它用于确保在同一时间内只有一个线程可以访问被锁定的资源,从而保证线程安全。

  1. 显示锁(Explicit Lock):显式锁是由代码明确调用的锁机制。在 Java 中,ReentrantLock 是一个常见的显式锁实现,它提供了显式的 lock () 和 unlock () 方法来控制对临界区的访问。
  2. 隐式锁(Implicit Lock):隐式锁是由语言或运行时环境自动管理的锁机制。在 Java 中,synchronized 关键字就是一种隐式锁机制,它可以应用于方法或代码块,隐式地在进入和退出临界区时获取和释放锁。

2. 互斥锁(Mutex Lock)

互斥锁是一种特殊类型的锁,用于保护共享资源的互斥访问。当一个线程获取了互斥锁后,其他线程必须等待锁的释放才能访问共享资源。互斥锁提供了排他性,保证同一时间只有一个线程能够持有锁。

3. 自旋锁(Spin Lock)

自旋锁是一种特殊类型的锁,采用忙等待的方式来实现线程的同步。当一个线程尝试获取自旋锁时,如果锁已经被其他线程占用,该线程会进入忙等待状态,不断循环检查锁是否被释放。自旋锁一般适用于锁的持有时间较短的情况,避免线程切换带来的开销。

总而言之,锁是一种用于实现并发编程中线程同步和对共享资源进行保护的机制。它可以防止多个线程同时修改共享资源,从而确保数据的一致性和线程的安全性。不同类型的锁有不同的特点和适用场景,开发者需要根据具体需求选择合适的锁机制。

11、volatile

volatile 是 Java 中的关键字,用于修饰变量。它具有以下两个主要的作用:

  1. 可见性(Visibility):使用 volatile 修饰的变量对所有线程可见。当一个线程修改了 volatile 变量的值,该变量的新值会立即被写入主内存,并且其他线程可以立即看到最新的值。这解决了多线程间的可见性问题,避免了使用普通变量时的数据不一致性问题。
  2. 禁止指令重排序(Ordering):使用 volatile 修饰的变量的读写操作会被插入到内存屏障(Memory Barrier)之前和之后,防止指令重排序优化。这保证了 volatile 变量的读写操作具有所谓的 happens-before 关系,即写操作发生在后续的读操作之前,确保了操作的有序性。

需要注意的是,volatile 提供的可见性和禁止指令重排序的保证是有限的,它并不能保证原子性。对于复合操作,如 i++ 的原子性操作,volatile 无法保证线程安全,需要使用其他同步机制(如锁)或者使用原子类(如 AtomicIntegerFieldUpdater)来实现。

使用 volatile 的常见场景包括:

  1. 标志位:用于控制并发任务的启动、暂停或停止。
  2. 双重检查锁定(Double-Checked Locking):用于在延迟初始化对象的情况下,确保多个线程能够正确地获取初始化后的对象。

总结来说,volatile 是一种用于保证变量可见性和禁止指令重排序的关键字。它提供了一种简单而轻量的线程同步机制,适用于某些特定的并发场景。然而,对于更复杂的线程同步需求,需要使用更强大的同步机制(如锁)来确保线程安全性。

12、synchronized

synchronized 是 Java 中的关键字,用于实现线程之间的同步和互斥访问。它可以应用于方法或代码块,用于控制对共享资源的访问,并保证多个线程对共享资源的安全性。

使用 synchronized 的主要作用包括:

  1. 互斥访问:当多个线程同时访问同一个被 synchronized 修饰的方法或代码块时,只会有一个线程能够进入临界区,其他线程需要等待。这样可以确保在同一时间内只有一个线程对共享资源进行操作,避免了数据的竞争和不一致性。
  2. 可见性和有序性:除了提供互斥访问之外,synchronized 还提供了对变量的可见性和有序性保证。当一个线程释放 synchronized 的锁时,它会将对共享变量的更新刷新到主内存中,使得其他线程能够立即看到最新的值,并且保证了操作的有序性。

使用 synchronized 的方式有两种:

  1. 同步方法(Synchronized Method):使用 synchronized 修饰的方法称为同步方法。当线程调用同步方法时,它会自动获取该方法所属对象(或类)的锁,并在方法执行过程中保持独占。其他线程需要等待锁的释放才能执行相同对象(或类)的同步方法。
  2. 同步代码块(Synchronized Block):使用 synchronized 修饰的代码块称为同步代码块。它需要指定一个对象作为锁,也称为监视器对象。同一时间只有一个线程可以持有该对象的锁,并执行进入锁定的代码块。其他线程需要等待该锁的释放才能执行相同对象的同步代码块。

需要注意的是,synchronized 是一种重量级的锁机制,涉及到线程的上下文切换和内核态的操作。在使用 synchronized 时,应尽量减小同步范围,避免持有锁的时间过长,以提高程序的性能。

总结来说,synchronized 是一种用于实现线程同步和互斥访问的关键字。它能够保证对共享资源的原子性操作、可见性和有序性,并防止多个线程同时修改共享资源。在多线程编程中,使用 synchronized 是一种常见且有效的同步机制。

微服务

1、微服务事务解决方案 2 PC、ttc 等

1. 2 PC:

2 PC(Two-Phase Commit)是一种常见的分布式事务协议,用于实现多个参与者(Participants)之间的一致性。它通过协调器(Coordinator)和参与者之间的协作来确保在分布式环境中的事务的一致性。
2 PC 协议的执行过程分为两个阶段:

  1. 准备阶段(Prepare Phase):

    在该阶段,协调器向所有参与者发出准备请求。参与者执行事务操作并将准备状态(Prepare)通知协调器。如果参与者成功执行并准备好提交事务,则返回准备就绪状态(Ready to Commit)。如果有任何一个参与者无法准备好提交事务,它将返回无法准备状态(Unable to Prepare)。

  2. 提交阶段(Commit Phase):

    如果所有参与者都返回了准备就绪状态,协调器将发送提交请求给所有参与者。参与者接收到提交请求后,执行事务的最终提交操作,并将提交确认(Commit Acknowledgment)通知协调器。一旦协调器接收到所有参与者的提交确认,它将发出全局提交(Global Commit)的通知。

通过这个两阶段的协作,2 PC 协议实现了分布式事务的提交一致性。但是,2 PC 协议也存在一些缺点:

  1. 阻塞问题:在 2 PC 的执行过程中,参与者在等待协调器的请求时会阻塞,这可能导致整个事务的执行时间较长,并且会增加协调器故障的风险。

  2. 单点故障:协调器作为中心化的组件,一旦发生故障,将导致整个协议无法继续执行,从而影响整个分布式事务的一致性。

  3. 数据不一致问题:2 PC 协议在网络分区、参与者故障或通信故障等情况下可能导致数据不一致的问题。例如,在准备阶段失败时,已准备好的参与者可能无法回滚之前的操作,导致数据不一致。

鉴于 2 PC 的缺点,一些替代的分布式事务协议也被提出,如 3 PC(Three-Phase Commit)、Paxos、Raft 等,它们针对 2 PC 的一些问题进行了改进和优化。此外,一些基于补偿的事务处理模式(如 Saga 模式)也被广泛应用于大规模微服务架构中。

2. TTC:

在微服务架构中,处理分布式事务是一个挑战性的任务。其中,TTC(Transactional Two-Phase Commit)是一种用于解决微服务事务一致性的解决方案之一。
TTC 是一种变种的 2 PC(Two-Phase Commit)协议,专门用于处理微服务架构中的分布式事务。它在传统的 2 PC 协议基础上进行了改进,以提高性能和可靠性。
TTC 的主要思想是将事务划分为一组子事务(Subtransactions),每个子事务对应于一个微服务。协调者(Coordinator)负责协调所有子事务的一致性。
TTC 的执行过程如下:

  1. 准备阶段(Prepare Phase):

    协调者将准备请求发送给所有子事务。每个子事务执行自身的事务操作,并将准备状态(Prepare)返回给协调者。如果所有子事务都准备好提交事务,则进入下一阶段。否则,如果有任何一个子事务无法准备好,则中止整个事务。

  2. 提交阶段(Commit Phase):

    协调者将提交请求发送给所有准备好的子事务。每个子事务执行自身的事务提交操作,并将确认状态(Commit Acknowledgment)返回给协调者。协调者等待所有子事务的确认,如果所有子事务都成功确认,则整个事务提交成功。如果有任何一个子事务无法确认,则中止整个事务。

通过 TTC 协议,微服务架构中的分布式事务可以实现一致性。相较于传统的 2 PC 协议,TTC 减少了等待时间,并且在部分子事务失败的情况下能够快速中止整个事务,减少了不必要的等待和资源占用。
需要注意的是,尽管 TTC 在解决微服务事务一致性方面具有一定的优势,但它仍然可能遭遇网络分区、故障恢复等问题。因此,针对具体的应用场景,可能还需要结合其他技术和策略来提高事务处理的可靠性和性能。

2、CAP、BASE 理论,在项目中的取舍

1. CAP 定理

  • 一致性
  • 可用性
  • 分区容错性

这三个指标不可能同时做到,这个结论就叫做 CAP 定理。

  1. 分区容错性(Partition tolerance) :比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。介于分区状态下,G 1 和 G 2 是两台跨区的服务器,G 1 向 G 2 发送一条消息,G 2 可能无法收到,因为网络总是不可靠的,因此可以认为 P 总是成立的。
  2. 可用性(Availability) :只要收到用户的请求,服务器就必须给出回应,但此时由于网络问题会导致服务器之间的数据无法做到实时同步,牺牲了一致性,此时满足 AP。
  3. 一致性(Consistency) :用户 A 访问系统 A,用户 B 访问系统 B,系统 A 和系统 B 保持同步,当用户 A 对系统 A 的 data 做出更改后,用户 B 查询系统 B 的 data 时,需要查询出用户 A 操作后的数据,而同步是要通过网络,网络却又总是不可靠的,所以为确保用户 B 能查询出用户 A 修改的数据,必须在用户 B 查询前将系统 A 的 data 同步到系统 B 上,这样就牺牲了可用性,此时满足 CP。

2. BASE 理论

由于 CAP 中一致性 C 和可用性 A 无法兼得,eBay 的架构师,提出了 BASE 理论,它是通过牺牲数据的强一致性,来获得可用性。它有如下3种特征:

  1. 基本可用(Basically Available):分布式系统在出现不可预知故障的时候,允许损失部分可用性,保证核心功能的可用。
  2. 软状态(Soft State):软状态也称为弱状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
  3. 最终一致性(Eventually consistent):最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

BASE 理论并没有要求数据的强一致性,而是允许数据在一定的时间段内是不一致的,但在最终某个状态会达到一致。在生产环境中,很多公司,会采用 BASE 理论来实现数据的一致,因为产品的可用性相比强一致性来说,更加重要。比如在电商平台中,当用户对一个订单发起支付时,往往会调用第三方支付平台,比如支付宝支付或者微信支付,调用第三方成功后,第三方并不能及时通知我方系统,在第三方没有通知我方系统的这段时间内,我们给用户的订单状态显示支付中,等到第三方回调之后,我们再将状态改成已支付。虽然订单状态在短期内存在不一致,但是用户却获得了更好的产品体验。

数据库

1、MySQL存储引擎有哪些,默认的存储引擎是什么,为什么用这个

https://blog.csdn.net/qq_48826058/article/details/123690955

1.InnoDB存储引擎

  • 事务安全
  • 支持外键
  • 支持全文索引

适用场景:需要事务支持、行级锁定对高并发有很好地适应能力,但需要确保查询是通过索引完成、数据更新较为频繁。

2.MyISAM存储引擎

  • 不是事务安全的
  • 不支持外键
  • 表格可以被压缩,且支持全文索引
  • 不支持缓存数据文件

适用场景不需要事务支持并发相对较低数据修改相对较少以读为主数据一致性要求不是特别高

3.MEMORY存储引擎

  • 把表临时性存放在内存中,数据库重启或崩溃数据就会丢失
  • 默认使用哈希索引
  • 只支持表锁
  • 并发性能较差
  • 不支持text和blob列类型
  • 浪费内存,比如:存储变长字段(varchar)时是按照定长字段(char)的方式进行的。

4.MERGE存储引擎

  • 是一组MyISAM表的组合
  • 对MERGE表进行drop操作,这个操作只删除MERGE的定义,对内部的表没有任何影响。
  • 对表的大小有要求,不能是太大的表。

适用场景:需要很快的读/写速度,对数据安全性要求较低。

5.默认的存储引擎

mysql-5.1版本之前默认引擎是MyISAM,之后是innoDB

6.MyISAM和InnoDB的区别

  1. InnoDB支持事务,而MyISAM不支持事务

  2. InnoDB支持外键,而MyISAM不支持外键

  3. InnoDB是行锁,而MyISAM是表锁(每次更新增加删除都会锁住表)。

  4. 和MyISAM的索引都是基于b+树,但他们具体实现不一样,InnoDB的b+树的叶子节点是存放数据的,MyISAM的b+树的叶子节点是存放指针的。

  5. InnoDB是聚簇索引,必须要有主键,一定会基于主键查询,但是辅助索引就会查询两次。MyISAM是非聚簇索引,索引和数据是分离的,索引里保存的是数据地址的指针,主键索引和辅助索引是分开的。

  6. InnoDB不存储表的行数,所以select count( * )的时候会全表查询。而MyISAM会存放表的行数,select count(*)的时候会查的很快。

2、数据库事务特性,事务的隔离级别

事务就是对数据的一系列操作

  1. 事务的4个特性

    1. 原子性Atomicity) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
    2. 一致性Consistency): 执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;
    3. 隔离性Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
    4. 持久性Durabilily): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

    只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。

  2. 并发事务会产生哪些问题?

    • 脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
    • 丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。
    • 不可重复读(Unrepeatable read): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
    • 幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

    不可重复读和幻读有什么区别?

    • 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改;
    • 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。
  3. 事务的4个隔离级别

    • READ-UNCOMMITTED(读未提交) : 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
    • READ-COMMITTED(读已提交) : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
    • REPEATABLE-READ(可重复读) : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
    • SERIALIZABLE(可串行化) : 最高的隔离级别,所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
    隔离级别 脏读 不可重复读 幻读
    READ-UNCOMMITTED
    READ-COMMITTED ×
    REPEATABLE-READ × ×
    SERIALIZABLE × × ×

3、索引的优缺点,组合索引特性

优点

  • 使用索引可以大大加快 数据的检索速度(大大减少检索的数据量), 这也是创建索引的最主要的原因。
  • 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。

缺点

  • 创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。
  • 索引需要使用物理文件存储,也会耗费一定空间。

4、外联接和内联接的区别

  • 内连接:指连接结果仅包含符合连接条件的行,参与连接的两个表都应该符合连接条件。
  • 外连接:连接结果不仅包含符合连接条件的行同时也包含自身不符合条件的行。包括左外连接、右外连接和全外连接。
    • 左外连接:左边表数据行全部保留,右边表保留符合连接条件的行。
    • 右外连接:右边表数据行全部保留,左边表保留符合连接条件的行。
    • 全外连接:左外连接 union 右外连接。

5、索引失效场景及原理

索引失效场景及原理

  1. 使用函数或表达式:

    在 WHERE 子句中对索引列使用函数或表达式会导致索引失效。因为 MySQL 无法预先计算表达式的结果,所以无法使用索引进行查找。
    例:SELECT * FROM users WHERE YEAR(birthday) = 1990;
    原理:此查询中,YEAR函数作用在索引列birthday上,导致索引失效。

  2. 隐式类型转换:

    如果查询条件与索引列类型不匹配,MySQL会进行隐式类型转换,可能导致索引失效。
    例:SELECT * FROM users WHERE age = ‘30’;
    原理:此查询中,假设age字段是整数类型,而查询条件使用了字符串类型,导致类型不匹配。MySQL会尝试将age字段转换为字符串,从而导致索引失效。

  3. 不等于(!= 或 <>)操作符:

    使用不等于操作符会导致索引失效,因为MySQL无法利用索引进行范围查找。
    例:SELECT * FROM users WHERE age <> 30;
    原理:此查询中,由于使用了不等于操作符,MySQL无法利用索引进行查找,因此索引失效。

  4. 范围查询的多列索引:

    对于多列联合索引,如果查询条件中包含范围查询(如BETWEEN、>、< 等),那么在范围查询之后的索引列将失效。
    例:SELECT * FROM orders WHERE user_id = 1 AND order_date > ‘2022-01-01’;
    原理:假设存在一个多列联合索引(user_id, order_date),此查询中对order_date进行了范围查询,使得索引列order_date之后的索引失效。

  5. OR 连接的条件:

    使用OR连接的条件可能导致索引失效,尤其是在OR条件中涉及多个索引列时。
    例:SELECT * FROM users WHERE age = 30 OR name = ‘Alice’;
    原理:此查询中,由于使用了OR连接,MySQL可能无法同时利用age和name两个索引列进行查找,导致索引失效。

  6. LIKE 查询:

    如果在LIKE查询中,通配符(%或_)在字符串的开头,将导致索引失效。
    例:SELECT * FROM users WHERE name LIKE ‘%Alice%’;
    原理:此查询中,由于通配符在字符串的开头,MySQL无法使用索引进行查找,因此索引失效。

需要注意的是,实际查询优化器会根据数据量、数据分布等因素决定是否使用索引。

6、MySQL 全文索引

MySql全文索引

全文索引的创建和使用

1
2
//创建全文索引 
CREATE FULLTEXT INDEX <index_name> on tableName(字段名) ALTER TABLE tableName ADD FULLTEXT[index_name](字段名); CREATE TABLE tableName([....],FULLTEXT KEY[index_name](字段名))`

和常用的like不同,全文索引有自己的格式,使用matchagainst关键字,如下:

1
select * from user where match(name) against('aaa');

7、SQL 关键字实际执行顺序

书写顺序:SELECT -> FROM -> JOIN -> ON -> WHERE -> GROUP BY -> HAVING -> UNION -> ORDER BY ->LIMIT

执行顺序:FROM -> ON -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> UNION -> ORDER BY ->LIMIT

SQL的执行顺序

  1. FROM

    确定查询的数据来源,执行笛卡尔积生成基础数据集。

    选择from后面跟的表,产生虚拟表1。

  2. ON(对于JOIN操作):

    应用JOIN条件,从笛卡尔积生成的结果中筛选出匹配的行,形成新的结果集。

    ON是JOIN的连接条件,符合连接条件的行会被记录在虚拟表2中。

  3. JOIN

    根据JOIN类型合并表,如果有多个JOIN链接,会重复执行步骤1~3,直到处理完所有表。

  4. WHERE

    对上一步产生的结果集应用行级别的筛选条件,进一步减少行数,对虚拟表3进行WHERE条件过滤,符合条件的记录会被插入到虚拟表4中。

  5. GROUP BY

    根据GROUP BY子句中的列,对虚拟表2中的记录进行分组操作,产生虚拟表5。

  6. HAVING

    对分组后的数据集应用条件过滤,只保留满足条件的组,对虚拟表5进行HAVING过滤,符合条件的记录会被插入到虚拟表6中。

  7. SELECT

    SELECT到一步才执行,选择指定的列,插入到虚拟表7中

    从处理过的数据集中选择指定的列,执行投影操作。这可能包括对列进行计算、使用别名等。

  8. UNION

    UNION连接的两个SELECT查询语句,会重复执行步骤1~7,产生两个虚拟表7,UNION会将这些记录合并到虚拟表8中。

  9. ORDER BY

    将结果集按照指定的列排序。

    将虚拟表8中的记录进行排序,虚拟表9。

  10. LIMIT

    限制返回结果的数量或跳过指定数量的行后开始返回结果。

    取出指定行的记录,返回结果集。

JVM

1、JVM 的内存结构

按线程来讲可以分成两部分,一个是线程独占的,一个是线程共享的

线程共享的有方法区和堆

  • 堆:包括年轻代与老年代+字符串常量池,年轻代由一个Eden与两个Survivor区。

  • 方法区:方法区是Java虚拟机的模型规范,具体实现是元空间和永久代,永久代是1.7的,1.8以后永久代就被移除了,就变成元空间了,元空间是分布在计算机内存中的,是脱离了Java虚拟机内存的,是独立存在的。

线程独占的是虚拟机栈、本地方法栈、程序计数器

2、JVM常用参数

  • -Xms:初始堆内存大小,设定程序启动时占用内存大小,默认物理内存1/64 -Xms = -XX:InitialHeapSiz
  • -Xmx:最大堆内存,设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出OutOfMemory异常,默认物理内存1/4,-Xmx = -XX:MaxHeapSize。上图中的-Xms与-Xmx设置的大小一样 6000M
  • -Xmn:设置年轻代大小。整个堆大小=年轻代大小 + 年老代大小 + 常量池。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8
  • -Xss: 设置单个线程栈大小,一般默认512~1024kb。单个线程栈大小跟操作系统和 JDK 版本都有关系,-Xss = -XX:ThreadStackSize
  • -XX:MetaspaceSize :元空间大小,元空间本质跟永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,由操作系统支配。因此,元空间大小仅受本地内存限制。
  • -XX:+PrintGCDetails :打印GC详细日志信息
  • -XX:SurvivorRatio:幸存者比例设置,设置年轻代中Eden区与Survivor区的大小比值。设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
  • -XX:NewRatio:新生代比例设置(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为1,则年轻代与年老代所占比值为1:1,年轻代占整个堆栈的1/2。
  • -XX:MaxTenuringThreshold:进入老年代阈值设置

3、JVM常用工具

  • jsp

    查看java进程及其相关的信息。

  • jinfo

    主要作用为实时查看和调整虚拟机各项参数。

  • jmap

    查看堆内对象示例的统计信息、ClassLoader 的信息以及finalizer 队列。

    也可以生成 java 程序的 dump 文件。

  • jhat

    用来分析jmap生成dump文件的命令。

  • jstat

    查看JVM运行时的状态信息,包括内存状态、垃圾回收等。

  • jstack

    查看JVM线程快照的命令,线程快照是当前JVM线程正在执行的方法堆栈集合。

    使用jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环等。

    jstack还可以查看程序崩溃时生成的core文件中的stack信息。

4、JVM类加载机制

https://blog.csdn.net/qq_48508278/article/details/122929631

加载-验证-准备-解析-初始化

  • 加载

    根据类的完整路径查找二进制文件,根据二进制文件创建类对象,存储在堆中。

  • 验证

    验证加载内容是否安全,是否会对虚拟机造成异常,验证文件格式,元数据和字节码。

  • 准备

    准备给类变量在方法区中进行内存分配,初始化赋零值,给初始值占坑。

  • 解析

    把常量池的符号引用转变成直接引用,在内存中通过这个引用找到目标。

  • 初始化

    执行Java代码,进行初始化,执行静态代码块,给静态变量赋值。

5、类加载器有哪些

  • 启动类加载器
  • 扩展类加载器
  • 系统类加载器
  • 自定义类加载器

6、双亲委派是什么

当一个类要使用类加载器进行类加载时,会先请求委派给父类加载,当父类还有父类时,会继续往上委派,一直到顶,当父类加载器无法完成这个请求的时候,子类才会尝试去加载

为什么要有双亲委派:认定两个对象同属于一个类型

7、垃圾回收算法常用的有哪些

  1. 标记清除
  2. 标记复制
  3. 标记整理
  4. 分代

8、Java常见的几种垃圾收集器,Java8默认的垃圾收集器是什么

1.常见的几种

7种经典垃圾回收器:Serial、Serial old、ParNew、Parallel Scavenge、Parallel old、CMS、G1

串行回收器:Serial、Serial old

并行回收器:ParNew、Parallel Scavenge、Parallel old

并发回收器:CMS、G1

1.Serial收集器:

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。单线程的收集器,收集垃圾时,必须stop the world,使用复制算法。

2.Serial Old收集器:

是Serial收集器的老年代版本,单线程收集器,使用标记整理算法。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。

启用命令: -XX:+UseSerialGC -XX:+UseSerialOldGC

3.Parale Scavenge收集器:

Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。

Parallel Scavenge收集器关注点是达到一个可控的吞叶量,所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。如果虚拟机总共运行100分钟,其中垃圾花掉1分钟,香叶量就是99%。

新生代采用复制算法,老年代采用标记-整理算法。

4.Parallel Old收集器:

Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。

启用命令 -XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代)

5.ParNew收集器:

ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。新生代采用复制算法,老年代采用标记-整理算法。

6.CMS(Concurrent Mark Sweep) 收集器:

收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。整个过程分为5个步骤:初始标记->并发标记->重新标记->并发清理->并发重置。

7.G1收集器:

标记整理算法实现,运作流程主要包括以下:初始标记,并发标记,最终标记,筛选回收,不会产生空间碎片,可以精确地控制停顿。

2.默认:ParallelGC

1.新生代使用 Parallel Scavenge收集器

使用的算法是基于标记-复制算法实现。收集器的目标是达到一个可控制的吞吐量,如何计算:吞吐量=用户代码运行时间/(代码运行时间+垃圾收集时间),重点关注一个参数吧 -XX:UserAdaptiveSizePolicy 这个参数激活后,不需要人工的指定新生代大小(-Xmn)、Eden与Surivivor区的比例,晋升老年代对象大小等参数了,虚拟机会根据当前系统运行情况收集性能监控信息动态调整这些参数以提供最合适的停顿时间合或最大的吞吐量。

2.老年带使用的是Parallel Old收集器

是Parallel Scavenge的老年代版本,基于标记-整理算法实现,支持多线程并行收集。他的出现缓解了Parallel Scavenge的尴尬处境,因为Parallel Scavenge和别的优秀的老年代收集器不搭。出现后他俩搭配,才让吞吐量优先的收集器名副其实。

9、JVM 怎么调优

JVM调优总结

Redis

1、Redis 数据类型

  • string
  • hash
  • list
  • set
  • zset

2、Redis 分布式锁用在哪里,哨兵机制

当多个线程想要去操作同一个缓存数据时,通过 redis 分布式锁将其锁住,并设置一个到期时间,防止业务异常导致无法解锁。

在查询一个数据的时候,先用 redis 分布式锁将其锁住,然后继续查询,先从 redis 缓存里面查,查不到再从数据库查,并且把查询结果保存到缓存中,然后返回结果,然后删除分布式锁。当下一个操作线程进入时,同样锁住,然后执行业务,可以从缓存中取到值。就是防止缓存穿透。

Redis 哨兵机制

Redis的四种模式,单机、主从、哨兵、集群

3、Redis 的 LUA 脚本有用过吗

在 Redis 分布式锁用过,在代码中调用 execute 方法,script 参数用执行官方文档上的 LUA 脚本删除分布式锁

4、缓存雪崩、穿透、击穿、数据库一致

三者出现的根本原因是:Redis 缓存命中率下降,请求直接打到 DB 上了

正常情况下,大量的资源请求都会被 redis 响应,在 redis 得不到响应的小部分请求才会去请求 DB,这样 DB 的压力是非常小的,是可以正常工作的

如果大量的请求在 redis 上得不到响应,那么就会导致这些请求会直接去访问 DB,导致 DB 的压力瞬间变大而卡死或者宕机。

大量的高并发的请求打在 redis 上

这些请求发现 redis 上并没有需要请求的资源,redis 命中率降低

因此这些大量的高并发请求转向 DB(数据库服务器)请求对应的资源

DB 压力瞬间增大,直接将 DB 打垮,进而引发一系列“灾害”

1、缓存穿透:

是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。

解决:

根本原因(结合上文)就是:请求根本不存在的资源

  • 对空值进行缓存

    类似于上面的例子,虽然数据库中没有 id=-9527 的用户的数据,但是在 redis 中对他进行缓存(key=-9527,value=null),这样当请求到达 redis 的时候就会直接返回一个 null 的值给客户端,避免了大量无法访问的数据直接打在 DB 上。

  • 实时监控

    对 redis 进行实时监控,当发现 redis 中的命中率下降的时候进行原因的排查,配合运维人员对访问对象和访问数据进行分析查询,从而进行黑名单的设置限制服务。

  • 使用布隆过滤器

    使用 BitMap 作为布隆过滤器,将目前所有可以访问到的资源通过简单的映射关系放入到布隆过滤器中(哈希计算),当一个请求来临的时候先进行布隆过滤器的判断,如果有那么才进行放行,否则就直接拦截。

  • 接口校验

    类似于用户权限的拦截,对于 id=-3872 这些无效访问就直接拦截,不允许这些请求到达 Redis、DB 上。

2、缓存雪崩:

我们可以简单的理解为:由于原有缓存失效,新缓存未到时间 (例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),所有原本应该访问缓存的请求都去查询数据库了,而对数据库 CPU 和内存造成巨大压力,严重的会造成数据库宕机。从而形成一系列连锁反应,造成整个系统崩溃。

解决:

产生的原因:redis 中大量的 key 集体过期

  • 将失效时间分散开

    通过使用自动生成随机数使得 key 的过期时间是随机的,防止集体过期

  • 使用多级架构

    使用 nginx 缓存+redis 缓存+其他缓存,不同层使用不同的缓存,可靠性更强

  • 设置缓存标记

    记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际的 key

  • 使用锁或者队列的方式

    如果查不到就加上排它锁,其他请求只能进行等待

3、缓存击穿:

某个 key 非常非常热,访问非常的频繁,高并发访问的情况下,当这个 key 在失效(可能 expire 过期了,也可能 LRU 淘汰了)的瞬间,大量的请求进来,这时候就击穿了缓存,直接请求到了数据库,一下子来这么多,数据库肯定受不了,这就叫缓存击穿。某个 key 突然失效,然后这时候高并发来访问这个 key,结果缓存里没有,都跑到 db 了。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决:

产生的原因:redis 中的某个热点 key 过期,但是此时有大量的用户访问该过期 key。

  • 提前对热点数据进行设置

    类似于新闻、某博等软件都需要对热点数据进行预先设置在 redis 中

  • 监控数据,适时调整

    监控哪些数据是热门数据,实时的调整 key 的过期时长

  • 使用锁机制

    只有一个请求可以获取到互斥锁,然后到 DB 中将数据查询并返回到 Redis,之后所有请求就可以从 Redis 中得到响应

MQ

1. 消息确认

消费者收到的每一条消息都必须进行确认(自动确认和消费者自行确认)。

消费者在声明队列时,可以指定 autoAck 参数,当 autoAck=false 时,RabbitMQ 会等待消费者显式发回 ack 信号后才从内存 (和磁盘,如果是持久化消息的话)中移去消息。否则,RabbitMQ 会在队列中消息被消费后立即删除它。

采用消息确认机制后,只要令 autoAck=false,消费者就有足够的时间处理消息 (任务),不用担心处理消息过程中消费者进程挂掉后消息丢失的问题,因为 RabbitMQ 会一直持有消息直到消费者显式调用 basicAck 为止。

当 autoAck=false 时,对于 RabbitMQ 服务器端而言,队列中的消息分成了两部分:一部分是等待投递给消费者的消息;一部分是已经投递给消费者,但是还没有收到消费者 ack 信号的消息。如果服务器端一直没有收到消费者的 ack 信号,并且消费此消息的消费者已经断开连接,则服务器端会安排该消息重新进入队列。

RabbitMQ 不会为未 ack 的消息设置超时时间,它判断此消息是否需要重新投递给消费者的唯一依据是消费该消息的消费者连接是否已经断开。这么设计的原因是 RabbitMQ 允许消费者消费一条消息的时间可以很久很久。

2. 消息堆积

  1. 消息堆积的后果

    新消息无法进入队列、旧消息无法丢失、消息等待消费时间过长以至于超出了业务容许的范围。

  2. 消息堆积的原因

    生产者突然大量发布消息、消费者来不及消费或消费失败、消费者出现性能瓶颈、消费者直接挂掉了。

  3. 如何解决消息堆积

    (1)排查生产者,是否突然大量发布消息,限制下

    (2)排查消费者,消费性能瓶颈,增加消费者的多线程处理(缩短线程休眠时间等)、部署多个消费者

    (3)排查消息队列,可以想办法把消息按顺序的转移到另外一个新的队列,让消费者消费新队列中的消息。

    (4)可以通过修改 RabbitMQ 的两个参数来增大消费消息的并发数:

    1. concurrentConsumers:对每个 listener 在初始化的时候设置的并发消费者的个数。

    2. prefetchCount

      每次一次性从 broker 里面取的待消费的消息的个数,prefetchCount 是 BlockingQueueConsumer 内部维护的一个阻塞队列 LinkedBlockingQueue 的大小,其作用就是如果某个消费者队列阻塞,就无法接收新的消息,该消息会发送到其它未阻塞的消费者。

3. 消息丢失

消息分别在生产者、消息队列、消费者中丢失:

1. 消息在生产者丢失

原因: 生产者发送消息成功,但 MQ 没收到该消息,一般由网络不稳定造成。

解决方案:发送方采用消息确认机制,当消息成功被 MQ 接收到后,会给生产者发送一个确认消息,表示接收成功。RabbitMQ 发送方确认模式有三种,普通确认、批量确认、异步确认。Spring 整合 RabbitMQ 后只使用了异步监听确认模式。

2. 消息在队列中丢失

原因: 消息发送到 MQ 后,消息还没被消费却在 MQ 中丢失了。比如 MQ 服务器宕机或者未进行持久化就进行了重启。

解决方案:持久化交换机(Exchange)、队列、消息。确保 MQ 服务器异常重启时仍然能从磁盘恢复对应的交换机,队列和消息。然后我们把 MQ 做多台分布式集群,防止出现某一 MQ 服务器挂掉~

3. 消息在消费者丢失

原因: 默认消费者消费消息时,设置的是自动回复 MQ 收到了消息。MQ 会立刻删除自身保存的这条消息,如果消息已经在 MQ 中被删除,但消费者的业务处理出现异常或消费者服务宕机,那么就会导致该消息没有处理成功从而导致消息丢失。

解决方案: 消费者向 MQ 的回复我们设置成手动回复(配置成手动 ACK)。当消费者出现异常或者服务宕机时,MQ 服务器不会删除该消息,而是会把消息重发给绑定该队列的消费者,如果该队列只绑定了一个消费者,则该消息会一直保持在 MQ 服务器,直到消费者能正常消费为止。

正常业务逻辑应该是本地业务执行成功,手动 ack 这条消息。如果业务执行完毕,手动 ack 的时候恰好服务宕机了,重启……这不是会造成重复消费吗?没错,这就牵扯 mq 的另一个问题了,mq 消息重复消费~

4. 重复消费

  1. 场景
    因消息重发机制会出现消息重复消费的情况

  2. 解决方案

(1)幂等操作,同一个操作执行 N 次,结果不变。

(2)若实际业务中用不了幂等,则保存消息 id 到数据库(Redis)中,每次消费前查看消息是否已经被消费过。

5. 有序消费

  1. 场景
    在 work queue 模式下,只有一个队列,但存在多个消费者。多个消费者线程的竞争会导致数据乱序。
    在简单队列模式下,同样的多个消费者线程也会导致数据乱序。

  2. 解决方案
    使用多个队列,对消息的 id 值做 hash。再对队列数取模(hash 值%队列数),将结果相同的消息压入同一个队列中去,这就保证了一个队列中有且仅有一个消费者。
    在 MQ 队列后的 Java 代码中(消费方),再为每一个线程加一个内存队列,根据消息的 id 求 hash 值,然后把相同的结果压入同一个内存队列……

设计模式

1、线程安全的单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {

private volatile static Singleton singleton;

private Singleton() {
}

private static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}