在本节,我们介绍Java中线程的一些基本概念,包括创建线程、线程的基本属性和方法、共享内存及问题、线程的优点及成本。
1. 创建线程
线程表示一条单独的执行流,它有自己的程序执行计数器,有自己的栈。 下面,我们通过创建线程来对线程建立一个直观感受。在Java中创建线程有两种方式:一种是继承Thread
;另外一种是实现Runnable
接口。
1.1 继承Thread
Java中java.lang.Thread
这个类表示线程,一个类可以继承Thread
并重写其run
方法来实现一个线程,如下所示:
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println("hello ripjava");
}
}
HelloThread
这个类继承了Thread
,并重写了run
方法。run
方法的方法签名是固定的,public,没有参数,没有返回值,不能抛出受检异常。run
方法类似于单线程程序中的main
方法,线程从run
方法的第一条语句开始执行直到结束。
定义了这个类不代表代码就会开始执行,线程需要被启动,启动需要先创建一个HelloThread
对象,然后调用Thread
的start
方法,如下所示:
public static void main(String[] args) {
Thread thread = new HelloThread();
thread.start();
}
我们在main
方法中创建了一个线程对象,并调用了其start
方法,调用start
方法后,HelloThread
的run
方法就会始执行,屏幕输出为:
hello
为什么调用的是start
,执行的却是run
方法呢?start
表示启动该线程,使其成为一条单独的执行流,操作系统会分配线程相关的资源,每个线程会有单独的程序执行计数器和栈,操作系统会把这个线程作为一个独立的个体进行调度,分配时间片让它执行,执行的起点就是run
方法。
如果不调用start
,而直接调用run
方法呢?屏幕的输出并不会发生变化,但并不会启动一条单独的执行流,run
方法的代码依然是在main
线程中执行的,run
方法只是main
方法调用的一个普通方法。怎么确认代码是在哪个线程中执行的呢?Thread
有一个静态方法currentThread
,返回当前执行的线程对象:
public static native Thread currentThread();
每个Thread
都有一个id和name:
public long getId()
public final String getName()
这样,我们就可以判断代码是在哪个线程中执行的。修改HelloThead
的run
方法:
@Override
public void run() {
System.out.println("thread name: "+ Thread.currentThread().getName());
System.out.println("hello");
}
如果在main
方法中通过start
方法启动线程,程序输出为:
thread name: Thread-0
hello
如果在main
方法中直接调用run
方法,程序输出为:
thread name: main
hello
调用start
后,就有了两条执行流,新的一条执行run
方法,旧的一条继续执行main
方法,两条执行流并发执行,操作系统负责调度,在单CPU的机器上,同一时刻只能有一个线程在执行,在多CPU的机器上,同一时刻可以有多个线程同时执行,但操作系统给我们屏蔽了这种差异,给程序员的感觉就是多个线程并发执行,但哪条语句先执行哪条后执行是不一定的。当所有线程都执行完毕的时候,程序退出。
2.2.实现Runnable接口
通过继承Thread
来实现线程虽然比较简单,但Java中只支持单继承,每个类最多只能有一个父类,如果类已经有父类了,就不能再继承Thread,这时,可以通过实现java.lang.Runnable
接口来实现线程。Runnable
接口的定义很简单,只有一个run
方法,如下所示:
public interface Runnable {
public abstract void run();
}
一个类可以实现该接口,并实现run
方法,如下所示:
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println("hello");
}
}
仅仅实现Runnable
是不够的,要启动线程,还是要创建一个Thread
对象,但传递一个Runnable
对象,如下所示:
public static void main(String[] args) {
Thread helloThread = new Thread(new HelloRunnable());
helloThread.start();
}
无论是通过继承Thead
还是实现Runnable
接口来创建线程,启动线程都是调用start方法。
2. 线程的基本属性和方法
线程有一些基本属性和方法,包括id、name、优先级、状态、是否daemo线程、sleep方法、yield方法、join方法、过时方法等,我们简单的介绍一下。
2.1 id和name
前面我们提到,每个线程都有一个id
和name
。id是一个递增的整数,每创建一个线程就加一。name
的默认值是Thread-
后跟一个编号,name
可以在Thread
的构造方法中进行指定,也可以通过setName
方法进行设置,给Thread
设置一个友好的名字,可以方便调试。
2.2优先级
线程有一个优先级的概念,在Java中,优先级从1到10,默认为5,相关方法是:
public final void setPriority(int newPriority)
public final int getPriority()
这个优先级会被映射到操作系统中线程的优先级,不过,因为操作系统各不相同,不一定都是10个优先级,Java中不同的优先级可能会被映射到操作系统中相同的优先级。另外,优先级对操作系统而言主要是一种建议和提示,而非强制。简单地说,在编程中,不要过于依赖优先级。
2.3 状态
线程有一个状态的概念,Thread
有一个方法用于获取线程的状态:
public State getState()
返回值类型为Thread.State
,它是一个枚举类型,有如下值:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
关于这些状态,我们简单解释下:
1)NEW:没有调用start的线程状态为NEW。
2)TERMINATED:线程运行结束后状态为TERMINATED。
3)RUNNABLE:调用start后线程在执行run方法且没有阻塞时状态为RUNNABLE,不过,RUNNABLE不代表CPU一定在执行该线程的代码,可能正在执行也可能在等待操作系统分配时间片,只是它没有在等待其他条件。
4)BLOCKED、WAITING、TIMED_WAITING:都表示线程被阻塞了,在等待一些条件,其中的区别我们在后面再介绍。
Thread
还有一个方法,返回线程是否活着:
public final native boolean isAlive()
线程被启动后,run方法运行结束前,返回值都是true。
2.4 是否daemon线程
Thread
有一个是否daemon
线程的属性,相关方法是:
public final void setDaemon(boolean on)
public final boolean isDaemon()
前面我们提到,启动线程会启动一条单独的执行流,整个程序只有在所有线程都结束的时候才退出,但daemon线程是例外,当整个程序中剩下的都是daemon线程的时候,程序就会退出。
daemon线程有什么用呢?它一般是其他线程的辅助线程,在它辅助的主线程退出的时候,它就没有存在的意义了。在我们运行一个即使最简单的"hello world"类型的程序时,实际上,Java也会创建多个线程,除了main线程外,至少还有一个负责垃圾回收的线程,这个线程就是daemon线程,在main线程结束的时候,垃圾回收线程也会退出。
2.5 sleep方法
Thread
有一个静态的sleep
方法,调用该方法会让当前线程睡眠指定的时间,单位是毫秒:
public static native void sleep(long millis) throws InterruptedException;
睡眠期间,该线程会让出CPU,但睡眠的时间不一定是确切的给定毫秒数,可能有一定的偏差,偏差与系统定时器和操作系统调度器的准确度和精度有关。睡眠期间,线程可以被中断,如果被中断,sleep会抛出InterruptedException
,关于中断以及中断处理,我们在15.4节介绍。
2.6 yield方法
Thread
还有一个让出CPU的方法:
public static native void yield();
这也是一个静态方法,调用该方法,是告诉操作系统的调度器:我现在不着急占用CPU,你可以先让其他线程运行。不过,这对调度器也仅仅是建议,调度器如何处理是不一定的,它可能完全忽略该调用。
2.7 join方法
在前面HelloThread
的例子中,HelloThread
没执行完,main线程可能就执行完了,Thread
有一个join方法,可以让调用join的线程等待该线程结束,join方法的声明为:
public final void join() throws InterruptedException
在等待线程结束的过程中,这个等待可能被中断,如果被中断,会抛出InterruptedException
。
join方法还有一个变体,可以限定等待的最长时间,单位为毫秒,如果为0,表示无期限等待:
public final synchronized void join(long millis) throws InterruptedException
在前面HelloThread
示例中,如果希望main
线程在子线程结束后再退出,main
方法可以改为:
public static void main(String[] args) throws InterruptedException {
Thread thread = new HelloThread();
thread.start();
thread.join();
}
2.8 过时方法
Thread
类中还有一些看上去可以控制线程生命周期的方法,如:
public final void stop()
public final void suspend()
public final void resume()
这些方法因为各种原因已被标记为了过时,我们不应该在程序中使用它们。
3. 共享内存及可能存在的问题
前面我们提到,每个线程表示一条单独的执行流,有自己的程序计数器,有自己的栈,但线程之间可以共享内存,它们可以访问和操作相同的对象。我们看个例子,如下所示。
public class ShareMemoryDemo {
private static int shared = 0;
private static void incrShared(){
shared ++;
}
static class ChildThread extends Thread {
List<String> list;
public ChildThread(List<String> list) {
this.list = list;
}
@Override
public void run() {
incrShared();
list.add(Thread.currentThread().getName());
}
}
public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<String>();
Thread t1 = new ChildThread(list);
Thread t2 = new ChildThread(list);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(shared);
System.out.println(list);
}
}
在代码中,定义了一个静态变量shared
和静态内部类ChildThread
,在main
方法中,创建并启动了两个ChildThread
对象,传递了相同的list
对象,ChildThread
的run
方法访问了共享的变量shared
和list
,main
方法最后输出了共享的shared
和list
的值,大部分情况下,会输出期望的值:
2
[Thread-0, Thread-1]
通过这个例子,我们想强调说明执行流、内存和程序代码之间的关系。
1)该例中有三条执行流,一条执行main方法,另外两条执行ChildThread
的run
方法。
2)不同执行流可以访问和操作相同的变量,如本例中的shared
和list
变量。
3)不同执行流可以执行相同的程序代码,如本例中incrShared
方法,ChildThread
的run
方法,被两条ChildThread
执行流执行,incrShared
方法是在外部定义的,但被ChildThread
的执行流执行。在分析代码执行过程时,理解代码在被哪个线程执行是很重要的。
4)当多条执行流执行相同的程序代码时,每条执行流都有单独的栈,方法中的参数和局部变量都有自己的一份。
当多条执行流可以操作相同的变量时,可能会出现一些意料之外的结果,包括竞态条件和内存可见性问题,我们来看下。
3.1 竞态条件
所谓竞态条件(race condition)是指,当多个线程访问和操作同一个对象时,最终执行结果与执行时序有关,可能正确也可能不正确。我们看一个例子,如下所示。
public class CounterThread extends Thread {
private static int counter = 0;
@Override
public void run() {
for(int i = 0; i < 1000; i++) {
counter++;
}
}
public static void main(String[] args) throws InterruptedException {
int num = 1000;
Thread[] threads = new Thread[num];
for(int i = 0; i < num; i++) {
threads[i] = new CounterThread();
threads[i].start();
}
for(int i = 0; i < num; i++) {
threads[i].join();
}
System.out.println(counter);
}
}
这段代码容易理解,有一个共享静态变量counter
,初始值为0,在main
方法中创建了1000个线程,每个线程对counter
循环加1000次,main
线程等待所有线程结束后输出counter
的值。
期望的结果是100万,但实际执行,发现每次输出的结果都不一样,一般都不是100万,经常是99万多。为什么会这样呢?因为counter++
这个操作不是原子操作, 它分为三个步骤:
1)取counter
的当前值;
2)在当前值基础上加1;
3)将新值重新赋值给counter
。
两个线程可能同时执行第一步,取到了相同的counter
值,比如都取到了100,第一个线程执行完后counter
变为101,而第二个线程执行完后还是101,最终的结果就与期望不符。
怎么解决这个问题呢?有多种方法:
-
使用synchronized关键字;
-
使用显式锁;
-
使用原子变量。
关于这些方法,我们在后文章会逐步介绍。
3.2 内存可见性
多个线程可以共享访问和操作相同的变量,但一个线程对一个共享变量的修改,另一个线程不一定马上就能看到,甚至永远也看不到。这可能有悖直觉,我们来看一个例子,如下代码所示。
public class VisibilityDemo {
private static boolean shutdown = false;
static class HelloThread extends Thread {
@Override
public void run() {
while(!shutdown){
// do nothing
}
System.out.println("exit hello");
}
}
public static void main(String[] args) throws InterruptedException {
new HelloThread().start();
Thread.sleep(1000);
shutdown = true;
System.out.println("exit main");
}
}
在这个程序中,有一个共享的boolean
变量shutdown
,初始为false
,HelloThread
在shutdown
不为true的情况下一直死循环,当shutdown
为true时退出并输出"exit hello"
,main
线程启动HelloThread
后休息了一会儿,然后设置shutdown
为true,最后输出"exit main"
。
期望的结果是两个线程都退出,但实际执行时,很可能会发现HelloThread
永远都不会退出,也就是说,在HelloThread
执行流看来,shutdown
永远为false,即使main
线程已经更改为了true。
这是怎么回事呢?这就是内存可见性问题。 在计算机系统中,除了内存,数据还会被缓存在CPU的寄存器以及各级缓存中,当访问一个变量时,可能直接从寄存器或CPU缓存中获取,而不一定到内存中去取,当修改一个变量时,也可能是先写到缓存中,稍后才会同步更新到内存中。在单线程的程序中,这一般不是问题,但在多线程的程序中,尤其是在有多CPU的情况下,这就是严重的问题。一个线程对内存的修改,另一个线程看不到,一是修改没有及时同步到内存,二是另一个线程根本就没从内存读。
怎么解决这个问题呢?有多种方法:
-
使用volatile关键字。
-
使用synchronized关键字或显式锁同步。
关于这些方法,我们在后面会逐步介绍。
4. 线程的优点及成本
为什么要创建单独的执行流?或者说线程有什么优点呢?至少有以下几点:
1)充分利用多CPU的计算能力,单线程只能利用一个CPU,使用多线程可以利用多CPU的计算能力。
2)充分利用硬件资源,CPU和硬盘、网络是可以同时工作的,一个线程在等待网络IO的同时,另一个线程完全可以利用CPU,对于多个独立的网络请求,完全可以使用多个线程同时请求。
3)在用户界面(GUI)应用程序中,保持程序的响应性,界面和后台任务通常是不同的线程,否则,如果所有事情都是一个线程来执行,当执行一个很慢的任务时,整个界面将停止响应,也无法取消该任务。
4)简化建模及IO处理,比如,在服务器应用程序中,对每个用户请求使用一个单独的线程进行处理,相比使用一个线程,处理来自各种用户的各种请求,以及各种网络和文件IO事件,建模和编写程序要容易得多。
关于线程,我们需要知道,它是有成本的。创建线程需要消耗操作系统的资源,操作系统会为每个线程创建必要的数据结构、栈、程序计数器等,创建也需要一定的时间。
此外,线程调度和切换也是有成本的,当有大量可运行线程的时候,操作系统会忙于调度,为一个线程分配一段时间,执行完后,再让另一个线程执行,一个线程被切换出去后,操作系统需要保存它的当前上下文状态到内存,上下文状态包括当前CPU寄存器的值、程序计数器的值等,而一个线程被切换回来后,操作系统需要恢复它原来的上下文状态,整个过程称为上下文切换 ,这个切换不仅耗时,而且使CPU中的很多缓存失效。
当然,这些成本是相对而言的,如果线程中实际执行的事情比较多,这些成本是可以接受的;但如果只是执行示例中的counter++,那相对成本就太高了。
另外,如果执行的任务都是CPU密集型的,即主要消耗的都是CPU,那创建超过CPU数量的线程就是没有必要的,并不会加快程序的执行。