锁的分类及介绍

一、锁(Lock)介绍

锁(Lock)是一种并发控制机制,用于保护共享资源,防止多个线程同时访问或修改同一个资源而导致数据不一致或竞态条件的问题。锁提供了互斥访问的机制,确保在某一时刻只有一个线程能够获取到锁,并执行临界区代码。(锁的本质,就是一种资源,是由操作系统维护的一种专门用于同步的资源)

锁在并发编程中起着重要的作用,它可以用来实现线程安全和数据一致性。当多个线程需要访问共享资源时,通过获取锁来争夺资源的使用权。如果某个线程获取到了锁,其他线程则需要等待,直到持有锁的线程释放锁。这样可以确保在同一时间只有一个线程能够访问临界区代码,从而避免了数据竞争和不一致的问题。

常见的锁包括内置锁(如Java中的synchronized关键字)、显式锁(如Java中的ReentrantLock类)、读写锁(如Java中的ReentrantReadWriteLock类)等。锁除了提供了互斥访问的功能外,还可以支持可重入性(一个线程可以多次获取同一个锁)、可中断性(一个线程可以在等待锁的过程中被中断)、公平性(按照线程等待的顺序获得锁的机会)等特性。

通过合理地使用锁,可以保证多线程程序的正确性和效率,并避免由于并发访问共享资源而导致的问题。但是过度使用锁也可能引发死锁或性能瓶颈等问题,因此在设计并发程序时需要根据具体情况选择适当的锁机制。

1.1 java中的锁

Java中的锁机制主要是基于监视器锁(Monitor Lock)实现的。监视器是Java中的一种同步机制,用于实现线程的互斥访问和协调线程之间的通信。

在Java中,每个对象都有一个内置的监视器(也称为锁),可以通过synchronized关键字来获取和释放这个监视器。当一个线程进入synchronized代码块或方法时,它会尝试获取到对应对象的监视器,如果监视器已被其他线程占用,则该线程将进入阻塞状态,直到获取到这个监视器才能继续执行。

基于监视器锁的机制,Java提供了对互斥访问的支持,确保同一时间只有一个线程能够执行临界区代码,从而避免了数据竞争和不一致的问题。同时,Java的监视器锁还提供了可重入性(同一线程可以多次获取同一个锁)、内部锁定和等待/通知机制等特性。

除了基于监视器锁的synchronized关键字,Java还提供了更灵活的锁实现,如ReentrantLock类,它是显式锁的一种实现。显式锁相对于内置的监视器锁,可以提供更多功能,如可定时、可中断的锁等。

总之,Java中的锁机制主要基于监视器锁实现,通过使用synchronized关键字或显式锁,可以有效地管理线程的并发访问和同步操作。

1.2 相关文章:

synchronized&监视器锁_做测试的喵酱的博客-CSDN博客

监视器锁(Monitor Lock)_做测试的喵酱的博客-CSDN博客

二、锁的分类

锁可以按照以下几个维度进行划分:

1、拥有方式:锁的拥有方式指的是锁的获取和释放方式,主要分为两种类型:

  • 独占锁(Exclusive Lock):只允许一个线程获取到锁,其他线程需要等待。常见的独占锁包括内置锁(synchronized)和显式锁(如ReentrantLock)。
  • 共享锁(Shared Lock):允许多个线程同时获得锁,并发地读取共享资源,但阻止其他线程进行写操作。常见的共享锁是读写锁(ReentrantReadWriteLock)。

2、并发策略:锁的并发策略指的是锁在并发环境下对资源的访问策略,常见的并发策略有:

  • 乐观锁(Optimistic Locking):通过标识资源状态的方式进行并发控制,通常使用版本号(Versioning)或时间戳(Timestamp)。
  • 悲观锁(Pessimistic Locking):默认采用独占锁来保护共享资源,在访问资源之前就会获取锁,并阻塞其他线程的访问。

3、应用场景:锁的应用场景可以根据具体的需求和使用情况进行不同的选择,常见的应用场景包括:

  • 内置锁(synchronized):用于保护对象的临界区代码,用于实现线程安全。
  • 显式锁(如ReentrantLock):提供了更灵活的锁操作,例如可重入性、可中断性、公平性等特性。
  • 读写锁(ReentrantReadWriteLock):适用于读多写少的情况,提供了读共享、写独占的并发控制。

总结起来,锁可以按照拥有方式、并发策略和应用场景等进行分类。具体的分类还会因编程语言、框架和使用环境的不同而有所差异。不同类型的锁各有优缺点,选择合适的锁机制可以提高并发性能和数据一致性。

三、乐观锁&悲观锁

3.1 乐观锁

