现在的位置: 首页 > 综合 > 正文

线程同步

2013年03月12日 ⁄ 综合 ⁄ 共 9276字 ⁄ 字号 评论关闭

 

在前面的文章中,所涉及的线程大多都是独立的,而且异步执行。也就是说每个线程都包含了运行时自身所需要的数据或方法,而不需要外部的资源或方法,也不必关心其他线程的状态或行为。但是,有时候在进行多线程的程序设计中需要实现多个线程共享同一段代码,从而实现共享同一个私有成员或类的静态成员的目的。这时,由于线程和线程之间互相竞争CPU资源,使得线程无序地访问这些共享资源,最终可能导致无法得到正确的结果。例如,一个多线程的火车票预订程序中将已经预订过的火车票再次售出,这是由于当该车票被预订以后没有及时更新数据库中的信息而导致在同一时刻购买该火车票的另一乘客也将其预订。这一问题通常称为线程安全问题,为了解决这个问题,必须要引入同步机制,那么什么是同步,如何实现在多线程访问同一资源的时候保持同步呢?

解决思路

首先分析一个多线程的程序,在这个程序中各线程之间共享同一数据资源,从而模拟出火车站订票系统的处理程序,但是最后程序却出现了意料不到的结果。这个火车站订票系统不同步的模拟程序代码如下。

// 例4.5.1  NoSynchronizeDemo.java

class SaleTickets implements Runnable



private String ticketNo = "100750";  // 车票编号

private int ticket = 1;  // 共享私有成员,编号为100750的车票数量为1

public void run()

{

System.out.println(Thread.currentThread().getName()+" is saling

Ticket "+ticketNo);  // 当前系统正在处理订票业务

if(ticket>0)

{

 try   // 休眠0-1000毫秒,用来模拟网络延迟

{

Thread.sleep((int)(Math.random()*1000));

}catch(InterruptedException e){

e.printStackTrace();

}

ticket=ticket-1;     // 修改车票数据库的信息

// 显示当前该车票的预订情况

System.out.println("ticket's amount is left: "+ticket);



}

else

// 显示该车票已被预订

System.out.println("Sorry,Ticket "+ticketNo+" is saled");



}

}

class NoSynchronizeDemo   // 创建两个线程模拟两个订票系统的订票过程

{

public static void main(String[] args)

{

SaleTickets m = new SaleTickets();

Thread t1 = new Thread(m,"System 1");

Thread t2 = new Thread(m,"System 2");

t1.start();

t2.start();

}

}

程序的要求是创建两个线程模拟两个预订票子系统,这两个订票子系统将共享同一个数据库信息,只不过在这里数据库中只有一张编号为100750的车票。当该车票在其中一个系统中被预定以后,则不能再被其他订票系统预订,并且能够提示抱歉信息。

运行这个程序,观察输出的结果,如图4.5.1所示:

图 4.5.1  模拟两个订票系统的订票过程

可以发现同一时间有两个订票系统在处理编号为100750的车票的预订请求,结果由于网络延迟的影响,该车票被预订了两次,使得车票的数量变成了负数。出现这一严重问题的原因就是因为两个线程同时进入了共享代码区域。这个共享代码区域就是程序中的if…else语句的内容。

当线程t1执行到if(ticket>0)时,立即休眠若干毫秒,而此时线程t2得到执行,也执行到if(ticket>0),此时线程t1休眠结束继续执行下面的代码。经过两个线程的交替执行,最终完成预订服务,结果就出现了车票被预订了两次的严重错误。

程序中之所以在run()方法中使用sleep()方法,是因为在实际联网的预订票系统中确实存在着因为某些原因使得预定过程暂时中止的情况,而这一情况也极有可能发生在执行了if(ticket>0)语句之后,修改数据库信息之前。那么,如何避免这种情况发生呢?实际上在预订票系统中的这种暂时中止,瞬时延迟的情况是很普遍的。因此只能对程序做一些处理,使得一个预订票操作完全结束以后才能处理下一个预订票操作,以此来保证数据库的一致性和操作的正确性。Java提供了这样的方法,只需要在程序中引入同步机制就可以完全解决这类线程安全问题。

