Java线程基本概念与多线程协调机制探讨

可能排版存在问题,可以在下载PDF版。

1.线程的基本概念

进程为程序执行提供了环境(比如,内存,与操作系统的交互接口)。而线程则是进程中代码执行的基本单元,它为“进程”中的程序代码提供了执行环境,并因此能够对程序代码的执行状态进行查看和管控。进程中如果没有线程,那就没有办法执行代码。在Java中,进程、线程结构关系如下:

简而言之,线程的为代码运行提供了堆栈(stack),记录调用中的JVM函数或非JVM的本地函数(比如动态连接库-DLL中的函数)入口和参数,记录线程执行过程所使用的局部变量(通常是堆中对象的引用);线程有程序计数器(PC)PC中有寄存器指向下一条应该执行的指令(虚拟机中字节码的地址)。处于运行状态的线程会不断地更新这里的指令,从而使程序不断地向前执行,此时,称为“运行(Running)”状态,线程会占用CPU资源。当线程休眠阻塞(Block)时,就不再更新PC中执行指令,此时称为“非运行(Non-Runnable)“状态。当所有指令都执行完毕,则称为线程终结(Terminated)或死亡。

完整的线程状态变化生命周期及状态说明如下图所示【2,3】:

生命周期状态

说明

New

线程实例被创建但strat()方法还没有调用时的状态。

Runnable

strat()被调用后,线程就处于该状态,此阶段,它正等待线程调度器(scheduler)来运行它。注意:此状态可能极为短暂,此状态与下一状态的转换不由程序员控制。

Running

被线程调度器选中执行的线程就处于该状态。注意:程序员可以通过sleep,wait或锁操作来控制其向Non-Runnable状态的转换。通过yield使其向Runnable状态转化。任其执行不管,自然就会进入到Terminated状态。

Non-Runnable (blocked)

该状态下,线程仍然活着,但是没有资格运行,此时,线程的程序计数器停止运转,指向将要执行的下一条指令的地址。一旦线程变为执行状态(Running)之后,就从程序计数器所指定的代码处继续执行。

Terminated

当线程自身的run()方法退出(exist)之后,线程就处于此状态。run()方法正常执行完毕,或遇到异常都会导致其run()方法退出。

上述状态都是线程对象的内部状态,用来帮助开发者理解线程的生命周期,开发者无法获得。开发者可以利用线程类的静态方法及线程对象的方法,对线程施加管控方法如下:

方法

功能

中断异常

threadN.start

启动线程threadN的执行。

  1. 不涉及

Thread.sleep

当前线程自己决定原地休眠,让出CUP资源,如果它获得某个对象锁,它不会释放锁,其他试图获取该对象锁的线程仍会继续阻塞

  1. 可能抛出中断异常

Thread.yield

当前线程自己决定向同样优先级的其他线程屈服(yield),让出CUP,让其他同优先级线程获得可以执行的机会,但原地随时等待工作。调用此方法不一定会导致当前线程失去执行机会。

  1. 不可能抛出中断异常

threadN.join

让threadN“加塞”到当前线程之前,当前线程等待threadN的完成。Join方法中,当前线程把threadN当作一个可变的共享资源进行同步操作。它必须取得threadN对象的锁才能进行此操作,但join中调用了wait方法,因此会阻塞调用该方法的当前线程,也会释放threadN的锁。

  1. 可能抛出中断异常

threadN.interrupt

当前线程(试图)中断线程threadN注意Java中线程不会被强行中断,尽管对threadN调用了Interrupt()方法,实际上只是将threadN 的“中断标志”设置为true,并没有改变threadN的状态,如果threadN处于running或runnable状态,则该线程在其所执行的代码中通过查看“中断标志”,自主决定其是否终止执行(中断)。而当threadN处于阻塞(No-Runnable)状态时,引起threadN阻塞的方法(seep或wait和join)会收到“中断标志”变更的通知,如果发现该标志被设置为true,就主动抛出InterruptedException。那么调用引起休眠或阻塞方法(sleep\wait\join)的线程在运行到这些方法时就会捕获到该异常。

  1. 导致执行sleep,

