《Java特种兵》5.1 基础介绍

本文是《Java特种兵》的样章,感谢博文视点和作者授权本站发布

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的执行

public static void main(String []args) {
		new Thread() {
			public void run() {
				System.out.println("我是被创建的线程,我执行了...");
			}
		}.start();
		System.out.println("main process end...");
	}

}.start();

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 Thread(KLT)的映射来实现:KLT是内核线程,内核线程由OS直接完成调度切换,它相对应用程序的线程来讲只是一个接口,外部程序会使用一种轻量级进程(Light Weight Process,LWP)来与KLT进行一对一的接口调用。也就是说,进程内部会尝试利用OS的内核线程去参与实际的调度,而自己使用API调用作为中间桥梁与自己的程序进行交互。

◎ 基于用户线程(User Thread,UT)的实现:这种方式是考虑是否可以没有中间这一层映射,自己的线程直接由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这些值。现在对照源码中的注释,以及胖哥自己的理解来说明它们的意思。

(1)NEW状态
2F6KKOFKN`VST1FYW)RZYKV
意思是这个线程没有被start()启动,或者说还根本不是一个真正意义上的线程,从本质上讲这只是创建了一个Java外壳,还没有真正的线程来运行。

再一次提醒大家注意:调用了start()并不代表状态就立即改变,中间还有一些步骤,如果在这个启动的过程中有另一个线程来获取它的状态,其实是不确定的,要看那些中间步骤是否已经完成了。

(2)RUNNABLE状态
7`M((26P$4H0W]XP7UCS5FN
当处于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之间的关系才能在面对问题时更加接近本质。

(3)BLOCKED状态
LS_}R9MF(`HPCF[}U$P(P7J
BLOCKED称为阻塞状态,或者说线程已经被挂起,它“睡着”了,原因通常是它在等待一个“锁”,当某个synchronized正好有线程在使用时,一个线程尝试进入这个临界区,就会被阻塞,直到另一个线程走完临界区或发生了相应锁对象的wait()操作后,它才有机会去争夺进入临界区的权利。

细节补充:synchronized会有各种粒度的问题,这里的临界区是指多个线程尝试进入同一块资源区域,这个区域在Java代码中的体现方式通常是基于某个对象锁代码片段。关于它的一些细节,将在后文中详细介绍。

争取到锁的权利后才会从BLOCKEN状态恢复到RUNNABLE状态,如果在征用锁的过程中没有抢到,那么它就又要回到休息室去等待了。

在实际的工作中,BLOCKEN状态也并非显式地存在于synchronized上,可能会是一种嵌套隐藏的方式,例如使用了某种三方控件、集合类。

一旦线程处于阻塞状态,线程就像真的什么也不做一样,在Java层面始终无法唤醒它。许多人说现在用interrupt()方法来唤醒它,小伙伴们可以进行小测试,一点用处都没有,因为interrupt()只是在里面做一个标记而已,不会真正唤醒处于阻塞状态的线程。

所以,在程序中出现synchronized时通常会考虑它的粒度问题,更要考虑它是否可能会被死锁的问题。

(4)WAITING状态
SUSZCN(KH@7_O0D6XIKF9ZO
这种状态通常是指一个线程拥有对象锁后进入到相应的代码区域后,调用相应的“锁对象”的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),这是一个更精确的超时设置,理论上可以精确到纳秒,这个纳秒值可接受的范围是0~999999(因为1000000ns等于1ms)。

同样的,LockSupport.park()、LockSupport.parkNanos()、LockSupport.parkUntil()、Thread. join()这些方法都会有类似的重构方法来设置超时,达到类似的目的,不过此时的状态不再是WAITING,而是TIMED_WAITING。

通常写代码的人肯定不想让程序死掉,但是又希望通过这些等待、通知的方式来实现某些平衡,这样就不得不去尝试采用“超时+重试+失败告知”等方式来达到目的。

(5)TIMED_WAITING状态
LS_}R9MF(`HPCF[}U$P(P7J
相信使用过线程的小伙伴们都应该使用过Thread.sleep(),前文中已经提到了通过其他的方式也可以进入这种TIME_WATING状态。或许可以这种理解:当调用Thread.sleep()方法时,相当于使用某个时间资源作为锁对象,进而达到等待的目的,当时间达到时触发线程回到工作状态。

(6)TERMINATED状态

线程结束了就处于这种状态,换句话说,run()方法走完了,线程就处于这种状态。其实这只是Java语言级别的一种状态,在操作系统内部可能已经注销了相应的线程,或者将它复用给其他需要使用线程的请求,而在Java语言级别只是通过Java代码看到的线程状态而已。

下面再来探讨一些问题。

为什么wait()和notify()必须要使用synchronized?

如果不用就会报错IllegalMonitorStateException,常见的写法如下:

synchronized(object) {
   object.wait();//object.notify();
}
synchronized(this) {
   this.wait();
}
synchronized fun() {
   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()方法能够判定是否被发起过中断操作,如果被中断了,那么如何处理程序就是设计上的事情了。

举个例子,如果代码运行是一个死循环,那么在循环中可以这样做:

while(true) {
    if(Thread.currentThread.isInterrupt()) {
     //可以做类似的break、return,抛出InterruptedException达到某种目的,这完全由自己决定
    //如抛出异常,通常包装一层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 反面例子

public class SuspendAndResume {

	private final static Object object =  new Object();

	static class ThreadA extends Thread {

		public void run() {
			synchronized(object) {
System.out.println("start...");
			Thread.currentThread().suspend();
System.out.println("thread end...");
			}
		}
	}

	public static void main(String []args) throws InterruptedException {
		ThreadA t1 = new ThreadA();
		ThreadA t2 = new ThreadA();
		t1.start();
		t2.start();
		Thread.sleep(100);
		System.out.println(t1.getState());
		System.out.println(t2.getState());
		t1.resume();
		t2.resume();
	}
}

输出结果如下:
(6Z[F$`Z$UNO_39AH[XWNF4
代码中启动了两个子线程,这两个子线程几乎是同时启动的,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平台设定了1~10个优先级(理论上数字越大,优先级越高),但这并不代表每个OS也有10个优先级,某些OS可能只有3个或5个优先级。因此,JVM会在相应的平台上根据实际情况设定1~10这10个数字与OS的线程优先级做一个映射关系,总体会保持顺序化。通过这一点大家应该清楚,Java中连续的两个数字所表示的优先级在实际场景中可能是同一个优先级。

作为程序员使用优先级时,又不想脱离Java语言本身的限制,通常将优先级设置为“普通”、“最大”、“最小”(如图5-1所示,其定义在Thread类中),通常不会设置一些细节的数字,那样设置可能根本达不到目的。
ETKC$57]QEEDJVT_~$]QYVP
图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的例子

public class ThreadJoinTest {

	static class Computer extends Thread {
		private int start;
		private int end;
		private int result;
		private int []array;

		public Computer(int []array , int start , int end) {
			this.array = array;
			this.start = start;
			this.end = end;
		}

		public void run() {
			for(int i = start; i < end ; i++) {
				result += array[i];
				if(result < 0) result &= Integer.MAX_VALUE;
			}
		}

		public int getResult() {
			return result;
		}
	}

	private final static int COUNTER = 10000000;

	public static void main(String []args) throws InterruptedException {
		int []array = new int[COUNTER];
		Random random = new Random();
		for(int i = 0 ; i < COUNTER ; i++) {
			array[i] = Math.abs(random.nextInt());
		}
		long start = System.currentTimeMillis();
		Computer c1 = new Computer(array , 0 , COUNTER / 2);
		Computer c2 = new Computer(array , COUNTER / 2 + 1 , COUNTER);
		c1.start();
		c2.start();
		c1.join();
		c2.join();
		System.out.println(System.currentTimeMillis() - start);
		//System.out.println(c1.getResult());
		System.out.println((c1.getResult() + c2.getResult())
& Integer.MAX_VALUE);
	}
}

这个例子或许不太好,只是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 获取线程栈的简单例子

public class ThreadStackTest {

	public static void main(String []args) {
		printStack(getStackByThread());
		printStack(getStackByException());
	}

	private static void printStack(StackTraceElement []stacks) {
		for(StackTraceElement stack : stacks) {
			System.out.println(stack);
		}
		System.out.println("/n");
	}

	private static StackTraceElement[] getStackByThread() {
		return Thread.currentThread().getStackTrace();
	}

	private static StackTraceElement[] getStackByException() {
		return new Exception().getStackTrace();
	}
}

这样就通过两种方式输出线程栈了!

这么简单?

不信,我们就看看输出结果:

java.lang.Thread.getStackTrace(Thread.java:1568)
chapter05.base.ThreadStackTest.getStackByThread(ThreadStackTest.java:23)
chapter05.base.ThreadStackTest.main(ThreadStackTest.java:11)

chapter05.base.ThreadStackTest.getStackByException(ThreadStackTest.java:27)
chapter05.base.ThreadStackTest.main(ThreadStackTest.java:12)

这和异常信息很像,只是没有异常类型而已。没错,在例子中大家也应当看到有通过异常来获取线程栈的方式。对于该例子,大家可以方法套用方法,进行多层套用后看看输出结果会是什么样子的。

获取到的这个线程栈是一个数组,数组的顺序就是调用代码的来源路径,数组中的每个元素是一个java.lang.StackTraceElement类型的对象,它内部包含了相应的class、方法、文件名、行号信息,我们可以通过这些信息来追踪代码、监控、定位异常、控制调用来源等。

对于调用来源的类,可以通过sun.reflect.Reflection的getCallerClass(int)来获取,在JDK 1.7以后API有少量变化。

(2)UncaughtExceptionHandler的简单使用

这是Java本身提供的一种对run()方法没有捕获到的异常、错误的一次补救,在这里可以吃点后悔药。通常我们不依赖这种方式,因为这是线程级别的,业务代码中通常不会关心这个层次,即使要关心也是在框架当中,通常我们希望在内层就将该异常处理掉,走到这个位置也意味着线程已经脱离了run()方法,会立即结束,不能再被线程所复用了。

不过,从学习Java的角度来讲,也需要知道Java确实提供了这样一种机制,请看下面的例子。

代码清单5-5 UncaughtExceptionHandler的测试

class TestExceptionHandler implements UncaughtExceptionHandler {
	@Override
	public void uncaughtException(Thread t, Throwable e) {
		System.out.printf("线程出现异常:");
		e.printStackTrace();
	}
}
public class ExceptionHandlerTest {

	public static void main(String []args) {
		Thread t = new Thread() {
			public void run() {
				Integer.parseInt("ABC");
			}
		};
		t.setUncaughtExceptionHandler(new TestExceptionHandler());
		t.start();
	}
}

代码中模拟了一个数字转换的异常抛出,在run()方法中并没有捕获此异常,最终会进入自定义的TestExceptionHandler中来处理(也可以直接throw new Error()抛出,得到的结果也是类似的)。

原创文章,作者:3628473679,如若转载,请注明出处:https://blog.ytso.com/141287.html

(0)
上一篇 2021年9月5日
下一篇 2021年9月5日

相关推荐

发表回复

登录后才能评论