编写多线程Java应用程序

当编写使用AWT或Swing的图形程序时,除了最琐碎的程序之外,其他所有程序都需要多线程。 线程编程带来了许多困难,许多开发人员经常发现自己容易陷入诸如错误的应用程序行为和死锁的应用程序之类的问题。

在本文中,我们将探讨与多线程相关的问题,并讨论最常见陷阱的解决方案。

什么是线程?

一个程序或进程可以包含多个线程,这些线程根据程序代码执行指令。 就像可以在一台计算机上运行的多个进程一样,多个线程似乎正在并行执行它们的工作。 它们在多处理器计算机上实现,实际上可以并行工作。 与进程不同,线程共享相同的地址空间。 也就是说,他们可以读写相同的变量和数据结构。

在编写多线程程序时,必须格外小心,不要让一个线程干扰任何其他线程的工作。 您可以将这种方法比作一个办公室,在该办公室中,工人需要独立且并行地工作,除非他们需要使用共享的办公室资源或彼此通信。 仅当另一名工人“在听”并且他们都说相同的语言时,他才可以与另一名工人说话。 此外,工作人员必须在免费且可用状态下使用复印机(没有半完成的复印作业,卡纸等)。 在阅读本文时,您将看到如何像在一个行为规范的组织中的工作人员一样,在Java程序中获得线程来进行协调和协作。

在多线程程序中,线程是从可用的准备运行线程池中获取的,并在可用的系统CPU上运行。 OS可以将线程从处理器移动到就绪队列或阻塞队列,在这种情况下,据说线程已“屈服”了处理器。 另外,Java虚拟机(JVM)可以管理线程(在合作模型或抢占模型下)从就绪队列到处理器的线程移动,在处理器中线程可以开始执行其程序代码。

协作线程允许线程决定何时将处理器放弃给其他等待线程。 应用程序开发人员确定线程何时会屈服于其他线程,从而使它们彼此之间非常有效地工作。 缺点是恶意线程或编写不当的线程可能会耗尽其他线程,同时又会消耗所有可用的CPU时间。

在抢占式线程模型下,操作系统通常会在允许线程运行一段时间(称为时间片)后随时中断线程。 结果,没有线程可以不公平地占用处理器。 但是,随时中断线程给程序开发人员带来了问题。 以我们的办公室为例,考虑如果一名工人抢占了另一名在复印作业中途进行复印的工人会发生的情况:新工人将在已经有玻璃原件或出纸盘中有复印件的机器上开始其复印工作。 抢占式线程模型要求线程适当地使用共享资源,而协作模型要求线程共享执行时间。 因为JVM规范不要求特定的线程模型,所以Java开发人员必须为这两种模型编写程序。 在仔细研究线程和线程之间的通信之后,我们将看到如何为这两种模型设计程序。

线程和Java语言

要使用Java语言创建线程,请实例化Thread (或子类)类型的对象并将其发送给start()消息。 (程序可以将start()消息发送到实现Runnable接口的任何对象。)每个线程的行为的定义包含在其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.
      }
   }
}

细粒度锁
通常,在对象级别使用锁定太粗糙了。 为什么要锁定整个对象,只允许短暂访问共享资源而不允许访问任何其他同步方法? 如果一个对象具有多个资源,则不必为了使一个线程仅使用线程资源的子集而将所有线程锁定在整个对象之外。 因为每个对象都有一个锁,所以我们可以将虚拟对象用作简单的锁,如下所示:

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(xlock) {
         synchronized(ylock) {
            //access both x and y here
         }
      }
      //do something here - but don't use shared resources
   }
}

这些方法不需要通过在整个方法级别上使用synced关键字声明整个方法来进行synchronized 他们使用的是成员锁,而不是同步方法获取的对象范围锁。

信号量

