Java锁深入理解5——共享锁

Posted 发现存在

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java锁深入理解5——共享锁相关的知识,希望对你有一定的参考价值。

前言

本篇博客是《Java锁深入理解》系列博客的第五篇,建议依次阅读。
各篇博客链接如下:
Java锁深入理解1——概述及总结
Java锁深入理解2——ReentrantLock
Java锁深入理解3——synchronized
Java锁深入理解4——ReentrantLock VS synchronized
Java锁深入理解5——共享锁

概述

前面无论是ReentrantLock 还是 synchronized,我们都称之为“独占锁”。其实AQS还支持另外一种锁:共享锁。
说到共享锁。我首先想到的就是“读写锁”里的“读锁”,在我的认知里,共享锁就是读锁。其实也差不多,因为读写锁中动读锁就实现了AQS共享锁机制。JDK中除了读写锁ReentrantReadWriteLock之外,还有CountDownLatch。
共享锁的朴素概念:有多个持用该锁的线程可以同时运行。

其实分析了前面的独占锁代表ReentrantLock,然后又分析了两种共享锁之后。就可以发现AQS的核心就是一个volatile标志(state)和一个同步队列。
同步机制都是:

  • 根据state判断,能抢就抢,不能抢就入同步队列。
  • 入了队列就进入了一个死循环。要么直接阻塞,等着前面的节点来唤醒。唤醒之后就尝试CAS抢锁。
  • 抢到锁才可以结束死循环。

知道了这个规律,我们简单看一下两种共享锁在这个规律基础上有哪些个性化的东西。

ReentrantReadWriteLock

由于它也是可重入锁,而且还分两种锁:读锁,写锁。
想一想ReentrantLock中state的含义:记录有没有线程占用;如果占用了,是占用了几次(重入了几次)。

但对于读锁,这就产生了一个问题:state记录不了这么多含义。尤其是读锁,还允许一堆线程同时拥有它,那怎么记录重入次数呢。
所以ReentrantReadWriteLock扩展了state的含义,并且增加了ThreadLocal来记录每个线程的重入读锁的次数。

特点1: 改造扩展state功能
把state的int(32位二进制数),分解成了两个16位来用。如图所示

左边(高位)用来表示读锁的线程占用数(当前有几个线程在占用读锁)。
右边16位表示写锁的重入数量(因为写锁就是普通排他锁,一次只会有一个线程进来,所以它记录的含义和ReentrantLock一样,记录重入次数)

所以代码中会看到一些“位运算”和“与运算”。

为了记录读锁的重入次数,还用了ThreadLocal,存储在下面这个类的对象

        static final class HoldCounter 
            int count = 0;
            // Use id, not reference, to avoid garbage retention
            final long tid = getThreadId(Thread.currentThread());
        

对象中两个成员:

  • tid: 当前线程id
  • 表示该线程的重入读锁的次数

锁降级

对于排他锁,必须一个线程一个线程的处理。
但如果是读写锁,就有点不一样了。想象这样两个场景:

场景1:
线程1抢到了写锁,还在执行过程中。。。
此时有一个线程2,想要抢读锁
按照规则,写锁和读锁是互斥的,所以线程2要等待线程1释放写锁。然后它才能抢读锁。 这个很好理解。

场景2:
线程1抢到了写锁,还在执行过程中。。。
此时线程1 还想要抢读锁
我们给不给它呢?

场景2中,虽然严格按照朴素的读写规则,应该公事公办,不给。
但问题是:同一个线程,它要么读 要么写,肯定不会给自己带来不一致的问题。是不是应该包容这种情况呢?

是的,我们包容了。这就是锁降级。

反过来想

场景3:
线程1抢到了读锁,还在执行过程中。。。
线程1 现在还想抢写锁
我们给不给它呢?

不行。
假如我们给了写锁。它自己读写确实不矛盾,不会产生不一致。但问题是它一开始拿的是读锁。同一时期还有很多线程可能都拿到了读锁。
此时我们给线程1写锁,是不是就跟那其他读锁冲突了。

打个比喻
读锁就像普通游乐园票,很多人都可以一起用。
写锁就像是“包场票”,只能独享。
如果一个线程已经买了“包场票”,再给他一个普通票,当然没问题了。
但如果它只买了普通票,肯定不能再给他“包场票”了。否则游乐园里的其他人怎么办,赶出去吗?

