16.多线程

本章要点

  • 线程的基础知识
  • 理解线程和进程的区别与联系
  • 两种创建线程的方式
  • 线程的run()方法和start()方法的区别于联系
  • 线程的生命周期
  • 线程死亡的几种情况
  • 控制线程的常用方法
  • 线程同步的概念和必要性
  • 使用synchronized控制线程同步
  • 使用Lock对象控制线程同步
  • 使用Object提供的方法实现线程通信
  • 使用条件变量实现线程通信
  • 使用管道流实现线程通信
  • 使用Callable接口创建线程
  • 线程池的功能和用法
  • ThreadLocal类的功能和用法
  • 使用线程安全的集合类

大部分的时候,我们都做着单线程的编程,前面所有程序都只有一条顺序执行流:程序从main方法开始执行,依次向下执行每行代码,如果程序执行某行代码遇到了阻塞,则程序将会停滞在该处。如果我们使用IDE工具的单步调试功能将可以非常清楚地看出这一点。

但实际的情况是,单线程的程序往往功能非常有限,例如我们需要开发一个简单的服务器程序,这个服务器程序需要向不同客户端提供服务时,不同客户端之间应该互不干扰,否则会让客户端感觉非常沮丧。多线程听上去是非常专业的概念,其实非常简单:单线程的程序只有一个顺序执行;流,多线程程序如同只雇佣一个服务员的餐厅,他必须做完一件事情后才可以做下一件事情;多线程程序则如同雇佣多个服务员的餐厅,他们可以同时进行着多件事情。

java语言提供了非常优秀的多线程支持,程序可以通过非常简单的方式来启动多线程,本章将会详细介绍java多线程编程的相关方面,包括创建,启动线程,控制线程,以及多线程的同步操作。并会介绍如何利用java内建支持的线程池来提高多线程性能。

16.1 线程概述

几乎所有的操作系统都支持同时运行多个任务,一个任务通常就是一个程序,每个运行中的程序就是一个进程。当一个程序运行时,内部可能包含了多个顺序执行流,每个顺序执行流就是一个线程。

16.1.1 线程和进程

几乎所有操作系统都支持进程的概念,所有运行中的任务通常对应一条进程(process)。当一个程序进入内存运行,即变成一个进程。进程是处于运行过程中的程序,并且具有一定独立功能,进程是系统进行资源分配和调度的一个独立单位。

一般而言,进程包含如下三个特征:

  • 独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
  • 动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,这些概念在程序中都是不具备的。
  • 并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。

注意:

并发性和并行性是两个概念,并行指在同一时刻,有多条指令在多个处理器上同时执行;并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。

多线程则扩展了多进程的概念,使得同一个进程可以同时并发处理多个任务。线程也被称作轻量级进程,线程是进程的执行单元。就像进程在操作系统中的地位一样,线程在程序中是独立的,并发的执行流。当进程被初始化后,主线程就被创建了。对于绝大多数的应用程序来说,通常仅要求有一个主线程,但我们也可以在该进程内创建多条顺序执行流,这些顺序执行流就是线程,每条线程也是互相独立的。

线程是进程的组成部分,一个进程可以拥有多个线程,一个线程必须有一个父进程。线程可以拥有自己的堆栈,自己的程序计数器和自己的局部变量,但不再3拥有系统资源,它与父进程的其他线程共享该进程所拥有的全部资源。因为多个线程共享父进程里的全部资源,因此编程更加方便;但必须更加小心,我们必须确保线程不会妨碍同一进程里的其他线程。

扫描二维码关注公众号,回复: 6016817 查看本文章

线程可以完成一定的任务,可与其他线程共享父进程中的共享变量及部分环境,相互之间协同来完成进程所要完成的任务。

线程是独立运行的,它并不知道进程中是否还有其他线程存在。线程的执行是抢占式的,也就是说当前运行的线程在任何时候都可能被挂起,以便另外一个线程可以运行。

一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行。

