分享

Java并发入门原理教程(五)

 夜猫速读 2022-05-05 发布于湖北

1、并发锁之 Lock 接口

1. 前言

本节内容主要是对 Java 并发锁之 Lock 接口进行介绍,Lock 是类似于 synchronized 的另外一种锁的使用,那么本节我们会对 Lock 进行详细的介绍,主要知识点如下:

  • Lock 接口的介绍,这是我们开始认识 Lock 的敲门砖,本节课程的基础知识;

  • Lock 接口相比于 synchronized 关键字的优点,这也是我们学习 Lock 接口的意义所在;

  • Lock 接口的常用方法介绍,了解 Lock 接口中的常用方法,是本节内容的核心知识点。

Lock 是一个接口,并非一个实现类,本节内容主要对 Lock 接口进行一个意义、结构及方法的介绍,为后续讲解 Lock 接口的实现类常用锁奠定一个扎实的基础。

2. Lock 接口的介绍

Lock 接口的诞生:在 Java 中锁的实现可以由 synchronized 关键字来完成,但在 Java5 之后,出现了一种新的方式来实现,即 Lock 接口。

诞生的意义:Lock 接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括 hand-over-hand 和锁重排算法)中使用这些规则。主要的实现是 ReentrantLock。对于 ReentrantLock,后续有专门的小节进行讲解。

JDK 1.5 前的 synchronized:在多线程的情况下,当一段代码被 synchronized 修饰之后,同一时刻只能被一个线程访问,其他线程都必须等到该线程释放锁之后才能有机会获取锁访问这段代码。

Lock 接口:实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的 Condition 对象。

Lock 相对于 synchronized 关键字而言更加灵活,你可以自由得选择你想要加锁的地方。当然更高的自由度也带来更多的责任。

使用示例:我们通常会在 try catch 模块中使用 Lock 关键字,在 finally 模块中释放锁。

     Lock lock = new ReentrantLock(); //通过子类进行创建,此处以ReentrantLock进行举例
     lock.lock(); //加锁
     try {
         // 对上锁的逻辑进行操作
     } finally {
         lock.unlock(); //释放锁
     }

3. Lock 接口与 synchronized 关键字的区别

  • 实现:synchronized 关键字基于 JVM 层面实现,JVM 控制锁的获取和释放。Lock 接口基于 JDK 层面,手动进行锁的获取和释放;

  • 使用:synchronized 关键字不用手动释放锁,Lock 接口需要手动释放锁,在 finally 模块中调用 unlock 方法;

  • 锁获取超时机制:synchronized 关键字不支持,Lock 接口支持;

  • 获取锁中断机制:synchronized 关键字不支持,Lock 接口支持;

  • 释放锁的条件:synchronized 关键字在满足占有锁的线程执行完毕,或占有锁的线程异常退出,或占有锁的线程进入 waiting 状态才会释放锁。Lock 接口调用 unlock 方法释放锁;

  • 公平性:synchronized 关键字为非公平锁。Lock 接口可以通过入参自行设置锁的公平性。

4. Lock 接口相比 synchronized 关键字的优势

我们通过两个个案例分析来了解 Lock 接口的优势所在。案例 1 :在使用 synchronized 关键字的情形下,假如占有锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。

案例 1 分析:该案例体现了 synchronized 的缺陷,当线程被占有时,其他线程会陷入无条件的长期等待。这是非常可怕的,因为系统资源有限,最终可能导致系统崩溃。

案例 1 解决:Lock 接口中的 tryLock (long time, TimeUnit unit) 方法或者响应中断 lockInterruptibly () 方法,能够解决这种长期等待的情况。

案例 2 :我们知道,当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用 synchronized 关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。

案例 2 分析:该案例体现了 synchronized 的缺陷,悲观锁的缺陷。我们说过,如果只是读操作,没有增删改操作的话,多线程环境下无需加锁。但是这种情况下,如果在同一时间多个线程进行读操作,synchronized 会 block 其他的读操作,这是不合理的。

