小蔡学Java

Java的锁机制

2024-09-20 10:05 1136 0 JVM / JUC synchronized / lock

Java的锁机制

Java 中的锁机制主要用于多线程编程中,确保共享资源的同步访问。Java 提供了几种锁机制,包括 synchronized 关键字和 Lock 接口及其实现类。

synchronized 关键字

特点:

  • Java 中最基本的锁机制,隐式地获取和释放锁。

  • 自动保证锁的释放,避免了手动释放锁可能导致的问题。

  • 支持重入性(线程可以重复获取已经持有的锁),可避免自身死锁。

适用场景:

  • 单线程环境下或简单的多线程同步场景。

  • 代码块内部需要同步的情况。

  • 保证方法或代码块的原子性操作。

synchronized 是 Java 中最基本的锁机制,用于实现方法或代码块的同步。它有两种使用方式:

方法级别的 synchronized:

将 synchronized 关键字放在方法声明中,确保每次只有一个线程可以进入该方法执行。

 public synchronized void synchronizedMethod() {
     // 同步代码块
 }

代码块级别的 synchronized:

将 synchronized 关键字放在代码块中,指定锁对象。

Object lock = new Object();

 synchronized(lock) {
     // 同步代码块
 }

Lock 接口

Lock 接口提供了更灵活的锁定操作,相比于 synchronized,它具有更多的功能和扩展性。Lock 接口的常见实现类包括 ** ReentrantLock 和 ReentrantReadWriteLock.ReadLock/WriteLock** 。

ReentrantLock

特点:

  • 显式获取和释放锁,提供更多的灵活性和控制。

  • 支持公平锁和非公平锁,默认为非公平锁。

  • 支持可中断的锁获取和超时的锁获取。

  • 提供条件变量(Condition),可以精确控制线程的等待和唤醒。

适用场景:

  • 需要更灵活的锁获取和释放机制,如需要定时、可中断的锁操作。

  • 需要实现公平性或非公平性选择的场景。

  • 需要使用条件变量进行复杂线程通信的情况。

public class ReentrantLockExample {
     private int count = 0;
     private Lock lock = new ReentrantLock();

     public void increment() {
         lock.lock();
         try {
             count++;
         } finally {
             lock.unlock();
         }
     }

     public int getCount() {
         lock.lock();
         try {
             return count;
         } finally {
             lock.unlock();
         }
     }
 }

ReentrantReadWriteLock

特点:

  • 支持读写分离的锁,即可以同时有多个线程获取读锁(共享锁),但只能有一个线程获取写锁(排他锁)。

  • 读锁可以共享,适合读多写少的场景,提高并发性能。

  • 获取读锁(readLock() 方法)时,会调用 Sync 类中的 tryAcquireShared(int arg) 方法来尝试获取锁。如果当前没有线程持有写锁并且没有线程在等待获取写锁,则获取读锁成功。

  • 获取写锁(writeLock() 方法)时,会调用 Sync 类中的 tryAcquire(int arg) 方法来尝试获取锁。只有当没有其他线程持有读锁或写锁时,获取写锁才会成功。

适用场景:

  • 数据库的读写分离场景,例如多线程读取共享数据,少量线程修改共享数据。

  • 需要提高读操作的并发性能,降低写操作的锁竞争。

public class ReentrantReadWriteLockExample {
     private int count = 0;
     private ReadWriteLock lock = new ReentrantReadWriteLock();

     public void increment() {
         lock.writeLock().lock();
         try {
             count++;
         } finally {
             lock.writeLock().unlock();
         }
     }

     public int getCount() {
         lock.readLock().lock();
         try {
             return count;
         } finally {
             lock.readLock().unlock();
         }
     }
 }

性能比较和注意事项

性能比较:

  • synchronized 简单易用,适合低竞争、低并发的场景。

  • ReentrantLock 提供更多高级特性,适合复杂的同步需求和高并发场景。

  • ReentrantReadWriteLock 适合读多写少的场景,提高了读操作的并发性能。

注意事项:

  • 避免死锁:使用锁时要注意锁的获取顺序,避免出现循环等待导致死锁。

  • 合理释放锁资源:始终在 finally 块中释放锁资源,确保异常情况下能正常释放。

  • 锁的粒度:尽量将锁的范围缩小到必要的代码块,减少锁的持有时间,提高并发性能。

读锁跟写锁

读锁(Read Lock)

读锁允许多个线程同时访问共享资源,因为读操作不会修改数据,所以多个线程可以同时读取资源而不会导致数据的不一致。读锁适用于读多写少的场景,可以提高系统的并发性能。

特点:

  • 多个线程可以同时获取读锁并访问共享资源。

  • 读锁与读锁之间不互斥,允许并发读取。

  • 当有线程持有写锁时,其他线程无法获取读锁,保证数据的一致性。

