您的位置:首页 > 编程语言 > Java开发

Java— 齐头并进完成任务—多线程

2020-08-07 18:47 756 查看

感知多线程

Java是少数的几种支持“多线程”的语言之一。大多数的程序语言只能循序运行单独的一个程序块,无法同时运行不同的多个程序块。Java的“多线程”恰可弥补这个缺憾,它可以让不同的程序块并发执行,如此一来就可让程序运行得更为顺畅,同时也可达到多任务处理的目的。

现实生活中的多线程

任何抽象的理论(本质)都离不开具体的现象。通过现象比较容易看清楚本质,在没有讲解Java的多线程概念之前,我们先从现实生活中体会一下“多线程”。

在高速公路收费匝道上,经常会看到排成长龙的车队。如果让你来缓解这一拥塞的交通状况,你的方案是什么?很自然地,你会想到多增加几个收费匝道,这样便能同时通过更多的车辆。如果把进程比作一个高速公路的收费站,那么这个地点的多个收费匝道就可以比作线程。

再举一个例子,在一个行政收费大厅里,如果只有一个办事窗口,等待办事的客户很多,如果排队序列中前面的一个客户没有办完事情,后面的客户再着急也无济于事,一个较好的方案就是在行政大厅里多开放几个窗口,更一般的情况是,每个窗口可以办理不同的事情,这样客户可以根据自己的需求来选择服务的窗口。如果把行政大厅比喻为一个进程,那么每一个办事窗口都是一个线程。

目前,在一个应用程序中部署多线程技术已经非常普及了,读者可能已在不经意间体验到多线程的好处了。例如,在我们常用的Eclipse软件中,在编辑区编写代码时,编辑器是需要一个线程来维持的,而当我们敲入了非法的Java语句,编译器中立马就报错了(出现红线或有提示框),之所以会这样便捷,是因为有多线程来支撑,一个线程完成代码编辑,而另外一个线程对即时输入的代码实施语法分析(事实上,Eclipse软件使用远远不止两个线程)。倘若在单线程状态下,情况变得就很糟糕。要么等待编辑代码完毕,然后再做语法检查。要么等语法分析结束后,再继续编辑代码。可试想一下,如果每编写一段代码,编辑区就会卡死一会,直到语法分析结束才能编辑代码,这样的集成开发环境是不会有人用的。

而现在的浏览器都支持多窗口、多标签。其实这也是多线程的一个表现,试想一下,如果没有多线程,那么每次只能打开一个网页,如果连接不是很好的话,只能苦等,这是何等的痛苦。而有了多线程技术,情况得以改善,如果一个多标签的网页打不开,我们很容易地切换至其他可打开的网页,稍等一会再来浏览原来打开的网页。此外,我们经常为网络流读取、IO读取及下载等一些任务量比较巨大的、耗时比较长的任务单独开发一个线程(如迅雷的多线程下载等)。

由此我们可以发现,多线程技术就在我们身边,且占据着着非常重要的地位。多线程是实现并发机制的一种有效手段,其应用范围很广。Java的多线程是一项非常基本和重要的技术,在偏底层和偏技术的Java程序中不可避免地要使用到它,因此,我们有必要学习好这一技术。

进程与线程

这里,我们首先回顾一下操作系统中的两个重要概念:什么是进程?什么是线程?

简单来讲,进程就是一个执行中的程序。它是一个动态的概念。当我们下载一个QQ程序(腾讯公司出品的一款即时聊天软件)时,程序是静态不变的,而当我们开启3个QQ窗口聊天时,实际上就是开启了三个QQ进程。由此可见,一个程序是可以对应多个进程的。每一个进程都有自己独立的一组系统资源(包括处理机、内存等)。在进程的概念中,每一个进程的内部数据和状态都是完全独立的。这是容易理解的,回到前面的例子,当我们开启3个QQ窗口聊天时,3个QQ进程对应的聊天对象、聊天内容都是不同的。

由此我们可以发现,多线程就在我们身边,而且占着非常重要的地位,经常为网络流读取,IO读取,下载等一些任务量比较巨大,耗时比较长的任务单独开发一个线程。

进程是操作系统的资源分配单位,创建并执行一个进程的系统开销是比较大的。相比而言,线程是进程的一个执行路径。多线程(Multithread)指的是在单个进程中同时运行多个不同的线程,执行不同的任务。多线程意味着一个程序的多行语句块并发执行。

同属一个进程的多个线程是共享一块内存空间和一组系统资源,属于线程本身的数据通常只有的寄存器和堆栈内的数据。所以,当进程生产一个线程,或者在各个线程之间切换时,负担要比进程小得多,正因如此,线程也被称为轻负荷进程(light-weight process),如下图所示。

进程的调度是带资源调度的,而线程的调度是不带资源的。就如同参加接力赛跑一样,对于进程来说,它们就是背着书包(资源)跑,故此运动员(进程)在交接资源时,比较慢。而对于线程来说,它就好比就是轻装上阵,线程间的切换是便捷的。

在一般情况下,程序的某些部分同特定的事件或资源联系在一起,同时又不想为它而暂停程序其他部分的执行,在这种情况下,就可以考虑创建一个线程,令它与那个事件或资源关联到一起,并让它独立于主程序运行。通过使用线程,可以避免用户在运行程序和得到结果之间的停顿,还可以让一些任务(如打印任务)在后台运行,而用户则在前台继续完成一些其他的工作。再例如,在Android程序开发中,多线程成了必不可少的一项重要技术。利用多线程技术,可以使编程人员方便地开发出能同时处理多个任务的功能强大的应用程序。

