锁理论基础

2023/12/16 Java多线程与并发

锁是多线程高并发重要的概念,不止Java语言如此,其他的语言也是一样的。所以我们需要对各种锁的理论实现理解,才能在实际开发中更好的使用。

# 1、乐观锁与悲观锁

乐观锁

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

悲观锁

悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

乐观锁悲观锁

案例

// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
	// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
	lock.lock();
	// 操作同步资源
	lock.unlock();
}

// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

在实际应用中,根据具体场景和需求选择乐观锁或悲观锁来控制并发访问是非常重要的。

乐观锁和悲观锁各有优缺点,适用于不同的场景。乐观锁适用于读操作远远多于写操作的场景,能够提高并发性能,但在写操作冲突较多时可能会导致性能下降。悲观锁适用于读写操作相对均衡的场景,能够确保数据的一致性,但在并发较高的情况下可能导致阻塞和性能下降。

# 2、自旋锁与适应性自旋锁

自旋锁

  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
  • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

适应性自旋锁

  • 自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
  • 自适应自旋锁即自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

# 3、公平锁与非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

# 4、可重入锁与非可重入锁

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}
1
2
3
4
5
6
7
8
9
10

类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

非可重入锁

非可重入锁,又叫不可重复获取锁,是指一个锁在解锁之前只能被单个线程获取一次。也就是说,如果一个线程已经获得了一个非可重入锁,那么这个线程不能再次获取该锁,即使它已经拥有了这个锁,它也无法再次获取。这就可能会导致死锁。

# 5、独享锁与共享锁

独享锁

  • 独享锁有多个叫法,排他锁、同步锁、互斥锁都是指独享锁。
  • 独享锁是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

# 6、读写锁

读写锁是一种特殊类型的锁,用于在并发环境下实现对共享资源的读写操作。它与传统的互斥锁不同之处在于,读写锁允许多个线程同时读取共享资源,但只允许一个线程写入。

读写锁的基本规则如下:

  • 多个线程可以同时获得读锁,以实现共享资源的并发读取。当没有线程持有写锁时,可以有多个线程同时申请并持有读锁;
  • 写锁是互斥的,即如果一个线程持有写锁,其他线程无法获得读锁或写锁。只允许一个线程申请并持有写锁;
  • 当有线程持有写锁时,其他线程申请读锁会被阻塞,直到写锁被释放。

读写锁的优势在于能够提高并发性能。在读多写少的场景下,多个线程可以同时读取共享资源,不会相互影响。只有在写操作时才需要排他访问,确保数据一致性。

然而,读写锁仅在读操作远远多于写操作的情况下才能发挥优势。如果写操作频繁,那么读写锁的性能可能不如互斥锁,因为读写锁需要保证写操作的互斥访问。

在实际应用中,根据读写操作的比例和并发访问模式,选择适合的锁机制是非常重要的。读写锁适用于大部分操作是读取的场景,能够提高并发性能;而在写操作频繁的场景,应该选择其他适当的锁机制。

# 7、分段锁

分段锁也并非一种实际的锁,而是一种思想。

  • 分段锁是将共享资源划分为多个段(Segment),每个段都有独立的锁。不同的线程可以同时访问不同的段,以实现并发访问。
  • 分段锁的主要思想是将锁的粒度细化,将共享资源划分为多个独立的部分,每个部分可以由不同的线程并发访问。通过锁的细粒度控制,可以提高并发性能,减少线程间的争用。
  • 分段锁可以应用于很多场景,其中一个典型的应用是在并发哈希表中。在一个哈希表中,不同的键值对可以被同时访问,因此使用分段锁可以提高并发访问的性能。每个段都有独立的锁,不同的线程可以同时访问不同的段,减少了线程间的冲突。
  • 分段锁的优势在于可伸缩性强,可以根据实际需求进行调节和扩展,适应高并发情况下的资源访问需求。然而,分段锁的实现相对复杂,需要确保对不同段的访问是互斥的,并且需要维护多个锁,增加了开销和复杂性。
  • ConcurrentHashMap 是学习分段锁的最好实践

# 8、锁的等级

锁的等级总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁 (此过程是不可逆的)。

# 8.1、偏向锁

偏向锁消除了无竞争同步的情况下的部分无关操作,假设一个对象在运行过程中主要被一个线程所访问,则使这个线程持有该对象锁的代价更低,以提高程序的整体性能。

  • 偏向锁的思想是,当一个线程访问一个被锁保护的对象时,JVM会偏向于这个线程,认为该线程在未来的一段时间内将会独占该对象,从而省去了后续获取锁的操作。当其他线程也想要获取该对象的锁时,JVM会先检查该对象是否处于偏向状态,如果是,则检查当前持有锁的线程是否为自己,如果是,则直接执行代码;如果不是,则通过撤销偏向锁并重新竞争锁。这个过程称为锁重获。
  • 偏向锁的优势在于避免了多余的锁竞争,减少了对系统的影响,提高了程序的执行效率。但是偏向锁也有一些限制,比如在竞争激烈的场景下,偏向锁的撤销和重获操作可能会成为性能瓶颈,此时最好使用其他的锁策略。

# 8.2、轻量级锁