这就是为什么读写锁,只有“锁降级”(写锁变读锁),没有“锁升级”(读锁变写锁)。

CountDownLatch

这个同步器的功能是:定义一个计数器。多个地方都可以减计数器的值。当计数器减到0的时候。阻塞在等待队列里的线程才能开始运行。这也是一个共享锁,因为当计数器减到0,等待队列里的线程允许一起运行。

对这个锁来说,它只关心计数器是不是变成0了,只要变成0,就能运行。
所以state的设计就很简单了,0或非0,作为阻塞判断。

共享锁队列共同的特点

共享锁相对于排他锁,对AQS的队列的使用方式是不太一样的。

  • 排他锁,线程是一个一个执行。后一个线程在前一个线程释放锁之后才会被唤醒。
  • 共享锁,线程可以一起运行。只要前一个线程修改完锁标记等一系列处理之后,不用等开始运行用户程序,更不用等释放锁unlock,就会唤醒下一个节点。
    那unlock的时候干了些什么呢?其实相当于又重复的唤醒了一次。然后主要是修改读些锁自己的锁记录信息(state拆开后,记录的读锁占用线程数,写锁重入数量这些东西)。

这里可能产生一个困惑,既然共享锁节点从队列里出来也是一个一个排队出来,那么他们运行结果是不是顺序执行呢?
答案:是也不是。画个图表示一下

Demo

public class TestReadAndWriteLock 
	
	/**
	 * 阅读
	 * @param lock			锁
	 * @param readerName	读者名字
	 */
	public void read(Lock lock, String readerName) 
		lock.lock();
		try 
			System.out.println(readerName + " read start");
			TimeUnit.SECONDS.sleep(1);
			System.out.println(readerName + " read over");
		 catch (InterruptedException e) 
			e.printStackTrace();
		 finally 
			lock.unlock();
		
	
	
	/**
	 * 写
	 * @param lock			锁
	 * @param writerName	作者名称
	 */
	public void write(Lock lock, String writerName) 
		lock.lock();
		try 
			System.out.println(writerName + " write start");
			TimeUnit.SECONDS.sleep(1);
			System.out.println(writerName + " write over");
		 catch (InterruptedException e) 
			e.printStackTrace();
		 finally 
			lock.unlock();
		
	

	public static void main(String[] args) 
		TestReadAndWriteLock tw = new TestReadAndWriteLock();
		ReadWriteLock rw = new ReentrantReadWriteLock();//定义一个读写锁生成器
		
		Lock readLock = rw.readLock();//生成读锁
		Lock writeLock = rw.writeLock();//生成写锁
		
		Lock lock = new ReentrantLock();

		for (int i = 0; i < 3; i++) //3个作者入场
			final int id = i;
			new Thread(() -> tw.write(writeLock, "作者"+id)).start();
		
		for (int i = 0; i < 3; i++) //10个读者入场
			final int id = i;
			new Thread(() -> tw.read(readLock, "读者"+id)).start();
		


	


运行结果

作者1 write start
作者1 write over
作者0 write start
作者0 write over
作者2 write start
作者2 write over
读者1 read start
读者2 read start
读者1 read over
读者0 read start
读者2 read over
读者0 read over

可以看到,写锁保证了写完整流程不受干扰。
但读锁之间,会相互穿插。虽然他们是顺序从队列里出来,但用户程序部分基本是并行的

小结

  • ReentrantReadWriteLock和CountDownLatch作为两种AQS共享锁的实现。
    依然沿用了AQS的主体框架:判断标志state和同步队列
  • 他们都根据自己要实现的同步效果,都对state含义上做了扩展改造(int字段没变,只是解释的意思变了。尤其是ReentrantReadWriteLock,甚至把int拆成了二进制来读取)。
  • 同步队列的入队逻辑基本都一样,只是唤醒节点的时机不太一样。

Java锁深入理解2——ReentrantLock

前言

本篇博客是《Java锁深入理解》系列博客的第二篇,建议依次阅读。
各篇博客链接如下:
Java锁深入理解1——概述及总结
Java锁深入理解2——ReentrantLock
Java锁深入理解3——synchronized
Java锁深入理解4——ReentrantLock VS synchronized
Java锁深入理解5——共享锁

概述

虽然我们常用的可能是Synchronized,但我们还是先看JDK锁。因为它由JDK实现,有可见的源代码。分析起来会方便一些。

