Single Thread Execution

本文介绍SingleThreadExecution模式,即独木桥模式,用于确保多线程环境下资源访问的安全性。通过示例展示如何使用synchronized关键字实现线程安全,探讨其适用场景与潜在问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Single Thread Execution

这里有一条独木桥。因为桥身非常的细,一次只能容许一个人经过。当一个人还没有走到桥的另一头,下一个人不可以过桥。如果桥上同时有两个人,桥身就会变成碎片而掉落河里了。

Single ThreadExecution是指“以1个线程执行”的意思。就像独木桥只能允许一个人通行一样,这个Pattern用来限制同时只让一个线程执行。

Single ThreadExecution有时候也称为CriticalSection(临界区:危险区域)或CriticalRegionSingle Thread Execution是把视点放在运行的线程(过桥的人)上所取的名称。而Critical SectionCritical Region则是把视点放在执行的范围(桥身)上所取的名称

1. 不使用Single Thread Execution Pattern的范例

在这里要写的程序,是要模拟3个人频繁地经过一个只能容许一个人经过的门。当人通过门的时候,这个程序会在计数器中,递增通过的人数。另外,还会记录通过的人的“姓名与出生地”。

程序使用到的类如表所示。

名称

解说

Main

创建一个门,并操作3个人不断地穿越门的类

Gate

表示门的类,当人经过时会记录姓名与出生地

UserThread

表示人的类,只负责处理不断地在门间穿梭通过

Main

Main类用来创建一个门(Gate),并让3个人(UserThread)不断通过。创建Gate类的实例,并将这个实例丢到UserThread类的构造器作为参数,告诉人这个对象“请通过这个门”。

有下面3个人会通过这个门:

Alice——Alaska(阿拉斯加)出生地

Bobby——Brazil(巴西)出生地

Chris——Canada(加拿大)出生地

在主线程中,先创建3UserThread类的实例,并以start方法启动这些线程。

publicclass Main {

 

  /**

   * @param args

   */

  publicstaticvoid main(String[] args) {

 

      Gate gate = new Gate();

 

      new UserThread(gate, "Alice", "Alaska").start();

      new UserThread(gate, "Bobby", "Brazil").start();

      new UserThread(gate, "Chris", "Canada").start();

  }

 

}

并非线程安全(thread-safe)的Gate

Gate类表示人所要通过的门。

counter字段表示目前已经通过这道门的“人数”。name字段表示通过门的行人的“姓名”,而address字段则表示通过者的“出生地”。

pass是穿越这道门时使用的方法。在这个方法中,会将表示通过人数的counter字段的值递增1,并将参数中传入行人的姓名与出生地,分别拷贝到name字段与address字段中。

this.name = name;

这行赋值(assignment)语句中,左方的name是这个实例的字段,而右方的name则是方法的参数。

toString方法,会以字符串的形式返回现在门的状态。使用现在的counternameaddress各字段的值,创建字符串。如No.123:Alice,Alaska

check方法,用来检查现在门的状态(最后通过的行人的记录数据)是否正确。当人的姓名(name)与出生地(address)第一个字符不相同时,就断定记录是有问题的。当发现记录有问题时,就显示下面的字符串:

***** BROKEN *****

并紧接着调用toString方法显示出现在门的状态。broken是“损坏”的意思。

这个Gate类,在单线程时可以正常运行,但在多线程下就无法正常执行。Gate类并不是线程安全(thread-safe)的类。

publicclass Gate {

 

  privateintcounter = 0;

 

  private String name = "noBody";

 

  private String address = "noAddress";

 

  publicvoid pass(String name, String address) {

 

      this.counter++;

 

      this.name = name;

 

      this.address = address;

 

      check();

 

  }

 

  @Override

  public String toString() {

 

      return"No." + counter + ":" + name + "," + address;

 

  }

 

  privatevoid check() {

 

      if (name.charAt(0) != address.charAt(0)){

 

         System.out.println("***** BORKEN ***** " + toString());

 

      }

  }

}

UserThread

UserThread类表示不断穿越门的行人。这个类声明成Thread类的子类。

