【Java】Java多线程学习笔记(三) 甚欢篇
wait 和 notify、notifyAll
Java多线程学习笔记(一) 初遇篇,我们已经介绍了多线程常用的API(废弃的接口不做介绍),但是我们还有两个比较重要的没有介绍,即wait(等待)、notify(通知),这是现实世界中比较常见的动作,比如你的女朋友说周末想跟你去看电影,然后你兴冲冲的去你女朋友家等她,然后你女朋友说让你等下(wait),她见心上人需要画个妆,画完妆之后,跟你说我画好了(notify),我们出门吧。
像下面这样:
public class RomaticDateThreadDemo implements Runnable {// 画妆标志位
private boolean flag;
private static final String THREAD_GIRLFRIEND = "女朋友";
public RomaticDateThreadDemo(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
synchronized (this) {
// RomaticDateThreadDemo 的构造函数给的是false
if (!flag) {
flag = true;
String currentThreadName = Thread.currentThread().getName();
if (THREAD_GIRLFRIEND.equals(currentThreadName)) {
// 输出这句话说明女朋友线程先进来
System.out.println("你死定了,敢让你女朋友等");
} else {
// 假设男孩子线程先进来
System.out.println("...........女朋友正在化妆中.................,请等一会儿");
try {
//等待女朋友唤醒,wait代表释放对象锁(释放许可证)
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} else {
String currentThreadName = Thread.currentThread().getName();
if (THREAD_GIRLFRIEND.equals(currentThreadName)) {
// 走到这里,说明男孩子线程率先被线程调度器选中
System.out.println("..........要画十秒的妆.............");
try {
TimeUnit.SECONDS.sleep(5);
// 唤醒
this.notify();
System.out.println(currentThreadName+"说:我们走吧");
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
// 走到这里,说明女朋友线程率先被线程调度器选中
System.out.println(currentThreadName+"说:我们走吧");
this.notify();
}
}
}
}
}
public class WaitDemo {public static void main(String[] args) {
RomaticDateThreadDemo romaticDateThreadDemo = new RomaticDateThreadDemo(false);
Thread you = new Thread(romaticDateThreadDemo);
you.setName("你");
Thread girlFriend = new Thread(romaticDateThreadDemo);
girlFriend.setName("女朋友");
you.start();
girlFriend.start();
}
}
但是notify、wait、notifyAll(唤醒所有处于等待中的线程)没有位于Thread类下,所有的类都具有这三个方法,那么请问Java的设计者是怎么做到呢?让所有的类都具备了这三个方法呢?当然是在Object类中做了,而且做成public,我们知道所有的类都继承自Object。由于wait方法在调用的时候是释放了当前线程持有的锁,那么我们可以大致得出一个结论,wait、notify、notifyAll只能配合synchronized使用,显式锁是通过调用unlock方法来实现释放锁的,而我们在Object看到,wait、notify、notifyAll是一个native(java和其他语言通信的一种手段,由于操作系统大多都采用C、C++编写而成,而一些文件的操作只能通过调用操作系统提供的接口完成,所以Java调用C、C++就通过native这种机制来实现调用操作系统的接口)方法。
那么为什么呢? 为什么要讲这三个原属于线程的方法放在所有类中呢?
那这就跟锁的位置有关系了,我们知道锁是放在对象中的,JVM(一般不加说明说的虚拟机都是HotSpot,Oracle发行的虚拟机)会为每个对象维护一个入口集(Entry Set)用于存储申请该对象内部锁的线程。除此之外,JVM还会为每一个对象维护一个等待集(Wait Set)的队列,该队列用于存储该对象上的等待线程。
首先synchronized是一个对象锁,从面向对象的角度来讲,获取锁是一种行为,释放锁也是一种行为,那么wait、notify、notifyAll放在Obejct中就是合理的,一切对象都可以是锁,所以锁放在Object中,所有的类都间接继承Object,这个设计合理。
那么现在我们假设我们就将wait、notify、notifyAll放入Thread类中,这个时候线程调用wait方法释放对应锁的时候,就要拿到对应的对象,由于一切对象都可以当锁来用,那么这个方法可能还需要一个泛型参数接收存储锁的对象(一个线程可能持有多把锁,假设如果调用者不说明要释放哪吧锁,JVM无从得知当前线程希望释放哪吧锁),用来释放锁。除此之外,notify方法的作用是唤醒相应对象上的等待线程,上面我们也提到JVM会为每一个对象维护一个入口集(Entry Set)用于存储申请该对象内部锁的线程,JVM还会为每一个对象维护一个等待集的队列,该对象用于存储该对象上的等待线程。如果这三个方法都放在Thread类内,那么这两个队列是直接跟每一个线程挂钩吗?但是线程可以持有的锁可不止一种,那有同学可能这里会讲,还是跟对象挂钩比较合理,这三个方法都接收一个对象,用于维护这两个队列和做释放锁操作,那如果是这样的话,你为什么不直接放入Object中。
使用wait、notify、notifyAll来实现生产者消费者模式
生产者消费者模式是web中比较常见的一种模式,比如消息队列,一方负责生产信息,一方负责消费信息,这是我们实现解耦的一种方式。本次我们做的是一个卖包子的案例,生产者负责卖包子,消费者负责卖包子。
public class GoodsStock {private int goodsNum;
public GoodsStock(int goodsNum) {
this.goodsNum = goodsNum;
}
public void produceGoods() throws InterruptedException {
synchronized (this) {
if (goodsNum < 100) {
goodsNum++;
System.out.println("正在生产第" + goodsNum + "个包子");
// 休眠,防止执行的太快,影响我们分析问题
TimeUnit.MILLISECONDS.sleep(50);
// 生产之后,马上唤醒全部的线程
notifyAll();
} else {
System.out.println("生产达到峰值,打工人可以休息");
wait();
}
}
}
public void consumerGoods() throws InterruptedException {
synchronized (this) {
if (goodsNum == 100) {
goodsNum--;
System.out.println("正在消费第" + goodsNum + "个包子");
// 休眠,防止执行的太快,影响我们分析问题
TimeUnit.MILLISECONDS.sleep(50);
// 消费一个,马上唤醒全部的线程
notifyAll();
}
if (goodsNum == 0){
System.out.println("还未开始生产,或已将包子全部售出");
wait();
}
}
}
}
public class ProduceGoods implements Runnable {private GoodsStock goodsStock;
public ProduceGoods(GoodsStock goodsStock) {
this.goodsStock = goodsStock;
}
@Override
public void run() {
while (true) {
try {
goodsStock.produceGoods();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ConsumerGoods implements Runnable {private GoodsStock goodsStock;
public ConsumerGoods(GoodsStock goodsStock) {
this.goodsStock = goodsStock;
}
@Override
public void run() {
while (true) {
try {
goodsStock.consumerGoods();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {GoodsStock goodsStock = new GoodsStock(0);
ProduceGoods produceGoods = new ProduceGoods(goodsStock);
ConsumerGoods consumerGoods = new ConsumerGoods(goodsStock);
Thread p1 = new Thread(produceGoods);
Thread p2 = new Thread(produceGoods);
Thread p3 = new Thread(consumerGoods);
Thread p4 = new Thread(consumerGoods);
p1.start();
p2.start();
p3.start();
p4.start();
}
结果:
wait、notify、notifyAll存在的问题
上面模拟的生产者消费者模式,在刚开始生产者会率先开始生产,即使消费者先被CPU选中,也会陷入等待状态,在生产完成100个包子之后,消费者开始消费包子。这个时候就是一种动态平衡了,因为按照我们的代码,在包子的数量达到100的时候,生产者唤醒所有的线程,于是我们大概就能看到一个动态平衡了,包子的数量总是维持在一百个。但是如果说我们希望的场景是消费者线程卖完了仅仅通知生产者线程呢?生产者生产够100个包子后仅唤醒消费者线程呢? 那么notify和notifyAll就不满足我们的要求了,notify仅仅唤醒任意一个处于对象锁下的线程,notifyAll通知所有。除此之外notify和notifyAll只能配合synchronzed使用,我们希望显式锁也能有类似的操作,实现通知和唤醒,这也就是condition类出现的原因。
那么Condition是如何做到的呢?
我们再来大致的看一下Lock和Condition的源码:
每个Condition实例内部都维护了一个用于存储等待线程的队列:
那么生产者线程和消费者线程就放在了两个队列中, 就能够避免误唤醒的现象了。因为signalAll只唤醒所属的Condition实例上的等待线程。
这样说的可能有点抽象,我们这里举一个例子吧: 假设我们有两个Condition变量,一个叫con1,一个叫con2,执行con1.await()方法的线程,其生命周期的状态就变为等待,并被存入con1对应的等待队列中,con1.signal()会随机唤醒处于cond1等待队列中的任意一个线程。
condition版本的生产者消费者模式
基于上面的论述,我们就可以实现,生产者生产100个包子休息,消费者把包子卖完再通知生产者了。
public class GoodsStock {private int goodsNum;
private static final ReentrantLock lock = new ReentrantLock();
private final Condition produceCondition = lock.newCondition();
private final Condition consumerCondition = lock.newCondition();
public GoodsStock(int goodsNum) {
this.goodsNum = goodsNum;
}
public void produceGoods() throws InterruptedException {
lock.lock();
if (goodsNum < 100) {
goodsNum++;
System.out.println("正在生产第" + goodsNum + "个包子");
// 休眠,防止执行的太快,影响我们分析问题
TimeUnit.MILLISECONDS.sleep(50);
}
if (goodsNum == 100) {
System.out.println("生产达到峰值,打工人可以休息");
// 通知所有的消费者线程
consumerCondition.signalAll();
produceCondition.await();
}
lock.unlock();
}
public void consumerGoods() throws InterruptedException {
lock.lock();
if (goodsNum > 0) {
System.out.println("正在消费第" + goodsNum-- + "个包子");
// 休眠,防止执行的太快,影响我们分析问题
TimeUnit.MILLISECONDS.sleep(50);
// 唤醒所有的生产者
}
if (goodsNum == 0) {
System.out.println("还未开始生产,或已将包子全部售出");
// 获取该produceCondition变量对应的等待线程队列的长度
int waitQueueLength = lock.getWaitQueueLength(produceCondition);
if (waitQueueLength > 0){
produceCondition.signalAll();
}
// 消费者线程先暂停,放入消费者对应的队列中
consumerCondition.await();
}
lock.unlock();
}
}
CountDownLatch简介
Thread.join()实现的是一个线程等待另外一个线程结束,有的时候一个线程可能只需要等待其他线程执行特定的操作结束即可,而不必等待这些线程完全执行完毕。我们可以用条件变量来实现,也可以用更加直接的工具类—JUC下的CountDownLatch。
那么问题来了,那么该线程是怎么知道其他线程特定操作结束的呢?这就需要一个计数器,CountDownLatch内部维护了一个表示未完成操作的计数器,线程调用CountDownLatch.countDown(),计数器减一,代表该线程已经执行了特定操作。
CountDownLatch.await()相当于一个未保护的方法,当计数器为0时,也就是其他线程都执行了特定操作时,调用该方法的线程才会恢复执行。
一个典型的应用场景是: 我们需要做一个统计,由五段SQL构成,我们需要取出这五段SQL的数据,在代码中形成前端对应的数据结构返回给前端,那这五段SQL每个都执行一秒,那么这个接口响应的时间就需要五秒,一种优化的思路就是启动五个线程去取数据,取完数据放在一个集合中,五个线程都取完数据就通知主线程处理数据,这样接口的相应时间就取决于哪段SQL的最长执行时间。其实这个我们也可以通过Thread.join方法来做,但是如果这五个线程取完数据还有别的动作呢? 但是处理数据的线程只需要知道数据取好了。
CyclicBarrier 栅栏 简介
有的时候多个线程需要相互等待对方执行到代码中的某个地方(集合点),这时这些线程才能往下执行,类似于你和你女朋友去看电影,你女朋友让你九点在楼下等她,然后只有你们两个都到齐了才会去电影院。又像寻找龙珠,七队人都去寻找龙珠,龙珠到了,才能许愿。
public static void main(String[] args) {CyclicBarrier cyclicBarrier = new CyclicBarrier(7);
for(int i = 1;i <= 7; i++){
int finalI = i;
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 收集到第"+ finalI +"颗龙珠");
try {
// 用于等待
cyclicBarrier.await();
System.out.println("****召唤神龙");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
CyclicBarrier内部也维护了一个计数器,用于标识多个线程是否已经执行到了某个地方,通知这些线程往下执行。与CountDownLatch不同的在于,栅栏可以被使用多次,所有参与方处于唤醒状态,任何一个参与方再度调用CyclicBarrier.await()又会被暂停,直到最后一个参与方执行了CyclicBarrier.await()。
Semaphore 信号量与限购简介
一般到了节假日,风景区就会很火爆,但是一个风景区能够容纳的人就那么些,资源是给定的,但是有些景区格外紧俏,为了防黄牛,我们发明了限购。java.util.concurrent.Semaphore可以用来做流量控制,线程在访问资源之前必须申请许可证,如果当前许可证被申请完了,那么申请的线程会被暂停,许可证恢复了,这些被暂停的线程就会被唤醒。
线程池
线程池简介
前面几篇我们讲示例,都是直接new Thread()来一个一个的创建线程,那么在实际中,一般我们不建议显式的创建线程。相对于普通的对象,一个Thread的实例还占用了额外的存储空间,大约是1M。除此之外线程的销毁也有其开销。一个系统能够创建的线程总是受限于该系统所拥有的处理器数目。无论是CPU密集型还是I/O密集型:
- CPU密集型
- IO密集型任务
线程池和数据库连接池有些不同,数据库连接池算是对象池内部维护了一定数量的对象,客户端代码需要一个对象时就向对象池借用一个对象,用完之后再将该对象返回给对象池,于是数据库连接池就可以先后被多个客户端线程所使用。线程池也是一个对象,我们并不是从线程池像数据库连接池一样借用线程,而是将我们希望让线程执行的任务提交给线程池,线程池将这些任务放在工作队列中,由线程池的线程将这些任务取走执行。因此,线程池可以被看做是一种基于生产者-消费者模式的一种服务,该服务内部维护的工作者线程相当于消费者线程,向线程池提交任务的线程相当于生产者线程。
如何创建线程池?
通过java.util.concurrent.ThreadPoolExecutor来创建线程池,ThreadPoolExecutor包含参数最多的一个构造函数如下:
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
ThreadPoolExecutor的线程数量参数有三个:
- 当前线程池大小(表示线程池中实际工作者线程的数量)
- 最大线程池大小(表示线程池最大能够拥有多少工作者线程,maximumPoolSize)
- 核心线程池大小(corePoolSize)(小于最大线程池,一般情况下当前线程池大小超过核心线程池时,新来的人任务会被存放于工作队列(workQueue)) 。
在刚开始的时候,线程池并不会先创建线程,客户端没提交一个任务,线程池就会启用有一个线程执行任务,随着提交的任务越来越多,在超过线程池核心数时,就会被放入阻塞队列中,那么阻塞队列也放不下了怎么办? 这也就是handler参数的作用,拒绝策略。线程池是通过threadFactory.newThread()方法来创建线程的。如果我们在创建线程池的时候没有指定线程工厂,那么ThreadPoolExecutor就会使用Executors.defaultThreadFactory()所返回的线程工厂。
RejectedExecutionHandler是一个接口,JDK有默认的实现:
在当前线程池的大小超过核心线程池的大小且阻塞队列中没有任务时,线程中的线程的空闲时间达到keepAliveTime所制定的时间时,线程池就会销毁掉这类不干活的线程,以达到节省资源的目的。
线程池常用的方法
ThreadPoolExecutor的prestartCoreThread方法可以让线程池在没有接到任何任务的情况下创建所有的核心线程,这样可以减少任务被线程池处理时所需的等待时间。
通数据库连接池不一样的是,数据库连接池伴随着web程序一直开启,随着我们web的程序关闭而关闭,曾经我也是这么理解线程池的。但是线程池和数据库连接池并不是相同的思路,线程池需要关闭,假设客户端提交的任务都执行完毕的话。毕竟线程的开销还是比较大的。我们使用线程池的初衷也是复用线程,一个线程执行任务完毕之后,还能接着执行任务,这就达到了复用的目的。那么问题来了,为什么线程池不设计成类似于数据库连接池这样的呢? 我们可以这么想,基本上对于一个服务端的程序基本上里面都需要执行SQL,都需要获取连接,但是并不是都需要线程池。我们上面已经强调过,线程相对于普通的对象更消耗资源,基于这种思想线程池设计了关闭的方法。我们再次强调一下线程池的价值,复用线程,通过new Thread创建出来的线程,执行完毕就结束了,等待GC回收其占用的资源。但是通过线程池创建的线程在任务执行完毕之后还可以去执行任务。
ThreadPoolExecutor.shutdown()/shutdownNow()方法可用来关闭线程池。使用shutdown关闭线程池的时候,已提交的任务会被继续执行,而新提交的任务会像线程池饱和那样被拒绝掉。即使ThreadPoolExecutor.shutdown()执行,线程池也不会马上销毁,因为线程池可能还有线程在执行任务。可通过awaitTermination(long timeout, TimeUnit unit)方法来等待线程池关闭结束,线程池关闭结束,该方法返回true。
ThreadPoolExecutor.submit(Runnable task)和execute(Runnable command)被用于提交任务,不同的是submit()返回线程的执行结果,execute不返回。
参考资料:
- 对象池和线程池
- 并发编程之CyclicBarrier原理与使用
- 《Java多线程编程实战指南》 黄文海著
以上是 【Java】Java多线程学习笔记(三) 甚欢篇 的全部内容, 来源链接: utcz.com/a/93469.html