理解了之后,在去看Synchronized,会容易很多(毕竟都是锁,不管是谁实现的,大致的思想应该有共同之处)。

由于后面要从Demo一路深入到JDK源码。而看多线程源码和普通单线程源码还不太一样。如果还没尝试过多线程debug的,可以先看一下Java锁深入理解1——概述及总结,其中讲了如何多线程debug。

Demo1

JDK锁有很多,我们就以最常用的ReentrantLock(可重入锁,也是一种排他锁)来举例

public void testReentrantLock() 
    ReentrantLock mylock = new ReentrantLock();

    mylock.lock();//抢锁 加锁
    System.out.println("------do something....");//线程安全操作
    mylock.unlock();//释放锁

在这段demo中,如果有多个线程都会执行这个方法。那么同一时间,只会有一个线程进入到mylock.lock();mylock.unlock();之间。可以在其中做一些需要线程安全的操作。

Demo2

Demo1只是一种最基本的使用方式,通过lock-unlock来圈定一个安全区(也叫临界区),来保证线程安全。

还有两个操作await, signal也挺常见。分别是用来把自己阻塞,把别人唤醒。其实这两个操作对线程安全并没有什么直接作用。已经不属于“解决多线程客观问题”的范畴,而是属于“把多线程玩出更多花样”的范畴。如果说lock-unlock是锁的核心功能,那么await/signal则属于锁的附属功能。

    ReentrantLock mylock = new ReentrantLock();
    Condition c = mylock.newCondition();    

	public void testReentrantLock2() 

        mylock.lock();//抢锁 加锁
        System.out.println("------do something....");//线程安全操作
        try 
            c.await();//把自己阻塞
         catch (InterruptedException e) 
            throw new RuntimeException(e);
        
        mylock.unlock();//释放锁
    

    public void testReentrantLock2_1() 

        mylock.lock();//抢锁 加锁
        System.out.println("------do something....");//线程安全操作
        c.signal();//把阻塞的线程唤醒(配合await使用)
        mylock.unlock();//释放锁
    

Demo2中,首先是增加了Condition c = mylock.newCondition();,不知道怎么翻译。自面意思就是“条件”,一般我们就直接称之为Condition。

语言和语言体系之间必然不可能一一对应。而专业领域的翻译有“精度要求”。当含义误差比较大时,就没必要硬翻译。

此时中文里夹杂英文专业词汇不叫装逼,而是为了表意更准确。(日常表达是没必要的)

testReentrantLock2方法中,在lock-unlock划定的临界区里(这个条件很重要),使用了c.await();。当某个线程A执行到这里的时候,会被阻塞在这里。

此时该线程A会失去锁(它虽然还身在临界区里,但却处于休眠状态)。相当于其他线程忽略线程A的存在,可以继续抢锁。

testReentrantLock2_1中,在lock-unlock划定的临界区里(这个条件很重要),使用了c.signal();。当线程B执行到这里的时候,会把阻塞的线程唤醒(比如上面的线程A)。

此时你可能有个疑问:那如果另一个线程B立马抢到锁,并唤醒A。是不是会和刚醒来的线程A同时身处临界区。

答:是的。 而且如果B使用的是signalAll(),还有可能唤醒一堆被阻塞线程。(所以不要误认为“临界区”同一时间只能有一个线程)

但区别就是:B手中有锁,只要B不出来,其他线程就进不来。而那些被B唤醒的线程能做的 只能默默的把剩下的路走完。

问题

如果用过锁,或许会产生一些疑问:

  • 代码为什么会在mylock.lock()位置停下来
  • 代码为什么会在c.await()位置停下来
  • 抢到锁的本质是什么
  • 怎么保证只有一个线程抢到锁
  • 什么时候才能抢锁

内部机制

下面就正式进入ReentrantLock类的内部,来解答上面的疑惑。

代码结构

这张图就表示ReentrantLock类的总体结构。(图中并没有严格按照URL的规范画。包含关系直接使用了更直观的嵌套,而不是用线条表示。箭头含义是按规范画的:A—>B表示A继承B)

当new ReentrantLock()时,其实使用的是FairSync(公平锁)或者NonfairSync(非公平锁)。

也可以通过传参数true,来创建公平锁

而这两种锁的顶级父类就是AbstrateQueuedSynchronizer(AQS)。

AQS

先整体看一下这个锁的核心类,AQS原理示意图

这张图相当于图代码结构示意图中,AQS部分的进一步放大,可以看到其中更多丰富的细节。

