文章目录
一、内容与特点
JUC主要是指JDK8中java.util.concurrent里提供的一系列线程并发工具,但是线程并发的问题远不止几个工具这么简单。要学习工具使用,更要能深入理解工具的原理以及处理线程并发问题的思路。
新手学技术、老手学体系,高手学格局。
二、理解线程
1、线程与进程
进程是操作系统中分配资源的最小单位,线程是CPU调度运行的最小单位。同一个进程下分配多个线程,这些线程可以共享进程内的资源。
现在的CPU都是有多个核心独立运行的,所以使用多线程能够更多的使用CPU的性能。并且CPU的计算速度非常非常快,比我们程序的执行速度快很多。多线程情况下,CPU可以在多个线程之间轮转切换执行,也能进一步提高CPU的使用率。
2、为什么线程不是越多越好?
1:线程执行需要消耗CPU资源,即使线程休眠,依然会要消耗CPU资源。线程过多,就会提高CPU的使用率,加大CPU的负担。
2:CPU运行过程中会在线程之间不断切换,切换过程中,需要不断的切换线程上下文。切换过程中的资源消耗不大,但是如果线程过多,这个消耗就会更大,CPU的使用率反而降低了。
3:经常听说一句话:频繁的开启线程,需要分配线程ID,并将相关上下文保存下来。在线程切换过程中也需要进行上下文切换,还原线程运行的环境,这样会消耗系统资源。
有个简单的比喻,CPU就好比一个饭店的一批食客(多个CPU核心),他们只负责吃东西(计算数据),饭店(操作系统)给他们上什么他就吃什么。而线程就好比是服务员,负责给CPU上菜(分配计算资源)。这个食客吃东西非常快,合理的增加服务员数量,确实可以提高饭店的运行效率。但是如果服务员太多,就需要增加非常多的碗碟,这一批食客也需要在一大堆的碗碟当中去分配食物,反而浪费了非常多的吃饭的时间,整体效率自然就降低了。
所以线程并不是越多越好,需要合理的规划线程数量。通常在Java中,可以通过Runtime.getRuntime().availableProcessors();获取当前机器的CPU核心数。而在对程序进行线程规划时,对于数据计算比较多的进程,称为CPU密集型。这类进程相当于小碗菜,菜种类多和分量少,临时的中间变量也会非常多。规划的线程数最好和CPU核心数一样,这就相当于在饭店中合理的减少碗碟的数量。而对于数据读取比较多的进程,称为IO密集型。这类进程相当于大锅菜,菜的种类不多但是分量很大,临时的中间变量会比较少。这时规划的线程数可以相对多一点,达到CPU核心数的两倍都是可以接受的,这就相当于大碗传菜,碗碟相对不会太多,增加服务员更能提高执行效率。
同时,还可以使用一些技术如池化技术来进一步提高线程的使用率。这就相当于调整服务员的排班,让服务员的工作时间更紧凑,工作效率更高,同时也能减少服务员的个数。
3、Java中开启线程的方式
Runnable:没有返回值的线程任务。
new Thread(()->{},'Thread1').start()。
java可以开启线程吗? java开启线程是通过调用native本地方法,调用JVM的C/C++代码来启动线程。
另外,还有一种类似的异步任务Callable经常拿来跟Runnable比较。Callable是一种有返回值的任务。
FutureTask<String> futureTask = new FutureTask<>(()->{return "callable"});
futureTask.run();
futureTask.get();
但是futureTask实际上是在run()方法阻塞执行的。
public class CallAbleDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
final FutureTask<String> futureTask = new FutureTask<>(() -> {
Thread.sleep(10000);
return "call";
});
new Thread(()->{futureTask.run();}).start();
try{
System.out.println(futureTask.get(5, TimeUnit.SECONDS));
}catch (TimeoutException e){
System.out.println(e.getMessage());
System.out.println(futureTask.get(6, TimeUnit.SECONDS));
}
}
}
三、理解线程安全问题
1、什么是线程安全问题?
多个线程操作同一个资源就会造成线程安全问题。
public class SaleTicketDemo1 {
public int ticket = 100 ;
public void saleTicket(){
ticket --;
System.out.println(Thread.currentThread().getName()+"; ticket = "+ ticket);
}
public static void main(String[] args) {
final SaleTicketDemo1 saler = new SaleTicketDemo1();
for (int i = 0; i < 10; i++) {
new Thread(()->{saler.saleTicket();},""+i).start();
}
}
}
会发现ticket减少的顺序是乱的,并且会有重复的问题。
线程安全问题的根源在于多个线程同时争抢同一个内存资源,造成线程工作的结果未能及时通知给其他线程。
2、如何解决线程安全问题?
解决线程安全问题的方法就是要规范线程对资源的操作顺序。主要有两种,一种是针对资源加锁,让线程有序的竞争对资源的操作。另一种是针对线程,让线程进行有序排队。
1、针对资源加锁:
加锁有两种常用的方法,一种是使用Java提供的Synchronized关键字自动加锁。另一种是使用JUC包中的Lock对象,手动加锁。
对于Synchronized关键字:使用方法比较简单,可以在后面加一个作为锁使用的对象。任何的java对象都可以作为锁来使用。持有该对象的线程才能够去执行后面受保护的代码。
synchroniezd(obj){ protected code}
另外Synchronized关键字,可以直接加在方法描述上。如果是static方法,表示对Class加锁,如果是普通方法,表示对该对象加锁。例如对上面示例中的saleTicket()方法前面加上一个synchronized关键字,整个执行结果就会是正确且有序的。
关于锁的本质其实只是在对象的头部markdown部分加一个描述符。根据资源竞争激烈程度分为无锁、偏向锁、轻量级锁、重量级锁。 参见 https://blog.csdn.net/roykingw/article/details/107389045
对于Lock锁:是java.util.concurrent包中提供的一个顶层接口,由此提供了一系列的同步锁工具。最为常用的实现类就是ReentrantLock,可重入锁。
public class SaleTicketDemo1 {
private Lock lock = new ReentrantLock();
public int ticket = 100 ;
public void saleTicket(){
lock.lock();
try{
ticket --;
System.out.println(Thread.currentThread().getName()+"; ticket = "+ ticket);
}finally {
lock.unlock();
}
}
.....
}
关于Lock锁,在JDK中的实现类大致如下:
其中Lock接口提供了基础的上锁lock与unlock操作。他的一个重要子类ReentrantLock是一个可重入锁的实现。可重入的概念从字面上就能看到,表示可以对这个锁对象多次上锁。需要注意的是多次上锁也需要多次解锁。
另外,在ReentrantLock当中,可以通过扩展出多个Condition来扩展锁的使用范围。
关于ReentrantLock,是JUC当中很重要的一个实现类。内部使用AQS框架实现了公平锁与非公平锁。公平锁表示排队的线程不能插队。在构造ReentrantLock时,就可以传入一个boolean类型的参数,默认是false表示是非公平锁。传入true表示是公平锁。在后面AQS部分还会回过头来分析ReentrantLock。
然后还一个ReadWriteLock接口,提供了读写锁分离的实现。提供一个readLock()方法获取读锁,还提供一个writeLock()方法获取写锁。 读写锁可以分开操作。在提供的ReentrantReadWriteLock实现类中,提供了读写锁的具体实现。
主要的实现思想就是,
- 对同一个锁对象,写锁是独占锁,同一时刻只能一个线程写,其他线程需要排队。
- 而读锁是共享锁,同一时刻可以有多个线程同时读。
- 读锁与写锁不能同时存在。
2、让线程合理的排队:
public class SaleTicketDemo2 {
public int ticket = 100;
public void saleTicket(){
if(ticket >0) {
this.ticket--;
System.out.println(Thread.currentThread().getName() + "; ticket = " + ticket);
}
}
public static void main(String[] args) throws InterruptedException {
SaleTicketDemo2 demo = new SaleTicketDemo2();
Semaphore s1 = new Semaphore(1);
Semaphore s2 = new Semaphore(1);
s1.acquire();
new Thread(()->{
while(demo.ticket >0){
try{
s1.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
demo.saleTicket();
s2.release();
}
},"saler1").start();
new Thread(()->{
while(demo.ticket >0){
try{
s2.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
demo.saleTicket();
s1.release();
}
},"saler2").start();
}
}
没有使用锁,让两个线程交替进行。
public class SaleTicketDemo3 {
public volatile int ticket = 20;
public void saleTicket(){
if(ticket >0) {
this.ticket--;
System.out.println(Thread.currentThread().getName() + "; ticket = " + ticket);
}
}
public static int number = 0 ;
public static void main(String[] args) {
SaleTicketDemo3 demo = new SaleTicketDemo3();
for (int i = 0; i < 10; i++) {
int current = i;
new Thread(()->{
while(demo.ticket >0){
if(number == current){
demo.saleTicket();
number++;
if(number==10){
number = 0 ;
}
}
}
},"saler"+i).start();
}
}
}
实现了十个线程依次有序售票的效果。这个示例中要注意对ticket的定义,volatile关键字是很重要的,没有volatile关键字的话,整个程序大概率不能正常结束。volatile关键字是干什么用的?记得吗?别急,后面会再梳理这个关键字。
有人可能会觉得这种让线程排序的方式会将并行转为串行,会影响效率。其实这问题基本上是不存在的。因为对于每一个CPU核心来说,他处理问题是通过时间片轮换的方式进行的,本质上来说只能是串行的。只是CPU执行速度非常快,才产生了并行的效果。所以如果让线程合理的进行排序,对效率影响不会很大。
当然,让线程乖乖的排序并不容易,也正是这样才体现出Doug Lea大师带来的JUC的强大。实际上,JUC中很大一部分的功能就是通过对线程进行合理的排序来解决的。在后面的部分会再做深入的介绍。
四、JDK集合工具并发思路
在JDK提供的集合工具中,针对线程安全其实做了非常多的设计。常用的思路有以下几种。
1、加锁
即便是在JDK源码中,加锁依然是解决线程安全问题做常用的方式。
比如常用的ArrayList,在多线程下是不安全的。例如
public class ArrayListUnsafeDemo {
public static List arrayList = new ArrayList();
public static void main(String[] args) {
Thread []threadArray = new Thread[1000];
//启动多线程,操作同一个ArrayList
for(int i=0;i<threadArray.length;i++){
threadArray[i] = new Thread(()->{
ArrayListUnsafeDemo.arrayList.add(Thread.currentThread().getName());
});
threadArray[i].start();
}
//等待所有线程结束
for(int i=0;i<threadArray.length;i++){
try {
threadArray[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//打印ArrayList结果
for(int i=0;i<arrayList.size();i++){
System.out.println(arrayList.get(i));
}
}
}
在执行这段代码的过程中,会出现三种可能的线程安全问题,1、输出值为null,2、某些线程没有输出值,3、数组越界异常。
然后下面这个示例会抛出java.util.ConcurrentModificationException异常
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 1; i <= 30; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}).start();
}
}
而他抛出异常的地方在这个方法
private void checkForComodification() {
if (ArrayList.this.modCount != this.modCount)
throw new ConcurrentModificationException();
}
其实在每次对数据做操作时都会同步这两个modCount,而之所以会出现不相等的情况就是因为在多线程情况下,会出现同步代码未执行的情况。
而JDK给出的一个线程安全的实现是Vector。Vector中一些主要的修改数据的方式都是加了sychronized关键字,以这种方式来实现线程安全,会将多线程的并行运行改为串行运行,会降低应用的执行效率,所以官方也是不推荐使用Vector。
2、CAS
关于CAS,CAS在底层会通过线程自旋的方式进行资源竞争,但是在Java中,CAS操作只是Unsafe类中的一个同步方法而已。在Java中通常会通过for(;;)这样的操作来保证线程不停止。但是本质上,这只是一个循环而已,跟线程的自旋不是一个概念。另外,CAS操作本身其实并不能保证操作的原子性,Java中CAS操作能保证线程安全很大程度上是因为底层JVM的支持。
例如对于JDK的Map接口,关于线程安全问题,主要对比HashMap、HashTable和ConcurrentHashMap。
对于Map最常用的实现类就是HashMap。HashMap通过一个Node<K,V>[] table数组来维护数据HashEntry,而每一个Node会有一个next属性指向他后面的Node。每次put数据,会将key进行hash计算,获取hashcode后,以取模的方式分配到对应的一个Node后面。而如果发生Hash碰撞,就会将当前Node添加到对应Hash位置的Node的后面,形成一个链表结构。而如果某一个Node后面的链表长度大于8(不包括8,由一个内部参数TREEIFY_THRESHOLD=8定义),会将整个链表结构转换成红黑树结构,以提高查询数量。进行remove操作删除数据时,如果对应的红黑树节点个数小于等于6(包括6,由一个内部参数UNTREEIFY_THRESHOLD=6定义),会将整个红黑树结构再退化成链表结构。 HashMap中有一个参数DEFAULT_LOAD_FACTOR,加载因子,默认是0.75。这表示当Map中的数据容量超过最大容量的75%之后,HashMap就会进行扩容,每次扩容按照2的指数倍进行数量扩容。
但是HashMap是线程不完全的,他的线程不安全主要体现在扩容的过程当中。在多线程情况下,HashMap在容量扩容的过程中会有几率形成环形引用,会导致get获取数据时出现死循环。
JDK并没有从根本上尝试去解决HashMap的线程安全问题,因为要解决线程安全问题,就必然会降低HashMap的运行效率。JDK通过提供另外的Map实现类来解决HahMap的线程安全问题。
HashTable实现了HashMap的线程安全操作,但是他的实现方式是对所有的关键操作加sychronized关键字上锁来保证的。这种方式每次操作都是对当前对象上锁,将多线程的并行操作转换成了串行操作。但是这样一方面会造成执行效率低下,另外也无法完全解决多线程的安全问题。
Map<String,Object> map = new HashTable<>();
if(map.containsKey("aaa")){ // 锁当前对象
map.put("aaa",value); //
}
例如对于上面这种常用的符合操作,map.containsKey和map.put两个操作是两个不同的锁步骤,在多线程情况下,会分开进行两次不同的锁竞争过程,整体上依然会有线程安全问题。
HashTable并不靠谱,所以JDK又提供了另外一个线程安全的实现版本ConcurrentHashMap。
在1.7版本及以前,ConncurrentHashMap通过Segment实现分段加锁。Segment是ReentrantLock的一个子类。在内部会维护一个Segment数组,一个Segment对一部分的HashEntry进行加锁保护。也就是说在ConncurrentHashMap中会持有一个锁数组,每次只针对一部分的数据HashEntry进行加锁,这样相比HashTable就能够提供更细粒度的锁,提高并发性能。
而在1.8版本之后,ConncurrentHashMap去掉了分段锁的支持,改为使用CAS+synchronized的方式实现线程安全。并对Node对象的当前值属性val和下一个Node指针next增加volatile关键字保证多线程可见性。整体并发性能得到进一步提升。例如在ConcurrentHashMap中,put操作添加一个不存在的元素时,会使用cas操作保证只有一个线程能够添加新成功。(这个casTabAt方法就是使用的cas操作。)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
而在后面对已有的值进行更新时,则会使用synchronized关键字来保证线程安全性。
3、CopyOnWrite
CopyOnWrite写时复制也是JDK中解决线程安全问题的一种机制。这种思想就是在发现对数据的写操作反生时,就会为每个调用者创建一个专有副本。每个调用者在写数据时会先将数据在副本上完成修改。在修改完成后再整体同步到原始资源中。而对于所有读数据的调用者,只需要关注同一份数据,而不用考虑其他调用者在修改数据过程中产生的中间状态。在JDK8中提供了CopyOnWriteArrayList 和CopyOnWriteArraySet两种实现。
例如CopyOnWriteArrayList的add方法就会在写出过程中创建副本。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
这里锁的意义是用来保证副本数据写回的过程中不会发生混乱。
使用CopyOnWrite后,他的读数据操作就可以是一个无锁的普通操作。因为他们读的始终是同一份没有中间状态的数据。
public E get(int index) {
return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
五、JDK并发工具
JDK中提供了几个并发工具,能够很好的简化对线程进行各种排队逻辑的编程模型。常用的有三个,CountDownLatch,CyclicBarrier,Semaphore。另外还有一个用得不是很多的SynchronousQueue。
1、CountDownLatch
可以理解为赛跑,大家都在起跑线上等着,进行5,4,3,2,1倒数,倒数到0时,所有线程就一起开跑。这在我们需要模拟高并发操作时非常有用。我们平常的手段,例如多线程、for循环、线程休眠唤醒等,都很难模拟到CPU级别的并发操作。而使用CountDownLatch就非常容易实现。
//使用CountDownLatch模拟高并发
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDown = new CountDownLatch(1);
//5个线程启动时间不同
for (int i = 0; i < 5; i++) {
new Thread(()->{
String threadName = Thread.currentThread().getName();
System.out.println(threadName+" blocking at "+System.currentTimeMillis());
try {
countDown.await(); //阻塞
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadName+" started at "+System.currentTimeMillis());
},"Thread-"+i).start();
TimeUnit.SECONDS.sleep(1);
}
TimeUnit.SECONDS.sleep(5);
//使用countDownLatch让5个线程在同一时间恢复。
countDown.countDown();
}
2、CyclicBarrier
可以理解为拼车。即有一辆车在等待乘客,满员就走,否则所有乘客必须在车上一直等待。例如下面的示例,一个容量为5的barrier,会把前面启动的四个线程都阻塞住,当阻塞的线程数达到容量5之后,才会将所有线程一起继续执行。
public static void main(String[] args) throws InterruptedException {
CyclicBarrier barrier = new CyclicBarrier(5);
for (int i = 0; i < 5; i++) {
new Thread(()->{
String threadName = Thread.currentThread().getName();
System.out.println(threadName+" blocking at "+System.currentTimeMillis());
try {
barrier.await();
} catch (BrokenBarrierException | InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadName+" started at "+System.currentTimeMillis());
},"Thread-"+i).start();
TimeUnit.SECONDS.sleep(1);
}
}
CyclicBarrier在很多场景下跟CountDownLatch是非常相似的,但是CyclicBarrier相比CountDownLatch,多一个reset()方法,可以将阻塞的线程数重置,这也意味着CyclicBarrier是支持复用的,而CountDownLatch只能递减,无法重置,只能一次性使用。
3、Semaphore
可以理解为信号池。即在池中维护一定数量的信号,每个线程需要申请一个或多个信号才能执行,线程执行完后可以将申请到的信号返还给池中。如果池中的信号不够,申请信号的线程就只能阻塞住,等待其他线程返还信号,只到信号量足够。这种方式相当于给各个线程分配了不同的比重。在复杂的多线程场景下,需要信号量少的线程表现出来的优先级更高,申请到信号的可能性更高。而需要信号量多的线程表现出来的优先级则更低,更容易阻塞。例如我们之前实现的线程合理排队的例子就是使用的Semaphore来实现的。
这是最为常用的三种并发工具。另外还有一个SynchronousQueue也经常会作为并发工具来使用。
public static void main(String[] args) {
SynchronousQueue<String> queue1 = new SynchronousQueue<>();
Thread a = new Thread(()->{
while (true){
try {
TimeUnit.SECONDS.sleep(1);
queue1.put("switch");
System.out.println(Thread.currentThread().getName()+" putting switch");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"ThreadA");
Thread b = new Thread(()->{
while (true){
try {
TimeUnit.SECONDS.sleep(2);
queue1.take();
System.out.println(Thread.currentThread().getName()+" taking switch");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"ThreadB");
a.start();
b.start();
}
这个示例实现了两个线程交替执行的效果。也可以看做是多线程版本的生产者消费者模型。
另外Semaphore同样支持公平和非公平两种模式。
4、SynchronousQueue
是BlockingQueue的一个实现子类。他是一个不限容量的阻塞队列,他的特点是每次put数据后,必须先take拿走数据,才能再次put数据。而take同样需要先put,然后才能拿数据。而put和take方法都是阻塞的方法。
另外,SynchronousQueue默认是实现的非公平锁,也可以指定为公平锁。
public static void main(String[] args) {
SynchronousQueue<String> queue1 = new SynchronousQueue<>(true);
Thread a = new Thread(()->{
while (true){
try {
TimeUnit.SECONDS.sleep(1);
queue1.put("switch");
System.out.println(Thread.currentThread().getName()+" putting switch");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"ThreadA");
Thread b = new Thread(()->{
while (true){
try {
TimeUnit.SECONDS.sleep(2);
queue1.take();
System.out.println(Thread.currentThread().getName()+" taking switch");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"ThreadB");
Thread c = new Thread(()->{
while (true){
try {
TimeUnit.SECONDS.sleep(2);
queue1.take();
System.out.println(Thread.currentThread().getName()+" taking switch");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"ThreadC");
Thread d = new Thread(()->{
while (true){
try {
TimeUnit.SECONDS.sleep(2);
queue1.take();
System.out.println(Thread.currentThread().getName()+" taking switch");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"ThreadD");
a.start();
b.start();
c.start();
d.start();
}
这个示例就是将两个线程升级为多个线程后,A队列定时生产一个消息,然后B、C、D队列会依次排队执行。
其他线程同步的示例: 线程依次执行(一个线程执行完了,另一个线程再执行),线程交替执行,线程顺序执行,公平排队模型,非公平排队模型。。。。
六、BlockingQueue阻塞队列模型
之前提到,解决线程安全的问题,很重要的一个思想就是让线程有序的排队。而说到排队,自然而然就会想到队列的结构。JDK提供的BlockingQueue阻塞队列即实现了队列的先进先出结构,同时也可以通过阻塞功能很好的提供线程的阻塞等待模型。BlockQueue的常用实现子类如下:
BlockingQueue作为阻塞队列的一个重要的上层接口,在上层接口Queue的基础队列操作基础上,增加了提供线程阻塞特性的put()和take()操作,以及基于阻塞线程的对象移植方法drainTo。
1、ArrayBlockingQueue和LinkedBlockingQueue
这是最常用的两个BlockingQueue实现类。他们的功能是差不多的,只是底层一个是用数组实现的,一个是用链表实现的,就好比ArrayList和LinkedList
ArrayBlockingQueue是使用一个数组来实现的(final Object[] items;)。然后他的整体阻塞是通过一个ReentrantLock+两个Condition来分别对读写操作进行加锁。
而LinkedBlockingQueue是使用链表结果来实现的(Node对象 next属性指向下一个Node对象)。然后他使用两个ReentrantLock来分别对读写操作进行加锁。
这两个队列工具都是有长度限制的,ArrayBlockingQueue由于是使用的数组,所以需要在构建时必须传入一个容量参数,表示这个数组的长度。而LinkedBlockingQueue是使用链表来实现,随时可以在列表中添加节点,所以他的容量参数不是必须的,默认是Integer.MAX_VALUE。
他们的几组添加删除元素的API提供了不同的错误处理机制。
-
添加元素 add(),移除元素remove(),检查第一个元素element()
这一组API是Queue接口提供的基础队列方法。这一组方法都会返回一个布尔型的操作结果,并且当出队,入队失败时都会抛出错误异常。
-
添加元素 offer(),移除元素poll(),检查第一个元素peek()
这一组API也是Queue接口提供的基础队列方法。offer方法添加元素,返回操作结果,而poll方法会返回队列的第一个元素,并将这个元素从队列中移除。只是在ArrayListBlockingQueue和LinkedListBlokcingQueue的实现中,这一组API当出队、入队失败时不会抛出异常,只返回一个boolean型的操作结果。
另外,这一组API,还支持传入超时时间参数,表示在等待操作结果时,会检查是否超时,如果超时会抛出超时异常。
-
添加元素 put(),移除元素take()
这一组API是BlockingQueue新增一组重要API,提供了线程阻塞的功能。在队列满了无法put或者队列空了无法take时,都会将当前线程阻塞,直到另外有线程操作了队列中的元素为止。
使用这两个工具可以很容易的实现多线程场景下的生产者消费者场景。
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
// BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>(3);
Thread producer = new Thread(()->{
System.out.println("produce a "+blockingQueue.add("a"));
System.out.println("produce b "+blockingQueue.add("b"));
System.out.println("produce c "+blockingQueue.add("c"));
System.out.println("produce d "+blockingQueue.add("d"));//超过容量会抛出异常
},"produce");
Thread consumer = new Thread(()->{
System.out.println(blockingQueue.remove());
System.out.println(blockingQueue.remove());
System.out.println(blockingQueue.remove());
System.out.println(blockingQueue.remove()); //队列为空也会抛出异常。
},"consumer");
producer.start();
TimeUnit.SECONDS.sleep(1);
consumer.start();
}
这两个队列都是线程不安全的,线程安全的实现类是ConcurrentLinkedQueue。
2、PriorityQueue
这是一个带优先级的无界阻塞队列,所有添加的元素都会在底层的一个数组(Object[] queue)中进行排序。而排序的规则就是根据PriorityQueue队列中的一个比较接口(Comparator<? super E> comparator;),默认是null,可以在构造方法中传入。另外,PriorityQueue也可以从SortedSet集合或者另外一个PriorityQueue队列来创建,这样创建时就不需要再单独排序了。
虽然PriorityQueue是有容量的,他的默认容量是11。但是在添加元素时,PriorityQueue会按照需求自动进行扩容。所以PriorityQueue可以认为是一个无界的队列。
在添加元素时,会根据元素的排序结果在底层的数组中按序排列,最后在获取元素时,会按照元素的排序结果依次获取。
3、DelayedQueue
DelayedQueue主要是用来实现延迟任务,但是在开发中用得比较少(延迟任务我们有很多工具来实现,比如直接使用线程池)。其内部的元素都是Delayed接口的实现类。这个Delayed接口是Compartor的子接口,所以也必须定制排序的规则。另外,他提供了一个getDelay()方法,来表示任务的延迟时间。
在DelayedQueue内部,就是通过一个PriorityQueue来保存队列中的元素,所以他也是一个无界的阻塞队列。而他与PriorityQueue最大的不同在于使用poll方法获取数据时,只有当队列的第一个元素的延迟时间达到了getDelay()方法定义的延迟时间时才会返回,否则只会返回null。
这两个东西用得比较少,就不做示例了,用的时候想一想就行。
4、SynchronousQueue
SynchronousQueue在之前已经做了介绍,他是一个无界的阻塞队列,但是每次put()之后必须要先take()才能再次put(),也就是说,在使用时可以将他大致的理解为一个容量为1的ArrayListBlockingQueue或LinkedBlockingQueue。
但是SynchronousQueue其实与容量为1的队列还是有挺大区别的。他在阻塞时实现了公平锁和非公平锁。默认是非公平锁,底层使用一个TransferStack栈结构来保存线程。而配置为公平锁时,会使用一个TransferQueue队列来保存线程。另外,他的阻塞是通过CAS操作来实现的,所以性能是比较高的。
5、BlockingDeque和LinkedBlockingDeque
BlockingDeque是BlockingQueue的一个子接口,同时继承了Deque接口,提供双端队列的实现。只有一个实现类LinkedBlockingDeque。
他的作用是在BlockingQueue的基础上,增加了从队列的尾部加入数据的功能,也就是说这个链表的首尾都可以随时添加元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。但是,本质上他依然是线程不安全的。JUC中提供了一个线程安全的版本ConcurrentLinkedDeque。
七、AQS框架
在很多JUC的源码中,会经常发现这样的一种代码:
private static final class Sync extends AbstractQueuedSynchronizer {
...
}
很多并发工具都使用了这样的框架模式,像CountDownLatch,Semaphore这两个并发工具,另外还有像RenentrantLock等很多锁的实现中都用了这个框架。例如在CountDownLatch中就定义了这样一个Sync对象
private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 4982264981922014374L;
Sync(int count) {
setState(count);
}
int getCount() {
return getState();
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
这个AbstractQueuedSynchronizer就是JUC中提供的一个线程安全框架,简称为AQS。AQS框架整体的逻辑还是非常复杂的,尤其是他还涉及到了非常多的底层代码,要完全搞懂还是比较难的。我们这里只是注重对AQS框架的整体理解。
首先,AQS是一个线程同步器。使用AQS时,需要定义一个内部的非公开的AQS子类作为一个线程同步工具。使用这个内部的工具类来实现线程之间的同步控制。也就是说AQS只是提供了一种线程同步控制的内部工具,不能直接使用,具体的使用还是需要封装成各种同步工具类。而JDK中提供了非常丰富的线程同步工具,也就是说,开发过程中需要使用到AQS框架的场景还是非常少的。
然后,AQS的整体编程模型可以简单理解为一个信号量加一个双端队列。
先来看这个队列,队列相对比较好理解。线程要实现同步控制,必须要进行排队。AQS中会将线程封装成一个Node对象,在这个Node对象中会包含当前线程以及指向前一个Node和后一个Node的引用(另外还有一些关键属性,如等待状态、共享还是独占等),通过这些Node对象,可以形成一个双端的队列,称为CLH锁队列(Craig, Landin, and Hagersten 三个大神的名字)。不要视图太过了解这个队列,CLH队列通常是用在操作系统底层实现线程自旋的。AQS中只是借鉴了这种思想。
<------ prev <-----
head | | Node | | tail
------> next ----->
在AQS中的CLH队列,head节点是一个假节点,这个节点是为了让队列能够正确的开始。prev链接主要是用来处理线程的出队。如果一个Node被取消,通常只需要将他的子节点的prev属性重新链接到当前节点的一个未取消的前任节点。而next链接通常被用来实现线程的阻塞。AQS会通过前置节点的next链接来确定哪个线程需要被唤醒。但是这里,并不是一个简单的索引,因为确定后续节点的同时必须要避免与新入队的节点进行竞争。AQS框架完成了对整个队列的复杂的维护过程,包括公平锁与非公平锁的队列管理。而实现类只需要实现几个简单的方法来确定线程何时入队及出队。
接下来看信号量。在AQS中维护了一个state变量作为同步工具的信号灯。这是个int类型的变量,可以理解为红绿灯,用来确定线程何时乖乖排队,何时可以放行。当然,给线程排队的场景往往比红绿灯复杂了不止一点点。并且不同的实现子类也需要根据不同的场景给state维护不同的含义。例如CountDownLatch中,这个state就是倒数计数的值。而在Semaphore中,state就是剩余的信号量。而在ReentrantLock中,state就是锁重入的次数。
线程的执行过程是非常快,也非常复杂的,所以让线程乖乖排队这事情,看起来很简单,但是实际上是非常非常困难的。
八、线程池
通常对于一些使用频繁,竞争激烈的关键资源,都会采用池化技术来提高这些资源的使用率。而对于线程,也会使用线程池。线程池可以事先维护好一些线程,当有程序需要使用时,可以从线程池中快速申请线程,执行完自己的任务后,线程不用销毁,只需要归还给线程池即可。这样可以提高线程的重复利用率,提高程序的响应速度,减少线程频繁创建和销毁的系统消耗。
为什么要在BlockingQueue之后再来讲线程池,其实就是因为线程池是使用一个BlockingQueue来实现对线程的排队的。
1、四种线程池
JDK中通过Executor可以快速创建四种线程池:
1、Executors.newFixedThreadPool(int nThreads):固定大小的线程池。线程池中会维护固定个数的线程,这也意味着,在同一时刻,最多只能有nThreads个线程并发执行。并且,如果在线程池没有进行shutdown的情况下,有一个线程出现了非法中断的情况,线程池中也会创建一个新的线程代替中断的线程,保持线程个数是固定的。内部使用一个LinkedBlockingQueue来保持Runnable任务队列。
2、Executors.newSingleThreadExecutor():这是一个单线程的线程池,可以保证所有的线程都是串行执行的。内部同样使用了一个LinkedBlockingQueue来保持Runnable任务队列。
3、Executors.newCachedThreadPool():这是一个带缓存功能的无界线程池。这个线程池在需要时可以无限的创建新的线程(最大值是Integer.MAX_VALUE),但是他会尽量使用之前创建的线程。他非常适合用来运行一些包含非常多声明周期短的异步线程的程序,可以显著的提升程序的运行效率。当线程池内线程不够时,他会主动创建新的线程。但是当已有的线程长期没有使用时(60秒),线程就会被回收,并从线程池中移除。他内部就是使用一个SynchronousQueue来保持Runnable任务队列。
4、Executors.newScheduledThreadPool(int corePoolSize):这是一个带延迟功能的线程池。在执行Runnable任务时,可以指定一个延迟时间delay,或者指定一个执行周期,重复执行。另外还可以创建一个单线程版本的延迟线程池,Executors.newSingleThreadScheduledExecutor(); 他内部使用的一个自定义的DelayedWorkQueue队列来保持Runnable任务队列。
常用的就是这四种线程池。另外还有一种线程池Executors.newWorkStealingPool(int parallelism)是用来执行fork/join计算的,用得很少。
这四种线程池的实例类型都是ExecutorService的实现类。使用时,可以通过ExecutorService接口定义的submit方法提交一个runnable任务或者一个callable任务。也可以通过Executor接口定义的execute方法提交一个runnable任务。关于runnable和callable的区别就不再多说了。
2、自行创建线程池
在阿里公布的开发规范中强调不建议使用上述的默认线程池,而要手动创建线程池。比如CachedThreadPool的最大线程数是Integer.MAX_VALUE,当线程非常非常多时,这个地方就会成为隐患。所以手动创建线程池就是为了让开发人员更清楚线程池的运行机制。手动创建线程池主要是要弄明白线程池的七大参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler){
.....
}
-
corePoolSize
表示线程池中会始终保持的最小线程数。这些线程就算长期无所事事也不会回收。
-
maximumPoolSize
表示线程池中允许的最大线程数。
-
keepAliveTime
当线程池中的线程数大于corePoolSize时, 超过keepAliveTime时长没有任务的线程就会被回收。
-
unit
keepAliveTime的单位
-
workQueue
一个用来在任务执行之前,给任务排队的阻塞队列。注意这个队列只会保持通过execute方法提交的runnable任务。
-
threadFactory
线程池创建新线程的工厂类。通过ThreadFactory的newThread方法产生新的线程。最常用的是Executors.defaultThreadFactory()。他会默认产生一个非Daemon的普通线程。
-
handler
当任务执行被阻塞时的处理机制。即ThreadPoolExecutor无法通过execute执行任务时。 有三种情况会触发拒绝策略:1、线程池中的线程已满。2、线程池中的workQueue已达到边界( 使用ArrayBlockingQueue就有可能超界)。3、正在执行shutdown操作。
默认是AbortPolicy表示放弃当前任务,抛出RejectedExecutionException异常。
其他可选策略:DiscardPolicy 表示放弃当前任务,不做任何处理。DiscardOldestPolicy表示尝试将队列中最早添加的元素移除,再尝试添加任务。 CallerRunsPolicy表示直接调用主线程自己的执行器来执行此任务。
九、流式编程
流式编程是JDK8引入的新特性。其实流式编程在scala、kotlin等新型语言中是司空见惯的,而java的流式计算功能则是对这些新型语言的兼收并蓄。传统的计算过程是先有数据,然后将数据进行一次一次的函数计算,最终得到结果。而流式计算则是将各个函数组成一个完整的处理链,一个函数的输出可以作为下一个函数的输入,直接进行后续的计算。然后再将处理链去对接具体的数据源。流式计算很大的一个好处在于他对接的数据源可以是一个有界的数据集,也可以是无界的数据集,所以流式计算能够极大的提高计算的适用性。在流式计算中,很重要的一点就是要将函数像参数一样的一直在处理链中传递。在JDK8的一些流式计算API中,会看到很多函数式的接口对象传递,所以要理解流式编程,首先要理解到底什么是函数。
1、什么是函数式接口?
在scala中,可以将一个纯函数最为参数在方法中传递,而java在JDK8以前,是完全面向对象的,所以不存在纯函数这样的概念,任何方法都必须要归属于一个类。例如对于线程,其实所需要的只是一个描述任务逻辑的纯函数,但是在java中,就必须要定义一个类,实现Runnable接口,然后再在run方法中来描述任务逻辑。而在JDK8中提供了一种简化的lambda表达式来描述:
new Thread(()->{
//business
}).start();
中间 ()->{} 这一部分实际上是用lambda表达式传入了一个Runnable的实现类,只是这个实现类不再需要new来创建,而是以函数式接口的方式来声明,把一个类简化成了一个随用随建的函数,编程过程得到了简化。当我们细看这个代码时,会发现,JDK中通过@FunctionalInterface 注解来声明函数式接口,例如Runnable接口就添加了这个注解。而对于函数式接口,就可以使用lambda表达式的方式快速进行使用,而不需要再定义具体的实现类了。
首先,@FunctionalInterface注解的本质只是一个编译级别的注解,只是用来检测编写的函数式接口是否有错误,也就是说如果Runnable接口没有这个注解,其实也是一样可以用的。只不过加上这个注解后,如果你的函数式接口不符合规范,在IDEA中敲入代码就会报错。这样也就提前对函数式接口进行了规范。
然后,到底什么是函数式接口呢?其实从lambda表达式的使用过程中可以看到,他只能提供一个实现方法,不能指定方法,所以函数式接口的本质是接口只能有一个待实现的方法。这个比较好理解,我们看一下Runnable接口就知道了。但是,我们去看另外一个JDK8的函数式接口java.util.Comparator时,会发现一些不一样的地方。这个接口揭示了函数式接口的秘密。
这个接口的作用就不用多说了,源码自行去翻看,我就不贴了。
在Comparator接口中,不光有一个需要由子类去实现的int compare(T o1, T o2); 还有很多其他的方法。并且奇怪的是,除了这个compare方法,其他的方法有的竟然有实现代码,像这样:
default Comparator<T> reversed() {
return Collections.reverseOrder(this);
}
这个default关键字也是JDK8新引入的一个关键字,通过default,可以在接口中写默认实现方法,而这个实现方法可以由具体的实现类覆盖,当然也可以不覆盖。也就是说,JDK8中的接口加上default后,实际上跟抽象类已经很类似了。这个机制其实也是JDK8中引入函数式编程后所必须增加的一种机制。例如我们之后要说的流式计算stream()方法,如果使用JDK8以前的interface,是完全不能有实现类的。如果要实现流式计算,就需要在所有Collection接口的实现类中添加stream()方法的实现,而实际上这些实现的逻辑都是一样的。而加上这个接口的默认实现机制后,只需要在Collection接口中添加带默认实现的stream()方法即可。
接下来再回到Comparator接口中,除掉所有的default方法,还会发现一个没有实现的方法:
boolean equals(Object obj);
那这个方法要不要实现?其实从源码的注释中可以看到,对于函数式接口,可以直接使用Object类的public方法。另外,我们可以去做另一个测试,自己定义一个函数式接口,也带上一个Object的clone方法可不可以呢?例如这样:
@FunctionalInterface
interface MyInterface{
default void hello(){
System.out.println("hello");
}
int compare(String target);
boolean equals(Object obj);
Object clone();
}
这个时候,在IDEA上,会看到@FunctionalInterface注解上会报错:
这是因为JDK的函数式接口只能引用Object中的public方法,而clone方法是protected的。
所以,对于函数式接口的完整理解是:函数式接口的本质是只有一个非抽象,非Object的public方法的待实现方法的接口。
2、JDK8中提供的函数式接口
JDK8不光扩展了接口的机制,同时也提供了非常多典型的函数式接口。具体可以查看rt.jar当中的java.util.function包,这里面提供了非常多的函数式接口。要怎么去梳理呢?其实这里面可以看到JDK提供的四种基础的函数式接口类型:
1、java.util.function.Function 标准函数
接收参数,并返回结果,主要的方法是 R apply(T t);
2、java.util.function.Comsumer 消费型函数
接收参数,但是无返回结果。主要的方法是 void accept(T t)
3、java.util.function.Predicate 断言型函数
接收参数,返回boolean型结果。主要的方法是boolean test(T t)
4、java.util.function.Supplier 供给型函数
不接收参数,但是返回结果。主要的方法是 T get()
那这些函数式接口怎么用呢?比如我们看Collection中的foreach方法,是这样的:
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
其中需要传入的就是一个消费型的函数。就可以写这样的Demo:
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
list.forEach(new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println(integer);
}
});
}
接下来,就可以使用lambda表达式的方式来简化Consumer的声明。代码就可以写成这样:
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
list.forEach((number)->{
System.out.println(number);
});
}
JDK8中就只有这四种基本的函数式接口,但是也会扩展出一些其他的变种。例如标准的Consumer消费型接口是传入一个参数,不返回结果。那如果传入的是两个参数,就会扩展出BiConsumer这样的一个函数行接口。整个java.util.function包里的各种函数式接口也都是从这四种基础函数接口扩展出来的。
在下面的Stream流式编程中,将会看到大量的这种函数式接口。
3、Stream流式编程
先来看一个简单的例子。采用函数式接口来实现一个简单的示例,将一个集合中的所有数字乘以2
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
list.forEach((number)->{number = 2*number;});
list.forEach(System.out::println);
}
采用流式编程后,可以简化成这样:
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
list.stream().map(number -> 2*number).forEach(System.out::println);
}
这段代码很容易理解,stream方法打开一个流式操作,然后map方法对stream中的每个元素进行计算,然后返回的结果返回给forEach方法进行打印。整个处理链都是通过一些函数式接口来构建,例如map方法接收的是一个Function类型的标准函数,而forEach接收的是一个Consumer类型的消费式函数。当你熟悉了这些函数式接口后,要理解这些流式API就会容易很多。
关于流式计算的各种算子怎么使用,网上的资料非常多,这里就不再去多贴了。我们的重点是理解流式计算的原理。
jdk8的流式处理极大的简化了对于集合的操作,实际上,不光是集合,包括数组、文件等,只要能够转换成流,都可以借助流式计算,进行类似于SQL语句一样的操作。jdk8通过内部迭代来实现对流的处理。一般一个流式处理可以分为三个部分:转换成流、中间算子操作数据、终端操作聚合结果。
要理解这些算子可以参见源码中的java.util.stream.ReferencePipeline,这个是集合流的计算实现类。filter、map、flatmap这些算子可以划分为中间算子,他们有一个共同的特点就是他们的具体实现都是只添加一些以Op结尾的计算方法,也就是说他们这一类算子只是用来构建处理链,而不会真正进行计算。例如map方法,只是插入了一个StatelessOp这么一个操作函数,并没有实际进行计算。
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}
而findAny、findFirst、reduce这些算子可以归为另一类,终端聚合算子。他们的一个共同特点就是在具体实现时都调用了evaluate方法来实际执行算子。例如对于reduce方法:
public final Optional<P_OUT> reduce(BinaryOperator<P_OUT> accumulator) {
return evaluate(ReduceOps.makeRef(accumulator));
}
所以jdk8中的流式计算编程其实套路是固定的,就是先将数据转换成流(主要是stream方法)。然后通过中间算子构建函数处理链,最后通过终端聚合算子将结果进行整合。当然,这其中,终端聚合算子在进行计算的同时,其实也会插入一些函数算子,所以终端聚合算子的后面也可以再接其他的中间算子,但是整个流式计算程序必须以终端聚合算子结束。当把这些算子分好类之后,再要去理解流式计算编程就不难了。
实际上,这种流式计算的实现方式并不是JDK8的特性,在scala等其他新型语言中出现得更早。而流失计算中将中间算子与聚合算子分开计算的实现方式,在大数据领域更能体现出优势。实际上,像Spark这种大数据计算框架,他中间的很多算子跟JDK8中的实现方式基本上也都是一致的。可以说,JDK中的流式计算特性就是对大数据计算中的流式计算特性的复现。
stream流式计算能够带来的好处可不仅仅只是简化代码而已,更重要的是,他可以通过并行计算提升计算效率。我们先来看这样一个简单的示例:
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
Set<Thread> threadSet = new HashSet<>();
list.stream().forEach(number -> {
Thread thread = Thread.currentThread();
threadSet.add(thread);
});
System.out.println("stream 使用的线程数:"+threadSet.size());
Set<Thread> threadSet2 = new HashSet<>();
list.parallelStream().forEach(number -> {
Thread thread = Thread.currentThread();
threadSet2.add(thread);
});
System.out.println("stream 使用的线程数:"+threadSet2.size());
}
我先告诉你这个代码的执行结果:
stream 使用的线程数:1
parallelStream 使用的线程数:4
CPU核心数:8
在这个示例中可以看到,使用stream打开的是一个单线程,所有任务都是串行执行的,而使用parallelStream后,增加了执行任务的线程数,这意味着整个计算过程的并行度得到了增加,计算的效率也肯定得到了提高。
关于parallelStream对性能的提升,相信你也很容易自己设计一些例子来进行验证。
那parallelStream到底是怎么来帮我们提升程序运行性能的呢?其实就是使用的forkJoin框架来达到性能提升的效果。例如对于我们这个示例程序,对parallelStream的foreach方法进行调试,会发现在foreach执行任务时,最终会创建一个ForEachTask,而这个ForEachTask就是ForkJoinTask的一个实现子类。于是最终这条线又连到了ForkJoin中。
示例的调试代码线路: list.parallelStream().forEach -> ReferencePipeline.Head.foreach(Consumer<? super E_OUT> action) -> ReferencePipeline.forEach(Consumer<? super P_OUT> action) -> AbstractPipeline.evaluate() -> ForEachOps.evaluateSequential -> 最终执行的语句: new ForEachTask<>(helper, spliterator, helper.wrapSink(this)).invoke();
现在你们能够知道我这个JUC课程为什么要按照这个顺序来安排了吗?满满都是环环相扣的套路啊。
另外,parallelStream的性能通常情况下会比stream高,但是并不是意味着所有情况下都要优先使用parallelStream。这首先取决于你的任务是否需要并行执行,任务之间是否可以独立。另外更重要的是,forkJoin框架也并不是万能的,他并不适合处理延迟时间较长的复杂业务。这在后面会讨论到。所以,当你的任务比较复杂时,比如调用远程接口,那还是建议乖乖的使用stream单线程计算把。
十、Fork/Join计算
JDK8中的Fork/Join框架给复杂的计算过程提供了一种分而治之的思想,可以把一个复杂的任务拆分成很多简单的任务,充分利用CPU的线程资源来提高执行的效率。例如下面这个Demo完成了1-1000000数字的累加。
public class SumDemo {
public static void main(String[] args) {
//使用ForkJoinPool加快执行
int cores = Runtime.getRuntime().availableProcessors();
ForkJoinPool forkJoinPool = new ForkJoinPool(cores);
Integer sum = forkJoinPool.invoke(new CountRecursiveTask(1, 1000000));
System.out.println(sum);
//不只用ForkJoinPool直接执行
System.out.println(new CountRecursiveTask(1, 1000000).invoke());
}
}
class CountRecursiveTask extends RecursiveTask<Integer> {
//达到子任务直接计算的阈值
private int Th = 15;
private int start;
private int end;
public CountRecursiveTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
if (this.end - this.start < Th) {
//如果小于阈值,直接调用最小任务的计算方法
return count();
} else {
//如果仍大于阈值,则继续拆分为2个子任务,分别调用fork方法。
//这里可以根据情况拆成n个子任务
int middle = (end + start) / 2;
CountRecursiveTask left = new CountRecursiveTask(start, middle);
System.out.println("start:" + start + ";middle:" + middle + ";end:" + end);
left.fork();
CountRecursiveTask right = new CountRecursiveTask(middle + 1, end);
right.fork();
//join方法会计算每一个子任务的值,子任务依然会按照阈值决定要不要拆分。
//这里需要将两个子任务的值累加到一起。
return left.join() + right.join();
}
}
private int count() {
int sum = 0;
for (int i = start; i <= end; i++) {
sum += i;
}
return sum;
}
}
ForkJoin的执行流程比较容易理解,就是按照阈值决定是否拆分任务,最终将一个复杂的任务拆分成很多简单的任务,然后充分利用CPU的线程资源加快所有任务的执行速度。他的执行过程在网上有非常多的资料,大家自行翻阅。
我们这里一起去ForkJoin的底层逛一逛,另外,我们还需要去探究parallelStream执行速度快的秘密。
首先:ForkJoin任务有多种执行方式
ForkJoinTask并不一定需要配合ForkJoinPool使用,在普通线程中也是可以执行的,例如我们的main线程。直接调动invoke方法就可以执行ForkJoinTask任务,ForkJoinTask对普通线程也是兼容的。
但是最好的方式当然是配合ForkJoinPool来使用,在ForkJoinPool中会创建ForkJoinWorkerThread类型的线程来执行任务,他会将ForkJoinTask任务排队后执行,这样效果是最好的。声明ForkJoinPool的方式,除了直接new之外,也可以像其他线程池一样使用Executors来创建。 Executors.newWorkStealingPool()方法就可以创建一个ForkJoinPool线程池。
然后:什么叫WorkStealing?
好好的ForkJoinPool,为什么在Executors里被叫做WorkStealingPool?其实这里就涉及到了ForkJoin框架的底层执行原理。
与其他线程池相比,ForkJoin框架最大的亮点其实是他的工作窃取算法。这个算法的思想是,当有多个线程共同执行任务,有的空闲线程在自己的WorkQueue中的任务全部完成了之后,会尝试去遍历其他线程的WorkQueue,并窃取其他线程的任务拿过来执行,提升程序的整体响应性能。这种算法实现的细节挺复杂的,但是整体思想非常简单。就是在构建WorkQueue时,将他构建成一个双端队列,每个任务都从队列的头部去获取任务执行,而进行工作窃取时,从队列的尾部去尝试进行工作窃取。
至于ForkJoin框架到底好不好,其实也一直是一个尚待讨论的问题。可以看出,工作窃取其实也是有不小开销的,例如对workqueue要进行更复杂的排序,另外工作窃取也有非常多的资源竞争。例如对双端队列进行任务窃取时,如果头部线程和尾部线程抢到一起了,就会带来更多的资源竞争。所以,总体来说,ForkJoin或者说WorkStealingPool更适合用来执行大量的、并行的、延迟小的任务。
例如下面的这个Demo:
public static void main(String[] args) {
final long starttime = System.currentTimeMillis();
int cores = Runtime.getRuntime().availableProcessors();
final ExecutorService executorService = Executors.newWorkStealingPool(cores);
// final ExecutorService executorService = Executors.newFixedThreadPool(cores);
Map<String,Integer> threadCount = new HashMap<>();
CountDownLatch latch = new CountDownLatch(20);
for (int i = 0; i < 20; i++) {
int sleepTiem = i;
executorService.submit(()->{
final String threadName = Thread.currentThread().getName();
if(threadCount.containsKey(threadName)){
threadCount.put(threadName,threadCount.get(threadName)+1);
}else{
threadCount.put(threadName,1);
}
if(sleepTiem % cores != 0){
try {
TimeUnit.SECONDS.sleep(1 );
} catch (InterruptedException e) {
e.printStackTrace();
}
}
latch.countDown();
});
}
try {
latch.await();
System.out.println("Time costs > "+(System.currentTimeMillis()-starttime));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("pool >> "+executorService);
threadCount.forEach((threadName,count)->{
System.out.println(threadName+" count > "+count);
});
System.exit(0);
}
构建了一组耗时不平衡的线程任务,但是比较WorkStealingPool和FixedThreadPool,会发现其实性能并没有太大的差距。
最后,其实整个流式计算包括ForkJoin框架,其实都是JAVA吸收其他语言或者产品的思想形成的产物。像流式计算,在大数据领域就是标准的计算过程,而ForkJoin框架和Hadoop的MapReduce计算思想是非常相似的。