通常,几个线程将需要访问较少数量的资源。 例如,想象一下在Web服务器中运行的许多线程在回答客户端请求。 这些线程需要连接到数据库,但是只有固定数量的可用数据库连接可用。 您如何有效地将多个数据库连接分配给更大数量的线程? 控制对资源池的访问的一种方法(而不仅仅是使用简单的单线程锁)是使用所谓的计数信号量。 计数信号量封装了对可用资源池的管理。 信号量是在简单锁之上实现的,是一个线程安全计数器,已初始化为可使用的资源数量。 例如,我们将信号量初始化为可用的数据库连接数。 随着每个线程获取信号量,可用连接的数量减少一个。 在消耗资源后,将释放信号量,从而使计数器递增。 当信号量管理的所有资源都在使用中时,尝试获取信号量的线程只会阻塞,直到资源释放为止。

信号量的常见用法是解决“消费者—生产者问题”。 当一个线程完成另一线程将使用的工作时,会发生此问题。 消费线程只能在生产线程完成生成之后再获取更多数据。 要以这种方式使用信号量,请创建一个初始值为零的信号量,并使该信号量上具有消耗线程的线程。 对于完成的每个工作单元,生产线程都会发出信号(释放)信号量。 使用者每次使用一个数据单位并需要另一个数据单位时,都会尝试再次获取信号量,从而导致信号量的值始终是准备好要消费的已完成工作量的单位数。 这种方法比唤醒消耗线程,检查完成的工作以及在没有可用线程时进入睡眠状态更有效。

尽管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
   }
}

常见的锁定问题

不幸的是,使用锁带来许多问题。 让我们看一些常见的问题及其解决方案:

死锁
死锁是一个经典的多线程问题,其中所有工作都不完整,因为不同的线程正在等待永远不会释放的锁。 想象两个线程,它们代表两个饥饿的人,他们必须共享一把叉子和一把刀并轮流吃饭。 他们每个人都需要获得两个锁:一个用于共享fork资源,一个用于共享刀具资源。 想象一下,如果线程“ A”获得了刀,线程“ B”获得了叉子。 现在,线程A将阻塞等待叉,而线程B将阻塞等待刀,而线程A具有。 尽管是人为的示例,但这种情况经常发生,尽管在很难检测的情况下也是如此。 尽管在每种情况下都很难检测和散列,但是通过遵循以下几条规则,系统的设计可以避免出现死锁情况:

  • 让多个线程以相同的顺序获取一组锁。 此方法消除了X的所有者正在等待Y的所有者(正在等待X)的问题。
  • 将多个锁归为一个锁。 在我们的案例中,创建一个银器锁,必须在获取叉子或刀子之前将其获取。
  • 使用可读且无阻塞的变量标记资源。 获取银器锁之后,线程可以检查变量以查看是否有完整的银器集。 如果是这样,它可以获得相关的锁; 如果没有,它将释放主银器锁,然后再试一次。
  • 最重要的是,在编写代码之前,请彻底设计整个系统。 多线程很困难,在开始编写代码之前进行周密的设计将有助于避免难以发现的锁定问题。

易变变量
volatile关键字是作为优化编译器的一种方法引入该语言的。 以以下代码为例:

class VolatileTest {

   boolean flag;
   public void foo() {
      flag = false;
      if(flag) {
         //this could happen
      }
   }
}

一个优化的编译器可能会决定if语句的主体将永远不会执行,甚至不会编译代码。 如果该类被多个线程访问,则可以在先前的代码中设置了该flag之后,但在if语句中对其进行测试之前,另一个线程可以设置flag 使用volatile关键字声明变量会告诉编译器不要通过在编译时预测变量的值来优化代码段。

无法访问的线程
有时线程必须在对象锁以外的条件下阻塞。 IO是Java编程中此问题的最佳示例。 当对象内部的IO调用上阻塞线程时,该对象仍必须可被其他线程访问。 该对象通常负责取消阻塞的IO操作。 在同步方法中进行阻塞调用的线程通常会使此类任务无法实现。 如果该对象的其他方法也被同步,则该对象实际上在线程被阻塞时被冻结。 其他线程将无法向对象发送消息(例如,取消IO操作),因为它们无法获取对象锁。 确保不同步进行阻塞调用的代码,或确保具有同步阻塞代码的对象上存在非同步方法。 尽管此技术需要谨慎以确保结果代码仍然是线程安全的,但是当阻止持有其锁的线程被阻塞时,它允许对象响应其他线程。

