多线程相关概念和线程池

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Jintao_Ma/article/details/53197502

多线程相关概念和线程池

1.前言

其实在平时的工作中用到多线程相关的问题时,总是遇到一个就解决一个,从来没有在宏观上去看它们, 也就达不到所谓的"看山还是山,看水还是水"。在系统的总结多线程之前,先总结一些基本的概念

声明:部分观点仅由思考所得,欢迎讨论和指正.

2.多线程

条件:在一个进程下

2.1单cpu下的多线程称之为并发  

2.2多cpu下的多线程称之为并行

单cpu和多cpu的问题,后面还会再讨论,其实可以把多cpu看成单cpu来处理一些问题。

3.多线程的好处

3.1 以更简单的方式高效的使用系统资源(如cpu等)
因为一个程序运行的时候,并不是一直使用cpu,cpu的任务是处理和计算,当有其他操作并不使用cpu的时候(比如说进行读取磁盘文件的时候),这时改变操作顺序,可以减少cpu空闲的时间:
A: 一般情况下,处理多个文件:
5秒读取文件A
2秒处理文件A
5秒读取文件B
2秒处理文件B
---------------------
总共需要14秒
B:采用更好的算法,处理多个文件:
5秒读取文件A
5秒读取文件B + 2秒处理文件A
2秒处理文件B
---------------------
总共需要12秒
分析:在使cpu等资源的利用率高这个方面,明显B方法更好,但是这种算法或者说思想该如何实现呢?
我们看到这种思想的本质是保持cpu等系统资源的繁忙。
实现:那么我们可以采用单线程的方式,只要设置好相关的文件状态即可,达到B这种思想。但是在cpu的使用方面,这种思想有了更好的实现方式,那就是多线程,多线程会抢夺cpu等资源,完全符合了B这种"保持cpu等繁忙"的本质。所以说,实现系统高效的是B这种算法,而多线程让这种算法的代码实现变得简单。这才是多线程真正的好处之一

3.2 多线程的另一个好处是让大部分的用户感觉更快
这个都比较熟悉,对于多个用户来说,同一段时间内,是完成一个用户的全部任务,还是完成多个用户的一部分任务 对用户来说体验更好? 当然是后者了。
 注意:前面提到了单cpu和多cpu的问题,个人以为完全可以把多cpu当作单cpu来看待,多个cpu依然是一种资源,而且每一个cpu都高效的话,整个应用也会高效。 就像单兵,提高个人技巧,无疑在solo中更有优势,如果是一个部队,提高每个人的技巧,整个部队也一定会更优秀。

4.多线程的代价

4.1 有形的代价

A:空间 每开辟一个线程,就会占用一些内存空间
B:时间 每切换一次线程,就会消耗部分时间

4.2 无形的代价

A:多线程实现了前面说到的B算法,它能够保证cpu在一个时间点,只有一个线程在访问,但是对于其他的 资源,比如说全局变量,多个线程并不是按顺序访问的,可能会同时访问,所以要使用锁等办法;在访问两个及以上的资源时,也是不按顺序的,所以会有死锁。也就是说,多线程在cpu方面帮我们实现了算法B而在其他的资源方面,需要我们自己实现算法B,需要手动保证每个资源的访问顺序,而锁,同步,信号量等都是为了实现自己的算法B而产生的一些技术,也可以说是多线程在高效使用cpu时带来的一些副作用;

B:学习成本和使用成本;可能需要花很多时间去学习,更有可能花更多的时间去使用和调试它

注意:再深入思考一下,似乎发现多线程的本质。由单到多,带来的不就是顺序的问题么,多线程帮我们保证了访问cpu的顺序,那么访问其他资源的顺序怎么保证,当然是自己来保证; 所以,"解决多线程相关的问题,就是解决除cpu外的资源的使用顺序问题"。

5.多线程的创建方式和使用

5.1 创建方式

