java并发原理实战(10)--AQS 和公平锁分析

简介: java并发原理实战(10)--AQS 和公平锁分析

AQS


AQS原理

AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。

AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包


AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。


CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。


AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。


用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。


**注意:AQS是自旋锁:**在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功


实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物


AQS实现的具体方式如下:

1dc618a0ed9580ce8bfa6facb208c08f.png

如图示,AQS维护了一个volatile int state和一个FIFO线程等待队列,多线程争用资源被阻塞的时候就会进入这个队列。state就是共享资源,其访问方式有如下三种:

getState();setState();compareAndSetState();


AQS 定义了两种资源共享方式:


1.Exclusive:独占,只有一个线程能执行,如ReentrantLock

2.Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier


不同的自定义的同步器争用共享资源的方式也不同。


AQS底层使用了模板方法模式


同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):


使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)

将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。


这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS已经在顶层实现好了。自定义同步器实现的时候主要实现下面几种方法:


isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。


tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。


tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。


tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。


tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。


ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。

注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。


以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。


一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

 在acquire() acquireShared()两种方式下,线程在等待队列中都是忽略中断的,acquireInterruptibly()/acquireSharedInterruptibly()是支持响应中断的。


5d4c6812c8535adbb050f4ddf2e1bce8.png

java语言中有许多原生线程安全的数据结构,比如ArrayBlockingQueue、CopyOnWriteArrayList、LinkedBlockingQueue,它们线程安全的实现方式并非通过synchronized关键字,而是通过java.util.concurrent.locks.ReentrantLock来实现。

ReentrantLock的实现是基于其内部类FairSync(公平锁)和NonFairSync(非公平锁)实现的。 其可重入性是基于Thread.currentThread()实现的: 如果当前线程已经获得了执行序列中的锁, 那执行序列之后的所有方法都可以获得这个锁。


公平锁:


公平和非公平锁的队列都基于锁内部维护的一个双向链表,表结点Node的值就是每一个请求当前锁的线程。公平锁则在于每次都是依次从队首取值。


锁的实现方式是基于如下几点:


表结点Node和状态state的volatile关键字。


sum.misc.Unsafe.compareAndSet的原子操作(见附录)。


非公平锁:


在等待锁的过程中, 如果有任意新的线程妄图获取锁,都是有很大的几率直接获取到锁的。

ReentrantLock锁都不会使得线程中断,除非开发者自己设置了中断位。
ReentrantLock获取锁里面有看似自旋的代码,但是它不是自旋锁。
ReentrantLock公平与非公平锁都是属于排它锁。


ReentrantLock的可重入性分析


这里有一篇对锁介绍甚为详细的文章 朱小厮的博客-Java中的锁.


synchronized的可重入性



java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以每线程为粒度的,per-invocation互斥体获得对象锁的操作是以每调用作为粒度的)


ReentrantLock的可重入性


前言里面提到,ReentrantLock重入性是基于Thread.currentThread()实现的: 如果当前线程已经获得了锁, 那该线程下的所有方法都可以获得这个锁。ReentrantLock的锁依赖只有 NonfairSync和FairSync两个实现类, 他们的锁获取方式大同小异。


可重入性的实现基于下面代码片段的 else if 语句

1
 1 protected final boolean tryAcquire(int acquires) { 
 2 final Thread current = Thread.currentThread(); 
 3 int c = getState(); 
 4 if (c == 0) {
 5  ... // 尝试获取锁成功 
 6 } 
 7 else if (current == getExclusiveOwnerThread()) { 
 8 // 是当前线程,直接获取到锁。实现可重入性。
 9  int nextc = c + acquires; 
10 if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); 
11 return true; 
12 } 
13 return false; 
14 }


此处有两个值需要关心:


1  /**
 2      * The current owner of exclusive mode synchronization.
 3      * 持有该锁的当前线程
 4      */ private transient Thread exclusiveOwnerThread; -----------------两个值不在同一个类---------------- /**
 5      * The synchronization state.
 6      * 0: 初始状态-无任何线程得到了锁
 7      * > 0: 被线程持有, 具体值表示被当前线程持有的执行次数
 8      * 
 9      * 这个字段在解锁的时候也需要用到。
10      * 注意这个字段的修饰词: volatile
11      */ private volatile int state;


ReentrantLock锁的实现分析


公平锁和非公平锁


ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取。


