出处:http://www.tianshouzhi.com/api/tutorials/mutithread
目录(全部)-多线程
目录(part 01)-多线程
- 1.0 Java并发编程基本知识介绍
- 1.1 创建并运行Java线程
- 1.2 java程序启动至少会启动几个线程
- 1.3 什么是当前线程
- 1.4 多线程的优点
- 1.5 多线程的代价
- 1.6 Linux操作系统对线程数的限制
- 1.7 并发编程模型
1.0 Java并发编程基本知识介绍
2016-05-31 20:50:19
1、什么是并发?
用户通常认为计算机在同一时刻可以做多个事情是理所当然的事情。
例1:例如你可以一边听音乐,一边玩游戏,同时还通过聊天工具和别人聊天。假设你使用的qq音乐播放器在听音乐,玩的是qq斗地主游戏,使用qq和别聊天,那么这实际上是三个不同的软件在同时运行,完成三个不同的事情。
例2:当然我们也不能否认,在一个软件内,同时可以完成以上三个事情:聊天、游戏和音乐。例如你在玩qq斗地主,以便在打牌,播放了背景音乐,同时你还可以和其他牌友聊天,发一句"我等的花儿也谢了",这实际上是一个软件可以同时完成三个不同的事情。
不管是那种情况,我们应该知道的是,并发指的是在同一时刻执行多个任务。
2、进程与线程
进程:进程通常是程序、应用的同义词。不过,用户所看到的一个单独的应用事实上可能还会有一系列的协作进程(cooperating processes
),例如Linux上就可以通过fork创建一个进程副本。最简单的情况下,一个应用就是一个进程。
上例1中,我们使用了启动了三个不同的应用完成三个不同的事情,你可以认为打开了一个软件就相当于在操作系统中开启了一个进程。需要注意的是,软件本身就是一个文件而已,当你启动了之后,它才会成为操作系统中的一个进程。操作系统会给进程分配运行时需要的资源,例如内存。为了方便进程之前的通信,大多数操作系统有支持进程之间相互通信(Inter Process Communication ,IPC)的措施,例如管道、socket等。IPC不仅支持同一个操作系统上进程的通信,也支持不同操作系统上进程的通信。例如,我们通过IE浏览器访问网页,IE浏览器就是我们本机上的一个进程,而远程服务器又是另外一个进程,本地浏览器进程向远程服务器发送网页请求,远程服务器进程将数据返回给浏览器。
大部分Java虚拟机的实现都是作为单进程运行的,也就是说,启动Java虚拟机的时候,只会启动一个进程。在Java应用中,我们可以通过ProcessBuilder来创建附加的进程。
前面已经提到,一个操作系统可以同时运行多个进程,如果你使用的操作系统是windows,你可以通过任务管理器来查看现在你的电脑上到底启动了多少个用户进程,以下是本人现在操作系统中启动进程状态,总共有108
个,你的可能与我的不一样,因为我们电脑上装的软件可能不一样,而且即使完全一样,可能我打开了这个软件而你没有:
线程:我们已经提到,即使是在一个软件内,我们也能同时干多个事,这些不同的功能可以同时进行,是因为在进程内使用了多个线程。线程有时又称之为轻量级进程
。但是创建一个线程要消耗的资源通常比创建进程少的多。一个进程内的多个线程会共享进程的资源,同时也会有自己私有的资源。
线程必须存在于进程中,每个进程至少要有一个线程作为程序的入口。线程是可以并发执行的,所以我们在一个软件内也可以同时干多个事。操作系统上通常会同时运行多个进程,每个进程又会开启多个线程。
3、单核与多核
单核
现在的计算机基本上都是多核计算机,单核的估计你想买也买不到了。那么单核与多核有什么区别呢?
事实上,从计算机发展历史上来说,最早是单核CPU时代。在单核CPU早期,在任意时刻只能同时运行一个进程,注意这不是因为只有一个CPU,而是操作系统的限制,例如早期的DOS操作系统只能同时运行一个任务。之后发展到多任务阶段,计算机能并行执行多任务或多进程,同样请你注意的是,支持多个任务同时运行,是因为操作系统更加先进了(不否认CPU也在进化),支持在一个CPU中同时运行多个任务,例如Unix、Linux、Windows。
需要注意的是这并不是真正意义上的“同一时间点”,而是多个任务或进程共享一个CPU,并交由操作系统来完成多任务间对CPU的运行切换,以使得每个任务都有机会获得一定的时间片
运行。因为切换的速度足够快,所以感觉上就像是多个软件在同时运行。
再后来发展到多线程技术,使得在一个程序内部能拥有多个线程并行执行,这让我们可以在一个程序内同时进行多个任务。
类似的,当一个CPU在运行某一个进程的时候,也并不是同时执行多个线程,还是因为不同的线程执行切换的非常快,让我们感觉好像多个线程在同时运行。
事实上,在一个单核的CPU上,在任一时刻,只有一个进程的中一个线程在运行。笔者就曾经遇到过这样的面试题,什么情况下,一个计算机中任一时刻只会有一个线程在运行。这个问题很简单,只要你的CPU是单核的就行了。
多核
单核CPU之所以同一时刻只能运行一个线程,是因为一个CPU同一时刻只能处理一个指令。只有在CPU时多核的情况下,才能实现真正意义上的并行执行。如果你的CPU是双核的,那么就可以同时运行2个线程,如果是4核的,就可以同时运行4个线程...
4、Java与多线程
作为开发者,我们关心的是如何让我们的开发的应用具有并行处理任务的能力,因此我们通常关心的是多线程。
Java是最先支持多线程的开发的语言之一,Java从一开始就支持了多线程能力。而Java的并发编程是非常具有挑战性的,这也是我想为Java并发技术而写这篇系列的原因。作为对自己的笔记,和对其他Java开发的追随者都可获益的。
多线程比多任务更加有挑战。从进程的层面来说,当启动一个进程的时候,操作系统会分配给这个线程一定的内存空间,每个进程只要操作各自的内存空间即可。而多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作。这可能是在单线程程序中从来不会遇到的问 题。其中的一些错误也未必会在单CPU机器上出现,因为两个线程从来不会得到真正的并行执行。然而,更现代的计算机伴随着多核CPU的出现,也就意味着不 同的线程能被不同的CPU核得到真正意义的并行执行。
1.1 创建并运行Java线程
2016-05-31 00:12:16
行是知之始,知是行之成。
个人认为,学习一个东西应该是先知道其运行起来的效果是什么样的,然后再去深挖其中的原理和需要注意的问题。因此本节立刻让读者体验一下多线程运行起来的效果。
作为一个面向对象的语言,Java中线程也是用一个对象(java.lang.Thread)来表示的。每个进程至少有一个线程,作为程序的入口,通常情况下这个线程我们称之为主线程。在Java中,程序的入口是main方法,因此main方法实际上就是运行在主线程中的。
主线程
我们可以通过以下的代码来进行确认。
public class MainThreadDemo {
public static void main(String[] args) {
//用于打印主线程的名称
System.out.println(Thread.currentThread().getName());
}
}
这段程序的运行结果如下,打印出main,这是主线程的名称,事实上每个线程都有自己的名称。
| main |
创建自己的线程
主线程作为程序的入口,因此我们如果需要创建自己的线程,那么必须在主线程中进行创建,也就是在main方法中进行创建。在教程的开头,我们说过一个进程中的多个线程是可以同时运行的。以下的案例在主线程中创建了一个自定义的线程,这样我们的程序中就同时有了两个线程在运行,我们希望通过这段代码来确定多个线程是不是真的可以同时运行:
/**
* 演示多个线程可以并发执行的案例
*/
public class ThreadDemo {
public static void main(String[] args) {
//创建一个线程对象,覆盖其run方法,传入参数为线程的名字
Thread t1=new Thread(){
@Override
public void run() {
for (int i = 1; i <=100 ; i++) {
System.out.println("自定义线程循环:"+i+"次");
}
}
};
//调用start方法启动线程
t1.start();
for (int i = 1; i <=100 ; i++) {
System.out.println("主线程循环:"+i+"次");
}
}
}
为了使代码尽量简单,案例中并没有涉及过多的新的API。读者主要关注的就是我们创建了一个线程Thread
对象,并覆盖了其run
方法,然后调用了其start
方法使其运行。然后我们在main方法和线程的run方法中各自循环了100次,并且打印出一些内容。
以下是笔者在本机上运行以上代码的结果部分内容:
自定义线程循环:53次
自定义线程循环:54次
自定义线程循环:55次
自定义线程循环:56次
main方法循环:56次
自定义线程循环:57次
main方法循环:57次
自定义线程循环:58次
main方法循环:58次
自定义线程循环:59次
main方法循环:59次
自定义线程循环:60次
main方法循环:60次
自定义线程循环:61次
main方法循环:61次
自定义线程循环:62次
main方法循环:62次
自定义线程循环:63次
main方法循环:63次
自定义线程循环:64次
main方法循环:64次
自定义线程循环:65次
main方法循环:65次
自定义线程循环:66次
main方法循环:66次
main方法循环:67次
main方法循环:68次
main方法循环:69次
可以看到main方法中打印的内容和线程run方法中打印的内容有时是交叉运行的,有时又是一个方法循环了好几次才到另一个方法循环(注意,每次的运行结果可能都是不一样的,在后面会讲解原因)。
交叉实现部分说明:
线程run方法和main方法交叉打印出来的内容很好的说明了线程是可以并行运行的。如果不能并行运行,肯定是一个方法执行完,才会让另一个方法执行。
重复打印部分说明:
我们会发现,有的时候run方法或者main方法循环了好几次,才会到另一个方法运行。这是因为线程的运行是抢占式
的。因为有多个线程,为了达到并行执行的效果,CPU需要一会运行这个线程,一会又去运行别的线程,而在不同的线程切换的过程中,CPU并不是按顺序来主义调度这些线程,线程的运行是抢占式
的,意思是一个线程获得一次运行机会之后,下一次能继续请求CPU进行执行。如果CPU连续几次接受了某个线程的执行请求,那么就有可能出现上述情况。
每次运行结果不同说明:
同样还是因为线程的运行是抢占式的原因,我们并不知道程序在每次运行的时候CPU接受到的不同的线程的请求执行的顺序是什么样的。因此对于上述案例,在极端的情况下,可能会出现main方法(或者run方法)先全部打印完,然后再去执行另一个方法的情况。
提示:在调用线程对象的start()方法之前,我们的程序中是只有主线程在运行的。这类似我们运行一个软件,在点击运行图标之前,其只是硬盘上的一个文件而已,只有点击了运行按钮,其才会成为一个进程。对于线程来说是类似的,我们创建了线程对象之后,其仅仅就是Java中的对象而已,只有调用了其start()方法,其才能成为一个真正意义上运行的线程。
1.2 java程序启动至少会启动几个线程
2016-07-26 22:26:39
一个Java程序启动至少启动几个线程?这是一个很常见的面试题。网上大部分的回答都是2个。以下截图来自百度知道:
网上回答事实上,这个分析是没有错的。对于大部分人来说,对于Java中的垃圾回收机制都是有所了解的。不过这个答案只是回答了至少有几个,并没有回答到底有几个。要想弄明白到底有几个线程会被启动,最佳的方法是自己动手实践。
这个Demo在《Java并发编程艺术》中也讲到了
public class ThreadNumDemo {
public static void main(String[] args) {
ThreadMXBean threadMXBean =ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfos=threadMXBean.dumpAllThreads(false,false);
for (ThreadInfo threadInfo : threadInfos) {
System.out.println(threadInfo.getThreadId()+"-"+threadInfo.getThreadName());
}
}
}
这段代码的作用是打印出当前JVM中运行的所有线程信息,这使用到了JMX的API,读者不必关注,只要看运行的结果即可。
5-Attach Listener
4-Signal Dispatcher //分发处理发送给JVM信号的线程
3-Finalizer //调用对象的finalize方法的线程,就是垃圾回收的线程
2-Reference Handler //清除reference的线程
1-main //主线程
可以看到,当一个java进程启动的时候至少会开启以上的这些线程。不过需要注意的是,这是在JDK1.8中运行的结果,不同版本的JDK运行的结果可能会不同。
希望读者可以简单的记忆一下这些线程,毕竟面试的时候会被经常问到这个问题,如果你都能说出来,肯定会加分的。
1.3 什么是当前线程
2016-07-26 22:29:17
什么是当前线程?
我们知道,在一个单核CPU中,虽然应用可以同时创建多个线程,但是事实上在任一时刻,只有一个线程在运行,我们当前在运行的线程称之为当前线程
(Current Thread)。
需要注意的是,当前线程是不断的在变化的,因为CPU会一会执行这个线程,一会又去执行另外一个线程。因此当前线程并不是固定的。
如果获取当前线程的信息?
在Java中我们可以通过Thread
的静态方法currentThread()
获取当前线程的信息。以下代码用于打印出当前正在执行的线程的名称:
public class CurrentThreadDemo {
public static void main(String[] args) {
new Thread("custom thread"){
@Override
public void run() {
System.out.println("当前线程:"+Thread.currentThread().getName());
}
}.start();
System.out.println("当前线程:"+Thread.currentThread().getName());
}
}
程序的运行结果:
当前线程:main
当前线程:custom thread
可以看到同样是Thread.currentThread().getName(),但是在主线中和自定义线程中打印出来的内容确是不一样的。事实上,Thread.currentThread()位于哪一个线程的执行代码中,在运行时通过这个方法获取的这个线程,也就是当前线程。
1.4 多线程的优点
2016-05-29 14:51:17
尽管面临很多挑战,多线程有一些优点使得它一直被使用。这些优点是:
-
资源利用率更好
-
程序设计在某些情况下更简单
-
程序响应更快
资源利用率更好
想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要5秒,处理一个文件需要2秒。
1. 5秒读取文件A
2. 2秒处理文件A
3. 5秒读取文件B
4. 2秒处理文件B
5. ---------------------
6. 总共需要14秒
上述情况可以用以下的代码进行模拟。
public class ResourceThreadDemo {
public static void main(String[] args) throws InterruptedException {
long start=System.currentTimeMillis();
System.out.println("----------程序开始运行---------");
System.out.println("读取A文件开始...");
Thread.currentThread().sleep(5000);
System.out.println("读取A文件结束,耗时:"+(System.currentTimeMillis()-start)/1000+"秒...开始处理A文件,同时开始读取B文件..");
Thread t1=new Thread(){
@Override
public void run() {
try {
System.out.println("读取B文件开始...");
Thread.currentThread().sleep(5000);
System.out.println("读取B文件结束,耗时:"+(System.currentTimeMillis()-start)/1000+"秒...开始处理B文件");
Thread.currentThread().sleep(2000);
System.out.println("B文件处理完成...");
}catch (InterruptedException e){
e.printStackTrace();
}
}
};
t1.start();
Thread.currentThread().sleep(2000);
System.out.println("A文件处理完成...");
t1.join();
System.out.println("总耗时:"+(System.currentTimeMillis()-start)/1000+"秒");
}
}
这段代码我们使用了一个方法Thread.currentThread().sleep(miliseconds)来模拟文件的模拟和处理操作。其作用是让当前线程休眠
,休眠的含义是在指定的时间范围内,线程不会再向CPU发送执行的请求。等到休眠时间已过,才会重新请求CPU执行。因为我们的代码都是在main方法即主线程中运行,因此当主线程休眠的时候,就相当于程序停止了运行,等到休眠时间已过,程序才会继续运行,然后又休眠,运行...。
程序运行的结果如下:
----------程序开始运行---------
读取A文件开始...
读取A文件结束,耗时:5秒...开始处理A文件
A文件处理完成...,耗时:7秒
读取B文件开始...
读取B文件结束,耗时:12秒...开始处理B文件
B文件处理完成...,耗时:14秒
需要注意的是,上面的代码,资源利用率是很低的。
原因在于从磁盘中读取文件的时候,大部分的CPU时间用于等待磁盘去读取数据。在这段时间里,CPU非常的空闲。其深层次的原因是对于IO操作,往往是通过硬件直接存取器(DMA)来执行的,也就是说,CPU只需要将发送一个指令给DMA去执行对应的IO操作即可,指令发送是一瞬间的事,发送完成CPU就可以干其他的事了,我们说的IO操作需要执行5秒事实上是DMA执行这个操作需要5秒的时间,而不是CPU。
因此CPU现在很空闲,它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用CPU资源。看下面的顺序:
----------程序开始运行---------
读取A文件开始...
读取A文件结束,耗时:5秒...开始处理A文件,同时开始读取B文件..
读取B文件开始...
A文件处理完成...
读取B文件结束,耗时:10秒...开始处理B文件
B文件处理完成...
总耗时:12秒
CPU等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU会去处理第一个文件。这可以以下代码来演示:
public class ResourceThreadDemo {
public static void main(String[] args) throws InterruptedException {
long start=System.currentTimeMillis();
System.out.println("----------程序开始运行---------");
System.out.println("读取A文件开始...");
Thread.currentThread().sleep(5000);
System.out.println("读取A文件结束,耗时:"+(System.currentTimeMillis()-start)/1000+"秒...开始处理A文件,同时开始读取B文件..");
Thread t1=new Thread(){
@Override
public void run() {
try {
System.out.println("读取B文件开始...");
Thread.currentThread().sleep(5000);
System.out.println("读取B文件结束,耗时:"+(System.currentTimeMillis()-start)/1000+"秒...开始处理B文件");
Thread.currentThread().sleep(2000);
System.out.println("B文件处理完成...");
}catch (InterruptedException e){
e.printStackTrace();
}
}
};
t1.start();
Thread.currentThread().sleep(2000);
System.out.println("A文件处理完成...");
t1.join();
System.out.println("总耗时:"+(System.currentTimeMillis()-start)/1000+"秒");
}
}
在改进后的代码中,我们将B文件的操作放在了另外一个线程中执行,所以效率可以得到提升。这是因为我们在A文件读取完成之后,同时开始了A文件的处理和B文件的处理工作。程序的运行结果如下:
----------程序开始运行---------
读取A文件开始...
读取A文件结束,耗时:5秒...开始处理A文件,同时开始读取B文件..
读取B文件开始...
A文件处理完成...
读取B文件结束,耗时:10秒...开始处理B文件
B文件处理完成...
总耗时:12秒
当然上述代码还有继续改进的空间。因为现在我们是在A文件读取完成之后在读取B文件,我们完全可以同时开启两个线程,两个线程一个用于读取和处理A文件,一个用户读取和处理B文件。
记住,在等待磁盘读取文件的时候,CPU大部分时间是空闲的。
总的说来,CPU能够在等待IO的时候做一些其他的事情。这个不一定就是磁盘IO。它也可以是网络的IO,或者用户输入。通常情况下,网络和磁盘的IO比CPU和内存的IO慢的多。
程序设计更简单
在单线程应用程序中,如果你想编写程序手动处理上面所提到的读取和处理的顺序,你必须记录每个文件读取和处理的状态。相反,你可以启动两个线程,每个线程 处理一个文件的读取和操作。线程会在等待磁盘读取文件的过程中被阻塞。在等待的时候,其他的线程能够使用CPU去处理已经读取完的文件。其结果就是,磁盘 总是在繁忙地读取不同的文件到内存中。这会带来磁盘和CPU利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也很容易编程实现。
程序响应更快
将一个单线程应用程序变成多线程应用程序的另一个常见的目的是实现一个响应更快的应用程序。设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。
服务器的流程如下所述:
while(server is active){
listen for request
process request
}
如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收。另一种设计是, 监听线程把请求传递给工作者线程(worker thread),然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。这种设计如下所述:
while(server is active){
listen for request
hand request to worker thread
}
这种方式,服务端线程迅速地返回去监听。因此,更多的客户端能够发送请求给服务端。这个服务也变得响应更快.
桌面应用也是同样如此。如果你点击一个按钮开始运行一个耗时的任务,这个线程既要执行任务又要更新窗口和按钮,那么在任务执行的过程中,这个应用程 序看起来好像没有反应一样。相反,任务可以传递给工作者线程(work thread)。当工作者线程在繁忙地处理任务的时候,窗口线程可以自由地响应其他用户的请求。当工作者线程完成任务的时候,它发送信号给窗口线程。窗口 线程便可以更新应用程序窗口,并显示任务的结果。对用户而言,这种具有工作者线程设计的程序显得响应速度更快。
1.5 多线程的代价
2016-05-29 16:57:20
从一个单线程的应用到一个多线程的应用并不仅仅带来好处,它也会有一些代价。不要仅仅为了使用多线程而使用多线程。而应该明确在使用多线程时能多来的好处比所付出的代价大的时候,才使用多线程。如果存在疑问,应该尝试测量一下应用程序的性能和响应能力,而不只是猜测。
设计更复杂
虽然有一些多线程应用程序比单线程的应用程序要简单,但其他的一般都更复杂。在多线程访问共享数据的时候,这部分代码需要特别的注意。线程之间的交互往往非常复杂。不正确的线程同步产生的错误非常难以被发现,并且重现以修复。
上下文切换的开销
当CPU从执行一个线程切换到执行另外一个线程的时候,它需要先存储当前线程的本地的数据,程序指针等,然后载入另一个线程的本地数据,程序指针 等,最后才开始执行。这种切换称为“上下文切换”(“context switch
”)。CPU会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另外一个线程。
上下文切换并不廉价。如果没有必要,应该减少上下文切换的发生。你可以通过维基百科阅读更多的关于上下文切换相关的内容:
http://en.wikipedia.org/wiki/Context_switch
如果你使用的是Linux操作系统,你可以通过vmstat
命令查看当前操作系统每秒的上下文切换次数:
命令"vmstat 1 10
"的含义是:每个1秒统计一次,统计10次后结束。其中cs
那一列表示的就是上下文切换次数,cs是context switch的简写。可以看到目前操作系统系统上每秒上下文切换次数大致都在400-600之间(注意:第一次统计是不准的,而且这个统计是操作系统层面的)。关于其他显示的内容,不是本教程的讲解范围,感兴趣的读者可以阅读其他的参考资料。
增加资源消耗
线程在运行的时候需要从计算机里面得到一些资源。除了CPU,线程还需要一些内存来维持它本地的堆栈。它也需要占用操作系统中一些资源来管理线程。 我们可以尝试编写一个程序,让它创建100个线程,这些线程什么事情都不做,只是在等待,然后看看这个程序在运行的时候占用了多少内存。(代码...)
事实上类似于每个进程启动后,操作系统都要给其分配一定的内存类似,每一个线程启动后,进程也要给线程分配一定的内存,让其来保存自己的私有数据。JVM划分给每个线程的内存区域称之为线程栈内存
。默认情况下,栈内存的大小1M。也就是说,你每多启动一个线程,至少要多消耗1M的内存资源。
1.6 Linux操作系统对线程数的限制
2016-05-29 18:52:52
一个进程内可以创建多少个线程,受到操作系统使用的线程模型
限制。本文以Linux操作系统为例进行讲解。
1、不同线程模型的限制
Linux操作系统中主要有两种线程模型LinuxThreads
和 NPTL
。不同的线程模型对于一个进程可以创建的线程数有着不一样的规定。要想查看你的Linux操作系统使用的是哪一种线程模型,使用getconf GNU_LIBPTHREAD_VERSION
命令,这会产生类似于以下的输出结果:
NPTL 2.12
或者
linuxthreads-0.10
对于linuxthreads线程模型
:一般情况下是限制一个进程最多可以创建1024线程
,当然这是可以改变的,不过我们这里讨论的是默认的情况。
对于nptl模型
:一个进程内可以创建的线程数量是不受限制
的。
你可以通过查看/usr/include/bits/local_lim.h
文件中的PTHREAD_THREADS_MAX
配置项来查看当前你的操作系统是如何限制一个进程可以创建的线程数,以本人的机器为例:
阅读蓝色部分的英文注释,可以看到对于一个进程可以创建的线程数量并没有任何限制。那么这是不是意味着可以在一个Java进程中创建无限多个线程呢?当然不是,因为线程的数量除了受到操作系统使用的线程模型的限制外,还要受到系统资源的限制。
2、系统资源对线程数量的限制
我们前面提到,每个线程都会有自己的栈内存空间,而这都是系统内存中的一部分。因为系统的内存是有限的,因此不论使用的是哪种线程模型,你可以创建的线程数量实际上都是有限的。
通过ulimit -s
命令,可以查看操作系统默认情况下的线程栈的大小。
[root@www work]# ulimit -s
10240
显示的结果是10240
,这是以字节
为单位的,因此就是10M
。
对于JVM而言,默认情况下,一个线程的栈内存是1M
。
因此,不论是哪种情况,你你可以创建的线程数量必然是要受到限制的。而且需要注意的是,通常情况下,操作系统中会有很多进程,因此内存是划分给多个进程,而不仅仅是java进程。
因此对于Java程序员而言,一个Java进程中可以创建的线程数主要是受到JVM可以使用的内存大小的影响,而不是操作系统的总内存。
3、Java线程与操作系统线程的一一对应关系
Java里的线程是由JVM来管理的,它如何对应到操作系统的线程是由JVM的实现来确定的。Linux 2.6上的HotSpot使用了NPTL机制,JVM线程跟内核线程有一一对应的关系
。这样做的好处是什么?线程的调度完全交给了操作系统内核,当然jvm还保留一些策略足以影响到其内部的线程调度
。
1.7 并发编程模型
2016-05-29 18:34:36
并发系统可以采用多种并发编程模型
来实现。并发模型指定了系统中的线程如何通过协作来完成分配给它们的作业。不同的并发模型采用不同的方式拆分作业,同时 线程间的协作和交互方式也不相同。这篇并发模型教程将会较深入地介绍目前(2015年,本文撰写时间)比较流行的几种并发模型。
并发模型与分布式系统之间的相似性
本文所描述的并发模型类似于分布式系统中使用的很多体系结构。在并发系统中线程之间可以相互通信。在分布式系统中进程之间也可以相互通信(进程有可能在不同的机器中)。线程和进程之间具有很多相似的特性。这也就是为什么很多并发模型通常类似于各种分布式系统架构。
当然,分布式系统在处理网络失效、远程主机或进程宕掉等方面也面临着额外的挑战。但是运行在巨型服务器上的并发系统也可能遇到类似的问题,比如一块CPU失效、一块网卡失效或一个磁盘损坏等情况。虽然出现失效的概率可能很低,但是在理论上仍然有可能发生。
由于并发模型类似于分布式系统架构,因此它们通常可以互相借鉴思想。例如,为工作者们(线程)分配作业的模型一般与分布式系统中的负载均衡系统比较相似。同样,它们在日志记录、失效转移、幂等性等错误处理技术上也具有相似性。
【注:幂等性
,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同】
一 并行工作者
第一种并发模型就是我所说的并行工作者模型
。传入的作业会被分配到不同的工作者上。下图展示了并行工作者模型:
在并行工作者模型中,委派者(Delegator
)将传入的作业分配给不同的工作者。每个工作者完成整个任务。工作者们并行运作在不同的线程上,甚至可能在不同的CPU上。
如果在某个汽车厂里实现了并行工作者模型,每台车都会由一个工人来生产。工人们将拿到汽车的生产规格,并且从头到尾负责所有工作。
在Java应用系统中,并行工作者模型是最常见的并发模型(即使正在转变)。java.util.concurrent
包中的许多并发实用工具都是设计用于这个模型的。你也可以在Java企业级(J2EE)应用服务器的设计中看到这个模型的踪迹。
1.1 并行工作者模型的优点
并行工作者模式的优点是,它很容易理解。你只需添加更多的工作者来提高系统的并行度。
例如,如果你正在做一个网络爬虫,可以试试使用不同数量的工作者抓取到一定数量的页面,然后看看多少数量的工作者消耗的时间最短(意味着性能最 高)。由于网络爬虫是一个IO密集型工作,最终结果很有可能是你电脑中的每个CPU或核心分配了几个线程。每个CPU若只分配一个线程可能有点少,因为在 等待数据下载的过程中CPU将会空闲大量时间。
1.2 并行工作者模型的缺点
并行工作者模型虽然看起来简单,却隐藏着一些缺点。接下来的章节中我会分析一些最明显的弱点。
1)共享状态可能会很复杂
在实际应用中,并行工作者模型可能比前面所描述的情况要复杂得多。共享的工作者经常需要访问一些共享数据
,无论是内存中的或者共享的数据库中的。下图展示了并行工作者模型是如何变得复杂的:
有些共享状态是在像作业队列这样的通信机制下。但也有一些共享状态是业务数据,数据缓存,数据库连接池等。
一旦共享状态潜入到并行工作者模型中,将会使情况变得复杂起来。线程需要以某种方式存取共享数据,以确保某个线程的修改能够对其他线程可见(数据修改需要同步到主存中,不仅仅将数据保存在执行这个线程的CPU的缓存中)。线程需要避免竟态
,死锁
以及很多其他共享状态的并发性问题。
此外,在等待访问共享数据结构时,线程之间的互相等待将会丢失部分并行性。许多并发数据结构是阻塞的,意味着在任何一个时间只有一个或者很少的线程 能够访问。这样会导致在这些共享数据结构上出现竞争状态。在执行需要访问共享数据结构部分的代码时,高竞争基本上会导致执行时出现一定程度的串行化。
现在的非阻塞并发算法
也许可以降低竞争并提升性能,但是非阻塞算法的实现比较困难。
可持久化的数据结构
是另一种选择。在修改的时候,可持久化的数据结构总是保护它的前一个版本不受影响。因此,如果多个线程指向同一个可持久化的数据 结构,并且其中一个线程进行了修改,进行修改的线程会获得一个指向新结构的引用。所有其他线程保持对旧结构的引用,旧结构没有被修改并且因此保证一致性。 Scala编程包含几个持久化数据结构。
【注:这里的可持久化数据结构不是指持久化存储,而是一种数据结构,比如Java中的String类,以及CopyOnWriteArrayList类,具体可参考】
虽然可持久化的数据结构在解决共享数据结构的并发修改时显得很优雅,但是可持久化的数据结构的表现往往不尽人意。比如说,一个可持久化的链表需要在头部插入一个新的节点,并且返回指向这个新加入的节点的一个引用(这个节点指向了链表的剩余部分)。所有其他现场仍然保留了这个链表之前的第一个节点,对于这些线程来说链表仍然是为没改变的。它们无法看到新加入的元素。
这种可持久化的列表采用链表来实现。不幸的是链表在现代硬件上表现的不太好。链表中得每个元素都是一个独立的对象,这些对象可以遍布在整个计算机内 存中。现代CPU能够更快的进行顺序访问,所以你可以在现代的硬件上用数组实现的列表,以获得更高的性能。数组可以顺序的保存数据。CPU缓存能够一次加 载数组的一大块进行缓存,一旦加载完成CPU就可以直接访问缓存中的数据。这对于元素散落在RAM中的链表来说,不太可能做得到。
2)无状态的工作者
共享状态能够被系统中得其他线程修改。所以工作者在每次需要的时候必须重读状态,以确保每次都能访问到最新的副本,不管共享状态是保存在内存中的还是在外部数据库中。工作者无法在内部保存这个状态(但是每次需要的时候可以重读)称为无状态的。每次都重读需要的数据,将会导致速度变慢,特别是状态保存在外部数据库中的时候。
3)任务顺序是不确定的
并行工作者模式的另一个缺点是,作业执行顺序是不确定的。无法保证哪个作业最先或者最后被执行。作业A可能在作业B之前就被分配工作者了,但是作业B反而有可能在作业A之前执行。
并行工作者模式的这种非确定性的特性,使得很难在任何特定的时间点推断系统的状态。这也使得它也更难(如果不是不可能的话)保证一个作业在其他作业之前被执行。
2 流水线模式
第二种并发模型我们称之为流水线并发模型
。我之所以选用这个名字,只是为了配合“并行工作者”的隐喻。其他开发者可能会根据平台或社区选择其他称呼(比如说反应器系统,或事件驱动系统)。下图表示一个流水线并发模型:
类似于工厂中生产线上的工人们那样组织工作者。每个工作者只负责作业中的部分工作
。当完成了自己的这部分工作时工作者会将作业转发给下一个工作者。每个工作者在自己的线程中运行,并且不会和其他工作者共享状态。有时也被称为无共享并行模型
。
通常使用非阻塞的IO
来设计使用流水线并发模型的系统。非阻塞IO意味着,一旦某个工作者开始一个IO操作的时候(比如读取文件或从网络连接中读取 数据),这个工作者不会一直等待IO操作的结束。IO操作速度很慢,所以等待IO操作结束很浪费CPU时间。此时CPU可以做一些其他事情。当IO操作完 成的时候,IO操作的结果(比如读出的数据或者数据写完的状态)被传递给下一个工作者。
有了非阻塞IO,就可以使用IO操作确定工作者之间的边界
。工作者会尽可能多运行直到遇到并启动一个IO操作。然后交出作业的控制权。当IO操作完成的时候,在流水线上的下一个工作者继续进行操作,直到它也遇到并启动一个IO操作。
在实际应用中,作业有可能不会沿着单一流水线进行。由于大多数系统可以执行多个作业,作业从一个工作者流向另一个工作者取决于作业需要做的工作。在实际中可能会有多个不同的虚拟流水线同时运行。这是现实当中作业在流水线系统中可能的移动情况:
Image.png作业甚至也有可能被转发到超过一个工作者上并发处理。比如说,作业有可能被同时转发到作业执行器和作业日志器。下图说明了三条流水线是如何通过将作业转发给同一个工作者(中间流水线的最后一个工作者)来完成作业:
[图片上传中...(image-b7c0cb-1523543208957-4)]
流水线有时候比这个情况更加复杂。
反应器,事件驱动系统
采用流水线并发模型的系统有时候也称为反应器系统
或事件驱动系统
。系统内的工作者对系统内出现的事件做出反应,这些事件也有可能来自于外部世界或者 发自其他工作者。事件可以是传入的HTTP请求,也可以是某个文件成功加载到内存中等。在写这篇文章的时候,已经有很多有趣的反应器/事件驱动平台可以使 用了,并且不久的将来会有更多。比较流行的似乎是这几个:
-
Vert.x
-
AKKa
-
Node.JS(JavaScript)
我个人觉得Vert.x是相当有趣的(特别是对于我这样使用Java/JVM的人来说)
Actors 和 Channels
Actors
和 channels
是两种比较类似的流水线(或反应器/事件驱动)模型。
在Actor模型中每个工作者被称为actor。Actor之间可以直接异步地发送和处理消息。Actor可以被用来实现一个或多个像前文描述的那样的作业处理流水线。下图给出了Actor模型:
Image.png而在Channel模型中,工作者之间不直接进行通信。相反,它们在不同的通道中发布自己的消息(事件)。其他工作者们可以在这些通道上监听消息,发送者无需知道谁在监听。下图给出了Channel模型:
Image.png在写这篇文章的时候,channel模型对于我来说似乎更加灵活。一个工作者无需知道谁在后面的流水线上处理作业。只需知道作业(或消息等)需要转 发给哪个通道。通道上的监听者可以随意订阅或者取消订阅,并不会影响向这个通道发送消息的工作者。这使得工作者之间具有松散的耦合。
2.1 流水线模型的优点
相比并行工作者模型,流水线并发模型具有几个优点,在接下来的章节中我会介绍几个最大的优点。
1)无需共享的状态
工作者之间无需共享状态,意味着实现的时候无需考虑所有因并发访问共享对象而产生的并发性问题。这使得在实现工作者的时候变得非常容易。在实现工作者的时候就好像是单个线程在处理工作-基本上是一个单线程的实现。
2)有状态的工作者
当工作者知道了没有其他线程可以修改它们的数据,工作者可以变成有状态的。对于有状态,我是指,它们可以在内存中保存它们需要操作的数据,只需在最后将更改写回到外部存储系统。因此,有状态的工作者通常比无状态的工作者具有更高的性能。
3)较好的硬件整合(Hardware Conformity)
单线程代码在整合底层硬件的时候往往具有更好的优势。首先,当能确定代码只在单线程模式下执行的时候,通常能够创建更优化的数据结构和算法。
其次,像前文描述的那样,单线程有状态的工作者能够在内存中缓存数据。在内存中缓存数据的同时,也意味着数据很有可能也缓存在执行这个线程的CPU的缓存中。这使得访问缓存的数据变得更快。
我说的硬件整合是指,以某种方式编写的代码,使得能够自然地受益于底层硬件的工作原理。有些开发者称之为mechanical sympathy。 我更倾向于硬件整合这个术语,因为计算机只有很少的机械部件,并且能够隐喻“更好的匹配(match better)”,相比“同情(sympathy)”这个词在上下文中的意思,我觉得“conform”这个词表达的非常好。当然了,这里有点吹毛求疵 了,用自己喜欢的术语就行。
4)合理的作业顺序
基于流水线并发模型实现的并发系统,在某种程度上是有可能保证作业的顺序的。作业的有序性使得它更容易地推出系统在某个特定时间点的状态。更进一 步,你可以将所有到达的作业写入到日志中去。一旦这个系统的某一部分挂掉了,该日志就可以用来重头开始重建系统当时的状态。按照特定的顺序将作业写入日 志,并按这个顺序作为有保障的作业顺序。下图展示了一种可能的设计:
Image.png
实现一个有保障的作业顺序是不容易的,但往往是可行的。如果可以,它将大大简化一些任务,例如备份、数据恢复、数据复制等,这些都可以通过日志文件来完成。
2.2 流水线模型的缺点
流水线并发模型最大的缺点是作业的执行往往分布到多个工作者上,并因此分布到项目中的多个类上。这样导致在追踪某个作业到底被什么代码执行时变得困难。
同样,这也加大了代码编写的难度。有时会将工作者的代码写成回调处理的形式。若在代码中嵌入过多的回调处理,往往会出现所谓的回调地狱 (callback hell)现象。所谓回调地狱,就是意味着在追踪代码在回调过程中到底做了什么,以及确保每个回调只访问它需要的数据的时候,变得非常困难
使用并行工作者模型可以简化这个问题。你可以打开工作者的代码,从头到尾优美的阅读被执行的代码。当然并行工作者模式的代码也可能同样分布在不同的类中,但往往也能够很容易的从代码中分析执行的顺序。
3 函数式并行(Functional Parallelism)
第三种并发模型是函数式并行模型
,这是也最近(2015)讨论的比较多的一种模型。函数式并行的基本思想是采用函数调用实现程序。函数可以看作是” 代理人(agents)“或者”actor“,函数之间可以像流水线模型(AKA 反应器或者事件驱动系统)那样互相发送消息。某个函数调用另一个函数,这个过程类似于消息发送。
函数都是通过拷贝来传递参数的,所以除了接收函数外没有实体可以操作数据。这对于避免共享数据的竞态来说是很有必要的。同样也使得函数的执行类似于原子操作。每个函数调用的执行独立于任何其他函数的调用。
一旦每个函数调用都可以独立的执行,它们就可以分散在不同的CPU上执行了。这也就意味着能够在多处理器上并行的执行使用函数式实现的算法。
Java7中的java.util.concurrent包里包含的ForkAndJoinPool
能够帮助我们实现类似于函数式并行的一些东西。而Java8中并行streams
能够用来帮助我们并行的迭代大型集合。记住有些开发者对ForkAndJoinPool进行了批判(你可以在我的ForkAndJoinPool教程里面看到批评的链接)。
函数式并行里面最难的是确定需要并行的那个函数调用。跨CPU协调函数调用需要一定的开销。某个函数完成的工作单元需要达到某个大小以弥补这个开销。如果函数调用作用非常小,将它并行化可能比单线程、单CPU执行还慢。
我个人认为(可能不太正确),你可以使用反应器或者事件驱动模型实现一个算法,像函数式并行那样的方法实现工作的分解。使用事件驱动模型可以更精确的控制如何实现并行化(我的观点)。
此外,将任务拆分给多个CPU时协调造成的开销,仅仅在该任务是程序当前执行的唯一任务时才有意义。但是,如果当前系统正在执行多个其他的任务时 (比如web服务器,数据库服务器或者很多其他类似的系统),将单个任务进行并行化是没有意义的。不管怎样计算机中的其他CPU们都在忙于处理其他任务, 没有理由用一个慢的、函数式并行的任务去扰乱它们。使用流水线(反应器)并发模型可能会更好一点,因为它开销更小(在单线程模式下顺序执行)同时能更好的 与底层硬件整合。
4 使用那种并发模型最好?
所以,用哪种并发模型更好呢?
通常情况下,这个答案取决于你的系统打算做什么。如果你的作业本身就是并行的、独立的并且没有必要共享状态,你可能会使用并行工作者模型去实现你的 系统。虽然许多作业都不是自然并行和独立的。对于这种类型的系统,我相信使用流水线并发模型能够更好的发挥它的优势,而且比并行工作者模型更有优势。
你甚至不用亲自编写所有流水线模型的基础结构。像Vert.x这种现代化的平台已经为你实现了很多。我也会去为探索如何设计我的下一个项目,使它运行在像Vert.x这样的优秀平台上。我感觉JavaEE已经没有任何优势了。
网友评论