public static void main(String[] args) {
		/*Thread方式:一种是继承Thread类,一种是像下面这样使用匿名内部类*/
		Thread thread1 = new Thread(){
			@Override
			public void run() {
				System.out.println("Multi.main(...).new Thread() {...}.run()");
			}
		};
		thread1.start();
		
		/*Runnable方式:一种是实现一个接口Runnable,一种是像下面这样使用匿名内部接口*/
		Runnable runnable = new Runnable() {
			public void run() {
				System.out
						.println("Multi.main(...).new Runnable() {...}.run()");
			}
		};
		Thread thread2 = new Thread(runnable);
		thread2.start();
	}/*main method*/

5.2 认识一下两个名词

A.竞态条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件

B.临界区:导致竞态条件发生的代码区称作临界区;在临界区中使用适当的同步就可以避免竞态条件。

通过示例来看一下:

	public class Counter {
	    protected long count = 0;
	    public void add(long value){
	        this.count = this.count + value;  
	    }
	}


上面的add()方法就是临界区,它会产生竞态条件,当两个线程同时访问这个add方法就可能会导致count 的值不一致。

因为JVM的执行按照下面顺序:

--从内存获取 this.count 的值放到寄存器
--将寄存器中的值增加value
--将寄存器中的值写回内存
如果两个线程交错执行,会出现下面情况:
A:读取 this.count 到一个寄存器 (0)
B:读取 this.count 到一个寄存器 (0)
B:将寄存器的值加2
B:回写寄存器值(2)到内存. this.count 现在等于 2
A:将寄存器的值加3
A:回写寄存器值(3)到内存. this.count 现在等于 3
最终得到的是线程A得到的值,这样的话得到的count值,就不是期望的值。

5.3 线程安全,下面给出一个自己的定义:
一段资源(代码)是线程安全的条件:
A:如果其中仅存在一类资源(代码),这类资源(代码)不被多个线程共同执行写操作。
B:如果还存在指向资源(代码)的引用,且它所指向的资源(代码)是线程安全的
上面两个结合就是线程安全的定义,而且这个定义是递归的。

注意:
1)条件A指明只有写操作才会引起线程不安全
2)上面的条件B是有必要的,看下面的示例:
两个线程都创建了各自的数据库连接,每个连接自身是线程安全的,但它们所连接到的同一个数据库也许不是线程安全的。
比如,2个线程执行如下代码:
检查记录X是否存在,如果不存在,插入X
如果两个线程同时执行,而且碰巧检查的是同一个记录,那么两个线程最终可能都插入了记录:
线程1检查记录X是否存在。检查结果:不存在
线程2检查记录X是否存在。检查结果:不存在
线程1插入记录X
线程2插入记录X
同样的问题也会发生在文件或其他共享资源上。因此,区分某个线程控制的对象是资源本身,还是仅仅到某个资源的引用很重要。


6.线程池

6.1.线程池的概念

6.1.1 为什么要使用线程池(本质)
1)减少了每次都要创建线程的麻烦
2)线程池可以设置合理的线程数目,手动创建线程不方便了解系统已经存在的线程数目。
6.1.2 为什么要使用队列
在使用线程池的时候,可能会有请求无法及时处理,这个时候就需要队列,而且是阻塞队列。阻塞队列的特点是,在队列中没有数据的情况下,线程池中的所有线程都会被自动阻塞,直到有数据放入队列;在队列中填满数据的情况下,队列中的所有请求都会被自动阻塞,直到队列中有空的位置,线程被自动唤醒。 

6.2 线程池相关的类

Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池, 而只是一个执行线程的工具。真正的线程池接口是ExecutorService

6.3 如何使用线程池

6.3.1 先看一下ThreadPoolExecutor的构造函数

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
corePoolSize - 池中所保存的线程数,包括空闲线程。
maximumPoolSize-池中允许的最大线程数。
keepAliveTime - 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
unit - keepAliveTime 参数的时间单位。
workQueue - 执行前用于保持任务的队列。此队列仅保持由 execute方法提交的 Runnable任务。
threadFactory - 执行程序创建新线程时使用的工厂。
handler - 由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。
ThreadPoolExecutor是Executors类的底层实现。corePoolSize - 池中所保存的线程数,包括空闲线程。
maximumPoolSize-池中允许的最大线程数。
keepAliveTime - 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
unit - keepAliveTime 参数的时间单位。
workQueue - 执行前用于保持任务的队列。此队列仅保持由 execute方法提交的 Runnable任务。
threadFactory - 执行程序创建新线程时使用的工厂。
handler - 由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。
ThreadPoolExecutor是Executors类的底层实现。
主要注意的corePoolSize,maximumPoolSize,workQueue这三个概念,后面会讲. 

