张勇
(宿州职业技术学院计算机信息系,安徽宿州234101)
Java多线程是提高程序效能的利器,对于如何开发多线程的程序,已经有了很多的研究。本文并不是告诉您如何编写多线程Java程序,而着重于研究多线程的并发控制以及如何描述线程执行的过程,线程运行的机制,线程同步的必要性,和线程同步的解决方法。因为只有完全掌控Java多线程执行的过程,明白线程运行的机制,才能开发出高安全性的Java应用程序。
不同的平台,内存模型是不一样的,但是JVM的内存模型规范是统一的。其实Java的多线程并发问题最终都会反映在Java的内存模型上,所谓线程安全无非是要控制多个线程对某个资源的有序访问或修改。其实Java的内存模型就是要解决两个主要的问题:可见性和有序性。我们都知道计算机有高速缓存的存在,处理器并不是每次处理数据都是取内存的。JVM定义了自己的内存模型,屏蔽了底层平台内存管理细节,对于Java开发人员,要清楚在JVM内存模型的基础上如何解决多线程的可见性和有序性[1]。
在JAVA程序的执行过程中,线程不能直接为主存中的字段赋值,它会将值指定给工作内存中的变量副本(assign),完成后这个变量副本会同步到主存储区(store-write),至于何时同步到主存,根据JVM实现系统决定。有些字段,则会从主内存中将该字段赋值到工作内存中,这个过程为read -load,完成后线程会引用该变量副本,当同一线程多次重复对字段赋值时,如
线程有可能只对工作内存中的副本进行赋值,直到最后一次赋值后才同步到主存储区,所以assign,store,weite顺序可以由JVM实现系统决定。
假设有一个共享变量x,线程A执行x=x+ 1。从上面的描述中可以知道x=x+1并不是一个原子操作,它的执行过程如下:从主存中读取变量x副本到工作内存→给x加1→将x加1后的值写回主存,如果另外一个线程B执行x=x-1,执行过程如下:从主存中读取变量 x副本到工作内存→给x减1→将 x减1后的值写回主存。那么显然,最终的 x的值是不可靠的。假设 x现在为10,线程A加1,线程B减1,从表面上看,似乎最终x还是为10,但是多线程情况下会有这种情况发生:
1)线程 A从主存读取x副本到工作内存,工作内存中x值为10。
2)线程B从主存读取x副本到工作内存,工作内存中x值为10。
3)线程A将工作内存中x加1,工作内存中 x值为11。
4)线程A将x提交主存中,主存中x为11。
5)线程B将工作内存中x值减1,工作内存中x值为9。
6)线程B将x提交到中主存中,主存中x为9。
同样x有可能为11,如果x是一个银行账户,线程A存款,线程 B扣款,显然这样是有严重问题的,要解决这个问题,必须保证线程A和线程B是有序执行的,并且每个线程执行的加1或减1是一个原子操作。
上面说了,Java用synchronized关键字做为多线程并发环境的执行有序性的保证手段之一。当一段代码会修改共享变量,这一段代码成为互斥区或临界区,为了保证共享变量的正确性,synchronized标示了临界区。典型的用法如下:
为了保证银行账户的安全,可以操作账户的方法如下:
那么对于public synchronized void add(int num)这种情况,意味着什么呢?其实这种情况,锁就是这个方法所在的对象。同理,如果方法是public static synchronized void add(int num),那么锁就是这个方法所在的class。理论上,每个对象都可以做为锁,但一个对象做为锁时,应该被多个线程共享,这样才显得有意义,在并发环境下,一个没有共享的对象作为锁是没有意义的。
每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列,就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程,当一个线程被唤醒(notify)后,才会进入到就绪队列,等待cpu的调度。当一开始线程A第一次执行account.add方法时,JVM会检查锁对象account的就绪队列是否已经有线程在等待,如果有则表明account的锁已经被占用了,由于是第一次运行,account的就绪队列为空,所以线程A获得了锁,执行account.add方法。如果恰好在这个时候,线程b要执行account.withdraw方法,因为线程 A已经获得了锁还没有释放,所以线程 B要进入account的就绪队列,等到得到锁后才可以执行。
一个线程执行临界区代码过程如下:获得同步锁→清空工作内存→从主存拷贝变量副本到工作内存→对这些变量计算→将变量从工作内存写回到主存→释放锁,可见,synchronized既保证了多线程的并发有序性,又保证了多线程的内存可见性[3]。
生产者/消费者模式其实是一种很经典的线程同步模型,很多时候,并不是光保证多个线程对某共享资源操作的互斥性就够了,往往多个线程之间都是有协作的。
假设有这样一种情况,有一个桌子,桌子上面有一个盘子,盘子里只能放一颗鸡蛋,A专门往盘子里放鸡蛋,如果盘子里有鸡蛋,则一直等到盘子里没鸡蛋,B专门从盘子里拿鸡蛋,如果盘子里没鸡蛋,则等待直到盘子里有鸡蛋。其实盘子就是一个互斥区,每次往盘子放鸡蛋应该都是互斥的, A的等待其实就是主动放弃锁,B等待时还要提醒A放鸡蛋。
如何让线程主动释放锁,很简单,调用锁的wait()方法就好。wait()方法是从Object来的,所以任意对象都有这个方法。
如果一个线程获得了锁lock,进入了同步块,执行lock.wait(),那么这个线程会进入到lock的阻塞队列。如果调用lock.notify()则会通知阻塞队列的某个线程进入就绪队列。
声明一个盘子,只能放一个鸡蛋。
声明一个Plate对象为plate,被线程A和线程B共享,A专门放鸡蛋,B专门拿鸡蛋。
假设(1)开始,A调用plate.putEgg方法,此时eggs.size()为0,因此顺利将鸡蛋放到盘子,还执行了notify()方法,唤醒锁的阻塞队列的线程,此时阻塞队列还没有线程。(2)又有一个A线程对象调用plate.putEgg方法,此时eggs.size()不为0,调用wait()方法,自己进入了锁对象的阻塞队列。(3)此时,来了一个B线程对象,调用plate.getEgg方法,eggs.size()不为0,顺利的拿到了一个鸡蛋,还执行了notify()方法,唤醒锁的阻塞队列的线程,此时阻塞队列有一个A线程对象,唤醒后,它进入到就绪队列,就绪队列也就它一个,因此马上得到锁,开始往盘子里放鸡蛋,此时盘子是空的,因此放鸡蛋成功。(4)假设接着来了线程A,就重复2;假设来料线程B,就重复3。整个过程都保证了放鸡蛋,拿鸡蛋,放鸡蛋,拿鸡蛋。
volatile是Java提供的一种同步手段,只不过它是轻量级的同步,为什么这么说,因为volatile只能保证多线程的内存可见性,不能保证多线程的执行有序性。而最彻底的同步要保证有序性和可见性,例如synchronized。任何被volatile修饰的变量,都不拷贝副本到工作内存,任何修改都及时写在主存。因此对于valatile修饰的变量的修改,所有线程马上就能看到,但是volatile不能保证对变量的修改是有序的[4]。假如:
当一个VolatileTest对象被多个线程共享,a的值不一定是正确的,因为a=a+count包含了好几步操作,而此时多个线程的执行是无序的,因为没有任何机制来保证多个线程的执行有序性和原子性。volatile存在的意义是,任何线程对a的修改,都会马上被其他线程读取到,因为直接操作主存,没有线程对工作内存和主存的同步。所以,volatile的使用场景是有限的,在有限的一些情形下可以使用volatile变量替代锁。要使 volatile变量提供理想的线程安全,必须同时满足下面两个条件[5]。
(1)对变量的写操作不依赖于当前值。
(2)该变量没有包含在具有其他变量的不变式中。
volatile只保证了可见性,所以Volatile适合直接赋值的场景,如:
在没有volatile声明时,多线程环境下,a的最终值不一定是正确的,因为this.a=a;涉及到给a赋值和将a同步回主存的步骤,这个顺序可能被打乱。如果用volatile声明了,读取主存副本到工作内存和同步a到主存的步骤,相当于是一个原子操作。所以简单来说,volatile适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。这是一种很简单的同步场景,这时候使用volatile的开销将会非常小。
使用synchronized关键字、volatile关键字可以为多线程的同步提供基本的安全保障,在开发高安全性的Java程序时,为了防止竞争冒险、死锁、活动锁和资源耗等情况的发生,我们必须对线程的等待机制、资源占有机制等作详细的研究与规划,不仅要在线程的运行机制上认真探索,还要在程序的整体构建上作合理的部署,这也是在以后的研究中对这一类问题的从微观到宏观的一个研究转变。
[1]吴其庆.Java编程思想与实践[M].北京:冶金工业出版社,2002.
[2]包景州.Web服务中安全身份认证系统的设计和研究[D].上海:上海交通大学,2004.
[3]李尊朝,苏军.Java语言程序设计[M].北京:中国铁道出版社,2004.
[4]沈 袁.实时Java平台的研究[D].无锡:江南大学, 2009.
[5]金振乾.Java语言中read方法分析[J].科技信息,2010 (27):71.