深入理解进程与线程的区别(终结篇)

简而言之,一个程序至少有一个进程,一个进程至少有一个线程。
线程的划分尺度小于进程,使得多线程程序的并发性高。
另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位.
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源.
一个线程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行.

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。如果有兴趣深入的话,我建议你们看看《现代操作系统》或者《操作系统的设计与实现》。对就个问题说得比较清楚。

1. 简介

进程(process)是一块包含了某些资源的内存区域。操作系统利用进程把它的工作划分为一些功能单元。

进程中所包含的一个或多个执行单元称为线程(thread)。进程还拥有一个私有的虚拟地址空间,该空间仅能被它所包含的线程访问。

当运行.NET程序时,进程还会把被称为CLR的软件层包含到它的内存空间中。上一章曾经对CLR做了详细描述。该软件层是在进程创建期间由运行时宿主载入的(参见4.2.3节)。

线程只能归属于一个进程并且它只能访问该进程所拥有的资源。当操作系统创建一个进程后,该进程会自动申请一个名为主线程或首要线程的线程。主线程将执行运行时宿主, 而运行时宿主会负责载入CLR。

应用程序(application)是由一个或多个相互协作的进程组成的。例如,Visual Studio开发环境就是利用一个进程编辑源文件,并利用另一个进程完成编译工作的应用程序。

在Windows NT/2000/XP操作系统下,我们可以通过任务管理器在任意时间查看所有的应用程序和进程。尽管只打开了几个应用程序,但是通常情况下将有大约30个进程同时运行。 事实上,为了管理当前的会话和任务栏以及其他一些任务,系统执行了大量的进程。

2. 进程

2.1 简介

在运行于32位处理器上的32位Windows操作系统中,可将一个进程视为一段大小为4GB(232字节)的线性内存空间,它起始于0x00000000结束于0xFFFFFFFF。这段内存空间不能被其他进程所访问,所以称为该进程的私有空间。这段空间被平分为两块,2GB被系统所有,剩下2GB被用户所有。

如果有N个进程运行在同一台机器上,那么将需要N×4GB的海量RAM,还好事实并非如此。

  • Windows是按需为每个进程分配内存的,4GB是32位系统中一个进程所占空间的上限。
  • 将进程所需的内存划分为4KB大小的内存页,并根据使用情况将这些内存页存储在硬盘上或加载到RAM中,通过系统的这种虚拟内存机制,我们可以有效地减少对实际内存的需求量。当然这些对用户和开发者来说都是透明的。

2.2 System.Diagnostics.Process类

System.Diagnostics.Process类的实例可以引用一个进程,被引用的进程包含以下几种。

  • 该实例的当前进程。
  • 本机上除了当前进程的其他进程。
  • 远程机器上的某个进程。

通过该类所包含的方法和字段,可以创建或销毁一个进程,并且可以获得一个进程的相关信息。下面将讨论一些使用该类实现的常见任务。

2.3 创建和销毁子进程

下面的程序创建了一个称为子进程的新进程。在这种情况下,初始的进程称为父进程。子进程启动了一个记事本应用程序。父进程的线程在等待1秒后销毁该子进程。该程序的执行效果就是打开并关闭记事本。

例1

静态方法Start()可以使用已存在的Windows文件扩展名关联机制。例如,我们可以利用下面的代码执行同样的操作。

默认情况下,子进程将继承其父进程的安全上下文。但还可以使用Process.Start()方法的一个重载版本在任意用户的安全上下文中启动该子进程,当然需要通过一个System.Diagnostics. ProcessStartInfo类的实例来提供该用户的用户名和密码。

2.4 避免在一台机器上同时运行同一应用程序的多个实例

有些应用程序需要这种功能。实际上,通常来说在同一台机器上同时运行一个应用程序的多个实例并没有意义。

直到现在,为了在Windows下满足上述约束,开发者最常用的方法仍然是使用有名互斥体(named mutex)技术(参见5.7.2节)。然而采用这种技术来满足上述约束存在以下缺点:

  • 该技术具有使互斥体的名字被其他应用程序所使用的较小的、潜在的风险。在这种情况下该技术将不再有效并且会造成很难检测到的bug。
  • 该技术不能解决我们仅允许一个应用程序产生N个实例这种一般的问题。

幸而在System.Diagnostics.Process类中拥有GetCurrentProcess()(返回当前进程)和GetPro- cesses()(返回机器上所有的进程)这样的静态方法。在下面的程序中我们为上述问题找到了一个优雅且简单的解决方案。

例2

通过方法参数指定了远程机器的名字后,GetProcesses()方法也可以返回远程机器上所有的进程。

2.5 终止当前进程

可以调用System.Environment类中的静态方法Exit(int exitCode)或FailFast(stringmessage)终止当前进程。Exit()方法是最好的选择,它将彻底终止进程并向操作系统返回指定的退出代码值。之所以称为彻底终止是因为当前对象的所有清理工作以及finally块的执行都将由不同的线程完成。当然,终止进程将花费一定的时间。

顾名思义,FailFast()方法可以迅速终止进程。Exit()方法所做的预防措施将被它忽略。只有一个包含了指定信息的严重错误会被操作系统记录到日志中。你可能想要在探查问题的时候使用该方法,因为可以将该程序的彻底终止视为数据恶化的起因。

3. 线程

3.1 简介

一个线程包含以下内容。

  • 一个指向当前被执行指令的指令指针;
  • 一个栈;
  • 一个寄存器值的集合,定义了一部分描述正在执行线程的处理器状态的值;
  • 一个私有的数据区。

所有这些元素都归于线程执行上下文的名下。处在同一个进程中的所有线程都可以访问该进程所包含的地址空间,当然也包含存储在该空间中的所有资源。

我们不准备讨论线程在内核模式或者用户模式执行的问题。尽管.NET以前的Windows一直使用这两种模式,并且依然存在,但是对.NET Framework来说它们是不可见的。

并行使用一些线程通常是我们在实现算法时的自然反应。实际上,一个算法往往由一系列可以并发执行的任务组成。但是需要引起注意的是,使用大量的线程将引起过多的上下文切换,最终反而影响了性能。

同样,几年前我们就注意到,预测每18个月处理器运算速度增加一倍的摩尔定律已不再成立。处理器的频率停滞在3GHz~4GHz上下。这是由于物理上的限制,需要一段时间才能取得突破。同时,为了在性能竞争中不会落败,较大的处理器制造商如AMD和Intel目前都将目标转向多核芯片。因此我们可以预计在接下去的几年中这种类型的架构将广泛被采用。在这种情况下,改进应用性能的唯一方案就是合理地利用多线程技术。

3.2 受托管的线程与 Windows线程

必须要了解,执行.NET应用的线程实际上仍然是Windows线程。但是,当某个线程被CLR所知时,我们将它称为受托管的线程。具体来说,由受托管的代码创建出来的线程就是受托管的线程。如果一个线程由非托管的代码所创建,那么它就是非托管的线程。不过,一旦该线程执行了受托管的代码它就变成了受托管的线程。

一个受托管的线程和非托管的线程的区别在于,CLR将创建一个System.Threading.Thread类的实例来代表并操作前者。在内部实现中,CLR将一个包含了所有受托管线程的列表保存在一个叫做ThreadStore地方。

CLR确保每一个受托管的线程在任意时刻都在一个AppDomain中执行,但是这并不代表一个线程将永远处在一个AppDomain中,它可以随着时间的推移转到其他的AppDomain中。关于AppDomain的概念参见4.1。

从安全的角度来看,一个受托管的线程的主用户与底层的非托管线程中的Windows主用户是无关的。

3.3 抢占式多任务处理

我们可以问自己下面这个问题: 我的计算机只有一个处理器,然而在任务管理器中我们却可以看到数以百计的线程正同时运行在机器上!这怎么可能呢?

