MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java中Lock的类别及应用场景

2022-04-146.3k 阅读

Java中Lock的类别及应用场景

在Java并发编程领域,Lock接口为控制多线程对共享资源的访问提供了一种灵活且强大的方式。相较于传统的synchronized关键字,Lock接口提供了更细粒度的控制、更灵活的锁获取和释放机制以及更多的高级特性。Java提供了多种实现Lock接口的类,每种都有其独特的应用场景。下面我们将详细探讨这些Lock的类别及其应用场景,并通过代码示例加深理解。

1. ReentrantLock

1.1 基本概念

ReentrantLock是Java.util.concurrent包下最常用的锁实现之一,它是一种可重入的互斥锁。“可重入”意味着同一个线程可以多次获取同一个锁而不会造成死锁,每次获取锁时,锁的持有计数会增加,每次释放锁时,持有计数会减少,当持有计数为0时,锁被完全释放。

1.2 特性

  • 可中断的获取锁ReentrantLock提供了lockInterruptibly()方法,允许在获取锁的过程中响应中断,而synchronized关键字不具备此特性。当一个线程在等待获取synchronized锁时,是不能被中断的,除非持有锁的线程释放锁。
  • 公平性选择ReentrantLock可以选择公平锁或非公平锁模式。默认情况下,ReentrantLock是非公平的,这意味着在锁可用时,等待时间最长的线程不一定会优先获取锁,新到达的线程可能会“插队”获取锁。而公平锁则会按照线程等待的先后顺序分配锁,保证先来先得。虽然公平锁看起来更公平,但在高并发场景下,由于线程切换带来的开销,其性能通常不如非公平锁。

1.3 应用场景

  • 高竞争场景下的优化:在高竞争场景中,非公平的ReentrantLock由于减少了线程切换的开销,通常能提供更好的性能。例如,在一个多线程访问共享资源的服务器应用中,大量线程频繁竞争锁,如果使用公平锁,频繁的线程上下文切换会导致性能下降。而非公平锁允许新到达的线程有机会快速获取锁,减少了线程等待时间,提高了整体吞吐量。
  • 需要可中断获取锁的场景:在一些任务执行过程中,可能需要在等待锁的过程中响应中断信号,以便及时取消任务。例如,在一个长时间运行的计算任务中,用户可能希望通过外部操作(如点击取消按钮)来中断任务。如果任务在获取锁时使用ReentrantLocklockInterruptibly()方法,就可以在等待锁的过程中响应中断,及时停止任务。

1.4 代码示例

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private static final ReentrantLock lock = new ReentrantLock();
    private static int sharedResource = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            lock.lock();
            try {
                for (int i = 0; i < 5; i++) {
                    sharedResource++;
                    System.out.println(Thread.currentThread().getName() + " incremented sharedResource to " + sharedResource);
                }
            } finally {
                lock.unlock();
            }
        });

        Thread thread2 = new Thread(() -> {
            lock.lock();
            try {
                for (int i = 0; i < 5; i++) {
                    sharedResource--;
                    System.out.println(Thread.currentThread().getName() + " decremented sharedResource to " + sharedResource);
                }
            } finally {
                lock.unlock();
            }
        });

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final value of sharedResource: " + sharedResource);
    }
}

在上述代码中,ReentrantLock确保了在多线程环境下对sharedResource的安全访问。lock()方法用于获取锁,unlock()方法用于释放锁,并且在finally块中释放锁,以确保即使在代码块中发生异常,锁也能被正确释放。

2. ReentrantReadWriteLock

2.1 基本概念

ReentrantReadWriteLock是一种读写锁,它将对共享资源的访问分为读操作和写操作。允许多个线程同时进行读操作,因为读操作不会修改共享资源,不会产生数据不一致问题。而写操作则需要独占锁,以防止数据冲突。该锁也是可重入的,同一个线程可以多次获取读锁或写锁。

2.2 特性

  • 读写分离:读锁可以被多个线程同时持有,而写锁则是独占的。这种分离机制大大提高了并发性能,特别是在读多写少的场景中。
  • 锁降级ReentrantReadWriteLock支持锁降级,即持有写锁的线程可以在不释放写锁的情况下获取读锁,然后释放写锁,这样就实现了从写锁到读锁的降级。锁降级通常用于在写操作完成后,需要继续进行读操作的场景,避免了先释放写锁再获取读锁可能带来的数据不一致问题。