但是需要大家注意的是,多线程“同时”执行是给人的一种感觉,而实际上在线程之间轮换执行,只不过多个线程之间切换的延迟足够短,给人的感觉好像是在同时执行一样。

体验多线程

在传统的程序语言里,运行的顺序总是顺着程序的流程走,遇到if语句就加以判断,遇到for等循环就会多执行几次,最后程序还是按着一定的流程走,且一次只能执行一个程序块。

Java的“多线程”打破了这种传统的束缚。所谓的线程(Thread)是指程序的运行流程,“多线程”的机制则是指可以同时执行运行多个程序块,可克服传统程序语言所无法解决的问题。例如,有些循环可能运行的时间比较长,此时便可让一个线程来做这个循环,另一个线程做其他事情,比如与用户交互。

单一线程的运行流程(ThreadDemo 1.java)


第16~22行定义了run()方法,用于循环输出5个连续的字符串。

在第05行中,使用new关键词,创建了一个TestThread类的无名对象,之后这个类名通过点操作符“.”调用这个对象的run()方法,输出“TestThread 在运行”,最后执行main方法中的循环,输出“main 线程在运行”。

从本例中可看出,如果要想运行main方法中的for循环(第06~10行),必须要等TestThread类中的run()方法执行完后才可以运行,假设run()方法不是一个简单的for循环,是一个运行时间很长的方法,那么即使后面的代码块(例如main方法放后面的for循环块),不依赖于前面的代码块的计算结果,它也“无可奈何”地必须等待。这便是单一线程的缺陷。在Java里,是否可以并发运行第9与20行的语句,使得字符串“main 线程在运行”和“TestThread 在运行”交替输出呢?答案是肯定的,其方法是——在Java里激活多个线程。下面我们就开始学习Java中如何激活多个线程。

通过继承Thread类实现多线程

Thread类存放于java.lang类库里。java.lang包中提供常用的类、接口、一般异常、系统等编程语言的核心内容,如基本数据类型、基本数学函数、字符串处理、线程、异常处理类等,系统默认加载这个包,所以我们可以直接使用Thread类。在Thread类中定义了run()方法,要想实现多线程,必须覆写Thread类的run方法。也就是说要使一个类可激活线程,必须按照下面的语法来编写。

然后使用该类的对象调用start()方法,从而来激活一个新的线程。下面我们按照上述的语法来重新编写ThreadDemo_1,使它可以同时激活多个线程。

同时激活多个线程(ThreadDemo 2.java)。



第23~41行定义了的TestThread类,其继承父类Thread,并覆写了run方法。因此可以使用这个类激活一个新的线程。在run方法中,使用了try-catch模块,来捕获可能产生的异常。在第15行中的InterruptedException,表示中断异常类,Thread.sleep() 和 Object.wait(),都可以抛出这类中断异常。一旦发生异常,printStackTrace()方法输出异常信息。

第05行创建了一个TestThread类的匿名对象,并调用了start()方法创建了一个新的线程。

第13、33行使用Thread.sleep(1000)方法使两个线程休眠1000毫秒,以模拟其他耗时的操作。如果省略了这两条语句,这个程序的运行结果和可能和【范例(ThreadDemo 1.java)】一样(类似)

需要注意的是,读者运行本例程的结果可能和这里提供的运行结果不一样,这是容易理解的,因为多线程的执行顺序具有一定的不确定性。

通过Runnable接口实现多线程

在Java中不允许多继承,即一个子类只能有一个父类,因此如果一个类已经继承了其他类,那么这个类就不能再用继承Thread类。此时,如果一个其他类的子类又想采用多线程技术,那么这时就要用到Runnable接口,来创建线程。接口可以实现多继承。

通过实现Runnable接口实现多线程的语法如下。

需要注意的是,激活一个新线程同样使用Thread类的start方法。

用Runnable接口实现多线程使用实例(RunnableThread.java)。



第16行中的TestThread类实现了Runnable接口,同时覆写了Runnable接口之中的run()方法,也就是说此类为一个多线程实现类。

第03行实例化了一个TestThread类的对象。

第04行通过TestThread类去实例化一个Thread类的匿名对象,之后调用start()方法启动多线程。

可能大家会不理解,为什么实现了Runnable接口还需要调用Thread类中的start方法才能启动多线程?查找JDK文档就可以发现,在Runnable接口中只有一个run方法,如下表所示。

也就是说在Runnable接口并没有start方法,所以一个类即使实现了Runnable接口,也需用Thread类中的start方法来启动多线程。对这一点,通过查找JDK文档中的Thread类可以看到,在Thread类之中有这样一个构造方法。

由此构造方法可以看到,可以将一个Runnable接口(或其子类)的实例化对象作为参数去实例化Thread类对象(参见本范例的第04行)。在实际的开发中,建议大家尽可能的使用Runnable接口去实现多线程机制。

两种多线程实现机制的比较

从前面的分析得知,不管实现了Runnable接口还是继承了Thread类,其结果都是一样的,那么这两者之间到底有什么关系?

通过查阅JDK文档,可以发现Runnable接口和Thread类二者之间的联系,如下图所示。

由上图可知,Thread类实现Runnable接口。即在本质上,Thread类是Runnable接口的一个子类。通过前面章节的学习可以知道,接口是功能的集合,也就是说,只要实现了Runnable接口,就具备了可执行的功能,其中run()方法的实现就是可执行的表现。

Thread类和Runnable接口都可以实现多线程,那么两者之间除了上面这些联系之外,有什么区别呢?下面通过编写一个应用程序来比较分析。下面是一个模拟铁路售票系统的范例,实现4个售票点发售某日某次列车的车票20张,一个售票点用一个线程来表示。下面,首先用继承Thread类来实现上述功能。