案例 2 解决:Lock 接口家族也可以解决这种情况,后续我们会对 ReadWriteLock 接口的一个子类 ReentrantReadWriteLock 进行讲解。

总结:Lock 接口实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作,能够解决 synchronized 不能够避免的问题。

5. Lock 接口的常用方法

我们来简单的看下,JDK 中 Lock 接口的源码中所包含的方法:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

方法介绍

  1. void lock():获取锁。如果锁不可用,出于线程调度目的,将禁用当前线程,并且在获得锁之前,该线程将一直处于休眠状态;

  2. void lockInterruptibly():如果当前线程未被中断,则获取锁;

  3. boolean tryLock():仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false;

  4. boolean tryLock(long time, TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁;

  5. void unlock():释放锁。在等待条件前,锁必须由当前线程保持。调用 Condition.await () 将在等待前以原子方式释放锁,并在等待返回前重新获取锁;

  6. Condition newCondition():返回绑定到此 Lock 实例的新 Condition 实例。

Tips:对 Lock 接口方法的使用,我们必须基于子类进行 Lock 的创建来展示,由于目前我们还未接触 Lock 接口的实现子类,此处只做方法的介绍。后续对 ReentrantLock 进行讲解时,会进行深入讲解。

6. 小结

本节主要是对 Lock 接口的常用方法进行了介绍,为本节内容的核心知识。除了方法的介绍外,本节内容不容忽视的一个重点内容是 synchronized 关键字与 Lock 接口的区别,以及 Lock 接口的优势所在。

掌握本节内容,有助于同学对后续实现类锁的学习,为后续的学习奠定了良好的基础。

2、乐观锁与悲观锁

1. 前言

本节内容主要是对 Java 乐观锁与悲观锁进行更加深入的讲解,本节内容更加偏重于对乐观锁的讲解,因为 synchronized 悲观锁对于大部分学习者并不陌生,本节主要内容如下:

  • 乐观锁与悲观锁的概念,之前有所讲解,这里用很小的篇幅进行知识的回顾,巩固;

  • 乐观锁与悲观锁的使用场景介绍,通过理解悲观锁与乐观锁不同的风格,理解什么场景下需要选择合适的锁,为本节的重点内容之一;

  • 了解乐观锁的缺点,乐观锁有自己的特定的缺陷,不同的锁都有自己的优点与缺点;

  • 了解乐观锁缺陷的解决方式,作为本节内容的重点之一;

  • 通过引入 Atomic 操作,实现乐观锁,为本节内容的核心,通过对比 synchronized 的实现,用两种锁机制实现同一个需求。

本节内容为 CAS 原理的进阶讲解,也是乐观锁与悲观锁的深入讲解。因为对于并发编程,悲观锁与乐观锁的涉及频率非常高,所以对其进行更加深入的讲解。

2. 乐观锁与悲观锁的概念

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样其他线程想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。

乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。

乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。

3. 乐观锁与悲观锁的使用场景

简单的来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)。

  • 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 CPU 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能;

  • 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。

总结:乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。

但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断地进行 retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

4. 乐观锁的缺点

ABA 问题:我们之前也对此进行过介绍。

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?

很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA” 问题。

循环时间长开销大:在特定场景下会有效率问题。

自旋 CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。

总结:我们这里主要关注 ABA 问题。循环时间长开销大的问题,在特定场景下很难避免的,因为所有的操作都需要在合适自己的场景下才能发挥出自己特有的优势。

5. ABA 问题解决之版本号机制

讲解 CAS 原理时,对于解决办法进行了简要的介绍,仅仅是一笔带过。这里进行较详细的阐释。其实 ABA 问题的解决,我们通常通过如下方式进行解决:版本号机制。我们一起来看下版本号机制:

