使我短命的线程&线程池

1 程序&进程

 推荐:进程与线程的一个简单解释

1)程序是静态的,进程是动态的,把程序运行起来就会生成对应的进程,所以进程是程序的一次执行过程。类似于菜谱是程序,炒菜的过程是进程。

 

在现代操作系统中,能同时运行多个程序。可以一边打开浏览器看小片片,一边打开微信瞎聊,一边用idea写程序......启动一个浏览器,就相当于在运行一个进程,启动微信和 idea 同理。

2)每个进程都有独立的地址空间

 3)进程有生命周期,有诞生有消亡,是短暂的,而程序是相对长久的。

2 线程

线程是进程中的一个运行实体,是CPU调度的基本单位。

堆内存方法区内存共享,栈内存独立。

在 JAVA 中,java.lang.Thread 类充当虚拟的 CPU,负责执行代码,处理数据,执行 run() 方法就启动了线程。

2.1 线程与进程的区别

进程是资源分配的基本单位,每个进程都有独立的地址空间,进程切换的开销大;

线程是处理器调度的基本单位,线程共享所在进程的地址空间和其他资源,每个线程有独立的内存空间

2.2 为什么要在进程中派生出线程

(1)应用的需要

一个多线程的 Web 服务器可以一边接收客户端的请求,一边执行程序,一边响应客户端,提高了效率

(2)开销的考虑

(3)性能的需要

一个进程同时有多个任务在执行,提高了软件的运行性能。

2.3 线程的创建

2.3.1 继承Thread类

public class Thread implements Runnable {.......}

public class ExtendsThread extends Thread{

    @Override
    public void run() {
        // 定义要执行的业务逻辑
        ...
    }
}

public class ExtendsThreadTest {
	public static void main(String[] args) {
        // 创建线程
		ExtendsThread thread = new ExtendsThread();
	    //调用start()线程进入就绪状态,
        //然后线程调度机制自动调用run()使线程进入运行状态
		thread.start();
	}
}

2.3.2 实现Runnable接口(推荐)

public interface Runnable {
    public abstract void run();
}

public class RunnableThread implements Runnable{
    @Override
    public void run() {
        System.out.println('用实现Runnable接口实现线程');
    }
}

public class RunnableThreadTest {
	public static void main(String[] args) {

		//RunnableThreadTest实现了Runnable接口
		RunnableThread thread = new RunnableThread();
        new Thread(thread).start(); 
	}
}

2.3.3 实现有返回值的 Callable 

class CallableTask implements Callable<Integer> {

    @Override
    public Integer call() throws Exception {
        return new Random().nextInt();
    }
}

//创建线程池
ExecutorService service = Executors.newFixedThreadPool(10);
//提交任务,并用Future接收返回结果
Future<Integer> future = service.submit(new CallableTask());

Callable 和 Runnable 一样,都是一个任务,是需要被执行的,而不是说它们本身就是线程。通过 submit() 方法把任务放到线程池中,并由线程池创建线程,不管用什么方法,最终都是靠线程来执行的,而子线程的创建方式仍脱离不了最开始讲的两种基本方式,也就是实现 Runnable 接口和继承 Thread 类。

2.3.4 总结

事实上创建线程只有一种方式,就是构造一个 Thread 类,这是创建线程的唯一方式。

(1)无论是继承 Thread 还是实现 Runnable 接口,启动线程都是调用 start()

(2)使用继承 Thread 类方式,每次执行一次任务,都需要新建一个独立的线程,执行完任务后线程走到生命周期的尽头被销毁;使用实现 Runnable 接口的方式,就可以把任务直接传入线程池,使用一些固定的线程来完成任务,不需要每次新建销毁线程,大大降低了性能开销;

(3)Java 语言不支持双继承,一旦继承了 Thread 类,那么后续就没有办法再继承其他的类,不利于拓展。

2.4 如何停止线程

启动一个线程:调 Thread 的 start() 使线程进入就绪状态,然后线程调度机制自动调用 run() 使线程进入运行状态,并在 run() 方法中的定义需要执行的业务逻辑。

启动一个线程非常简单,但如果想要正确停止它就没那么容易了。

