目录
1.ReentrantLock(互斥锁)
2.ReentRantReaderWriterLock(互斥读写锁)
3.StampedLock(无障碍锁)
4.Condition(自定义锁)
5.LockSupport
问题引出:
由于传统的线程控制需要用到同步机制Synchronized与 Object类中的wait();notify();函数进行控制,但是这样控制并不容易,所以在JUC中提供了全新的框架,
框架核心接口为:1.Lock();2.ReaderWriteLock();
1.ReentrantLock(互斥锁)
ReentrantLock是一种互斥锁,意思是一旦有一个线程获取到锁,那么其他线程就无法运行将会进行等待阻塞。其中又分为公平锁和非公平锁。区别在于获取锁的机制是否公平。并且该锁通过一个FIFO队列管理所有的等待线程。
以下是ReentrantLock类的常用方法:
方法名 | 描述 |
---|---|
ReentrantLock() | 创建一个新的ReentrantLock实例。 |
ReentrantLock(boolean fair) | 创建一个新的ReentrantLock实例,根据参数fair的值决定是否按公平顺序获取锁。 |
lock() | 获取锁,如果锁不可用,则当前线程将被阻塞,直到锁可用。 |
unlock() | 释放锁,使得其他等待锁的线程可以尝试获取锁。 |
tryLock() | 尝试获取锁,如果锁可用则获取锁并返回true,否则立即返回false。 |
isFair() | 判断锁是否是公平锁,如果是公平锁则返回true,否则返回false。 |
new Condition() | 创建一个与该锁关联的Condition对象,用于线程间等待和通知,在调用Condition的await方法时,当前线程会释放锁。 |
案例:有五个售票员同时售卖8张票
package Example2110;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
class Ticeket{
private int ticket = 8;
// 创建互斥锁并设置为公平锁,所有线程运行都是等概率的
ReentrantLock reentrantLock = new ReentrantLock(true);
public void sale(){
while (true){
// 开启锁只有一个线程能通过
reentrantLock.lock();
try {
// 模拟网络延迟
TimeUnit.SECONDS.sleep(1);
if (ticket>0){
System.out.println(Thread.currentThread().getName()+"售卖第"+ticket--+"张票");
}else {
System.out.println("卖完咯");
break;
}
}catch (Exception e){
e.printStackTrace();
}finally {
// 释放锁
reentrantLock.unlock();
// 线程让行,让其他线程也有机会运行
Thread.yield();
}
}
}
}
public class javaDemo {
public static void main(String[] args) {
Ticeket ticket = new Ticeket();
// 创建多个售卖对象
for (int i=1;i<=5;i++){
new Thread(()->{
ticket.sale();
},"售票员"+i).start();
}
}
}
问题引出:
独占锁的最大弊端就在于其阻断了其他线程只允许一个线程工作。在很多情况下就会造成性能问题。所以引入了ReentrantLock(读写互斥锁)
面试题:ReentrantLock 是如何实现可重入性的?
(1)什么是可重入性
一个线程持有锁时,当其他线程尝试获取该锁时,会被阻塞;而这个线程尝试获取自己持有锁时,如果成功说明该锁是可重入的,反之则不可重入。
(2)synchronized是如何实现可重入性
synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令。每个锁对象内部维护一个计数器,该计数器初始值为0,表示任何线程都可以获取该锁并执行相应的方法。根据虚拟机规范要求,在执行monitorenter指令时,首先要尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了对象的锁,把锁的计数器+1,相应的在执行monitorexit指令后锁计数器-1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
(3)ReentrantLock如何实现可重入性
ReentrantLock使用内部类Sync来管理锁,所以真正的获取锁是由Sync的实现类控制的。Sync有两个实现,分别为NonfairSync(非公公平锁)和FairSync(公平锁)。Sync通过继承AQS实现,在AQS中维护了一个private volatile int state来计算重入次数,避免频繁的持有释放操作带来的线程问题。
(4)代码分析
当一个线程在获取锁过程中,先判断state的值是否为0,如果是表示没有线程持有锁,就可以尝试获取锁。
当state的值不为0时,表示锁已经被一个线程占用了,这时会做一个判断current==getExclusiveOwnerThread(),这个方法返回的是当前持有锁的线程,这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将state的值+1,表示重入返回即可。
2.ReentRantReaderWriterLock(互斥读写锁)
这个锁的机制和ReentrantLock区别并不大,但是将其权力拆分出来,分别为共享锁读,和互斥锁写锁。意思就是多线程想要修改数据就只允许其中一个线程修改其他等待,但是读取数据大家都可以随意读取。
ReentrantReaderWriter类的常用操作方法:
方法名 | 描述 |
---|---|
readLock() | 获取读锁,如果写锁被占用,则当前线程会被阻塞,直到写锁释放。 |
writeLock() | 获取写锁,如果读锁或写锁被占用,则当前线程会被阻塞,直到所有的读锁和写锁都释放。 |
注意:实例化该对象用到开头提到框架中的ReadWriteLock接口进行向上转型得到对象。
如:ReaderWriteLock readWriterLock = new RenntrantReaderWriterLock();
以下案例通过ReentrantReadWriteLock实现一个银行10个ATM机,五个进行存款,五个进行读取账户信息
package Example2111;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Bank{
private String name = "黄田";
private double account = 0;
private ReadWriteLock readWriteLock= new ReentrantReadWriteLock();
// 存钱
public void write(double money){
// 设置写锁并开启
this.readWriteLock.writeLock().lock();
try {
account = account+money;
// 模拟网络延迟
TimeUnit.SECONDS.sleep(1);
System.out.println("您正在使用"+Thread.currentThread().getName()+"存入存款:"+money+",当前账户余额为"+account);
}catch (Exception e){
e.printStackTrace();
}finally {
// 更新完数据就可以解放写锁
this.readWriteLock.writeLock().unlock();
}
}
// 读取存款
public void Read(){
// 设置读取共享锁
this.readWriteLock.readLock().lock();
try {
// 模拟网络延迟
TimeUnit.SECONDS.sleep(2);
System.out.println("您正在使用"+Thread.currentThread().getName()+"读取账户信息:"+":当前账户人名称"+name+",账户人当前的余额为"+account);
}catch (Exception e){
e.printStackTrace();
}finally {
// 读取完数据自动解锁
this.readWriteLock.readLock().unlock();
}
}
}
public class javaDemo {
public static void main(String[] args) {
Bank bank = new Bank();
double moneys[]=new double[]{10,500,300,400};
// 五个ATM存款
for (int i =1;i<=5;i++){
new Thread(()->{
for (int j=0;j<moneys.length;j++){
bank.write(moneys[j]);
}
},"银行分行ATM"+i+"号取款机").start();
}
// 五个ATM读取存款
for (int i=5;i<=10;i++){
new Thread(()->{
bank.Read();
},"银行分行ATM"+i).start();
}
}
}
面试题:synchronized 和 ReentrantLock 区别是什么?
synchronized 是和 if、else、for、while 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量
synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大,但是在 Java 6 中对 synchronized 进行了非常多的改进。
相同点:两者都是可重入锁
两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
主要区别如下:
ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark wordJava中每一个对象都可以作为锁,这是synchronized实现同步的基础:
普通同步方法,锁是当前实例对象
静态同步方法,锁是当前类的class对象
同步方法块,锁是括号里面的对象
3.StampedLock(无障碍锁)
问题引出:虽然ReentrantLock和ReentrantReaderWriter解决了并发访问下数据写入安全和效率的问题,但是如果出现非常多的线程的时候,有可能会造成一些线程一直阻塞。调度减少的情况。为此JUC提供了StampedLock(无障碍锁)该所的特点在于若干个读线程之间互不干预。并且可以保证多个写线程的独占操作
以下是StampedLock类的常用方法:
方法 | 描述 |
---|---|
readLock() | 获取读锁。 |
tryReadLock() | 尝试获取读锁,如果获取成功返回一个非负数,否则返回一个负数。 |
optimisticRead() | 获取一个乐观读锁。 |
tryConvertToOptimisticRead(stamp) | 尝试将锁从写锁转换为乐观读锁,如果转换成功返回一个非负数,否则返回一个负数。 |
tryConvertToReadLock() | 尝试将锁从乐观读锁转换为普通读锁,如果转换成功返回一个非负数,否则返回一个负数。 |
writeLock() | 获取写锁。 |
tryWriteLock() | 尝试获取写锁,如果获取成功返回一个非负数,否则返回一个负数。 |
unlock() | 释放锁。 |
unlockRead() | 释放读锁。 |
unlockWrite() | 释放写锁。 |
validate() | 验证锁是否仍然有效。 |
在StampedLock中有三种模式,乐观读,读,写用以提高并发处理性能,也用以转换锁的类型
案例代码:
假设我们有一个名为Point
的类,表示二维平面上的一个点,其中包含x坐标和y坐标。我们希望实现对这个点的读写操作,并且要确保在写操作时其他线程不能同时读取或写入。
package Example2113;
import org.omg.CORBA.BAD_CONTEXT;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.locks.StampedLock;
class Point{
// 坐标
double x;
double y;
private final StampedLock stampedLock = new StampedLock();
public void set(double x,double y){
// 获取写锁
long stamp = stampedLock.writeLock();
try {
this.x = x;
this.y = y;
}catch (Exception e){
e.printStackTrace();
}finally {
// 执行完后释放写锁
stampedLock.unlockWrite(stamp);
}
}
public double[] get(){
// 尝试获取乐观锁
long stamp = stampedLock.tryOptimisticRead();
double currentX =x;
double currentY =y;
// 如果获取乐观锁失败则转为悲观锁
if (!stampedLock.validate(stamp)){
// 获取
stamp = stampedLock.readLock();
try {
currentX =x;
currentY = y;
}catch (Exception e){
e.printStackTrace();
}finally {
stampedLock.unlockRead(stamp);
}
}
return new double[]{currentX, currentY};
}
}
public class javaDemo {
public static void main(String[] args) {
Point point = new Point();
Random random = new Random(10);
for (int i =0;i<5;i++){
new Thread(()->{
point.set(random.nextDouble(), random.nextDouble());
double recieve[] = point.get();
System.out.println(Arrays.toString(recieve));
}).start();
}
// for (int j = 0;j<20;j++){
// new Thread(()->{
// double recieve[] = point.get();
// System.out.println(Arrays.toString(recieve));
// }).start();
// }
}
}
问:乐观锁和悲观锁是什么?
乐观锁和悲观锁是并发编程中两种不同的锁策略。
乐观锁:乐观锁假设多个线程之间的并发冲突很少发生,所以它们可以同时读取共享数据而无需阻塞其他线程。在乐观锁中,线程首先尝试获取读锁,即乐观读取操作。如果没有发生写入冲突,那么乐观读锁会立即完成,并返回结果。但如果有其他线程在此期间进行了写入操作,则需要重新获取悲观锁再次尝试。
悲观锁:悲观锁假设多个线程之间的并发冲突经常发生,因此每个线程在访问共享数据之前会悲观地认为会发生冲突,所以必须先获得独占锁(写锁)或共享锁(读锁)。只有持有锁的线程完成操作后,其他线程才能访问共享数据。悲观锁会导致其他线程阻塞等待锁的释放,从而降低并发性能。
问:为什么要用Stamp,代码中的long stamp是什么意思
stamp
是一个标记,用于记录锁的状态。在读取锁和写入锁上下文之间传递,以确保数据一致性。
面试题 :请谈谈 ReadWriteLock 和 StampedLock
ReadWriteLock包括两种子锁
(1)ReadWriteLock
ReadWriteLock 可以实现多个读锁同时进行,但是读与写和写于写互斥,只能有一个写锁线程在进行。
(2)StampedLock
StampedLock是Jdk在1.8提供的一种读写锁,相比较ReentrantReadWriteLock性能更好,因为ReentrantReadWriteLock在读写之间是互斥的,使用的是一种悲观策略,在读线程特别多的情况下,会造成写线程处于饥饿状态,虽然可以在初始化的时候设置为true指定为公平,但是吞吐量又下去了,而StampedLock是提供了一种乐观策略,更好的实现读写分离,并且吞吐量不会下降。
StampedLock包括三种锁:
(1)写锁writeLock:
writeLock是一个独占锁写锁,当一个线程获得该锁后,其他请求读锁或者写锁的线程阻塞, 获取成功后,会返回一个stamp(凭据)变量来表示该锁的版本,在释放锁时调用unlockWrite方法传递stamp参数。提供了非阻塞式获取锁tryWriteLock。
(2)悲观读锁readLock:
readLock是一个共享读锁,在没有线程获取写锁情况下,多个线程可以获取该锁。如果有写锁获取,那么其他线程请求读锁会被阻塞。悲观读锁会认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据进行加锁,这是在读少写多的情况下考虑的。请求该锁成功后会返回一个stamp值,在释放锁时调用unlockRead方法传递stamp参数。提供了非阻塞式获取锁方法tryWriteLock。
(3)乐观读锁tryOptimisticRead:
tryOptimisticRead相对比悲观读锁,在操作数据前并没有通过CAS设置锁的状态,如果没有线程获取写锁,则返回一个非0的stamp变量,获取该stamp后在操作数据前还需要调用validate方法来判断期间是否有线程获取了写锁,如果是返回值为0则有线程获取写锁,如果不是0则可以使用stamp变量的锁来操作数据。由于tryOptimisticRead并没有修改锁状态,所以不需要释放锁。这是读多写少的情况下考虑的,不涉及CAS操作,所以效率较高,在保证数据一致性上需要复制一份要操作的变量到方法栈中,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性得到了保证。
面试题: 如何让 Java 的线程彼此同步?
- synchronized
- volatile
- ReenreantLock
使用局部变量实现线程同步
4.Condition(自定义锁)
在JUC中允许用户进行锁对象的创建,可以通过Condition接口实现。经常用于生产者消费者模型中。
以下是Condition接口的常用方法
方法 | 描述 |
---|---|
await() | 当前线程等待,并释放锁。等价于Object.wait() |
await(long time, TimeUnit unit) | 当前线程等待一段时间,并释放锁。等价于Object.wait() |
awaitUninterruptibly() | 当前线程不可中断地等待,并释放锁。 |
signal() | 唤醒一个等待该条件的线程。等价于Object.notify() |
signalAll() | 唤醒所有等待该条件的线程。等价于Object.notifyAll() |
使用案例:
假设有一个生产者-消费者模型,多个生产者线程负责向共享队列中生产数据,多个消费者线程负责从队列中消费数据。
package Example2114;
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class SharedQueue {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private static final int MAX_SIZE = 10;
private Queue<Integer> queue = new LinkedList<>();
public void produce(int num) throws InterruptedException {
lock.lock();
try {
while (queue.size() == MAX_SIZE) {
// 队列满时,等待条件notFull
notFull.await();
}
queue.offer(num);
System.out.println("Produced: " + num);
// 唤醒一个等待notEmpty条件的线程
notEmpty.signal();
} finally {
lock.unlock();
}
}
public int consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
// 队列空时,等待条件notEmpty
notEmpty.await();
}
int num = queue.poll();
System.out.println("Consumed: " + num);
// 唤醒一个等待notFull条件的线程
notFull.signal();
return num;
} finally {
lock.unlock();
}
}
}
public class javaDemo {
public static void main(String[] args) {
SharedQueue sharedQueue = new SharedQueue();
// 创建生产者线程
Thread producer1 = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
sharedQueue.produce(i);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread producer2 = new Thread(() -> {
try {
for (int i = 20; i < 40; i++) {
sharedQueue.produce(i);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 创建消费者线程
Thread consumer1 = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
sharedQueue.consume();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread consumer2 = new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
sharedQueue.consume();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 启动线程
producer1.start();
producer2.start();
consumer1.start();
consumer2.start();
}
}
案例2:实现缓存队列的读取:
package Example2116;
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
//缓冲读取与写入
class DataBuffer{
private final Lock lock = new ReentrantLock();
private final Condition notUll = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private static final int MAX_SIZE = 20;
Queue<String> queue = new LinkedList<>();
public void Set(String str){
// 获取互斥锁
lock.lock();
try {
// 当缓冲区满了
while (queue.size()==MAX_SIZE){
notUll.await();
}
// 模拟网络延迟
TimeUnit.SECONDS.sleep(2);
// 放入数据并尝试唤醒等待的消费者线程
queue.offer(str);
System.out.println(Thread.currentThread().getName()+"放入数据"+str);
notEmpty.signal();
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
public String Get() throws Exception{
// 使用互斥锁
lock.lock();
try {
while (queue.isEmpty()){
notEmpty.await();
}
String string = queue.poll();
System.out.println("获取到缓冲区内容"+string);
notUll.signal();
return string;
}finally {
// 解锁
lock.unlock();
}
}
}
public class javaDemo {
public static void main(String[] args) {
DataBuffer dataBuffer = new DataBuffer();
for (int i=0;i<5;i++){
new Thread(()->{
for (int j = 0;j<20;j++){
dataBuffer.Set("数据"+j+"号数、");
}
},"生产者"+i+"号").start();
}
for (int i=0;i<50;i++){
new Thread(()->{
for (int j=0;j<100;j++){
try {
System.out.println("消费者取走"+dataBuffer.Get());
}catch (Exception e){}
}
}).start();
}
}
}
面试题: Java 如何实现多线程之间的通讯和协作?
Java中线程通信协作的最常见的两种方式:
1、syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
2、ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll()
5.LockSupport
由于JDK1.2时候为了预防死锁废除了一些Thread中的方法,但是一部分人认为这几个方法在操作上实际会更加直观,所以就有了LockSupport来替代这几个方法
LockSupport类的常用方法:
方法签名 | 说明 |
---|---|
park() | 阻塞当前线程,直到调用unpark(Thread thread) 或者中断当前线程。 |
park(Object blocker) | 阻塞当前线程,并关联一个特定的阻塞对象,用于调试和监控的目的。 |
parkNanos(long nanos) | 阻塞当前线程,最多阻塞指定的纳秒数,参数nanos 为等待时间。 |
parkNanos(Object blocker, long nanos) | 阻塞当前线程,并关联一个特定的阻塞对象,最多阻塞指定的纳秒数。 |
unpark(Thread thread) | 解除指定线程的阻塞状态。可以提前唤醒被阻塞的线程,使其继续执行。 |
案例代码:设置一个长辈线程和一个孩子辈线程,只有当长辈线程吃饭时候子线程才能进行吃饭
package Example2117;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
public class javaDemo {
public static void main(String[] args) {
// 子线程
Thread son = new Thread(()->{
// 阻塞线程
LockSupport.park();
System.out.println("子辈开始吃饭");
});
// 父辈线程
Thread father = new Thread(()->{
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("长辈开始吃饭");
}catch (Exception e){
e.printStackTrace();
}finally {
LockSupport.unpark(son);
}
});
son.start();
father.start();
}
}
面试题:Java Concurrency API 中的 Lock 接口(Lock interface)是什么?对比同步它有什么优势?
Lock 接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。
它的优势有:
(1)可以使锁更公平
(2)可以使线程在等待锁的时候响应中断
(3)可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
(4)可以在不同的范围,以不同的顺序获取和释放锁
整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。