序言

Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。

本文旨在对锁相关源码(本文中的源码来自JDK 8)、使用场景进行举例,为读者介绍主流锁的知识点,以及不同的锁的适用场景。

Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助大家更快捷的理解相关知识。

下面给出本文内容的总体分类目录:

输入图片说明

悲观锁、乐观锁

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

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

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

悲观锁(Pessimistic Lock)

顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 block 直到它拿到锁。 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

乐观锁(Optimistic Lock)

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

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制,即对数据做版本控制。 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于 write_condition 机制的其实都是提供的乐观锁。

输入图片说明

适合场景

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

悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。

乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

实际例子

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

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

通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。

那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?

我们通过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为大家解惑。

CAS 技术详解

自旋锁 VS 适应性自旋锁

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

阻塞

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

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

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

输入图片说明

自旋锁本身是有缺点的,它不能代替阻塞。

自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。

所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

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

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。

JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中另有三种常见的锁形式:TicketLock、CLHlock和MCSlock,本文中仅做名词介绍,不做深入讲解,感兴趣的同学可以自行查阅相关资料。

无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

这四种锁是指锁的状态,专门针对 synchronized 的。

详情见 synchronized

公平锁、非公平锁

公平锁(Fair)

加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得。

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

非公平锁(Nonfair)

加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

ReentrantLock 锁内部提供了公平锁与分公平锁内部类之分,默认是非公平锁,如:

public ReentrantLock() {
    sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

例子

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

输入图片说明

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

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

如下图所示:

输入图片说明

参考 ReentrantLock 可重入锁

可重入锁、不可重入锁

可重入锁

可重入锁:即某个线程获得了锁之后,在锁释放前,它可以多次重新获取该锁。

可重入锁解决了重入死锁的问题。

java 的内置锁 synchronizedReentrantLock 都是可重入锁

不可重入锁

不可重入锁(自旋锁):不可以再次进入方法A,也就是说获得锁进入方法A是此线程在释放锁钱唯一的一次进入方法A。

例子

public class SyncTest {

    public synchronized void syncOne() {
        System.out.println("方法1执行");
        syncTwo();
    }

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

}

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

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

而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?

我们通过图示和源码来分别解析一下。

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

这就是可重入锁。

输入图片说明

但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。

第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。

输入图片说明

之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。

首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。

如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。

而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果 status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

互斥锁、读写锁

  • 互斥锁

指的是一次最多只能有一个线程持有的锁。

在jdk1.5之前, 我们通常使用 synchronized 机制控制多个线程对共享资源的访问。

而现在, Lock提供了比synchronized机制更广泛的锁定操作, Lock和synchronized机制的主要区别:

synchronized 机制提供了对与每个对象相关的隐式监视器锁的访问,并强制所有锁获取和释放均要出现在一个块结构中,当获取了多个锁时, 它们必须以相反的顺序释放。synchronized机制对锁的释放是隐式的,只要线程运行的代码超出了synchronized语句块范围,锁就会被释放。

而Lock机制必须显式的调用Lock对象的unlock()方法才能释放锁,这为获取锁和释放锁不出现在同一个块结构中,以及以更自由的顺序释放锁提供了可能。

  • 读写锁

ReadWriteLock 接口及其实现类 ReentrantReadWriteLock,默认情况下也是非公平锁。

ReentrantReadWriteLock中定义了2个内部类,ReentrantReadWriteLock.ReadLockReentrantReadWriteLock.WriteLock, 分别用来代表读取锁和写入锁,ReentrantReadWriteLock对象提供了readLock()和writeLock()方法,用于获取读取锁和写入锁。

java.util.concurrent.locks.ReadWriteLock 接口允许一次读取多个线程,但一次只能写入一个线程:

读锁 - 如果没有线程锁定ReadWriteLock进行写入,则多线程可以访问读锁。

写锁 - 如果没有线程正在读或写,那么一个线程可以访问写锁。

其中:

读取锁允许多个reader线程同时持有,而写入锁最多只能有一个 writer 线程持有。

读写锁的使用场合是:读取数据的频率远大于修改共享数据的频率。

在上述场合下使用读写锁控制共享资源的访问,可以提高并发性能。

如果一个线程已经持有了写入锁,则可以再持有读锁。

相反,如果一个线程已经持有了读取锁,则在释放该读取锁之前,不能再持有写入锁。

可以调用写入锁的 newCondition() 方法获取与该写入锁绑定的 Condition 对象,此时与普通的互斥锁并没有什么区别,但是调用读取锁的 newCondition() 方法将抛出异常。

小结

本文作为锁专题系列的开篇,旨在为了让各位极客们对 java 中的锁有一个大而全的理解。

希望对你有帮助,感兴趣的可以关注一下,便于实时接收最新内容。

觉得本文对你有帮助的话,欢迎点赞评论收藏转发一波。

各位极客的点赞转发收藏,是我创作的最大动力~

不知道你有哪些收获呢?或者有其他更多的想法,欢迎留言区和我一起讨论,期待与你的思考相遇。

参考文档

java 中的各种锁详细介绍

http://www.hollischuang.com/archives/934

http://www.hollischuang.com/archives/909

https://blog.csdn.net/truelove12358/article/details/54963791

https://blog.csdn.net/loongshawn/article/details/76985272