gate字段表示所要通过的门,myname字段表示姓名,而myaddress字段表示出生地。因为各字段通过构造器进行初始化以后,就不会再次赋值了,所以设置为final将不想被重复赋值的字段设置成final,是撰写程序的好习惯。因为如果将字段预先设置成final,就算不小心在程序里写了重复赋值的程序代码,在编译程序的时候也会被检查出来。这种声明字段时不设置被始值,而在构造器中初始化的形式,在Java里被称为blank final(空的final

run方法一开始会显示自己的姓名与BEGIN字样。接着马上以while进入无穷循环,在循环里面反复地调用pass方法。也就是说,这个人只会在门里不断穿梭通过。

public classUserThread 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;

 

  }

 

  @Override

  public void run() {

 

      System.out.println(name + " " + "BEGIN");

 

      while (true) {

 

         gate.pass(name, address);

 

      }

 

  }

 

}

执行看看......果然出错了

执行这个程序时,会因为时间点不同,而产生不同的结果。

Alice BEGIN

Bobby BEGIN

Chris BEGIN

*****BORKEN ***** No.3404755:Alice,Alaska

***** BORKEN *****No.6371598:Alice,Alaska

***** BORKEN *****No.6903554:Bobby,Brazil

***** BORKEN *****No.11452977:Alice,Brazil

***** BORKEN *****No.13055171:Alice,Alaska

uGate类并非线程安全

首先,AliceBobbyChris创建时的BEGIN各自显示出来后,就开始不断出现*****BROKEN *****的消息了。这是Gate类的检查方法check所输出的字符串,当最后通过的人留下的姓名与出生地第一个出现的字母不同的时候就会显示出这个消息。如果仔细观察的话,会发现即使姓名与出生地的第一个字母都相同的时候,还是会显示这段消息(原因后面解释)。

u测试并无法证明安全性

仔细看counter的值,一开始显示BROKEN的时候,counter的值已经非常大,也就是,发生第一个错误的时候,程序已经运行很多次了。

在这里,因为UserThread类的run方法内跑的是无穷循环,所以才检查到错误,但如果只是简单的几次测试,不,就算是测试几万次,可能也不会找到错误。

多线程程序设计中,这就是一个较为困难的地方。如果测试时找到错误表示写好的程序并不安全。但是,就算测试没有找到错误,也并不能表示程序一定是安全的。当测试次数不够、时间点不对,就可能检查不到问题。一般来说,操作测试并不足以证明程序的安全性。操作测试(执行实验)所得到的结果,只不过表示“也许是安全的”可能性比较高。

u调试消息也不可靠

***** BORKEN *****No.3404755:Alice,Alaska

这里既然出现了BORKEN的错误消息,姓名(Alice)与出生地(Brazil)的开头字母不是应该要不相同才对吗?可是显示出BORKEN消息了,而调试消息却好像是正确的。

会发生这种现象,是因为某个线程正在执行check方法时,其他线程正不停地调用pass方法,name字段与address字段的值已经被更改了。

这也是多线程程序设计中较困难的地方。若调试消息的程序本身就并非线程安全,或许会显示出错误的调试消息。

Ø 补充说明:重新审查程序代码

连执行测试与调试消息,都无法保证程序的安全性,那要怎么办才好呢?这个时候就要重新审查程序代码。由多个人仔细阅读程序源代码、检查是否会产生问题,这是确保程序安全性最有效的方法。

为什么会出错呢?

之所以会显示出BROKEN的原因。这是因为pass方法可被多个线程调用的关系。pass方法是下面4行语句程序代码所组成的:

this.counter++;

this.name = name;

this.address =address;

check();

多个线程调用pass方法时,上面4行语句可能会是交错依次执行的。

上述程序之所以会显示出BROKEN,是因为线程并没有考虑到其他线程,而将共享实例的字段改写了。

2. 使用Single Thread Execution Pattern的范例

线程安全的Gate

需要修改的有两个地方,在pass方法与toString方法前面都加上synchronized。这样Gate类就成为线程安全的类了。

publicclass Gate {

 

  privateintcounter = 0;

 

  private String name = "noBody";

 

  private String address = "noAddress";

 

  publicsynchronizedvoid pass(String name, String address) {

 

      this.counter++;

 

      this.name = name;

 

      this.address = address;

 

      check();

 

  }

 

  @Override

  publicsynchronized String toString() {

 

      return"No." + counter + ":" + name + "," + address;

 

  }

 

  privatevoid check() {

 

      if (name.charAt(0) != address.charAt(0)){

 

         System.out.println("***** BORKEN ***** " + toString());

 

      }

  }

}

