JAVA并发万字长文从ReentrantLock到juc框架

2023-05-02 21:02:39 浏览数 (3)

JAVA并发万字长文,从ReentrantLock到juc框架

ReentrantLock 是 Java 中的可重入锁,它实现了 Lock 接口,与 synchronized 相比,ReentrantLock提供了更强大和灵活的锁机制。

ReentrantLock 的原理

ReentrantLock 是通过一个volatile 的变量和一个 FIFO 的队列来实现的。该 volatile 变量表示当前获得锁的线程,FIFO 队列用来存储等待锁的线程。

具体实现方式是:当一个线程获取锁时,将当前线程设置为 volatile 变量的值。如果其他线程试图获取该锁,则会加入 FIFO 队列的尾部,并标记为等待状态。当持有锁的线程释放锁时,它会唤醒 FIFO 队列头部的线程,这个线程继续执行并获取锁。

ReentrantLock 是可重入锁,意味着同一个线程可以多次获取这把锁。这是通过一个计数器实现的,每获取一次锁,计数器的值就加1。释放锁时计数器减1,减到0时才会真正释放锁。

ReentrantLock 的使用

使用 ReentrantLock 主要有三个步骤:

  1. 创建 ReentrantLock 对象
代码语言:java复制
ReentrantLock lock = new ReentrantLock();
  1. 获取锁
代码语言:java复制
lock.lock();
  1. 释放锁
代码语言:java复制
lock.unlock();

一个使用示例:

代码语言:java复制
ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // do something...
} finally {
    lock.unlock();
}

相比 synchronized,ReentrantLock 提供了更丰富的功能,如可以中断正在等待锁的线程,可以设置锁的尝试获取时间等。这使得 ReentrantLock 在许多场景下可以替代 synchronized,成为并发程序的主流选择。

ReentrantLock 的框架使用实例常见于:线程池、死锁检测、队列等。日常开发中也经常会使用 ReentrantLock 来优化并发场景的性能。

ReentrantLock 的公平性选择

ReentrantLock 提供了公平锁和非公平锁两种模式,默认是非公平锁。

公平锁的锁获取顺序是按照线程请求锁的时间顺序来分配的,这保证了所有的线程获取锁的机会都是公平的。而非公平锁的锁获取顺序并不是根据请求锁的时间顺序来的,有可能刚请求的线程先获取到锁,这就是非公平的。

非公平锁的吞吐量通常高于公平锁,但可能造成饥饿,即某些线程长期等待锁。所以选择哪种模式需要根据具体应用来权衡。

使用方式:

代码语言:txt复制
// 公平锁:
ReentrantLock lock = new ReentrantLock(true); 

// 非公平锁:
ReentrantLock lock = new ReentrantLock(false);  

// 默认构造方法:
ReentrantLock lock = new ReentrantLock(); 

CAS操作

ReentrantLock 底层使用 CAS(Compare And Swap)操作来实现锁的获取和释放。

CAS操作包含三个操作数:

  • 要更新的内存值 V
  • 预期的内存值 A
  • 新值 B

如果V的值等于A,那么processors将内存值V更新为B。否则,processor不会执行任何操作。

ReentrantLock 通过CAS操作来更新锁的状态,实现高效且线程安全的锁获取和释放。

通过分析 ReentrantLock 的原理和使用,我们了解到它是一个高效且灵活的锁实现,相比 synchronized 具有更高的扩展性和更丰富的功能。在并发环境下,ReentrantLock 应该是我们首选的锁机制,它的公平与非公平实现和CAS操作也为我们提供了细粒度的控制选项。

参考框架中的ReentrantLock使用

ThreadPoolExecutor 中的应用

线程池ThreadPoolExecutor 使用 ReentrantLock 来控制对线程池状态的访问。主要有两个地方:

  1. 用于控制对 workerCount 和 runState 变量的访问。这两个变量分别代表线程池中的线程数和线程池状态。
  2. 用于控制对等待队列和已完成队列的访问。这是为了保证在将任务添加/删除到这些队列时的线程安全。
代码语言:java复制
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

