本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》(马俊昌著),由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买:京东自营连接 html
![]()
在以前的章节中,咱们都是假设程序中只有一条执行流,程序从main方法的第一条语句逐条执行直到结束。从本节开始,咱们讨论并发,在程序中建立线程来启动多条执行流,并发和线程是一个复杂的话题,本节,咱们先来讨论Java中线程的一些基本概念。java
线程表示一条单独的执行流,它有本身的程序执行计数器,有本身的栈。下面,咱们经过建立线程来对线程创建一个直观感觉,在Java中建立线程有两种方式,一种是继承Thread,另一种是实现Runnable接口,咱们先来看第一种。git
Java中java.lang.Thread这个类表示线程,一个类能够继承Thread并重写其run方法来实现一个线程,以下所示:程序员
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println("hello");
}
}
复制代码
HelloThread这个类继承了Thread,并重写了run方法。run方法的方法签名是固定的,public,没有参数,没有返回值,不能抛出受检异常。run方法相似于单线程程序中的main方法,线程从run方法的第一条语句开始执行直到结束。github
定义了这个类不表明代码就会开始执行,线程须要被启动,启动须要先建立一个HelloThread对象,而后调用Thread的start方法,以下所示:编程
public static void main(String[] args) {
Thread thread = new HelloThread();
thread.start();
}
复制代码
咱们在main方法中建立了一个线程对象,并调用了其start方法,调用start方法后,HelloThread的run方法就会开始执行,屏幕输出:swift
hello
复制代码
为何调用的是start,执行的倒是run方法呢?start表示启动该线程,使其成为一条单独的执行流,背后,操做系统会分配线程相关的资源,每一个线程会有单独的程序执行计数器和栈,操做系统会把这个线程做为一个独立的个体进行调度,分配时间片让它执行,执行的起点就是run方法。缓存
若是不调用start,而直接调用run方法呢?屏幕的输出并不会发生变化,但并不会启动一条单独的执行流,run方法的代码依然是在main线程中执行的,run方法只是main方法调用的一个普通方法。bash
怎么确认代码是在哪一个线程中执行的呢?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的机器上,同一时刻能够有多个线程同时执行,但操做系统给咱们屏蔽了这种差别,给程序员的感受就是多个线程并发执行,但哪条语句先执行哪条后执行是不必定的。当全部线程都执行完毕的时候,程序退出。
经过继承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接口来实现线程,启动线程都是调用Thread对象的start方法。
前面咱们提到,每一个线程都有一个id和name,id是一个递增的整数,每建立一个线程就加一,name的默认值是"Thread-"后跟一个编号,name能够在Thread的构造方法中进行指定,也能够经过setName方法进行设置,给Thread设置一个友好的名字,能够方便调试。
线程有一个优先级的概念,在Java中,优先级从1到10,默认为5,相关方法是:
public final void setPriority(int newPriority) public final int getPriority() 复制代码
这个优先级会被映射到操做系统中线程的优先级,不过,由于操做系统各不相同,不必定都是10个优先级,Java中不一样的优先级可能会被映射到操做系统中相同的优先级,另外,优先级对操做系统而言更多的是一种建议和提示,而非强制,简单的说,在编程中,不要过于依赖优先级。
线程有一个状态的概念,Thread有一个方法用于获取线程的状态:
public State getState() 复制代码
返回值类型为Thread.State,它是一个枚举类型,有以下值:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
复制代码
关于这些状态,咱们简单解释下:
Thread还有一个方法,返回线程是否活着:
public final native boolean isAlive() 复制代码
线程被启动后,run方法运行结束前,返回值都是true。
Thread有一个是否daemo线程的属性,相关方法是:
public final void setDaemon(boolean on) public final boolean isDaemon() 复制代码
前面咱们提到,启动线程会启动一条单独的执行流,整个程序只有在全部线程都结束的时候才退出,但daemo线程是例外,当整个程序中剩下的都是daemo线程的时候,程序就会退出。
daemo线程有什么用呢?它通常是其余线程的辅助线程,在它辅助的主线程退出的时候,它就没有存在的意义了。在咱们运行一个即便最简单的"hello world"类型的程序时,实际上,Java也会建立多个线程,除了main线程外,至少还有一个负责垃圾回收的线程,这个线程就是daemo线程,在main线程结束的时候,垃圾回收线程也会退出。
Thread有一个静态的sleep方法,调用该方法会让当前线程睡眠指定的时间,单位是毫秒:
public static native void sleep(long millis) throws InterruptedException;
复制代码
睡眠期间,该线程会让出CPU,但睡眠的时间不必定是确切的给定毫秒数,可能有必定的误差,误差与系统定时器和操做系统调度器的准确度和精度有关。
睡眠期间,线程能够被中断,若是被中断,sleep会抛出InterruptedException,关于中断以及中断处理,咱们后续章节再介绍。
Thread还有一个让出CPU的方法:
public static native void yield();
复制代码
这也是一个静态方法,调用该方法,是告诉操做系统的调度器,我如今不着急占用CPU,你能够先让其余线程运行。不过,这对调度器也仅仅是建议,调度器如何处理是不必定的,它可能彻底忽略该调用。
在前面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();
}
复制代码
Thread类中还有一些看上去能够控制线程生命周期的方法,如:
public final void stop() public final void suspend() public final void resume() 复制代码
这些方法由于各类缘由已被标记为了过期,咱们不该该在程序中使用它们。
前面咱们提到,每一个线程表示一条单独的执行流,有本身的程序计数器,有本身的栈,但线程之间能够共享内存,它们能够访问和操做相同的对象。咱们看个例子,代码以下:
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]
复制代码
经过这个例子,咱们想强调说明执行流、内存和程序代码之间的关系。
当多条执行流能够操做相同的变量时,可能会出现一些意料以外的结果,咱们来看下。
所谓竞态条件(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++这个操做不是原子操做,它分为三个步骤:
两个线程可能同时执行第一步,取到了相同的counter值,好比都取到了100,第一个线程执行完后counter变为101,而第二个线程执行完后仍是101,最终的结果就与指望不符。
怎么解决这个问题呢?有多种方法:
关于这些方法,咱们在后续章节再介绍。
多个线程能够共享访问和操做相同的变量,但一个线程对一个共享变量的修改,另外一个线程不必定立刻就能看到,甚至永远也看不到,这可能有悖直觉,咱们来看一个例子。
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的状况下,这就是个严重的问题。一个线程对内存的修改,另外一个线程看不到,一是修改没有及时同步到内存,二是另外一个线程根本就没从内存读。
怎么解决这个问题呢?有多种方法:
关于这些方法,咱们在后续章节再介绍。
为何要建立单独的执行流?或者说线程有什么优势呢?至少有如下几点:
关于线程,咱们须要知道,它是有成本的。建立线程须要消耗操做系统的资源,操做系统会为每一个线程建立必要的数据结构、栈、程序计数器等,建立也须要必定的时间。
此外,线程调度和切换也是有成本的,当有当量可运行线程的时候,操做系统会忙于调度,为一个线程分配一段时间,执行完后,再让另外一个线程执行,一个线程被切换出去后,操做系统须要保存它的当前上下文状态到内存,上下文状态包括当前CPU寄存器的值、程序计数器的值等,而一个线程被切换回来后,操做系统须要恢复它原来的上下文状态,整个过程被称为上下文切换,这个切换不只耗时,并且使CPU中的不少缓存失效,是有成本的。
固然,这些成本是相对而言的,若是线程中实际执行的事情比较多,这些成本是能够接受的,但若是只是执行本节示例中的counter++,那相对成本就过高了。
另外,若是执行的任务都是CPU密集型的,即主要消耗的都是CPU,那建立超过CPU数量的线程就是没有必要的,并不会加快程序的执行。
本节,咱们介绍了Java中线程的一些基本概念,包括如何建立线程,线程的一些基本属性和方法,多个线程能够共享内存,但共享内存也有两个重要问题,一个是竞态条件,另外一个是内存可见性,最后,咱们讨论了线程的一些优势和成本。
针对共享内存的两个问题,下一节,咱们讨论Java的一个解决方案 - synchronized关键字。
(与其余章节同样,本节全部代码位于 github.com/swiftma/pro…)
未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),深刻浅出,老马和你一块儿探索Java编程及计算机技术的本质。用心原创,保留全部版权。