从逻辑角度来看,多线程存在于一个应用程序中,让一个应用程序中可以有多个执行部分同时执行,但操作系统无须将多个线程看做多个独立的应用,对多线程实现调度和管理以及资源分配。线程的调度和管理由进程本身负责完成。

简而言之:一个程序运行后至少有一个进程,一个进程里可以包含多个线程,但至少要包含一个线程。

16.1.2 多线程的优势

线程在程序中是独立的,并发的执行流,但是,与分隔的进程相比,进程中的线程之间的隔离程度要小。他们共享内存,文件句柄和其他每个进程应有的状态。

因为线程的划分尺度小于进程,使得多线程程序的并发性高。进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。

线程比进程具有更高的性能,这是由于同一个进程中的线程都有共性:多个线程将共享同一个进程虚拟空间。线程共享的环境包括:进程代码段,进程的共有数据等。利用这些共享的数据等,线程很容易实现相互之间的通信。

当操作系统创建一个进程时,必须为该进程分配独立的内存空间,并分配大量相关资源;但创建一个线程则简单的多,因此使用多线程来实现并发比使用多进程实现并发的性能要高得多。

总结起来,使用多线程编程包含如下几个优点:

  • 进程间不能共享内存,但线程之间共享内存非常容易。
  • 系统创建进程需要为该进程重新分配系统资源,但创建线程则代价小得多,因此使用多线程来实现多任务并发比多进程的效率高。
  • java语言内置多线程功能支持,而不是的单纯地作为底层操作系统的调度方式,从而简化了java的多线程编程。

在实际应用中,多线程是非常有用的,一个浏览器必须能同时下载多个图片:一个web服务器必须能同时响应多个用户请求;java虚拟机本身就在后台提供了一个超级线程来进行垃圾回收;图形用户界面应用也需要启动单独的线程来从主机环境收集用户界面事件......总之,多线程在实际编程中的应用是非常广泛的。

16.2 线程的创建和启动

java使用Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例。每条线程的作用是完成一定的任务,实际上就是执行一段程序流。java使用run方法来封装这样一段程序流。

16.2.1 继承Tread类创建线程类

通过继承Thread类来创建并启动多线程的步骤如下:

  • 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就是代表了线程需要完成的任务。因此,我们经常把run方法称为线程的执行体。
  • 创建thread子类的实例,即创建了线程对象。
  • 用线程对象的start方法来启动该线程。

注意:

进行多线程编程时不要忘记了java程序运行时默认的主线程,main方法的方法体就是主线程的线程执行体。

使用继承thread类的方法来创建线程类,多条线程之间无法共享线程类的实例变量。

16.2.2 实现Runnable接口创建线程类

实现Runnable接口来创建并启动多条线程的步骤如下:

  • 定义Runnable接口的实现类,并重写该接口的run方法,该方法的方法体同样是该线程的线程执行体。
  • 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
  • 调用线程对象的start方法来启动该线程。 提示:

Runable对象仅仅作为Thread对象的target,Runnable实现类里包含的run方法仅作为线程执行体,而实际的线程对象依然是THread实例,只是该Thread线程负责执行器target的run方法。出于这种考虑,Thread类是否可以把任意对象的任意方法作为线程执行体?java语言是不可以的,java语言的Thread必须使用Runable对象的run方法作为线程执行体,但C#可以把任何对象的任意方法作为线程执行体。

16.2.3 两种方式所创建线程的对比

通过继承Thread类或实现Runable接口都可以实现多线程,但两种方式存在一定的差别,相比之下两种方式的主要差别如下。

采用实现Runable接口方式的多线程:

  • 线程类只是实现了Runable接口,还可以继承其他类。
  • 在这种方式下,可以多个线程共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU,代码和数据分开,形成清晰的模型,较好地体现面向对象的思想。
  • 劣势是:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。

采用继承Thread类方式的多线程:

  • 劣势是:因为线程类已经继承了Thread类,所以不能再继承其他父类。
  • 优势是:编写简单,如果需要访问当前线程,无须使用Thread.currentThread()方法,直接使用this即可获得当前线程。