synchronized所扮演的角色

如前面所说,之所以会显示BROKEN,是因为pass方法内的程序可能会被多个线程交错执行。

synchronized方法,能够保证同时只有一个线程可以执行它。这句话的意思是说:当有一个线程执行pass方法的时候,其余线程就不能调用pass方法。在该线程执行完pass方法之前,其余线程会在pass方法的入口处被阻挡下。当该线程执行完pass方法,将锁定解除之后,其余线程才可以开始执行pass方法。

toString()方法为什么也要加synchronized

我们假设UserThread类的线程在通过pass方法时,其他线程X该线程不是UserThread线程)调用toString()。线程X在引用name字段的值之后到引用address字段的值之间,UserThread的线程可能会改掉address的值,这样一来,toString()方法很可能对线程使用nameaddress第一个字母不一致的值来构成字符串。

check()方法为什么不加synchronized

由于check()方法是private,所以绝对不可能从Gate之外直接调用。看看Gate类,调用check方法的都是已设成synchronized方法的pass方法。因此,不需要再将check方法设成synchronized方法。

3. Single ThreadExecution Pattern的所有参与者

uSharedResource(共享资源)参与者

Single ThreadExecution Pattern中,有担任SharedResource(共享资源)角色的类出现,Gate类就是这个SharedResource参与者。

SharedResource参与者是可由多个线程访问的类。SharedResource会拥有一些方法,而这些方法又分为下面两类:

SafeMethod——从多个线程同时调用也不会发生问题的方法;

UnsafeMethod——从多个线程同时调用会出问题,而需要加以防护的方法。

SafeMethod(安全的方法)并不需要特别处理。

UnsafeMethod(不安全的方法)则是被多个线程同时执行时,可能会使实例的状态产生矛盾的方法。所以必须加以防卫,使同时不能有多个线程同时执行这个方法。

Single Thread Execution Pattern中,我们将UnsafeMethod加以防卫,限制同时只能有1个线程可以调用它。在Java语言中,只要将UnsafeMethod定义成synchronized方法,就可以实现这个目标。

这个必须只让单线程执行的程序范围,我们称为临界区(critical section)。

4. 何时使用(适用性)

Single Thread ExecutionPattern该在什么情况下使用呢?

Ø 多线程时

单线程程序,并不需要使用Single Threaded Execution Pattern。因此,也不需要使用到synchronized方法。

即使在单线程程序中使用synchronized方法,也不会对程序的安全性造成危害。但是调用synchronized方法会比调用一般的方法多花一些时间,所以会使程序性能略微降低。

Ø 数据可能被多个线程访问的时候

需要使用Single Thread Execution Pattern的情况,是在SharedResource的实例可以同时被多个线程访问的时候。

就算是多线程程序,如果所有线程完全地独立运行,那也没有使用SingleThread Execution Pattern的必要。我们将这个状态称为线程互不干涉。

有些管理多线程的环境,会帮我们确保线程的独立性,这种情况下这个环境的用户就不必考虑使用Single Thread Execution Pattern

Ø 状态可能变化的时候

SharedResource参与者状态可能变化的时候,才会有使用Single Thread Execution Pattern的需要。

如果实例创建了以后,从此不会再改变状态,也没有使用Single Threaded Execution Pattern的必要。

Ø 需要确保安全性的时候

只有需要确保安全性的时候,才会需要使用Single Threaded Execution Pattern

例如,Java的集合架构在多半并非线程安全。这是为了在不考虑安全性的时候,可以让程序的运行速度较高。

所以用户需要考虑自己要用的类需不需要考虑线程安全再使用。

5. 生命性与死锁

接着要思考的是Single Thread Execution Pattern的生命性。

使用Single Thread Execution Pattern时,可能会有发生死锁(deadlock)的危险。

所谓的死锁,是指两个线程分别获取了锁定,互相等待另一个线程解除锁定的现象。发生死锁时,哪个线程都无法继续执行下去,所以程序会失去生命性。

多个线程僵持不下,使程序无法继续运行的状态,就称为死锁。

Single Thread Execution达到下面这些条件时,可能会出现死锁的现象。

1)具有多个SharedResource参与者。

2)线程锁定一个SharedResource时,还没解除前就去锁定另一个SharedResource

3)获取SharedResource参与者的顺序不固定(和SharedResource参与者是对等的)。

