java

位置:IT落伍者 >> java >> 浏览文章

在Java平台上进行多线程编程的缺陷


发布日期:2020年05月27日
 
在Java平台上进行多线程编程的缺陷
Java 语言的并发编程

就其自身来说并发编程是一种技术提供了操作的同时执行不论是在单一系统上还是分布在大量系统上这类操作实际是一些指令顺序例如单独某个顶级任务的子任务这类操作能够并行执行或者是作为线程或者是作为进程线程和进程之间的本质区别在于进程通常是独立的(例如独立的地址空间)所以只能通过系统提供的进程间通信机制进行交互而线程通常共享单一进程的状态信息能够直接共享系统资源和内存中的对象

可以使用下面两种方法之一通过多个进程来实现并发第一种方法是在同一个处理器上运行进程由操作系统处理进程之间的上下文环境切换(可以理解这种切换要比同一进程内多线程之间的上下文环境切换更慢)第二种方法是构建大规模的并行和复杂的分布式系统在不同的物理处理器上运行多个进程

从内建支持的角度来说Java 语言通过线程提供并发编程;每个 JVM 都能支持许多线程同时执行可以用以下两种方法之一在 Java 语言中创建线程

继承 javalangThread 类在这种情况下已经重写的子类的 run() 方法必须包含实现线程运行时行为的代码要执行这个代码需要实例化子类对象然后调用对象的 start() 方法这样就可以在内部执行 run() 方法了

创建 Runnable 接口的定制实现这个接口只包含一个 run() 方法在这个方法中要放置应用程序代码要执行这个代码需要实例化实现类的对象然后在创建新 Thread 时把对象作为构造函数的参数传入然后调用新创建的线程对象的 start() 方法开始执行控制的新线程

线程安全性和同步

如果 Java 对象中的某个方法能够安全地运行在多线程环境中那么就称该方法是 线程安全的要获得这种安全性必须有一种机制通过该机制运行同一方法的多个线程就能够同步其操作这样在访问相同的对象或代码行时就会只允许一个线程被处理这种同步要求线程使用叫作 信号 的对象彼此进行沟通

有一种类型的信号叫作 互斥信号 或 互斥体顾名思义这个信号对象的拥有权是互斥的也就是说在任意指定时间只有一个线程能够拥有互斥体其他想获得所有权的线程会被阻塞它们必须等待直到拥有互斥体的线程释放互斥体如果多个线程按顺序排队等候同一互斥体那么在当前拥有者释放它的时候只有一个等候线程能够得到它;其他线程将继续阻塞

年代初CAR Hoare 和其他人共同开发了一个叫作 监视器 的概念一个 监视器 就是一个代码主体它的访问受到互斥体的保护任何想执行这个代码的线程都必须在代码块顶部得到关联的互斥体然后在底部再释放它因为在指定时间只有一个线程能够拥有互斥体所以这就有效地保证了只有拥有它的线程才能执行监视器的代码块(受保护的代码不需要相邻 —— 例如Java 语言中的每个对象都有一个与之关联的监视器)

任何想在 Java 语言中进行线程编程的开发人员都会立即把上面的内容当成 synchronized 关键字所带来的效果可以确保包含在 synchronized 块中的 Java 代码在指定时间只被一个线程执行在内部可以由运行时将 synchronized 关键字转换成某一种情况所有的竞争线程都试图获得与它们(指线程)正在操作的对象实例关联的那个(惟一的一个)互斥体成功得到互斥体的线程将运行代码然后在退出 synchronized 块时释放互斥体

等候和通知

wait/notify 构造在 Java 语言的线程间通信机制中也扮演了重要的角色基本的想法是一个线程需要的某个条件可以由另外一个线程促成这样条件的 wait 就可以得到满足一旦条件为真那么引发条件的线程就会 notify 等候线程苏醒并从中止的地方继续进行

wait/notify 机制要比 synchronized 机制更难理解和判断要想判断出使用 wait/notify 的方法的行为逻辑就要求判断出使用它的所有方法的逻辑一次判断一个方法把该方法和其他方法隔离开是对整体系统行为得出错误结论的可靠方式显然这样做的复杂性会随着要判断的方法的数量增长而迅速提高