实际上几乎所有的多线程应用都可采用第一种方式,也就是实现Runable接口的方式。

16.3 线程的生命周期

当线程被创建并启动以后,它既不是一启动就进入执行状态,也不是一直处于执行状态,在线程的声明周期中,它要经过新建,就绪,运行,阻塞和死亡五种状态。尤其是当线程启动以后,它不能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行,阻塞之间切换。

16.3.1 新建和就绪状态

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他java对象一样,仅仅由java虚拟机为其分配了内存,并初始化了其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

当线程对象调用了start()方法之后,该线程处于就绪状态,java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,它只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。

注意:

启动线程使用start方法,而不是run方法!永远不要调用线程对象的run方法!调用start方法来启动线程,系统会把该run方法当成线程执行体来处理,但如果直接调用线程对象的run方法,则run方法立即就会被执行,而且在run方法返之前其他线程无法并发执行--也就是说系统把线程对象当成一个普通对象,而run方法也是一个普通方法,而不是线程执行体。

不要对已经处于启动状态的线程再次调用start方法,否则将引发IllegalThreadStateException异常。

提示:

如果程序希望调用子线程的start()方法后子线程立即开始执行,程序可以使用Thread.sleep (1)来让当前运行的线程(主线程)睡眠1毫秒--1毫秒就够了,因为在这1毫秒内CPU不会空闲,它就会去执行另一条就绪状态的线程,这样就可以让我们的子线程立即获得执行。

16.3.2 运行和阻塞状态

如果处于就绪状态的线程获得了CPU,开始执行run方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,在任何时刻只有一条处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行执行;但当线程数大于处理器数时,依然会有多条线程在同一个CPU上轮换的现象。

当一条线程开始运行后,它不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就执行结束了),线程在运行过程中需要被终端,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务;当该时间段用完,系统就会剥夺该线程所占据的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级。

所有现代的桌面和服务器操作系统都是采用抢占式调度策略,但一些小型设备如手机则可能采用协作式调度,在这样的系统中,只有当一个线程调用了它的sleep或yield方法后才会放弃所占用的资源--也就是必须由该线程主动放弃所占用的资源。

当发生如下情况下,线程将会进入阻塞状态:

  • 线程调用sleep方法主动放弃所占用的处理器资源。
  • 线程调用可一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。关于同步监视器的知识将在后面有更深入的介绍。
  • 线程在等待某个通知(notity)。
  • 程序调用了线程的suspend方法将该线程挂起。不过这个方法容易导致死锁,所以程序应该尽量避免使用该方法。

当前正在执行的线程被阻塞之后,其他线程就可以获得执行的机会了。被阻塞的线程会在合适时候重新进入就绪状态,注意是就绪状态而不是运行状态。也就是说被阻塞线程的阻塞解除后,必须重新等待线程调度器再次调度它。

针对上面的几种情况,当发生如下特定的情况将可以解除上面的阻塞,让该线程重新进入就绪状态:

  • 调用sleep方法的线程经过了指定时间。
  • 线程调用的阻塞式IO方法已经返回。
  • 线程成功地获得了试图取得同步监视器
  • 线程正在等待某个通知时,其他线程发出了一个通知。
  • 处于挂起状态的线程被调用了resume恢复方法。

16.3.3 线程死亡

线程会以以下三种方式之一结束,结束后就处于死亡状态:

  • run()方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的Exception或Error。
  • 直接调用该线程的stop()方法来结束该线程--aging方法容易导致死锁,通常不推荐使用。

注意:

当主线程结束时候,其他线程不受任何影响,并不会随之结束。一旦子线程启动起来后,它就拥有和主线程相同的地位,它不会受主线程的影响。

为了测试某条线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪,运行,阻塞三种状态时,该方法将返回true,当线程处于新建,死亡两种状态时,该方法将返回false。