使用Thread实现多线程模拟铁路售票系统(ThreadDemo 3.java)。


第3行创建了一个TestThread类的实例化对象,之后调用了4个start()方法(第05~08行)。但从运行结果可以看到,程序运行时出现了异常,之后却只有一个线程在运行。这说明了一个类继承了Thread类之后,这个类的实例化对象无论调用多少次start方法,结果都只有一个线程在运行。

另外,在第15行可以看到这样一条语句"Thread.currentThread().getName()",此语句表示取得当前运行的线程名称,此方法会在后面讲解。

下面修改ThreadDemo_3程序,让main方法中产生4个线程。

修改ThreadDemo 3,使main方法里产生4个线程(ThreadDemo 4.java)。


第04~07行,分别使用new TestThread().start()并发了4个线程对象

从这部分输出结果中可以看出,这4个线程对象各自占有自己的资源,例如,这4个线程的每个线程都有自己的数据tickets(第12行),我们的本意是一共有5张票,每个线程模拟一个售票窗口,一起把这5张票卖完,但从运行的结果可以看出,每个线程都卖了5张票,这样就卖出了4×5=20张票,这不是我们所需要的。因此,用Thread实际上很难达到资源共享的目的,但是可以通过静态变量达到资源共享,例如,可将tickets设置为static类型的。

那么如果我们实现Runnable接口会如何呢?下面的这个范例也修改自于ThreadDemo_3,读者可以观察输出的结果。

使用Runnable接口实现多线程,并实现资源共享(RunnableDemo.java)。


第05~08行启动了4个线程。从程序的输出结果来看,尽管启动了4个线程对象,但结果都是操纵同一个资源(即tickets=5),这4个线程一起协同把这5张票卖完了,实现了资源共享的目的。

可见,实现Runnable接口相对于继承Thread类来说,有如下几个显著的优势。

⑴ 避免由于Java的单继承特性带来的局限。

⑵ 可以使多个线程共享相同的资源,以达到资源共享的目的。

细心的读者如果多运行几次本程序,就会发现,程序的运行结果不唯一,事实上,就是产生了与时间有关的错误。这是“共享”付出的代价,比如在上面的运行结果中,第5张票就被线程0、线程2和线程3卖了3次,出现“一票多卖”的现象,这是当tickets=1时,线程0、线程2和线程3都同时看见了,满足条件tickets > 0,当第一个线程就把票卖出去了,tickets理应减1(参加第16行),当它还没有来得及更新,当前的线程的运行时间片就到了,必须推出CPU,让其他线程执行,而其他线程看到的tickets依然是旧状态(tickets=1),所以,依次也把那张已经卖出去的票再次“卖”出去了。事实上,在多线程运行环境中,tickets属于典型的临界资源(Critical resource),而第13~17行就属于临界区(Critical Section)。多个进程中涉及到同一个临界资源的临界区称为相关临界区。

线程的状态

每个Java程序都有一个默认的主线程,对于Java应用程序,主线程是main方法执行的线程;对于Applet程序,主线程是指挥浏览器加载并执行Java Applet程序的线程。要想实现多线程,必须在主线程中创建新的线程对象。

正如一条公路会有它的生命周期,例如,规划、建造、使用、停用、拆毁等状态,而一个线程也有类似的这几种状态。线程具有5种状态,即创建、就绪、运行、阻塞、终止。线程状态的转移与转移原因之间的关系如下图所示。

在给定时间点上,一个线程只能处于一种状态(详见JDK文档 Thread.State)

⑴ New(创建态):至今尚未启动的线程处于这种状态。

⑵ Runnable(运行态):正在Java虚拟机中执行的线程处于这种状态。

⑶ Blocked(阻塞态):受阻塞并等待某个监视器锁的线程处于这种状态。

⑷ Waiting(无限等待态):无限期的等待另一个线程来执行某一个特定操作的线程处于这种状态。

⑸ Timed_Waiting(限时等待态):具有指定等待时间的某一等待线程的线程状态。

⑹ Terminated(终止态):已退出的线程处于这种状态。

演示线程的生命周期。



第01行代码,导入使用了Scanner类,它所定义的对象scanner(第05行),类似与C++中输入对象cin。第08行,next()方法查看scanner输入的字符串,此时线程由于等待I/O而阻塞。在第09行,使用close()方法,结束scanner对象,系统不再等待I/O,线程重新进入就绪状态。

需要读者注意的是,多线程给用户提供的仅是一种“宏观并行”的错觉,同属一个进程的多个线程切换,时空开销比较小,当这种切换足够快(因此程序的响应性就比较好),就给用户的感觉,多个线程是在“同时”执行。而事实上,由于CPU一次只能执行一条指令,所以对于单核CPU来说,在某一特定时刻t,只能执行一个线程,因此在t时刻,只有一个线程正处于运行态,这就是所谓的“微观串行”。

现在回头看看【范例(ThreadDemo 2.java)】,在最后我们说“如果将那两条Thread.sleep(1000)省略,程序的运行结果可能和【范例(ThreadDemo 1.java)】一样(或类似)。”为什么会这样呢?想一下,在某一时刻t只有一个线程在运行,所以一种可能的结果是,main线程(主线程)的for循环执行完毕了(或时间片到),系统才将main线程从运行状态切换至就绪状态,这时再转而去执行TestThread的线程,此时输出的结果就和【范例(ThreadDemo 1.java)】一样了。当然如果for的次数比较多,从而运行时间比较长,例如,超过一个时间片,就不会出现这种状况了。

线程操作的一些方法