// Packing and unpacking ctl
private static int runStateOf(int c)     { return c & ~CAPACITY; }
private static int workerCountOf(int c)  { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

// CAS (atomic) access to ctl 
private boolean compareAndIncrementWorkerCount(int expect) {
    return ctl.compareAndSet(expect, expect   1);
}

private boolean compareAndDecrementWorkerCount(int expect) {
    return ctl.compareAndSet(expect, expect - 1); 
}

private void decrementWorkerCount() {
    do {} while (!compareAndDecrementWorkerCount(ctl.get()));
}  

可以看到,ThreadPoolExecutor 通过 CAS 操作来原子地更新 ctl 变量,而 ctl 变量 Contains 线程池的状态和线程数,这就保证了线程池状态的修改的线程安全。

同时,ThreadPoolExecutor 也使用 ReentrantLock 来保护队列的访问,如:

代码语言:java复制
private final ReentrantLock mainLock = new ReentrantLock();
private void addWorker(Runnable firstTask, boolean core) {
    mainLock.lock();
    try {
        // ...
    } finally {
        mainLock.unlock(); 
    }
}

所以,ReentrantLock 在 ThreadPoolExecutor 中发挥了很重要的作用,用于保证线程池状态和队列访问的线程安全。

ConcurrentHashMap中的应用

ConcurrentHashMap 也广泛使用了 ReentrantLock 来实现线程安全。主要使用在:

  1. 分段锁 - ConcurrentHashMap 使用 16 个锁来控制对 hash 表的访问,这 16 个锁就是 ReentrantLock 实例。
  2. 重构链表时的锁 - 当两个线程并发扩容时,如果发现 hash 冲突,需要对相应的链表进行重构。这个过程使用的就是 ReentrantLock。
  3. 读写锁 - 除了分段锁外,ConcurrentHashMap 还提供了读写锁来实现对 get 操作的优化。读锁就是 ReentrantReadWriteLock 的读锁。

下面是 ConcurrentHashMap 中使用 ReentrantLock 的部分示例代码:

代码语言:java复制
/**
 * The segments, each of which is a specialized hash table.
 */ 
final Segment<K,V>[] segments;

static final class Segment<K,V> extends ReentrantLock {...}

final ReentrantLock putLock = new ReentrantLock();  

public V put(K key, V value) {
  Segment<K,V> s;
  if (value == null)
    throw new NullPointerException();
  int hash = hash(key);
  int j = (hash >>> segmentShift) & segmentMask;
  s = ensureSegment(j);
  s.lock(); // lock the segment 
  try { 
    // ...
  } finally { 
    s.unlock(); // unlock the segment
  }
}

final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock rl = rwl.readLock(); 
private final ReentrantReadWriteLock.WriteLock wl = rwl.writeLock();

public V get(Object key) { 
  rl.lock(); // lock for reading  
  try {
    // ... 
  } finally {
    rl.unlock();
  }
}

通过代码我们可以看出,ReentrantLock 被广泛用于 ConcurrentHashMap 的各个方面,起到了保证线程安全的关键作用。

小结

综上,我们分析了 ReentrantLock 在 ThreadPoolExecutor 和 ConcurrentHashMap 两个重要开源框架中的应用。可以看出:

  1. ReentrantLock 用于保证并发环境下对关键数据结构的访问线程安全。
  2. 相比 synchronized,ReentrantLock 提供更加灵活的锁机制,如可中断锁、超时锁、公平锁等,这为框架的并发控制提供了更精细的权衡。
  3. ReentrantLock 通过 CAS 操作来高效地实现锁的获取和释放,这也是它比 synchronized 性能更高的原因之一。

熟悉这些开源框架的实现原理,不仅可以让我们在使用时更加得心应手,也可以在我们自己设计框架时学以致用。ReentrantLock 的应用就是一个很好的参考范例。

ReentrantLock 在实践中的运用

在实际开发中,我们也常常会使用 ReentrantLock 来实现高效的并发控制。这里给出几个示例:

自定义链表的线程安全实现

我们可以使用 ReentrantLock 来实现一个线程安全的链表:

代码语言:java复制
public class ThreadSafeLinkedList {
    private Node head;
    private ReentrantLock lock = new ReentrantLock();
    
    public void add(int value) {
        Node node = new Node(value);
        lock.lock();
        try {
            node.next = head;
            head = node;
        } finally {
            lock.unlock();
        }
    }
    
    public void remove() {
        lock.lock();
        try {
            head = head.next;
        } finally {
            lock.unlock();
        }
    }
    
    private class Node {
        int value;
        Node next;
        
        public Node(int value) {
            this.value = value;
        }
    }
}

我们在添加节点和删除节点时获取 ReentrantLock 的锁,这样可以确保在并发环境下链表操作的线程安全。

实现一个阻塞队列

我们可以使用 ReentrantLock 与 Condition 对象来实现一个阻塞队列:

代码语言:java复制
public class BlockingQueue {
    private LinkedList<Integer> queue = new LinkedList<>();
    private ReentrantLock lock = new ReentrantLock();
    private Condition notEmpty = lock.newCondition();
    
    public void put(Integer value) {
        lock.lock();
        try {
            queue.add(value);
            notEmpty.signal(); // 唤醒等待的线程
        } finally {
            lock.unlock();
        }
    }
    
    public Integer take() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await(); // 等待
            }
            return queue.removeFirst();
        } catch (InterruptedException e) {
            // ...
        } finally {
            lock.unlock();
        }
    }
}

put 方法用于添加元素到队列,并唤醒等待的消费线程。take 方法用于消费元素,如果队列为空则等待生产者的通知。ReentrantLock 与 Condition 配合使用,很好地实现了阻塞队列的功能。

其他

  • 锁超时:使用 ReentrantLock 的 tryLock(long time, TimeUnit unit) 方法实现锁的超时获取。
  • 锁中断:通过 ReentrantLock 的 lockInterruptibly() 方法获取锁,如果线程等待锁的过程中被中断,则会响应中断异常。
  • 公平锁:使用 ReentrantLock(true) 构造方法创建公平锁实现,可以避免饥饿问题的发生。
  • Condition:ReentrantLock 提供的 Condition对象可以实现线程之间复杂的协作。

综上,ReentrantLock 在实践中有很强的实用性,我们可以运用它来实现定制化的并发控制和复杂的协作逻辑。

