Single Threaded Execution
Single Threaded Execution是指“以1个线程执行”的意思。就像独木桥只能允许一个人通行一样,这个Pattern用来限制同时只让一个线程运行。
Single Threaded Execution有时候也称为 Critical Section(临界区:危险区域)或 Critical Region。
范例程序1: 不使用 Single Threaded Execution Pattern 的范例
模拟3个人频繁地经过一个只能容许一个人经过的们,当人通过门的时候,这个程序会在计数器中,递增通过的人数。并记录通过的人的姓名与出生地。
Main类 创建一个门,并负责操作3个人不断地穿越门
非线程安全类Gate 表示门的类,当人经过时会记录姓名与出生地
UserThread 表示的类,负责处理不断地在门间穿梭通过
Main类
public class Main {
public static void main(String[] args) {
Gate gate = new Gate();
new UserThread(gate, "Alice", "Alaske").start();
new UserThread(gate, "Blue", "Brazil").start();
new UserThread(gate, "Chris", "Canada").start();
}
}
非线程安全类Gate
check()方法用来检查现在门的状态(最后通过的行人的记录数据)是否正确。当人的姓名与出生地第一个字符不同时,就断定记录是有问题的。当发现记录有问题时,就显示出“*****BROKEN*****”
public class Gate {
private int counter = 0;
private String name = null;
private String address = null;
public void pass(String name, String address) {
this.counter++;
this.name = name;
this.address = address;
check();
}
public String toString() {
return "No." + counter + ": " + name + ", " + address;
}
private void check() {
if (name.charAt(0) != address.charAt(0)) {
System.out.println("*****BROKEN*****" + toString());
}
}
}
public class UserThread extends Thread{
private final Gate gate;
private final String name;
private final String address;
public UserThread(Gate gate, String name, String address) {
this.gate = gate;
this.name = name;
this.address = address;
}
public void run() {
System.out.println(name + "Begin");
while (true) {
gate.pass(name, address);
}
}
}
执行结果
该程序结果因时间不同,结果有所差异
AliceBeginBlueBeginChrisBegin*****BROKEN*****No.64721: Alice, Alaske*****BROKEN*****No.68653: Blue, Brazil*****BROKEN*****No.73048: Blue, Brazil*****BROKEN*****No.77092: Alice, Alaske*****BROKEN*****No.94239: Alice, Alaske*****BROKEN*****No.162687: Alice, Alaske*****BROKEN*****No.185035: Alice, Alaske*****BROKEN*****No.195845: Blue, Brazil*****BROKEN*****No.201228: Alice, Alaske*****BROKEN*****No.211578: Alice, Brazil*****BROKEN*****No.211578: Alice, Brazil
- Gate类并非线程安全
首先,Alice,Blue,Chris创建时的GEGIN各显示出来后,就开始不断出现*****BROKEN*****的消息了。如果仔细观察,会发现即使姓名和出生地的第一个字母都相同的时候,还是会显示这段消息,关于这点后面解释。
现在我们知道了Gate类的实例,在多线程的环境下使用时,与我们所期待的状态并不相同。我们称它为非线程安全(thread-safe)的类。
- 测试并无法证明安全性
仔细观察结果,发现一开始显示BROKEN的时候,counter的值是64721。也就是说发生第一个错误,已经有6万多人经过了门。在这里,是因为UserThread类的run()内跑的是无穷循环,所以才检查到错误。但如果只是简单的几次测试,就算是几万次测试,也可能不会出现错误。
多线程设计中,这就是一个较为困难的地方。如果测试时找到错误,表示写好的程序并不安全。但,就算测试没找到错误,也不表示程序一定是安全的。当测试次数不够,时间点不对,就可能检查不到问题。一般来说,操作测试并不足以证明程序的安全性。操作测试所得到的结果,只不过表示“也许是安全的”可能性比较高。
- 调试消息也不可靠
在范例1的运行结果中,当出现了BROKEN的错误消息,姓名与出生地的开头字母应该是不相同的。可是实际结果中,出现了BROKEN消息,而调试消息确好像是正确的(姓名与出生地首字母相同)。
出现这种现象,是因为某个线程正在执行check方法时,其他线程正不停地调用pass方法,name字段与address字段的值已经又被改变了。
这也是多线程程序设计中较困难的地方。若调试消息的程序本身就并非线程安全,可能会显示出错误的调试消息。
当执行测试和调试消息都无法保证程序的安全性,这个时候要做的就是重新审查代码。由多个人仔细阅读程序源代码,检查是否会产生问题
为什么会出错呢
之所以会出现BROKEN情况,是因为pass方法被多个线程调用时,pass中的四条语句可能会交替执行,如果交替执行的情况如以下这种情况时就会出现BROKEN信息。
为了解说方便,现在只考虑两个线程Alice和Blue。
线程Alice | 线程Blue | this.name的值 | this.address的值 |
this.counter++; | this.counter++; | (之前的值) | (之前的值) |
this.name=name
| "Blue" | (之前的值) | |
this.name=name | "Alice" | (之前的值) | |
this.address=address | "Alice" | "Alaska" | |
this.address=address | "Alice" | "Brazil" | |
check() | check() | "Alice" | "Brazil" |
*****BROKEN***** |
通常,线程是不会去考虑其他线程,而自己只会一直不停地跑下去的。范例程序1之所以会出现显示出BROKEN,是因为线程 并没有考虑到其他线程,而将共享实例的字段改写了。
范例程序2:使用 Single Threaded Pattern 的范例
线程安全的Gate类
需要修改的有两个地方,在pass方法和toString方法前面都加上synchronized。
public class Gate {
private int counter = 0;
private String name = null;
private String address = null;
public synchronized void pass(String name, String address) {
this.counter++;
this.name = name;
this.address = address;
check();
}
public synchronized String toString() {
return "No." + counter + ": " + name + ", " + address;
}
private void check() {
if (name.charAt(0) != address.charAt(0)) {
System.out.println("*****BROKEN*****" + toString());
}
}
}
执行结果如下,无论执行多久都不会出现BROKEN消息。这个执行结果也并不能证明Gate类的安全性,只可以说安全性的可能性很高。
AliceBeginBlueBeginChrisBegin
synchronized 所扮演的角色
为什么在 pass 方法和 toString 方法前加上synchronized后就不会出现BROKEN消息呢?
synchronized方法,能够保证同时只有一个线程可以执行它。也就是说,线程Alice执行 pass 方法的时候,线程 Blue 就不能调用 pass 方法。在线程 Alice 执行完 pass 方法前,线程 Blue 会在 pass 方法的入口被阻挡下。当线程 Alice 执行完 pass 方法后,将锁定解除,线程 Blue 才可以开始执行 pass 方法。加入synchronized后,pass方法执行的情况会类似一下这种情况。
线程Alice 线程Blue this.name的值 this.address的值 |
【获取锁定】
this.counter++; (之前的值) (之前的值)
this.name=name; "Alice” (之前的值)
this.address=address; "Alice" "Alaska"
check(); "Alice" "Alaska"
【解除锁定】
|
【获取锁定】
this.counter++; "Alice" "Alaska"
this.name=name; "Blue" "Alaska"
this.address=address; "Blue" "Brazil"
check(); "Blue" "Brazil"
【解除锁定】
|
Single Threaded Execution 的所有参与者
- SharedResource (共享资源)参与者
Single Threaded Execution Pattern中,有担任SharedResource角色的类出现。在范例程序2中,Gate类就是这个SharedResource参与者。
SharedResource参与者是可由多个线程访问的类。SharedResource会拥有一些方法,而这些方法又分为下面两类:
SafeMethod --- 从多个线程同时调用也不回发生问题的方法。
UnSafeMethod --- 从多个线程同时调用会出问题,而需要加以防护的方法。
在 Single Threaded Execution Pattern 中,我们将 unsafeMethod 加以防卫,限制同时只能有1个线程可以调用它。在 java 语言中,只要将 unsafeMethod 定义成synchronized 方法,就可以实现这个目标。
这个必须让单线程执行的程序范围,称之为临界区(critical section)
扩展思考方向的提示
何时使用(适用性)
Single Threaded Execution Pattern 该在什么情况下使用呢?
- 多线程时
在单线程中不需要使用 synchronized 方法,即使使用也不会对程序的安全性造成危害。但是调用 synchronized 方法会比调用一般的方法多花些时间。程序性能会略微降低。
- 数据可被多个线程访问的时候
就算是多线程程序,如果所有线程完全独立运行,那也没有使用 Single Threaded Execution Pattern 的必要。我们将这个状态称为线程互不干涉。
- 状态可能变化的时候
当SheradResource参与者状态可能变化的时候,才会有使用 Single Threaded Execution Pattern 的需要。
如果实例创建后,从此不会再变化状态,也没有使用 Single Threaded Execution Pattern 的必要。 如在 Immutable Pattern 中实例的状态不会改变,所以不需要使用到synchronized方法。
- 需要确保安全性的时候
需要确保安全性的时候,需要使用 Single Threaded Execution Pattern。如,java 2 的集合架构类多半并非线程安全。这是为了在不考虑安全性的时候,可以让程序的运行速度较高。
生命性与死锁
使用 Single Threaded Execution Pattern 时,可能会有发生死锁(deadlock)的危险。
所谓死锁,是指两个线程分别获取了锁定,互相等待另一个线程解除锁定的现象。发生死锁时,哪个线程都无法继续执行下去,所以程序会失去生命性。
假设 Alice 与 Blue 同吃一盘面条。盘子旁边只有一支汤勺与一支叉子,而吃面,同时需要用到汤勺与叉子。只有一支的汤勺被Alice拿去了,而另一只叉子被Blue拿去了。就会出现这种情况:握着汤勺的 Alice 一直等着 Blue 把叉子放下。 握着叉子的 Blue 一直等着 Alice 把汤勺放下。 这样 Alice 和Blue 就会僵持不动,谁都吃不了面条。
Single Threaded Execution 达到下面这些条件时,就会出现死锁的现象。
(1)具有多个 SharedResource 参与者。
(2)线程锁定一个 SharedResource 时,还没解除前就去锁定另一个 SharedResource。
(3)获取 SharedResource 参与者的顺序不固定(和 SharedResource 参与者是对等的)
再看看吃面的例子:
(1)多个 SharedResource 参与者,相当于汤勺和叉子。
(2)线程锁定某个 SharedResource 时,还没解除前就去锁定其他 SharedResource。就相当于握着汤勺而想去获取对方的叉子,或握着叉子想去获取对方的汤勺这些操作。
(3)SharedResource 角色是对等的,就像“拿汤勺-》拿叉子”与“拿叉子-》拿汤勺”两个操作都可能发生。就是说在这里汤勺与叉子没有优先级区别。
其实,(1)(2)(3)中只要破坏一种条件,就可以避免死锁。
临界区的大小与执行性能
一般来说,Single Threaded Execution Pattern 会使程序执行性能降低的原因有两个:
- 理由1:获取锁定要花时间
进入 synchronized 方法时,要获取对象的锁定,这个操作会花点时间。若能减少SharedResource 参与者的数量,就能减少需要获取的锁定数,可以减少性能低落的幅度。
- 理由2:线程冲突时必须等待
当线程 Alice 执行临界区内的操作时,其他要进入临界区的线程会被阻挡。这种状况称之为冲突。当冲突发生时,线程等待的时间就会使整个线程的性能往下掉。
尽可能缩小临界区范围,以减少出现线程冲突的机会,可抑制性能的降低。
这个 synchronized 在保护什么
在阅读代码的时候,看到 synchronized 时,就要思考:“这个 synchronized 是在保护什么?”
无论是 synchronized 方法还是 synchronized 快, synchronized 势必保护着“某个东西”。如在范例程序2中,pass 定义成 synchronized 方法。所保护的是Gate类的 counter,name,address 这些字段。使这些字段不会被多个线程同时访问。
在确认“保护什么”之后,接下来思考:“其他的地方也有妥善保护到吗?”
在范例程序2中,pass 方法和 toString 方法的确以 synchronized 保护着字段。但 chenk 方法也用到 name 字段与 address 字段,却没有定义成 synchronized。会不会影响程序的安全性能?
其实不会,因为只有 pass 方法会去调用 check 方法,而 pass 方法已经设置成 synchronized了。而且 check 方法又被设置成 private ,所以不会有其他类调用这个方法。所以 check 方法不需要设置成 synchronized 。
在范例程序2的范围内,toString 方法设不设置成 synchronized 方法都无损程序的安全性,之所以设置成 synchronized ,是担心 UserThread 类的线程在通过 pass 方法时,其他线程X调用 toString 。线程 X 在引用 name 字段的值后到引用 address 字段的值之间,UserThread 的线程可能会改掉 address 的值,这样一来,toString 方法很可能对线程使用name 与 address 第一个字母不一致的值来构成字符串。
获取谁的锁定来保护呢
在思考了前面两个问题后,我们这来看看这个问题:“获取谁的锁定来保护呢?”
要调用 synchronized 实例方法的线程,一定会获取 this 的锁定。一个实例的锁定,同一时间只能有一个线程可以得到。
使用 synchronized 快的时候,特别需要考虑“获取谁的锁定来保护的呢”这种情况。因为 synchronized 快需要明确地指明获取的是哪个对象的锁定。如
synchronized (obj) {
...
}
...
}
等这样的代码中,obj 就是我们所要获取锁定的对象。
原子的操作
synchronized 方法同时只有一个线程可以执行。从线程的角度看,是“原子的操作”。
long与double并不是原子的
java 语言规范中,一开始就定义了一些原子的操作。如,char,int 这些基本类型的赋值与应用操作时原子的。另,对象等引用类型的赋值与引用操作也是原子的。
java 语言规格上,long 与 double 的指定,引用并非不可分割。
如有一个 long 类型的 longField 字段,某个线程进行:longField=123L;而同时有另外一个线程执行:longField=456L。这样的操作 longField 的值会使什么,是无法保证的。可能是 123L 或 456L 也可能是223553L。当然,这里一直在强调是"java 语言规格"而已。实际上大部分的 java 执行环境都将 long 和 double 当作原子的操作来实现。
可以总结如下:
@ 基本类型,引用类型的指定,引用是原子的操作。
@ long 与 double 的指定,引用是可以分割的
@ 要在线程间共享 long 或 double 的字段时,必须在 synchronized 中操作,或是声明成volatile。