一.锁策略
1.悲观锁 vs 乐观锁
1.悲观锁:
- 悲观锁认为数据在并发访问时极有可能会发生冲突,所以在数据获取阶段就先加锁,只有获得锁的线程才能进行数据的读写操作。
- 在数据库层面,悲观锁可以通过
SELECT ... FOR UPDATE
、UPDATE
或DELETE
加上WHERE
子句锁定符合条件的数据行。 - 在事务结束后,数据库会自动释放锁。
- 优点:能有效避免脏读、不可重复读、幻读等问题,适合并发更新较为频繁且数据一致性要求高的场景。
- 缺点:过度依赖锁会导致并发性能下降,尤其是在锁竞争激烈时可能出现大量线程等待,造成资源浪费和响应延迟。
2.乐观锁:
- 乐观锁假定在大多数情况下,数据在并发访问时不会有冲突,只有在提交更新时才会检查是否发生了冲突。
- 实现方式通常是在表中增加一个版本号字段(version)或其他标识符,读取数据时不加锁,但在更新时除了原条件外还会检查版本号是否发生变化。
- 如在JPA或Hibernate中可通过@Version注解实现乐观锁,更新时如果版本号与之前读取时不一致,则表示在此期间已有其他事务修改了数据,此时事务回滚。
- 优点:无锁状态下读取速度快,提高了并发性能,减少了锁的竞争开销。
- 缺点:不适合并发更新特别频繁的场景,因为随着冲突概率的增加,会有更多的事务回滚;同时,频繁的版本号检测也会带来一定的性能损耗。
3.总结
总结来说,选择悲观锁还是乐观锁取决于具体的业务场景和并发程度,悲观锁适用于冲突概率较高、对数据一致性要求严格的场景,而乐观锁适用于冲突较少、追求高并发性能的场景。
2.重量级锁 vs 轻量级锁
1.重量级锁:
- 实现:依赖于操作系统的Mutex Lock(互斥锁)实现,因此状态转换涉及用户态和内核态的切换,开销较大。
- 优点:适用于线程竞争激烈且锁持有时间长的场景,可以有效避免CPU资源浪费。
- 缺点:线程阻塞和唤醒涉及系统调用,可能导致性能问题,尤其是在高并发场景下。
- 适用场景:当存在多个线程长时间竞争同一把锁时,使用重量级锁较为合适。
2.轻量级锁:
- 实现:在JDK1.6中引入,通过CAS操作尝试获取锁,避免了操作系统层面的开销。
- 优点:在没有多线程竞争或竞争不激烈的情况下,可以提高性能,因为避免了线程的阻塞和唤醒。
- 缺点:如果线程始终无法获得锁,长时间自旋会消耗CPU资源。
- 适用场景:适用于线程交替执行同步块,且同步块执行速度非常快的场景。
3.对比:
- 性能:轻量级锁在没有锁竞争的情况下性能更优,而重量级锁在高竞争环境下更合适。
- 资源消耗:轻量级锁避免了操作系统资源的消耗,而重量级锁则涉及系统调用。
- 锁升级:JVM中的锁机制设计为一个升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,目的是为了在不同的竞争情况下提供合适的同步机制。
3.自旋锁 vs 挂起等待锁
1.自旋锁:
- 自旋锁是一种积极的锁策略,当线程试图获取一个已被其他线程持有的锁时,不会立刻放弃处理器并进入睡眠状态,而是会不断循环(自旋)检查锁是否已被释放。
- 这种做法的优点在于,如果锁持有者很快释放了锁,那么自旋线程能够立即获得锁,避免了线程上下文切换的开销,提升了响应速度。
- 缺点在于,如果锁被长时间占用,自旋线程将持续占用CPU资源,导致性能浪费。因此,自旋锁适用于锁持有时间短且线程上下文切换开销较大的场景,如多核处理器环境下的短期同步。
2.挂起等待锁:
- 挂起等待锁是一种消极的锁策略,当线程无法获取锁时,会选择主动让出CPU并进入等待队列(通常是被操作系统挂起),直到锁被释放并收到通知后才重新参与CPU调度。
- 这样做的优点是避免了无谓的CPU消耗,当锁被长时间占用时,自旋的线程会停止消耗CPU资源。
- 缺点是线程从等待状态恢复到运行状态需要经历上下文切换,这本身有一定的开销,尤其是频繁的上下文切换可能导致性能下降。
- 挂起等待锁适用于锁持有时间不确定或可能较长的情况,因为它可以节省CPU资源,防止无效的循环等待。
3.总结
在Java中,这两种锁策略的表现形式并不是直接作为API提供,而是JVM在实现锁机制时采取的策略,例如在自旋锁策略失效后,JVM会升级锁为重量级锁,这时线程就会进入挂起等待状态。在现代Java虚拟机中,锁机制往往是复合的,包括偏向锁、轻量级锁、重量级锁等不同层次的设计,其中轻量级锁在一定程度上借鉴了自旋锁的思想
4.可重入锁 vs 不可重入锁
1.可重入锁:
- 可重入锁是指同一个线程可以重复获取同一把锁,而不会发生死锁。这意味着如果一个线程已经持有了锁,当它再次请求同一把锁时,仍然可以得到许可并继续执行,而不是被阻塞。
- 在Java中,
java.util.concurrent.locks.ReentrantLock
是一种典型的可重入锁实现,同时也支持公平锁和非公平锁的选择。 - 可重入锁内部会维护一个计数器,记录当前线程获取锁的次数,每获取一次锁计数器加1,每释放一次锁计数器减1,直到计数器为0时,锁真正释放。
- 可重入锁的优势在于支持递归调用和嵌套同步,简化了编程模型,并降低了死锁风险。
2.不可重入锁:
- 不可重入锁一旦被一个线程获取后,该线程如果再次请求该锁,就会被阻塞,无法获取,即一个线程不能再次获取自己已经持有的锁。
- 不可重入锁不允许锁的持有线程对自己已经持有的锁进行二次锁定,因此在递归调用或者嵌套同步场景下容易产生死锁。
3.总结
总结起来,可重入锁相较于不可重入锁在处理多层同步和递归调用时具有更高的灵活性和安全性,不易引发死锁问题,但在某些特定场景下,不可重入锁也可能因其简洁性和性能特点而被选用。并且Java中的synchronized 就是可重入锁.
5.公平锁和非公平锁
1.公平锁 (Fair Lock):
- 公平锁是一种遵循先进先出(FIFO)原则的锁机制,当多个线程请求同一个锁时,会严格按照线程请求锁的顺序来决定哪个线程可以获得锁。也就是说,已经在等待队列中的线程会优先获得锁,不存在后来的线程抢占先来线程锁的情况。
- 公平锁的优势在于能够避免线程饥饿现象的发生,确保每个等待锁的线程最终都能够获得锁的机会,提高了线程调度的公平性。
- 缺点是由于每次锁释放后都需要唤醒队列中等待最久的线程,这会增加线程上下文切换的开销,特别是在高并发场景下可能会降低系统的整体吞吐量。
2.非公平锁 (Non-Fair Lock):
- 非公平锁则不保证线程获取锁的顺序与请求锁的顺序一致,当锁可用时,任何等待的线程都有可能获取到锁,即使是刚刚开始等待的线程也可能优于已经在等待队列中的线程。
- 非公平锁在没有等待队列或者锁刚释放时,允许线程“插队”,直接尝试获取锁,这种策略在某些场景下可以提高系统的整体性能,因为减少了线程上下文切换的次数。
- 缺点是可能导致部分线程长期得不到锁,形成线程饥饿,尤其是在连续的锁请求和释放中,总是有新来的线程抢先获得锁的情况下,这种情况尤为明显。
3.总结
这里的公平是相对的,你也可以说,先来后到是公平的,或者是每个线程都有同等机会获取到锁是公平的,但是在Java中是这样定义公平的,在Java中,java.util.concurrent.locks.ReentrantLock
类可以指定是否启用公平锁,通过构造函数传入true
参数即可创建公平锁实例,否则默认为非公平锁Java的并发包中,公平锁强调的是线程获取锁的有序性和公平性,而非公平锁则倾向于提高系统的并发性能,但可能会使得部分线程在竞争锁时面临不公平待遇。开发者可以根据实际应用场景的需求来选择使用哪种类型的锁.
6.互斥锁 vs 读写锁
1.互斥锁:
- 互斥锁是一种最基本的同步原语,它确保在同一时间只有一个线程能够访问被锁定的资源或临界区。
- 当一个线程获取互斥锁后,其他所有试图获取相同锁的线程将被阻塞,直到拥有锁的线程释放锁为止。
- 在Java中,
synchronized
关键字以及java.util.concurrent.locks.ReentrantLock
类都可以实现互斥锁的功能。 - 互斥锁主要用于保护那些既需要进行读操作又需要进行写操作的共享资源,无论何种操作,都需要获取锁。
2.读写锁:
- 读写锁是一种比互斥锁更细粒度的锁,它区分读操作和写操作,允许多个线程同时进行读操作,但同一时刻只允许一个线程进行写操作。
- 当没有任何线程持有写锁时,多个读线程可以同时获取读锁并读取共享资源;一旦有线程请求写锁,读线程将被阻塞,直到写锁被释放。
- 在Java中,
java.util.concurrent.locks.ReentrantReadWriteLock
类是读写锁的典型实现。 - 读写锁适用于读操作远大于写操作的场景,可以大大提高并发读取数据的性能,减少锁竞争。
3.总结:
- 互斥锁在任何时刻仅允许一个线程访问资源,无论读写;
- 读写锁允许多个线程同时读取资源,但在进行写操作时,禁止其他读写线程介入,确保数据一致性;
- 使用读写锁可以显著提高读密集型场景下的并发性能,而互斥锁在所有情况下都能确保数据一致性,但在读多写少的情况下可能会导致性能瓶颈。
二.synchronized的实现原理
synchronized 是 悲观锁也是乐观锁,是重量锁,也是轻量级锁,重量级锁是自旋锁实现的,轻量级锁是挂起等待锁实现的,是可重入锁,是非公平锁,以及互斥锁.
现在我们就从synchronized的实现原理来分析为什么它具有多种特性的锁机制
1.锁升级(synchronized的自适应)
1.锁升级过程
2.偏向锁(面试常考)
在synchronized
中的锁升级机制中,偏向锁是一种针对无多线程竞争情况的优化。为了便于理解,我们可以用生活中的图书馆借书证办理过程来类比偏向锁的工作方式:
1.图书馆借书证办理的例子
- 新书证发放(偏向锁初始化): 假设你去图书馆办理借书证。在非繁忙时段,图书馆只有一个人在办理,工作人员会直接为你制作一张借书证,并将你的信息记录在证上,比如照片和姓名。这张借书证在之后的使用中会被认为“偏向”于你,因为你是第一个使用它的人。
- 借书(线程获得偏向锁): 当你再次来借书时,图书馆的系统会检查借书证,发现这张证已经被“偏向”于你,于是系统无需再次核对你的身份信息,直接允许你借书。这个过程非常快速,因为系统已经“认识”了你。
- 多人借书(锁竞争开始): 如果在某个时刻,另一个人也拿着这张借书证来借书,图书馆系统会意识到这张借书证不再“偏向”于单一的一个人,因此它需要采取更公平的方式来处理。此时,你的借书证会被“升级”,比如变成一张需要每次借书时都要核对信息的普通借书证。
- 借书证升级(偏向锁升级为轻量级锁或重量级锁): 根据图书馆的繁忙程度(即线程竞争的激烈程度),这张借书证可能只是简单地每次要求你出示身份证来确认身份(类似于轻量级锁),或者可能需要你排队等待,直到轮到你办理借书手续(类似于重量级锁)。
2.总结
- 初始无锁状态:当一个对象被创建时,它的锁对象(由对象头中的Mark Word表示)处于无锁状态。这意味着没有线程正在使用这个锁,也没有任何锁标记。
- 线程首次获取锁:当第一个线程尝试进入
synchronized
块时,如果之前没有线程获取过这个锁,JVM会给这个锁打上一个偏向标记,这个标记指向当前获取锁的线程。这个过程是非常快速的,因为实际上并没有进行真正的锁加锁操作,而只是在Mark Word中记录了线程的信息。 - 偏向锁:如果同一个线程再次尝试获取这个锁,JVM会检查Mark Word中的偏向标记。如果发现标记指向的是当前线程,那么JVM允许该线程直接获取锁,无需进行任何同步操作。这样就大大提高了线程获取锁的效率。
- 锁竞争出现:如果有另一个线程也尝试获取这个锁,JVM会检测到锁竞争。这时,偏向锁会被撤销,锁的状态会升级为轻量级锁。轻量级锁使用CAS(Compare-And-Swap)操作来尝试获取锁,如果失败,线程会进行自旋等待。
- 锁状态进一步升级:如果自旋等待仍然无法获取锁,或者有更多线程参与竞争,轻量级锁可能会进一步升级为重量级锁。重量级锁涉及到操作系统级别的锁机制,线程在获取锁失败后会被阻塞,直到锁被释放。
通过这个锁升级的过程,Java虚拟机在保证线程安全的同时,尽可能地优化了锁的性能,以适应不同的并发场景。偏向锁特别适合于那些在大部分时间里只有一个线程会访问的同步资源,这样可以避免不必要的锁开销,提高程序的执行效率。
注意:升级过程是单向的,该锁的升级过程是不可逆的也就是说,当锁升级成重量级锁时就不可能退化为轻量级锁了
2.锁消除(编译器优化策略)
锁消除是Java虚拟机(JVM)中的一个优化技术,它发生在即时编译(JIT)过程中。锁消除主要针对的是那些通过逃逸分析(Escape Analysis)确认为不会逃逸出方法的对象,即这些对象不会被其他线程所访问,因此可以被认为是线程安全的。
在多线程环境中,为了保证数据的一致性和线程安全,通常会使用同步代码块来避免并发问题。然而,在某些情况下,如果编译器能够证明某个共享资源的读写操作不存在数据竞争(即在并发执行时,不会有多个线程同时修改该资源),那么这些同步措施就是多余的。
锁消除的常见场景:
- 栈上分配:如果一个对象的生命周期被限制在单个方法内部,并且不会逃逸到其他线程,那么这个对象上的操作可以认为是线程安全的,因此可以消除对它的锁保护。
- 单线程代码块:如果代码块被证明只会由一个线程执行,那么该代码块内的锁可以被消除。
- 方法内联:在某些情况下,当JVM对代码进行内联操作时,如果内联导致某些代码路径的执行变得不可能,那么这些路径上的锁可以被消除。
- 不变性变量:如果一个变量在初始化后其值不再发生变化,那么该变量是线程安全的,因为它不会被任何线程修改。
锁消除的过程:
- 逃逸分析:JVM通过逃逸分析来确定对象的作用域,即对象是否会逃逸到其他线程。
- 确定锁的安全性:如果对象被证明不会逃逸,并且锁的保护范围仅限于这个对象,那么JVM可以认为这个锁是多余的。
- 编译器优化:在即时编译过程中,JVM的编译器会根据逃逸分析的结果,对代码进行优化,移除那些被认为是多余的锁。
锁消除的优点:
- 性能提升:消除不必要的锁操作可以减少同步开销,从而提高程序的执行效率。
- 减少资源消耗:减少锁的使用可以降低操作系统资源的消耗,如减少对互斥量的申请。
锁消除是JVM优化的一部分,它依赖于逃逸分析和编译器的优化能力。在某些情况下,锁消除可以显著提升程序性能,但也要注意,过度优化可能会导致难以发现的并发问题,因此需要谨慎使用。
3.锁粗化
锁粗化是Java虚拟机(JVM)为了减少不必要的锁获取和释放操作而进行的一种优化手段。在某些情况下,连续的多个对同一对象锁的加锁和解锁操作可能会导致性能损失,因为每次加解锁都会涉及到线程状态的切换、内存屏障的插入等开销。为了提高性能,JVM会对这些连续的琐碎加锁操作进行合并,使之成为一个范围更大的锁。
1for (int i = 0; i < array.length; i++) { 2 synchronized (this) { 3 // 对数组元素进行操作 4 } 5}
在这种情况下,如果JVM检测到循环体内部的同步代码块频繁执行且涉及的对象锁是同一个,那么它可能会将锁粗化为:
1synchronized (this) { 2 for (int i = 0; i < array.length; i++) { 3 // 对数组元素进行操作 4 } 5}
通过锁粗化,可以减少系统因频繁申请和释放锁带来的性能消耗,同时也降低了死锁的可能性。但是,锁粗化的优化行为并不是总能被触发,需要满足一定的条件,例如编译器或JVM能够识别到一系列相邻的锁操作,并且这些操作都在一个很短的时间窗口内完成。
感谢你的阅读,祝你一天愉快