总的来说,优先考虑使用 ReentrantLock,而不是 synchronized,这可以使我们的程序更加灵活高效。

ReentrantLock总结

  1. ReentrantLock 的原理:基于volatile变量和FIFO队列实现,支持可重入。
  2. ReentrantLock 的使用:lock()获取锁,unlock()释放锁,提供了更加灵活的锁机制。
  3. ReentrantLock 的公平性选择:公平锁和非公平锁,需要根据具体场景来选择。
  4. ReentrantLock 底层使用 CAS 操作来实现高效的锁获取和释放。
  5. ReentrantLock 在 ThreadPoolExecutor 和 ConcurrentHashMap 等开源框架中的应用,用于保证并发环境下的数据结构和状态的线程安全。
  6. ReentrantLock 在实践中的几个运用示例:自定义线程安全链表、阻塞队列的实现、锁超时和中断、公平锁等。

ReentrantLock 是一个功能强大且高效的锁实现,它应该是我们实现并发控制的首选方案。熟练掌握 ReentrantLock 有助于我们设计出更加健壮的多线程程序。

ReentrantLock FAQ

这里总结一些关于 ReentrantLock 常见的问题.

为什么ReentrantLock是可重入的?

可重入意味着同一个线程可以多次获取同一把锁。对于 ReentrantLock 来说,这是实现隐式锁的必要功能。

举个例子,如果一个类中的方法获取了锁,然后再调用同一个类的另一个方法,第二个方法也会自动获取锁。如果锁不可重入,那么第二个方法将永远等待锁,导致死锁。

所以,可重入的锁一定要在实现隐式锁机制的锁中实现,ReentrantLock做了这点考虑,使其成为更易用的锁。

与synchronized相比,ReentrantLock有什么优势?

相比 synchronized,ReentrantLock 有以下优势:

  1. 灵活性:ReentrantLock 提供了更加灵活的锁机制,如可中断锁、超时锁、公平锁等。这在许多场景下都很有用。
  2. 性能:基于CAS实现,ReentrantLock的性能通常优于synchronized。尤其是在高并发的场景下,优势更加明显。
  3. 可中断:获取 ReentrantLock 锁的线程可以响应中断,这在调试死锁时很有帮助。而synchronized的锁获取不可中断。
  4. 超时:ReentrantLock可以设置锁的最大等待时间,这可以快速响应获取锁失败的情况。synchronized 的锁一直等待,容易发生死锁。
  5. 公平性:可以选择公平锁或非公平锁。synchronized 的锁始终是非公平的。
  6. 条件变量:ReentrantLock 提供的 Condition可以实现复杂的线程协作,而synchronized很难实现这点。

所以,总体来说,ReentrantLock提供了更强大和灵活的锁机制。如果需要这些功能,那么选择 ReentrantLock 是更好的选择。当然,在某些极简单的场景下,synchronized 也完全能够满足需求。

Condition和wait/notify对比?

Condition 是 JDK5 中引入的替代 Object 的 wait/notify 的一个工具,它依赖于 Lock 对象。主要有以下差异:

  1. wait/notify必须和同步块一起使用,Condition可以和任何具有锁的对象一起使用。
  2. wait/notify只能在同步方法或同步块内调用,而Condition可以在任何地方调用。
  3. wait/notify唤醒的线程是随机的,而Condition可以精确地唤醒指定数量的线程。
  4. Condition可以提供更加强大的线程通信机制。

所以,总体来说,Condition的功能更加强大和灵活。对于复杂的线程协作逻辑,Condition是更好的选择。

不过,Condition依赖于Lock对象,使用略微复杂。而wait/notify是Object的内置方法,使用简单,适用于基本的线程通信场景。

所以,根据具体需求选择对应的工具就可以了。

ReentrantLock和synchronized的锁释放是否一定成功?

不一定。释放锁的操作并非原子操作,所以锁释放可能会失败。

对于 synchronized 来说,锁的释放依赖于 JVM,如果 JVM 在锁释放的时刻进行了线程切换,那么就可能导致锁释放失败。不过 JVM 总体来说会保证锁一定能被成功释放。

而对于 ReentrantLock 来说,它是通过 CAS 操作来释放锁的。如果 CAS 操作失败,那么锁的释放也就失败了。不过 ReentrantLock 提供了一套机制来保证锁最终一定会成功释放:

  1. 当释放锁失败时,锁持有线程会自旋一段时间再重试。
  2. 如果重试多次仍未成功,则会加入一个释放锁的队列,并停止自旋。
  3. 锁对象会定期重试那些在释放锁队列中的线程。
  4. 为了避免重试过于频繁影响性能,释放锁操作会有一个最大自旋时间和最大重试次数。
  5. 如果超过最大重试次数仍未成功,则会采取更加激进的措施来释放锁,比如通过 Thread.stop() 强制终止锁持有线程。

所以,总体来说,虽然锁释放可能失败,但是 JVM 和 ReentrantLock 都提供了相应的机制来保证锁最终一定会被成功释放,对程序的正确性影响不大。但频繁的释放失败会影响程序的性能,所以我们应该尽量减少造成这种情况的可能。

公平锁与非公平锁的优缺点?