图中关键的两个东西:一个是state,一个是同步队列

队列中的一个个节点封装着一个个线程。绿色代表是当前获得锁的,在队列中位列第一。后面的黄色节点则处于阻塞状态。AQS就是通过这个队列来管理线程,实现“先来后到”的方式顺序执行。

state是一个标志,相当于一个红绿灯(更像公共厕所的锁上的显示:有人/无人):1表示有线程正在占有锁,其他线程不用白费力气去抢了。0表示当前没有占用,其他线程有机会去抢。当同一个线程在前一个锁还没释放的时候,就又再次抢锁也是可以的,此时state会加到2,以此类推,重入几次,state就是几。

图中的另外一种队列(红色的那种),画了两个,表示这种队列可以有多个(也可以没有)。叫条件队列。也就是代码中,我们使用await之后,线程节点被放置的位置。再被signal唤醒之后,线程节点就从这个红色队列中脱离出来(脱离的优先级也是按照先来后到的方式,从队列头部一个一个的脱落),然后重新回到同步队列中排队。

名词统一

关于AQS中的两种队列的名字,有点乱(有些博客自己都前后不一致)。我根据源码上的注释,给本文统一如下:

等待队列(wait queues):上面两种队列的统称。这两种队列都是有AQS类中的内部类Node类组成的,都是阻塞等待状态(除了同步队列的头节点)。(参考AQS源码中的Node类上的注释的第一句:Wait queue node class)

同步队列(sync queue):也就是实现lock-unlock的核心队列,图中第一条队列。(参考AQS源码中的transferForSignal方法的注释:Transfers a node from a condition queue onto sync queue.)

条件队列(condition queue):就是图中的红色队列。(参考AQS源码中的transferForSignal方法的注释:Transfers a node from a condition queue onto sync queue.)

Transfers a node from a condition queue onto sync queue.意思是:将节点从条件队列转移到同步队列。

Node

上面那个AQS的原理图中,Node只是一个小方块,我们继续放大这个方块,以及两个队列链表

Node节点示意图:

同步队列示意图:

条件队列示意图:

可以看到Node节点之间通过prev和next,组成了同步队列的双向链表。通过nextWaiter,组成了条件队列的单向链表。

线程组织成队列的逻辑场景

那这个两个队列是怎么用Node节点自动组织起来的呢。以同步队列为例介绍一下。

一般情况下,我们会把锁的定义

ReentrantLock mylock = new ReentrantLock();
Condition c = mylock.newCondition();

写在方法外面,因为只需要定义一个即可,后面不需要重复定义。

有了这两句话,我们的AQS容器,以及其中的Condition就生成了。后面只要有线程碰到这个容器,它就像一个高速公路检查站一样,在里面触发一系列的操作。看一下AQS的初始化时的示意图(注意观察和【AQS原理图】的差异):

在容器里,除了有state之外,还有headtail(组织队列的关键元素)。

当某个线程进来之后,在state的指挥下,被包装成Node节点,然后被head和tail引用。

然后是第二个,它会自动被追加到第一个节点的后面,然后是第三个,第四个,,,

最后就形成了前面我们看到的【AQS原理图】的样子。

可重入锁逻辑

通过上面的介绍,我们基本就掌握了ReentrantLock以及AQS的基本原理。下面是一些源码细节。

流程图


这个流程图很重要。结合这张图,会帮助理解后面各种操作的逻辑。

lock()

公平锁上锁逻辑

看看不能抢(看state状态,是不是锁定中(其他线程正在运行中))

  • 1-1. 如果能抢,就抢就抢一抢(不断循环尝试)
    • 1-1-1. 抢到了,就把老大给踢出队列(如果有老大的话),自己做老大
    • 1-1-2. 没抢到,自己就阻塞
  • 1-2. 如果不能抢,就进队列去等
    • 1-2-1. 进了队列,发现自己是老二,那么就去尝试抢一抢(进入1-1的循环)
    • 1-2-2. 进了队列,发现自己不是老二,那么就阻塞

