随心记

这篇用于随时记各类八股文,后面会详细拆分各个模块

一、Spring相关

1.Spring框架中的单例bean是线程安全的吗?

答:不是。首先,Spring中的单例bean是全局共享的。一般情况下,spring的bean中注入的都是无状态的对象(无状态指的是不会存储数据,即没有定义可修改的成员变量),所以不会有线程安全问题。但如果假设这个bean定义了可修改的成员变量,多个线程如果同时访问并修改这个成员变量时,可能会造成数据的不一致,这种情况可用使用多例或者加锁来解决

知识点:无状态Bean(即没有可修改的成员变量)、有状态Bean

2.什么是AOP

答:AOP的话,指的就是面向切面编程,简单说就是把一些业务逻辑中相同的代码抽取到一个独立模块中,通过动态代理的方式增强原方法,让业务逻辑更加简洁。

知识点:

JDK动态代理,被代理的对象必须实现一个接口

CGLIB动态代理,适用于被代理的对象没有实现接口的场景。

Spring AOP 默认使用JDK动态代理,但如果目标类没有实现接口,则会自动切换到CGLIB动态代理。

3.项目中有没有用到过AOP

答: 我之前在尚庭公寓的后台管理系统中使用AOP来记录系统的操作日志,主要思路就是找到要记录日志的方法,使用AOP的环绕通知和切点表达式来获取该方法的相关数据,像是调用的用户信息、类名、方法参数这些,将它们其保存到数据库。此外,在项目里,比如@Transactional开启事务的注解、以及拦截器(实现 HandlerInterceptor 这个接口)、@ExceptionHandler异常处理类注解其实都是用到了aop。

4.Spring中事务失效的场景有哪些

答:我知道的有4种。

第一种是异常捕获处理。就是在方法里对异常进行try catch后,假设发生了异常,而catch代码块中没有抛出异常,这种情况就会导致事务失效。解决方法的话就是可以在catch中抛出这个异常。

第二种是抛出检查型异常。因为事务的这个注解默认是对非检查型异常抛出时才会进行事务回滚。解决方法的话,我就会直接配置rollback属性为Exception,这样可以保证对于任何异常都会进行回滚。

第三种是方法没有声明public这个访问控制符。解决方法就是修改方法的访问控制符为public。

第四种是使用this调用带有@Transaction注解的方法

第四种我在生活优选这个项目里碰到过,就是我在这个项目的创建订单业务里,我一开始没有使用分布式锁,使用的是悲观锁和乐观锁,那时我碰到的问题就是,如果在创建订单的这个方法上加上事务的话,会导致锁的提前释放。所以我把锁里面的这部分代码抽取出来到一个方法里,这个方法携带@Transaction注解,但是使用this这个关键字调用这个方法的话,会导致事务失效,需要利用AopContext的currentProxy()方法获取这个类的代理对象,使用代理对象调用这个方法才可以让事务成功生效。

5.Spring的Bean的生命周期

答:bean的声明周期分为7个阶段。

1.首先调用构造函数实例化bean

2.进行bean的依赖注入

3.处理以Aware为后缀的接口

4.执行bean的后置处理器BeanPostProcessor的前置方法

5.执行初始化方法(一个是实现InitializingBean这个接口并重写对应的方法,一种是自己在方法上面加上@PostConstruct这个注解)

6.执行bean的后置处理器的后置方法

7.最后就是bean的销毁

下面是我自定义的测试

image-20250420182919589

6.Spring的循环依赖问题

循环依赖:指的是两个及以上的bean相互依赖对方,比如a依赖b,b依赖a。

循环依赖问题大部分可以由三级缓存来解决。

一级缓存:单例池,缓存的是已经初始化完成的bean.

二级缓存:缓存的是早期的生命周期未走完的bean对象(来自三级缓存)

三级缓存:缓存的是ObjectFactory对象工厂,用于创建某个对象(可以是原始对象或代理对象)

至于为什么要使用三级缓存而不是二级主要是因为代理对象的问题,代理对象的创建是在二级缓存对象之后(即生命周期后期),所以无法(在需要代理对象注入的情况下)将代理对象注入给对方。三级缓存通过ObjectFactory延迟了Bean对象的创建时机,使得Spring可以在需要时动态创建原始对象或代理对象,并将其存入二级缓存。

假设a依赖b,b依赖a的代理对象.

a先初始化,将a的对象工厂存入三级缓存,此时需要依赖注入b。所以b开始初始化,需要依赖注入a的代理对象,三级缓存中的a看到b需要的是代理对象,就创建代理对象存入到二级缓存并注入给b。b成功创建后存入一级缓存并注入给二级缓存中的a,a(代理对象或者原始对象)就创建成功并存入到一级缓存中了。

虽然Spring通过三级缓存解决了大部分循环依赖问题,但仍然有一些限制:

  • 构造器注入:Spring无法解决构造器注入的循环依赖问题,因为构造器注入要求在构造过程中完成所有依赖注入,此时原对象是无法存入三级缓存。解决方法是在构造函数参数上使用@Lazy注解延迟初始化。

7.SpringMVC的执行流程

1.首先用户发起请求到前端控制器DispatcherServlet.

2.DispatcherServlet收到请求后会调用HandlerMapping(处理器映射)

3.HandlerMapping找到具体的处理器,生成处理器对象和处理器拦截器(如果有),再一起返回给DispatcherServlet.

4.DispatcherServlet再调用HandlerAdapter(处理器适配器)

5.HandlerAdapter经过适配调用具体的处理器(handler/Controller)

如果是前后端分离的情况下,到这里流程就结束了,返回给前端的一般都是json字符串