不要试图对一个已经死亡的线程调用start()方法使它重新启动,死亡就是死亡,该线程将不可再次作为线程执行。

注意:

不要对处于死亡状态的线程调用start()方法,程序只能对新建状态的线程调用start()方法,对新建状态的线程两次调用start()方法也是错误的。

16.4 控制线程

java的线程支持提供了一些便捷的工具方法,通过这些便捷的工具方法可以很好地控制线程的执行。

16.4.1 join线程

Thread提供了让一个线程等待另一个线程完成的方法:join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,知道被join方法加入的join线程完成为止。

join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题分配一个线程。当所有的小问题都得到处理后,再调用主线程来进一步操作。

16.4.2 后台线程

有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务,这种线程被称为“后台线程”,又称为“守护线程”或“精灵线程”。JVM的垃圾回收线程就是典型的后台数据。

后台线程有个特征:如果所有的前台线程都死亡,后台线程会自动死亡。

调用Thread对象setDaemon(true)方法可将指定线程设置成后台线程。

Thread类还提供了一个isDaemon()方法,用于判断指定线程是否为后台线程。

注意:

前台线程死亡后,JVM会通知后台线程死亡,但从它接受指令,到它作出响应,需要一定时间。而且要将某个线程设置为后台线程,必须在该线程启动之前设置,也就是说setDaemon(true)必须在start()方法之前调用。否则会引发IllegalThreadStateException异常。

16.4.3 线程睡眠:sleep

如果我们需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态sleep方法,sleep方法有两种重载的形式:

当当前线程调用sleep方法进入阻塞状态后,在其sleep时间段内,该线程不会获得执行的机会,即使系统中没有其他可运行的线程,处于sleep中的线程也不会运行,因此sleep方法常用来暂停程序的执行。

16.4.4 线程让步:yield

yield()方法是一个和sleep方法有点相似的方法,它也是一个Thread类提供的一个静态方法,它也可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是将该线程转入就绪状态。yield只是让当前线程暂停一下,让系统的线程调度器重新调度一次完全可能的情况是:当某个线程调用了yield方法暂停之后,线程调度器又将其调度出来重新执行。

实际上,当某个线程调用了yield方法暂停之后,只有优先级与当前线程相同,或者优先级比当前线程更高的就绪状态的线程才会获得执行的机会。

关于sleep方法和yield方法的区别如下:

  • sleep方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级。但yield方法只会给优先级相同,或优先级更高的线程执行机会。
  • sleep方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态。而yield不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程调用yield方法暂停之后,立即再次获得处理器资源被执行。
  • sleep方法声明抛出了IterruptedException异常,所以调用sleep方法时要么捕捉该异常,要么显式声明抛出该异常。而yield方法则没有声明抛出任何异常。
  • sleep方法比yield方法有更好的可移植性,通常不要依靠yield来控制并发线程的执行。

16.4.5 改变线程优先级

每个线程执行时都有具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。

每个线程默认的优先级都与创建它的父线程具有相同的优先级,在默认情况下,main线程具有普通优先级,由main线程创建的子线程也有普通优先级。

Thread提供了setPriority(int newPriority)和getPriority()方法来设置和返回指定线程的优先级,其中setPriority方法的参数可以是一个整数,范围是1~10之间,也可以使Thread类的三个静态常量:

  • MAX_PRIORITY:其值是10.
  • MIN_PRIORITY:其值是1.
  • NORM_PRIORITY:其值是5.

16.5 线程的同步

多线程编程是有趣的事情,它常常容易出现“错误情况”,这是由于系统的线程调度具有一定的随机性。即使程序在运行过程中偶尔出现问题,那也是由于我们编程不当所引起的。当使用多个线程来访问同一个数据时,非常容易出现线程安全问题。

16.5.1 线程安全问题

关于线程安全问题,有一个经典的问题:银行取钱的问题。银行取钱的基本流程基本可以分为如下几个步骤:

  1. 用户输入账号,密码,系统判断用户的账号,密码是否匹配。
  2. 用户输入取款金额。
  3. 系统判断账户余额是否大于取款金额。
  4. 如果余额大于取款金额,取款成功;如果余额小于取款金额,则取款失败。

