Java并发编程的艺术 -- Java中的锁(第五章)

简介: Java中的锁(第五章)
本文参考于《Java并发编程的艺术》

1、Lock接口

1.1、锁说明

用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。

1.2、Lock接口和synchronized关键字的对比

  1. Lock接口:它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
  2. synchronized关键字:使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好

1.3、Lock接口的使用

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class locks {
    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println("t1.....");
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        },"t1").start();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println("t2.....");
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        },"t2").start();
    }
}

输出结果

在这里插入图片描述

使用说明

  • 在finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放
  • 不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放

1.4、Lock的API

方法名称 描述
void lock() 获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回
void lockInterruptibly() throws InterruptedException 可中断地获取锁,和 lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
boolean tryLock() 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false
boolean tryLock(1ong time,TimeUnit unit) throws InterruptedException 超时的获取锁,当前线程在以下3种情况下会返回:①当前线程在超时时间内获得了锁 ②当前线程在超时时间内被中断 ③超时时间结束,返回false
void unlock() 释放锁
Condition newCondition() 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的 wait()方法,而调用后,当前线程将释放锁

2、队列同步器

2.1、什么是队列同步器?

  • 队列同步器(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态通过内置的FIFO队列来完成资源获取线程的排队工作
  • 同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态

2.2、队列同步器来访问或修改同步状态的方法

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性

2.3、同步器可重写的方法

在这里插入图片描述

2.4、同步器提供的模板方法

在这里插入图片描述

2.5、独占锁说明

独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁

2.6、队列同步器的实现分析

2.6.1、同步队列

1. 简要说明

  • 同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理。 当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程。 当同步状态释放时会把首节点中的线程唤醒,使其再次尝试获取同步状态。
  • 同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点
  • 节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部

2. 同步器的基本结构

在这里插入图片描述

3. 节点加入到同步队列的过程

在这里插入图片描述

过程说明

同步器包含了 两个节点类型的引用一个指向头节点,而 另一个指向尾节点。当一个线程成功地获取了同步状态(或者锁), 其他线程将无法获取到同步状态, 转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要 保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法: compareAndSetTail

4. 首节点

在这里插入图片描述

  • 首节点获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点
  • 设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可

2.6.2、独占式同步状态获取与释放

1. 独占式同步状态获取与释放的总过程

在这里插入图片描述

  • 首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态
  • 如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部
  • 最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态
  • 如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

2. 头节点的说明

当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。

3. 节点自旋获取同步状态的过程

在这里插入图片描述
过程说明

节点进入同步队列之后, 就进入了一个自旋的过程每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。

4. 只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?

  1. 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点
  2. 维护同步队列的FIFO原则

5. 总结

  • 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋
  • 移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态
  • 在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点

2.6.3、共享式同步状态获取与释放(读写锁)

1. 共享式锁与独占锁的区别

  • 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态
  • 共享式和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。

在这里插入图片描述
图示说明

左半部分,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞,右半部分是独占式访问资源时,同一时刻其他访问均被阻塞。

2. 成功获取到同步状态并退出自旋的条件

如果当前节点的前驱为头节点时,尝试获取同步状态,如果tryAcquireShared(int arg)方法返回值返回值大于等于0表示该次获取同步状态成功并从自旋过程中退出

2.6.4、自定义同步器实现自定义独占锁案例

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

public class test {
    public static void main(String[] args) {
        MyLock lock = new MyLock();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println(Thread.currentThread().getName() + "  locking....");
                TimeUnit.SECONDS.sleep(1L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                System.out.println(Thread.currentThread().getName() + "  unlocking....");
                lock.unlock();
            }
        },"t1").start();

        new Thread(()->{
            lock.lock();

            try{
                System.out.println(Thread.currentThread().getName() + "  locking....");
            }finally {
                System.out.println(Thread.currentThread().getName() + "  unlocking....");
                lock.unlock();
            }
        },"t2").start();
    }
}

// 独占锁
class MyLock implements Lock{

    class MySync extends AbstractQueuedSynchronizer{

        @Override
        protected boolean tryAcquire(int arg) {
            if(compareAndSetState(0,1)){
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int arg) {
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        protected Condition newCondition(){
            return new ConditionObject();
        }
    }

    private MySync sync = new MySync();

    @Override
    public void lock() {
        sync.acquire(1);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1,unit.toNanos(time));
    }

    @Override
    public void unlock() {
        sync.release(1);
    }

    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

同步器重写的方法

  • tryAcquire()
  • tryRelease()
  • isHeldExclusively()
  • newCondition()

3、重入锁

3.1、什么是重入锁?

重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁

3.2、重入锁例子

  1. synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。
  2. ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞

3.3、实现重入锁需要解决的问题

  1. 线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  2. 锁的最终释放:线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。(就是获得了几次就要释放几次

3.4、实现重入锁的解决办法

  1. 线程再次获取锁通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。
  2. 锁的最终释放成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值。该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将占有线程设置为null,并返回true,表示释放成功

3.5、公平与非公平获取锁

1. 公平锁说明

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。

2. 公平锁的好处

公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。

3. 非公平锁的说明

对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同。

4. 非公平锁的坏处

非公平性锁可能使线程“饥饿”

5. 非公平锁的好处

但极少的线程切换,保证了其更大的吞吐量

4、读写锁

4.1、什么是读写锁?

读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升

4.2、读写锁的特点

  1. 保证写操作对读操作的可见性以及并发性的提升
  2. 读写锁能够简化读写交互场景的编程方式
  3. 读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量

4.3、读写锁的工作流程

当写操作开始时所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读,保证写操作对读操作的可见性

4.4、读写锁的实现分析

4.4.1、 读写状态的设计

读写状态就是其同步器的同步状态。在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写

在这里插入图片描述

  • S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取

4.4.2、写锁的获取与释放

1. 写锁简介

写锁一个支持重进入的排它锁

2. 写锁获取过程

  • 如果当前线程已经获取了写锁,则增加写状态
  • 如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态

3. 写锁不能获取的情况

如果存在读锁,则写锁不能被获取

原因

读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞

4. 写锁的释放过程

写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见

4.4.3、读锁的获取与释放

1. 读锁简介

读锁是一个支持重进入的共享锁

2. 获取过程

它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态

3. 释放过程

读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)。

4.4.4、锁降级

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

5、LockSupport工具

LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程。

在这里插入图片描述

6、Condition接口

6.1、Condition接口简介