但如果是jsp这种前后端不分离的情况下,还会有几个步骤。

6.处理器执行完成会返回ModelAndView给DispatcherServlet

7.DispatcherServlet将ModelAndView传递给ViewReslover视图解析器

8.视图解析器会将ModelAndView解析成View返回给DispatcherServlet

9.DispatcherServlet对View进行视图渲染后返回给用户

8.SpringBoot自动配置原理

在SpringBoot项目的启动类上面我们会使用@SpringBootApplication这个注解,这个注解是对三个注解进行了封装,包括@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan这三个注解。

其中**@EnableAutoConfiguration这个注解是实现自动配置的核心注解,该注解通过@Import注解导入对应的配置选择器。而这个配置选择器会自动读取一个文件,就是该项目引用的jar包的classpath路径下的META-INF/spring.factories文件,这个文件中包含了所要自动配置类的全类名的列表**。

这些配置类有条件注解,像是@ConditionalOnClass(判断是否有对应的字节码文件)和@ConditionalOnProperty(判断是否有对应的配置信息)这种,根据条件注解判断是否注入到Spring容器中,且其中定义的Bean也一样有条件注解。

注意,我现在这个版本的这个文件里面已经没有自动导入的全类名了。查看了下,好像放到了META-INF/spring文件下的以imports为后缀的文件里。

image-20250421222934681

image-20250421224243054

spring自动装配的实际应用:

1.属性自动装配(Setter注入)

在属性上使用@Autowired注解

2.构造器自动装配

在构造器上使用@Autowired注解或者定义final成员属性,在类上面加上@RequiredArgsConstructor注解

注意,构造器自动装配的参数可以使用final关键字定义成员变量,而属性自动装配是动态注入,无法使用final关键字。所以两者各有应用场景。

9.Spring框架常见注解

1.Spring常见注解–主要是跟bean的实例化和依赖注入相关

image-20250421224821837

其中的Scope指的就是作用域,使用在@Component类上面或者@Bean注解的方法上面。默认情况下,Spring 的 Bean 是单例的(singleton),但通过 @Scope 注解,可以指定其他作用域,如原型(prototype)、会话(session)、请求(request)等。

1
2
3
4
5
@Component
@Scope("prototype") // 指定作用域为原型
public class MyBean {
// 类的实现
}
1
2
3
4
5
6
7
8
@Configuration
public class AppConfig {
@Bean
@Scope("session") // 指定作用域为会话
public MyBean myBean() {
return new MyBean();
}
}

2.SpringMVC常见注解–主要是跟web相关的(请求和响应)

image-20250421225352598

3.SpringBoot常见注解–主要是跟自动配置相关

image-20250421225510711

10.Mybatis执行流程

1.首先会读取Mybatis的相关配置。SpringBoot下是yml文件里配置,而SSM下是mybatis-config.xml文件里配置。

2.然后会构造一个会话工厂SqlSessionFactory

3.再从会话工厂里创建SqlSession对象,这个对象包含了执行SQL语句的所有方法

4.在 SqlSession 对象内部,包含了一个 Executor 执行器。Executor 是 MyBatis 用于操作数据库的接口,它负责执行 SQL 语句并返回结果。

5.Executor 接口的执行方法会接收一个 MappedStatement 类型的参数。MappedStatement 封装了 SQL 映射信息,包括 SQL 语句、输入参数映射和输出结果映射等。

6.执行sql语句时,会把输入参数映射到sql语句里。

7.执行完毕返回时,会将输出结果转化成java对象。

image-20250421230608971

11.Mybatis的延迟加载

(1)什么是延迟加载

简单来说就是需要用到这个数据才进行加载,不需要用到这个数据时不会加载。

就是Mybatis在进行一对一或一对多关联查询对象(比例用户对象除了用户信息,还包含了订单列表,所以需要集合映射查询订单表)时,不会立马执行集合映射那部分的sql语句,只有当使用到这个对象的集合时才会动态的去查询这个集合并赋值给当前对象。

默认情况下,mybatis是关闭延迟加载的。

image-20250421232949235

(2)延迟对象原理

就是使用CGLIB实现的动态代理对象。

以用户这个实体类获取订单列表为例

当调用目标方法user.getOrderList()时,会进入拦截器的invoke方法,如果发现订单列表是null值,则会进行执行sql查询order列表。然后将查询到的order列表调用user.setOrderList()方法将结果传给订单列表。之后结束方法调用。返回订单列表。

image-20250421233546471

12.Mybatis的一级、二级缓存

  • 一级缓存:一级缓存是基于 SqlSession 的,当一个 SqlSession 查询了某个数据后,会将其存储在一级缓存中。在同一个 SqlSession 中,如果再次查询相同的数据,会直接从一级缓存中获取,而不会再次查询数据库。当会话关闭或提交后,一级缓存数据会转移到二级缓存。
  • 二级缓存:二级缓存是跨多个 SqlSession 的,它存储在 Mapper 的级别(就是Mapper这个类下的所有方法都适用)。其下的 SqlSession 在查询相同的数据时,可以直接从二级缓存中获取,而不需要再次查询数据库。默认情况下,二级缓存是禁用的,需要在配置文件里手动开启
  • 数据更新机制:当数据在某一作用域(一级缓存session/二级缓存mapper)发生增删改操作时,默认该作用域下的所有缓存都会被清空。

二、java集合相关

1.为什么数组索引从0开始呢?假如从1开始不行吗

这主要是跟数组的寻址公式相关。数组在取指定索引的元素时,会使用首地址+索引值*数组中的元素类型大小。如果是从1开始,公式就多了一个索引值减1的操作,对于CPU来说相当于多了一次指令,当遍历量大时,性能就会不高。