16.5.2 同步代码块

同步监视器

synchronized

任何时刻只能有一条线程可以获得对同步监视器的锁定,当同步代码块执行结束后,该线程自然释放了对该同步监视器的锁定。

虽然java程序允许使用任何对象来作为同步监视器,但想一下同步监视器的目的:阻止两条线程对同一个共享资源进行并发访问。因此通常推荐使用可能被并发访问的共享资源充当同步监视器。

上面程序使用synchronized将run方法里方法体修改成同步代码块,该同步代码块的同步监视器是account对象,这样的做法符合“枷锁-修改完成-释放锁”逻辑,任何线程想修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当该线程修改完成,该线程释放对该资源的锁定。通过这种方式就可以保证并发线程在任一时刻只有一条线程可以进入修改共享资源的代码区(也别称为临界区),所以同一时刻最多只有一条线程处于临界区内,从而保证了线程的安全性。

16.5.3 同步方法

与同步代码块对应的,java的多线程安全支持还提供了同步方法,同步方法就是使用synchronized关键字来修饰某个方法,则该方法称为同步方法。对于同步方法而言,无须显式指定同步监视器,同步方法的同步监视器是this,也就是该对象本身。

通过使用同步方法可以非常方便地将某类变成线程安全的类,线程安全的类具有如下特征:

  • 该类的对象可以被多个线程安全的访问。
  • 每个线程调用该对象的任意方法之后都将得到正确结果。
  • 每个线程调用该对象的任意方法之后,该对象状态依然保持合理状态。

前面我们介绍了可变类和不可变类,其中不可变类总是线程安全的,因为它的对象的状态不可改变。但可变对象需要额外的方法来保证其线程安全。

synchronized关键字可以修饰方法,可以修饰代码块,但不能修饰构造器,属性等。

可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:

  • 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步。例如上面的Account类中accountNo属性就无须同步,所以程序只对draw方法进行同步控制。
  • 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。

16.5.4 释放同步监视器的锁定

任何线程进入同步代码块,同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定:

  • 当前线程的同步方法,同步代码块执行结束,当前线程即释放同步监视器。
  • 当线程在同步代码块,同步方法中遇到break,return终止了该代码块,该方法的继续执行,当前线程会释放同步监视器。
  • 当线程在同步代码块,同步方法中出现了未处理的Error或exception,导致了该代码块,该方法异常结束时将会释放同步监视器。
  • 当线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。

在下面情况下,线程不会释放同步监视器:

  • 线程执行同步代码块或同步方法时,程序调用Thread.sleep,Thread.yied()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
  • 线程执行同步代码块时,其他线程调用了该线程的suspend方法将该线程挂起,该线程不会释放同步监视器。当然,我们应该尽量避免使用suspend和resume方法来控制线程。

16.5.5 同步锁(Lock)

从JDK1.5之后,java提供了另外一种线程同步的机制:它通过显式定义同步锁对象来实现 同步,在这种机制下,同步锁应该使用Lock对象充当。

通常认为:Lock提供了比synchronized方法和synchroized代码块更广泛的锁定操作,Lock实现允许更灵活的结构,可以具有差别很大的属性,并且可以支持多个相关的Condition对象。

Lock是控制多个线程对共享资源进行访问的工具。

16.5.6 死锁

当两个线程相互等待对方释放同步监视器时就会发生死锁,java虚拟机没有检测,也没有采用措施来处理死锁情况,所以多线程编程时应该采取措施避免死锁的出现。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

16.6 线程通信

当线程在系统内运行时,线程的调度具有一定的透明性,程序通常无法准确控制线程的轮换执行,但我们可以通过一些机制来保证线程协调运行。

16.6.1 线程的协调运行

16.9 线程池

猜你喜欢

转载自blog.csdn.net/qq_21874145/article/details/81867703