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

Java中的可重入锁与公平锁

2024-12-316.3k 阅读

Java中的可重入锁

在Java的并发编程领域,锁机制是控制多线程访问共享资源的重要手段。可重入锁是一种特殊类型的锁,它允许同一个线程对同一把锁进行多次获取。这一特性在实际编程中有着重要的意义。

可重入锁的原理

从本质上讲,可重入锁是通过为每个锁关联一个持有线程和一个计数器来实现的。当一个线程获取锁时,如果该锁没有被其他线程持有,那么这个线程就成为锁的持有者,同时计数器设置为1。如果同一个线程再次获取该锁,计数器会递增。每次线程释放锁时,计数器会递减,当计数器为0时,锁被完全释放,其他线程可以获取。

代码示例

下面通过一个简单的代码示例来展示可重入锁的使用:

import java.util.concurrent.locks.ReentrantLock;

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

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " 获取到锁,第一次进入");
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + " 获取到锁,第二次进入");
                } finally {
                    lock.unlock();
                }
                System.out.println(Thread.currentThread().getName() + " 第一次进入部分即将结束");
            } finally {
                lock.unlock();
            }
        });
        thread.start();
    }
}

在上述代码中,我们创建了一个ReentrantLock实例。在main方法中启动了一个线程,该线程在获取锁后,又再次获取锁,这展示了可重入锁的可重入特性。第一次获取锁时,计数器变为1,第二次获取锁时,计数器变为2。当内层代码块执行完毕释放锁时,计数器减为1,外层代码块执行完毕再次释放锁,计数器减为0,锁被完全释放。

可重入锁的应用场景

  1. 多层方法调用:在一个类的方法中,可能会调用其他方法,而这些方法也需要访问共享资源。如果使用可重入锁,就可以避免因重复获取锁而导致的死锁问题。例如,在一个业务逻辑类中,methodA调用methodB,而这两个方法都需要对某个共享资源进行操作,使用可重入锁可以确保线程在调用过程中顺利获取锁。
public class BusinessLogic {
    private static final ReentrantLock lock = new ReentrantLock();

    public void methodA() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 在 methodA 中获取到锁");
            methodB();
        } finally {
            lock.unlock();
        }
    }

    public void methodB() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 在 methodB 中获取到锁");
        } finally {
            lock.unlock();
        }
    }
}
  1. 递归调用:当一个方法递归调用自身时,可重入锁可以确保每次递归都能顺利获取锁。例如,在计算阶乘的方法中,如果需要对共享资源进行操作,使用可重入锁就可以保证递归过程的正确性。
public class FactorialCalculator {
    private static final ReentrantLock lock = new ReentrantLock();

    public int factorial(int n) {
        lock.lock();
        try {
            if (n == 0 || n == 1) {
                return 1;
            } else {
                return n * factorial(n - 1);
            }
        } finally {
            lock.unlock();
        }
    }
}

Java中的公平锁

公平锁是另一种重要的锁机制,它强调线程获取锁的顺序。公平锁会按照线程请求锁的先后顺序来分配锁,先请求的线程先获取锁,这与非公平锁形成了鲜明的对比,非公平锁可能会让后请求的线程优先获取锁。

公平锁的原理

公平锁的实现依赖于一个等待队列。当一个线程请求锁时,如果锁不可用,该线程会被放入等待队列。当锁被释放时,等待队列中最前面的线程会被唤醒并获取锁。这样就保证了线程获取锁的公平性。

代码示例

在Java中,ReentrantLock默认是非公平锁,但可以通过构造函数来创建公平锁。以下是一个公平锁的代码示例:

import java.util.concurrent.locks.ReentrantLock;

public class FairLockExample {
    private static final ReentrantLock fairLock = new ReentrantLock(true);

    public static void main(String[] args) {
        Thread[] threads = new Thread[5];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                fairLock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + " 获取到公平锁");
                } finally {
                    fairLock.unlock();
                }
            });
        }
        for (Thread thread : threads) {
            thread.start();
        }
    }
}

在上述代码中,我们通过new ReentrantLock(true)创建了一个公平锁。多个线程启动后,它们会按照请求锁的顺序依次获取锁,打印出获取到公平锁的信息。