操作线程的主要方法在Thread类中,下表列出了Thread类中的主要方法,读者朋友可以查阅SDK文档获得更多线程方法的信息。

取得和设置线程的名称

在Thread类中,可以通过getName方法取得线程的名称,通过setName方法设置线程的名称。线程的名称一般在启动线程前设置,但也允许为已经运行的线程设置名称。允许两个Thread对象有相同的名称,但为了清晰,应尽量避免这种情况的发生。如果程序并没有为线程制定名称,系统会自动为线程分配名称,此外,Thread类中currentThread()也是个常用的方法,它是个静态方法,该方法的返回值是执行该方法的线程实例。

线程名称的操作(GetNameThreadDemo.java)



第01行声明了一个GetNameThreadDemo类,此类继承自Thread类,之后第02~11行覆写(Override)了Thread类中的run方法。

第13~18行声明了一个printMsg方法,此方法用于取得当前线程的信息。第15行,通过Thread类中的currentThread()方法,返回一个Thread类的实例化对象。由于currentThread()是静态方法,所以它的访=问方式是“类名.方法名”,此方法返回当前正在运行的线程及返回正在调用此方法的线程。第16行通过Thread类中的getName方法,返回当前运行的线程的名称。

第04行和第24行分别调用了printMsg方法,但第04行是从多线程的run方法中调用的,而第24行则是从main方法中调用的。

为了捕获可能发生的异常,在使用线程的sleep()方法时,要使用try{}和catch{}代码块。try{}块内包括的是可能发生异常的代码,而catch{}代码块包括的是一旦发生异常,捕获并处理这些异常的代码,其中printStackTrace()方法的用途是输出异常信息的。

提示
有些读者可能不理解,为什么程序中输出的运行线程的名称中会有一个main呢?这是因为main()方法也是一个线程,实际上在命令行中运行java命令时,就启动了一个JVM的进程,默认情况下,此进程会产生多个线程,如main方法线程,垃圾回收线程等。

下面看一下如何在线程中设置线程的名称。

设置与获取线程名(GetSetNameThreadDemo.java)


第01行声明了一个GetSetNameThreadDemo类,它实现了Runnable接口,同时覆写了Runnable接口之中的run()方法(第03~07行)。

在第10行,用new GetSetNameThreadDemo()创建一个GetSetNameThreadDemo类的无名对象,然后这个无名对象作为Thread类的构造方法中的参数,创建一个新的线程对象t。第11行,使用了setName()方法设置了这个线程对象的名称为“线程_范例22-8”。第12行,用start()方法开启了这个线程,新线程在运行状态时,会自动执行run()方法。

这里,我们需要注意区分Thread中的start()和run()方法的联系和不同。

⑴ start()方法。它的作用是启动一个新线程,有了它的调用,才能真正实现多线程运行,这时无需等待run方法体代码执行完毕,而是直接继续执行start()其后的代码。读者可以这样理解,start()的调用,使得主线程“兵分两路”——创建了一个新线程,并使得这个线程进入“就绪状态”。 “就绪状态”其实就是“万事俱备,只欠CPU”。读者可参见上面的线程状态图。如果主线程执行完start()语句后,它的CPU时间片还没有用完,那么它就会很自然的接着运行start()后面的语句。

一旦新的线程获得CPU时间片,就开始执行run()方法,这里run()方法称为线程体,它包含了这个线程要执行的内容,一旦run()方法运行结束,那么此线程随即终止。

此外,要注意start()不能被重复调用。例如,【范例(ThreadDemo 3.java)】调用了多次start(),除了得到一个异常中断外,并没有多创建新的线程。

⑵ run()方法。run()方法只是类的一个普通方法而已,如果直接调用run()方法,程序中依然只有主线程这一个线程,其程序执行路径依然只有一条,也就是说,一旦run()方法被调用,程序还要顺序执行,只有run()方法体执行完毕后,才可继续执行其后的代码,这样并没有达到多线程并发执行的目的。

由于run()方法和普通的成员方法一样,所以,很自然地,它可以被重复调用多次。每次单独调用run(),就会在当前线程中执行run(),而并不会启动新线程。

判断线程是否启动

在下面的程序中,我们可以通过isAlive()方法来测试线程是否已经启动而且仍然在运行。

判断线程是否启动。



在第16行中,在线程运行之前调用isAlive()方法,判断线程是否启动,但在此处并没有启动,所以返回false。第17行,开启新线程。

第18行在启动线程之后调用isAlive()方法,此时线程已经启动,所以返回true

第23行,在main方法快结束时调用isAlive()方法,此时的状态不再固定,有可能是true,也有可能是false。

守护线程与setDaemon方法

JVM(Java虚拟机)中线程分为两种,用户线程和守护线程。用户线程也称之为前台线程(一般线程)。对Java程序来说,只要还有一个用户线程在运行,这个进程就不会结束。守护线程(daemon)也称之为后台线程。顾名思义,守护线程就是守护其他线程的线程,它是指用户程序在运行时后台提供的一种通用服务的线程。例如,对于JVM来说,其中垃圾回收的线程就是一个守护线程。这类线程并不是用户线程不可或缺的部分,只是用于提供服务的“服务线程”。当线程中只剩下守护线程时JVM就会退出,反之,如果还有任何用户线程在,JVM都不会退出。

查看Thread源码可以知道这么一句话,

private boolean daemon = false;

这就意味着,默认创建的线程,都属于普通的用户线程。只有调用setDaemon(true) 之后,才能转成守护线程。

下面看一下进程中只有后台线程在运行的情况。

setDaemon()方法的使用( ThreadDaemon.java )