版本号机制:一般是在数据中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加 1。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

场景示例:假设商店类 Shop 中有一个 version 字段,当前值为 1 ;而当前商品数量为 50。

  • 店员 A 此时将其读出( version=1 ),并将商品数量扣除 10,更新为 50 - 10 = 40;

  • 在店员 A 操作的过程中,店员 B 也读入此信息( version=1 ),并将商品数量扣除 20,更新为 50 - 20 = 30;

  • 店员 A 完成了修改工作,将数据版本号加 1( version=2 ),商品数量为 40,提交更新,此时由于提交数据版本大于记录当前版本,数据被更新,数据记录 version 更新为 2 ;

  • 店员 B 完成了操作,也将版本号加 1( version=2 ),试图更新商品数量为 30。但此时比对数据记录版本时发现,店员 B 提交的数据版本号为 2 ,数据记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,店员 B 的提交被驳回;

  • 店员 B 再次重新获取数据,version = 2,商品数量 40。在这个基础上继续执行自己扣除 20 的操作,商品数量更新为 40 - 20 = 20;

  • 店员 B 将版本号加 1 ,version = 3,将之前的记录 version 2 更新为 3 ,将之前的数量 40 更新 为 20。

从如上描述来看,所有的操作都不会出现脏数据,关键在于版本号的控制。

Tips:Java 对于乐观锁的使用进行了良好的封装,我们可以直接使用并发编程包来进行乐观锁的使用。本节接下来所使用的 Atomic 操作即为封装好的操作。

之所以还要对 CAS 原理以及 ABA 问题进行深入的分析,主要是为了让学习者了解底层的原理,以便更好地在不同的场景下选择使用锁的类型。

6. Atomic 操作实现乐观锁

为了更好地理解悲观锁与乐观锁,我们通过设置一个简单的示例场景来进行分析。并且我们采用悲观锁 synchronized 和乐观锁 Atomic 操作进行分别实现。

Atomic 操作类,指的是 java.util.concurrent.atomic 包下,一系列以 Atomic 开头的包装类。例如 AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于 Boolean,Integer,Long 类型的原子性操作。

Atomic 操作的底层实现正是利用的 CAS 机制,而 CAS 机制即乐观锁。

场景设计

  • 创建两个线程,创建方式可自选;

  • 定义一个全局共享的 static int 变量 count,初始值为 0;

  • 两个线程同时操作 count,每次操作 count 加 1;

  • 每个线程做 100 次 count 的增加操作。

结果预期:最终 count 的值应该为 200。

悲观锁 synchronized 实现