线程状态

我前面提到过必须调用新创建的线程的 start() 方法来启动它的执行但是仅仅是调用 start() 方法并不意味着线程会立即开始运行这个方法只是把线程的状态从 new 变成 runnable只有在操作系统真正安排线程执行的时候线程状态才会变成 running (从 runnable)

典型的操作系统支持两种线程模型 —— 协作式和抢占式在协作式 模型中每个线程对于自己对 CPU 的控制权要保留多久什么时候放弃有最终意见在这个模型中因为可能存在某个无赖线程占住控制权不放所以其他线程可能永远无法得到运行在 抢占式 模型中操作系统本身采用基于时钟滴答的计时器基于这个计时器操作系统可以强制把控制权从一个线程转移到另外一个线程在这种情况下决定哪个线程会得到下一次控制权的调度策略就有可能基于各种指标例如相对优先级某个线程已经等待执行的时间长短等等

如果出于某些原因处在 running 状态的线程需要等候某个资源(例如等候设备的输入数据到达或者等候某些条件已经设定的通知)或者在试图获得互斥体的时候被阻塞因此线程决定睡眠那么这时它可以进入 blocked 状态当睡眠周期到期预期输入到达或者互斥体当前的拥有者将其释放并通知等候线程可以再次夺取互斥体时阻塞的线程重新进入 runnable 状态

当线程的 run() 方法完成时(或者正常返回或者抛出 RuntimeException 这样的未检测到异常)线程将终止这时线程的状态是 dead当线程死亡时就不能通过再次调用它的 start() 方法来重新启动它如果那么做则会抛出 InvalidThreadStateException 异常

四个常见缺陷

正如我已经展示过的Java 语言中的多线程编程是通过语言支持的大量精心设计的构造实现的另外还设计了大量设计模式和指导原则来帮助人们了解这种复杂性带来的许多缺陷除此之外多线程编程会很容易地在不经意间把细微的 bug 带进多线程代码而且更重要的是这类问题分析和调试起来非常困难接下来要介绍的是用 Java 语言进行多线程编程时将会遇到(或者可能已经遇到过)的最常见问题的一个列表

争用条件

据说 争用条件 存在于这样的系统中多个线程之间存在对共享资源的竞争而胜出者决定系统的行为Allen Holub 在他撰写的文章 programming Java threads in the real world 提供了一个带有这样 bug 的简单的多线程程序示例在沖突的访问请求之间进行不正确同步的另一个更可怕的后果是 数据崩溃此时共享的数据结构有一部分由一个线程更新而另一部分由另一个线程更新在这种情况下系统的行为不是按照胜出线程的意图进行系统根本不按照任何一个线程的意图行动所以两个线程最后都将以失败告终

死锁

死锁 的情况是指线程由于等候某种条件变成真(例如资源可以使用)但是它等候的条件无法变成真因为能够让条件变成真的线程在等候第一个线程做某件事这样两个线程都在等候对方先采取第一步所以都无法做事

活动锁

活动锁 与 死锁 不同它是在线程实际工作的时候发生的但这时还没有完成工作这通常是在两个线程交叉工作的时候发生所以第一个线程做的工作被另一个线程取消一个简单的示例就是每个线程已经拥有了一个对象同时需要另外一个线程拥有的另外一个对象可以想像这样的情况每个线程放下自己拥有的对象捡起另外一个线程放下的对象显然这两个线程会永远都运行在上锁这一步操作上结果是什么都做不成(常见的真实示例就是两个人在狭窄的走廊相遇每个人都礼貌地让到另一边让对方先行但却在相同的时间都让到同一边了所以两个人还都没法通过这种情况会持续一些时间然后两个人都从这边闪到那边结果还是一点进展也没有)

资源耗尽

资源耗尽又称为 线程耗尽是 Java 语言的 wait/notify 原语无法保证 liveness 的后果Java 强制这些方法要拥有它们等候或通知的对象的锁在某个线程上调用的 wait() 方法在开始等候之前必须释放监视器锁然后在从方法返回并获得通知之后必须再次重新获得锁因此Java 语言规范在锁本身之外还描述了一套与每个对象相关的 等候集(wait set)一旦线程释放了对象上的锁(在 wait 的调用之后)线程就会放在这个等候集上

