文章目录
- 前言
- 读完本章你将了解到
- 一、多线程基础相关
- 二、多线程的挑战
- 1.上下文切换
- 2.多线程一定快吗
- 2.死锁
- 3.资源限制的挑战
- 三、Java并发机制的底层实现原理
- volatile
- volatile的定义与实现原理
- synchronized的实现原理与应用
- Java对象头
- 原子操作的实现原理
- 术语定义
- 处理器如何实现原子操作
- (1)使用总线锁保证原子性
- (2)使用缓存锁保证原子性
- Java如何实现原子操作
- (1)使用循环CAS实现原子操作
- CAS实现原子操作的三大问题
- 1)ABA问题。
- 2)循环时间长开销大。
- 3)只能保证一个共享变量的原子操作
- 四、Java中的线程池
- 线程池的实现原理
- 线程池的创建
- 向线程池提交任务
- 关闭线程池
- 合理地配置线程池
- 线程池的监控
前言
多线程在实际开发中扮演着重要的角色,是每一个资深程序员必不可少的技能,我愿意将多线程叫做“上
天”,java虚拟机称之“入地”。本章先说"上天"。以下内容是结合《Java并发编程艺术》里的内容以及自己
的浅薄之见
提示:以下是本篇文章正文内容,下面案例可供参考
读完本章你将了解到1.多线程基础相关
2.多线程的挑战
3.java并发机制的底层实现原理
4.java中的线程池
1.什么是线程
现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作
系统就会创建一个Java进程。现代操作系统调度的最小单元是线程,也叫轻量级进程(Light
Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局
部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉
到这些线程在同时执行。
2.为什么要使用多线程?
(1)更多的处理器核心
随着处理器上的核心数量越来越多,以及超线程技术的广泛运用,现在大多数计算机都
比以往更加擅长并行计算,而处理器性能的提升方式,也从更高的主频向更多的核心发展。如
何利用好处理器上的多个核心也成了现在的主要问题。
线程是大多数操作系统调度的基本单元,一个程序作为一个进程来运行,程序运行过程
中能够创建多个线程,而一个线程在一个时刻只能运行在一个处理器核心上。试想一下,一个
单线程程序在运行时只能使用一个处理器核心,那么再多的处理器核心加入也无法显著提升
该程序的执行效率。相反,如果该程序使用多线程技术,将计算逻辑分配到多个处理器核心
上,就会显著减少程序的处理时间,并且随着更多处理器核心的加入而变得更有效率
(2) 更快的响应时间
有时我们会编写一些较为复杂的业务逻辑,例如,一笔订单的创建,它包括插入订单数据、生成订单快照、发送邮件通知卖家和记录货品销售数量等。用户从单击“订购”按钮开始,就要等待这些操作全部完成才能看到订购成功的结果。但是这么多业务操作,如何能够让其更快地完成呢?
在上面的场景中,可以使用多线程技术,缩短了响应时间,提升了用户体验。
3.线程的状态
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现
这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切
换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个
任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这
个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是
便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第
多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文
切换也会影响多线程的执行速度
代码如下(示例):
public class ConcurrencyTest {
private static final long count = 100000000L;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time+"ms,b="+b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial:" + time+"ms,b="+b+",a="+a);
}
}
结果
concurrency :24ms,b=-100000000 serial:46ms,b=-100000000,a=500000000 ------------------------------------- concurrency :1ms,b=-1000 serial:0ms,b=-1000,a=50002.死锁
锁是个非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但 同时它也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可 用。让我们先来看一段代码,这段代码会引起死锁,使线程t1和线程t2互相等待对方释放锁
代码如下(示例):
public class DeadLockDemo {
private static String A = "A";
private static String B = "B";
public static void main(String[] args) {
new DeadLockDemo().deadLock();
}
private void deadLock() {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
}
});
t1.start();
t2.start();
}
}
一旦出现死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程查看 到底是哪个线程出现了问题
现在我们介绍避免死锁的几个常见方法。
1.避免一个线程同时获取多个锁。
2.避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
3.尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
4.对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
(1)什么是资源限制 资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。 例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资 源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限 制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接 数和socket连接数等。
(2)资源限制引发的问题 在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行, 但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不 会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程 序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不 能运行完成任务,后来修改成单线程,一个小时就执行完成了。
(3)如何解决资源限制的问题 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让 程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同 的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这 笔数据。 对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket 连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
(4)在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整 程序的并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作 时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则 某些线程会被阻塞,等待数据库连接。
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节 码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和 CPU的指令。本章我们将深入底层一起探索下Java并发机制的底层实现原理。
volatile在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程 修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当 的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。本 文将深入分析在硬件层面上Intel处理器是如何实现volatile的,通过深入分析帮助我们正确地 使用volatile变量。
volatile的定义与实现原理Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了 确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言 提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存 模型确保所有线程看到这个变量的值是一致的。 在了解volatile实现原理之前,我们先来看下与其实现原理相关的CPU术语与说明。表2-1 是CPU术语的定义。
volatile是如何来保证可见性的呢?
instance = new Singleton(); // instance是volatile变量 转变成汇编代码,如下。 0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);(具体怎么转的请自行实验,博主也没验证过书里的内容)
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架 构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情[1]。
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部
缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据 写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操 作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一 致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状 态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存 里。
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但 是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。本文 详细介绍Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级 锁,以及锁的存储结构和升级过程。
先来看下利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现 为以下3种形式。
·对于普通同步方法,锁是当前实例对象。
·对于静态同步方法,锁是当前类的Class对象。
对于同步方法块,锁是Synchonized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。 那么锁到底存在哪里呢?锁里面会存储什么信息呢?
从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对 象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有 详细说明。但是,方法的同步同样可以使用这两个指令来实现。 monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
Java对象头synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽 等于4字节,即32bit,如表2-2所示。
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意 为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们 一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。
术语定义 处理器如何实现原子操作32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操 作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写 入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节 的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位 的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂 内存操作的原子性。
(1)使用总线锁保证原子性第一个机制是通过总线锁保证原子性。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该 处理器可以独占共享内存。
在同一时刻,我们只需保证对某个内存地址 的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处 理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下 使用缓存锁定代替总线锁定来进行优化。
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在 处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存 锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存 行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声 言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子 性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处 理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如图2-3所示的例子中,当CPU1修 改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。
但是有两种情况下处理器不会使用缓存锁定。
第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行 (cache line)时,则处理器会调用总线锁定。
第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的 内存区域在处理器的缓存行中也会调用总线锁定。 针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测 试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指 令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
Java如何实现原子操作在Java中可以通过锁和循环CAS的方式来实现原子操作。
(1)使用循环CAS实现原子操作JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本 思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器 方法safeCount和一个非线程安全的计数器count。
从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子 方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更 新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和 自减1。
CAS实现原子操作的三大问题 1)ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化 则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它 的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面 追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从 Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个 类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是 否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2)循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第 一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间 取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候 因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而 提高CPU的执行效率。
当对一个共享变量执行操作时,我们可以使用循 环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子 性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来 操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始, JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对 象里来进行CAS操作。
四、Java中的线程池Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序 都可以使用线程池。在开发过程中,合理地使用线程池能够带来3个好处。
第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源, 还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用 线程池,必须对其实现原理了如指掌。
线程池的实现原理当向线程池提交一个任务之后,线程池是如何处理这个任务的呢?本节来看一下线程池 的主要处理流程
ThreadPoolExecutor执行execute()方法的示意图,
执行示意图
我们可以通过ThreadPoolExecutor来创建一个线程池。
new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, milliseconds,runnableTaskQueue, handler);
1)corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线 程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任 务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads()方法, 线程池会提前创建并启动所有基本线程。
2)runnableTaskQueue(任务队列):用于保存等待执行的任务的阻塞队列。可以选择以下几 个阻塞队列。
·ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原 则对元素进行排序。
·linkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通 常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。·SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用 移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于linked-BlockingQueue,静态工 厂方法Executors.newCachedThreadPool使用了这个队列。
·PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
3)maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并 且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如 果使用了无界的任务队列这个参数就没什么效果。
4)RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状 态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法 处理新任务时抛出异常。在JDK 1.5中Java线程池框架提供了以下4种策略。
·AbortPolicy:直接抛出异常。
·CallerRunsPolicy:只用调用者所在线程来运行任务。
·DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
·DiscardPolicy:不处理,丢弃掉。
当然,也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录 日志或持久化存储不能处理的任务。
·keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以, 如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率。
·TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS)、小时(HOURS)、分钟 (MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒
向线程池提交任务可以使用两个方法向线程池提交任务,分别为execute()和submit()方法。
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。 通过以下代码可知execute()方法输入的任务是一个Runnable类的实例
threadsPool.execute(new Runnable() {
@Override public void run() {
// TODO Auto-generated method stub
}
});
submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方 法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线 程一段时间后立即返回,这时候有可能任务没有执行完。
Future关闭线程池
可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线 程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务 可能永远无法终止。但是它们存在一定的区别,
shutdownNow首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,
而 shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线 程。 只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务 都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。
至于应该调用哪 一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭 线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。
·任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
·任务的优先级:高、中和低。
·任务的执行时间:长、中和短。
·任务的依赖性:是否依赖其他系统资源,如数据库连接。
性质不同的任务可以用不同规模的线程池分开处理。
CPU密集型任务应配置尽可能小的 线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配 置尽可能多的线程,如2*Ncpu。
混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务 和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量 将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。
可以通过 Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高 的任务先执行。
注意 如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能 执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让 执行时间短的任务先执行。 依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越 长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。
建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点 儿,比如几千。有一次,我们系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任 务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线 程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻 塞,任务积压在线程池里。如果当时我们设置成无界队列,那么线程池的队列就会越来越多, 有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然,我们的系统所 有的任务是用单独的服务器部署的,我们使用不同规模的线程池完成不同类型的任务,但是 出现这样问题时也会影响到其他任务。
线程池的监控如果在系统中大量使用线程池,则有必要对线程池进行监控,方便在出现问题时,可以根 据线程池的使用状况快速定位问题。可以通过线程池提供的参数进行监控,在监控线程池的 时候可以使用以下属性。 ·taskCount:线程池需要执行的任务数量。
·completedTaskCount:线程池在运行过程中已完成的任务数量,小于或等于taskCount。 ·largestPoolSize:线程池里曾经创建过的最大线程数量。通过这个数据可以知道线程池是 否曾经满过。如该数值等于线程池的最大大小,则表示线程池曾经满过。
·getPoolSize:线程池的线程数量。如果线程池不销毁的话,线程池里的线程不会自动销 毁,所以这个大小只增不减。
·getActiveCount:获取活动的线程数。
通过扩展线程池进行监控。可以通过继承线程池来自定义线程池,重写线程池的 beforeExecute、afterExecute和terminated方法,也可以在任务执行前、执行后和线程池关闭前执 行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。 这几个方法在线程池里是空方法。