通常情况下,我们不会手动停止一个线程,而是允许线程运行到结束,让它自然停止。但是依然会有许多特殊的情况导致线程提前停止,比如:用户突然关闭程序,或程序运行出错重启等。

为什么不强制停止?而是通知、协作

stop()、suspend() 和 resume(),它们由于有很大的安全风险比如死锁风险而被舍弃。

对于 Java 而言,最正确的停止线程的方式是使用 interrupt。但 interrupt 仅仅起到通知被停止线程的作用。而对于被停止的线程而言,它拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。

为什么 Java 不提供强制停止线程的能力呢?

事实上,Java 希望程序间能够相互通知、相互协作地管理线程,因为如果不了解对方正在做的工作,贸然强制停止线程就可能会造成一些安全的问题,为了避免造成问题就需要给对方一定的时间来整理收尾工作。

如何用 interrupt 停止线程 

public class StopThread implements Runnable {

    @Override
    public void run() {
        int count = 0;
        // 判断线程是否被中断,检查是否还有工作要处理
        while (!Thread.currentThread().isInterrupted() && more work to do) {
            // 线程停止前的业务逻辑
            ...
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new StopThread());
        thread.start();
        Thread.sleep(5);// 休眠 5 秒
        thread.interrupt();// 中断线程
    }
}

2.5 线程的状态

2.5.1 5 种基本状态

2.5.2 6 种状态

 2.5 线程间的通信

2.5.1 wait,notify,notifyAll

 wait 和 notify 让线程间可以更好地沟通,它们都是 Object 类里的方法 

public class Object {
 
    // notify() 方法不能唤醒某个具体的线程,所以只有一个线程在等待的时候它才有用武之地
    public final native void notify();
    // notifyAll() 唤醒所有线程并允许他们争夺锁确保了至少有一个线程能继续运行
    public final native void notifyAll();
 
    public final void wait() throws InterruptedException {
        wait(0L);
    }
    ...
}

利用 wait 和 notify 实现售票功能

public class Tickets {
	int size;         //总票数
	int number = 0;   //存票序号
	
	boolean available = false;    //表示目前是否有票可售
 
	public Tickets(int size) {
		this.size = size;
	}
	
	//同步方法,实现存票的功能
	public synchronized void put() {
		if(available)//available=true表示还有票可售,就先不生产
			wait();//暂停执行,进入等待池,释放锁
		available = true;
		notify();//唤醒售票线程开始售票
	}
	
	//同步方法,实现售票的功能
	public synchronized void sell() {
		if(!available)//available=false表示无票可售
            wait();
        available = false;
		notify();//唤醒存票线程开始存票
		//number>size表示售票结束
		if(number==size)
			number=size+1;
	}
}

为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?

1 Java 中每个对象都有一把称之为 monitor 监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。
2 如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。

wait/notify 和 sleep 方法的区别?

(1)相同点

  • 它们都可以让线程阻塞;
  • 它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常;

(2)不同点

  • wait 方法必须在 synchronized 保护的代码中使用,否则会抛异常,而 sleep 方法并没有这个要求。当一个线程需要调用对象的 wait()方法的时候,这个线程必须拥有该对象的锁;
  • 在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁;
  • sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复;
  • wait 是 Object 类的方法,而 sleep 是 Thread 类的方法;

怎么检测一个线程是否持有 monitor 锁?

调用 java.lang.Thread 中的 holdsLock()

2.6 线程安全

2.6.1 什么是线程安全

在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或读写不能并行的问题,也不需要考虑任何额外的同步问题,比如不需要额外加 synchronized 锁。

2.6.2 哪些场景需额外注意线程安全

1)访问共享变量或资源

典型的场景有访问共享对象的属性,访问 static 静态变量,访问共享的缓存等。

public class ThreadNotSafe1 {
    static int i;

    public static void main(String[] args) throws InterruptedException {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                for (int j = 0; j < 10000; j++) {
                    i++;
                }
            }
        };
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        // 两个线程同时对i进行i++操作,最后的输出可能是15875等小于20000的数,而不是20000
        System.out.println(i);
    }
}

2)依赖时序的操作

// 先检查后执行(两步操作有可能被打断,非原子操作)
if (x == 1) {
    // 多个线程同时进来
    x = 7 * x;
}

3)不同数据之间存在绑定关系

有时候不同数据之间是成组出现的,如 IP 和端口号。更换了 IP 往往需要同时更换端口号,如果没有把这两个操作绑定在一起,就有可能出现单独更换了 IP 或端口号的情况,而此时信息如果已经对外发布,信息获取方就有可能获取一个错误的 IP 与端口绑定情况,这时就发生了线程安全问题。

4)非线程安全工具类用于并发场景

如 ArrayList 本身并不是线程安全的,如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在 ArrayList,因为它本身并不是并发安全的,而错误的使用它才导致了线程安全问题。如果要把 ArrayList 用在了多线程的场景,需要在外部手动用 synchronized 等方式保证并发安全。

2.6.3 为什么多线程会带来性能问题

单线程程序是独立工作的,不需要与其他线程进行交互,但多线程之间则需要调度以及合作,调度与合作就会带来性能开销从而产生性能问题。

1)上下文切换      

在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。

2)缓存失效

由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。

那么什么情况会导致密集的上下文切换呢?如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生。

3)协作开销       

线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。

3 线程池

老大让我把一个单线程的日播放量统计任务变成一个多线程的任务,时间由 2小时 优化到 30 分钟以内,然后有了我与线程池死去活来的日子。

学习自:Java 并发编程 78 讲

3.1 线程池的由来

在 Java 诞生之初是没有线程池的概念的,而是先有线程,随着线程数的不断增加,人们发现需要一个专门的类来管理它们,于是才诞生了线程池。没有线程池的时候,每发布一个任务就需要创建一个新的线程,这样在任务少时是没有问题的。

/** 
* 描述:单个任务的时候,新建线程来执行 
*/ 
public class OneTask { 
 
    public static void main(String[] args) { 
        Thread thread0 = new Thread(new Task());
        // 线程进入就绪状态
        thread0.start();
    } 
 
    static class Task implements Runnable { 
        // 调用了run方法才表示被cpu真正调度了
        public void run() { 
           System.out.println("Thread Name: " + Thread.currentThread().getName());
        } 
    } 
}

执行结果:

Thread Name: Thread-0

如图,主线程调用 start() 方法,启动了一个 t0 的子线程。这是在一个任务的场景下,随着我们的任务增多,比如现在有 10 个任务了,那么我们就可以使用 for 循环新建 10 个子线程

/** 
* 描述:for循环新建10个线程 
*/ 
public class TenTask { 
 
    public static void main(String[] args) { 
        for (int i = 0; i < 10; i++) { 
            Thread thread = new Thread(new Task());
            thread.start();
        } 
    } 
 
    static class Task implements Runnable { 
 
        public void run() { 
            System.out.println("Thread Name: " + Thread.currentThread().getName());
        } 
    } 
}

执行结果:

Thread Name: Thread-9
Thread Name: Thread-5
Thread Name: Thread-4
Thread Name: Thread-7
Thread Name: Thread-0
Thread Name: Thread-1
Thread Name: Thread-3
Thread Name: Thread-8
Thread Name: Thread-2
Thread Name: Thread-6

打印出来的顺序是为毛是错乱的?比如 Thread-4 打印在了 Thread-3 之前,这是因为,虽然  Thread-3 比  Thread-4 先执行 start 方法,但是这并不代表  Thread-3 就会先运行,运行的顺序取决于线程调度器,有很大的随机性。

主线程通过 for 循环创建了 t0~t9 这 10 个子线程,它们都可以正常的执行任务。如果此时我们的任务量突然飙升到 10000,我们就需要创建 10000 个子线程,创建线程时会产生系统开销,并且每个线程还会占用一定的内存等资源,会造成系统开销大且浪费资源。因此创建线程的数量需要有一个上限,线程执行完需要被回收,大量的线程又会给垃圾回收带来压力。

总结:如果每个任务都创建一个线程会带来哪些问题?

  • 反复创建线程系统开销比较大,每个线程创建和销毁都需要时间,如果任务比较简单,那么就有可能导致创建和销毁线程消耗的资源比线程执行任务本身消耗的资源还要大。
  • 过多的线程会占用过多的内存等资源,还会带来过多的上下文切换,同时还会导致系统不稳定

但我们的任务确实非常多,如果都在主线程串行执行,那效率也太低了,那应该怎么办呢?

  • 用一些固定的线程一直保持工作状态并反复执行任务
  • 根据需要创建线程,控制线程的总数量,避免占用过多内存资源。

有个东西专门负责这些活就好了,于是乎。

3.2 线程池来了,它来了

线程池好比于一个项目的负责人,根据项目的任务量,时刻把控项目参与人员的数量。当我们选择线程数固定数量的线程池,假设线程池有 5 个线程,但此时的任务大于 5 个,线程池会让余下的任务进行排队,而不是无限制的扩张线程数量,保障资源不会被过度消耗。如代码所示,我们往 5 个线程的线程池中放入 10000 个任务并打印当前线程名字。

package com.wyd.thread;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/** 
 * 描述:用固定线程数的线程池执行10000个任务 
 */
public class ThreadPoolDemo {

    public static void main(String[] args) {
        // 创建线程数为 5 的线程池
        ExecutorService pool = Executors.newFixedThreadPool(5);
        // 创建 10000 个任务交给线程池,线程池分配线程去执行
        for(int i=0; i<10000; i++){
            Task task = new Task();
            task.setNum(i);
            pool.execute(task);
        }
    }

    static class Task implements Runnable{
        private int num;

        @Override
        public void run() {
            System.out.println("ThreadName:" + Thread.currentThread().getName() + " is dealing with task " + num);
        }

        public int getNum() {
            return num;
        }

        public void setNum(int num) {
            this.num = num;
        }
    }
}

执行结果:

......

ThreadName:pool-1-thread-5 is dealing with task 9986
ThreadName:pool-1-thread-1 is dealing with task 9981
ThreadName:pool-1-thread-2 is dealing with task 9980
ThreadName:pool-1-thread-3 is dealing with task 9999
ThreadName:pool-1-thread-4 is dealing with task 9998

执行流程如图所示,首先创建了一个线程池,线程池中有 5 个线程,然后线程池将 10000 个任务分配给这 5 个线程,这 5 个线程反复领取任务并执行,直到所有任务执行完毕,这就是线程池的思想。

使用线程池比手动创建线程的好处:

  • 线程池中的线程是可以复用的,用少量的线程去执行大量的任务,大大减少了线程生命周期的开销;
  • 线程通常不是等接到任务后再临时创建,而是已经创建好时刻待命,消除了线程创建所带来的延迟,提升了响应速度,增强了用户体验;
  • 线程池会根据配置和任务数量灵活地控制线程数量,不够的时候就创建,太多的时候就回收,避免线程过多导致内存溢出,或线程太少导致 CPU 资源浪费;
  • 线程线程池可以统一管理任务队列和线程,统一开始或结束任务,比单个线程逐一处理任务要更方便、更易于管理。

线程池的内部结构

  • 线程池管理器,主要负责线程池的创建、销毁、添加任务等管理操作,它是整个线程池的管家。
  • 工作线程,也就是图中的线程 t0~t9,这些线程勤勤恳恳地从任务队列中获取任务并执行。
  • 任务队列,作为一种缓冲机制,线程池会把当下没有处理的任务放入任务队列中。
  • 任务,任务要求实现统一的接口,以便工作线程可以处理和执行。

3.3 线程池的参数们

3.3.1 线程数 corePoolSize 与 maxPoolSize

  • corePoolSize 是核心线程数,也就是常驻线程池的线程数量,即便未来没有可执行的任务也不会被销毁;
  • maxPoolSize,表示线程池最大线程数量,当我们的任务特别多而 corePoolSize 核心线程数无法满足需求的时候,就会向线程池中增加线程,以便应对任务突增的情况。

线程池初始化时线程数默认为 0,当有新的任务提交后,会创建新线程执行任务。如果不做特殊设置,此后线程数通常不会再小于 corePoolSize ,因为它们是核心线程,即便未来没有可执行的任务也不会被销毁。随着任务量的增加,在任务队列满了之后,线程池会进一步创建新线程,最多可以达到 maxPoolSize 来应对任务多的场景,如果未来线程有空闲,大于 corePoolSize 的线程会被合理回收。

线程创建的时机

当提交任务后,线程池首先会检查当前线程数,如果此时线程数 < 核心线程数,则新建核心线程并执行任务,随着任务的不断增加,线程数会逐渐增加并达到核心线程数,此时如果仍有任务被不断提交,就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务。

如果任务特别多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maxPoolSize 最大线程数。线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maxPoolSize 最大线程数。如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会拒绝这些任务。

总结

  • 线程池只有在任务队列填满时才创建多于 corePoolSize 的线程,如果使用的是无界队列(例如 LinkedBlockingQueue),那么由于队列不会满,所以线程数不会超过 corePoolSize。
  • 通过设置 corePoolSize 和 maxPoolSize 为相同的值,就可以创建固定大小的线程池。
  • 通过设置 maxPoolSize 为很高的值,例如 Integer.MAX_VALUE,就可以允许线程池创建任意多的线程。

3.3.2 keepAliveTime+时间单位

当线程池中线程数量 > 核心线程数,而此时又没有任务可做,线程池就会检测非核心线程的 keepAliveTime,如果超过规定的时间,无所事事的非核心线程就会被销毁,以便减少内存的占用和资源消耗。可以用 setKeepAliveTime 方法动态改变 keepAliveTime 的参数值。

Java 核心线程的回收由 allowCoreThreadTimeOut 参数控制,默认为 false,若开启为 true,则此时线程池中不论核心线程还是非核心线程,只要其空闲时间达到 keepAliveTime 都会被回收。但如果这样就违背了线程池的初衷(减少线程创建和开销),所以默认该参数为false。

3.3.3 线程工厂 ThreadFactory 

ThreadFactory 实际上是一个线程工厂,它的作用是生产线程以便执行任务。我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,我们也可以选择自己定制线程工厂,以方便给线程自定义命名,不同线程池内的线程通常会根据具体业务来定制不同的线程名。

3.4 任务队列 workQueue

3.4.1 阻塞队列 BlockingQueue

BlockingQueue 是线程安全的,在很多场景下都可以利用线程安全的队列来优雅地解决业务自身的线程安全问题。比如说,使用生产者/消费者模式的时候,生产者只需要往队列里添加元素,而消费者只需要从队列里取出即可。

实现阻塞最重要的两个方法是 take 方法和 put 方法。

 

 3.4.2 为什么采用阻塞队列来存放任务

任务队列作为一种缓冲机制,线程池会把当下没有处理的任务放入任务队列中,由于多线程同时从任务队列中获取任务是并发场景,此时就需要任务队列满足线程安全的要求,所以线程池中任务队列采用 BlockingQueue 来保障线程安全。 

3.4.1 LinkedBlockingQueue(任务无极限)

LinkedBlockingQueue 的容量为 Integer.MAX_VALUE,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,此时的最大线程数成了个摆设。

3.4.2 SynchronousQueue(线程近乎无极限)

线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的,所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那最大线程数要尽可能设置大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。

3.4.3 DelayedWorkQueue 

线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 的最大特点是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构(堆本质上是一棵用数组表示的完全二叉树)。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。

3.4.4 ArrayBlockingQueue

ArrayBlockingQueue 是一个有界缓存等待队列,可以指定缓存队列的大小,当线程数量大于corePoolSize时,多余的任务会缓存在 ArrayBlockingQueue 队列中等待有空闲的线程时继续执行;当 ArrayBlockingQueue 满时,则又会开启新的线程去执行,直到线程数量达到maximumPoolSize。

ArrayBlockingQueue 利用 ReentrantLock 和两个 Condition 来实现并发安全,真正执行在读写操作前,都需要先获取到锁。

final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;

public void put(E e) throws InterruptedException {
    // 检查插入的元素是不是 null
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    // 上锁
    lock.lockInterruptibly();
    try {
        // 判断队列是否满了,满了就进入等待
        while (count == items.length)
        notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

3.5 线程池的拒绝策略

3.5.1 拒绝时机

线程池在以下两种情况下会拒绝新提交的任务:

  1. 调用 shutdown 等方法关闭线程池后,即便此时可能线程池内部依然有没执行完的任务正在执行。
  2. 线程池没有能力继续处理新提交的任务,也就是工作已经非常饱和了。

主要讲下第二种情况

当一个 线程池的 corePoolSize 、workQueue 、maxPoolSize 都达到了上限,就会触发拒绝策略。

3.5.2 如何正确地选择拒绝策略

Java 在 ThreadPoolExecutor 类中为我们提供了 4 种默认的拒绝策略来应对不同的场景,都实现了 RejectedExecutionHandler 接口:

1) AbortPolicy

这种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。

2) DiscardPolicy