从程序和运行结果图中可以看到,虽然创建了一个无限循环的线程(第18行将for循环退出的条件设置为true,即永远都满足for循环条件),但因为它是守护线程,因此整个进程在主线程main结束时就随之终止运行了。这验证了进程中在只有守护线程运行时,进程就会结束的说法。

这里需要读者注意的是,设置为某个线程为守护线程时,一定要在start()方法调用之前设置,也就是说一个线程启动之前设置其属性(参加代码第06行和07行)。

线程的联合

一个线程A在占有CUP资源期间,可以让其他线程调用join()和本线程联合。如果一旦线程A在占有CUP资源期间联合B线程,那么A线程将立刻挂起(suspend),直到它所联合的线程B执行完毕,A线程再重新排队等待CUP资源,以便恢复执行。例如,B.join()方法阻塞调用此方法的A线程(calling thread),直到线程B完成,此线程再继续。通常用于在main()主线程内,等待其他线程完成再结束main()主线程。

演示线程的强制运行(ThreadJoin.java)。



本程序启动了两个线程,一个是main线程,另一个是pp线程。

在main线程中,如果for循环中的变量i=3,则在第10行调用了pp线程对象的join()方法,所以main线程暂停执行,直到pp线程执行完毕。所以输出结果和没有这句代码完全不一样。虽然pp线程需要运行10秒钟,但是它的输出结果还是在一起。也就是说pp线程没有运行完毕,main线程是被挂起,不被执行的。由此可以看到,join方法是可以用来控制某一线程的运行。

由此可见,pp线程和main线程由两个交替执行的线程合并为顺序执行的线程,就好象pp和main是一个线程,也就是说pp线程中的代码不执行完,main线程中的代码就只能一直等待。查看JDK文档可以发现,除了无参数的join方法外,还有两个带参数的join方法,分别是join( long millis ) 和join( long millis, int nanos ),它们的作用是指定最长等待时间,前者精确到毫秒,后者精确到纳秒,意思是如果超过了指定时间,合并的线程还没有结束,就直接分开。读者可以把上面的程序修改一下,再看看程序运行的结果。

线程的中断

在Java多线程编程中,经常会遇到需要中止线程的情况。例如,启动多个线程在数据库中搜索,如果有一个线程返回了需要的搜索结果,则其他线程就可以取消了。

在实施中断线程过程中,有三个函数比较常用的成员方法。

⑴ Thread.interrupt():来设置中断状态为true,当一个线程运行时,另一个线程可以调用另外一个线程对应的interrupt()方法来中断它。

⑵ Thread.isInterrupted():来获取线程的中断状态。

⑶ Thread.interrupted():这是一个静态方法,用来获取中断状态(),并清除中断状态,其获取的是清除之前的值,也就是说连续两次调用此方法,第二次一定会返回false。

线程的中断使用范例(SleepInterrupt.java)



第21行调用了sleep方法,将线程休眠2秒,这样做是为了保证run方法中的内容能够多执行一段时间。

第27行调用线程t的interrupt()方法,将t线程中断。使t线程产生一个InterruptedException异常,从而退出休眠状态。

需要注意的是,调用interrupt()方法并不会使正在执行的线程停止执行,它只对调用wait、join、sleep等方法或由于I/O操作等原因受阻的线程产生影响,使其退出暂停执行的状态(详见JDK文档)。

换句话说,它对正在运行的线程是不起作用的,只有对阻塞的线程有效。

当然,正在执行的线程可以通过isInterrupted()方法判断某个线程(包括自己)是否处于中断状态,以决定是否需要执行某些操作。

线程的中断使用范例2(SleepInterrupt.java)


第03行通过Thread类中的currentThread()方法取得当前运行的线程,也就是main线程。

第04行因为没有调用中断方法,所以此时线程没有被中断。但在第05行调用了中断方法,所以之后的线程状态都为中断,直到中断状态被清除。

第09行让线程休眠,但此时线程已经被中断,所以这个时候会抛出中断异常,然后清除中断标记,所以在最后判断是否中断的时候,会返回线程没有被中断。

多线程的同步

同步问题的引出

在前面讲解过的卖票程序中,很有可能碰到一种意外,就是同一个票号被打印两次或多次,也可能出现打印出的票号为0或负数的情况。这种意外(运行结果不唯一)出现的原因在于这部分代码。

假设tickets的值为1的时候,线程1刚执行完if( tickets > 0 )这行代码,正准备执行下面的代码(第03行以后的代码),操作系统却将CPU切换到了线程2上执行(这可能因为线程1在CPU中运行的时间片结束了),此时tickets的值没有来得及更新,其值仍为1。线程2执行完上面几行代码(01~05行), tickets的值变为0,这时CPU重新切换回线程1上执行,但此时线程1不会在判断tickets是否大于0,而是直接执行下面两条代码。

而此时tickets的值也变为0,屏幕打印出来的仍然是0。这样,仅剩下的1张票,被线程1和线程2“一票两卖”,显然,这是不正确的。如果读者在多运行几次这段代码,就会发现,运行的结果可能完全不一样。例如,如果线程1完全运行完毕,这时才轮到线程2执行,那么最终的结果就是0,且“一票一卖”,但对于一个稳定的票务系统来说,我们不能赌运气。

为了模拟上面描述的这种情况,我们可以在程序中调用Thread.sleep()方法来刻意造成线程间的这种切换。Thread.sleep()方法将迫使线程执行到该处后暂停执行,让出CPU给别的线程,在指定的时间后的某个时刻CPU才会回到刚才暂停的线程上执行。修改后的代码如下。

线程的同步问题(threadSynchronization.java)。


