浅谈Java线程模型的缺陷

2023-10-06 12:23
Java编程语言的线程模型可能是该语言最薄弱的部分。完全不适合实际复杂程序的要求,根本不是面向对象的。本文建议对 Java 语言进行重大修改和补充,以解决这些问题。 Java 语言的线程模型是该语言中最难满足的部分之一。虽然Java语言本身支持线程编程是一件好事,但是它对线程语法和类包的支持太少,只能适合非常小的应用环境。 大多数关于Java线程编程的书籍都煞费苦心地指出Java线程模型的缺点,并提供急救箱(Band-Aid/Band-Aid)库来解决这些问题。我将这些类称为急救箱,因为它们解决了 Java 语言本身的语法应该涵盖的问题。从长远来看,使用语法而不是库方法将产生更高效的代码。这是因为编译器和Java虚拟机(JVM)共同优化程序代码,而这些优化对于类库中的代码来说很难或不可能实现。 Allen Holub 指出,在我的书《Taming Java Threads 》(请参阅参考资料)和本文中,我进一步建议对 Java 编程语言本身进行一些修改,以便它能够真正解决这些线程编程问题。这篇文章和我的书的主要区别在于,我在写这篇文章时考虑了更多,因此改进了书中的建议。 这些建议是暂时的——只是我自己对这些问题的想法,实施它们需要大量的工作和同行评审。但这毕竟是一个开始,我打算成立一个专门的工作组来解决这些问题。如果您有兴趣,请发送电子邮件至 support@m.gsm-guard.net。一旦我真正开始做,我就会告诉你。 这里提出的建议是大胆的。有些人建议对 Java 语言规范 (JLS)(请参阅参考资料)进行微小的修改,以解决当前不明确的 JVM 行为,但我想做一个更彻底的改进。 在实际的草案中,我的许多建议包括为这种语言引入新的关键字。虽然要求不要破坏语言的现有代码通常是正确的,但如果语言不想保持不变以致过时,则必须能够引入新的关键字。为了防止引入的关键字与现有的标识符冲突,经过深思熟虑,我将使用一个($)字符,这在现有的标识符中是非法的。 (例如,使用 $task 而不是 task)。这需要编译器命令行开关的支持,它可以使用这些关键字的变体而不是忽略美元符号。 任务的概念 Java 线程模型的根本问题是它根本不是面向对象的。面向对象 (OO) 设计者根本不考虑线程;他们只考虑线程。他们从同步信息和异步信息的角度思考(同步信息立即处理——信息处理完成后才返回消息句柄;异步信息在后台处理一段时间——消息句柄返回long)在处理消息之前)。 异步消息传递的一个很好的例子是 Java 编程语言中的 Toolkit.getImage() 方法。 getImage() 的消息句柄将立即返回,而无需等待后台线程检索到整个图像。 这是一种面向对象(OO)方法。然而,正如前面提到的,Java 的线程模型是非面向对象的。 Java 编程语言线程实际上只是一个调用其他过程的 run() 过程。这里根本没有对象、异步或同步信息以及其他概念。 在我的书中深入讨论的这个问题的一个解决方案是使用 Active_object。活动对象是可以接收异步请求并在收到请求后一段时间内在后台处理它们的对象。 在Java编程语言中,请求可以封装在对象中。例如,您可以向此活动对象传递一个通过 Runnable 接口实现的实例,该实例的 run() 方法封装了需要完成的工作。可运行对象由这个活动对象排队,当轮到它执行时,活动对象使用后台线程来执行它。 在活动对象上运行的异步消息实际上是同步的,因为它们是由单个服务线程出队并顺序执行的。因此,在更加过程化的模型中使用活动对象可以消除大多数同步问题。 从某种意义上说,Java 编程语言的整个 Swing/AWT 子系统都是一个活动对象。将消息发送到 Swing 队列的唯一安全方法是调用 SwingUtilities.invokeLater() 等方法,该方法会在 Swing 事件队列上发送可运行对象。当轮到它执行的时候,Sw​​ing事件处理线程就会处理它。所以我的第一个建议是在Java编程语言中添加一个任务(task)的概念,将活动对象集成到语言中。 (任务的概念借鉴自Intel的RMX操作系统和Ada编程语言。大多数实时操作系统都支持类似的概念。) 任务有一个内置的活动对象调度程序,并自动管理处理异步消息的所有机制。 定义任务与定义类基本相同。唯一的区别是,您需要在任务的方法之前添加异步修饰符,以指示活动对象的分配器在后台处理这些方法。 所有写请求都使用dispatch()过程调用在活动对象的输入队列上排队。在后台处理这些异步消息时发生的任何异常都由 Exception_handler 对象处理,该对象被传递给 File_io_task 的构造函数。 这种基于类的方法的主要问题是它太复杂 - 对于如此简单的操作来说代码太复杂。在Java语言中引入$task和$asynchronous关键字后,你可以重写之前的代码如下: 请注意,异步方法不指定返回值,因为它的句柄将立即返回,而无需等待请求的操作被处理。因此,此时并没有合理的返回值。对于派生模型,$task 关键字与 class 具有相同的效果:$task 可以实现接口、继承类和其他继承任务。标有 asynchronous 关键字的方法由 $task 在幕后处理。其他方法将同步运行,就像在类中一样。 $task 关键字可以使用可选的 $error 子句进行限定(如上所示),这表明对于异步方法本身无法捕获的任何异常,将有一个默认处理程序。我使用 $ 来表示抛出的异常对象。如果未指定 $error 子句,则会打印合理的错误消息(很可能是堆栈跟踪)。 注意,为了保证线程安全,异步方法的参数必须是不可变的。运行时系统应该通过相关语义来保证这种不变性(简单的复制通常是不够的)。 所有任务对象必须支持某种伪消息。除了常用的修饰符(public 等)之外,task 关键字还应该接受 $pooled(n) 修饰符,这会导致任务使用线程池而不是使用单个线程来运行异步请求。 n指定所需线程池的大小;如果需要,该线程池可以增长,但当不再需要线程时,它应该收缩回其原始大小。伪字段 $pool_size 返回 $pooled(n) 中指定的原始 n 参数值。 在《Taming Java Threads 》的第8章中,我给出了一个服务器端套接字处理程序作为线程池的示例。这是使用线程池的任务的一个很好的示例。基本思想是生成一个独立的对象,其任务是监视服务器端套接字。每当客户端连接到服务器时,服务器端对象就会从池中获取预先创建的睡眠线程,并将该线程设置为为客户端连接提供服务。套接字服务器将产生一个额外的客户端服务线程,但是当连接关闭时,这些额外的线程将被删除。 Socket_server 对象使用单独的后台线程来处理异步listen() 请求,它封装了套接字的“accept”循环。当每个客户端连接时,listen()通过调用handle()请求一个Client_handler来处理该请求。每个handle() 请求都在自己的线程中执行(因为这是一个$pooled 任务)。 请注意,传递到 $pooled $task 的每条异步消息实际上都是使用其自己的线程进行处理的。通常,由于 $pooled $task 用于实现自主操作,因此与访问状态变量相关的潜在同步问题的最佳解决方案是在 $asynchronous 方法中使用 this 指向的对象。独家副本。这意味着当异步请求发送到$pooled$task时,将执行clone()操作,并且this方法的this指针将指向克隆对象。线程之间的通信是通过同步访问静态区域来实现的。 改善同步 虽然$task在大多数情况下消除了同步操作的需要,但并不是所有的多线程系统都是使用任务来实现的。因此,现有的线程模块仍然需要改进。 Synchronized 关键字有以下缺点: 不能指定超时值。无法中断正在等待请求的锁的线程。无法安全地请求多个锁。 (多个锁只能按顺序获得。) 这些问题的解决方案是扩展synchronized的语法以支持多个参数并接受超时规范(在下面的括号中指定)。这是我想要的语法: synchronized(x && y && z) 获取 x、y 和 z 对象上的锁。 synchronized(x || y || z) 获取 x、y 或 z 对象的锁。 Synchronized( (x && y) || z) 对之前的代码进行了一些扩展。 synchronized(...)[1000] 设置 1 秒超时来获取锁。 synchronized[1000] f(){...} 在进入 f() 函数时获取 this 的锁,但可能有 1 秒超时。 TimeoutException 是 RuntimeException 派生类,在等待超时后抛出。 超时是必要的,但不足以使代码变得强大。您还需要能够从外部中止请求锁定等待。因此,当将interrupt()方法传递给等待锁的线程时,该方法应该抛出SynchronizationException对象并中断等待线程。这个异常应该是RuntimeException的派生类,这样你就不必专门处理它。 这些建议对同步语法进行的更改的主要问题是它们需要在二进制代码级别进行修改。目前,这些代码使用enter-monitor和exit-monitor指令来实现同步。这些指令没有参数,因此需要扩展二进制代码的定义以支持多个锁定请求。这一修改并不比修改 Java 2 中的 Java 虚拟机容易,但它将向后兼容现有的 Java 代码。 另一个可解决的问题是最常见的死锁情况,其中两个线程正在等待另一个线程完成操作。 想象一下,一个线程调用 a() ,但在获取 lock1之后但在获取lock2之前被剥夺了运行权。第二个线程进入运行状态,调用 b() 并获取 lock2 ,但由于第一个线程占用了 lock1 ,因此无法获取 lock1 ,因此它会等待。此时第一个线程被唤醒,尝试获取lock2,但由于被第二个线程占用而无法获取。此时就出现了死锁。 编译器(或虚拟机)将重新排列请求锁的顺序,使得lock1始终首先被获取,从而消除了死锁。 然而,这种方法对于多线程来说可能并不总是成功,因此必须提供一些方法来自动打破死锁。一个简单的解决方案是在等待第二个锁的同时不时地释放所获取的锁。 如果每个等待锁的程序使用不同的超时值,则可以打破死锁并且其中一个线程可以运行。我建议用以下语法替换以前的代码: 同步语句将永远等待,但它偶尔会放弃获取的锁以打破潜在的死锁。在理想情况下,每次重复等待的超时值将是与前一个不同的随机值。 改进wait()和notify() wait()/notify()系统也存在一些问题:它无法检测wait()是正常返回还是超时返回。使用传统的条件变量无法实现处于“有信号”状态。嵌套监视器锁很容易发生。 超时检测问题可以通过重新定义 wait() 使其返回一个布尔变量(而不是 void)来解决。返回值true表示正常返回,false表示超时返回。 基于状态的条件变量的概念很重要。如果该变量设置为 false 状态,则等待线程将被阻塞,直到该变量进入 true 状态;任何等待真条件变量的等待线程将被自动释放。 (在这种情况下,wait() 调用不会阻塞。) 嵌套监视器锁定问题非常麻烦,我没有简单的解决方案。嵌套监视器锁定是死锁的一种形式,当持有锁的线程在挂起自身之前未释放锁时,就会发生死锁。 在此示例中,get() 和 put() 操作涉及两把锁:一把锁在 Stack 对象上,一把锁在 LinkedList 对象上。接下来我们考虑当线程尝试在空堆栈上调用 pop() 操作时的情况。该线程获取两个锁,然后调用 wait() 释放 Stack 对象上的锁,但不释放列表上的锁。如果此时第二个线程尝试将对象压入堆栈,它将永远挂在synchronized(list)语句上,并且永远不允许压入对象。 由于第一个线程正在等待非空堆栈,因此会发生死锁。这意味着第一个线程永远无法从 wait() 返回,因为第二个线程永远无法运行到 notification() 语句,因为它持有锁。 在此示例中,有许多明显的方法可以解决该问题:例如,对任何方法使用同步。但在现实世界中,解决方案往往并不那么简单。 一种可行的方法是在wait()中以相反的顺序释放当前线程获取的所有锁,然后在满足等待条件时按照原来的获取顺序重新获取它们。然而,我可以想象利用这一点的代码对人们来说根本无法理解,所以我认为这不是一个真正可行的方法。如果您有好的方法,请发邮件给我。 我也希望等到下面复杂的条件实现之后。例如:其中 a 、 b 和 c 是任意对象。 修改Thread类 支持抢占式和协作式线程的能力是某些服务器应用程序的基本要求,特别是如果您想最大限度地提高系统性能。我认为Java编程语言在简化线程模型方面走得太远了,Java编程语言应该支持Posix/Solaris的“绿色线程”和“轻量级进程”概念(在第1章的“驯服Java线程”中讨论) 。这意味着某些 Java 虚拟机实现(例如 NT 上的 Java 虚拟机)应该在内部模拟协作进程,而其他 Java 虚拟机应该模拟抢占式线程。对于 Java 虚拟机而言,将这些扩展添加到您的计算机中非常简单。 Java 线程应该始终是抢占式的。也就是说,Java 编程语言线程的行为应该类似于 Solaris 轻量级进程。 Runnable 接口可用于定义 Solaris 风格的“绿色线程”,该线程必须能够将控制权转移到同一轻量级进程中运行的其他绿色线程。 有效地为 Runnable 对象生成一个绿色线程,并将其绑定到 Thread 对象表示的轻量级进程。此实现对于现有代码是透明的,因为它的工作方式与现有代码完全相同。 将 Runnable 对象视为绿色线程。使用这种方法,您只需将几个 Runnable 对象传递给 Thread 构造函数,就可以扩展 Java 编程语言的现有语法,以支持单个轻量级线程中的多个绿色线程。(绿色线程可以相互协作,但它们可以被其他轻量级进程(Thread 对象)上运行的绿色进程(Runnable 对象)抢占。)例如,下面的代码为每个可运行对象创建一个绿色线程,并且这些绿色线程线程共享由 Thread 对象表示的轻量级进程。 重写 Thread 对象并实现 run() 的现有约定继续有效,但应将其映射到绑定到轻量级进程的绿色线程。 (Thread() 类中的默认 run() 方法实际上在内部创建了第二个 Runnable 对象。) #p# 线程间的协作 应该向该语言添加更多功能以支持线程间通信。目前,PipedInputStream 和 PipedOutputStream 类可用于此目的。但对于大多数应用程序来说,它们太弱了。我建议向 Thread 类添加以下函数: 添加 wait_for_start() 方法,该方法通常会阻塞,直到线程的 run() 方法启动。 (如果等待线程在调用 run 之前被释放,这没有问题)。这样,一个线程就可以创建一个或多个辅助线程,并保证这些辅助线程在创建线程继续执行操作之前运行。 添加了 $send (Object o) 和 Object=$receive() 方法(到 Object 类),这将使用内部阻塞队列在线程之间传输对象。阻塞队列应该作为第一个 $send() 调用的副产品自动创建。 $send() 调用将对象添加到队列中。 $receive() 调用通常会阻塞,直到一个对象入队,然后返回该对象。此方法中的变量应支持为入队和出队操作设置超时的能力:$send(Object o,长超时)和$receive(长超时)。 内部支持读写锁 读写锁的概念应该内置于 Java 编程语言中。读写锁在“Taming Java Threads”(以及其他地方)中详细讨论,但总而言之:读写锁允许多个线程同时访问一个对象,但一次只有一个线程可以修改该对象,并且访问期间无法修改。 对于一个对象来说,只有当$writing块中没有线程时才支持多个线程进入$reading块。在读操作期间,尝试进入$writing块的线程将被阻塞,直到读线程退出$reading块。当$writing块中有其他线程时,试图进入$reading或$writing块的线程将被阻塞,直到写入线程退出$writing块。 如果读线程和写线程都在等待,则默认情况下读线程将首先继续。但是,可以通过使用 $writer_priority 属性修改类定义来更改此默认值。 访问部分创建的对象应该是非法的 目前,JLS 允许访问部分创建的对象。例如,在构造函数中创建的线程可以访问正在创建的对象,即使该对象尚未完全创建。 将 x 设置为 -1 的线程可以与将 x 设置为 0 的线程同时执行此操作。因此,此时 x 的值无法预测。 解决这个问题的一种方法是在构造函数返回之前禁止在此构造函数中创建的线程的 run() 方法运行,即使它的优先级高于调用 new 的线程。 这意味着 start() 请求必须推迟到构造函数返回为止。 此外,Java 编程语言应该允许构造函数同步。换句话说,以下代码(在当前情况下是非法的)按预期工作: 我认为第一种方法比第二种方法更清晰,但更难实施。 易失性关键字应该按预期工作 JLS 要求保留对易失性操作的请求。大多数Java虚拟机只是忽略这部分,但事实并非如此。许多具有多处理器的主机上都会出现此问题,但 JLS 应该可以修复该问题。如果您对此感兴趣,马里兰大学的 Bill Pugh 正在研究它(请参阅 参考资料)。 访问问题 缺乏良好的访问控制会使线程编程变得非常困难。大多数情况下,如果能保证线程只从同步子系统调用,则不需要考虑线程安全(threadsafe)问题。我建议对 Java 编程语言的访问概念进行以下限制;应精确使用 package 关键字来限制包访问。我认为默认行为的存在是任何计算机语言中的缺陷,并且我对默认权限的存在感到困惑(并且默认权限是“包”级别而不是“私有”)。私人的)”)。 否则,Java 编程语言不提供等效的默认关键字。尽管使用显式包限定符会破坏现有代码,但它会使代码更具可读性并消除整个类的潜在错误(例如,如果由于错误而忽略访问权限,而不是故意忽略)。重新引入 private protected ,其功能应与现在的 protected 相同,但不应允许包级访问。 private private 语法允许指定“实现的访问”对于所有外部对象都是私有的,甚至是与当前对象属于同一类的对象。 “.”左侧的唯一引用(隐式或显式)。应该是这个。 扩展 public 的语法以授予其对特定类的访问权限。例如,下面的代码应该允许 Fred 类的对象调用 some_method(),但该方法对于其他类的对象应该是私有的。 这个建议与C++的“friend”机制不同。在“朋友”机制中,它授予一个类访问另一类的所有私有部分的权限。在这里,我建议严格控制对一组有限方法的访问。这样,一个类可以为另一个类定义一个接口,而这个接口对于系统的其余部分是不可见的。 所有字段定义都应该是私有的,除非该字段引用真正不可变的对象或静态最终基元类型。直接访问类中的字段违反了 OO 设计的两个基本规则:抽象和封装。从线程的角度来看,允许直接访问域只会使非同步访问变得更容易。 添加 $property 关键字。带有该关键字的对象可以被使用Class类中定义的反射操作(内省)API的“bean box”应用程序访问,否则与private private具有相同的效果。 $property 属性可用于字段和方法,以便现有 JavaBean getter/setter 方法可以轻松定义为属性。 不变性 由于访问不可变对象不需要同步,因此不可变性(对象的值在创建后不能更改)的概念在多线程条件下非常宝贵。在Java编程语言中,不可变性的实现不够严格,原因有两个:对于不可变对象,在完全创建之前就可以访问它。此访问可能会为某些字段生成不正确的值。常量的定义(类的所有字段都是final的)过于宽松。对于由最终引用指定的对象,虽然引用本身不能更改,但对象本身可以更改状态。 第一个问题可以通过在构造函数中不允许线程开始执行(或者在构造函数返回之前不执行启动请求)来解决。 对于第二个问题,可以通过限制final修饰符指向一个常量对象来解决这个问题。也就是说,对于一个对象来说,只有所有字段都是final的,并且所有引用对象的字段也都是final的,这个对象才能真正是常量。为了不破坏现有代码,编译器可以强制执行此定义,以便只有类被显式标记为不可变。 使用 $immutable 修饰符时,字段定义中的 Final 修饰符是可选的。 最后,Java 编译器中的一个错误使得在使用内部类时无法可靠地创建不可变对象。 即使在每个构造函数中初始化了空final,仍然会出现此错误消息。自 1.1 版本引入内部类以来,编译器中就一直存在此错误。在这个版本(三年后)中,该错误仍然存​​在。现在,是时候纠正这个错误了。 实例级访问类级字段 除了访问权限之外,还存在一个问题:类级(静态)方法和实例(非静态)方法都可以直接访问类级(静态)字段。这种访问是非常危险的,因为实例方法的同步并不会获取类级别的锁,所以一个synchronized静态方法和一个synchronized方法仍然可以同时访问类域。纠正此问题的一个明显方法是要求只能通过在实例方法中使用静态访问方法来访问非不可变类的静态字段。当然,这个要求需要编译器和运行时检查。 由于 f() 和 g() 可以并行运行,因此它们可以同时更改 x 的值(产生未定义的结果)。请记住,这里有两个锁:静态方法需要属于 Class 对象的锁,而非静态方法需要属于此类实例的锁。 或者,编译器应该获取读/写锁的使用: 或者(理想情况下),编译器应该自动使用读/写锁来同步对非不可变静态字段的访问,以便程序员不必担心这个问题。 后台线程突然结束 当所有非后台线程终止时,后台线程会突然终止。当后台线程创建某些全局资源(例如数据库连接或临时文件)并且后台线程结束时该资源未关闭或删除时,可能会出现问题。对于这个问题,我建议制定规则,以便 Java 虚拟机在以下情况下不会关闭应用程序: 有任何非后台线程正在运行,或者: 有任何后台线程正在执行同步方法或同步代码块。 后台线程在执行完同步块或同步方法后可以立即关闭。 重新引入 stop()、suspend() 和resume() 关键字 由于实际原因这可能不可行,但我希望 stop() (在 Thread 和 ThreadGroup 中)不会被弃用。但是,我会更改 stop() 的语义,以便调用它不会破坏现有代码。但是,关于 stop() 的问题,请记住,当线程终止时,stop() 将释放所有锁,这可能会使正在该对象上工作的线程进入不稳定(本地修改)状态。由于停止的线程已经释放了该对象上的所有锁,因此无法再访问这些对象。 对于这个问题,可以重新定义stop()的行为,使得线程只有在没有持有任何锁时才立即终止。如果它持有锁,我建议仅在该线程释放最后一个锁后才终止它。这种行为可以使用类似于抛出异常的机制来实现。停止的线程应该设置一个标志,并在退出所有同步块时立即测试该标志。如果设置了此标志,则会引发隐式异常,但该异常不应再被捕获,并且当线程终止时不应产生任何输出。 请注意,Microsoft 的 NT 操作系统不能很好地处理外部指令的突然中断。 (它不会通知动态链接库停止消息,因此可能会导致系统级资源漏洞。)这就是为什么我建议使用类似异常的东西来简单地导致 run() 返回。 这种类似异常的方法的实际问题是,您必须在每个同步块之后插入代码来测试“已停止”标志。而且这些额外的代码会降低系统性能并增加代码长度。我想到的另一个解决方案是让 stop() 实现“惰性”停止,在这种情况下,直到下一次调用 wait() 或yield() 时它才会终止。我还想向Thread添加isStopped()和stopped()方法(此时,Thread将像isInterrupted()和interrupted()一样工作,但会检测“停止请求”状态)。这种方法不如第一种方法通用,但它是可行的并且不会产生过载。 suspend() 和resume() 方法应该放回到Java 编程语言中,它们非常有用,我不想被当作幼儿园的孩子对待。删除它们是没有意义的,因为它们可能会产生潜在的危险(挂起时,线程可能会占用锁)。请让我决定是否使用它们。如果接收线程持有锁,Sun 应通过调用 suspend() 将其作为运行时异常处理;或者,更好的是,延迟实际的挂起,直到线程释放所有锁。 阻塞的 I/O 应该可以正常工作 应该可以中断任何阻塞的操作,而不仅仅是 wait() 和 sleep() 它们。我在《驯服 Java 线程》第 2 章的套接字部分讨论了这个问题。但现在,对于阻塞套接字上的 I/O 操作,中断它的唯一方法是关闭套接字,而没有办法中断阻塞的文件 I/O 操作。例如,一旦发起读请求,线程进入阻塞状态,该线程将保持阻塞状态,直到它真正读取到某些内容。即使关闭文件句柄也不能中断读取操作。 此外,程序应该支持 I/O 操作超时。所有可能发生阻塞操作的对象(例如InputStream对象)也应该支持此方法。 这相当于Socket类的setSoTimeout(time)方法。同样,应该支持将超时作为参数传递给阻塞调用。 线程组类 ThreadGroup应该实现Thread中所有可以改变线程状态的方法。我特别希望它实现 join() 方法,这样我就可以等待组中的所有线程终止。 总结 以上是我的建议。就像我在标题中所说的那样,如果我是国王...(哎)。我希望这些改变(或其它等同的方法)最终能被引入 Java 语言中。我确实认为 Java 语言是一种伟大的编程语言;但是我也认为 Java 的线程模型设计得还不够完善,这是一件很可惜的事情。但是,Java 编程语言正在演变,所以还有可提高的前景。