2.操作数组的时间复杂度

根据索引查找是O(1)。

查找未排序的数组中的某一元素是O(n)。查找排序后的数组时,利于二分查找的时间复杂度为O(logn)。

添加和删除操作时,时间复杂度为O(n)。

3.ArrayList底层实现原理是什么

ArrayList底层是基于动态数组实现的。

初始容量是0,当第一次添加数据时才会初始化容量为10。在添加数据时会先确保当前数组已使用长度小于数组容量。如果等于容量大小就会对数组进行扩容。

数组扩容后是大约是原来容量的1.5倍,就是原数大小加上右移一位后的大小。此外,每次扩容都会拷贝数组。

小问题

ArrayList list = new Arraylist(10)中的list扩容几次

答:0次,因为源码里这个传入容量的构造方法实际就是按传入的数值大小直接创建存储数据的数组。根本没有涉及扩容。

image-20250423001656778

4.如何实现数组和List之间的转换

数组转List使用Arrays.asList()方法。

List转数组使用List的toArray(new数组对象)方法例如String[] array = list.toArray(new String[list.size()]);

追问:

1.用Arrays.asList转List后,如果修改了数组内容,list会受影响吗

答:会。因为Arrays.asList方法底层就是把数组传给内部类ArrayList的构造函数,这个构造函数会直接把传入的数组做为自己的底层数组,所以List和数组最终指向的都是同一个内存地址。

image-20250518020436754

2.List用toArray转数组后,如果修改了List内容,数组会受影响吗

答:不会。因为toArray方法实际上是拷贝了List中的数组,所以新数组和List没有关系。

image-20250518020551908

5.链表相关

image-20250423005036389

6.ArrayList和LinkedList的区别是什么

从四方面回答:

1.底层数据结构:

ArrayList是动态数组,LinkedList是双向链表。

2.效率

ArrayList访问随机元素的时间复杂度是O(1),而LinkedList是O(n).

A添加和删除数据的时间复杂度是O(n),而LinkedList是O(1).

3.空间

ArrayList底层是数组,数据存储占用的是连续的内存空间,内存更省。

而LinkedList的数据存储占用的是不连续的内存空间,而且除了存储数据还需要存储前后指针,内存开销更大。

4.线程是否安全

两个都不是线程安全的。

要保证线程安全的话可以在方法内做为局部变量使用,直接避免线程不安全问题产生。

或者使用Collections的synchronizedList()方法创列表。

image-20250423005248216

image-20250423005339337

7.HashMap底层原理

HashMap的底层数据结构是数组+链表+红黑树(jdk1.8及以后)。

当我们向HashMap put元素时,会利用key的hashCode重新计算hash值,将hash值和(数组容量-1)进行与运算计算出元素在数组中的下标。

如果出现hash冲突。则会判断key是否相同,key相同则覆盖原值。不同,则会将key-value放入到链表或红黑树中。

至于链表和红黑树之间是有转换规则的。

HashMap一开始创建时是数组+链表的结构,当链表长度超过8以及map容量大小大于64时,会将这个链表转化为红黑树。而如果链表长度超过8但map容量小于64时,会对数组进行扩容而不是转化为红黑树。

8.讲讲HashMap的扩容机制

对于扩容,HashMap中是设有扩容阈值的,其大小是根据当前数组长度*负载因子得到的,而负载因子默认大小是0.75。

在添加元素时候,会调用resize方法进行扩容。第一次添加数据时会初始化数组长度为16,之后每次扩容的大小都是原来的2倍大小,扩容时机是根据扩容阈值判断的。

扩容时,会创建一个新数组,把老数组中的数据拷贝到新数组中。在拷贝过程中。要注意三点

一、对于没有hash冲突(判断next是否为null)的节点,会直接使用e.hash&(newCap - 1)计算新数组的索引位置。

二、如果是红黑树,则会走红黑树的添加方式。

三、如果是链表,则会对遍历链表,通过判断链表元素节点的hash值和oldCap进行与运算后的结果是否为0,若为0则停留到原始位置,否则移动到原始位置+旧数组大小的这个位置上。

注意:HashMap是懒加载。在创建对象时不会初始化数组,在无参构造函数中会加载默认的负载因子(0.75)。对于索引变更,根据第10点可知,数组扩容的情况下,索引要么不变,要么加上旧数组大小

9.hashMap的寻址算法

hashMap首先会获取key的hashCode,然后对这个hashCode右移16位后进行异或运算得到hash值。这个右移异或是扰动算法,可以让hash分布更加均匀。

根据hash值跟(数组的容量大小-1)进行与运算后得到索引。

10.为什么HashMap的数组长度一定是2的次幂?

因为2的次幂-1的与运算可以代替取余运算,计算索引的效率更高。

此外,扩容时计算索引的效率也更高,不是通过hash & (oldCap - 1)获取新索引,而是通过hash & oldCap == 0 判断元素是否留在原来的位置,否则新位置=旧位置+oldCap。

oldCap是旧数组大小。

至于为什么会用hash&oldCap == 0来判断是基于公式推导得到的

对旧索引:index = hash & (oldCap - 1)

对新索引:newIndex = hash & (newCap - 1) = hash & (oldCap + oldCap - 1) = hash & (oldCap -1) + hash & oldCap = index + hash & oldCap

从公式我们可以看到,新索引的增加数位就是由 hash & oldCap 决定的。

而oldCap是2的次幂,导致oldCap转换为二进制数时只有一位是1,因此hash & oldCap的与运算结果只可能是0或者oldCap;