第05~08行,共启动了四个线程,实现并发多线程“售票”的目的。从运行结果可以看到,打印出了负数的票号以及几张票号相同的票,这说明有几张票被重复卖了出去。而且多次运行这个程序,得到的运行结果也是不唯一的。

造成这种意外的根本原因在于,因为没有对这些线程在访问临界资源(也即共享资源:tickets )做必要的控制。那么该如何去解决这个问题呢?下面介绍使用线程的同步来解决这种问题。

同步代码块

如何避免上面的情况出现呢?如何保证开发出的程序是线程安全的呢?这就要涉及到线程间的同步问题。要解决上面的问题,必须保证下面这段代码的原子性。这里所谓的原子性是指,一段代码要么被执行,要么不被执行,不存在执行一部分被中断的情况。也就是说,这段代码的执行像原子一样,不可拆分。回到上面的提到的代码。

即当一个线程运行到while( tickets > 0 )后,CPU不去执行其他线程中可能影响当前线程中的下一句代码的执行结果的代码块。这段代码就好比一座独步桥,任何时刻都只能有一个人在桥上行走,即程序中不能有多个线程同时访问临界区,这就是线程的互斥——一种在临界区执行的特殊同步。一般意义上的同步是指,多线程(进程)在代码执行的关键点上,互通消息、相互协作,共同把任务正确地完成。同步代码块定义语法如下。

下面我们修改【范例(threadSynchronization.java)】程序中的TestThread类,使程序具有同步性,修改后的代码如下。

同步代码块的使用(ThreadSynchronization 2.java)



将第16~25行的这些具有原子性的代码(即临界区代码)放入synchronized语句中,形成了同步代码块。在同一时刻只能由一个线程可以进入同步代码块内运行,只有当该线程离开同步代码块后,其他线程才能进入同步代码块内运行。

从上面的运行结果来看,5张票均实现了“一票一卖”的效果。但是这时可能会出现负载不均衡的情况,即有的线程卖了3张票(如上图左所示的线程3、如上图右所示的线程0),而有的线程压根就没有卖到票(如线程1和2)。这是另外一个层面的问题,至少现在我们解决了正确卖票的问题。

同步方法

除了对代码块进行同步外,也可以对方法实现同步,只要是需要同步的方法定义前面加上synchronized关键字即可。同步方法定义语法如下。

根据上述格式,我们再次修改【范例(ThreadSynchronization 2.java)】TestThread类,得到如下代码。

同步方法的使用(ThreadSynchronization 3.java)。



代码第23~33行中,把对临界变量(多线程共享变量)操作的代码封装成一个方法sale()。在23行用关键词synchronized表明了这个方法的原子性——即对于一个线程而言,要么执行完毕这个方法,要么不执行这个方法。由上面的运行可见,该程序的效果(售票结果)与【范例(ThreadSynchronization 2.java)】的同步代码块的运行结果完全一样,也就是说在方法定义前用synchronized关键字也能够很好地实现线程间的同步。

同步方法相当于下面形式的同步代码块。

由此可见,同步方法锁定的也是对象,而不是代码段。也就是说,在同一个类中,使用synchronized关键字定义的若干方法,当有一个线程进入了有synchronized修饰的方法时,其他线程就不能进入同一个对象(注意:是同一个对象,不是同一个类的不同对象)使用synchronized来修饰的所有方法,直到第一个线程执行完它所进入的synchronized修饰的方法为止。

死锁

在没有讲解死锁的概念之前,我们先从一个生活中的案例来体会一下:假设有甲乙两个人在就餐,每个人就餐必须同时拥有一把餐刀和一把叉子。但目前餐具不足,统共只有一把餐刀和一把叉子。现在,甲拿到了一把餐刀,乙拿到了一把叉子,他们都无法吃到饭。于是,就有了下面的对话:

甲:“乙你先给我叉子,我再给你餐刀!”

乙:“甲你先给我餐刀,我才给你叉子!”……

如果甲乙双方都不让步,那么局面就会一直僵持下去,他们只能一直等下去,只到等死——这就是“死锁”在生活中的影子,如下图所示。

如果将上面案例的人数扩展到5人,就是著名计算机科学家迪科斯彻(Dijkstra)1965年提出的经典的同步问题—— “哲学家进餐(Dining philosophersproblem)”。

在操作系统中,计算资源不足是常态。通常我们使用共享的方法来解决资源不足的问题,例如,操作系统中有很多执行中的程序——进程(或线程),都想使用打印机,在一般情况下,我们不会为每个执行中的程序配备一台打印机,而是让它们共享一台打印机。有些设备(如打印机)是独占设备,即一个进程(或线程)在使用该设备时,其他进程(或线程)是不能使用的。为了达到这个目的,操作系统使用了“锁”的概念,来保证对某个设备的独占访问。独占设备也是可以实现资源共享的,但它们的共享是通过“我完全不用时,你再用”来实现的。

进程或线程在执行过程中,通常需要不止一种计算资源,一旦有多个进程或线程已经分配部分独占设备,同时又想申请其他进程或线程占据的独占设备,那么就有可能发生死锁。 如果有一组进程(或线程),其中的每一个都无限等待被该组进程(或线程)中另一进程(或线程)所占有的资源,就会产生无限期地僵持下去的局面,这种现象称为死锁。

具体到Java中的多线程编程,最常见的死锁形式是当线程1已经占据资源R1,并持有资源R1上的锁,而且正在等待资源R2的开锁;而线程2已经占据资源R2,并持有资源R2上的锁,却正在等待资源R1的开锁。如果两个线程不释放自己占据的资源锁,而还申请对方资源的上的锁,申请不到时就等待,那么它们只能永远等待下去。