多数 JVM 实现把等候线程放在队列中所以如果在通知发生的时候还有其他线程在等候监视器那么就会把一个新线程放在队列尾部而它并不是下一个获得锁的线程所以等到被通知线程实际得到监视器的时候通知该线程的条件可能已经不再为真所以它不得不再次 wait这种情况可能无限持续下去从而造成运算工作上浪费(因为要反复把该线程放入等候集和从中取出)和线程耗尽

贪心哲学家的寓言

演示这种行为的原型示例是 Peter Welch 教授描述的聪明人没有鸡肉在这个场景中考虑的系统是一所由五位哲学家一位厨师和一个食堂组成的学院所有的哲学家(除了一位)都要想想(在代码示例中考虑的时间是 秒)之后才去食堂取饭贪心的哲学家则不想把时间浪费在思考上 —— 相反他一次又一次地回到食堂企图拿到鸡肉来吃

厨师按照一批四份的定量准备鸡肉每准备好一批就送到食堂贪心的哲学家不断地去厨房但他总是错过食物!事情是这样的他第一次到的时候时间太早厨师还没开火因此贪心的哲学家只好干等着(通过 wait() 方法调用)在开饭的时候(通过 notify() 方法调用)贪心的哲学家再一次回到食堂排队等候但是这次在他前来等候的时候他的四位同事已经到了所以他在食堂队列中的位置在他们后面他的同事们把厨房送来的一批四份鸡肉全部拿走了所以贪心的哲学家又要在一边等着了 可怜(也可能是公平的) 他永远处在这个循环之外

验证的问题

一般来说很难按照普通的规范对 Java 编程的多线程程序进行验证同样开发自动化工具对于常见的并发问题(例如死锁活动锁和资源耗尽)进行完整而简单的分析也不太容易——特别是在任意 Java 程序中或者在缺乏并发的正式模型的时候

更糟的是并发性问题出了名的变化多端难于跟蹤每个 Java 开发人员都曾经听说过(或者亲自编写过)这样的 Java 程序经过严格分析而且正常运行了相当一段时间没有表现出潜在的死锁然后突然有一天问题发生了结果弄得开发团队经历许多的不眠之夜来试图发现并修补根本原因

一方面多线程 Java 程序容易发生的错误非常不明显有可能在任意什么时候发生另一方面完全有可能这些 bug 在程序中从不出现问题取决于一些不可知的因素多线程程序的复杂本质使得人们很难有效地对其进行验证没有一套现成的规则可以找出多线程代码中的这类问题也无法确切地证明这些问题不存在这些导致许多 Java 开发人员完全避开多线程应用程序的设计和开发即使用并发和并行的方式对系统进行建模会非常棒他们也不使用多线程

确实想进行多线程编程的开发人员通常准备好了以下一个或两个解决方案(至少是一部分)

长时间艰苦地测试代码找出所有出现的并发性问题诚心地希望到应用程序真正运行地时候已经发现并修复了所有这类问题

大量运行设计模式和为多线程编程建立的指导原则但是这类指导原则只在整个系统都按照它们的规范设计的时候才有效没有设计规则能够覆盖所有类型的系统

虽然知道的人不多但是对于编写(然后验证)正确的多线程应用程序这一问题还有第三个选项使用称为通信顺序进程( Communicating Sequential ProcessesCSP)的精确的线程同步的数学理论可以在设计时最好地处理死锁和活动锁之类的问题CSP 由 CAR Hoare 与 世纪 年代后期设计CSP 提供了有效的方法证明用它的构造和工具构建的系统可以免除并发的常见问题

结束语

在这份面向 Java 程序员的 CSP 全面介绍中我把重点放在克服多线程应用程序开发常见问题的第一步上即了解这些问题我介绍了 Java 平台上目前支持的多线程编程构造解释了它们的起源讨论了这类程序可能会有的问题我还解释了用正式理论在任意的大型的和复杂的应用程序中清除这些问题(即竞争冒险死锁活动锁和资源耗尽)或者证明这些问题不存在的困难

上一篇:java设计模式之Template(算法的骨架)

下一篇:漫谈Java加密技术(二)