前言
人人皆知,多线程编程在充分利用计算资源、提高软件服务质量方面扮演着非常重要的角色,然而,多线程编程并非一个简单地使用多个线程进行编程的数量问题,其自身也有诸多问题,好比俗话说“一个和尚打水喝,两个和尚挑水喝,三个和尚没水喝”,简单的使用多个线程进行过编程可能导致更加糟糕的计算效率。
所以让我们来系统的探讨一下Java多线程的奥秘吧。
一、无处不在的线程
进程(process)代表运行中的程序。一个运行的Java程序就是一个进程。从操作系统的角度来看,线程(Thread)是进程中可独立执行的子任务。一个进程可以包含多个线程,同个进程中的线程共享该进程所申请到的资源,如内存空间和文件句柄等。从JVM的角度来看,线程是进程中的一个组件(Component),它可以看作执行Java代码的最小单位。Java程序中的任何一段代码总是执行在某个确定的线程中。JVM启动时会创建一个main线程,负责执行Java程序的入口方法(main方法)。
下例1.1展示Java程序中代码由某个确定的线程运行:
public class JavaThreadAnywhere {
public static void main(String[] args) {
System.out.println("The main method was executed by thread:" + Thread.currentThread().getName());
Helper helper = new Helper("Java Thread Anywhere");
helper.run();
}
static class Helper implements Runnable{
private final String message;
public Helper(String message) {
this.message = message;
}
private void doSomething(String message) {
System.out.println("The doSomething method was executed by thread:" + Thread.currentThread().getName());
System.out.println("Do something with " + message);
}
public void run() {
doSomething(message);
}
}
}
1.1运行结果:
在多线程编程中,弄清楚一段代码具体是由哪个线程去负责执行是很重要的,关系到性能问题、线程安全问题等。
Java的线程可以分为守护线程和用户线程两种,具体区别我们后面再细谈。
一般来说,守护线程用于执行一些中重要性不高的任务,例如监视其他线程的运行状况。
二、线程的创建与运行
在Java中, 一个线程就是一个java.lang.Thread类的实例。创建一个Thread实例(线程)与创建其他类的实例有所不同:JVM会为一个Thread实例分配两个调用栈(Call Stack)所需的空间。这两个调用栈一个用于追踪Java代码间的调用关系,另一个用于追踪Java代码对本地代码的调用关系。
一个Thread实例通常对应两个线程。一个是JVM中的线程,而另一个是与JVM中的线程相对应的依赖于JVM宿主机操作系统的本地线程。启动线程只需要调用start方法。线程启动后,当相应的线程被JVM的线程调度器调度到运行,相应Thread实例的run方法会被JVM所调用。
下例1.2所示Java线程的创建与运行:
import java.lang.Thread;
public class JavaThreadAnywhere {
public static void main(String[] args) {
System.out.println("The main method was executed by thread:" + Thread.currentThread().getName());
Helper helper = new Helper("Java Thread Anywhere");
//创建一个线程
Thread thread = new Thread(helper);
//设置线程名
thread.setName("workThread");
//启动线程
thread.start();
}
static class Helper implements Runnable{
private final String message;
public Helper(String message) {
this.message = message;
}
private void doSomething(String message) {
System.out.println("The doSomething method was executed by thread:" + Thread.currentThread().getName());
System.out.println("Do something with " + message);
}
public void run() {
doSomething(message);
}
}
}
1.2运行结果:
与1.1结果相比,同样的Helper的同方法doSomething此时由线程workThread而非main线程执行。是因为我们使用了workThread线程对代码进行调用。
其中,对线程对象的start方法调用的操作是运行在main方法中的,而main方法是又main线程负责执行的,因此,我们所创建的线程thread就可以看成是main线程的一个子线程,而main线程则为父线程。
Java中,子线程是否是一个守护线程取决于其父线程:默认情况下,父线程为守护线程则子线程也是守护线程,反之亦然。当然,父线程在创建子线程后,启动子线程之前可以调用Threa实例的setDaemon方法来修改线程的属性。
Thread类自身是一个实现java.lang.Runnable接口的对象,我们可以定义一个Thread类的子类来创建线程,自定义的线程要覆盖其父类的run方法。
如下例1.3所示:
public class ThreadCreationViaSubclass{
public static void main(String[] args) {
Thread thread = new CustomThread();
thread.run();
}
static class CustomThread extends Thread{
public void run() {
System.out.println("Running...");
}
}
}
三、线程的状态与上下文切换
Java语言中,一个线程从创建、启动到其运行结束的整个生命周期可能经历若干个状态,如图:
Java线程的状态可以通过调用相应Thread实例的getState方法来获取,返回一个枚举类型值(Enum),具体状态在这里不细谈。
线程的状态切换时,可能意味着上下文切换。上下文切换类似于我们接听语音电话的场景。但我们在通话并讨论某件事情时,突然有另外一个来电,这时我们会跟对方说:“我先接个电话,待会说”,并记下与之讨论的进度。当处理完毕后再与第一个来电者进行通话,在此之前,若我们没有特意的记下当前的讨论进度,那再次开始讨论时可能得询问对方“我们说到哪里了”。
多线程环境中,当一个线程的状态由RUNNABLE转换为非RUNNABLE(BLOCKED、WAITING、TIMED_WAITING)时,相应线程的上下文信息(所谓的Context,包括CPU的寄存器和程序计数器在某一个时间节点的内容等)需要被保存,以便之后继续进行。这个对线程的上下文信息进行保存和回复的过程就被称为上下文切换。同时,上下文切换会带来额外的开销。
四、认识synchronized和volatile
首先我们要了解三个知识点:
- 原子性操作
指相应的操作是单一不可分割的操作。例如,对int型变量x进行x- - 的操作就不是原子操作:1.读取x当前值 2.进行x- -数值运算 3.将2步骤的运算值赋值给变量x。 - 内存可见性
CPU在执行代码时,为了减少变量访问的时间消耗可能将代码中访问的变量的值缓存到该CPU的缓存区中。因此相应的代码再次访问某个变量时,相应的值可能是从CPU缓存区读取的。由于每个CPU都有自己的缓存区,而且每个CPU缓冲区的内容对其他CPU是不可见的。导致其他CPU上运行的其他线程可能无法“看到”该线程对某个变量的更改。 - 重排序
编译器和CPU为了提高指令的执行效率,可能会进行指令重排序,使得代码的实际执行方式不是按照我们所认为的方式进行的。
下面了解两个关键字:
- synchronized
synchronized关键字可以帮助我们实现操作的原子性,避免线程间的干扰情况。通过该关键字所包括的临界区的排他性保证在任何一个时刻只有一个线程能够执行临界区的代码;
synchronized关键字还可以保证一个线程执行临界区中的代码时所修改的变量值对于稍后执行该临界区中的代码的线程来说是安全的。 - volatile
volatile关键字也可以保证内存可见性。其机制是:当一个线程修改了一个volatile修饰的变量的值时,该值会被写入主内存而不仅仅是当前线程所在的CPU缓存区,而其他CPU缓存区中储存的该变量的值因此失效。
volatile关键字还可以禁止指令重排序,对多线程代码的正确性起到很大作用。
与synchronized关键字相比,volatile关键字仅能保证内存可见性,而前者既能保证操作的原子性,又能保证内存可见性。然而,前者会导致上下文切换,后者不会。
五、线程的优势和风险
多线程具有以下优势:
- 提高系统吞吐率
- 提高响应性
- 充分利用多核CPU资源
- 最小化对系统资源的使用
- 简化程序的结构
多线程也有自身的问题和风险:
- 线程安全问题
- 线程生命特征
- 上下文切换
- 可靠性