当前位置: 首页 > 图灵资讯 > 技术篇> 编写多线程的 Java 应用程序 如何避免当前编程中最常见的问题

编写多线程的 Java 应用程序 如何避免当前编程中最常见的问题

来源:图灵教育
时间:2024-02-28 17:14:28

几乎所有的使用 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 2 平台上的 API 规范说明书(1.3 版标准):Java 2 API 文档. 更多关于 JVM 可以参考线程和上锁处理的信息 Java 虚拟机规范说明书. Allen Holub 的 Taming Java Threads (APress, June 2000) 这是一本极好的参考书 也许你还想读书 Allen 的文章 如果我是国王:关于解决方案 Java 编程语言线程的建议 (developerWorks, October 2000), 它阐述了一些被他称为“伟大语言中最弱的地方”的问题。