多亏了抢占式多任务处理,通过它对线程的调度,使得上述问题成为可能。调度器作为Windows内核的一部分,将时间切片,分成一段段的时间片。这些时间间隔以毫秒为精度且长度并不固定。针对每个处理器,每个时间片仅服务于单独一个线程。线程的迅速执行给我们造成了它们在同时运行的假象。我们在两个时间片的间隔中进行上下文切换。该方法的优点在于,那些正在等待某些Windows资源的线程将不会浪费时间片,直到资源有效为止。

之所以用抢占式这个形容词来修饰这种多任务管理方式,是因为在此种方式下线程将被系统强制性中断。那些对此比较好奇的人应该了解到,在上下文切换的过程中,操作系统会在下一个线程将要执行的代码中插入一条跳转到下一个上下文切换的指令。该指令是一个软中断,如果线程在遇到这条指令前就终止了(例如,它正在等待某个资源),那么该指定将被删除而上下文切换也将提前发生。

抢占式多任务处理的主要缺点在于,必须使用一种同步机制来保护资源以避免它们被无序访问。除此之外,还有另一种多任务管理模型,被称为协调式多任务管理,其中线程间的切换将由线程自己负责完成。该模型普遍认为太过危险,原因在于线程间的切换不发生的风险太大。如我们在4.2.8节中所解释的那样,该机制会在内部使用以提升某些服务器的性能,例如SQL Server2005。但Windows操作系统仅仅实现了抢占式多任务处理。

3.4 进程与线程的优先级

某些任务拥有比其他任务更高的优先级,它们需要操作系统为它们申请更多的处理时间。例如,某些由主处理器负责的外围驱动器必须不能被中断。另一类高优先级的任务就是图形用户界面。事实上,用户不喜欢等待用户界面被重绘。

那些从Win32世界来的用户知道在CLR的底层,也就是Windows操作系统中,可以为每个线程赋予一个0~31的优先级。但你无法在.NET的世界中也使用这些数值,因为:

  • 它们无法描述自身的含义。
  • 随着时间的流逝这些值是非常容易变化的。

1. 进程的优先级

可以使用Process类中的类型为ProcessPriorityClass的PriorityClass{get;set;}属性为进程赋予一个优先级。System.Diagnostics.ProcessPriorityClass枚举包含以下值:

如果某个进程中属于Process类的PriorityBoostEnabled属性的值为true(默认值为true),那么当该进程占据前台窗口的时候,它的优先级将增加一个单位。只有当Process类的实例引用的是本机进程时,才能够访问该属性。

可以通过以下操作利用任务管理器来改变一个进程的优先级:在所选的进程上点击右键>设置优先级>从提供的6个值(和上图所述一致)中做出选择。

Windows操作系统有一个优先级为0的空闲进程。该进程不能被其他任何进程使用。根据定义,进程的活跃度用时间的百分比表示为:100%减去在空闲进程中所耗费时间的比率。

2. 线程的优先级

每个线程可以结合它所属进程的优先级,并使用System.Threading.Thread类中类型为ThreadPriority的Priority{get;set;}属性定义各自的优先级。System.Threading.Thread- Priority包含以下枚举值:

在大多数应用程序中,不需要修改进程和线程的优先级,它们的默认值为Normal。

3.5 System.Threading.Thread类

CLR会自动将一个System.Threading.Thread类的实例与各个受托管的线程关联起来。可以使用该对象从线程自身或从其他线程来操纵线程。还可以通过System.Threading.Thread类的静态属性CurrentThread来获得当前线程的对象。

Thread类有一个功能使我们能够很方便的调试多线程应用程序,该功能允许我们使用一个字符串为线程命名:

3.6 创建与Join一个线程

只需通过创建一个Thread类的实例,就可以在当前的进程中创建一个新的线程。该类拥有多个构造函数,它们将接受一个类型为System.Threading.ThreadStart或System.Threading.Parame-trizedThreadStart的委托对象作为参数,线程被创建出来后首先执行该委托对象所引用的方法。使用ParametrizedThreadStart类型的委托对象允许用户为新线程将要执行的方法传入一个对象作为参数。Thread类的一些构造函数还接受一个整型参数用于设置线程要使用的最大栈的大小,该值至少为128KB(即131072字节)。创建了Thread类型的实例后,必须调用Thread.Start()方法以真正启动这个线程。

例3

该程序输出:

在这个例子中,我们使用Join()方法挂起当前线程,直到调用Join()方法的线程执行完毕。该方法还存在包含参数的重载版本,其中的参数用于指定等待线程结束的最长时间(即超时)所花费的毫秒数。如果线程中的工作在规定的超时时段内结束,该版本的Join()方法将返回一个布尔量True。

3.7 挂起一个线程

可以使用Thread类的Sleep()方法将一个正在执行的线程挂起一段特定的时间,还可以通过一个以毫秒为单位的整型值或者一个System.TimeSpan结构的实例设定这段挂起的时间。该结构的一个实例可以设定一个精度为1/10 ms(100ns)的时间段,但是Sleep()方法的最高精度只有1ms。

我们也可以从将要挂起的线程自身或者另一个线程中使用Thread类的Suspend()方法将一个线程的活动挂起。在这两种情况中,线程都将被阻塞直到另一个线程调用了Resume()方法。相对于Sleep()方法,Suspend()方法不会立即将线程挂起,而是在线程到达下一个安全点之后,CLR才会将该线程挂起。安全点的概念参见4.7.11节。

3.8 终止一个线程

一个线程可以在以下场景中将自己终止。

  • 从自己开始执行的方法(主线程中的Main()方法,其他线程中ThreadStart委托对象所引用的方法)中退出。
  • 被自己终止。
  • 被另一个线程终止。

第一种情况不太重要,我们将主要关注另两种情况。在这两种情况中,都可以使用Abort()方法(通过当前线程或从当前线程之外的一个线程)。使用该方法将在线程中引发一个类型为ThreadAbortException的异常。由于线程正处于一种被称为AbortRequested的特殊状态,该异常具有一个特殊之处:当它被异常处理所捕获后,将自动被重新抛出。只有在异常处理中调用Thread.ResetAbort()这个静态方法(如果我们有足够的权限)才能阻止它的传播。

例4 主线程的自杀

当线程A对线程B调用了Abort()方法,建议调用B的Join()方法,让A一直等待直到B终止。Interrupt()方法也可以将一个处于阻塞状态的线程(即由于调用了Wait()、Sleep()或者Join()其中一个方法而阻塞)终止。该方法会根据要被终止的线程是否处于阻塞状态而表现出不同的行为。

  • 如果该方法被另一个线程调用时,要被终止的线程处于阻塞状态,那么会产生ThreadInterruptedException异常。
  • 如果该方法被另一个线程调用时,要被终止的线程不处于阻塞状态,那么一旦该线程进入阻塞状态,就会引发异常。这种行为与线程对自己调用Interrupt()方法是一样的。

3.9 前台线程与后台线程

Thread类提供了IsBackground{get;set}的布尔属性。当前台线程还在运行时,它会阻止进程被终止。另一方面,一旦所指的进程中不再有前台线程,后台线程就会被CLR自动终止(调用Abort()方法)。IsBackground的默认值为false,这意味着所有的线程默认情况处于前台状态。

3.10 受托管线程的状态图

Thread类拥有一个System.Threading.ThreadState枚举类型的字段ThreadState,它包含以下枚举值:

有关每个状态的具体描述可以在MSDN上一篇名为“ThreadStateEnumeration”的文章中找到。该枚举类型是一个二进制位域,这表示一个该类型的实例可以同时表示多个枚举值。例如,一个线程可以同时处于Running、AbortRequested和Background这三种状态。二进制位域的概念参见10.11.3节。

根据我们在前面的章节中所了解的知识,我们定义了如图5-1所示的简化的状态图。