解释

  • 老大:也就是头节点,抢到锁的线程。

  • 这里忽略了一些细节:

    • 抢的过程中也可能发现是自己重入(上一次抢到锁的就是自己,现在绕了一圈又进来了),那么也算抢成功(自己是老大,抢完之后还是老大)
    • 等待中:被取消的,会被踢出队列
    • 我们看到类似中断的一些代码,仔细看这些代码,其实并不会引起中断。只是在收到中断信号之后,这个中断信号会唤醒阻塞(但因为在循环里面,所以并不影响结果),然后这个中断信号被抹去,最后又给恢复了(如果感觉有点晕,没关系,你只要知道这个逻辑无伤大雅,不用去刻意理解这部分逻辑,后面会讲到中断这块)。
  • 我们一开始可能认为:一个线程队列,如果简单设计的话。前一个运行完,触发后一个运行似乎是最简单的。

    但实际设计的方案是:老大运行完,确实“通知”老二了。但这个“通知”的意思是:唤醒后一个线程(从阻塞变为非阻塞)。

    就是说:老大退位了,并不意味值老二自动变老大。只是告诉老二,你有权利上位了(上位的过程还是老二主动循环尝试去争取)。

    其实想想也好理解:线程和线程之间都是独立的,没有很强的耦合关系。最大的耦合就是signal唤醒了。

  • 老二怎么踢掉的老大:源码

    setHead(node);
    p.next = null; // help GC

这里的p就是当前节点(老二)的前面的节点(老大)。就是说把老大的next引用指到null。
第一句的setHead方法里,把原本指向前节点的引用指向null。
也就是把双向的引用都断掉。而且把head也指向了老二。老大彻底“失联”,等着被GC回收。

非公平锁上锁逻辑

直接抢抢试试(不去判断state)

  • 1-1. 如果成功,自己直接做老大
  • 1-2. 如果失败,进入“公平锁”流程

unlock()

解锁流程,无论是公平锁还是非公平锁都一样

  1. 把锁的状态改为“非锁定中”
  2. 唤醒下一个节点(unpark)
  3. 从节点上退下来? 【并没有这一步!老大的位置是被老二踢下来的】

await()

  1. 排进条件队列
  2. 释放锁(这一步就是unlock的操作)
  3. 阻塞

signal()

  1. 找到条件队列的第一个节点
  2. 让这个节点从条件队列脱离掉(first.nextWaiter = null;)
  3. 让这个节点排到同步队列的队尾(tail.next = node;)
  4. 唤醒这个节点(unpark)

小结

  • 在AQS中,试图抢锁的只有老大,老二和还未入队列“外来者”,其他节点都处于阻塞状态
  • unlock和await(注意:能做这两个动作的只有拿到锁的头节点),都会调用同一个释放锁的过程(改锁状态为0,唤醒同步队列里的第二个节点)。
    不同点是:await后还会把自己加入条件队列,然后阻塞自己(其实可以说await流程中包含unlock的流程)。
  • 无论是同步队列里的自动阻塞(那些黄色节点),还是使用await后的阻塞(红色节点),本质原理是一样的,都是用park阻塞,都需要被别的线程用unpark唤醒。区别在于:
    • 在哪阻塞:前者在同步队列里阻塞,后者在条件队列里阻塞。
    • 被谁唤醒:前者被头节点释放锁后唤醒,后者被其他线程(其实还是头节点)使用signal唤醒。
  • unlock和signal,都会涉及到唤醒节点(unpark)的操作。
    前者是唤醒的是同步队列里的第二个节点,后着是唤醒条件队列里的第一个节点。
  • 唤醒(unpark):就是是给指定节点“解穴”,让它继续动起来。
  • 被唤醒之后,至于去干什么,取决于线程当前执行到哪了,后面还要做什么。如果是同步队列的节点,被唤醒后就是继续抢锁。而条件队列里的节点,正常就是默默的继续往下执行代码。当然,如果它身处一个循环语句之中,转一圈,它也许还会再次去抢锁。

其实前面的流程已经把取消等流程都给省略了,但还是太细节,太复杂。再画一个更简化版的整体动态概览图(两条实线表示节点的变换位置的方向)

可能的困惑

  1. lock是为了实现线程安全,那么lock源代码本身的线程安全怎么保证?
    比如:lock()源码中,抢锁(改锁状态),线程入队列都是用了CAS(也是AQS的核心),保证了线程安全。而await的时候在节点入队列时,却直接使用的=,不会出现线程安全问题吗?

答:这是一个思维盲区。或许有读者已经想到问题出在哪了。
因为await只能在lock和unlock之间(临界区)的线程安全区里调用,所以await内不用担心线程安全问题。
整个过程,其实只有抢锁的时候,需要考虑线程安全。后面的操作一直到unlock其实都是线程安全的,其他线程都被阻止在抢锁那一步了。

  1. 线程被唤醒后,在哪复活?是不是像打游戏一样,在泉水(出生地)里复活?