公平锁与非公平锁的主要区别在于锁的分配方式不同:

  • 公平锁:以 FIFO 的方式依次分配锁给等待线程。这保证了所有的线程获取锁的机会都是公平的。
  • 非公平锁:不依赖等待线程的队列顺序来分配锁。有可能刚请求的线程先获取到锁,这就是非公平的。

两者有以下优缺点:

公平锁:

优点:防止饥饿,保证每个线程获取锁的机会。

缺点:公平策略会降低吞吐量。

非公平锁:

优点:通常可以提高吞吐量。

缺点:可能会导致某些线程长期无法获取锁(饥饿)。

所以,选择哪种锁需要根据具体场景来权衡:

如果对吞吐量要求较高,可以选择非公平锁,并采取其他手段防止饥饿。

如果需要严格的公平性,以防止某些线程无法获取锁,可以选择公平锁,并容忍一定程度的吞吐量降低。

如果同时需要高吞吐量和公平性,可以选择自适应的锁,它可以根据当前情况动态选择公平锁或非公平锁。

综上,没有一种锁可以绝对优于另一种,我们需要根据具体应用场景选择最合适的锁。

如何解决ReentrantLock带来的死锁问题?

ReentrantLock 虽然提供了比 synchronized 更强大的锁机制,但是也带来了更复杂的死锁问题。主要有以下几点需要注意:

  1. 避免嵌套锁:一个线程获取多个锁时,必须遵循获取锁的顺序,否则很容易出现死锁。
  2. 不要在锁内调用可被其他锁调用的方法:如果在锁内调用其它锁保护的方法,那么可能会出现死锁。
  3. 使用 lockInterruptibly() 方法:这样可以在等待锁的线程中响应中断,提高死锁发生时的可调试性。
  4. 使用 tryLock() 设置超时:如果在指定超时时间内无法获取锁,线程可以选择放弃获取,避免死锁发生。
  5. 分层加锁:按照锁的粒度从大到小依次加锁,这样可以避免加锁顺序错误导致的死锁。
  6. 使用 Thread.join() 代替同步:在某些场景下,join() 可以代替同步,而 join() 不会有加锁操作,所以也就不存在死锁问题。
  7. 选择非阻塞算法:在并发程序设计过程中,尽量选择非阻塞的数据结构和算法,这样可以避免加锁产生死锁。
  8. 合理设置同步范围:同步范围应尽可能小,只在真正需要同步的地方添加锁,这样可以减少加锁操作带来的死锁风险。
  9. 检查加锁顺序:对复杂的并发程序来说,最好能检查加锁顺序,避免加锁顺序的错误配置导致死锁发生。这通常需要对程序加锁逻辑进行静态检查。
  10. 使用 jps 和 jstack 检查死锁:如果发生死锁,可以使用这两个工具来分析线程 Dump,找到死锁的根源,然后修复代码。

总之,解决 ReentrantLock 带来的死锁问题主要依靠灵活的使用它提供的功能,遵循良好的加锁规则与并发设计原则,选择合理的线程协作方式,并在发生死锁时能够快速找到问题所在。

ReentrantLock 中的 Condition 如何唤醒指定数量的线程?

ReentrantLock 提供的 Condition 对象允许我们唤醒指定数量的线程。这是它相比 Object 的 wait/notify 具有的优势之一。

Condition 定义了几个方法来唤醒线程:

  • signal():唤醒一个等待线程
  • signalAll():唤醒所有等待线程
  • await():使当前线程等待,直到被唤醒

那么如何实现唤醒指定数量的线程呢?主要靠循环调用 signal() 方法来实现:

代码语言:java复制
// 唤醒 n 个线程 
for (int i = 0; i < n; i  ) {
    condition.signal();
}

每调用一次 signal(),就会从等待队列中唤醒一个线程。所以通过循环调用,我们可以精确地唤醒想要的线程数量。

例如,我们可以实现一个生产者-消费者场景,生产者负责唤醒指定数量的消费线程:

代码语言:java复制
public class ProducerConsumer {
    private ReentrantLock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    
    private int queueSize = 0;
    
    public void produce(int num) {
        lock.lock();
        try {
            // 生产 num 个商品
            queueSize  = num;  
            // 唤醒 num 个消费者线程
            for (int i = 0; i < num; i  ) {
                condition.signal();
            }
        } finally {
            lock.unlock();
        }
    }
    
