GUI,Graphical User Interface,图像用户界面,也称图形用户接口
几乎所有的GUI工具包(包括Swing和SWT)都被实现为单线程子系统,这意味着所有的GUI操作都被限制在单个程序中。如果你不打算编写一个单线程程序,那么就会有部分操作在一个应用程序线程中执行,而其他操作则在事件线程中执行。
9.1 为什么GUI是单线程
当前的GUI框架使用了这种模型:在该模型中创建一个专门事件分发线程(Event Dispatch Thread,EDT)来处理GUI事件。
GUI框架都是单线程的事件队列模型:采用一个专门的线程从队列中抽取事件,并将它们转发到应用程序定义的事件处理器。
多线程的GUI框架更容易发生死锁问题,其部分原因在于,在输入事件的处理过程与GUI组件的面向对象模型之间会存在错误的交互。
用户引发的动作将通过一种类似与“气泡上升”的方式从操作系统传递给应用程序——操作系统首先检测到一次鼠标点击,然后通过工具包将其转化为“鼠标点击”事件,该事件最终被转换为一个更高层事件(例如“鼠标被按下”事件)转发给应用程序的监听器。另一方面,应用程序引发的动作又会以“气泡下沉”的方式从应用程序返回到操作系统。例如在应用程序中引发修改某个组件背景色的请求,该请求将转发给某个特定的组件类,并最终转发给操作系统进行绘制。
因此,一方面这组操作将以完全相反的顺序来访问相同的GUI对象;另一方面又要确保每个对象都是线程安全的,从而导致不一致额锁定顺序,并引发死锁。
另一个在多线程GUI框架中导致死锁的原因就是“模型——视图——控制(MVC)”这种设计模式。”控制”模块将调用”模型”模块,而”模型”模块将发生的变化通知给”视图”模块。”控制”模块同样可以调用”视图”模块,并调用”模型”模块来查询模型的状态,这将再次导致不一致的锁定顺序并出现死锁。
单线程的GUI框架通过线程封闭机制来实现线程安全性。
9.1.1 串行事件处理(Sequential Event Processing)
GUI应用程序需要处理一些细粒度的事件,例如点击鼠标,按下键盘或定时器超时等。事件是另一种类型的任务,而AWT和Swing提供的事件处理机制在结构上也类似与Executor。
因为只有单个线程来处理所有的GUI任务,因此会采用依次处理的方式。
串行任务处理不利之处在于,如果某个任务执行时间很长,那么其他任务必须等到该任务执行结束。
在事件线程中执行的任务必须尽快地把控制权交还给事件线程。要启动一些执行时间较长的任务,例如对某个大型文档执行拼写检查,或者通过网络获取资源等,必须在另一个线程中执行这些任务,从而尽快地将控制权交换给事件线程。
9.1.2 Swing中的线程封闭机制
所有Swing组件和数据模型对象都被封闭在事件线程中,因此任何访问它们的代码都必须在事件线程中运行。
GUI对象并非通过同步来确保一致性,而是通过线程封闭机制。好处是当 访问表现对象(Presentation Object)时在事件线程中运行的任务无需担心同步问题,坏处在于,无法从事件线程之外的线程中访问表现对象。
Swing的单线程规则是:Swing中的组件以及模型只能在这个事件分发线程中进行创建,修改以及查询。
单线程规则的一些例外情况包括:
①SwingUtilities.isEventDispatchThread,用于判断当前线程是否是事件线程
②SwingUtilities.invokeLater,该方法可以将一个Runnable任务调度到事件线程中执行(可以从任意线程中调用)
③SwingUtilities.invokeAndWait,该方法可以将一个Runnable任务调度到事件线程中执行,并阻塞当前线程直到任务完成(只能从非GUI线程中调用)
④所有将重绘(Repaint)请求或重生效(revalidation)请求插入队列的方法(可以从任意线程中调用)
⑤所有添加或移除监听器的方法(这些方法可以从任务线程中调用,但监听器本身一定要在事件线程中调用)
invokeLater和invokeAndWait两个方法的作用酷似Executor。事实上,用单线程Executor来实现SwingUtilities中与线程相关的方法是容易的,如9-1。这并非SwingUtilities的真正实现,因为Swing的框架要比Executor框架出现得早,但如果现在来实现Swing,或许应该采用这种方式。
// 9-1 使用Executor来实现SwingUtilities
public class SwingUtilities {
// 没添加参数则用默认的线程工厂方法创建线程
private static final ExecutorService exec =
Executors.newSingleThreadExecutor(new SwingThreadFactory());
private static volatile Thread swingThread;
//可以将Swing的事件线程视为一个单线程的Executor,它处理来自事件队列的任务。
private static class SwingThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
swingThread = new Thread(r);
return swingThread;
}
}
public static boolean isEventDispatchThread() {
//返回对当前正在执行的线程对象的引用,用于判断当前线程是否是事件线程
return Thread.currentThread() == swingThread;
}
//该方法可以将一个Runnable任务调度到事件线程中执行(可以从任意线程中调用)
public static void invokeLater(Runnable task) {
exec.execute(task);
}
//该方法可以将一个Runnable任务调度到事件线程中执行,并阻塞当前线程直到任务完成(只能从非GUI线程中调用)
public static void invokeAndWait(Runnable task)
throws InterruptedException, InvocationTargetException {
Future f = exec.submit(task);
try {
f.get();
} catch (ExecutionException e) {
//由Method.invoke(obj, args...)方法抛出。当被调用的方法的内部抛出了异常而没有被捕获时,将由此异常接收。
throw new InvocationTargetException(e);
}
}
}
可以将Swing的事件线程视为一个单线程的Executor,它处理来自事件队列的任务。
如果所有任务的执行时间都很短,或者任务调度的可预见性并不重要,又或者任务不能被并发执行,那么应该采用串行的和单线程的执行策略。
9-2中的GuiExecutor是一个Executor,它将任务委托给SwingUtilities来执行,也可以采用其他的GUI框架来实现它,例如SWT提供的Display.asyncExec方法,它类似与Swing中的invokeLater
GuiExecutor代表着事件线程
// 9-2 基于SwingUtilities构建的Executor
public class GuiExecutor extends AbstractExecutorService{
//采用"单件(Singleton)"模式,有一个私有构造函数和一个公有的工厂方法
private static final GuiExecutor instance=new GuiExecutor();
//构造方法是私有的,所以在类外不能new出多个实例
private GuiExecutor(){
}
//公有的工厂方法
public static GuiExecutor instance(){
return instance;
}
public void execute(Runnable r){
if(SwingUtilities.isEventDispatchThread())
r.run();
else
SwingUtilities.invokeLater(r);
}
public void shutdown() {
throw new UnsupportedOperationException();
}
public List<Runnable> shutdownNow() {
throw new UnsupportedOperationException();
}
public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
throw new UnsupportedOperationException();
}
public boolean isShutdown() {
return false;
}
public boolean isTerminated() {
return false;
}
}
9.2 短时间的GUI任务
在GUI应用程序中,事件在事件线程中,并通过“气泡上升”的方式传递给应用程序提供的监听器,而监听器则根据收到的时间执行一些计算来修改表现对象。
为了简便,短时间的任务可以把整个操作都放到事件线程中执行,而长时间的任务,则应该将某些操作放到另一个线程中执行。
在这种情况下,表现对象封闭在事件线程中。9-3创建了一个按钮,它的颜色在被按下时会随机的变化。
// 9-3 简单的事件监听器
final Random random = new Random();
final JButton button = new JButton("Change Color");
...
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
button.setBackground(new Color(random.nextInt()));
}
});
当用户点击按钮时,工具包将事件线程中的一个ActionEvent投递给所有已注册的ActionListener。
作为响应,ActionListener将选择一个新的颜色,并将按钮的背景颜色设置为这个新颜色。
这样,在GUI工具包中产生了事件,然后发送到应用程序,而应用程序则通过GUI来响应用户的动作。
这期间,执行控制始终不会离开事件线程。
这个示例揭示了应用程序和GUI工具包之间的主要交互。只要任务是短期的,并且只访问GUI对象(或者其他线程封闭或线程安全的应用程序对象),那么就可以基本忽略与线程相关的问题,而在事件线程中执行任何操作都不会出问题。
图9-2,其中使用了正式的数据模型,例如TableModel或TreeModel。
Swing将大多数可视化组件都分为两个对象,即模型对象与视图对象。在模型对象中保存的的是将被显示的数据,而在视图对象中则保存了控制显示的规则。模型对象可以通过引发事件来表示模型数据发生了变化,而视图对象则通过“订阅”来接收这些事件。当视图对象收到表示模型数据已发生变化的事件时,将想模型对象查询新的数据,并更新页面显示。
因此,在一个修改表格内容的按钮监听器中,事件监听器将更新模型并调用其中的一个fireXxx方法,这个方法会依次调用视图对象中表格模型监听器,从而更新视图的显示。同样,执行控制权仍然不会离开事件线程。(Swing数据模型的fireXxx方法通常会直接调用模型监听器,而不会向线程队列中提交新的事件,因此fireXxx方法只能从事件线程中调用)
9.3 长时间的GUI任务
执行时间较长的任务必须在另一个线程中运行,才能使得GUI在运行时保存高响应性。
可以创建自己的Executor来执行长时间的任务。对于长时间的任务,可以使用缓存线程池。只有GUI应用程序很少会发起大量的长时间任务,因此即使线程池可以无限制增长也不会有太大的风险。
首先来看一个简单的任务,该任务不支持取消操作和进度指示,也不会爱完成后更新GUI,我们之后再将这些功能一次添加进来。9-4给出了一个与某个可视化组件绑定的监听器,它将一个长时间任务提交一个Executor。尽管有两个层次的内部类,但通过这个方式使某个GUI任务启动另一个任务还是很简单的:在事件线程中调用UI动作监听类,然后将一个Runnable提交到线程池中执行。
// 9-4 将一个长时间任务绑定到一个可视化组件
ExecutorService backgroundExec = Executors.newCachedThreadPool();
...
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
backgroundExec.execute(new Runnable() {
public void run() { doBigComputation(); }
});
}});
这个示例将长时间任务从事件线程中分离出来,这种方式可能并不是非常有用。在执行完一个长时间的任务后,通常会产生某种可视化的反馈。但你并不能从后台线程中访问这些表现对象,因此任务在完成时必须向事件线程提交另一个任务来更新用户界面。
9-5给出了如何实现这个功能的方式,有了三层的内部类。动作监听器首先使按钮无效,并设置一个标签表示正在进行某个计算,然后将一个任务提交给后台的Executor。当任务完成时,它会在事件线程中添加另一个任务,该任务将重新激活按钮并恢复标签文本。
// 支持用户反馈的长时间任务
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
button.setEnabled(false); //动作监听器首先使按钮无效,因为是长时间任务避免用户再按添加任务
label.setText("busy"); //设置一个标签表示正在进行某个计算
backgroundExec.execute(new Runnable() { //将一个任务提交给后台的Executor
public void run() {
try {
doBigComputation(); //进行计算
} finally { //在事件线程中添加另一个任务
GuiExecutor.instance().execute(new Runnable() {
public void run() {
button.setEnabled(true); //重新激活按钮
label.setText("idle"); //恢复标签文本
}
});
}
}
});
}
});
在按下按钮时触发的任务中包含3个连续的子任务,它们将在事件线程与后台线程之间交替运行。第一个子任务更新用户界面(使按钮无效,设置标签),表示一个长时间的操作已经开始,然后在后台线程中启动第二个子任务(计算)。当第二个子任务完成时,它把第三个任务再次提交到事件线程中运行,第三个子任务更新用户界面表示操作已经结束(激活按钮,恢复标签)。在GUI应用程序中,这种“线程接力”时处理长时间任务的典型方法。
9.3.1 取消
当某个任务在线程中运行了过长时间还没有结束时,用户可能希望取消它。你可以直接通过线程中断来实现取消操作。但是一种更简单的方法是使用Future,专门来管理可取消的任务。
如果调用Future的cancel方法,并将参数mayInterruptIfRunning设置为true,那么这个Future可以中断正在执行任务的线程。如果你编写的任务能够响应中断,那么当它被取消时就可提前返回。在9-6中,将轮询线程的中断状态,并且在发现中断时提前返回。
// 9-6 取消一个长时间任务
Future<?> runningTask = null; // 线程封闭
...
startButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
if (runningTask != null) { //确保每次只有一个后台任务在运行
runningTask = backgroundExec.submit(new Runnable() {
public void run() {
while (moreWork()) { //轮询线程的中断状态
if (Thread.currentThread().isInterrupted()) {
cleanUpPartialWork();
break; //发现中断时提前返回
}
doSomeWork();
}
}
});
};
}});
// 调用Future的cancel方法,并将参数mayInterruptIfRunning设置为true,那么这个Future可以中断正在执行任务的线程
cancelButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
if (runningTask != null)
runningTask.cancel(true);
}});
由于FutureTask被封闭在事件线程中,因此对它进行设置或检查时不需要同步,并且“开始”按钮监听器可以确保每次只有一个后台任务在运行。然而,当任务完成时最好能通知按钮监听器,例如可以禁用“取消”按钮。
9.3.2 进度标识和完成标识
通过Future来表示一个长时间的任务,可以极大地简化取消操作的实现。在FutureTask中也有一个done方法同样有助于实现完成通知。当后台的Callable完成后,将调用done。通过done方法在事件线程中触发一个完成任务,我们能构造一个BackGroundTask类,这个类将提供一个在事件线程中调用的onCompletion方法,如9-7
// 9-7 支持取消,完成通知以及进度通知的后台类
public abstract class BackgroundTask <V> implements Runnable, Future<V> {
private final FutureTask<V> computation = new Computation();
private class Computation extends FutureTask<V> { //创建一个计算的类
public Computation() {
super(new Callable<V>() {
public V call() throws Exception {
return BackgroundTask.this.compute(); //调用BackgroundTask中的compute方法
}
});
}
//当后台的Callable完成后,将调用done。通过done方法在事件线程中触发一个完成任务,我们能构造一个BackGroundTask类,这个类将提供一个在事件线程中调用的onCompletion方法
protected final void done() {
//在事件线程中触发一个完成任务
GuiExecutor.instance().execute(new Runnable() { //GuiExecutor使用单件模式创建构造函数,提交任务
public void run() {
V value = null;
Throwable thrown = null;
boolean cancelled = false;
try {
value = get();
} catch (ExecutionException e) {
thrown = e.getCause();
} catch (CancellationException e) {
cancelled = true; //将取消标志设为true
} catch (InterruptedException consumed) {
} finally {
onCompletion(value, thrown, cancelled);
}
};
});
}
}
//setProgress方法以数字形式来指示进度
protected void setProgress(final int current, final int max) {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
onProgress(current, max); //因而在事件线程中调用onProgress,从而更新用户界面以显示可视化的进度消息
}
});
}
// 在后台线程中被取消
protected abstract V compute() throws Exception;
// 在事件线程中被取消
protected void onCompletion(V result, Throwable exception,
boolean cancelled) {
}
//更新用户界面以显示可视化的进度消息
protected void onProgress(int current, int max) {
}
// Future的其他方法
public boolean cancel(boolean mayInterruptIfRunning) {
return computation.cancel(mayInterruptIfRunning);
}
public V get() throws InterruptedException, ExecutionException {
return computation.get();
}
public V get(long timeout, TimeUnit unit)
throws InterruptedException,
ExecutionException,
TimeoutException {
return computation.get(timeout, unit);
}
public boolean isCancelled() {
return computation.isCancelled();
}
public boolean isDone() {
return computation.isDone();
}
public void run() {
computation.run();
}
}
BackgroundTask还支持进度标识。compute方法可以调用setProgress方法以数字形式来指示进度。因而在事件线程中调用onProgress,从而更新用户界面以显示可视化的进度消息。
要想实现BackgroundTask,你只需要实现compute,该方法将在后台线程中调用。也可以改写onCompletion和onProgress,这两个方法也会在事件线程中调用。
基于FutureTask构造的Background还能简化取消操作。Compute不会检查线程的中断状态,而是调用Future.isCancelled。9-8通过BackgroundTask重新实现了9-6中的示例。
Void类是一个不可实例化的占位符类,它持有一个 代表Java关键字void的Class对象的引用。多用于泛型中作占位符使用。
// 9-8 通过BackgroundTask来执行长时间的并且可取消的任务
public void runInBackground(final Runnable task) {
startButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
class CancelListener implements ActionListener { //取消按钮的监听器,按下取消按钮后会将任务取消
BackgroundTask<?> task; //通过BackgroundTask来执行长时间的并且可取消的任务,将task包装成BackgroundTask
public void actionPerformed(ActionEvent event) {
if (task != null)
task.cancel(true);
}
}
final CancelListener listener = new CancelListener();//创建一个取消按钮监听器
//Void类是一个不可实例化的占位符类,它持有一个 代表Java关键字void的Class对象的引用。多用于泛型中作占位符使用。
listener.task = new BackgroundTask<Void>() {
public Void compute() {
while (moreWork() && !isCancelled())
doSomeWork(); //如果还有任务且任务未被取消则执行
return null;
}
public void onCompletion(boolean cancelled, String s,
Throwable exception) {
cancelButton.removeActionListener(listener);//如果正常结束则移除取消按钮监听器
label.setText("done");//设置标签
}
};
cancelButton.addActionListener(listener); //按下开始按钮后才可以为取消按钮添加监听器
backgroundExec.execute(task);
}
});
}
9.4 共享数据模型
Swing的表现对象(包括TableModel和TreeModel等数据模型)都被封闭在事件线程中。
在简单的GUI程序中,所有的可变状态都被保存在表现对象中,并且除了事件线程之外,唯一的线程就是主线程。
要在这些程序中强制实施单线程规则是很容易的:不要从主线程外访问数据模型或表现组件。在一些复杂的程序中,可能会使用其他线程对持久化的存储(例如文件系统,数据库等)进行读写操作以免降低系统的响应性。
最简单的情况是,数据模型中的数据由用户来输入或者由应用程序在启动时静态地从文件或其他数据源加载。在这种情况下,除了事件线程之外的任何线程都不可能访问到数据。
在某些情况下,表现模型对象只是一个数据源(例如数据库,文件系统或远程服务等)的视图对象。这时,当数据在应用程序中进出时,有多个线程都可以访问这些数据。
例如,你可以使用树形控件来显示远程文件系统的内容。在显示树形控件之前,并不需要枚举整个文件系统——会消耗大量内存和时间。而应该当树节点被展开时才读取对应的内容。
枚举操作应该在后台任务中执行。当后台任务完成后,必须通过某种方式将数据填充到树形模型中。可以使用线程安全的树形模型来实现这个功能:通过invokeLater提交一个任务,将数据从后台任务“推入”事件线程,或者让事件线程池通过轮询查看是否有数据可用。
9.4.1 线程安全的数据模型
只要阻塞操作不会过度地影响响应性,那么多个线程操作同一份数据的问题都可以通过线程安全的数据模型来解决。
如果数据模型支持细粒度的并发,那么事件线程和后台线程就能共享该数据模型,而不会发生响应性问题。线程安全的数据模型必须在更新模板时产生事件,这样才能在数据发生变化后进行更新。
9.4.2 分解数据模型(Split Data Models)
如果在程序中既包含用于表示的数据模型,又包含应用程序特定的数据模型,那么这种应用程序就被称为拥有一种分解模型设计。
在分解模型设计中,表现模型被封闭在事件线程中,而其他模型,即共享模型,时线程安全的,因此既可以由事件线程方法,亦可以由应用程序线程访问。
表现模型会注册共享模型的监听器,从而在更新时得到通知。然后,表示模型可以在共享模型中得到更新:通过将相关状态的快照嵌入到更新信息中,或者由表现模型在收到更新事件时直接从共享模型中获取数据。
快照虽然简单,但存在局限:如果数据模型很大,或者更新频率极高,在分解模型包含的信息中有一方或双方对另一方不可见,就工作得不好。
更高效的方式是发送增量更新信息,这种方法将共享模型上的更新操作序列化,并在事件线程中重现。另一好处是,细粒度的变化信息可以提高显示的视觉效果。
如果一个数据模型必须被多个线程共享,而且由于阻塞,一致性或复杂度等原因而无法实现一个线程安全的模型时,可以考虑使用分解模型设计。
9.5 其他形式的单线程子系统
线程封闭不仅仅可以在GUI中使用,每当某个工具需要被实现为单线程子系统时,都可以使用。
有时,当无法避免同步或死锁等问题时,将不得不使用线程封闭。
借鉴在GUI框架中采用的方法,可以创建一个专门的线程或一个单线程的Executor来访问这些库,并提供一个代理对象来拦截所有对线程封闭对象的调用,并将这些调用作为一个任务来提交这个专门的线程。
将Future和newSingleThreadExecutor一起使用,可以简化这项工作。
在代理方法中可以调用submit提交任务,然后立即调用Future.get来等待结果。