公平锁的应用场景

  1. 资源分配公平性要求高:在一些场景中,如多用户共享的文件系统资源分配,每个用户的请求应该按照先后顺序得到处理。使用公平锁可以确保先发出请求的用户先获得资源,避免某些用户长时间等待。
  2. 避免线程饥饿:在高并发环境下,如果使用非公平锁,可能会导致某些线程长时间无法获取锁,即线程饥饿。公平锁可以有效地避免这种情况,保证每个线程都有机会获取锁。例如,在一个多线程的任务调度系统中,每个任务都希望能按照提交的顺序得到执行,使用公平锁可以满足这一需求。

可重入锁与公平锁的比较

  1. 性能方面
    • 可重入锁:由于可重入锁在实现上相对简单,不需要维护等待队列等复杂的数据结构,因此在高并发环境下,尤其是竞争不激烈的情况下,性能表现较好。例如,在一个只有少量线程竞争锁的系统中,可重入锁可以快速地被获取和释放,减少线程上下文切换的开销。
    • 公平锁:公平锁因为需要维护等待队列,每次锁的获取和释放都需要对队列进行操作,这增加了额外的开销。在高并发环境下,频繁的队列操作会导致性能下降。例如,在一个有大量线程竞争锁的场景中,公平锁的队列维护操作会消耗大量的CPU时间,降低系统的整体性能。
  2. 公平性方面
    • 可重入锁:可重入锁并不保证公平性,它可能会让后请求的线程先获取锁。在某些情况下,这可能会导致部分线程长时间等待,出现线程饥饿现象。例如,在一个多线程的任务处理系统中,如果使用可重入锁,可能会有一些任务长时间得不到执行。
    • 公平锁:公平锁严格按照线程请求锁的先后顺序分配锁,确保了公平性,避免了线程饥饿问题。这在一些对公平性要求较高的场景中非常重要,如银行排队叫号系统,必须按照客户排队的顺序进行服务。
  3. 使用场景选择
    • 可重入锁:适用于对性能要求较高,对公平性要求不严格的场景。例如,在一个内部系统的缓存更新操作中,更关注的是操作的快速执行,而不太在意线程获取锁的顺序,此时可重入锁是一个较好的选择。
    • 公平锁:适用于对公平性要求较高,对性能要求相对较低的场景。例如,在一个面向公众的在线服务系统中,为了保证每个用户都能得到公平的服务,需要使用公平锁来确保请求按照先后顺序处理。

深入理解可重入锁与公平锁的实现细节

可重入锁的实现细节

  1. AQS框架:在Java中,ReentrantLock是基于AbstractQueuedSynchronizer(AQS)框架实现的。AQS是一个用于构建锁和同步器的框架,它提供了一个FIFO的等待队列来管理等待获取资源的线程。ReentrantLock通过继承AQS并重写其方法来实现可重入锁的功能。
  2. 获取锁的过程:当调用lock()方法获取锁时,ReentrantLock首先尝试使用tryAcquire(int)方法获取锁。在ReentrantLock的实现中,tryAcquire(int)方法会检查当前锁是否被其他线程持有。如果锁未被持有,当前线程将成为锁的持有者,并将AQS中的同步状态(state)设置为1。如果锁已经被当前线程持有,同步状态会递增。如果锁被其他线程持有,当前线程会被封装成一个节点加入到AQS的等待队列中,并进入阻塞状态。
  3. 释放锁的过程:当调用unlock()方法释放锁时,ReentrantLock会调用tryRelease(int)方法。该方法会将同步状态递减,如果同步状态减为0,表示锁已经完全释放,此时会唤醒等待队列中的一个线程。
public class ReentrantLock {
    private final Sync sync;

    public ReentrantLock() {
        sync = new NonfairSync();
    }

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

    public void lock() {
        sync.acquire(1);
    }

    public void unlock() {
        sync.release(1);
    }

    abstract static class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = -5179523762034025860L;

        abstract void lock();

        @Override
        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

        @Override
        protected final boolean isHeldExclusively() {
            return getExclusiveOwnerThread() == Thread.currentThread();
        }
    }

    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        @Override
        void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

        @Override
        protected boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }

    static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        @Override
        void lock() {
            acquire(1);
        }

        @Override
        protected boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            } else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

    final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        } else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