什么是同步呢?当两个或多个线程需要访问同一资源时,它们需要以某种顺序来确保该资源某一时刻只能被一个线程使用的方式称为同步。

要想实现同步操作,必须要获得每一个线程对象的锁。获得它可以保证在同一时刻只有一个线程访问对象中的共享关键代码,并且在这个锁被释放之前,其他线程就不能再进入这个共享代码。此时,如果还有其他线程想要获得该对象的锁,只得进入等待队列等待。只有当拥有该对象锁的线程退出共享代码时,锁被释放,等待队列中第一个线程才能获得该锁,从而进入共享代码区。

Java在同步机制中提供了语言级的支持,可以通过对关键代码段使用synchronized关键字修饰来实现针对该代码段的同步操作。实现同步的方式有两种,一种是利用同步代码块来实现同步,一种是利用同步方法来实现同步。下面将分别介绍这两种方法,并给出实际的例子。

 

 

具体步骤

(1)使用同步代码块

为了防止多个线程无序地访问共享资源,只需将对共享资源操作的关键代码放入一个同步代码块中即可。

同步代码块的语法形式如下所示:

synchronized(Object)

{

// 关键代码

}

其中,Object是需要同步的对象的引用。当一个线程欲进入该对象的关键代码时,JVM将检查该对象的锁是否被其他线程获得,如果没有,则JVM把该对象的锁交给当前请求锁的线程,该线程获得锁后就可以进入花括弧之间的关键代码区域。

对例4.5.1的程序进行修改,得到如下代码。

// 例 4.5.2  SynchronizeDemo.java

class SaleTickets implements Runnable



private String ticketNo = "100750";  // 车票编号

private int ticket = 1;  // 共享私有成员,编号为100750的车票数量为1

public void run()

