Java锁机制(二)锁分类

95 阅读15分钟

锁分类

image.png

乐观锁VS悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

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

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

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

image.png

根据上面的概念描述我们可以发现:

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

光说概念有点抽象,我们来看下乐观锁和悲观锁的调用方式示例:

// ------------------------- 悲观锁的调用方式 -------------------------
// 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  

通过调用示例,我们可以发现悲观锁基本都是在显示的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么乐观锁是如何在不锁定同步资源也可以正确的实现线程同步呢?主要是因为CAS。

todo q1:乐观锁有哪些问题 q2:乐观锁会不会导致数据同步失败? 毕竟每个线程内存里面都有备份数据

自旋锁VS适应性自旋锁

在介绍自旋锁前,我们需要介绍一些前提知识来帮助大家明白自旋锁的概念。

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要操作系统的上下文切换线程状态切换锁的释放和获取,这需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,现成挂起和恢复现场的话费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面的那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

image.png

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

无锁VS偏向锁VS轻量级锁VS重量级锁(jdk1.6升级的synchronized锁升级的过程)

这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种状态之前还需要介绍一些额外的知识。

偏向锁

  1. 偏向锁标记:

    • 当一个线程第一次获得锁时,JVM会在对象头部设置一个偏向锁标记。这个标记表示该对象已经被某个线程偏向了。
  2. 线程ID记录:

    • JVM会将获得锁的线程ID记录在对象头中。这样,在没有竞争的情况下,JVM就能够知道哪个线程拥有该锁。
  3. 加锁过程:

    • 当一个线程尝试获得偏向锁时,JVM会首先检查对象头中的偏向锁标记。如果该标记为偏向锁,且线程ID匹配当前线程ID,那么线程就可以直接获得锁,而无需进行竞争。
  4. 撤销偏向锁:

    • 如果其他线程尝试获得偏向锁,而持有锁的线程不是当前线程,JVM会撤销偏向锁,升级为轻量级锁或重量级锁。这是因为偏向锁的设计初衷是针对无竞争的情况,一旦出现竞争,偏向锁就会失去优势。
    • todo
  5. 适应性撤销:

    • 偏向锁提供了适应性撤销的机制,即在持有锁的线程有其他线程争夺时,JVM会撤销偏向锁。这是为了防止在有竞争的情况下继续使用偏向锁,浪费资源。

轻量级锁

轻量级锁(Lightweight Locking)是Java中的一种锁优化机制,用于提高多线程竞争情况下的性能。轻量级锁主要针对的是短时间内只有一个线程持有锁的场景,避免了传统的重量级锁(synchronized)在这种情况下的性能开销。

以下是轻量级锁的基本实现原理:

  1. 锁对象的存储结构:

    • 在对象头中有一个字段用于存储锁信息。当对象刚创建时,该字段默认为无锁状态。
  2. 加锁过程:

    • 当一个线程尝试获得轻量级锁时,首先检查对象头中的锁信息字段。
    • 如果对象处于无锁状态,线程会在对象头中将锁信息字段设置为指向线程栈中锁记录的指针,表示该线程成功获得了锁。
  3. CAS操作:

    • 如果对象处于无锁状态,线程会使用CAS(Compare And Swap)操作尝试将锁信息字段从无锁状态修改为指向线程栈中锁记录的指针。
    • 如果CAS操作成功,表示线程成功获得了锁。如果失败,说明其他线程可能已经获得了锁。
  4. 自旋等待:

    • 如果CAS操作失败,当前线程会进行一定次数的自旋等待。自旋等待的目的是为了避免线程阻塞,以提高性能。
    • 如果在自旋等待期间,其他线程释放了锁,当前线程有机会通过CAS重新尝试获得锁。
  5. 膨胀为重量级锁:

    • 如果自旋等待仍然无法获得锁,当前线程可能会将轻量级锁膨胀为重量级锁,这时候就会使用传统的互斥量来保护临界区。

总体而言,轻量级锁的实现通过CAS操作来避免了传统锁在无竞争时的性能开销。它适用于短时间内只有一个线程持有锁的情况。如果锁争用激烈,轻量级锁可能会膨胀为重量级锁,以确保在高并发情况下保持线程安全。轻量级锁的实现在JVM的不同版本中可能有所调整和优化。

重量级锁

Java 中的重量级锁(Heavyweight Lock)通常是指使用 synchronized 关键字实现的锁,也叫做内部锁。重量级锁主要是为了解决多线程并发访问共享资源时的线程安全问题,保证在同一时刻只有一个线程可以访问共享资源。

以下是 synchronized 关键字实现的重量级锁的基本实现原理:

  1. 互斥性:

    • 重量级锁实现了互斥性,即同一时刻只允许一个线程获得锁,其他线程需要等待。
  2. 锁的升级:

    • synchronized 关键字的实现中,锁可以从偏向锁(适用于无竞争的情况)、轻量级锁(适用于短时间内只有一个线程持有锁的情况)逐步升级为重量级锁。
  3. 进入和退出临界区:

    • 当一个线程尝试获取重量级锁时,如果锁被其他线程占用,那么当前线程会被阻塞,直到锁被释放。
    • 进入和退出临界区是通过 monitorentermonitorexit 指令来实现的。
  4. 锁的释放:

    • 当拥有锁的线程执行完临界区代码,或者发生异常需要释放锁时,会执行 monitorexit 指令来释放锁。
  5. 等待队列:

    • 当线程无法获得锁时,它会被放入等待队列中。当锁被释放时,等待队列中的线程可以竞争锁。
  6. 性能开销:

    • 重量级锁的性能开销相对较高,因为涉及到线程的阻塞和唤醒操作,以及等待队列的管理。

总体而言,重量级锁的实现保证了多线程对共享资源的同步访问。然而,由于它的性能开销较高,因此在高并发场景中可能会影响程序的性能。在一些情况下,可以考虑使用其他更轻量级的同步机制,例如 java.util.concurrent 包中提供的一些并发工具,以更好地满足性能要求。

总体而言,偏向锁的实现是通过在对象头中设置标记和记录线程ID,以及在竞争发生时进行适应性撤销,从而在单线程场景下提高锁的性能。这个机制在Java 6引入,但在JVM的后续版本中也可能进行了一些调整和优化。

image.png

公平锁 VS 非公平锁

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

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

直接用语言描述可能有点抽象,这里作者用从别处看到的一个例子来讲述一下公平锁和非公平锁.

image.png

如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。

但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示:

image.png

可重入锁 VS 非可重入锁

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

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

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

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

如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。

而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。

还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。

image.png

但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。

image.png

独享锁(排他锁) VS 共享锁

独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。

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

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

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

下图为ReentrantReadWriteLock的部分源码:

image.png

我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。