1 public final void acquire(int arg) { 
2 if (!tryAcquire(arg) && 
3     acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 
4     selfInterrupt(); 
5 }


tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在。


addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等到队列去取后续值,而非公平锁在对于新晋线程有很大优势。


acquireQueued 在多次循环中尝试获取到锁或者将当前线程阻塞。


selfInterrupt 如果线程在阻塞期间发生了中断,调用 Thread.currentThread().interrupt() 中断当前线程。


ReentrantLock 对线程的阻塞是基于 LockSupport.park(this); (见 AbstractQueuedSynchronizer#parkAndCheckInterrupt)。 先决条件是当前节点有限次尝试获取锁失败。



公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。


但是当并发情况下多个线程都读取到 state == 0时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。

volatile 和 CAS的结合是并发抢占的关键。


公平锁FairSync


公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有, 当前线程会执行如下步骤:


1 if (!hasQueuedPredecessors() &&
2     compareAndSetState(0, acquires)) {
3     setExclusiveOwnerThread(current);
4     return true;
5 }


其中hasQueuedPredecessors是用于检查是否有等待队列的。


1 public final boolean hasQueuedPredecessors() { 
2     Node t = tail; // Read fields in reverse initialization order 
3     Node h = head; 
4     Node s; 
5     return h != t && ((s = h.next) == null || s.thread !=     
6     Thread.currentThread()); 
7 }


非公平锁NonfairSync


非公平锁在实现的时候多次强调随机抢占:


1 if (c == 0) {
2     if (compareAndSetState(0, acquires)) {
3         setExclusiveOwnerThread(current);
4         return true;
5     }
6 }


与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁。如果被加入了等待队列后则跟公平锁没有区别。


ReentrantLock锁的释放


ReentrantLock锁的释放是逐级释放的,也就是说在 可重入性 场景中,必须要等到场景内所有的加锁的方法都释放了锁, 当前线程持有的锁才会被释放!

释放的方式很简单, state字段减一即可:


1 protected final boolean tryRelease(int releases) { 
 2 //  releases = 1 int c = getState() - releases; 
 3 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); 
 4     boolean free = false;
 5        if (c == 0) {
 6        free = true; 
 7        setExclusiveOwnerThread(null);
 8     } 
 9     setState(c); 
10     return free; 
11 }


ReentrantLock等待队列中元素的唤醒


当当前拥有锁的线程释放锁之后, 且非公平锁无线程抢占,就开始线程唤醒的流程。

通过tryRelease释放锁成功,调用LockSupport.unpark(s.thread); 终止线程阻塞。

见代码:


1     private void unparkSuccessor(Node node) { 
 2         // 强行回写将被唤醒线程的状态 
 3         int ws = node.waitStatus; 
 4         if (ws < 0) 
 5             compareAndSetWaitStatus(node, ws, 0); 
 6         Node s = node.next; 
 7         // s为h的下一个Node, 一般情况下都是非Null的 
 8         if (s == null || s.waitStatus > 0) { 
 9             s = null; 
10             // 否则按照FIFO原则寻找最先入队列的并且没有被Cancel的Node 
11             for (Node t = tail; t != null && t != node; t = t.prev){ 
12                 if (t.waitStatus <= 0)
13                     s = t; 
14                 // 再唤醒它 
15                 if (s != null)
16                     LockSupport.unpark(s.thread); 
17             }
18         }
19     }


锁降级、锁升级


1dc618a0ed9580ce8bfa6facb208c08f.png


锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。


接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处

理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码清单5-19所示。

代码清单5-19 processData方法


public void processData() {
readLock.lock();
if (!update) {
     // 必须先释放读锁
    readLock.unlock();
    // 锁降级从写锁获取到开始
    writeLock.lock();
    try {
    if (!update) {
        // 准备数据的流程(略)
        update = true;
    }
    readLock.lock();
    } finally {
      writeLock.unlock();
    }
    // 锁降级完成,写锁降级为读锁
    }
    try {
      // 使用数据的流程(略)
    } finally {
     readLock.unlock();
    }
}


上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。


锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。


RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。


线程安全性问题总结


5d4c6812c8535adbb050f4ddf2e1bce8.png



相关文章
|
11天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
12天前
|
Java
Java之CountDownLatch原理浅析
本文介绍了Java并发工具类`CountDownLatch`的使用方法、原理及其与`Thread.join()`的区别。`CountDownLatch`通过构造函数接收一个整数参数作为计数器,调用`countDown`方法减少计数,`await`方法会阻塞当前线程,直到计数为零。文章还详细解析了其内部机制,包括初始化、`countDown`和`await`方法的工作原理,并给出了一个游戏加载场景的示例代码。
Java之CountDownLatch原理浅析
|
14天前
|
Java 索引 容器
Java ArrayList扩容的原理
Java 的 `ArrayList` 是基于数组实现的动态集合。初始时,`ArrayList` 底层创建一个空数组 `elementData`,并设置 `size` 为 0。当首次添加元素时,会调用 `grow` 方法将数组扩容至默认容量 10。之后每次添加元素时,如果当前数组已满,则会再次调用 `grow` 方法进行扩容。扩容规则为:首次扩容至 10,后续扩容至原数组长度的 1.5 倍或根据实际需求扩容。例如,当需要一次性添加 100 个元素时,会直接扩容至 110 而不是 15。
Java ArrayList扩容的原理
|
15天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
20天前
|
存储 Java 关系型数据库
在Java开发中,数据库连接是应用与数据交互的关键环节。本文通过案例分析,深入探讨Java连接池的原理与最佳实践
在Java开发中,数据库连接是应用与数据交互的关键环节。本文通过案例分析,深入探讨Java连接池的原理与最佳实践,包括连接创建、分配、复用和释放等操作,并通过电商应用实例展示了如何选择合适的连接池库(如HikariCP)和配置参数,实现高效、稳定的数据库连接管理。
37 2
|
12天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
|
21天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
8天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
28 9
|
11天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
8天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin