一、多线程的弊端
多线程的软件设计方法确实可以最大限度地发挥现代多核处理器的计算能力,提高生产系统的吞吐量和性能。但是,若不加控制和管理,随意使用线程,对系统的性能反而会产生不利的影响。
一种最为简单的线程创建和回收的方法类似如下代码:
以上代码创建了一个线程,并在run()
方法结束后自动回收该线程。在简单的应用系统中,这段代码并没有太多问题。但是在真实的生产环境中,系统由于真实环境的需要,可能会开启很多线程来支撑其应用。而当线程数量过大时,反而会耗尽CPU和内存资源。
首先,虽然与进程相比,线程是一种轻量级的工具,但其创建和关闭依然需要花费时间,如果为每一个小的任务都创建一个线程,则很有可能出现创建和销毁线程所占用的时间大于该线程真实工作所消耗的时间的情况,反而会得不偿失。
其次,线程本身也是要占用内存空间的,大量的线程会抢占宝贵的内存资源,如果处理不当,可能会导致Out of Memory
异常。即便没有,大量的线程回收也会给GC带来很大的压力,延长GC的停顿时间。
因此,对线程的使用必须掌握一个度,在有限的范围内增加线程的数量可以明显提高系统的吞吐量,一旦超出了这个范围,大量的线程只会拖垮应用系统。因此,在生产环境中使用线程必须对其加以控制和管理。
注意:在实际生产环境中,线程的数量必须得到控制。盲目创建大量线程对系统性能是有伤害的
二、什么是线程池
为了避免系统频繁地创建和销毁线程,我们可以让创建的线程复用。如果大家进行过数据库开发,那么对数据库连接池应该不会陌生。为了避免每次数据库查询都重新建立和销毁数据库连接,我们可以使用数据库连接池维护一些数据库连接,让它们长期保持在一个激活状态。当系统需要使用数据库时,并不是创建一个新的连接,而是从连接池中获得一个可用的连接即可。反之,当需要关闭连接时,并不真的把连接关闭,而是将这个连接“还”给连接池即可。这种方式可以节约不少创建和销毁对象的时间
线程池也是类似的概念。在线程池中,总有那么几个活跃线程。当你需要使用线程时,可以从池子中随便拿一个空闲线程,当完成工作时,并不急着关闭线程,而是将这个线程退回到线程池中,方便其他人使用。
简而言之,在使用线程池后,创建线程变成了从线程池获得空闲线程,关闭线程变成了向线程池归还线程,如图3.6所示。
二、不要重复发明轮子:JDK 对线程池的支持
为了能够更好地控制多线程,JDK提供了一套Executor框架,帮助开发人员有效地进行线程控制,其本质就是一个线程池,它的核心成员如图3.7所示。
以上成员均在java.util.concurrent
包中,是JDK并发包的核心类。其中,ThreadPoolExecutor
表示一个线程池。Executors
类则扮演着线程池工厂的角色,通过Executors
可以取得一个拥有特定功能的线程池。
从UML图中亦可知,ThreadPoolExecutor
类实现了Executor
接口,因此通过这个接口,任何Runnable
的对象都可以被ThreadPoolExecutor
线程池调度。
Executor框架提供了各种类型的线程池,主要有以下工厂方法:
以上工厂方法分别返回具有不同工作特性的线程池。这些线程池工厂方法的具体说明如下。
newFixedThreadPool()
方法:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理任务队列中的任务。newSingleThreadExecutor()
方法:该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。newCachedThreadPool()
方法:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。newSingleThreadScheduledExecutor()
方法:该方法返回一个ScheduledExecutorService
对象,线程池大小为1。ScheduledExecutorService
接口在ExecutorService
接口之上扩展了在给定时间执行某任务的功能,如在某个固定的延时之后执行,或者周期性执行某个任务。newScheduledThreadPool()
方法:该方法也返回一个ScheduledExecutorService
对象,但该线程池可以指定线程数量。
1. 固定大小的线程池
这里,我们以newFixedThreadPool()
为例,简单地展示线程池的使用方法。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolDemo {
public static class MyTask implements Runnable {
@Override
public void run() {
System.out.println(System.currentTimeMillis() + " Thread id:" + Thread.currentThread().getId());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 前5个线程执行时间和后5个比差了1秒钟
public static void main(String[] args) {
MyTask task = new MyTask();
//创建了固定大小的线程池,内有5个线程
ExecutorService es = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
//依次向线程池提交了10个任务
es.submit(task);
}
}
}
线程池就会安排调度这10个任务。每个任务都会将自己的执行时间和执行这个线程的ID打印出来,并且在这里,安排每个任务要执行1秒。
这个输出就表示这10个线程的执行情况。很显然,前5个任务和后5个任务的执行时间正好相差1秒(注意时间戳的单位是毫秒),并且前5个任务的线程ID和后5个任务的线程ID也是完全一致的(都是8、9、10、11、12)。这说明在这10个任务中,是分成两个批次执行的。这也完全符合一个只有5个线程的线程池的行为。
2. 计划任务
另外一个值得注意的方法是newScheduledThreadPool()
。它返回一个ScheduledExecutorService
对象,可以根据时间需要对线程进行调度。它的一些主要方法如下:
与其他几个线程池不同,ScheduledExecutorService
并不一定会立即安排执行任务。它其实是起到了计划任务的作用。它会在指定的时间,对任务进行调度。如果大家使用过Linux下的crontab工具应该就能很容易地理解它了。作为说明,这里给出了三个方法。方法schedule()
会在给定时间,对任务进行一次调度。方法scheduleAtFixedRate()
和方法scheduleWithFixedDelay()
会对任务进行周期性的调度,但是两者有一点小小的区别,如图3.8所示。
对于FixedRate方式来说,任务调度的频率是一定的。它是以上一个任务开始执行时间为起点,在之后的period时间调度下一次任务。而FixDelay方式则是在上一个任务结束后,再经过delay时间进行任务调度。
scheduleWithFixedDelay:创建并执行一个周期性任务。任务开始于初始延时时间,后续任务将会按照给定的延时进行:即上一个任务的结束时间到下一个任务的开始时间的时间差。
下面的例子使用scheduleAtFixedRate()
方法调度一个任务。这个任务会执行1秒,调度周期是2秒。也就是说每2秒,任务就会被执行一次。
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledExecutorServiceDemo {
public static void main(String[] args) {
ScheduledExecutorService es = Executors.newScheduledThreadPool(10);
//如果前面的任务没有完成,则调度不会启动
es.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println(System.currentTimeMillis() / 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, 0, 2, TimeUnit.SECONDS);
}
}
上述输出的单位是秒。可以看到,时间间隔是2秒。
这里还想说一个有意思的事情,如果任务的执行时间超过调度时间会发生什么情况呢?比如,这里调度周期是2秒,如果任务的执行时间是8秒,是不是会出现多个任务堆叠在一起呢?
实际上,ScheduledExecutorService不会让任务堆叠出现。我们将第9行的代码改为:
再执行上述代码,你就会发现任务的执行周期不再是2秒,而是变成了8秒。如下所示,是一种可能的结果。
也就是说,周期如果太短,那么任务就会在上一个任务结束后立即被调用。可以想象,如果采用scheduleWithFixedDelay()
方法,并且按照修改8秒,调度周期2秒计,那么任务的实际间隔将是10秒,大家可以自行尝试。
另外一个值得注意的问题是,调度程序实际上并不保证任务会无限期地持续调用。如果任务本身抛出了异常,那么后续的所有执行都会被中断,因此,如果你想让你的任务持续稳定地执行,那么做好异常处理非常重要,否则你很有可能观察到调度器无疾而终。
注意:如果任务遇到异常,那么后续的所有子任务都会停止调度,因此,必须保证异常被及时处理,为周期性任务的稳定调度提供条件。