一、小林-Java并发编程面试题
1、synchronized和reentrantlock及其应用场景?
synchronized 工作原理
synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,
使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。
从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。
如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。
- 当多个线程进入同步代码块时,首先进入entryList
- 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
- 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
- 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null
reentrantlock工作原理
ReentrantLock 的底层实现主要依赖于 AbstractQueuedSynchronizer(AQS)这个抽象类。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。
ReentrantLock 在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑:
- 可中断性: ReentrantLock 实现了可中断性,这意味着线程在等待锁的过程中,可以被其他线程中断而提前结束等待。在底层,ReentrantLock 使用了与 LockSupport.park() 和 LockSupport.unpark() 相关的机制来实现可中断性。
- 设置超时时间: ReentrantLock 支持在尝试获取锁时设置超时时间,即等待一定时间后如果还未获得锁,则放弃锁的获取。这是通过内部的 tryAcquireNanos 方法来实现的。
- 公平锁和非公平锁: 在直接创建 ReentrantLock 对象时,默认情况下是非公平锁。公平锁是按照线程等待的顺序来获取锁,而非公平锁则允许多个线程在同一时刻竞争锁,不考虑它们申请锁的顺序。公平锁可以通过在创建 ReentrantLock 时传入 true 来设置,例如:
ReentrantLock fairLock = new ReentrantLock(true);
- 多个条件变量: ReentrantLock 支持多个条件变量,每个条件变量可以与一个 ReentrantLock 关联。这使得线程可以更灵活地进行等待和唤醒操作,而不仅仅是基于对象监视器的 wait() 和 notify()。多个条件变量的实现依赖于 Condition 接口,例如:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 使用下面方法进行等待和唤醒
condition.await();
condition.signal();
- 可重入性: ReentrantLock 支持可重入性,即同一个线程可以多次获得同一把锁,而不会造成死锁。这是通过内部的 holdCount 计数来实现的。当一个线程多次获取锁时,holdCount 递增,释放锁时递减,只有当 holdCount 为零时,其他线程才有机会获取锁。
应用场景的区别
synchronized:
- 简单同步需求: 当你需要对代码块或方法进行简单的同步控制时,
synchronized是一个很好的选择。它使用起来简单,不需要额外的资源管理,因为锁会在方法退出或代码块执行完毕后自动释放。 - 代码块同步: 如果你想对特定代码段进行同步,而不是整个方法,可以使用
synchronized代码块。这可以让你更精细地控制同步的范围,从而减少锁的持有时间,提高并发性能。 - 内置锁的使用:
synchronized关键字使用对象的内置锁(也称为监视器锁),这在需要使用对象作为锁对象的情况下很有用,尤其是在对象状态与锁保护的代码紧密相关时。
ReentrantLock:
- 高级锁功能需求:
ReentrantLock提供了synchronized所不具备的高级功能,如公平锁、响应中断、定时锁尝试、以及多个条件变量。当你需要这些功能时,ReentrantLock是更好的选择。 - 性能优化: 在高度竞争的环境中,
ReentrantLock可以提供比synchronized更好的性能,因为它提供了更细粒度的控制,如尝试锁定和定时锁定,可以减少线程阻塞的可能性。 - 复杂同步结构: 当你需要更复杂的同步结构,如需要多个条件变量来协调线程之间的通信时,
ReentrantLock及其配套的Condition对象可以提供更灵活的解决方案。
综上,synchronized适用于简单同步需求和不需要额外锁功能的场景,而ReentrantLock适用于需要更高级锁功能、性能优化或复杂同步逻辑的情况。选择哪种同步机制取决于具体的应用需求和性能考虑。
2、synchronized和reentrantlock区别?
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:
- 用法不同:synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
- 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
- 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
- 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
3、怎么理解可重入锁?
可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。
ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。
- 当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。
- 当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。
这种计数器的设计使得同一个线程可以多次获取同一个锁,而不会造成死锁或其他问题。每次获取锁时,计数器加1;每次释放锁时,计数器减1。只有当计数器减到0时,锁才会完全释放。
ReentrantLock通过这种计数器的方式,实现了可重入锁的机制。它允许同一个线程多次获取同一个锁,并且能够正确地处理锁的获取和释放,避免了死锁和其他并发问题。
4、synchronized 支持重入吗?如何实现的?
synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。
synchronized底层是利用计算机系统mutex Lock实现的。每一个可重入锁都会关联一个线程ID和一个锁状态status。
当一个线程请求方法时,会去检查锁状态。
- 如果锁状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID替换成自己的线程ID。
- 如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,如果是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待。
在释放锁时,
- 如果是可重入锁的,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。
- 如果非可重入锁的,线程退出方法,直接就会释放该锁。
5、syncronized锁升级的过程讲一下
具体的锁升级的过程是:无锁->偏向锁->轻量级锁->重量级锁。
- 无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM启动之后的多少秒之后才能开启,这个可以通过JVM参数进行设置,同时是否开启偏向锁也可以通过JVM参数设置。
- 偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
- 轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
- 重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。
了解完 4 种锁状态之后,我们就可以整体的来看一下锁升级的过程了。 线程A进入 synchronized 开始抢锁,JVM 会判断当前是否是偏向锁的状态,如果是就会根据 Mark Word 中存储的线程 ID 来判断,当前线程A是否就是持有偏向锁的线程。如果是,则忽略 check,线程A直接执行临界区内的代码。
但如果 Mark Word 里的线程不是线程 A,就会通过自旋尝试获取锁,如果获取到了,就将 Mark Word 中的线程 ID 改为自己的;如果竞争失败,就会立马撤销偏向锁,膨胀为轻量级锁。
后续的竞争线程都会通过自旋来尝试获取锁,如果自旋成功那么锁的状态仍然是轻量级锁。然而如果竞争失败,锁会膨胀为重量级锁,后续等待的竞争的线程都会被阻塞。
6、JVM对Synchornized的优化?
synchronized 核心优化方案主要包含以下 4 个:
- 锁膨胀:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫做锁膨胀也叫做锁升级。JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了 synchronized 的性能。
- 锁消除:指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
- 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
- 自适应自旋锁:指通过自身循环,尝试获取锁的一种方式,优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销。
7、介绍一下AQS
AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。
AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
主要原理图如下:
AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。
可以看到:Sync是AQS的实现。 AQS主要完成的任务:
- 同步状态(比如说计数器)的原子性管理;
- 线程的阻塞和解除阻塞;
- 队列的管理。
AQS原理
AQS最核心的就是三大部分:
- 状态:state;
- 控制线程抢锁和配合的FIFO队列(双向链表);
- 期望协作工具类去实现的获取/释放等重要方法(重写)。
状态state
- 这里state的具体含义,会根据具体实现类的不同而不同:比如在Semapore里,他表示剩余许可证的数量;在CountDownLatch里,它表示还需要倒数的数量;在ReentrantLock中,state用来表示“锁”的占有情况,包括可重入计数,当state的值为0的时候,标识该Lock不被任何线程所占有。
- state是volatile修饰的,并被并发修改,所以修改state的方法都需要保证线程安全,比如getState、setState以及compareAndSetState操作来读取和更新这个状态。这些方法都依赖于unsafe类。
FIFO队列
- 这个队列用来存放“等待的线程,AQS就是“排队管理器”,当多个线程争用同一把锁时,必须有排队机制将那些没能拿到锁的线程串在一起。当锁释放时,锁管理器就会挑选一个合适的线程来占有这个刚刚释放的锁。
- AQS会维护一个等待的线程队列,把线程都放到这个队列里,这个队列是双向链表形式。
实现获取/释放等方法
- 这里的获取和释放方法,是利用AQS的协作工具类里最重要的方法,是由协作类自己去实现的,并且含义各不相同;
- 获取方法:获取操作会以来state变量,经常会阻塞(比如获取不到锁的时候)。在Semaphore中,获取就是acquire方法,作用是获取一个许可证; 而在CountDownLatch里面,获取就是await方法,作用是等待,直到倒数结束;
- 释放方法:在Semaphore中,释放就是release方法,作用是释放一个许可证; 在CountDownLatch里面,获取就是countDown方法,作用是将倒数的数减一;
- 需要每个实现类重写tryAcquire和tryRelease等方法。
8、Threadlocal作用,原理,具体里面存的key value是啥,会有什么问题,如何解决?
ThreadLocal是Java中用于解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。
从内存结构图,我们可以看到:
- Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。
- ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值。
ThreadLocal的作用
- 线程隔离:
ThreadLocal为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题。 - 降低耦合度:在同一个线程内的多个函数或组件之间,使用
ThreadLocal可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。 - 性能优势:由于
ThreadLocal避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。
ThreadLocal的原理
ThreadLocal的实现依赖于Thread类中的一个ThreadLocalMap字段,这是一个存储ThreadLocal变量本身和对应值的映射。每个线程都有自己的ThreadLocalMap实例,用于存储该线程所持有的所有ThreadLocal变量的值。
当你创建一个ThreadLocal变量时,它实际上就是一个ThreadLocal对象的实例。每个ThreadLocal对象都可以存储任意类型的值,这个值对每个线程来说是独立的。
- 当调用
ThreadLocal的get()方法时,ThreadLocal会检查当前线程的ThreadLocalMap中是否有与之关联的值。 - 如果有,返回该值;
- 如果没有,会调用
initialValue()方法(如果重写了的话)来初始化该值,然后将其放入ThreadLocalMap中并返回。 - 当调用
set()方法时,ThreadLocal会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap中存储一个键值对,键是ThreadLocal对象自身,值是传入的值。 - 当调用
remove()方法时,会从当前线程的ThreadLocalMap中移除与该ThreadLocal对象关联的条目。
可能存在的问题
当一个线程结束时,其ThreadLocalMap也会随之销毁,但是ThreadLocal对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。
因此,在使用ThreadLocal时需要注意,如果不显式调用remove()方法,或者线程结束时未正确清理ThreadLocal变量,可能会导致内存泄漏,因为ThreadLocalMap会持续持有ThreadLocal变量的引用,即使这些变量不再被其他地方引用。
因此,实际应用中需要在使用完ThreadLocal变量后调用remove()方法释放资源。
9、悲观锁和乐观锁的区别?
- 乐观锁: 就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总 是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。
- 悲观锁: 还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总 是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像 synchronized,不管三七二十一,直接上了锁就操作资源了。
10、CAS 有什么缺点?
CAS的缺点主要有3点:
- ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。Java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。
- 循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。
- 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。
二、代码随想录-Java新特性,Spring面试题
1、Lambda表达式
2、Spring框架体系
3、AOP
4、IOC和DI
5、Bean实例化过程
6、Bean的作用域
三、代码随想录-MySQL面试题
1、内外连接
内连接(INNER JOIN)和外连接(OUTER JOIN)是 SQL 中两种常见的表连接方式,主要区别在于如何处理不匹配的记录:
1. 内连接(INNER JOIN)
- 核心逻辑:仅返回两个表中满足连接条件的匹配行。
- 特点:
- 如果某一行在其中一个表中没有匹配项,则不会被包含在结果中。
- 语法:
SELECT ... FROM TableA INNER JOIN TableB ON Condition;
2. 外连接(OUTER JOIN)
外连接分为三种类型,核心逻辑是包含不匹配的记录,并用 NULL 填充缺失值:
(a) 左外连接(LEFT OUTER JOIN)
-
核心逻辑:保留左表(
TableA)的所有记录,右表(TableB)无匹配时补NULL。 -
语法:
SELECT ... FROM TableA LEFT JOIN TableB ON Condition; -
结果:所有学生都会被显示(包括未选课的),未选课学生的
Course列为NULL。
(b) 右外连接(RIGHT OUTER JOIN)
-
核心逻辑:保留右表(
TableB)的所有记录,左表无匹配时补NULL。 -
语法:
SELECT ... FROM TableA RIGHT JOIN TableB ON Condition; -
结果:所有课程都会被显示(包括未被学生选修的),未选课的课程对应的学生列为
NULL。
(c) 全外连接(FULL OUTER JOIN)
-
核心逻辑:保留左右表的所有记录,无匹配时两边均补
NULL。 -
语法:
SELECT ... FROM TableA FULL OUTER JOIN TableB ON Condition; -
结果:所有学生和所有课程都会被显示,未匹配的部分用
NULL补齐。
对比总结
| 连接类型 | 包含的记录 | 适用场景 |
|---|---|---|
| INNER JOIN | 仅匹配的行 | 需要精确匹配数据时(如订单与商品) |
| LEFT OUTER JOIN | 左表全部 + 右表匹配部分 | 以左表为主,查询右表是否存在关联数据 |
| RIGHT OUTER JOIN | 右表全部 + 左表匹配部分 | 以右表为主,查询左表是否存在关联数据 |
| FULL OUTER JOIN | 左右表全部记录 | 合并两个完整数据集(如合并两个部门数据) |
2、MySQL架构
3、主键查询
4、最左匹配
四、面试指北-Redis面试题
1、Redis缓存更新策略
1.1 旁路缓存模式
1.2 读写穿透
1.3 异步缓存写入
2、哨兵机制
2.1 什么是哨兵
2.2 哨兵作用
2.3 如何选出新的master,leader
2.4 哨兵可以防止脑裂吗?
Redis Sentinel(哨兵)可以预防和处理“脑裂”(Split Brain)现象,但其效果依赖于合理配置和场景条件。以下是具体分析:
什么是脑裂?
脑裂是指在分布式系统中,由于网络故障或节点宕机,导致同一集群中的多个节点同时认为自己是合法的主节点(Master),从而引发数据不一致和服务冲突的现象。
在Redis中,若主节点与从节点因网络断开而失去联系,部分从节点可能误判主节点失效并自行晋升为新主节点,导致原主节点恢复后出现双主共存的问题。
哨兵如何预防脑裂?
-
心跳检测与故障判定
- 哨兵会定期向主节点发送
PING命令检测其状态。若主节点连续多次未响应(由down-after-milliseconds参数定义),哨兵会将其标记为“失效”。 - 若主节点实际未失效(如短暂网络抖动),但哨兵误判其失效,仍会触发故障转移流程,避免从节点自行升级导致的脑裂。
- 哨兵会定期向主节点发送
-
自动故障转移(Failover)
- 当哨兵判定主节点失效后,会通过选举算法(基于优先级和ID)选择一个从节点作为新主节点,并通知其他从节点重新同步。
- 新主节点接管后,原主节点恢复时会自动降级为从节点,避免双主冲突。
-
配置优化降低误判风险
- 合理设置
sentinel monitor、down-after-milliseconds和failover-timeout参数,确保哨兵对主节点状态的判断准确。 - 使用多哨兵实例(至少3个)组成多数派机制,只有当超过半数哨兵同意主节点失效时才会触发故障转移,显著降低误判概率。
- 合理设置
局限性:哨兵无法完全消除脑裂
某些极端情况下仍可能出现脑裂,例如:
- 网络分区:哨兵与主节点处于不同分区,导致哨兵误判主节点失效并触发故障转移,而主节点实际仍在运行。此时新旧主节点可能共存。
- 硬件故障:若哨兵自身故障或配置不当(如未启用多数派投票),可能导致错误决策。
最佳实践
- 部署至少3个哨兵节点,利用多数派机制提高容错性。
- 合理配置超时参数(如
down-after-milliseconds设为2秒以上),避免因短暂网络延迟误判。 - 使用物理隔离的网络环境,减少网络分区风险。
- 监控与日志:实时跟踪哨兵日志和集群状态,及时发现异常。