编写多线程Java应用措施常见问题
副标题#e#
险些所有利用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 工具的职员就必需列队等待。
#p#分页标题#e#
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.
}
}
}
#p#副标题#e#
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 的拥有者别离期待对方的资源的问题。
将多个锁构成一组并放到同一个锁下。前面死锁的例子中,可以建设一个银器工具的锁。于是在得到刀或叉之前都必需得到这个银器的锁。
#p#分页标题#e#
将那些不会阻塞的可得到资源用变量符号出来。当某个线程得到银器工具的锁时,就可以通过查抄变量来判定是否整个银器荟萃中的工具锁都可得到。假如是,它就可以得到相关的锁,不然,就要释放掉银器这个锁并稍后再实验。
最重要的是,在编写代码前当真仔细地设计整个系统。多线程是坚苦的,在开始编程之前具体设计系统可以或许辅佐你制止难以发明死锁的问题。
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 线程响应其他事件。
#p#分页标题#e#
假如一个 Swing 线程不可以或许同步运行、响应事件并重绘输出,那怎么可以或许让其他的线程安详地修改 Swing 的状态?正如上面提到的,Swing callback 在 Swing 线程中运行。因此他们能修改 Swing 数据并绘到屏幕上。
可是假如不是 Swing callback 发生的变革该怎么办呢?利用一个非 Swing 线程来修改 Swing 数据是不安详的。Swing 提供了两个要领来办理这个问题:invokeLater() 和 invokeAndWait()。为了修改 Swing 状态,只要简朴地挪用个中一个要领,让 Runnable 的工具来做这些事情。因为 Runnable 工具凡是就是它们自身的线程,你大概会认为这些工具会作为线程来执行。但那样做其实也是不安详的。事实上,Swing 会将这些工具放到行列中,并在未来某个时刻执行它的 run 要领。这样才气够安详修改 Swing 状态。
总结
Java 语言的设计,使得多线程对险些所有的 Applet 都是须要的。出格是,IO 和 GUI 编程都需要多线程来为用户提供完美的体验。假如依照本文所提到的若干根基法则,并在开始编程前仔细设计系统包罗它对共享资源的会见等,你就可以制止很多常见和难以觉察的线程陷阱。