{

System.out.println(Thread.currentThread().getName()+" is saling

Ticket "+ticketNo);  // 当前系统正在处理订票业务

// 下面同步代码块中的代码为关键代码,用synchronized关键字来标识

synchronized(this) 

{

if(ticket>0)

{

  try{   // 休眠0-1000毫秒,用来模拟网络延迟

Thread.sleep((int)(Math.random()*1000));

}catch(InterruptedException e){}

ticket=ticket-1;     // 修改车票数据库的信息

System.out.println("ticket is saled by

"+Thread.currentThread().getName()+", amount is: "+ticket);

// 显示当前该车票的预订情况

}

else

System.out.println("Sorry "+Thread.currentThread().getName()+", 

Ticket "+ticketNo+" is saled");   // 显示该车票已被预订

}

}

}

class SynchronizeDemo

{

public static void main(String[] args)

{

SaleTickets m = new SaleTickets();

Thread t1 = new Thread(m,"System 1");

Thread t2 = new Thread(m,"System 2");

t1.start();

t2.start();

}

}

本程序中,线程t1先获得该关键代码的对象的锁,因此,当线程t2也开始执行并欲获得关键代码的对象的锁时,发现该锁已被线程t1获得,只好进行等待。当线程t1执行完关键代码后,会将锁释放并通知线程t2,此时线程t2才获得锁并开始执行关键代码。运行这个程序,将看到如图4.5.2所示的结果。

图4.5.2  使用了同步代码块

可以看到几乎在同一时间两个系统都获得了预订票的指令,但是由于预订票子系统System1比System2 先得到处理,因此编号为100750的车票就必须先售给在System1提交预定请求的乘客。而在System2提交预定请求的乘客并没有得到编号为100750的车票,因此系统提示抱歉信息。

这一切正确地显示都源于引入了同步机制,将程序中的关键代码放到了同步代码块中,才使得同一时刻只能有一个线程访问该关键代码块。可见,同步代码块的引入保持了关键代码的原子性,保证了数据访问的安全。

程序中用到了this来作为同步的参数,这种方式会将整个对象都上锁,因为this代表了当前线程对象。因此同一时刻只能有一个线程访问共享资源。不过,也可以使用虚拟对象来上锁:

class SaleTickets implements Runnable





String str = " ";  // 创建一个空字符串来作为虚拟对象上锁

public void run()

{



synchronized(str)   // 同步代码块中的代码为关键代码

{



}

}

}

当需要判断关键代码的锁是否被某一线程所获取时,可以使用Thread类的静态布尔型方法holdsLock(Object o)来进行测试,其中参数o是与判断的关键代码锁所对应的对象的引用。如果某一线程已进入同步代码块或者同步方法,正在访问该对象的关键代码段,那么holdsLock()方法将返回一个布尔真值,否则,返回一个布尔假值。

 

(2)使用同步方法

同步方法和同步代码块的功能是一样的,都是利用互斥锁实现关键代码的同步访问。只不过在这里通常关键代码就是一个方法的方法体,此时只需要调用synchronized关键字修饰该方法即可。一旦被synchronized关键字修饰的方法已被一个线程调用,那么所有其他试图调用同一实例中的该方法的线程都必须等待,直到该方法被调用结束后释放其锁给下一个等待的线程。将例4.5.2的程序作一些改动得到下面的代码。

// 例 4.5.3  SynchronizeDemo2.java

class SaleTickets implements Runnable



private String ticketNo = "100750";  // 车票编号

private int ticket = 1;  // 共享私有成员,编号为100750的车票数量为1

public void run()

{

System.out.println(Thread.currentThread().getName()+" is saling

Ticket "+ticketNo);  // 当前系统正在处理订票业务

sale(); 

}

public synchronized void sale()   // 同步方法中的代码为关键代码

{

if(ticket>0)

{

 try   // 休眠0-1000毫秒,用来模拟网络延迟

{

Thread.sleep((int)(Math.random()*1000));

}catch(InterruptedException e){

e.printStackTrace();

}

ticket=ticket-1;     // 修改车票数据库的信息

System.out.println("ticket is saled by

"+Thread.currentThread().getName()+", amount is: "+ticket);

// 显示当前该车票的预订情况

}

else

System.out.println("Sorry "+Thread.currentThread().getName()+", 

Ticket "+ticketNo+" is saled");   // 显示该车票已被预订

}

}

class SynchronizeDemo2

{

public static void main(String[] args)

{

SaleTickets m = new SaleTickets();

Thread t1 = new Thread(m,"System 1");

Thread t2 = new Thread(m,"System 2");

t1.start();

t2.start();

}

}

运行程序,可以发现结果显示和例4.5.2完全相同,这就说明了利用同步方法也可以实现针对关键代码的同步访问。那么这两个方法有什么区别呢?

简单地说,用synchronized关键字修饰的方法不能被继承。或者说,如果父类的某个方法使用了synchronized关键字来修饰,那么在其子类中该方法的重载方法是不会继承其同步特征的。如果需要在子类中实现同步,应该重新使用synchronized关键字来修饰。

在多线程的程序中,虽然可以使用synchronized关键字来修饰需要同步的方法,但是并不是每一个方法都可以被其修饰。比如,不要同步一个线程对象的run()方法,因为每一个线程运行都是从run()方法开始的。在需要同步的多线程程序中,所有线程共享这一方法,由于该方法又被synchronized关键字所修饰,因此一个时间内只能有一个线程能够执行run()方法,结果所有线程都必须等待前一个线程结束后才能执行。

显然,同步方法的使用要比同步代码块显得简洁。但在实际解决这类问题时,还需要根据实际情况来考虑具体使用哪一种方法来实现同步比较合适。

专家说明

通过本节的学习,解决了多个线程之间共享同一数据资源时发生冲突的问题。解决的方法就是实现同步机制,解决的手段就是将欲访问的共享资源所在的关键代码封装到一个同步代码块或者同步方法中,使所有针对这一共享资源的操作成为互斥操作,这样就保证了同一时刻只能有一个线程访问其共享资源,从而保证了数据资源在多个线程之间的一致性和正确性,解决了线程安全问题。

专家指点

现在,虽然已经知道多个线程访问同一共享资源的时候会发生不同步的问题,但是还应该知道引起不同步的现象的根本原因是什么。

那么,到底是什么引起了不同步呢?这个问题可以从两个方面来回答。第一,在一个单处理器的计算机中,线程的执行由一个处理器来调度,由于处理器的时间片调度原则,决定了一个线程仅能执行一定的时间。这样,在其他时间里,其他线程也可以执行。第二,在一个单处理器的计算机中,一个线程的执行时间可能没有足够长到其他线程开始执行关键代码前执行完自己的关键代码。

解决这个问题的办法就是将多个线程间所共享的关键代码封装起来,放到一个同步代码块或者同步方法中,把其当作一个原子操作来实现就可以了,因为原子操作是安全的。那么什么是原子操作呢?原子操作就是计算机在执行指令过程中不可分割的最小指令单元。比如声明变量的操作、给变量直接赋值的操作,这些都是原子操作,这些操作是安全的。在多线程的程序中,一旦将某个关键代码封装成一个原子操作,那么对它们的操作就不会存在不同步的情况。

对于原子操作的要求是很苛刻的,有些读者可能以为像i=i+1这样的操作也是原子级的操作,便随意的在多个线程所共享的关键代码中使用,殊不知这样做也是很危险的。因为虽然i是int型的,而这个类型的变量也是个原子型的变量,但是对于i=i+1这样的操作,通过右图的执行过程就可以看到,整个操作在中间部分还是能够被中断的。

在执行过程中,能够发现,第三步到第五步之间的操作是可以在多线程的程序中被打断的。

解决的办法就是将这些操作封装在同步代码块中或者同步方法中。例如:

public synchronized add(){i=i+1; }

或者

synchronized(Object){ i=i+1;}

 

有些Java的数据类型也不能保证原子性的特点,因而也会出现不同步的情况,比如长整型和双精度浮点型。JVM是32位的,它只能用临近的两个32位的步长访问一个64位的长整型数据或者一个64位的双精度浮点型数据。这样一来,就有可能出现问题。

比如,一个线程访问了第一个32位以后被阻塞,这时其他线程执行,当该线程再次得到执行去完成访问第二个32位时,这个64位的数据可能已经被其他线程改变,结果可想而知,数据处理发生了错误。解决这个问题的办法就是在long和double变量之前使用volatile修饰符。因为volatile修饰符可以使其修饰的变量保持本地的拷贝与主内存一致,因此有时候还将使用volatile修饰符修饰变量的方法称为“变量的同步”。

使用同步机制使得一个完整的操作被封装起来,就像一个盒子,一次只能有一个线程进入,这样做有助于防止对象的状态受到破坏,提高了安全性。但是,使用同步机制也有其不足的地方,比如,同步的时间代价很高。这是因为,同步机制中线程之间在获得和释放互斥锁的时候需要花费相当的时间,这些时间的消耗会降低程序的性能。因此,当问题中可以确定不需要使用同步机制来解决时,一定不要引入同步方法或者同步代码块。

其实,大部分同步是可以避免的,比如,只访问本地变量(即在方法体内声明的变量),而不操作类成员,也不去修改外部对象的方法,就不需要使用synchronized关键字来修饰。对于那些非原子级的对象,也可以通过将其声明为final类型来保证该对象不可改变,从而避免同步。当然,并不是要为了避免同步而把所有的对象都声明为final,因为有些对象本身就是原子级的,比如一个String对象就是原子级对象。此外,还可以通过将一些对象声明为不变性(volatile)对象来避免同步,比如当程序中需要频繁地只读访问一个对象时,将其声明为一个不可变对象就可以很好地避免同步发生。

相关问题

在处理线程同步时还需要注意一个问题,那就是死锁。死锁是多线程程序最常见的问题之一,那么什么是死锁,为什么会发生死锁呢?

死锁问题:即由于两个或多个线程都无法得到相应的锁而造成的两个线程都等待的现象。这种现象主要是因为相互嵌套的synchronized代码段而造成。

例如,在某一多线程的程序中有两个共享资源A和B,并且每一个线程都需要获得这两个资源后才可以执行。这是一个同步问题,但是如果没有合理地安排获取这些资源的顺序,很有可能发生下面的情况:

线程1已经获取资源A的锁,由于某种原因被阻塞,此时线程2启动并获得资源B的锁,再去获得资源A的锁时发现线程1已经获取,因此等待线程1释放A锁。线程1从阻塞中恢复以后继续执行,欲获取资源B的锁,却发现B锁已被线程2获得,因此也陷入等待。在这种情况下,程序已无法向前推进,在没有外力的情况下,也不会自动退出,因而造成了严重的死锁问题。来看下面这个程序,在这个程序中,创建了两个独立的线程,但是这两个线程都需要同时拥有资源A和资源B方可执行,由于资源获取的顺序不合理,从而造成了死锁的发生。

// 例4.5.4  DeadLockDemo.java

class ShareString   // 封装了两个共享资源

{

public static String str1="A";

public static String str2="B";

}

class SynchDeadLock1 extends Thread



SynchDeadLock1(String name)

{

super(name);



public void run()

{

synchronized(ShareString.str1)

{

System.out.println(getName()+" have resource A");

System.out.println(getName()+" is waitiing resource B...");

try

{

Thread.sleep(1000);

}catch(InterruptedException e){ }

synchronized(ShareString.str2)

{

System.out.println(getName()+" have resource B");

System.out.println("Result is "+ShareString.str1

+ShareString.str2);

}

}

}

}

class SynchDeadLock2 extends Thread



SynchDeadLock2(String name)

{

super(name);



public void run()

{

synchronized(ShareString.str2)

{

System.out.println(getName()+" have resource B");

System.out.println(getName()+" is waitiing resource A...");

try

{

Thread.sleep(500);

}catch(InterruptedException e){ }

synchronized(ShareString.str1)

{

System.out.println(getName()+" have resource A");

System.out.println("Result is "+ShareString.str2

+ShareString.str1);

}

}

}

}

class DeadLockDemo

{

public static void main(String[] args)   // 创建两个独立的线程

{

SynchDeadLock1 s1 = new SynchDeadLock1("Mission 1");

SynchDeadLock2 s2 = new SynchDeadLock2("Mission 2");

s1.start();

s2.start();

}

}

程序运行的结果如图4.5.3所示:

图4.5.3  死锁演示

可以看到,两个任务由于获取资源的顺序不合理,从而造成了互相不能推进的局面,也就是这里所说的死锁。欲打破这种局面,需要按Ctrl+C来终止程序。为了避免这个问题,可以改善获取资源的顺序,以合理的方式获取资源。但是,有时候,现实问题又要求必须按照这种顺序获取资源,即线程1必须先获取资源A后才可以获取资源B,而线程2又必须先获取资源B后方可获取资源A,那么此时死锁将不可避免。就语言本身来说,Java尚未直接提供防止死锁的帮助措施,需要程序员通过谨慎地设计来避免。例如,使用同步封装器,其原理来自设计模式中的Decorator模式,具体实现请读者参考有关书籍。此外,也可以通过在程序中尽可能少用嵌套的synchronized代码段来避免线程死锁。

 

抱歉!评论已关闭.