问题引出:
由于传统的线程控制需要用到同步机制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() | 获取写锁,如果读锁或写锁被占用,则当前线程会被阻塞,直到所有的读锁和写锁都释放。 |
注意:实例化该对象用到开头提到框架中的ReaderWriterLock接口进行向上转型得到对象。
如:ReaderWriteLock readWriterLock = new RenntrantReaderWriterLock();
以下案例通过ReentrantReaderWriterLock实现一个银行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 word
Java中每一个对象都可以作为锁,这是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 只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。