并发锁相关知识

死锁

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

例如:线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

../../../../ZZZ-Misc/Z-Attachment/images/Pasted image 20241219171502.png|375

对于这种情况的代码:

public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

OUT:

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。

产生死锁的必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系,

死锁相关处理

如何检测死锁?

使用 jmap、jstack 等命令查看 JVM 线程栈和堆内存的情况。

如何预防和避免线程死锁?

预防:(破坏必要条件即可)

  1. 破坏请求与保持条件:一次性申请所有的资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

避免:借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态

安全状态指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。

修改线程二的代码:可以避免死锁

new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 2").start();

OUT:

Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

Process finished with exit code 0

线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

volatile

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

../../../../ZZZ-Misc/Z-Attachment/images/Pasted image 20241219175154.png|400 ../../../../ZZZ-Misc/Z-Attachment/images/Pasted image 20241219175214.png|500

在 C 语言中:原始的意义就是禁用 CPU 缓存。指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

如何保证变量的可见性?

具体如上

主内存与工作内存的直接交互:当一个变量被声明为 volatile 时,每当该变量被修改或读取时,线程会直接从主内存中读取或写入数据。这使所有线程能够看到变量的最新值

如何禁止指令重排序 ?

如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序。

可以参考 2.5.2 禁止进行指令重排序

添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

不能

public class VolatileAtomicityDemo {
    public volatile static int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
        for (int i = 0; i < 5; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 500; j++) {
                    volatileAtomicityDemo.increase();
                }
            });
        }
        // 等待1.5秒,保证上面程序执行完成
        Thread.sleep(1500);
        System.out.println(inc);
        threadPool.shutdown();
    }
}

对于这段代码:正常情况下输出的是 2500,而在这个地方结果是小于 2500 的。

问题在于 inc++ 实际上是复合操作:

乐观锁和悲观锁

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。

总结:

乐观锁的实现

乐观锁一般用版本号机制或 CAS 算法实现(CAS 算法更多)

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

e.g.如果有两个操作对象对数据库进行操作的时候,第一个对象对数据库操作完成后会将 version 变为 2,然后提交的是 version 和操作后的对象,而第二个操作对象操作完成的时候 version 仍然还是 1,提交的时候会发现 version 不一致,所以提交会被驳回

CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 为一个原子操作,底层依赖于 CPU 的原子指令

原子操作:操作一旦开始,就不能被打断,直到操作完成。

CAS 的三个操作数:

当且仅当 V===E CAS 会用 N 来更新 V 的值即 V<-N,否则放弃更新(这个时候已经被更新过了)

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

CAS 的实现

通过 C++与汇编实现,一个关键类为: Unsafe

提供的方法有:

CAS 的具体实现与操作系统以及 CPU 密切相关。

/**
  *  CAS
  * @param o         包含要修改field的对象
  * @param offset    对象中某field的偏移量
  * @param expected  期望值
  * @param update    更新值
  * @return          true | false
  */
public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

java.util.concurrent.atomic 包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。

原子类用于对某类型的变量进行原子操作,它利用 Unsafe 类提供的低级别原子操作方法实现无锁的线程安全性。

关于原子类的总结:Atomic 原子类总结 | JavaGuide Atomic 原子类

AtomicInteger 核心源码:

// 获取 Unsafe 实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        // 获取“value”字段在AtomicInteger类中的内存偏移量
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}
// 确保“value”字段的可见性
private volatile int value;

// 如果当前值等于预期值,则原子地将值设置为newValue
// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

// **原子地**将当前值加 delta 并返回旧值
public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}

// **原子地**将当前值加 1 并返回加之前的值(旧值)
// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

// **原子地**将当前值减 1 并返回减之前的值(旧值)
public final int getAndDecrement() {
    return unsafe.getAndAddInt(this, valueOffset, -1);
}

Unsafe#getAndAddInt 源码:

// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    // 返回旧值
    return v;
}

getAndAddInt 方法中用 do-while 体现了操作失败时,会不断重试直到成功。

getAndAddInt 方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败,它会重新获取当前值并再次尝试更新,直到操作成功。