要预防死锁有多种方法,其中一种就是利用有序资源分配策略,把系统的所有资源排列成一个顺序。例如,系统若共有n个线程,共有m个资源,用Ri(1≤i≤m)表示第i个资源,于是这m个资源是:如果规定线程i在占用资源Ri时候不得再申请Rj(i>j),即在申请多项资源时,这种策略要求线程申请资源必须按以编号上升的次序依次申请——这样做,在本质上破坏了死锁的必要条件——循环等待。回到刚才的例子,如果按照有序资源分配的策略,线程1必须先得到资源R1,然后再申请资源R2。而线程2也必须先得到资源R1,才能申请资源R2,即使它需要先使用资源R2,在线程2没有得到R1之前,它被禁止申请资源R2。虽然一开始线程2可能因为等待R1且不占据R2而浪费一些时间,但是这种等待和“奉献精神”换来了避免死锁的发生。

在下面的例子模拟了死锁的发生,在真实程序中,死锁是较难发现的。

模拟死锁的发生。(DeadLock.java)




在主类DeadLock中,构建了三个子类,静态类A和B及掩饰类Demo。类A继承父类Thread,并覆写了run()方法,用以模拟甲占据资源“餐刀”,而去申请资源“叉子”。类似地,类B也继承父类Thread,并覆写了run()方法,用以模拟乙占据资源“叉子”,而去申请资源 “餐刀”。Demo类模拟一个守护线程,用来显示线程执行的状况。

在类A和B中定义了多个用synchronized修饰的方法。当一个线程获得了其中一个方法的锁后,它将同时获得该类中的其他方法的锁。这是因为synchronized 锁住的是对象,该方法所属的对象就是临界资源。例如,第05行的knife和第18行的fork都是临界资源。第08行和第21行分别使用了sleep()方法,让相应的线程都睡眠一会儿,来让对方有机会获得部分资源,从而强制死锁条件出现。第36行也使用了sleep方法,每隔1秒(即1000毫秒)输出一下守护线程的状态。

从运行结果可以看到,从守护线程一直在执行,说明用户线程A和B一直在运行(实则是在无限忙等),否则,如果没有用户线程,守护线程会自动终止。由于线程A和B产生了死锁,所以这个程序永远不会完成。由于A和B都不释放自己的所占资源,所以第11行和24行代码永远不会执行到。

其实死锁的预防也是容易实现的。例如,在本例中,如果规定线程A和B都必须先拿起餐刀,然后再拿起叉子,就不会发生死锁。

线程间通信

同属于一个进程的多个线程,是共享地址空间的,它们可以一起协作来完成指定的任务。因此,线程之间必须相互通信,才能完成协作。

问题的引出

下面通过一个应用案例来讲解线程间的通信。把一个数据储存空间划分为两个部分:一部分用于储存用户的姓名,另一部分用于储存用户的性别。这个案例包含两个线程:一个线程向数据存储空间添加数据(生产者),另一个线程从数据存储空间中取出数据(消费者)。这个程序有两种意外需要读者考虑。

第一种意外,假设生产者线程刚向数据储存空间中添加了一个人的姓名,还没有加入这个人的性别,CPU就切换到了消费者线程,消费者线程则把这个人的姓名和上一个人的性别联系到一起。这个过程可用下图表示。

第2种意外,生产者放入了若干次数据,消费者才开始取数据,或者是,消费者取完一个数据后,还没等到生产者放入新的数据,又重新取出已取过的数据。在操作系统里,上面的案例属于经典的同步问题——生产者消费者问题,下面我们通过线程间的通信来解决上面提到的意外。

问题如何解决

下面先来构思这个程序,程序中的生产者线程和消费者线程运行的是不同的程序代码,因此这里需要编写两个包含有run方法的类来完成这两个线程,一个是生产者类Producer,另一个是消费者类Consumer。

下面是消费者线程的代码。

当程序写到这里,还需要定义一个新的数据结构Person,用来作为数据储存空间。在这个数据结构中,类Person只有数据,而没有对数据的操作,非常类似于C语言的结构体。

进程间的通信(ThreadCommunation.java)




从输出结果可以看到,原本“李四是女”、“张三是男”,现在却打印了“李四是男”、“张三是女”的奇怪现象,这是什么原因?从程序中可以看到,Producer类和Consumer类都是操纵了用一个Person类,这就有可能Producer类还未操纵完P类,Consumer类就已经将P类中的内容取走了,这就是资源不同步的原因。程序为了模拟生产者和消费者的生产(消费)耗时,分别使用了sleep(1000)方法做了模拟(代码10~11行、代码15~16行及30~31行)。为了避免这类“生产者没有生产完,消费者就来消费”或“消费者没有消费完,生产者又来生产,覆盖了还没有来得生产及消费的数据”情况,我们在Person类中添加两个同步方法,put() 和get(),这两个方法都使用了synchronized关键词,从而保证了生产或消费操作过程的原子性——即正在生产过程中,不能消费,或消费过程中,不能生产。具体代码如下范例所示。

进程同步使用(ThreadCommunation2.java)。




可以看到程序的输出结果是正确的,能够保证“李四是女的”。但是另外一个问题又产生了,从程序的执行结果来看,Consumer线程对Producer线程放入的一次数据连续地读取了多次,多次输出:“李四 ---->女”,这并不符合实际的要求。合理的结果应该是,Producer放一次数据,Consumer就取一次;反之,Producer也必须等到Consumer取完后才能放入新的数据,而这一问题的解决就需要使用下面要讲到的线程间的通信。

Java是通过Object类的wait()、notify ()、notifyAll ()这几个方法来实现线程间的通信的,又因为所有的类都是从Object继承的,因此任何类都可以直接使用这些方法。下面是这3个方法的简要说明。