图1 简化的托管线程状态图

4. 访问资源同步简介

在多线程应用(一个或多个处理器)的计算中会使用到同步这个词。实际上,这些应用程序的特点就是它们拥有多个执行单元,而这些单元在访问资源的时候可能会发生冲突。线程间会共享同步对象,而同步对象的目的在于能够阻塞一个或多个线程,直到另一个线程使得某个特定条件得到满足。

我们将看到,存在多种同步类与同步机制,每种制针对一个或一些特定的需求。如果要利用同步构建一个复杂的多线程应用程序,那么很有必要先掌握本章的内容。我们将在下面的内容中尽力区分他们,尤其要指出那些在各个机制间最微妙的区别。

合理地同步一个程序是最精细的软件开发任务之一,单这一个主题就足以写几本书。在深入到细节之前,应该首先确认使用同步是否不可避免。通常,使用一些简单的规则可以让我们远离同步问题。在这些规则中有线程与资源的亲缘性规则,我们将在稍后介绍。

应该意识到,对程序中资源的访问进行同步时,其难点来自于是使用细粒度锁还是粗粒度锁这个两难的选择。如果在访问资源时采用粗粒度的同步方式,虽然可以简化代码但是也会把自己暴露在争用瓶颈的问题上。如果粒度过细,代码又会变的很复杂,以至于维护工作令人生厌。然后又会遇上死锁和竞态条件这些在下面章节将要介绍的问题。

因此在我们开始谈论有关同步机制之前,有必要先了解一下有关竞态条件和死锁的概念。

4.1 竞态条件

竞态条件指的是一种特殊的情况,在这种情况下各个执行单元以一种没有逻辑的顺序执行动作,从而导致意想不到的结果。

举一个例子,线程T修改资源R后,释放了它对R的写访问权,之后又重新夺回R的读访问权再使用它,并以为它的状态仍然保持在它释放它之后的状态。但是在写访问权释放后到重新夺回读访问权的这段时间间隔中,可能另一个线程已经修改了R的状态。

另一个经典的竞态条件的例子就是生产者/消费者模型。生产者通常使用同一个物理内存空间保存被生产的信息。一般说来,我们不会忘记在生产者与消费者的并发访问之间保护这个空间。容易被我们忘记的是生产者必须确保在生产新信息前,旧的信息已被消费者所读取。如果我们没有采取相应的预防措施,我们将面临生产的信息从未被消费的危险。

如果静态条件没有被妥善的管理,将导致安全系统的漏洞。同一个应用程序的另一个实例很可能会引发一系列开发者所预计不到的事件。一般来说,必须对那种用于确认身份鉴别结果的布尔量的写访问做最完善的保护。如果没有这么做,那么在它的状态被身份鉴别机制设置后,到它被读取以保护对资源的访问的这段时间内,很有可能已经被修改了。已知的安全漏洞很多都归咎于对静态条件不恰当的管理。其中之一甚至影响了Unix操作系统的内核。

4.2 死锁

死锁指的是由于两个或多个执行单元之间相互等待对方结束而引起阻塞的情况。例如:

一个线程T1获得了对资源R1的访问权。

一个线程T2获得了对资源R2的访问权。

T1请求对R2的访问权但是由于此权力被T2所占而不得不等待。

T2请求对R1的访问权但是由于此权力被T1所占而不得不等待。

T1和T2将永远维持等待状态,此时我们陷入了死锁的处境!这种问题比你所遇到的大多数的bug都要隐秘,针对此问题主要有三种解决方案:

  • 在同一时刻不允许一个线程访问多个资源。
  • 为资源访问权的获取定义一个关系顺序。换句话说,当一个线程已经获得了R1的访问权后,将无法获得R2的访问权。当然,访问权的释放必须遵循相反的顺序。
  • 为所有访问资源的请求系统地定义一个最大等待时间(超时时间),并妥善处理请求失败的情况。几乎所有的.NET的同步机制都提供了这个功能。

前两种技术效率更高但是也更加难于实现。事实上,它们都需要很强的约束,而这点随着应用程序的演变将越来越难以维护。尽管如此,使用这些技术不会存在失败的情况。

大的项目通常使用第三种方法。事实上,如果项目很大,一般来说它会使用大量的资源。在这种情况下,资源之间发生冲突的概率很低,也就意味着失败的情况会比较罕见。我们认为这是一种乐观的方法。秉着同样的精神,我们在19.5节描述了一种乐观的数据库访问模型。

5. 使用volatile字段与Interlocked类实现同步

5.1 volatile字段

volatile字段可以被多个线程访问。我们假设这些访问没有做任何同步。在这种情况下,CLR中一些用于管理代码和内存的内部机制将负责同步工作,但是此时不能确保对该字段读访问总能读取到最新的值,而声明为volatile的字段则能提供这样的保证。在C#中,如果一个字段在它的声明前使用了volatile关键字,则该字段被声明为volatile。

不是所有的字段都可以成为volatile,成为这种类型的字段有一个条件。如果一个字段要成为volatile,它的类型必须是以下所列的类型中的一种:

  • 引用类型(这里只有访问该类型的引用是同步的,访问其成员并不同步)。
  • 一个指针(在不安全的代码块中)。
  • sbyte、byte、short、ushort、int、uint、char、float、bool(工作在64位处理器上时为double、long与ulong)。
  • 一个使用以下底层类型的枚举类型:byte、sbyte、short、ushort、int、uint(工作在64位的处理器上时为double、long与ulong)。

你可能已经注意到了,只有值或者引用的位数不超过本机整型值的位数(4或8由底层处理器决定)的类型才能成为volatile。这意味着对更大的值类型进行并发访问必须进行同步,下面我们将会对此进行讨论。

5.2 System.Threading.Interlocked类

经验显示,那些需要在多线程情况下被保护的资源通常是整型值,而这些被共享的整型值最常见的操作就是增加/减少以及相加。.NETFramework利用System.Threading.Interlocked类提供了一个专门的机制用于完成这些特定的操作。这个类提供了Increment()、Decrement()与Add()三个静态方法,分别用于对int或者long类型变量的递增、递减与相加操作,这些变量以引用方式作为参数传入。我们认为使用Interlocked类让这些操作具有了原子性。

下面的程序显示了两个线程如何并发访问一个名为counter的整型变量。一个线程将其递增5次,另一个将其递减5次。

例5

该程序输出(以非确定方式输出,意味着每执行一次显示的结果都是不同的):

如果我们不让这些线程在每次修改变量后休眠10毫秒,那么它们将有足够的时间在一个时间片中完成它们的任务,那样也就不会出现交叉操作,更不用说并发访问了。

5.3 Interlocked类提供的其他功能

Interlocked类还允许使用Exchange()静态方法,以原子操作的形式交换某些变量的状态。还可以使用CompareExchange()静态方法在满足一个特定条件的基础上以原子操作的形式交换两个值。

6.使用System.Threading.Monitor类与C#的lock关键字实现同步

以原子操作的方式完成简单的操作无疑是很重要的,但是这还远不能涵盖所有需要用到同步的事例。System.Threading.Monitor类几乎允许将任意一段代码设置为在某个时间仅能被一个线程执行。我们将这段代码称之为临界区。

6.1 Enter()方法和Exit()方法

Monitor类提供了Enter(object)与Exit(object)这两个静态方法。这两个方法以一个对象作为参数,该对象提供了一个简单的方式用于唯一标识那个将以同步方式访问的资源。当一个线程调用了Enter()方法,它将等待以获得访问该引用对象的独占权(仅当另一个线程拥有该权力的时候它才会等待)。一旦该权力被获得并使用,线程可以对同一个对象调用Exit()方法以释放该权力。

一个线程可以对同一个对象多次调用Enter(),只要对同一对象调用相同次数的Exit()来释放独占访问权。