public class DemoTest extends Thread{
    private static int count = 0; //定义count = 0
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) { //通过for循环创建两个线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    //每个线程让count自增100次
                    for (int i = 0; i < 100; i++) {
                        synchronized (DemoTest.class){
                            count++;
                        }
                    }
                }
            }). start();
        }
        try{
            Thread.sleep(2000);
        }catch (Exception e){
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

结果验证

200
代码块1

乐观锁 Atomic 操作实现

public class DemoTest extends Thread{
    //Atomic 操作,引入AtomicInteger。这是实现乐观锁的关键所在。
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    //每个线程让count自增100次
                    for (int i = 0; i < 100; i++) {
                        count.incrementAndGet();
                    }
                }
            }). start();
        }
        try{
            Thread.sleep(2000);
        }catch (Exception e){
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

结果验证

200
代码块1

代码解读此处主要关注两个点,第一个是 count 的创建,是通过 AtomicInteger 进行的实例化,这是使用 Atomic 的操作的入口,也是使用 CAS 乐观锁的一个标志。

第二个是需要关注 count 的增加 1 调用是 AtomicInteger 中 的 incrementAndGet 方法,该方法是原子性操作,遵循 CAS 原理。

7. 小结

本节内容所有的知识点讲解都可以作为重点内容进行学习。悲观锁与乐观锁是并发编程中所涉及的非常重要的内容,一定要深入的理解和掌握。

对于课程中 CAS 原理的进阶讲解,也是非常重要的知识点,对于 ABA 问题,是并发编程中所涉及的高频话题、考题,也要对此加以理解和掌握。

3、AQS 原理

1. 前言

本节内容主要是对 AQS 原理的讲解,之所以需要了解 AQS 原理,是因为后续讲解的 ReentrantLock 是基于 AQS 原理的。本节内容相较于其他小节难度上会大一些,基础薄弱的学习者可以选择性学习本节内容或者跳过本节内容。

  • 了解什么是 AQS,这是认识 AQS 原理的前提,是本节的基础知识点;

  • 了解 AQS 提供的两种锁功能,对其有一个全局的了解;

  • 了解 AQS 的内部框架原理结构,这是本节课程的核心所在,其他所有的知识点讲解都是围绕这一知识点的;

  • 释放锁以及添加线程对于 AQS 内部的变化,这是本节课程的重点知识,了解队列的学习者能够更快的掌握这部分知识;

  • AQS 与 ReentrantLock 的联系,这是本节课程与 ReentrantLock 之间的过度知识。

2. 什么是 AQS

定义:AbstarctQueuedSynchronizer 简称 AQS,是一个用于构建锁和同步容器的框架。

事实上 concurrent 包内许多类都是基于 AQS 构建的,例如 ReentrantLock,ReentrantReadWriteLock,FutureTask 等。AQS 解决了在实现同步容器时大量的细节问题。

AQS 使用一个 FIFO 队列表示排队等待锁的线程,队列头结点称作 “哨兵节点” 或者 “哑结点”,它不与任何线程关联。其他的节点与等待线程关联,每个阶段维护一个等待状态 waitStatus。

3. AQS 提供的两种功能

从使用层面来说,AQS 的锁功能分为两种:独占锁和共享锁。

独占锁:每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是以独占方式实现的互斥锁;共享锁:允许多个线程同时获取锁,并发访问共享资源,比如 ReentrantReadWriteLock。

4. AQS 的内部实现

AQS 的实现依赖内部的同步队列,也就是 FIFO 的双向队列,如果当前线程竞争锁失败,那么 AQS 会把当前线程以及等待状态信息构造成一个 Node 加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点 (线程)。

如下图所示,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),其实就是个双端双向链表,其数据结构如下:

Tips:AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始,很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线程争抢锁失败后会封装成 Node 加入到 ASQ 队列中去。

5. 添加线程对于 AQS 队列的变化

当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加线程的场景。

这里会涉及到两个变化

  • 队列操作的变化:新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 节点指向自己;

  • tail 指向变化:通过同步器将 tail 重新指向新的尾部节点。

6. 释放锁移除节点对于 AQS 队列的变化

第一个 head 节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下:

这个过程也是涉及到两个变化:

head 节点指向:修改 head 节点指向下一个获得锁的节点;新的获得锁的节点:如图所示,第二个节点被 head 指向了,此时将 prev 的指针指向 null,因为它自己本身就是第一个首节点,所以 pre 指向 null。

7. AQS 与 ReentrantLock 的联系

ReentrantLock 实现:ReentrantLock 是根据 AQS 实现的独占锁,提供了两个构造方法如下:

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

ReentrantLock 有三个内部类:Sync,NonfairSync,FairSync,继承关系如下:

总结:我们可以看到,这三个内部类都是基于 AQS 进行的实现,由此可见,ReentrantLock 是基于 AQS 进行的实现。

ReentrantLock 提供两种类型的锁:公平锁,非公平锁。分别对应 FairSync,NonfairSync。默认实现是 NonFairSync。

8. 小结

本节内容为 AQS 原理进行讲解,会涉及到一些原理问题,队列问题,基础薄弱的学习者可以跳过或者选看本节内容,不会影响后续课程的学习。本节内容其实主要为了提供原理性的知识,对本节的知识掌握,使我们不仅仅是一个使用者。