wait():通知当前线程进入睡眠状态,直到其他线程进入并调用notify()或notifyAll()为止。在当前线程睡眠之前,该线程会释放所占有的“锁标志”,即其占有的所有synchronized标识的代码块可被其他线程使用。

notify():唤醒在该同步代码块中第1个调用wait()的线程。这类似排队买票,一个人买完之后,后面的人才可以继续买。

notifyAll():唤醒该同步代码块中所有调用wait的所有线程,具有最高优先级的线程首先被唤醒并执行。

如果想让【范例(ThreadCommunation2.java)。】的程序符合预先的设计需求,就必须在类Person中定义一个新的成员变量bFull来表示数据储存空间的状态。当Consumer线程取走数据后,bFull值为false,当Producer线程放入数据后,bFull值为true。只有bFull为true时,Consumer线程才能取走数据,否则就必须等待Producer线程放入新的数据后的通知;反之,只有bFull为false,Producer线程才能放入新的数据,否则就必须等待Consumer线程取走数据后的通知。修改后的P类的程序代码如下。

线程间通信问题的解决(ThreadCommunation3.java)




本程序满足了设计的需求,解决了线程间通信的问题。

需要读者注意的是,wait()、notify()、notifyAll()这3个方法只能在synchronized方法中调用,即无论线程调用的是wait()还是notify()方法,该线程必须先得到该对象的所有权。这样,notify()就只能唤醒同一对象监视器中调用wait()的线程。而使用多个对象监视器,就可以分别有多个wait()、notify()的情况,同组里的wait()只能被同组的notify()唤醒。

线程生命周期的控制

任何事物都有一个生命周期,线程也不例外。那么在一个程序中,怎样控制一个线程的生命并让它更有效地工作呢?要想控制线程的生命,先得了解线程产生和消亡的整个过程。请读者结合前面讲的内容,请观察下图。

控制线程生命周期的方法有多种,如suspend()方法、resume()方法和stop()方法。但是这3个方法都不推荐使用,特别是suspend和resume方法尽量慎用,原因是它们很可能导致发生死锁。虽然stop()能够避免死锁的发生,但是也有其不足之处。如果一个线程正在操作共享数据段,操作过程没有完成就 “stop()”了的话,将会导致数据的不完整,因此对stop()方法也不推荐使用。既然对这3个方法都不推荐使用,那么到底该使用什么方法控制线程的生命周期?请看下面的代码。

线程的生命周期。


本程序中定义了一个计数器i,用来控制main线程的循环打印次数。在i的值从0到4的这段时间内,这两个线程是交替运行的,但当计数器i的取值变为5的时候,程序调用了TestThread类的stopMe()方法,而在stopMe()方法中,将布尔变量bFlag赋值为false(第17行),也就是终止了while循环,run方法结束,Thread-0线程随之结束。main线程在计数器i等于5的时候,调用TestThread类的stopMe方法后,CPU不一定会马上切换到Thread-0线程上,也就是说Thread-0线程不一定会马上终止,main线程的计数器i可能还会继续累加,之后Thread-0线程才真正结束。

综上所述,通过控制run方法中循环条件的方式来结束一个线程的方法是值得推荐使用的方法,这也是实际中用的最多的方法。

1. 线程的几个特点。

⑴ 同步代码块和同步方法锁的是对象,而不是代码。即如果某个对象被同步代码块或同步方法锁住了,那么其他使用该对象的代码必须等待,直到该对象的锁被释放。

⑵ 如果一个进程只有后台线程,则这个进程就会结束。

⑶ 每一个已经被创建的线程在结束之前均会处于就绪、运行、阻塞状态之一。

2.另外一种多线程同步的锁机制。

多线程的同步也是经常使用并让人头疼的。对于在Java中, synchronized方法在同一时刻只能被一个线程调用,但是性能却很差,从JDK1.5开始Java的API中引入了另一个锁的机制,这种锁机制包含在java.util.concurrent.locks包中。下面以案例说明


这种模式更类似于操作系统中的P、V操作,更加直观。当系统中出现不同的读、写线程同时访问某一资源时,需要考虑共享互斥问题,可使用 synchronized 解决问题。若对性能要求较高的情况下,可考虑使用 ReadWriteLock 接口及其ReentrantReadWriteLock 实现类。此外,为了在高并发情况下获取较高的吞吐率,建议使用 Lock 接口及其 ReentrantLock 实现类来替换以前的 synchronized方法或代码块。

3. Java 8 新引入Lambda表达式

在Java 8新引入了Lambda表达式,所以创建线程的形式有所变化,Lambda表达式更能够适用在并发的条件下,跟多线程起的作用不一样。这里提到的lambda表达式,相当于大多数动态语言中常见的闭包、匿名函数的概念。也类似于C语言中一个函数指针,这个指针可以把一个函数名作为一个参数传递到另外一个函数中。

利用Lambda表达式,创建新线程的示范代码如下。

可以看到这段代码比前面章节学习到的创建线程的代码精简了,也有较好的可读性,下面对这个语句分析如下。

() -> {System.out.println(“Java 8”);} 就是lambda表达式,它等同于上面的newRunnable()。Lambda表达式的结构可大体分为3部分。

⑴ 最前面的部分是一对括号,里面是参数,这里无参数,就是一对空括号。

⑵ 中间的是 -> ,用来分割参数和主体部分,它用花括号括起来{}。

⑶ 是主体部分,它可以是一个表达式或者一个语句块。如果是一个表达式,表达式的值会被作为返回值返回;如果是语句块,需要用return语句指定返回值。

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: