------------------------------------------------------------
基础知识
Java语言提供了对于线程很好的支持,实现方法小巧、优雅。对于方法重入的保护,信号量(semaphore)和临界区(critical section)机制的实现都非常简洁。可以很容易的实现多线程间的同步操作从而保护关键数据的一致性。这些特点使得Java成为面向对象语言中对于多线程特性支持方面的佼佼者。
Java中内置了对于对象并发访问的支持,每一个对象都有一个监视器(monitor),同时只允许一个线程持有监视器从而进行对对象的访问,那些没有获得监视器的线程必须等待直到持有监视器的线程释放监视器。对象通过synchronized关键字来声明线程必须获得监视器才能进行对自己的访问。
synchronized声明仅仅对于一些较为简单的线程间同步问题比较有效,对于哪些复杂的同步问题,比如带有条件的同步问题,Java提供了另外的解决方法,wait/notify/notifyAll。获得对象监视器的线程可以通过调用该对象的wait方法主动释放监视器,等待在该对象的线程等待队列上,此时其他线程可以得到监视器从而访问该对象,之后可以通过调用notify/notifyAll方法来唤醒先前因调用wait方法而等待的线程。一般情况下,对于wait/notify/notifyAll方法的调用都是根据一定的条件来进行的,比如:经典的生产者/消费者问题中对于队列空、满的判断。熟悉POSIX的读者会发现,使用wait/notify/notifyAll可以很容易的实现POSIX中的一个线程间的高级同步技术:条件变量。
1. 多线程中对共享、可变的数据进行同步.
对于函数中的局部变量没必要进行同步.
对于不可变数据,也没必要进行同步.
多线程中访问共享可变数据才有必要.
2. 单个线程中可以使用synchronized,而且可以嵌套,但无意义.
class Test {
public static void main(String[] args) {
Test t = new Test();
synchronized(t) {
synchronized(t) {
System.out.println("ok!");
}
}
}
}
3. 对象实例的锁
class Test{
public synchronized void f1(){
//do something here
}
public void f2(){
synchronized(this){
//do something here
}
}
}
上面的f1()和f2()效果一致, synchronized取得的锁都是Test某个实列(this)的锁.
比如: Test t = new Test();
线程A调用t.f2()时, 线程B无法进入t.f1(),直到t.f2()结束.
作用: 多线程中访问Test的同一个实例的同步方法时会进行同步.所以在多用户的web开发中这种锁是不够强的,每个用户都会在自己的session中生成对象实例,没有多少机会操作同一实例的同一方法。
4. class的锁
class Test{
final static Object o= new Object();
public static synchronized void f1(){
//do something here
}
public static void f2(){
synchronized(Test.class){
//do something here
}
}
public static void f3(){
try {
synchronized (Class.forName("Test")) {
//do something here
}
}
catch (ClassNotFoundException ex) {
}
}
public static void g(){
synchronized(o){
//do something here
}
}
}
上面f1(),f2(),f3(),g()效果一致
f1(),f2(),f3()中synchronized取得的锁都是Test.class的锁.
g()是自己产生一个对象o,利用o的锁做同步
作用: 多线程中访问此类或此类任一个实例的同步方法时都会同步. singleton模式lazily initializing属于此类.
5. static method
class Test{
private static int v = 0;
public static void f1(){
//do something, 但函数中没用用到v
}
public synchronized static void f2(){
//do something, 函数中对v进行了读/写.
}
}
多线程中使用Test的某个实列时,
(1) f1()是线程安全的,不需要同步
(2) f2()这个静态方法中使用了函数外静态变量,所以需要同步.
6. 对线程的run()进行同步没有意义,如 public synchronized void run()
class Test extends Thread{
public synchronized void run(){
while(true){
//do something
}
}
public synchronized void f(){
//...
}
}
这种例子会有一个问题, 执行run()时(内部在循环), 外部无法执行f()
class Test extends Thread{
public synchronized void run(){
//do something
}
}
这种例子同步基本没用, 因为run()通常靠 new Test().start()来执行的.
因为Test实例不同,锁也不同.
7.jsp文件中的同步问题:
当客户端第一次请求某一个JSP文件时,服务端把该JSP编译成一个CLASS文件,并创建一个该类的实例,然后创建一个线程处理CLIENT端的请求。如果有多个客户端同时请求该JSP文件,则服务端会创建多个线程。每个客户端请求对应一个线程。以多线程方式执行可大大降低对系统的资源需求,提高系统的并发量及响应时间.对JSP中可能用的的变量说明如下:
1. 实例变量
实例变量是在堆中分配的,并被属于该实例的所有线程共享,所以不是线程安全的.
2. JSP系统提供的8个类变量
JSP中用到的OUT,REQUEST,RESPONSE,SESSION,CONFIG,PAGE,PAGECONXT是线程安全的, APPLICATION在整个系统内被使用,所以不是线程安全的.
3. 局部变量
局部变量在堆栈中分配,因为每个线程都有它自己的堆栈空间,所以是线程安全的.
4. 静态类
静态类不用被实例化,就可直接使用,也不是线程安全的.
5. 外部资源:
在程序中可能会有多个线程或进程同时操作同一个资源(如:多个线程或进程同时对一个文件进行写操作).此时也要注意同步问题.
下面的例子存在的多线程问题:
<%@ page import="
javax.naming.*,
java.util.*,
java.sql.*,
weblogic.common.*
" %>
<%
String name
String product;
long quantity;
name=request.getParameter("name");
product=request.getParameter("product");
quantity=request.getParameter("quantity"); /*(1)*/
savebuy();
%>
<%!
public void savebuy()
{
/*进行数据库操作,把数据保存到表中*/
try {
Properties props = new Properties();
props.put("user","scott");
props.put("password","tiger");
props.put("server","DEMO");
Driver myDriver = (Driver) iver").newInstance();
conn = myDriver.connect("jdbc:weblogic:oracle", props);
stmt = conn.createStatement();
String inssql = "insert into buy(empid, name, dept) values (?, ?, ?,?)";
stmt = conn.prepareStatement(inssql);
stmt.setString(1, name);
stmt.setString(2, procuct);
stmt.setInt(3, quantity);
stmt.execute();
}
catch (Exception e)
{
System.out.println("SQLException was thrown: " + e.getMessage());
}
finally //close connections and {
try {
if(stmt != null)
stmt.close();
if(conn != null)
conn.close();
} catch (SQLException sqle) {
System.out.println("SQLException was thrown: " + sqle.getMessage());
}
}
}
%>
上面的程序模拟网上购物中的一部分,把用户在浏览器中输入的用户名,购买的物品名称,数量保存到表BUY中。在savebuy()函数中用到了实例变量, 所以它不是线程安全的.因为:程序中的每一条语句都不是原子操作,如name=request.getParameter("name");在执行是会对应多个机器指令,在任何时候都可能因系统调度而转入睡眠状态,让其他的线程继续执行.如果线程A在执行到(1)的时候转入睡眠状态,线程B开始执行并改变 QUANTITY的值,那么当又到A执行时,它会从调用savebuy()函数开始执行,这样它保存到表中的QUANTITY是被线程B改过的值,那么线程A对应的用户所实际购买的数量与保持到表中的数据不一致.这是个很严重的问题.
解决方法
1. 采用单线程方式
在该JSP文件中加上: <%@ page isThreadSafe="false" %> ,使它以单线程方式执行,这时,仍然只有一个实例,所有客户端的请求以串行方 式执行。这样会降低系统的性能.
2. 对函数savebuy()加synchronized进行线程同步,该JSP仍然以多线程方式执行,但也会降低系统的性能
public synchronized void savebuy()
{
......
}
3. 采用局部变量代替实例变量,函数savebuy()声明如下:
因为在savebuy()中使用的是传给他的形参,是在堆栈中分配的,所以是线程安全的.
public void savebuy(String name,String product, int quantity)
{
......
}
调用方式改为:
<%
String name
String product;
long quantity;
name=request.getParameter("name");
product=request.getParameter("product");
quantity=request.getParameter("quantity");
savebuy(name,product,quantity)
%>
如果savebuy的参数很多,或这些数据要在很多地方用到,也可声明一个类,并用他做参数,如:
public class buyinfo
{
String name;
String product;
long quantity;
}
public void savebuy(buyinfo info)
{
......
}
调用方式改为:
<%
buyinfo userbuy = new buyinfo();
userbuy.name=request.getParameter("name");
userbuy.product=request.getParameter("product");
userbuy.quantity=request.getParameter("quantity");
savebuy(userbuy);
%>
对于某一时段内要求实施同步技术的JSP页面
这是对应于JSP的Web特性的,即指同一时段内只能有一个客户端访问该JSP页面。简单说来就是当有一人访问某一JSP页面时,其他人就不能访问这一JSP页面,情况很类似于数据独占和数据锁。这种情况在大多系统开发中都可能碰到。具体说来又细分为两种情形:
A、涉及数据更新的JSP页面同步
此类情况主要考虑的就是数据的安全性,所以一般就直接套用数据并发策略(如数据锁),但是要注意目前是基于JSP的Web特性来实施同步的,对应Web特性有两个问题:Web的不稳定性、Web与服务器的弱耦合通讯。如果直接使用数据锁方案就会出现问题,我们可以设想,有一个用户进入JSP页面,同时触发同步的数据锁,此后的所有用户都不能在进入到此JSP页面(因为得不到数据),直到该用户离开此JSP页面,再触发条件释放数据锁。但是如果这个用户掉线或者当机了,那个释放数据锁的触发条件将不会传给服务器,那个数据锁就会永远不被释放,形成可怕的死锁。解决的方法当然很多,比如定时检测数据锁的占用情况,并释放不在线用户占用的数据锁。
就个人实践来说,我更喜欢采用"版本同步"的方案,这一方案相较而言更安全简单。简单说来就是在数据表中添加一个"版本号"字段,用户进入JSP页面时同时读取当前记录"版本号"字段,进行更新操作时除了要满足正常条件外还需要满足先前取出的"版本号"与数据库记录中存储的版本号相一致,完成更新后同时将版本号也更新(可以用时间来标识,也可以用修改的此书来标识)。这样,同时段内进入JSP页面的用户中只有一人(第一个完成更新的用户)能实现数据的更新,从而达到"准同步"的目的。
这一方案去掉了数据死锁的问题,但也有缺点,那就是因"手慢"而未能实现更新操作的用户会抱怨由于"准同步"的缘故,导致他浪费时间去做无用的更新操作(版本过期),因此这一方案适用于要求数据线程安全,同时可能执行更新操作的线程不多、更新数据量不大的系统功能中。
B、不涉及数据更新的JSP页面同步
这类情况要求的就是实现"JSP页面同步锁",效果是一个用户进入JSP页面后,就触发"JSP页面同步锁",其他用户就不能再进入该JSP页面,直到该用户离开后触发条件释放"JSP页面同步锁"。这几句啰嗦的描述与数据锁地描述基本相同,关键的问题是要你自己去开发实现一个"JSP页面同步锁"。
实践中,我们选用ServletContext对象来充当"JSP页面同步锁",因为它满足"在应用程序的整个生命周期间都有效且放在这个对象内的数据任何 Web组件都能访问到",ServletContext被认为是对于Web应用软件的一个整体性存储区域(每一个Web应用软件都具有 ServletContext)。存储在ServletContext之中的对象将一直被保留,除非是被删除。
简单例子
本文将围绕一个简单的例子展开论述,这样可以更容易突出我们解决问题的思路、方法。本文想向读者展现的正是这些思路、方法。这些思路、方法更加适用于解决大规模、复杂应用中的并发问题。考虑一个简单的例子,我们有一个服务提供者,它通过一个接口对外提供服务,服务内容非常简单,就是在标准输出上打印Hello World。类结构图如下:
代码如下:
interface Service
{
public void sayHello();
}
class ServiceImp implements Service
{
public void sayHello() {
System.out.println( "Hello World!");
}
}
class Client
{
public Client(Service s) {
_service = s;
}
public void requestService() {
_service.sayHello();
}
private Service _service;
}
如果现在有新的需求,要求该服务必须支持Client的并发访问。一种简单的方法就是在ServicImp类中的每个方法前面加上 synchronized声明,来保证自己内部数据的一致性(当然对于本例来说,目前是没有必要的,因为ServiceImp没有需要保护的数据,但是随 着需求的变化,以后可能会有的)。但是这样做至少会存在以下几个问题:
- 现在要维护ServiceImp的两个版本:多线程版本和单线程版本(有些地方,比如其他项目,可能没有并发的问题),容易带来同步更新和正确选择版本的问题,给维护带来麻烦。
- 如果多个并发的Client频繁调用该服务,由于是直接同步调用,会造成Client阻塞,降低服务质量。
- 很难进行一些灵活的控制,比如:根据Client的优先级进行排队等等。
这些问题对于大型的多线程应用服务器尤为突出,对于一些简单的应用(如本文中的例子)可能根本不用考虑。本文正是要讨论这些问题的解决方案,文中的简单的例子只是提供了一个说明问题,展示思路、方法的平台。
如何才能较好的解决这些问题,有没有一个可以重用的解决方案呢?让我们先把这些问题放一放,先来谈谈和框架有关的一些问题。
框架概述
熟悉 面向对象的读者一定知道面向对象的最大的优势之一就是:软件复用。通过复用,可以减少很多的工作量,提高软件开发生产率。复用本身也是分层次的,代码级的复用和设计架构的复用。大家可能非常熟悉C语言中的一些标准库,它们提供了一些通用的功能让你的程序使用。但是这些标准库并不能影响你的程序结构和设计思路,仅仅是提供一些机 能,帮助你的程序完成工作。它们使你不必重头编写一般性的通用功能(比如printf),它们强调的是程序代码本身的复用性,而不是设计架构的复用性。
那么什么是框架呢?所谓框架,它不同于一般的标准库,是指一组紧密关联的(类)classes,强调彼此的配合以完成某种可以重复运用的设计概念。这些类 之间以特定的方式合作,彼此不可或缺。它们相当程度的影响了你的程序的形貌。框架本身规划了应用程序的骨干,让程序遵循一定的流程和动线,展现一定的风貌 和功能。这样就使程序员不必费力于通用性的功能的繁文缛节,集中精力于专业领域。
有一点必须要强调,放之四海而皆准的框架是不存在的,也是最没有用处的。框架往往都是针对某个特定应用领域的,是在对这个应用领域进行深刻理解的基础上, 抽象出该应用的概念模型,在这些抽象的概念上搭建的一个模型,是一个有形无体的框架。不同的具体应用根据自身的特点对框架中的抽象概念进行实现,从而赋予 框架生命,完成应用的功能。
基于框架的应用都有两部分构成:框架部分和特定应用部分。要想达到框架复用的目标,必须要做到框架部分和特定应用部分的隔离。使用面向对象的一个强大功 能:多态,可以实现这一点。在框架中完成抽象概念之间的交互、关联,把具体的实现交给特定的应用来完成。其中一般都会大量使用了 Template Method设计模式。
Java中的Collection Framework以及微软的MFC都是框架方面很好的例子。有兴趣的读者可以自行研究。
构建框架
如何构建一个Java并发模型框架呢?让我们先回到原来的问题,先来分析一下原因。造成要维护多线程和单线程两个版本的原因是由于把应用逻辑和并发逻辑混 在一起,如果能够做到把应用逻辑和并发模型进行很好的隔离,那么应用逻辑本身就可以很好的被复用,而且也很容易把并发逻辑添加进来而不会对应用逻辑造成任 何影响。造成Client阻塞,性能降低以及无法进行额外的控制的原因是由于所有的服务调用都是同步的,解决方案很简单,改为异步调用方式,把服务的调用 和服务的执行分离。首先来介绍一个概念,活动对象(Active Object)。所谓活动对象是相对于被动对象(passive object)而言的,被动对象的方法的 调用和执行都是在同一个线程中的,被动对象方法的调用是同步的、阻塞的,一般的对象都属于被动对象;主动对象的方法的调用和执行是分离的,主动对象有自己 独立的执行线程,主动对象的方法的调用是由其他线程发起的,但是方法是在自己的线程中执行的,主动对象方法的调用是异步的,非阻塞的。
本框架的核心就是使用主动对象来封装并发逻辑,然后把Client的请求转发给实际的服务提供者(应用逻辑),这样无论是Client还是实际的服务提供 者都不用关心并发的存在,不用考虑并发所带来的数据一致性问题。从而实现应用逻辑和并发逻辑的隔离,服务调用和服务执行的隔离。下面给出关键的实现细节。
本框架有如下几部分构成:
- 一个ActiveObject类,从Thread继承,封装了并发逻辑的活动对象
- 一个ActiveQueue类,主要用来存放调用者请求
- 一个MethodRequest接口,主要用来封装调用者的请求,Command设计模式的一种实现方式
它们的一个简单的实现如下:
//MethodRequest接口定义
interface MethodRequest
{
public void call();
}
//ActiveQueue定义,其实就是一个producer/consumer队列
class ActiveQueue
{
public ActiveQueue() {
_queue = new Stack();
}
public synchronized void enqueue(MethodRequest mr) {
while(_queue.size() > QUEUE_SIZE) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
_queue.push(mr);
notifyAll();
System.out.println( "Leave Queue");
}
public synchronized MethodRequest dequeue() {
MethodRequest mr;
while(_queue.empty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mr = (MethodRequest)_queue.pop();
notifyAll();
return mr;
}
private Stack _queue;
private final static int QUEUE_SIZE = 20;
}
//ActiveObject的定义
class ActiveObject extends Thread
{
public ActiveObject() {
_queue = new ActiveQueue();
start();
}
public void enqueue(MethodRequest mr) {
_queue.enqueue(mr);
}
public void run() {
while( true) {
MethodRequest mr = _queue.dequeue();
mr.call();
}
}
private ActiveQueue _queue;
}
通过上面的代码可以看出正是这些类相互合作完成了对并发逻辑的封装。开发者只需要根据需要实现MethodRequest接口,另外再定义一个服务代理类 提供给使用者,在服务代理者类中把服务调用者的请求转化为MethodRequest实现,交给活动对象即可。
使用该框架,可以较好的做到应用逻辑和并发模型的分离,从而使开发者集中精力于应用领域,然后平滑的和并发模型结合起来,并且可以针对ActiveQueue定制排队机制,比如基于优先级等。
基于框架的解决方案
本小节将使用上述的框架重新实现前面的例子,提供对于并发的支持。第一步先完成对于MethodRequest的实现,对于我们的例子来说实现如下:class SayHello implements MethodRequest
{
public SayHello(Service s) {
_service = s;
}
public void call() {
_service.sayHello();
}
private Service _service;
}
该类完成了对于服务提供接口sayHello方法的封装。接下来定义一个服务代理类,来完成请求的封装、排队功能,当然为了做到对Client透明,该类必须实现Service接口。定义如下:
class ServiceProxy implements Service
{
public ServiceProxy() {
_service = new ServiceImp();
_active_object = new ActiveObject();
}
public void sayHello() {
MethodRequest mr = new SayHello(_service);
_active_object.enqueue(mr);
}
private Service _service;
private ActiveObject _active_object;
}
其他的类和接口定义不变,下面对比一下并发逻辑增加前后的服务调用的变化,并发逻辑增加前,对于sayHello服务的调用方法:
Service s = new ServiceImp();
Client c = new Client(s);
c.requestService();
并发逻辑增加后,对于sayHello服务的调用方法:
Service s = new ServiceProxy();
Client c = new Client(s);
c.requestService();
可以看出并发逻辑增加前后对于Client的ServiceImp都无需作任何改变,使用方式也非常一致,ServiceImp也能够独立的进行重用。类结构图如下:
读者容易看出,使用框架也增加了一些复杂性,对于一些简单的应用来说可能根本就没有必要使用本框架。希望读者能够根据自己的实际情况进行判断。
结论
本文围绕一个简单的例子论述了如何构架一个Java并发模型框架,其中使用了一些构建框架的常用技术,当然所构建的框架和一些成熟的商用框架相比,显得非 常稚嫩,比如没有考虑服务调用有返回值的情况,但是其思想方法是一致的,希望读者能够深加领会,这样无论对于构建自己的框架还是理解一些其他的框架都是很 有帮助的。读者可以对本文中的框架进行扩充,直接应用到自己的工作中。参考文献〔1〕中对于构建并发模型框架中的很多细节问题进行了深入的论述,有兴趣的 读者可以自行研究。下面列出本框架的优缺点:优点:
- 增强了应用的并发性,简化了同步控制的复杂性
- 服务的请求和服务的执行分离,使得可以对服务请求排队,进行灵活的控制
- 应用逻辑和并发模型分离,使得程序结构清晰,易于维护、重用
- 可以使开发者集中精力于应用领域
缺点:
- 由于框架所需类的存在,在一定程度上增加了程序的复杂性
- 如果应用需要过多的活动对象,由于线程切换开销会造成性能下降
- 可能会造成调试困难