故而当hashCode & oldCap == 0时,索引不变。不等于0时,索引+oldCap。

三、并发编程相关

1.线程和进程的区别

进程可以说是正在运行的程序实例,每个进程都有自己独立的内存空间,不同进程之间是相互隔离的。而线程是进程里面的执行单元,一个进程里可以有多个线程,这些线程共享进程的内存空间

另外,线程比进程更轻量,上下文切换成本也低很多。因为切换线程时,系统只需要保存和恢复线程的上下文信息,而切换进程则需要处理更多资源和状态信息,开销更大。

2.并行和并发有什么区别

并发指的是多个任务交替执行,在宏观上看是同时运行的,而微观上看其实就是快速切换,比如单核CPU处理多个任务。

并行指的是多个任务同时执行,比如说多核CPU同时运行多个程序。

3.创建线程的方式有哪些

有四种方式

继承Thread类实现Runnable接口实现Callable接口使用线程池创建线程

关于如何使用Callable接口

主要就是重写call方法,然后将该类做为FutureTask构造函数的参数。在把FutureTask做为Thread构造函数的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
String name = Thread.currentThread().getName();
System.out.println(name + "执行了");
return name;
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<String> future = new FutureTask<>(mc);
Thread thread1 = new Thread(future);
Thread thread2 = new Thread(future);
thread1.start();
thread2.start(); //future只能供一个线程,所有这个不生效
System.out.println(future.get()); //获取执行完的返回值
}
}

注意:

(1)一个FutureTask类只能被一个线程获取,再开其他线程不生效。这是因为FutureTask 是一个线程安全的类,它内部会管理任务的执行状态。如果尝试用多个线程启动同一个 FutureTask,只有第一个线程会真正执行任务,其他线程会发现任务已经被启动,从而不会重复执行。

(2)FutureTask实际上是Runnable的实现类。

image-20250425200843184

追问1.Runnable和Callable有什么区别

Runnbale接口的run方法没有没有返回值,且不允许抛出异常。

Callable接口有返回值,需要用FutureTask获取返回值,且允许抛出异常。

追问2.run()和start()有什么区别

start()是用来启动线程的,并在新线程中调用run方法。一个线程的start方法只能被调用一次(尝试多次会抛出异常)。

如果直接调用run方法,run方法只是会做为一个普通方法被调用,仍旧在当前线程执行,并不会启动新线程。

4.线程包含哪些状态

有六种状态。新建(New)、可运行(Runnable)、阻塞(Blocked)、等待(waiting)、时间等待(Timed_waiting)、终止(Terminated)。

5.线程状态之间是如何变化的

创建线程对象是新建状态

调用start()方法后转变为可执行状态

线程获取到CPU执行权时会开始执行,执行结束是终止态

在执行过程中,可能会切换为其他状态。

  • 若要当前线程要获取锁,而锁被其他线程占有,则会进入阻塞态
  • 若当前线程调用了wait()方法进入等待状态,则需要其他线程调用notify()唤醒为可执行态。
  • 若当前线程调用了sleep(500)方法,则会进入计时等待状态,到时间后切换可执行态。

image-20250425213446655

注意:wait方法会让当前线程释放锁,进入等待唤醒状态。而notify方法会随机唤醒在等待中的线程。notifyAll方法会唤醒所有在等待中的线程。notify和notifyAll不会释放锁,只是唤醒线程让它们可以重新获取锁。

6.新建T1、T2、T3三个线程,如何保证按顺序执行

可以使用线程的join()方法解决。比如说要保证执行顺序为T1、T2、T3,那么可以在T2线程的run方法开头调用T1线程的join方法,T3线程的run方法开头调用T2线程的join方法。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class ThreadOrder extends Thread{
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("线程1执行了");
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
t1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程2执行了");
}
});
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
try {
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程3执行了");
}
});
t1.start();
t2.start();
t3.start();
}
}

注意:在上面的代码中,t1的方法参数的匿名内部类不能调用t2和t3的join方法,因为t2、t3的变量的声明在t1之后。此外,在匿名内部类在调用外部类的局部变量时,变量必须是final或实际上不可变(指的是创建后并没有发生改变),否则会编译错误。

7.wait和sleep方法的不同

共同点:都是让当前线程暂时放弃对CPU的使用权。

1.方法归属不同

  • sleep是Thread的静态方法
  • 而wait是Object的成员方法,每个对象都有

2.醒来的时机不同

  • 执行sleep(long)和wait(long)的线程都会等待相应的毫秒后醒来。
  • wait()和wait(long)可以被notify唤醒, wait()如果不唤醒就会一直等待下去

3.锁的特性不同

  • wait()方法必须在synchronized代码块中调用,且必须获取wait对象的锁,而sleep无此限制。
  • wait()方法执行后会释放对象锁,允许其他线程获取该对象锁。而sleep如果在synchronized代码块中执行,则不会释放对象锁

8.如何停止一个正在运行的线程

有三种方法

1.使用退出标志,使线程正常退出,也就是当run方法执行完成后线程就会终止。

2.使用stop方法强行终止(方法已作废)

3.使用interrupt方法中断线程

9.synchronized关键字底层原理

synchronized底层是由monitor实现的,就是锁监视器。monitor是jvm级别的对象。使用synchronized获取线程的某个对象的锁时,实际上是获取对象关联的monitor。

在monitor内部有三个属性,分别是owner、entrylist、waitset

其中的owner关联的是当前持有锁的线程entrylist关联的是处于阻塞状态的线程waitset关联的是处于waiting状态的线程

image-20250428194935115