  • 任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。
  • Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式

6.2、Object的监视器方法与Condition接口的对比

在这里插入图片描述

6.3、Condition接口的方法

  • 当调用await()方法后,当前线程会释放锁并在此等待。
  • 其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。

在这里插入图片描述

6.4、Condition的实现分析

6.4.1、等待队列

1. 什么是等待队列?

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用该线程就是在Condition对象上等待的线程

2. 等待队列的基本结构

在这里插入图片描述

  • 一个Condition包含一个等待队列
  • Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。
  • 当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。该节点更新过程是由锁来保证线程安全的。
  • Condition拥有首尾节点的引用

3. 并发包中的Lock的同步队列和等待队列

在这里插入图片描述

而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列

6.4.2、等待

  • 调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。
  • 如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中

调用await()方法后相关线程的工作流程

  • 调用await()方法的线程成功获取了锁的线程,也就是同步队列中的首节点,该方法会将当前线程构造成节点并加入等待队列中然后释放同步状态唤醒同步队列中的后继节点,然后当前线程会进入等待状态
  • 当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
  • 同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中

6.4.3、通知

1. 当前线程加入等待队列

在这里插入图片描述
调用该方法的前置条件当前线程必须获取了锁

2. 节点从等待队列移动到同步队列的过程

在这里插入图片描述

  1. 通过调用同步器的enq(Node node)方法,等待队列中的头节点线程安全地移动到同步队列
  2. 当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程

3. Condition的signalAll()

Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程

相关文章
|
1天前
|
安全 Java 开发者
Java中的读写锁ReentrantReadWriteLock详解,存在一个小缺陷
Java中的读写锁ReentrantReadWriteLock详解,存在一个小缺陷
10 2
|
1天前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第11天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个方面,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。我们将通过实例和代码片段来说明这些概念和技术。
2 0
|
1天前
|
Java 调度
Java并发编程:深入理解线程池
【5月更文挑战第11天】本文将深入探讨Java中的线程池,包括其基本概念、工作原理以及如何使用。我们将通过实例来解释线程池的优点,如提高性能和资源利用率,以及如何避免常见的并发问题。我们还将讨论Java中线程池的实现,包括Executor框架和ThreadPoolExecutor类,并展示如何创建和管理线程池。最后,我们将讨论线程池的一些高级特性,如任务调度、线程优先级和异常处理。
|
1天前
|
安全 Java 数据安全/隐私保护
【JAVA进阶篇教学】第十一篇:Java中ReentrantLock锁讲解
【JAVA进阶篇教学】第十一篇:Java中ReentrantLock锁讲解
|
1天前
|
安全 Java
【JAVA进阶篇教学】第十篇:Java中线程安全、锁讲解
【JAVA进阶篇教学】第十篇:Java中线程安全、锁讲解
|
2天前
|
缓存 Java 数据库
Java并发编程学习11-任务执行演示
【5月更文挑战第4天】本篇将结合任务执行和 Executor 框架的基础知识,演示一些不同版本的任务执行Demo,并且每个版本都实现了不同程度的并发性。
20 4
Java并发编程学习11-任务执行演示
|
3天前
|
Java 数据库
【Java多线程】对线程池的理解并模拟实现线程池
【Java多线程】对线程池的理解并模拟实现线程池
11 1
|
22小时前
|
Java
Java一分钟:线程协作:wait(), notify(), notifyAll()
【5月更文挑战第11天】本文介绍了Java多线程编程中的`wait()`, `notify()`, `notifyAll()`方法,它们用于线程间通信和同步。这些方法在`synchronized`代码块中使用,控制线程执行和资源访问。文章讨论了常见问题,如死锁、未捕获异常、同步使用错误及通知错误,并提供了生产者-消费者模型的示例代码,强调理解并正确使用这些方法对实现线程协作的重要性。
9 3
|
22小时前
|
安全 算法 Java
Java一分钟:线程同步:synchronized关键字
【5月更文挑战第11天】Java中的`synchronized`关键字用于线程同步,防止竞态条件,确保数据一致性。本文介绍了其工作原理、常见问题及避免策略。同步方法和同步代码块是两种使用形式,需注意避免死锁、过度使用导致的性能影响以及理解锁的可重入性和升级降级机制。示例展示了同步方法和代码块的运用,以及如何避免死锁。正确使用`synchronized`是编写多线程安全代码的核心。
10 2
|
22小时前
|
安全 Java 调度
Java一分钟:多线程编程初步:Thread类与Runnable接口
【5月更文挑战第11天】本文介绍了Java中创建线程的两种方式:继承Thread类和实现Runnable接口,并讨论了多线程编程中的常见问题,如资源浪费、线程安全、死锁和优先级问题,提出了解决策略。示例展示了线程通信的生产者-消费者模型,强调理解和掌握线程操作对编写高效并发程序的重要性。
10 3