1)、(2)、(3)中只要破坏一种条件,就可以避免死锁的发生。

6. 可用性与继承异常

假设现在有人写了一个SharedResource参与者的子类,当SharedResource的字段开放给子类访问时,会有撰写子类的程序员可能写出没有防卫的UnsafeMethod。也就是说好不容易考虑安全性而写出的SharedResource参与者,还是有可能在子类化时丧失安全性。也就是说,包括子类,若非所有UnsafeMethod都定义成synchronized,就无法保证SharedResource参与者的安全性。

在面向对象程序设计中,负责进行子类化的“继承”担任了重要的角色。但是,对多线程程序设计来说,继承也会引起许多麻烦的问题。我们通常称之为继承异常(inheritance anomaly)。

7. 临界区的大小与执行性能

一般来说,Single Thread Execution Pattern会使程序执行性能低落的原因有两个:

Ø 理由(1):获取锁定要花时间

进入synchronized方法时,要获取对象的锁定,这个操作会花一点时间。

若能减少SharedResource参与者的数量,就能减少需要获取的锁定数,可以减少性能低落的幅度。

Ø 理由(2):线程冲突时必须等待

当某一个线程执行临界区内的操作时,其他要进入临界区的线程会被阻挡。这个状况称之为冲突(conflict)。当冲突发生时,线程等待的时间就会使整个程序的性能往下掉。

尽可能缩小临界区的范围,以减少出现线程冲突的机会,可抑制性能的降低。

8. 进阶说明:关于synchronized

synchronized语法与Before/After Pattern

无论是synchronized方法或synchronized块,是由“{”与“}”所括住的块构成的。

synchronized方法:

synchronized voidmethod() {

 

...

 

}

synchronized块:

synchronized(obj) {

 

...

 

}

上面两者,都可以视为在“{”处获取锁定,并在“}”处解除锁定。在下面我们对synchronized的程序代码,与明确操作“锁定”的程序代码加以比较。

假设有一个lock方法用来获取锁定,并有一个解除锁定用的unlock方法存在。

void method(){

  lock();

  ...

  unlock();

}

是不是认为“虽然程序代码看起来复杂点,不过与使用synchronized的程序代码没有太大的差异”呢?不,其实它们有着极大的差异。在上面的程序代码中省略的部分,是有可能会出现问题的。如果在调用lockunlock方法之间,存在有return语句的话,那锁定可能就不会被解除了。

void method(){

  lock();

  if(条件式){

      return;

  }

  unlock();

}

也许会认为“那小心不要有return语句就好了”。但其实问题不只有return而已,异常处理也是一个问题。调用的方法(或是调用的方法所调用的方法)抛出异常时,锁定也有可能没有解除。

doMethod抛出异常的话,锁定没被解除

void method(){

  lock();

  doMethod();//这里抛出异常的话,unlock()就不会被调用了。

  unlock();

}

相对地,synchronized方法和synchronized块,无论碰到return或是异常,都会确实解除锁定。

如果是成对的lock()unlock()方法,“想要在lock()被调用后,无论发生什么事,都要调用到unlock()”,就要像下面这样使用finally块。

lock()被调用后,无论发生什么事都要调用到unlock()

voidmethod(){

  lock();

  try{

      ...

  }finally{

      unlock();

  }

}

无论出现return、抛出异常或是发生其他什么事,都会执行到finally的部分,这是Java的规定。

这种finally的使用方法,是Before/AfterPattern(事前/事后Pattern)的一种实现方式。

这个synchronized在保护什么

无论是synchronized方法或是synchronized块,synchronized势必保护着“某个东西”。例如,前面我们将pass定义成synchronized方法。这个synchronized所保护的是Gate类的counternameaddress这些字段。使这些字段不会被多个线程同时访问。

确认“保护什么”之后,接下来应该要思考的是:

“其他的地方也有妥善保护到吗?”

若这些字段还在其他许多地方使用着,这里使用synchronized小心保护,但其他地方并没有做好保护措施,那其实这个字段还是没被保护的。就像小心翼翼地把大门跟后门都锁得好好的,如果窗户敞开,还是没有意义一样。

pass方法与toString方法的确以synchronized保护着字段。但check方法也有用到name字段与address字段,却没有定义成synchronized。这会不会变成敞开的窗户呢?