注意:在java中,每个对象都有一个与之关联的monitor。monitor是一种同步机制,用于确保同一时刻只有一个线程可以执行某个对象相关的同步代码。

10.JMM(java内存模型)

JMM是java内存模型,它定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作,从而保证指令的正确性。

JMM把内存分为了两块,一块是线程私有的工作内存,一块是所有线程的共享的主内存

线程跟线程之间是相互隔离的,线程跟线程之间的交互需要通过主内存。

image-20250428195127443

11.CAS

CAS全称是Compare And Swap,它体现的其实就是一种乐观锁的思想。实现原理就是用一个预期值与要更新的值进行比较,两值相等的情况下才能成功更新。其底层调用的是Unsafe类中的方法,是由操作系统提供的。

12.乐观锁和悲观锁的区别

乐观锁无需加锁也无需等待,只需要在提交修改时去验证对应的资源是否被其他线程修改了即可,可以使用版本号机制或CAS算法。

而悲观锁则是在每次获取到资源时上锁。当有一线程获取到锁时,其他线程需要阻塞等待锁。

在高并发场景下,乐观锁相比悲观锁不存在竞争造成的线程阻塞问题。但如果冲突发生的较为频繁的话,会出现频繁的失败和重试,很浪费CPU,因为CPU要花费时间片去不断重试。

12.volatile关键字

volatile可以保证数据的可见性禁止指令重排序

数据的可见性:

加了volatile关键字的变量在获取或者修改时会直接对主内存中进行操作,而不会经过线程私有的工作内存。能够防止编译优化产生的问题

禁止指令重排序:

用valatile修饰的变量会在读和写时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。

比如双重校验锁实现对象单例的情况。

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

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}

}

uniqueInstance = new Singleton(); 这段代码其实是分为三步执行的:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空(因为已经指向内存地址了),因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

13.什么是AQS

image-20250428214445414

AQS(抽象队列同步器) 是一个抽象类,它维护了一个共享变量 state一个线程等待队列,为 ReentrantLock 等类提供底层支持。

AQS 的思想是,如果被请求的共享资源处于空闲状态,则当前线程成功获取锁;否则,将当前线程加入到等待队列中,当其他线程释放锁时,从等待队列中挑选一个线程,把锁分配给它。

AQS底层源码

源码阅读指的是AbstractQueuedSynchronizer这个抽象类。

第一,状态变量state由volatile修饰,用于保证多线程之间变量的可见性

1
private volatile int state;

第二,同步队列由内部定义的Node类实现,每个 Node 包含了等待状态、前后节点、线程的引用等,是一个先进先出的双向链表。

1
2
3
4
5
6
7
abstract static class Node {
volatile Node prev; // 前驱节点
volatile Node next; // 后继节点
Thread waiter; // 当前节点所关联的线程
volatile int status; // 等待状态
//剩下方法省略
}

此外

AQS 支持两种同步方式,具体看state的赋值情况:

  • 独占模式下:每次只能有一个线程持有锁,例如 ReentrantLock。
  • 共享模式下:多个线程可以同时获取锁,例如 Semaphore (信号量)和 CountDownLatch(计数器)。

AQS 使用一个 CLH 队列来维护等待线程,CLH 是三个作者 Craig、Landin 和 Hagersten 的首字母缩写,是一种基于链表的自旋锁。

在 CLH 中,当一个线程尝试获取锁失败后,会被添加到队列的尾部并自旋,等待前一个节点的线程释放锁。

CLH 的优点是,假设有 100 个线程在等待锁,锁释放之后,只会通知队列中的第一个线程去竞争锁。避免同时唤醒大量线程,浪费 CPU 资源。

CountDownLatch源码

CountDownLatch内部维护了一个继承AQS的静态内部类Sync

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 4982264981922014374L;

Sync(int count) {
setState(count);
}

int getCount() {
return getState();
}

protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
//计数器减一操作
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc)) //调用父类AQS的CAS方法,保证原子性
return nextc == 0;
}
}
}

//AQS的CAS方法
protected final boolean compareAndSetState(int expect, int update) {
return U.compareAndSetInt(this, STATE, expect, update);
}

当创建CountDownLatch对象时,其实就是把计数值传递给AQS的state变量

1
2
3
4
5
6
7
8
9
10
11
12
13
CountDownLatch countDownLatch = new CountDownLatch(100);
countDownLatch.countDown();

//对应的底层实现
//1.对象创建,实际就是创建AQS子类Sync对象并赋值给成员变量
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);//看上面Sync的构造方法
}
//2.减一操作,Sync内存重写了AQS的releaseShared方法
public void countDown() {
sync.releaseShared(1);
}

14.ReentrantLock实现原理

ReentrantLock翻译过来是可重入锁,相对应synchronized,它具备以下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量
  • 与synchronized一样,都支持重入

ReentrantLock主要是利用CAS和AQS队列来实现的。

支持公平锁和非公平锁。在构造函数中,无参默认非公平锁,也可以传参设置为公平锁。

image-20250428214321303

image-20250428214318357

15.ConcurrentHashMap

jdk1.7底层采用分段数组+链表实现

采用的是Segment分段锁,底层使用的是ReentrantLock。

而jdk1.8及以上,ConcurrentHashMap的数据结构和HashMap一样,都是数组+链表/红黑树,只是在添加节点时用到了CAS和Synchronized来保证线程的安全

在对数组的空节点进行添加时,会使用CAS算法。(解决两个数据同时添加到数组的同一个位置时的问题)

而在对链表或者红黑树进行节点添加时,会对通过Synchronized,对首节点进行锁定。

以下是源码截图

给空节点添加时

image-20250428215707901

image-20250428220319085

image-20250428220736895