乐观锁(Optimistic Locking)是一种并发控制机制,用于处理并发操作时的数据一致性问题。相对于悲观锁(Pessimistic Locking)需要在整个操作期间持有锁,乐观锁采取一种更加宽松的策略,假设在操作期间不会发生冲突。

乐观锁的基本思想:

每次更新数据时,先读取当前版本号(或时间戳等表示数据状态的值),然后执行修改操作之前,再次检查数据的版本号是否发生了改变。如果数据版本号未改变,说明在操作期间没有其他线程修改过该数据,那么乐观锁认为操作可以成功,否则就意味着有其他线程对数据进行了修改,此时操作可能存在冲突,需要进行相应处理。

在乐观锁的实现中,通常会使用一个版本号字段或时间戳字段来标识数据的版本。当线程读取到数据时,会同时记录下当前的版本号。当线程要更新数据时,在执行更新操作之前会再次检查当前的版本号是否与初始时记录的版本号一致。如果一致,说明数据没有被其他线程修改,可以进行更新操作,并增加版本号。如果不一致,说明数据已被其他线程修改,此时可以选择放弃操作、重试操作或通过其他逻辑处理冲突。

乐观锁的优点:

是在大多数情况下,不需要加锁,避免了线程间的竞争和等待。这样可以提高并发性能和吞吐量。

问题:

乐观锁也存在一些问题,比如可能会出现冲突导致的更新失败,需要进行适当的重试机制,并且对于需要多次操作的复杂场景,乐观锁可能实现起来更加复杂。

在实际应用中,乐观锁通常与版本号、时间戳或哈希值等机制结合使用,以提供简单而高效的并发控制。在数据库领域中,乐观锁常用于解决并发更新问题,在分布式系统中也有类似的应用。

3.1.1 乐观锁在数据库中的应用

在数据库中,乐观锁常用于处理并发更新的场景,以确保数据的一致性和完整性。下面介绍一些常见的乐观锁在数据库中的应用方式:

  1. 版本号(Versioning):在数据表中新增一个版本号字段,每次更新操作都会更新该字段的值。当进行更新操作时,先读取当前数据的版本号,然后进行更新操作之前再次检查版本号是否一致。如果一致,则表示没有其他线程修改过数据,可以执行更新操作并增加版本号;如果不一致,则表示有其他线程修改了数据,此时可以选择放弃操作、重试操作或进行冲突处理。
  2. 时间戳(Timestamp):类似于版本号,使用时间戳记录数据的修改时间。在进行更新操作之前,通过比较当前数据的时间戳与初始读取时记录的时间戳,来判断数据是否被其他线程修改。
  3. 哈希值(Hash Value):将数据的内容生成哈希值,并将哈希值存储在数据表中。在执行更新操作时,重新计算数据的哈希值,并与最初读取时保存的哈希值进行比较。如果哈希值相同,则表示数据未被修改,可以进行更新操作;如果哈希值不同,则表示有其他线程修改了数据。
  4. 检查列(Check Column):在数据表中添加一个额外的检查列,用于记录数据的一些状态信息。在执行更新操作之前,根据检查列的状态来判断是否可以进行更新操作。

乐观锁的应用通常需要一定的编程支持,比如在SQL语句中使用WHERE子句来进行条件判断,或者使用乐观锁相关的函数或语句来处理并发冲突。在实际应用中,具体的乐观锁实现方式会根据数据库的类型、特性和业务需求等因素而有所不同。

3.1.2 乐观锁在java中的应用

在Java中,乐观锁的应用通常涉及到多线程环境下对共享数据的并发更新。以下是一些常见的乐观锁实现方式及其示例:

1、使用版本号(Versioning)

public class OptimisticLockExample {
    private int value;
    private int version;

    public synchronized void updateValue(int newValue) {
        // 保存原始版本号
        int oldVersion = version;
        
        // 模拟耗时操作
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 检查版本号是否发生变化
        if (oldVersion == version) {
            value = newValue;
            version++; // 修改版本号
            System.out.println("Update succeeded!");
        } else {
            System.out.println("Update failed due to concurrent modification!");
        }
    }
}

2、使用Atomic类:

import java.util.concurrent.atomic.AtomicReference;

public class OptimisticLockExample {
    private AtomicReference<Integer> value = new AtomicReference<>();
    
    public void updateValue(int newValue) {
        // 获取当前值和版本号
        Integer oldValue = value.get();
        Integer oldVersion = oldValue != null ? oldValue.hashCode() : null;

        // 模拟耗时操作
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 检查值和版本号是否发生变化
        if (value.compareAndSet(oldValue, newValue)) {
            System.out.println("Update succeeded!");
        } else {
            System.out.println("Update failed due to concurrent modification!");
        }
    }
}