这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。

3) DiscardOldestPolicy

如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。

4) CallerRunsPolicy

当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。

优点

  • 新提交的任务不会被丢弃,这样也就不会造成业务损失。
  • 由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。

缺点

当流量比较大时,由于线程池设置较小,工作队列数不够大,任务没有设置超时时间或代码bug等原因,导致处理线程一直阻塞,导致线程池被打满出发拒绝策略,主线程既要处理其他任务,又要忙碌处理线程池的源源不断的大量任务,导致阻塞加剧,进而导致线上故障。

但是最大线程数和任务队列长度过大,也会堆积大量的请求和线程,从而导致 OOM。

解决方案:

将饱和策略调整为默认的 Abort 或 discard,类似于一种降级手段,不影响主流程即可。

3.6 常见的线程池

无论创建何种类型的线程池,均会调用 ThreadPoolExecutor 的构造函数,只是每种线程池的参数值不同。

3.6.1 FixedThreadPool

固定线程数的线程池,核心线程数和最大线程数一样。

newFixedThreadPool 可能会出现 OOM 问题,因为底层默认使用的 LinkedBlockingQueue,LinkedBlockingQueue 是一个 Integer.MAX_VALUE 长度的队列,可以认为是无界的。

虽然使用 newFixedThreadPool 可以把工作线程控制在固定的数量上,但任务队列是无界的。如果任务较多并且执行较慢的话,队列可能会快速积压,撑爆内存导致 OOM。 

3.6.2 CachedThreadPool

可缓存线程池,这种线程池的最大线程数是 Integer.MAX_VALUE,可以认为是没有上限的,而其工作队列 SynchronousQueue 是一个没有存储空间的阻塞队列。这意味着,只要有请求到来,就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的。

由于我们的任务需要 1 小时才能执行完成,大量的任务进来后会创建大量的线程。我们知道线程是需要分配一定的内存空间作为线程栈的,比如 1MB,因此无限制创建线程必然会导致 OOM。

3.6.3 ScheduledThreadPool

支持定时或周期性执行任务

3.6.4 SingleThreadExecutor

只有一个线程数

3.6.5 SingleThreadScheduledExecutor

只有一个线程的,支持定时或周期性执行任务

3.6.6 ForkJoinPool

适合执行可以产生子任务的任务

3.7 合理设置核心线程数

3.7.1 CPU 密集型任务(N+1)

CPU 密集型任务简单理解就是利用 CPU 计算能力的任务,例如:在内存中对大量数据进行排序。这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N (CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断, 或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而 在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

3.7.2 I/O 密集型任务(2N)

单凡涉及到网络读取,文件读取这类都是 IO 密集型任务。例如:数据库交互,文件上传下 载,网络传输等。这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

public ExecutorService financeExecutorService() {
        int processors = Runtime.getRuntime().availableProcessors();
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                // 核心线程池大小,表示线程池常驻线程数量
                processors * 2,
                // 最大线程数,表示线程池最多创建的线程数量
                processors * 20,
                // 保活时间,表示一个非核心线程多久没有使用,会被回收
                5,
                TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(processors * 50),
                threadFactory
        );
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy() {
        });
        return TsrExecutorsUtil.getTtlExecutor(executor);
}

3.8 线程的实战例子

1)根据粉丝 id 切分,分批处理粉丝数据

 // 6000/60=100,每组 60 个,100 个任务
List<List<WXPAppUserOpenId>> userOpenIdPartitions = Lists.partition(wxpAppUserOpenIds,60);
List<Future<?>> futureList = Lists.newArrayList();
for (List<WXPAppUserOpenId> userOpenIdPartition : userOpenIdPartitions) {
       Future<?> submit = storeBindWxAppExecutor.submit(() -> {
            // 分批处理粉丝数据
            handleOuterUserBatch(appId,rootSaasId,userOpenIdPartition);});
            futureList.add(submit);
}
for (Future<?> future : futureList) {
       future.get();
}