2.3 应用场景

  • 读多写少的场景:在许多应用中,数据的读取操作远远多于写入操作,如数据库缓存、配置文件读取等场景。例如,一个在线新闻网站,大量用户同时访问新闻内容(读操作),而只有管理员偶尔更新新闻(写操作)。使用ReentrantReadWriteLock可以让多个用户并发读取新闻内容,而在管理员更新新闻时,通过获取写锁来保证数据的一致性。
  • 数据缓存场景:在缓存系统中,缓存数据被频繁读取,而只有在数据更新时才需要写入操作。例如,一个电商应用的商品信息缓存,多个线程会频繁读取商品的价格、库存等信息,而只有在商品信息发生变化时才会进行写入操作。使用读写锁可以显著提高缓存系统的并发性能。

2.4 代码示例

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private static final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
    private static int sharedData = 0;

    public static void main(String[] args) {
        Thread writerThread = new Thread(() -> {
            writeLock.lock();
            try {
                sharedData++;
                System.out.println(Thread.currentThread().getName() + " updated sharedData to " + sharedData);
            } finally {
                writeLock.unlock();
            }
        });

        Thread readerThread1 = new Thread(() -> {
            readLock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " read sharedData as " + sharedData);
            } finally {
                readLock.unlock();
            }
        });

        Thread readerThread2 = new Thread(() -> {
            readLock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " read sharedData as " + sharedData);
            } finally {
                readLock.unlock();
            }
        });

        writerThread.start();
        readerThread1.start();
        readerThread2.start();

        try {
            writerThread.join();
            readerThread1.join();
            readerThread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,writeLock用于保护写操作,确保在写操作进行时,其他线程不能进行读写操作。readLock用于保护读操作,允许多个线程同时进行读操作。

3. StampedLock

3.1 基本概念

StampedLock是Java 8引入的一种新型锁,它提供了一种乐观读的机制,进一步提高了并发性能。StampedLock使用一个时间戳(stamp)来表示锁的状态,每次获取锁时会返回一个时间戳,释放锁时需要使用这个时间戳。

3.2 特性

  • 乐观读StampedLock允许线程在没有获取写锁的情况下进行乐观读操作。线程首先尝试获取一个乐观读的时间戳,然后读取数据。在读取完成后,通过验证时间戳来判断在读取过程中数据是否被修改。如果没有被修改,则读操作成功;如果被修改,则需要重新获取读锁或写锁进行读取。
  • 读锁升级为写锁StampedLock支持读锁升级为写锁,与ReentrantReadWriteLock的锁降级相反。这种机制在某些场景下非常有用,例如在对数据进行读取后,发现需要对数据进行修改,此时可以直接将读锁升级为写锁,而不需要先释放读锁再获取写锁,减少了锁竞争。

3.3 应用场景

  • 高并发且数据一致性要求不是特别严格的场景:在一些实时性要求较高,但对数据一致性要求相对宽松的场景中,StampedLock的乐观读机制可以显著提高并发性能。例如,在一个股票交易系统中,实时显示股票价格的功能可以使用乐观读,因为即使在显示过程中股票价格发生了微小变化,对用户体验影响不大,但可以大大提高系统的并发处理能力。
  • 需要读锁升级为写锁的场景:在一些业务逻辑中,先读取数据,然后根据读取结果决定是否需要修改数据。例如,在一个用户信息管理系统中,先读取用户的基本信息,然后根据业务规则判断是否需要更新用户的某些属性。使用StampedLock可以方便地将读锁升级为写锁,避免了复杂的锁获取和释放操作。

3.4 代码示例

import java.util.concurrent.locks.StampedLock;

public class StampedLockExample {
    private static final StampedLock lock = new StampedLock();
    private static int sharedValue = 0;

    public static void main(String[] args) {
        Thread writerThread = new Thread(() -> {
            long stamp = lock.writeLock();
            try {
                sharedValue++;
                System.out.println(Thread.currentThread().getName() + " updated sharedValue to " + sharedValue);
            } finally {
                lock.unlockWrite(stamp);
            }
        });

        Thread readerThread = new Thread(() -> {
            long stamp = lock.tryOptimisticRead();
            int value = sharedValue;
            if (!lock.validate(stamp)) {
                stamp = lock.readLock();
                try {
                    value = sharedValue;
                    System.out.println(Thread.currentThread().getName() + " read sharedValue as " + value);
                } finally {
                    lock.unlockRead(stamp);
                }
            } else {
                System.out.println(Thread.currentThread().getName() + " optimistically read sharedValue as " + value);
            }
        });

        writerThread.start();
        readerThread.start();

        try {
            writerThread.join();
            readerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,writerThread获取写锁来更新sharedValuereaderThread首先尝试乐观读,如果乐观读验证失败,则获取读锁进行读取。

4. Condition

4.1 基本概念

Condition是与Lock接口紧密相关的一个接口,它提供了一种更灵活的线程间通信机制,类似于传统synchronized关键字中的wait()notify()方法。每个Lock对象可以创建多个Condition对象,每个Condition对象可以用于线程之间的特定通知和等待。

4.2 特性

  • 精准通知:与synchronized中的notifyAll()方法不同,Conditionsignal()方法可以精准地唤醒在该Condition上等待的一个线程,signalAll()方法可以唤醒在该Condition上等待的所有线程。这使得线程间的通信更加灵活和高效。
  • 等待队列:每个Condition都有自己的等待队列,线程在调用await()方法后会进入该队列等待,当调用signal()signalAll()方法时,相应的线程会从队列中被唤醒。

4.3 应用场景

  • 生产者 - 消费者模型:在生产者 - 消费者模型中,生产者线程生产数据并放入共享队列,消费者线程从队列中取出数据。当队列满时,生产者线程需要等待;当队列空时,消费者线程需要等待。使用Condition可以很方便地实现这种线程间的协作。例如,在一个消息队列系统中,生产者线程将消息放入队列,当队列达到最大容量时,生产者线程等待;消费者线程从队列中取出消息,当队列为空时,消费者线程等待。
  • 多线程协作任务:在一些复杂的多线程任务中,不同线程需要根据特定条件进行协作。例如,在一个分布式计算任务中,一个主线程负责分配任务给多个子线程,当所有子线程完成任务后,主线程再进行下一步操作。使用Condition可以实现主线程等待所有子线程完成任务的通知,然后进行统一处理。

4.4 代码示例

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

public class ConditionExample {
    private static final Lock lock = new ReentrantLock();
    private static final Condition condition = lock.newCondition();
    private static boolean flag = false;

    public static void main(String[] args) {
        Thread waitingThread = new Thread(() -> {
            lock.lock();
            try {
                while (!flag) {
                    System.out.println(Thread.currentThread().getName() + " is waiting...");
                    condition.await();
                }
                System.out.println(Thread.currentThread().getName() + " has been signaled.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        Thread signalingThread = new Thread(() -> {
            lock.lock();
            try {
                flag = true;
                System.out.println(Thread.currentThread().getName() + " is signaling...");
                condition.signal();
            } finally {
                lock.unlock();
            }
        });

        waitingThread.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        signalingThread.start();

        try {
            waitingThread.join();
            signalingThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,waitingThreadflagfalse时调用condition.await()方法进入等待状态,signalingThread在设置flagtrue后调用condition.signal()方法唤醒waitingThread

5. Semaphore

5.1 基本概念

Semaphore是一种计数信号量,它通过一个计数器来控制同时访问共享资源的线程数量。当一个线程获取Semaphore时,计数器会减1;当一个线程释放Semaphore时,计数器会加1。当计数器为0时,其他线程无法获取Semaphore,只能等待。

5.2 特性

  • 控制并发访问数量Semaphore的主要作用是控制同时访问某个资源或执行某个操作的线程数量。例如,可以设置Semaphore的初始计数为5,那么最多允许5个线程同时获取Semaphore,从而同时访问共享资源。
  • 公平性选择:与ReentrantLock类似,Semaphore也可以选择公平或非公平模式。公平模式下,线程按照等待的先后顺序获取Semaphore;非公平模式下,新到达的线程可能会优先获取Semaphore

5.3 应用场景

  • 资源限流:在一些系统中,某些资源的数量是有限的,如数据库连接池中的连接数量、线程池中的线程数量等。使用Semaphore可以限制同时使用这些资源的线程数量,防止资源耗尽。例如,在一个Web应用中,数据库连接池有100个连接,为了避免过多线程同时请求数据库连接导致连接池耗尽,可以使用Semaphore来限制同时获取数据库连接的线程数量为100。
  • 控制并发任务数量:在一些任务执行场景中,可能需要限制同时执行的任务数量,以避免系统资源过度消耗。例如,在一个文件下载系统中,为了防止过多的下载任务同时占用网络带宽和磁盘I/O资源,可以使用Semaphore来限制同时进行的下载任务数量。

5.4 代码示例

import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    private static final Semaphore semaphore = new Semaphore(3);

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + " acquired semaphore.");
                    // 模拟任务执行
                    Thread.sleep(2000);
                    System.out.println(Thread.currentThread().getName() + " released semaphore.");
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
    }
}

在上述代码中,Semaphore的初始计数为3,意味着最多允许3个线程同时获取Semaphore。5个线程尝试获取Semaphore,前3个线程可以立即获取并执行任务,后2个线程需要等待前面的线程释放Semaphore

6. CountDownLatch

6.1 基本概念

CountDownLatch是一个同步辅助类,它允许一个或多个线程等待,直到其他一组线程完成一系列操作。CountDownLatch通过一个计数器来工作,初始化时设置一个初始值,每个线程在完成任务后调用countDown()方法将计数器减1,当计数器减到0时,所有等待在CountDownLatch上的线程将被释放。

6.2 特性

  • 一次性使用CountDownLatch是一次性使用的,一旦计数器减到0,就不能再重新设置初始值。如果需要重复使用类似的功能,可以考虑使用CyclicBarrier
  • 线程等待await()方法用于使当前线程等待,直到计数器为0。可以设置等待的超时时间,如果在超时时间内计数器没有减到0,await()方法将返回false

6.3 应用场景

  • 等待所有任务完成:在一些任务执行场景中,需要等待所有子任务完成后再进行下一步操作。例如,在一个数据处理系统中,有多个线程分别处理不同部分的数据,当所有线程完成数据处理后,需要将处理结果进行汇总。此时可以使用CountDownLatch,每个子任务完成后调用countDown()方法,主线程在调用await()方法等待所有子任务完成。
  • 并发性能测试:在进行并发性能测试时,可能需要同时启动多个线程来模拟并发场景,并且希望在所有线程都准备好后再同时开始执行任务。可以使用CountDownLatch来实现这种同步,主线程在所有线程调用await()方法后,调用countDown()方法,使所有线程同时开始执行任务。

6.4 代码示例

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    private static final CountDownLatch latch = new CountDownLatch(3);

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is working...");
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + " has finished.");
                latch.countDown();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is working...");
                Thread.sleep(1500);
                System.out.println(Thread.currentThread().getName() + " has finished.");
                latch.countDown();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread thread3 = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is working...");
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName() + " has finished.");
                latch.countDown();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread1.start();
        thread2.start();
        thread3.start();

        try {
            System.out.println(Thread.currentThread().getName() + " is waiting for all threads to finish.");
            latch.await();
            System.out.println(Thread.currentThread().getName() + " all threads have finished.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,3个线程分别模拟不同的任务执行,每个线程完成任务后调用latch.countDown()方法。主线程调用latch.await()方法等待所有线程完成任务,当所有线程都调用了countDown()方法后,主线程继续执行。

7. CyclicBarrier

7.1 基本概念

CyclicBarrier也是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共的屏障点(barrier point)。与CountDownLatch不同的是,CyclicBarrier可以重复使用。当所有线程都到达屏障点后,CyclicBarrier会执行一个可选的Runnable任务,然后所有线程可以继续执行,并且CyclicBarrier可以被重新使用。

7.2 特性

  • 可循环使用CyclicBarrier在所有线程到达屏障点后,会将内部计数器重置,可以再次使用。这使得它适用于需要多次同步的场景。
  • 屏障动作CyclicBarrier可以设置一个Runnable任务,当所有线程到达屏障点时,会执行这个任务。这个任务可以用于进行一些汇总、初始化等操作。

7.3 应用场景

  • 多阶段任务同步:在一些复杂的任务中,可能需要将任务分为多个阶段,每个阶段需要所有线程都完成前一阶段的任务后才能继续。例如,在一个分布式机器学习训练过程中,每一轮训练都需要所有节点完成当前轮的计算后,才能开始下一轮训练。使用CyclicBarrier可以方便地实现这种多阶段任务的同步。
  • 并发数据处理:在一些数据处理场景中,可能需要将数据分成多个部分,由多个线程并行处理,然后在每个处理阶段结束后,对结果进行汇总或其他操作。例如,在一个大数据分析任务中,将数据按行分成多个部分,由多个线程并行处理每行数据,每个线程处理完后,等待所有线程都处理完,然后进行结果汇总。CyclicBarrier可以用于实现这种并发数据处理过程中的同步。

7.4 代码示例

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    private static final CyclicBarrier barrier = new CyclicBarrier(3, () -> {
        System.out.println("All threads have reached the barrier. Starting next phase...");
    });

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is working...");
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + " has reached the barrier.");
                barrier.await();
                System.out.println(Thread.currentThread().getName() + " is continuing after the barrier.");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is working...");
                Thread.sleep(1500);
                System.out.println(Thread.currentThread().getName() + " has reached the barrier.");
                barrier.await();
                System.out.println(Thread.currentThread().getName() + " is continuing after the barrier.");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        });

        Thread thread3 = new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " is working...");
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName() + " has reached the barrier.");
                barrier.await();
                System.out.println(Thread.currentThread().getName() + " is continuing after the barrier.");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        });

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

在上述代码中,3个线程分别模拟不同的任务执行,每个线程在完成任务后调用barrier.await()方法等待其他线程。当所有线程都调用await()方法后,CyclicBarrier会执行设置的Runnable任务,然后所有线程继续执行。

通过对以上各种Lock类别及其应用场景的详细介绍和代码示例,希望能帮助开发者在Java并发编程中根据具体需求选择合适的锁机制,从而提高程序的性能和稳定性。在实际应用中,需要根据业务场景、并发量、数据一致性要求等多方面因素综合考虑,选择最适合的锁方案。