我们先来学习一下java当中一些前辈对锁是怎么优化的,然后通过前面的学习再来结合我们自已的业务中分享一些锁优化的经验
文章目录
- 1、什么是可重入锁?
- 2、ReentrantLock和Synchroized有什么区别?
- 3、为什么要用可重入锁?
- 4、可重入锁是怎么实现的或者说ReentrantLock是怎么实现可重入锁的? -- 重点
- 5、那CAS又是什么呢?
- 6、CAS缺点?
- 7、公平锁?
- 8、非公平锁?
- 9、那什么又是线程饥饿呢?
- 10、那线程饥饿会有什么影响呢?
- 11、分布式锁是如何实现可重入锁的,或者你怎么设计一个分布式锁的可重入锁?
- 1、volatile是什么解释一下?
- 2、那为什么普通变量修改对其他线程不是立即可见的?
- 3、volatile是怎么做到对其他线程及时可见的?
- 4、那是怎么发现数据是否是失效了呢?
- 5、为什么volatile不能保证原子性?
一、可重入锁
1、什么是可重入锁?
可重入锁也称递归锁,指的是同一线程 外层函数获得锁之后,内层递归函数任然有获取该锁的代码,但不受影响,对同一条线程来说是可重入的。
ReentrantLock和Synchroized都是可重入锁
2、ReentrantLock和Synchroized有什么区别?
- 用法不同:synchroized可用来修饰普通方法、静态方法和代码块,而ReentrantLock只能用于代码块上。ReentrantLock在使用前需要创建ReentrantLock对象,然后使用lock方法进行加锁,使用完之后使用unlock方法释放锁。
- 获取锁和释放锁的方式不同:synchroized会自动加锁和释放锁,当进入synchroized修饰的代码块时会自动加锁,当离开synchroized的代码块时会自动释放锁。而ReentrantLock需要手动创建和释放锁。
- 锁类型不同:synchroized属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。默认情况下 ReentrantLock 为非公平锁。
- 响应中断不同:ReentrantLock 可以使用 lockInterruptibly 获取锁并响应中断指令,而 synchronized 不能响应中断,也就是如果发生了死锁,使用 synchronized 会一直等待下去,而使用 ReentrantLock 可以响应中断并释放锁,从而解决死锁的问题。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
3、为什么要用可重入锁?
因为如果一个线程对一个函数进行了加锁,然后执行另一个函数的时候又掉了第一个函数,这个时候锁没有释放,是同一个线程的锁,这样就造成了自己等待自己释放锁,造成了死锁。
4、可重入锁是怎么实现的或者说ReentrantLock是怎么实现可重入锁的? – 重点
- 线程调用加锁时:锁的状态为0表示可以加锁,使用CAS将锁的状态设置成1(使用CAS是因为是个原子操作),进行锁的争抢,记录当前持有锁的线程,锁重入时,判断当前线程是否为可重入锁的线程(也就是是否等于上一个加锁的线程)如果是,状态自增+1。
- 解锁时:状态-1,减到0时,释放锁,唤醒阻塞队列的线程,进行CAS锁争抢。
加锁源码:
final boolean tryLock() { Thread current = Thread.currentThread(); // 状态为0表示可获得锁 int c = getState(); if (c == 0) { // CAS操作,锁争抢,只有一个线程能成功加锁 if (compareAndSetState(0, 1)) { // 记录当前持有锁的线程 setExclusiveOwnerThread(current); return true; } } else if (getExclusiveOwnerThread() == current) { // 锁重入啦:持有锁的线程 == 当前线程 // 状态自增 if (++c < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(c); return true; } return false; }
释放锁源码:
protected final boolean tryRelease(int releases) { // 这里releases == 1 // 状态自减1 int c = getState() - releases; if (getExclusiveOwnerThread() != Thread.currentThread()) throw new IllegalMonitorStateException(); // 减到状态为0,说明锁释放了 boolean free = (c == 0); if (free) setExclusiveOwnerThread(null); setState(c); // 如果返回true,会唤醒阻塞队列的线程 return free; }
5、那CAS又是什么呢?
CAS(Compare-and-Swap)比较并交换
CAS指令需要三个操作数,分别是内存位置(在java中可以简单的理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则他就比执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
AtomicInteger是一个原子类,这一切都要归功于incrementAndGet()方法的原子性
public final int incrementAndGet() { // this是当前对象,valueOffset是当前对象的内存地址 // unsafe的类几乎所有的方法都是native的,直接调用底层资源执行相应的任务,这个放法会拿到当前对象,和当前对象的地址然后+1 return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }
6、CAS缺点?
首先我们可以看到getAndAddInt执行时,有个do while方法如果CAS失败他就会一直尝试,如果CAS长时间不成功,可能会给cpu造成很大的开销。
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
7、公平锁?
所谓公平,就是排队的时候讲究先来后到,先来的先获取到锁,优点是不会线程饥饿,缺点是系统线程上下文切换次数很高,从而降低吞吐量
8、非公平锁?
不要求先来先获取锁,阻塞队列存在等待锁,新来的线程也可以立马获取到锁,很大程度上解决上下文的切换,优点是吞吐量高,缺点是可能存在 线程饥饿
默认是非公平锁
public ReentrantLock() { sync = new NonfairSync(); }
9、那什么又是线程饥饿呢?
比如线程A现在正在阻塞队列里,然后如果是非公平锁,那很有可能锁一直被后面的线程争抢到,导致线程A一直拿不到锁,我们就称线程A为饥饿。
10、那线程饥饿会有什么影响呢?
线程的任务无法继续执行有可能出现超时等情况。
11、分布式锁是如何实现可重入锁的,或者你怎么设计一个分布式锁的可重入锁?
我们知道可重入锁主要是通过CAS计数器来完成,他最大的特性就是计数,计算加锁的次数,所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁的次数。
分布式锁的实现方案有两种:
- 基于ThreadLocal实现方案
- 基于RedisHash实现方案
基于ThreadLocal实现方案
Java 中 ThreadLocal可以使每个线程拥有自己的实例副本,我们可以利用这个特性对线程重入次数进行计数,下面我们定义一个ThreadLocal
的全局变量 LOCKS
,内存存储 Map
实例变量
private static ThreadLocal<Map<String, Integer>> LOCKS = ThreadLocal.withInitial(HashMap::new);
每个线程都可以通过 ThreadLocal
获取自己的 Map
实例,Map
中 key
存储锁的名称,而 value
存储锁的重入次数。
/** * 可重入锁 * * @param lockName 锁名字,代表需要争临界资源 * @param request 唯一标识,可以使用 uuid,根据该值判断是否可以重入 * @param leaseTime 锁释放时间 * @param unit 锁释放时间单位 * @return */ public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) { Map<String, Integer> counts = LOCKS.get(); if (counts.containsKey(lockName)) { counts.put(lockName, counts.get(lockName) + 1); return true; } else { if (redisLock.tryLock(lockName, request, leaseTime, unit)) { counts.put(lockName, 1); return true; } } return false; }
加锁方法首先判断当前线程是否已经已经拥有该锁,若已经拥有,直接对锁的重入次数加 1。
若还没拥有该锁,则尝试去 Redis 加锁,加锁成功之后,再对重入次数加 1 。
释放锁的代码:
/** * 解锁需要判断不同线程池 * * @param lockName * @param request */ public void unlock(String lockName, String request) { Map<String, Integer> counts = LOCKS.get(); if (counts.getOrDefault(lockName, 0) <= 1) { counts.remove(lockName); Boolean result = redisLock.unlock(lockName, request); if (!result) { throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: " + request); } } else { counts.put(lockName, counts.get(lockName) - 1); } }
释放锁的时首先判断重入次数,若大于 1,则代表该锁是被该线程拥有,所以直接将锁重入次数减 1 即可。
若当前可重入次数小于等于 1,首先移除 Map
中锁对应的 key,然后再到 Redis 释放锁。
二、自旋锁
1、什么是自旋锁?
如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程‘’稍微等会‘’,但是不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
2、为什么要引入自旋锁或者说自旋锁的优点是什么?
如果没有自旋锁,那么后面的线程获取不到锁就会阻塞,然后就需要就需要进入到内核态去执行线程调度,执行上下文切换,并保留线程和恢复现场,这些操作给虚拟机的并发性能带来了很大的影响。
3、那你知道自旋锁的缺点吗?
自旋锁虽然避免了线程切换的开销,但是他需要占用处理器的时间,所以如果锁占用的时间很短,自旋等待的效果就会非常好,反之如果锁占用的时间很长,那么自旋的线程就会白白浪费cpu的资源。
因此自旋锁等待的时间必须要有限度,如果自旋超过了限定的次数还没有成功获得锁,那就会使用传统的方式将线程挂起。
自旋的默认次数是10次,用户也可以是用参数 -XX:PreBlockSpin来自行更改
4、如何开启自旋锁?
自旋锁在jdk1.4.2中引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启
在jdk7中已经改为默认开启
5、自适应自旋
自适应以为着自旋的时间不在是固定的了,而是由前一次在同一个锁的自旋时间及锁的拥有者的状态来决定的。
如果对于某个锁,自旋很少成功获得锁,那在以后要获取这个锁时将有可能直接省略到自旋的过程。
6、自旋锁的底层是怎么实现的?
使用CAS实现,CAS是什么详见上面CAS讲解
三、偏向锁
1、什么是偏向锁?
偏向锁的目的是消除数据在无竞争情况下的同步原语(计算机科学中的一种同步机制,用来指示等待中的进程特定条件已经变为真),进一步提高程序的运行性能。
偏向锁的 偏 就是 偏心 的 偏 ,他的意思是这个锁会偏向于第一个获得他的线程,如果在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
2、偏向锁是如何实现的?
当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为 01 、把偏向模式设置为 1 ,表示进入偏向模式,并使用CAS操作把获取到这个锁的线程ID记录在对象的 Mark Word 中。如果CAS操作成功,持有偏向锁的现场以后每次进入这个锁相关的同步块时,虚拟机都可以不在进行任何同步操作(例如加锁、解锁及对Mark Word的更行操作)。
一旦出现另一个线程去尝试获取锁的情况,偏向模式会马上宣告结束。
3、如何权衡偏向锁的利弊?
如果程序中大多数锁总是被多个不同的线程访问,那偏向模式就是多余的,偏向需要每次去操作对象头。
四、轻量级锁
1、什么是轻量级锁?
轻量级锁的设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
2、要理解轻量级锁先来了解下HotSpot虚拟机对象的内存布局
HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄等。这部分数据的长度在32位和64位的虚拟机中分别会占用32个活64个比特,官方称他为 “Mark Word” 。这部分是实现轻量级锁和偏向锁的关键。
另一个部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分存储数组长度。
3、介绍一下轻量级锁的工作过程是怎样的?
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前Mark Work的拷贝。
然后,虚拟机将使用CAS操作尝试把对象的Mark Work更新为指向Lock Record的指针,如果这个更新成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Work的锁标志位将转变为 00 ,表示此对象处于轻量级锁定转态。
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机首先会检查对象的Mark Work是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块进行执行就行了,否则就说明这个锁对象已经被其他线程抢占了,如果出现两条以上的线程竞争同一锁的情况,那轻量级锁就不在有效,必须膨胀为重量级锁,锁标志的状态值变为 10
4、轻量级锁的利弊
轻量级锁能提升程序同步性能的依据是 对于绝对部分的锁,在整个同步周期内都是不存在竞争的这一经验法则,如果没有竞争,轻量级锁变通过CAS成功避免了使用互斥量的开销;但如果存在锁竞争,除了互斥量本身的开销外,还额外发生了CAS的开销,因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
五、ThreadLocal
1、ThreadLocal是什么?
ThreadLocal即线程本地变量,如果你创建了一个ThreadLocal的变量,那么访问这个变量的每一个线程都会有这个变量的一份本地拷贝,多个线程操作这个变量的时候,实际上是在操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了并发场景下的线程安全问题。
2、为什么要使用ThreadLocal?
并发场景下,会存在多个线程同时修改一个共享变量的场景,这就有可能出现线程安全的问题。
我们虽然可以通过加锁的方式来避免线程安全,但是加锁会导致系统变慢,阻塞其他线程,导致切换到cpu内核调度线程(java使用的是cpu内核线程),还会造成上下文切换。
3、ThreadLocal的实现原理?
使用ThreadLocal类来访问共享变量时,会在每个线程的本地都保存一份共享变量的副本。
每一个Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的k-v值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程k-v值对中找到本地对应的线程变量。
看下源码:
public class Thread implements Runnable { //ThreadLocal.ThreadLocalMap是Thread的属性 ThreadLocal.ThreadLocalMap threadLocals = null; }
ThreadLocalMap的关键源码:
static class ThreadLocalMap { static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } //Entry数组 private Entry[] table; // ThreadLocalMap的构造器,ThreadLocal作为key ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); } }
ThreadLocal中的set() 方法源码:
public void set(T value) { Thread t = Thread.currentThread(); //获取当前线程t ThreadLocalMap map = getMap(t); //根据当前线程获取到ThreadLocalMap if (map != null) //如果获取的ThreadLocalMap对象不为空 map.set(this, value); //K,V设置到ThreadLocalMap中 else createMap(t, value); //创建一个新的ThreadLocalMap } ThreadLocalMap getMap(Thread t) { return t.threadLocals; //返回Thread对象的ThreadLocalMap属性 } void createMap(Thread t, T firstValue) { //调用ThreadLocalMap的构造函数 t.threadLocals = new ThreadLocalMap(this, firstValue); this表示当前类ThreadLocal }
ThreadLocal中的get() 方法源码:
public T get() { Thread t = Thread.currentThread();//获取当前线程t ThreadLocalMap map = getMap(t);//根据当前线程获取到ThreadLocalMap if (map != null) { //如果获取的ThreadLocalMap对象不为空 //由this(即ThreadLoca对象)得到对应的Value,即ThreadLocal的泛型值 ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); //初始化threadLocals成员变量的值 } private T setInitialValue() { T value = initialValue(); //初始化value的值 Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); //以当前线程为key,获取threadLocals成员变量,它是一个ThreadLocalMap if (map != null) map.set(this, value); //K,V设置到ThreadLocalMap中 else createMap(t, value); //实例化threadLocals成员变量 return value; }
六、volatile
1、volatile是什么解释一下?
关键字volatile是java虚拟机提供的最轻量级的同步机制,被volatile关键字修饰的变量对所有线程是立即可见的,对volatile变量所有的写操作都都能立刻反映到其他线程之中。保证变量的及时可见性。
2、那为什么普通变量修改对其他线程不是立即可见的?
因为普通变量在的值在线程间传递时均需要通过主内存来完成,比如,线程A修改了一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写之后再对主内存进行读取,新变量的值才会对线程B可见。
3、volatile是怎么做到对其他线程及时可见的?
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作的数据并且回写到了主内存,其他已经读取的线程的变量副本就会失效,需要对数据操作就又要去主内存中读取了,说白了就是,一个线程改变了一个共享变量,会告诉其他线程,让他们的变量失效。
当cpu写数据的时候,如果发现操作的变量是共享变量,即在其他cpu中也存在该变量的副本,这个时候会发出信号通知其他cpu将该变量的缓存置为无效转态,因此当其他cpu需要读取这个变量的时候,发现自己缓存中缓存该变量的缓存行是无效的,那么他就会从主内存进行读写。这就是缓存一致性协议。
4、那是怎么发现数据是否是失效了呢?
嗅探
每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是否过期了,当处理器发现自己缓存行对应的地址被修改,就会将自己处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
5、为什么volatile不能保证原子性?
对于i=1这种赋值操作,由于其本身是原子操作,因此在多线程中不会出现不一致的问题,但是对于i++这种复和操作,即使用volatile修饰也不能保证原子性,可能会引发数据不一致的情况。我们可以是使用AtmoicInteger() 类来实现自增。
七、Synchronized
1、synchroized关键字是干什么的?
在java里最基本的互斥手段就是synchroized关键字,这是一种块状的同步语法,synchroized关键字经过javac反编译后会在同步块前后形成monitorenter和monitorexit这两个字节码指令,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
互斥同步是一种也是最主要的并发正确性保障手段,同步指的是在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用,而互斥是实现同步的一种手段。
2、synchroized是怎么实现的?
在执行monitorenter指令时,首先要去尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值+1,而在执行monitorexit指令的时候,会将锁的计数器的值-1,一旦计数器的值为0,即锁就被释放了。
3、synchroized锁有什么特点?
synchroized属于可重入锁,被synchoized修饰的代码块对同一条线程来说是可以重入的,这意味着同一条线程反复进入同步块也不会出现自己把自己锁死的情况。
被synchroized修饰的同步块在持有锁的线程执行完毕并释放锁前,会无条件的阻塞后面其他线程的进入。
从执行成本来看,持有锁是一个重量级的操作。
我们知道java的线程是映射到操作系统原生内核线程之上的,如果要阻塞或者唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免的陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多处理器的时间。
八、高并发下的锁的几种优化方案?
1、业务中常见的几种锁优化方案?
- 减少锁的持有时间:避免给整个方法加锁,
- 减小锁的粒度:将大对象,拆成小对象,大大增加并行度,降低锁竞争。如此一来偏向锁,轻量级锁成功率提高,一个简单的例子就是 jdk 内置的 ConcurrentHashMap 与 SynchronizedMap
- 使用读写分离替代独占锁:顾名思义,用 ReadWriteLock 将读写的锁分离开来,尤其在读多写少的场合,可以有效提升系统的并发能力。
- 读-读不互斥:读读之间不阻塞。
- 读-写互斥:读阻塞写,写也会阻塞读。
- 写-写互斥:写写阻塞。
- 锁分离:在读写锁的思想上做进一步的延伸,根据不同的功能拆分不同的锁,进行有效的锁分离。一个典型的示例便是 LinkedBlockingQueue,在它内部,take 和 put 操作本身是隔离的。有若干个元素的时候,一个在 queue 的头部操作,一个在 queue 的尾部操作,因此分别持有一把独立的锁。
- 锁粗话:通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短。
即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。
而凡事都有一个度,如果对同一个锁不停的进行请求同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
一个极端的例子如下,在一个循环中不停的请求同一个锁。
for(int i = 0; i < 1000; i++){ synchronized(lock){ } } // 优化后 synchronized(lock){ for(int i = 0;i < 1000; i++){ } }
- 使用TheadLocal:除了控制有限资源访问外,我们还可以增加资源来保证对象线程安全。
对于一些线程不安全的对象,例如 SimpleDateFormat,与其加锁让 100 个线程来竞争获取。
不如准备 100 个 SimpleDateFormat,每个线程各自为营,很快的完成 format 工作。 - 使用无锁操作,例如CAS,Atomic:与锁相比,使用 CAS 操作,由于其非阻塞性,因此不存在死锁问题,同时线程之间的相互影响,也远小于锁的方式。使用无锁的方案,可以减少锁竞争以及线程频繁调度带来的系统开销。
2、讲一下ConcurrentHashMap的分段锁?
分段锁其实是一种锁的设计,并不是一种具体的锁,对与ConcurrentHashMap来说其并发的实现就是通过分段锁的形式来实现的高效的并发操作。
ConcurrentHashMap的分段锁称为Segment,他类似与HashMap的结构,即内部拥有一个Entry数组,数组中的每一个元素又是一个链表,同时又是一个ReentrantLock。
当需要put的时候,并不是对整个HashMap来加锁,而是先通过hashCode来知道他要放到哪个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是在一个分段中,就实现了真正的并发插入。
但是,值统计size的时候,可就是过去hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
分割线 ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶ ✶
最近真的太忙了😭,终于抽出时间更新了
整理了下最近面试比较容易问到的问题,我想大家肯定也是一遇到锁的问题就很头疼,现在我下血本都给大家总结了,都是在真实的面试经历中遇到的问题,绝对高频!!!
创作不易,点赞+评论+收藏吧👍