join, wait方法使threadN“阻塞”的线程(也可能是threadN自己)会抛出异常。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

注:当前线程是指执行当前代码行的线程

上面介绍了线程的基本概念,可以说,在运行中的java程序——进程中,线程是java对象真正的创建、维护和使用者,是java对象的主人,当存在多个线程对Java对象进行创建、维护和使用时,就意味着一个资源有多个主人,就需要引入一个管家机制来协调主人们对资源的使用,注意:管家只能协调,不能替主人做主。下面介绍Java基本的多线程协调机制就涉及到锁(lock)与锁管理(监管者)的机制。

2.Java多线程协调机制

我们知道,在java中用new所创建java对象都在进程的堆内存(Heap)之中,堆内存被多个线程所共享,如果不加以限制和管控,多个线程就可能会对同一个java对象进行操作而线程之间彼此并不知道对方做了什么。这是一件可怕的事情,会引起“条件竞争”。所谓“条件竞争”就是一个线程根据它对当前所观察到的“可变的共享资源”状态来决定后续与该资源状态相关的操作(更改,或者再次与之比较),而共享资源的状态在其进行后续操作过程中,很可能被其他线程所更改,这就使得线程进行后续操作所依赖的条件并不可靠,从而导致不可预期的后果。

打个比方,当你走入一个咖啡厅,看到有座位是空的(当前共享的座位资源状态——是可变的),根据这个情况(当前有座位是空的这个状态作为条件),你决定可以在此休息,于是,你去点了咖啡和面包(后续操作,可能会花点时间),当你端着咖啡和面包托盘去寻找那个几个空座位时,你发现这些座位都被别人占用了,你立即感觉自己悲剧了(不可预料的后果发生了)。这就是多线程(你和其他的顾客都看作线程对象,查看座位情况、点咖啡,寻找座位是该线程要执行的代码)共享状态可变的资源时的条件竞争1

引发这个问题的一个原因就是资源状态的可变性,如果资源状态不可变,那么就不存在任何问题,因而不可变对象是线程安全对象1。所以很多人倡导采用类似Scala这样在JVM上运行的函数式编程语言,函数式编程鼓励资源不可变,并在语言层面为之提供了很多便利保障(当然,Java8也提供部分函数式编程支持,但是不够简洁和全面)。但是,我们总有些代码需要去改变资源的状态,否则世界只能进行仿真预测,而不会发生真正的,实际的变化。那么,解决上述问题的另一个关键点就是保证线程能够对状态可变的共享资源实现排他性的独占(互斥)

Java采用锁(Lock)来辅助线程管理对象(Monitor)实现线程的排他性独占。锁(Lock)可以看作每个Java对象的头部(header)附加一个类似信号灯的状态数据,用来指示作为资源的Java对象的“独占状态”【2,3】,同时还为每个java对象分配了一个“监管器(Monitor)”对象来管理线程对java对象的占用,如下图所示:

 

