Java并发编程进阶——并发锁

1 JAVA 多线程锁介绍

1.1 悲观锁

定义悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改(很悲观),所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。

悲观锁的实现:开发中常见的悲观锁实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。

实例:Java 中的 synchronized 关键字就是一种悲观锁,一个线程在操作时,其他的线程必须等待,直到锁被释放才可进入方法进行执行,保证了线程和数据的安全性,同一时间,只能有一条线程进入执行。

public class Student {
   
    private String name;

    public synchronized String getName() {
   
        return name;
    }

    public synchronized void setName(String name) {
   
        this.name = name;
    }
}

代码分析 :假设有 3 条线程,如下图,线程 3 正在操作 Student 类,此时线程 1 和线程 2 必须要等待线程 3 执行完毕方可进入,这就是悲观锁。
在这里插入图片描述

1.2 乐观锁

定义:乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新的时候,才会正式对数据冲突与否进行检测。

乐观锁的实现:依旧拿数据库的锁进行比较介绍,乐观锁并不会使用数据库提供的锁机制, 一般在表中添加 version 宇段或者使用业务状态来实现。 乐观锁直到提交时才锁定,所以不会产生任何死锁。

Java 中的乐观锁:Java中的 CAS采用的就是乐观锁技术,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
在这里插入图片描述

Tips:当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败。注意失败两字,失败意味着有操作,而悲观锁是等待,意味着不能同时操作。

1.3 悲观锁机制存在的问题

  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题;
  • 一个线程持有锁会导致其它所有需要此锁的线程挂起;
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

相比于悲观锁的这些问题,另一个更加有效的锁就是乐观锁。其实乐观锁就是:每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。

1.4 公平锁与非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。Java中的ReentrantLock 提供了公平和非公平锁的实现。

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

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

1.5 独占锁与共享锁

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。

独占锁:保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占锁方式实现的。

共享锁:则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。

共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。

1.6 自旋锁

由于 Java 中的线程是与操作系统中的线程相互对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。而当该线程获取到锁后又需要将其切换回用户态执行。用户状态与内核状态的切换开销是比较大的,在一定程度上会影响并发性能。

自旋锁:自旋锁是当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用-XX:PreBlockSpinsh 参数设置该值)。

很有可能在后面几次尝试中其他线程己经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。

2 并发锁之 Lock 接口

2.1 Lock 接口的介绍

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

诞生的意义:Lock 接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括 hand-over-hand 和锁重排算法)中使用这些规则。主要的实现是 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(); //释放锁
     }

2.2 Lock 接口与 synchronized 关键字的区别

  • 实现:synchronized 关键字基于 JVM 层面实现,JVM 控制锁的获取和释放。Lock 接口基于 JDK 层面,手动进行锁的获取和释放;
  • 使用:synchronized 关键字不用手动释放锁,Lock 接口需要手动释放锁,在 finally 模块中调用 unlock 方法;
  • 锁获取超时机制:synchronized 关键字不支持,Lock 接口支持;
  • 获取锁中断机制:synchronized 关键字不支持,Lock 接口支持;
  • 释放锁的条件:synchronized 关键字在满足占有锁的线程执行完毕,或占有锁的线程异常退出,或占有锁的线程进入 waiting 状态才会释放锁。Lock 接口调用 unlock 方法释放锁;
  • 公平性:synchronized 关键字为非公平锁。Lock 接口可以通过入参自行设置锁的公平性。

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

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

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

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

解决方案:Lock 接口家族的ReentrantReadWriteLock也可以解决这种情况。

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

2.4 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();
}

方法介绍

  • void lock():获取锁。如果锁不可用,出于线程调度目的,将禁用当前线程,并且在获得锁之前,该线程将一直处于休眠状态;
  • void lockInterruptibly():如果当前线程未被中断,则获取锁;
  • boolean tryLock():仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false;
  • boolean tryLock(long time, TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁;
  • void unlock():释放锁。调用前锁必须由当前线程保持;
  • Condition newCondition():返回绑定到此 Lock 实例的新 Condition 实例。

3 乐观锁与悲观锁

3.1 乐观锁与悲观锁的概念

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

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

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

3.2 乐观锁与悲观锁的使用场景

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

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

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

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

3.3 乐观锁的缺点

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

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

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

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

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

3.4 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 操作即为封装好的操作。

3.5 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

乐观锁 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

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

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

4 AQS 原理

4.1 什么是 AQS

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

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

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

4.2 AQS 提供的两种功能

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

独占锁:每次只能有一个线程持有锁,比如前面演示的 ReentrantLock 就是以独占方式实现的互斥锁;

共享锁:允许多个线程同时获取锁,并发访问共享资源,比如 ReentrantReadWriteLock。

4.3 AQS 的内部实现

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

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

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

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

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

这里会涉及到两个变化:

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

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

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

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

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

4.6 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。

5 ReentrantLock 使用

5.1 ReentrantLock 介绍

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

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

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

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

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

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

5.2 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();
  • 1
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值