Java 并发编程中各种锁的概念

Posted by icoding168 on 2020-04-02 21:09:19

分类: Java  

并发和并行

并发是指不同进程的逻辑控制流在同一个时间段内交错执行或同时执行,并发与处理器的核心数量没有关系。并行是指不同进程的逻辑控制流并发地在不同的处理器核心或不同的处理器中执行,并行是并发的一个特例,并行执行的进程一定是并发的。

并行的多个任务是真实的同时执行,而对于并发来说,这个过程只是交替的,一会儿运行任务A一会儿执行任务B,系统会不停地在两者间切换。但对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多任务间是并行执行的错觉。

上下文切换

CPU 分配给每个进程的运行时间段叫 CPU 时间片,CPU 时间片一般为几十毫秒。通过时间片算法,CPU 不停地在进程之间切换运行,使各个程序从表面上看是同时进行的。CPU 切换到另一个进程需要保存当前进程的状态并恢复另一个进程的状态,这个过程称之为上下文切换。

上下文切换对计算资源的开销在进程不多、切换不频繁的应用场景下问题不大,但对于高并发网络服务器来说,应尽量减少上下文切换。

并发控制

在计算机科学中,特别是程序设计、操作系统、多处理机和数据库等领域,并发控制(英语:Concurrency Control)是确保及时纠正由并发操作导致的错误的一种机制,悲观并发控制(也叫悲观锁)、乐观并发控制(也叫乐观锁)是两种常用的并发控制策略。悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

悲观锁

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁

乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,如果数据已经被别人更新就重试或者报错。

死锁

死锁是指当两个以上的运算单元,彼此都在等待对方停止运行以获取系统资源,但是没有任何一方停止运行。

CAS 算法

CAS 算法即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A 时,CAS 通过用新值 B 来更新 V 的值,否则会不断地重试。CAS 的比较和替换是一个原子操作(atomic operation),原子操作是指不可被中断的一个或一系列操作。

CAS 虽然很高效,但是它也存在一些问题:

ABA 问题。

CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。

ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet() 中。compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

循环时间长开销大。

CAS 操作如果长时间不成功,会给 CPU 带来非常大的开销。

只能保证一个共享变量的原子操作。

对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。Java 从 1.5 版本开始提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。

自旋锁和适应性自旋锁

阻塞或唤醒一个 Java 线程需要进行线程上下文切换,如果资源的锁定时间很短,线程上下文转换消耗的时间有可能比资源的锁定时间还长。为了这一小段时间去切换线程,可能会造成更大的系统开销。为了让当前线程“稍等一下”,我们可以让当前线程进行自旋,自旋是指当前线程循环检查资源是否可用。如果在自旋完成后前面锁定资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。从 1.6 版本开始 Java 引入了适应性自旋锁,适应性自旋锁会根据当前 CPU 负载、自旋线程数量等情况来选择自旋或者阻塞。

Synchronized

在 JDK 1.6 之前,synchronized 用的锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种锁就是重量级锁,因为其系统开销比较大,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

从 1.6 版本开始 Java 引入了偏向锁和轻量级锁,JVM 会根据运行状况对锁进行由低到高的升级,提升了 synchronized 的并发性能。

偏向锁和轻量级锁具体如何实现非常复杂,以下只是简单介绍一下概念。

偏向锁

偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。

当线程请求到锁对象后,JVM 将锁对象的状态改为偏向模式,然后使用 CAS 操作将线程的 ID 记录在锁对象中。以后该线程可以直接进入同步块,连 CAS 操作都不需要。但是,一旦有第二条线程需要竞争锁,JVM 会分析持有偏向锁的线程的运行状况,来决定将偏向锁偏向给新的线程还是升级到轻量级锁。

轻量级锁

轻量级锁是基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是依靠一条 CAS 原子指令来完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将进行自旋,自旋超时后轻量级锁会升级到重量级锁。

偏向锁是为了消除无竞争情况下的同步操作,进一步提升程序性能。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程频繁阻塞和唤醒而影响性能。偏向锁和轻量级锁都是乐观锁,重量级锁是悲观锁。

公平锁和非公平锁

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

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

Java 中的 ReentrantLock、ReentrantReadWriteLock 默认都是非公平锁。

可重入锁和不可重入锁

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

在 Java 中,ReentrantLock 和 synchronized 都是可重入锁,NonReentrantLock 是不可重入锁。

共享锁和排他锁

共享锁是指该锁可被多个线程所持有,如果有一个线程给某个对象加了共享锁,则其他线程只能对这个对象再加共享锁,不能加排它锁。获得共享锁的线程只能读取数据,不能修改数据。Java 中的 ReentrantReadWriteLock 就是共享锁。

排他锁是指该锁只能被一个线程所持有,如果有一个线程给某个对象加了排他锁,则其他线程不能再对这个对象加任何类型的锁。获得排它锁的线程既能读取数据,又能修改数据。Java 中的 synchronized 和 ReentrantLock 就是排他锁。

思维导图