答:这就是想多了。它在哪阻塞,就在哪被唤醒。例如下面的await方法代码

线程在LockSupport.park(this);阻塞,那么当它被其他线程唤醒时,就还是从这句话开始执行。
但是,之所以可能引起困惑。从await()开始,到park最终停下,最后再次被唤醒开始往下执行,中间经历了很长的流程,如下图所示:

这个一维流程图看着晕?再换个二维流程图视角看看:

我们还看到park这句话被while语句包裹着。也就意味着:即使被唤醒,也又可能立马又阻塞。
这个写法也值得我们学习:线程被唤醒后别晕着头就往下执行,最好看看当前什么状况,如果不能往下执行,也许还得继续阻塞。

  1. 如果锁重入了多次,比如重入了三次,state的值被加到3。此时做await()操作。state值需要清零吗。
    答:不需要,这里的重入就有点像事务,你进了多少层事务,最后都得一层层的出来。除非程序报错。

CAS(compareAndSet)和自旋锁

在说AQS的时候总会有人说CAS和自旋锁。
首先明确一点:CAS本身是不会自旋的,只试一次:返回true或者false
那自旋体现在哪呢,有两段循环语句:

  1. 这是当前线程节点 作为一个“外来节点”(还没排到同步队列里)的接下来的行为:入队
    private Node enq(final Node node) 
        for (;;) 
            Node t = tail;
            if (t == null)  // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
             else 
                node.prev = t;
                if (compareAndSetTail(t, node)) 
                    t.next = node;
                    return t;
                
            
        
    
    

代码逻辑:

  • 如果队列的结尾是空(根本没人排队),就去尝试当那第一个节点(也可能尝试失败)。
  • 否则就尝试排到队尾(不一定能排进去)。
  • 这两个条件内的方法都是CAS尝试,如果失败了,就再次循环执行一遍,直到排进去为止。
  1. 这是当前节点,作为同步队列里一个节点,接下来的行为:抢锁或阻塞
    final boolean acquireQueued(final Node node, int arg) 
        boolean failed = true;
        try 
            boolean interrupted = false;
            for (;;) 
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) 
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            
         finally 
            if (failed)
                cancelAcquire(node);
        
    

代码逻辑:

  • 如果前面那个节点是头节点,就抢锁(不一定抢到)
  • 否则就阻塞(等着前面的节点执行完,唤醒我)
  • 循环上面两步,一直到抢到为止

简化代码写法分析及思考

在ReentrantLock源代码中有这么两处典型的if判断语句

public final void acquire(int arg) 
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    
    
和

if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;

以第一段为例,他的逻辑其实是

public final void acquire(int arg) 
        if (!tryAcquire(arg)) 
            if(acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
                selfInterrupt();
        
            
    

这并不难看出。因为&&的作用,if条件语句中的第一个条件其实也相当于一个判断,对第二个条件的执行与否造成影响。
但如果按照我日常开发的习惯,我基本会写成第二种拆开的写法。甚至写成这样:

public final void acquire(int arg) 
        boolean tryAcquireRes = !tryAcquire(arg);
        if (tryAcquireRes) 
        	//看代码我们就会明白,下面两句话是顺序执行的两句,也给拆开
            Node newWaiter = addWaiter(Node.EXCLUSIVE);
            boolean acquireQueuedRes = acquireQueued(newWaiter, arg);
            if(acquireQueuedRes) 
                selfInterrupt();
            
                
        
            
    

原因无他,只是为了让代码更易读。减少团队合作中的沟通成本,一眼就看出逻辑(这相当于团队之间用代码在沟通)。
但是,这里的写法我是认可的。因为这是在封装工具包,而且是多线程这种对性能要求极高的代码。当然是能多榨取一点性能就多榨取一点。作为开源软件,测试是非常到位的,不担心出bug。

以上是关于Java锁深入理解5——共享锁的主要内容,如果未能解决你的问题,请参考以下文章

深入理解Java内存模型——锁

带你深入理解多线程 --- 锁策略篇

带你深入理解多线程 --- 锁策略篇

深入理解Java内存模型——锁

带你深入理解 Redis分布式锁...

10行代码理解Java锁消除