浅谈Java线程
本文将帮助诸位读者学习什么是线程,线程为什么有用,以及如何在编程的过程中使用线程。
——布莱恩·戈茨
关于本教程
本教程的主要内容是什么?
本教程探讨线程的基础知识——他们是什么,为什么有用,并会带着读者一起编写一些利用线程的小demo。
在此基础上,我们还会探讨线程的基础应用——比如如何在线程之间交换数据,如何控制线程,以及线程如何交换数据。通过学习这些,我们可以构建更复杂的线程应用程序。
谁适合这个教程?
那些对Java基础知识有所了解,但是对多线程及并发编程的经验十分有限的程序员们。
在学习本教程后,你可以编写使用线程的简单程序,你也可以直接阅读和理解他人写的多线程代码。
线程的基础知识
什么是线程?
几乎所有的操作系统都支持进程的概念——-某种程度上相互隔离的、独立运行的程序。
线程是允许多个活动在单个进程中共存的工具。大多数现代操作系统都支持线程,并且线程的概念以各种形式存在很久了。不同于之前的编程语言仅仅将线程视为底层操作系统的工具,Java是第一种在语言本身中明确包含线程的主流编程语言。
线程也被成为轻量级进程,与进程一样,多个独立的线程在程序中并发执行。并且每个线程都有他自己的堆栈,程序计数器,和局部变量。然而,线程的独立性不如进程,线程之间会共享内存,文件句柄和其他进程状态。
一个进程可以支持多个线程,这些线程看起来同时执行并且彼此异步。 一个进程中的多个线程共享相同的内存地址空间,这意味着它们可以访问同一个的变量和对象,并且它们从同一个的堆中分配对象。这使线程之间可以轻松共享信息,但必须确保它们不会干扰同一进程中的其他线程。
Java线程工具和API看似简单。然而,编写那些有效使用多线程的复杂程序并不那么简单。由于多个线程共处于同一内存地址空间并共享相同的变量,因此必须确保它们不会干扰同一进程中的其他线程。
线程其实随处可见
每个Java程序都有至少一个线程——主线程。当一个Java程序启动时,JVM会创建一个主线程,并且在这个主线程中调用Java程序的Main方法。
JVM还会创建其他的线程,这些线程大多数是对你不可见的,比如说,与垃圾收集,对象销毁和其他与JVM内存管理相关的线程。其他工具也会创建线程,就像AWT或swing UI工具包,servlet容器,应用程序服务器和RMI(远程方法调用)。
为什么要使用线程?
有很多理由去促使你在Java程序中使用线程,如果你使用Swing,servlets,RMI,或者EJB技术,你可能已经使用过了线程只是没有意识到罢了。
使用线程的一部分原因是线程可能有助于:
- 使UI交互性更好
- 更好的利用多处理器系统
- 简化建模复杂程度
- 执行异步或后台处理流程
交互性更好的UI
像AWT和Swing这样的事件驱动型的UI工具包,会使用一个处理UI事件的事件线程,可以处理诸如按键盘和鼠标点击等UI事件。
AWT和Swing程序将事件监听器与UI对象相关联,如果特定事件(比如说按钮被点击)发生时,就会通知给监听器。这个通知,就是事件线程负责的。
如果事件监听器要处理一个长时间的任务(比如说检查一个大型文档的字母拼写),事件线程将忙于运行拼写检查器,因此在事件侦听器完成任务之前无法处理其他UI事件。这会使程序看起来停滞,会造成用户的不安。
为了避免这种情况,事件监听器应该将长时间任务的处理转交给其他线程,以便AWT线程可以在任务进行时继续处理UI事件(包括取消正在执行的长时间运行任务的请求)。
更好的利用多处理器系统
多处理器系统逐渐被更广泛的使用了,不再是仅仅用于大数据和科学计算中。现代很多桌面级的家用系统或者普通服务器系统都支持好多个处理器。
现代操作系统,包括Linux,Solaris和Windows NT/2000在内,可以充分利用多个处理器灵活的调度线程。
调度的基本单位通常是线程,如果一个程序只有一个活动线程,那么它一次只能在一个处理器上运行。 如果一个程序有多个活动线程,那么多处理器系统可以同时调度多个线程。 在一个设计良好的程序中,使用多线程可以提高程序的吞吐量和性能。
简化建模复杂程度
在某些情况下,使用线程可以帮助你更好的编写和维护程序,在我们想要编写模拟多个实体之间交互的应用程序时,给每个实体分配各自的线程可以极大的简化模拟或建模的复杂程度。
另一个使用一个被分离出来的线程来简化程序的例子是当应用程序具有多个独立的事件驱动组件时,比如说某个程序有一个记录某事件A发生后的秒数并将其更新在屏幕上的计时组件。与其用主线程不断循环定期获取时间并更新显示的时间,不如让一个线程只负责定时更新时间,这样更简单,也不容易出错。用这种方式主线程无需再担心计时组件的问题。
执行异步或后台处理流程
服务器应用程序从远程端(例如socket)获取输入。从socket读取数据时,如果没有可用的数据,则SocketInputStream.read() 会一直阻塞直到有数据可以被读取。
如果一个单线程的程序想要从socket中读取数据,而socket的另一端却迟迟不发送数据,此时程序会一直空等下去,也不会运行任何其他逻辑。除此之外,单线程程序只能通过轮询socket的方式判断数据是否可用,这本身对性能不利。
然而如果你创建一个线程专门去读取socket,不但可以让主线程在这段时间去处理其他任务,你甚至可以通过创建多个线程来同时从多个socket中读取数据。通过这种方式,当数据可用时你会很快的接收到通知(因为等待状态的线程被唤醒了),而不必频繁轮询以检查数据是否可用。使用线程异步的从socket获取数据也比轮询更简单和稳健得多。
线程在有上述便利的同时,也带来了一些风险
尽管Java线程工具非常易于使用,但在编写多线程程序时应尽量避免一些风险。
当多个线程访问相同的数据项时(比如静态变量,全局变量,或者数据库中的数据),你需要确保各个线程之间不会互相影响。Java语言为此提供了synchronized和volatile两个关键字。我们将在本教程后面探讨这些关键字的用途和含义。
如果我们通过synchronized关键字来保证各个线程之间不会互相影响,那就必须确保每个线程都可以使用被synchronized关键字锁住的变量。
不要过度使用线程
线程为帮助我们更加简单的编程,但是过度的使用线程会导致程序的可维护性和性能变差,线程本身无论是创建或者切换都需要消耗一定资源,因此在不降低性能的情况下可创建的线程是有限的。对于依赖CPU的任务,盲目的使用线程是无意义的。
举个例子
这个例子中,我们会有一个线程A负责计时,还会有一个主线程B负责不断计算素数。
在主线程B启动之前,线程A启动一个定时器线程,计时十秒后将一个标志位置为true,主线程B会在检查标志位为true后,主线程会停止工作。我们使用volatile来修饰这个标志位。
/**
* 十秒内尽可能的计算素数
*/
public class CalculatePrimes extends Thread {
public static final int MAX_PRIMES = 1000000;
public static final int TEN_SECONDS = 10000;
public volatile boolean finished = false;
public void run() {
int[] primes = new int[MAX_PRIMES];
int count = 0;
for (int i=2; count<MAX_PRIMES; i++) {
// Check to see if the timer has expired
if (finished) {
break;
}
boolean prime = true;
for (int j=0; j<count; j++) {
if (i % primes[j] == 0) {
prime = false;
break;
}
}
if (prime) {
primes[count++] = i;
System.out.println("Found prime: " + i);
}
}
}
public static void main(String[] args) {
CalculatePrimes calculator = new CalculatePrimes();
calculator.start();
try {
Thread.sleep(TEN_SECONDS);
}
catch (InterruptedException e) {
// fall through
}
calculator.finished = true;
}
}
线程基础知识总结
Java语言内置了一个强大的线程工具,你可以用他来:
- 提高 GUI 应用程序的响应能力
- 利用多处理器系统
- 当有多个独立实体时,简化建模复杂程度
- 在不阻塞整个程序的情况下执行阻塞 I/O
当你使用多线程时,你必须小心遵守线程间共享数据的规则,我们将在共享访问数据中详细介绍,在此时你只需要记得,不要因为共享数据造成程序错误的被执行。
线程的生命周期
创建线程
在 Java 程序中有多种创建线程的方法。每个 Java 程序至少包含一个主线程。额外的线程是通过Thread构造器或实例化一个继承Thread的其他类来创建的。
Java里的线程可以通过直接实例化“Thread”对象或继承“Thread”类的对象来创建其他线程。在线程基础知识的示例中,我们通过实例化继承了Thread类的一个类名为 CalculatePrimes 的对象来创建线程。
当我们谈论Java程序中的线程时,我们可能会提到两个相关的实体:一个是正在工作的线程A,一个是代表线程的Thread对象B,其中线程A由操作系统创建,JVM通过创建对象B来控制与其关联的线程。
创建线程与启动线程的区别
直到另一个线程为新线程调用Thread对象上的start()方法,新线程才会被执行。无论线程是否开始被执行,或被退出,都不影响Thread对象本身的生命周期。这让你可以在线程的大多数生命周期内都可以获取或改变线程的信息。
在构造Thread对象时直接启动线程并不是一个好主意,这样会让我们没有完全构造好一个对象的时候,可能会在新线程使用这个对象。如果一个对象拥有一个线程,那么它应该提供一个start()或init()方法来启动线程,而不是从构造函数启动它。
线程的三种结束方式
- 线程运行完成
run()方法。 - 线程抛出未捕获的“Exception”或“Error”。
- 另一个线程调用该线程的stop()方法(该方法已被弃用)。弃用意味着它们仍然存在,但你不应在新代码中使用它们,并努力在现有代码中消除它们。
当 Java 程序中的所有线程都完成时,程序才会退出。
插入线程
Thread API 包含一个等待另一个线程完成的方法join()方法。 当你调用 Thread.join() 时,调用线程将阻塞,直到被调用线程完成。
Thread.join()通常由使用线程将大问题划分为较小问题的程序使用,本节末尾的示例创建了10个线程,启动它们,然后使用Thread.join()等待它们全部完成。
调度
除了使用 Thread.join() 和 Object.wait() 时,线程调度和执行的时间是不确定的。如果两个线程同时运行并且都没有阻塞,你必须考虑到任意两个指令之间,一个线程可能会修改另一个线程用到的变量。如果你的两个线程可能会访问同一数据,比如直接或间接从静态字段(全局变量)引用的数据,则必须使用synchronize来确保数据一致性。
在下面的简单示例中,我们将创建并启动两个线程,每个线程向 System.out 打印两行数据:
public class TwoThreads {
public static class Thread1 extends Thread {
public void run() {
System.out.println("A");
System.out.println("B");
}
}
public static class Thread2 extends Thread {
public void run() {
System.out.println("1");
System.out.println("2");
}
}
public static void main(String[] args) {
new Thread1().start();
new Thread2().start();
}
}
我们不知道这些行将以什么顺序被打印(当然不包括A在B前,1在2前),打印顺序可以是以下任何一种:
- 1 2 A B
- 1 A 2 B
- 1 A B 2
- A 1 2 B
- A 1 B 2
- A B 1 2
结果完全随机,不在机器和运行时间上具备任何规律,因此最好使用synchronize来保证数据同步。
沉睡
Thread API中包含一个sleep()方法,该方法导致线程进入等待状态并持续指定的时间,或者被其他线程通过调用该线程的Thread.interrupt()中断。当线程经过指定时间后退出等待状态时,线程再次变为可运行状态并返回到调度器的可运行线程队列中;而被Thread.interrupt()中断的线程会抛出一个InterruptedException异常,以此区分是被中断还是经过指定时间后自行苏醒。
如果线程被调用 Thread.interrupt() 中断,则休眠线程将抛出 InterruptedException ,以便线程知道它被中断唤醒,而不必检查是否计时器过期。
Thread.yield() 方法类似于 Thread.sleep(),但它不是休眠,而是暂停当前线程,以便其他线程可以运行。在大多数实现中,当高优先级的线程调用 Thread.yield() 时,低优先级的线程会停止运行。
CalculatePrimes 示例使用后台线程来计算素数,然后休眠 10 秒。当计时器到期时,它会设置一个标志表示十秒已到期。
守护线程
之前我们提到过Java主线程在所有其他线程工作完成时会退出,这并不完全正确,像GC线程或其他JVM创建的线程并不是这样,我们没有办法阻止这些线程工作,如果这些线程正在运行,那Java程序要怎么退出呢?
这些系统线程我们统称为守护线程,当其他非守护线程工作完成时,守护线程会自行结束。
任何线程都可以成为守护线程。 你可以通过调用Thread.setDaemon() 方法来声明一个线程是一个守护线程。你可能想要将自己项目中的后台线程设置为守护线程,例如计时器线程,或其他延迟事件线程,这些线程的共性是只有在非守护线程还在工作时才有意义。
举个例子
TenThreads展示了一个创建十个线程的程序,每个线程都做一些工作。 它等待它们全部完成,然后收集结果。
/**
* Creates ten threads to search for the maximum value of a large matrix.
* Each thread searches one portion of the matrix.
*/
public class TenThreads {
private static class WorkerThread extends Thread {
int max = Integer.MIN_VALUE;
int[] ourArray;
public WorkerThread(int[] ourArray) {
this.ourArray = ourArray;
}
// Find the maximum value in our particular piece of the array
public void run() {
for (int i = 0; i < ourArray.length; i++)
max = Math.max(max, ourArray[i]);
}
public int getMax() {
return max;
}
}
public static void main(String[] args) {
WorkerThread[] threads = new WorkerThread[10];
int[][] bigMatrix = getBigHairyMatrix();
int max = Integer.MIN_VALUE;
// Give each thread a slice of the matrix to work with
for (int i=0; i < 10; i++) {
threads[i] = new WorkerThread(bigMatrix[i]);
threads[i].start();
}
// Wait for each thread to finish
try {
for (int i=0; i < 10; i++) {
threads[i].join();
max = Math.max(max, threads[i].getMax());
}
}
catch (InterruptedException e) {
// fall through
}
System.out.println("Maximum value was " + max);
}
}
总结
和程序一样,线程也有生命周期:它们启动、执行和完成。 一个程序或进程可能包含多个线程,这些线程看起来彼此独立执行。
线程是通过实例化一个 Thread 对象或继承了 Thread 的对象来创建的,但是在新的 Thread 对象上调用 start() 方法之前,线程不会开始执行。 当线程到达其 run() 方法的末尾或抛出未处理的异常时,它们就会结束。
sleep() 方法可以用来等待一定的时间; join() 方法可用于等待另一个线程完成。
线程无处不在
谁会创建线程
即使你从来没有显式的创建过一个线程,你也可能在你的程序任何位置发现线程的存在。线程从各种源程序引入到我们项目中。
有许多工具可以为你创建线程,如果你要使用这些设施,你应该了解线程之间如何交互以及如何防止线程相互妨碍。
AWT和Swing
任何使用 AWT 或 Swing 的程序都必须处理线程。 AWT 工具包创建了一个用于处理 UI 事件的线程,AWT 事件调用的任何事件侦听器都在 AWT 事件线程中执行。
你不仅要担心同步访问事件侦听器与其他线程之间共享的数据项,而且还必须找到一种方法来处理由事件侦听器触发的长时间运行的任务——例如检查大文档中的拼写或搜索文件系统中的一个文件,这类大任务通畅运行在后台线程中,以便我们在任务运行时UI依旧流畅的与用户交互。SwingWorker很好的体现了上述特点。
AWT 事件线程不是守护线程,因此不能自动退出;这就是为什么 System.exit() 经常被用来结束 AWT 和 Swing 应用程序。
TimerTask
TimerTask 工具在 JDK 1.3 中被引入到 Java 语言中。这个工具允许你可以定期或延时执行任务,Timer类的实现非常简单,它创建一个定时器线程并构建一个按执行时间排序的等待事件队列。
TimerTask 线程是一个守护线程,因此它不会阻止程序退出。由于计时事件执行在Timer线程中,因此你必须确保对Timer线程中的任务内使用的任何数据项的访问正确同步。
在计算素数的例子中,我们通过使用“TimerTask”来代替主线程休眠的情况,代码如下:
public static void main(String[] args) {
Timer timer = new Timer();
final CalculatePrimes calculator = new CalculatePrimes();
calculator.start();
timer.schedule(
new TimerTask() {
public void run()
{
calculator.finished = true;
}
}, TEN_SECONDS);
}
Servlet 和 JavaServer Pages 技术
Servlet 容器创建多个线程用于执行Servlet请求。作为编写Servlet代码的人,你很难知道请求在哪个线程中被执行,如果同一时刻,一个URL被多次请求,一个Servlet会在多个线程中同时处于活动状态。
在编写 servlet 或 JavaServer Pages (JSP) 文件时,你必须始终假设同一个 servlet 或 JSP 文件可能在多个线程中同时执行。 servlet 或 JSP 文件访问的任何共享数据都必须被恰当的同步;这包括 servlet 对象本身的属性。
实现一个 RMI 对象
RMI 工具允许你调用在其他 JVM 中运行的对象上的操作。当你调用远程方法时,RMI 编译器创建的 RMI 存根将方法参数打包并通过网络发送到远程系统,远程系统将它们解包并调用远程方法。
假设你创建了一个 RMI 对象并将其注册到 RMI 注册表或 Java 命名和目录接口 (JNDI) 名称空间中。当远程客户端调用其方法之一时,该方法在哪个线程中执行?
实现 RMI 对象的常用方法是继承UnicastRemoteObject类,当一个 UnicastRemoteObject 被构造时,用于调度远程方法调用的基础设施被初始化。比如接收远程调用请求的套接字侦听器,以及执行远程请求的一个或多个线程。
因此,当你收到执行 RMI 方法的请求时,这些方法将在 RMI 管理器线程中执行。
总结
线程通过多种机制进入 Java 程序。 除了使用 Thread 构造函数显式创建线程之外,线程还可以通过多种其他机制创建:
- AWT 和 Swing
- RMI
java.util.TimerTask工具- Servlet 和 JSP 技术
共享数据访问
变量数据共享
如果多个线程的工作是有意义的,那代表着线程之间一定会有交互,这种交互方式可以是线程之间的相互通信,也可以是线程间共享他们的工作结果。
而共享工作结果最简单的方式就是共享变量,我们可以用同步帮助我们正确的的共享变量,避免一个线程在其他线程更新相关数据项时看到不一致的中间结果。
[线程基础]中计算素数的例子使用了一个共享的布尔变量来表示指定的时间已经过去,这展示了线程之间共享数据的最简单形式:轮询共享变量以查看另一个线程是否完成了某个任务。
所有线程共享内存空间
正如我们之前所讨论的,它们与同一进程中的其他线程共享相同的进程上下文,包括内存。这让我们可以轻松的共享变量来在线程间交换数据的同时,也督促我们在交换时必须保证访问变量过程受控,以免他们彼此影响。
任何线程都可以像主线程一样访问任何可访问的变量,素数示例使用一个名为“finished”的公共实例字段来指示指定的时间已经过去。当计时器到期时,一个线程给该字段赋值;另一个定期从此字段读取以检查它是否应该停止。请注意,该共享变量被声明为“volatile”,这对于该程序的正常运行很重要。我们将在本节后面看到原因。
通过同步实现可控的访问
Java 语言提供了两个关键字来确保可以以可控方式在线程之间共享数据:synchronized 和 volatile。
Synchronized 有两个重要意义:它确保一次只有一个线程执行一段受保护的代码(互斥或加锁),并确保一个线程更改的数据对其他线程可见(更改的可见性))。
如果没有同步,很容易让数据处于不一致的状态。例如,如果一个线程正在更新两个相关的值(例如,粒子的位置和速度),而另一个线程正在读取这两个值,另一个线程可能在第一个线程只改变1个值的时候读取这两个值,从而看到一个旧值和一个新值。同步允许我们定义必须原子地运行的代码块,在这些代码块中,对其他线程来说,它们要么全部执行,要么不执行。
确保对共享数据更改的可见性
同步使我们能够确保线程看到一致的内存视图。
处理器可以使用缓存来加速对内存的访问(或者编译器可以将值存储在寄存器中以加快访问速度)。在某些多处理器架构上,如果在一个处理器上的缓存中修改了内存位置,则在写入者的缓存被刷新和读取者的缓存失效之前,它不一定对其他处理器可见。
这意味着在这样的系统上,在两个不同处理器上执行的两个线程可能会看到同一个变量的两个不同值!这听起来很可怕,但它是正常运行的。这只是意味着你在访问其他线程使用或修改的数据时必须遵循一些规则。
Volatile 比同步更简单,仅适用于控制对原始变量的单个实例的访问——整数、布尔值等。当一个变量被声明为 volatile 时,对该变量的任何写入都将直接进入主内存,绕过缓存,而对该变量的任何读取都将直接来自主内存。这意味着所有线程看到“volatile”变量的值始终相同。
如果没有适当的同步,线程可能会看到变量的过去某一时刻的值。
受锁保护的原子代码块
Volatile 可用于确保每个线程看到变量的最新值,但有时我们需要保护对较大代码段的访问,例如涉及更新多个变量的代码段。
同步使用监视器或锁的概念来协调对特定代码块的访问。
每个 Java 对象都有一个与之关联的锁。 Java 锁一次只能由一个线程持有。当线程进入“同步”代码块时,线程会阻塞并等待锁可用,当锁可用时获取锁,然后执行代码块。无论是到达代码块的末尾还是抛出未在“同步”块中捕获的异常,只要程序退出受保护的代码块时,它都会释放锁定。
这样,一次只有一个线程可以执行由给定监视器保护的代码块。该代码块可以被认为是原子的,因为从其他线程的角度来看,它似乎要么完全执行,要么根本不执行。
一个简单的同步例子
使用同步控制代码块允许我们将多个变量更新作为一组执行,不必担心其他线程中断当前线程的计算任务,以下示例代码将打印“1 0”或“0 1”。 在没有同步的情况下,它也可以打印“1 1”(或者甚至“0 0”)。
public class SyncExample {
private static Object lockObject = new Object();
private static class Thread1 extends Thread {
public void run() {
synchronized (lockObject) {
x = y = 0;
System.out.println(x);
}
}
}
private static class Thread2 extends Thread {
public void run() {
synchronized (lockObject) {
x = y = 1;
System.out.println(y);
}
}
}
public static void main(String[] args) {
new Thread1().start();
new Thread2().start();
}
}
你必须在每个线程中使用同步才能使该程序正常工作。
java锁
一次只能有一个线程持有锁。 锁用于保护代码块或整个方法,需要注意的是,保护代码块的是锁的身份,而不是代码块本身。 一把锁可以保护许多代码块或方法。
相反,仅仅因为代码块受锁保护并不意味着两个线程不能同时执行该块。 这仅意味着如果两个线程正在等待同一个锁,则它们不能同时执行该块。
在下面的示例中,两个线程可以自由地同时执行 setLastAccess() 中的同步块,因为每个线程都有不同的 thingie 值。 因此,同步代码块在两个执行线程中受到不同锁的保护。
public class SyncExample {
public static class Thingie {
private Date lastAccess;
public synchronized void setLastAccess(Date date) {
this.lastAccess = date;
}
}
public static class MyThread extends Thread {
private Thingie thingie;
public MyThread(Thingie thingie) {
this.thingie = thingie;
}
public void run() {
thingie.setLastAccess(new Date());
}
}
public static void main() {
Thingie thingie1 = new Thingie(),
thingie2 = new Thingie();
new MyThread(thingie1).start();
new MyThread(thingie2).start();
}
}
同步方法
创建“同步”块的最简单方法是将方法声明为“同步”。 这意味着在进入方法体之前,调用者必须获得一个锁:
public class Point {
public synchronized void setXY(int x, int y) {
this.x = x;
this.y = y;
}
}
对于普通的“同步”方法,此锁将关联调用方法的对象。 对于静态“同步”方法,此锁是与声明方法的“类”对象关联的监视器。
仅仅因为setXY() 被声明为synchronized 并不意味着两个不同的线程仍然不能同时执行setXY(),只要它们在不同的”Point“对象实例。 一次只有一个线程可以在某个“Point”实例上执行“setXY()”或该对象的任何其他“同步”方法。
同步锁
同步代码块比同步方法稍微复杂一点,因为你要明确指定同步范围,如下。
public class Point {
public void setXY(int x, int y) {
synchronized (this) {
this.x = x;
this.y = y;
}
}
}
使用 this 引用作为锁是很常见的,但不是必需的。 这意味着该块将使用与该类中的“同步”方法相同的锁。
因为同步会阻止多个线程同时执行一个块,所以它对性能有影响,即使在单处理器系统上也是如此。 在需要保护的尽可能小的代码块周围使用同步是一种很好的做法。
对本地(基于堆栈的)变量的访问永远不需要受到保护,因为它们只能从主线程访问。
绝大多数类不使用同步
因为同步会带来一些的性能损失,大多数通用类,如java.util 中的 Collection 类,在内部不使用同步。这意味着在没有额外同步的情况下,不能从多个线程中使用像 HashMap 这样的类。
通过在每次访问共享集合中的方法时使用同步,你可以在多线程应用程序中使用 Collections 类。对于任何给定的集合,你每次都必须在同一个锁上进行同步。一个常见的锁选择是集合对象本身。
下面的示例类 SimpleCache 展示了如何使用 HashMap 以线程安全的方式提供缓存。然而,通常,正确的同步不仅仅意味着同步每个方法。
Collections 类为我们提供了一组用于 List、Map 和 Set 接口的便利包装器。你可以使用 Collections.synchronizedMap 包装一个 Map,它将确保对该地图的所有访问都正确同步。
如果一个类的文档没有说它是线程安全的,那么你必须假设它不是。
一个简单的线程安全缓存的例子
如下所示,SimpleCache.java 使用 HashMap 为对象加载器提供简单的缓存。 load() 方法知道如何通过key加载对象。 对象加载一次后,它会存储在缓存中,以便后续访问将从缓存中检索它,而不是每次都加载它。 对共享缓存的每次访问都受到“同步”块的保护。 因为它是正确同步的,所以多个线程可以同时调用 getObject 和 clearCache 方法而没有数据损坏的风险。
public class SimpleCache {
private final Map cache = new HashMap();
public Object load(String objectName) {
// load the object somehow
}
public void clearCache() {
synchronized (cache) {
cache.clear();
}
}
public Object getObject(String objectName) {
synchronized (cache) {
Object o = cache.get(objectName);
if (o == null) {
o = load(objectName);
cache.put(objectName, o);
}
}
return o;
}
}
总结
因为线程执行的时间是不确定的,所以我们需要小心控制线程对共享数据的访问。 否则,多个并发线程可能会干扰彼此的更改并导致数据损坏,以及共享数据的更改可能无法及时对其他线程可见。
通过使用同步来保护对共享变量的访问,我们可以确保线程以可预测的方式与程序变量交互。
每个 Java 对象都可以充当一个锁,“同步”块可以确保一次只有一个线程执行受给定锁保护的“同步”代码。
同步细节
在共享数据访问中,我们讨论了synchronized块的特性和 将它们描述为实现经典互斥(即互斥或临界区),其中一次只有一个线程可以执行由给定锁保护的块。
互斥是同步功能的重要组成部分,但同步还有其他几个对于在多处理器系统上获得正确结果很重要的特性。
可见性
除了互斥,同步之外,如“volatile”,强制执行某些可见性约束。当一个对象获得一个锁时,它首先使它的缓存失效,以保证它直接从主内存加载变量。
类似地,在一个对象释放锁之前,它会刷新其缓存,迫使所做的任何更改出现在主内存中。
通过这种方式,可以保证在同一个锁上同步的两个线程在“同步”块内修改的变量中看到相同的值。
什么时候必须使用同步
为了保持跨线程的正确可见性,你必须使用“synchronized”(或“volatile”)来确保一个线程所做的更改在多个线程之间共享非最终变量时对另一个线程可见。
同步可见性的基本规则是,在如下时刻,你必须同步:
- 读取可能已由另一个线程最后写入的变量
- 编写一个可能被另一个线程接下来读取的变量
一致性
除了同步可见性之外,你还必须同步以确保从应用程序的角度保持一致性。 修改多个相关值时,你希望其他线程以原子方式查看该组更改——要么全部更改,要么不查看。 这适用于相关数据项(例如粒子的位置和速度)和元数据项(例如包含在链表内的数据项或者一个链表数组的元素)。
考虑以下示例,它实现了一个简单的(但不是线程安全的)整数堆栈:
public class UnsafeStack {
public int top = 0;
public int[] values = new int[1000];
public void push(int n) {
values[top++] = n;
}
public int pop() {
return values[--top];
}
}
如果多个线程同时尝试使用这个类会发生什么? 这可能是一场灾难。 因为没有同步,多个线程可以同时执行 push() 和 pop()。 如果一个线程调用 push() 而另一个线程在 top 递增并且它被用作 values 的索引之间调用 push() 会怎样? 两个线程将它们的新值存储在同一个位置! 这只是多个线程依赖数据值之间的已知关系时可能发生的多种数据损坏形式中的一种,但不能确保在给定时间只有一个线程在操作这些值。
在这种情况下,解决方法很简单:同步 push() 和 pop(),这样你就可以防止一个线程干扰到另一个线程。
请注意,使用 volatile 还不够——你需要使用 synchronized 来确保 top 和 values 之间的关系保持一致。
递增一个多个线程共享的计数器
通常,如果你要保护单个原始变量,比如说保护一个整数,则只需使用“volatile”即可。 但是如果变量的新值是从以前的值派生的,则必须使用同步。考虑这个类:
public class Counter {
private int counter = 0;
public int get() { return counter; }
public void set(int n) { counter = n; }
public void increment() {
set(get() + 1);
}
}
当我们想要增加计数器时会发生什么?查看increment()的代码。显然它不是线程安全的。如果两个线程同时执行increment方法会发生什么?计数器可能会增加 1 或 2。即使我们将 counter 标记为 volatile 没有帮助,也不会使 get() 和 set() 同步。
假设计数器为零,并且两个线程完全同时执行增量代码。两个线程都调用了 Counter.get() 并看到计数器为零。现在两者都添加一个,然后调用Counter.set()。如果我们的时机不走运,即使 counter 是 volatile 或 get() 和 set() 是 synchronized,双方都不会看到对方的更新。现在,即使我们将计数器增加了两次,结果值也可能只是一而不是二。
为了使计数器增加这个工作正常运行,不仅get() 和set() 必须被synchronized,而且increment() 也需要synchronized!否则,调用 increment() 的线程可能会中断另一个调用 increment() 的线程。如果你不走运,最终结果将是计数器增加一次而不是两次。同步 increment() 可以防止这种情况发生,因为整个增量操作是原子的。
当你遍历 Vector 的元素时也是如此。即使 Vector 的方法是同步的,Vector 的内容在你迭代时仍然可能改变。如果你想确保 Vector 的内容在你遍历它时不会改变,你必须用同步来包装整个块。
不变性和被final修饰的字段
许多 Java 类,包括 String、Integer 和 BigDecimal,都是不可变的:一旦被构造,它们的状态就永远不会改变。 如果类的所有字段都声明为“final”,则该类将是不可变的。 (实际上,许多不可变类都有非 final 字段来缓存先前计算的方法结果,例如 String.hashCode() ,但这对调用者是不可见的。)
不可变类使并发编程变得更加简单。 因为它们的字段不会更改,所以你无需担心将状态更改从一个线程传播到另一个线程。 一旦对象被正确构造,它就可以被认为是常量。
同样,final 字段也对线程更友好。 因为 final 字段一旦初始化就不能改变它们的值,所以在跨线程共享 final 字段时不需要同步访问。
什么情况不需要使用同步
在某些情况下,你无需同步即可将数据从一个线程传播到另一个线程,因为 JVM 会隐式地为你执行同步。 这些案例包括:
- 当数据由静态初始化器(静态字段或
static{}块中的初始化器)初始化时 - 访问final字段时
- 在创建线程之前创建对象时
- 当一个对象已经对一个他稍后会加入的线程可见时
死锁
每当你有多个进程争用对多个锁的独占访问时,就有可能发生死锁。 当一组进程或线程都在等待只有一个进程或线程可以执行的操作时,就会说它是死锁的。
最常见的死锁形式是线程1持有对象A的锁并等待对象B的锁,线程2持有对象B的锁并等待对象A的锁。两个线程都不会获取 第二把锁或放弃第一把锁。 他们只会永远等待。
为避免死锁,应确保在获取多个锁时,在所有线程中始终以相同的顺序获取锁。
性能方面的考虑
关于同步时性能的文章,可以说是前人之述赘矣,甚至其中大多数还有错误。同步确实对性能有影响,但也不是那么大。
许多人通过使用花哨但无效的技巧来试图避免同步,从而使自己陷入困境。一个经典的例子是双重检查锁定模式(请参阅参考资料[但参考资料我在原网站没找到])。这种看似无害的构造旨在避免公共代码路径上的同步,但被巧妙地破坏了,并且所有修复它的尝试也被破坏了。
在编写并发代码时,在你真正看到性能问题的证据之前,不要太担心性能。瓶颈出现在我们通常最难猜到的地方。以牺牲程序正确性为代价,推测性地优化一个甚至可能不会成为性能问题的代码路径是杞人忧天。
同步使用指南
在编写“同步”块时,你可以遵循一些简单的指导,这些指导会帮助你避免死锁和性能危害的风险:
- 同步代码块尽可能小,在保证锁住相关变量的基础上,将前置和后置代码尽量移出去。
- 不要在同步代码块里写会造成阻塞的方法,比如
InputStream.read() - 不要在持有锁的时候调用其他方法,听起来有些极端,但可以大大降低死锁出现的概率。
线程API介绍
wait(), notify(),notifyAll()
除了使用会消耗大量 CPU 资源且具有不精确计时特性的轮询之外,“Object”类还包括多种线程方法,用于将事件从一个线程发送到另一个线程。
Object 类定义了方法 wait()、notify() 和 notifyAll()。要执行这些方法中的任何一个,你必须持有该对象的锁。
Wait() 导致调用线程休眠,直到它被 Thread.interrupt() 中断,指定的超时时间过去,或者另一个线程使用 notify() 或 notifyAll() 唤醒它。
当在一个对象上调用 notify() 时,如果有任何线程通过 wait() 在该对象上等待,那么一个线程将被唤醒。当对一个对象调用 notifyAll() 时,所有等待该对象的线程都将被唤醒。
这些方法是我们实现更加复杂的加锁,排队和并发代码的基石,然而,notify() 和 notifyAll() 的使用很复杂。尤其是使用 notify()是有风险的。除非你真的知道自己在做什么,否则请使用 notifyAll()。
与其使用 wait() 和 notify() 来编写自己的调度程序、线程池、队列和锁,你应该使用 util.concurrent 包,这是一个广泛使用的开源工具包,包含很多常用的并发实用程序。 JDK 1.5以上均包含java.util.concurrent 包;它的许多类都是从 util.concurrent 派生的。
线程优先级
Thread API 允许你为你的线程设置优先级,但这并不代表底层操作系统如此实现。有时候多个甚至所有的线程在操作系统层面都是同一优先级
许多人在遇到诸如死锁、饥饿或其他不期望出现的调度类的问题时,都倾向于修改线程优先级。然而,通常情况下,这并不能直接解决问题。大多数程序应该避免更改线程优先级。
线程组
线程组被设计用于将线程集合组织成组,然而事实证明用处不大,尽量避免使用它。
线程组确实具备一个线程类至今没有出现的特性:uncaughtException() 方法
当线程组中的线程因抛出未捕获的异常而退出时,将调用 ThreadGroup.uncaughtException() 方法。这使你有机会关闭系统、将消息写入日志文件或重新启动失败的服务。
SwingUtilities
尽管不属于Thread类的API,SwingUtilities还是可圈可点的。
Swing 应用程序有一个 UI 线程(有时称为事件线程),所有 UI 活动都必须在其中发生。SwingUtilities.invokeLater() 方法允许你向它传递一个 Runnable 对象,指定的 Runnable 将在事件线程中执行。它还具备invokeAndWait()方法,不同于invokeLater(),该方法在事件线程中调用 Runnable对象时,会阻塞直到Runnable执行完毕。
void showHelloThereDialog() throws Exception {
Runnable showModalDialog = new Runnable() {
public void run() {
JOptionPane.showMessageDialog(myMainFrame, "Hello There");
}
};
SwingUtilities.invokeLater(showModalDialog);
}
对于 AWT 应用程序,java.awt.EventQueue 也提供了 invokeLater() 和 invokeAndWait()。
总结
线程在Java程序中随处可见。如果你正在使用 Java UI 工具包(AWT 或 Swing)、Java Servlets、RMI 或 JavaServer Pages 或 Enterprise JavaBeans 技术,你可能在使用线程而没有意识到。
很多时候,你可能希望显式使用线程来提高程序的性能、响应能力或简化建模复杂度:
- 使用户界面在执行长任务时对用户响应更灵敏
- 利用多处理器系统并行处理多个任务
- 简化模拟或基于代理的系统的建模
- 执行异步或后台处理
虽然线程 API 很简单,但编写线程安全程序依旧有难度。当变量在线程之间共享时,你必须非常小心以确保你已正确同步对它们的读取和写入访问。当写入一个可能被另一个线程读取的变量,或者读取一个可能被另一个线程写入的变量时,你必须使用同步来确保对数据的更改跨线程可见。
然而仅仅使用同步是不够的,当使用同步来保护共享变量时,需要确保读取和写入在相同监听器上同步。此外如果一个线程中有多个变量需要同步,盲目鲁莽的使用同步可能会造成死锁。