一个线程也可以在同一时间拥有多个对象的独占权,但是这样会产生死锁的情况。

绝不能对一个值类型的实例调用Enter()与Exit()方法。

不管发生了什么,必须在finally子句中调用Exit()以释放所有的独占访问权。

如果在例5-5中,一个线程非要将counter做一次平方而另一个线程非要将counter乘2,我们就不得不用Monitor类去替换对Interlocked类的使用。f1()与f2()的代码将变成下面这样:

例6[1]

人们很容易想到用counter来代替typeof(Program),但是counter是一个值类型的静态成员。需要注意平方和倍增操作是不满足交换律的,所以counter的最终结果是非确定性的。

6.2 C#的lock关键字

C#语言通过lock关键字提供了一种比使用Enter()和Exit()方法更加简洁的选择。我们的程序可以改写为下面这个样子:

例7

和for以及if关键字一样,如果被lock关键字定义的块仅包含一条指令,就不再需要花括号。我们可以再次改写为:

使用lock关键字将引导C#编译器创建出相应的try/finally块,这样仍旧可以预期到任何可能引发的异常。可以使用Reflector或者ildasm.exe工具验证这一点。

6.3 SyncRoot模式

和前面的例子一样,我们通常在一个静态方法中使用Monitor类配合一个Type类的实例。同样,我们往往会在一个非静态方法中使用this关键字来实现同步。在两种情况下,我们都是通过一个在类外部可见的对象对自身进行同步。如果其他部分的代码也利用这些对象来实现自身的同步,就会出现问题。为了避免这种潜在的问题,我们推荐使用一个类型为object的名为SyncRoot的私有成员,至于该成员是静态的还是非静态的则由需要而定。

例8

System.Collections.ICollection接口提供了object类型的SyncRoot{get;}属性。大多数的集合类(泛型或非泛型)都实现了该接口。同样地,可以使用该属性同步对集合中元素的访问。不过在这里SyncRoot模式并没有被真正的应用,因为我们对访问进行同步所使用对象不是私有的。

例9

6.4 线程安全类

若一个类的每个实例在同一时间不能被一个以上的线程所访问,则该类称之为一个线程安全的类。为了创建一个线程安全的类,只需将我们见过的SyncRoot模式应用于它所包含的方法。如果一个类想变成线程安全的,而又不想为类中代码增加过多负担,那么有一个好方法就是像下面这样为其提供一个经过线程安全包装的继承类。

例10

另一种方法就是使用System.Runtime.Remoting.Contexts.SynchronizationAttribute,这点我们将在本章稍后讨论。

6.5 Monitor.TryEnter()方法

该方法与Enter()相似,只不过它是非阻塞的。如果资源的独占访问权已经被另一个线程占据,该方法将立即返回一个false返回值。我们也可以调用TryEnter()方法,让它以毫秒为单位阻塞一段有限的时间。因为该方法的返回结果并不确定,并且当获得独占访问权后必须在finally子句中释放该权力,所以建议当TryEnter()失败时立即退出正在调用的函数:

例11[2]

6.6 Monitor类的Wait()方法, Pulse()方法以及PulseAll()方法

Wait()、Pulse()与PulseAll()方法必须在一起使用并且需要结合一个小场景才能被正确理解。我们的想法是这样的:一个线程获得了某个对象的独占访问权,而它决定等待(通过调用Wait())直到该对象的状态发生变化。为此,该线程必须暂时失去对象独占访问权,以便让另一个线程修改对象的状态。修改对象状态的线程必须使用Pulse()方法通知那个等待线程修改完成。下面有一个小场景具体说明了这一情况。

  • 拥有OBJ对象独占访问权的T1线程,调用Wait(OBJ)方法将它自己注册到OBJ对象的被动等待列表中。
  • 由于以上的调用,T1失去了对OBJ的独占访问权。因此,另一个线程T2通过调用Enter(OBJ)获得OBJ的独占访问权。
  • T2最终修改了OBJ的状态并调用Pulse(OBJ)通知了这次修改。该调用将导致OBJ被动等待列表中的第一个线程(在这里是T1)被移到OBJ的主动等待列表的首位。而一旦OBJ的独占访问权被释放,OBJ主动等待列表中的第一个线程将被确保获得该权力。然后它就从Wait(OBJ)方法中退出等待状态。
  • 在我们的场景中,T2调用Exit(OBJ)以释放对OBJ的独占访问权,接着T1恢复访问权并从Wait(OBJ)方法中退出。
  • PulseAll()将使得被动等待列表中的线程全部转移到主动等待列表中。注意这些线程将按照它们调用Wait()的顺序到达非阻塞态。

如果Wait(OBJ)被一个调用了多次Enter(OBJ)的线程所调用,那么该线程将需要调用相同次数的Exit(OBJ)以释放对OBJ的访问权。即使在这种情况下,另一个线程调用一次Pulse(OBJ)就足以将第一个线程变成非阻塞态。

下面的程序通过ping与pong两个线程以交替的方式使用一个ball对象的访问权来演示该功能。

例12

该程序输出(以不确定的方式):

pong线程没有结束并且仍然阻塞在Wait()方法上。由于pong线程是第二个获得ball对象的独占访问权的,所以才导致了该结果。

Java多线程

尽管线程对象的常用方法可以通过API文档来了解,但是有很多方法仅仅从API说明是无法详细了解的。我们先来说一下线程对象的几个重要的方法:
   