目标元素的实际内存地址为:

1
tabAddress + ((long)i << ASHIFT) + ABASE

首节点锁定

image-20250428222341524

如果首节点被其他线程替换了,就会迭代循环(无限,直至遇到break)。

image-20250428222731113

16.ThreadLocal

ThreadLocal 是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。

在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。

ThreadLocal实现的底层原理

首先,ThreadLocal 自身并不保存任何线程的数据,ThreadLocal 只是一个操作接口,真正的线程本地值存储在当前调用的线程的 ThreadLocalMap 中。

当我们创建一个 ThreadLocal 对象并调用 set 方法时,其实是在当前线程中初始化了一个 ThreadLocalMap。

ThreadLocalMap 是 ThreadLocal 的一个静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象value 是我们所传入的变量,所以每个ThreadLocal虽然只能存一个value值,但我们可以创建多个ThreadLocal对象用于保存各种信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//ThreadLocalMap的构造方法,firstKey是ThreadLocal实例,firstValue是传入的值
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY]; //初始化entry数组
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); //计算索引位置
table[i] = new Entry(firstKey, firstValue); //存放具体Entry
size = 1; //已用的数组节点大小
setThreshold(INITIAL_CAPACITY); //设置扩容阈值
}
//设置扩容阈值为容量大小的2/3
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
//以ThreadLocal为key,进行赋值
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table; //获取entry数组
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//若hash冲突,则会
for (Entry e = tab[i];
e != null;//为null,则跳出遍历
e = tab[i = nextIndex(i, len)] //若hash冲突,线性探测下一个索引位置) {
//判断是否是同一个ThreadLocal实例.若是,则直接进行值替换
if (e.refersTo(key)) {
e.value = value;
return;
}
//表示这个 Entry 的 Key 已经被 GC 回收了,现在是个无效 Entry。就是ThreadLocal实例已经不存在了。那么就可以替换掉这个entry了
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}
//若哈希冲突,且无遇到无效Entry,则创建新Entry并赋值
tab[i] = new Entry(key, value);
int sz = ++size;
//进行无效entry的清理
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();// 这是扩容, 即清理没效果,并且已经达到了扩容阈值
}
//线性探测索引位置,因为扩容阈值为2/3大小,所以不会有无限循环探测的问题
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

在 ThreadLocalMap 中,Key 是 ThreadLocal 实例本身。所以如果你每次调用方法都 new 一个新的 ThreadLocal(),即使值一样,也会被认为是不同的 Key。导致同一个线程多次 set(),其实是存了多个 Entry,浪费内存且无法复用。

总之,将 ThreadLocal 设置为 static final 是一种最佳实践,它确保 Key 唯一可重用避免不必要的对象创建,并减少因 Key 不一致或弱引用导致的潜在内存泄漏风险。

四、Mysql

1.如何定位慢查询

Mysql本身提供了慢查询日志功能。可以在系统的配置文件中开启慢查询日志,设置SQL执行超过多长时间就记录到日志文件中。

2.那这个SQL语句执行很慢,如何分析呢?

如果一条SQL执行很慢,我们通常会使用MySQL的EXPLAIN命令来分析这条SQL的执行情况。通过keykey_len可以检查是否命中了索引,如果已经添加了索引,也可以判断索引是否有效。通过type字段可以查看SQL是否有优化空间,比如是否存在全索引扫描或全表扫描。通过extra建议可以判断是否出现回表情况,如果出现,可以尝试添加索引或修改返回字段来优化。

3. 索引的底层数据结构了解过吗?

MySQL的默认存储引擎InnoDB使用的是B+树作为索引的存储结构。选择B+树的原因包括:节点可以有更多子节点,路径更短;磁盘读写代价更低,非叶子节点只存储键值和指针,叶子节点存储数据;B+树适合范围查询和扫描,因为叶子节点之间形成了一个双向链表。

image-20250513184525036

4.B树和B+树的区别是什么呢?

  1. B树的非叶子节点和叶子节点都存放数据,而B+树的所有数据只出现在叶子节点,这使得B+树在查询时效率更稳定。
  2. B+树在进行范围查询时效率更高,因为所有数据都在叶子节点,并且叶子节点之间形成了双向链表。

5.什么是聚簇索引和非聚簇索引

聚簇索引是指数据与索引放在一起,B+树的叶子节点保存了整行数据,通常只有一个聚簇索引,一般是由主键构成。

非聚簇索引则是数据与索引分开存储,B+树的叶子节点保存的是主键值,可以有多个非聚簇索引,通常我们自定义的索引都是非聚簇索引。

6.什么是回表查询

回表查询是指通过二级索引找到对应的主键值,然后再通过主键值查询聚簇索引中对应的整行数据的过程。

注意二级索引和非聚簇索引的区别:

  • 二级索引:是一种在数据库表中除了主索引(聚簇索引)之外的其他索引。它允许用户根据非主键列进行快速查找。二级索引的结构通常是一个独立的索引结构,它存储了索引列的值和指向数据行的指针。

  • 非聚簇索引:是一种索引类型,它不改变数据行的物理存储顺序。非聚簇索引的存储结构是一个独立的索引表,它存储了索引列的值和指向数据行的指针。数据行的存储顺序与非聚簇索引的顺序无关。

  • 在实际应用中,非聚簇索引通常是二级索引的一种实现方式,但二级索引也可以是其他类型的索引,如哈希索引等。

7.什么是覆盖索引

覆盖索引指的是使用select查询数据时,所需的列全部能在索引中找到,避免了回表查询。使用覆盖索引可以减少对主键索引的查询次数,提高查询效率。