写锁(Write Lock)

写锁用于对共享资源的写操作,保证写操作的原子性和一致性,因此只允许一个线程获取写锁进行写操作。写锁适用于写多读少的场景,避免多个写操作之间的竞争和数据不一致。

特点

  • 只允许一个线程获取写锁,执行写操作。

  • 写锁与写锁之间互斥,避免多个写操作同时进行。

  • 写锁与读锁之间互斥,当有线程持有写锁时,其他线程无法获取读锁,避免读操作读到不一致的数据。

适用场景

读锁适用场景:

  • 数据读取操作频繁,写操作较少。

  • 多线程可以并发读取数据,提高系统的读取性能。

写锁适用场景:

  • 数据写入操作频繁,读操作较少。

  • 保证写操作的原子性和一致性,避免数据写入冲突。

在这个示例中,ReadWriteLockExample 类使用了 ReentrantReadWriteLock 来管理共享资源 sharedResource 的读写访问。readData 方法使用读锁进行读操作,允许多个线程同时调用;writeData 方法使用写锁进行写操作,保证了写操作的原子性和一致性。通过这种方式,可以有效地控制对共享资源的并发访问。

 public class ReadWriteLockExample {
     private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
     private final Lock readLock = lock.readLock();
     private final Lock writeLock = lock.writeLock();
     private int sharedResource = 0;

     public void readData() {
         readLock.lock();
         try {
             // 读取共享资源的操作
             System.out.println("Reading data: " + sharedResource);
         } finally {
             readLock.unlock();
         }
     }

     public void writeData(int newValue) {
         writeLock.lock();
         try {
             // 写入共享资源的操作
             sharedResource = newValue;
             System.out.println("Writing data: " + newValue);
         } finally {
             writeLock.unlock();
         }
     }

     public static void main(String[] args) {
         ReadWriteLockExample example = new ReadWriteLockExample();

         // 读操作示例
         Runnable readTask = () -> {
             for (int i = 0; i < 5; i++) {
                 example.readData();
             }
         };

         // 写操作示例
         Runnable writeTask = () -> {
             for (int i = 0; i < 5; i++) {
                 example.writeData(i);
             }
         };

         // 创建多个线程进行读写操作
         Thread reader1 = new Thread(readTask);
         Thread reader2 = new Thread(readTask);
         Thread writer1 = new Thread(writeTask);

         reader1.start();
         reader2.start();
         writer1.start();
     }
 }

公平锁和非公平锁

ReentrantReadWriteLock 支持公平锁和非公平锁。这两种锁的区别在于获取锁的顺序和策略:

非公平锁:

  • 默认情况下,ReentrantReadWriteLock 是非公平锁。

  • 非公平锁允许当前线程在竞争时通过插队的方式获取锁,即不考虑等待队列中是否有等待的线程,直接尝试获取锁,如果获取不到则进入等待状态。

  • 这种方式可能会导致某些线程长时间等待,因为新来的线程有可能会插队成功获取到锁,从而延迟了等待时间较长的线程。

公平锁:

  • 通过 ReentrantReadWriteLock 的构造函数可以选择将其配置为公平锁。例如:ReentrantReadWriteLock(true)。

  • 公平锁会按照线程的请求顺序获取锁,即先到先得的原则,所有线程都会先加入等待队列,按照FIFO(先进先出)的顺序获取锁。

  • 公平锁保证了所有线程公平地竞争锁,避免了饥饿情况的发生,但可能会降低系统的整体性能,因为线程需要更多的上下文切换。

  • 在使用 ReentrantReadWriteLock 时,默认情况下是非公平锁,如果需要公平性,可以在构造锁时显式指定为公平锁。选择公平与非公平锁取决于应用的具体场景和性能需求。

乐观锁跟悲观锁

悲观锁(Pessimistic Locking)

  • 悲观锁假设在整个数据处理过程中,其他事务可能会对数据进行修改,因此在读取和修改数据时都会进行加锁操作。它的特点包括:

  • 加锁机制:在读取数据之前,会先获取数据的排他锁(Exclusive Lock),确保其他事务不能同时修改数据。

  • 适用场景:适用于对数据并发修改频率较高的场景,比如数据库系统中的行级锁、表级锁等。例如,在更新某个数据库记录时,悲观锁可以确保在更新期间不会有其他事务修改同一行数据。

  • 悲观锁假设数据可能会被其他线程修改,因此在读取数据时会加锁,以防止其他线程修改数据。常见的实现方式包括synchronized关键字和ReentrantLock。

ReentrantLock用来模拟悲观锁,确保在转账操作期间其他线程无法修改余额。lock()和unlock()方法用于手动加锁和释放锁。