4、ReentrantLock 使用

1. 前言

本节内容主要是对 ReentrantLock 的使用进行讲解,之前对于 Lock 接口进行了讲解,ReentrantLock 是 Lock 接口的常用实现子类,占据着十分重要的地位。本节内容的知识点如下:

  • ReentrantLock 基本方法的使用,即 lock 与 unlock 方法的使用,这是最基础的方法使用,为重点内容;

  • ReentrantLock lockInterruptibly 与 tryLock 方法的使用,也是经常使用到的方法,为本节重点内容;

  • ReentrantLock 公平锁与非公平锁的使用,也是本节的重点内容;

  • ReentrantLock 其他方法的介绍与使用。

通篇来看,ReentrantLock 所有的知识点均为重点内容,是必须要掌握的内容。

2. ReentrantLock 介绍

ReentrantLock 在 Java 中也是一个基础的锁,ReentrantLock 实现 Lock 接口提供一系列的基础函数,开发人员可以灵活的使用函数满足各种复杂多变应用场景。

定义:ReentrantLock 是一个可重入且独占式的锁,它具有与使用 synchronized 监视器锁相同的基本行为和语义,但与 synchronized 关键字相比,它更灵活、更强大,增加了轮询、超时、中断等高级功能。

ReentrantLock,顾名思义,它是支持可重入锁的锁,是一种递归无阻塞的同步机制。除此之外,该锁还支持获取锁时的公平和非公平选择。

公平性:ReentrantLock 的内部类 Sync 继承了 AQS,分为公平锁 FairSync 和非公平锁 NonfairSync。

如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平锁的获取,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。

ReentrantLock 的公平与否,可以通过它的构造函数来决定。

3. ReentrantLock 基本方法 lock 与 unlock 的使用

我们使用一个之前涉及到的 synchronized 的场景,通过 lock 接口进行实现。

场景回顾

  • 创建两个线程,创建方式可自选;

  • 定义一个全局共享的 static int 变量 count,初始值为 0;

  • 两个线程同时操作 count,每次操作 count 加 1;

  • 每个线程做 100 次 count 的增加操作。

结果预期:获取到的结果为 200。之前我们使用了 synchronized 关键字和乐观锁 Amotic 操作进行了实现,那么此处我们进行 ReentrantLock 的实现方式。

实现步骤

  • step 1 :创建 ReentrantLock 实例,以便于调用 lock 方法和 unlock 方法;

  • step 2:在 synchronized 的同步代码块处,将 synchronized 实现替换为 lock 实现。

实例