8.超大分页怎么处理

超大分页通常发生在数据量大的情况下,使用LIMIT分页查询且需要排序时效率较低。可以通过覆盖索引和子查询来解决。首先查询数据的ID字段进行分页,然后根据ID列表用子查询来过滤只查询这些ID的数据,因为查询ID时使用的是覆盖索引,所以效率可以提升。

image-20250513185734032

我的理解是,对于超大分页,如果直接对所有数据进行limit的话,需要扫描全表并排序,数据量不仅大还耗时。但如果我们先使用子查询的方式获取排序后的id临界值,因为只需要id值,所以是覆盖索引,效率可以提升。再把获取到的id值做为主查询的临界值,这样子主查询就可以避免对全表的扫描和排序。

例如:select * from tb_user limit 90000,10 这个语句会先遍历前90000行,再往后取10行,效率很低

9.什么情况下,索引会失效

1.没有遵循最左匹配原则

2.使用了模糊查询且%在前面

3.在索引字段上进行了运算或者类型转换

4.使用了联合索引,但在中间使用了范围查询,导致右边的条件索引失效

面渣八股文

1.为什么InnoDB要使用B+树做为索引?

因为 B+ 树是一种高度平衡的多路查找树,能有效降低磁盘的 IO 次数,并且支持有序遍历和范围查询

再换一种回答:

  • 相比哈希表:B+ 树支持范围查询和排序
  • 相比二叉树和红黑树:B+ 树更“矮胖”,层级更少,磁盘 IO 次数更少
  • 相比 B 树:B+ 树的非叶子节点只存储键值,叶子节点存储数据并通过链表连接,支持范围查询

tip:

多路查找树是一种树形数据结构,每个节点可以有多个子节点。例如,一个三路查找树的每个节点最多有三个子节点。

高度平衡的树是指树的左右子树的高度差不超过一个固定值(通常是1)。

2.事务的四大特性-ACID

分别是原子性、一致性、隔离性和持久性

image-20250513192209329

原子性:

原子性指的是事务中所有的操作,要么全部执行成功,要么全部失败。事务中只要任何一个操作失败了,就会回滚到事务开始之前的状态。

一致性:

一致性确保数据的状态从一个一致状态转变为另一个一致状态。一致性与业务规则有关,比如银行转账,不论事务成功还是失败,转账双方的总金额应该是不变的

隔离性:

指的是多个并发事务之间相互隔离,即一个事务的执行不能被其他事务干扰

持久性:

一旦事务提交,则其所做的修改将永久保存到 MySQL 中。即使发生系统崩溃,修改的数据也不会丢失。

3.事务的隔离级别

MySQL中支持4种隔离级别,分别是读未提交、读已提交、可重复读和串行化。

image-20250513193331803

读未提交:会出现脏读、不可重复读、幻读

读已提交:会出现不可重复读、幻读

可重复读:会出现幻读

串行化:无

读未提交

事务可以读取其他未提交事务修改的数据。也就是说,如果未提交的事务一旦回滚,读取到的数据就会变成了“脏数据”,通常不会使用。

读已提交

读已提交避免了脏读,但可能会出现不可重复读,即同一事务内多次读取同一数据结果会不同,因为其他事务提交的修改,对当前事务是可见的

可重复读

可重复读能确保同一事务内多次读取相同数据的结果一致,即使其他事务已提交修改。可重复读是MySQL的默认隔离级别。但不可避免幻读。

串行化

串行化是最高的隔离级别,通过强制事务串行执行来解决“幻读”问题。

image-20250513193812078

但会导致大量的锁竞争问题,实际应用中很少用。

4.事务的隔离级别是如何实现的

注意:针对MySQL的MVCC,事务的隔离级别的底层实现相较于传统的三级封锁协议有些区别。因为用到了ReadView。

下面写的是针对MVCC和传统的封锁协议写的,不要等价了。

读未提交,写操作加行级排他锁(X 锁),防止其他事务同时修改同一行。但读操作不加锁,所以允许读取其他事务未提交的数据,也就导致了脏读的问题。

对应一级封锁协议。会在数据更新前添加行级排他锁,行级排他锁会阻止其他事务对数据再加排他锁或者共享锁,但由于一级封锁协议没有共享锁这个概念,导致其他事务读取数据时,排他锁并不会拦截。也就会导致脏读问题。

读已提交,写操作会加行级排他锁,每次读操作时,都会生成一个新的ReadView,确保读取到的数据是最新已提交的,从而解决脏读的问题。但因为每次读都是最新已提交的数据,所以会出现不可重复读的问题。

对应二级封锁协议。在一级封锁协议上引入了共享锁,使得其他事务读取数据时需要先添加共享锁,而数据修改时会添加排他锁,导致获取锁必须在拥有排他锁的事务释放锁之后才可以读取数据,所以可以避免脏读问题。但二级封锁协议的共享锁是在查询时添加共享锁,查询结束就立刻释放共享锁。所以并不能解决不可重复读问题。

可重复读,可重复读只在第一次读操作时生成 ReadView,后续读操作都会使用这个 ReadView,从而避免不可重复读的问题。另外,对于当前读操作,可重复读会通过临键锁来锁住当前行和前间隙,防止其他事务在这个范围内插入数据,从而避免幻读的问题。

对应三级封锁协议。可重复读的共享锁的释放必须在事务结束之后,所以可以解决不可重复读的问题。但由于排他锁和共享锁只能针对已经存在的数据加锁。对于不存在的数据,其他事务可以进行增加操作,所以可能会有幻读问题存在。

串行化,事务在读操作时,会先加表级共享锁;在写操作时,会先加表级排他锁。所以可以避免幻读问题的发生。