形象一点说,可以把java对象看作一个有多个主人”共同使用的“书房”资源,线程监视器(Monitor可以看作书房的管家,服务于当前进入书房的主人,入主书房也可以看作是书房管家当前的主人,只有他才有资格向管家发号施令。书房的门上有一把自动锁(Lock),可以从内部对书房上锁,当“主人”请求独占书房时,会看一下房间是否反锁(处于关门状态),如果没有反锁,说明书房无其他主人占用,这人“主人”就能够入主书房,此时管家会记录谁在占用书房,并为他服务。“主人”进入书房后,房门就会自动反锁。此时房间主人可以放心地在书房中做事,不用担心自己想要使用的东西在下一刻被其他人拿走,而其他请求进入这个书房的“主人”则会在门外等待,该书房的管家会记录哪些“主人”在等待该书房。当然,虽然“主人”还没有使用完书房,但是可以决定临时离开,去休息一下,只需要声明或登记一下即可,在其临时离开书房后,门被打开(门锁被打开\释放),书房可以让其他“主人”进入,但管家会记录从此书房临时离开去休息的人,书房“当前主人”也可以要求管家把从此书房临时离开去休息的“其他主人”召唤回来,此时管家就会根据“记录”召唤临时离开的人,当“当前主人”退出书房后,这些被召唤回来的“主人”就会凭优先级和运气先后依次进入书房继续自己后续的事情。如果“主人”真正使用完书房,退房而去,管家就不再操心这个“主人”。管家(Monitor对象结构及对“书房主人(线程)”的管理如下【4】:

线程,Java对象,LockMnitor的互动关系就如同上述主人、书房、门锁和管家的互动关系,稍有不同的是,使用者看不到锁和管家,只与房间打交道。也就是说,LockMonitor对外界(包括线程)而言是隐藏的,线程与Java对象互动即可,Java对象内部实现代码中完成了MonitorLock进行互动,这样就极大地简化了线程协调代码的编写。但对于开发者而言,还是要了解背后隐藏的LockMonitor的工作机制,否则很难理解其线程相关API

上述申请独占资源,对独占资源进行处理,临时离开去休息,当前主人召唤其他休息的人回来继续工作的机制如何在编程中体现呢?请看以下语句:
synchronized (objx)  {
//同步块,即,独占资源后的操作代码块!
指令1:………………
指令2:…………….
指令m…………….
objx.wait()  //告知监管者临时离开
指令n…………//召唤回来后,从此处继续执行
指令z……………
}
当线程执行到synchronized (objx)语句之后,用关键字synchronized表示对objx资源独占的申请,我们把objx可以称为同步对象。一旦对同步对象独占成功,线程则会执行同步块中的代码。此时,所有想要执行上面或其他不同的synchronized (objx){……}代码块的线程都会阻塞,直到这个独占objx资源的线程临时离开或者同步操作全部执行完毕,它们才有机会独占资源并进入同步块中执行代码。
在同步代码块中objx.wait()表示当前独占资源的线程“通过被独占的objx资源”来告知Monitor即将临时离开,去休息一会儿,英文的字面意思是:“等休息一会儿我还会回来的”。注意,wait()方法的语义是objx当前的主人才有资格通过“被独占的objx资源”向管家发出临时离开的告知,如果资源没有被独占,调用此方法会抛出异常。也就是说,不在同步代码块中调用objx.wait()会抛出异常:IllegalMonitorStateException。
当位于任何一个synchronized (objx)同步块(包括前面提到的同步块)中的代码被执行时,就意味着执行该代码的线程是当前独占资源的主人,只要在同步块中调用objx.notify()或objx.notifyAll()就表示当前线程在退出资源独占后,把其他临时离开的线程召唤回来继续他们各自未完成的处理。其中objx.notify只能召唤任意的一个线程,其他临时离开的线程还会处于等待状态,而objx.notifyAll则是召唤所有的临时离开的线程,这些线程凭优先级和运气先后独占资源并继续执行。同wait()方法道理一样,不在同步块中调用objx.notify()或objx.notifyAll(),也会抛出IllegalMonitorStateException异常。
还需要特别注意的是:更多的时候,入主书房的主人对书房本身兴趣并不大,独占书房是为了使用书房之中的笔墨纸砚等资源。此时,书房是个容器,真正要使用的笔墨纸砚是被封闭在容器里面的资源。因此,把哪些东西封闭在书房中很重要,这些东西被封闭在书房之中,就不允许在其他地方还能使用。这就是“线程封闭”技术的主要思路1。
还是通过上面提到同步块来看一看: 
synchronized (objx)  {
//同步块,即,独占资源后的操作代码块!指令1:………………
指令2:…………….
指令m…………….
objx.wait()  //告知监管者临时离开
指令n…………//召唤回来后,从此处继续执行
指令z……………
}
   注意:在该同步块中所操作的所有可变的共享Java对象,都是被objx作为容器所封闭的资源,开发者必须保证,只能在以objx作为容器的各个同步块(简称objx同步块)中对这些被封闭的资源进行操作,如果在“objx同步块”中所操作的可变共享资源还在其他“非objx同步块”的地方被操作,那说明该资源已从封闭容器objx中泄漏出去,会导致不可预期的后果。
此时,回头再看看thread上的线程操作方法,可以这样理解:
  • Tthread.sleep,是当前执行线程自己决定原地休息,如果threadN正在“书房”中,threadN不会离开“书房”,只是关灯(节省公共基础设施,相当于让出CPU)并在书房中休息。
  • Thread.yield是当前线程向其他同样优先级的“同事”屈服,表示撑不住其他同事要求用电的压力,暂时停止工作,但随时准备工作,如果他正在“书房中”,那么只是关灯等待(节省CUP),一旦电力不紧张,他就开始开灯工作,因此,他不会释放锁。
  • threadN.join则是当前线程(正在执行threadN.join的线程)把threadN作“共享可变的资源”予以操作,当前线程会观察线程threadN的状态是否已结束,如果没有结束(还活着isAlive=true就一直(或按照给定等待时间)等待threadN结束,等待期间,当前线程会“临时释放”对threadN锁(或独占),允许其他线程独占thraedN而自己则处于阻塞状态5。这里需要了解的是,其实Java线程对象(Thread对对象)也是存放在堆中的一种Java对象,这个Java线程对象是操作系统线程对象在JVM中的代理。当多个线程对象对某一个线程对象进行操作的时候,这个被操作的线程对象也是一个共享的可变资源。
基于以上理解,下面列出上述方法相关的线程阻塞与锁的释放情况:

方法

谁会受影响而阻塞

阻塞点及PC指令地址

锁的释放情况

Thread.sleep

当前执行了该代码的线程会阻塞

阻塞在原地,PC指令位于该代码的下一有效行。如果原地是在同步块内,则会引起其他试图进入同步块的线程阻塞。

如果原地是在同步块中,则不会释放锁

Thread.yield

当前执行了该代码的线程处于原地准备运行状态,不会阻塞当前线程。

不存在

如果原地是在同步块中,则不会释放锁

threadN.join

当前执行该代码的线程会阻塞。

阻塞在threadN的join方法中,PC指令位置位于该方法结尾处。一旦恢复运行,则立即会从join方法代码中退出。

释放对threadN的锁

objx.wait

当前正在执行该代码的线程阻塞

阻塞在以objx为同步对象的同步块中。PC指针位于该代码的下一有效行。

释放对objx的锁

Java并发编程非常复杂,

就算使用从Java7开始附带的并发开发类库,也需要正确理解Java线程的基本知识。哪怕使用Java并发开发类库,如果并发编程规模较大,使多线程程序正确运行也是极具挑战的一件事情。如果真的需要大规模开发高并发业务系统,建议使用Akka框架,Akka框架所提供的API从根本上回避了并发编程,但仍能够使程序在多台多核计算机集群环境下,以高可用和高容错方式高性能地运行。基于Akka框架,你可以开发出真正的响应式系统,关于什么是响应式系统,请参见《响应式宣言》https://www.reactivemanifesto.org/)。

主要参考资料:

  1. Java并发编程:设计原则与模式(第二版)》 Doug Lea
  2. https://www.techbeamers.com/java-multithreading-with-examples/
  3. https://dzone.com/articles/java-thread-tutorial-creating-threads-and-multithr
  4. https://www.artima.com/insidejvm/ed2/threadsynch.html
  5. Java Thread 类源代码文件
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值