常见的锁策略
锁策略在并发编程中起着重要的作用,主要用于控制对共享资源的访问。以下是对不同锁策略的详细描述:
1. 乐观锁 vs 悲观锁
悲观锁:假设会发生冲突,因此在访问共享资源之前会主动加锁。在操作过程中,只有持有锁的线程才能对资源进行修改,适用于高冲突的场景。常见实现方式有数据库的行锁和表锁。
乐观锁:假设冲突发生的概率低,允许多个线程同时对资源进行操作。在提交修改时,再检查是否发生了冲突(如版本号检查)。适用于冲突较少的场景,能提高系统的并发性能。
2. 读写锁
读写锁:将锁分为读锁和写锁。多个线程可以同时持有读锁,但写锁是独占的,即在写操作时,其他读或写操作都无法进行。适用于读多写少的场景,可以提高并发性能。3. 重量级锁 vs 轻量级锁
重量级锁:也称为互斥锁,通常用于高竞争的场景。因需要操作系统层面的资源调度,开销较大。在竞争激烈时,可能会导致线程的上下文切换和竞争。
轻量级锁:设计用于在程序内部进行锁控制,当锁处于非竞争状态时,可以提高性能,减少上下文切换的开销。当锁竞争发生时,会从轻量级锁切换到重量级锁。
4. 自旋锁 vs 挂起等待锁
自旋锁:线程在尝试获取锁时会循环检查锁的状态,处于忙等待中,这适用于锁持有时间短的情况。优点是减少了上下文切换的开销,但可能导致CPU资源的浪费。
挂起等待锁:当线程无法获取锁时,会被挂起,直到锁可用为止。这避免了忙等待,适用于锁保持时间较长的情况。
5. 公平锁 vs 非公平锁
公平锁:按照申请锁的顺序来分配锁资源,确保先到的线程优先获取锁,避免饥饿现象。适合对公平性要求高的场景。
非公平锁:不按照顺序获取锁,后来的请求有可能会抢先获取锁,能提高性能,但可能导致某些线程长时间得不到锁(饥饿)。
6. 可重入锁 vs 不可重入锁
可重入锁:同一线程可以多次获取锁,而不会造成死锁。这对于递归调用或者需要多次获取同一资源的场景非常有用。
不可重入锁:如果同一线程已经持有锁,再次请求将导致死锁,适用于简单的锁场景,但需要小心使用。
这些锁策略各有优缺点,需要根据具体的应用场景和性能需求做出合理的选择,以确保系统的高效和稳定。
CAS(Compare And Swap)
CAS(Compare And Swap)是一种并发控制机制,广泛用于多线程编程中以实现锁-free 的数据结构和算法。CAS 操作需要三个参数:内存位置(变量的地址)、期望的旧值和要更新的新值。其工作原理如下:
检查内存中指定位置的值是否等于期望的旧值。如果相等,则将该位置的值更新为新值。如果不相等,操作失败,返回当前内存中的值。通过这种方式,CAS 能够确保在没有锁的情况下进行线程安全的更新,适合于高并发的场景。
在 Java 中的 CAS 实现
Java 中的 java.util.concurrent.atomic
包提供了一系列原子类,利用 CAS 实现无锁的并发控制。这些类包括但不限于:
AtomicInteger:提供了一些原子操作的方法,例如 incrementAndGet()
和 decrementAndGet()
,都使用了 CAS 来安全地更新值。
AtomicInteger atomicInt = new AtomicInteger(0);int updatedValue = atomicInt.incrementAndGet(); // 原子性地将值加1。
AtomicLong:与 AtomicInteger
类似,但用于处理长整型。AtomicReference:可以用于原子性地更新引用类型的对象。 AtomicReference<MyObject> atomicRef = new AtomicReference<>(new MyObject());MyObject newObject = new MyObject();atomicRef.compareAndSet(oldObject, newObject); // 只有当前值等于 oldObject 时才更新为 newObject
AtomicBoolean:提供对布尔值的原子性操作。
这些原子类实现了高效的并发操作,依赖于底层硬件提供的原子性指令,使得在多线程环境中能够减少竞争,提高性能,避免了使用传统锁机制可能带来的开销和复杂性。
优缺点
优点:
提高性能:减少了上下文切换和锁竞争的开销。简化代码:在多线程程序中,CAS 可以避免显式管理锁的复杂性。缺点:
自旋问题:在高竞争情况下,CAS 可能会导致大量自旋,消耗 CPU 资源。ABA 问题:在两个线程相继进行 CAS 操作时,如果一个线程在更新值的过程中,另一个线程将值改回原来的值,导致第一个线程的 CAS 操作成功,但实际上数据已被改变。对此,可以结合版本号等方法来避免。总的来说,CAS 是一种有效的工具,特别适合于构建高性能和低延迟的并发数据结构。在 Java 中,通过原子类的实现,CAS 提供了简化的并发编程解决方案。
Synchronized 的原理
基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
Synchronized 的加锁过程
在 Java 中,synchronized
关键字用于控制对共享资源的访问,其执行过程涉及锁的升级、锁的消除和锁的粗化等优化策略。这些策略旨在提高多线程环境中的性能,减少锁争用造成的开销。以下是这些概念的详细描述:
1. 锁的升级
锁升级是指在 Java 虚拟机中,当一个对象的锁被多个线程竞争时,JVM 会将该对象的锁从 lighter weight(轻量级 Lock)升级为 heavier weight(重量级 Lock),以减少锁的竞争。锁的状态大致分为以下几种:
无锁状态:
初始状态下,锁是无锁状态。当没有线程尝试获取锁时,JVM 不会分配任何锁。此外,常用的同步对象(如synchronized
方法或块)在此状态下不会造成任何性能损失。 偏向锁:
当一个线程第一次访问被synchronized
修饰的对象时,JVM 会对该对象加上一个偏向锁。偏向锁的机制使得该线程可以在后续的锁定过程中避免获取锁的竞争。具体来说,偏向锁会在对象头上记录线程的 ID。下一次该线程访问相同的对象时,它会直接获取锁而不执行任何同步操作。这种机制主要用于优化程序中的单线程访问场景,减少不必要的上下文切换。 轻量级锁:
如果在一个线程持有偏向锁时,另一个线程尝试获取同一对象的锁,就会引发竞争。此时,偏向锁会被撤销,锁的状态将升级为轻量级锁。在轻量级锁状态下,JVM 使用 CAS(Compare and Swap)操作来尝试获取锁。成功的线程会获得锁,而失败的线程则会被阻塞,并转变为重量级锁状态。轻量级锁允许一小部分线程同时获得锁,适用于竞争不是非常激烈的场合。重量级锁:
当锁的竞争加剧,多个线程同时尝试获取同一把锁,并且轻量级锁的 CAS 操作失败时,轻量级锁会被升级为重量级锁。在这一状态下,线程会被阻塞并且可能导致线程的上下文切换。此时,只有获得锁的线程能够进入关键区,其他线程必须等待。重量级锁虽然提供了良好的互斥性,但其代价较高,开销包括上下文切换和操作系统调度。总结
锁的升级过程是从无锁状态到偏向锁,接着到轻量级锁,最后到重量级锁的一个渐进过程。每一次状态的转变都是为了确保在多线程环境中对共享资源的安全访问,同时在适当的时候减少锁的开销。通过这种机制,Java 努力在高并发环境中实现性能和安全的平衡。
2. 锁的消除
锁消除是 JIT 编译器的一种优化技术。当编译器分析代码时,如果它能够确定某个对象的锁永远不会被多个线程访问,它会在编译期间消除对该对象的加锁操作。例如:
public void someMethod() { Object obj = new Object(); // 对象 obj 是局部的 synchronized (obj) { // 代码块 }}
如果编译器分析到 obj
是一个局部变量且在多线程环境中不能被其他线程共享,它可能会消除 synchronized (obj)
的锁操作,从而提高性能。
3. 锁的粗化
锁粗化是一种优化策略,用于减少锁的获取和释放次数。当多个操作被频繁执行并且锁的范围相对小,但实际上这些操作是相互连续的、互相关联的,JIT 编译器可能会将这些独立的锁合并成一个更大的锁,从而减少频繁的加锁和解锁操作。例如:
public void someMethod() { synchronized (this) { // 代码块1 } // 一些其他操作 synchronized (this) { // 代码块2 }}
在这种情况下,编译器可能会优化为:
synchronized (this) { // 代码块1 // 一些其他操作 // 代码块2}
通过锁的粗化,可以减少锁的频繁获取和释放,提升程序的执行效率。
总结
锁升级、锁消除和锁粗化是 Java 中对synchronized
关键字的优化策略,旨在提高并发性能。这些优化通常由 Java 虚拟机的 JIT 编译器自动进行,程序员通常不需要手动管理。通过合理的设计和优化,可以降低锁的竞争,提高多线程程序的性能,同时确保数据的一致性和安全性。