编写多线程Java应用程序常见问题
几乎所有使用 AWT 或 Swing 编写的画图程序都需要多线程。但多线程程序会造成许多困难,刚开始编程的开发者常常会发现他们被一些问题所折磨,例如不正确的程序行为或死锁。
??在本文中,我们将探讨使用多线程时遇到的问题,并提出那些常见陷阱的解决方案。
??线程是什么?
??一个程序或进程能够包含多个线程,这些线程可以根据程序的代码执行相应的指令。多线程看上去似乎在并行执行它们各自的工作,就像在一台计算机上运行着多个处理机一样。在多处理机计算机上实现多线程时,它们确实可以并行工作。和进程不同的是,线程共享地址空间。也就是说,多个线程能够读写相同的变量或数据结构。
??编写多线程程序时,你必须注意每个线程是否干扰了其他线程的工作。可以将程序看作一个办公室,如果不需要共享办公室资源或与其他人交流,所有职员就会独立并行地工作。某个职员若要和其他人交谈,当且仅当该职员在“听”且他们两说同样的语言。此外,只有在复印机空闲且处于可用状态(没有仅完成一半的复印工作,没有纸张阻塞等问题)时,职员才能够使用它。在这篇文章中你将看到,在 Java 程序中互相协作的线程就好像是在一个组织良好的机构中工作的职员。
??在多线程程序中,线程可以从准备就绪队列中得到,并在可获得的系统 CPU 上运行。操作系统可以将线程从处理器移到准备就绪队列或阻塞队列中,这种情况可以认为是处理器“挂起”了该线程。同样,Java 虚拟机 (JVM) 也可以控制线程的移动——在协作或抢先模型中——从准备就绪队列中将进程移到处理器中,于是该线程就可以开始执行它的程序代码。
??协作式线程模型允许线程自己决定什么时候放弃处理器来等待其他的线程。程序开发员可以精确地决定某个线程何时会被其他线程挂起,允许它们与对方有效地合作。缺点在于某些恶意或是写得不好的线程会消耗所有可获得的 CPU 时间,导致其他线程“饥饿”。
??在抢占式线程模型中,操作系统可以在任何时候打断线程。通常会在它运行了一段时间(就是所谓的一个时间片)后才打断它。这样的结果自然是没有线程能够不公平地长时间霸占处理器。然而,随时可能打断线程就会给程序开发员带来其他麻烦。同样使用办公室的例子,假设某个职员抢在另一人前使用复印机,但打印工作在未完成的时候离开了,另一人接着使用复印机时,该复印机上可能就还有先前那名职员留下来的资料。抢占式线程模型要求线程正确共享资源,协作式模型却要求线程共享执行时间。由于 JVM 规范并没有特别规定线程模型,Java 开发员必须编写可在两种模型上正确运行的程序。在了解线程以及线程间通讯的一些方面之后,我们可以看到如何为这两种模型设计程序。
??线程和 Java 语言
??为了使用 Java 语言创建线程,你可以生成一个 Thread 类(或其子类)的对象,并给这个对象发送 start() 消息。(程序可以向任何一个派生自 Runnable 接口的类对象发送 start() 消息。)每个线程动作的定义包含在该线程对象的 run() 方法中。run 方法就相当于传统程序中的 main() 方法;线程会持续运行,直到 run() 返回为止,此时该线程便死了。
??上锁
??大多数应用程序要求线程互相通信来同步它们的动作。在 Java 程序中最简单实现同步的方法就是上锁。为了防止同时访问共享资源,线程在使用资源的前后可以给该资源上锁和开锁。假想给复印机上锁,任一时刻只有一个职员拥有钥匙。若没有钥匙就不能使用复印机。给共享变量上锁就使得 Java 线程能够快速方便地通信和同步。某个线程若给一个对象上了锁,就可以知道没有其他线程能够访问该对象。即使在抢占式模型中,其他线程也不能够访问此对象,直到上锁的线程被唤醒、完成工作并开锁。那些试图访问一个上锁对象的线程通常会进入睡眠状态,直到上锁的线程开锁。一旦锁被打开,这些睡眠进程就会被唤醒并移到准备就绪队列中。
??在 Java 编程中,所有的对象都有锁。线程可以使用 synchronized 关键字来获得锁。在任一时刻对于给定的类的实例,方法或同步的代码块只能被一个线程执行。这是因为代码在执行之前要求获得对象的锁。继续我们关于复印机的比喻,为了避免复印冲突,我们可以简单地对复印资源实行同步。如同下列的代码例子,任一时刻只允许一位职员使用复印资源。通过使用方法(在 Copier 对象中)来修改复印机状态。这个方法就是同步方法。只有一个线程能够执行一个 Copier 对象中同步代码,因此那些需要使用 Copier 对象的职员就必须排队等候。
class CopyMachine {
public synchronized void makeCopies(Document d, int nCopies) {
// only one thread executes this at a time
}
public void loadPaper() {
// multiple threads could access this at once!
synchronized(this) {
// only one thread accesses this at a time
// feel free to use shared resources, overwrite members, etc.
}
}
}
??Fine-grain 锁
??在对象级使用锁通常是一种比较粗糙的方法。为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:
class FineGrainLock {
MyMemberClass x, y;
Object xlock = new Object(), ylock = new Object();
public void foo() {
synchronized(xlock) {
// access x here
}
// do something here - but don't use shared resources
synchronized(ylock) {
// access y here
}
}
public void bar() {
synchronized(this) {
// access both x and y here
}
// do something here - but don't use shared resources
}
}
??若为了在方法级上同步,不能将整个方法声明为 synchronized 关键字。它们使用的是成员锁,而不是 synchronized 方法能够获得的对象级锁。
??信号量
??通常情况下,可能有多个线程需要访问数目很少的资源。假想在服务器上运行着若干个回答客户端请求的线程。这些线程需要连接到同一数据库,但任一时刻只能获得一定数目的数据库连接。你要怎样才能够有效地将这些固定数目的数据库连接分配给大量的线程?一种控制访问一组资源的方法(除了简单地上锁之外),就是使用众所周知的信号量计数 (counting semaphore)。信号量计数将一组可获得资源的管理封装起来。信号量是在简单上锁的基础上实现的,相当于能令线程安全执行,并初始化为可用资源个数的计数器。例如我们可以将一个信号量初始化为可获得的数据库连接个数。一旦某个线程获得了信号量,可获得的数据库连接数减一。线程消耗完资源并释放该资源时,计数器就会加一。当信号量控制的所有资源都已被占用时,若有线程试图访问此信号量,则会进入阻塞状态,直到有可用资源被释放。
??信号量最常见的用法是解决“消费者-生产者问题”。当一个线程进行工作时,若另外一个线程访问同一共享变量,就可能产生此问题。消费者线程只能在生产者线程完成生产后才能够访问数据。使用信号量来解决这个问题,就需要创建一个初始化为零的信号量,从而让消费者线程访问此信号量时发生阻塞。每当完成单位工作时,生产者线程就会向该信号量发信号(释放资源)。每当消费者线程消费了单位生产结果并需要新的数据单元时,它就会试图再次获取信号量。因此信号量的值就总是等于生产完毕可供消费的数据单元数。这种方法比采用消费者线程不停检查是否有可用数据单元的方法要高效得多。因为消费者线程醒来后,倘若没有找到可用的数据单元,就会再度进入睡眠状态,这样的操作系统开销是非常昂贵的。
??尽管信号量并未直接被 Java 语言所支持,却很容易在给对象上锁的基础上实现。一个简单的实现方法如下所示:
class Semaphore {
private int count;
public Semaphore(int n) {
this.count = n;
}
public synchronized void acquire() {
while(count == 0) {
try {
wait();
} catch (InterruptedException e) {
// keep trying
}
}
count--;
}
public synchronized void release() {
count ;
notify(); // alert a thread that's blocking on this semaphore
}
}
??常见的上锁问题
??不幸的是,使用上锁会带来其他问题。让我们来看一些常见问题以及相应的解决方法:
??死锁。死锁是一个经典的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。假如线程 "A" 获得了刀,而线程 "B" 获得了叉。线程 A 就会进入阻塞状态来等待获得叉,而线程 B 则阻塞来等待 A 所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,这类情况却时常发生。虽然要探测或推敲各种情况是非常困难的,但只要按照下面几条规则去设计系统,就能够避免死锁问题:
??让所有的线程按照同样的顺序获得一组锁。这种方法消除了 X 和 Y 的拥有者分别等待对方的资源的问题。
??将多个锁组成一组并放到同一个锁下。前面死锁的例子中,可以创建一个银器对象的锁。于是在获得刀或叉之前都必须获得这个银器的锁。
??将那些不会阻塞的可获得资源用变量标志出来。当某个线程获得银器对象的锁时,就可以通过检查变量来判断是否整个银器集合中的对象锁都可获得。如果是,它就可以获得相关的锁,否则,就要释放掉银器这个锁并稍后再尝试。
??最重要的是,在编写代码前认真仔细地设计整个系统。多线程是困难的,在开始编程之前详细设计系统能够帮助你避免难以发现死锁的问题。
??Volatile 变量. volatile 关键字是 Java 语言为优化编译器设计的。以下面的代码为例:
class VolatileTest {
public void foo() {
boolean flag = false;
if(flag) {
// this could happen
}
}
}
??一个优化的编译器可能会判断出 if 部分的语句永远不会被执行,就根本不会编译这部分的代码。如果这个类被多线程访问,flag 被前面某个线程设置之后,在它被 if 语句测试之前,可以被其他线程重新设置。用 volatile 关键字来声明变量,就可以告诉编译器在编译的时候,不需要通过预测变量值来优化这部分的代码。
??无法访问的线程 有时候虽然获取对象锁没有问题,线程依然有可能进入阻塞状态。在 Java 编程中 IO 就是这类问题最好的例子。当线程因为对象内的 IO 调用而阻塞时,此对象应当仍能被其他线程访问。该对象通常有责任取消这个阻塞的 IO 操作。造成阻塞调用的线程常常会令同步任务失败。如果该对象的其他方法也是同步的,当线程被阻塞时,此对象也就相当于被冷冻住了。其他的线程由于不能获得对象的锁,就不能给此对象发消息(例如,取消 IO 操作)。必须确保不在同步代码中包含那些阻塞调用,或确认在一个用同步阻塞代码的对象中存在非同步方法。尽管这种方法需要花费一些注意力来保证结果代码安全运行,但它允许在拥有对象的线程发生阻塞后,该对象仍能够响应其他线程。
??为不同的线程模型进行设计
??判断是抢占式还是协作式的线程模型,取决于虚拟机的实现者,并根据各种实现而不同。因此,Java 开发员必须编写那些能够在两种模型上工作的程序。
??正如前面所提到的,在抢占式模型中线程可以在代码的任何一个部分的中间被打断,除非那是一个原子操作代码块。原子操作代码块中的代码段一旦开始执行,就要在该线程被换出处理器之前执行完毕。在 Java 编程中,分配一个小于 32 位的变量空间是一种原子操作,而此外象 double 和 long 这两个 64 位数据类型的分配就不是原子的。使用锁来正确同步共享资源的访问,就足以保证一个多线程程序在抢占式模型下正确工作。
??而在协作式模型中,是否能保证线程正常放弃处理器,不掠夺其他线程的执行时间,则完全取决于程序员。调用 yield() 方法能够将当前的线程从处理器中移出到准备就绪队列中。另一个方法则是调用 sleep() 方法,使线程放弃处理器,并且在 sleep 方法中指定的时间间隔内睡眠。
??正如你所想的那样,将这些方法随意放在代码的某个地方,并不能够保证正常工作。如果线程正拥有一个锁(因为它在一个同步方法或代码块中),则当它调用 yield() 时不能够释放这个锁。这就意味着即使这个线程已经被挂起,等待这个锁释放的其他线程依然不能继续运行。为了缓解这个问题,最好不在同步方法中调用 yield 方法。将那些需要同步的代码包在一个同步块中,里面不含有非同步的方法,并且在这些同步代码块之外才调用 yield。
??另外一个解决方法则是调用 wait() 方法,使处理器放弃它当前拥有的对象的锁。如果对象在方法级别上使同步的,这种方法能够很好的工作。因为它仅仅使用了一个锁。如果它使用 fine-grained 锁,则 wait() 将无法放弃这些锁。此外,一个因为调用 wait() 方法而阻塞的线程,只有当其他线程调用 notifyAll() 时才会被唤醒。
??线程和AWT/Swing
??在那些使用 Swing 和/或 AWT 包创建 GUI (用户图形界面)的 Java 程序中,AWT 事件句柄在它自己的线程中运行。开发员必须注意避免将这些 GUI 线程与较耗时间的计算工作绑在一起,因为这些线程必须负责处理用户时间并重绘用户图形界面。换句话来说,一旦 GUI 线程处于繁忙,整个程序看起来就象无响应状态。Swing 线程通过调用合适方法,通知那些 Swing callback (例如 Mouse Listener 和 Action Listener )。 这种方法意味着 listener 无论要做多少事情,都应当利用 listener callback 方法产生其他线程来完成此项工作。目的便在于让 listener callback 更快速返回,从而允许 Swing 线程响应其他事件。
??如果一个 Swing 线程不能够同步运行、响应事件并重绘输出,那怎么能够让其他的线程安全地修改 Swing 的状态?正如上面提到的,Swing callback 在 Swing 线程中运行。因此他们能修改 Swing 数据并绘到屏幕上。
??但是如果不是 Swing callback 产生的变化该怎么办呢?使用一个非 Swing 线程来修改 Swing 数据是不安全的。Swing 提供了两个方法来解决这个问题:invokeLater() 和 invokeAndWait()。为了修改 Swing 状态,只要简单地调用其中一个方法,让 Runnable 的对象来做这些工作。因为 Runnable 对象通常就是它们自身的线程,你可能会认为这些对象会作为线程来执行。但那样做其实也是不安全的。事实上,Swing 会将这些对象放到队列中,并在将来某个时刻执行它的 run 方法。这样才能够安全修改 Swing 状态。
??总结
??Java 语言的设计,使得多线程对几乎所有的 Applet 都是必要的。特别是,IO 和 GUI 编程都需要多线程来为用户提供完美的体验。如果依照本文所提到的若干基本规则,并在开始编程前仔细设计系统——包括它对共享资源的访问等,你就可以避免许多常见和难以发觉的线程陷阱。
分享到:
相关推荐
本文将详细介绍如何安全地编写多线程Java应用程序,避免最常见的问题,并提供一些实用的解决方案。 #### 二、多线程基础 **1. 线程概念** - **定义**: 线程是程序执行流的最小单元,是进程内的一个执行实体。一个...
总之,编写多线程Java应用程序需要深入理解线程的概念和Java提供的并发工具。开发者必须谨慎处理线程间的交互,避免数据不一致性、竞态条件和死锁。通过合理地使用同步机制和并发工具,可以创建出高效且可靠的多线程...
### 如何使用Java编写多线程程序 #### 一、简介 ##### 1.1 什么是线程? 在深入探讨Java中的多线程编程之前,我们需要先了解什么是线程。线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程...
总之,掌握Java多线程的生命周期、创建、启动、同步以及线程池的使用是编写高效、稳定并发程序的基础。理解这些知识点对于解决并发编程中的问题,比如资源竞争、死锁、线程安全性等问题,至关重要。在实际开发中,...
**JAVA多线程端口扫描器** 在计算机网络中,端口扫描是一种常见的技术,用于检测目标主机上开放的服务和应用程序。此项目是基于Java语言实现的多线程端口扫描器,它允许用户对本地系统或指定的远程IP地址进行快速...
在Java编程中,多线程是一项...通过这个实验,学生不仅能够掌握Java多线程的基本概念,还能了解到如何将多线程应用于实际问题中,比如网络聊天应用程序。这对于进一步学习Java的并发编程和大型系统设计是非常有益的。
理解并熟练掌握这些知识对于编写高效、稳定的多线程Java应用至关重要。通过阅读附带的源码和中文教程,你可以更深入地学习如何在实际项目中应用这些概念和技术。记住,实践是检验真理的唯一标准,尝试编写多线程代码...
以上就是Java多线程设计模式的一些关键知识点,理解并熟练运用这些模式,将有助于编写出高效、稳定、易于维护的多线程程序。通过阅读《java多线程设计模式》这本书,你可以深入理解这些概念,并学习到更多实际应用的...
Java多线程技术是Java编程中的重要组成部分,它允许程序同时执行多个任务,极大地提高了程序的效率和响应性。在现代计算机系统中,多线程是实现并发处理的关键技术,尤其在服务器端应用和高性能计算中不可或缺。 ...
总之,Java多线程是构建高性能并发应用的基础,理解并掌握线程的创建、同步、通信、协作模式以及异常处理,对于编写高效、稳定的Java程序至关重要。在实际开发中,结合Java提供的工具和设计模式,能够更好地解决多...
值得注意的是,并非所有类型的计算机都支持多线程应用程序,但Java程序设计语言将线程支持与语言运行环境紧密结合,提供了多任务并发执行的能力。 #### Java语言的多线程程序设计方法 Java语言提供了一种简单的...
本习题集收集了Java程序编写常见问题,并以习题形式总结和归纳Java程序设计知识要点,同时兼顾了SCJP(Sun公司 Java程序员认证考试),从而降低学习难度,减少编写程序出现错误的枘地,也便于掌握各个知识要点或测试...
Java多线程是Java编程中的核心概念,尤其在开发高性能、高并发的应用时,多线程技术显得尤为重要。本系列视频教程将深入讲解Java多线程的各个方面,帮助你掌握如何在Java环境中创建、管理以及优化多线程程序。 首先...
Java多线程是Java编程中的重要概念,它允许...理解并掌握Java多线程下变量共享的原理和解决方案,有助于编写出高效、稳定的并发程序。在实际开发中,应结合具体业务场景选择合适的同步机制,以达到最佳性能和安全性。
但考虑到描述中提到了电子书的标题以及它涉及的主题,我们可以从标题“Java多线程”出发,详细阐述Java多线程编程的核心概念、技术细节、应用场景和常见问题处理。 Java多线程是一种允许多个线程在单个Java程序中...
Java多线程设计模式是Java开发中的重要...深入学习并熟练运用这些模式,将有助于你编写出更高效、稳定和易于扩展的多线程应用程序。这个PDF版教程和源代码集合是你学习多线程设计模式的理想资源,值得仔细研读和实践。
在Java编程领域,客户端-服务器(Client-Server)程序是一种常见的架构模式,它涉及两个主要组件:客户端应用程序和服务器端应用程序。在这个“Java客户端服务器程序学习笔记”中,我们将深入探讨这一主题,包括如何...
Java多线程程序设计是Java开发中的重要领域,它允许应用程序同时执行多个任务,从而提高系统资源利用率和程序性能。本教程将深入探讨Java多线程的各个方面,旨在帮助开发者熟练掌握这一关键技术。 首先,我们要了解...
在Java中,使用多线程非常普遍,下面详细介绍两种常见的实现方式: 1. **继承Thread类** 创建一个新的线程最直接的方式是通过继承`Thread`类,并重写其`run`方法。当线程被启动时,`run`方法将被执行。示例代码...
Java线程编程是Java开发中的重要组成部分,尤其在构建高性能、高并发的应用程序时不可或缺。...通过学习本资料,开发者可以深入理解如何在Java中有效地利用多线程,编写出更加高效和可靠的并发程序。