针对不同的线程模型进行设计

线程模型是抢占式还是协作式的确定取决于虚拟机的实现者,并且可以在不同的实现中有所不同。 结果,Java开发人员必须编写在两种模型下均可运行的程序。

如前所述,在抢占式模型下,线程可以在代码的任何部分的中间中断,除了原子的代码块。 原子段是代码段,一旦启动,这些代码段将由当前线程完成,然后换出。 在Java编程中,分配给小于32位的变量是原子操作,它不包括doublelong类型的变量(均为64位)。 结果,原子操作不需要同步。 使用锁来正确同步对共享资源的访问足以确保多线程程序与抢占式虚拟机正常工作。

对于协作线程,程序员应确保线程例行放弃处理器,以使它们不会占用其他线程的执行时间。 一种实现方法是调用yield() ,它将当前线程移出处理器并移至就绪队列。 第二种方法是调用sleep() ,它使线程放弃处理器,并且直到经过sleep参数中指定的时间后才允许它运行。

如您所料,仅将这些调用放在代码中的任意点并不总是有效。 如果线程持有锁(因为它处于同步方法或代码块中),则在调用yield()时不会释放该锁。 这意味着,即使正在运行的线程已屈服于它们,其他等待相同锁的线程也不会运行。 要缓解此问题,请在不使用同步方法的情况下调用yield() 将要同步的代码放在非同步方法内的同步块中,并在该块外部调用yield()

另一种解决方案是调用wait() ,它使处理器放弃属于当前对象的锁。如果对象在方法级别同步,则此方法可以正常工作,因为它仅使用了那个锁。 如果使用的是细粒度锁,则wait()不会放弃这些锁。 此外,在调用wait()时被阻塞的线程不会唤醒,直到另一个线程调用notify() ,该线程将等待线程移至就绪队列。 要唤醒所有在wait()调用中阻塞的线程,一个线程调用notifyAll()

线程和AWT / Swing

在具有使用Swing和/或AWT的GUI的Java程序中,AWT事件处理程序在其自己的线程中运行。 开发人员必须小心不要占用此GUI线程来执行耗时的工作,因为它负责处理用户事件和重绘该GUI。 换句话说,每当GUI线程繁忙时,程序将显示为冻结状态。 Swing线程会(通过调用适当的方法)通知Swing回调,例如Mouse Listener和Action Listener。 这种方法意味着必须通过使侦听器回调方法产生另一个线程来执行侦听器,来执行侦听器要做的大量工作。 目的是使侦听器回调快速返回,从而允许Swing线程响应其他事件。

如果Swing线程异步运行,响应事件并重新绘制显示,那么其他线程如何安全地修改Swing状态? 正如我刚刚提到的,Swing回调在Swing线程中运行; 因此,他们可以安全地修改Swing数据并绘制到屏幕上。

但是,由于Swing回调而不会发生的其他更改呢? 让非SWING线程修改Swing数据不是线程安全的。 Swing提供了两种方法来解决此问题: invokeLater()invokeAndWait() 要修改Swing状态,只需调用这些方法之一,并传递一个可正常工作的Runnable对象。 因为Runnable对象通常是自己的线程,你可能会认为这对象催生了作为一个线程来执行。 实际上,事实并非如此,因为那样也不是线程安全的。 相反,Swing将此对象放在队列中,并在将来的任意时间执行其run方法。 这使得对Swing状态线程的更改安全。

Wrapping up

Java语言的设计使多线程对于除了最简单的applet之外的所有小程序都是必不可少的。 特别是,IO和GUI编程都需要多线程来为用户提供无缝的体验。 通过遵循本文概述的简单规则,并在开始编程之前彻底设计一个系统(包括对共享资源的访问),可以避免许多常见且难以检测的线程陷阱。


翻译自: https://www.ibm.com/developerworks/java/library/j-thread/index.html

你可能感兴趣的:(编写多线程Java应用程序)