在示例中,当一个线程进入transfer(int amount)方法时,它会首先获取锁,执行转账操作,然后释放锁。其他线程在第一个线程释放锁之前无法进入transfer方法,确保了数据操作的原子性和线程安全性。

 public class PessimisticLockingExample {
     private int balance = 100; // 初始账户余额为100元
     private Lock lock = new ReentrantLock(); // 使用可重入锁

     public void transfer(int amount) {
         lock.lock(); // 加锁

         try {
             // 模拟业务逻辑
             System.out.println("当前余额:" + balance);
             System.out.println("尝试转账:" + amount + " 元");

             // 模拟转账过程中,其他线程可能已经修改了余额
             try {
                 Thread.sleep(100); // 模拟转账过程中的耗时操作
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }

             if (balance >= amount) {
                 balance -= amount;
                 System.out.println("转账成功!余额:" + balance + " 元");
             } else {
                 System.out.println("转账失败,余额不足!");
             }
         } finally {
             lock.unlock(); // 释放锁
         }
     }

     public static void main(String[] args) {
         PessimisticLockingExample account = new PessimisticLockingExample();

         // 模拟两个线程同时进行转账操作
         Runnable transferTask = () -> {
             account.transfer(50);
         };

         Thread thread1 = new Thread(transferTask);
         Thread thread2 = new Thread(transferTask);

         thread1.start();
         thread2.start();
     }
 }

乐观锁(Optimistic Locking)

  • 乐观锁则假设数据在大多数情况下不会发生冲突,因此不会使用显式的锁来保护数据,在更新操作之前不会加锁。它的特点包括:

  • 版本控制:通过记录数据的版本号或时间戳来实现。在读取数据时,同时获取数据的版本信息;在更新数据时,比较当前版本号与读取时的版本号是否一致,若一致则执行更新操作,否则认为数据已被其他事务修改。

  • 适用场景:适用于读操作频繁、写操作相对较少的场景,可以减少加锁对系统性能的影响。例如,在处理Web页面的并发访问时,乐观锁可以通过版本号或时间戳来避免数据冲突,提高并发性能。

  • 乐观锁假设读取数据时不会修改,只有在更新数据时才会检查是否被其他线程修改过。常见的实现方式包括版本号或者CAS(Compare and Swap)操作。如果并发写入冲突频繁,乐观锁可能会导致大量的重试操作,影响系统性能。

AtomicInteger用来模拟账户余额,初始为100元。 transfer(int amount)方法模拟了转账操作,首先读取当前余额(乐观地假设没有其他线程在转账过程中修改余额),然后尝试更新余额。

compareAndSet(expectedBalance, expectedBalance - amount)方法是乐观锁的关键,它会比较当前值和期望值,如果相等则更新。如果在更新时发现值已经被其他线程修改,则更新失败。

在示例中,两个线程同时进行转账操作,由于乐观锁不会在读取时加锁,因此允许并发的读取操作,只在更新时进行竞争。

 public class OptimisticLockingExample {
     private AtomicInteger balance = new AtomicInteger(100); // 初始账户余额为100元

     public void transfer(int amount) {
         int expectedBalance = balance.get(); // 获取当前余额(假设此时没有其他线程修改)

         // 模拟业务逻辑
         System.out.println("当前余额:" + expectedBalance);
         System.out.println("尝试转账:" + amount + " 元");

         // 模拟转账过程中,其他线程可能已经修改了余额
         try {
             Thread.sleep(100); // 模拟转账过程中的耗时操作
         } catch (InterruptedException e) {
             e.printStackTrace();
         }

         if (balance.compareAndSet(expectedBalance, expectedBalance - amount)) {
             System.out.println("转账成功!余额:" + balance.get() + " 元");
         } else {
             System.out.println("转账失败,余额不足或账户已被修改!");
         }
     }

     public static void main(String[] args) {
         OptimisticLockingExample account = new OptimisticLockingExample();

         // 模拟两个线程同时进行转账操作
         Runnable transferTask = () -> {
             account.transfer(50);
         };

         Thread thread1 = new Thread(transferTask);
         Thread thread2 = new Thread(transferTask);

         thread1.start();
         thread2.start();
     }
 }

区别和适用场景

  • 加锁方式:悲观锁在读取和修改数据时加锁,乐观锁在更新数据时根据版本或时间戳进行冲突检测。

  • 并发控制策略:悲观锁适合高并发写入的场景,能够确保数据的一致性;乐观锁适合读多写少的场景,能够提高系统的并发性能。

  • 实现方式:悲观锁需要数据库支持或显式的锁机制;乐观锁通常通过版本控制或时间戳来实现,并不需要显式加锁。

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录