public class DemoTest{
    private static int count = 0; //定义count = 0
    private static ReentrantLock lock = new ReentrantLock();//创建 lock 实例
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) { //通过for循环创建两个线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    //每个线程让count自增100次
                    for (int i = 0; i < 100; i++) {
                        try {
                            lock.lock(); //调用 lock 方法
                            count++;
                        } finally {
                            lock.unlock(); //调用unlock方法释放锁
                        }
                    }
                }
            }). start();
        }
        try{
            Thread.sleep(2000);
        }catch (Exception e){
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

代码分析我们通过 try finally 模块,替代了之前的 synchronized 代码块,顺利的实现了多线程下的并发。

4. tryLock 方法

我们之前进行过介绍,Lock 接口包含了两种 tryLock 方法,一种无参数,一种带参数。

  • boolean tryLock():仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false;

  • boolean tryLock(long time, TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁;

为了了解两种方法的使用,我们先来设置一个简单的使用场景。

场景设置

  • 创建两个线程,创建方式自选;

  • 两个线程同时执行代码逻辑;

  • 代码逻辑使用 boolean tryLock () 方法,如果获取到锁,执行打印当前线程名称,并沉睡 5000 毫秒;如果未获取锁,则打印 timeout,并处理异常信息;

  • 观察结果并进行分析;

  • 修改代码,使用 boolean tryLock (long time, TimeUnit unit) 方法,设置时间为 4000 毫秒;

  • 观察结果并进行分析;

  • 再次修改代码,使用 boolean tryLock (long time, TimeUnit unit) 方法,设置时间为 6000 毫秒;

  • 观察结果并进行分析。

实例:使用 boolean tryLock () 方法

public class DemoTest implements Runnable{
    private static Lock locks = new ReentrantLock();
    @Override
    public void run() {
        try {
            if(locks.tryLock()){ //尝试获取锁,获取成功则进入执行,不成功则执行finally模块
                System.out.println(Thread.currentThread().getName()+"-->");
                Thread.sleep(5000);
            }else{
                System.out.println(Thread.currentThread().getName()+" time out ");
            }
        } catch (InterruptedException e) {
             e.printStackTrace();
        }finally {
            try {
                locks.unlock();
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + "未获取到锁,释放锁抛出异常");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        DemoTest test =new DemoTest();
        Thread t1 = new Thread(test);
        Thread t2 = new Thread(test);
        t1. start();
        t2. start();
        t1.join();
        t2.join();
        System.out.println("over");
    }
}

结果验证

Thread-1-->
Thread-0 time out
Thread-0 未获取到锁,释放锁抛出异常
over
代码块1234

结果分析:从打印的结果来看, Thread-1 获取了锁权限,而 Thread-0 没有获取锁权限,这就是 tryLock,没有获取到锁资源则放弃执行,直接调用 finally。

实例:使用 boolean tryLock (4000 ms) 方法将 if 判断进行修改如下:

 if(locks.tryLock(4000,TimeUnit.MILLISECONDS)){ //尝试获取锁,获取成功则进入执行,不成功则执行finally模块
      System.out.println(Thread.currentThread().getName()+"-->");
      Thread.sleep(5000);
  }
代码块1234

结果验证

Thread-1-->
Thread-0 time out
Thread-0 未获取到锁,释放锁抛出异常
over
代码块1234

结果分析:tryLock 方法,虽然等待 4000 毫秒,但是这段时间不足以等待 Thread-1 释放资源锁,所以还是超时。我们换成 6000 毫秒试试。

实例:使用 boolean tryLock (6000 ms) 方法将 if 判断进行修改如下:

 if(locks.tryLock(6000,TimeUnit.MILLISECONDS)){ //尝试获取锁,获取成功则进入执行,不成功则执行finally模块
      System.out.println(Thread.currentThread().getName()+"-->");
      Thread.sleep(5000);
  }

结果验证

Thread-1-->
Thread-0-->
over
代码块123

结果分析:tryLock 方法,等待 6000 毫秒,Thread-1 先进入执行,5000 毫秒后 Thread-0 进入执行,都能够有机会获取锁。

总结:以上就是 tryLock 方法的使用,可以指定最长的获取锁的时间,如果获取则执行,未获取则放弃执行。

5. 公平锁与非公平锁

分类:根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。

公平锁:表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。

非公平锁:非公平锁则在运行时闯入,不遵循先到先执行的规则。

ReentrantLock:ReentrantLock 提供了公平和非公平锁的实现。

ReentrantLock 实例

//公平锁
ReentrantLock pairLock = new ReentrantLock(true);
//非公平锁
ReentrantLock pairLock1 = new ReentrantLock(false);
//如果构造函数不传递参数,则默认是非公平锁。
ReentrantLock pairLock2 = new ReentrantLock();
代码块123456

场景介绍:通过模拟一个场景假设,来了解公平锁与非公平锁。

  • 假设线程 A 已经持有了锁,这时候线程 B 请求该锁将会被挂起;

  • 当线程 A 释放锁后,假如当前有线程 C 也需要获取该锁,如果采用非公平锁方式,则根据线程调度策略,线程 B 和线程 C 两者之一可能获取锁,这时候不需要任何其他干涉;

  • 而如果使用公平锁则需要把 C 挂起,让 B 获取当前锁,因为 B 先到所以先执行。

Tips:在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

6. lockInterruptibly 方法

lockInterruptibly () 方法:能够中断等待获取锁的线程。当两个线程同时通过 lock.lockInterruptibly () 获取某个锁时,假若此时线程 A 获取到了锁,而线程 B 只有等待,那么对线程 B 调用 threadB.interrupt () 方法能够中断线程 B 的等待过程。

场景设计

  • 创建两个线程,创建方式可自选实现;

  • 第一个线程先调用 start 方法,沉睡 20 毫秒后调用第二个线程的 start 方法,确保第一个线程先获取锁,第二个线程进入等待;

  • 最后调用第二个线程的 interrupt 方法,终止线程;

  • run 方法的逻辑为打印 0,1,2,3,4,每打印一个数字前,先沉睡 1000 毫秒;

  • 观察结果,看是否第二个线程被终止。

实例

public class DemoTest{
    private Lock lock = new ReentrantLock();
    public void doBussiness() {
        String name = Thread.currentThread().getName();
        try {
            System.out.println(name + " 开始获取锁");
            lock.lockInterruptibly(); //调用lockInterruptibly方法,表示可中断等待
            System.out.println(name + " 得到锁,开工干活");
            for (int i=0; i<5; i++) {
                Thread.sleep(1000);
                System.out.println(name + " : " + i);
            }
        } catch (InterruptedException e) {
            System.out.println(name + " 被中断");
        } finally {
            try {
                lock.unlock();
                System.out.println(name + " 释放锁");
            } catch (Exception e) {
                System.out.println(name + " : 没有得到锁的线程运行结束");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        final DemoTest lockTest = new DemoTest();
        Thread t0 = new Thread(new Runnable() {
                    public void run() {
                        lockTest.doBussiness();
                    }});
        Thread t1 = new Thread(new Runnable() {
                    public void run() {
                        lockTest.doBussiness();
                    }});
        t0. start();
        Thread.sleep(20);
        t1. start();
        t1.interrupt();
    }
}

结果验证:可以看到,thread -1 被中断了。

Thread-0 开始获取锁
Thread-0 得到锁,开工干活
Thread-1 开始获取锁
Thread-1 被中断
Thread-1 : 没有得到锁的线程运行结束
Thread-0 : 0
Thread-0 : 1
Thread-0 : 2
Thread-0 : 3
Thread-0 : 4
Thread-0 释放锁

7. ReentrantLock 其他方法介绍

对 ReentrantLock 来说,方法很多样,如下介绍 ReentrantLock 其他的方法,有兴趣的同学可以自行的尝试使用。

  • getHoldCount():当前线程调用 lock () 方法的次数;

  • getQueueLength():当前正在等待获取 Lock 锁的线程的估计数;

  • getWaitQueueLength(Condition condition):当前正在等待状态的线程的估计数,需要传入 Condition 对象;

  • hasWaiters(Condition condition):查询是否有线程正在等待与 Lock 锁有关的 Condition 条件;

  • hasQueuedThread(Thread thread):查询指定的线程是否正在等待获取 Lock 锁;

  • hasQueuedThreads():查询是否有线程正在等待获取此锁定;

  • isFair():判断当前 Lock 锁是不是公平锁;

  • isHeldByCurrentThread():查询当前线程是否保持此锁定;

  • isLocked():查询此锁定是否由任意线程保持。

8. 小结

本节内容对 ReentrantLock 进行了比较详细的讲解,通篇内容皆为重点内容,需要同学们进行细致的掌握。核心内容即为 ReentrantLock 的使用,可以根据小节中的实例进行自行的编码和试验,更深刻的理解 ReentrantLock 的使用。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多