@lambeta
2016-08-29T01:43:29.000000Z
字数 13518
阅读 344
translation
第1到3章介绍了java.lang.Thread类、java.lang.Runnable接口、同步、等待以及通知等内容。在本章中,我会以线程组和线程局部变量来结束有关线程基础知识的讨论。同时,我也会介绍定时器框架以及其如何利用幕后的线程简化了面向定时器的任务。
查看Thread类的时候,你可能会在构造函数中看到java.lang.ThreadGroup的引用,如:Thread(ThreadGroup group, Runnable target),又或者在static int activeCount()和static int enumerate(Thread[] tarray)方法文档中看到相关描述。
对于ThreadGroup,JDK文档这样阐述:一个线程组代表了一组线程。除此之外,一个线程组也能包含其它的线程组。这些线程组形成一棵树,其中除了初始线程,每个线程组都有一个双亲。
使用ThreadGroup对象,你对其中的所有线程对象进行操作。举个例子:假设一个线程组被变量tg引用,tg.suspend();会暂停线程组内的所有线程。线程组简化了多条线程的管理工作。
尽管线程组看上去很有用,但是有碍于下列原因,你万万不能使用这个类:
ThreadGroup中最有用的方法就是void suspend(), void resume()以及void stop(),然而这些方法都已经被废弃了,原因和Thread类似(这些方法分派给该线程组的各个线程),它们很容易诱发死锁等问题。
ThreadGroup是非线程安全的。举个例子,为了获取线程组中的活跃线程数,你会调用ThreadGroup的int activeCount()方法。然后使用此方法的返回值限定传递给enumerate()方法参数数组的大小。不过,这儿并不会保证线程数是精确的,原因在于数组创建完成到被传递给enumerate()方法期间存在线程创建与消亡带来的数量改变。如果数组太小了,enumerate()会默默忽略掉多余的线程。同样的描述也适用于Thread类的activeCount()和enumerate()方法,它们会被代理到当前线程所在ThreadGroup的方法上。这就是一个“检查时间到使用时间”(https://en.wikipedia.org/wiki/Time_of_ check_to_time_of_use)类别的软件缺陷。(进行操作之前需检查文件是否存在的场景中,这个缺陷也会露出爪牙。因为检查文件和操作期间,文件也可能被删除或创建。)
不过,鉴于ThreadGroup在处理线程执行中抛出的异常方面做出的贡献,你还是应该对它有所了解。清单4-1示范了异常处理。它展示了一个尝试除0的run()方法,导致抛出java.lang.ArithmeticException。
清单4-1 从run()方法中抛出一个异常
public class ExceptionThread{public static void main(String[] args){Runnable r = new Runnable(){@Overridepublic void run(){int x = 1 / 0; // Line 10}};Thread thd = new Thread(r);thd.start();}}
默认主线程创建了一个尝试除0而故意抛出ArithmeticException对象的runnable。
按照下面的方式编译清单4-1:
javac ExceptionThread.java
运行结果应用程序如下:
java ExceptionThread
你会看到一条类ArithmeticException的实例被抛出的异常栈信息:
Exception in thread "Thread-0" java.lang.ArithmeticException: / by zeroat ExceptionThread$1.run(ExceptionThread.java:10)at java.lang.Thread.run(Thread.java:745)
一旦run()方法抛出异常,线程就会中止并被下列活动取代:
Java虚拟机(JVM)寻找Thread.UncaughtExceptionHandler的实例,该实例由Thread的void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)方法安装。当找到这个handler时,线程就会执行它的void uncaughtException(Thread t, Throwable e)方法,这里t代表了抛出异常线程所关联的Thread对象,而e代表了被抛出的异常或者错误本身——可能会抛出一个java.lang.OutOfMemoryError对象。如果uncaughtException()抛出了一个异常、错误,这个异常、错误会被JVM忽略。
假设setUncaughtExceptionHandler()没被调用,JVM就把控制权交给线程关联的ThreadGroup对象上的uncaughtException(Thread t, Throwable e)方法。若这个ThreadGroup没被继承、其uncaughtException()方法没被重写以处理异常,此时如果父级ThreadGroup存在,uncaughtException()又会把控制权交到父级ThreadGroup对象上的uncaughtException()方法。否则,就检查默认的未捕获异常处理器是否已经被安装(通过Thread的static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler)方法)。如果默认未捕获异常处理器已经被安装过了,其uncaughtException()方法就会被传以相同的两个参数进行调用。反之,uncaughtException()方法检查其Throwable参数是否为java.lang.ThreadDeath的实例。如果是,就没什么特别的了。否则,就像清单4-1中异常消息显示的那样,一条包含了调用该线程getName()方法返回的线程名称和Throwable参数printStackTrace()方法得到的错误栈的消息就被打印到标准错误流中。
清单4-2演示了Thread的setUncaughtExceptionHandler()和setDefaultUncaughtExceptionHandler()方法。
清单4-2 未捕获异常处理器示例
public class ExceptionThread{public static void main(String[] args){Runnable r = new Runnable(){@Overridepublic void run(){int x = 1 / 0;}};Thread thd = new Thread(r);Thread.UncaughtExceptionHandler uceh;uceh = new Thread.UncaughtExceptionHandler(){@Overridepublic void uncaughtException(Thread t, Throwable e){System.out.println("Caught throwable " + e +" for thread " + t);}};thd.setUncaughtExceptionHandler(uceh);uceh = new Thread.UncaughtExceptionHandler(){@Overridepublic void uncaughtException(Thread t, Throwable e){System.out.println("Default uncaught exception handler");System.out.println("Caught throwable " + e +" for thread " + t);}};thd.setDefaultUncaughtExceptionHandler(uceh);thd.start();}}
编译清单4-2 (javac ExceptionThread.java)并运行结果程序(java ExceptionThread)。你应该能观测到这样的输出:
Caught throwable java.lang.ArithmeticException: / by zero for threadThread[Thread-0,5,main]
因为默认的处理器并未被调用,所以你看不到它的输出。想要看到那样的输出,你必须注释掉thd.setUncaught ExceptionHandler(uceh);这行。如果你也注释掉了thd.setUncaught ExceptionHandler(uceh);这行,那么就会看到原本清单4-1中的输出。
有时候,你想要把线程私有数据(如一个用户ID)和线程关联起来。尽管你可以用一个局部变量完成这项任务,那也得它确实存在才行。你可能会使用一个实例属性保存数据,但是这样你又不得不处理同步问题了。万幸的是,Java提供了类java.lang.ThreadLocal作为一个简单(还非常好用)的可选项。
每个ThreadLocal实例描述了一个线程局部变量,它针对每个访问该变量的线程都提供了单独的存储槽。你可以把它想象成每条线程在相同变量中存储不同值、具有多个槽的变量。每条线程只能看到自己的值而不会意识到其它的线程在这个变量中也有属于自己的值。
ThreadLocal被声明成泛型的ThreadLocal< T >,这里的T标识了存储在该变量中值的类型。这个类声明了如下的构造函数和方法:
清单4-3展示了如何使用ThreadLocal把不同的用户ID连接到两条线程上。
清单 4-3 为不同的线程关联不同的用户ID
public class ThreadLocalDemo{private static volatile ThreadLocal<String> userID =new ThreadLocal<String>();public static void main(String[] args){Runnable r = new Runnable(){@Overridepublic void run(){String name = Thread.currentThread().getName();if (name.equals("A"))userID.set("foxtrot");elseuserID.set("charlie");System.out.println(name + " " + userID.get());}};Thread thdA = new Thread(r);thdA.setName("A");Thread thdB = new Thread(r);thdB.setName("B");thdA.start();thdB.start();}}
初始化ThreadLocal并将引用赋值给一个volatile的类属性userID上(由于这个属性可能会运行在多处理器、多核的机器上,被多条线程访问,所以它是volatile的 —— 也可以指定final来替代)之后,默认的主线程创建了两条线程在userID中存储不同的java.lang.String对象并打印出这些对象。
照下面这样编译清单4-3:
javac ThreadLocalDemo.java
运行结果应用程序如下:
java ThreadLocalDemo
你应该观察到如下的输出(顺序可能有所不同):
A foxtrotB charlie
存储在线程本地变量中的值都是不相关的。当一个线程的线程被创建出来,它就会获得一个新的包含initialValue()值的存储槽。有时候你或许想要把值从父线程(一个创建其它线程的线程)传给子线程(被创建的线程),借助InheritableThreadLocal就可以完成这项任务。
InheritableThreadLocal是ThreadLocal的子类。除了定义了一个InheritableThreadLocal()构造方法,这个类还声明了下面的protected方法:
清单4-4展示了如何使用InheritableThreadLocal将父线程中的整型对象传给子线程中。
清单 4-4 将一个对象从父线程传到子线程中
public class InheritableThreadLocalDemo{private static final InheritableThreadLocal<Integer> intVal =new InheritableThreadLocal<Integer>();public static void main(String[] args){Runnable rP = () ->{intVal.set(new Integer(10));Runnable rC = () ->{Thread thd = Thread.currentThread();String name = thd.getName();System.out.printf("%s %d%n", name,intVal.get());};Thread thdChild = new Thread(rC);thdChild.setName("Child");thdChild.start();};new Thread(rP).start();}}
初始化InheritableThreadLocal之后,将其赋值给一个final名为intVal的类属性(我也可以用volatile替代),默认主线程创建一条父线程,它在intVal中存储了一个值为10的java.lang.Integer对象。该父线程会创建一条子线程,这条子线程访问intVal并取得父线程中的Integer对象。
按照下面的样子编译清单4-4:
javac InheritableThreadLocalDemo.java
运行结果应用程序如下:
java InheritableThreadLocalDemo
你应该能够看到如下输出:
Child 10
注意 更多关于ThreadLocal的洞见以及实现方式,请查看Patson Luk《Java ThreadLocal存储趣学指南》的博客(http://java.dzone.com/articles/painless-introduction-javas-threadlocal-storage)。
通常情况下,安排一次性的执行任务或者规律性的重复任务是有必要的。举个例子:你很可能定制了只运行一次的闹钟任务(或许是为了早上叫你起床)或者一个夜间定时的备份任务。不论是哪种类型的任务,你可能都需要它在未来某个指定的时间点或者经过一段初始延时后执行。
你可以使用线程和相关类型构造一个任务调度的框架。不过,Java 1.3已经以java.util.Timer和java.util.TimerTask的形式引入了一个更加便利和简单的替代方案。
Timer让你能够在一个后台线程中调度TimerTasks用于后续执行(以顺序的方式),它也被称为任务执行线程。定时器任务可能会因为一次性的执行或者规律性的重复执行而被调度。
清单4-5展示了一次性执行的定时器任务
清单4-5 一次性执行任务的示例
import java.util.Timer;import java.util.TimerTask;public class TimerDemo{public static void main(String[] args){TimerTask task = new TimerTask(){@Overridepublic void run(){System.out.println("alarm going off");System.exit(0);}};Timer timer = new Timer();timer.schedule(task, 2000); // Execute one-shot timer task after 2-second delay.} }
清单4-5描述了这样一个应用程序,其默认主线程首先初始化了一个TimerTask的匿名子类,由于应用程序直到非守护的任务执行线程终止之后才能终止,所以这个子类被重写的run()方法会在输出一条警告信息后执行System.exit(0);(译者注:终止程序)。之后默认主线程初始化了Timer并用task作为第一个参数来调用其schedule()方法。方法的第二个参数使得这个任务在经过2000毫秒初始延时后执行一次。
照下面的样子编译清单4-5:
javac TimerDemo.java
运行结果应用程序如下:
java TimerDemo
你应该能够观测到近似下面的输出:
alarm going off
清单4-6展示了一个以固定时间间隔重复执行的定时器任务。
清单4-6 以大约一秒钟的间隔不断显示当前系统时间
import java.util.Timer;import java.util.TimerTask;public class TimerDemo{public static void main(String[] args){TimerTask task = new TimerTask(){@Overridepublic void run(){System.out.println(System.currentTimeMillis());}};Timer timer = new Timer();timer.schedule(task, 0, 1000);}}
清单4-6描述了这样一个应用程序,其默认主线程首先初始化了一个TimerTask的匿名子类,该子类被重写的run()方法输出当前的系统时间(以毫秒的方式)。之后默认主线程初始化了Timer并将task作为第一个参数来调用schedule()方法。方法的第二和第三个参数使得这个任务立即开始、每隔1000毫秒执行一次。
编译清单4-6(javac TimerDemo.java)并运行结果应用程序(java TimerDemo)。你应该能观测到裁剪之后的输出:
144565584790214456558489021445655849902144565585090214456558519021445655852902
前面的应用程序以一种非守护任务执行线程的方式运行了它们自己的任务。当然,其中一个任务作为只执行一次,而另一个任务则重复执行。为了理解为何做出这些选择的,你需要更进一步学习Timer。
注意 Timer适用于大规模并发调度定时任务(数以千计的任务不在话下)。在内部,该类使用了一个二进制的堆表示其定时任务队列,使得调度定时任务的开销为O(log n),这里的n就是并发调度定时任务的数量。进一步了解O()表示法,请查看维基页上“大O表示法”的主题(http://en.wikipedia.org/wiki/Big_O_notation)。
Timer声明了下列构造函数:
Timer还声明了下列的方法:
int purge():从该定时器队列中移除所有取消的定时任务并且返回被移除任务的数目。调用purge()方法不会对该定时器的行为产生影响,但是会从队列中消除被取消任务的引用。当这些定时器任务没有外部引用的时候,它们就可以被垃圾回收了。(大部分应用程序不需要调用这个方法,该方法被设计用于极少数需取消大量定时器任务的应用程序。调用purge()方法是在用时间换空间:此方法运行时(复杂度)可能同n + c * log n成比例,这里的n就是定时任务的总数,而c则是被取消任务的数目。)从这个定时器调度的定时任务中调用purge()方式是可行的。
void schedule(TimerTask task, Date time):在某个时间点调度任务执行。当time是过去时间,任务就会立即执行。当time.getTime()返回负数时,此方法抛出java.lang.IllegalArgumentException;若任务已经被调度或者取消、定时器被取消或者任务执行线程中断了,则抛出java.langIllegalStateException;如果task或者time是null,则抛出NullPointerException。
void schedule(TimerTask task, Date firstTime, long period):调度任务于firstTime开始,以固定时隔的方式重复执行,后续将以大约period毫秒数的固定时隔执行。在固定延迟执行中,每次执行都是相对于上次执行的实际发生时间的。当某次执行因为一些原因被延迟了(比如垃圾回收),后续的执行也都会被延迟。从长远来看,执行频率大体上会稍慢于对应的period(假设在Object.wait(long)下的系统时钟是精确的)。总而言之,当被调度的firstTime是过去时间,任务就会被立即执行。固定延时的执行对于需要“平滑”执行的重复任务是合适的。换句话说,这种类型的执行方式对于某些场景下的任务是合适的,即在短期内需要保持精准的频率。这就包括了大部分的动画任务,比如固定时隔的光标闪烁。也包括响应用户输入的规律性活动,比如只要一个键被按下就会自动出现一个字符。当firstTime.getTime()返回负数或者period为非正数的时候,该方法抛出IllegalArgumentException;当任务已经被调度或取消、定时器被取消又或者任务执行线程终止时,抛出IllegalStateException;若task或firstTime为null,则抛出NullPointerException。
void schedule(TimerTask task, long delay):在delay毫秒数之后调度任务执行。当delay为负数或者delay + System.currentTimeMillis()是负数时该方法抛出IllegalArgumentException;若任务已经被调度或者取消、定时器被取消或者任务执行线程终止都会抛出IllegalStateException;若task是null,则抛出NullPointerException。
void scheduleAtFixedRate(TimerTask task, Date firstTime, long period):调度任务于firstTime开始,以固定速率的方式重复执行,后续将以大约period毫秒数的固定时隔执行。在以固定速率执行的方式当中,每次执行都是相对于初次执行的调度时间而被调度的。当某个执行因为一些原因被延迟了(比如垃圾回收),两次以上的执行将会相继发生。从长远来看,执行频率大体上会稍慢于对应的period(假设在Object.wait(long)下的系统时钟是精确的)。总而言之,当被调度的firstTime是过去时间,任何“错过”的执行都会被调度立即“赶上来”执行。固定速率的执行方式适用于递归的活动,这些活动对绝对时间很敏感(比如每小时响一次的时钟或者每天固定时间点运行的调度维护任务)。它也适用于操作固定数量的执行任务总时间尤其重要的任务,比如10秒中每秒滴答一次的倒计时器。最后,固定速率的执行方式对于调度多个重复的定时器任务也是适用的,这些任务彼此之间必须保持同步。当firstTime.getTime()返回负数或者period为非正数的时候,该方法抛出IllegalArgumentException;当任务已经被调度或取消、定时器被取消又或者任务执行线程终止时,抛出IllegalStateException;若task或firstTime为null,则抛出NullPointerException。
void scheduleAtFixedRate(TimerTask task, long delay, long period):在delay毫秒数之后开始调度任务以固定速率的方式重复执行,后续将以大约period毫秒数的固定时隔执行。当delay是负数,delay + System.currentTimeMills为负数或者period为非正数的时候,该方法抛出IllegalArgumentException;当任务已经被调度或取消、定时器被取消又或者任务执行线程终止抛出IllegalStateException;若task为null,则抛出NullPointerException。
当最后一条指向Timer对象的引用消失并且所有重要的定时任务完成执行之后,该定时器的任务执行线程会优雅地终止(同时被垃圾回收)。不过,这个过程会在不定的时长里发生。(默认情况下,这个任务执行线程不会以守护线程的方式运行,所以它能够阻止应用程序终止。)当应用程序想要快速地终止一个定时器执行线程,它应该调用Timer的cancel()方法。如果这个定时器的任务执行线程异常终止,举个例子,由于其stop()方法被调用过(你绝不应该调用任何线程的stop()方法,因为它们本身就是不安全的),以后任意针对该定时器尝试调度定时任务都会导致抛出IllegalStateException,就如同Timer的cancel()方法已经被调用过一样。
定时任务都是抽象类TimerTask子类的实例,这些子类实现了Runnable接口。当子类化TimerTask的时候,你需要重写其void run()方法。
注意 定时任务应该很快完成。如果一个定时任务完成花费了很长的时间,它就会“霸占”定时器的任务执行线程,推迟后续定时任务的执行,这些后续任务可能会“裹成一团”,并且当这个侵入的定时任务最终完成,它们会接连快速地执行。
你也可以从定时任务被重写的run()方法中调用下列方法:
下面的练习被设计来验证你对第4章内容的理解程度:
1. 定义线程组。
2. 为何你要使用线程组?
3. 为何你应该避免使用线程组?
4. 为何你应该认识线程组?
5. 定义线程局部变量。
6. 判断对错:如果线程调用get()方法时,调用线程的存储槽里没有值,该方法就会去调用initialValue()。
7. 你如何从父线程传递一个值到子线程中?
8. 明确组成Timer框架的所有类。
9. 判断对错:Timer()会创建一个新的定时器,其任务执行线程会以守护线程的方式运行。
10. 定义固定延迟的执行任务。
11. 你会调用哪些方法来调度一个固定延迟的执行任务?
12. 定义固定速率的执行任务。
13. Timer的cancel()方法和TimerTask的cancel()方法有何区别?
14. 创建一个BackAndForth应用程序,使用Timer和TimerTask去反复地移动一个星号向前20步,然后向后20步。星号通过System.out.print()输出。
类ThreadGroup描述了一个线程组,它存储了一组线程。线程组通过把方法应用到其中的所有线程简化了多条线程的管理工作。由于多数有用的方法因竞态条件遭到废弃,你不应该再使用线程组。
类ThreadLocal描述了一个线程局部变量,它可以让你把每条线程的数据(如用户ID)和线程关联。线程局部变量针对每个访问该变量的线程都提供了单独的存储槽。你可以把它想象成每条线程可以在其中存储不同值、具有多个槽的同一变量。每条线程只能看到自己的值而不会意识到其它的线程在这个变量中也有属于自己的值。存储在线程局部变量中的值都是不相关的。父线程可以借助类InheritableThreadLocal把值传递到子线程中。
通常情况下,安排一次性的执行任务或者规律性的重复任务是有必要的。Java 1.3引入了定时器框架用于在定时器上下文中控制线程的执行,其由类Timer和TimerTask构成。
第5章会引入并发工具并展示executors。