轻量级锁是一种JVM的锁实现技术,用于提高多线程竞争下的锁性能。它的设计目标是在锁竞争不激烈的情况下,通过减少重量级锁的使用,减少锁操作的开销。

  • 轻量级锁的思想是在对象的头部添加一个指向锁记录的指针,该指针指向一个锁记录的数据结构。当一个线程尝试获取轻量级锁时,首先会将对象的锁记录拷贝到自己的线程栈中,并将对象头部中的锁记录指针指向线程栈中的锁记录。接着,线程会使用CAS操作将对象的锁记录指针修改为指向自己的锁记录,如果CAS成功,线程就获得了轻量级锁。在获取到锁的过程中,其他线程还是可以继续读取该对象的锁记录,并根据线程栈中的锁记录信息判断是否有其他线程拥有该对象的锁。
  • 当一个线程释放轻量级锁时,它会使用CAS操作判断自己是否还持有锁记录,如果是的话,会将对象头部的锁记录指针指向它所持有的锁记录,同时撤销自己的锁记录。如果判断失败,说明其他线程已经获取到了锁,需要使用适当的解锁操作进行处理。
  • 轻量级锁的优势在于避免了线程阻塞和唤醒的开销,减少了系统调用的开销,在锁竞争不激烈的情况下,性能较好。但是当锁竞争激烈时,会引发许多锁膨胀的操作,可能会影响性能。

# 8.3、重量级锁

重量级锁主要是指synchronized关键字所使用的锁。在Java中,每个对象都有一个与之关联的监视器锁(也称为内部锁或对象锁),并且synchronized关键字用来获取和释放这个锁。

  • synchronized关键字可以用于方法级别和代码块级别的同步。当synchronized用于方法时,它会对整个方法体加锁;当synchronized用于代码块时,它只对代码块加锁。
  • 重量级锁在实现上使用了操作系统层面的互斥锁机制,如互斥量或管程。当一个线程获取了重量级锁后,其他线程就无法获得该锁,只能等待之前的线程释放锁。这种等待和唤醒线程的操作需要操作系统的支持,因此重量级锁的性能相对较低,并且可能会导致线程的上下文切换。
  • 然而,Java虚拟机在实现synchronized时做了一些优化,例如偏向锁、轻量级锁和自旋锁。这些优化可以在不需要线程竞争的情况下,加快对锁的获取和释放操作,从而提高并发性能。只有在发生线程竞争时,synchronized才会退化为重量级锁,使用操作系统层面的互斥机制。

# 8.4、锁升级

锁的状态从低级别逐渐升级为高级别的过程。Java中的锁升级主要是指synchronized锁的升级过程,包括偏向锁、轻量级锁和重量级锁。

  • 无竞争场景下,当一个线程首次进入同步块时,锁会被标记为偏向锁。
  • 当多个线程争用同一个锁时,偏向锁会被升级为轻量级锁。轻量级锁使用了CAS(Compare And Swap)操作来进行加锁和解锁,避免了线程阻塞和唤醒的开销。
  • 当线程在竞争锁时,如果CAS操作失败(轻量级锁无法解决竞争),表示存在竞争,会升级为重量级锁。
  • 重量级锁使用操作系统提供的互斥量(例如互斥锁、管程)来实现线程的同步和互斥,因此涉及到了线程的阻塞和唤醒操作。当一个线程持有重量级锁时,其他线程无法获取锁,只能等待锁的释放。

锁升级是为了在不同的并发场景中灵活应对,提供了不同级别的锁实现来适应不同的性能要求。偏向锁和轻量级锁是为了尽量减少对锁的竞争,提高并发性能;而重量级锁则是为了保证线程安全和数据一致性,在高并发和存在竞争的情况下提供可靠的同步机制。锁升级的过程是根据对锁的使用情况进行动态调整的,既可以提供高性能的乐观锁,也可以提供强大的互斥和同步能力的悲观锁。

# 9、死锁

死锁(Deadlock)是指两个或多个线程互相持有对方所需的资源,并且都在等待对方释放资源的情况下造成的阻塞现象。

当发生死锁时,线程无法继续执行,程序可能会进入无限等待的状态,导致整个应用程序无法正常工作。死锁是一个典型的并发编程问题,很容易在代码中出现,并且很难排查和解决。

死锁产生的四个必要条件:

  • 互斥条件(Mutual Exclusion):至少有一个资源必须被一个线程独占使用,即一次只能有一个线程持有该资源。
  • 请求与保持条件(Hold and Wait):一个线程在持有某个资源的同时,继续请求其他线程持有的资源。
  • 不可剥夺条件(No Preemption):一个线程持有的资源不能被其他线程强制性地抢夺,只能由该线程释放。
  • 循环等待条件(Circular Wait):多个线程形成一个循环等待的资源关系。

解决死锁的方法包括预防死锁、避免死锁和检测与恢复死锁。

  • 预防死锁:通过破坏死锁产生的四个必要条件之一来预防死锁。常见的预防方法有资源有序分配法和银行家算法等。
  • 避免死锁:在运行时通过数据和资源的动态分析来避免死锁。常见的避免方法有安全序列算法和避免环路等。
  • 检测与恢复死锁:允许死锁的发生,但通过周期性检测死锁的发生,并采取措施打破死锁,恢复系统正常。常见的检测方法有资源分配图和银行家算法等。

合理设计并发控制策略、减少锁的粒度以及及时释放获取的资源等也是避免死锁的有效手段。