5.1 基础介绍
5.1.1 线程基础
本节内容介绍给那些还没接触过Java线程的朋友希望能有个感性认识。
Java线程英文名是Thread所有的Java程序的运行都是在进程中分配线程来处理的。如果是一个main方法则由一个主线程来处理如果不创建自定义线程那么这个程序就是单线程的。如果是Web应用程序那么就由Web容器分配线程来处理在4.4.1节中介绍了在Tomcat源码中是如何分配线程的。
也许在使用main方法写代码时我们感觉不到多线程的存在在Web程序中也感觉不到多线程和自己编写程序有什么关系但是当遇到一些由于Java并发导致的古怪的问题时当需要自己用多线程来编写程序或者控制多个线程访问共享资源时就会用到相应的知识。
掌握知识的目标是驾驭知识要驾驭知识的前提是了解知识认识它的内在
在Java代码中单独创建线程都要使用类java.lang.Thread通常可以通过继承并扩展Thread原本的run()方法也可以创建一个Thread将一个Runnable任务实体作为参数传入这是通过线程来执行任务的过程但并不能说实现了Runnable接口就是一个线程。
Runnable接口顾名思义就是“可以被执行”的意思。在Java语言中还有一些类似的接口例如Closeable它只能代表“可以被关闭”Closeable接口的描述中提供了一个close()方法要求子类实现。类似的Runnable接口提供了一个要求在实现类中去实现的run()方法换句话说Runnable接口只是说明了外部程序都可以通过其实例化对象调用到run()方法因此我们通常把它叫作“任务”切忌将任务和时间挂在一起基于时间的任务只是一类特殊的任务而已。
从另一个角度来看一个线程的启动是需要通过Thread.start()方法来完成的这个方法会调用本地方法JNI来实现一个真正意义上的线程或者说只有start()成功调用后由OS分配线程资源才能叫作线程而在JVM中分配的Thread对象只是与之对应的外壳。
Runnable既然不是线程那么有何用途
前面提到可以把Runnable看成一个“任务”如果它仅仅与Thread配合使用即在创建线程的时候将Runnable的实例化对象作为一个参数传入那么它将被设置到Thread所在的对象中一个名为“target”的属性上Thread默认的run()方法是调用这个target的run()方法来完成的这样Runnable的概念就与线程隔离了——它本身是任务线程可以执行任务否则Thread需要通过子类去实现run()方法来描述任务的内容。
在后文中会提到线程池中的每个Thread可以尝试获取多个Runnable任务每次获取过来后调用其run()方法这样就更加明显地说明Thread和Runnable不是一个概念。
区分了这个概念后下面用一段简单代码来模拟一个线程的创建和启动。请看代码清单5-1在这段代码中new Thread() {…}在Java堆中创建了一个简单的Java对象当通过这个对象调用其start()方法后就启动了一个线程。不过大家需要注意的是在这段代码中胖哥将两条代码合并为一条来完成不过在内在的执行上依然会是两条代码来完成。
代码清单5-1 一个简单的Thread的执行
01 |
public static void main(String []args) {
|
04 |
System.out.println( "我是被创建的线程我执行了..." );
|
07 |
System.out.println( "main process end..." );
|
12 |
System.out.println( "main process end..." );
|
为了简单起见这段程序使用了一个匿名子类重写了Thread的run()方法与单独写一个继承于Thread的类在功能上是一致的。
这段程序只是让初学者了解到线程的存在。
如果是顺序执行的程序则应当先输出“我是被创建的线程我执行了…”然后再输出“main process end…”因为代码顺序是这样的但是大家通过测试结果会发现不一定而且一般是先输出“main process end…”这是因为run()方法被另一个线程调用了main()方法启动线程后就直接向下执行不过启动线程还需要做一些内核调用的处理最后才会由C区域的方法回调Java中的run()方法此时main线程可能已经输出了内容。
为了进一步验证大家在main()方法和run()方法内部分别输出当前线程ID或NAME即可发现执行的线程是完全不同的如Thread.currentThread().getName()。
此代码验证了两个结果
◎ 通过Thread的start()方法启动了另一个线程来处理任务。
◎ 线程的run()方法调用并不是线程在调用start()方法时被同步调用的而是需要一个很短暂的延迟。
线程到底是什么东西它与进程有何区别呢
通常将线程理解为轻量级进程它和进程有个非常大的区别是多个线程是共享一个进程资源的对于OS的许多资源的分配和管理例如内存通常是进程级别的线程只是OS调度的最小单位线程相对进程更加轻量一些它的上下文信息会更少它的创建与销毁会更加简单线程因为某种原因挂起后不会导致整个进程被挂起一个进程中又可以分配许多的线程所以线程是许多应用系统中大家所喜欢的东西。
但是并非多线程就没有问题它有个很大的问题就是由于某个线程占用过多的资源会导致整个进程“宕”机由于资源共享所以线程之间会相互影响但是多进程通常不会有这个问题它们共享服务器资源相互影响的级别在服务器资源上而不是在进程内部。
选择多线程还是多进程要根据实际情况来定类似于Nginx这类负载均衡软件就采用多进程模型因为它的异步I/O对于高并发来讲已经足以解决进程或线程资源不足的情况而且比多线程模型处理得更好因为它是I/O密集型的。但是应用程序如果是计算密集型的或者涉及大量的业务逻辑处理则并不适合这样做换句话说最终还得根据实际场景来定。
前文中提到new Thread()操作并非完成了线程的创建只有当调用start()方法时才会真正在系统中存在一个线程。在OS处理线程上也有多种方式至于线程是哪种方式对于我们来讲并不是那么重要我们只需要知道存在一个单独的线程可以被调度即可。
我们回想一下第3章提到的一些内容当大量分配线程后可能会报错“unable to create new native thread”说明线程使用的是堆外的内存空间也再次说明Thread本身所对应的实例仅仅是JVM内的一个普通Java对象是一个线程操作的外壳而不是真正的线程。
补充知识通过Thread的实例对象调用start()方法到底是怎么启动线程的下面对其实现方式做一些简单的补充。
◎ 基于Kernel ThreadKLT的映射来实现KLT是内核线程内核线程由OS直接完成调度切换它相对应用程序的线程来讲只是一个接口外部程序会使用一种轻量级进程Light Weight ProcessLWP来与KLT进行一对一的接口调用。也就是说进程内部会尝试利用OS的内核线程去参与实际的调度而自己使用API调用作为中间桥梁与自己的程序进行交互。
◎ 基于用户线程User ThreadUT的实现这种方式是考虑是否可以没有中间这一层映射自己的线程直接由CPU来调度或许理论上效率会更高。不过这样实现时用户进程所需要关注的抽象层次会更低一些跳过OS更加接近CPU即自己要去做许多OS做的事情自然的OS的调度算法、创建、销毁、上下文切换、挂起等都要自己来搞定因为CPU只做计算。这样做显然很麻烦许多人曾经尝试过后来放弃了。
◎ 混合实现方式它的设计理念是希望保留Kernel线程原有架构又想使用用户线程轻量级进程依然与Kernel线程一一对应保持不变唯一变化的就是轻量级进程不再与进程直接挂钩而是与用户线程挂钩用户线程并不一定必须与轻量级进程一一对应而是多对多就像在使用一个轻量级进程列表一样这样增加了一层来解除轻量级进程与原进程之间的耦合可能会使得调度更为灵活。
在以前的JDK版本中尝试使用UT的方式来实现但后来放弃了采用了与Kernel线程对应的方式至于一些细节与具体的平台有很大的关系JVM会适当考虑具体平台的因素去实现在JVM规范中也没规定过必须如何去实现所以对于程序员来讲只需要知道在new Thread()调用start()方法后理论上就有一个可以被OS调度的线程了。
5.1.2 多线程
在上一节的代码中自己创建了一个线程main()方法本身也有一个线程虽然有主次之分但是已经是多线程了。
写多线程程序无非就是加线程数量让多个线程可以并行地去做一些事情。大家可以根据代码清单5-1增加线程来模拟本书就不再给出代码了。
大家在代码清单5-1的基础上多创建几个Thread就得到多线程的结果了例如可以让多个线程输出某些结果通过输出会发现它们会交替输出而不是一个线程输出结束后下一个线程紧跟着再输出结果。
5.1.3 线程状态
谈线程就必然要谈状态为何
对线程的每个操作都可能会使线程处于不同的工作机制下在不同的工作机制下某些动作可能会对它产生不同的影响而不同的工作机制就是用状态来标志的所以我们一定要了解它的状态否则在编写多线程程序时就会出现奇怪的问题。在本小节中胖哥会逐个描述线程中的状态说明导致此线程状态可能的原因以及在某种状态下可以做的事情。
我们不仅要关注线程本身的状态而且要养成一种关注状态变化的习惯甚至于在自己做多线程设计时尝试用一些状态控制某些东西。因为在多线程的知识体系中关于状态的信息远远不止线程本身的状态这样一些信息当然它是最基础的在后文中介绍的许多Java的并发模型中都会存在各种各样的状态转换如果没有养成习惯去抓住这个重点我们将很难看懂代码。
要获取状态可以通过线程Thread的getState()来获取状态的值。例如获取当前线程的状态就可以使用Thread.currentThread().getState()来获取。该方法返回的类型是一个枚举类型是Thread内部的一个枚举全称为“java.lang.Thread.State”这个枚举中定义的类型列表就是Java语言这个级别对应的线程状态列表包含了NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED这些值。现在对照源码中的注释以及胖哥自己的理解来说明它们的意思。
1NEW状态
意思是这个线程没有被start()启动或者说还根本不是一个真正意义上的线程从本质上讲这只是创建了一个Java外壳还没有真正的线程来运行。
再一次提醒大家注意调用了start()并不代表状态就立即改变中间还有一些步骤如果在这个启动的过程中有另一个线程来获取它的状态其实是不确定的要看那些中间步骤是否已经完成了。
2RUNNABLE状态
当处于NEW状态的线程发生start()结束后线程将变成RUNNABLE状态。程序正在运行中的线程就肯定处于RUNNABLE状态上面提到用Thread.currentThread().getState()来获取当前线程的状态只会得到“RUNNABLE”而不会得到其他的值因为要得到结果就必然处于运行中。所以获取状态都是获取其他线程的状态而不是自己的状态。
RUNNABLE状态也可以理解为存活着正在尝试征用CPU的线程有可能这个瞬间并没有占用CPU但是它可能正在发送指令等待系统调度。由于在真正的系统中并不是开启一个线程后CPU就只为这一个线程服务它必须使用许多调度算法来达到某种平衡不过这个时候线程依然处于RUNNABLE状态。
举个例子当某个运行中的线程发生了yield()操作时其实看到的线程状态也是RUNNABLE只是它有一个细节的内部变化就是做一个简单的让步。既然谈到让步我们就来简单说说什么叫作让步。
胖哥认为这是一种“高素质”的做法它自己可能在做大量CPU计算认为自己会在相对较长的时间内占用资源如果调度算法存在问题就会一直占用CPU所以在适当的时候做下让步让别人也来使用下CPU资源。
在生活中就好比一群人排队到取款机上取款有些人可能喜欢查了取、取了查、查了再取、取了再查也许中间还有许多思考的过程或许在计算也许还有许多从包里拿出和放入的动作或许再打个电话再整理下衣服。这在这些人心目中是正常的因为他们认为现在是属于自己的私人空间但却忽略了后面还有很多人在等待的因素可能有人在等待太久以后就放弃了就像放弃CPU调度一样。而高素质的人会觉得自己占用的时间太长了会“不好意思”主动意识到耽误了别人太多的时间自己会出来让别人先处理等别人处理好以后自己再进去。
对应到代码中比如某些任务可能会在一个比较集中的时间在后台启动有可能反复执行有可能执行时间相对较长。在资源有限的情况下这样的系统有可能和其他的系统部署在一台服务器上甚至于一个进程上自然会相互抢占资源在某些必要的情况下可以使用该方式做出一点让步让双方的资源得到平衡。
RUNNABLE状态可以由其他的许多状态通过某些操作后进入该状态处于RUNNABLE状态的线程本身也可以执行许多操作转换为其他的状态比如执行synchronized、sleep()、wait()等操作。在接下来的状态介绍中还会提到许多和RUNNABLE相关的状态转换关系。
不过就Java本身层面的RUNNABLE状态来讲并不代表它一定处于运行中的状态例如在BIO中线程正阻塞在网络等待时看到的状态依然是RUNNABLE状态而在底层线程已经被阻塞这也是Java内在一些状态不协调的问题所在。所以我们不仅仅要看状态本身还得了解更多的计算机与Java之间的关系才能在面对问题时更加接近本质。
3BLOCKED状态
BLOCKED称为阻塞状态或者说线程已经被挂起它“睡着”了原因通常是它在等待一个“锁”当某个synchronized正好有线程在使用时一个线程尝试进入这个临界区就会被阻塞直到另一个线程走完临界区或发生了相应锁对象的wait()操作后它才有机会去争夺进入临界区的权利。
细节补充synchronized会有各种粒度的问题这里的临界区是指多个线程尝试进入同一块资源区域这个区域在Java代码中的体现方式通常是基于某个对象锁代码片段。关于它的一些细节将在后文中详细介绍。
争取到锁的权利后才会从BLOCKEN状态恢复到RUNNABLE状态如果在征用锁的过程中没有抢到那么它就又要回到休息室去等待了。
在实际的工作中BLOCKEN状态也并非显式地存在于synchronized上可能会是一种嵌套隐藏的方式例如使用了某种三方控件、集合类。
一旦线程处于阻塞状态线程就像真的什么也不做一样在Java层面始终无法唤醒它。许多人说现在用interrupt()方法来唤醒它小伙伴们可以进行小测试一点用处都没有因为interrupt()只是在里面做一个标记而已不会真正唤醒处于阻塞状态的线程。
所以在程序中出现synchronized时通常会考虑它的粒度问题更要考虑它是否可能会被死锁的问题。
4WAITING状态
这种状态通常是指一个线程拥有对象锁后进入到相应的代码区域后调用相应的“锁对象”的wait()方法操作后产生的一种结果。变相的实现还有LockSupport.park()、LockSupport.parkNanos()、LockSupport.parkUntil()、Thread.join()等它们也是在等待另一个对象事件的发生也就是描述了等待的意思。
上面提到的BLOCKEN状态也是等待的意思它们有什么关系与区别呢
其实BLOCKEN是虚拟机认为程序还不能进入某个区域因为同时进去就会有问题这是一块临界区。发生wait()操作的先决条件是要进入临界区也就是线程已经拿到了“门票”自己可能进去做了一些事情但此时通过判定某些业务上的参数由具体业务决定发现还有一些其他配合的资源没有准备充分那么自己就等等再做其他的事情。
理解起来是不是很麻烦其实有一个非常典型的案例就是通过wait()和notify()来完成生产者消费者模型当生产者生产过快发现仓库满了即消费者还没有把东西拿走空位资源还没准备好时生产者就等待有空位再做事情消费者拿走东西时会发出“有空位了”的消息那么生产者就又开始工作了。反过来也是一样当消费者消费过快发现没有存货时消费者也会等存货到来生产者生产出内容后发出“有存货了”的消息消费者就又来抢东西了。
这种通过制衡方式的协调工作机制在工作中用得很多它稍加变化就能产生巨大的价值现代的Java语言很牛已经将这些复杂的细节包装成了对象对外提供了很好用的API这些API为我们提供的仅仅是简单的任务内容输入具体的调度细节由Java来完成。
在这种状态下如果发生了对该线程的interrupt()是有用的处于该状态的线程内部会抛出一个InterruptedException异常这个异常应当在run()方法里面捕获使得run()方法正常地执行完成。当然在run()方法内部捕获异常后还可以让线程继续运行这完全是根据具体的应用场景来决定的。
在这种状态下如果某线程对该锁对象做了notify()动作那么将从等待池中唤醒一个线程重新恢复到RUNNABLE状态。除notify()方法外还有一个notifyAll()方法前者是唤醒一个处于WAITING状态的线程而后者是唤醒所有的线程。
Object.wait()是否需要死等呢不是除中断外它还有两个重构方法
◎ Object.wait(int timeout)传入的timeout参数是超时的毫秒值超过这个值后会自动唤醒继续做下面的操作不会抛出InterruptedException异常但是并不意味着我们不去捕获因为不排除其他线程会对它做interrupt()动作。
◎ Object.wait(int timeout , int nanos)这是一个更精确的超时设置理论上可以精确到纳秒这个纳秒值可接受的范围是0999999因为1000000ns等于1ms。
同样的LockSupport.park()、LockSupport.parkNanos()、LockSupport.parkUntil()、Thread. join()这些方法都会有类似的重构方法来设置超时达到类似的目的不过此时的状态不再是WAITING而是TIMED_WAITING。
通常写代码的人肯定不想让程序死掉但是又希望通过这些等待、通知的方式来实现某些平衡这样就不得不去尝试采用“超时+重试+失败告知”等方式来达到目的。
5TIMED_WAITING状态
相信使用过线程的小伙伴们都应该使用过Thread.sleep()前文中已经提到了通过其他的方式也可以进入这种TIME_WATING状态。或许可以这种理解当调用Thread.sleep()方法时相当于使用某个时间资源作为锁对象进而达到等待的目的当时间达到时触发线程回到工作状态。
6TERMINATED状态
线程结束了就处于这种状态换句话说run()方法走完了线程就处于这种状态。其实这只是Java语言级别的一种状态在操作系统内部可能已经注销了相应的线程或者将它复用给其他需要使用线程的请求而在Java语言级别只是通过Java代码看到的线程状态而已。
下面再来探讨一些问题。
为什么wait()和notify()必须要使用synchronized
如果不用就会报错IllegalMonitorStateException常见的写法如下
2 |
object.wait(); //object.notify();
|
8 |
this .wait(); //this.notify();
|
首先要明确wait()和notify()的实现基础是基于对象存在的。那为什么要基于对象存在呢
解释既然要等就要考虑等什么这里等待的就是一个对象发出的信号所以要基于对象而存在。
不用对象也可以实现比如suspend()/resume()就不需要但是它们是反面教材表面上简单但是处处都是问题在5.1.4节中会介绍。
理解基于对象的这个道理后目前认为它调用的方式只能是Object.wait()方法这样才能和对象挂钩。但这些东西还与问题“wait()/notify()为什么必须要使用synchronized”没有半点关系或者说与对象扯上关系为什么非要用锁呢
我们还得继续探讨既然是基于对象的因此它不得不用一个数据结构来存放这些等待的线程而且这个数据结构应当是与该对象绑定的通过查看C++代码发现该数据结构为一个双向链表此时在这个对象上可能同时有多个线程调用wait()/notify()方法。
在向这个对象所对应的双向链表中写入、删除数据时依然存在并发的问题理论上也需要一个锁来控制。在JVM内核源码中并没有发现任何自己用锁来控制写入的动作只是通过检查当前线程是否为对象的OWNER来判定是否要抛出相应的异常。由此可见它希望该动作由Java程序这个抽象层次来控制它为什么不想去自己控制锁呢
因为有些时候更低抽象层次的锁未必是好事因为这样的请求对于外部可能是反复循环地去征用或者这些代码还可能在其他地方复用也许将它粗粒度化会更好一些而且这样的代码写在Java程序中本身也会更加清晰更加容易看到相互之间的关系。
在这个问题上胖哥的解释就到此结束了其中包含了许多个人的理解有兴趣的朋友可以去查阅资料细化这个问题的根源。
interrupt()操作在线程处于BLOCKEN状态时没用在其他状态下都有效吗
interrupt()操作对线程处于RUNNING状态时也没用或者说只对处于WAITING和TIME_WAITING状态的线程有用让它们产生实质性的异常抛出。
在通常情况下如果线程处于运行中状态也不会让它中断如果中断是成立的则可能会导致正常的业务运行出现问题。另外如果不想用强制手段就得为每条代码的运行设立检查但是这个动作很麻烦JVM不愿意做这件事情它做interrupt()仅仅是打一个标记此时程序中通过isInterrupt()方法能够判定是否被发起过中断操作如果被中断了那么如何处理程序就是设计上的事情了。
举个例子如果代码运行是一个死循环那么在循环中可以这样做
2 |
if (Thread.currentThread.isInterrupt()) {
|
3 |
//可以做类似的break、return抛出InterruptedException达到某种目的这完全由自己决定
|
4 |
//如抛出异常通常包装一层try catch异常处理进一步做处理如退出run方法或什么也不做
|
许多小伙伴认为这太麻烦了为什么不可以自动呢
小伙伴们可以通过一些生活的沟通方式来理解一下当你发现门外面有人呼叫你时你自己是否搭理他是你的事情胖哥认为这是一种有“爱”的沟通方式反之是暴力地破门而入把你强制“抓”出去的方式。
在JDK 1.6及以后的版本中可以使用线程的interrupted()方法来判定线程是否已经被调用过中断方法表面上的效果与isInterrupted()方法的结果一样不过这个方法是一个静态方法直接通过Thread.interrupted()调用判定的就是当前线程。除此之外更大的区别在于这个方法调用后将会重新将中断状态设置为false这样方便于循环利用线程而不是中断后状态就始终为true就无法将状态修改回来了。类似的判定线程的相关方法还有isAlive()、isDaemon()分别用来判定线程是否还活着以及是否为后台线程。
5.1.4 反面教材suspend()、resume()、stop()
虽然是反面教材但是胖哥认为反面教材往往体现在自己写代码时容易犯错的地方。只有看清楚这些反面教材自己写代码时才会去多考虑一些细节性的问题。
suspend()、resume()、stop()这些API虽然Java一直保留着但在代码中使用时会发现JVM已经不推荐使用了它们都被加上了@Deprecated注解表示它们已经过时了保留只是为了兼容而已。
关于suspend()/resume()这两个方法类似于wait()/notify()但是它们不是等待和唤醒线程。通过对它们的实验会发现suspend()后的线程处于RUNNING状态而不是WAITING状态但是线程本身在这里已经挂起了线程本身的状态就开始对不上号了。
如果是在synchronized区域内部发生suspend()操作那么它并不会像发生wait() 那样把锁释放出来因为它自己还在运行中。而当发生resume()时程序正常结束了其实如果代码正常走过synchronized区域锁也会释放的。但是很多资料上讲解的是没有释放资源这是怎么回事呢下面我们就写个反面教材的例子。
代码清单5-2 反面例子
01 |
public class SuspendAndResume {
|
03 |
private final static Object object = new Object();
|
05 |
static class ThreadA extends Thread {
|
08 |
synchronized (object) {
|
09 |
System.out.println( "start..." );
|
10 |
Thread.currentThread().suspend();
|
11 |
System.out.println( "thread end..." );
|
16 |
public static void main(String []args) throws InterruptedException {
|
17 |
ThreadA t1 = new ThreadA();
|
18 |
ThreadA t2 = new ThreadA();
|
22 |
System.out.println(t1.getState());
|
23 |
System.out.println(t2.getState());
|
输出结果如下
代码中启动了两个子线程这两个子线程几乎是同时启动的main方法所在的线程延迟100ms目的是为了让两个子线程都进入运行的区域至少其中一个发生了suspend()操作。
输出时首先会输出一个“start…”刚开始也只会输出一个“start…”因为这是由synchronized来保证的此时第一个进入synchronized区域的线程调用了suspend()方法此时它停止执行了。
然后输出的两个状态是在main方法中打印出来的因为一个线程在synchronized区域外部等待另一个线程调用了suspend()方法而被挂起这里输出的状态一个是BLOCKED状态另一个是RUNNABLE多次测试后结果相同说明有一个线程被阻塞了阻塞线程自然在synchronized区域外面等待进入而一个线程肯定是已经进入synchronized区域的线程并在调用suspend()方法时挂起但是我们看到的状态是RUNNABLE。
如果去掉synchronized动作将会输出两个RUNNABLE但是两个线程都在suspend()方法时停止执行了这说明什么呢suspend()/resume()并不需要synchronized的支持因此不需要基于对象。
接下来输出“thread end…”说明有一个线程正常结束了也说明resume()操作确实生效了在它输出后紧接着会输出一个“start…”说明另一个线程进入了synchronized区域但是神奇的事情发生了另一个线程也被主线程调用过resume()方法但实际情况是这个线程在这里卡住了没有释放掉为何
因为在这个例子中main方法所在的线程对第2个进入synchronized区域的线程做的resume()操作很可能发生在它未进入synchronized区域之前也自然发生在它调用suspend()操作之前在线程没有调用suspend()方法之前调用resume()是无效的也不会使得线程在其后面调用suspend()方法直接被唤醒。当该线程被挂起时相应持有的锁就释放不掉了因为它的操作与锁无关而外部认为已经将这个线程释放掉了因为外部看到的状态是RUNNING而且已经调用过resume()方法了由于这些信息的不一致就导致了各种资源无法释放的问题。
总的来说问题应当出在线程状态对外看到的是RUNNING状态外部程序并不知道这个线程挂起了需要去做resume()操作如果有状态判定还可以做检测。另外它并不是基于对象来完成这个动作的因此suspend()和wait()相关的顺序性很难保证。所以suspend()/resume()不推荐使用了。
反过来想这也更加说明了wait()和notify()为什么要基于对象来做数据结构因为它要控制生产者和消费者之间的关系它需要一个临界区来控制它们之间的平衡。它不是随意地在线程上做操作来控制资源的而是由资源反过来控制线程状态的。当然wait()/notify()并非不会导致死锁只是它们的死锁通常是程序设计不当导致的并且在通常情况下是可以通过优化解决的。
关于stop()胖哥认为它和interrupt()最大的区别如下
interrupt()是相对友爱的行为它不是破门而入而stop()却是这样的当你发起对某个线程的stop()操作时如果这个线程处于RUNNING状态stop()将会导致这个线程直接抛出一个java.lang.ThreadDeath的Error。这似乎没有问题那么我们就来探讨一下是否会有问题。
假如线程是一个死循环被外部容器所复用在业务代码中会通过多个步骤的计算将某些值赋予线程内的某些属性或更大作用域的属性这些属性可能是多个当发起stop()时程序可能会进入try {} catch(Throwable e)区域但是前面执行的计算和赋值只做了一半而且做到那里没法找回来这样就可能会导致业务程序中上下文数据不一致的情况发生。
5.1.5 调度优先级
线程的优先级就是对优先权的level设置就像VIP专区为何要设立VIP呢因为资源有限才会存在特权给予更多所以享有特权。
计算机也是这样的CPU资源是有限的那么在某些情况下我们希望先保证某些VIP先被执行。任务没有高低贵贱之分但是有重要性、紧急性之分因此会设立线程的优先级让OS根据不同的优先级进行调度这样在算法策略上就不再是一视同仁“吃大锅饭”了可以使得调度更加灵活达到局部优化的目的。
线程调度的优先级每个OS有着不同的实现而Java虚拟机为了兼容各种OS平台设定了110个优先级理论上数字越大优先级越高但这并不代表每个OS也有10个优先级某些OS可能只有3个或5个优先级。因此JVM会在相应的平台上根据实际情况设定110这10个数字与OS的线程优先级做一个映射关系总体会保持顺序化。通过这一点大家应该清楚Java中连续的两个数字所表示的优先级在实际场景中可能是同一个优先级。
作为程序员使用优先级时又不想脱离Java语言本身的限制通常将优先级设置为“普通”、“最大”、“最小”如图5-1所示其定义在Thread类中通常不会设置一些细节的数字那样设置可能根本达不到目的。
图5-1 线程优先级代码截图
创建一个线程时默认的优先级是Thread.NORM_PRIORITY值为5。在程序中可以为指定线程设定优先级通过setPriority(int)方法来完成调用这个方法时传入上面描述的几种值就基本可以达到调度优先的目的。
在JVM中还有一种特殊的后台线程通过对线程调用setDaemon(boolean)标志是否为后台线程它通常优先级极低也就是通常不会跟别人抢CPU但是它可能在某些时候提升自己的优先级来做一些事情。例如JVM的GC线程就是后台线程它很多时候不去和业务争用CPU而是在资源忙时会被提升优先级来做事情。
这类线程貌似与普通线程没有区别因为普通线程也可以做到这一点。但是后台线程有一个十分重要的特征是如果JVM进程中活着的线程只剩下后台线程那么意味着就要结束整个进程。
大家可以做一个实验来证明这个结论。在一个线程中做死循环main方法启动这个线程后就结束了此时整个进程不会退出。如果将线程设置为后台线程setDaemon(boolean)当main方法结束后进程会立即结束。本书光盘中的src/chapter05/base/ThreadDaemonTest. java是一个简单的测试例子大家只需要将代码中的setDaemon(true)操作注释掉或启用就会得到不同的结果。
5.1.6 线程合并Join
许多同学刚开始学Java多线程时可能不会关注Join这个动作因为不知道它是用来做什么的而当需要用到类似的场景时却有可能会说Java没有提供这种功能。为此胖哥就先说它的一些应用场景再说怎么用吧。
当我们将一个大任务划分为多个小任务多个小任务由多个线程去完成时显然它们完成的先后顺序不可能完全一致。在程序中希望各个线程执行完成后将它们的计算结果最终合并在一起换句话说要等待多个线程将子任务执行完成后才能进行合并结果的操作。
这时就可以选择使用Join了Join可以帮助我们轻松地搞定这个问题否则就需要用一个循环去不断判定每个线程的状态。
在实际生活中就像把任务分解给多个人去完成其中的各个板块但老板需要等待这些人全部都完成后才认为这个阶段的任务结束了也许每个人的板块内部和别人还有相互的接口依赖如果对方接口没有写好自己的这部分也不算完全完成就会发生类似于合并的动作到底要将任务细化到什么粒度完全看实际场景和自己对问题的理解。下面用一段简单的代码来说明Join的使用。
代码清单5-3 Join的例子
01 |
public class ThreadJoinTest {
|
03 |
static class Computer extends Thread {
|
09 |
public Computer( int []array , int start , int end) {
|
16 |
for ( int i = start; i < end ; i++) {
|
18 |
if (result < 0 ) result &= Integer.MAX_VALUE;
|
22 |
public int getResult() {
|
27 |
private final static int COUNTER = 10000000 ;
|
29 |
public static void main(String []args) throws InterruptedException {
|
30 |
int []array = new int [COUNTER];
|
31 |
Random random = new Random();
|
32 |
for ( int i = 0 ; i < COUNTER ; i++) {
|
33 |
array[i] = Math.abs(random.nextInt());
|
35 |
long start = System.currentTimeMillis();
|
36 |
Computer c1 = new Computer(array , 0 , COUNTER / 2 );
|
37 |
Computer c2 = new Computer(array , COUNTER / 2 + 1 , COUNTER);
|
42 |
System.out.println(System.currentTimeMillis() - start);
|
43 |
//System.out.println(c1.getResult());
|
44 |
System.out.println((c1.getResult() + c2.getResult())
|
这个例子或许不太好只是1000万个随机数叠加为了防止CPU计算过快在计算中增加一些判定操作最后再将计算完的两个值输出也输出运算时间。如果在有多个CPU的机器上做测试就会发现数据量大时多个线程计算具有优势但是这个优势非常小而且在数据量较小的情况下单线程会更快一些。为何单线程可能会更快呢
最主要的原因是线程在分配时就有开销每个线程的分配过程本身就需要执行很多条底层代码这些代码的执行相当于很多条CPU叠加运算的指令Join操作过程还有其他的各种开销。
如果尝试将每个线程叠加后做一些其他的操作例如I/O读写、字符串处理等操作多线程的优势一下子就出来了因为这样总体计算下来后线程的创建时间是可以被忽略的所以我们在考量系统的综合性能时不能就一个点或某种测试就轻易得出一个最终结论一定要考虑更多的变动因素。
要模拟单线程做许多相对时间较长的操作也不一定非要用文件读写、字符串处理等操作这样设计测试比较麻烦由于已经知道了关键点在于运行时间与线程创建时间的比重所以可以让每个线程循环时休眠一个随机的毫秒值这个时间其实不需要太长例如10ms、20ms、30ms就可以模拟出效果了。
但这并不代表多线程就一定能提升效率首先要检测CPU是不是多核如果不是那么使用多线程带来更多的是上下文切换的开销多线程操作的共享对象还会有锁瓶颈否则就是非线程安全的。
综合考量各种开销因素、时间、空间最后利用大量的场景测试来证明推理是有指导性的如果只是一味地为了用多线程而使用多线程则往往很多事情可能会适得其反。
Join只是语法层面的线程合并其实它更像是当前线程处于BLOCKEN状态时去等待其他线程结束的事件而且是逐个去Join。换句话说Join的顺序并不一定是线程真正结束的顺序要保证线程结束的顺序性它还无法实现即使在本例中它也不是唯一的实现方式本章后面会提到许多基于并发编程工具的方式来实现会更加理想管理也会更加体系化能适应更多的业务场景需求。
5.1.7 线程补充小知识
本小节的内容是一些小例子简单地讲解线程栈的获取以及UncaughtExceptionHandler的简单使用大家只需要对照本书光盘中的例子来运行以及本书的相应讲解就会清楚这些小例子的用途和意义。
1线程栈的获取
在前文中多次提到过栈尤其在第3章中介绍BTrace时通过BTraceUtils的jstack()方法就可以输出调用栈信息。由此我们知道了代码切入是怎么回事但是线程栈如何获取呢其实很简单请看下面的例子。
代码清单5-4 获取线程栈的简单例子
01 |
public class ThreadStackTest {
|
03 |
public static void main(String []args) {
|
04 |
printStack(getStackByThread());
|
05 |
printStack(getStackByException());
|
08 |
private static void printStack(StackTraceElement []stacks) {
|
09 |
for (StackTraceElement stack : stacks) {
|
10 |
System.out.println(stack);
|
12 |
System.out.println( "\n" );
|
15 |
private static StackTraceElement[] getStackByThread() {
|
16 |
return Thread.currentThread().getStackTrace();
|
19 |
private static StackTraceElement[] getStackByException() {
|
20 |
return new Exception().getStackTrace();
|
这样就通过两种方式输出线程栈了
这么简单
不信我们就看看输出结果
1 |
java.lang.Thread.getStackTrace(Thread.java: 1568 )
|
2 |
chapter05.base.ThreadStackTest.getStackByThread(ThreadStackTest.java: 23 )
|
3 |
chapter05.base.ThreadStackTest.main(ThreadStackTest.java: 11 )
|
5 |
chapter05.base.ThreadStackTest.getStackByException(ThreadStackTest.java: 27 )
|
6 |
chapter05.base.ThreadStackTest.main(ThreadStackTest.java: 12 )
|
这和异常信息很像只是没有异常类型而已。没错在例子中大家也应当看到有通过异常来获取线程栈的方式。对于该例子大家可以方法套用方法进行多层套用后看看输出结果会是什么样子的。
获取到的这个线程栈是一个数组数组的顺序就是调用代码的来源路径数组中的每个元素是一个java.lang.StackTraceElement类型的对象它内部包含了相应的class、方法、文件名、行号信息我们可以通过这些信息来追踪代码、监控、定位异常、控制调用来源等。
对于调用来源的类可以通过sun.reflect.Reflection的getCallerClass(int)来获取在JDK 1.7以后API有少量变化。
2UncaughtExceptionHandler的简单使用
这是Java本身提供的一种对run()方法没有捕获到的异常、错误的一次补救在这里可以吃点后悔药。通常我们不依赖这种方式因为这是线程级别的业务代码中通常不会关心这个层次即使要关心也是在框架当中通常我们希望在内层就将该异常处理掉走到这个位置也意味着线程已经脱离了run()方法会立即结束不能再被线程所复用了。
不过从学习Java的角度来讲也需要知道Java确实提供了这样一种机制请看下面的例子。
代码清单5-5 UncaughtExceptionHandler的测试
01 |
class TestExceptionHandler implements UncaughtExceptionHandler {
|
03 |
public void uncaughtException(Thread t, Throwable e) {
|
04 |
System.out.printf( "线程出现异常" );
|
08 |
public class ExceptionHandlerTest {
|
10 |
public static void main(String []args) {
|
11 |
Thread t = new Thread() {
|
13 |
Integer.parseInt( "ABC" );
|
16 |
t.setUncaughtExceptionHandler( new TestExceptionHandler());
|
代码中模拟了一个数字转换的异常抛出在run()方法中并没有捕获此异常最终会进入自定义的TestExceptionHandler中来处理也可以直接throw new Error()抛出得到的结果也是类似的。
时间: 2024-10-06 09:39:48