答案是不会。因为只有pass方法会调用check方法,而pass方法已经设置成synchronized了。而且check方法又被设置成private,所以不会有其他类调用这个方法,于是,check方法不需要设置成synchronized(设置成synchronized也没有关系,不过可能会降低程序的性能)。

所有可由多个线程共享,并会访问字段的方法,都应该设置成synchronized加以保护。

该以什么单位来保护呢

Gate中的赋值代码修改如下:

public synchronized voidsetName(String name){

  this.name = name;

 

}

 

public synchronized voidsetAddress(String address){

  this.address = address;

 

}

这些方法的确都设置成synchronized了。可是,加上这些方法以后,Gate类就不安全了。

因为Gate类,姓名跟出生地非得合在一起赋值不可。我们将pass方法设置成synchronized,也就是为了不要让多个线程穿插执行赋值的操作。然而,如果定义出setNamesetAddress这样的方法,线程对字段赋值的操作就被分散了。要保护Gate类时,若不将字段合在一起保护,是没有意义的。

获取谁的锁定来保护的呢

看到synchronized的时候,要先思考“synchronized是在保护什么”。接下来要更进一步地去思考:“获取谁的锁定来保护的呢?”。

要调用synchronized实例方法(instancemethod)的线程,一定会获取this的锁定。一个实例的锁定,同一时间内只能有一线程可以得到。因为这个惟一性,我们才能使用synchronized来做到SingleThread Execution Pattern

如果实例不同,那锁定也不同了。虽然我们说“使用synchronized来保护”,但如果有多个相异实例,那多个线程仍然可以分别执行不同实例的synchronized方法。

使用synchronized块的时候,特别需要考虑“获取谁的锁定来保护的呢”这种情况。因为synchronized块需要明确地指明要获取的是哪个对象的锁定。例如:

synchronized (obj){

 

  ...

 

}

这样的程序代码中,obj就是我们所要获取锁定的对象。请小心这个对象不可以写错,获取错误对象的锁定。

原子的操作

synchronized方法同时只有一个线程可以执行。当一个线程正在执行synchronized方法时,其他线程都不能进入这个方法。也就是这个synchronized方法所进行的操作,从多线程的角度来看,是“原子的操作(atomic operation)”。

为了让范例程序的Gate类线程安全,我们将pass定义成synchronized方法。也就是说pass方法就是所谓原子的操作。

longdouble并不是原子的

其实,Java语言规格中,一开始就定义了一些原子的操作。例如,charint这些基本类型(primitive type)的赋值与引用操作是原子的。另外,对象等引用类型(reference type)的赋值与引用操作也是原子的。因为本来就定义成不可分割,就算没有加上synchronized,也不会被分割。

例如,这里有一个int类型的字段n,而有某个线程进行:

n=123;

这样的赋值操作,而前后有别的线程也进行:

n=456;

这样的赋值操作。这时最后n的值不是123就会是456。并不用担心两个值的位模式会混在一起。

基本类型的指定、引用操作不可分割,但其实是有例外的。Java语言的规格上,longdouble的指定、引用操作并非不可分割。

例如有一个long类型的longField字段,某个线程进行:

longField=123L;

这样的指定操作,而同时有另一个线程执行:

longField=456L;

这样的指定操作。之后longField的值是什么,是无法保证的。也许是123L,也可能是456L,或许是0L,甚至还可能是31425926L。当然,这里所说的只是“Java语言规格”而已。实际上大部分的Java执行环境都将longdouble当作原子的操作来实现,但这也只能说是部分Java执行环境的实现就是了。

既然指定、引用longdouble的操作是可以分割的,若要在线程间共享longdouble的字段,那对字段进行操作时,就必须使用Single Thread Execution Pattern。最简单的方法,就是在synchronized方法内进行操作。

还有一种方法是将不使用synchronized,而在声明字段时,加上volatile关键字。在字段前加上volatile关键字,所有对这个字段的操作就成为不可分割。

总而言之,我们可以得到下面的结论。

l 基本类型、引用类型的指定、引用是原子的操作;

l 但是longdouble的指定、引用是可以分割的;

l 要在线程间共享longdouble的字段时,必须在synchronized中操作,或是声明成volatile

注:当线程正在指定字段的值时,若这个字段不是volatile,也没有以synchronized同步化,或许其他线程并非无法马上看到指定的结果。这不是可不可以分割(atomicity)的问题,而是其他线程能不能看到(visibility)的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值