前言
大概有快两周没有发文了,这段时间不断的充实自己,算算时间,也到了该收获的时候,今天带来一篇JUC详解,但说实话,我也不敢自信它详不详细。JUC说白了就是多线程,学Java不久的人知道多线程,恐怕还不知道JUC是什么。在这篇博客中,博主将对JUC做一个自认为比较全面的讲解,为了方便大家理解,博主尽量用自己的语言给大家总结,避免照搬各种资料里面生涩的文字让大家不解其意,不出意外这篇博客会很长,目前还没办法估计具体多长,但总之应该会比较长。在讲解的同时,博主也会做一些多线程的引申,引申到哪也不好说,只能边写边想,遇到博主自己也不是太理解的,会标注,大家可做查询或讨论。JUC是块硬骨头,你要说难,那倒未必多难,你要说简单?我想,能说明白的人恐怕也不会多,今天,我们就来啃一啃这块难啃的骨头,挖一挖JUC的祖坟,看看有没有意外收获。
线程基础
进程与线程
进程
从JVM的角度来理解进程我觉得会稍微简单一点,JVM是虚拟机,算得上是进程的载体,也就是操作系统了,那么所有的操作系统iOS/Android/Windows/macOS/Linux,其所运行的设备,比如手机,电脑都算得上是一个进程的载体设备。往细了说,每一个单独运行的实例就是一个进程,实例即应用,所以一个单独运行的应用程序,不管手机也好,电脑也罢,都是一个单独的进程。
用一句老话说:进程是资源分配和管理的最小单位。所以进程必定有自己的内存空间和执行任务的线程。用一张图表示如下:
大体上是这样,适用于各种操作系统,对于进程其实了解即可,更详细的博主也只能去查资料了,但毕竟已经脱离Java在应用上的范畴,有兴趣的自己查。
线程
如果说进程是资源分配和管理的最小单位,那么线程就是进程中程序执行的最小单元。
但是线程并不是越多越好,因为真正的多线程依赖于操作系统多核的特性,如果只有一核,你就是说破天他也只能是单线程。之所以你觉得线程是在并行,是因为线程在单核CPU上快速的上下文切换。
至于什么是上下文切换,其实很好理解,就是多个线程排排坐,一人份配一小段时间片,轮流执行,这个时间片非常短,在肉眼上造成了同时执行的假象,让人认为是并行的。线程开始执行叫加载,停止叫做挂起。而这种分配时间片工作的术语叫:CPU时间片轮转机制。
时间片太短会频繁切换线程,而挂起和加载都是要消耗CPU资源的,太长就会对有些比较短的交互请求不友好,人家完成工作了还需要等待时间片结束才能切换下一个线程。所以时间片设置比较讲究,通常来说100ms左右是个比较友好的时间。
并行与并发
刚刚提到了并行,提到了多线程,那么我们就有必要来掰扯下多线程并行与并发的区别。
先说并发,博主可以很负责任的告诉大家,并发是多个任务一起开始执行,也不是只有一个线程在工作,而是同一时间正在执行的线程只有一个,通过频繁的上下文切换来达到让人以为一起执行的效果,但实际同一时空下,只有一个线程在工作。
再说并行,并行是真正的多线程执行,多个任务由多个线程共同执行,但并行并不意味着每个任务都有一个线程来执行,这要看线程的数量,虽然多线程有利于提高程序执行效率,但切记,线程并不是越多越好,这就像吃饭,吃多了也撑得慌。
常见方法
run()&start()
这两个方法是用来启动线程的,但却有着本质的区别,线程在创建好之后要启动,必须用start(),它是真正意义上的启动线程,它会让线程进入就绪状态开始等待时间片分配,然后等到时间片分到后,才会调用run来开始工作。
需要注意的是,线程一旦启动,start方法就不能再次调用,一个线程只能被启动一次,这个需要看下start的源码:
//start源码分析
public synchronized void start() {
/**
Java里面创建线程之后,必须要调用start方法才能创建一个线程,
该方法会通过虚拟机启动一个本地线程,本地线程的创建会调用当前
系统去创建线程的方法进行创建线程。
最终会调用run()将线程真正执行起来
0这个状态,等于‘New’这个状态。
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();
/* 线程会加入到线程分组,然后执行start0() */
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
执行的流程大致如下:
从run方法的使用来说,它就像一个成员方法,但又不全是,一般我们会重写此方法,并在里面我们会执行一些业务代码。虽然它可以单独执行,也能重复执行,但它不会启动线程这个事情的本质不会改变。
suspend&()resume()&stop()
这三个方法对应的是暂停、恢复和中止,我们以几个事例来说明下:
public class Thr {
private static class MyThread implements Runnable{
@Override
public void run() {
DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
while (true){
System.out.println(Thread.currentThread().getName()+"run at"+dateFormat.format(new Date()));
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread());
//开启线程
System.out.println("开启线程");
thread.start();
TimeUnit.SECONDS.sleep(3);
//暂停线程
System.out.println("暂停线程");
thread.suspend();
TimeUnit.SECONDS.sleep(3);
//恢复线程
System.out.println("恢复线程");
thread.resume();
TimeUnit.SECONDS.sleep(3);
//中止线程
System.out.println("中止线程");
thread.stop();
}
}
其执行的结果如下:
开启线程 my threadrun at22:42:24 my threadrun at22:42:25 my threadrun at22:42:26 暂停线程 恢复线程 my threadrun at22:42:30 my threadrun at22:42:31 my threadrun at22:42:32 中止线程
虽然这三个方法使用我们说了,但还是要说一句,这三个方法已经被Java标注为过期,虽然还没删除,但也不建议继续使用了, 因为线程在暂停的时候仍然占用着锁,很容易导致死锁,我们通过一段代码来看看原因:
public class Thr {
private static Object obj = new Object();//作为一个锁
private static class MyThread implements Runnable{
@Override
public void run() {
synchronized (obj){
DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
while (true){
System.out.println(Thread.currentThread().getName()+"run at"+dateFormat.format(new Date()));
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread(),"正常线程");
Thread thread1 = new Thread(new MyThread(),"死锁线程");
//开启线程
thread.start();
TimeUnit.SECONDS.sleep(3);
//暂停线程
thread.suspend();
System.out.println("暂停线程");
thread1.start();
TimeUnit.SECONDS.sleep(3);
}
}
我们来猜猜输出结果会是什么样呢?来看看:
正常线程run at14:30:42 正常线程run at14:30:43 正常线程run at14:30:44 暂停线程
3s内执行了三次,在暂停之后,启动thread1后,没有任何输出,因为锁没有释放,thread1无法获取到锁,也就无法执行run里面的任务,导致死锁出现。
接着来说说stop,stop会立即停止run中剩余的操作,会导致一些收尾工作无法完成,特别是涉及到文件流或数据库关闭的时候,试想如果实在做数据同步,突然stop一定会导致最终数据不一致,而最终一致性一词出现在分布式事务中。这里就不代码演示了,只说下解决方法,如果需要平滑一点,有好一点的通知线程马上要中断了,可以用interrupt(),比如:
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyThread(),"myThread");
thread.start();
TimeUnit.SECONDS.sleep(3);
thread.interrupt();
}
方法上多了一个打断的异常,需要的时候可以利用起来。判断是否被打断可以通过isInterrupted(),true为收到中断消息,false则表示没收到中断消息。推荐另一种静态方法判断线程中断的方式:
Thread.interrupted()
使用更简洁,不依赖于线程实例化出来的对象。如果该线程已经被添加了中断标识,当使用了该方法后,会将线程的中断标识由true改为false,可谓是弄巧成拙,另外还需注意,此方法对死锁下线程无效。
wait()¬ify()
wait()、notify()、notifyAll()严格意义上来说不能算是线程的方法,他们是定义在Object中的方法,只是可以用来控制线程,但它在使用时要遵守一定的规则:必须在线程同步过程中使用,而且必须是使用的同一个锁的情况下。我们以一个简单的案例来说明:
public class WaitNotify {
static boolean flag = false;
static Object lock = new Object();
//创建等待线程
static class WaitThread implements Runnable{
@Override
public void run() {
synchronized (lock){
while (!flag){
//条件不满足,进入等待
System.out.println(Thread.currentThread().getName()+" flag is false,waiting");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//条件满足,退出等待
System.out.println(Thread.currentThread().getName()+" flag is true");
}
}
}
static class NotifyThread implements Runnable{
@Override
public void run() {
synchronized (lock){
System.out.println(Thread.currentThread().getName()+" hold lock");
lock.notify();
flag=true;
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(new WaitThread(),"wait").start();
TimeUnit.SECONDS.sleep(1);
new Thread(new NotifyThread(),"notify").start();
}
}
查看下输出就可以知道它的运行轨迹:
wait flag is false,waiting notify hold lock wait flag is true
我们来理一理这个流程:
- WaitThread先执行,并获取锁;
- WaitThread在锁内调用wait方法,代表着放弃锁,进入等待队列,状态为等待状态;
- 1s后,NotifyThread执行,此时锁没有被持有,那么NotifyThread可以持有锁,获取锁对象;
- NotifyThread在获取到锁对象后调用了notify()或者notifyAll()这俩都行,将WaitThread从等待队列中移除,或者叫出来了,放在一个同步队列中,其实还是WaitThread原本所处的队列,也有可能是主线程所在队列,但由于NotifyThread此时仍没有释放锁,所以WaitThread还是阻塞状态,但马上就要开始工作了;
- NotifyThread改变了条件,发了通知,释放了锁,WaitThread重新获取锁,通过while判断条件flag为true,跳出等待,执行了接下来的输出。
听着都觉得精彩,就是不知道你有没有听懂啊。听不懂没关系,多看几遍就懂了。
wait()&sleep()
我们在上面的方法中看到过很多sleep方法,你即使不明其意,但单从英文上也看得出两者从在的本质差别:等待和睡觉。这两者还是有很大差别的。
从所属关系来说,wait方法术语Object,sleep方法属于Thread专属。
从锁的角度来说,sleep时,我们可以认为一切只是暂停了,等到暂停时间结束,任务还是要接着执行的,所以sleep时线程依旧占有锁,而我们通过wait¬ify可知,wait时锁被释放了。
从执行的角度来说,sleep后,只要等待指定的时间,线程仍可以正常运行,它让出的只是CPU的调度权,用梁静茹的话说:还没有完全交出自己。它的监控状态依然还保持着,只等时间一到,就继续干活。而wait执行后,则是完全交出了自己并处于等待状态,没有同一个对象调用notify方法,休想继续接下来的工作,果然等待才是最没安全感的啊。
yield()
yield有点神奇,就像是赛跑的时候,执行此方法后代表抢跑了,你就要被叫回来,然后重新开始准备开跑,但是总有人会接着抢跑,包括你自己在内。执行yield的线程会让出自己持有的时间片,让CPU重新选择线程执行,虽然你让出了时间片,但仍有机会再下次选择执行线程的时候被选中,这个过程是随机的。
public class YieldDemo {
static class MyThread implements Runnable{
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName()+" : "+i);
if (i == 2){
System.out.println(Thread.currentThread().getName());
Thread.yield();
}
}
}
}
public static void main(String[] args) {
new Thread(new MyThread()).start();
new Thread(new MyThread()).start();
new Thread(new MyThread()).start();
}
}
这是一个大家常举的例子,可以看看,最终的输出结果如下:
Thread-1 : 0 Thread-0 : 0 Thread-1 : 1 Thread-0 : 1 Thread-0 : 2 Thread-0 Thread-1 : 2 Thread-1 Thread-2 : 0 Thread-2 : 1 Thread-2 : 2 Thread-2 Thread-0 : 3 Thread-0 : 4 Thread-1 : 3 Thread-1 : 4 Thread-2 : 3 Thread-2 : 4
每次执行结果都是随机的,可以自己试试看。
join()
join在实际场景很少使用,目的是保证线程的执行顺序,让每一个线程都持有前一个线程的引用,实际中,我们似乎不需要让线程按照某种我们设定好的顺序来执行,主要也看业务场景吧。
举个例子:
public class JoinDemo {
private static class MyThread extends Thread{
int i;
Thread previousThread; //上一个线程
public MyThread(Thread previousThread,int i){
this.previousThread=previousThread;
this.i=i;
}
@Override
public void run() {
//调用上一个线程的join方法. 不使用join方法解决是不确定的
try {
previousThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("num:"+i);
}
}
public static void main(String[] args) {
Thread previousThread=Thread.currentThread();
for(int i=0;i<10;i++){
//每一个线程实现都持有前一个线程的引用。
MyThread joinDemo=new MyThread(previousThread,i);
joinDemo.start();
previousThread=joinDemo;
}
}
}
在没有添加join的情况下,线程的执行顺序必然是随机的,而在添加了join后,线程会依次等待上一个线程执行完成之后收到通知才会执行当前线程,可以自己运行下代码,看看join注释前后代码的执行结果。
我们把源码复制出来:
/**
* Waits for this thread to die.
*
* <p> An invocation of this method behaves in exactly the same
* way as the invocation
*
* <blockquote>
* {@linkplain #join(long) join}{@code (0)}
* </blockquote>
*
* @throws InterruptedException
* if any thread has interrupted the current thread. The
* <i>interrupted status</i> of the current thread is
* cleared when this exception is thrown.
*/
public final void join() throws InterruptedException {
join(0);
}
/**
* Waits at most {@code millis} milliseconds for this thread to
* die. A timeout of {@code 0} means to wait forever.
*
* <p> This implementation uses a loop of {@code this.wait} calls
* conditioned on {@code this.isAlive}. As a thread terminates the
* {@code this.notifyAll} method is invoked. It is recommended that
* applications not use {@code wait}, {@code notify}, or
* {@code notifyAll} on {@code Thread} instances.
*
* @param millis
* the time to wait in milliseconds
*
* @throws IllegalArgumentException
* if the value of {@code millis} is negative
*
* @throws InterruptedException
* if any thread has interrupted the current thread. The
* <i>interrupted status</i> of the current thread is
* cleared when this exception is thrown.
*/
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
//判断是否携带阻塞的超时时间,等于0表示没有设置超时时间
if (millis == 0) {
//isAlive获取线程状态,无限等待直到previousThread线程结束
while (isAlive()) {
//调用Object中的wait方法实现线程的阻塞
wait(0);
}
} else { //阻塞直到超时
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
该方法被synchronized修饰,内部的阻塞通过wait方法实现,记住一点,使用wait方法,必须添加synchronized关键字。
优先级
线程的优先级决定了被执行的顺序,优先级越高,执行的顺序就越靠前。通过setPriority()设置优先级,默认5,范围为1-10,话虽这么说,但优先级并非万能,因为有些系统可能会无视优先级的存在,而且优先级高的线程也会无理的抢占资源导致优先级低的线程迟迟不能执行,还是慎用吧。
守护线程
一提到守护线程,博主脑海中立刻就跳出来了分布式锁方案中使用Redis方案下的锁过期问题,它就是通过守护线程定期给未释放的锁添加过期时间来解决锁过期的问题的。
守护线程是一种支持型的线程,相比而言,我们前面创建的线程都叫做用户线程,守护线程创建出来的目的就是为了守护用户线程,被守护的用户线程结束,守护线程也会结束,两者是伴生的。关于守护线程,博主不会讲的太详细,了解下就可以了,如需使用,可自行学习,好吧,其实也不难用。加个线程设置为守护线程,然后在run方法中执行你想让它做的事情就可以了,如果需要设置某时间做什么事,可在while中通过sleep来实现,可以尝试自己写下,博主就不写了。
状态总结
关于线程的状态,我们用一张图来说明:
看看这些线程执行的过程调用不同的方法是不是进入了对应的状态。 说实话,写到这里博主已经快要没有耐心了,而这里也只不过是写了一小部分而已,路漫漫其修远兮,吾将上下而求索。换下个话题。
内置锁synchronized
关于synchronized关键字我想大家都不会陌生,它的出场率还算是蛮高的,但你可能不知道他其实是线程的内置锁,有内置,当然也有外置锁,Java中叫显示锁,名为Lock的类。不过不急,我们都会一一讲解到的,下面还是专心来看synchronized的使用。
关于线程中为什么要用锁这个问题,我觉得有必要回答一下,否则锁的存在毫无意义,因为多个线程相互之间单独执行是没有意义的,所以我们需要线程之间互相是能够协调工作的,所以锁的出现是为了保护那些被线程访问的资源的安全性。这个问题在分布式事务中尤为重要,这决定我们的数据库数据的ACID/CAP,甚至于BASE理论,CP/AP模式等是否成立。不过不用急,这些知识一个都跑不了。
基本使用
添加在方法上
//添加在方法上
public synchronized void count(){
count++;
}
添加在方法内
//添加同步代码块
//需要声明一个锁对象
//作为锁对象
private Object object = new Object();
public void count(){
synchronized (object){
count++;
}
}
总结
两种方式都保证了数据的安全性,在多线程下,只有等到获取到锁的线程将锁释放掉后,下一个线程才能持有锁。synchronized就是通过这种机制来保证数据的一致性的。
这是一种对象锁,要求我们锁定的必须是同一个对象,否则将无法生效。
注意:你一定好奇,能加在方法上,方法内,难道不能加在类上?额~这还真可以,为什么不说呢?那是因为博主不希望你学会往类上加锁这种方式,类锁的锁定范围太大了,我们在使用的锁的时候要坚持范围越小性能越好的原则,不要盲目加锁。
实现原理
原理解析
锁的获取和释放我们都已经了解了,但它的内部究竟是怎么实现的呢?首先我们创建这样一个类:
package com.codingfire.cache;
public class Thr {
public void test2() {
synchronized (this) {
}
}
}
然后通过javac命令编译生成class文件,接着用javap命令反编译,得到的信息如下:
从上述内容可以看出,同步代码块是使用monitorenter和monitorexit指令实现的。而同步方法的实现方式这里无法得知,在JVM规范里也没有详细说明。但有一点我们可以确定,同步方法可以使用这两个指令来实现,有这,就够了。
但是我们还需要去思考一个问题,synchronized的锁在哪?经过查阅一些资料,我们知道,synchronized的锁在Java头中,奶奶的,Java头又是啥?Java对象在内存中由三部分组成:
- Java头
- MarkWord
- 类型指针
- 数组长度(只有数组对象有)
- 实例数据
- 对齐填充字节
需要说明的是,这是HotSpot JVM下的对象组成,为什么说这个呢,这里牵涉到一个jdk7到jdk8时jvm中永久带被取消的原因,其中最重要一点就是HotSpot JVM和JRockit JVM融合,而JRockit没有永久带。我只说一遍,记住了哈。
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等。到此为止了,后面的就不再说了,说了也记不住,而且没有实际意义,徒增烦恼啊。
Synchronized锁优化
你可能没想到,Synchronized还有被优化的时候,这是源自Java1.6之后,为了减少锁获取和释放带来的性能问题而做的,主要是引入了偏向锁和轻量级锁的概念,让锁的等级发生了变化,分别是:无锁,偏向锁,轻量级锁,重量级锁。状态间的转换会随着竞争情况逐渐升级,锁可以升级但不能降级,但偏向锁降级为无锁的情况不包括在内,这种情况称之为锁撤销,而升级成为锁膨胀。
自旋锁
在锁的转化过程中,诞生了一种新的锁:自选锁,说是锁,用一种状态来说可能更好一些。
自选锁从阻塞到唤醒需要CPU从用户态转换为内核态,这个和iOS中runtime的环形过程一样,不过自选锁可不会像iOS的运行时那样会韬光养晦,在不需要的时候就沉睡,等待被再次唤醒。自选锁的出现是因为锁状态持续的时间很短,从线程阻塞到唤醒的切换过程要耗费太多的性能,所以才通过自旋来等待锁的释放。所谓自旋,就是执行一段毫无意义的循环即可。
注意:自旋不能代替阻塞,不要觉得自旋就没有性能消耗,自旋的过程消耗的是处理器的性能,如果自旋等待的锁很快释放,那样自旋的效率就很高,反之就很低。
自旋锁在JDK1.4中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。
但有时候,这样的次数很是差强人意,所以就诞生了自适应的自旋锁,它的次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果上一次在同一个锁上的自旋成功了,那么下次就会适当增加自旋的次数,如果失败了,就会适当减少自旋的次数。
锁撤销
在锁升级的过程中,一般是不会降级的,但有一种特殊情况会出现锁撤销,这种情况发生在编译期,在此期间,JVM监测到不需要发生竞争的锁,机会通过锁消除来降低毫无意义的请求锁的时间。
比如说下面这个方法:
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
StringBuffer是作为方法内部的局部变量,每次都初始化,因此它不可能被多个线程同时访问,也就没有资源竞争,但是StringBuffer的append操作却需要执行同步操作,我们看下append源码:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
此方法加了synchronized关键字,代表是一个同步方法,而这完全没有必要,这就浪费了系统资源 ,因此在编译时一旦JVM发现此种情况就会通过锁消除方式来优化性能。在JDK1.8中锁消除是自动开启的。
这个东西大家只要知道一下就行,实际也并不需要我们额外做什么,扩充下知识量就行。
锁升级
偏向锁
偏向锁加锁步骤:
- 线程初次执行synchronized块的时候,通过自旋的方式修改MarkWord锁标记,此时,锁对象为偏向锁,这是从无锁到偏向锁的一个转化过程;
- 代码块执行完毕,锁不会释放,这是因为偏向锁的偏向特性,一旦下次来获取锁的线程仍然是同一个线程,那么Java头中的MarkWord信息就不需要修改了,也就达成了偏向的目的,而我们这里目的就是要让它达成偏向,因为偏向锁不需要替换MarkWord的锁标记,执行效率是非常高的;
- 所以第二次执行同步块,首先会判断MarkWord中的线程ID是否为当前线程,如果是,由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高,效率会很高,就印证了上一条;
- 但也有可能,第二次发现MarkWord中线程ID和当前线程不一样,会启动CAS操作替换MarkWork中的线程ID。如果替换成功,获取锁,执行同步代码块;如果替换失败,执行下一步;
- 替换失败,表示有竞争,有另一个线程抢先获取了偏向锁,当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,被阻塞在安全点的线程继续往下执行同步块中的代码,此时锁已经变为轻量级锁。
关于撤销,博主有几句话要说,偏向锁不会主动释放,只有在替换失败的情况下,持有偏向锁的对象才会在升级的时候释放偏向锁,偏向锁的撤销需要等待全局安全点,这个时间点上没有任何的字节码执行,它先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,如果对象处于未锁定状态,说明同步块中代码已经执行完毕,相当于原有的偏向锁已经过期无效了,此时该对象就应该被直接转换为不可偏向的无锁状态,标志位为“01”,否则锁存在,该锁上升为轻量级锁(标志位为“00”)的状态。
这比较适合始终只有一个线程在执行同步代码块,综上可知,此时的效率才是最高的。
轻量级锁
轻量级锁由偏向锁升级而来,轻量级锁的升级我觉得很多地方总结的都不好,理解起来其实很简单:就是当一个线程在自旋等待锁的过程中,又来了一个线程获取锁,那么锁就会升级为重量级锁。
这知识最简单的说明,但博主觉得简单粗暴的很,虽然不够细节,但重在描述简洁,了解即可。
解锁和轻量级锁相似,如果下次进来的线程锁记录和对象头中的MarkWord能匹配的上,说明是同一个线程,则执行代码块,锁不会升级,如果不匹配,则线程被挂起,轻量级锁升级为重量级锁,轻量级的锁被释放,接着唤醒被挂起的线程,重新争夺同步块的锁。
注意:因为自旋操作是依赖于CPU的,为了避免无用的自旋操作(获得锁的线程被阻塞住了),轻量级锁一旦升级为重量级锁,就不会再恢复到轻量级锁。当锁处于重量级锁时,其他线程试图获取锁时,都会被阻塞住。当持有锁的线程释放锁之后会唤醒其他线程再次开始竞争锁。
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
切换成本高的原因在于,当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的 。
概括下来就是:竞争失败后,线程阻塞,释放锁后,唤醒阻塞的线程,为了CPU性能,不再使用自旋锁,将等待同步代码执行完毕后唤醒阻塞的竞争线程展开竞争。所以重量级锁适合用在同步块执行时间比较长的情况下。
总结
前面的描述总感觉不在重点上,虽然写了很多,但不好理解。博主觉得下面的总结可能更容易让大家看懂,不妨再看看吧:
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁会存在CAS,没有额外的性能消耗,和执行非同步方法相比,仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块或者同步方法的场景 |
轻量级锁 | 竞争的线程不会阻塞,通过自旋提高程序响应速度 | 若线程长时间抢不到锁,自旋会消耗CPU性能 | 追求响应时间。同步代码块执行非常快 |
重量级锁 | 线程竞争不使用自旋,不消耗CPU | 线程阻塞,响应时间缓慢,在多线程下吗,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步块或者同步方法执行时间较长的场景 |
偏向锁:在不存在多线程竞争情况下,默认会开启偏向锁。
偏向锁升级轻量级锁:当一个对象持有偏向锁,一旦第二个线程访问这个对象,如果产生竞争,偏向锁升级为轻量级锁。
轻量级锁升级重量级锁:一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
半路吐槽
卧槽,实在扛不住了,本想一次性写完,但感觉没个一星期以上写不完的节奏,准备分成几篇一起发。整体上,掌握同步块和一些工具类的使用就很OK了,很多文字描述都是晦涩的,真的不好懂,博主也不敢说能完全搞懂,但本着挖穿JUC祖坟的念头,咱们深啃一下,争取搞个七七八八,了却一桩心事。但为了此系列不能毁在我第一篇博客手里,博主决定继续写吧,争取5w字以内写完。
死锁
你要是不知道什么是死锁,那博主可就没办法了,关于什么是死锁,怎么造成的,我本来想给你们省略的,想了想,还是简单写写吧,你们可别嫌弃我写的简单啊!
死锁及其原因
一个线程申请资源时,恰好没有可用的资源,此时需要的资源正被其他线程占有,如果被占有的资源永远不会释放,那么就会造成死锁。
举个例子:线程1使用A资源,线程2使用B资源,A资源中要使用B,B资源要使用A,此时他们分别被线程1和线程2持有,都没执行完就不会释放,所以只能相互等待,造成死锁。
造成死锁的原因是因为彼此都不肯放弃自己的资源,就像是鹬蚌相争,最终都没有好下场,哈哈哈哈!!!
排查死锁
排查死锁的方式有很多种,说实话,博主也很少用,我相信大家都用的不会太多,这里我就不去乱教大家了,有需要自己查吧,咱们也别五十步笑百步,博主就提供下几个工具和思路表示下歉意:
- 通过JDK工具jps+jstack
- 通过JDK工具jconsole
- 通过JDK工具VisualVM
避免死锁
避免死锁其实很简单,细心一点就不会出问题,比如:
- 避免一个线程获取多个锁;
- 避免一个线程占用多个资源;
- 可以考虑使用一些工具类,因为方便,还不用担心性能问题,思索问题等,尽量不直接使用内置锁,这些工具类后面会讲到。
CAS
其实前面在写同步块的时候就提到过CAS,当时没有介绍这是什么,现在大家可以来看看了。CAS(Compare and Swap),即比较并替换,是用于实现多线程同步的原子操作。
所谓原子操作,是不会被线程调度打断的操作,只要开始,就会一直运行到结束,中间不会有任何的切换或停顿。这不由使我想到了ACID,这是数据库事物的特性,其中A就是Atomicity,代表事物一旦开始就不会中断,要么全部成功,要么全部失败。分布式事务后面也会挖坟,大家敬请期待。
实现原子性需要锁,但是同步块synchronized基于阻塞,在使用上算是粒度比较大的比较粗糙的,使用它的线程在获取到锁后,其他来访问的线程需要等待,直到锁释放,这对于一些简单的需求显得过于笨重,还有一些譬如死锁问题,竞争问题,等待问题不好解决。
说了这么多synchronized的坏话,带我们也不能抹杀它的存在,它还是有用的。关于它的使用我们可以回过头去上看再看看。
CAS原理
CAS是compare and swap的缩写,从字面来看,比较并交换,浅显一点,这就是远离了,但肯定不会是单纯的比较交换,否则也没必要说了。
CAS操作过程都包含三个运算符:内部地址V、期望值A、新值B。这让我想到一些系统方法里面就是这三个参数,比如,哎呀,突然想不起来了,最近还用过的,似乎是MybatisPlus里用的,不纠结了,大家可以去翻我SSM系列的内容看看。
使用这三个参数时,当操作的这个内存地址V上存放的值等于期望值A,则将内存地址上的值修改为新值B,否则不做任何操作。常见的CAS循环其实就是在一个循环里不断的做CAS操作,直到成功为止。有自旋那味儿了。
CAS对于线程安全层面是没有显示处理的,而是在CPU和内存中完成的,充分利用了多核的特性,实现了硬件层面的阻塞,再加上volatile关键字就可以实现原子上的线程安全了。
悲观锁&乐观锁
说到CAS,不得不说的就是悲观锁和乐观锁。和人的性格一样,这种同名锁就像是悲观的人和乐观的人一样,下面我们来看看他们具体悲观在哪里,乐观在哪里。
悲观锁总是假设会出现最坏的情况,说好听点叫未雨绸缪。每次去获取数据时,都会认为其他线程会修改,总有刁民想害朕?所以每次在获取数据时都会上锁,当其他线程去拿到这个数据就会阻塞,直到锁被释放。在关系型数据库中就大量应用了这种锁机制,如行锁、表锁、读锁、写锁,他们都是在操作前先上锁,刚刚学的synchronized就是一个最好的例子。
乐观锁总是假设一直都是最好的情况,有点侥幸心理,每次获取时都认为其他线程不会修改,所以不会上锁,但是在更新时会判断在此期间别人有没有更新这个数据,可以使用基于条件或者版本号的方式实现。乐观锁适用于读多写少的场景,可以提升系统吞吐量,而CAS就是一种乐观锁的实现。
CAS的问题
任何东西都是把双刃剑,有好处,自然就有弊端,它最大的三个问题:ABA、循环时间开销大、只能保证一个共享变量的原子操作。
ABA
CAS原理我们已经讲过了,从图中我们基本能明白它的步骤,这也符合乐观锁的使用方式。但是你有没有想过一个问题,假如我第一次把A修改成了B,第二次再把B修改成了A,那么我到底修改了吗?我可以负责任的告诉你,CAS认为没有修改,但实际已经发生两次修改了。
这就是著名的ABA问题,解决这个问题也很简单,添加判断条件,如版本号来解决。这在分布式事务中对数据库的优化上比较常见。
循环时间开销大
上图中我们也看到了,一旦比较失败,会进行无休止的自旋,如果自旋时间过长,会给CPU带来巨大性能开销。假如有高并发情况出现,这种自旋将吃掉几乎所有的CPU性能,电脑就卡死机了。
只能保证一个共享变量的原子操作
CAS工作原理图中,我们看到内存中存储着旧值,但是也只能存储一个值,这也是为什么它只能保证一个变量的原子操作,多个变量时,无法全部保存就无法保证多变量时的原子操作。如果要使用多个变量的时候,可以通过存储引用类型,也就是我们所谓的对象,把多个变量包装进去就可以解决这个问题。
原子操作类
原子操作提供了一些基于原子性操作的工具类,但说实话,实际应用中, 恐怕他们出场机会微乎其微,除了增改需要保证原子性,删除和查询是不需要的,而我们大多数的请求都是基于查询的,即使我们增改真的要保证原子性,也多的是办法,这些东西博主还真没怎么用过,不知大小伙伴们用过没?下面,我们一起来看看都有什么吧。
基本类型
AtomicInteger、AtomicBoolean、AtomicLong,这三个类的方法基本相同,以AtomicInteger为例:
int addAndGet():以原子的方式将输入的数字与AtomicInteger里的值相加,并返回结果:
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static Integer addAndGetDemo(int value){
return atomicInteger.addAndGet(value);
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Integer result = addAndGetDemo(i);
System.out.println(result);
}
}
boolean compareAndSet(int expect, int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值:
public static boolean compareAndSetDemo(int expect,int update){
return atomicInteger.compareAndSet(expect, update);
}
/*System.out.println(compareAndSetDemo(1,1));*/
就不一一列出来了,真要是需要用到了,单独去查吧,估计用的会很少。
数组
AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray几个类中的方法几乎一样,只是操作的数据类型不同,以AtomicIntegerArray中的API为例:
一些方法解释如下:
//执行加法,第一个参数为数组的下标,第二个参数为增加的数量,返回增加后的结果
int addAndGet(int i, int delta)
//对比修改,参1数组下标,参2原始值,参3修改目标值,成功返回true否则false
boolean compareAndSet(int i, int expect, int update)
//参数为数组下标,将数组对应数字减少1,返回减少后的数据
int decrementAndGet(int i)
// 参数为数组下标,将数组对应数字增加1,返回增加后的数据
int incrementAndGet(int i)
//和addAndGet类似,区别是返回值是变化前的数据
int getAndAdd(int i, int delta)
//和decrementAndGet类似,区别是返回变化前的数据
int getAndDecrement(int i)
//和incrementAndGet类似,区别是返回变化前的数据
int getAndIncrement(int i)
// 将对应下标的数字设置为指定值,第一个参数数组下标,第二个参数为设置的值,返回是变化前的数据
getAndSet(int i, int newValue)
我们来写个小案例:
public class AtomicIntegerArrayDemo {
static int[] value = new int[]{1,2,3};
static AtomicIntegerArray array = new AtomicIntegerArray(value);
public static void main(String[] args) {
System.out.println(array.getAndSet(2,6));
System.out.println(array.get(2));
System.out.println(value[2]);
}
}
输出结果如下:
3 22 3
你会发现AtomicIntegerArray获取的值与原传入数组的值不同,这是因为数组是通过构造方法传递,然后AtomicIntegerArray会将当前传入数组复制一份,当AtomicIntegerArray对内部数组元素进行修改时,不会影响原数组,哇!好秀啊!。
引用类型
引用类型就是我们所说的对象了,关于引用类型需要使用Atomic包中的三个类,分别为:AtomicReference(用于原子更新引用类型)、AtomicMarkableReference(用于原子更新带有标记位的引用类型)、AtomicStampedReference(用于原子更新带有版本号的引用类型)。
public class AtomicReferenceTest {
static class User{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
public static AtomicReference<User> atomicReference = new AtomicReference<>();
public static void main(String[] args) {
User u1 = new User("张三",18);
User u2 = new User("李四",19);
atomicReference.set(u1);
atomicReference.compareAndSet(u1,u2);
System.out.println(atomicReference.get().getName());
System.out.println(atomicReference.get().getAge());
}
}
AtomicMarkableReference可以用于解决CAS中的ABA的问题:
public static void main(String[] args) throws InterruptedException {
User u1 = new User("张三", 22);
User u2 = new User("李四", 33);
//只有true和false两种状态。相当于未修改和已修改
//构造函数出传入初始化引用和初始化修改标识
AtomicMarkableReference<User> amr = new AtomicMarkableReference<>(u1,false);
//在进行比对时,不仅比对对象,同时还会比对修改标识
//第一个参数为期望值
//第二个参数为新值
//第三个参数为期望的mark值
//第四个参数为新的mark值
System.out.println(amr.compareAndSet(u1,u2,false,true));
System.out.println(amr.getReference().getName());
}
AtomicStampedReference会是基于版本号思想解决ABA问题的,因为其内部维护了一个Pair对象,Pair对象记录了对象引用和时间戳信息,实际使用的时候,要保证时间戳唯一,如果时间戳重复,还会出现ABA的问题。
我们来看看这个Pair:
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
/**
* Creates a new {@code AtomicStampedReference} with the given
* initial values.
*
* @param initialRef the initial reference
* @param initialStamp the initial stamp
*/
public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp);
}
从这段代码,我们看到Pair里面带着的时间戳 ,根据时间戳做对比,比如ABA中,多次修改,值相同,时间戳肯定变了,所以比对后,肯定会发现被修改了,就能解决ABA问题。
我们以两个线程为例写个Demo:
package com.codingfire.cache;
import java.util.concurrent.atomic.AtomicStampedReference;
public class Thr {
private static final Integer INIT_NUM = 1000;
private static final Integer UPDATE_NUM = 100;
private static final Integer TEM_NUM = 200;
private static AtomicStampedReference atomicStampedReference = new AtomicStampedReference(INIT_NUM, 1);
public static void main(String[] args) {
new Thread(() -> {
int value = (int) atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + value + " 版本号为:" + stamp);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(atomicStampedReference.compareAndSet(value, UPDATE_NUM, stamp, stamp + 1)){
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + atomicStampedReference.getReference() + " 版本号为:" + atomicStampedReference.getStamp());
}else{
System.out.println("版本号不同,更新失败!");
}
}, "线程A").start();
new Thread(() -> {
// 确保线程A先执行
Thread.yield();
int value = (int) atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + value + " 版本号为:" + stamp);
System.out.println(Thread.currentThread().getName() +" : "+atomicStampedReference.compareAndSet(atomicStampedReference.getReference(), TEM_NUM, stamp, stamp + 1));
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + atomicStampedReference.getReference() + " 版本号为:" + atomicStampedReference.getStamp());
System.out.println(Thread.currentThread().getName() +" : "+atomicStampedReference.compareAndSet(atomicStampedReference.getReference(), INIT_NUM, stamp, stamp + 1));
System.out.println(Thread.currentThread().getName() + " : 当前值为:" + atomicStampedReference.getReference() + " 版本号为:" + atomicStampedReference.getStamp());
}, "线程B").start();
}
}
这个Demo中初始值1000,线程A先执行,输出信息,线程B后执行,yield就是为了保证线程A先执行,线程A执行到sleep后进入休眠,线程B开始执行compareAndSet操作,第一次修改成功,值200,第二次stamp值已经变了,修改回原值1000,修改失败了,第三次就是线程A中的判断,为false,版本号变更,修改失败。除非我们保持时间戳不变,否则是无法修改成功的,但是成功了,就会导致ABA问题,如果不考虑ABA,那也就没有使用这种方式的必要了。
以上代码可以自己输出下看看是不是博主说的步骤。
更新操作
使用原子更新某字段时,就要使用更新字段类,Atomic包下提供了3个类,AtomicIntegerFieldUpdater(原子更新整型字段)、AtomicLongFieldUpdater(原子更新长整型字段)、AtomicReferenceFieldUpdater(原子更新引用类型字段)
private static AtomicIntegerFieldUpdater<User> fieldUpdater = AtomicIntegerFieldUpdater.newUpdater(User.class,"age");
public static void main(String[] args) {
User user = new User("张三",18);
System.out.println(fieldUpdater.getAndIncrement(user));
System.out.println(fieldUpdater.get(user));
}
这个就很简单了,用来更新就行了,用起来很神奇,getAndIncrement后,age+1,自己试试看。它的方法还有很多,大家可以自己去发现并尝试。
版本差异
1.8之后,Java新增了几个原子类:
- LongAdder:长整型原子类
- DoubleAdder:双浮点型原子类
- LongAccumulator:类似LongAdder,但要更加灵活(要传入一个函数式接口)
- DoubleAccumulator:类似DoubleAdder,但要更加灵活(要传入一个函数式接口)
AtomicLong已经通过CAS提供了非阻塞的原子性操作,相比使用阻塞算法的同步器来说性能已经很好了,但LongAdder才是目前的天花板,LongAdder的特点在于多线程并发的情况下依然保持良好的性能,解决了AtomicLong的高并发瓶颈。
我们用一张图来说明下:
也就是说, LongAdder会把竞争的值分为多份,让同样多的线程去竞争多个资源那么性能问题就解决了,听上去有点不可思议,但事实就是如此。其工作原理如下:
当没有出现多线程竞争的情况,线程会直接对初始value进行修改,当多线程的时候,那么LongAdder会初始化一个cell数组,然后对每个线程获取对应的hash值,之后通过hash & (size -1)[size为cell数组的长度]将每个线程定位到对应的cell单元格,之后这个线程将值写入对应的cell单元格中的value,最后再将所有cell单元格的value和初始value进行累加求和得到最终的值。并且每个线程竞争的Cell的下标不是固定的,如果CAS失败,会重新获取新的下标去更新,从而极大地减少了CAS失败的概率。
显示锁
显示锁基础
显示锁在说内置锁的时候就已经提到过,显示锁就是明着加锁和解锁操作,这是因为synchronized的使用方式比较固定,只能加在固定的地方,而我们需要根据业务自己控制的时候synchronized显然不是那么的方便,所以就出现了按照程序猿主观思想来加锁的显示锁,显示锁中其提供了三个很常见方法:lock()、unLock()、tryLock()。
其标准用法如下:
//加锁
lock.lock();
//业务逻辑
try{
i++;
}finally{
//解锁
lock.unLock();
}
加锁的过程不能写在try中,否则一旦发生异常,锁将被释放。 最后在finally块中释放锁,这是保证在获取到锁之后,最终锁能够被释放。如果你了解seata里的TCC模式,那你一定知道有个叫事务悬挂和空回滚的东西,很相似。后期的博客我会介绍道。
那如何选择使用synchronized还是Lock 呢?这要看具体的业务场景,如果不需要考虑锁中断取消的情况,synchronized无疑是更好的选择,因为现在的JDK中对于synchronized的优化是很多的,比如刚刚学过的锁优化升级,如果想要更多的主动权,就选择Lock。
ReentrantLock
Lock本身是一个接口,基于这个接口,可以实现各式各样的锁,ReentrantLock就是其中使用比较频繁的一个锁。
基本使用
public class LockTest extends Thread{
private static int count = 100000;
private static int value = 0;
private static Lock lock = new ReentrantLock();
@Override
public void run() {
for (int i = 0; i < count; i++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName()+" : "+value);
value++;
}finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
LockTest l1 = new LockTest();
LockTest l2 = new LockTest();
l1.start();
l2.start();
TimeUnit.SECONDS.sleep(5);
System.out.println(value);
}
}
加锁和解锁的注意事项我们在上面已经提到过,此处不再赘述。
可重入
可重入感觉就像是你穿了一件T桖后,还能再穿一件外套,你脱衣服的时候,也需要先脱外套才能脱T桖,穿衣服是加锁,脱衣服是解锁,这样说,你能明白吗?
ReentrantLock也会把它称之为可重入锁,这是一种递归无阻塞的同步机制。它可以等同于synchronized的使用,但是ReentrantLock提供了比synchronized更强大、灵活的锁机制,可以减少死锁发生的概率。
其内部实现流程为:
- 每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,线程都会可获得该锁;
- 当某个线程请求锁成功后,JVM会记录锁的持有线程,并将计数器置为1,此时其他线程请求获取锁,就必须等待;
- 当持有锁的线程再次请求这个锁,就可以再次拿到这个锁,同时计数器再次递增,这就是重入了
- 当持有锁的线程退出同步代码块时,计数器递减,当计数器减到0时,释被放锁
synchronized的重入:
public class SynDemo {
public static synchronized void lock1(){
System.out.println("lock1");
lock2();
}
public static synchronized void lock2(){
System.out.println("lock2");
}
public static void main(String[] args) {
new Thread(){
@Override
public void run() {
lock1();
}
}.start();
}
}
ReentrantLock的重入:
public class ReentrantTest {
private static Lock lock = new ReentrantLock();
private static int count = 0;
public static int getCount() {
return count;
}
public void test1(){
lock.lock();
try {
count++;
test2();
}finally {
lock.unlock();
}
}
public void test2(){
lock.lock();
try {
count++;
}finally {
lock.unlock();
}
}
static class MyThread implements Runnable{
private ReentrantTest reentrantTest;
public MyThread(ReentrantTest reentrantTest) {
this.reentrantTest = reentrantTest;
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
reentrantTest.test1();
}
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantTest reentrantTest = new ReentrantTest();
new Thread(new MyThread(reentrantTest)).start();
TimeUnit.SECONDS.sleep(2);
System.out.println(count);
}
}
多次加锁不被阻塞就是可重入锁的基本特征。
公平锁&非公平锁
公不公平的判定标准是等待时间,比如排队买东西,谁排在前面谁先买,同理就是谁等的时间久谁先买,要分个先来后到。
如果硬要问,谁的效率高,那一定是非公平锁的效率高,想想我们生活中的案例,竞争才能让人变得优秀,才能提高产能,锁也不例外。但光这么说并没有什么说服力,那就说点靠谱的:主要是因为竞争充分利用了CPU,减少了线程唤醒上下文切换的时间。
ReentrantLock开启公平锁的方式:
//开启公平锁
private Lock lock = new ReentrantLock(true);
如果不传入参数,那就是非公平锁本锁了。
最后关于内置锁和显示锁的对比,你们觉得还有必要说吗?我知道大家懒,还是总结下吧:
相同点:
都以阻塞性方式进行加锁同步,当一个线程获得了对象锁,执行同步代码块,则其他线程要访问都要阻塞等待,直到获取锁的线程释放锁才能继续获取锁。
不同点:
- 对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成;
- Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
综上可知,ReenTrantLock的锁粒度和灵活度要优于Synchronized。
ReentrantReadWriteLock
了解读写锁
ReenTrantLock还有一个变种锁ReentrantReadWriteLock,从表述来看就是多了读写的功能,没错,ReentrantReadWriteLock内部维护了一对锁:读锁,写锁。
在当下读写分离的大开发环境下,对读写分别加锁的场景就是和于ReentrantReadWriteLock,关于读操作是否需要加锁的问题我们先保留,这是因为它的工作方式我们还不了解,继续往下看。
ReentrantReadWriteLock的主要特征就是:
- 读操作不互斥,写操作互斥,读和写互斥;
- 支持公平性和非公平性;
- 支持锁重入;
- 写锁能够降级成为读锁,遵循获取写锁、获取读锁在释放写锁的次序。读锁不能升级为写锁。
哎~看到没?问题解决没?不多说了啊。
实现原理
刚刚说过,ReentrantReadWriteLock内部维护了一对读写锁,类中定义了几个核心方法,readLock()返回用于读操作的锁,writeLock()返回用于写操作的锁。writeLock()用于获取写锁,readLock()用于获取读锁。
通过一个简单的for循环案例来演示下:
package com.codingfire.cache;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import java.util.concurrent.atomic.AtomicStampedReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Thr {
private static int count = 0;
private static class WriteDemo implements Runnable{
ReentrantReadWriteLock lock ;
public WriteDemo(ReentrantReadWriteLock lock) {
this.lock = lock;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.writeLock().lock();
count++;
System.out.println("写锁: "+count);
lock.writeLock().unlock();
}
}
}
private static class ReadDemo implements Runnable{
ReentrantReadWriteLock lock ;
public ReadDemo(ReentrantReadWriteLock lock) {
this.lock = lock;
}
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.readLock().lock();
count++;
System.out.println("读锁: "+count);
lock.readLock().unlock();
}
}
public static void main(String[] args) {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
WriteDemo writeDemo = new WriteDemo(lock);
ReadDemo readDemo = new ReadDemo(lock);
//运行多个写线程,不会重复,证明写互斥
//运行多个读线程,可能重复,证明读不互斥
//同时运行,读锁和写锁后面不会出现重复的数字,证明读写互斥
for (int i = 0; i < 3; i++) {
new Thread(writeDemo).start();
}
for (int i = 0; i < 3; i++) {
new Thread(readDemo).start();
}
}
}
查看运行输出:
写锁: 1 写锁: 2 写锁: 3 读锁: 4 读锁: 6 读锁: 5 写锁: 7 写锁: 8 写锁: 9 写锁: 10 写锁: 11 写锁: 12 写锁: 13 写锁: 14 写锁: 15 写锁: 16 写锁: 17 写锁: 18
从结果来看,符合我们对它的描述。能不符合吗?官方爸爸都这么说。
锁降级
读写锁是支持锁降级的,但不支持锁升级。写锁可以被降级为读锁,但读锁不能被升级写锁。这我们在前面就已经说过了,但这里降级的概念该怎么理解呢?这么说:获取到了写锁的线程能够再次获取到同一把锁的读锁。因为其内部有两把锁,它当然可以获取另一把读锁,这就是写锁降级为读锁。
我们来看看下面的案例:
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void fun1(){
//获取写锁
lock.writeLock().lock();
System.out.println("fun1");
fun2();
lock.writeLock().unlock();
}
public void fun2(){
//获取读锁
lock.readLock().lock();
System.out.println("fun2");
lock.readLock().unlock();
}
}
public static void main(String[] args) {
new Demo().fun1();
}
输出:
fun1 fun2
说明我们的理论是正确的,但还没有验证写锁升级读锁,可以试试,还是上面的案例,调换下顺序:
private static class Demo{
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void fun1(){
//获取写锁
lock.writeLock().lock();
System.out.println("fun1");
//fun2();
lock.writeLock().unlock();
}
public void fun2(){
//获取读锁
lock.readLock().lock();
System.out.println("fun2");
fun1();
lock.readLock().unlock();
}
}
public static void main(String[] args) {
new Demo().fun2();
}
输出:
fun2
只输出了fun2,fun1没有输出,说明无法获取到同一把锁的写锁。
总结
最后,关于ReentrantReadWriteLock锁什么时候用,和ReentrantLock一样,只有当高并发条件下才适合使用,其效率非常高,单线程或线程很少的情况下,没有什么差别,甚至还可能由于结构更复杂导致效率不如synchronized同步块。
LockSupport
LockSupport是一个工具类,其内部定义了一组公共静态方法,通过这些方法可以对线程进行阻塞和唤醒功能,LockSupport也是构建同步组件的基础工具。
LockSupport定义了一组以park开头的方法用来阻塞当前线程,并以unpark来唤醒一个被阻塞的线程。
这部分内容了解即可,你可能整个生涯都用不到。
AQS
AQS(AbstractQueuedSynchronizer),即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),是JUC并发包中的核心基础组件。它维护了一个 volatile 的 state 和一个 CLH(FIFO)双向队列。
我觉得这玩意你得了解下,虽然我也很不喜欢这部分内容,但它确实重要,为什么说重要呢?因为它是ReentrantLock、ReentrantReadWriteLock、Semaphore这些东西的基础,这些东西有多重要,但对于我们开发者而言,这部分内容,我们大致了解它的工作方式,设计模式即可,毕竟我们真正要用只是它的上层封装工具类。这些类我们已经讲过ReentrantLock、ReentrantReadWriteLock,其他的后面也会有的。
CLH队列
CLH队列锁即Craig, Landin, and Hagersten (CLH) locks。这是三个人的名字。 同时它也是现在PC机内部对于锁的实现机制。我们正说的AQS就是基于CLH队列锁的一种变体实现。
CLH队列锁是一种基于单向链表的可扩展的公平的自旋锁,这个title可真多,它申请线程仅在本地变量上自旋,通过轮询前驱锁的状态决定是否继续轮询或者获取锁。你现在可能不明白这种结构,没关系,博主通过几个状态和图来说明下。
单线程状态
多线程状态
自旋状态
获取到锁状态
总结
简单描述就是:
- 当有一个线程的时候,队列没什么意义;
- 第二个线程来了之后,第一个线程的节点锁被获取变为true,第二个节点指向第一个节点;
- 第二个节点不断自旋,以获取第一个节点的锁状态;
- 一旦第一个节点的任务执行完,锁被释放,第二个节点获取到锁,第一个节点就会从队列中被删除。
设计模式
AQS是一个抽象类,这个没什么可争议的,子类通过继承它,并实现其抽象方法。不知道你注意没,ReentrantLock、ReentrantReadWriteLock这两个刚学过的类就是如此:
从图中可以看出,他们两个并没有直接继承AQS,而是在其内部扩展了静态内部类来继承AQS。 这么做的原因,就是想通过区分使用者和实现者,来让使用者可以更加方便的完成对锁的操作。
锁面向使用者,其定义了锁与使用者的交互实现方式,同时隐藏了实现细节。AQS面向的是锁的实现者,其内部完成了锁的实现方式。通过这种方式,可以通过区分锁和同步器让使用者和实现者能够更好的关注各自的领域。
博主觉得吧,大家听听就好,知道这么回事就行,莫深究,越挖越深,吃不消了都。
实现思路
其实博主对设计模式不是很在行,但是看到AQS是用了模版设计模式,一开始还非常好奇,细看代码才发现,什么模版设计模式,这不就是个工厂类吗?这种设计模式在我们的开发中大量运用,比如JDBCTemplate、RedisTemplate、RabbitTemplate等等都在用,我们自己有时候也会用。
就好比我们定一个抽象的动物类,并有bark,eat,sleep三个抽象方法,子类非别是people,dog,cat,分别继承了动物类,然后各自重写自己的bark,eat,sleep类。
AQS使用的模版方法大致有三类:
- xxSharedxx:共享式获取与释放,如读锁;
- acquire:独占式获取与释放,如写锁;
- 查询同步队列中等待线程情况。
AQS对于锁的操作是通过同步状态切换来完成的,其有一个变量state,我们上面提到过,这个状态用来表示锁的状态,state>0时表示当前已有线程获取到了资源,当state = 0时表示释放了资源。
注意:多线程下,一定会有多个线程来同时修改state变量,所以在AQS中也提供了一些方法能够安全的对state值进行修改,那就是CAS,我们在子类中找一下有没有这样的方法,在ReentrantLock源码中找到了这个:
点进去的类是AbstractQueuedSynchronizer,在里面找到了找一下这个方法:
果然被我找到了,CAS真是把利器,不得不服,简直无处不在,特别是你在做分布式相关的东西时,只要你肯挖,多半都有它的影子。
AQS原理
原理
前面提到过AQS是基于CLH队列锁的来实现的,其内部不同于CLH的单向链表,使用二十的双向链表。对于一个队列来说,其内部一定会通过一个节点来保存线程信息,如:前驱节点、后继节点、当前线程节点、线程状态这些信息,CLH的队列图我们已经画过了,相信大家都很了解了,AQS内部同样定义了一个这样的Node对象用于存储这些信息。
总结下来就是:
两种线程等待模式:
- SHARED:表示线程以共享模式等待锁,如读锁。
- EXCLUSIVE:表示线程以独占模式等待锁,如写锁。
五种线程状态:
- 初始Node对象时,默认值为0。
- CANCELLED:表现线程获取锁的请求已经取消,值为1。
- SINNAL:表现线程已经准备就绪,等待锁空闲给我,值为-1。
- CONDITION:表示线程等待某一个条件被满足,值为-2。
- PROPAGETE:当线程处于SHARED模式时,该状态才会生效,用于表示线程可以被共享传 播,值为-3。
五个成员变量:
- waitStatus:表示线程在队列中的状态,值对应上述五种线程状态。
- prev:表示当前线程的前驱节点。
- next:表示当前线程的后继节点。
- thread:表示当前线程。
- nextWaiter:表示等待condition条件的节点。
同时在AQS中还存在两个成员变量,head和tail,分别代表队头节点和队尾节点
。
说了这么多,但整这些博主可是记不住,但是其运行模式我们要清楚,节点怎么工作也要了解。
下面,我们来图来加深对它的结构的理解。
整体结构图
新增一个节点时:
释放锁后删除头节点:
总结
看到图,我相信总结已经不需要再写什么东西了,大家看完图对它的工作状态和结构已经很清晰了,真的不需要记住全部,这三张图能记住就足够了。
再次吐槽
第二天晚上了,目前已经写了3w+的字,还没有写完,名天这时候可能差不多了吧,今天写的内容比较晦涩,其实说实话,博主写完也未必能再复述下来,但总结好总归是随用随取,很多东西开发中根本不会涉及那么底层,但我们还是要明白其工作原理,这对我们开发有好处。还是洗洗睡吧,来日再战。
Fork/Join分解合并框架
什么是Fork/Join框架
Fork/Join框架是jdk1.7开始提供的一个并行任务框架,可以在不去了解Thread、Runnable等相关知识的情况下,只要遵循fork/join开发模式,就完成写出很好的多线程并发任务,可以说简化了多线程的开发步骤。
它的主要思想是分而治之,把一个大任务分成若干小份,分别执行,最后再汇聚成一份,得到大任务结果。所以理解起来一很容易,简单来说分为两步:
第一步,分割任务,并行执行;
第二步,合并子任务结果,得到最终结果。
为了更好的表示这个过程,我们画个图,我先找找,看能不能找到一个合适的图,不行就自己画,最终还是自己画:
从图中你可以看到一点,任务的划分并不是均匀的。
工作窃取算法
按照上图,每一个小任务最终都会存在于一个任务队列中,说到这里,我觉得有必要再画个图了,上图不足以描述队列中的任务:
假如说有这两个线程,当线程1中的任务率先执行完毕,线程1将从线程2中取出没有执行的任务放到自己的线程队列中执行。利用自己的闲置时间去执行其他线程的任务,能够减少线程阻塞或是闲置的时间,提高 CPU 利用率 。这就是工作窃取算法的核心。
Fork/Join基本使用
使用前瞻
Fork/Join的任务叫ForkJoinTask,ForkJoinTask的执行需要一个pool。ForkJoinTask的内部包含了fork和join的操作机制,开发者在使用的时候不需要直接继承ForkJoinTask,而是继承它的子类:
- RecursiveAction:返回没有结果的任务。
- RecursiveTask:返回有结果的任务。
工作流程:
- 新建ForkJoinPool;
- 新建ForkJoinTask(RecursiveAction/RecursiveTask);
- 在任务的compute方法,根据自定义条件进行任务拆分,如果条件满足则执行任务,如果条件不满足则继续拆分任务,当所有任务都执行完,进行最终结果的合并;
- 通过get或join获取最终结果。
同步有结果值
//forkJoin累加
public class RecursiveTest{
//自定义任务
private static class SumTask extends RecursiveTask<Integer> {
private final static int THRESHOLD=5;
private int[] src;
private int fromIndex;
private int endIndex;
public SumTask(int[] src, int fromIndex, int endIndex) {
this.src = src;
this.fromIndex = fromIndex;
this.endIndex = endIndex;
}
@Override
protected Integer compute() {
//判断是否符合任务大小
if (endIndex-fromIndex<THRESHOLD){
//符合条件
int count = 0;
for (int i = fromIndex; i <= endIndex; i++) {
count+=src[i];
}
return count;
}else {
//继续拆分任务
//基于二分查找对任务进行拆分
int mid = (fromIndex+endIndex)/2;
SumTask left = new SumTask(src,fromIndex,mid);
SumTask right = new SumTask(src,mid+1,endIndex);
invokeAll(left,right);
return left.join()+right.join();
}
}
}
public static void main(String[] args) {
int[] numArray = new int[]{1,23,15,45,145,456,21,3,55,22,77,44,33,22,90,12,46,78};
ForkJoinPool pool = new ForkJoinPool();
SumTask sumTask = new SumTask(numArray,0,numArray.length-1);
long start = System.currentTimeMillis();
pool.invoke(sumTask);
System.out.println("spend time: "+(System.currentTimeMillis()-start));
System.out.println("sum = " + sum);
}
}
不是说所有的这种操作都要使用fork/join,fork/join适合运算量比较大的操作,它内部会利用CPU多核特性,结合线程上下文切换,所以肯定是有消耗的,和传统的for循环便利相比,数据量少肯定性能不如它。
异步无结果值
package com.codingfire.cache;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
public class FindFile extends RecursiveAction {
private File path;
public FindFile(File path) {
this.path = path;
}
@Override
protected void compute() {
List<FindFile> takes = new ArrayList<>();
//获取指定路径下的所有文件
File[] files = path.listFiles();
if (files != null){
for (File file : files) {
//是否为文件夹
if (file.isDirectory()){
//递归调用
takes.add(new FindFile(file));
}else {
//不是文件夹。执行检查
if (file.getAbsolutePath().endsWith("vue")){
System.out.println(file.getAbsolutePath());
}
}
}
//调度所有子任务执行
if (!takes.isEmpty()){
for (FindFile task : invokeAll(takes)){
//阻塞当前线程并等待获取结果
task.join();
}
}
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
FindFile task = new FindFile(new File("/Users/Codeliu/Desktop/vue"));
pool.submit(task);
//主线程join,等待子任务执行完毕。
task.join();
System.out.println("task end");
}
}
执行结果会把vue结尾的文件全部打印出来,有结果无结果主要看你继承的什么类,执行的是什么任务,compute里怎么写,不要过多纠结,知道怎么用就行。
fork/join总结
博主觉得fork/join暂时可能给大家讲不明白,这里只简单的涉及下,后期还会单独出一篇博客来说明,因为有很多涉及源码的地方还需要再考虑考虑,特别是join的流程,invoke和invokeAll的区别,fork和invoke关系等,都存在很大的疑点,想要段时间搞明白恐怕不易,有点后悔博客写太长了,还是应该分成几篇循序渐进啊,这篇还是硬着头皮继续写,后续内容看情况,实在不行就分开写,要不然写的太长,内容太多,很多东西涉及了,但却讲的不深会被大家诟病:你这还挖坟呢?地皮都没揭起来吧?哎~心好累,写了好几天了都。
并发的工具类
JDK并发包下提供了几个很有用的并发工具类:CountDownLatch、CyclicBarrier、Semaphore、Exchanger,通过他们可以在不同场景下完成一些特定的功能,AQS里有提到其中的一些,毕竟队列同步器是构建锁或者其他同步组件的基础框架。
CountDownLatch
CountDownLatch是闭锁,它允许一个或多个线程等待其他线程完成工作,其内部通过计数器实现,当执行到某个节点时,开始等待其他任务执行,每完成一个,计数器减1,当计数器等于0时,代表任务已全部完成,恢复之前等待的线程并继续向下执行。
CountDownLatch的一个典型使用场景就是解析一个多sheet的Excel文件,等到解析完所有sheet后,再进行后续操作。
举个例子:
package com.codingfire.cache;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class CountDownLatchDemo {
static CountDownLatch countDownLatch = new CountDownLatch(5);
//任务线程
private static class TaskThread implements Runnable{
@Override
public void run() {
countDownLatch.countDown();
System.out.println("task thread is running");
}
}
//等待线程
private static class WaitThread implements Runnable{
@Override
public void run() {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait thread is running");
}
}
public static void main(String[] args) throws InterruptedException {
//等待线程执行
for (int i = 0; i < 2; i++) {
new Thread(new WaitThread()).start();
}
for (int i = 0; i < 5; i++) {
new Thread(new TaskThread()).start();
}
TimeUnit.SECONDS.sleep(3);
}
}
输出结果:
task thread is running task thread is running task thread is running task thread is running task thread is running wait thread is running wait thread is running
普通任务执行结束后,CountDownLatch任务才执行,不管你设置1个还是多个,都是如此。博主觉得通过文字描述和案例理解起来更容易,这个图画出来反倒不好理解了,所以就不画了。
CycliBarrier
CycliBarrier是同步屏障,当一组任务执行时,第一个任务到达屏障点开始等待,直到最后一个任务到达屏障点,屏障解除,任务可以继续向下执行,它内部是基于计数器思想实现的。此处可以有图:
代码实现:
public class CyclicBarrierDemo {
static CyclicBarrier barrier = new CyclicBarrier(3);
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
//主线程
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}
}
输出:
Thread-0: do somethings main: do somethings Thread-1: do somethings Thread-1:continue somethings Thread-0:continue somethings main:continue somethings
屏障点之后主线程继续执行,在此之前,不必关心哪个先哪个后。
CycliBarrier的构造函数不仅可以传入需要等待的线程数,同时还可以传入一个Runnable,对于这个传入的Runnable,可以作为一个扩展任务来使用。
看案例:
public class CyclicBarrierExtendDemo {
static CyclicBarrier barrier = new CyclicBarrier(3,new ExtendTask());
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}).start();
//主线程
try {
System.out.println(Thread.currentThread().getName()+": do somethings");
barrier.await();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":continue somethings");
}
static class ExtendTask implements Runnable{
@Override
public void run() {
System.out.println("extend task running");
}
}
}
输出:
Thread-0: do somethings main: do somethings Thread-1: do somethings extend task running Thread-1:continue somethings Thread-0:continue somethings main:continue somethings
到达屏障点后,先执行扩展任务,然后才继续执行剩下的任务,增强了自主性,可以个性化功能了。
不知道你发现没,CyclicBarrier是固定线程数,CountDownLatch则没有这个限制,可多可少。另外,CountDownLatch的await 阻塞工作线程,所有准备执行的线程都要执行countDown来减少计数器的值,CyclicBarrier是通过自身的await来阻塞线程,两者有本质区别,都仔细看看。
最后再爆一嘴,这个功能和iOS多线程GCD里面的栅栏功能类似,只是栅栏也不限定线程的数量。
Semaphore
Semaphore是信号量,好巧不巧的,iOS也有信号量,而且功能还类似,天下语言一家亲啊。信号量主要做流量控制,比如说1000个人要进入展厅参观,但是每次最多只能进100个,100是最大载客量,那就只能在门口安排一个工作人员来数数,第一次达到100个后咔的拉上警戒线,其他人都在外面等着,但是这100人不必同时离开,当出一个人时,入口就放进来1个人,只要保证展厅最多只能存在100人即可,但不必一定达到100,要看进出的速度,也就是任务执行的速度。
看代码理解下:
package com.codingfire.cache;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SemaphoreDemo {
private static final int THREAD_COUNT=20;
private static ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
static Semaphore semaphore = new Semaphore(5);
public static void main(String[] args) {
for (int i = 0; i < THREAD_COUNT; i++) {
executorService.execute(()->{
try {
//获取资源
semaphore.acquire();
System.out.println("进入");
//释放资源
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
}
}
输出有点长,就不贴出来了,也没什么意义,要注意,获取资源后执行任务,在后面要对任务进行释放,释放任务的契机可以是执行结束后,也可以是其他,要看具体业务,但同意之间,最多只能有指定数量的任务进入。
Exchanger
Exchanger是交换器,它是一个线程协作工具类,可以进行线程间的数据交换,但仅限两个线程间。它提供了一个同步点,在这个同步点,两个线程可以交换彼此的数据。
来看个案例:
package com.codingfire.cache;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
private static final Exchanger<Set<String>> exchange = new Exchanger<Set<String>>();
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
Set<String> setA = new HashSet<String>();//存放数据的容器
try {
setA.add("a1");
setA = exchange.exchange(setA);//交换set
/*处理交换后的数据*/
System.out.println(Thread.currentThread().getName()+" : "+setA.toString());
} catch (InterruptedException e) {
}
}
},"setA").start();
new Thread(new Runnable() {
@Override
public void run() {
Set<String> setB = new HashSet<String>();//存放数据的容器
try {
/*添加数据
* set.add(.....)
* set.add(.....)
* */
setB.add("b1");
setB = exchange.exchange(setB);//交换set
/*处理交换后的数据*/
System.out.println(Thread.currentThread().getName()+" : "+setB.toString());
} catch (InterruptedException e) {
}
}
},"setB").start();
}
}
执行输出:
setB : [a1] setA : [b1]
可以看到,数据已经交换了。
队列
队列分为阻塞和非阻塞两种,有一张表想分享给大家:
队列类别 | 阻塞 | 有界 | 线程安全 | 场景 | 注意事项 |
---|---|---|---|---|---|
ConcurrentLinkedQueue | 非阻塞 | 无界 | CAS | 操作全局集合 | size() 要遍历一遍集合,慎用 |
ArrayBlockingQueue | 阻塞 | 有界 | 一把全局锁 | 生产消费模型,平衡两边处理速度 | |
LinkedBlockingQueue | 阻塞 | 可配置 | 存取采用2把锁 | 生产消费模型,平衡两边处理速度 | 无界的时候注意内存溢出 |
PriorityBlockingQueue | 阻塞 | 无界 | 一把全局锁 | 支持优先级排序 | |
SynchronousQueue | 阻塞 | 无界 | CAS | 不存储元素的阻塞队列 |
ConcurrentLinkedQueue
简介
ConcurrentLinkedQueue是非阻塞队列,在单线程中,经常会用到一些例如ArrayList,HashMap的集合,但他们都不是线程安全的,其中Vector算是线程安全的,但它的做法过于简单粗暴,就是直接在方法上添加synchronized同步块作为独占锁,将原本的多线程串行化,ArrayList同样也可以这么做,这种方式效率很低,明显不是我们想要的结果,这时候ConcurrentLinkedQueue队列就派上用场了。
ConcurrentLinkedQueue是一个基于链接节点的无边界的线程安全队列,它遵循队列的FIFO原则,队尾入队,队首出队,采用CAS算法来实现这一点。
使用
public class ConcurrentLinkedQueueDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue();
//offer(E e),add(E e)都是将指定元素插入队列的尾部
queue.offer("java");
queue.add("iOS");
System.out.println("offer后,队列是否空?" + queue.isEmpty());
//peek()获取但不移除此队列的头,如果队列为空,则返回null
System.out.println("从队列中peek:" + queue.peek());
//poll()获取并移除此队列的头,如果此队列为空,则返回null
System.out.println("从队列中poll:" + queue.poll());
System.out.println("pool后,队列是否空?" + queue.isEmpty());
//remove():从队列中删除指定元素
System.out.println("从队列中remove元素:"+queue.remove("iOS"));
}
}
上面提到过,ConcurrentLinkedQueue的size方法会遍历集合,很慢,慎用!!!queue.size()>0用 !queue.isEmpty()替代。ConcurrentLinkedQueue本身并不能保证线程安全,还需要自己进行同步或加锁操作,区别在于,我们之前保证的是线程安全,现在保证的是队列安全,主要是防止其他线程操作队列。
BlockingQueue
BlockingQueue是阻塞队列,当队列满的时候入,当队列空的时候出都会造成阻塞。我们在上面已经知道了,阻塞队列共有四种,下面我们来分别介绍这四种队列。
ArrayBlockingQueue
ArrayBlockingQueue是一个由数组实现的有界阻塞队列,该队列采用FIFO的原则对元素进行排序添加。其大小在构造时由构造函数来决定,确认之后就不能再改变。这和我们普通数组也是一样的。
ArrayBlockingQueue可以选择公平还是不公平的访问策略,公平性通常会降低吞吐量,但是减少了可变性,避免了“不平衡性”。内部使用了可重入锁ReentrantLock + Condition来完成多线程环境的并发操作。
它的使用场景适合于多线程处理某个任务,但对顺序有要求。它可以一边多线程的处理数据,一边多线程的保存数据,而且顺序不会乱,比如入站口高并发的人脸识别,都需要提交到服务器处理,不可能一次搞一个吧?肯定是多个设备同时工作,服务器假设只有一台,我们希望它可以更高效率的执行验证的部分,就可以用到这个队列,看下案例:
package com.codingfire.cache;
import java.util.Random;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.TimeUnit;
public class ArrayBlockingQueueDemo {
//最大容量为5的数组阻塞队列
private static ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(5, true);
public static void main(String[] args) {
Thread t1 = new Thread(new ProducerTask());
Thread t2 = new Thread(new ConsumerTask());
//启动线程
t1.start();
t2.start();
}
//生产者
static class ProducerTask implements Runnable {
private Random rnd = new Random();
@Override
public void run() {
try {
while (true) {
int value = rnd.nextInt(100);
//如果queue容量已满,则当前线程会堵塞,直到有空间再继续
queue.put(value);
System.out.println("生产者:" + value);
TimeUnit.MILLISECONDS.sleep(100); //线程休眠
}
} catch (Exception e) {
}
}
}
//消费者
static class ConsumerTask implements Runnable {
@Override
public void run() {
try {
while (true) {
//如果queue为空,则当前线程会堵塞,直到有新数据加入
Integer value = queue.take();
System.out.println("消费者:" + value);
TimeUnit.MILLISECONDS.sleep(15); //线程休眠
}
} catch (Exception e) {
}
}
}
}
这个队列最大处理量为5,有空闲就会放新的数据进来,生产者记录人脸信息,消费者负责比对,顺序是一样的,保证不会出错。自己运行下看看吧,其源码分析可以自己去查下,网上博主看到有很多。
LinkedBlockingQueue
LinkedBlockingQueue和ArrayBlockingQueue的使用方式基本一样,区别如下:
- 队列的数据结构不同
- ArrayBlockingQueue是一个由数组支持的有界阻塞队列
- LinkedBlockingQueue是一个基于链表的有界(可设置)阻塞队列
-
队列大小初始化方式不同
- ArrayBlockingQueue实现的队列中必须指定队列的大小,这和数组一样
- LinkedBlockingQueue实现的队列中可以不指定队列的大小,但是默认是Integer.MAX_VALUE
- 队列中锁实现不同
- ArrayBlockingQueue实现的队列中锁没有分离,生产和消费用共同一个锁
- LinkedBlockingQueue实现的队列中锁是分离的,生产用putLock,消费用takeLock
-
生产或消费时操作不同
- ArrayBlockingQueue队列在生产和消费的时候,是直接将对象插入或移除的
- LinkedBlockingQueue队列在生产和消费的时候,是先将对象转换为Node,再进行插入或移除,多了这一步会影响性能
其实这些大家根据数组和链表的特征基本上是可以找到不同点的。
PriorityBlockingQueue
PriorityBlockingQueue是一个优先级队列,内部使用一个独占锁来控制,同时只有一个线程可以进行入队和出队,它是无界的,就是说向Queue里面增加元素没有数量限制,但可能会导致内存溢出而失败。
PriorityBlockingQueue如其名,始终保证出队的元素是优先级最高的元素,并且优先级规则可以定制。内部通过一个可扩容数组保存元素,规则是:当前元素个数>=最大容量时候会通过算法扩容,为了避免在扩容操作时其他线程不能进行出队操作,会先释放锁,然后通过CAS保证同一时间只有一个线程可以扩容成功。
PriorityBlockingQueue不允许空值,而且不支持non-comparable(不可比较)的对象,优先队列的头是基于自然排序或Comparator排序的最小元素,如果有多个对象拥有同样的排序,那么就随机地取其中任意一个,也可以通过Comparator(比较器)在队列实现自定义排序。当获取队列时,将返回队列的头对象。他也是无界的,初始化时可设置大小,但随着添加会自动扩容。
SynchronousQueue
SynchronousQueue不存储元素,而是维护一组线程用于数据的入队和出队,所以严格意义上不是一个真正的队列。正因为如此,put和take会一直阻塞,直到有另一个线程已经准备好要处理数据。
SynchronousQueue使用直接交付的方式,将更多关于任务状态的信息反馈给生产者,当交付被接受时,它就知道消费者已经得到了任务,而不是简单地把任务放入一个队列不管是否被队列拿到。
SynchronousQueue默认使用非公平排序,也可设置公平排序。但公平所构造的队列使线程以 FIFO 的顺序进行访问,通常会降低吞吐量,好处是可以减小可变性并避免得不到服务。
SynchronousQueue特点:
- 是一种阻塞队列,其中每个 put 必须等待一个 take,反之亦然。同步队列没有任何内部容量,甚至连一个队列的容量都没有。
- 是线程安全的,是阻塞的。
- 不允许使用 null 元素。
- 公平排序策略是指调用put的线程之间,或take的线程之间的线程以 FIFO 的顺序进行访问。
SynchronousQueue的方法:
- iterator(): 永远返回空,因为里面没东西。
- peek() :永远返回null。
- put() :往queue放进去一个element以后就一直wait直到有其他thread进来把这个element取走。
- offer() :往queue里放一个element后立即返回,如果碰巧这个element被另一个thread取走了,offer方法返回true,认为offer成功;否则返回false。
- offer(2000, TimeUnit.SECONDS) :往queue里放一个element但等待时间后才返回,和offer()方法一样。
- take() :取出并且remove掉queue里的element,取不到东西他会一直等。
- poll() :取出并且remove掉queue里的element,方法立即能取到东西返回。否则立即返回null。
- poll(2000, TimeUnit.SECONDS) :等待时间后再取,并且remove掉queue里的element,
- isEmpty():永远是true。
- remainingCapacity() :永远是0。
- remove()和removeAll() :永远是false。
使用案例:
package com.codingfire.cache;
import java.util.Random;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
public class SynchronousQueueDemo {
public static void main(String[] args) throws InterruptedException {
SynchronousQueue<Integer> queue = new SynchronousQueue<Integer>();
new Thread(new Product(queue)).start();
new Thread(new Customer(queue)).start();
}
static class Product implements Runnable {
SynchronousQueue<Integer> queue;
Random r = new Random();
public Product(SynchronousQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
int number = r.nextInt(1000);
try {
TimeUnit.SECONDS.sleep(2);
System.out.println("等待2秒后发送" + number);
queue.put(number);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
static class Customer implements Runnable {
SynchronousQueue<Integer> queue;
public Customer(SynchronousQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
System.out.println("接收到num:" + queue.take());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
输出:
等待2秒后发送310 接收到num:310 等待2秒后发送382 接收到num:382 等待2秒后发送897 接收到num:897 等待2秒后发送898 接收到num:898 等待2秒后发送774 接收到num:774 等待2秒后发送60 接收到num:60 等待2秒后发送532 接收到num:532 等待2秒后发送773 接收到num:773 ......
感觉上和ConcurrentLinkedQueue一样,差别是,SynchronousQueue是线程安全的,是阻塞的,ConcurrentLinkedQueue队列内虽然也是线程安全的,但我们要放着其他线程同时操作这个队列。
ThreadPoolExecutor线程池
在介绍Fork/Join的时候我们提到,ForkJoinTask的执行时需要一个ForkJoinPool,这是一个类似线程池的东西,但和Java线程池有区别,虽然都是用来管理线程的,但ForkJoinPool的线程池内的线程都对应一个任务队列(WorkQueue),队列可能有多个,工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。Java中的线程池则是一个没有感情的调度机器,按照规章制度办事,什么规章制度呢?如下:
- 任务提交到线程池后,如果当前线程数小于核心线程数,就创建线程并执行,不会销毁原来的线程,直到达到核心线程数;
- 当核心线程都在执行还有任务提交时,任务放在阻塞队列中等待线程执行完之后再来执行队列中的任务;
- 当阻塞队列也满了后,继续创建线程并执行任务,直到达到最大线程数;
- 最大线程也满了以后,执行拒绝策略;
- 当线程空闲后,线程在达到空闲等待时间后自动销毁,直至数量降低至核心线程数为止。
线程池的意义
我们经常说上下文切换,经常说消耗资源,那么到底说的是谁呢?此线程尔!所以线程处于无序状态时非常不利于程序的运行,这时候就需要一个线程池了,线程池应用最多的场景是多线程并发,其优点如下:
- 降低资源消耗!通过重复利用已创建的线程降低线程创建和销毁造成的消耗;
- 提高响应速度!当任务到达时,任务可以不需要等线程创建就能立即执行;
- 提高线程的可管理性!线程是稀缺资源,要合理利用,通过线程池可以进行统一分配、调优和监控。
线程池共有五种状态,分别是:
- RUNNING:处于RUNNING状态的线程池能够接收新任务,对新添加的任务进行处理;
- SHUTDOWN:处于SHUTDOWN状态的线程池不可以接收新任务,但是可以对已添加的任务进行处理;
- STOP:处于STOP状态的线程池不接收新任务,不处理已添加的任务,并且会中断正在处理的任务;
- TIDYING:当所有的任务已终止,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,可以通过重载terminated()函数来实现;
- TERMINATED:线程池彻底终止。
用一张图来说明它们之间的关系:
构造方法参数介绍
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
corePoolSize:
核心线程数(线程池基本大小),在没有任务需要执行的时候的线程池大小。当提交一个任务时,线程池创建一个新线程执行任务,直到线程数等于该参数。 如果当前线程数为该参数,后续提交的任务被保存到阻塞队列中,等待被执行。
maximumPoolSize:
线程池中允许的最大线程数,线程池中的当前线程数目不会超过该值。如果当前阻塞队列满了,且继续提交任务,如果当前的线程数小于maximumPoolSize,则会新建线程来执行任务。
keepAliveTime:
线程池空闲时的存活时间,即当线程池没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize时才有用。
workQueue:
必须是BolckingQueue有界阻塞队列,用于实现线程池的阻塞功能。当线程池中的线程数超过它的corePoolSize时,线程会进入阻塞队列进行阻塞等待。
threadFactory:
用于设置创建线程的工厂。ThreadFactory的作用就是提供创建线程的功能的线程工厂。他是通过newThread()方法提供创建线程的功能,newThread()方法创建的线程都是“非守护线程”而且“线程优先级都是默认优先级”,默认5还记得吗?守护线程需要设置d开头的一个属性为true,都还记得吧?
handler:
线程池拒绝策略。当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,则必须采取一种策略处理该任务。
- AbortPolicy:默认策略,直接抛出异常;
- CallerRunsPolicy:用调用者所在的线程执行任务;
- DiscardOldestPolicy:插入阻塞队列的头部任务,并执行当前任务;
- DiscardPolicy:丢弃任务。
自定义一个线程池看看:
package com.codingfire.cache;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolDemo {
public static void main(String[] args) {
//创建阻塞队列
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(100);
//创建工厂
ThreadFactory threadFactory = new ThreadFactory() {
AtomicInteger atomicInteger = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
//创建线程把任务传递进去
Thread thread = new Thread(r);
//设置线程名称
thread.setName("MyThread: "+atomicInteger.getAndIncrement());
return thread;
}
};
ThreadPoolExecutor pool = new ThreadPoolExecutor(10,
10,
1,
TimeUnit.SECONDS,
queue,
threadFactory);
for (int i = 0; i < 11; i++) {
pool.execute(new Runnable() {
@Override
public void run() {
//执行业务
System.out.println(Thread.currentThread().getName()+" 执行");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"结束");
}
});
}
}
}
执行结果:
下不说这个执行结果,你看看红色圈的空白区域,按理说执行完结束应该有个Process finished字样,但这里没有,说明被挂起了,等待新任务进入。然后看执行的任务,我们设置的最大线程数10,任务数11,所以第十一个在前10个后执行,不过有一点博主要说明,第一个任务执行的结果和第十一个任务执行的结果位置有可能互换,前提是执行都要在结果前,这里是因为我们设置了固定的睡眠时间,实际中不可能会这么均匀。
预定义线程池
除了通过ThreadPoolExecutor自定义线程池外,Executor框架还提供了四种线程池,他们都可以通过工具类Executors来创建。下面,我们就来看看这些线程池及其特点。
FixedThreadPool
这是一个创建固定线程数的线程池,适用于满足资源管理而需要限制当前线程数量的场景,同时也适用于负载较重的服务器。
其定义如下:
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
三个参数意义看下方:
-
nThreads
- FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为创建FixedThreadPool 时指定的参数 nThreads
-
keepAliveTime
- 此处设置为了0L,代表多于的空闲线程会被立即终止
-
LinkedBlockingQueue
- FixedThreadPool 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Integer.MAX_VALUE。
举个例子吧:
package com.codingfire.cache;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolCase {
static class Thr implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name);
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newFixedThreadPool(3);
for (int i = 0; i < 5; i++) {
exec.execute(new Thr());
Thread.sleep(10);
}
exec.shutdown();
}
}
看输出:
由于设定最大线程数3,输出里面最大的线程标号也就是3,符合我们对它的期望。
SingleThreadExecutor
使用单个工作线程来执行一个无边界的队列,它适用于保证顺序地执行多个任务,并且在任意时间点,都保证只有一个线程存在。
定义如下:
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
corePoolSize 和 maximumPoolSize 被设置为 固定数值1,其他参数与 FixedThreadPool相同。SingleThreadExecutor 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Integer.MAX_VALUE)。
举个例子看看:
package com.codingfire.cache;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SingleThreadPoolCase {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newSingleThreadExecutor();
for (int i = 0; i < 5; i++) {
exec.execute(new Thr());
Thread.sleep(10);
}
exec.shutdown();
}
static class Thr implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 2; i++) {
count++;
System.out.println(name + ":" + count);
}
}
}
}
查看输出:
即使创建多个任务,最终输出显示也只有一个线程在工作。
CachedThreadPool
这是一个大小无界的线程池,它根据需要创建新线程,适用于执行短期异步的小任务或者是负载较轻的服务器。
其定义如下:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
corePoolSize 被设置为 0,即 核心线程数为空,maximumPoolSize 被设置为Integer.MAX_VALUE。这里把 keepAliveTime 设置为 60L,意味着 CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被终止。
FixedThreadPool 和 SingleThreadExecutor 使用有界队列 LinkedBlockingQueue作为线程池的工作队列,CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但 CachedThreadPool的maximumPool是无界的,也就是说,如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度,CachedThreadPool会不断创建新线程,甚至不惜因此耗尽CPU和内存,简直太可怕了,博主个人觉得还是能不用尽量别用,这就是个疯子,疯子你怕不怕?
举个例子:
package com.codingfire.cache;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CachedThreadPoolCase {
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
exec.execute(new Thr());
Thread.sleep(10);
}
exec.shutdown();
}
static class Thr implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
try {
//修改睡眠时间,模拟线程执行需要花费的时间
Thread.sleep(10);
System.out.println(name + "执行完了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
直接看执行结果:
执行完直接结束了,是因为调用了shutdown(),大家尝试注释掉这句,等一分钟,你会看到程序结束了:
ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor,继承自ThreadPoolExecutor,所以严格意义上算是管党给我们提供的自定义线程池。它实现了ScheduledExecutorService接口,就像是提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。它可在给定的延迟后运行命令或定期执行命令,适用于为了满足资源管理的需求而需要限制后台线程数量的场景,同时也可以保证多任务的顺序执行。
它的构造方法比较多:
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), handler);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}
它们都是利用ThreadLocalExecutor来构造的,唯一不同点在它所使用的阻塞队列变成了DelayedWorkQueue (延时工作队列)。
DelayedWorkQueue是ScheduledThreadPoolExecutor的内部类,类似于延时队列和优先级队列,在执行定时任务的时候,DelayedWorkQueue让任务执行时间的升序来排列,可以保证每次出队的任务都是当前队列中执行时间最靠前的,这也就是为什么说它可以保证线程执行的顺序。
举个例子:
package com.codingfire.cache;
import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledThreadPool {
public static void main(String[] args) throws InterruptedException {
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
// 第二个参数是延迟多久执行
scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS);
scheduledThreadPool.schedule(new Task(), 2, TimeUnit.SECONDS);
scheduledThreadPool.schedule(new Task(), 3, TimeUnit.SECONDS);
Thread.sleep(5000);
// 关闭线程池
scheduledThreadPool.shutdown();
}
static class Task implements Runnable {
@Override
public void run() {
try {
String name = Thread.currentThread().getName();
System.out.println("线程" + name + ", 开始:" + new Date());
Thread.sleep(1000);
System.out.println("线程" + name + ", 结束:" + new Date());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
查看输出:
我们设置最大线程数3,然后创建3个线程,最终输出里,任务是按照线程编号执行的,这个要看结束的时间。
WorkStealingPool
这是JDK1.8中新增的线程池,利用所有运行的CPU来创建一个工作窃取线程池,是对ForkJoinPool的扩展,适用于非常耗时的操作。听起来都很牛逼啊,在线程池界绝对是爸爸的存在。不好意思,忘了还有个疯子线程池呢,地位不保啊。
看代码:
package com.codingfire.cache;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class WorkStealingPoolDemo {
public static void main(String[] args) throws IOException {
//获取当前可用CPU核数
System.out.println(Runtime.getRuntime().availableProcessors());
//创建线程池
ExecutorService stealingPool = Executors.newWorkStealingPool();
stealingPool.execute(new Thr(1000));
/**
* 我现在CPU是4个,开启了5个线程,第一个线程一秒执行完,其他的都是两秒
* 此时会有一个线程进行等待,当第一个执行完毕后,会偷取第5个线程执行
*/
for (int i = 0; i < Runtime.getRuntime().availableProcessors(); i++) {
stealingPool.execute(new Thr(2000));
}
// 因为work stealing 是deamon线程
// 所以当main方法结束时, 此方法虽然还在后台运行,但是无输出
// 可以通过对主线程阻塞解决
System.in.read();
}
static class Thr implements Runnable{
int time;
public Thr(int time) {
this.time = time;
}
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" : "+time);
}
}
}
博主电脑4核,循环创建4个,循环外1个,看执行结果:
从执行结果看,worker-1窃取了循环中最后一个线程。 第一个线程一秒执行完,其他的都是两秒,此时会有一个线程等待,当第一个执行完毕后,会偷取第5个线程执行。你要是老板的话,这样的员工你喜不喜欢?文章来源:https://www.toymoban.com/news/detail-460550.html
最终吐槽
博主骂骂咧咧的写完了,最终5.8w字,还是没能把握住字数,不敢说写的太详细,对自己是个总结,对大家有用的不妨留个赞,真真写了好几天啊,手疼胳膊疼眼睛疼,真没想到JUC相关的内容这么多,但可能还有遗漏的,算了,就这么着吧,这时有人会说:博主你不是挖坟呢?这就不行了?嗯?男人不能说不行,但这篇就先这样吧,里面涉及到一些详细的部分,以后再补吧,也可能会遥遥无期。兄弟们,不敢再挖了,再挖博主就要改行了。就这样发出去大家一起看看讨论讨论吧。文章来源地址https://www.toymoban.com/news/detail-460550.html
到了这里,关于Java开发 - 不知道算不算详细的JUC详解的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!