线程池在并发编程中使用很普遍,而且线程池的原理很比较容易懂,但是这个不管是面试还是工作中都还是很重要的。下面我们主要来具体讲一下线程池、线程这些知识,汇总下,大家看这一篇文章我认为就够用了。里面有各种面试会问的,看懂了,线程池这块就拿捏的死死的了。
目录
一、多线程
二、线程池
2.1、线程池目的
2.2、线程池基本框架
2.3、线程池状态
2.4、线程池 excute 分析
2.5、自定义线程池
2.6、线程池核心线程数怎么设置
三、线程间通信方式
3.1、进程间通信方式
3.2、线程间通信方式
多线程的原理简单描述如下:
相当于玩游戏机,只有一个游戏机(cpu),可是有很多人要玩,于是,start是排队!等CPU选中你就是轮到你,你就run(),当CPU的运行的时间片执行完,这个线程就继续排队,等待下一次的run()。
调用start()后,线程会被放到等待队列,等待CPU调度,并不一定要马上开始执行,只是将这个线程置于可运行状态。然后通过JVM,线程Thread会调用run()方法,执行本线程的线程体。先调用start后调用run,这么麻烦,为了不直接调用run?就是为了实现多线程的优点,没这个start还不行。请耐心往下看具体原因。
JAVA多线程实现方式主要有以下三种:
1、继承Thread类,重写run方法。用start方法启动线程
2、实现Runnable接口,实现run方法。用new Thread(Runnable target).start()方法来启动
3、使用ExecutorService、Callable、Future实现有返回结果的多线程。
其中前两种方式线程执行完后都没有返回值,只有最后一种是带返回值的在异步中用的较多。其中最常用的也是前两种实现方式。
我们看下用start和run启动的区别,代码示例:
public class ThreadTest {
public static void main(String[] args) {
Runner1 runner1 = new Runner1();
Runner2 runner2 = new Runner2();
// Thread(Runnable target) 分配新的 Thread 对象。
Thread thread1 = new Thread(runner1);
Thread thread2 = new Thread(runner2);
thread1.start(); //执行start,thread1与thread2交叉执行
thread2.start();
//thread1.run(); //执行run,thread1与thread2顺序执行
//thread2.run();
}
}
class Runner1 implements Runnable { // 实现了Runnable接口,jdk就知道这个类是一个线程
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("进入Runner1运行状态——————————" + i);
}
}
}
class Runner2 implements Runnable { // 实现了Runnable接口,jdk就知道这个类是一个线程
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("进入Runner2运行状态==========" + i);
}
}
}
大家运行下就会发现thread.run()是串行化的。这就是区别。
1.start()方法来启动线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码;通过调用Thread类的start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。 然后通过此Thread类调用方法run()来完成其运行操作的, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止。然后CPU再调度其它线程。
(多线程同时运行)
2.run()方法当作普通方法的方式调用。程序还是要顺序执行,要等待run方法体执行完毕后,才可继续执行下面的代码; 程序中只有主线程——这一个线程, 其程序执行路径还是只有一条, 这样就没有达到多线程的目的。
(相当于顺序运行)
多线程就是分时利用CPU,宏观上让所有线程一起执行 ,也叫并发。这下就知道为啥没有start不行了吧。
在并发编程中,多线程运行的时候如果我们使用Thread[] thread = new Thread[100] 这种方式来创建多线程,然后执行,不仅代码写的繁琐,而且线程之间的唤醒,阻塞,还得需要自己去用代码控制,很是麻烦。相较于这种new出来线程,使用线程池管理线程就有了很大的优势。
1)减少系统维护线程的开销;
2)解耦 : 可以使运行和创建分开;
3)线程复用;
使用线程池主要目的就是能够线程复用,减少系统对线程创建,销毁,维护的开销。
线程池的基本框架是Excutor,使用ThreadPoolExcutor。
线程池的核心参数:
CorePoolSize 核心线程大小 与pool的生命周期相同
maximumPoolSize 最大线程数量
keepAliveTime 线程保持活动的时间,为了设定maximumPoolSize配置的线程的生命周期,一旦keepalivetime时间内没有任务就会销毁
TimeUnit : 时间单位
BlockingQueue : 任务阻塞队列
DefaultHandler : 拒绝策略
1、先判断线程池中核心线程池(corePoolSize)所有的线程是否都在执行任务。如果不是,则新创建一个线程执行刚提交的任务,否则,核心线程池中所有的线程都在执行任务,则进入第2步;
2、判断当前阻塞队列是否已满,如果未满,则将提交的任务放置在阻塞队列中;否则,则进入第3步;
3、判断线程池中所有的线程(maximumPoolSize)是否都在执行任务,如果没有,则创建一个新的线程来执行任务,否则,则交给饱和策略进行处理
如下图所示:
2次询问线程池的时候,线程数的判断标准是不一样的,一个是核心线程数,一个是最大线程数。这点要注意下。
拒绝策略有四种我们附上源码:
会抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后根据需求编写自己的处理代码;在业务中可以去捕获这个异常,然后进行一些业务的处理,比如返回默认的接口返回数据。
该策略既不会抛弃任务,也不会抛出异常,而是当线程池中的所有线程都被占用后,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行,从而降低新任务的流量。由于执行任务需要一定的时间,因此主线程至少在一定的时间内不能提交任何任务,从而使得工作者线程有时间来处理正在执行的任务。
另一方面,在这期间,主线程不会调用accept,那么到达的请求将被保存在TCP层的队列中而不是在应用程序的队列中。如果持续过载,那么TCP层将最终发现他的请求队列被填满,因此同样会开始抛弃请求。
当服务器过载时,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终到达客户端,导致服务器在高负载的情况下实现一种平缓的性能降低。
抛弃队列中最老的任务,然后重新提交最新的任务排到队尾。这个策略千万不要跟优先级队列配合使用。否则会把优先级最高的给扔了。
如果线程池队列满了,会直接丢掉这个任务并且不会有任何异常。当提交的任务无法保存到队列中等待执行时,Discard策略会悄悄抛弃该任务。
当然如果大家想统一处理异常,可以自定义一个异常处理类。然后继承RejectedExecutionHandler。统一处理异常。
了解的线程池里面的状态控制的参数 ctl。
线程池的ctl是一个原子的 AtomicInteger。
这个ctl包含两个参数 :
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
ctl是32位,它的低29位用于存放当前的线程数, workerCount的理论最大值就应该是29个1,理论上最大的线程数是 536870911;
CAPACITY的运算过程为1左移29位,也就是00000000 00000000 00000000 00000001 --> 001 0000 00000000 00000000 00000000,再减去1的话,就是 000 11111 11111111 11111111 11111111,前三位代表线程池运行状态runState
其中高三位的值和状态对应如下:
说明:运行状态,线程池创建后就处于Running
切换:一进入就是运行状态
说明:关闭,不接受新任务,但是已经队列中的任务需要处理完
切换:pool 执行 showdown的时候
说明:不接受新任务,也不执行队列中的任务
切换:shutdownnow() 方法执行的时候
五种状态转换一图搞定,都是心血总结啊。
ctl提供了相关api:都是位运算,
// Packing and unpacking ctl
// 获取线程池的状态
private static int runStateOf(int c) { return c & ~CAPACITY; }
// 获取线程池的工作线程数
private static int workerCountOf(int c) { return c & CAPACITY; }
// 根据工作线程数和线程池状态获取 ctl
private static int ctlOf(int rs, int wc) { return rs | wc; }
我们将源码贴在下面便于分析。看这段代码的时候,千万不要陷进去,因为特别绕。站在大体角度了解下就可以了。代码里面我加了注释
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
//获取线程池的状态码
int c = ctl.get();
//如果线程池的工作线程个数少于corePoolSize则创建新线程执行当前任务
if (workerCountOf(c) < corePoolSize) {
//执行addWork,提交为核心线程,提交成功return。提交失败重新获取ctl
if (addWorker(command, true))
return;
c = ctl.get();
}
//如果线程个数大于corePoolSize则检查线程池状态是否是正在运行,且将新线程向阻塞队列提交。
if (isRunning(c) && workQueue.offer(command)) {
//recheck 需要再次检查,主要目的是判断加入到阻塞队里中的线程是否可以被执行
int recheck = ctl.get();
//如果线程池状态不为running,将任务从阻塞队列里面移除,启用拒绝策略
if (! isRunning(recheck) && remove(command))
reject(command);
// 如果线程池的工作线程为零,则调用addWoker提交任务
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//如果当前任务无法放进阻塞队列中,则创建新的线程来执行任务,如果创建失败则拒绝
else if (!addWorker(command, false))
reject(command);
}
execute方法执行逻辑有这样几种情况:
* 1. SimpleAsyncTaskExecutor:不是真的线程池,这个类不重用线程,每次调用都会创建一个新的线程(默认)。
* 2. SyncTaskExecutor:这个类没有实现异步调用,只是一个同步操作。只适用于不需要多线程的地方
* 3. ConcurrentTaskExecutor:Executor的适配类,不推荐使用。如果ThreadPoolTaskExecutor不满足要求时,才用考虑使用这个类
* 4. SimpleThreadPoolTaskExecutor:是Quartz的SimpleThreadPool的类。线程池同时被quartz和非quartz使用,才需要使用此类
* 5. ThreadPoolTaskExecutor :最常使用,推荐。 其实质是对java.util.concurrent.ThreadPoolExecutor的包装
一般说来,大家认为线程池的大小经验值应该这样设置:(其中N为CPU的个数)
* 如果是CPU密集型应用,则线程池大小设置为N+1
* 如果是IO密集型应用,则线程池大小设置为2N+1
某些进程花费了绝大多数时间在计算上,而其他则在等待I/O上花费了大多是时间,前者称为计算密集型(CPU密集型),后者称为I/O密集型
如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。
但是,IO优化中,这样的估算公式可能更适合:
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
因为很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
*下面举个例子: 比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。这个公式进一步转化为: **最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目
刚刚说到的线程池大小的经验值,其实是这种公式的一种估算值。
到这里,线程和线程池的主要内容就讲完了,应该大家没啥问题了。面试绝对面试官问不出什么新花样了。
下面在根据面试问题,给大家介绍下线程、进程间的通信方式。
管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
有名管道 (namedpipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
信号量(semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
消息队列( messagequeue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
信号 (sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
共享内存(shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
套接字(socket ) : 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
锁机制:包括互斥锁、条件变量、读写锁,比如volatle就是靠着cache line进行通信的
互斥锁提供了以排他方式防止数据结构被并发修改的方法。
读写锁允许多个线程同时读共享数据,而对写操作是互斥的。
条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变 量始终与互斥锁一起使用。
信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
信号机制(Signal):类似进程间的信号处理
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。