我的理解是串行化超过了三级封锁协议,主要原因还是标准的三级封锁协议无法对不存在的数据加锁,不可避免幻读的产生。

5.MVCC机制

MVCC指的是多并发版本控制,每次修改数据时,都会生成一个新的版本,而不是直接在原有的数据上进行修改。并且每个事务只能看到在它开始之前已经提交的数据版本。

这样的话,读操作和写操作之间就不会相互阻塞,从而避免了频繁加锁带来的性能损耗。

底层实现主要依赖于Undo Log和Read View。

每次修改数据前,会将当前的记录拷贝到Undo Log中,其中的每条记录都包含三个隐藏列,DB_TRX_ID用于记录当前修改该行的事务ID,DB_ROLL_PTR用来指向Undo Log中的前一个版本,DB_ROW_ID用来唯一标识改行数据(仅无主键时生成)。

如下图所示,当前的行数据会指向undo log中最近的数据版本,这样子使得数据的版本串行连接,方便数据的回滚等。

guozhchun:额外的存储信息

每次读取数据时,都会生成一个 ReadView,其中记录了当前活跃事务的 ID 集合、最小事务 ID、最大事务 ID 等信息,通过与 DB_TRX_ID 进行对比,判断当前事务是否可以看到该数据版本。

luozhiyun:ReadView

什么是版本链

版本链是指 InnoDB 中同一条记录的多个历史版本,通过 DB_ROLL_PTR 字段将它们像链表一样串起来,用来支持 MVCC 的快照读。

二哥的 Java 进阶之路:版本链

当更新一行数据时,innoDB不会覆盖原有的数据,会记录上一次的数据版本到undo日志中,并创建一个新的数据版本,更新DB_TRX_ID和DB_ROLL_PTR,使他们存放当前的事务ID和上一次数据版本在undo日志中的指针。

这样,老版本的数据就不会丢失,可以通过版本链找到。

由于 undo 日志会记录每一次的 update,并且新插入的行数据会记录上一条 undo 日志的指针,所以可以通过 DB_ROLL_PTR 这个指针找到上一条记录,这样就形成了一个版本链。

三分恶面渣逆袭:版本链

什么是Read View

ReadView 是 InnoDB 为每个事务创建的一份“可见性视图”,用于判断在执行快照读时,哪些数据版本是当前这个事务可以看到的,哪些不能看到。

当事务开始执行时,InnoDB 会为该事务创建一个 ReadView,这个 ReadView 会记录 4 个重要的信息:

  • creator_trx_id:创建该 ReadView 的事务 ID。
  • m_ids:所有活跃事务的 ID 列表,活跃事务是指那些已经开始但尚未提交的事务
  • min_trx_id:所有活跃事务中最小的事务 ID。它是 m_ids 数组中最小的事务 ID。
  • max_trx_id :事务 ID 的最大值加一。换句话说,它是下一个将要生成的事务 ID。

如何判断记录的某个版本是否可见?

二哥的 Java 进阶之路:ReadView判断规则

①、如果某个数据版本的 DB_TRX_ID 小于 min_trx_id,则该数据版本在生成 ReadView 之前就已经提交,因此对当前事务是可见的。

②、如果 DB_TRX_ID 大于 max_trx_id,则表示创建该数据版本的事务在生成 ReadView 之后开始,因此对当前事务不可见。

③、如果 DB_TRX_ID 在 min_trx_id 和 max_trx_id 之间,需要判断 DB_TRX_ID 是否在 m_ids 列表中:

  • 不在,表示创建该数据版本的事务在生成 ReadView 之后已经提交,因此对当前事务也是可见的。
  • 在,表示事务仍然活跃,或者在当前事务生成 ReadView 之后才开始,因此是不可见的。

总之,readView规定了当前事务可见的范围,可见的只能是不在活跃事务范围内、且事务id不超过当前记录的未来事务id(即事务可见)的已经提交的事务。

可重复读和读已提交在ReadView上的区别

可重复读:在第一次读取数据时生成一个 ReadView,这个 ReadView 会一直保持到事务结束,这样可以保证在事务中多次读取同一行数据时,读取到的数据是一致的。

读已提交:每次读取数据前都生成一个 ReadView,这样就能保证每次读取的数据都是最新的。

如果两个 AB 事务并发修改一个变量,那么 A 读到的值是什么,怎么分析

事务 A 在读取时是否能读到事务 B 的修改,取决于 A 是快照读还是当前读。如果是快照读,InnoDB 会使用 MVCC 的 ReadView 判断记录版本是否可见,若事务 B 尚未提交或在 A 的视图不可见,则 A 会读到旧值;如果是当前读,则需要加锁,若 B 已提交可直接读取,否则 A 会阻塞直到 B 结束。

知识点速过:UndoLog记录数据的历史版本(记录指针来连接),ReadView可以使快照读操作不阻塞。

到这里,豁然开朗了,原来mysql的事务隔离并不是完全参考遵循三级封锁协议的,它原来是用到了MVCC来减少频繁创建锁带来的性能损耗以及优化读操作的性能。

6.什么是快照读和当前读

  • 快照读(Snapshot Read)
    • 读取的是符合当前事务可见性规则的历史版本数据。
    • 不加锁,适用于非阻塞读取。
    • 适用于 REPEATABLE READREAD COMMITTED 隔离级别下的普通查询。
  • 当前读(Current Read)
    • 读取的是数据的最新版本。
    • 通常会加锁,适用于需要最新数据的场景。
    • 适用于 READ COMMITTEDREPEATABLE READ 隔离级别下的 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE 查询。