由于 CAS 操作可能会因为并发冲突而失败,因此通常会与 while 循环搭配使用,在失败后不断重试,直到操作成功。这就是自旋锁机制

CAS 的问题
  1. ABA 问题

是 CAS 最常见的问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题

解决方式:追加上版本号或者时间戳

JDK1.5 后 AtomicStampedReference 类用来解决 ABA 问题,其中的 compareAndSet 方法先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  1. 循环时间长开销大
    CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能够支持处理器提供的 pause 指令,那么自旋操作的效率将有所提升。

pause 指令有两个重要作用:

  1. 只能保证一个共享变量的原子操作
    CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了 AtomicReference 类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用 AtomicReference 来执行 CAS 操作。

除了 AtomicReference 这种方式之外,还可以利用加锁来保证。

synchronized*

主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

JDK 6之前为重量级锁(效率低)

因为监视器锁(monitor)是依赖于底层的操作系统Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

JDK6 之后:引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销

synchronized 可以在项目中使用
偏向锁:增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

锁升级原理详解:浅析synchronized锁升级的原理与实现 - 小新成长之路 - 博客园

修饰场景

锁当前对象实例,进入要获得当前对象实例的锁

synchronized void method() {
    //业务代码
}

锁当前对象实例,进入同步代码前要获得当前 class 的锁

synchronized static void method() {
    //业务代码
}
Question

静态 synchronized 和非静态的 synchronized 方法之间的调用互斥吗?

不互斥,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

synchronized(object) 表示进入同步代码库前要获得给定对象的锁。
synchronized(类.class) 表示进入同步代码前要获得给定 Class 的锁

synchronized(this) {
    //业务代码
}

尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

虽然不能直接在方法上修饰,但可以在方法内部使用(方法本身线程安全,如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。)

底层原理

使用 javap -v xx.class 的命令查看字节码文件,可以知道:

synchronized 同步语句块的情况

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

synchronized 修饰方法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

但两者的本质都是对对象监视器 monitor 的获取

monitor 的详解:Java锁与线程的那些事

与 volatile 有什么区别?*

ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

public class ReentrantLock implements Lock, java.io.Serializable {}

../../../../ZZZ-Misc/Z-Attachment/images/Pasted image 20241221150553.png|475

ReentrantLock 里面有一个内部类 SyncSync 继承 AQSAbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

可以指定使用公平锁与非公平锁

// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock 的底层就是由 AQS 来实现的

关于 AQS :AQS详解

与 synchronized 的区别*

都为可重入锁:也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁

Lock 实现类与 synchronized 都是可重入的。

Example

  • 由于 synchronized 锁是可重入的,同一个线程在调用 method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。
  • 假如 synchronized 是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题。
public class SynchronizedDemo {
    public synchronized void method1() {
        System.out.println("方法1");
        method2();
    }

    public synchronized void method2() {
        System.out.println("方法2");
    }
}

区别体现在:

补充:

Misc

了解即可。

ReentrantReadWriteLock

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁(一把锁可以被多个线程同时获得),写锁是独占锁(一把锁只能被一个线程获得)。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。

ReentrantReadWriteLock 底层也是基于 AQS 实现的
ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。

使用场景:在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。

读锁为什么不能升级为写锁?(写锁可以降级为读锁,但是读锁却不能升级为写锁。)

StampedLock

StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition

不同于一般的 Lock 类,StampedLock 并不是直接实现 LockReadWriteLock 接口,而是基于 CLH 锁独立实现的(AQS 也是基于这玩意)。

提供了三种模式的读写控制模式:读锁、写锁和乐观读。

StampedLock 的性能为什么更好?
相比于传统读写锁多出来的乐观读是 StampedLockReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。

使用场景:
和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock 的替代品,性能更好。

不过,需要注意的是 StampedLock 不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。

另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用 StampedLock 之前,看看 StampedLock 官方文档中的案例。

底层原理:
StampedLock 不是直接实现 LockReadWriteLock 接口,而是基于 CLH 锁实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。

知道 AQS 原理即可。