    public void consume() {
        lock.lock();
        try {
            while (queueSize == 0) {  
                // 如果没有商品,等待生产者的通知
                condition.await(); 
            } 
            // 消费一个商品
            queueSize--;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

在这个例子中,生产者线程会调用 produce() 方法生产商品,并唤醒指定数量的消费者线程。消费者线程调用 consume() 方法消费商品,如果商品为空会等待生产者的通知。

所以,通过 Condition 的 signal() 方法循环调用,我们可以精确地唤醒指定数量的线程,这在实现复杂的线程协作逻辑时很有用。

这个例子可以帮你加深对 Condition 的理解,在并发编程中运用自如。Condition 是一个很强大的工具,它配合 ReentrantLock可以实现各种复杂的线程间协作与控制。

ReentrantLock 的读写锁如何实现?

ReentrantLock 本身不提供读写锁的功能,但是我们可以通过它提供的锁与条件变量来简单实现一个读写锁。

读写锁主要有以下几个方法:

  • readLock():获取读锁,多个线程可以同时持有读锁
  • writeLock():获取写锁,只有一个线程可以获取写锁
  • readUnlock():释放读锁
  • writeUnlock():释放写锁

读写锁还需要遵循以下规则:

  1. 同一时刻只允许有一个写锁,或者多个读锁
  2. 写锁不能与其他锁(读锁或写锁)同时持有
  3. 读锁之间可以同时被多个线程持有

基于此,我们可以这样实现一个简单的读写锁:

代码语言:java复制
public class ReadWriteLock {
    private ReentrantLock lock = new ReentrantLock();
    private Condition readCondition = lock.newCondition();
    private Condition writeCondition = lock.newCondition();
    private int readCount = 0;
    private boolean writing = false;
    
    public void readLock() {
        lock.lock();
        try {
            while (writing) {  // 如果有线程持有写锁,等待
                readCondition.await(); 
            }
            readCount  ;      // 增加读锁持有数
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    
    public void readUnlock() {
        lock.lock();
        readCount--;           // 减少读锁持有数
        if (readCount == 0) { // 如果没有线程持有读锁
            writeCondition.signal(); // 唤醒等待的写线程
        }
        lock.unlock();
    }
    
    public void writeLock() {
        lock.lock();
        try {
            while (readCount > 0 || writing) { // 如果有读锁或写锁,等待
                writeCondition.await();   
            }
            writing = true;                     // 标记有线程持有写锁
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    
    public void writeUnlock() {
        lock.lock();
        writing = false;                   // 标记写锁已经释放
        readCondition.signalAll();        // 唤醒所有等待的读线程
        writeCondition.signal();          // 唤醒一个等待的写线程
        lock.unlock();
    }
}

通过 ReentrantLock 与 Condition,我们实现了一个简单的读写锁。读锁通过读锁持有数 readCount 来实现多个线程同时获取,写锁通过 writing 标记来保证同一时间只有一个线程获取。

读写锁是一个比较复杂的并发工具,上面这个实现还比较简单,但已经能够满足基本的需求。如果需要一个更为高级的读写锁,可以参考 JDK 中的 ReadWriteLock 接口的实现。

通过这个例子可以加深你对 ReentrantLock 的理解,以及如何运用它来实现各种并发控制工具。读写锁只是其中一个例子,我们还可以实现各种锁,信号量,栅栏等并发工具。

ReentrantLock 的锁降级会带来什么问题?

锁降级指的是线程持有较高级别的锁(例如写锁),之后又获取较低级别的锁(例如读锁)的场景。对于 ReentrantLock 来说,这会带来一些问题:

  1. 无法实现锁降级:ReentrantLock 自身并不支持锁降级功能。如果一个线程持有写锁,之后再获取读锁,这两个操作是互斥的,所以无法实现锁降级。
  2. 可能导致死锁:如果实现锁降级,必须严格遵守加锁与解锁的顺序。否则很容易产生加锁顺序错误,导致死锁。例如:

线程1获取写锁 -> 线程2获取读锁 -> 线程1尝试获取读锁,等待线程2释放读锁

线程2尝试获取写锁,等待线程1释放写锁 -> 死锁

  1. 需要额外的读写状态标记:要实现锁降级,需要维护读写状态,以判断线程是否可以从写锁降级到读锁。这会增加实现的复杂度。
  2. 锁消除带来的并发问题:锁降级其实是一种锁消除的手段,它可以提高并发度。但是,并不一定是越高的并发度越好。过高的并发也会带来令人难以应对的并发问题。

所以,对 ReentrantLock 来说,锁降级是一个比较复杂的特性,它需要解决许多并发方面的困难才能实现。通常只有在读写锁中才会提供锁降级的功能,而唯独会要求使用者遵守一定的加锁顺序与规则,避免死锁等问题的发生。

对于一般使用 ReentrantLock 的场景,不建议去实现锁降级。ReentrantLock 本身支持可重入,但这仅限于同一种锁(写锁或读锁)。如果我们需要锁降级功能,可以选择 JDK 中的 ReadWriteLock 等并发工具。

在并发领域,锁降级是一个比较高级的功能,建议我们在熟练掌握 ReentrantLock 的基本使用后才去尝试实现。

自旋锁和互斥锁的区别与适用场景?

自旋锁:

  • 不会阻塞线程,通过忙等待的方式实现同步。
  • 在线程数少、同步段极短或预期锁很快就会释放的场景下,自旋锁的性能会更好,因为它避免了阻塞带来的上下文切换开销。
  • 如果锁被占用的时间较长,那么自旋会消耗较多 CPU 资源,效率会变低。

互斥锁:

  • 会阻塞请求锁而获取不到的线程。
  • 可以避免自旋锁消耗 CPU 资源的问题,因为阻塞的线程会被切换出 CPU。
  • 上下文切换 also 会带来开销,如果锁被占用时间较短,那么互斥锁的性能可能会较差。

所以,两者适用的场景也不同:

自旋锁:适用于锁被占用时间极短的场景,这时自旋可以避免上下文切换带来的开销,获得更好的性能。

互斥锁:适用于锁被占用时间较长,同步段较长的场景。这时互斥锁可以避免自旋消耗过多 CPU 资源的问题。

实际上,我们很难准确判断锁被占用的时间,所以两种锁都采取一定的自适应策略:

  • 自旋锁会设置一个循环次数上限,超过后会使用互斥锁。
  • 互斥锁在线程阻塞前,也会先进行少量的自旋,以避免立即阻塞带来的上下文切换开销。

ReentrantLock 就采用了自适应的策略,它会在一定次数的自旋后采用互斥锁阻塞线程。所以,我们使用 ReentrantLock就无需过于关注自旋锁与互斥锁的区别,ReentrantLock 会根据当前情况选择最优的策略。

总之,自旋锁与互斥锁各有优缺点,我们需要根据锁被占用的时间长短选择最合适的同步手段。但在实际开发中,很难准确判断这个时间,所以 ReentrantLock 等并发工具采用自适应策略,这也是它们性能强劲的原因之一。

CountDownLatch与CyclicBarrier的区别?

CountDownLatch 和 CyclicBarrier 都是用于线程协作的工具类,但有以下主要区别:

CountDownLatch:

  • 一次性的,计数器的值只能在构造方法中初始化一次,之后只能通过 countDown() 方法减 1 。
  • 主要用于某些线程正在 doSomething,另一些线程需要在 doSomething 完成后才能继续做其他事情。
  • 当计数器的值减到 0 时,所有调用 await() 方法而在等待的线程才会继续执行。

CyclicBarrier:

  • 可循环使用,计数器的值可以在构造方法中初始化,之后在每次调用 await() 方法之后加 1。
  • 主要用于一组线程互相等待,只有当所有线程都到达一个屏障点之后才继续执行。
  • 调用 await() 方法的线程会阻塞,直到所有的线程都调用 await() 方法。一旦计数器的值加到 parties 的值,所有线程就被释放,继而继续执行。

所以主要区别在于:

CountDownLatch 是一次性的,主要用于一个线程等待多个线程。

CyclicBarrier 可循环使用,主要用于多个线程相互等待。

示例代码:

CountDownLatch:

代码语言:java复制
CountDownLatch countDownLatch = new CountDownLatch(3);

new Thread(() -> {
    System.out.println("子线程1执行完毕");
    countDownLatch.countDown();
}).start();

new Thread(() -> {
    System.out.println("子线程2执行完毕");
    countDownLatch.countDown();  
}).start();

countDownLatch.await();  
System.out.println("所有子线程执行完毕");  // 等待子线程执行完,然后打印

CyclicBarrier:

代码语言:java复制
CyclicBarrier cyclicBarrier = new CyclicBarrier(3);

new Thread(() -> {
    System.out.println("子线程1到达栅栏");
    cyclicBarrier.await();
}).start();

new Thread(() -> {
    System.out.println("子线程2到达栅栏");
    cyclicBarrier.await();
}).start();

cyclicBarrier.await();  
System.out.println("所有子线程到达栅栏");  // 等待所有子线程到达,然后同时继续

所以根据实际需要,选择使用 CountDownLatch 还是 CyclicBarrier。它们是 JDK 中实现线程协作的两个很有用的工具。

Java中的阻塞队列你了解哪些?各自的特点是什么?

Java 中提供了以下几种阻塞队列:

  1. ArrayBlockingQueue:基于数组的阻塞队列,遵循FIFO原则。
    • 可以指定最大容量,如果塞满则插入操作会阻塞等待。
    • 吞吐量通常要高于LinkedBlockingQueue。
  2. LinkedBlockingQueue:基于链表的阻塞队列,遵循FIFO原则。
    • 可以指定最大容量,若不指定则为Integer.MAX_VALUE。
    • 对入队和出队操作的吞吐量较低,但对插入和移除操作的吞吐量较高。
  3. PriorityBlockingQueue:具有优先级的阻塞队列。
    • 不允许放入null元素,按元素自然顺序或Comparator定义的顺序排序。
    • 无最大容量,会一直扩容。
    • 对入队和出队操作的吞吐量较低,时间复杂度为O(logN)。
  4. DelayQueue:具有延时性的阻塞队列。
    • 队列元素必须实现Delayed接口。
    • 出队时会等待队头元素的延时时间到期。
    • 无最大容量,会一直扩容。
  5. SynchronousQueue:不存储元素的阻塞队列。
    • 每一个put操作必须等待一个take操作,否则将会阻塞。
    • 与其他阻塞队列不同,它是一个不存储元素的队列。
    • 吞吐量很高,可以当作线程间传递数据的通道。

除此之外,还有LinkedTransferQueue、LinkedBlockingDeque等。

阻塞队列提供了阻塞的插入、移除操作,它们在并发编程中非常有用。我们可以根据实际场景选择合适的阻塞队列。它们有各自的特点,需要权衡吞吐量、排序、延时等因素来决定用哪个队列。

CountDownLatch和Semaphore的区别与应用场景?

CountDownLatch 和 Semaphore 都是用于控制线程并发访问的工具类,但有以下主要区别:

CountDownLatch:

  • 用于使一个或多个线程等待其他线程完成各自的工作后再执行。
  • 内部维护一个计数器,每次调用countDown()方法计数器的值减 1。
  • 调用await()方法的线程会一直等待,直到计数器的值为 0。
  • 一次性的,计数器的值不能被重置。

Semaphore:

  • 用于控制对某组资源的访问权限。
  • 内部维护一个计数器,每次调用acquire()方法计数器的值减 1;调用release()方法计数器的值加 1。
  • 调用acquire()方法的线程会等待,直到有许可证可以获得。
  • 可重用,计数器的值可以被重置。

主要应用场景:

CountDownLatch:

使一个线程等待多个线程完成各自工作后再继续执行。例如,主线程等待多个子线程完成初始化工作。

Semaphore:

  • 用于限制可以访问某组资源的线程数量。例如,限制文件IO与数据库连接池中的连接数。
  • 可以用于实现资源池等。

示例代码:

CountDownLatch:

代码语言:java复制
CountDownLatch countDownLatch = new CountDownLatch(3);

new Thread(() -> {   // 子线程1
    System.out.println("子线程1开始执行");
    countDownLatch.countDown();
    System.out.println("子线程1执行完毕");
}).start();

new Thread(() -> {   // 子线程2
    System.out.println("子线程2开始执行");
    countDownLatch.countDown();   
    System.out.println("子线程2执行完毕");
}).start(); 

countDownLatch.await();   // 主线程等待
System.out.println("所有子线程执行完毕");

Semaphore:

代码语言:java复制
Semaphore semaphore = new Semaphore(2);  // 许可证数量为2

new Thread(() -> {
    try {
        semaphore.acquire();   // 获取一个许可证
        System.out.println(Thread.currentThread().getName()   "获取到许可证");
        TimeUnit.SECONDS.sleep(2);
        semaphore.release();   // 释放许可证
    } catch (InterruptedException e) {
        e.printStackTrace();
    } 
}).start();

new Thread(() -> {   // 其他线程也申请许可证
    try {
        semaphore.acquire();
        System.out.println(Thread.currentThread().getName()   "获取到许可证");
        TimeUnit.SECONDS.sleep(2);
        semaphore.release();
    } catch (InterruptedException e) {
        e.printStackTrace();
    } 
}).start(); 

所以根据需要控制线程启动顺序还是资源访问,选择使用 CountDownLatch 或 Semaphore。它们都是 Java 并发包中比较基本但非常有用的工具。

Java中的阻塞算法你了解哪些?

Java 中提供了几种常见的阻塞算法:

  1. 互斥锁(Mutual Exclusion Lock):用于保证同一时间只有一个线程可以访问共享资源。例如 ReentrantLock。
  2. 读写锁(Read-Write Lock):用于解决读写操作的互斥问题。在同一时间可以允许多个读线程或一个写线程访问数据。例如 ReadWriteLock。
  3. 信号量(Semaphore):用于控制可以访问某组资源的线程数量。它内部维护一个计数器,每调用一次 acquire() 方法计数器减 1,调用 release() 方法计数器加 1。
  4. 屏障(Barrier):用于使多个线程相互等待,只有当所有线程都到达屏障点时才可以继续执行。例如 CyclicBarrier。
  5. 栅栏(Latch):用于使一个或多个线程等待其他线程完成工作后才可以继续执行。区别于 Barrier,栅栏是一次性的。例如 CountDownLatch。
  6. 阻塞队列(Blocking Queue):用于不同线程之间通过队列进行数据传递,当队列为空或满时会使生产者或消费者线程等待。
  7. 选择器(Selector):基于事件驱动的 I/O 复用模型。用于监听多个网络连接的就绪事件,包括连接就绪、读就绪和写就绪等。
  8. 线程池(ThreadPoolExecutor):用于创建一定数量的线程,重复使用以便需要时创建新线程。它内部维护着一组线程,等待监督管理者分派可执行的任务。

这些都是 Java 并发包中比较基本与常用的阻塞算法与工具。通过它们,我们可以实现各种线程间的协作与并发控制。

互斥锁用于独占资源,读写锁用于读多写独占,信号量用于资源池,屏障与栅栏用于线程协调,阻塞队列用于生产者消费者,选择器用于 I/O 多路复用,线程池用于线程复用。掌握它们的原理与用法,是成为并发编程高手的基础。

阻塞算法虽然会使线程等待,但通过 Thread.sleep() 等方式实现的等待不属于阻塞,因为它会释放 CPU 使用权。阻塞算法会使线程在等待过程中保持活跃状态,这是一个重要的区别。

常见的并发容器与并发集合?

Java 提供了几种并发容器与集合,主要有:

  1. ConcurrentHashMap: 线程安全的 HashMap,通过分段锁实现高效并发。
    • 支持高并发的插入、删除和获取操作。
    • 键值对数量过多时(超过 Share Segment)才会带来锁的竞争开销,所以在特定场景下性能优于 HashMap。
    • 可以指定初始容量与负载因子,默认为 16 的并发级别。
  2. CopyOnWriteArrayList: 读写分离的线程安全 List,采用写入时复制的思想实现。
    • 适合读多写少的场景,因为写入时会进行复制。
    • 在迭代的过程中对列表进行修改会抛出 ConcurrentModificationException。
    • 不支持 addIfAbsent、removeIf 等原子条件操作。
  3. BlockingQueue: 线程安全的队列,常用于生产者消费者场景。例如 ArrayBlockingQueue,LinkedBlockingQueue 等。
    • 支持阻塞的插入、移除等操作。
    • 不同的实现各有特点,需要根据实际需要选择合适的队列。
  4. ConcurrentSkipListMap: 线程安全的有序 Map,通过跳表数据结构实现。
    • 键值对会按键自动排序,支持 Range 查询与迭代。
    • 除了有序性,其他方面与 ConcurrentHashMap 相似。
    • 支持较高的并发性,性能也不错。
  5. CopyOnWriteArraySet: 与 CopyOnWriteArrayList 类似,采用写入时复制的 Set。
  6. 读多写少场景,迭代时快照数据,不会抛出并发修改异常。
  7. 不支持条件操作,添加、删除单个元素操作时需要遍历并复制数据。

理解并发容器与集合的特性,选择合适的集合来解决问题,这些都是并发编程的重要技能。它们可以有效解决多线程环境下的线程安全问题,并且性能较好。并发程序设计的难点在于处理好同步与互斥,选择高效的同步手段与数据结构。并发集合正是在这方面提供了很好的支持。

Java 8中新增的并发API有哪些?

  1. StampedLock:乐观读锁与悲观写锁相结合的锁算法。它可以提高读操作的效率,适用于读多写少的场景。
    • readLock():获取读锁,锁住后可以安全读,但不保证读取的数据一定是最新。
    • writeLock():获取写锁,获取后可以安全读写,但会阻塞其他读写操作。
    • tryOptimisticRead():尝试获取乐观读锁,锁住后可以读取但不保证一定是最新数据,返回戳记。
    • validate(stamp):验证戳记,如果失败说明数据已被修改,需要重试。
  2. LongAdder:高并发场景下统计值的累加器,比 AtomicLong 性能更好。
    • increment():增加值,线程安全。
    • increment():增加值,线程安全。
  3. CompletableFuture:代表异步计算结果的完整的 Future。它提供了很丰富的 API 来组合(thenCombine() 等)执行多个 Future,对它们的结果进行处理(thenApply() 等)。
    • supplyAsync():异步提供结果的 Future。
    • thenApply():接收上一步的结果并处理,返回一个新的 Future。
    • thenCombine():合并两个 Future 的结果,并处理,返回一个新的 Future。
    • thenAccept():接收上一步的结果并处理,但没有返回值。
    • exceptionally():处理 Future 产生的异常。
    • get():获取最终结果。
  4. CompletionStage:与 CompletableFuture 差不多,但 API 更简单。常与 Stream 搭配使用。
    • thenApply():接收上一步的结果并处理,返回一个新的 Stage。
    • thenCombine():合并两个Stage的结果,并处理,返回一个新的Stage 。
    • exceptionally():处理Stage产生的异常。
    • toCompletableFuture():转换为CompletableFuture。

这些 API 使异步编程变得更简单,也为我们提供更多的并发工具。虽然学习成本也有所提高,但使用它们可以轻松编写出高性能的并发程序。Java 一直在着力丰富其并发支持与工具。理解并熟练使用这些 API 有助于我们编写出更高效的多线程程序。这也是成为并发编程高手必须掌握的技能。

JDK 中的并发工具类有哪些?你最常用的有哪些?

JDK 中提供了许多并发工具类,主要有:

  1. Executor 和 ExecutorService:用于线程池与任务执行。这是我使用最频繁的工具类之一。
  2. CountDownLatch 和 CyclicBarrier:用于线程同步协作。CountDownLatch 更常用。
  3. Semaphore:用于控制对某组资源的访问权限。用来实现资源池等机制。
  4. BlockingQueue:线程安全的队列,常用于生产者消费者场景。ArrayBlockingQueue 和 LinkedBlockingQueue 使用较多。
  5. Lock 和 ReentrantLock:用于显式锁定,替代 synchronized。ReentrantLock 是我最常使用的锁实现。
  6. Atomic 系列类:用于原子操作,线程安全。AtomicInteger,AtomicBoolean 和 AtomicReference 使用较频繁。
  7. ConcurrentHashMap:线程安全的 HashMap。这可能是我使用最多的并发集合了。
  8. CopyOnWriteArrayList:读写分离的线程安全 List,适用于读多写少的场景。偶尔会使用。
  9. CompletableFuture:代表异步计算结果的 Future,可以串行执行任务和处理计算结果。Java 8 中使用较频繁。
  10. ForkJoinPool:Java 7 加入的分治框架,用来并行执行任务。适用于可以分治的 CPU 密集型计算。

除此之外,还有 ConcurrentSkipListMap、CopyOnWriteArraySet、StampedLock 等并发类。

这些并发工具类和集合覆盖了并发编程中最主要与常用的功能。掌握它们的特性与用法,可以有效编写出高效与正确的多线程程序。

这些类可以解决线程池、线程同步、互斥与协作、资源控制、原子操作、线程安全集合与异步编程等方面的问题。所以对我来说,它们是最重要与不可或缺的并发工具。

0 人点赞