2)利用线程异步发送消息

public void updateSubOrderFulFillStatusBySourceStatus() {
        //异步发消息
        sendFulFillUpdateMsgThreadPool.execute(() -> 
                proxy.sendFulFillUpdateMsg(orderNo, fulfillComponent, status);      
       );
}

4 Future 掌控未来

4.1 Callable 和 Runable 的不同

Runable 不能返回一个返回值,不能抛出 checked exception

public class RunThrowException {
   /**
    * 普通方法内可以 throw 异常,并在方法签名上声明 throws
    */
   public void normalMethod() throws Exception {
       throw new IOException();
   }

   Runnable runnable = new Runnable() {
       /**
        *  run方法上无法声明 throws 异常,且run方法内无法 throw 出 checked Exception,
           除非使用try catch进行处理
        */
       @Override
       public void run() {
           try {
               throw new IOException();
           } catch (IOException e) {
               e.printStackTrace();
           }
       }
   }
}

Callable 是一个类似于 Runnable 的接口,实现 Callable 接口的类和实现 Runnable 接口的类都是可以被线程执行的任务。

public interface Callable<V> {
     V call() throws Exception;
}

4.2 Future 的主要功能

Future 用来接收 Callable 的 call 方法的返回结果

public interface Future<V> {

    // 取消任务的执行
    boolean cancel(boolean mayInterruptIfRunning);

    // 判断是否被取消
    boolean isCancelled();

    // 判断当前这个任务是否执行完毕了
    boolean isDone();

    // 若任务执行过程中抛出异常,去调用 get 的时候,就会抛出 ExecutionException
    V get() throws InterruptedException, ExecutionException;

    // 但是如果到达了指定时间依然没有完成任务,就会抛出 TimeoutException,代表超时了
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutExceptio

}


// 用线程池的 submit 方法会返回一个 future 对象
ExecutorService service = Executors.newFixedThreadPool(20);
Future<Integer> future = service.submit(new CallableTask());

// 或用 FutureTask 来创建 Future
Task task = new Task();
FutureTask<Integer> integerFutureTask = new FutureTask<>(task);
new Thread(integerFutureTask).start();

4.3 CompletableFuture

需求:搭建一个旅游平台,用户想同时获取多家航空公司的航班信息。比如,从北京到上海的机票钱是多少?有很多家航空公司都有这样的航班信息,要把所有航空公司的航班、票价等信息都获取到,然后再聚合。由于每个航空公司都有自己的服务器,所以需要分别去请求它们的服务器。

规定一个超时时间,比如 3 秒钟,那么到 3 秒钟的时候如果都已经返回了那当然最好,把它们收集起来即可;但是如果还有些网站没能及时返回,就把这些请求给忽略掉,这样一来用户体验就比较好了,它最多只需要等固定的 3 秒钟就能拿到信息,虽然拿到的可能不是最全的,但是总比一直等更好。

public class CompletableFutureDemo {
    private Set<Integer> getPrices() {
        Set<Integer> prices = Collections.synchronizedSet(new HashSet<Integer>());
        // runAsync() 异步去执行任务
        CompletableFuture<Void> task1 = CompletableFuture.runAsync(new Task());
        CompletableFuture<Void> task2 = CompletableFuture.runAsync(new Task());
        CompletableFuture<Void> task3 = CompletableFuture.runAsync(new Task());
        CompletableFuture<Void> allTasks = CompletableFuture.allOf(task1, task2, task3);

        try {
            allTasks.get(3, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
        } catch (ExecutionException e) {
        } catch (TimeoutException e) {
          // 但是如果有某一个任务没能来得及在 3 秒钟之内返回,
          // 那么这个带超时参数的 get 方法便会抛出 TimeoutException 异常
        }
        return prices;
    }
}

5 ThreadLocal

6 协程

协程不是进程,也不是线程,它就是一个可以在某个地方挂起的特殊函数,并且可以重新在挂起处继续运行。一个线程可以调用多个协程,每个协程串行运行,不能利用多核。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值