陈益 王佩
摘要:为了避免Java应用程序中多个线程共享同一个资源时产生访问冲突,确保线程安全,采用同步机制为每个线程合理地分配访问资源。编写一个模拟火车站售票过程的Java应用程序,由4个线程完成100张火车票的出售,调用sleep方法查看非同步环境下每个线程访问资源的状况。分析多线程采用同步机制和非同步机制的实验给系统带来的影响。实验证明,借助同步机制能合理地为每个线程提供没有任何冲突的资源访问,使Java多线程程序获得更好的健壮性。
关键词:Java多线程;访问资源;线程安全;同步机制;健壮性
Java Multi thread Safety Problems Application Analysis Based on Synchronized Mechanism
CHEN Yi, WANG Pei
(School of Science, Hubei University of Technology, Wuhan 430068,China)
Abstract:In order to avoid access conflicts when multiple threads in a Java application share the same resource and ensure thread safety, a synchronized mechanism is used to reasonably allocate access resources for each thread. We write a Java application that simulates the ticket sales process at the train station. The sale of 100 train tickets are completed by 4 threads, and the sleep method is called to check the status of each thread accessing resources in an asynchronous environment. The impacts of the experimental results of multi threaded synchronization and non synchronization mechanisms on the system are analyzed. Experiments show that the synchronization mechanism can reasonably provide each thread resource access without conflicts, which makes Java multi threaded program execution more robust.
Key Words:Java multi thread;access to resources;thread safety;synchronization mechanism;robustness
0 引言
Java是一種在语言级提供支持多线程程序设计的编程语言。Java多线程表现灵活、应用复杂、不易掌握。处理不好,不仅不能发挥其优势,还会引发一些线程安全方面的问题,线程安全是由CPU控制多个线程对某个资源的有序访问。多线程安全访问过程指当一个线程访问该类某个数据时,需加锁进行保护使其它线程不能访问,直至该线程读取完成,以免出现数据不一致或数据污染[1]。Hashtable引入多线程安全机制,因本身并没有实现序列化接口,所以多线程机制中线程并不安全。多线程的安全访问必须借用同步(synchronized)加锁机制,确保线程间访问安全[2]。
单线程只有一条从头至尾的执行线索,CPU资源利用率不够充分,多线程最大限度地利用CPU资源,当某一线程的处理不需要占用CPU资源时,可以让其它线程有机会获取CPU资源,节省时间,提高系统执行效率[3]。多线程还可将任务分块后同时执行,效率更高、利用率更优、程序更简单、响应速度更快。
1 多线程概念、Java多线程创建方式与同步机制
线程最早出现在操作系统中,是程序的一个执行流,称为轻量级进程,由操作系统调度。任何一个Java应用程序至少有一个单线程,称为main主线程[4]。多线程是实现并发机制的一种手段,指一个程序包括多个执行流,多个线程共享一个进程存储空间。
Java中创建多线程主要有两种方式:继承自Thread类和实现Runnable接口。Java语言引入包的概念,方便了类的继承和接口的实现,Thread类和Runnable接口来自于Java.lang包,是Java API中唯一一个不需要用户导入便可以直接使用其中的类和接口的包[5]。
继承Thread创建一个新的线程,首先用关键字class声明一个类,使其继承Thread类,用new关键字创建一个类的对象,由对象调用Thread类中start方法,启动创建的新线程,最后Java虚拟机(JVM)调用线程的run方法,用户在run方法中的功能被执行。如果把main方法称为Java程序入口方法,则run方法为多线程入口方法。继承Thread类创建多线程的方法简单,但Java中只提供了对类的单继承操作,若一个类已经继承了另一个类,便不能再继承Thread类。用一个声明好的类去实现Runnable接口是创建Java多线程的另一种方法[6]。Runnable接口中只有一个run方法,一个类去实现Runnable接口,必须在实现类中重新定义run方法。实现类中重新定义run方法的访问权限不能低于Runnable接口中run方法的访问权限,否则程序编译会报错。由实现了Runnable接口所创建多个线程的类,既可以继承其它类,还能实现其它接口,每个接口之间用逗号进行分隔,增强类的功能,在一个类中包容所有代码,增加逻辑性,便于封装[7]。
比较创建多线程的两种方法,发现其行为一致。通常情况下,如不需要修改线程类中除了run方法之外的其它行为,一般用实现Runnable接口的方式创建新线程,实现Runnable接口对多个线程访问同一个资源极为方便。本文模拟系统以实现Runnable接口使用Java多线程技术进行操作。当Java多个线程共享同一个资源时,在并发运行过程中可能会同时访问“临界区”,为确保线程间访问安全,必须用线程同步操作对“临界区”共享资源一致性进行维护。关键字同步有同步块和同步方法两种方式[8],保证了线程间同步。
2 多线程同步机制
2.1 访问共享资源引发线程安全问题
假如有100张火车票可以出售,由4个售票窗口同时为旅客服务,完成售票过程。4个售票窗口需要创建4个新线程,新线程的创建由一个类实现Runnable接口完成。一个类继承自Thread类能创建新线程,就Thread类本身而言,它实现了Runnable接口,用户可以将由实现类创建的对象作为Thread类的参数传递进来,创建一个新线程,调用Thread类start方法启动线程,如图1中矩形标注所示。编写一个完整的Java应用程序完成火车票售票工作模拟过程的源程序段如图1所示,图2是执行结果的一部分。
由图2的结果可以发现,用户在执行程序后,所售票号不连续且呈无序状态,导致同一张票由多个线程同时出售,比如100、99张票都分别有多个线程出售,如图2标注所示,这是一个显式的线程访问安全问题。还有一个更致命的隐式问题:当剩下最后1张票时,由于时间片的缘故,会打印0、 -1、-2等不正确的票据格式。因为4个线程共享同一个资源,引发了线程间访问安全问题。4个线程共享同一个tickets变量的状况,如图1中椭圆形标注所示,显示的安全问题如图2标注所示。对于隐藏的访问安全问题,用户只需在源程序SellThread类run方法中调用sleep方法,让执行的线程睡眠10ms,所有的错误便能清晰地显示出来,对sleep方法的调用会产生异常,需要用try/catch进行处理,源代码段如图3矩形框内标注所示,执行结果如图4矩形框内标注所示。
由图1和图3源程序中的if代码段可知,该代码区域为“临界区”,指在一个多线程程序中,单独、并发的线程访问代码段中的同一资源,代码段被称为“临界区”。当多个线程共享同一个资源,方便的同时也存在访问安全的风险。Java多线程利用同步机制协调管理“临界区”,以确保线程访问安全[9]。
2.2 同步机制保证线程间访问安全
Java中的同步机制保证线程间的访问安全,具体操作分同步块和同步方法两种,都需借助synchronized关键字完成[10]。同步块需要在“临界区”的前面加上synchronized關键字并为之配备对象锁,锁可以是任意对象,把“临界区”内容放在对象锁可控范围内;同步方法需要在一个方法前面加上synchronized,表示方法是同步方法,将“临界区”放入该方法中执行[11]。为保证线程间访问安全,同步方法包括3个步骤:首先在一个普通方法前面加上synchronized修饰符,变成同步方法,将“临界区”的信息放入同步方法中,最后用线程入口run方法调用同步方法,即可获得正确结果。同步块和同步方法的应用保证线程访问安全的源代码段如图5、图6矩形框和椭圆形框标注所示。
同步块和同步方法都用synchronized修饰符,源程序表述状态不同,但执行结果一致。多线程同步原理是用synchronized关键字对“临界区”加以保护,保证结果的正确性,上例中所有票销售一空,每张票由一个线程所售,票号连续且按由大到小的顺序排列。具体过程如图7标注所示。
图7 用synchronized锁确保线程访问安全
同步机制的执行,是因为Java中引入了“互斥锁”(监视器)。互斥可以看作是一种特殊的同步,同步是一种更为复杂的互斥[12]。本文重点讨论如何借助同步保证线程间的安全,至于同步和互斥的区别与联系在此不进行更深入的研究。每个对象都有一个“互斥锁”标志,锁的作用是保证在任意时刻,只有一个线程访问该对象,即关键字synchronized与对象的锁联系。当某个对象由synchronized修饰时,实现对临界资源的互斥操作,被同步synchronized锁定的代码段称为“临界区”,每个线程必须获取到临界资源所有权才能执行[13]。
同步块实施过程(见图5)包括:当线程1进入“临界区”时,先给obj对象的监视器加锁,执行程序后面的代码,到达sleep方法时,线程1睡眠了10ms。线程2接着运行,到达同步对象时obj的监视器已被加锁,无法进入,JVM将其放入等待区域中,以此类推线程3,线程4也会被放入等待区域中。线程1的10ms睡眠状态结束后,继续往后执行,直到代码结束为止,obj对象的监视器才被解锁,由等待区域的线程2获得锁进入到同步代码段中。由此形成了多个线程对同一个对象的“互斥”使用方式,该对象称为“同步对象”。
图6的同步方法也需要加锁,它是给类中的一个this变量的监视器加锁,即给this对象的监视器加锁。当线程1进入同步方法时,首先查看this对象的监视器(this对象的锁)是否加锁,加锁后进入到方法内部,当它睡眠时线程2开始运行,因为this对象的监视器已加锁,它只能等待,同样进入等待队列的还有线程3、线程4。当第一个线程睡醒后执行完剩余的代码返回时,将this对象的监视器解锁,线程2才能进入。
2.3 多线程同步块与同步原理分析
同步分为共享式和分布式两种,指有多个线程在“临界区”上等待消息但互相排斥。Java多线程引用synchronized关键字锁定“临界区”,每个对象都有一个监视器(互斥锁),每个线程首先要获得监视器,才能进入synchronized锁保护的“临界区”,执行完“临界区”内容后释放监视器。期间若某个线程想要获取的监视器被其它线程占用,该线程会被JVM放入等待区域中,直到监视器被占用的线程释放后,该线程才能进入到同步“临界区”执行代码段[14]。Java多线程对“临界区”的保护一般以系统通过同步块或同步方法给对象加锁的方式实现。
(1)同步块实现线程同步。通过synchronized关键字声明同步块。同步块是一个代码段,内容是“临界区”,通过同步块对“临界区”加锁保证线程执行过程的正常秩序,“临界区”必须要在线程获得对象obj的锁后才能执行,对象锁可以是任意的。执行状态如下:
synchronized(obj)
{ //“临界区”内容 }
(2)同步方法实现线程同步。对一个普通方法用synchronized关键字修饰,即变成同步方法。同步方法的访问是给类中this变量对象的监视器加锁,同步方法须在获得调用该方法类的对象监视器后才能执行。同步方法一旦执行,即独占监视器,直到从该方法返回时将监视器释放,之前等待的线程才可获得监视器,进入可运行状态。同步机制确保了同一时刻对于每一个类实例,其所有声明为synchronized的成员方法中至多只有一个处于可运行状态,有效解决了多线程间的访问安全问题[15]。执行状态如下:
public synchronized void sell()
{ //“临界区”内容 }
同步块和同步方法的执行过程类似,但同步块的执行灵活性更高。
关于同步机制的监视器(互斥锁),还有一种情况需要加以说明,图6同步方法的this对象监视器不适用于同步静态方法。在一个类中,有一个静态方法访问了一个静态变量,而该静态方法又要被多个线程同时访问,用户需要对该静态方法的访问进行同步。静态方法只属于类本身,不属于某个对象,调用静态方法时并不需要产生类的对象。因此静态方法的访问同步使用的监视器既不是同步方法中this对象的监视器,也不是同步块中任意对象的监视器。每个class也有对应对象的一把锁。每个类都对应一个class对象,同步静态方法使用的是方法所在的类对应的Class对象的监视器[[16]。
3 同步关联问题
多线程同步时,需要注意两个问题:同步失效和线程锁死。
3.1 同步失效
同步的正常執行过程是,一个线程获得该对象的监视器后进入“临界区”,完成所有工作,退出“临界区”并释放监视器;下一个线程获得监视器,进入“临界区”,所有线程共享同一个监视器,避免多个线程同时进入“临界区”,产生访问冲突。
如果每个线程都有属于自己独占的监视器,则每个线程都无须等待另一个线程释放共享的监视器,每个线程才都能进入“临界区”,该设想与图1中源程序不加监视器的情况一致,此时线程同步失效。为避免该情况发生,使所有线程共享同一个监视器,确保在任意时刻只有获得了监视器的线程能进入“临界区”[17]。
run方法是多线程入口方法,任何线程都需要运行run( )方法,所以线程run( )方法不能同步,多线程同步同一个对象,每个时间只有一个线程可以执行run( )方法,若同步了run( )方法,每个线程必须等待前一个线程运行结束后才开始,此时也会产生同步失效。
3.2 线程死锁
死锁是指两个或者多个线程被永久阻塞的一种局面,产生的前提是有两个或多个线程操作两个或多个共同资源[18]。Java多线程同步机制可能出现两个线程的情况,两个线程分别独占一个监视器,线程1锁住了对象A的监视器,等待对象B的监视器,线程2锁住了对象B的监视器,等待对象A的监视器。此时每个对象仅凭自己的监视器无法完成工作,必须借助另一个对象的监视器,因此,每个对象都在等待对方先释放自己的监视器,以此获得先执行的机会。事实上,每个对象都不愿释放自己的监视器,先成就对方、自己再获得执行,最终导致了死锁[19]。
4 结语
本文以由4个窗口售出100张火车票的过程为例,展示了Java多线程节省时间、优化资源利用率、提高系统执行效率等优点。但多线程问题繁多、表现复杂,要保证售票过程中CPU能为4个线程有序地分配资源,确保多个线程共享同一个数据时,线程间访问秩序良好,执行结果正确,需借助Java多线程同步块或同步方法加锁的措施保证线程间访问安全。经实验证明,同步加锁能解决Java多个线程共享同一个数据时潜在的线程安全问题,Java多线程执行不仅具有高效性,利用同步机制还能维护其稳定性和健壮性[20]。
参考文献:
[1] HYDE P.Java线程编程[M].周良忠,译.北京:人民邮电出版社,2003.
[2] OAKS S,WONG H.Java线程[M].第二版.黄若波,等,译.北京:中国电力出版社,2003.
[3] 结城浩.JAVA多线程设计模式[M].北京:中国铁道出版社,2005.
[4] 吴红萍.Java的多线程机制分析与应用[J].软件导刊,2014(1):114 116.
[5] 回健永.基于Java语言的多线程机制的实现[J].天津职业院校联合学报,2011,13(8):58 61.
[7] 孙超.Java语言中多线程的实现[J].佳木斯教育学报,2011(2):428 429.
[8] 张冬姣,孟庆伟,王萍.基于Java多线程的并行计算技术研究及应用[J].科学中国人,2014(10):15 16.
[9] 杨军.多线程在Java中的应用及线程同步安全问题的解决方法[J].硅谷,2010(16):153 154.
[10] 李娟.Java多线程同步机制研究分析[J].中国科教创新导刊,2014(7):183 184.
[11] 路勇.Java多线程同步问题分析[J].软件,2012,33(4):31 33.
[12] 张步忠.Java语言中的线程同步互斥研究[J].安庆师范学院学报:自然科学版,2011(4):106 110.
[13] 耿祥义,张跃平.Java2实用教程[M].第5版.北京:清华大学出版社,2017.
[14] 耿祥义,张跃平.Java2实用教程[M].第4版.北京:清华大学出版社,2012.
[15] 张桂珠,张跃平,刘丽.Java面向对象程序设计 [M].第3版. 北京:北京邮电大学出版社.2010.
[16] 叶核亚.Java2程序设计实用教程 [M].第2版.北京:电子工业出版社,2007.
[17] ECKEL B.Java编程思想[M].第4版.陈昊鹏,译.北京:机械工业出版社,2007.
[18] YUYX.Java编程之多线程死锁与线程间通信简单实现代码[EB/OL].http:∥www.jb51.net/article/126852.htm.
[19] Java红茶.Java多线程之死锁的出现和解决方法[EB/OL].http:∥www.jb51.net/article/126410.htm.
[20] 林炳文.Java多线程学习[EB/OL]. https:∥www.cnblogs.com/GarfieldEr007/p/5746362.html.