Java 常见的锁及同步机制
Java 常见的锁
一、synchronized 关键字
它是 Java 的内置锁机制,可以用于同步代码块或方法。通过 synchronized,你可以锁定对象或类。
1.1 使用规则:
synchronized 方法:你可以在方法前面加上,synchronized 关键字来锁定整个方法,确保同一时刻只有一个线程可以执行这个方法。这通常用于保护对象的状态。例如:
synchronized 代码块:你也可以使用 synchronized 代码块,锁定特定的对象,而不是整个方法。这样可以更精确地控制同步。例如:
1.2 使用场景:
1、多线程协同工作:synchronized 适用于需要多个线程协同工作,共享某个资源的情况。例如,在多线程环境下访问共享的数据结构时,你可以使用 synchronized 来确保数据的一致性。
多个线程在执行过程中相互配合以完成某项任务。这种协同工作通常涉及多个线程之间的数据共享和互斥访问,因此需要合适的同步机制来确保线程安全。
比如生产者-消费者模型:
这是一个经典的多线程协同工作场景。生产者线程负责生产数据,消费者线程负责消费数据。两者需要协同工作以确保生产和消费的顺序和数据完整性。你可以使用 synchronized 来实现这种模型。
2、单例模式:在创建单例对象时,你可能需要确保只有一个对象被实例化。你可以使用 synchronized 来实现线程安全的单例模式。
3、线程安全的数据结构:
Java 标准库提供了线程安全的数据结构,如 Vector,HashTable,Collections.synchronizedList 等,它们在内部使用 synchronized 来确保线程安全。
4、等待-通知机制:当一个线程等待另一个线程的通知时,你可以使用 synchronized 来创建一个监视器对象,以控制线程的等待和通知。
synchronized (monitor) { // 等待 monitor.wait(); // 通知 monitor.notify(); }
需要注意的是,虽然 synchronized 是最简单的同步机制,但它可能会导致性能问题,因为只有一个线程能够执行同步块。在高度并发的情况下,你可能需要考虑使用更高级的锁机制,如 ReentrantLock,以提高性能和灵活性。
二、ReentrantLock
java.util.concurrent.locks.ReentrantLock 是 Java 提供的显式锁。它提供了更多的灵活性,如可中断、超时等等。 与 synchronized 关键字不同,ReentrantLock 提供了更多的灵活性和控制,特别是在多线程环境下。
2.1 使用规则:
获取锁:使用 lock()方法来获取锁。如果锁已被其他线程获取,当前线程会阻塞直到锁可用
释放锁:确保在合适的地方释放锁,通常在 finally 块中释放锁,以确保异常情况下锁一定会被释放。
2.2 使用场景:
替代 synchronized:当需要更精确的控制线程的锁定行为时,ReentrantLock 可以替代 synchronized。
条件等待:ReentrantLock 可以通过 Condition 对象来实现线程的等待和唤醒,以实现更复杂的同步机制,这通常在一些高级的多线程应用中非常有用。
以下是一个简单的示例,演示如何使用 ReentrantLock 和 Condition 来控制两个线程的通信。
在上述示例中,ReentrantLock 和 Condition 用于实现生产者和消费者之间的同步。produce 方法生产数据,如果数据已经可用,它会等待;consume 方法消费数据,如果数据不可用,它也会等待。这种方式确保了生产者和消费者之间的协同工作,避免了竞态条件。
公平锁与非公平锁:ReentrantLock 允许选择是否使用公平锁,公平锁会按照线程请求锁的顺序获得锁,而非公平锁不做这样的保证,可能导致某些线程在其他线程之前获得锁。下面是一个简单的示例来说明这两者之间的差异。
在这个示例中,我们创建了一个使用公平锁和一个使用非公平锁的 ReentrantLock 。然后,我们创建了两个线程分别使用这两个锁执行任务。你会注意到,使用公平锁的情况下,线程按顺序获得锁,而使用非公平锁的情况下,线程之间的获锁顺序是不确定的。
2.3 优缺点:
优点:
更灵活的线程控制:ReentrantLock 提供了一系列灵活的方法,如超时获取锁、尝试获取锁等。
更细粒度的锁控制:可以精确控制锁的获取和释放。
可中断:在等待锁的过程中,可以响应中断。
缺点:
复杂性:相较于 synchronized,ReentrantLock 使用起来较为复杂。
需要手动释放锁:使用 synchronized 时,锁的释放由 Java 虚拟机自动处理,而 ReentrantLock 需要手动释放,容易忘记。
二、ReadWriteLock
(读写锁)是 Java 并发编程中的一个重要概念,它是一个接口,通常由两个具体的类来实现:ReentrantReadWriteLock 和 StampedLock。ReadWriteLock 的主要特点是它分离了读锁和写锁,允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
读锁:多个线程可以同时获取读锁,用于读取共享资源,读锁之间不互斥。
写锁:只允许一个线程获取写锁,用于修改共享资源,写锁会阻塞其他读锁和写锁。
3.1 使用规则:
在使用 ReadWriteLock 时,通常需要创建一个 ReadWriteLock 实例,然后根据需要获取读锁或写锁。
读锁的获取使用 readLock()方法,写锁的获取使用 writeLock()方法。
一般的使用规则是,读操作可以同时进行,而写操作会排他执行,即写锁会阻塞其他写锁和读锁。
3.2 使用场景:
适用于读多写少的场景,当共享资源被频繁读取而很少被修改时,使用读写锁可以提高并发性能。
适用于需要降低锁竞争的情况,因为读锁之间不互斥,可以提高并发性。
3.3 优缺点:
写锁的等待可能导致读锁的饥饿:如果写锁一直有请求,读锁可能一直等待。
不能替代所有锁:虽然读写锁可以提高并发性能,但并不是所有情况下都适用,某些场景下仍需要使用普通锁。
下面是一个使用 ReadWriteLock 的简单示例:
这个示例中,readSharedResource 方法获取读锁来读取 sharedResource,writeSharedResource 方法获取写锁来修改 sharedResource。这样,多个线程可以同时读取,但只有一个线程可以修改。
四、Semaphore
java.util.concurrent.Semaphore 是一个计数信号量,它可以控制同时访问资源的线程数。
4.1 使用规则:
使用 acquire()方法获取许可证。
使用 release()方法释放许可证。
可以通过构造函数指定 Semaphore 的许可证数量。
4.2 使用场景:
Semaphore 通常用于以下情况:
控制资源访问:限制同时访问资源的线程数量,例如控制数据库连接池的访问。
并发任务控制:限制同时执行的任务数量,以免系统过载。
流量控制:控制访问服务器的并发连接数,以维护系统的稳定性。
同步问题的解决:在某些同步问题中,Semaphore 可以用于实现更复杂的同步方案。
4.3 优缺点:
优点:
可以有效控制资源的并发访问,避免资源竞争问题。
提供了一种机制,可以限制同时执行的任务数量。
缺点:
如果使用不当,可能会引入死锁问题。
需要仔细考虑许可证的数量和资源的特性,否则可能导致系统性能问题。
示例代码:
下面是一个简单的 Semaphore 示例,演示了如何使用 Semaphore 来控制对共享资源的访问:
五、CyclicBarrier
java.util.concurrent.CyclicBarrier
CyclicBarrier 是一个同步辅助类,它允许一组线程在达到一个共同的屏障点之前相互等待。一旦所有线程都达到这个屏障点,它们可以继续执行。CyclicBarrier 的一个重要特点是,它可以在多个线程间创建一个屏障,当所有线程都到达这个屏障后,它们可以同步执行下一阶段的任务。
5.1 使用规则:
创建 CyclicBarrier 时,需要指定参与线程的数量和在屏障处执行的动作(可选)。
调用 await()方法,当线程到达屏障点时,它会等待其他线程也到达,然后一起继续执行。
5.2 使用场景:
CyclicBarrier 通常用于以下情况:
分布式系统中的任务协同:多个节点的任务必须协同工作,等待所有节点准备好后再一起执行下一步操作。
数据分析:多个数据处理任务在某一阶段合并结果后,继续进行下一阶段的分析。
多阶段计算:任务分成多个阶段,每个阶段的任务需要等待其他任务完成后才能继续。
5.3 优缺点:
优点:
提供了一种同步机制,允许线程在某个屏障点同步等待。
可以用于实现多阶段任务的同步。
缺点:
一旦屏障打破,就无法重置。如果需要多次使用,必须重新创建新的 CyclicBarrier 实例。
可能引入线程竞争和死锁问题,因此需要小心使用。
示例代码:
下面是一个简单的 CyclicBarrier 示例,演示了如何使用 CyclicBarrier 来控制一组线程在达到屏障点后同步执行:
在这个示例中,我们创建了一个 CyclicBarrier,它需要三个线程同时到达屏障点才会执行屏障后的动作。每个线程在到达屏障点后会等待其他线程,然后一起继续执行。CyclicBarrier 在多阶段任务中同步线程的执行非常有用。
特别备注:
对于 CyclicBarrier 来说,确实需要在构造时指定的参与线程数是设置的屏障点的倍数,这样可以确保所有线程都能继续执行。如果参与线程数不是屏障点的倍数,有可能导致某些线程无法继续执行,或者某些线程会永久地等待。
比如: 参与线程数为 10,CyclicBarrier 设置屏障点为 3;
第一组:1 等待,2 等待,3 到达屏障,1、2、3 继续执行。
第二组:4 等待,5 等待,6 到达屏障,4、5、6 继续执行。
第三组:7 等待,8 等待,9 到达屏障,7、8、9 继续执行。
第四组:10 等待
因为第四组只有 1 个,无法到达屏障点,只能一次等待
六、CountDownLatch
CountDownLatch 是 Java 中的一个同步工具类,它允许一个或多个线程等待其他线程完成操作。它的工作方式是,创建一个 CountDownLatch 实例时,通过指定一个计数器来初始化它。这个计数器代表需要等待的事件数量。每当一个事件完成,就会通过 countDown() 方法将计数器减一。当计数器的值达到零时,等待的线程可以继续执行。
6.1 使用规则:
创建 CountDownLatch 对象并初始化计数器,通常设置计数器的值为需要等待的线程数量。
在需要等待的线程中,在适当的位置(通常是事件完成的地方)调用 countDown() 方法,表示一个事件完成。
其他等待的线程(通常是主线程)可以调用 await() 方法来等待计数器的值达到零。await() 方法将会阻塞当前线程,直到计数器的值为零,然后继续执行。
6.2 使用场景:
CountDownLatch 通常用于以下场景:
启动多个线程,等待所有线程都完成后再继续主线程的执行。
实现一个并发控制,确保一组任务都完成后再执行下一组任务。
等待多个网络请求完成后再进行某项操作。
6.3 优点和缺点:
优点:
简单易用,易于理解。
可以用于控制多个线程的同步。
灵活性强,可以根据需要等待不同数量的事件。
缺点:
一旦计数器达到零,就无法重置,需要创建新的 CountDownLatch 实例。
CountDownLatch 只能用于一次性的事件,不能用于周期性的事件等。
以下是一个简单的示例,演示了 CountDownLatch 的基本用法:
CountDownLatch latch = new CountDownLatch(3); // 等待 3 个线程完成 // 在各个线程中执行 countDown(),当 3 个线程都执行到此时,等待的线程可以继续执行。
在这个示例中,我们创建了一个 CountDownLatch,计数器的初始值为 3。然后,我们启动了三个线程执行任务,并在每个任务完成时调用 countDown() 方法。最后,主线程通过 await() 方法等待计数器的值为零,然后继续执行。
版权声明: 本文为 InfoQ 作者【echoes】的原创文章。
原文链接:【http://xie.infoq.cn/article/206e29ae885b9a5ca542ae9ee】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论