上述代码展示了ReentrantLock的部分实现,其中Sync类继承自AbstractQueuedSynchronizerNonfairSyncFairSync分别实现了非公平锁和公平锁的获取锁逻辑。

公平锁的实现细节

  1. 公平性的保证:公平锁在获取锁时,会调用hasQueuedPredecessors()方法检查等待队列中是否有前驱节点。如果有前驱节点,说明有其他线程先于当前线程请求锁,当前线程会进入等待队列。只有当等待队列中没有前驱节点且锁可用时,当前线程才能获取锁。这确保了线程按照请求锁的先后顺序获取锁。
  2. 等待队列的管理:AQS的等待队列是一个双向链表,每个节点封装了一个等待获取锁的线程。当线程请求锁失败时,会创建一个节点并加入到等待队列的尾部。当锁释放时,会从等待队列的头部唤醒一个线程。公平锁通过严格按照等待队列的顺序唤醒线程来保证公平性。
  3. 性能影响:由于公平锁需要频繁地操作等待队列,包括节点的插入、删除和唤醒操作,这在高并发环境下会带来较大的性能开销。相比之下,非公平锁在获取锁时不需要检查等待队列,直接尝试获取锁,因此在性能上更具优势。但公平锁在保证公平性方面具有不可替代的作用,适用于对公平性要求较高的场景。

可重入锁与公平锁在实际项目中的选择与优化

在实际项目中的选择

  1. 考虑业务需求:如果业务对公平性有严格要求,如在线票务系统,每个用户的购票请求应该按照先后顺序处理,以保证公平竞争,此时应选择公平锁。而如果业务对性能更为关注,对公平性要求不高,如内部的缓存更新操作,可选择可重入锁(非公平锁)。
  2. 分析并发场景:在并发量较低、竞争不激烈的场景中,可重入锁和公平锁的性能差异并不明显,此时可以根据业务需求选择。但在高并发、竞争激烈的场景中,可重入锁(非公平锁)通常能提供更好的性能,因为它减少了等待队列操作的开销。然而,如果需要避免线程饥饿问题,即使在高并发场景下,公平锁可能也是必要的选择。
  3. 结合系统架构:如果系统架构中存在多个层次的锁,需要考虑不同层次锁的特性。例如,在分布式系统中,可能会使用分布式锁,此时可以在本地使用可重入锁提高性能,而在分布式层面根据业务需求选择公平或非公平的分布式锁。

优化策略

  1. 减少锁的粒度:无论是可重入锁还是公平锁,都可以通过减少锁的粒度来提高性能。即将大的锁范围划分为多个小的锁范围,使得不同线程可以同时访问不同的资源,减少锁竞争。例如,在一个多线程操作的数据库缓存系统中,可以为每个缓存分区设置独立的锁,而不是使用一个全局锁。
  2. 合理设置锁的超时时间:在使用锁时,可以设置合理的超时时间,避免线程长时间等待锁。如果在超时时间内未能获取锁,线程可以执行其他操作或进行重试。这在一定程度上可以提高系统的响应性,避免线程饥饿。例如,在一个网络请求处理系统中,设置锁的超时时间可以防止因锁等待时间过长而导致的请求超时。
  3. 使用读写锁:如果系统中读操作远多于写操作,可以考虑使用读写锁(如ReentrantReadWriteLock)。读写锁允许多个线程同时进行读操作,而只允许一个线程进行写操作。这样可以在保证数据一致性的前提下,提高系统的并发性能。例如,在一个新闻资讯网站的后台管理系统中,大量用户读取新闻内容,而只有管理员进行写操作,使用读写锁可以有效地提高系统性能。

通过深入理解可重入锁与公平锁的原理、实现细节、应用场景以及优化策略,开发人员可以在Java并发编程中更加灵活地选择和使用锁机制,提高系统的性能和稳定性。无论是在性能优先的场景,还是公平性至上的业务需求下,都能找到最合适的解决方案。同时,合理的优化策略可以进一步提升系统在高并发环境下的表现,满足不断增长的业务需求。在实际项目中,要根据具体情况进行全面的分析和权衡,选择最适合的锁机制,并进行针对性的优化,以构建高效、稳定的并发系统。在不断发展的技术领域,对锁机制的研究和应用也在持续演进,开发人员需要保持学习,紧跟技术发展的步伐,以应对日益复杂的并发编程挑战。