首先我们来说明start() 方法。
    一个线程对象生成后,如果要产生一个执行的线程,就一定要调用它的start()方法.在介绍这个方法时不得不同时说明run方法.其实线程对 象的run方法完全是一个接口回调方法,它是你这个线程对象要完成的具体逻辑.简单说你要做什么就你在run中完成,而如何做,什么时候做就不需要你控制 了,你只要调用start()方法,JVM就会管理这个线程对象让它产生一个线程并注册到线程处理系统中。
    从表面上看,start()方法调用了run()方法,事实上,start()方法并没有直接调用run方法.在JDK1.5以前 start()方法是本地方法,它如何最终调用run方法已经不是JAVA程序员所能了解的.而在JDK1.5中,原来的那个本地start()方法被 start0()代替,另个一个纯JAVA的start()中调用本地方法start0(),而在start()方法中做了一个验证,就是对一个全局变量 (对象变量)started做检验,如果为true,则start()抛出异常,不会调用本地方法start0(),否则,先将该变量设有true,然后 调用start0()。
    从中我们可以看到这个为了控制一个线程对象只能运行成功一次start()方法.这是因为线程的运行要获取当前环境,包括安全 , 父线程的权限, 优先级等条件,如果一个线程对象可以运行多次,那么定义一个static 的线程在一个环境中获取相应权限和优先级,运行完成后它在另一个环境中利用原来的权限和优先级等属性在当前环境中运行,这样就造成无法预知的结果.简单说 来,让一个线程对象只能成功运行一次,是基于对线程管理的需要。
    start()方法最本质的功能是从CPU中申请另一个线程空间来执行 run()方法中的代码,它和当前的线程是两条线,在相对独立的线程空间运行 ,也就是说,如果你直接调用线程对象的run()方法,当然也会执行,但那是 在当前线程中执行,run()方法执行完成后继续执行下面的代码.而调用start()方法后,run()方法的代码会和当前线程并发(单CPU)或并行 (多CPU)执行。 
    所以请记住一句话[调用线程对象的run方法不会产生一个新的线程 ],虽然可以达到相同的执行结果,但执行过程和执行效率不同。
    [线程的interrupt()方法,interrupted()和isInterrupted()] 
    这三个方法是关系非常密切而且又比较复杂的,虽然它们各自的功能很清楚,但它们之间的关系有大多数人不是真正的了解。
    先说interrupt()方法,它是实例方法,而它也是最奇怪的方法,在java语言中,线程最初被设计为"隐晦难懂"的东西,直到现在它的 语义不没有象它的名字那样准确。大多数人以为,一个线程象调用了interrupt()方法,那它对应的线程就应该被中断而抛出异常,事实中,当一个线程对象调用interrupt()方法,它对应的线程并没有被中断,只是改变了它的中断状态。 
    使当前线程的状态变以中断状态,如果没有其它影响,线程还会自己继续执行。
    只有当线程执行到sleep,wait,join等方法时,或者自己检查中断状态而抛出异常的情况下,线程才会抛出异常。 
    如果线程对象调用interrupt()后它对应的线程就立即中断,那么interrupted()方法就不可能执行。
    因为interrupted()方法是一个static方法,就是说只能在当前线程上调用,而如果一个线程interrupt()后它已经中断了,那它又如何让自己interrupted()?
    正因为一个线程调用interrupt()后只是改变了中断状态,它可以继续执行下去,在没有调用sleep,wait,join等法或自己抛 出异常之前,它就可以调用interrupted()来清除中断状态(还会原状)interrupted()方法会检查当前线程的中断状态,如果为 "被中断状态"则改变当前线程为"非中断状态"并返回true,如果为"非中断状态"则返回false,它不仅检查当前线程是否为中断状态,而且在保证当 前线程回来非中断状态,所以它叫"interrupted",是说中断的状态已经结束(到非中断状态了) isInterrupted()方法则仅仅检查线 程对象对应的线程是否是中断状态,并不改变它的状态。
    目前大家只能先记住这三个方法的功能,只有真正深入到多线程编程实践中,才会体会到它们为什么是对象方法,为什么是类方法。
    线程到底什么时候才被中断抛出InterruptedException异常,我们将在提高篇中详细讨论。


    [sleep(),join(),yield()方法] 
    在现在的环节中,我只能先说明这些方法的作用和调用原则,至于为什么,在基础篇中无法深入,只能在提高篇中详细说明。
    sleep()方法中是类方法,也就是对当前线程而言的,程序员不能指定某个线程去sleep,只能是当前线程执行到sleep()方法时,睡眠指定的时间(让其它线程运行).事实上也只能是类方法,在当前线程上调用.试想如果你调用一个线程对象的sleep()方法,那么这个对象对应的线程如 果不是正在运行,它如何sleep()?所以只有当前线程,因为它正在执行,你才能保证它可以调用sleep()方法。
    原则:[在同步方法中尽量不要调用线程的sleep()方法 ],或者简单说,对于一般水平的程序员你基本不应该调用sleep()方法。
    join()方法,正如第一节所言,在一个线程对象上调用join方法,是当前线程等待这个线程对象对应的线程结束 ,比如有两个工作,工作A要耗时10秒钟,工作B要耗时10秒或更多。我们在程序中先生成一个线程去做工作B,然后做工作A。
    new B().start();//做工作B
    A();//做工作A
    工作A完成后,下面要等待工作B的结果来进行处理.如果工作B还没有完成我就不能进行下面的工作C,所以
    B b=new B();
    b.start();//做工作B
    A();//做工作A
    b.join();//等工作B完成。
    C();//继续工作C。
    原则:[join是测试 其 它工作状态的唯一正确方法],我见过很多人,甚至有的是博士生,在处理一项工作时如果另一项工作没有完成,说让当前工 作线程sleep(x),我问他,你这个x是如何指定的,你怎么知道是100毫秒而不是99毫秒或是101毫秒?其实这就是OnXXX事件的实质,我们不 是要等多长时间才去做什么事,而是当等待的工作正好完成的时候去做。
    yield()方法也是类方法,只在当前线程上调用,理由同上,它主是让当前线程放弃本次分配到的时间片原则 :[不是非常必要的情况下,没有理 由调用它 ].调用这个方法不会提高任何效率,只是降低了CPU的总周期上面介绍的线程一些方法,基于(基础篇)而言只能简单提及.以后具体应用中我会结合 实例详细论述。
    线程本身的其它方法请参看API文档.下一节介绍非线程的方法,但和线程密切相关的两[三]个对象方法:
    [wait(),notify()/notifyAll()] 
    这是在多线程中非常重要的方法。
    关于这两个方法,有很多的内容需要说明.在下面的说明中可能会有很多地方不能一下子明白,但在看完本节后,即使不能完全明白,你也一定要回过头来记住下面的两句话:
    [wait(),notify()/notityAll()方法是普通对象的方法(Object超类中实现),而不是线程对象的方法]
    [wait(),notify()/notityAll()方法只能在同步方法中调用]
 
    [线程的互斥控制]
    多个线程同时操作某一对象时,一个线程对该对象的操作可能会改变其状态,而该状态会影响另一线程对该对象的真正结果.
    这个例子我们在太多的文档中可以看到,就象两个操售票员同时售出同一张票一样.
    线程A 线程B
    1.线程A在数据库中查询存票,发现票C可以卖出
    class="left"2.线程A接受用户订票请求,准备出票.
    3.这时切换到了线程B执行
    4.线程B在数据库中查询存票,发现票C可以卖出
    5.线程B将票卖了出去
    6.切换到线程A执行,线程A卖了一张已经卖出的票
    所以需要一种机制来管理这类问题的发生,当某个线程正在执行一个不可分割的部分时,其它线程不能不能同时执行这一部分.
    象这种控制某一时刻只能有一个线程执行某个执行单元的机制就叫互斥控制或共享互斥(mutual exclusion)
    在JAVA中,用synchornized关键字来实现互斥控制(暂时这样认为,JDK1.5已经发展了新的机制)
    [synchornized关键字]
    把一个单元声明为synchornized,就可以让在同一时间只有一个线程操作该方法.
    有人说synchornized就是一把锁,事实上它确实存在锁,但是是谁的锁,锁谁,这是一个非常复杂的问题.
    每个对象只有一把监视锁(monitor lock),一次只能被一个线程获取.当一个线程获取了这一个锁后,其它线程就只能等待这个线程释放锁才能再获取. 
    那么synchornized关键字到底锁什么?得到了谁的锁?
    对于同步块,synchornized获取的是参数中的对象锁:
    synchornized(obj){  //...............  }  线程执行到这里时,首先要获取obj这个实例的锁,如果没有获取到线程只能等待.如果多个线程执行到这里,只能有一个线程获取obj的锁,然后执行 {}中的语句,所以,obj对象的作用范围不同,控制程序不同.
    假如:
    public void test(){ 

 Object o = new Object();                                                                          synchornized(obj){  //...............  }  }                                                   这段程序控制不了任何,多个线程之间执行到Object o = new Object();时会各自产生一个对象然后获取这个对象有监视锁,各自皆大欢喜地执行.
    而如果是类的属性:
    class Test{    Object o = new Object();    public void test(){    synchornized(o){    //...............    }    }}  所有执行到Test实例的synchornized(o)的线程,只有一个线程可以获取到监视锁.
    有时我们会这样:
    public void test(){    synchornized(this){    //...............    }    }  那么所有执行Test实例的线程只能有一个线程执行.而synchornized(o)和synchornized(this)的范围是不同 的,因为执行到Test实例的synchornized(o)的线程等待时,其它线程可以执行Test实例的synchornized(o1)部分,但多 个线程同时只有一个可以执行Test实例的synchornized(this).]
    而对于
    synchornized(Test.class ){    //...............    }  这样的同步块而言,所有调用Test多个实例的线程赐教只能有一个线程可以执行. 
    [synchornized方法]
    如果一个方法声明为synchornized的,则等同于把在为个方法上调用synchornized(this).
    如果一个静态方法被声明为synchornized,则等同于把在为个方法上调用synchornized(类.class).
 
    现在进入wait方法和notify/notifyAll方法.这两个(或叫三个)方法都是Object对象的方法,而不是线程对象的方法.如同锁一样,它们是在线程中调用某一对象上执行的.
    class Test{    public synchornized void test(){    //获取条件,int x 要求大于100;    if(x < 100)    wait();    }    }  这里为了说明方法没有加在try{}catch(){}中,如果没有明确在哪个对象上调用wait()方法,则为this.wait();
    假如:
    Test t = new Test();
    现在有两个线程都执行到t.test();方法.其中线程A获取了t的对象锁,进入test()方法内.
    这时x小于100,所以线程A进入等待.
    当一个线程调用了wait方法后,这个线程就进入了这个对象的休息室(waitset),这是一个虚拟的对象,但JVM中一定存在这样的一个数据结构用来记录当前对象中有哪些程线程在等待.
    当一个线程进入等待时,它就会释放锁,让其它线程来获取这个锁.
    所以线程B有机会获得了线程A释放的锁,进入test()方法,如果这时x还是小于100,线程B也进入了t的休息室.
    这两个线程只能等待其它线程调用notity[All]来唤醒.
    但是如果调用的是有参数的wait(time)方法,则线程A,B都会在休息室中等待这个时间后自动唤醒.
    [为什么真正的应用都是用while(条件)而不用if(条件) ]
    在实际的编程中我们看到大量的例子都是用?
    while(x < 100)
    wait();go();而不是用if,为什么呢?
    在多个线程同时执行时,if(x <100)是不安全 的. 因为如果线程A和线程B都在t的休息室中等待,这时另一个线程使x==100了,并调用notifyAll方法,线程A继续 执行下面的go().而它执行完成后,x有可能又小于100,比如下面的程序中调用了--x,这时切换到线程B,线程B没有继续判断,直接执行go(); 就产生一个错误的条件,只有while才能保证线程B又继续检查一次 .
    [notify/notifyAll方法]
    这两个方法都是把某个对象上休息区内的线程唤醒,notify只能唤醒一个,但究竟是哪一个不能确定,而notifyAll则唤醒这个对象上的休息室中所有的线程.
    一般有为了安全性,我们在绝对多数时候应该使用notifiAll(),除非你明确知道只唤醒其中的一个线程. 
    那么是否是只要调用一个对象的wait()方法,当前线程就进入了这个对象的休息室呢?事实中,要调用一个对象的wait()方法,只有当前线程获取了这个对象的锁,换句话说一定要在这个对象的同步方法或以这个对象为参数的同步块中. 
    class MyThread extends Thread{                                                           

              Test t = new Test();

               public void run(){                               

                                 t.test();                                  

                               System.out.println("Thread say:Hello,World!");              

                             } 

                      }                                                    

                 public class Test {                                   

                                 int x=0;                                    

                         public  void test(){                                                                               

                                    if(x==0)                             

                                      try{ 

                                                   wait();        

                                          }catch(Exception e){}  

                           }                              

                  public static void main(String[] args) throws Exception{            

                                    new MyThread().start(); 

                            } 

               }                   这个线程就不会进入t的wait方法而直接打印出Thread say:Hello,World!. 
      而如果改成:
               public class Test {    

                       int x = 0; 

              public synchornized void test(){ 

                       if(x==0) 

                 try{   

                          wait(); 

                      }catch(Exception e){}  

               }    

             public static void main(String[] args) throws Exception{  

                     new MyThread().start(); 

                 }  

             }

 


相比单线程更难、更复杂的一个重要原因就是因为多线程充满着未知性,某条线程是否执行了?某条线程执行了多久?某条线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而Callable+Future/FutureTask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务,真的是非常有用。

 

5、CyclicBarrier和CountDownLatch的区别

两个看上去有点像的类,都在java.util.concurrent下,都可以用来表示代码运行到某个点上,二者的区别在于:

(1)CyclicBarrier的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了这个点,所有线程才重新运行;CountDownLatch则不是,某线程运行到某个点上之后,只是给某个数值-1而已,该线程继续运行

(2)CyclicBarrier只能唤起一个任务,CountDownLatch可以唤起多个任务

(3)CyclicBarrier可重用,CountDownLatch不可重用,计数值为0该CountDownLatch就不可再用了

 

6、volatile关键字的作用

一个非常重要的问题,是每个学习、应用多线程的Java程序员都必须掌握的。理解volatile关键字的作用的前提是要理解Java内存模型,这里就不讲Java内存模型了,可以参见第31点,volatile关键字的作用主要有两个:

(1)多线程主要围绕可见性和原子性两个特性而展开,使用volatile关键字修饰的变量,保证了其在多线程之间的可见性,即每次读取到volatile变量,一定是最新的数据前言

Java多线程分类中写了21篇多线程的文章,21篇文章的内容很多,个人认为,学习,内容越多、越杂的知识,越需要进行深刻的总结,这样才能记忆深刻,将知识变成自己的。这篇文章主要是对多线程的问题进行总结的,因此罗列了40个多线程的问题。

这些多线程的问题,有些来源于各大网站、有些来源于自己的思考。可能有些问题网上有、可能有些问题对应的答案也有、也可能有些各位网友也都看过,但是本文写作的重心就是所有的问题都会按照自己的理解回答一遍,不会去看网上的答案,因此可能有些问题讲的不对,能指正的希望大家不吝指教。

 

40个问题汇总

1、多线程有什么用?

一个可能在很多人看来很扯淡的一个问题:我会用多线程就好了,还管它有什么用?在我看来,这个回答更扯淡。所谓"知其然知其所以然","会用"只是"知其然","为什么用"才是"知其所以然",只有达到"知其然知其所以然"的程度才可以说是把一个知识点运用自如。OK,下面说说我对这个问题的看法:

(1)发挥多核CPU的优势

随着工业的进步,现在的笔记本、台式机乃至商用的应用服务器至少也都是双核的,4核、8核甚至16核的也都不少见,如果是单线程的程序,那么在双核CPU上就浪费了50%,在4核CPU上就浪费了75%。单核CPU上所谓的"多线程"那是假的多线程,同一时间处理器只会处理一段逻辑,只不过线程之间切换得比较快,看着像多个线程"同时"运行罢了。多核CPU上的多线程才是真正的多线程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核CPU的优势来,达到充分利用CPU的目的。

(2)防止阻塞

从程序运行效率的角度来看,单核CPU不但不会发挥出多线程的优势,反而会因为在单核CPU上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核CPU我们还是要应用多线程,就是为了防止阻塞。试想,如果单核CPU使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。

(3)便于建模

这是另外一个没有这么明显的优点了。假设有一个大的任务A,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务A分解成几个小任务,任务B、任务C、任务D,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。

 

2、创建线程的方式

比较常见的一个问题了,一般就是两种:

(1)继承Thread类

(2)实现Runnable接口

至于哪个好,不用说肯定是后者好,因为实现接口的方式比继承类的方式更灵活,也能减少程序之间的耦合度,面向接口编程也是设计模式6大原则的核心。

 

3、start()方法和run()方法的区别

只有调用了start()方法,才会表现出多线程的特性,不同线程的run()方法里面的代码交替执行。如果只是调用run()方法,那么代码还是同步执行的,必须等待一个线程的run()方法里面的代码全部执行完毕之后,另外一个线程才可以执行其run()方法里面的代码。

 

4、Runnable接口和Callable接口的区别

有点深的问题了,也看出一个Java程序员学习知识的广度。

Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。

这其实是很有用的一个特性,因为多线程

(2)代码底层执行不像我们看到的高级语言----Java程序这么简单,它的执行是Java代码-->字节码-->根据字节码执行对应的C/C++代码-->C/C++代码被编译成汇编语言-->和硬件电路交互,现实中,为了获取更好的性能JVM可能会对指令进行重排序,多线程下可能会出现一些意想不到的问题。使用volatile则会对禁止语义重排序,当然这也一定程度上降低了代码执行效率

从实践角度而言,volatile的一个重要作用就是和CAS结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如AtomicInteger。

 

7、什么是线程安全

又是一个理论的问题,各式各样的答案有很多,我给出一个个人认为解释地最好的:如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的

这个问题有值得一提的地方,就是线程安全也是有几个级别的:

(1)不可变

像String、Integer、Long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用

(2)绝对线程安全

不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,Java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,Java中也有,比方说CopyOnWriteArrayList、CopyOnWriteArraySet

(3)相对线程安全

相对线程安全也就是我们通常意义上所说的线程安全,像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这个Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制

(4)线程非安全

这个就没什么好说的了,ArrayList、LinkedList、HashMap等都是线程非安全的类

 

8、Java中如何获取到线程dump文件

死循环、死锁、阻塞、页面打开慢等问题,打线程dump是最好的解决问题的途径。所谓线程dump也就是线程堆栈,获取到线程堆栈有两步:

(1)获取到线程的pid,可以通过使用jps命令,在Linux环境下还可以使用ps -ef | grep java

(2)打印线程堆栈,可以通过使用jstack pid命令,在Linux环境下还可以使用kill -3 pid

另外提一点,Thread类提供了一个getStackTrace()方法也可以用于获取线程堆栈。这是一个实例方法,因此此方法是和具体线程实例绑定的,每次获取获取到的是具体某个线程当前运行的堆栈,

 

9、一个线程如果出现了运行时异常会怎么样

如果这个异常没有被捕获的话,这个线程就停止执行了。另外重要的一点是:如果这个线程持有某个某个对象的监视器,那么这个对象监视器会被立即释放

 

10、如何在两个线程之间共享数据

通过在线程之间共享对象就可以了,然后通过wait/notify/notifyAll、await/signal/signalAll进行唤起和等待,比方说阻塞队列BlockingQueue就是为线程之间共享数据而设计的

 

11、sleep方法和wait方法有什么区别 

这个问题常问,sleep方法和wait方法都可以用来放弃CPU一定的时间,不同点在于如果线程持有某个对象的监视器,sleep方法不会放弃这个对象的监视器,wait方法会放弃这个对象的监视器

 

12、生产者消费者模型的作用是什么

这个问题很理论,但是很重要:

(1)通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用

(2)解耦,这是生产者消费者模型附带的作用,解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要收到相互的制约

 

13、ThreadLocal有什么用

简单说ThreadLocal就是一种以空间换时间的做法,在每个Thread里面维护了一个以开地址法实现的ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了

 

14、为什么wait()方法和notify()/notifyAll()方法要在同步块中被调用

这是JDK强制的,wait()方法和notify()/notifyAll()方法在调用前都必须先获得对象的锁

 

15、wait()方法和notify()/notifyAll()方法在放弃对象监视器时有什么区别

wait()方法和notify()/notifyAll()方法在放弃对象监视器的时候的区别在于:wait()方法立即释放对象监视器,notify()/notifyAll()方法则会等待线程剩余代码执行完毕才会放弃对象监视器

 

16、为什么要使用线程池

避免频繁地创建和销毁线程,达到线程对象的重用。另外,使用线程池还可以根据项目灵活地控制并发的数目。

 

17、怎么检测一个线程是否持有对象监视器

我也是在网上看到一道多线程面试题才知道有方法可以判断某个线程是否持有对象监视器:Thread类提供了一个holdsLock(Object obj)方法,当且仅当对象obj的监视器被某条线程持有的时候才会返回true,注意这是一个static方法,这意味着"某条线程"指的是当前线程

 

18、synchronized和ReentrantLock的区别

synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:

(1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁

(2)ReentrantLock可以获取各种锁的信息

(3)ReentrantLock可以灵活地实现多路通知

另外,二者的锁机制其实也是不一样的。ReentrantLock底层调用的是Unsafe的park方法加锁,synchronized操作的应该是对象头中mark word,这点我不能确定。

 

19、ConcurrentHashMap的并发度是什么

ConcurrentHashMap的并发度就是segment的大小,默认为16,这意味着最多同时可以有16条线程操作ConcurrentHashMap,这也是ConcurrentHashMap对Hashtable的最大优势,任何情况下,Hashtable能同时有两条线程获取Hashtable中的数据吗?

 

20、ReadWriteLock是什么

首先明确一下,不是说ReentrantLock不好,只是ReentrantLock某些时候有局限。如果使用ReentrantLock,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。

因为这个,才诞生了读写锁ReadWriteLock。ReadWriteLock是一个读写锁接口,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。

 

21、FutureTask是什么

这个其实前面有提到过,FutureTask表示一个异步运算的任务。FutureTask里面可以传入一个Callable的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。当然,由于FutureTask也是Runnable接口的实现类,所以FutureTask也可以放入线程池中。

 

22、Linux环境下如何查找哪个线程使用CPU最长

这是一个比较偏实践的问题,这种问题我觉得挺有意义的。可以这么做:

(1)获取项目的pid,jps或者ps -ef | grep java,这个前面有讲过

(2)top -H -p pid,顺序不能改变

这样就可以打印出当前的项目,每条线程占用CPU时间的百分比。注意这里打出的是LWP,也就是操作系统原生线程的线程号,我笔记本山没有部署Linux环境下的Java工程,因此没有办法截图演示,网友朋友们如果公司是使用Linux环境部署项目的话,可以尝试一下。

使用"top -H -p pid"+"jps pid"可以很容易地找到某条占用CPU高的线程的线程堆栈,从而定位占用CPU高的原因,一般是因为不当的代码操作导致了死循环。

最后提一点,"top -H -p pid"打出来的LWP是十进制的,"jps pid"打出来的本地线程号是十六进制的,转换一下,就能定位到占用CPU高的线程的当前线程堆栈了。

 

23、Java编程写一个会导致死锁的程序

第一次看到这个题目,觉得这是一个非常好的问题。很多人都知道死锁是怎么一回事儿:线程A和线程B相互等待对方持有的锁导致程序无限死循环下去。当然也仅限于此了,问一下怎么写一个死锁的程序就不知道了,这种情况说白了就是不懂什么是死锁,懂一个理论就完事儿了,实践中碰到死锁的问题基本上是看不出来的。

真正理解什么是死锁,这个问题其实不难,几个步骤:

(1)两个线程里面分别持有两个Object对象:lock1和lock2。这两个lock作为同步代码块的锁;

(2)线程1的run()方法中同步代码块先获取lock1的对象锁,Thread.sleep(xxx),时间不需要太多,50毫秒差不多了,然后接着获取lock2的对象锁。这么做主要是为了防止线程1启动一下子就连续获得了lock1和lock2两个对象的对象锁

(3)线程2的run)(方法中同步代码块先获取lock2的对象锁,接着获取lock1的对象锁,当然这时lock1的对象锁已经被线程1锁持有,线程2肯定是要等待线程1释放lock1的对象锁的

这样,线程1"睡觉"睡完,线程2已经获取了lock2的对象锁了,线程1此时尝试获取lock2的对象锁,便被阻塞,此时一个死锁就形成了。代码就不写了,占的篇幅有点多,Java多线程7:死锁这篇文章里面有,就是上面步骤的代码实现。

 

24、怎么唤醒一个阻塞的线程

如果线程是因为调用了wait()、sleep()或者join()方法而导致的阻塞,可以中断线程,并且通过抛出InterruptedException来唤醒它;如果线程遇到了IO阻塞,无能为力,因为IO是操作系统实现的,Java代码并没有办法直接接触到操作系统。

 

25、不可变对象对多线程有什么帮助

前面有提到过的一个问题,不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。

 

26、什么是多线程的上下文切换

多线程的上下文切换是指CPU控制权由一个已经正在运行的线程切换到另外一个就绪并等待获取CPU执行权的线程的过程。

 

27、如果你提交任务时,线程池队列已满,这时会发生什么

如果你使用的LinkedBlockingQueue,也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy。

 

28、Java中用到的线程调度算法是什么

抢占式。一个线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。

 

29、Thread.sleep(0)的作用是什么

这个问题和上面那个问题是相关的,我就连在一起了。由于Java采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到CPU控制权的情况,为了让某些优先级比较低的线程也能获取到CPU控制权,可以使用Thread.sleep(0)手动触发一次操作系统分配时间片的操作,这也是平衡CPU控制权的一种操作。

 

30、什么是自旋

很多synchronized里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内核态切换的问题。既然synchronized里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在synchronized的边界做忙循环,这就是自旋。如果做了多次忙循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略。

 

31、什么是Java内存模型

Java内存模型定义了一种多线程访问Java内存的规范。Java内存模型要完整讲不是这里几句话能说清楚的,我简单总结一下Java内存模型的几部分内容:

(1)Java内存模型将内存分为了主内存和工作内存。类的状态,也就是类之间共享的变量,是存储在主内存中的,每次Java线程用到这些主内存中的变量的时候,会读一次主内存中的变量,并让这些内存在自己的工作内存中有一份拷贝,运行自己线程代码的时候,用到这些变量,操作的都是自己工作内存中的那一份。在线程代码执行完毕之后,会将最新的值更新到主内存中去

(2)定义了几个原子操作,用于操作主内存和工作内存中的变量

(3)定义了volatile变量的使用规则

(4)happens-before,即先行发生原则,定义了操作A必然先行发生于操作B的一些规则,比如在同一个线程内控制流前面的代码一定先行发生于控制流后面的代码、一个释放锁unlock的动作一定先行发生于后面对于同一个锁进行锁定lock的动作等等,只要符合这些规则,则不需要额外做同步措施,如果某段代码不符合所有的happens-before规则,则这段代码一定是线程非安全的

 

32、什么是CAS

CAS,全称为Compare and Swap,即比较-替换。假设有三个操作数:内存值V、旧的预期值A、要修改的值B,当且仅当预期值A和内存值V相同时,才会将内存值修改为B并返回true,否则什么都不做并返回false。当然CAS一定要volatile变量配合,这样才能保证每次拿到的变量是主内存中最新的那个值,否则旧的预期值A对某条线程来说,永远是一个不会变的值A,只要某次CAS操作失败,永远都不可能成功。

 

33、什么是乐观锁和悲观锁

(1)乐观锁:就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。

(2)悲观锁:还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像synchronized,不管三七二十一,直接上了锁就操作资源了。

 

34、什么是AQS

简单说一下AQS,AQS全称为AbstractQueuedSychronizer,翻译过来应该是抽象队列同步器。

如果说java.util.concurrent的基础是CAS的话,那么AQS就是整个Java并发包的核心了,ReentrantLock、CountDownLatch、Semaphore等等都用到了它。AQS实际上以双向队列的形式连接所有的Entry,比方说ReentrantLock,所有等待的线程都被放在一个Entry中并连成双向队列,前面一个线程使用ReentrantLock好了,则双向队列实际上的第一个Entry开始运行。

AQS定义了对双向队列所有的操作,而只开放了tryLock和tryRelease方法给开发者使用,开发者可以根据自己的实现重写tryLock和tryRelease方法,以实现自己的并发功能。

 

35、单例模式的线程安全性

老生常谈的问题了,首先要说的是单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。单例模式有很多种的写法,我总结一下:

(1)饿汉式单例模式的写法:线程安全

(2)懒汉式单例模式的写法:非线程安全

(3)双检锁单例模式的写法:线程安全

 

36、Semaphore有什么作用

Semaphore就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore构造函数中传入的int型整数n=1,相当于变成了一个synchronized了。

 

37、Hashtable的size()方法中明明只有一条语句"return count",为什么还要做同步?

这是我之前的一个困惑,不知道大家有没有想过这个问题。某个方法中如果有多条语句,并且都在操作同一个类变量,那么在多线程环境下不加锁,势必会引发线程安全问题,这很好理解,但是size()方法明明只有一条语句,为什么还要加锁?

关于这个问题,在慢慢地工作、学习中,有了理解,主要原因有两点:

(1)同一时间只能有一条线程执行固定类的同步方法,但是对于类的非同步方法,可以多条线程同时访问。所以,这样就有问题了,可能线程A在执行Hashtable的put方法添加数据,线程B则可以正常调用size()方法读取Hashtable中当前元素的个数,那读取到的值可能不是最新的,可能线程A添加了完了数据,但是没有对size++,线程B就已经读取size了,那么对于线程B来说读取到的size一定是不准确的。而给size()方法加了同步之后,意味着线程B调用size()方法只有在线程A调用put方法完毕之后才可以调用,这样就保证了线程安全性

(2)CPU执行代码,执行的不是Java代码,这点很关键,一定得记住。Java代码最终是被翻译成汇编代码执行的,汇编代码才是真正可以和硬件电路交互的代码。即使你看到Java代码只有一行,甚至你看到Java代码编译之后生成的字节码也只有一行,也不意味着对于底层来说这句语句的操作只有一个。一句"return count"假设被翻译成了三句汇编语句执行,完全可能执行完第一句,线程就切换了。

 

38、线程类的构造方法、静态块是被哪个线程调用的

这是一个非常刁钻和狡猾的问题。请记住:线程类的构造方法、静态块是被new这个线程类所在的线程所调用的,而run方法里面的代码才是被线程自身所调用的。

如果说上面的说法让你感到困惑,那么我举个例子,假设Thread2中new了Thread1,main函数中new了Thread2,那么:

(1)Thread2的构造方法、静态块是main线程调用的,Thread2的run()方法是Thread2自己调用的

(2)Thread1的构造方法、静态块是Thread2调用的,Thread1的run()方法是Thread1自己调用的

 

39、同步方法和同步块,哪个是更好的选择

同步块,这意味着同步块之外的代码是异步执行的,这比同步整个方法更提升代码的效率。请知道一条原则:同步的范围越小越好

借着这一条,我额外提一点,虽说同步的范围越少越好,但是在Java虚拟机中还是存在着一种叫做锁粗化的优化方法,这种方法就是把同步范围变大。这是有用的,比方说StringBuffer,它是一个线程安全的类,自然最常用的append()方法是一个同步方法,我们写代码的时候会反复append字符串,这意味着要进行反复的加锁->解锁,这对性能不利,因为这意味着Java虚拟机在这条线程上要反复地在内核态和用户态之间进行切换,因此Java虚拟机会将多次append方法调用的代码进行一个锁粗化的操作,将多次的append的操作扩展到append方法的头尾,变成一个大的同步块,这样就减少了加锁-->解锁的次数,有效地提升了代码执行的效率。

 

40、高并发、任务执行时间短的业务怎样使用线程池?并发不高、任务执行时间长的业务怎样使用线程池?并发高、业务执行时间长的业务怎样使用线程池?

这是我在并发编程网上看到的一个问题,把这个问题放在最后一个,希望每个人都能看到并且思考一下,因为这个问题非常好、非常实际、非常专业。关于这个问题,个人看法是:

(1)高并发、任务执行时间短的业务,线程池线程数可以设置为CPU核数+1,减少线程上下文的切换

(2)并发不高、任务执行时间长的业务要区分开看:

  a)假如是业务时间长集中在IO操作上,也就是IO密集型的任务,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以加大线程池中的线程数目,让CPU处理更多的业务

  b)假如是业务时间长集中在计算操作上,也就是计算密集型任务,这个就没办法了,和(1)一样吧,线程池中的线程数设置得少一些,减少线程上下文的切换

(3)并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦。

 


猜你喜欢

转载自blog.csdn.net/harrytsz/article/details/56671652