这里使用了AtomicReference类来保证原子性操作,并利用hashCode()方法生成版本号。compareAndSet()方法会比较旧值是否与当前值相等,如果相等则进行更新操作。

乐观锁的具体实现方式还可以使用锁-Free数据结构(如CAS算法)、数据库乐观锁插件、乐观锁注解等。这些方式都旨在通过一定的机制或算法来避免并发操作引起的数据冲突,并保证数据的一致性。在实际应用中,选择适合场景的乐观锁实现方式十分重要。

3.1.2.1 扩展:

原子性:

原子性指的是一个操作要么完整地执行成功,要么完全不执行,不会出现中间状态或部分执行的情况。原子性保证了操作在多线程环境下的一致性和可靠性。

在并发编程中,原子性对于共享数据的读取和写入操作非常重要。多个线程同时访问并修改同一份数据时,如果没有原子性的保证,就可能导致数据不一致、竞态条件(Race Condition)等问题。

为了保证原子性,Java提供了多种机制和类,例如:

  • synchronized关键字:使用synchronized关键字可以将方法或代码块标记为同步,只允许一个线程进入同步区域,防止并发访问。
  • Lock接口及其实现类:Lock接口提供了比synchronized更灵活的锁机制,例如ReentrantLock,它提供了可重入性、公平性等特性。
  • Atomic类:Java.util.concurrent.atomic包下的Atomic类提供了一些原子操作类,如AtomicInteger、AtomicLong等,它们保证了特定操作的原子性。
  • 原子性容器:Java中的ConcurrentHashMap、ConcurrentLinkedQueue等容器类提供了原子性的操作,避免了并发操作导致的数据不一致。

总之,保证操作的原子性能够消除并发编程中的数据竞争问题,确保数据在多线程环境下的一致性。合适地使用同步机制和原子操作可以有效提升程序的并发性能和正确性。

3.2 悲观锁

3.2.1 什么是悲观锁

悲观锁是一种并发控制机制,它基于一种悲观的思想假设,即并发访问共享资源时很可能会发生冲突。因此,在使用悲观锁时,程序会假设其他线程会对共享资源进行修改,并采取相应的措施来防止冲突和数据不一致。

悲观锁的主要特点是,在访问共享资源之前,会先获取锁,以确保当前线程能够独占资源,并在完成操作后释放锁。悲观锁的实现方式包括使用互斥锁、synchronized关键字、数据库锁等。

当一个线程获取到悲观锁后,其他线程需要等待直到该锁被释放,这样可以保证在同一时间只有一个线程能够对共享资源进行修改,从而避免了数据竞争和并发冲突的问题。但是,悲观锁可能导致高并发环境下的性能下降,因为它限制了同时访问共享资源的线程数量。

悲观锁适用于对共享资源频繁修改的场景,它提供了一种保守的并发控制方法,确保数据的安全性和一致性。然而,随着乐观锁等更为高效的并发控制机制的出现,悲观锁在某些场景下可能不再是最佳选择。

3.2.2  悲观锁在java中的应用

在Java中,悲观锁的应用通常涉及到对共享数据进行临界区保护,在访问共享数据之前,悲观锁会假设其他线程会对数据进行修改,因此会先获取锁,然后执行操作,并在操作完成后释放锁。以下是一些常见的悲观锁实现方式及其示例:

使用synchronized关键字:

public class PessimisticLockExample {
    private int value;
  
    public synchronized void updateValue(int newValue) {
        // 模拟耗时操作
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        value = newValue;
        System.out.println("Update succeeded!");
    }
}

在这个示例中,通过使用synchronized关键字修饰方法,确保在一个线程执行updateValue()方法时,其他线程无法同时进入该方法,从而保证了对共享数据的互斥访问。

使用Lock接口及其实现类:

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

public class PessimisticLockExample {
    private int value;
    private Lock lock = new ReentrantLock();
  
    public void updateValue(int newValue) {
        lock.lock(); // 获取锁

        // 模拟耗时操作
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        value = newValue;
        System.out.println("Update succeeded!");

        lock.unlock(); // 释放锁
    }
}

在这个示例中,通过使用ReentrantLock实现类创建一个锁对象,并在需要保护的临界区代码段前后调用lock()和unlock()方法来获取和释放锁。

悲观锁的具体实现方式还可以使用数据库锁机制、分布式锁等。这些方式都旨在通过加锁来保证对共享资源的独占性,防止其他线程同时访问和修改数据。然而,悲观锁会导致线程竞争增加、并发性能降低,因此在实际应用中,需要权衡使用悲观锁的场景和代价。

猜你喜欢

转载自blog.csdn.net/qq_39208536/article/details/131814501
今日推荐