问题的引入: 一个线程两次调用start()方法会出现什么情况?谈谈线程的生命周期和状态转移。
初步了解
Java线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。
关于线程生命周期的不同状态,在Java 5之后线程状态被明确定义在其公共内部枚举类型java.lang.Thread.State中,分别是:
public final native void wait(long timeout) throws InterruptedException;
在第二次调用start()方法的时候,线程可能处于终止或其他(非New)状态,但是无论如何,都是不可以再次启动的。
针对此问题,在面试的时候面试官可能会从不同角度考察对线程的掌握:
知识扩展
1、整体来看一下线程是什么?
从操作角度来看,可以简单认为线程是系统调度的最小单元,一个进程可以包含多个线程,作为任务的真正运作着,有自己的栈(Stack)、寄存器(Register)、本地存储器(Thread Local)等,但是会和进程内其他线程共享文件描述符、虚拟地址空间等。
具体实现中,线程还分为内核线程、用户线程,Java线程实现其实是与虚拟机相关的。对于我们最熟悉的Sun/Oracle JDK,其他线程也经历了一个演进过程,基本上在Java 1.2以后,JDK已经抛弃了所谓的Green Thread,也就是用户调度的线程,现在的模型是一对一映射到操作系统内核线程。
如果看Thread源码,你会发现其基本操作逻辑大都是以JNI形式调用的本地代码。
private native void start0();
private native void setPriority0(int newPriority);
private native void interrupt0();
这种实现有利有弊,但是总体上来说,Java语言得益于精细粒度的线程和相关的并发操作,其构建高扩展性的大型应用能力已经毋庸置疑。但是其复杂性也提高了并发编程的门槛,近几年的Go语言等提供了协程(coroutine),大大提高了构建并发应用的效率。与此同时,Java也在Loom项目中,孕育新的类似轻量级用户线程(Fiber)等机制,也许在不久的将来就可以在新版Jdk中使用。
下面,来简单分析下线程的基本操作,如何创建线程想必大家都很清楚了,请看如下示例:
Runnable task = () -> {System.out.println("Hello World!");};
Thread myThread = new Thread(task);
myThread.tsart();
myThread.join();
可以直接扩展Thread类,然后实例化。但是在本例中,选取了另外一种方式,就是实现一个Runnable,将代码逻辑放在Runnable中,然后构建Thread并启动(start),等待结束(join)。
Runnable好处是:不会受Java不支持类多继承的限制,重用代码实现,当我们重复执行相应逻辑时优点明显。而且,也能更好的与现代Java并发库中的Executor之类框架结合使用,比如将上面start和join逻辑完全写成下面结构:
Future future = Executors.newFixedThreadPool(1)
.submit(task)
.get();
这样就不用操心线程的创建和管理,也能利用Future等机制更好地处理执行结果。线程生命周期通常和业务之间没有本质联系,混淆实现需求和业务需求,就会降低开发的效率。
从线程生命周期的状态开始展开,那么在Java编程中,哪些因素是影响线程的状态呢?主要有:
如下图是状态和方法之间的对应图:
Thread和Object的方法,听起来简单,但是实际应用中被证明非常晦涩、易错,这也是为什么Java后来又引入了并发包。总的来说,有了并发包,大多数情况下,我们已经不再需要去调用wait/notify之类的方法了。
下面谈谈线程API使用,会侧重平时工作学习中,容易被忽略掉的一些方面:
先来看看守护线程( Daemon Thread),有时候应用中需要一个长期驻留的服务程序,但是不希望其影响应用退出,就可以将其设置为守护线程,如果JVM发现只有守护线程存在将结束线程,如下面的代码,注意:必须在线程启动之前设置。
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
再来看看Spurious wakeup。尤其是在多核CPU的系统中,线程等待存在一种可能,就是在没有任何线程广播或者发出信号的情况下,线程就被唤醒,如果处理不当就可能出现诡异的并发问题,所以我们在等待条件过程中,建议采用下面模式来书写。
// 推荐
while ( isCondition()) {
waitForAConftion(...);
}
// 不推荐,可能引入bug
if ( isCondition()) {
waitForAConftion(...);
}
Thread.onSpinWait(),这是Java 9中引入的特性。前面的文章中,提到的“自旋锁”( spin-wait, busy-waiting),也可以认为其不算是一种锁,而是一种针对短期等待的性能优化技术。 “onSpinWait()”没有任何行为上的保证,而是对JVM的一个暗示, JVM可能会利用CPU的pause指令进一步提高性能,性能特别敏感的应用可以关注。
再有就是慎用ThreadLocal,这是Java提供的一种保存线程私有信息的机制,因为其在整个线程生命周期内有效,所以可以方便地在一个线程关联的不同业务模块之间传递信息,比如事务ID、 Cookie等上下文相关信息。
它的实现结构,数据存储于线程相关的ThreadLocalMap,其内部条目是弱引用,如下面片段。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// …
}
当key为null时,该条目就变成”废弃条目“,相关”value“的回收,往往依赖于几个关键点,即set、remove、rehash
下面是set的实例,进行了精简和注释:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];; …) {
//…
if (k == null) {
// 替换废弃条目
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
// 扫描并清理发现的废弃条目,并检查容量是否超限
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();// 清理废弃条目,如果仍然超限,则扩容(加倍)
}
之前提到过引用类型,会发现通常弱应用都会和引用队列配合清理机制使用,但是ThreadLocal是个例外。
这就意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收相应的ThreadLocalMap!这就是很多OOM的来源,所以通常都会建议,应用一定要自己负责remove,并且不要和线程池配合,因为worker线程往往是不会退出的。