6.3.2 线程池与队列搭配的使用策略。

了解了线程池和队列概念,下面就是如何使用它们的问题。先看一下所有线程池和队列在使用时的原则:

1)如果运行的线程少于 corePoolSize,则 Executor始终首选添加新的线程,而不进行排队。(如果当前运行的线程小于corePoolSize,则任务根本不会存放,添加到queue中,而是直接抄家伙(thread)开始运行)
2)如果运行的线程等于或多于 corePoolSize,则 Executor始终首选将请求加入队列,而不添加新的线程。
3)如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。

6.3.3 了解原则后,再看一下在这种原则上,线程池和队列的三种常见搭配:

1)直接提交
工作队列的默认选项是 SynchronousQueue,它将任务直接提交给线程而不保持它们。在此,如果不存在可用于立即运行任务的线程,则试图把任务加入队列将失败,因此会构造一个新的线程。此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。直接提交通常要求无界 maximumPoolSizes 以避免拒绝新提交的任务。这里SynchronousQueue可看作它的长度为0
2)无界队列
使用无界队列(例如,不具有预定义容量的 LinkedBlockingQueue)将导致在所有 corePoolSize 线程都忙时新任务在队列中等待。这样,创建的线程就不会超corePoolSize。(因此,maximumPoolSize的值也就无效了。)当每个任务完全独立于其他任务,即任务执行互不影响时,适合于使用无界队列;例如,在 Web页服务器中。这种排队可用于处理瞬态突发请求,当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性
3)有界队列
当使用有限的 maximumPoolSizes时,有界队列(如 ArrayBlockingQueue)有助于防止资源耗尽,但是可能较难调整和控制。队列大小和最大池大小可能需要相互折衷:使用大型队列和小型池可以最大限度地降低 CPU 使用率、操作系统资源和上下文切换开销,但是可能导致人工降低吞吐量。如果任务频繁阻塞(例如,如果它们是 I/O边界),则系统可能为超过您许可的更多线程安排时间。使用小型队列通常要求较大的池大小,CPU使用率较高,但是可能遇到不可接受的调度开销,这样也会降低吞吐量。
针对上面三种策略,系统已经有预设置的搭配(设置好最合适的线程池大小和队列大小),能够处理常见的应用场景: 
直接提交-->newScheduledThreadPool 创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
无界队列-->newCachedThreadPool
有界队列-->newFixedThreadPool
除此之外,还有newSingleThreadExecutor,创建一个单线程的线程池,用来支持串行的任务。
这四种实现都是由Executors内的工厂方法产生的,而且JDK文档中强烈建议使用。

6.4.线程池的使用
直接拿来使用即可,关键是分析业务场景,从上面四种已有实现中选择最合适的

public class MultiThread {
	public static void main(String[] args) {
		 /*创建一个可重用固定线程数的线程池*/
		ExecutorService  executorService = Executors.newFixedThreadPool(2);
		
        /*创建实现了Runnable接口对象*/
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		MyThread t3 = new MyThread();
		MyThread t4 = new MyThread();
		MyThread t5 = new MyThread();
        /*将线程放入池中进行执行*/
		executorService.execute(t1);
		executorService.execute(t2);
		executorService.execute(t3);
		executorService.execute(t4);
		executorService.execute(t5);
        /*关闭线程池*/
		executorService.shutdown();
	}
}


7.总结

基本总结了多线程和线程池本质,它们的优点和带来的缺点;后面就开始讲述多线程在"除cpu外的资源的访问顺序"(文章中提到过,这是多线程带来优点的同时,需要解决的问题)多线程带来)方面的详细语法。

猜你喜欢

转载自blog.csdn.net/Jintao_Ma/article/details/53197502