|
2006年8月24日
摘要: 一、 桥梁(Bridge)模式
桥梁模式是一个非常有用的模式,也是比较复杂的一个模式。熟悉这个模式对于理解面向对象的设计原则,包括"开-闭"原则(OCP)以及组合/聚合复用原则(CARP)都很有帮助。理解好这两个原则,有助于形成正确的设计思想和培养良好的设计风格。
注:《Java与模式》一书认为Bridge模式不是一个使用频率很高的模式,我不太赞同,我认为Bridge模式中... 阅读全文
在项目的业务属性中,你是不是要经常验证属性的取值范围呢. 想要了解比较优美的解决方案吗?
看看Hibernate Validator 是怎么做的吧.一见到她,相信你就会说: Oh God, 这就是我需要的.
任何获得Matrix授权的网站,转载请保留以下作者信息和链接:
作者:icess(作者的blog:http://blog.matrix.org.cn/page/icess) 关键字:Hibernate Validator
用Annotations 给类或者类的属性加上约束(constraint),在运行期检查属性值是很优雅的.Hibernate Validator就是这样的一个框架.该框架是十分容易的(就像参考文档中宣称的那样),几乎没有什么学习曲线,Validator 是一个验证框架 不需要和Hibernate的其他部分绑定就可以使用,只要在你的项目中添加Hibernate-annotations.jar库就可以了.那么下面就让我们看看怎么使用吧.
Person.java 类
/*
* Created on 2006-1-12 Person.java
* @author
*/
package
test.annotation.validator;
import
org.hibernate.validator.Length;
import
org.hibernate.validator.Min;
import
org.hibernate.validator.Valid;
//@Serializability //测试自定义约束
public class
Person {
private
String name;
private int
age;
private
Address address;
public
Person() {}
@Valid //注意此处
public
Address getAddress() {
return
address;
}
public void
setAddress(Address address) {
this
.address = address;
}
@Min(value =
1
)
public int
getAge() {
return
age;
}
public void
setAge(
int
age) {
this
.age = age;
}
@Length(min =
4
)
public
String getName() {
return
name;
}
public void
setName(String name) {
this
.name = name;
}
}
Address.java 类
/*
* Created on 2006-1-12 Address.java
* @author
*/
package
test.annotation.validator;
import
org.hibernate.validator.Length;
import
org.hibernate.validator.Max;
import
org.hibernate.validator.Min;
public class
Address {
private
String street;
private int
num;
public
Address() {}
@Min(value =
1
)
@Max(value =
100
)
public int
getNum() {
return
num;
}
public void
setNum(
int
num) {
this
.num = num;
}
@Length(min =
3
,max =
8
)
public
String getStreet() {
return
street;
}
public void
setStreet(String street) {
this
.street = street;
}
}
上面是两个用 Validator Annotations 注释的 类. 每个属性都用 约束限制了. 下面看看测试的类吧:
TestValidator.java 类
/*
* Created on 2006-1-12
* @author icerain
*/
package
test.annotation.validator;
import
org.hibernate.validator.ClassValidator;
import
org.hibernate.validator.InvalidValue;
public class
TestValidator {
public void
test() {
Address add =
new
Address();
add.setNum(
0
);
add.setStreet(
"1"
);
Person p =
new
Person();
p.setAddress(add);
p.setAge(
0
);
p.setName(
"ice"
);
/******************Test validator ********/
// 注意该处只验证了Person 为了说明 @Valid 注释的使用
ClassValidator<Person> classValidator =
new
ClassValidator<Person> (Person.
class
);
InvalidValue[] validMessages = classValidator.getInvalidValues(p);
for
(InvalidValue value : validMessages) {
System.out.println(
"InvalidValue 的长度是:"
+ validMessages.length
+
" . 验证消息是: "
+ value.getMessage()
+
" . PropertyPath 是:"
+ value.getPropertyPath()
+
" .\n\t PropertyName 是: "
+value.getPropertyName()
+
"Value 是: "
+ value.getValue()
+
" Bean 是: "
+ value.getBean()
+
"\n\t BeanClass 是:"
+ value.getBeanClass());
}
}
public static void
main(String[] args) {
new
TestValidator().test();
}
}
程序的输出如下 InvalidValue 的长度是:4 . 验证消息是: 必须大于等于 1 . PropertyPath 是:age . PropertyName 是: age. Value 是: 0 Bean 是: test.annotation.validator.Person@dd87b2 BeanClass 是:class test.annotation.validator.Person InvalidValue 的长度是:4 . 验证消息是: 长度必须介于 4 与 2147483647 之间 . PropertyPath 是:name . PropertyName 是: name. Value 是: ice Bean 是: test.annotation.validator.Person@dd87b2 BeanClass 是:class test.annotation.validator.Person InvalidValue 的长度是:4 . 验证消息是: 必须大于等于 1 . PropertyPath 是:address.num . PropertyName 是: num. Value 是: 0 Bean 是: test.annotation.validator.Address@197d257 BeanClass 是:class test.annotation.validator.Address InvalidValue 的长度是:4 . 验证消息是: 长度必须介于 3 与 8 之间 . PropertyPath 是:address.street . PropertyName 是: street. Value 是: 1 Bean 是: test.annotation.validator.Address@197d257 BeanClass 是:class test.annotation.validator.Address 可以看出不满足约束的值都被指出了. 同时该句: ClassValidator<Person> classValidator = new ClassValidator<Person> (Person.class); 我们只验证了 Person. 在Person里面的Address的属性 由于有@Valid Annotations 所以 Address的相关属性也被机联验证了 . 如果 把 @Valid Annotations 去掉,结果如下:
InvalidValue 的长度是:2 . 验证消息是: 必须大于等于 1 . PropertyPath 是:age . PropertyName 是: age. Value 是: 0 Bean 是: test.annotation.validator.Person@18fef3d BeanClass 是:class test.annotation.validator.Person InvalidValue 的长度是:2 . 验证消息是: 长度必须介于 4 与 2147483647 之间 . PropertyPath 是:name . PropertyName 是: name. Value 是: ice Bean 是: test.annotation.validator.Person@18fef3d BeanClass 是:class test.annotation.validator.Person
可以看出 没有验证 Address.
当然了 ,你还可以只验证一个属性 , 没有必要验证整个类.只需要在调用
classValidator.getInvalidValues(p,"age")方法时 加上你要验证的属性就可以了.如我们只想验证age 属性 把代码改为如下所示:
InvalidValue[] validMessages = classValidator.getInvalidValues(p,"age"); /
/只验证age 属性
运行结果如下:
InvalidValue 的长度是:1 . 验证消息是: 必须大于等于 1 . PropertyPath 是:age .
PropertyName 是: age. Value 是: 0 Bean 是: test.annotation.validator.Person@1457cb
BeanClass 是:class test.annotation.validator.Person
只是验证了 age 属性.
怎么样 ,很简单吧. 关于 Hibernate Validator 内建的验证Annotations 大家可以看看 API 或者 参考文档(中文版我正在翻译中 请访问我的 Blog 获得最新信息).
如果你要写自己的约束呢 , 你不用担心 ,这也是很容易的.
任何约束有两部分组成: [约束描述符 即注释]the constraint descriptor (the annotation) 和[约束validator 即 实现类] the constraint validator (the implementation class).下面我们扩展Hibernate Test suit 中的一个Test 来讲解一下.
首先: 要声明一个
constraint descriptor .如下:
package
test.annotation.validator;
import
java.lang.annotation.Documented;
import static
java.lang.annotation.ElementType.TYPE;
import static
java.lang.annotation.ElementType.FIELD;
import static
java.lang.annotation.ElementType.METHOD;
import
java.lang.annotation.Retention;
import static
java.lang.annotation.RetentionPolicy.RUNTIME;
import
java.lang.annotation.Target;
import
org.hibernate.validator.ValidatorClass;
/**
* Dummy sample of a bean-level validation annotation
*
*
@author
Emmanuel Bernard
*/
@ValidatorClass(SerializabilityValidator.
class
)
@Target({METHOD,FIELD,TYPE})
@Retention(RUNTIME)
@Documented
public
@interface Serializability {
int
num()
default
11
;
String message()
default
"bean must be serialiable"
;
}
@ValidatorClass(SerializabilityValidator.
class
) 指出了
constraint validator 类.
@Target({METHOD,FIELD,TYPE})
@Retention(RUNTIME)
@Documented
这几个我就不用解释了吧.
Serializability 里面声明了一个 message 显示约束的提示信息. num 只是为了说明一个方面 在这里面没有实际用途用 .
然后就是 实现一个
constraint validator 类 该类要实现Validator<ConstraintAnnotation>.这里是SerializabilityValidator.java 如下:
//$Id: SerializabilityValidator.java,v 1.3 2005/11/17 18:12:11 epbernard Exp $
package
test.annotation.validator;
import
java.io.Serializable;
import
org.hibernate.validator.Validator;
/**
* Sample of a bean-level validator
*
*
@author
Emmanuel Bernard
*/
public class
SerializabilityValidator
implements
Validator<Serializability>, Serializable {
public boolean
isValid(Object value) {
//这里只是Validator 里面的 实现验证规则的 方法. value 是要验证的值.
System.out.println(
"IN SerializabilityValidator isValid:"
+value.getClass()+
": "
+value.toString());
return
value instanceof Serializable; }
public void initialize(Serializability parameters) { // 在这里可以 取得
constraint descriptor 里面的属性 如上面我们声明的 num
System.out.println(
"IN SerializabilityValidator: parameters:"
+ parameters.num() );
}
}
然后在你的类中应用@Serializability 就可以约束一个类实现 Serializable 接口了. 如下:
在我们的Person.java类 添加@Serializability Annotations ,把Person.java 中的 //@Serializability //测试自定义约束 注释去掉就ok了.
运行结果如下:
InvalidValue 的长度是:3 . 验证消息是:
bean must be serialiable
. PropertyPath 是:null .
PropertyName 是: null. Value 是: test.annotation.validator.Person@1a73d3c Bean 是: test.annotation.validator.Person@1a73d3c
BeanClass 是:class test.annotation.validator.Person
现在把Person类实现 java.io.Serializable 接口 则没有出现 验证错误消息.
消息的国际化也是很简单的,把
Serializability 中的message 改为以{}扩住的 属性文件的Key就可以了
public
@interface Serializability {
int
num()
default
11
;
String message()
default
"{Serializable}";
//"bean must be serialiable";
//消息的国际化
}
然后编辑资料文件. 注意 该资源文件中要包括 Hibernate Validator 内建的资源. 可以在该org\hibernate\validator\resources 包里面的资源文件基础上修改 ,在打包里面 这样就可以了. 自己打包可能不太方便.你可以把该包里面的文件复制出来.然后放到你自己的项目包下在自己编辑, 该测试中 我是放在 test\resources 包下的.
然后在 资源文件中添加
Serializable = '''''' 这么一行, 样例如下:
#DefaultValidatorMessages.properties (DefaultValidatorMessages_zh.properties 不再列出^_^)
#下面是 Hibernate Validator 内建的国际化消息
validator.assertFalse=
assertion
failed
validator.assertTrue=
assertion
failed
validator.future=
must
be
a
future
date
validator.length=
length
must
be
between
{min}
and
{max}
validator.max=
must
be
less
than
or
equal
to
{value}
validator.min=
must
be
greater
than
or
equal
to
{value}
validator.notNull=
may
not
be
null
validator.past=
must
be
a
past
date
validator.pattern=
must
match
"{regex}"
validator.range=
must
be
between
{min}
and
{max}
validator.size=
size
must
be
between
{min}
and
{max}
#下面是自定义的消息
Serializable=
Bean
not
Serializable //加上自己定义的国际化消息.
在构造
ClassValidator
时要添上 资源文件 如下:(在测试类中)
ClassValidator<Person> classValidator = new ClassValidator<Person> (Person.class,ResourceBundle.getBundle("test.resources.DefaultValidatorMessages"));//加载资源
这样就可以了 . 当然 你还可以 更改 Hibernate Validator 的消息(不是在上面的资源文件中直接修改
validator.length = ... 等等
) , 还记得 Validator 注释中有个 message 元素吗? 你以前用的都是默认值,现在你可以该为你自己定义的了.
如:validator.length 我把他改为 "该字符串的长度不符合规定范围范围". 在资源文件中添加一行键值属性对(key定义为 "myMsg")如下:
myMsg=该字符串的长度不符合规定范围范围
并且还要在
@Length
注释中提供message的引用的key 如下
@Length(min = 4,message = "{
myMsg
}")
再一次运行测试 ,我们就可以看到上面两条自定义绑定的消息了 .如下:
InvalidValue 的长度是:3 . 验证消息是: Bean 不是 可 Serializable . PropertyPath 是:null . PropertyName 是: null. Value 是: test.annotation.validator.Person@1bd4722 Bean 是: test.annotation.validator.Person@1bd4722 BeanClass 是:class test.annotation.validator.Person
InvalidValue 的长度是:3 . 验证消息是: 该字符串的长度不符合规定范围范围 . PropertyPath 是:name . PropertyName 是: name. Value 是: ice Bean 是: test.annotation.validator.Person@1bd4722 BeanClass 是:class test.annotation.validator.Person
怎么样,比你想象的简单吧.
OK 上面我们讨论了
Hibernate Validator 的主要用法: 但是 该框架有什么用呢? ^_^
看到这里其实不用我在多说了 大家都知道怎么用,什么时候用. 作为一篇介绍性文章我还是在此给出一个最常用的例子吧,更好的使用方式大家慢慢挖掘吧.
比如 : 你现在在开发一个人力资源(HR)系统 (其实是我们ERP课程的一个作业 ^_^), 里面要处理大量的数据,尤其是在输入各种资料时 如 登记员工信息. 如果你公司的员工的年龄要求是18 -- 60 那么你所输入的年龄就不能超出这个范围. 你可能会说这很容易啊 , 不用Validator就可以解决啊.这保持数据前验证就可以啦 如if ( e.getAge() > 60 || e.getAge() < 18 ) ........ 给出错误信息 然后提示重新输入不就OK啦 用得着 兴师动众的来个第三方框架吗?
是啊 当就验证这一个属性时, 没有必要啊 ! 但是一个真正的HR 系统,会只有一个属性要验证吗? 恐怕要有N多吧
你要是每一个都那样 写一段验证代码 是不是很烦啊 ,况且也不方便代码重用. 现在考虑一些 Validator 是不是更高效啊,拦截到 约束违例的 属性 就可以直接得到 国际化的消息 可以把该消息显示到一个弹出对话框上 提示更正 !
Validator的用处不只这一种 ,你可以想到如何用呢 ! 欢迎发表你的高见!!
OK 到此 我们的 Hibernate Validator 之旅就要先告一段落了 . 希望这是令你心旷神怡的一次寒冬之旅,
把你学到的应用到你的项目中吧,一定会提高你的生产率的. 相信我 ,没错的 ^_^ !
-- hbm.xml 与 Annotations 性能比较
任何获得Matrix授权的网站,转载请保留以下作者信息和链接:
作者:icess(作者的blog:http://blog.matrix.org.cn/page/icess) 关键字:Hibernate Validator
我在前面一篇文章<Hibernate Annotations 实战-- 从 hbm.xml 到 Annotations>:
中,有很多开发者在谈论中提到,有没有必要从 hbm.xml 往 Annotations 上转移. 那么在这篇文章中我们就来讨论一下 hbm.xml 与 Annotations的优缺点,看看那种情况最适合你.
首先,讨论一下 xml 配置文件的优点, 个人认为主要优点就是当你改变底层配置时 不需要改变和重新编译代码,只需要在xml 中更改就可以了,例如 Hibernate.cfg.xml 当你要更改底层数据库时, 只要更改配置文件就可以了.Hibernate会为你做好别的事情.
那么xml的缺点呢,个人认为有以下几点:
-
描述符多,不容易记忆,掌握 要深入了解还有看DTD文件
-
无法做自动校验,需要人工查找
-
读取和解析xml配置要消耗一定时间,导致应用启动慢,不便于测试和维护
-
当系统很大时,大量的xml文件难以管理
-
运行中保存xml配置需要消耗额外的内存
-
在O/R Mapping的时候需要在java文件和xml配置文件之间交替,增大了工作量
其中第一 二点 借助于先进的IDE 可能不是什么问题. 但是对初学者还是个问题 ^_^.
下面我们看看 Annotations的 特性吧! 可以解决xml遇到的问题,有以下优点 描述符减少。以前在xml配置中往往需要描述java属性的类型,关系等等。而元数据本身就是java语言,从而省略了大量的描述符 编译期校验。错误的批注在编译期间就会报错。 元数据批注在java代码中,避免了额外的文件维护工作 元数据被编译成java bytecode,消耗的内存少,读取也很快,利于测试和维护
关于 映射文件是使用 hbm.xml 文件还是使用 Annotations 我们来看看2者的性能吧. 先声明一下,个人认为映射文件一旦配置好就不会在很大程度上改变了.所以使用xml文件并不会带来很大的好处.如果你认为 映射文件在你的项目中也经常变化,比如一列String数据 ,今天你使用 length="16" 明天你认为 该数据的长度应该更长才能满足业务需求 于是改为length="128" 等等类似的问题 . 如果你经常有这方面的变动的话,下面的比较你可以不用看了 , 你应该使用 xml文件 因为Annotations 无法很好的满足你的要求. 现在让我们就来看看2者的性能比较吧. (说明: 这里只是比较查找 插入 的时间快慢,没有比较除运行时间以外的其他性能,如 内存占用量 等等) 先来看看测试程序和配置. 首先在 Hibernate.cfg.xml 文件中去掉了 <property name="hibernate.hbm2ddl.auto">update</property> 这一行, 因为在前面的实验中以及建立了数据库表了 不再需要更新了.如果你是第一次运行该例子 还是要该行的. Test.java 如下: /* * Created on 2005 * @author */ package test.hibernate.annotation;
import org.hibernate.Session; import org.hibernate.Transaction;
public class Test { public static void main(String [] args) { long start = 0; long end = 0; start = System.currentTimeMillis(); //程序开始时间 Session s = HibernateUtil.currentSession(); long mid = System.currentTimeMillis(); //初始化完毕的时间 (可能此时并没有初始化完毕^_^) Transaction tx = s.beginTransaction(); /********************测试读取的代码************************/ Person p = null; for(int i = 1; i <= 100; i ++) { p = (Person) s.get(Person.class, i); System.out.println(p.getName()); } System.out.println(p.getName());
/********************测试读取1次的代码************************/ Person p = null; p = (Person) s.get(Person.class, 1); System.out.println(p.getName()); /*********************测试插入的代码*************************************/ /* for (int i = 0; i < 100; i ++) { Person p = new Person(); p.setAge(i+1); p.setName("icerain"+i); p.setSex("male"+i); s.save(p); s.flush(); } */ tx.commit(); HibernateUtil.closeSession(); end = System.currentTimeMillis(); //测试结束时间 System.out.println("String[] - start time: " + start); System.out.println("String[] - end time: " + end); System.out.println("Init time : " + (mid-start)); // 打印初始化用的时间 System.out.println("Last time is :" +(end - mid) ); //打印 数据操作的时间 System.out.println("Total time : " +(end - start)); //打印总时间 } }
Annotations 包中的Person.java 如下 package test.hibernate.annotation;
import java.util.LinkedList; import java.util.List;
import javax.persistence.AccessType; import javax.persistence.Basic; import javax.persistence.Entity; import javax.persistence.GeneratorType; import javax.persistence.Id; import javax.persistence.Table; import javax.persistence.Transient;
/** * Person generated by hbm2java */
@SuppressWarnings("serial") @Entity(access = AccessType.PROPERTY) @Table public class Person implements java.io.Serializable { private Integer id; private String name; private String sex; private Integer age; private List list = new LinkedList();
// Constructors /** default constructor */ public Person() { }
/** constructor with id */ public Person(Integer id) { this.id = id; }
// Property accessors @Id(generate=GeneratorType.AUTO) public Integer getId() { return this.id; }
public void setId(Integer id) { this.id = id; }
@Basic public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
@Basic public String getSex() { return this.sex; }
public void setSex(String sex) { this.sex = sex; }
@Basic public Integer getAge() { return this.age; }
public void setAge(Integer age) { this.age = age; } @Transient public List getList() { return list; } public void setList(List list) { this.list = list; }
}
其他的代码几乎没有改变: 下面的每种类型的测试都测试了3次以上, 取中间的测试时间. 测试机器配置: CPU: AMD Athlon (xp) 2000+ 内存: 784880KB 硬盘: 三星 SP0812N 读取一次 的比较:(单位: 毫秒) 使用Annotations 的测试数据 | 使用Xml文件的测试数据 | 简要说明 | Init time : | 2444 | Init time : | 2431 | 测试前我认为该项结果xml应该比较大,要读取映射文件啊,实际情况不是这样,不知道为什么? | Last time is : | 62 | Last time is : | 85 | 相差比较大不知道为什么? | Total time : | 2506 | Total time : | 2516 | xml文件总体上慢了一点 |
读取100次的比较: 使用Annotations 的测试数据 | 使用Xml文件的测试数据 | 简要说明 | Init time : | 2437 | Init time : | 2422 | 和前面初始化差不多 | Last time is : | 438 | Last time is : | 484 | 有时间差 | Total time : | 2875 | Total time : | 2906 | 也是xml文件总体上慢了一点 |
插入100次的比较: 使用Annotations 的测试数据 | 使用Xml文件的测试数据 | 简要说明 | Init time : | 2453 | Init time : | 2469 | 和前面初始化差不多 | Last time is : | 469 | Last time is : | 656 | 有时间差 | Total time : | 2922 | Total time : | 3125 | 也是xml文件总体上慢了一点 |
从上面的三次对比中大家可以看到 初始化的部分几乎两者是一样的, 在数据操作上面 使用xml文件 总是比使用Annotations 慢一点.在我们只操纵一个只有几个属性的小持久化类的操作中就有 几十毫秒的差距. 几十毫秒在计算机中算不算很大 大家应该都知道,我就不在多说了. 总结: 经过 xml 文件 和Annotations 的优缺点和 性能上的对比.现在使用那个作为你持久化映射策略.我相信大家都会正确选择的. 测试后记: 经过多次测试 感觉有时候很不稳定 ,有的时候很稳定不知道是测试有问题还是别的问题.大家可以自己测试一下. 有什么新的发现 请大家讨论讨论.
Hibernate是ORM的解决方案,其底层对数据库的操作依赖于JDBC,所以您必须先取得JDBC驱动程序,在这边所使用的是MySQL,所以您必须至 MySQL® Connector/J 取得MySQL的JDBC驱动程序。
接下来至
Hibernate 官方网站 取得 Hibernate 3.2、 Hibernate Annotations 3.2。 您必须 安装JDK 5.0才可以使用Hibernate Annotations的功能。 解开Hibernate 3.2的zip档案后,当中的hibernate3.jar是必要的,而在lib目录中还包括了许多jar档案,您可以在 Hibernate 3.0官方的参考手册 上找到这些jar的相关说明,其中必要的是 antlr、dom4j、CGLIB、asm、Commons Collections、Commons Logging、 EHCache,Hibernate底层还需要Java Transaction API,所以您还需要jta.jar。 解开Hibernate Annotations 3.2的zip档案后,您需要hibernate-annotations.jar、ejb3-persistence.jar这两个档案。 到这边为止,总共需要以下的jar档案: Hibernate可以运行于单机之上,也可以运行于Web应用程序之中,如果是运行于单机,则将所有用到的jar档案(包括JDBC驱动程序)设定至CLASSPATH中,如果是运行于Web应用程序中,则将jar档案置放于WEB-INF/lib中。 如果您还需要额外的Library,再依需求加入,例如JUnit、Proxool等等,接下来可以将etc目录下的 log4j.properties复制至Hibernate项目的Classpath下,并修改一下当中的 log4j.logger.org.hibernate为error,也就是只在在错误发生时显示必要的讯息。 接着设置基本的Hibernate配置文件,可以使用XML或Properties档案,这边先使用XML,档名预设为hibernate.cfg.xml: <?xml version="1.0" encoding="utf-8"?> <!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd"> <hibernate-configuration> <session-factory> <!-- 显示实际操作数据库时的SQL --> <property name="show_sql">true</property> <!-- SQL方言,这边设定的是MySQL --> <property name="dialect">org.hibernate.dialect.MySQLDialect</property> <!-- JDBC驱动程序 --> <property name="connection.driver_class">com.mysql.jdbc.Driver</property> <!-- JDBC URL --> <property name="connection.url">jdbc:mysql://localhost/demo</property> <!-- 数据库使用者 --> <property name="connection.username">root</property> <!-- 数据库密码 --> <property name="connection.password">123456</property> <!-- 以下设置对象与数据库表格映像类别 --> <mapping class="onlyfun.caterpillar.User"/> </session-factory> </hibernate-configuration>这边以一个简单的单机程序来示范Hibernate的配置与功能,首先作数据库的准备工作,在MySQL中新增一个demo数据库,并建立user表格: CREATE TABLE user ( id INT(11) NOT NULL auto_increment PRIMARY KEY, name VARCHAR(100) NOT NULL default'', age INT ); 对于这个表格,您有一个User类别与之对应,表格中的每一个字段将对应至User实例上的Field成员。
package onlyfun.caterpillar; import javax.persistence.*; @Entity@Table(name="user") // 非必要,在表格名称与类别名称不同时使用public class User { @Id @GeneratedValue(strategy=GenerationType.AUTO) private Integer id; @Column(name="name") // 非必要,在字段名称与属性名称不同时使用 private String name; @Column(name="age") private Integer age; // 非必要,在字段名称与属性名称不同时使用 // 必须要有一个预设的建构方法 // 以使得Hibernate可以使用Constructor.newInstance()建立对象 public User() { } public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; }}其中id是个特殊的属性,Hibernate会使用它来作为主键识别,您可以定义主键产生的方式,这边设定为自动产生主键,可以看到,实体标识,主键生成,以及相关映像,都可以使用Annotation来完成。
接下来撰写一个测试的程序,这个程序直接以Java程序设计人员熟悉的语法方式来操作对象,而实际上也直接完成对数据库的操作,程序将会将一笔数据存入表格之中: package onlyfun.caterpillar; import org.hibernate.SessionFactory;import org.hibernate.Session;import org.hibernate.Transaction; import org.hibernate.cfg.AnnotationConfiguration;import org.hibernate.cfg.Configuration; public class HibernateAnnotationDemo { public static void main(String[] args) { // 需要AnnotationConfiguration读取Annotation讯息 Configuration config = new AnnotationConfiguration().configure(); // 根据 config 建立 SessionFactory // SessionFactory 将用于建立 Session SessionFactory sessionFactory = config.buildSessionFactory(); // 将持久化的物件 User user = new User(); user.setName("caterpillar"); user.setAge(new Integer(30)); // 开启Session,相当于开启JDBC的Connection Session session = sessionFactory.openSession(); // Transaction表示一组会话操作 Transaction tx= session.beginTransaction(); // 将对象映像至数据库表格中储存 session.save(user); tx.commit(); session.close(); sessionFactory.close(); System.out.println("新增资料OK!请先用MySQL观看结果!"); }}注意,使用Annotation时,需要的是AnnotationConfiguration类别。
如您所看到的,程序中只需要直接操作User对象,并进行Session与Transaction的相关操作,Hibernate就会自动完成对数据库的操作,您看不到任何一行JDBC或SQL的陈述,撰写好以上的各个档案之后,各档案的放置位置如下: 接着可以开始运行程序,结果如下:
Hibernate: insert into user (name, age) values (?, ?) 新增资料OK!请先用MySQL观看结果!
执行结果中显示了Hibernate所实际使用的SQL,由于这个程序还没有查询功能,所以要进入MySQL中看看新增的数据,如下: mysql> select * from user; +----+-----------------+------+ | id | name | age | +----+-----------------+------+ | 1 | caterpillar | 30 | +----+-----------------+------+ 1 row in set (0.03 sec)
摘要: 任何获得Matrix授权的网站,转载请保留以下作者信息和链接:
作者:icess(作者的blog:http://blog.matrix.org.cn/page/icess)关键字:Hibernate Validator
下面让我们先看一个通常用 hbm.xml 映射文件的例子. 有3个类 .HibernateUtil.java 也就是 Hibernate文档中推荐的工具类,Pers... 阅读全文
利用Thread-Specific Storage撰寫一個HibernateUtil
import java.io.Serializable;
import net.sf.hibernate.HibernateException; import net.sf.hibernate.Session; import net.sf.hibernate.SessionFactory; import net.sf.hibernate.Transaction;
public class HibernateSessionUtil implements Serializable { publicstaticfinal ThreadLocal tLocalsess = new ThreadLocal();
publicstaticfinal ThreadLocal tLocaltx = new ThreadLocal();
/* * getting the thread-safe session for using */ publicstatic Session currentSession(){ Session session = (Session) tLocalsess.get();
try{ if (session == null){ session = openSession(); tLocalsess.set(session); } }catch (HibernateException e){ thrownew InfrastructureException(e); } return session; }
/* * closing the thread-safe session */ publicstatic void closeSession(){
Session session = (Session) tLocalsess.get(); tLocalsess.set(null); try{ if (session != null && session.isOpen()){ session.close(); }
}catch (HibernateException e){ thrownew InfrastructureException(e); } }
/* * begin the transaction */ publicstatic void beginTransaction(){ Transaction tx = (Transaction) tLocaltx.get(); try{ if (tx == null){ tx = currentSession().beginTransaction(); tLocaltx.set(tx); } }catch (HibernateException e){ thrownew InfrastructureException(e); } }
/* * close the transaction */ publicstatic void commitTransaction(){ Transaction tx = (Transaction) tLocaltx.get(); try{ if (tx != null && !tx.wasCommitted() && !tx.wasRolledBack()) tx.commit(); tLocaltx.set(null); }catch (HibernateException e){ thrownew InfrastructureException(e); } }
/* * for rollbacking */ publicstatic void rollbackTransaction(){ Transaction tx = (Transaction) tLocaltx.get(); try{ tLocaltx.set(null); if (tx != null && !tx.wasCommitted() && !tx.wasRolledBack()){ tx.rollback(); } }catch (HibernateException e){ thrownew InfrastructureException(e); } }
privatestatic Session openSession() throws HibernateException{ return getSessionFactory().openSession(); }
privatestatic SessionFactory getSessionFactory() throws HibernateException{ return SingletonSessionFactory.getInstance(); } }
filter中的程式碼如下
public class HibernateSessionCloser implements Filter{
protected FilterConfig filterConfig = null;
public void init(FilterConfig filterConfig)throws ServletException{ this.filterConfig = filterConfig; }
public void destroy(){ this.filterConfig = null; }
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try{ chain.doFilter(request, response); } finally{ try{ HibernateSessionUtil.commitTransaction(); }catch (InfrastructureException e){ HibernateSessionUtil.rollbackTransaction(); }finally{ HibernateSessionUtil.closeSession(); } }
} }
然後在操作資料庫之前加上
HibernateSessionUtil.beginTransaction(); HibernateSessionUtil.currentSession();
功能实现分析
这个例子使用了HSQL做数据库,spring的AOP作为基础,使用Acegi做安全控制组件。 联系人管理的web应用在启动时候,会做一系列初始化动作: 1. 读取web.xml文件,
2. 并解析文件里的内容。 a) context-param元素。 i. contextConfigLocation属性。这个属性定义了spring所需要的3个属性文件。它们分别是:applicationContext -acegi-security.xml、applicationContext-common-business.xml、 applicationContext-common-authorization.xml ii. log4jConfigLocation属性。这个属性定义了log4j配置文件。
b) filter元素。 这里定义了acegi的一个过滤器。Acegi的大部分过滤器都是这样配置的。使用FilterToBeanProxy组件,给它传递一个targetClass属性。这个targetClass必须实现javax.servlet.Filter接口。 这里配置的是FilterChainProxy。这个FilterChainProxy比较好用,可以为它定义一串filter属性。这些filter将会按照定义的顺序被调用。例如, <bean id="filterChainProxy" class="net.sf.acegisecurity.util.FilterChainProxy"> <property name="filterInvocationDefinitionSource"> <value> CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON PATTERN_TYPE_APACHE_ANT /**=httpSessionContextIntegrationFilter,authenticationProcessingFilter,basicProcessingFilter,rememberMeProcessingFilter,anonymousProcessingFilter,securityEnforcementFilter </value> </property> </bean> 这个过滤器的mapping是“/*”。 c) listener元素。 i. ContextLoaderListener。这个是Spring使用来加载根applicationcontext。并分别解析 applicationContext-acegi-security.xml、applicationContext-common- business.xml、applicationContext-common-authorization.xml等配置文件,把相关的对象初始化 iii. Log4jConfigListener。这个是spring用来初始化log4j组件的listener。 iv. HttpSessionEventPublisher。这个组件将发布HttpSessionCreatedEvent和HttpSessionDestroyedEvent事件给spring的applicationcontext。 d) servlet元素。 i. contacts。这里采用了spring的MVC框架, 所以这个servlet是spring MVC的一个核心控制器(org.springframework.web.servlet.DispatcherServlet)。这个servlet 启动时候,会从contacts-servlet.xml里面读取信息,并做相关的初始化。 v. remoting。也是spring MVC的一个核心控制器。与contacts不同,这个servlet主要是提供web services服务。这个servlet启动时候, 会从remoting-servlet.xml里面读取信息,并做相关的初始化。 e) taglib元素。这里定义了spring的标f) 签库。 3. 解析applicationContext-acegi-security.xml。 a) 过滤器链。定义了一个FilterChainProxy,b) 并指c) 定了一系列的过滤器链。httpSessionContextIntegrationFilter, authenticationProcessingFilter,basicProcessingFilter,rememberMeProcessingFilter,anonymousProcessingFilter,securityEnforcementFilter d) 认证管理器。这个管理器由acegi提供。这个管理器需要一个providers参数。这个providers参数包含了提供系统认证的对象。 i. daoAuthenticationProvider。一般用户认证。 ii. anonymousAuthenticationProvider。匿名用户认证。 iv. rememberMeAuthenticationProvider。记住我认证。
e) 密码加密。这里定义了一个acegi的Md5算法加密对象Md5PasswordEncoder。 f) 定义了一个jdbcDao实现类。这个类由acegi提供的net.sf.acegisecurity.providers.dao.jdbc.JdbcDaoImpl。这个对象需要一个dataSource的参数。 g) 定义daoAuthenticationProvider。这个对象由acegi提供。它有3个属性: authenticationDao。这里指向前面定义的jdbcDao。 userCache。这里指向后面定义的user缓存对象。 passwordEncoder。这里指向前面定义的密码加密对象。 h) 用户缓存管理。 为了缓存user,这里使用spring的ehcache来缓存user。缓存机制: i. 定义缓存管理器――CacheManager。这个对象是spring的EhCacheManagerFactoryBean对象 ii. 定义user缓存实际执行对象――UserCacheBackend。这个对象是spring的EhCacheFactoryBean。它有两个属性: 1. cacheManager。这里指向前面定义的缓存管理器。 2. cacheName。 iii. 定义user缓存――UserCache。它是acegi提供的EhCacheBasedUserCache对象。它有一个属性: 1. cache。这里指向的是前面定义的userCacheBackend。
i) 定义接收来自DaoAuthenticationProvider的认证事件的listener――LoggerListener。 j) 4. 解析applicationContext-common-business.xml。 a) dataSource. 这里使用了spring的DriverManagerDataSource对象。这个对象是一个JDBC数据源的定义。 b) TransactionManager。这里使用spring的DataSourceTransactionManager对象。 c) 事务拦截器。这里使用spring的事务拦截器TransactionInterceptor。它有2个属性: transactionManager。这个属性指向前面定义的TransactionManager。 transactionAttributeSource。这个属性里, 指定了ContactManager的各个方法的事务方面的要求。 d) DataSourcePopulator。 使用sample.contact.DataSourcePopulator对象,往HSQL里创建相关的表结构和数据。 实现原理:DataSourcePopulator 实现了接口 InitializingBean。其中afterPropertiesSet方法将在spring初始化DataSourcePopulator后被调用。 e) ContactDao。这里指向一个ContactDaoSpring对象。它继承spring的 JdbcDaoSupport,g) 并实现ContactDao接口。它是真正实现JDBC操作的对象。 h) ContactManager。这里使用的是spring的ProxyFactoryBean。它有2个属性: i. ProxyInterfaces。代理接口:sample.contact.ContactManager
ii. InterceptorNames。拦截器名称。可以有多个,iv. 这里包括:transactionInterceptor、contactManagerSecurity、contactManagerTarget。其中,v. transactionInterceptor是前面定义的事务拦截器。ContactManagerSecurity则是在 applicationContext-common-authorization.xml里定义的方法调用授权。 i) ContactManagerTarget。这里指向的是sample.contact.ContactManagerBackend对象。 ContactManagerBackend实现了ContactManager接口和InitializingBean接口。它有2个自定义属性: contactDao和basicAclExtendedDao。这里会调用ACL的API去做些创建权限和删除权限的工作。
这是一个
Acegi
官方的例子。它以联系人的管理为例子,说明如何使用
Acegi
作权限控制。这个例子包含在
acegi
的包里面。下载地址:
http://prdownloads.sourceforge.net/acegisecurity/acegi-security-0.8.3.zip?download。
联系人管理说明了下列中心的Acegi安全控制能力:
-
Role-based security
(基于角色的安全)
――每个责任人都是某个角色的一员。而角色被用来限制对某些安全对象的访问。
-
Domain object instance security
(域对象实例安全)
――合同,这个系统里的主要域对象,拥有一个访问控制列表(
ACL
),用来指明谁允许读、管理和删除对象。
-
Method invocation security
(方法调用安全)――
这个
ContactManager
服务层对象
包含一些受保护的和公开的方法。
-
Web request security
(
Web
请求安全)
――这个“
/secure
”
URI
路径被使用
Acegi
安全保护,使得没有
ROLE_USER
角色的用户无法访问。
.
-
Security unaware application objects
(保护未知的应用对象)
――受保护的对象与
Acegi
之间没有明显的耦合或契约,所以它们没有察觉到安全是由
Acegi
提供的。
*
-
Security taglib usage
(安全标签库使用)
――所有的
JSP
使用
Acegi
安全标签库来封装安全信息。
*
-
Fully declarative security(
完全声明式的安全
)
――每一个安全方面特性都是在
application context
里面使用标准的
Acegi
安全对象来配置的。
*
-
Database-sourced security data
(支持数据库来源的安全数据)
――所有的用户、角色和
ACL
信息都可以从一个兼容
JDBC
的内存数据库获得。
-
Integrated form-based and BASIC authentication
(集成基于表单和
BASIC
验证)――
任何
BASIC
验证头部被检测以及作为验证使用。默认使用基于表单的普通交互式验证。
-
Remember-me services
(记住我的服务)――
Acegi
安全的插件式的“
remember-me
”
策略被演示。在登录表单里有一个相关的选择框与之对应。
联系人管理的业务功能描述:
1.1.
每个用户登录后,可以看到一个联系人列表。例如,
marissa's Contacts
Add
说明:用户没有权限访问的联系人信息,将不会显示。
2.2.
用户可以增加新的联系人信息。
3.3.
如果有删除权限,用户可以看到在联系人后面有一个“
Del
”链接。用户可以点击这个链接来删除某个联系人信息。
4.4.
如果有管理权限,用户可以看到在联系人后面有一个“
Admin Permission
”链接。用户可以点击这个链接来管理访问这个联系人的权限。例如,
Administer Permissions
sample.contact.Contact@26807f: Id: 1; Name: John Smith; Email: john@somewhere.com
-R--- [2] dianne
|
Del
|
-RW-D [22] peter
|
Del
|
A---- [1] marissa
|
Del
|
Add Permission
Manage
说明:每一行记录包含有
3
列。
第一列表示权限,例如,“
-RW-D
”表示可读、可写、可删除。
第二列也表示权限,但它是以类似
unix
权限的数字表达。例如,“
[22]
”
,
表示可读、可写、可删除。
第三列是用户名称。
每一行记录后面都有一个“
Del
”链接。点击这个链接,可以删除掉指定用户对这个联系人信息的权限。
5.5.
用户可以为某个联系人信息添加权限。例如,
Add Permission
说明:权限是动态添加的。例如,上图中给用户
scott
增加了读联系人
John
的权限。那么
scott
马上就可以看到联系人
John
的信息了。
摘要: ServletDispatcher
原理
ServletDispatcher
是默认的处理
Web Http
请求的调度器,它是一个
JavaServlet
,是
WebWork
框架的控制器。... 阅读全文
验证框架
WebWork
提供了在
Action
执行之前,对输入数据的验证功能,它使用了其核心
XWork
的验证框架。提供了如下功能:
1、
可配置的验证文件。它的验证文件是一个独立的
XML
配置文件,对验证的添加、修改只需更改配置文件,无需编译任何的
Class
。
2、
验证文件和被验证的对象完全解藕。验证对象是普通的
JavaBean
就可以了(可以是
FormBean
、域对象等),它们不需实现任何额外的方法或继承额外的类。
3、
多种不同的验证方式。因为它验证功能是可以继承的,所以可以用多种不同的方式指定验证文件,比如:通过父类的
Action
、通过
Action
、通过
Action
的方法、通过
Action
所使用的对象,等等。
4、
强大的表达式验证。它使用了
OGNL
的表达式语言,提供强大的表达式验证功能。
5、
同时支持服务器端和客户端验证。
下面我们来看看如何为用户注册添加验证功能:
1、
注册我们的验证类型
WebWork
为不同的验证要求提供不同的验证类型。一个验证类型,一般是有一个类来提供。这个类必须实现接口:
com.opensymphony.xwork.validator.Validator
,但我们在写自己的验证类型时,无需直接实现
Validator
接口,它有抽象类可供直接使用如
ValidatorSupport
、
FieldValidatorSupport
等。
验证类型在使用之前,必须要在
ValidatorFactory
(
com.opensymphony.xwork.validator
. ValidatorFactory
)中
注册。可以有二种方法实现验证类型的注册。一、写程序代码进行注册,它使用
ValidatorFactory
类的静态方法:
registerValidator(String name, String className)
。
二、使用配置文件
validators.xml
进行注册,要求把文件
validators.xml
放到
ClassPath
的跟目录中(
/WEB-INF/classes
)。但在实际开发中,一般都使用第二中注册方法。我们的验证类型注册如下:
<validators>
<validator name="required" class="com.opensymphony.xwork.validator.validators.RequiredFieldValidator"/>
<validator name="requiredstring" class="com.opensymphony.xwork.validator.validators.RequiredStringValidator"/>
<validator name="int" class="com.opensymphony.xwork.validator.validators.IntRangeFieldValidator"/>
<validator name="date" class="com.opensymphony.xwork.validator.validators.DateRangeFieldValidator"/>
<validator name="expression" class="com.opensymphony.xwork.validator.validators.ExpressionValidator"/>
<validator name="fieldexpression" class="com.opensymphony.xwork.validator.validators.FieldExpressionValidator"/>
<validator name="email" class="com.opensymphony.xwork.validator.validators.EmailValidator"/>
<validator name="url" class="com.opensymphony.xwork.validator.validators.URLValidator"/>
<validator name="visitor" class="com.opensymphony.xwork.validator.validators.VisitorFieldValidator"/>
<validator name="conversion" class="com.opensymphony.xwork.validator.validators.ConversionErrorFieldValidator"/>
<validator name="stringlength" class="com.opensymphony.xwork.validator.validators.StringLengthFieldValidator"/>
</validators>
注册验证类型的配置文件非常简单。它使用标签
<validator
>
提供名-值对的形式注册。这样我们的验证文件就可以直接引用它的名字。
2、
开启
Action
的验证功能
如果
Action
要使用验证框架的验证功能,它必须在配置文件中指定拦截器“
validation
”,它的定义如下:
<interceptor name="validation" class="com.opensymphony.xwork.validator.ValidationInterceptor"/>
。
我们的验证文件必须以
ActionName-validation.xml
格式命名,它必须被放置到与这个
Action
相同的包中。你也可以为这个
Action
通过别名的方式指定验证文件,它的命名格式为:
ActionName-aliasname-validation.xml
。“
ActionName
”是我们
Action
的类名;“
aliasname
”是我们在配置文件(
xwork.xml
)中定义这个
Action
所用到的名称。这样,同一个
Action
类,在配置文件中的不同定义就可以对应不同的验证文件。验证框架也会根据
Action
的继承结构去查找
Action
的父类验证文件,如果找到它会去执行这个父类的验证。
3、
实现我们的验证文件:
RegisterActionSupport-validation.xml
<!DOCTYPE validators PUBLIC "-//OpenSymphony Group//XWork Validator 1.0//EN" "http://www.opensymphony.com/xwork/xwork-validator-1.0.dtd">
<validators>
<field name="user.username">
<field-validator type="requiredstring">
<message>You must enter a value for username.</message>
</field-validator>
</field>
<field name="user.password">
<field-validator type="requiredstring">
<message>You must enter a value for password.</message>
</field-validator>
<field-validator type="fieldexpression">
<param name="expression">user.password == verifyPassword</param>
<message>Passwords don't match.</message>
</field-validator>
</field>
<field name="user.email">
<field-validator type="email">
<message>You must enter a valid email.</message>
</field-validator>
</field>
<field name="user.age">
<field-validator type="int">
<param name="min">6</param>
<param name="max">100</param>
<message>Age must be between ${min} and ${max}, current value is ${user.age}.</message>
</field-validator>
</field>
</validators>
说明:
1
)、
<field
>
标签代表一个字段,它的属性“
name
”和页面输入框的“
name
”属性必需完全一致,其实它也就是我们的表达式语言。
2
)、
<field-validator
>
标签定义我们的验证规则,
type
属性的值就是就是我们前面定义的验证类型。
3
)、验证文件中,字段的数据是通过表达式语言从我们的值堆栈(
OgnlValueStack
)中取得,一般是
Action
或
Model
对象。例如:我们的字段“
user.age
”,它会通过
Action
的
getUser().getAge()
来取得用户输入的年龄,再来根据验证的类型“
int
”和最大值最小值的参数来判断输入的数据是否能通过验证。
4
)、不管验证是否通过,我们的
Action
都会执行,但如果验证没有通过,它不会调用
Action
的
execute()
方法。
4、
显示
Action
的验证错误信息
如果用户输入的数据验证没有通过,我们需重新返回输入页面,并给出错误信息提示。拦截器栈“
validationWorkflowStack
”为我们实现了这个功能。它首先验证用户输入的数据,如果验证没有通过将不执行我们
Action
的
execute()
方法,而是将请求重新返回到输入页面。
我们的
xwork.xml
配置文件如下:
<action name="registerSupport" class="example.register.RegisterActionSupport">
<result name="success" type="dispatcher">
<param name="location">/register-result.jsp</param>
</result>
<result name="input" type="dispatcher">
<param name="location">/registerSupport.jsp</param>
</result>
<interceptor-ref name="validationWorkflowStack"/>
</action>
通过接口
ValidationAware
,
我们可以获得类级别或字段级别的验证错误信息,这个错误信息也就是我们验证文件中
<message>
标签里的数据。
ActionSupport
类已实现了此接口,这样在应用中我们的
Action
只要继承
ActionSupport
类就可以了。
RegisterActionSupport
.java
代码如下:
package
example.register;
import
com.opensymphony.xwork.ActionSupport;
public
class RegisterActionSupport extends ActionSupport {
private User user= new User();
private String verifyPassword;
public User getUser(){
returnthis.user;
}
public String execute(){
//
在这里调用用户注册的业务逻辑,比如:将注册信息存储到数据库
return SUCCESS;
}
public String getVerifyPassword(){
returnthis.verifyPassword;
}
publicvoid setVerifyPassword(String verPassword){
this.verifyPassword = verPassword;
}
}
我们
WebWork
的
UI
标签库直接提供了验证错误信息显示功能。如果字段级别的验证没有通过,它会在输入框上方显示验证文件定义的错误提示信息。我们将用户输入的页面更改如下:
registerSupport.jsp
<%@ taglib uri="webwork" prefix="ww" %>
<html>
<head><title>Register Example</title></head>
<body>
<table border=0 width=97%>
<tr><td align="left">
<ww:form name="'test'" action="'/example/registerSupport.action'" method="'POST'">
<ww:textfield label="'Username'" name="'user.username'" required="true"/>
<ww:textfield label="'Password'" name="'user.password'" required="true"/>
<ww:textfield label="'VerifyPassword'" name="'verifyPassword'" required="true"/>
<ww:textfield label="'Email'" name="'user.email'" required="true"/>
<ww:textfield label="'Age'" name="'user.age'" required="true"/>
<ww:submit value="'Submit'"/>
</ww:form>
</td></tr>
</table>
</body>
</html>
我们上面的例子使用的是服务器端验证。
WebWork
也为我们提供了方便的客户端验证。它将验证自动生成
JavaScript
脚本。如果要使用客户端验证只需改变相应的验证类型就可以了(输入页面的表单必需使用
<ww:form>
标签,并设置属性“
validate="true"
”)。具体的验证类型可以在
WebWork
的包
com.opensymphony.webwork.validators
中找到。
摘要: Interceptor
(拦截器)将
Action
共用的行为独立出来,在
Action
执行前后运行。这也就是我们所说的
AOP
(
Aspect Oriented Programming
,面向切面编程),它是分散关注的编程方法,它将通用需求功能从不相关类之中分离出来;同时,能够使得很多类共享一个行为,一... 阅读全文
摘要: Action
简介
Action
在
MVC
模式中担任控制部分的角色
,
在
WebWork
中使用的最多
,
用于接收页面参数,起到对
HttpRequest
判断处理作用。每个请求的动作都对应于一个相应的
... 阅读全文
一、简介 在程序中输出信息的目的有三:一是监视程序运行情况;一是将程序的运行情况记录到日志文件中,以备将来查看;一是做为调试器。但信息输出的手段不仅限于System.out.println()或System.out.print(),还有日志记录工具可以选择。与System.out.pringln()和System.out.print()相比,日志记录工具可以控制输出级别,并且可以在配置文件中对输出级别进行设置,这样开发阶段的信息在程序发布后就可以通过设置输出级别来消除掉,而无须对代码进行修正了。现在流行的日志记录工具很多, Log4J就是其中的佼佼者。 Log4J是由著名开源组织Apache推出的一款日志记录工具,供Java编码人员做日志输出之用,可以从网站http://logging.apache.org/log4j上免费获得,最新版本1.2.11。获得logging-log4j-1.2.11.zip文件后,解压缩,需要的是其中的log4j-1.2.11.jar文件,将该文件放到特定的文件夹中备用,我放到了我机器的G:\YPJCCK\Log4J\lib文件夹中。 这里选择的IDE是Eclipse和JBuilder。Eclipse用的是3.0.1加语言包,可以到www.eclipse.org网站上下载;JBuilder用的是JBuilder 2005。 二、配置类库 下面打开Eclipse或JBuilder。 如果使用的是Eclipse,那么在Eclipse打开后,点击菜单"文件"->"新建"->"项目",打开"新建项目"对话框:
请选中"Java项目",点击"下一步",进入"新建Java项目"对话框:
在这个对话框中需要设置项目的名称以及项目所在目录,我为自己的项目起名为Log4JTest,目录为G:\YPJCCK\Log4J\Eclipse\ Log4JTest。设置好后点击"下一步",进入下一个窗口。在这个窗口中选择名为"库"的选项卡,然后点击"添加外部JAR"按钮,将保存于特定文件夹中的log4j-1.2.11.jar文件引用进来。
设置好后,点击"完成",至此,已经具备了在Eclipse下使用Log4J的环境。 如果使用的是JBuilder,那么在JBuilder打开后,点击菜单"Tools"->"Configure" ->"Libraries",打开"Configure Libraries"对话框:
点击"New"按钮,打开"New Library Wizard"对话框:
使用"Add"按钮将保存于特定文件夹中的log4j-1.2.11.jar文件引用进来,并设置Name,即该类库的名字,我将Name设置为 Log4J。设置好后点击"OK"按钮,回到"Configure Libraries"对话框,再点击"OK"按钮,则JUnit类库已经被添加到JBuilder当中。 下面继续,在JBuilder中创建项目。点击菜单"File"->"New Project",打开"Project Wizard"对话框:
在这个窗口中设置项目名称及存放目录,我的项目名称仍为Log4JTest,路径为G:/YPJCCK/log4J/JBuilder/Log4JTest。点击"Next"进入下一个窗口:
在这个窗口中选择"Required Libraries"选项卡,点击"Add"按钮,将刚才设置的JUnit库引用进来。然后点击"Next"按钮,进入下一个窗口:
在这个窗口中用鼠标点击Encoding下拉列表框,然后按一下"G"键,选中相应选项,此时该项目的字符集就被设置成GBK了。如果做的是国内项目,这绝对是个好习惯。最后点击"Finish",项目创建完成。 三、编写一个简单的示例 在了解Log4J的使用方法之前,先编写一个简单的示例,以对Log4J有个感性认识。 如果使用的是Eclipse,请点击"文件"->"新建"->"类",打开"新建Java类"对话框,设置包为 piv.zheng.log4j.test,名称为Test,并确保"public static void main(String[] args)"选项选中;如果使用的是JBuilder,请点击"File"->"New Class",打开"Class Wizard"对话框,设置Package为piv.zheng.log4j.test,Class name为Test,并确保"Generate main method"选项选中。设置完成后,点击"OK"。代码如下: package piv.zheng.log4j.test; import org.apache.log4j.Logger; import org.apache.log4j.Level; import org.apache.log4j.SimpleLayout; import org.apache.log4j.ConsoleAppender; public class Test { public static void main(String[] args) { SimpleLayout layout = new SimpleLayout(); ConsoleAppender appender = new ConsoleAppender(layout); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.setLevel(Level.FATAL); log.debug("Here is DEBUG"); log.info("Here is INFO"); log.warn("Here is WARN"); log.error("Here is ERROR"); log.fatal("Here is FATAL"); } } 至此,示例编写完成。请点击运行按钮旁边的倒三角,选择"运行为"->"2 Java应用程序"(Eclipse),或者在Test类的选项卡上点击鼠标右键,在调出的快捷菜单中点击"Run using defaults"(JBuilder),运行程序,观察从控制台输出的信息。 四、Log4J入门 看过程序的运行效果后可能会奇怪,为何控制台只输出了"FATAL - Here is FATAL"这样一条信息,而程序代码中的log.debug()、log.info()等方法也都设置了类似的内容,却没有被输出?其实答案很简单,但在公布之前,先来了解一下Log4J的使用。 请先看前边的示例代码,会发现,示例中用到了Logger、Level、 ConsoleAppender、SimpleLayout等四个类。其中Logger类使用最多,甚至输出的信息也是在其对象log的fatal方法中设置的,那么Logger究竟是做什么的呢?其实Logger就是传说中的日志记录器(在Log4J中称为Category),创建方法有三: 1.根Category,默认创建,获取方法:
Logger log = Logger.getRootLogger();
2.用户创建的Category,方法:
Logger log = Logger.getLogger("test");
其中字符串test是为Category设定的名称。Category的名称允许使用任何字符,但区分大小写,例如:
Logger l1 = Logger.getLogger("x"); Logger l2 = Logger.getLogger("X");
l1和l2就是两个Category;而如果名称完全相同,例如:
Logger l1 = Logger.getLogger("x"); Logger l2 = Logger.getLogger("x");
l1和l2就是同一个Category。此外,符号"."在Category的名称中有特殊作用,这一点将在后边介绍。 3.与方法2类似,只是参数由字符串换成了类对象,其目的是通过类对象获取类的全名。这个方法比较常用,示例中使用的就是这个方法。 那么Category是如何输出信息的呢?其实示例中用到的debug、info、warn、error、fatal等五个方法都是用来输出信息的。什么,怎么这么多?原因很简单,Log4J支持分级输出。Log4J的输出级别有五个,由低到高依次是DEBUG(调试)、INFO(信息)、WARN(警告)、ERROR(错误)和FATAL(致命),分别与以上方法对应。当输出级别设置为DEBUG时,以上方法都能够输出信息,当输出级别设置为INFO 时,则只有debug方法将不能再输出信息,依此类推,当输出级别设置为FATAL时,就只有fatal方法可以输出信息了。现在再回头看前边的问题,为何只有设置给fatal方法的信息被输出就不难理解了,示例中有这样一行代码:
log.setLevel(Level.FATAL);
正是这行代码将log对象的输出级别设成了FATAL。在为log对象设置输出级别时用到了Level类,该类中定义了DEBUG、INFO、WARN、 ERROR、FATAL等五个静态对象,与五个输出级别相对应。此外,Level还有两个特殊的静态对象ALL和OFF,前者允许所有的方法输出信息,其级别其实比DEBUG还低;后者则会禁止所有的方法输出信息,其级别比FATAL要高。除前边示例中用到的五个方法,Logger还提供了这五个方法的重载,以在输出信息的同时抛出异常,以fatal方法为例:
log.fatal("Here is FATAL", new Exception("Exception"));
执行后输出信息: FATAL - Here is FATAL java.lang.Exception: Exception at piv.zheng.log4j.test.Test.main(Test.java:24) 其他方法类似。此外,Logger还提供了log方法,该方法不针对任何输出级别,需要在调用时设置,例如:
log.log(Level.FATAL, "Here is FATAL"); log.log(Level.FATAL, "Here is FATAL", new Exception("Exception"));
虽然一般情况下log方法不如其它方法方便,但由于允许设置级别,因此log方法在很多时候反而比其它方法更灵活,甚至可以在输出级别为OFF时输出信息。不过log方法主要是给用户自定义的输出级别用的,而且设立OFF输出级别的目的也为了不输出任何信息,因此请不要在log方法中使用OFF来输出信息。 此外,Category的输出级别并非必须,若未设置,子Category会默认使用其父Category的输出级别,若父Category也没设置,就使用再上一级Category的设置,直到根Category为止。根Category默认输出级别为DEBUG,因此在示例中,若将 "log.setLevel(Level.FATAL);"一行注释掉,则所有方法都会输出信息。 下面简单介绍一下Log4J中 Category的继承关系。其实在Log4J中Category之间是存在继承关系的,根Category默认创建,是级别最高的Category,用户创建的Category均继承自它。而用户创建的Category之间也存在继承关系,例如:
Logger lx = Logger.getLogger("x"); Logger lxy = Logger.getLogger("xy"); Logger lx_y = Logger.getLogger("x.y"); Logger lx_z = Logger.getLogger("x.z"); Logger lx_y_z = Logger.getLogger("x.y.z");
其中的lx_y、lx_z就是lx的子Category,而lx_y_z是lx_y的子Category。但lxy并不是lx的子Category。也许有点乱,下面来一个一个看。首先看与lx_y、lx_z对应的Category的名称"x.y"和"x.z","."前边的是什么,"x",这说明与名称为 "x"的Category对应lx就是它们的父Category;而与lx_y_z对应的Category的名称"x.y.z",最后一个"."前边的是什么,"x.y",这说明lx_y是lx_y_z的父Category;至于lxy,由于与之对应的Category名称"xy"之间没有".",因此它是一个与lx同级的Category,其父Category就是根Category器。此外还有一种情况,例如有一个名称为"a.b"的 Category,如果没有名称为"a"的Category,那么它的父Category也是根Category。前边说过,"."在Category名称中有特殊作用,其实它的作用就是继承。至此,为何使用类对象来创建Category也就不难理解了。 可是,仅有Category是无法完成信息输出的,还需要为Category添加Appender,即Category的输出源。前边的例子使用的是ConsoleAppender,即指定 Category将信息输出到控制台。其实Log4J提供的Appender有很多,这里选择几常用的进行介绍。 1.org.apache.log4j.WriterAppender,可以根据用户选择将信息输出到Writer或OutputStream。 示例代码: SimpleLayout layout = new SimpleLayout (); //向文件中输出信息,OutputStream示例 WriterAppender appender1 = null; try { appender1 = new WriterAppender(layout, new FileOutputStream("test.txt")); } catch(Exception ex) {} //向控制台输出信息,Writer示例 WriterAppender appender2 = null; try { appender2 = new WriterAppender(layout, new OutputStreamWriter(System.out)); } catch(Exception ex) {} //Category支持同时向多个目标输出信息 Logger log = Logger.getLogger(Test.class); log.addAppender(appender1); log.addAppender(appender2); log.debug("output"); 这个示例由第一个示例修改而来,没有设置输出级别,而且向Category中添加了两个输出源,运行后会在控制台中输出"DEBUG - output",并在工程目录下生成test.txt文件,该文件中也记录着"DEBUG - output"。若要将test.txt文件放到其它路径下,例如f:,则将"test.txt"改为"f:/test.txt",又如e:下的temp 文件夹,就改为"e:/temp/test.txt"。后边FileAppender、RollingFileAppender以及 DailyRollingFileAppender设置目标文件时也都可以这样来写。 2.org.apache.log4j.ConsoleAppender,向控制台输出信息,继承了WriterAppender,前边的示例使用的就是它。 3.org.apache.log4j.FileAppender,向文件输出信息,也继承了WriterAppender。 示例代码: SimpleLayout layout = new SimpleLayout(); //若文件不存在则创建文件,若文件已存在则向文件中追加信息 FileAppender appender = null; try { appender = new FileAppender(layout, "test.txt"); } catch(Exception e) {} Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 这个示例也由第一个示例修改而来,运行后会在工程目录下生成test.txt文件,该文件中记录着"DEBUG - output"。再次运行程序,查看文件,则"DEBUG - output"有两行。 另外,FileAppender还有一个构造:
FileAppender(Layout layout, String filename, boolean append)
与示例的类似,只是多了一个boolean型的参数append。append参数是个开关,用来设置当程序重启,而目标文件已存在时,是向目标文件追加信息还是覆盖原来的信息,当值为true时就追加,这是FileAppender默认的,当值为false时则覆盖。此外,FileAppender还提供了setAppend方法来设置append开关。 4.org.apache.log4j.RollingFileAppender,继承了 FileAppender,也是向文件输出信息,但文件大小可以限制。当文件大小超过限制时,该文件会被转为备份文件或删除,然后重新生成。文件的转换或删除与设置的备份文件最大数量有关,当数量大于0时就转为备份文件,否则(小于等于0)删除,默认的备份文件数量是1。转换备份文件非常简单,就是修改文件名,在原文件名之后加上".1",例如文件test.txt,转为备份文件后文件名为"test.txt.1"。但若同名的备份文件已存在,则会先将该备份文件删除或更名,这也与设置的备份文件最大数量有关,若达到最大数量就删除,否则更名。若备份文件更名时也遇到同样情况,则使用同样的处理方法,依此类推,直到达到设置的备份文件最大数量。备份文件更名也很简单,就是将扩展名加1,例如test.txt.1文件更名后变为test.txt.2, test.txt.2文件更名后变为test.txt.3。 示例代码: SimpleLayout layout = new SimpleLayout(); //若文件不存在则创建文件,若文件已存在则向文件中追加内容 RollingFileAppender appender = null; try { appender = new RollingFileAppender(layout, "test.txt"); } catch(Exception e) {} //限制备份文件的数量,本例为2个 appender.setMaxBackupIndex(2); //限制目标文件的大小,单位字节,本例为10字节 appender.setMaximumFileSize(10); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output0"); log.debug("output1"); log.debug("output2"); 程序运行后,会在工程目录下生成test.txt、test.txt.1和test.txt.2三个文件,其中test.txt内容为空,而后两个文件则分别记录着"DEBUG - output2"和"DEBUG - output1",这是怎么回事?原来由于目标文件大小被限制为10字节,而三次使用log.debug方法输出的信息都超过了10字节,这样就导致了三次备份文件转换,所以test.txt内容为空。而备份文件最大数量被设为2,因此第一次转换的备份文件就被删掉了,而后两次的则保存下来。此外,由于 test.txt转换备份文件时是先转为test.txt.1,再转为test.txt.2,因此最后test.txt.2的内容是"DEBUG - output1",而test.txt.1是"DEBUG - output2",这点千万别弄混了。 另外,RollingFileAppender还提供了两个方法: (1)setMaxFileSize,功能与setMaximumFileSize一样,但参数是字符串,有两种情况:一是仅由数字组成,默认单位为字节,例如"100",即表示限制文件大小为100字节;一是由数字及存储单位组成,例如"1KB"、"1MB"、"1GB",其中单位不区分大小写,分别表示限制文件大小为1K、1M、1G。 (2)rollOver,手动将目标文件转换为备份文件,使用起来较灵活,适用于复杂情况。 示例代码: SimpleLayout layout = new SimpleLayout(); RollingFileAppender appender = null; try { appender = new RollingFileAppender(layout, "test.txt"); } catch(Exception e) {} appender.setMaxBackupIndex(2);
Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output0"); appender.rollOver(); log.debug("output1"); appender.rollOver(); log.debug("output2"); appender.rollOver(); 这里没限制目标文件大小,但程序运行后,效果与上例相同。 5.org.apache.log4j.DailyRollingFileAppender,也继承了FileAppender,并且也是向文件输出信息,但会根据设定的时间频率生成备份文件。 时间频率格式简介: '.'yyyy-MM,按月生成,生成时间为每月最后一天午夜过后,例如test.txt在2005年7月31日午夜过后会被更名为test.txt.2005-07,然后重新生成。 '.'yyyy-ww,按周生成,生成时间为每周六午夜过后,例如test.txt在2005年8月13日午夜过后会被更名为test.txt.2005-33,33表示当年第33周。 '.'yyyy-MM-dd,按天生成,生成时间为每天午夜过后,例如2005年8月16日午夜过后,test.txt会被更名为test.txt.2005-08-16。 '.'yyyy-MM-dd-a,也是按天生成,但每天会生成两次,中午12:00过后一次,午夜过后一次,例如test.txt在2005年8月16 日12:00过后会被更名为test.txt.2005-8-16-上午,午夜过后会被更名为test.txt.2005-8-16-下午。 '.'yyyy-MM-dd-HH,按小时生成,例如test.txt在2005年8月16日12:00过后会被更名为test.txt.2005-8-16-11。 '.'yyyy-MM-dd-HH-mm,按分钟生成,例如test.txt在2005年8月16日12:00过后会被更名为test.txt.2005-8-16-11-59。 示例代码: SimpleLayout layout = new SimpleLayout(); DailyRollingFileAppender appender = null; try { appender = new DailyRollingFileAppender(layout, "test.txt", "'.'yyyy-MM-dd-HH-mm"); } catch(Exception e) {} Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 编码完成后运行程序,等一分钟后再次运行,由于我是在2005年8月17日15:42分第一次运行程序的,因此工程目录下最终有两个文件test.txt和test.txt.2005-08-17-15-42。 6.org.apache.log4j.AsyncAppender,用于管理不同类型的Appender,也能实现同时向多个源输出信息,但其执行是异步的。 示例代码: SimpleLayout layout = new SimpleLayout(); //向控制台输出 ConsoleAppender appender1 = null; try { appender1 = new ConsoleAppender(layout); } catch(Exception e) {} //向文件输出 FileAppender appender2 = null; try { appender2 = new FileAppender(layout, "test.txt"); } catch(Exception e) {} //使用AsyncAppender实现同时向多个目标输出信息 AsyncAppender appender = new AsyncAppender(); appender.addAppender(appender1); appender.addAppender(appender2); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 此外,AsyncAppender和Logger都提供了更多的方法来管理Appender,例如getAppender、 getAllAppenders、removeAppender和removeAllAppenders,分别用来获取指定的Appender、获取全部 Appender、移除指定的Appender以及移除全部Appender。 7.org.apache.log4j.jdbc.JDBCAppender,将信息输出到数据库。 示例代码: JDBCAppender appender = new JDBCAppender(); appender.setDriver("com.mysql.jdbc.Driver"); appender.setURL("jdbc:mysql://localhost:3306/zheng"); appender.setUser("root"); appender.setPassword("11111111"); appender.setSql("insert into log4j (msg) values ('%m')"); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 这里使用的数据库是MySQL 5.0.4beta,用户名root,密码11111111,我在其中建了一个库zheng,包含表log4j,该表只有一个字段msg,类型为varchar(300)。此外,本例用到的JDBC驱动可以从http://dev.mysql.com/downloads/connector/j/3.1.html下载,版本3.1.8a,下载mysql-connector-java-3.1.8a.zip文件后解压缩,需要其中的mysql-connector- java-3.1.8-bin.jar文件。下面再来看代码。由于JDBCAppender内部默认使用PatternLayout格式化输出信息,因此这里没用到SimpleLayout,而appender.setSql所设置的SQL语句就是PatternLayout所需的格式化字符串,故此其中才有"%m"这样的字符,有关PatternLayout的具体内容后边介绍。执行后,表log4j增加一条记录,内容为"output"。 8.org.apache.log4j.nt.NTEventLogAppender,向Windows NT系统日志输出信息。 示例代码: SimpleLayout layout = new SimpleLayout(); NTEventLogAppender appender = new NTEventLogAppender("Java", layout);
Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 注意,要完成此示例,还需向C:\WINNT\system32文件夹(我的操作系统装在了C:\)中复制一个名为 NTEventLogAppender.dll的文件。如果跟我一样用的是Log4J 1.2.11,实在对不住,Log4J 1.2.11并未提供该文件。虽然logging-log4j-1.2.11.zip文件解压缩后,其下的src\java\org\apache\ log4j\nt文件夹中有一个make.bat文件执行后可以编译出该文件,但还需要配置,很麻烦。还好,条条大道通罗马,1.2.11不行,就换 1.2.9,可以从http://apache.justdn.org/logging/log4j/1.2.9下载,下载后解压缩logging-log4j-1.2.9.zip文件,在其下的src\java\org\apache\log4j\nt文件夹中找到 NTEventLogAppender.dll,复制过去就可以了。程序执行后,打开"事件查看器",选择"应用程序日志",其中有一条来源为Java的记录,这条记录就是刚才输出的信息了。 9.org.apache.log4j.lf5.LF5Appender,执行时会弹出一个窗口,信息在该窗口中以表格的形式显示。 示例代码: LF5Appender appender = new LF5Appender(); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 由于LF5Appender不需要Layout格式化输出信息,因此这里没有设置。此外LF5Appender还提供了一个setMaxNumberOfRecords方法,用来限制信息在表格中显示的行数。 10.org.apache.log4j.net.SocketAppender,以套接字方式向服务器发送日志,然后由服务器将信息输出。 示例代码: //指定要连接的服务器地址及端口,这里使用的是本机9090端口 SocketAppender appender = new SocketAppender("localhost", 9090); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); SocketAppender不需要设置Layout,因为SocketAppender不负责输出信息。那么如何看到信息输出的效果呢?这就需要SocketServer和SimpleSocketServer了。 示例代码1: package piv.zheng.log4j.test; import org.apache.log4j.net.SocketServer; public class TestServer { public static void main(String[] args) { SocketServer.main(new String[]{"9090", "test.properties", "G:/YPJCCK/Log4J"}); } } 这是SocketServer的示例。SocketServer只有一个静态方法main,该方法意味着SocketServer不仅可以在代码中被调用,也可以用java命令执行。main方法只有一个参数,是个字符串数组,但要求必须有三个元素:元素一用来指定端口,本例为9090;元素二用来指定输出信息时需要的配置文件,该文件放在工程目录下,本例使用的test.properties内容如下: log4j.rootLogger=, console log4j.appender.console =org.apache.log4j.ConsoleAppender log4j.appender.console.layout=org.apache.log4j.SimpleLayout 该配置指定SocketServer使用ConsoleAppender以SimpleLayout格式输出信息;元素三用来指定一个路径,以存放.lcf 文件,我指定的是本机的G:/YPJCCK/Log4J文件夹。.lcf文件也是输出信息时使用的配置文件,格式与元素二所指定的配置文件一样,但 test.properties是默认配置文件,即当.lcf文件找不到时才使用。那么.lcf文件如何命名呢?其实.lcf文件的名称并不是随意起的,当SocketAppender与SocketServer建立连接时,SocketServer就会获得SocketAppender所在计算机的IP 地址与网络ID,并将其格式化成"网络ID/IP地址"这样的字符串,然后获取其中的网络ID作为.lcf文件的主名,例如 "zhengyp/127.0.0.1",其中的"zhengyp"就是主文件名,而后再根据这个文件名来调用相应的.lcf文件。这意味着对不同的计算机可以提供不同的配置文件,使信息输出时有不同的效果。此外,SocketServer还默认了一个名为generic.lcf的文件,用于处理网络ID 获取不到或其他情况,本例是用的就是这个文件,内容如下: log4j.rootLogger=, console log4j.appender.console =org.apache.log4j.ConsoleAppender log4j.appender.console.layout=org.apache.log4j.PatternLayout log4j.appender.console.layout.ConversionPattern=%m%n 该配置指定SocketServer使用ConsoleAppender以PatternLayout格式输出信息。运行程序时请先运行 SocketServer,再运行SocketAppender。SocketAppender运行结束后,就可以从SocketServer的控制台看到输出的信息了。 示例代码2: package piv.zheng.log4j.test; import org.apache.log4j.net.SimpleSocketServer;
public class TestServer { public static void main(String[] args) { SimpleSocketServer.main(new String[]{"9090", "test.properties"}); } } 这是SimpleSocketServer的示例,与SocketServer相比,只允许指定一个默认的配置文件,而无法对不同计算机使用不同的配置文件。 11.org.apache.log4j.net.SocketHubAppender,也是以套接字方式发送日志,但与SocketAppender相反,SocketHubAppender是服务器端,而不是客户端。 示例代码: //指定服务器端口,这里使用的是本机9090端口 SocketHubAppender appender = new SocketHubAppender(9090); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); while (true) { Thread.sleep(1000); log.debug("output"); //输出信息 } 由于SocketHubAppender一旦运行就开始发送消息,而无论有无接收者,因此这里使用了while语句并将条件设为true以保证程序持续运行。不过为了保证性能,这里还使用了Thread.sleep(1000),这样程序每循环一次都休眠1秒,如果机器性能不好,还可以将值设的再大些。此外,由于SocketHubAppender也不负责输出信息,因此同样不需要设置Layout。那么如何看到信息输出的效果呢?这里我自己写了个客户端程序,代码如下: package piv.zheng.log4j.test; import java.net.Socket; import java.lang.Thread; import org.apache.log4j.LogManager; import org.apache.log4j.PropertyConfigurator; import org.apache.log4j.net.SocketNode; public class TestClient { public static void main(String[] args) throws Exception { //创建客户端套接字对象 Socket s = new Socket("localhost", 9090); //调用配置文件 PropertyConfigurator.configure("test.properties"); //从套接字中恢复Logger,并输出信息 new Thread(new SocketNode(s, LogManager.getLoggerRepository())).start(); } } 由于SocketHubAppender与SocketAppender一样,发送的也是SocketNode对象,因此编写该程序时参考了 SocketServer的源码。此外,这里的配置文件直接使用了上例的test.properties文件。运行程序时请先运行 SocketHubAppender,再运行客户端程序,然后从客户端的控制台就可以看到效果了。 13.org.apache.log4j.net.TelnetAppender,与SocketHubAppender有些类似,也是作为服务器发送信息,但TelnetAppender发送的不是SocketNode对象,而是Category输出的结果。 示例代码: SimpleLayout layout = new SimpleLayout(); TelnetAppender appender = new TelnetAppender(); appender.setLayout(layout); //设置Layout appender.setPort(9090); //设置端口号 appender.activateOptions(); //应用设置 Logger log = Logger.getLogger(Test.class); log.addAppender(appender); while (true) { java.lang.Thread.sleep(1000); log.debug("output"); //输出信息 } //appender.close(); 注意最后一行被注释掉的代码,若该行代码执行,则TelnetAppender的资源会被清理,从而导致TelnetAppender无法继续运行。那么如何看到信息输出的效果呢?这里提供两种方法:方法一,使用Telnet工具,我使用的就是Windows自带的Telnet。运行 TelnetAppender程序后,点击[开始]菜单->[运行],在"运行"框中输入"telnet",回车,telnet客户端弹出,这是一个命令行程序,输入命令"open localhost 9090",回车,然后就可以看到效果了。方法二,自己写程序,代码如下: package piv.zheng.log4j.test; import java.net.*; import java.io.*; public class TestClient { public static void main(String[] args) throws Exception { //创建客户端套接字对象 Socket s = new Socket("localhost", 9090); //将BufferedReader与Socket绑定,以输出Socket获得的信息 BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream())); //获得信息并输出 String line = in.readLine(); while (line != null) { System.out.println(line); line = in.readLine(); } } } 13.org.apache.log4j.net.SMTPAppender,向指定的电子邮件发送信息,但只能发送ERROR和FATAL级别的信息,而且还没提供身份验证功能。 示例代码: SimpleLayout loyout = new SimpleLayout(); SMTPAppender appender = new SMTPAppender(); appender.setLayout(loyout); //设置Layout appender.setFrom("zhengyp@126.com"); //设置发件人 appender.setSMTPHost("smtp.126.com"); //设置发送邮件服务器 appender.setTo("zhengyp@126.com"); //设置收件人 appender.setSubject("Log4J Test"); //设置邮件标题 appender.activateOptions(); //应用设置 Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("Here is DEBUG"); log.info("Here is INFO"); log.warn("Here is WARN"); log.error("Here is ERROR"); log.fatal("Here is FATAL"); 要运行此示例,还需要JavaMail 和JAF,前者是Sun推出的电子邮件类库,可以从http://java.sun.com/products/javamail/downloads/index.html下载,最新版本1.3.3,下载javamail-1_3_3-ea.zip压缩包后需要其中的mail.jar文件;后者全称是JavaBeans Activation Framework,提供了对输入任意数据块的支持,并能相应地对其进行处理,可以从http://www.sun.com/download中找到,最新版本1.1,下载jaf-1_1-ea.zip压缩包后需要其中的activation.jar文件。不过,程序运行后会抛出两次异常,分别是log.error和log.fatal方法导致的,失败的原因很简单,我用的邮件服务器需要身份验证。 14.piv.zheng.log4j.test.SMTPAppender,自定义的,依照Log4J提供的SMTPAppender修改而来,增加了身份验证功能,并去掉了对级别的限制。由于代码太长,所以放到了另一篇文章《自定义SMTPAppender的源码》中,有兴趣的请自行去查看。 示例代码: SimpleLayout layout = new SimpleLayout(); SMTPAppender appender = new SMTPAppender(layout); appender.setFrom("zhengyp@126.com"); //发件人 appender.setSMTPHost("smtp.126.com"); //发送邮件服务器 appender.setTo("zhengyp@126.com"); //收件人 appender.setSubject("Log4J Test"); //邮件标题 appender.setAuth("true"); //身份验证标识 appender.setUsername("zhengyp"); //用户名 appender.setPassword("1111111"); //密码 appender.activateOptions(); //应用设置 Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 同样需要JavaMail 和JAF。程序运行后会发送一封邮件,快去查看一下自己的邮箱吧^_^ 此外,Log4J还提供了SyslogAppender、JMSAppender(均在org.apache.log4j.net包下)以及更多的 Appender,或者用来向Unix操作系统的syslogd服务发送信息,或者通过JMS方式发送信息,或者以其他方式发送信息。由于条件有现,就不再介绍了。 不过,在前边的示例中还使用了SimpleLayout和PatternLayout来格式化输出的信息,这里也简单介绍一下。 1.org.apache.log4j.SimpleLayout,一直用的就是它,输出的格式比较简单,就是"级别 - 信息"。 2.org.apache.log4j.HTMLLayout,以HTML格式输出信息。 示例代码: HTMLLayout layout = new HTMLLayout(); layout.setTitle("Log4J Test"); //HTML页标题 FileAppender appender = null; try { appender = new FileAppender(layout, "test.html"); } catch(Exception e) {} Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 程序运行后会在工程目录下生成一个HTML页,可以用浏览器来查看。 3.org.apache.log4j.xml.XMLLayout,以XML格式输出信息。 示例代码: XMLLayout layout = new XMLLayout(); FileAppender appender = null; try { appender = new FileAppender(layout, "test.xml"); } catch(Exception e) {} Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 程序运行后会在工程目录下生成一个test.xml文件。 4.org.apache.log4j.TTCCLayout,输出信息的同时输出日志产生时间、相关线程及Category等信息。 示例代码: TTCCLayout layout = new TTCCLayout(); //是否打印与TTCCLayout关联的Category的名称,默认为true,表示打印 layout.setCategoryPrefixing(true); //是否打印当前线程,默认为true,表示打印 layout.setThreadPrinting(true); //是否打印输出和当前线程相关的NDC信息,默认为true,表示打印 layout.setContextPrinting(true); //设置日期时间格式 layout.setDateFormat("iso8601"); //设置时区 layout.setTimeZone("GMT+8:00"); //设置时区后需要调用此方法应用设置 layout.activateOptions(); ConsoleAppender appender = new ConsoleAppender(layout); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 注意,TTCCLayout输出的时间格式及时区是可以设置的: (1)setDateFormat,设置日期时间格式,有五个常用值:"NULL",表示不输出;"RELATIVE",输出信息所用的时间,以毫秒为单位,默认使用该值;"ABSOLUTE",仅输出时间部分;"DATE",按当前所在地区显示日期和时间;"ISO8601",按ISO8601标准显示日期和时间。这些字符串不区分大小写。此外,还可以使用时间模式字符来格式化日期时间,详细内容请参考J2SE文档中的 java.text.SimpleDateFormat类。 (2)setTimeZone,设置时区,详细内容请参考J2SE文档中的java.util.TimeZone类和java.util.SimpleTimeZone类。但请注意,当日期格式为"RELATIVE"时,设置时区会造成冲突。 5.org.apache.log4j.PatternLayout,用模式字符灵活指定信息输出的格式。 示例代码: String pattern = "Logger: %c %n" + "Date: %d{DATE} %n" + "Message: %m %n"; PatternLayout layout = new PatternLayout(pattern); ConsoleAppender appender = new ConsoleAppender(layout); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("output"); 模式字符串简介: %c:Category名称。还可以使用%c{n}的格式输出Category的部分名称,其中n为正整数,输出时会从Category名称的右侧起查 n个".",然后截取第n个"."右侧的部分输出,例如Category的名称为"x.y.z",指定格式为"%c{2}",则输出"y.z"。 %C:输出信息时Category所在类的名称,也可以使用%C{n}的格式输出。 %d:输出信息的时间,也可以用%d{FormatString}的格式输出,其中FormatString的值请参考TTCCLayout的setDateFormat方法,但NULL和RELATIVE在%d中无法使用。 %F:输出信息时Category所在类文件的名称。 %l:输出信息时Category所在的位置,使用"%C.%M(%F:%L)"可以产生同样的效果。 %L:输出信息时Category在类文件中的行号。 %m:信息本身。 %M:输出信息时Category所在的方法。 %n:换行符,可以理解成回车。 %p:日志级别。 %r:输出信息所用的时间,以毫秒为单位。 %t:当前线程。 %x:输出和当前线程相关的NDC信息。 %X:输出与当前现成相关的MDC信息。 %%:输出%。 此外,还可以在%与模式字符之间加上修饰符来设置输出时的最小宽度、最大宽度及文本对齐方式,例如: %30d{DATE}:按当前所在地区显示日期和时间,并指定最小宽度为30,当输出信息少于30个字符时会补以空格并右对齐。 %-30d{DATE}:也是按当前所在地区显示日期和时间,指定最小宽度为30,并在字符少于30时补以空格,但由于使用了"-",因此对齐方式为左对齐,与默认情况一样。 %.40d{DATE}:也是按当前所在地区显示日期和时间,但指定最大宽度为40,当输出信息多于40个字符时会将左边多出的字符截掉。此外,最大宽度只支持默认的左对齐方式,而不支持右对齐。 %30.40d{DATE}:如果输出信息少于30个字符就补空格并右对齐,如果多于40个字符,就将左边多出的字符截掉。 %-30.40d{DATE}:如果输出信息少于30个字符就补空格并左对齐,如果多于40个字符,就将左边多出的字符截掉。 五、Log4J进阶 了解以上内容后,就已经初步掌握Log4J了,但要想灵活使用Log4J,则还需要了解其配置功能。这里简单介绍一下。 1.org.apache.log4j.BasicConfigurator,默认使用ConsoleAppender以PatternLayout (使用PatternLayout.TTCC_CONVERSION_PATTERN,即"%r [%t] %p %c %x - %m%n"格式)输出信息。 示例代码: BasicConfigurator.configure(); Logger log = Logger.getLogger(Test.class); log.debug("output"); 注意,BasicConfigurator及其它Configurator其实都只对根Category进行配置,但由于用户创建的Category会继承根Category的特性(声明,许多资料介绍Category继承关系时都主要在讨论输出级别,而事实上,Category间继承的不仅是输出级别,所有特性都可以继承),因此输出时仍会显示BasicConfigurator配置的效果。此外,还可以使用configure方法指定Appender,以自定义输出。BasicConfigurator允许同时指定多个Appender。 示例代码: SimpleLayout layout1 = new SimpleLayout(); ConsoleAppender appender1 = new ConsoleAppender(layout1); BasicConfigurator.configure(appender1); String pattern = "Logger: %c %n" + "Date: %d{DATE} %n" + "Message: %m %n"; PatternLayout layout2 = new PatternLayout(pattern); FileAppender appender2 = null; try { appender2 = new FileAppender(layout2, "test.log", false); } catch(Exception e){} BasicConfigurator.configure(appender2); Logger log = Logger.getLogger(Test.class); log.debug("output"); 这里用BasicConfigurator指定了两个Appender,即ConsoleAppender和FileAppender,程序运行后信息会在以SimpleLayout输出到控制台的同时以PatternLayout输出到test.log文件。若要清除这些Appender,可以调用 BasicConfigurator的resetConfiguration方法。 2. org.apache.log4j.PropertyConfigurator,调用文本配置文件输出信息,通常使用.properties文件。配置文件以"键=值"的形式保存数据,注释以"#"开头。PropertyConfigurator和配置文件在介绍SocketAppender和 SocketHubAppender时曾提到过。使用PropertyConfigurator可以避免硬编码。 示例代码: PropertyConfigurator.configure("test.properties"); Logger log = Logger.getLogger(Test.class); log.debug("output"); 要完成该示例,还需要在工程目录下创建一个test.properties文件,内容如下: ##设置根Category,其值由输出级别和指定的Appender两部分组成 #这里设置输出级别为DEBUG log4j.rootLogger=DEBUG,appender ##输出信息到控制台 #创建一个名为appender的Appender,类型为ConsoleAppender log4j.appender.appender=org.apache.log4j.ConsoleAppender #设置appender以SimpleLayout输出 log4j.appender.appender.layout=org.apache.log4j.SimpleLayout 此外,PropertyConfigurator也允许同时指定多个Appender,例如: #这里没有设置输出级别,但指定了两个Appender log4j.rootLogger=,appender1,appender2 #输出信息到控制台 log4j.appender.appender1=org.apache.log4j.ConsoleAppender log4j.appender.appender1.layout=org.apache.log4j.SimpleLayout #输出信息到文件 log4j.appender.appender2=org.apache.log4j.FileAppender log4j.appender.appender2.File=test.log log4j.appender.appender2.Append=false log4j.appender.appender2.layout=org.apache.log4j.PatternLayout log4j.appender.appender2.layout.ConversionPattern=Logger: %c %nDate: %d{DATE} %nMessage: %m %n 关于更多配置,网上示例很多,这里不再赘述。但要说明一件事,就是配置文件中的键是怎么来的。参照后一个示例,查看 PropertyConfigurator源码,会发现"log4j.rootLogger"是定义好的,只能照写;而"log4j.appender" 字样也可以找到,与指定的Appender名称appender1、appender2联系起来,log4j.appender.appender1和 log4j.appender.appender2也就不难理解了;再看下去,还能找到"prefix + ".layout"",这样log4j.appender.appender1.layout也有了;可是 log4j.appender.appender2.File 和log4j.appender.appender2.Append呢?还记得前边介绍FileAppender时曾提到的setAppend方法吗?其实FileAppender还有个getAppend方法,这说明FileAppender具有Append属性。那么File呢?当然也是 FileAppender的属性了。至于log4j.appender.appender2.layout.ConversionPattern也一样,只不过FileAppender换成了PatternLayout。其实别的Appender和Layout的属性也都是这样定义成键来进行设置的。此外,定义键时,属性的首字母不区分大小写,例如"File",也可以写成"file"。 3. org.apache.log4j.xml.DOMConfigurator,调用XML配置文件输出信息。其定义文档是log4j- 1.2.11.jar中org\apache\log4j\xml包下的log4j.dtd文件。与PropertyConfigurator相比, DOMConfigurator似乎是趋势。 示例代码: DOMConfigurator.configure("test.xml"); Logger log = Logger.getLogger(Test.class); log.debug("output"); 要完成该示例,也需要在工程目录下创建一个test.xml文件,内容如下: <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE log4j:configuration SYSTEM "log4j.dtd"> <log4j:configuration xmlns:log4j="http://jakarta.apache.org/log4j/"> <!-- 输出信息到控制台 创建一个名为appender的Appender,类型为ConsoleAppender --> <appender name="appender" class="org.apache.log4j.ConsoleAppender"> <!-- 设置appender以SimpleLayout输出 --> <layout class="org.apache.log4j.SimpleLayout"/> </appender> <!-- 设置根Category,其值由输出级别和指定的Appender两部分组成 这里设置输出级别为DEBUG --> <root> <priority value ="debug" /> <appender-ref ref="appender"/> </root> </log4j:configuration> 此外,DOMConfigurator也允许同时指定多个Appender,例如: <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE log4j:configuration SYSTEM "log4j.dtd"> <log4j:configuration xmlns:log4j="http://jakarta.apache.org/log4j/"> <!-- 输出信息到控制台 --> <appender name="appender1" class="org.apache.log4j.ConsoleAppender"> <layout class="org.apache.log4j.SimpleLayout"/> </appender> <!-- 输出信息到文件 --> <appender name="appender2" class="org.apache.log4j.FileAppender"> <param name="File" value="test.log"/> <param name="Append" value="false"/> <layout class="org.apache.log4j.PatternLayout"> <param name="ConversionPattern" value="Logger: %c %nDate: %d{DATE} %nMessage: %m %n"/> </layout> </appender> <!-- 这里没有设置输出级别,但指定了两个Appender --> <root> <appender-ref ref="appender1"/> <appender-ref ref="appender2"/> </root> </log4j:configuration> 由于以上两个示例是在PropertyConfigurator的两个示例基础上改的,而且也写了注释,因此这里只简单介绍一下<param> 标记。<param>标记有两个属性,name和value,前者的值也是Appender或Layout的属性名,作用与 log4j.appender.appender2.File这样的键一样。设置时,首字母同样不区分大小写,例如"File"也可以写成"file"。此外还请注意,使用这两段XML代码时应将中文注释去掉,或者把<?xml version="1.0" encoding="UTF-8" ?>中的UTF-8改成GBK或GB2312,否则会导致错误。这里使用的UTF-8是XML默认的字符集。 4. org.apache.log4j.lf5.DefaultLF5Configurator,默认使用LF5Appender来输出信息,需要调用 log4j-1.2.11.jar中org\apache\log4j\lf5\config包下的defaultconfig.properties文件。 示例代码: try { DefaultLF5Configurator.configure(); } catch(Exception e){} Logger log = Logger.getLogger(Test.class); log.debug("output"); 下面讨论另外一个话题:Diagnostic Context。Diagnostic Context意为诊断环境,针对于多用户并发环境,在这种环境下,通常需要对每个客户端提供独立的线程以处理其请求,此时若要在日志信息中对客户端加以区分,为每个线程分别创建Category是个办法。但这样做并不高效,反而会导致大量资源被占用。Diagnostic Context所要解决的就是这个问题。Diagnostic Context会为当前线程提供一定空间,然后将信息保存到该空间供Category调用。与创建一个Category相比,这点信息所占的资源自然要少得多。 1.org.apache.log4j.NDC。NDC是Nested Diagnostic Context的简写,意为嵌套诊断环境,使用时提供一个堆栈对象来保存信息。堆栈的特点是数据后进先出、先进后出,即清理堆栈时,后保存的数据会被先清掉,而先保存的数据则被后清掉。 示例代码: PatternLayout layout = new PatternLayout("%m %x%n"); ConsoleAppender appender = new ConsoleAppender(layout); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); String tmp = "zhengyp"; //模拟从客户端获取的信息 log.debug("Start"); NDC.push(tmp); //添加信息到堆栈中 log.debug("Before"); NDC.pop(); //将信息从堆栈中移除 log.debug("After"); NDC.remove(); //将当前线程移除,退出NDC环境 log.debug("End"); 这里使用了PatternLayout来格式化信息,其模式字符%x就是用来输出NDC信息的。程序运行后会输出如下内容: Start Before zhengyp After End 可以看到,第二行输出时由于已向堆栈中添加了信息,因此"zhengyp"也会同时输出;而第三行输出时由于信息已被移除,因此就没再输出"zhengyp"。不过这个示例仅简单演示了NDC的用法,而没有显示出NDC的堆栈特性,所以下面再提供一个示例,代码如下: TTCCLayout layout = new TTCCLayout(); ConsoleAppender appender = new ConsoleAppender(layout); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("Start"); NDC.push("zhengyp"); //添加信息到堆栈中 log.debug("Test1"); NDC.push("192.168.0.1"); //向堆栈中追加信息 log.debug("Test2"); NDC.pop(); //从堆栈中移除信息,但移除的只是最后的信息 log.debug("Test3"); NDC.pop(); //再次从堆栈中移除信息 log.debug("Test4"); log.debug("End"); 这里格式化输出信息使用的是TTCCLayout,还记得其setContextPrinting方法吗?程序运行后,从输出的信息就可以看到效果了。此外,NDC还提供了其他方法: (1)get,获取堆栈中的全部信息。以上例为例,当输出Test2时,使用该方法会获得"zhengyp 192.168.0.1"。 (2)peek,获取堆栈中最后的信息。仍以上例为例,当输出Test1时会获得"zhengyp",Test2时为"192.168.0.1",而当输出Test3时由于"192.168.0.1"已被移除,"zhengyp"又成了最后的信息,因此获得的仍是"zhengyp"。 (3)clear,清空堆栈中的全部信息。 (4)setMaxDepth,设置堆栈的最大深度,即当前的信息可以保留多少,对之后追加的信息没有影响。当需要一次清掉多条信息时,使用setMaxDepth会比多次调用pop方便。 2.org.apache.log4j.MDC。MDC是Mapped Diagnostic Context的简写,意为映射诊断环境,提供了一个Map对象来保存信息。Map对象使用Key、Value的形式保存值。 示例代码: PatternLayout layout = new PatternLayout("%m %X{name} %X{ip}%n"); ConsoleAppender appender = new ConsoleAppender(layout); Logger log = Logger.getLogger(Test.class); log.addAppender(appender); log.debug("Start"); //添加信息到Map中 MDC.put("name", "zhengyp1"); MDC.put("ip", "192.168.1.1"); log.debug("Test1"); //添加信息到Map中,若Key重复,则覆盖之前的值 MDC.put("name", "zhengyp2"); MDC.put("ip", "192.168.1.2"); log.debug("Test2"); //将信息从Map中移除,此时信息不再输出 MDC.remove("name"); MDC.remove("ip"); log.debug("End"); 这个示例演示了MDC的基本用法,格式化信息用的也是PatternLayout,模式字符为"%X",其格式必须为"%X{Key}"。其中Key就是向 Map对象添加信息时put方法所用的Key,这里为name和ip。由于可以使用"%X{Key}"输出信息,因此MDC使用起来会比NDC更灵活。此外,MDC还提供了get方法来获取指定Key的信息。 六、小结 用了近半个月,终于大概掌握了Log4J。由于本文是边学边写的,目的是将Log4J的用法记录下来,而非提供一份中文参考,因此内容并不细致,但尽量提供了示例。不过到最后才发现,示例存在问题,其实Logger做为类的static成员比较恰当,而我为了图方便,竟直接写到了main方法中,这一点还请注意。 此外,这里再推荐一下《The Complete log4j Manual》,是对Log4J较详细的介绍,在网上可以找到,只不过是英文的。
|
产者-消费者方案是多线程应用程序开发中最常用的构造之一 ― 因此困难也在于此。因为在一个应用程序中可以多次重复生产者-消费者行为,其代码也可以如此。软件开发人员 Ze'ev Bubis 和 Saffi Hartal 创建了 Consumer 类,该类通过在一些多线程应用程序中促进代码重用以及简化代码调试和维护来解决这个问题。请通过单击本文顶部或底部的 讨论来参与本文的 论坛,与作者和其他读者分享您的想法。 多线程应用程序通常利用生产者-消费者编程方案,其中由生产者线程创建重复性作业,将其传递给作业队列,然后由消费者线程处理作业。虽然这种编程方法很有用,但是它通常导致重复的代码,这对于调试和维护可能是真正的问题。 为了解决这个问题并促进代码重用,我们创建了 Consumer 类。 Consumer 类包含所有用于作业队列和消费者线程的代码,以及使这两者能够结合在一起的逻辑。这使我们可以专注于业务逻辑 ― 关于应该如何处理作业的细节 ― 而不是专注于编写大量冗余的代码。同时,它还使得调试多线程应用程序的任务变得更为容易。 在本文中,我们将简单观察一下多线程应用程序开发中公共线程用法,同时,解释一下生产者-消费者编程方案,并研究一个实际的示例来向您演示 Consumer 类是如何工作的。请注意,对于多线程应用程序开发或消费者-生产者方案,本文不作深入介绍;有关那些主题,请参阅 参考资料获取文章的清单。 多线程基础知识 多线程是一种使应用程序能同时处理多个操作的编程技术。通常有两种不同类型的多线程操作使用多个线程: - 适时事件,当作业必须在特定的时间或在特定的间隔内调度执行时
- 后台处理,当后台事件必须与当前执行流并行处理或执行时
适时事件的示例包括程序提醒、超时事件以及诸如轮询和刷新之类的重复性操作。后台处理的示例包括等待发送的包或等待处理的已接收的消息。 生产者-消费者关系 生产者-消费者方案很适合于后台处理类别的情况。这些情况通常围绕一个作业“生产者”方和一个作业“消费者”方。当然,关于作业并行执行还有其它考虑事项。在大多数情况下,对于使用同一资源的作业,应以“先来先服务”的方式按顺序处理,这可以通过使用单线程的消费者轻松实现。通过使用这种方法,我们使用单个线程来访问单个资源,而不是用多个线程来访问单个资源。 要启用标准消费者,当作业到来时创建一个作业队列来存储所有作业。生产者线程通过将新对象添加到消费者队列来交付这个要处理的新对象。然后消费者线程从队列取出每个对象,并依次处理。当队列为空时,消费者进入休眠。当新的对象添加到空队列时,消费者会醒来并处理该对象。因为大多数应用程序喜欢顺序处理方式,所以消费者通常是单线程的。 问题:代码重复 因为生产者-消费者方案很常用,所以在构建应用程序时它可能会出现几次,这导致了代码重复。我们认识到,这显示了在应用程序开发过程期间多次使用了生产者-消费者方案的问题。 当第一次需要生产者-消费者行为时,通过编写一个采用一个线程和一个队列的类来实现该行为。当第二次需要这种行为时,我们着手从头开始实现它,但是接着认识到以前已经做过这件事了。我们复制了代码并修改了处理对象的方式。当第三次在该应用程序中实现生产者-消费者行为时,很明显我们复制了太多代码。我们决定,需要一个适用的 Consumer 类,它将处理我们所有的生产者-消费者方案。 我们的解决方案:Consumer 类 我们创建 Consumer 类的目的是:在我们的应用程序中,消除这种代码重复 ― 为每个生产者-消费者实例编写一个新作业队列和消费者线程来解决这个问题。有了适当的 Consumer 类,我们所必须做的只是编写专门用于作业处理(业务逻辑)的代码。这使得我们的代码更清晰、更易于维护以及更改起来更灵活。 我们对 Consumer 类有如下需求: - 重用:我们希望这个类包括所有东西。一个线程、一个队列以及使这两者结合在一起的所有逻辑。这将使我们只须编写队列中“消费”特定作业的代码。(因而,例如,程序员使用
Consumer 类时,将重载 onConsume(ObjectjobToBeConsumed) 方法。)
- 队列选项:我们希望能够设置将由
Consumer 对象使用的队列实现。但是,这意味着我们必须确保队列是线程安全的或使用一个不会与消费操作冲突的单线程生产者。无论使用哪种方法,都必须将队列设计成允许不同的进程能访问其方法。
- Consumer 线程优先级:我们希望能够设置
Consumer 线程运行的优先级。
- Consumer 线程命名:线程拥有一个有意义的名称会比较方便,当然这的确有助于调试。例如,如果您向 Java 虚拟机发送了一个信号,它将生成一个完整的线程转储 ― 所有线程及其相应堆栈跟踪的快照。要在 Windows 平台上生成这个线程转储,您必须在 Java 程序运行的窗口中按下键序列
<ctrl><break> ,或者单击窗口上的“关闭”按钮。有关如何使用完整的线程转储来诊断 Java 软件问题的更多信息,请参阅 参考资料。
类代码 在 getThread() 方法中,我们使用“惰性创建”来创建 Consumer 的线程,如清单 1 所示: 清单 1. 创建 Consumer 的线程 /**
* Lazy creation of the Consumer's thread.
*
* @return the Consumer's thread
*/
private Thread getThread()
{
if (_thread==null)
{
_thread = new Thread()
{
public void run()
{
Consumer.this.run();
}
};
}
return _thread;
|
该线程的 run() 方法运行 Consumer 的 run() 方法,它是主消费者循环,如清单 2 所示: 清单 2. run() 方法是主 Consumer 循环 /**
* Main Consumer's thread method.
*/
private void run()
{
while (!_isTerminated)
{
// job handling loop
while (true)
{
Object o;
synchronized (_queue)
{
if (_queue.isEmpty())
break;
o = _queue.remove();
}
if (o == null)
break;
onConsume(o);
}
// if we are not terminated and the queue is still empty
// then wait until new jobs arrive.
synchronized(_waitForJobsMonitor)
{
if (_isTerminated)
break;
if(_queue.isEmpty())
{
try
{
_waitForJobsMonitor.wait();
}
catch (InterruptedException ex)
{
}
}
}
}
}// run()
|
基本上, Consumer 的线程一直运行,直到队列中不再有等待的作业为止。然后它进入休眠,只在第一次调用 add(Object) 时醒来,该方法向队列添加一个新作业并“踢”醒该线程。 使用 wait() 和 notify() 机制来完成“睡眠”和“踢”。实际的消费者工作由 OnConsume(Object) 方法处理,如清单 3 所示: 清单 3. 唤醒和通知 Consumer /**
* Add an object to the Consumer.
* This is the entry point for the producer.
* After the item is added, the Consumer's thread
* will be notified.
*
* @param the object to be 'consumed' by this consumer
*/
public void add(Object o)
{
_queue.add(o);
kickThread();
}
/**
* Wake up the thread (without adding new stuff to consume)
*
*/
public void kickThread()
{
if (!this._thread.isInterrupted())
{
synchronized(_waitForJobsMonitor)
{
_waitForJobsMonitor.notify();
}
}
}
|
示例:MessagesProcessor 为了向您展示 Consumer 类是如何工作的,我们将使用一个简单示例。 MessagesProcessor 类以异步方式处理进入的消息(也就是说,不干扰调用线程)。其工作是在每个消息到来时打印它。 MessagesProcessor 具有一个处理到来的消息作业的内部 Consumer 。当新作业进入空队列时, Consumer 调用 processMessage(String) 方法来处理它,如清单 4 所示: 清单 4. MessagesProcessor 类 class MessagesProcessor
{
String _name;
// anonymous inner class that supplies the consumer
// capabilities for the MessagesProcessor
private Consumer _consumer = new Consumer()
{
// that method is called on each event retrieved
protected void onConsume(Object o)
{
if (!(o instanceof String))
{
System.out.println("illegal use, ignoring");
return;
}
MessagesProcesser.this.processMessage((String)o);
}
}.setName("MessagesProcessor").init();
public void gotMessageEvent(String s)
{
_consumer.add(s);
}
private void processMessage(String s)
{
System.out.println(_name+" processed message: "+s);
}
private void terminate()
{
_consumer.terminateWait();
_name = null;
}
MessagesProcessor()
{
_name = "Example Consumer";
}
}
|
正如您可以从上面的代码中所看到的,定制 Consumer 相当简单。我们使用了一个匿名内部类来继承 Consumer 类,并重载抽象方法 onConsume() 。因此,在我们的示例中,只需调用 processMessage 。 Consumer 类的高级特性 除了开始时提出的基本需求以外,我们还为 Consumer 类提供了一些我们觉得有用的高级特性。 事件通知
- onThreadTerminate():只在终止
Consumer 前调用该方法。我们出于调试目的覆盖了这个方法。
- goingToRest():只在 Consumer 线程进入休眠前调用该方法(也就是说,只在调用
_waitForJobsMonitor.wait() 之前调用)。只在需要消费者在进入休眠之前处理一批已处理工作的复杂情况中,可能需要这种通知。
终止
- terminate():Consumer 线程的异步终止。
- terminateWait():设置调用线程一直等待,直到消费者线程实际终止为止。
在我们的示例中,如果使用 terminate() 而不是 terminateWait() ,那么将会出现问题,因为在将 _name 设置成空值之后调用 onConsume() 方法。这将导致执行 processMessage 的线程抛出一个 NullPointerException 。 结束语:Consumer 类的好处 可在 参考资料一节下载 Consumer 类的源代码。请自由使用源代码,并按照您的需要扩展它。我们发现将这个类用于多线程应用程序开发有许多好处:
- 代码重用/重复代码的消除:如果您有
Consumer 类,就不必为您应用程序中的每个实例编写一个新的消费者。如果在应用程序开发中频繁使用生产者-消费者方案,这可以很大程度地节省时间。另外,请牢记重复代码是滋生错误的沃土。它还使基本代码的维护更为困难。
- 更少错误:使用验证过的代码是一种防止错误的好实践,尤其是处理多线程应用程序时。因为
Consumer 类已经被调试过,所以它更安全。消费者还通过在线程和资源之间担任安全中介来防止与线程相关的错误。消费者可以代表其它线程以顺序的方式访问资源。
- 漂亮、清晰的代码:使用
Consumer 类有助于我们编写出更简单的代码,这样的代码更容易理解和维护。如果我们不使用 Consumer 类,就必须编写代码来处理两种不同的功能:消费逻辑(队列和线程管理、同步等)和指定消费者的用法或功能的代码。
虽然用 Java™ 语言编写的程序在理论上是不会出现“内存泄漏”的,但是有时对象在不再作为程序的逻辑状态的一部分之后仍然不被垃圾收集。本月,负责保障应用程序健康的工程师 Brian Goetz 探讨了无意识的对象保留的常见原因,并展示了如何用弱引用堵住泄漏。
要让垃圾收集(GC)回收程序不再使用的对象,对象的逻辑 生命周期(应用程序使用它的时间)和对该对象拥有的引用的实际 生命周期必须是相同的。在大多数时候,好的软件工程技术保证这是自动实现的,不用我们对对象生命周期问题花费过多心思。但是偶尔我们会创建一个引用,它在内存中包含对象的时间比我们预期的要长得多,这种情况称为无意识的对象保留(unintentional object retention)。
全局 Map 造成的内存泄漏
无意识对象保留最常见的原因是使用 Map 将元数据与临时对象(transient object)相关联。假定一个对象具有中等生命周期,比分配它的那个方法调用的生命周期长,但是比应用程序的生命周期短,如客户机的套接字连接。需要将一些元数据与这个套接字关联,如生成连接的用户的标识。在创建 Socket 时是不知道这些信息的,并且不能将数据添加到 Socket 对象上,因为不能控制 Socket 类或者它的子类。这时,典型的方法就是在一个全局 Map 中存储这些信息,如清单 1 中的 SocketManager 类所示:
清单 1. 使用一个全局 Map 将元数据关联到一个对象
public class SocketManager {
private Map<Socket,User> m = new HashMap<Socket,User>();
public void setUser(Socket s, User u) {
m.put(s, u);
}
public User getUser(Socket s) {
return m.get(s);
}
public void removeUser(Socket s) {
m.remove(s);
}
}
SocketManager socketManager;
...
socketManager.setUser(socket, user);
|
这种方法的问题是元数据的生命周期需要与套接字的生命周期挂钩,但是除非准确地知道什么时候程序不再需要这个套接字,并记住从 Map 中删除相应的映射,否则,Socket 和 User 对象将会永远留在 Map 中,远远超过响应了请求和关闭套接字的时间。这会阻止 Socket 和 User 对象被垃圾收集,即使应用程序不会再使用它们。这些对象留下来不受控制,很容易造成程序在长时间运行后内存爆满。除了最简单的情况,在几乎所有情况下找出什么时候 Socket 不再被程序使用是一件很烦人和容易出错的任务,需要人工对内存进行管理。
找出内存泄漏
程序有内存泄漏的第一个迹象通常是它抛出一个 OutOfMemoryError ,或者因为频繁的垃圾收集而表现出糟糕的性能。幸运的是,垃圾收集可以提供能够用来诊断内存泄漏的大量信息。如果以 -verbose:gc 或者 -Xloggc 选项调用 JVM,那么每次 GC 运行时在控制台上或者日志文件中会打印出一个诊断信息,包括它所花费的时间、当前堆使用情况以及恢复了多少内存。记录 GC 使用情况并不具有干扰性,因此如果需要分析内存问题或者调优垃圾收集器,在生产环境中默认启用 GC 日志是值得的。
有工具可以利用 GC 日志输出并以图形方式将它显示出来,JTune 就是这样的一种工具(请参阅 参考资料)。观察 GC 之后堆大小的图,可以看到程序内存使用的趋势。对于大多数程序来说,可以将内存使用分为两部分:baseline 使用和 current load 使用。对于服务器应用程序,baseline 使用就是应用程序在没有任何负荷、但是已经准备好接受请求时的内存使用,current load 使用是在处理请求过程中使用的、但是在请求处理完成后会释放的内存。只要负荷大体上是恒定的,应用程序通常会很快达到一个稳定的内存使用水平。如果在应用程序已经完成了其初始化并且负荷没有增加的情况下,内存使用持续增加,那么程序就可能在处理前面的请求时保留了生成的对象。
清单 2 展示了一个有内存泄漏的程序。MapLeaker 在线程池中处理任务,并在一个 Map 中记录每一项任务的状态。不幸的是,在任务完成后它不会删除那一项,因此状态项和任务对象(以及它们的内部状态)会不断地积累。
清单 2. 具有基于 Map 的内存泄漏的程序
public class MapLeaker {
public ExecutorService exec = Executors.newFixedThreadPool(5);
public Map<Task, TaskStatus> taskStatus
= Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
private Random random = new Random();
private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };
private class Task implements Runnable {
private int[] numbers = new int[random.nextInt(200)];
public void run() {
int[] temp = new int[random.nextInt(10000)];
taskStatus.put(this, TaskStatus.STARTED);
doSomeWork();
taskStatus.put(this, TaskStatus.FINISHED);
}
}
public Task newTask() {
Task t = new Task();
taskStatus.put(t, TaskStatus.NOT_STARTED);
exec.execute(t);
return t;
}
}
|
图 1 显示 MapLeaker GC 之后应用程序堆大小随着时间的变化图。上升趋势是存在内存泄漏的警示信号。(在真实的应用程序中,坡度不会这么大,但是在收集了足够长时间的 GC 数据后,上升趋势通常会表现得很明显。)
图 1. 持续上升的内存使用趋势
确信有了内存泄漏后,下一步就是找出哪种对象造成了这个问题。所有内存分析器都可以生成按照对象类进行分解的堆快照。有一些很好的商业堆分析工具,但是找出内存泄漏不一定要花钱买这些工具 —— 内置的 hprof 工具也可完成这项工作。要使用 hprof 并让它跟踪内存使用,需要以 -Xrunhprof:heap=sites 选项调用 JVM。
清单 3 显示分解了应用程序内存使用的 hprof 输出的相关部分。(hprof 工具在应用程序退出时,或者用 kill -3 或在 Windows 中按 Ctrl+Break 时生成使用分解。)注意两次快照相比,Map.Entry 、Task 和 int[] 对象有了显著增加。
请参阅 清单 3。
清单 4 展示了 hprof 输出的另一部分,给出了 Map.Entry 对象的分配点的调用堆栈信息。这个输出告诉我们哪些调用链生成了 Map.Entry 对象,并带有一些程序分析,找出内存泄漏来源一般来说是相当容易的。
清单 4. HPROF 输出,显示 Map.Entry 对象的分配点
TRACE 300446:
java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)
java.util.HashMap.addEntry(<Unknown Source>:Unknown line)
java.util.HashMap.put(<Unknown Source>:Unknown line)
java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)
com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)
com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)
|
弱引用来救援了
SocketManager
的问题是 Socket -User 映射的生命周期应当与 Socket 的生命周期相匹配,但是语言没有提供任何容易的方法实施这项规则。这使得程序不得不使用人工内存管理的老技术。幸运的是,从 JDK 1.2 开始,垃圾收集器提供了一种声明这种对象生命周期依赖性的方法,这样垃圾收集器就可以帮助我们防止这种内存泄漏 —— 利用弱引用。
弱引用是对一个对象(称为 referent)的引用的持有者。使用弱引用后,可以维持对 referent 的引用,而不会阻止它被垃圾收集。当垃圾收集器跟踪堆的时候,如果对一个对象的引用只有弱引用,那么这个 referent 就会成为垃圾收集的候选对象,就像没有任何剩余的引用一样,而且所有剩余的弱引用都被清除。(只有弱引用的对象称为弱可及(weakly reachable)。)
WeakReference
的 referent 是在构造时设置的,在没有被清除之前,可以用 get() 获取它的值。如果弱引用被清除了(不管是 referent 已经被垃圾收集了,还是有人调用了 WeakReference.clear() ),get() 会返回 null 。相应地,在使用其结果之前,应当总是检查 get() 是否返回一个非 null 值,因为 referent 最终总是会被垃圾收集的。
用一个普通的(强)引用拷贝一个对象引用时,限制 referent 的生命周期至少与被拷贝的引用的生命周期一样长。如果不小心,那么它可能就与程序的生命周期一样 —— 如果将一个对象放入一个全局集合中的话。另一方面,在创建对一个对象的弱引用时,完全没有扩展 referent 的生命周期,只是在对象仍然存活的时候,保持另一种到达它的方法。
弱引用对于构造弱集合最有用,如那些在应用程序的其余部分使用对象期间存储关于这些对象的元数据的集合 —— 这就是 SocketManager 类所要做的工作。因为这是弱引用最常见的用法,WeakHashMap 也被添加到 JDK 1.2 的类库中,它对键(而不是对值)使用弱引用。如果在一个普通 HashMap 中用一个对象作为键,那么这个对象在映射从 Map 中删除之前不能被回收,WeakHashMap 使您可以用一个对象作为 Map 键,同时不会阻止这个对象被垃圾收集。清单 5 给出了 WeakHashMap 的 get() 方法的一种可能实现,它展示了弱引用的使用:
清单 5. WeakReference.get() 的一种可能实现
public class WeakHashMap<K,V> implements Map<K,V> {
private static class Entry<K,V> extends WeakReference<K>
implements Map.Entry<K,V> {
private V value;
private final int hash;
private Entry<K,V> next;
...
}
public V get(Object key) {
int hash = getHash(key);
Entry<K,V> e = getChain(hash);
while (e != null) {
K eKey= e.get();
if (e.hash == hash && (key == eKey || key.equals(eKey)))
return e.value;
e = e.next;
}
return null;
}
|
调用 WeakReference.get() 时,它返回一个对 referent 的强引用(如果它仍然存活的话),因此不需要担心映射在 while 循环体中消失,因为强引用会防止它被垃圾收集。WeakHashMap 的实现展示了弱引用的一种常见用法 —— 一些内部对象扩展 WeakReference 。其原因在下面一节讨论引用队列时会得到解释。
在向 WeakHashMap 中添加映射时,请记住映射可能会在以后“脱离”,因为键被垃圾收集了。在这种情况下,get() 返回 null ,这使得测试 get() 的返回值是否为 null 变得比平时更重要了。
用 WeakHashMap 堵住泄漏
在 SocketManager 中防止泄漏很容易,只要用 WeakHashMap 代替 HashMap 就行了,如清单 6 所示。(如果 SocketManager 需要线程安全,那么可以用 Collections.synchronizedMap() 包装 WeakHashMap )。当映射的生命周期必须与键的生命周期联系在一起时,可以使用这种方法。不过,应当小心不滥用这种技术,大多数时候还是应当使用普通的 HashMap 作为 Map 的实现。
清单 6. 用 WeakHashMap 修复 SocketManager
public class SocketManager {
private Map<Socket,User> m = new WeakHashMap<Socket,User>();
public void setUser(Socket s, User u) {
m.put(s, u);
}
public User getUser(Socket s) {
return m.get(s);
}
}
|
引用队列
WeakHashMap
用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,get() 实现可以根据 WeakReference.get() 是否返回 null 来区分死的映射和活的映射。但是这只是防止 Map 的内存消耗在应用程序的生命周期中不断增加所需要做的工作的一半,还需要做一些工作以便在键对象被收集后从 Map 中删除死项。否则,Map 会充满对应于死键的项。虽然这对于应用程序是不可见的,但是它仍然会造成应用程序耗尽内存,因为即使键被收集了,Map.Entry 和值对象也不会被收集。
可以通过周期性地扫描 Map ,对每一个弱引用调用 get() ,并在 get() 返回 null 时删除那个映射而消除死映射。但是如果 Map 有许多活的项,那么这种方法的效率很低。如果有一种方法可以在弱引用的 referent 被垃圾收集时发出通知就好了,这就是引用队列 的作用。
引用队列是垃圾收集器向应用程序返回关于对象生命周期的信息的主要方法。弱引用有两个构造函数:一个只取 referent 作为参数,另一个还取引用队列作为参数。如果用关联的引用队列创建弱引用,在 referent 成为 GC 候选对象时,这个引用对象(不是 referent)就在引用清除后加入 到引用队列中。之后,应用程序从引用队列提取引用并了解到它的 referent 已被收集,因此可以进行相应的清理活动,如去掉已不在弱集合中的对象的项。(引用队列提供了与 BlockingQueue 同样的出列模式 —— polled、timed blocking 和 untimed blocking。)
WeakHashMap
有一个名为 expungeStaleEntries() 的私有方法,大多数 Map 操作中会调用它,它去掉引用队列中所有失效的引用,并删除关联的映射。清单 7 展示了 expungeStaleEntries() 的一种可能实现。用于存储键-值映射的 Entry 类型扩展了 WeakReference ,因此当 expungeStaleEntries() 要求下一个失效的弱引用时,它得到一个 Entry 。用引用队列代替定期扫描内容的方法来清理 Map 更有效,因为清理过程不会触及活的项,只有在有实际加入队列的引用时它才工作。
清单 7. WeakHashMap.expungeStaleEntries() 的可能实现
private void expungeStaleEntries() {
Entry<K,V> e;
while ( (e = (Entry<K,V>) queue.poll()) != null) {
int hash = e.hash;
Entry<K,V> prev = getChain(hash);
Entry<K,V> cur = prev;
while (cur != null) {
Entry<K,V> next = cur.next;
if (cur == e) {
if (prev == e)
setChain(hash, next);
else
prev.next = next;
break;
}
prev = cur;
cur = next;
}
}
}
|
结束语
弱引用和弱集合是对堆进行管理的强大工具,使得应用程序可以使用更复杂的可及性方案,而不只是由普通(强)引用所提供的“要么全部要么没有”可及性。下个月,我们将分析与弱引用有关的软引用,将分析在使用弱引用和软引用时,垃圾收集器的行为。
许多程序员在其整个开发生涯中都不曾使用定点或浮点数,可能的例外是,偶尔在计时测试或基准测试程序中会用到。Java语言和类库支持两类非整数类型 ― IEEE 754 浮点( float 和 double ,包装类(wrapper class)为 Float 和 Double ),以及任意精度的小数( java.math.BigDecimal )。在本月的 Java 理论和实践中,Brian Goetz 探讨了在 Java 程序中使用非整数类型时一些常碰到的陷阱和“gotcha”。请在本文的 论坛上提出您对本文的想法,以飨笔者和其他读者。(您也可以单击本文顶部或底部的讨论来访问论坛)。
虽然几乎每种处理器和编程语言都支持浮点运算,但大多数程序员很少注意它。这容易理解 ― 我们中大多数很少需要使用非整数类型。除了科学计算和偶尔的计时测试或基准测试程序,其它情况下几乎都用不着它。同样,大多数开发人员也容易忽略 java.math.BigDecimal 所提供的任意精度的小数 ― 大多数应用程序不使用它们。然而,在以整数为主的程序中有时确实会出人意料地需要表示非整型数据。例如,JDBC 使用 BigDecimal 作为 SQL DECIMAL 列的首选互换格式。
IEEE 浮点
Java 语言支持两种基本的浮点类型: float 和 double ,以及与它们对应的包装类 Float 和 Double 。它们都依据 IEEE 754 标准,该标准为 32 位浮点和 64 位双精度浮点二进制小数定义了二进制标准。
IEEE 754 用科学记数法以底数为 2 的小数来表示浮点数。IEEE 浮点数用 1 位表示数字的符号,用 8 位来表示指数,用 23 位来表示尾数,即小数部分。作为有符号整数的指数可以有正负之分。小数部分用二进制(底数 2)小数来表示,这意味着最高位对应着值 ?(2 -1),第二位对应着 ?(2 -2),依此类推。对于双精度浮点数,用 11 位表示指数,52 位表示尾数。IEEE 浮点值的格式如图 1 所示。
图 1. IEEE 754 浮点数的格式
因为用科学记数法可以有多种方式来表示给定数字,所以要规范化浮点数,以便用底数为 2 并且小数点左边为 1 的小数来表示,按照需要调节指数就可以得到所需的数字。所以,例如,数 1.25 可以表示为尾数为 1.01,指数为 0: (-1) 0*1.01 2*2 0
数 10.0 可以表示为尾数为 1.01,指数为 3: (-1) 0*1.01 2*2 3
特殊数字
除了编码所允许的值的标准范围(对于 float ,从 1.4e-45 到 3.4028235e+38),还有一些表示无穷大、负无穷大、 -0 和 NaN(它代表“不是一个数字”)的特殊值。这些值的存在是为了在出现错误条件(譬如算术溢出,给负数开平方根,除以 0 等)下,可以用浮点值集合中的数字来表示所产生的结果。
这些特殊的数字有一些不寻常的特征。例如, 0 和 -0 是不同值,但在比较它们是否相等时,被认为是相等的。用一个非零数去除以无穷大的数,结果等于 0 。特殊数字 NaN 是无序的;使用 == 、 < 和 > 运算符将 NaN 与其它浮点值比较时,结果为 false 。如果 f 为 NaN,则即使 (f == f) 也会得到 false 。如果想将浮点值与 NaN 进行比较,则使用 Float.isNaN() 方法。表 1 显示了无穷大和 NaN 的一些属性。
表 1. 特殊浮点值的属性
表达式
|
结果
|
Math.sqrt(-1.0)
|
-> NaN
|
0.0 / 0.0
|
-> NaN
|
1.0 / 0.0
|
-> 无穷大
|
-1.0 / 0.0
|
-> 负无穷大
|
NaN + 1.0
|
-> NaN
|
无穷大 + 1.0
|
-> 无穷大
|
无穷大 + 无穷大
|
-> 无穷大
|
NaN > 1.0
|
-> false
|
NaN == 1.0
|
-> false
|
NaN < 1.0
|
-> false
|
NaN == NaN
|
-> false
|
0.0 == -0.01
|
-> true
|
基本浮点类型和包装类浮点有不同的比较行为
使事情更糟的是,在基本 float 类型和包装类 Float 之间,用于比较 NaN 和 -0 的规则是不同的。对于 float 值,比较两个 NaN 值是否相等将会得到 false ,而使用 Float.equals() 来比较两个 NaN Float 对象会得到 true 。造成这种现象的原因是,如果不这样的话,就不可能将 NaN Float 对象用作 HashMap 中的键。类似的,虽然 0 和 -0 在表示为浮点值时,被认为是相等的,但使用 Float.compareTo() 来比较作为 Float 对象的 0 和 -0 时,会显示 -0 小于 0 。
浮点中的危险
由于无穷大、NaN 和 0 的特殊行为,当应用浮点数时,可能看似无害的转换和优化实际上是不正确的。例如,虽然好象 0.0-f 很明显等于 -f ,但当 f 为 0 时,这是不正确的。还有其它类似的 gotcha,表 2 显示了其中一些 gotcha。
表 2. 无效的浮点假定
这个表达式……
|
不一定等于……
|
当……
|
0.0 - f
|
-f
|
f 为 0 |
f < g
|
! (f >= g)
|
f 或 g 为 NaN |
f == f
|
true
|
f 为 NaN |
f + g - g
|
f
|
g 为无穷大或 NaN |
舍入误差
浮点运算很少是精确的。虽然一些数字(譬如 0.5 )可以精确地表示为二进制(底数 2)小数(因为 0.5 等于 2 -1),但其它一些数字(譬如 0.1 )就不能精确的表示。因此,浮点运算可能导致舍入误差,产生的结果接近 ― 但不等于 ― 您可能希望的结果。例如,下面这个简单的计算将得到 2.600000000000001 ,而不是 2.6 :
double s=0;
for (int i=0; i<26; i++)
s += 0.1;
System.out.println(s);
|
类似的, .1*26 相乘所产生的结果不等于 .1 自身加 26 次所得到的结果。当将浮点数强制转换成整数时,产生的舍入误差甚至更严重,因为强制转换成整数类型会舍弃非整数部分,甚至对于那些“看上去似乎”应该得到整数值的计算,也存在此类问题。例如,下面这些语句:
double d = 29.0 * 0.01;
System.out.println(d);
System.out.println((int) (d * 100));
|
将得到以下输出:
这可能不是您起初所期望的。
浮点数比较指南
由于存在 NaN 的不寻常比较行为和在几乎所有浮点计算中都不可避免地会出现舍入误差,解释浮点值的比较运算符的结果比较麻烦。
最好完全避免使用浮点数比较。当然,这并不总是可能的,但您应该意识到要限制浮点数比较。如果必须比较浮点数来看它们是否相等,则应该将它们差的绝对值同一些预先选定的小正数进行比较,这样您所做的就是测试它们是否“足够接近”。(如果不知道基本的计算范围,可以使用测试“abs(a/b - 1) < epsilon”,这种方法比简单地比较两者之差要更准确)。甚至测试看一个值是比零大还是比零小也存在危险 ―“以为”会生成比零略大值的计算事实上可能由于积累的舍入误差会生成略微比零小的数字。
NaN 的无序性质使得在比较浮点数时更容易发生错误。当比较浮点数时,围绕无穷大和 NaN 问题,一种避免 gotcha 的经验法则是显式地测试值的有效性,而不是试图排除无效值。在清单 1 中,有两个可能的用于特性的 setter 的实现,该特性只能接受非负数值。第一个实现会接受 NaN,第二个不会。第二种形式比较好,因为它显式地检测了您认为有效的值的范围。
清单 1. 需要非负浮点值的较好办法和较差办法
// Trying to test by exclusion -- this doesn't catch NaN or infinity
public void setFoo(float foo) {
if (foo < 0)
throw new IllegalArgumentException(Float.toString(f));
this.foo = foo;
}
// Testing by inclusion -- this does catch NaN
public void setFoo(float foo) {
if (foo >= 0 && foo < Float.INFINITY)
this.foo = foo;
else
throw new IllegalArgumentException(Float.toString(f));
}
|
不要用浮点值表示精确值
一些非整数值(如几美元和几美分这样的小数)需要很精确。浮点数不是精确值,所以使用它们会导致舍入误差。因此,使用浮点数来试图表示象货币量这样的精确数量不是一个好的想法。使用浮点数来进行美元和美分计算会得到灾难性的后果。浮点数最好用来表示象测量值这类数值,这类值从一开始就不怎么精确。
用于较小数的 BigDecimal
从 JDK 1.3 起,Java 开发人员就有了另一种数值表示法来表示非整数: BigDecimal 。 BigDecimal 是标准的类,在编译器中不需要特殊支持,它可以表示任意精度的小数,并对它们进行计算。在内部,可以用任意精度任何范围的值和一个换算因子来表示 BigDecimal ,换算因子表示左移小数点多少位,从而得到所期望范围内的值。因此,用 BigDecimal 表示的数的形式为 unscaledValue*10 -scale 。
用于加、减、乘和除的方法给 BigDecimal 值提供了算术运算。由于 BigDecimal 对象是不可变的,这些方法中的每一个都会产生新的 BigDecimal 对象。因此,因为创建对象的开销, BigDecimal 不适合于大量的数学计算,但设计它的目的是用来精确地表示小数。如果您正在寻找一种能精确表示如货币量这样的数值,则 BigDecimal 可以很好地胜任该任务。
所有的 equals 方法都不能真正测试相等
如浮点类型一样, BigDecimal 也有一些令人奇怪的行为。尤其在使用 equals() 方法来检测数值之间是否相等时要小心。 equals() 方法认为,两个表示同一个数但换算值不同(例如, 100.00 和 100.000 )的 BigDecimal 值是不相等的。然而, compareTo() 方法会认为这两个数是相等的,所以在从数值上比较两个 BigDecimal 值时,应该使用 compareTo() 而不是 equals() 。
另外还有一些情形,任意精度的小数运算仍不能表示精确结果。例如, 1 除以 9 会产生无限循环的小数 .111111... 。出于这个原因,在进行除法运算时, BigDecimal 可以让您显式地控制舍入。 movePointLeft() 方法支持 10 的幂次方的精确除法。
使用 BigDecimal 作为互换类型
SQL-92 包括 DECIMAL 数据类型,它是用于表示定点小数的精确数字类型,它可以对小数进行基本的算术运算。一些 SQL 语言喜欢称此类型为 NUMERIC 类型,其它一些 SQL 语言则引入了 MONEY 数据类型,MONEY 数据类型被定义为小数点右侧带有两位的小数。
如果希望将数字存储到数据库中的 DECIMAL 字段,或从 DECIMAL 字段检索值,则如何确保精确地转换该数字?您可能不希望使用由 JDBC PreparedStatement 和 ResultSet 类所提供的 setFloat() 和 getFloat() 方法,因为浮点数与小数之间的转换可能会丧失精确性。相反,请使用 PreparedStatement 和 ResultSet 的 setBigDecimal() 及 getBigDecimal() 方法。
对于 BigDecimal ,有几个可用的构造函数。其中一个构造函数以双精度浮点数作为输入,另一个以整数和换算因子作为输入,还有一个以小数的 String 表示作为输入。要小心使用 BigDecimal(double) 构造函数,因为如果不了解它,会在计算过程中产生舍入误差。请使用基于整数或 String 的构造函数。
构造 BigDecimal 数
对于 BigDecimal ,有几个可用的构造函数。其中一个构造函数以双精度浮点数作为输入,另一个以整数和换算因子作为输入,还有一个以小数的 String 表示作为输入。要小心使用 BigDecimal(double) 构造函数,因为如果不了解它,会在计算过程中产生舍入误差。请使用基于整数或 String 的构造函数。
如果使用 BigDecimal(double) 构造函数不恰当,在传递给 JDBC setBigDecimal() 方法时,会造成似乎很奇怪的 JDBC 驱动程序中的异常。例如,考虑以下 JDBC 代码,该代码希望将数字 0.01 存储到小数字段:
PreparedStatement ps =
connection.prepareStatement("INSERT INTO Foo SET name=?, value=?");
ps.setString(1, "penny");
ps.setBigDecimal(2, new BigDecimal(0.01));
ps.executeUpdate();
|
在执行这段似乎无害的代码时会抛出一些令人迷惑不解的异常(这取决于具体的 JDBC 驱动程序),因为 0.01 的双精度近似值会导致大的换算值,这可能会使 JDBC 驱动程序或数据库感到迷惑。JDBC 驱动程序会产生异常,但可能不会说明代码实际上错在哪里,除非意识到二进制浮点数的局限性。相反,使用 BigDecimal("0.01") 或 BigDecimal(1, 2) 构造 BigDecimal 来避免这类问题,因为这两种方法都可以精确地表示小数。
结束语
在 Java 程序中使用浮点数和小数充满着陷阱。浮点数和小数不象整数一样“循规蹈矩”,不能假定浮点计算一定产生整型或精确的结果,虽然它们的确“应该”那样做。最好将浮点运算保留用作计算本来就不精确的数值,譬如测量。如果需要表示定点数(譬如,几美元和几美分),则使用 BigDecimal 。
不变对象具有许多能更方便地使用它们的特性,包括不严格的同步需求和不必考虑数据讹误就能自由地共享和高速缓存对象引用。尽管不变性可能未必对于所有类都有意义,但大多数程序中至少有一些类将受益于不可变。在本月的 Java 理论与实践中,Brian Goetz 说明了不变性的一些长处和构造不变类的一些准则。请在附带的 论坛中与作者和其他读者分享您关于本文的心得。(也可以单击文章顶部或底部的“讨论”来访问论坛。)
不变对象是指在实例化后其外部可见状态无法更改的对象。Java 类库中的 String 、 Integer 和 BigDecimal 类就是不变对象的示例 ― 它们表示在对象的生命期内无法更改的单个值。
不变性的长处
如果正确使用不变类,它们会极大地简化编程。因为它们只能处于一种状态,所以只要正确构造了它们,就决不会陷入不一致的状态。您不必复制或克隆不变对象,就能自由地共享和高速缓存对它们的引用;您可以高速缓存它们的字段或其方法的结果,而不用担心值会不会变成失效的或与对象的其它状态不一致。不变类通常产生最好的映射键。而且,它们本来就是线程安全的,所以不必在线程间同步对它们的访问。
自由高速缓存
因为不变对象的值没有更改的危险,所以可以自由地高速缓存对它们的引用,而且可以肯定以后的引用仍将引用同一个值。同样地,因为它们的特性无法更改,所以您可以高速缓存它们的字段和其方法的结果。
如果对象是可变的,就必须在存储对其的引用时引起注意。请考虑清单 1 中的代码,其中排列了两个由调度程序执行的任务。目的是:现在启动第一个任务,而在某一天启动第二个任务。
清单 1. 可变的 Date 对象的潜在问题
Date d = new Date();
Scheduler.scheduleTask(task1, d);
d.setTime(d.getTime() + ONE_DAY);
scheduler.scheduleTask(task2, d);
|
因为 Date 是可变的,所以 scheduleTask 方法必须小心地用防范措施将日期参数复制(可能通过 clone() )到它的内部数据结构中。不然, task1 和 task2 可能都在明天执行,这可不是所期望的。更糟的是,任务调度程序所用的内部数据结构会变成讹误。在编写象 scheduleTask() 这样的方法时,极其容易忘记用防范措施复制日期参数。如果忘记这样做,您就制造了一个难以捕捉的错误,这个错误不会马上显现出来,而且当它暴露时人们要花较长的时间才会捕捉到。不变的 Date 类不可能发生这类错误。
固有的线程安全
大多数的线程安全问题发生在当多个线程正在试图并发地修改一个对象的状态(写-写冲突)时,或当一个线程正试图访问一个对象的状态,而另一个线程正在修改它(读-写冲突)时。要防止这样的冲突,必须同步对共享对象的访问,以便在对象处于不一致状态时其它线程不能访问它们。正确地做到这一点会很难,需要大量文档来确保正确地扩展程序,还可能对性能产生不利后果。只要正确构造了不变对象(这意味着不让对象引用从构造函数中转义),就使它们免除了同步访问的要求,因为无法更改它们的状态,从而就不可能存在写-写冲突或读-写冲突。
不用同步就能自由地在线程间共享对不变对象的引用,可以极大地简化编写并发程序的过程,并减少程序可能存在的潜在并发错误的数量。
在恶意运行的代码面前是安全的
把对象当作参数的方法不应变更那些对象的状态,除非文档明确说明可以这样做,或者实际上这些方法具有该对象的所有权。当我们将一个对象传递给普通方法时,通常不希望对象返回时已被更改。但是,使用可变对象时,完全会是这样的。如果将 java.awt.Point 传递给诸如 Component.setLocation() 的方法,根本不会阻止 setLocation 修改我们传入的 Point 的位置,也不会阻止 setLocation 存储对该点的引用并稍后在另一个方法中更改它。(当然, Component 不这样做,因为它不鲁莽,但是并不是所有类都那么客气。)现在, Point 的状态已在我们不知道的情况下更改了,其结果具有潜在危险 ― 当点实际上在另一个位置时,我们仍认为它在原来的位置。然而,如果 Point 是不变的,那么这种恶意的代码就不能以如此令人混乱而危险的方法修改我们的程序状态了。
良好的键
不变对象产生最好的 HashMap 或 HashSet 键。有些可变对象根据其状态会更改它们的 hashCode() 值(如清单 2 中的 StringHolder 示例类)。如果使用这种可变对象作为 HashSet 键,然后对象更改了其状态,那么就会对 HashSet 实现引起混乱 ― 如果枚举集合,该对象仍将出现,但如果用 contains() 查询集合,它就可能不出现。无需多说,这会引起某些混乱的行为。说明这一情况的清单 2 中的代码将打印“false”、“1”和“moo”。
清单 2. 可变 StringHolder 类,不适合用作键
public class StringHolder {
private String string;
public StringHolder(String s) {
this.string = s;
}
public String getString() {
return string;
}
public void setString(String string) {
this.string = string;
}
public boolean equals(Object o) {
if (this == o)
return true;
else if (o == null || !(o instanceof StringHolder))
return false;
else {
final StringHolder other = (StringHolder) o;
if (string == null)
return (other.string == null);
else
return string.equals(other.string);
}
}
public int hashCode() {
return (string != null ? string.hashCode() : 0);
}
public String toString() {
return string;
}
...
StringHolder sh = new StringHolder("blert");
HashSet h = new HashSet();
h.add(sh);
sh.setString("moo");
System.out.println(h.contains(sh));
System.out.println(h.size());
System.out.println(h.iterator().next());
}
|
何时使用不变类
不变类最适合表示抽象数据类型(如数字、枚举类型或颜色)的值。Java 类库中的基本数字类(如 Integer 、 Long 和 Float )都是不变的,其它标准数字类型(如 BigInteger 和 BigDecimal )也是不变的。表示复数或精度任意的有理数的类将比较适合于不变性。甚至包含许多离散值的抽象类型(如向量或矩阵)也很适合实现为不变类,这取决于您的应用程序。
|
Flyweight 模式
不变性启用了 Flyweight 模式,该模式利用共享使得用对象有效地表示大量细颗粒度的对象变得容易。例如,您可能希望用一个对象来表示字处理文档中的每个字符或图像中的每个像素,但这一策略的幼稚实现将会对内存使用和内存管理开销产生高得惊人的花费。Flyweight 模式采用工厂方法来分配对不变的细颗粒度对象的引用,并通过仅使一个对象实例与字母“a”对应来利用共享缩减对象数。有关 Flyweight 模式的更多信息,请参阅经典书籍 Design Patterns(Gamma 等著;请参阅 参考资料)。
|
|
Java 类库中不变性的另一个不错的示例是 java.awt.Color 。在某些颜色表示法(如 RGB、HSB 或 CMYK)中,颜色通常表示为一组有序的数字值,但把一种颜色当作颜色空间中的一个特异值,而不是一组有序的独立可寻址的值更有意义,因此将 Color 作为不变类实现是有道理的。
如果要表示的对象是多个基本值的容器(如:点、向量、矩阵或 RGB 颜色),是用可变对象还是用不变对象表示?答案是……要看情况而定。要如何使用它们?它们主要用来表示多维值(如像素的颜色),还是仅仅用作其它对象的一组相关特性集合(如窗口的高度和宽度)的容器?这些特性多久更改一次?如果更改它们,那么各个组件值在应用程序中是否有其自己的含义呢?
事件是另一个适合用不变类实现的好示例。事件的生命期较短,而且常常会在创建它们的线程以外的线程中消耗,所以使它们成为不变的是利大于弊。大多数 AWT 事件类都没有作为严格的不变类来实现,而是可以有小小的修改。同样地,在使用一定形式的消息传递以在组件间通信的系统中,使消息对象成为不变的或许是明智的。
编写不变类的准则
编写不变类很容易。如果以下几点都为真,那么类就是不变的:
- 它的所有字段都是 final
- 该类声明为 final
- 不允许
this 引用在构造期间转义
- 任何包含对可变对象(如数组、集合或类似
Date 的可变类)引用的字段:
- 是私有的
- 从不被返回,也不以其它方式公开给调用程序
- 是对它们所引用对象的唯一引用
- 构造后不会更改被引用对象的状态
最后一组要求似乎挺复杂的,但其基本上意味着如果要存储对数组或其它可变对象的引用,就必须确保您的类对该可变对象拥有独占访问权(因为不然的话,其它类能够更改其状态),而且在构造后您不修改其状态。为允许不变对象存储对数组的引用,这种复杂性是必要的,因为 Java 语言没有办法强制不对 final 数组的元素进行修改。注:如果从传递给构造函数的参数中初始化数组引用或其它可变字段,您必须用防范措施将调用程序提供的参数或您无法确保具有独占访问权的其它信息复制到数组。否则,调用程序会在调用构造函数之后,修改数组的状态。清单 3 显示了编写一个存储调用程序提供的数组的不变对象的构造函数的正确方法(和错误方法)。
清单 3. 对不变对象编码的正确和错误方法
class ImmutableArrayHolder {
private final int[] theArray;
// Right way to write a constructor -- copy the array
public ImmutableArrayHolder(int[] anArray) {
this.theArray = (int[]) anArray.clone();
}
// Wrong way to write a constructor -- copy the reference
// The caller could change the array after the call to the constructor
public ImmutableArrayHolder(int[] anArray) {
this.theArray = anArray;
}
// Right way to write an accessor -- don't expose the array reference
public int getArrayLength() { return theArray.length }
public int getArray(int n) { return theArray[n]; }
// Right way to write an accessor -- use clone()
public int[] getArray() { return (int[]) theArray.clone(); }
// Wrong way to write an accessor -- expose the array reference
// A caller could get the array reference and then change the contents
public int[] getArray() { return theArray }
}
|
通过一些其它工作,可以编写使用一些非 final 字段的不变类(例如, String 的标准实现使用 hashCode 值的惰性计算),这样可能比严格的 final 类执行得更好。如果类表示抽象类型(如数字类型或颜色)的值,那么您还会想实现 hashCode() 和 equals() 方法,这样对象将作为 HashMap 或 HashSet 中的一个键工作良好。要保持线程安全,不允许 this 引用从构造函数中转义是很重要的。
偶尔更改的数据
有些数据项在程序生命期中一直保持常量,而有些会频繁更改。常量数据显然符合不变性,而状态复杂且频繁更改的对象通常不适合用不变类来实现。那么有时会更改,但更改又不太频繁的数据呢?有什么方法能让 有时更改的数据获得不变性的便利和线程安全的长处呢?
util.concurrent
包中的 CopyOnWriteArrayList 类是如何既利用不变性的能力,又仍允许偶尔修改的一个良好示例。它最适合于支持事件监听程序的类(如用户界面组件)使用。虽然事件监听程序的列表可以更改,但通常它更改的频繁性要比事件的生成少得多。
除了在修改列表时, CopyOnWriteArrayList 并不变更基本数组,而是创建新数组且废弃旧数组之外,它的行为与 ArrayList 类非常相似。这意味着当调用程序获得迭代器(迭代器在内部保存对基本数组的引用)时,迭代器引用的数组实际上是不变的,从而可以无需同步或冒并发修改的风险进行遍历。这消除了在遍历前克隆列表或在遍历期间对列表进行同步的需要,这两个操作都很麻烦、易于出错,而且完全使性能恶化。如果遍历比插入或除去更加频繁(这在某些情况下是常有的事), CopyOnWriteArrayList 会提供更佳的性能和更方便的访问。
结束语
使用不变对象比使用可变对象要容易得多。它们只能处于一种状态,所以始终是一致的,它们本来就是线程安全的,可以被自由地共享。使用不变对象可以彻底消除许多容易发生但难以检测的编程错误,如无法在线程间同步访问或在存储对数组或对象的引用前无法克隆该数组或对象。在编写类时,问问自己这个类是否可以作为不变类有效地实现,总是值得的。您可能会对回答常常是肯定的而感到吃惊。
JDBC java.sql.PreparedStatement接口的简单扩展可以使查询记录更少犯错,同时整理您的代码。在本文中,IBM电子商务顾问Jens Wyke向您介绍如何应用基本的封装技术(“通过封装来实现扩展”也称为Decorator设计模式)来获得最满意的结果。
在大多数情况下,JDBC PreparedStatements 使执行数据库查询更简便并可以显著提升您整体应用程序的性能。当谈到日志查询语句时 PreparedStatement 接口就显得有些不足了。 PreparedStatement 的优势在于其可变性,但是一个好的日志条目必须正确描述如何将SQL发送到数据库,它将密切关注用实际的参数值来替换所有参数占位符。虽然有多种方法可以解决这一难题,但没有任何一种易于大规模实施并且大部分将扰乱您的程序代码。
在本文中,您将了解到如何扩展JDBC PreparedStatement 接口来进行查询日志。 LoggableStatement 类实现 PreparedStatement 接口,但添加用于获得查询字符串的方法,使用一种适用于记录的格式。使用 LoggableStatement 类可以减少日志代码中发生错误的几率,生成简单且易于管理的代码。
注意:本文假设您有丰富的JDBC和 PreparedStatement 类经验。
典型日志解决方案
表1介绍了数据库查询时通常是如何使用 PreparedStatement (虽然忽略了初始化和错误处理)。在本文中,我们将使用SQL query SELECT 做为例子,但讨论使用其它类型的SQL语句,如 DELETE 、 UPDATE 和 INSERT 。
表1:一个典型的SQL数据库查询
String sql = "select foo, bar from foobar where foo < ? and bar = ?";
String fooValue = new Long(99);
String barValue = "christmas";
Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setLong(1,fooValue);
pstmt.setString(2,barValue);
ResultSet rs = pstmt.executeQuery();
// parse result...
|
表1中一个好的查询日志条目看起来应与下面有几分类似:
Executing query: select foo,bar from foobar where foo < 99 and
bar='christmas'
|
下面是查询的日志代码的一个例子。注意:表1中的问号已经被每个参数的值替换。
System.out.println("Executing query: select foo, bar from foobar where foo
< "+fooValue+" and bar = '+barValue+"'")
|
一种更好的方法是创建方法,我们称之为 replaceFirstQuestionMark ,它读取查询字符串并用参数值替换问号,如表2所示。这类方法的使用无需创建复制的字符串来描述SQL语句。
表 2:使用replaceFirstQuestionMark来进行字符串替换
// listing 1 goes here
sql = replaceFirstQuestionMark(sql, fooValue);
sql = replaceFirstQuestionMark(sql, barValue);
System.out.println("Executing query: "+sql);
|
虽然这些解决方案都易于实施,但没有一种是完美的。问题是在更改SQL模板的同时也必须更改日志代码。您将在某一点上犯错几乎是不可避免的。查询将更改但您忘记了更新日志代码,您将结束与将发送到数据库的查询不匹配的日志条目 -- 调试恶梦。
我们真正需要的是一种使我们能够一次性使用每个参数变量(在我们的实例中为 fooValue 和 barValue )的设计方案。我们希望有一种方法,它使我们能够获得查询字符串,并用实际的参数值替换参数占位符。由于 java.sql.PreparedStatement 没有此类方法,我们必须自己实现。
定制解决方案
我们的 PreparedStatement 定制实施将做为围绕JDBC驱动器提供的“真实语句(real statement)”的封装器(Wrapper)。封装器语句将转发所有方法调用(例如 setLong(int, long) 和 setString(int,String) ) 到“真实语句”。在这样做之前它将保存相关的参数值,从而它们可以用于生成日志输出结果。
表3介绍了 LoggableStatement 类如何实现 java.sql.PreparedStatement ,以及它如何使用JDBC连接和SQL模板作为输入来构建。
表3:LoggableStatement实现java.sql.PreparedStatement
public class LoggableStatement implements java.sql.PreparedStatement {
// used for storing parameter values needed
// for producing log
private ArrayList parameterValues;
// the query string with question marks as
// parameter placeholders
private String sqlTemplate;
// a statement created from a real database
// connection
private PreparedStatement wrappedStatement;
public LoggableStatement(Connection connection, String sql)
throws SQLException {
// use connection to make a prepared statement
wrappedStatement = connection.prepareStatement(sql);
sqlTemplate = sql;
parameterValues = new ArrayList();
}
}
|
LoggableStatement如何工作
表4介绍了 LoggableStatement 如何向 saveQueryParamValue() 方法添加一个调用,以及在方法 setLong 和 setString 的“真实语句”上调用相应的方法。我们采用与用于参数设置的所有方法(例如 setChar 、 setLong 、 setRef 和 setObj )相同的方式来增加 saveQueryParamValue() 调用。表4还显示了在不调用 saveQueryParamValue() 的情况下如何封装方法 executeQuery ,因为它不是一个“参数设置”方法。
表4:LoggableStatement 方法
public void setLong(int parameterIndex, long x)
throws java.sql.SQLException {
wrappedStatement.setLong(parameterIndex, x);
saveQueryParamValue(parameterIndex, new Long(x));
}
public void setString(int parameterIndex, String x)
throws java.sql.SQLException {
wrappedStatement.setString(parameterIndex, x);
saveQueryParamValue(parameterIndex, x);
}
public ResultSet executeQuery() throws java.sql.SQLException {
return wrappedStatement.executeQuery();
}
|
表5中显示了 saveQueryParamValue() 方法。它把每个参数值转换成 String 表示,保存以便 getQueryString 方法日后使用。缺省情况下,一个对象使用其 toString 方法将被转换成 String ,但如果对象是 String 或 Date ,它将用单引号('')表示。 getQueryString() 方法使您能够从日志复制大多数查询并进行粘贴,无需修改交互式SQL处理器就可进行测试和调试。您可以根据需要修订该方法来转换其它类的参数值。
表5:saveQueryParamValue()方法
private void saveQueryParamValue(int position, Object obj) {
String strValue;
if (obj instanceof String || obj instanceof Date) {
// if we have a String, include '' in the saved value
strValue = "'" + obj + "'";
} else {
if (obj == null) {
// convert null to the string null
strValue = "null";
} else {
// unknown object (includes all Numbers), just call toString
strValue = obj.toString();
}
}
// if we are setting a position larger than current size of
// parameterValues, first make it larger
while (position >= parameterValues.size()) {
parameterValues.add(null);
}
// save the parameter
parameterValues.set(position, strValue);
}
|
当我们使用标准方法来设置所有参数时,我们在 LoggableStatement 中简单调用 getQueryString() 方法来获得查询字符串。所有问号都将被真正的参数值替换,它准备输出到我们选定的日志目的地。
使用LoggableStatement
表6显示如何更改表1和表2中的代码来使用 LoggableStatement 。将 LoggableStatement 引入到我们的应用程序代码中可以解决复制的参数变量问题。如果改变了SQL模板,我们只需更新 PreparedStatement 上的参数设置调用(例如添加一个 pstmt.setString(3,"new-param-value") )。这一更改将在日志输出结果中反映出,无需任何记录代码的手工更新。
表6:使用LoggableStatement 
String sql = "select foo, bar from foobar where foo < ? and bar = ?";
long fooValue = 99;
String barValue = "christmas";
Connection conn = dataSource.getConnection();
PreparedStatement pstmt;
if(logEnabled) // use a switch to toggle logging.
pstmt = new LoggableStatement(conn,sql);
else
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1,fooValue);
pstmt.setString(2,barValue);
if(logEnabled)
System.out.println("Executing query: "+
((LoggableStatement)pstmt).getQueryString());
ResultSet rs = pstmt.executeQuery();
|
结束语
使用本文介绍的非常简单的步骤,您可以为查询记录扩展JDBC PreparedStatement 接口。我们在此处使用的技术可以被视为“通过封装来实现扩展”,或作为Decorator设计模式的一个实例(见 参考资料)。通过封装来实现扩展在当您必须扩展API但subclassing不是一项可选功能时极其有用。
摘要: 恰当地使用对象池化技术,可以有效地减少对象生成和初始化时的消耗,提高系统的运行效率。Jakarta Commons Pool组件提供了一整套用于实现对象池化的框架,以及若干种各具特色的对象池实现,可以有效地减少处理对象池化时的工作量,为其它重要的工作留下更多的精力和时间。
创建新的对象并初始化的操作,可能会消耗很多的时间。在这种对象的初始化工作包含了一些费时的操作(例... 阅读全文
在这个系列的 第 1 部分,我们讨论了事务并研究了它们的基本属性 ― 原子性(atomicity)、一致性(consistency)、孤立性(isolation)和持久性(durability)。事务是企业应用程序的基本构件;没有它们,几乎不可能构建有容错能力的企业应用程序。幸运的是,Java 事务服务(Java Transaction Service,JTS)和 J2EE 容器自动为您做了大量的事务管理工作,这样您就不必将事务意识直接集成到组件代码中。结果简直是一种魔术 ― 通过遵守几条简单的规则,J2EE 应用程序就可以自动获得事务性语义,只需极少或根本不需要额外的组件代码。本文旨在通过展示事务管理如何发生,以及发生在何处来揭开这个魔术的神秘面纱。
什么是 JTS?
JTS 是一个 组件事务监视器(component transaction monitor)。这是什么意思?在第 1 部分,我们介绍了 事务处理监视器(TPM)这个概念,TPM 是一个程序,它代表应用程序协调分布式事务的执行。TPM 与数据库出现的时间长短差不多;在 60 年代后期,IBM 首先开发了 CICS,至今人们仍在使用。经典的(或者说 程序化)TPM 管理被程序化定义为针对事务性资源(比如数据库)的操作序列的事务。随着分布式对象协议,如 CORBA、DCOM 和 RMI 的出现,人们希望看到事务更面向对象的前景。将事务性语义告知面向对象的组件要求对 TPM 模型进行扩展 ― 在这个模型中事务是按照事务性对象的调用方法定义的。JTS 只是一个组件事务监视器(有时也称为 对象事务监视器(object transaction monitor)),或称为 CTM。
JTS 和 J2EE 的事务支持设计受 CORBA 对象事务服务(CORBA Object Transaction Service,OTS)的影响很大。实际上,JTS 实现 OTS 并充当 Java 事务 API(Java Transaction API)― 一种用来定义事务边界的低级 API ― 和 OTS 之间的接口。使用 OTS 代替创建一个新对象事务协议遵循了现有标准,并使 J2EE 和 CORBA 能够互相兼容。
乍一看,从程序化事务监视器到 CTM 的转变好像只是术语名称改变了一下。然而,差别不止这一点。当 CTM 中的事务提交或回滚时,与事务相关的对象所做的全部更改都一起被提交或取消。但 CTM 怎么知道对象在事务期间做了什么事?象 EJB 组件之类的事务性组件并没有 commit() 或 rollback() 方法,它们也没向事务监视器注册自己做了什么事。那么 J2EE 组件执行的操作如何变成事务的一部分呢?
透明的资源征用
当应用程序状态被组件操纵时,它仍然存储在事务性资源管理器(例如,数据库和消息队列服务器)中,这些事务性资源管理器可以注册为分布式事务中的资源管理器。在第 1 部分中,我们讨论了如何在单个事务中征用多个资源管理器,事务管理器如何协调这些资源管理器。资源管理器知道如何把应用程序状态中的变化与特定的事务关联起来。
但这只是把问题的焦点从组件转移到了资源管理器 ― 容器如何断定什么资源与该事务有关,可以供它征用?请考虑下面的代码,在典型的 EJB 会话 bean 中您可能会发现这样的代码:
清单 1. bean 管理的事务的透明资源征用
InitialContext ic = new InitialContext();
UserTransaction ut = ejbContext.getUserTransaction();
ut.begin();
DataSource db1 = (DataSource) ic.lookup("java:comp/env/OrdersDB");
DataSource db2 = (DataSource) ic.lookup("java:comp/env/InventoryDB");
Connection con1 = db1.getConnection();
Connection con2 = db2.getConnection();
// perform updates to OrdersDB using connection con1
// perform updates to InventoryDB using connection con2
ut.commit();
|
注意,这个示例中没有征用当前事务中 JDBC 连接的代码 ― 容器会为我们完成这个任务。我们来看一下它是如何发生的。
资源管理器的三种类型
当一个 EJB 组件想访问数据库、消息队列服务器或者其它一些事务性资源时,它需要到资源管理器的连接(通常是使用 JNDI)。而且,J2EE 规范只认可三种类型的事务性资源 ― JDBC 数据库、JMS 消息队列服务器和“其它通过 JCA 访问的事务性服务”。后面一种服务(比如 ERP 系统)必须通过 JCA(J2EE Connector Architecture,J2EE 连接器体系结构)访问。对于这些类型资源中的每一种,容器或提供者都会帮我们把资源征调到事务中。
在清单 1 中, con1 和 con2 好象是普通的 JDBC 连接,比如那些从 DriverManager.getConnection() 返回的连接。我们从一个 JDBC DataSource 得到这些连接,JDBC DataSource 可以通过查找 JNDI 中的数据源名称得到。EJB 组件中被用来查找数据源( java:comp/env/OrdersDB )的名称是特定于组件的;组件的部署描述符的 resource-ref 部分将其映射为容器管理的一些应用程序级 DataSource 的 JNDI 名称。
隐藏的 JDBC 驱动器
每个 J2EE 容器都可以创建有事务意识的池态 DataSource 对象,但 J2EE 规范并不向您展示如何创建,因为这不在 J2EE 规范内。浏览 J2EE 文档时,您找不到任何关于如何创建 JDBC 数据源的内容。相反,您不得不为您的容器查阅该文档。创建一个数据源可能需要向属性或配置文件添加一个数据源定义,或者也可以通过 GUI 管理工具完成,这取决于您的容器。
每个容器(或连接池管理器,如 PoolMan)都提供它自己的创建 DataSource 机制,JTA 魔术就隐藏在这个机制中。连接池管理器从指定的 JDBC 驱动器得到一个 Connection ,但在将它返回到应用程序之前,将它与一个也实现 Connection 的虚包包在一起,将自己置入应用程序和底层连接之间。当创建连接或者执行 JDBC 操作时,包装器询问事务管理器当前线程是不是正在事务的上下文中执行,如果事务中有 Connection 的话,就自动征用它。
其它类型的事务性资源,JMS 消息队列和 JCA 连接器,依靠相似的机制将资源征用隐藏起来,使用户看不到。如果要使 JMS 队列在部署时对 J2EE 应用程序可用,您就要再次使用特定于提供者的机制来创建受管 JMS 对象(队列连接工厂和目标),然后在 JNDI 名称空间内发布这些对象。提供者创建的受管对象包含与 JDBC 包装器(由容器提供的连接池管理器添加)相似的自动征用代码。
透明的事务控制
两种类型的 J2EE 事务 ― 容器管理的和 bean 管理的 ― 在如何启动和结束事务上是不同的。事务启动和结束的地方被称为 事务划分(transaction demarcation)。清单 1 中的示例代码演示了 bean 管理的事务(有时也称为 编程(programmatic)事务)。Bean 管理的事务是由组件使用 UserTransaction 类显式启动和结束的。通过 ejbContext 使 UserTransaction 对 EJB 组件可用,通过 JNDI 使其对其它 J2EE 组件可用。
容器根据组件的部署描述符中的事务属性代表应用程序透明地启动和结束容器管理的事务(或称为 宣告式事务(declarative transaction))。通过将 transaction-type 属性设置为 Container 或 Bean 您可以指出 EJB 组件是使用 bean 管理的事务性支持还是容器管理的事务性支持。
使用容器管理的事务,您可以在 EJB 类或方法级别上指定事务性属性;您可以为 EJB 类指定缺省的事务性属性,如果不同的方法会有不同的事务性语义,您还可以为每个方法指定属性。这些事务性属性在装配描述符(assembly descriptor)的 container-transaction 部分被指定。清单 2 显示了一个装配描述符示例。 trans-attribute 的受支持的值有:
-
Supports
-
Required
-
RequiresNew
-
Mandatory
-
NotSupported
-
Never
trans-attribute
决定方法是否支持在事务内部执行、当在事务内部调用方法时容器会执行什么操作以及在事务外部调用方法时容器会执行什么操作。最常用的容器管理的事务属性是 Required 。如果设置了 Required ,过程中的事务将在该事务中征用您的 bean,但如果没有正在运行的事务,容器将为您启动一个。在这个系列的第 3 部分,当您可能想使用每个事务属性时,我们将研究各个事务属性之间的区别。
清单 2. EJB 装配描述符样本
<assembly-descriptor>
...
<container-transaction>
<method>
<ejb-name>MyBean</ejb-name>
<method-name>*</method-name>
</method>
<trans-attribute>Required</trans-attribute>
</container-transaction>
<container-transaction>
<method>
<ejb-name>MyBean</ejb-name>
<method-name>updateName</method-name>
</method>
<trans-attribute>RequiresNew</trans-attribute>
</container-transaction>
...
</assembly-descriptor>
|
功能强大,但很危险
与清单 1 中的示例不同,由于有宣告式事务划分,这段组件代码中没有事务管理代码。这不仅使结果组件代码更加易读(因为它不与事务管理代码混在一起),而且它还有另一个更重要的优点 ― 不必修改,甚至不必访问组件的源代码,就可以在应用程序装配时改变组件的事务性语义。
尽管能够指定与代码分开的事务划分是一种非常强大的功能,但在装配时做出不好的决定会使应用程序变得不稳定,或者严重影响它的性能。对容器管理的事务进行正确分界的责任由组件开发者和应用程序装配人员共同担当。组件开发者需要提供足够的文档说明组件是做什么的,这样应用程序部署者就能够明智地决定如何构建应用程序的事务。应用程序装配人员需要理解应用程序中的组件是怎样相互作用的,这样就可以用一种既强制应用程序保持一致又不削弱性能的方法对事务进行分界。在这个系列的第 3 部分中我们将讨论这些问题。
透明的事务传播
在任何类型的事务中,资源征用都是透明的;容器自动将事务过程中使用的任意事务性资源征调到当前事务中。这个过程不仅扩展到事务性方法使用的资源(比如在清单 1 中获得的数据库连接),还扩展到它调用的方法(甚至远程方法)使用的资源。我们来看一下这是如何发生的。
容器用线程与事务相关联
我们假设对象 A 的 methodA() 启动一个事务,然后调用对象 B 的 methodB() (对象 B 将得到一个 JDBC 连接并更新数据库)。 B 获得的连接将被自动征调到 A 创建的事务中。容器怎么知道要做这件事?
当事务启动时,事务上下文与执行线程关联在一起。当 A 创建事务时, A 在其中执行的线程与该事务关联在一起。由于本地方法调用与主调程序(caller)在同一个线程内执行,所以 A 调用的每个方法也都在该事务的上下文中。
橱中骸骨
如果对象 B 其实是在另一个线程,甚至另一个 JVM 中执行的 EJB 组件的存根,情况会怎样?令人吃惊的是,远程对象 B 访问的资源仍将在当前事务中被征用。EJB 对象存根(在主调程序的上下文中执行的那部分)、EJB 协议(IIOP 上的 RMI)和远端的骨架对象协力要使其透明地发生。存根确定调用者是不是正在执行一个事务。如果是,事务标识,或者说 Xid,被作为 IIOP 调用的一部分与方法参数一起传播到远程对象。(IIOP 是 CORBA 远程-调用协议,它为传播执行上下文(比如事务上下文和安全性上下文)的各种元素而备;关于 RMI over IIOP 的更多信息,请参阅 参考资料。)如果调用是事务的一部分,那么远程系统上的骨架对象自动设置远程线程的事务上下文,这样,当调用实际的远程方法时,它已经是事务的一部分了。(存根和骨架对象还负责开始和提交容器管理的事务。)
事务可以由任何 J2EE 组件来启动 ― 一个 EJB 组件、一个 servlet 或者一个 JSP 页面(如果容器支持的话,还可以是一个应用程序客户机)。这意味着,应用程序可以在请求到达时在 servlet 或者 JSP 页面中启动事务、在 servlet 或者 JSP 页面中执行一些处理、作为页面逻辑的一部分访问多个服务器上的实体 bean 和会话 bean 并使所有这些工作透明地成为一个事务的一部分。图 1 演示了事务上下文怎样遵守从 servlet 到 EJB,再到 EJB 的执行路径。
图 1.单个事务中的多个组件
最优化
让容器来管理事务允许容器为我们做出某些最优化决定。在图 1 中,我们看到一个 servlet 和多个 EJB 组件在单个事务的上下文中访问一个数据库。每个组件都获得到数据库的 Connection ;很可能每个组件都在访问同一个数据库。即使多个连接是从不同的组件到同一个资源,JTS 也可以检测出多个资源是否和事务有关,并最优化该事务的执行。您可以从第 1 部分回忆起来,单个事务要包含多个资源管理器需要使用两阶段提交协议,这比单个资源管理器使用的单阶段提交代价要高。JTS 能够确定事务中是不是只征用了一个资源管理器。如果它检测出所有与事务相关的资源都一样,它可以跳过两阶段提交并让资源管理器自己来处理事务。
结束语
这个虑及透明事务控制、资源征用和透明传播的魔术不是 JTS 的一部分,而是 J2EE 容器如何在幕后代表 J2EE 应用程序使用 JTA 和 JTS 服务的一部分。在幕后有许多实体合力使这个魔术透明地发生;EJB 存根和骨架、容器厂商提供的 JDBC 驱动器包装器、数据库厂商提供的 JDBC 驱动器、JMS 提供器和 JCA 连接器。所有这些实体都与事务管理器进行交互,于是应用程序代码就不必与之交互了。
在第 3 部分,我们将看一下关于管理 J2EE 上下文中事务的一些实际问题 ― 事务划分和孤立 ― 以及它们对应用程序一致性、稳定性和性能的影响。
与许多其它的编程语言不同,Java语言规范包括对线程和并发的明确支持。语言本身支持并发,这使得指定和管理共享数据的约束以及跨线程操作的计时变得更简单,但是这没有使得并发编程的复杂性更易于理解。这个三部分的系列文章的目的在于帮助程序员理解用Java 语言进行多线程编程的一些主要问题,特别是线程安全对 Java程序性能的影响。
请点击文章顶部或底部的 讨论进入由 Brian Goetz 主持的 “Java线程:技巧、窍门和技术”讨论论坛,与本文作者和其他读者交流您对本文或整个多线程的想法。注意该论坛讨论的是使用多线程时遇到的所有问题,而并不限于本文的内容。
大多数编程语言的语言规范都不会谈到线程和并发的问题;因为一直以来,这些问题都是留给平台或操作系统去详细说明的。但是,Java 语言规范(JLS)却明确包括一个线程模型,并提供了一些语言元素供开发人员使用以保证他们程序的线程安全。
对线程的明确支持有利也有弊。它使得我们在写程序时更容易利用线程的功能和便利,但同时也意味着我们不得不注意所写类的线程安全,因为任何类都很有可能被用在一个多线程的环境内。
许多用户第一次发现他们不得不去理解线程的概念的时候,并不是因为他们在写创建和管理线程的程序,而是因为他们正在用一个本身是多线程的工具或框架。任何用过 Swing GUI 框架或写过小服务程序或 JSP 页的开发人员(不管有没有意识到)都曾经被线程的复杂性困扰过。
Java 设计师是想创建一种语言,使之能够很好地运行在现代的硬件,包括多处理器系统上。要达到这一目的,管理线程间协调的工作主要推给了软件开发人员;程序员必须指定线程间共享数据的位置。在 Java 程序中,用来管理线程间协调工作的主要工具是 synchronized 关键字。在缺少同步的情况下,JVM 可以很自由地对不同线程内执行的操作进行计时和排序。在大部分情况下,这正是我们想要的,因为这样可以提高性能,但它也给程序员带来了额外的负担,他们不得不自己识别什么时候这种性能的提高会危及程序的正确性。
synchronized 真正意味着什么?
大部分 Java 程序员对同步的块或方法的理解是完全根据使用互斥(互斥信号量)或定义一个临界段(一个必须原子性地执行的代码块)。虽然 synchronized 的语义中确实包括互斥和原子性,但在管程进入之前和在管程退出之后发生的事情要复杂得多。
synchronized
的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大;频繁地刷新缓存代价会很大。
使用一条好的运行路线
如果同步不适当,后果是很严重的:会造成数据混乱和争用情况,导致程序崩溃,产生不正确的结果,或者是不可预计的运行。更糟的是,这些情况可能很少发生且具有偶然性(使得问题很难被监测和重现)。如果测试环境和开发环境有很大的不同,无论是配置的不同,还是负荷的不同,都有可能使得这些问题在测试环境中根本不出现,从而得出错误的结论:我们的程序是正确的,而事实上这些问题只是还没出现而已。
|
争用情况定义
争用情况是一种特定的情况:两个或更多的线程或进程读或写一些共享数据,而最终结果取决于这些线程是如何被调度计时的。争用情况可能会导致不可预见的结果和隐蔽的程序错误。
|
|
另一方面,不当或过度地使用同步会导致其它问题,比如性能很差和死锁。当然,性能差虽然不如数据混乱那么严重,但也是一个严重的问题,因此同样不可忽视。编写优秀的多线程程序需要使用好的运行路线,足够的同步可以使您的数据不发生混乱,但不需要滥用到去承担死锁或不必要地削弱程序性能的风险。
同步的代价有多大?
由于包括缓存刷新和设置失效的过程,Java 语言中的同步块通常比许多平台提供的临界段设备代价更大,这些临界段通常是用一个原子性的“test and set bit”机器指令实现的。即使一个程序只包括一个在单一处理器上运行的单线程,一个同步的方法调用仍要比非同步的方法调用慢。如果同步时还发生锁定争用,那么性能上付出的代价会大得多,因为会需要几个线程切换和系统调用。
幸运的是,随着每一版的 JVM 的不断改进,既提高了 Java 程序的总体性能,同时也相对减少了同步的代价,并且将来还可能会有进一步的改进。此外,同步的性能代价经常是被夸大的。一个著名的资料来源就曾经引证说一个同步的方法调用比一个非同步的方法调用慢 50 倍。虽然这句话有可能是真的,但也会产生误导,而且已经导致了许多开发人员即使在需要的时候也避免使用同步。
严格依照百分比计算同步的性能损失并没有多大意义,因为一个无争用的同步给一个块或方法带来的是固定的性能损失。而这一固定的延迟带来的性能损失百分比取决于在该同步块内做了多少工作。对一个 空方法的同步调用可能要比对一个空方法的非同步调用慢 20 倍,但我们多长时间才调用一次空方法呢?当我们用更有代表性的小方法来衡量同步损失时,百分数很快就下降到可以容忍的范围之内。
表 1 把一些这种数据放在一起来看。它列举了一些不同的实例,不同的平台和不同的 JVM 下一个同步的方法调用相对于一个非同步的方法调用的损失。在每一个实例下,我运行一个简单的程序,测定循环调用一个方法 10,000,000 次所需的运行时间,我调用了同步和非同步两个版本,并比较了结果。表格中的数据是同步版本的运行时间相对于非同步版本的运行时间的比率;它显示了同步的性能损失。每次运行调用的都是清单 1 中的简单方法之一。
表格 1 中显示了同步方法调用相对于非同步方法调用的相对性能;为了用绝对的标准测定性能损失,必须考虑到 JVM 速度提高的因素,这并没有在数据中体现出来。在大多数测试中,每个 JVM 的更高版本都会使 JVM 的总体性能得到很大提高,很有可能 1.4 版的 Java 虚拟机发行的时候,它的性能还会有进一步的提高。
表 1. 无争用同步的性能损失
JDK
|
staticEmpty
|
empty
|
fetch
|
hashmapGet
|
singleton
|
create
|
Linux / JDK 1.1 |
9.2 |
2.4 |
2.5 |
n/a |
2.0 |
1.42 |
Linux / IBM Java SDK 1.1 |
33.9 |
18.4 |
14.1 |
n/a |
6.9 |
1.2 |
Linux / JDK 1.2 |
2.5 |
2.2 |
2.2 |
1.64 |
2.2 |
1.4 |
Linux / JDK 1.3 (no JIT) |
2.52 |
2.58 |
2.02 |
1.44 |
1.4 |
1.1 |
Linux / JDK 1.3 -server |
28.9 |
21.0 |
39.0 |
1.87 |
9.0 |
2.3 |
Linux / JDK 1.3 -client |
21.2 |
4.2 |
4.3 |
1.7 |
5.2 |
2.1 |
Linux / IBM Java SDK 1.3 |
8.2 |
33.4 |
33.4 |
1.7 |
20.7 |
35.3 |
Linux / gcj 3.0 |
2.1 |
3.6 |
3.3 |
1.2 |
2.4 |
2.1 |
Solaris / JDK 1.1 |
38.6 |
20.1 |
12.8 |
n/a |
11.8 |
2.1 |
Solaris / JDK 1.2 |
39.2 |
8.6 |
5.0 |
1.4 |
3.1 |
3.1 |
Solaris / JDK 1.3 (no JIT) |
2.0 |
1.8 |
1.8 |
1.0 |
1.2 |
1.1 |
Solaris / JDK 1.3 -client |
19.8 |
1.5 |
1.1 |
1.3 |
2.1 |
1.7 |
Solaris / JDK 1.3 -server |
1.8 |
2.3 |
53.0 |
1.3 |
4.2 |
3.2 |
清单 1. 基准测试中用到的简单方法
public static void staticEmpty() { }
public void empty() { }
public Object fetch() { return field; }
public Object singleton() {
if (singletonField == null)
singletonField = new Object();
return singletonField;
}
public Object hashmapGet() {
return hashMap.get("this");
}
public Object create() {
return new Object();
}
|
这些小基准测试也阐明了存在动态编译器的情况下解释性能结果所面临的挑战。对于 1.3 JDK 在有和没有 JIT 时,数字上的巨大差异需要给出一些解释。对那些非常简单的方法( empty 和 fetch ),基准测试的本质(它只是执行一个几乎什么也不做的紧凑的循环)使得 JIT 可以动态地编译整个循环,把运行时间压缩到几乎没有的地步。但在一个实际的程序中,JIT 能否这样做就要取决于很多因素了,所以,无 JIT 的计时数据可能在做公平对比时更有用一些。在任何情况下,对于更充实的方法( create 和 hashmapGet ),JIT 就不能象对更简单些的方法那样使非同步的情况得到巨大的改进。另外,从数据中看不出 JVM 是否能够对测试的重要部分进行优化。同样,在可比较的 IBM 和 Sun JDK 之间的差异反映了 IBM Java SDK 可以更大程度地优化非同步的循环,而不是同步版本代价更高。这在纯计时数据中可以明显地看出(这里不提供)。
从这些数字中我们可以得出以下结论:对非争用同步而言,虽然存在性能损失,但在运行许多不是特别微小的方法时,损失可以降到一个合理的水平;大多数情况下损失大概在 10% 到 200% 之间(这是一个相对较小的数目)。所以,虽然同步每个方法是不明智的(这也会增加死锁的可能性),但我们也不需要这么害怕同步。这里使用的简单测试是说明一个无争用同步的代价要比创建一个对象或查找一个 HashMap 的代价小。
由于早期的书籍和文章暗示了无争用同步要付出巨大的性能代价,许多程序员就竭尽全力避免同步。这种恐惧导致了许多有问题的技术出现,比如说 double-checked locking(DCL)。许多关于 Java 编程的书和文章都推荐 DCL,它看上去真是避免不必要的同步的一种聪明的方法,但实际上它根本没有用,应该避免使用它。DCL 无效的原因很复杂,已超出了本文讨论的范围(要深入了解,请参阅 参考资料里的链接)。
不要争用
假设同步使用正确,若线程真正参与争用加锁,您也能感受到同步对实际性能的影响。并且无争用同步和争用同步间的性能损失差别很大;一个简单的测试程序指出争用同步比无争用同步慢 50 倍。把这一事实和我们上面抽取的观察数据结合在一起,可以看出使用一个争用同步的代价至少相当于创建 50 个对象。
所以,在调试应用程序中同步的使用时,我们应该努力减少实际争用的数目,而根本不是简单地试图避免使用同步。这个系列的第 2 部分将把重点放在减少争用的技术上,包括减小锁的粒度、减小同步块的大小以及减小线程间共享数据的数量。
什么时候需要同步?
要使您的程序线程安全,首先必须确定哪些数据将在线程间共享。如果正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。有些程序员可能会惊讶地发现,这些规则在简单地检查一个共享引用是否非空的时候也用得上。
许多人会发现这些定义惊人地严格。有一种普遍的观点是,如果只是要读一个对象的字段,不需要请求加锁,尤其是在 JLS 保证了 32 位读操作的原子性的情况下,它更是如此。但不幸的是,这个观点是错误的。除非所指的字段被声明为 volatile ,否则 JMM 不会要求下面的平台提供处理器间的缓存一致性和顺序连贯性,所以很有可能,在某些平台上,没有同步就会读到陈旧的数据。有关更详细的信息,请参阅 参考资料。
在确定了要共享的数据之后,还要确定要如何保护那些数据。在简单情况下,只需把它们声明为 volatile 即可保护数据字段;在其它情况下,必须在读或写共享数据前请求加锁,一个很好的经验是明确指出使用什么锁来保护给定的字段或对象,并在你的代码里把它记录下来。
还有一点值得注意的是,简单地同步存取器方法(或声明下层的字段为 volatile )可能并不足以保护一个共享字段。可以考虑下面的示例:
...
private int foo;
public synchronized int getFoo() { return foo; }
public synchronized void setFoo(int f) { foo = f; }
|
如果一个调用者想要增加 foo 属性值,以下完成该功能的代码就不是线程安全的:
...
setFoo(getFoo() + 1);
|
如果两个线程试图同时增加 foo 属性值,结果可能是 foo 的值增加了 1 或 2,这由计时决定。调用者将需要同步一个锁,才能防止这种争用情况;一个好方法是在 JavaDoc 类中指定同步哪个锁,这样类的调用者就不需要自己猜了。
以上情况是一个很好的示例,说明我们应该注意多层次粒度的数据完整性;同步存取器方法确保调用者能够存取到一致的和最近版本的属性值,但如果希望属性的将来值与当前值一致,或多个属性间相互一致,我们就必须同步复合操作 ― 可能是在一个粗粒度的锁上。
如果情况不确定,考虑使用同步包装
有时,在写一个类的时候,我们并不知道它是否要用在一个共享环境里。我们希望我们的类是线程安全的,但我们又不希望给一个总是在单线程环境内使用的类加上同步的负担,而且我们可能也不知道使用这个类时合适的锁粒度是多大。幸运的是,通过提供同步包装,我们可以同时达到以上两个目的。Collections 类就是这种技术的一个很好的示例;它们是非同步的,但在框架中定义的每个接口都有一个同步包装(例如, Collections.synchronizedMap() ),它用一个同步的版本来包装每个方法。
结论
虽然 JLS 给了我们可以使我们的程序线程安全的工具,但线程安全也不是天上掉下来的馅饼。使用同步会蒙受性能损失,而同步使用不当又会使我们承担数据混乱、结果不一致或死锁的风险。幸运的是,在过去的几年内 JVM 有了很大的改进,大大减少了与正确使用同步相关的性能损失。通过仔细分析在线程间如何共享数据,适当地同步对共享数据的操作,可以使得您的程序既是线程安全的,又不会承受过多的性能负担。
观察者模式在 Swing 开发中很常见,在 GUI 应用程序以外的场景中,它对于消除组件的耦合性也非常有用。但是,仍然存在一些侦听器登记和调用方面的常见缺陷。在 Java 理论与实践 的这一期中,Java 专家 Brian Goetz 就如何做一个好的侦听器,以及如何对您的侦听器也友好,提供了一些感觉很好的建议。请在相应的 讨论论坛 上与作者和其他读者分享您对这篇文章的想法。(您也可以单击本文顶部或底部的 讨论 访问论坛。)
Swing 框架以事件侦听器的形式广泛利用了观察者模式(也称为发布-订阅模式)。Swing 组件作为用户交互的目标,在用户与它们交互的时候触发事件;数据模型类在数据发生变化时触发事件。用这种方式使用观察者,可以让控制器与模型分离,让模型与视图分离,从而简化 GUI 应用程序的开发。
“四人帮”的 设计模式 一书(参阅 参考资料)把观察者模式描述为:定义对象之间的“一对多”关系,这样一个对象改变状态时,所有它的依赖项都会被通知,并自动更新。观察者模式支持组件之间的松散耦合;组件可以保持它们的状态同步,却不需要直接知道彼此的标识或内部情况,从而促进了组件的重用。
AWT 和 Swing 组件(例如 JButton 或 JTable )使用观察者模式消除了 GUI 事件生成与它们在指定应用程序中的语义之间的耦合。类似地,Swing 的模型类,例如 TableModel 和 TreeModel ,也使用观察者消除数据模型表示 与视图生成之间的耦合,从而支持相同数据的多个独立的视图。Swing 定义了 Event 和 EventListener 对象层次结构;可以生成事件的组件,例如 JButton (可视组件) 或 TableModel (数据模型),提供了 addXxxListener() 和 removeXxxListener() 方法,用于侦听器的登记和取消登记。这些类负责决定什么时候它们需要触发事件,什么时候确实触发事件,以及什么时候调用所有登记的侦听器。
为了支持侦听器,对象需要维护一个已登记的侦听器列表,提供侦听器登记和取消登记的手段,并在适当的事件发生时调用每个侦听器。使用和支持侦听器很容易(不仅仅在 GUI 应用程序中),但是在登记接口的两边(它们是支持侦听器的组件和登记侦听器的组件)都应当避免一些缺陷。
线程安全问题
通常,调用侦听器的线程与登记侦听器的线程不同。要支持从不同线程登记侦听器,那么不管用什么机制存储和管理活动侦听器列表,这个机制都必须是线程安全的。Sun 的文档中的许多示例使用 Vector 保存侦听器列表,它解决了部分问题,但是没有解决全部问题。在事件触发时,触发它的组件会考虑迭代侦听器列表,并调用每个侦听器,这就带来了并发修改的风险,比如在侦听器列表迭代期间,某个线程偶然想添加或删除一个侦听器。
管理侦听器列表
假设您使用 Vector<Listener> 保存侦听器列表。虽然 Vector 类是线程安全的(意味着不需要进行额外的同步就可调用它的方法,没有破坏 Vector 数据结构的风险),但是集合的迭代中包含“检测然后执行”序列,如果在迭代期间集合被修改,就有了失败的风险。假设迭代开始时列表中有三个侦听器。在迭代 Vector 时,重复调用 size() 和 get() 方法,直到所有元素都检索完,如清单 1 所示:
清单 1. Vector 的不安全迭代
Vector<Listener> v;
for (int i=0; i<v.size(); i++)
v.get(i).eventHappened(event);
|
但是,如果恰好就在最后一次调用 Vector.size() 之后,有人从列表中删除了一个侦听器,会发生什么呢?现在,Vector.get() 将返回 null (这是对的,因为从上次检测 vector 的状态以来,它的状态已经变了),而在试图调用 eventHappened() 时,会抛出 NullPointerException 。这是“检测然后执行”序列的一个示例 —— 检测是否存在更多元素,如果存在,就取得下一元素 —— 但是在存在并发修改的情况下,检测之后状态可能已经变化。图 1 演示了这个问题:
图 1. 并发迭代和修改,造成意料之外的失败
这个问题的一个解决方案是在迭代期间持有对 Vector 的锁;另一个方案是克隆 Vector 或调用它的 toArray() 方法,在每次发生事件时检索它的内容。所有这两个方法都有性能上的问题:第一个的风险是在迭代期间,会把其他想访问侦听器列表的线程锁在外面;第二个则要创建临时对象,而且每次事件发生时都要拷贝列表。
如果用迭代器(Iterator)去遍历侦听器列表,也会有同样的问题,只是表现略有不同; iterator() 实现不抛出 NullPointerException ,它在探测到迭代开始之后集合发生修改时,会抛出 ConcurrentModificationException 。同样,也可以通过在迭代期间锁定集合防止这个问题。
java.util.concurrent
中的 CopyOnWriteArrayList 类,能够帮助防止这个问题。它实现了 List ,而且是线程安全的,但是它的迭代器不会抛出 ConcurrentModificationException ,遍历期间也不要求额外的锁定。这种特性组合是通过在每次列表修改时,在内部重新分配并拷贝列表内容而实现的,这样,遍历内容的线程不需要处理变化 —— 从它们的角度来说,列表的内容在遍历期间保持不变。虽然这听起来可能没效率,但是请记住,在多数观察者情况下,每个组件只有少量侦听器,遍历的数量远远超过插入和删除的数量。所以更快的迭代可以补偿较慢的变化过程,并提供更好的并发性,因为多个线程可以同时迭代列表。
初始化的安全风险
从侦听器的构造函数中登记它很诱惑人,但是这是一个应当避免的诱惑。它仅会造成“失效侦听器(lapsed listener)的问题(我稍后讨论它),而且还会造成多个线程安全问题。清单 2 显示了一个看起来没什么害处的同时构造和登记侦听器的企图。问题是:它造成到对象的“this”引用在对象完全构造完成之前转义。虽然看起来没什么害处,因为登记是构造函数做的最后一件事,但是看到的东西是有欺骗性的:
清单 2. 事件侦听器允许“this”引用转义,造成问题
public class EventListener {
public EventListener(EventSource eventSource) {
// do our initialization
...
// register ourselves with the event source
eventSource.registerListener(this);
}
public onEvent(Event e) {
// handle the event
}
}
|
在继承事件侦听器的时候,会出现这种方法的一个风险:这时,子类构造函数做的任何工作都是在 EventListener 构造函数运行之后进行的,也就是在 EventListener 发布之后,所以会造成争用情况。在某些不幸的时候,清单 3 中的 onEvent 方法会在列表字段还没初始化之前就被调用,从而在取消 final 字段的引用时,会生成非常让人困惑的 NullPointerException 异常:
清单 3. 继承清单 2 的 EventListener 类造成的问题
public class RecordingEventListener extends EventListener {
private final ArrayList<Event> list;
public RecordingEventListener(EventSource eventSource) {
super(eventSource);
list = Collections.synchronizedList(new ArrayList<Event>());
}
public onEvent(Event e) {
list.add(e);
super.onEvent(e);
}
}
|
即使侦听器类是 final 的,不能派生子类,也不应当允许“this”引用在构造函数中转义 —— 这样做会危害 Java 内存模型的某些安全保证。如果“this”这个词不会出现在程序中,就可让“this”引用转义;发布一个非静态内部类实例可以达到相同的效果,因为内部类持有对它包围的对象的“this”引用的引用。偶然地允许“this”引用转义的最常见原因,就是登记侦听器,如清单 4 所示。事件侦听器不应当在构造函数中登记!
清单 4. 通过发布内部类实例,显式地允许“this”引用转义
public class EventListener2 {
public EventListener2(EventSource eventSource) {
eventSource.registerListener(
new EventListener() {
public void onEvent(Event e) {
eventReceived(e);
}
});
}
public void eventReceived(Event e) {
}
}
|
侦听器线程安全
使用侦听器造成的第三个线程安全问题来自这个事实:侦听器可能想访问应用程序数据,而调用侦听器的线程通常不直接在应用程序的控制之下。如果在 JButton 或其他 Swing 组件上登记侦听器,那么会从 EDT 调用该侦听器。侦听器的代码可以从 EDT 安全地调用 Swing 组件上的方法,但是如果对象本身不是线程安全的,那么从侦听器访问应用程序对象会给应用程序增加新的线程安全需求。
Swing 组件生成的事件是用户交互的结果,但是 Swing 模型类是在 fireXxxEvent() 方法被调用的时候生成事件。这些方法又会在调用它们的线程中调用侦听器。因为 Swing 模型类不是线程安全的,而且假设被限制在 EDT 内,所以对 fireXxxEvent() 的任何调用也都应当从 EDT 执行。如果想从另外的线程触发事件,那么应当用 Swing 的 invokeLater() 功能让方法转而在 EDT 内调用。一般来说,要注意调用事件侦听器的线程,还要保证它们涉及的任何对象或者是线程安全的,或者在访问它们的地方,受到适当的同步(或者是 Swing 模型类的线程约束)的保护。
失效侦听器
不管什么时候使用观察者模式,都耦合着两个独立组件 —— 观察者和被观察者,它们通常有不同的生命周期。登记侦听器的后果之一就是:它在被观察对象和侦听器之间建立起很强的引用关系,这种关系防止侦听器(以及它引用的对象)被垃圾收集,直到侦听器取消登记为止。在许多情况下,侦听器的生命周期至少要和被观察的组件一样长 —— 许多侦听器会在整个应用程序期间都存在。但是在某些情况下,应当短期存在的侦听器最后变成了永久的,它们这种无意识的拖延的证据就是应用程序性能变慢、高于必需的内存使用。
“失效侦听器”的问题可以由设计级别上的不小心造成:没有恰当地考虑包含的对象的寿命,或者由于松懈的编码。侦听器登记和取消登记应当结对进行。但是即使这么做,也必须保证是在正确的时间执行取消登记。清单 5 显示了会造成失效侦听器的编码习惯的示例。它在组件上登记侦听器,执行某些动作,然后取消登记侦听器:
清单 5. 有造成失效侦听器风险的代码
public void processFile(String filename) throws IOException {
cancelButton.registerListener(this);
// open file, read it, process it
// might throw IOException
cancelButton.unregisterListener(this);
}
|
清单 5 的问题是:如果文件处理代码抛出了 IOException —— 这是很有可能的 —— 那么侦听器就永远不会取消登记,这就意味着它永远不会被垃圾收集。取消登记的操作应当在 finally 块中进行,这样,processFile() 方法的所有出口都会执行它。
有时推荐的一个处理失效侦听器的方法是使用弱引用。虽然这种方法可行,但是实现起来很麻烦。要让它工作,需要找到另外一个对象,它的生命周期恰好是侦听器的生命周期,并安排它持有对侦听器的强引用,这可不是件容易的事。
另外一项可以用来找到隐藏失效侦听器的技术是:防止指定侦听器对象在指定事件源上登记两次。这种情况通常是 bug 的迹象 —— 侦听器登记了,但是没有取消登记,然后再次登记。不用检测问题,就能缓解这个问题的影响的一种方式是:使用 Set 代替 List 来存储侦听器;或者也可以检测 List ,在登记侦听器之前检查是否已经登记了,如果已经登记,就抛出异常(或记录错误),这样就可以搜集编码错误的证据,并采取行动。
其他侦听器问题
在编写侦听器时,应当一直注意它们将要执行的环境。不仅要注意线程安全问题,还需要记住:侦听器也可以用其他方式为它的调用者把事情搞糟。侦听器 不该 做的一件事是:阻塞相当长一段时间(长得可以感觉得到);调用它的执行上下文很可能希望迅速返回控制。如果侦听器要执行一个可能比较费时的操作,例如处理大型文本,或者要做的工作可能阻塞,例如执行 socket IO,那么侦听器应当把这些操作安排在另一个线程中进行,这样它就可以迅速返回它的调用者。
对于不小心的事件源,侦听器会造成麻烦的另一个方式是:抛出未检测的异常。虽然大多数时候,我们不会故意抛出未检测异常,但是确实有些时候会发生这种情况。如果使用清单 1 的方式调用侦听器,列表中的第二个侦听器就会抛出未检测异常,那么不仅后续的侦听器得不到调用(可能造成应用程序处在不一致的状态),而且有可能把执行它的线程破坏掉,从而造成局部应用程序失败。
在调用未知代码(侦听器就是这样的代码)时,谨慎的方式是在 try-catch 块中执行它,这样,行为有误的侦听器不会造成更多不必要的破坏。对于抛出未检测异常的侦听器,您可能想自动对它取消登记,毕竟,抛出未检测异常就证明侦听器坏掉了。(您可能还想记录这个错误或者提醒用户注意,好让用户能够知道为什么程序停止像期望的那样继续工作。)清单 6 显示了这种方式的一个示例,它在迭代循环内部嵌套了 try-catch 块:
清单 6. 健壮的侦听器调用
List<Listener> list;
for (Iterator<Listener> i=list.iterator; i.hasNext(); ) {
Listener l = i.next();
try {
l.eventHappened(event);
}
catch (RuntimeException e) {
log("Unexpected exception in listener", e);
i.remove();
}
}
|
结束语
观察者模式对于创建松散耦合的组件、鼓励组件重用非常有用,但是它有一些风险,侦听器的编写者和组件的编写者都应当注意。在登记侦听器时,应当一直注意侦听器的生命周期。如果侦听器的寿命应当比应用程序的短,那么请确保取消它的登记,这样它就可以被垃圾收集。在编写侦听器和组件时,请注意它包含的线程安全性问题。侦听器涉及的任何对象,都应当是线程安全的,或者是受线程约束的对象(例如 Swing 模型),侦听器应当确定自己正在正确的线程中执行。
虽然用 Java™ 语言编写的程序在理论上是不会出现“内存泄漏”的,但是有时对象在不再作为程序的逻辑状态的一部分之后仍然不被垃圾收集。本月,负责保障应用程序健康的工程师 Brian Goetz 探讨了无意识的对象保留的常见原因,并展示了如何用弱引用堵住泄漏。
要让垃圾收集(GC)回收程序不再使用的对象,对象的逻辑 生命周期(应用程序使用它的时间)和对该对象拥有的引用的实际 生命周期必须是相同的。在大多数时候,好的软件工程技术保证这是自动实现的,不用我们对对象生命周期问题花费过多心思。但是偶尔我们会创建一个引用,它在内存中包含对象的时间比我们预期的要长得多,这种情况称为无意识的对象保留(unintentional object retention)。
全局 Map 造成的内存泄漏
无意识对象保留最常见的原因是使用 Map 将元数据与临时对象(transient object)相关联。假定一个对象具有中等生命周期,比分配它的那个方法调用的生命周期长,但是比应用程序的生命周期短,如客户机的套接字连接。需要将一些元数据与这个套接字关联,如生成连接的用户的标识。在创建 Socket 时是不知道这些信息的,并且不能将数据添加到 Socket 对象上,因为不能控制 Socket 类或者它的子类。这时,典型的方法就是在一个全局 Map 中存储这些信息,如清单 1 中的 SocketManager 类所示:
清单 1. 使用一个全局 Map 将元数据关联到一个对象
public class SocketManager {
private Map<Socket,User> m = new HashMap<Socket,User>();
public void setUser(Socket s, User u) {
m.put(s, u);
}
public User getUser(Socket s) {
return m.get(s);
}
public void removeUser(Socket s) {
m.remove(s);
}
}
SocketManager socketManager;
...
socketManager.setUser(socket, user);
|
这种方法的问题是元数据的生命周期需要与套接字的生命周期挂钩,但是除非准确地知道什么时候程序不再需要这个套接字,并记住从 Map 中删除相应的映射,否则,Socket 和 User 对象将会永远留在 Map 中,远远超过响应了请求和关闭套接字的时间。这会阻止 Socket 和 User 对象被垃圾收集,即使应用程序不会再使用它们。这些对象留下来不受控制,很容易造成程序在长时间运行后内存爆满。除了最简单的情况,在几乎所有情况下找出什么时候 Socket 不再被程序使用是一件很烦人和容易出错的任务,需要人工对内存进行管理。
找出内存泄漏
程序有内存泄漏的第一个迹象通常是它抛出一个 OutOfMemoryError ,或者因为频繁的垃圾收集而表现出糟糕的性能。幸运的是,垃圾收集可以提供能够用来诊断内存泄漏的大量信息。如果以 -verbose:gc 或者 -Xloggc 选项调用 JVM,那么每次 GC 运行时在控制台上或者日志文件中会打印出一个诊断信息,包括它所花费的时间、当前堆使用情况以及恢复了多少内存。记录 GC 使用情况并不具有干扰性,因此如果需要分析内存问题或者调优垃圾收集器,在生产环境中默认启用 GC 日志是值得的。
有工具可以利用 GC 日志输出并以图形方式将它显示出来,JTune 就是这样的一种工具(请参阅 参考资料)。观察 GC 之后堆大小的图,可以看到程序内存使用的趋势。对于大多数程序来说,可以将内存使用分为两部分:baseline 使用和 current load 使用。对于服务器应用程序,baseline 使用就是应用程序在没有任何负荷、但是已经准备好接受请求时的内存使用,current load 使用是在处理请求过程中使用的、但是在请求处理完成后会释放的内存。只要负荷大体上是恒定的,应用程序通常会很快达到一个稳定的内存使用水平。如果在应用程序已经完成了其初始化并且负荷没有增加的情况下,内存使用持续增加,那么程序就可能在处理前面的请求时保留了生成的对象。
清单 2 展示了一个有内存泄漏的程序。MapLeaker 在线程池中处理任务,并在一个 Map 中记录每一项任务的状态。不幸的是,在任务完成后它不会删除那一项,因此状态项和任务对象(以及它们的内部状态)会不断地积累。
清单 2. 具有基于 Map 的内存泄漏的程序
public class MapLeaker {
public ExecutorService exec = Executors.newFixedThreadPool(5);
public Map<Task, TaskStatus> taskStatus
= Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
private Random random = new Random();
private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };
private class Task implements Runnable {
private int[] numbers = new int[random.nextInt(200)];
public void run() {
int[] temp = new int[random.nextInt(10000)];
taskStatus.put(this, TaskStatus.STARTED);
doSomeWork();
taskStatus.put(this, TaskStatus.FINISHED);
}
}
public Task newTask() {
Task t = new Task();
taskStatus.put(t, TaskStatus.NOT_STARTED);
exec.execute(t);
return t;
}
}
|
图 1 显示 MapLeaker GC 之后应用程序堆大小随着时间的变化图。上升趋势是存在内存泄漏的警示信号。(在真实的应用程序中,坡度不会这么大,但是在收集了足够长时间的 GC 数据后,上升趋势通常会表现得很明显。)
图 1. 持续上升的内存使用趋势
确信有了内存泄漏后,下一步就是找出哪种对象造成了这个问题。所有内存分析器都可以生成按照对象类进行分解的堆快照。有一些很好的商业堆分析工具,但是找出内存泄漏不一定要花钱买这些工具 —— 内置的 hprof 工具也可完成这项工作。要使用 hprof 并让它跟踪内存使用,需要以 -Xrunhprof:heap=sites 选项调用 JVM。
清单 3 显示分解了应用程序内存使用的 hprof 输出的相关部分。(hprof 工具在应用程序退出时,或者用 kill -3 或在 Windows 中按 Ctrl+Break 时生成使用分解。)注意两次快照相比,Map.Entry 、Task 和 int[] 对象有了显著增加。
请参阅 清单 3。
清单 4 展示了 hprof 输出的另一部分,给出了 Map.Entry 对象的分配点的调用堆栈信息。这个输出告诉我们哪些调用链生成了 Map.Entry 对象,并带有一些程序分析,找出内存泄漏来源一般来说是相当容易的。
清单 4. HPROF 输出,显示 Map.Entry 对象的分配点
TRACE 300446:
java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)
java.util.HashMap.addEntry(<Unknown Source>:Unknown line)
java.util.HashMap.put(<Unknown Source>:Unknown line)
java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)
com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)
com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)
|
弱引用来救援了
SocketManager
的问题是 Socket -User 映射的生命周期应当与 Socket 的生命周期相匹配,但是语言没有提供任何容易的方法实施这项规则。这使得程序不得不使用人工内存管理的老技术。幸运的是,从 JDK 1.2 开始,垃圾收集器提供了一种声明这种对象生命周期依赖性的方法,这样垃圾收集器就可以帮助我们防止这种内存泄漏 —— 利用弱引用。
弱引用是对一个对象(称为 referent)的引用的持有者。使用弱引用后,可以维持对 referent 的引用,而不会阻止它被垃圾收集。当垃圾收集器跟踪堆的时候,如果对一个对象的引用只有弱引用,那么这个 referent 就会成为垃圾收集的候选对象,就像没有任何剩余的引用一样,而且所有剩余的弱引用都被清除。(只有弱引用的对象称为弱可及(weakly reachable)。)
WeakReference
的 referent 是在构造时设置的,在没有被清除之前,可以用 get() 获取它的值。如果弱引用被清除了(不管是 referent 已经被垃圾收集了,还是有人调用了 WeakReference.clear() ),get() 会返回 null 。相应地,在使用其结果之前,应当总是检查 get() 是否返回一个非 null 值,因为 referent 最终总是会被垃圾收集的。
用一个普通的(强)引用拷贝一个对象引用时,限制 referent 的生命周期至少与被拷贝的引用的生命周期一样长。如果不小心,那么它可能就与程序的生命周期一样 —— 如果将一个对象放入一个全局集合中的话。另一方面,在创建对一个对象的弱引用时,完全没有扩展 referent 的生命周期,只是在对象仍然存活的时候,保持另一种到达它的方法。
弱引用对于构造弱集合最有用,如那些在应用程序的其余部分使用对象期间存储关于这些对象的元数据的集合 —— 这就是 SocketManager 类所要做的工作。因为这是弱引用最常见的用法,WeakHashMap 也被添加到 JDK 1.2 的类库中,它对键(而不是对值)使用弱引用。如果在一个普通 HashMap 中用一个对象作为键,那么这个对象在映射从 Map 中删除之前不能被回收,WeakHashMap 使您可以用一个对象作为 Map 键,同时不会阻止这个对象被垃圾收集。清单 5 给出了 WeakHashMap 的 get() 方法的一种可能实现,它展示了弱引用的使用:
清单 5. WeakReference.get() 的一种可能实现
public class WeakHashMap<K,V> implements Map<K,V> {
private static class Entry<K,V> extends WeakReference<K>
implements Map.Entry<K,V> {
private V value;
private final int hash;
private Entry<K,V> next;
...
}
public V get(Object key) {
int hash = getHash(key);
Entry<K,V> e = getChain(hash);
while (e != null) {
K eKey= e.get();
if (e.hash == hash && (key == eKey || key.equals(eKey)))
return e.value;
e = e.next;
}
return null;
}
|
调用 WeakReference.get() 时,它返回一个对 referent 的强引用(如果它仍然存活的话),因此不需要担心映射在 while 循环体中消失,因为强引用会防止它被垃圾收集。WeakHashMap 的实现展示了弱引用的一种常见用法 —— 一些内部对象扩展 WeakReference 。其原因在下面一节讨论引用队列时会得到解释。
在向 WeakHashMap 中添加映射时,请记住映射可能会在以后“脱离”,因为键被垃圾收集了。在这种情况下,get() 返回 null ,这使得测试 get() 的返回值是否为 null 变得比平时更重要了。
用 WeakHashMap 堵住泄漏
在 SocketManager 中防止泄漏很容易,只要用 WeakHashMap 代替 HashMap 就行了,如清单 6 所示。(如果 SocketManager 需要线程安全,那么可以用 Collections.synchronizedMap() 包装 WeakHashMap )。当映射的生命周期必须与键的生命周期联系在一起时,可以使用这种方法。不过,应当小心不滥用这种技术,大多数时候还是应当使用普通的 HashMap 作为 Map 的实现。
清单 6. 用 WeakHashMap 修复 SocketManager
public class SocketManager {
private Map<Socket,User> m = new WeakHashMap<Socket,User>();
public void setUser(Socket s, User u) {
m.put(s, u);
}
public User getUser(Socket s) {
return m.get(s);
}
}
|
引用队列
WeakHashMap
用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,get() 实现可以根据 WeakReference.get() 是否返回 null 来区分死的映射和活的映射。但是这只是防止 Map 的内存消耗在应用程序的生命周期中不断增加所需要做的工作的一半,还需要做一些工作以便在键对象被收集后从 Map 中删除死项。否则,Map 会充满对应于死键的项。虽然这对于应用程序是不可见的,但是它仍然会造成应用程序耗尽内存,因为即使键被收集了,Map.Entry 和值对象也不会被收集。
可以通过周期性地扫描 Map ,对每一个弱引用调用 get() ,并在 get() 返回 null 时删除那个映射而消除死映射。但是如果 Map 有许多活的项,那么这种方法的效率很低。如果有一种方法可以在弱引用的 referent 被垃圾收集时发出通知就好了,这就是引用队列 的作用。
引用队列是垃圾收集器向应用程序返回关于对象生命周期的信息的主要方法。弱引用有两个构造函数:一个只取 referent 作为参数,另一个还取引用队列作为参数。如果用关联的引用队列创建弱引用,在 referent 成为 GC 候选对象时,这个引用对象(不是 referent)就在引用清除后加入 到引用队列中。之后,应用程序从引用队列提取引用并了解到它的 referent 已被收集,因此可以进行相应的清理活动,如去掉已不在弱集合中的对象的项。(引用队列提供了与 BlockingQueue 同样的出列模式 —— polled、timed blocking 和 untimed blocking。)
WeakHashMap
有一个名为 expungeStaleEntries() 的私有方法,大多数 Map 操作中会调用它,它去掉引用队列中所有失效的引用,并删除关联的映射。清单 7 展示了 expungeStaleEntries() 的一种可能实现。用于存储键-值映射的 Entry 类型扩展了 WeakReference ,因此当 expungeStaleEntries() 要求下一个失效的弱引用时,它得到一个 Entry 。用引用队列代替定期扫描内容的方法来清理 Map 更有效,因为清理过程不会触及活的项,只有在有实际加入队列的引用时它才工作。
清单 7. WeakHashMap.expungeStaleEntries() 的可能实现
private void expungeStaleEntries() {
Entry<K,V> e;
while ( (e = (Entry<K,V>) queue.poll()) != null) {
int hash = e.hash;
Entry<K,V> prev = getChain(hash);
Entry<K,V> cur = prev;
while (cur != null) {
Entry<K,V> next = cur.next;
if (cur == e) {
if (prev == e)
setChain(hash, next);
else
prev.next = next;
break;
}
prev = cur;
cur = next;
}
}
}
|
结束语
弱引用和弱集合是对堆进行管理的强大工具,使得应用程序可以使用更复杂的可及性方案,而不只是由普通(强)引用所提供的“要么全部要么没有”可及性。下个月,我们将分析与弱引用有关的软引用,将分析在使用弱引用和软引用时,垃圾收集器的行为。
大多数具有一定重要性的 Web 应用程序都要求维护某种会话状态,如用户购物车的内容。如何在群集服务器应用程序中管理和复制状态对应用程序的可伸缩性有显著影响。许多 J2SE 和 J2EE 应用程序将状态存储在由 Servlet API 提供的 HttpSession 中。本月,专栏作家 Brian Goetz 分析了状态复制的一些选项以及如何最有效地使用 HttpSession 以提供好的伸缩性和性能。在本文论坛中与本文作者和其他读者分享您的观点。(可以单击文章顶部或者底部的 讨论访问论坛。)
不管正在构建的是 J2EE 还是 J2SE 服务器应用程序,都有可能以某种方式使用 Java Servlet —— 可能是直接地通过像 JSP 技术、Velocity 或者 WebMacro 这样的表示层,也可能通过一个基于 servlet 的 Web 服务实现,如 Axis 或者 Glue。Servlet API 提供的一个最重要的功能是会话管理 —— 通过 HttpSession 接口进行用户状态的认证、失效和维护。
会话状态
几乎每一个 Web 应用程序都有一些会话状态,这些状态有可能像记住您是否已登录这么简单,也可能是您的会话的更详细的历史,如购物车的内容、以前查询结果的缓存或者 20 页动态问卷表的完整响应历史。因为 HTTP 协议本身是无状态的,所以需要将会话状态存储在某处并与浏览会话以某种方式相关联,使得下次请求同一 Web 应用程序的页面时可以容易地获取。幸运的是,J2EE 提供了几种管理会话状态的方法 —— 状态可以存储在数据层,用 Servlet API 的 HttpSession 接口存储在 Web 层,用有状态会话 bean 存储在 Enterprise JavaBeans(EJB)层,甚至用 cookie 或者隐藏表单字段将状态存储在客户层。不幸的是,会话状态管理不当会带来严重的性能问题。
如果应用程序能够在 HttpSession 中存储用户状态,这种方法通常比其他方法更好。在客户端用 HTTP cookie 或者隐藏表单字段存储会话状态有很大的安全风险 —— 它将应用程序的一部分内部内容暴露给了非受信任的客户层。(一个早期的电子商务网站将购物车内容(包括价格)存储在隐藏表单字段中,从而可以很容易被非法利用,让任何了解 HTML 和 HTTP 的用户可以以 0.01 美元购买任何商品。噢)此外,使用 cookie 或者隐藏表单字段很混乱,容易出错,并且脆弱(如果用户禁止在浏览器中使用 cookie,那么基于 cookie 的方法就完全不能工作)。
在 J2EE 应用程序中存储服务器端状态的其他方法是使用有状态会话 bean,或者在数据库中存储会话状态。虽然有状态会话 bean 在会话状态管理方面有更大的灵活性,但是在可能的情况下,将会话状态存储在 Web 层仍然有好处。如果业务对象是无状态的,那么通常可以仅仅添加更多 Web 服务器来扩展应用程序,而不用添加更多 Web 服务器和更多 EJB 容器, 这样的成本一般要低一些并且容易完成。使用 HttpSession 存储会话状态的另一个好处是 Servlet API 提供了一种会话失效时通知的容易方法。在数据库中存储会话状态的成本可能难以承受。
servlet 规范没有要求 servlet 容器进行某种类型的会话复制或者持久性,但是它建议将状态复制作为 servlet 首要 存在理由(raison d'etre) 的重要部分,并且它对作为进行会话复制的容器提出了一些要求。会话复制可以提供大量好处 —— 负载平衡、伸缩性、容错和高可用性。相应地,大多数 servlet 容器支持某种形式的 HttpSession 复制,但是复制的机制、配置和时间是由实现决定的。
HttpSession API
简单地说, HttpSession 接口支持几种方法,servlet、JSP 页或者其他表示层组件可以用这些方法来跨多个 HTTP 请求维护会话信息。会话绑定到特定的用户,但是在 Web 应用程序的所有 servlet 中共享 —— 不特定于某一个 servlet。一种考虑会话的有用方法是,会话像一个在会话期间存储对象的 Map —— 可以用 setAttribute 按名字存储会话属性,并用 getAttribute 提取它们。 HttpSession 接口还包含会话生存周期方法,如 invalidate() (它通知容器应丢弃会话)。清单 1 显示 HttpSession 接口最常用的元素:
清单 1. HttpSession API
public interface HttpSession {
Object getAttribute(String s);
Enumeration getAttributeNames();
void setAttribute(String s, Object o);
void removeAttribute(String s);
boolean isNew();
void invalidate();
void setMaxInactiveInterval(int i);
int getMaxInactiveInterval();
...
}
|
理论上,可以跨群集一致性地完全复制会话状态,这样群集中的所有节点都可以服务任何请求,一个简单的负载平衡器可以以轮询方式传送请求,避开有故障的主机。不过,这种紧密的复制有很高的性能成本,并且难于实现,当群集接近某一规模时,还会有伸缩性的问题。
一种更常用的方式是将负载平衡与会话相似性(affinity) 结合起来 —— 负载平衡器可以将会话与连接相关联,并将会话中以后的请求发送给同一服务器。有很多硬件和软件负载平衡器支持这个功能,并且这意味着只有主连接主机和会话需要故障转移到另一台服务器时才访问复制的会话信息。
复制方式
复制提供了一些可能的好处,包括可用性、容错和伸缩性。此外,有大量会话复制的方法可用:方法的选择取决于应用程序群集的规模、复制的目标和 servlet 容器支持的复制设施。复制有性能成本,包括 CPU 周期(存储在会话中的序列化对象)、网络带宽(广播更新),以及基于磁盘的方案中写入到磁盘或者数据库的成本。
几乎所有 servlet 容器都通过存储在 HttpSession 中的序列化对象进行 HttpSession 复制,所以如果是创建一个分布式应用程序,应当确保只将可序列化对象放到会话中。(一些容器对像 EJB 引用、事务上下文、还有其他非可序列化的 J2EE 对象类型有特殊的处理。)
基于 JDBC 的复制
一种会话复制的方法是序列化会话内容并将它写入数据库。这种方法相当直观,其优点是不仅会话可以故障转移到其他主机,而且即使整个群集失效,会话数据也可以保存下来。基于数据库的复制的缺点是性能成本 —— 数据库事务是昂贵的。虽然它可以在 Web 层很好地伸缩,但是它可能在数据层产生伸缩问题 —— 如果群集增长大到一定程度,扩展数据层以容纳会话数据会很困难或者成本无法接受。
基于文件的复制
基于文件的复制类似于使用数据库存储序列化的会话,只不过是使用共享文件服务器而不是数据库来存储会话数据。这种方式的成本一般比使用数据库的成本(硬件成本、软件许可证和计算开销)低,其代价则是可靠性(数据库可提供比文件系统更强的持久化保证)。
基于内存的复制
另一种复制方式是与群集中的一个或者多个其他服务器共享序列化的会话数据副本。复制所有会话到所有主机中提供了最大的可用性,并且负载平衡最容易,但是因为复制消息所消耗的每个节点的内存和网络带宽,最终会限制群集的规模。一些应用服务器支持与“伙伴(buddy)”节点的基于内存的复制,其中每一个会话存在于主服务器上和一台(或更多)备份服务器上。这种方案比将所有会话复制到所有服务器的伸缩性更好,但是当需要将会话故障转移到另一台服务器上时会使负载平衡任务复杂化,因为它必须找出另外哪一台(几台)服务器有这个会话。
时间考虑
除了决定如何存储复制会话数据,还有什么时候复制数据的问题。最可靠但也最昂贵的方法是每次数据改变时复制它(如每次 servlet 调用结束)。不那么昂贵、但是在故障时会有丢失一些数据的风险的方法是在每超过 N 秒时复制数据。
与时间问题有关的问题是,是复制整个会话还是只试尝复制会话中改变了的属性(它包含的数据会少得多)。这些都需要在可靠性和性能之间进行取舍。Servlet 开发人员应当认识到在故障转移时,会话状态可能变得“过时”(是几次请求前的复制),并应当准备处理不是最新的会话内容。(例如,如果一个interview 的第 3 步产生一个会话属性,而用户在第 4 步时,请求被故障转移到一个具有两次请求之前的会话状态复制的系统上,那么第 4 步的 servlet 代码应预备在会话中找不到这个属性,并采取相应的行动 —— 如重定向,而不是认定它会在那里、并在找不到它时抛出一个 NullPointerException 。)
容器支持
Servlet 容器的 HttpSession 复制选项以及如何配置这些选项是各不相同的。IBM WebSphere ®提供的复制选项是最多的,它提供了在内存中复制或者基于数据库的复制、在 servlet 末尾或者基于时间的复制时间、传播全部会话快照(JBoss 3.2 或以后版本)或者只传播改变了的属性等选择。基于内存的复制基于 JMS 发布-订阅,它可以复制到所有克隆、一个“伙伴”复制品或者一个专门的复制服务器。
WebLogic 还提供了一组选择,包括内存中(使用一个伙伴复制品)、基于文件的或者基于数据库的。JBoss 与 Tomcat 或者 Jetty servlet 容器一同使用时,进行基于内存的复制,可以选择 servlet 末尾或者基于时间的复制时间,而快照选项(在 JBoss 3.2 或以后版本)是只复制改变了的属性。Tomcat 5.0 为所有群集节点提供了基于内存的复制。此外,通过像 WADI 这样的项目,可以用 servlet 过滤机制将会话复制添加到像 Tomcat 或者 Jetty 这样的 servlet 容器中。
改进分布式 Web 应用程序的性能
不管决定使用什么机制进行会话复制,可以用几种方式改进 Web 应用程序的性能和伸缩性。首先记住,为了获得会话复制的好处,需要在部署描述符中将 Web 应用程序标记为 distributable,并保证在会话中的所有内容都是可序列化的。
保持会话最小
因为复制会话有随着会话中的对象图(object graph) 的变大而增加成本,所以应当尽可能地在会话中少放置数据。这样做会减少复制的序列化的开销、网络带宽要求和磁盘要求。特别地,将共享对象存储在会话中一般不是好主意,因为它们需要复制到它们所属的 每一个会话中。
不要绕过 setAttribute
在改变会话的属性时,要知道即使 servlet 容器只是试图做最小的更新(只传播改变了的属性),如果没有调用 setAttribute ,容器也可能没有注意到已经改变的属性。(想像在会话中有一个 Vector ,表示购物车中的商品 —— 如果调用 getAttribute() 获取 Vector 、然后向它添加一些内容,并且不再次调用 setAttribute ,容器可能不会意识到 Vector 已经改变了。)
使用细化的会话属性
对于支持最小更新的容器,可以通过将多个细化的对象而不是一个大块头放到会话中而降低会话复制的成本。这样,对快速改变的数据的改变也不会迫使容器去序列化并传播慢速改变的数据。
完成后使之失效
如果知道用户完成了会话的使用(如,用户选择注销登录),确保调用 HttpSession.invalidate() 。否则,会话将持久化直到它失效,这会消耗内存,并且可能是长时间的(取决于会话超时时间)。许多 servlet 容器对可以跨所有会话使用的内存的数量有一个限制,达到这个限制时,会序列化最先使用的会话并将它写到磁盘上。如果知道用户使用完了会话,可以使容器不再处理它并使它作废。
保持会话干净
如果在会话中有大的项,并且只在会话的一部分中使用,那么当不再需要时应删除它们。删除它们会减少会话复制的成本。(这种做法类似于使用显式 nulling 以帮助垃圾收集器,老读者知道我一般不建议这样做,但是在这种情况下,因为有复制,在会话中保持垃圾的成本要高得多,因此值得以这种方式帮助容器。)
结束语
通过 HttpSession 复制,Servlet 容器可以在构建复制的、高可用性的 Web 应用程序方面给您减轻很多负担。不过,对于复制有一些配置选项,每个容器都不一样,复制策略的选择对于应用程序的容错、性能和伸缩性有影响。复制策略的选择不应当是事后的 —— 您应当在构建 Web 应用程序时就考虑它。并且,一定不要忘记进行负载测试以确定应用程序的伸缩性 —— 在客户替您做之前。
关于在 Java 语言中使用异常的大多数建议都认为,在确信异常可以被捕获的任何情况下,应该优先使用检查型异常。语言设计(编译器强制您在方法签名中列出可能被抛出的所有检查型异常)以及早期关于样式和用法的著作都支持该建议。最近,几位著名的作者已经开始认为非检查型异常在优秀的 Java 类设计中有着比以前所认为的更为重要的地位。在本文中,Brian Goetz 考察了关于使用非检查型异常的优缺点。请在附带的讨论论坛中与作者和其他读者一起分享您有关本文的心得体会(您也可以点击文章顶部或底部的 讨论来访问该论坛。)
与 C++ 类似,Java 语言也提供异常的抛出和捕获。但是,与 C++ 不一样的是,Java 语言支持检查型和非检查型异常。Java 类必须在方法签名中声明它们所抛出的任何检查型异常,并且对于任何方法,如果它调用的方法抛出一个类型为 E 的检查型异常,那么它必须捕获 E 或者也声明为抛出 E(或者 E 的一个父类)。通过这种方式,该语言强制我们文档化控制可能退出一个方法的所有预期方式。
对于因为编程错误而导致的异常,或者是不能期望程序捕获的异常(解除引用一个空指针,数组越界,除零,等等),为了使开发人员免于处理这些异常,一些异常被命名为非检查型异常(即那些继承自 RuntimeException 的异常)并且不需要进行声明。
传统的观点
在下面的来自 Sun 的“The Java Tutorial”的摘录中,总结了关于将一个异常声明为检查型还是非检查型的传统观点(更多的信息请参阅 参考资料):
因为 Java 语言并不要求方法捕获或者指定运行时异常,因此编写只抛出运行时异常的代码或者使得他们的所有异常子类都继承自 RuntimeException ,对于程序员来说是有吸引力的。这些编程捷径都允许程序员编写 Java 代码而不会受到来自编译器的所有挑剔性错误的干扰,并且不用去指定或者捕获任何异常。尽管对于程序员来说这似乎比较方便,但是它回避了 Java 的捕获或者指定要求的意图,并且对于那些使用您提供的类的程序员可能会导致问题。
检查型异常代表关于一个合法指定的请求的操作的有用信息,调用者可能已经对该操作没有控制,并且调用者需要得到有关的通知 —— 例如,文件系统已满,或者远端已经关闭连接,或者访问权限不允许该动作。
如果您仅仅是因为不想指定异常而抛出一个 RuntimeException ,或者创建 RuntimeException 的一个子类,那么您换取到了什么呢?您只是获得了抛出一个异常而不用您指定这样做的能力。换句话说,这是一种用于避免文档化方法所能抛出的异常的方式。在什么时候这是有益的?也就是说,在什么时候避免注明一个方法的行为是有益的?答案是“几乎从不。”
换句话说,Sun 告诉我们检查型异常应该是准则。该教程通过多种方式继续说明,通常应该抛出异常,而不是 RuntimeException —— 除非您是 JVM。
在 Effective Java: Programming Language Guide一书中(请参阅 参考资料),Josh Bloch 提供了下列关于检查型和非检查型异常的知识点,这些与 “The Java Tutorial” 中的建议相一致(但是并不完全严格一致):
-
第 39 条:只为异常条件使用异常。也就是说,不要为控制流使用异常,比如,在调用
Iterator.next() 时而不是在第一次检查 Iterator.hasNext() 时捕获 NoSuchElementException 。
-
第 40 条:为可恢复的条件使用检查型异常,为编程错误使用运行时异常。这里,Bloch 回应传统的 Sun 观点 —— 运行时异常应该只是用于指示编程错误,例如违反前置条件。
-
第 41 条:避免不必要的使用检查型异常。换句话说,对于调用者不可能从其中恢复的情形,或者惟一可以预见的响应将是程序退出,则不要使用检查型异常。
-
第 43 条:抛出与抽象相适应的异常。换句话说,一个方法所抛出的异常应该在一个抽象层次上定义,该抽象层次与该方法做什么相一致,而不一定与方法的底层实现细节相一致。例如,一个从文件、数据库或者 JNDI 装载资源的方法在不能找到资源时,应该抛出某种
ResourceNotFound 异常(通常使用异常链来保存隐含的原因),而不是更底层的 IOException 、 SQLException 或者 NamingException 。
重新考察非检查型异常的正统观点
最近,几位受尊敬的专家,包括 Bruce Eckel 和 Rod Johnson,已经公开声明尽管他们最初完全同意检查型异常的正统观点,但是他们已经认定排他性使用检查型异常的想法并没有最初看起来那样好,并且对于许多大型项目,检查型异常已经成为一个重要的问题来源。Eckel 提出了一个更为极端的观点,建议所有的异常应该是非检查型的;Johnson 的观点要保守一些,但是仍然暗示传统的优先选择检查型异常是过分的。(值得一提的是,C# 的设计师在语言设计中选择忽略检查型异常,使得所有异常都是非检查型的,因而几乎可以肯定他们具有丰富的 Java 技术使用经验。但是,后来他们的确为检查型异常的实现留出了空间。)
对于检查型异常的一些批评
Eckel 和 Johnson 都指出了一个关于检查型异常的相似的问题清单;一些是检查型异常的内在属性,一些是检查型异常在 Java 语言中的特定实现的属性,还有一些只是简单的观察,主要是关于检查型异常的广泛的错误使用是如何变为一个严重的问题,从而导致该机制可能需要被重新考虑。
检查型异常不适当地暴露实现细节
您已经有多少次看见(或者编写)一个抛出 SQLException 或者 IOException 的方法,即使它看起来与数据库或者文件毫无关系呢?对于开发人员来说,在一个方法的最初实现中总结出可能抛出的所有异常并且将它们增加到方法的 throws 子句(许多 IDE 甚至帮助您执行该任务)是十分常见的。这种直接方法的一个问题是它违反了 Bloch 的 第 43 条 —— 被抛出的异常所位于的抽象层次与抛出它们的方法不一致。
一个用于装载用户概要的方法,在找不到用户时应该抛出 NoSuchUserException ,而不是 SQLException —— 调用者可以很好地预料到用户可能找不到,但是不知道如何处理 SQLException 。异常链可以用于抛出一个更为合适的异常而不用丢弃关于底层失败的细节(例如栈跟踪),允许抽象层将位于它们之上的分层同位于它们之下的分层的细节隔离开来,同时保留对于调试可能有用的信息。
据说,诸如 JDBC 包的设计采取这样一种方式,使得它难以避免该问题。在 JDBC 接口中的每个方法都抛出 SQLException ,但是在访问一个数据库的过程中可能会经历多种不同类型的问题,并且不同的方法可能易受不同错误模式的影响。一个 SQLException 可能指示一个系统级问题(不能连接到数据库)、逻辑问题(在结果集中没有更多的行)或者特定数据的问题(您刚才试图插入行的主键已经存在或者违反实体完整性约束)。如果没有犯不可原谅的尝试分析消息正文的过失,调用者是不可能区分这些不同类型的 SQLException 的。( SQLException 的确支持用于获取数据库特定错误代码和 SQL 状态变量的方法,但是在实践中这些很少用于区分不同的数据库错误条件。)
不稳定的方法签名
不稳定的方法签名问题是与前面的问题相关的 —— 如果您只是通过一个方法传递异常,那么您不得不在每次改变方法的实现时改变它的方法签名,以及改变调用该方法的所有代码。一旦类已经被部署到产品中,管理这些脆弱的方法签名就变成一个昂贵的任务。然而,该问题本质上是没有遵循 Bloch 提出的第 43 条的另一个症状。方法在遇到失败时应该抛出一个异常,但是该异常应该反映该方法做什么,而不是它如何做。
有时,当程序员对因为实现的改变而导致从方法签名中增加或者删除异常感到厌烦时,他们不是通过使用一个抽象来定义特定层次可能抛出的异常类型,而只是将他们的所有方法都声明为抛出 Exception 。换句话说,他们已经认定异常只是导致烦恼,并且基本上将它们关闭掉了。毋庸多言,该方法对于绝大多数可任意使用的代码来说通常不是一个好的错误处理策略。
难以理解的代码
因为许多方法都抛出一定数目的不同异常,错误处理的代码相对于实际的功能代码的比率可能会偏高,使得难以找到一个方法中实际完成功能的代码。异常是通过集中错误处理来设想减小代码的,但是一个具有三行代码和六个 catch 块(其中每个块只是记录异常或者包装并重新抛出异常)的方法看起来比较膨胀并且会使得本来简单的代码变得模糊。
异常淹没
我们都看到过这样的代码,其中捕获了一个异常,但是在 catch 块中没有代码。尽管这种编程实践很明显是不好的,但是很容易看出它是如何发生的 —— 在原型化期间,某人通过 try...catch 块包装代码,而后来忘记返回并填充 catch 块。尽管这个错误很常见,但是这也是更好的工具可以帮助我们的地方之一 —— 对于异常淹没的地方,通过编辑器、编译器或者静态检查工具可以容易地检测并发出警告。
极度通用的 try...catch 块是另一种形式的异常淹没,并且更加难以检测,因为这是 Java 类库中的异常类层次的结构而导致的(可疑)。让我们假定一个方法抛出四个不同类型的异常,并且调用者遇到其中任何一个异常都将捕获、记录它们,并且返回。实现该策略的一种方式是使用一个带有四个 catch 子句的 try...catch 块,其中每个异常类型一个。为了避免代码难以理解的问题,一些开发人员将重构该代码,如清单 1 所示:
清单 1. 意外地淹没 RuntimeException
try {
doSomething();
}
catch (Exception e) {
log(e);
}
|
尽管该代码与四个 catch 块相比更为紧凑,但是它具有一个问题 —— 它还捕获可能由 doSomething 抛出的任何 RuntimeException 并且阻止它们进行扩散。
过多的异常包装
如果异常是在一个底层的设施中生成的,并且通过许多代码层向上扩散,在最终被处理之前它可能被捕获、包装和重新抛出若干次。当异常最终被记录的时候,栈跟踪可能有许多页,因为栈跟踪可能被复制多次,其中每个包装层一次。(在 JDK 1.4 以及后来的版本中,异常链的实现在某种程度上缓解了该问题。)
替换的方法
Bruce Eckel, Thinking in Java(请参阅 参考资料)的作者,声称在使用 Java 语言多年后,他已经得出这样的结论,认为检查型异常是一个错误 —— 一个应该被声明为失败的试验。Eckel 提倡将所有的异常都作为非检查型的,并且提供清单 2 中的类作为将检查型异常转变为非检查型异常的一个方法,同时保留当异常从栈向上扩散时捕获特定类型的异常的能力(关于如何使用该方法的解释,请参阅他在 参考资料小节中的文章):
清单 2. Eckel 的异常适配器类
class ExceptionAdapter extends RuntimeException {
private final String stackTrace;
public Exception originalException;
public ExceptionAdapter(Exception e) {
super(e.toString());
originalException = e;
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
stackTrace = sw.toString();
}
public void printStackTrace() {
printStackTrace(System.err);
}
public void printStackTrace(java.io.PrintStream s) {
synchronized(s) {
s.print(getClass().getName() + ": ");
s.print(stackTrace);
}
}
public void printStackTrace(java.io.PrintWriter s) {
synchronized(s) {
s.print(getClass().getName() + ": ");
s.print(stackTrace);
}
}
public void rethrow() { throw originalException; }
}
|
如果查看 Eckel 的 Web 站点上的讨论,您将会发现回应者是严重分裂的。一些人认为他的提议是荒谬的;一些人认为这是一个重要的思想。(我的观点是,尽管恰当地使用异常确实是很难的,并且对异常用不好的例子大量存在,但是大多数赞同他的人是因为错误的原因才这样做的,这与一个政客位于一个可以随便获取巧克力的平台上参选将会获得十岁孩子的大量选票的情况具有相似之处。)
Rod Johnson 是 J2EE Design and Development(请参阅 参考资料) 的作者,这是我所读过的关于 Java 开发,J2EE 等方面的最好的书籍之一。他采取一个不太激进的方法。他列举了异常的多个类别,并且为每个类别确定一个策略。一些异常本质上是次要的返回代码(它通常指示违反业务规则),而一些异常则是“发生某种可怕错误”(例如数据库连接失败)的变种。Johnson 提倡对于第一种类别的异常(可选的返回代码)使用检查型异常,而对于后者使用运行时异常。在“发生某种可怕错误”的类别中,其动机是简单地认识到没有调用者能够有效地处理该异常,因此它也可能以各种方式沿着栈向上扩散而对于中间代码的影响保持最小(并且最小化异常淹没的可能性)。
Johnson 还列举了一个中间情形,对此他提出一个问题,“只是少数调用者希望处理问题吗?”对于这些情形,他也建议使用非检查型异常。作为该类别的一个例子,他列举了 JDO 异常 —— 大多数情况下,JDO 异常表示的情况是调用者不希望处理的,但是在某些情况下,捕获和处理特定类型的异常是有用的。他建议在这里使用非检查型异常,而不是让其余的使用 JDO 的类通过捕获和重新抛出这些异常的形式来弥补这个可能性。
使用非检查型异常
关于是否使用非检查型异常的决定是复杂的,并且很显然没有明显的答案。Sun 的建议是对于任何情况使用它们,而 C# 方法(也就是 Eckel 和其他人所赞同的)是对于任何情况都不使用它们。其他人说,“还存在一个中间情形。”
通过在 C++ 中使用异常,其中所有的异常都是非检查型的,我已经发现非检查型异常的最大风险之一就是它并没有按照检查型异常采用的方式那样自我文档化。除非 API 的创建者明确地文档化将要抛出的异常,否则调用者没有办法知道在他们的代码中将要捕获的异常是什么。不幸的是,我的经验是大多数 C++ API 的文档化非常差,并且即使文档化很好的 API 也缺乏关于从一个给定方法可能抛出的异常的足够信息。我看不出有任何理由可以说该问题对于 Java 类库不是同样的常见,因为 Jav 类库严重依赖于非检查型异常。依赖于您自己的或者您的合作伙伴的编程技巧是非常困难的;如果不得不依赖于某个人的文档化技巧,那么对于他的代码您可能得使用调用栈中的十六个帧来作为您的主要的错误处理机制,这将会是令人恐慌的。
文档化问题进一步强调为什么懒惰是导致选择使用非检查型异常的一个不好的原因,因为对于文档化增加给包的负担,使用非检查型异常应该比使用检查型异常甚至更高(当文档化您所抛出的非检查型异常比检查型异常变得更为重要的时候)。
文档化,文档化,文档化
如果决定使用非检查型异常,您需要彻底地文档化这个选择,包括在 Javadoc 中文档化一个方法可能抛出的所有非检查型异常。Johnson 建议在每个包的基础上选择检查型和非检查型异常。使用非检查型异常时还要记住,即使您并不捕获任何异常,也可能需要使用 try...finally 块,从而可以执行清除动作例如关闭数据库连接。对于检查型异常,我们有 try...catch 用来提示增加一个 finally 子句。对于非检查型异常,我们则没有这个支撑可以依靠。
为 EJB 组件定义事务划分和隔离属性(attribute)的职责由应用程序装配人员来承担。如果这些属性设置不当,会对应用程序的性能、可伸缩性或容错能力造成严重的后果。不幸的是,并没有一种必须遵守的规则用于正确设置这些属性,但有一些指导可以帮助我们在并发危险和性能危险之间找到一种平衡。
我们在第 1 部分中讨论过,事务主要是一种异常处理机制。事务在程序中的用途与合法合同在日常业务中的用途相似:如果出了什么问题它们可以帮助恢复。但由于大多数时间内都没实际 发生什么错误,我们就希望能够尽量减少它们的开销以及对其余时间的占用。我们在应用程序中如何使用事务会对应用程序的性能和可伸缩性产生很大的影响。
事务划分
J2EE 容器提供了两种机制用来定义事务的起点和终点:bean 管理的事务和容器管理的事务。在 bean 管理的事务中,用 UserTransaction.begin() 和 UserTransaction.commit() 在 bean 方法中显式开始和结束一个事务。另一方面,容器管理的事务提供了更多的灵活性。通过在装配描述符中为每个 EJB 方法定义事务性属性,您可以指定每个方法的事务性需求并让容器确定何时开始和结束一个事务。无论在哪种情况下,构建事务的基本指导方针都是一样的。
进来,出去
事务划分的第一条规则是“尽量短小”。事务提供并发控制;这通常意味着资源管理器将代表您获得您在事务期间访问的数据项的锁,并且它必须一直持有这些锁,直到事务结束。(请回忆一下本系列第 1 部分所讨论的 ACID特性,其中“ACID”的“I”代表“隔离”(Isolation)。也就是说,一个事务的结果影响不到与该事务并发执行的其它事务。)当您拥有锁时,任何需要访问您锁定的数据项的其它事务将不得不一直等待,直到您释放锁。如果您的事务很长,那些其它的所有事务都将被锁定,您的应用程序吞吐量将大幅度下降。
规则 1:使事务尽可能短小。
通过使事务尽量短小,您可以把阻碍其它事务的时间缩到最短,从而提高应用程序的可伸缩性。保持事务尽可能短小的最好方法当然是不在事务中间做任何不必要耗费时间的事,特别是不要在事务中间等待用户输入。
开始一个事务,从数据库检索一些数据,显示数据,然后在仍处于事务中时请用户做出一个选择可能比较诱人。千万别这么做!即使用户注意力集中,也要花费数秒来响应 ― 而在数据库中拥有锁数秒的时间已经是很长的了。如果用户决定离开计算机,或许是去吃午餐或者甚至回家一天,会发生什么情况?应用程序将只好无奈停机。在事务期间执行 I/O 是导致灾难的秘诀。
规则 2:在事务期间不要等待用户输入。
将相关的操作归在一起
由于每个事务都有不小的开销,您可能认为最好是在单个事务中执行尽可能多的操作以使每个操作的开销达到最小。但规则 1 告诉我们长事务对可伸缩性不利。那么如何实现最小化每个操作的开销和可伸缩性之间的平衡呢?
我们把规则 1 设置为逻辑上的极端 ― 每个事务一个操作 ― 这样不仅会导致额外开销,还会危及应用程序状态的一致性。假定事务性资源管理器维护应用程序状态的一致性(请回忆一下第 1 部分,其中“ACID”的“C”代表“一致性”(Consistency)),但它们依赖应用程序来定义一致性的意思。实际上,我们在描述事务时使用的一致性的定义有点圆滑:应用程序说一致性是什么意思它就是什么意思。应用程序把几组应用程序状态的变化组织到几个事务中,结果应用程序的状态就成了 定义上的(by definition)一致。然后资源管理器确保如果它必须从故障恢复的话,就把应用程序状态恢复到最近的一致状态。
在第 1 部分中,我们给出了一个在银行应用程序中将资金从一个帐户转移到另一个帐户的示例。清单 1 展示了这个示例可能的 SQL 实现,它包含 5 个 SQL 操作(一个选择,两个更新和两个插入操作):
清单 1. 资金转移的样本 SQL 代码
SELECT accountBalance INTO aBalance
FROM Accounts WHERE accountId=aId;
IF (aBalance >= transferAmount) THEN
UPDATE Accounts
SET accountBalance = accountBalance - transferAmount
WHERE accountId = aId;
UPDATE Accounts
SET accountBalance = accountBalance + transferAmount
WHERE accountId = bId;
INSERT INTO AccountJournal (accountId, amount)
VALUES (aId, -transferAmount);
INSERT INTO AccountJournal (accountId, amount)
VALUES (bId, transferAmount);
ELSE
FAIL "Insufficient funds in account";
END IF
|
如果我们把这个操作作为五个单独的事务来执行会发生什么情况?这样不仅会使执行速度变慢(由于事务开销),还会失去一致性。例如,如果一个人从帐户 A 取了钱,作为执行第一次 SELECT(检查余额)和随后的记入借方 UPDATE 之间的一个单独事务的一部分,会发生什么情况?这样会违反我们认为这段代码会强制遵守的业务规则 ― 帐户余额应该是非负的。如果在第一次 UPDATE 和第二次 UPDATE 之间系统失败会发生什么情况?现在,当系统恢复时,钱已经离开了帐户 A 但还没有记入帐户 B 的贷方,并且也无记录说明原因。这样,哪个帐户的所有者都不会开心。
清单 1 中的五个 SQL 操作是单个相关操作 ― 将资金从一个帐户转移到另一个帐户 ― 的一部分。因此,我们希望要么全部执行它们,要么一个也不执行,建议在单个事务中全部执行它们。
规则 3:将相关操作归到单个事务中。
理想化的平衡
规则 1 说事务应尽可能短小。清单 1 中的示例表明有时候我们必须把一些操作归到一个事务中来维护一致性。当然,它要依赖应用程序来确定“相关操作”是由什么组成的。我们可以把规则 1 和 3 结合在一起,提供一个描述事务范围的一般指导,我们规定它为规则 4:
规则 4:把相关操作归到单个事务中,但把不相关的操作放到单独的事务中。
容器管理的事务
在使用容器管理的事务时,不是显式声明事务的起点和终点,而是为每个 EJB 方法定义事务性需求。bean 的 assembly-descriptor 的 container-transaction 部分的 trans-attribute 元素中定义了事务模式。(清单 2 中显示了一个 assembly-descriptor 示例。)方法的事务模式以及状态 ― 调用方法是否早已在事务中被征用 ― 决定了当 EJB 方法被调用时容器应该进行下面几个操作中的哪一个:
- 征用现有事务中的方法。
- 创建一个新事务,并征用该事务中的方法。
- 不征用任何事务中的方法。
- 抛出一个异常。
清单 2. 样本 EJB 装配描述符
<assembly-descriptor>
...
<container-transaction>
<method>
<ejb-name>MyBean</ejb-name>
<method-name>*</method-name>
</method>
<trans-attribute>Required</trans-attribute>
</container-transaction>
<container-transaction>
<method>
<ejb-name>MyBean</ejb-name>
<method-name>logError</method-name>
</method>
<trans-attribute>RequiresNew</trans-attribute>
</container-transaction>
...
</assembly-descriptor>
|
J2EE 规范定义了六种事务模式: Required 、 RequiresNew 、 Mandatory 、 Supports 、 NotSupported 和 Never 。表 1 概述了每种模式的行为 ― 在现有事务中被调用和不在事务内调用时的行为 ― 并描述了每种模式受哪些类型的 EJB 组件支持。(一些容器可能允许您在选择事务模式时有更多的灵活性,但这种使用要依赖特定于容器的功能,因此不适合跨容器的情况)。
表 1. 事务模式
事务模式
|
Bean 类型
|
在事务 T 内被调用时的行为
|
在事务外被调用时的行为
|
Required
|
会话、实体、消息驱动 |
在 T 中征用 |
新建事务 |
RequiresNew
|
会话、实体 |
新建事务 |
新建事务 |
Supports
|
会话、消息驱动 |
在 T 中征用 |
不带事务运行 |
Mandatory
|
会话、实体 |
在 T 中征用 |
出错 |
NotSupported
|
会话、消息驱动 |
不带事务运行 |
不带事务运行 |
Never
|
会话、消息驱动 |
出错 |
不带事务运行 |
在只使用容器管理的事务的应用程序中,只有组件调用事务模式为 Required 或 RequiresNew 的 EJB 方法时才启动事务。如果容器创建一个事务作为调用事务性方法的结果,当该方法完成时将关闭该事务。如果方法正常返回,容器将提交事务(除非应用程序已经要求回滚事务)。如果方法通过抛出一个异常退出,容器将回滚事务并传播该异常。如果在现有事务 T 中调用了一个方法,并且事务模式指定应该不带事务运行该方法或者在新事务中运行该方法,那么事务 T 将被暂挂,一直到方法完成,然后先前的事务 T 被恢复。
选择一种事务模式
那么我们应该为自己的 bean 方法选择哪种模式呢?对于会话 bean 和消息驱动 bean,您通常想使用 Required 来确保每个调用都被作为事务的一部分执行,但仍将允许方法作为一个更大的事务的组件。请小心使用 RequiresNew ;只有在确定自己的方法的行为应该与调用您的方法的行为分开提交时,才应该使用这种模式。 RequiresNew 一般情况下只和与系统中其它对象关系很少或没什么关系的对象(比如日志对象)一起使用。(把 RequiresNew 与日志对象一起使用比较有意义,因为您可能希望在不管外围事务是否提交的情况下提交日志消息。)
RequiresNew
使用不当会导致与上面的描述相似的情况,其中,清单 1 中的代码在五个分开的事务而不是一个事务中执行,这样会使应用程序处于不一致状态。
对于 CMP(容器管理的持久性,container-managed persistence)实体 bean,通常是希望使用 Required 。 Mandatory 也是一个合理的选项,特别是在最初开发时;这将会警告您实体 bean 方法在事务外被调用这种情况,这时可能会指出一个部署错误。您几乎从不希望把 RequiresNew 和 CMP 实体 bean 一起使用。 NotSupported 和 Never 旨在用于非事务性资源,比如 Java 事务 API(Java Transaction API,JTA)事务中无法征用的外部非事务性系统或事务性系统的适配器。
如果 EJB 应用程序设计得当,应用上面的事务模式指导往往会自然地产生规则 4 建议的事务划分。原因是 J2EE 体系架构鼓励把应用程序分解为最小的方便处理的块,并且每个块都作为一个单独的请求被处理( 不管是以 HTTP 请求的形式还是作为在 JMS 队列中排队的消息的结果)。
重温隔离
在第 1 部分中,我们定义了 隔离(isolation)的意思是:一个事务的影响对与该事务并发执行的其它事务是不可见的;从事务的角度来看,好象事务是连续执行而非并行执行。尽管事务性资源管理器经常可以同时处理许多事务并提供隔离的假象,但有时隔离限制实际上要求把新事务延迟到现有事务完成后才开始。由于完成一个事务至少包括一个同步磁盘 I/O(写到事务日志),这就会把每秒的事务数限制到接近每秒的写磁盘次数,这对可伸缩性不利。
实际上,通常是充分放松隔离需求以允许更多的事务并发执行并使系统响应能够得到改善,使可伸缩性变得更强。几乎所有的数据库都支持标准隔离级别:读未提交的(Read Uncommitted)、读已提交的(Read Committed)、可重复的读(Repeatable Read) 和可串行化的(Serializable)。
不幸的是,为容器管理的事务管理隔离目前是在 J2EE 规范的范围之外。但是,许多 J2EE 容器,比如 IBM WebSphere 和 BEA WebLogic,将提供特定于容器的扩展,这些扩展允许您以每方法(per-method)为基础设置事务隔离级别,设置方法与在装配描述符中设置事务模式的方法相同。对于 bean 管理的事务,您可以通过 JDBC 或者其它资源管理器连接设置隔离级别。
为阐明隔离级别之间的差异,我们首先把几个并发危险分类 ― 这几种危险是当没有适当地隔离时一个事务可能会干涉另一个事务的情况。下列的所有这些危险都与这种情况( 第二个事务已经启动后第一个事务变得对第二个事务 可见)的结果有关:
-
脏读(Dirty Read):当一个事务的中间(未提交的)结果对另一个事务可见时就会发生这种情况。
-
不可重复的读(Unrepeatable Read):当一个事务读取一个数据项,然后重新读取这个数据项并看到不同的值时就是发生了这种情况。
-
虚读(Phantom Read):当一个事务执行返回多个行的查询,稍后再次执行同一个查询并看到第一次执行该查询没出现的额外行时就是发生了这种情况。
四个标准隔离级别与这三个隔离危险相关,如表 2 所示。最低的隔离级别“读未提交的”并不能保护事务不被其它事务更改,但它的速度最快,因为它不需要争夺读锁。最高的隔离级别“可串行化的”与上面给出的隔离的定义相当;每个事务好象都与其它事务的影响完全隔离。
表 2. 事务隔离级别
隔离级别
|
脏读
|
不可重复的读
|
虚读
|
读未提交的 |
是 |
是 |
是 |
读已提交的 |
否 |
是 |
是 |
可重复的读 |
否 |
否 |
是 |
可串行化的 |
否 |
否 |
否 |
对于大多数数据库,缺省的隔离级别为“读已提交的”,这是个很好的缺省选择,因为它阻止事务在事务中的任何给定的点看到应用程序数据的不一致视图。“读已提交的”是一个很不错的隔离级别,用于大多数典型的短事务,比如获取报表数据或获取要显示给用户的数据的时候(多半是作为 Web 请求的结果),也用于将新数据插入到数据库的情况。
当您需要所有事务间有较高级别的一致性时,使用较高的隔离级别“可重复的读”和“可串行化的”比较合适,比如在清单 1 示例中,您希望从检查余额以确保有足够的资金到您实际取钱期间账户余额一直保持不变;这就要求至少要用“可重复的读”隔离级别。在数据一致性绝对重要的情况下,比如审核记帐数据库以确保一个帐户的所有借方金额和贷方金额的总数等于它目前的余额时,可能还需要防止创建新行。这种情况下就需要使用“可串行化的”隔离级别。
最低的隔离级别“读未提交的”很少使用。它适用于您只需要获得近似值,否则查询将导致您不希望的性能开销这种情况。当您想要估计一个变化很快的数量,如定单数或者今天所下定单的总金额(以美元为单位)时一般使用““读未提交的”。
因为隔离和可伸缩性之间实际是一种此消彼长的关系,所以您在为事务选择隔离级别时应该小心行事。选择太低的级别对数据比较危险。选择太高的级别可能对性能不利,尽管负载比较轻时可能不会这样。一般来说,数据一致性问题比性能问题更严重。如果拿不准,应该以小心为主,选择一个较高的隔离级别。这就引出了规则 5:
规则 5:使用保证数据安全的最低隔离级别,但如果拿不准,请使用“可串行化的”。
即使您打算刚开始时以小心为主并希望结果性能可以接受 ―(被称为“拒绝和祈祷(denial and prayer)”的性能管理技术 ― 很可能是最常用的性能策略,尽管大多数开发者都不承认这一点),在开发组件时考虑隔离需求也是有利的。您应该努力编写能够容忍级别较低但实用的隔离级别的事务,这样,当稍后性能成为问题时,自己就不会陷入困境。因为您需要知道方法正在做什么以及这个方法中隐藏了什么一致性假设来正确设置隔离级别,那么在开发期间仔细说明并发需求和假设,以便在装配应用程序时帮助作出正确的决定也不失为一个好主意。
结束语
本文中提供的许多指导可能看起来有点互相矛盾,因为象事务划分和隔离这种问题本来就是此消彼长的。我们正在努力平衡安全性(如果我们不关心安全性,那就压根不必用事务了)和我们用来提供安全限度的工具的性能开销。正确的平衡要依赖许多因素,包括与系统故障或当机时间相关的代价或损害以及组织的风险承受能力。
手里有锤子的时候,看什么东西都像钉子(就像古谚语所说的那样)。但是如果没有锤子时该怎样办呢?有时,您可以去借一把锤子。然后,拿着这把借来的锤子敲打虚拟的钉子,最后归还锤子,没人知道这些。在本月的 Java 理论与实践 系列中,Brian Goetz 将演示如何将 SQL 或者 XQuery 这样的数据操纵之锤应用于非持久存储的数据。请在本文附带的 讨论论坛 中与作者和其他读者分享您对本文的看法。(也可以单击本文顶部或底部的 讨论 来访问该论坛。)
我最近仔细考察了一个项目,该项目涉及相当多的 Web 快速搜索。当爬虫程序爬过不同的 Web 站点时,它将建立一个数据库,该数据库中包括它所爬过的站点和网页、每一页所包含的链接、每一页的分析结果等数据。最终结果是一组报告,详细说明经过了哪些站点和页面、哪些是一直链接的、哪些链接已经断开、哪些页面有错误、计算出的页面规格,等等。开始的时候,没人确切知道需要什么样的报告,或者应当采用什么样的格式 —— 只知道有一些内容要报告。这表明报告开发阶段会是一个反复的阶段,要经过多次反馈、修改,并且可能尝试使用不同的结构。惟一确定的报告要求是,报告应当以 XML 形式展示,也可能以 HTML 形式展示。因此,开发和修改报告的过程必须是轻量级的,因为报告要求是“动态发现”的,而不是预先指定的。
不需要数据库
对这个问题的“最显而易见的”解决方法是将所有东西都放入 SQL 数据库中 —— 页面、链接、度量标准、HTTP 结果代码、计时结果和其他元数据。这个问题可以借助关系表示来很好地解决,特别是因为这种方法不需要存储已访问页面的内容,只需要存储它们的结构和元数据。
到目前为止,这个项目看起来像是一个典型的数据库应用程序,并且它并不缺少可供选择的持久性策略。但是,或许可以避免使用数据库持久存储数据的复杂性 —— 这个快速搜索工具(crawler)只访问数万个页面。这个数字不是很大,因此可以将整个数据库放在内存中,当需要持久存储数据时,可以通过序列化来实现它。(是的,加载和保存操作要花费较长的时间,但是这些操作并不经常执行。)懒惰反而带来了一个好处 —— 不需要处理持久性极大地缩短了开发应用程序的时间,因而显著地减少了开发工作量。构建和操纵内存中的数据结构要比每次添加、提取或者分析数据时都使用数据库容易得多。不管选择了哪种持久存储模型,都会限制任何触及到数据的代码的构造。
内存中的数据结构是一种树型结构,如清单 1 所示,它的根是快速搜索过的各个网站的主页,因此 Visitor 模式是搜索这些主页或者从中提取数据的理想模式。(构建一个防止陷入链接循环 —— A 链接到 B、B 链接到 C、C 链接到 A —— 的基本 Visitor 类并不是很难。)
清单 1. Web 爬行器的一个简化方案
public class Site {
Page homepage;
Collection<Page> pages;
Collection<Link> links;
}
public class Page {
String url;
Site site;
PageMetrics metrics;
}
public class Link {
Page linkFrom;
Page linkTo;
String anchorText;
}
|
这个快速搜索工具的应用程序中有十多个 Visitor,它们所做的事情类似于选择页面做进一步分析、选择不带链接的页面、列出“被链接最多”的页面,等等。因为所有这些操作都很简单,所以 Visitor 模式(如清单 2 所示)可以工作得很好,由于数据结构可以放到内存中,因此就算进行彻底搜索,花费也不是很大:
清单 2. 用于 Web 快速搜索工具数据库的 Visitor 模式
public interface Visitor {
public void visitSite(Site site);
public void visitLink(Link link);
}
|
噢,忘记报告了
如果不运行报告的话,Visitor 策略在访问数据方面会做得非常好。使用数据库进行持久存储的一个好处是:在生成报告时,SQL 的能力就会大放光彩 —— 几乎可以让数据库做任何事情。甚至用 SQL 生成报告原型也很容易 —— 运行原型报告,如果结果不是所需要的结果,那么可以修改 SQL 查询或者编写新的查询,然后再试一试。如果改变的只是 SQL 查询的话,那么这个编辑-编译-运行周期可能很快。如果 SQL 不是存储在程序中,那么您甚至可以跳过这个周期的编译部分,这样可以快速生成报告的原型。确定所需要的报告后,将它们构建到应用程序中就很容易了。
因此,虽然对于添加新结果、寻找特定的结果和进行特殊传输来说,内存中的数据结构都表现得很不错,但是对于报告来说,这些变成了不利条件。对于所有其自身结构与数据库结构不同的报告,Visitor 都必须创建一个全新的数据结构,以包含报告数据。因此,每一种报告类型都需要有自己的、特定于报告的中间数据结构来存放结果,还需要一个用来填充中间数据结构的访问者,以及用来将中间数据结构转换成最终报告的后处理(post-processing)代码。似乎需要做很多工作,尤其在大多数原型报告将被抛弃时。例如,假定您想要列出所有从其他网站链接到某个给定网站的页面的报告、所有外部页面的列表报告,以及站点上链接该页面的那些页面的列表,然后,根据链接的数量对报告进行归类,链接最多的页面显示在最前面。这个计划基本上将数据结构从里到外翻了个个儿。为了用 Visitor 实现这种数据转换,需要获得从某个给定网站可以到达的外部页面链接的列表,并根据被链接的页面对它们进行分类,如清单 3 所示:
清单 3. Visitor 列出被链接最多的页面,以及链接到它们的页面
public class InvertLinksVisitor {
public Map<Page, Set<Page>> map = ...;
public void visitLink(Link link) {
if (link.linkFrom.site.equals(targetSite)
&& !link.linkTo.site.equals(targetSite)) {
if (!map.containsKey(link.linkTo))
map.put(link.linkTo, new HashSet<Page>());
map.get(link.linkTo).add(link.linkFrom);
}
}
}
|
清单 3 中的 Visitor 生成一个映射,将每一个外部页面与链接它的一组内部页面相关联。为了准备该报告,还必须根据关联页面的大小对这些条目进行分类,然后创建报告。虽然没有任何困难步骤,但是每一个报告需要的特定于报告的代码数量却很多,因此快速报告原型就成为一个重要的目标(因为没有提出报告要求),试验新报告的开销比理想情况更高。许多报告需要多次传递数据,以便对数据进行选择、汇总和分类。
我的数据模型王国
这时,缺少一个正式的数据模型开始成为一项不利因素,该数据模型可以用于描述收集的数据,并且可以用它更容易地表示选择和聚合查询。也许懒惰不像开始希望的那样有效。但是,虽然这个应用程序缺少正式数据模型,但也许我们可以将数据存储到内存中的数据库,并凭借该数据库进行查询,通过这种方式借用一个数据模型。有两种可能会立即出现在您的脑海中:开源的内存中的 SQL 数据库 HSQLDB 和 XQuery。我不需要数据库提供的持久性,但是我确实需要查询语言。
HSQLDB 是一个用 Java 语言编写的可嵌入的数据库引擎。它既包含适用于内存中表的表类型,又包含适用于基于磁盘的表的表类型,设计该引擎为了将表完全嵌入到应用程序中,消除与大多数真实数据库相关的管理开销。要将数据装载到 HSQLDB,只需编写一个 Visitor 即可,该 Visitor 将遍历内存中的数据结构,并为每一个将要存储的实体生成相应的 INSERT 语句。然后可以对这个内存中的数据库表执行 SQL 查询,以生成报告,并在完成这些操作后抛弃这个“数据库”。
噢,忘记了关系数据库有多烦人
HSQLDB 方法是一个可行方法,但您很快就发现,我必须为对象关系的不匹配而两次(而不是一次)受罚 —— 一次是在将树型结构数据库转换为关系数据模型时,一次是在将平面关系查询结果转换成结构化的 XML 或者 HTML 结果集时。此外,将 JDBC ResultSet 后处理为 DOM 表示形式的 XML 或者 HTML 文档也不是一项很容易的任务,需要为每一个报告提供一些定制的编码。因此虽然内存中的 SQL 数据库 的确 可以简化查询,但是从数据库中存入和取出数据所需要的额外代码会抵消所有节省的代码。
让 XQuery 来拯救您
另一个容易得到的数据查询方法是 XQuery。XQuery 的优点是,它是为生成 XML 或者 HTML 文档作为查询结果而设计的,因此不需要对查询结果进行后处理。这种想法很有吸引力 —— 每个报告只有一层编码,而不是两层或者更多层。因此第一项任务是构建一个表示整个数据集的 XML 文档。设计一个简单的 XML 数据模型和编写遍历数据结构,并将每一个元素附加到一个 DOM 文档中的 Visitor 很简单。(不需要写出这个文档。可以将它保持在内存中,用于查询,然后在完成查询时丢弃它。当底层数据改变时,可以重新生成它。)之后,所有要做的就是编写 XQuery 查询,该查询将选择并聚集用于报告的数据,并按最终需要的格式(XML 或 HTML)对它们进行格式化。查询可以存储在单独的文件中,以便进行快速原型制造,因此,可支持多种报告格式。使用 Saxon 评估查询的代码如清单 4 中所示:
清单 4. 执行 XQuery 查询并将结果序列化为 XML 或 HTML 文档的代码
String query = readFile(queryFile + ".xq");
Configuration c = new Configuration();
StaticQueryContext qp = new StaticQueryContext(c);
XQueryExpression xe = qp.compileQuery(query);
DynamicQueryContext dqc = new DynamicQueryContext(c);
dqc.setContextNode(new DocumentWrapper(document, z.getName(), c));
List result = xe.evaluate(dqc);
FileOutputStream os = new FileOutputStream(fileName);
XMLSerializer serializer = new XMLSerializer (os, format);
serializer.asDOMSerializer();
for(Iterator i = result.iterator(); i.hasNext(); ) {
Object o = i.next();
if (o instanceof Element)
serializer.serialize((Element) o);
else if (o instanceof Attr) {
Element e = document.createElement("scalar");
e.setTextContent(((Attr) o).getNodeValue());
serializer.serialize(e);
}
else {
Element e = document.createElement("scalar");
e.setTextContent(o.toString());
serializer.serialize(e);
}
}
os.close();
|
表示数据库的 XML 文档的结构与内存中的数据结构稍有不同,每一个 <site> 元素都有嵌套的 <page> 元素,每一个 <page> 元素都有嵌套的 <link> 元素,而每一个 <link> 元素都有 <link-to> 和 <link-from> 元素。实践证明,这种表示方法对于大多数报告都很方便。
清单 5 显示了一个示例 XQuery 报告,这个报告处理链接的选择、分类和表示。它有几个地方优于 Visitor 方法 —— 不仅代码少(因为查询语言支持选择、聚积和分类),而且所有报告的代码 —— 选择、聚积、分类和表示 —— 都在一个位置上。
清单 5.生成链接次数最多的页面的完整报告的 XQuery 代码
<html>
<head><title>被链接最多的页面</title></head>
<body>
<ul>
{
let $links := //link[link-to/@siteUrl ne $targetSite
and link-from/@siteUrl eq $targetSite]
for $page in distinct-values($links/link-to/@url)
let $linkingPages := $links[link-to/@url eq $page]/link-from/@url
order by count($linkingPages)
return
<li>Page {$page}, {count($linkingPages)} links
<ul> {
for $p in $linkingPages return <li>Linked from {$p/@url}</li>
}
</ul></li>
}
</ul> </body> </html>
|
结束语
从开发成本角度看,XQuery 方法已证实可以节约大量成本。树型结构对于构建和搜索数据很理想,但对于报告,就不是很理想了。XML 方法很适合于报告(因为可以利用 XQuery 的能力),但是对于整个应用程序的实现,该方法还有很多不便,并会降低性能。因为数据集的大小是可管理的 —— 只有几十兆字节,所以可以将数据从一种格式转换为从开发的角度看最方便的另一种格式。更大的数据集,比如不能完全存储到内存中的数据集,会要求整个应用程序都围绕着一个数据库构建。虽然有许多处理数据持久性的好工具,但是它们需要的工作都比简单操纵内存中数据结构要多得多。如果数据集的大小合适,那么就可以同时利用这两种方法的长处。
为动态编译的语言(例如 Java)编写和解释性能评测,要比为静态编译的语言(例如 C 或 C++)编写困难得多。在这期的 Java 理论与实践 中,Brian Goetz 介绍了动态编译使性能测试复杂的诸多原因中的一些。请在本文附带的讨论组上与作者和其他读者分享您对本文的看法。 (您也可以选择本文顶部或底部的 讨论 访问论坛。)
这个月,我着手撰写一篇文章,分析一个写得很糟糕的微评测。毕竟,我们的程序员一直受性能困扰,我们也都想了解我们编写、使用或批评的代码的性能特征。当我偶然间写到性能这个主题时,我经常得到这样的电子邮件:“我写的这个程序显示,动态 frosternation 要比静态 blestification 快,与您上一篇的观点相反!”许多随这类电子邮件而来的所谓“评测“程序,或者它们运行的方式,明显表现出他们对于 JVM 执行字节码的实际方式缺乏基本认识。所以,在我着手撰写这样一篇文章(将在未来的专栏中发表)之前,我们先来看看 JVM 幕后的东西。理解动态编译和优化,是理解如何区分微评测好坏的关键(不幸的是,好的微评测很少)。
动态编译简史
Java 应用程序的编译过程与静态编译语言(例如 C 或 C++)不同。静态编译器直接把源代码转换成可以直接在目标平台上执行的机器代码,不同的硬件平台要求不同的编译器。 Java 编译器把 Java 源代码转换成可移植的 JVM 字节码,所谓字节码指的是 JVM 的“虚拟机器指令”。与静态编译器不同,javac 几乎不做什么优化 —— 在静态编译语言中应当由编译器进行的优化工作,在 Java 中是在程序执行的时候,由运行时执行。
第一代 JVM 完全是解释的。JVM 解释字节码,而不是把字节码编译成机器码并直接执行机器码。当然,这种技术不会提供最好的性能,因为系统在执行解释器上花费的时间,比在需要运行的程序上花费的时间还要多。
即时编译
对于证实概念的实现来说,解释是合适的,但是早期的 JVM 由于太慢,迅速获得了一个坏名声。下一代 JVM 使用即时 (JIT) 编译器来提高执行速度。按照严格的定义,基于 JIT 的虚拟机在执行之前,把所有字节码转换成机器码,但是以惰性方式来做这项工作:JIT 只有在确定某个代码路径将要执行的时候,才编译这个代码路径(因此有了名称“ 即时 编译”)。这个技术使程序能启动得更快,因为在开始执行之前,不需要冗长的编译阶段。
JIT 技术看起来很有前途,但是它有一些不足。JIT 消除了解释的负担(以额外的启动成本为代价),但是由于若干原因,代码的优化等级仍然是一般般。为了避免 Java 应用程序严重的启动延迟,JIT 编译器必须非常迅速,这意味着它无法把大量时间花在优化上。所以,早期的 JIT 编译器在进行内联假设(inlining assumption)方面比较保守,因为它们不知道后面可能要装入哪个类。
虽然从技术上讲,基于 JIT 的虚拟机在执行字节码之前,要先编译字节码,但是 JIT 这个术语通常被用来表示任何把字节码转换成机器码的动态编译过程 —— 即使那些能够解释字节码的过程也算。
HotSpot 动态编译
HotSpot 执行过程组合了编译、性能分析以及动态编译。它没有把所有要执行的字节码转换成机器码,而是先以解释器的方式运行,只编译“热门”代码 —— 执行得最频繁的代码。当 HotSpot 执行时,会搜集性能分析数据,用来决定哪个代码段执行得足够频繁,值得编译。只编译执行最频繁的代码有几项性能优势:没有把时间浪费在编译那些不经常执行的代码上;这样,编译器就可以花更多时间来优化热门代码路径,因为它知道在这上面花的时间物有所值。而且,通过延迟编译,编译器可以访问性能分析数据,并用这些数据来改进优化决策,例如是否需要内联某个方法调用。
为了让事情变得更复杂,HotSpot 提供了两个编译器:客户机编译器和服务器编译器。默认采用客户机编译器;在启动 JVM 时,您可以指定 -server 开关,选择服务器编译器。服务器编译器针对最大峰值操作速度进行了优化,适用于需要长期运行的服务器应用程序。客户机编译器的优化目标,是减少应用程序的启动时间和内存消耗,优化的复杂程度远远低于服务器编译器,因此需要的编译时间也更少。
HotSpot 服务器编译器能够执行各种样的类。它能够执行许多静态编译器中常见的标准优化,例如代码提升( hoisting)、公共的子表达式清除、循环展开(unrolling)、范围检测清除、死代码清除、数据流分析,还有各种在静态编译语言中不实用的优化技术,例如虚方法调用的聚合内联。
持续重新编译
HotSpot 技术另一个有趣的方面是:编译不是一个全有或者全无(all-or-nothing)的命题。在解释代码路径一定次数之后,会把它重新编译成机器码。但是 JVM 会继续进行性能分析,而且如果认为代码路径特别热门,或者未来的性能分析数据认为存在额外的优化可能,那么还有可能用更高一级的优化重新编译代码。JVM 在一个应用程序的执行过程中,可能会把相同的字节码重新编译许多次。为了深入了解编译器做了什么,请用 -XX:+PrintCompilation 标志调用 JVM,这个标志会使编译器(客户机或服务器)每次运行的时候打印一条短消息。
栈上(On-stack)替换
HotSpot 开始的版本编译的时候每次编译一个方法。如果某个方法的累计执行次数超过指定的循环迭代次数(在 HotSpot 的第一版中,是 10,000 次),那么这个方法就被当作热门方法,计算的方式是:为每个方法关联一个计数器,每次执行一个后向分支时,就会递增计数器一次。但是,在方法编译之后,方法调用并没有切换到编译的版本,需要退出并重新进入方法,后续调用才会使用编译的版本。结果就是,在某些情况下,可能永远不会用到编译的版本,例如对于计算密集型程序,在这类程序中所有的计算都是在方法的一次调用中完成的。重量级方法可能被编译,但是编译的代码永远用不到。
HotSpot 最近的版本采用了称为 栈上(on-stack)替换 (OSR) 的技术,支持在循环过程中间,从解释执行切换到编译的代码(或者从编译代码的一个版本切换到另一个版本)。
那么,这与评测有什么关系?
我向您许诺了一篇关于评测和性能测量的文章,但是迄今为止,您得到的只是历史的教训和 Sun 的 HotSpot 白皮书的老调重谈。绕这么大的圈子的原因是,如果不理解动态编译的过程,就不可能正确地编写或解释 Java 类的性能测试。(即使深入理解动态编译和 JVM 优化,也仍然是非常困难的。)
为 Java 代码编写微评测远比为 C 代码编写难得多
判断方法 A 是否比方法 B 更快的传统方法,是编写小的评测程序,通常叫做 微评测。这个趋势非常有意义。科学的方法不能缺少独立的调查。魔鬼总在细节之中。为动态编译的语言编写并解释评测,远比为静态编译的语言难得多。为了了解某个结构的性能,编写一个使用该结构的程序一点也没有错,但是在许多情况下,用 Java 编写的微评测告诉您的,往往与您所认为的不一样。
使用 C 程序时,您甚至不用运行它,就能了解许多程序可能的性能特征。只要看看编译出的机器码就可以了。编译器生成的指令就是将要执行的机器码,一般情况下,可以很合理地理解它们的时间特征。(有许多有毛病的例子,因为总是遗漏分支预测或缓存,所以性能差的程度远远超过查看机器码所能够想像的程度,但是大多数情况下,您都可以通过查看机器码了解 C 程序的性能的很多方面。)
如果编译器认为某段代码不恰当,准备把它优化掉(通常的情况是,评测到它实际上不做任何事情),那么您在生成的机器码中可以看到这个优化 —— 代码不在那儿了。通常,对于 C 代码,您不必执行很长时间,就可以对它的性能做出合理的推断。
而在另一方面,HotSpot JIT 在程序运行时会持续地把 Java 字节码重新编译成机器码,而重新编译触发的次数无法预期,触发重新编译的依据是性能分析数据积累到一定数量、装入新类,或者执行到的代码路径的类已经装入,但是还没有执行过。持续的重新编译情况下的时间测量会非常混乱、让人误解,而且要想获得有用的性能数据,通常必须让 Java 代码运行相当长的时间(我曾经看到过一些怪事,在程序启动运行之后要加速几个小时甚至数天),才能获得有用的性能数据。
清除死代码
编写好评测的一个挑战就是,优化编译器要擅长找出死代码 —— 对于程序执行的输出没有作用的代码。但是评测程序一般不产生任何输出,这就意味着有一些,或者全部代码都有可能被优化掉,而毫无知觉,这时您实际测量的执行要少于您设想的数量。具体来说,许多微评测在用 -server 方式运行时,要比用 -client 方式运行时好得多,这不是因为服务器编译器更快(虽然服务器编译器一般更快),而是因为服务器编译器更擅长优化掉死代码。不幸的是,能够让您的评测工作非常短(可能会把评测完全优化掉)的死代码优化,在处理实际做些工作的代码时,做得就不会那么好了。
奇怪的结果
清单 1 的评测包含一个什么也不做的代码块,它是从一个测试并发线程性能的评测中摘出来的,但是它实际测量的根本不是要评测的东西。(这个示例是从 JavaOne 2003 的演示 “The Black Art of Benchmarking” 中借用的。请参阅 参考资料。)
清单 1. 被意料之外的死代码弄乱的评测
public class StupidThreadTest {
public static void doSomeStuff() {
double uselessSum = 0;
for (int i=0; i<1000; i++) {
for (int j=0;j<1000; j++) {
uselessSum += (double) i + (double) j;
}
}
}
public static void main(String[] args) throws InterruptedException {
doSomeStuff();
int nThreads = Integer.parseInt(args[0]);
Thread[] threads = new Thread[nThreads];
for (int i=0; i<nThreads; i++)
threads[i] = new Thread(new Runnable() {
public void run() { doSomeStuff(); }
});
long start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++)
threads[i].start();
for (int i = 0; i < threads.length; i++)
threads[i].join();
long end = System.currentTimeMillis();
System.out.println("Time: " + (end-start) + "ms");
}
}
|
表面上看, doSomeStuff() 方法可以给线程分点事做,所以我们能够从 StupidThreadBenchmark 的运行时间推导出多线程调度开支的一些情况。但是,因为 uselessSum 从没被用过,所以编译器能够判断出 doSomeStuff 中的全部代码是死的,然后把它们全部优化掉。一旦循环中的代码消失,循环也就消失了,只留下一个空空如也的 doSomeStuff 。表 1 显示了使用客户机和服务器方式执行 StupidThreadBenchmark 的性能。两个 JVM 运行大量线程的时候,都表现出差不多是线性的运行时间,这个结果很容易被误解为服务器 JVM 比客户机 JVM 快 40 倍。而实际上,是服务器编译器做了更多优化,发现整个 doSomeStuff 是死代码。虽然确实有许多程序在服务器 JVM 上会提速,但是您在这里看到的提速仅仅代表一个写得糟糕的评测,而不能成为服务器 JVM 性能的证明。但是如果您没有细看,就很容易会把两者混淆。
表 1. 在客户机和服务器 JVM 中 StupidThreadBenchmark 的性能
线程数量 |
客户机 JVM 运行时间 |
服务器 JVM 运行时间 |
10 |
43 |
2 |
100 |
435 |
10 |
1000 |
4142 |
80 |
10000 |
42402 |
1060 |
对于评测静态编译语言来说,处理过于积极的死代码清除也是一个问题。但是,在静态编译语言中,能够更容易地发现编译器清除了大块评测。您可以查看生成的机器码,查看是否漏了某块程序。而对于动态编译语言,这些信息不太容易访问得到。
预热
如果您想测量 X 的性能,一般情况下您是想测量它编译后的性能,而不是它的解释性能(您想知道 X 在赛场上能跑多快)。要做到这样,需要“预热” JVM —— 即让目标操作执行足够的时间,这样编译器在为执行计时之前,就有足够的运行解释的代码,并用编译的代码替换解释代码。
使用早期 JIT 和没有栈上替换的动态编译器,有一个容易的公式可以测量方法编译后的性能:运行多次调用,启动计时器,然后执行若干次方法。如果预热调用超过方法被编译的阈值,那么实际计时的调用就有可能全部是编译代码执行的时间,所有的编译开支应当在开始计时之前发生。
而使用今天的动态编译器,事情更困难。编译器运行的次数很难预测,JVM 按照自己的想法从解释代码切换到编译代码,而且在运行期间,相同的代码路径可能编译、重新编译不止一次。如果您不处理这些事件的计时问题,那么它们会严重歪曲您的计时结果。
图 1 显示了由于预计不到的动态编译而造成的可能的计时歪曲。假设您正在通过循环计时 200,000 次迭代,编译代码比解释代码快 10 倍。如果编译只在 200,000 次迭代时才发生,那么您测量的只是解释代码的性能(时间线(a))。如果编译在 100,000 次迭代时发生,那么您总共的运行时间是运行 200,000 次解释迭代的时间,加上编译时间(编译时间非您所愿),加上执行 100,000 次编译迭代的时间(时间线(b))。如果编译在 20,000 次迭代时发生,那么总时间会是 20,000 次解释迭代,加上编译时间,再加上 180,000 次编译迭代(时间线(c))。因为您不知道编译器什么时候执行,也不知道要执行多长时间,所以您可以看到,您的测量可能受到严重的歪曲。根据编译时间和编译代码比解释代码快的程度,即使对迭代数量只做很小的变化,也可能造成测量的“性能”有极大差异。
图 1. 因为动态编译计时造成的性能测量歪曲
那么,到底多少预热才足够呢?您不知道。您能做到的最好的,就是用 -XX:+PrintCompilation 开关来运行评测,观察什么造成编译器工作,然后改变评测程序的结构,以确保编译在您启动计时之前发生,在计时循环过程中不会再发生编译。
不要忘记垃圾收集
那么,您已经看到,如果您想得到正确的计时结果,就必须要让被测代码比您想像的多运行几次,以便让 JVM 预热。另一方面,如果测试代码要进行对象分配工作(差不多所有的代码都要这样),那么垃圾收集器也肯定会运行。这是会严重歪曲计时结果的另一个因素 —— 即使对迭代数量只做很小的变化,也意味着没有垃圾收集和有垃圾收集之间的区别,就会偏离“每迭代时间”的测量。
如果用 -verbose:gc 开关运行评测,您可以看到在垃圾收集上耗费了多少时间,并相应地调整您的计时数据。更好一些的话,您可以长时间运行您的程序,这可以保证触发许多垃圾收集,从而更精确地分摊垃圾收集的成本。
动态反优化(deoptimization)
许多标准的优化只能在“基本块”内执行,所以内联方法调用对于达到好的优化通常很重要。通过内联方法调用,不仅方法调用的开支被清除,而且给优化器提供了更大的优化块可以优化,会带来相当大的死代码优化机会。
清单 2 显示了一个通过内联实现的这类优化的示例。 outer() 方法用参数 null 调用 inner() ,结果是 inner() 什么也不做。但是通过把 inner() 的调用内联,编译器可以发现 inner() 的 else 分支是死的,因此能够把测试和 else 分支优化掉,在某种程度上,它甚至能把整个对 inner() 的调用全优化掉。如果 inner() 没有被内联,那么这个优化是不可能发生的。
清单 2. 内联如何带来更好的死代码优化
public class Inline {
public final void inner(String s) {
if (s == null)
return;
else {
// do something really complicated
}
}
public void outer() {
String s=null;
inner(s);
}
}
|
但是不方便的是,虚方法对内联造成了障碍,而虚函数调用在 Java 中要比在 C++ 中普遍。假设编译器正试图优化以下代码中对 doSomething() 的调用:
Foo foo = getFoo();
foo.doSomething();
|
从这个代码片断中,编译器没有必要分清要执行哪个版本的 doSomething() —— 是在类 Foo 中实现的版本,还是在 Foo 的子类中实现的版本?只在少数情况下答案才明显 —— 例如 Foo 是 final 的,或者 doSomething() 在 Foo 中被定义为 final 方法 —— 但是在多数情况下,编译器不得不猜测。对于每次只编译一个类的静态编译器,我们很幸运。但是动态编译器可以使用全局信息进行更好的决策。假设有一个还没有装入的类,它扩展了应用程序中的 Foo 。现在的情景更像是 doSomething() 是 Foo 中的 final 方法 —— 编译器可以把虚方法调用转换成一个直接分配(已经是个改进了),而且,还可以内联 doSomething() 。(把虚方法调用转换成直接方法调用,叫做 单形(monomorphic)调用变换。)
请稍等 —— 类可以动态装入。如果编译器进行了这样的优化,然后装入了一个扩展了 Foo 的类,会发生什么?更糟的是,如果这是在工厂方法 getFoo() 内进行的会怎么样? getFoo() 会返回新的 Foo 子类的实例?那么,生成的代码不就无效了么?对,是无效了。但是 JVM 能指出这个错误,并根据目前无效的假设,取消生成的代码,并恢复解释(或者重新编译不正确的代码路径)。
结果就是,编译器要进行主动的内联决策,才能得到更高的性能,然后当这些决策依据的假设不再有效时,就会收回这些决策。实际上,这个优化如此有效,以致于给那些不被覆盖的方法添加 final 关键字(一种性能技巧,在以前的文章中建议过)对于提高实际性能没有太大作用。
奇怪的结果
清单 3 中包含一个代码模式,其中组合了不恰当的预热、单形调用变换以及反优化,因此生成的结果毫无意义,而且容易被误解:
清单 3. 测试程序的结果被单形调用变换和后续的反优化歪曲
public class StupidMathTest {
public interface Operator {
public double operate(double d);
}
public static class SimpleAdder implements Operator {
public double operate(double d) {
return d + 1.0;
}
}
public static class DoubleAdder implements Operator {
public double operate(double d) {
return d + 0.5 + 0.5;
}
}
public static class RoundaboutAdder implements Operator {
public double operate(double d) {
return d + 2.0 - 1.0;
}
}
public static void runABunch(Operator op) {
long start = System.currentTimeMillis();
double d = 0.0;
for (int i = 0; i < 5000000; i++)
d = op.operate(d);
long end = System.currentTimeMillis();
System.out.println("Time: " + (end-start) + " ignore:" + d);
}
public static void main(String[] args) {
Operator ra = new RoundaboutAdder();
runABunch(ra); // misguided warmup attempt
runABunch(ra);
Operator sa = new SimpleAdder();
Operator da = new DoubleAdder();
runABunch(sa);
runABunch(da);
}
}
|
StupidMathTest
首先试图做些预热(没有成功),然后测量 SimpleAdder 、 DoubleAdder 、 RoundaboutAdder 的运行时间,结果如表 2 所示。看起来好像先加 1,再加 2 ,然后再减 1 最快。加两次 0.5 比加 1 还快。这有可能么?(答案是:不可能。)
表 2. StupidMathTest 毫无意义且令人误解的结果
方法 |
运行时间 |
SimpleAdder |
88ms |
DoubleAdder |
76ms |
RoundaboutAdder |
14ms |
这里发生什么呢?在预热循环之后, RoundaboutAdder 和 runABunch() 确实已经被编译了,而且编译器 Operator 和 RoundaboutAdder 上进行了单形调用转换,第一轮运行得非常快。而在第二轮( SimpleAdder )中,编译器不得不反优化,又退回虚函数分配之中,所以第二轮的执行表现得更慢,因为不能把虚函数调用优化掉,把时间花在了重新编译上。在第三轮( DoubleAdder )中,重新编译比第二轮少,所以运行得就更快。(在现实中,编译器会在 RoundaboutAdder 和 DoubleAdder 上进行常数替换(constant folding),生成与 SimpleAdder 几乎相同的代码。所以如果在运行时间上有差异,那么不是因为算术代码)。哪个代码首先执行,哪个代码就会最快。
那么,从这个“评测”中,我们能得出什么结论呢?实际上,除了评测动态编译语言要比您可能想到的要微妙得多之外,什么也没得到。
结束语
这个示例中的结果错得如此明显,所以很清楚,肯定发生了什么,但是更小的结果能够很容易地歪曲您的性能测试程序的结果,却不会触发您的“这里肯定有什么东西有问题”的警惕。虽然本文列出的这些内容是微评测歪曲的一般来源,但是还有许多其他来源。本文的中心思想是:您正在测量的,通常不是您以为您正在测量的。实际上,您通常所测量的,不是您以为您正在测量的。对于那些没有包含什么实际的程序负荷,测试时间不够长的性能测试的结果,一定要非常当心。
动态代理工具 是 java.lang.reflect 包的一部分,在 JDK 1.3 版本中添加到 JDK,它允许程序创建 代理对象,代理对象能实现一个或多个已知接口,并用反射代替内置的虚方法分派,编程地分派对接口方法的调用。这个过程允许实现“截取”方法调用,重新路由它们或者动态地添加功能。本期文章中,Brian Goetz 介绍了几个用于动态代理的应用程序。请在本文伴随的 讨论论坛 上与作者和其他读者分享您对这篇文章的想法。(也可以单击文章顶部或底部的 讨论 访问讨论论坛。)
动态代理为实现许多常见设计模式(包括 Facade、Bridge、Interceptor、Decorator、Proxy(包括远程和虚拟代理)和 Adapter 模式)提供了替代的动态机制。虽然这些模式不使用动态代理,只用普通的类就能够实现,但是在许多情况下,动态代理方式更方便、更紧凑,可以清除许多手写或生成的类。
Proxy 模式
Proxy 模式中要创建“stub”或“surrogate”对象,它们的目的是接受请求并把请求转发到实际执行工作的其他对象。远程方法调用(RMI)利用 Proxy 模式,使得在其他 JVM 中执行的对象就像本地对象一样;企业 JavaBeans (EJB)利用 Proxy 模式添加远程调用、安全性和事务分界;而 JAX-RPC Web 服务则用 Proxy 模式让远程服务表现得像本地对象一样。在每一种情况中,潜在的远程对象的行为是由接口定义的,而接口本质上接受多种实现。调用者(在大多数情况下)不能区分出它们只是持有一个对 stub 而不是实际对象的引用,因为二者实现了相同的接口;stub 的工作是查找实际的对象、封送参数、把参数发送给实际对象、解除封送返回值、把返回值返回给调用者。代理可以用来提供远程控制(就像在 RMI、EJB 和 JAX-RPC 中那样),用安全性策略包装对象(EJB)、为昂贵的对象(EJB 实体 Bean)提供惰性装入,或者添加检测工具(例如日志记录)。
在 5.0 以前的 JDK 中,RMI stub(以及它对等的 skeleton)是在编译时由 RMI 编译器(rmic)生成的类,RMI 编译器是 JDK 工具集的一部分。对于每个远程接口,都会生成一个 stub(代理)类,它代表远程对象,还生成一个 skeleton 对象,它在远程 JVM 中做与 stub 相反的工作 —— 解除封送参数并调用实际的对象。类似地,用于 Web 服务的 JAX-RPC 工具也为远程 Web 服务生成代理类,从而使远程 Web 服务看起来就像本地对象一样。
不管 stub 类是以源代码还是以字节码生成的,代码生成仍然会向编译过程添加一些额外步骤,而且因为命名相似的类的泛滥,会带来意义模糊的可能性。另一方面,动态代理机制支持在编译时没有生成 stub 类的情况下,在运行时创建代理对象。在 JDK 5.0 及以后版本中,RMI 工具使用动态代理代替了生成的 stub,结果 RMI 变得更容易使用。许多 J2EE 容器也使用动态代理来实现 EJB。EJB 技术严重地依靠使用拦截(interception)来实现安全性和事务分界;动态代理为接口上调用的所有方法提供了集中的控制流程路径。
动态代理机制
动态代理机制的核心是 InvocationHandler 接口,如清单 1 所示。调用句柄的工作是代表动态代理实际执行所请求的方法调用。传递给调用句柄一个 Method 对象(从 java.lang.reflect 包),参数列表则传递给方法;在最简单的情况下,可能仅仅是调用反射性的方法 Method.invoke() 并返回结果。
清单 1. InvocationHandler 接口
public interface InvocationHandler {
Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
|
每个代理都有一个与之关联的调用句柄,只要代理的方法被调用时就会调用该句柄。根据通用的设计原则:接口定义类型、类定义实现,代理对象可以实现一个或多个接口,但是不能实现类。因为代理类没有可以访问的名称,它们不能有构造函数,所以它们必须由工厂创建。清单 2 显示了动态代理的最简单的可能实现,它实现 Set 接口并把所有 Set 方法(以及所有 Object 方法)分派给封装的 Set 实例。
清单 2. 包装 Set 的简单的动态代理
public class SetProxyFactory {
public static Set getSetProxy(final Set s) {
return (Set) Proxy.newProxyInstance
(s.getClass().getClassLoader(),
new Class[] { Set.class },
new InvocationHandler() {
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
return method.invoke(s, args);
}
});
}
}
|
SetProxyFactory
类包含一个静态工厂方法 getSetProxy() ,它返回一个实现了 Set 的动态代理。代理对象实际实现 Set —— 调用者无法区分(除非通过反射)返回的对象是动态代理。SetProxyFactory 返回的代理只做一件事,把方法分派给传递给工厂方法的 Set 实例。虽然反射代码通常比较难读,但是这里的内容很少,跟上控制流程并不难 —— 只要某个方法在 Set 代理上被调用,它就被分派给调用句柄,调用句柄只是反射地调用底层包装的对象上的目标方法。当然,绝对什么都不做的代理可能有点傻,是不是呢?
什么都不做的适配器
对于像 SetProxyFactory 这样什么都不做的包装器来说,实际有个很好的应用 —— 可以用它安全地把对象引用的范围缩小到特定接口(或接口集)上,方式是,调用者不能提升引用的类型,使得可以更安全地把对象引用传递给不受信任的代码(例如插件或回调)。清单 3 包含一组类定义,实现了典型的回调场景。从中会看到动态代理可以更方便地替代通常用手工(或用 IDE 提供的代码生成向导)实现的 Adapter 模式。
清单 3. 典型的回调场景
public interface ServiceCallback {
public void doCallback();
}
public interface Service {
public void serviceMethod(ServiceCallback callback);
}
public class ServiceConsumer implements ServiceCallback {
private Service service;
...
public void someMethod() {
...
service.serviceMethod(this);
}
}
|
ServiceConsumer
类实现了 ServiceCallback (这通常是支持回调的一个方便途径)并把 this 引用传递给 serviceMethod() 作为回调引用。这种方法的问题是没有机制可以阻止 Service 实现把 ServiceCallback 提升为 ServiceConsumer ,并调用 ServiceConsumer 不希望 Service 调用的方法。有时对这个风险并不关心 —— 但有时却关心。如果关心,那么可以把回调对象作为内部类,或者编写一个什么都不做的适配器类(请参阅清单 4 中的 ServiceCallbackAdapter )并用 ServiceCallbackAdapter 包装 ServiceConsumer 。ServiceCallbackAdapter 防止 Service 把 ServiceCallback 提升为 ServiceConsumer 。
清单 4. 用于安全地把对象限制在一个接口上以便不被恶意代码不能的适配器类
public class ServiceCallbackAdapter implements ServiceCallback {
private final ServiceCallback cb;
public ServiceCallbackAdapter(ServiceCallback cb) {
this.cb = cb;
}
public void doCallback() {
cb.doCallback();
}
}
|
编写 ServiceCallbackAdapter 这样的适配器类简单却乏味。必须为包装的接口中的每个方法编写重定向类。在 ServiceCallback 的示例中,只有一个需要实现的方法,但是某些接口,例如 Collections 或 JDBC 接口,则包含许多方法。现代的 IDE 提供了“Delegate Methods”向导,降低了编写适配器类的工作量,但是仍然必须为每个想要包装的接口编写一个适配器类,而且对于只包含生成的代码的类,也有一些让人不满意的地方。看起来应当有一种方式可以更紧凑地表示“什么也不做的限制适配器模式”。
通用适配器类
清单 2
中的 SetProxyFactory 类当然比用于 Set 的等价的适配器类更紧凑,但是它仍然只适用于一个接口:Set 。但是通过使用泛型,可以容易地创建通用的代理工厂,由它为任何接口做同样的工作,如清单 5 所示。它几乎与 SetProxyFactory 相同,但是可以适用于任何接口。现在再也不用编写限制适配器类了!如果想创建代理对象安全地把对象限制在接口 T ,只要调用 getProxy(T.class,object) 就可以了,不需要一堆适配器类的额外累赘。
清单 5. 通用的限制适配器工厂类
public class GenericProxyFactory {
public static<T> T getProxy(Class<T> intf,
final T obj) {
return (T)
Proxy.newProxyInstance(obj.getClass().getClassLoader(),
new Class[] { intf },
new InvocationHandler() {
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
return method.invoke(obj, args);
}
});
}
}
|
动态代理作为 Decorator
当然,动态代理工具能做的,远不仅仅是把对象类型限制在特定接口上。从 清单 2 和 清单 5 中简单的限制适配器到 Decorator 模式,是一个小的飞跃,在 Decorator 模式中,代理用额外的功能(例如安全检测或日志记录)包装调用。清单 6 显示了一个日志 InvocationHandler ,它在调用目标对象上的方法之外,还写入一条日志信息,显示被调用的方法、传递的参数,以及返回值。除了反射性的 invoke() 调用之外,这里的全部代码只是生成调试信息的一部分 —— 还不是太多。代理工厂方法的代码几乎与 GenericProxyFactory 相同,区别在于它使用的是 LoggingInvocationHandler 而不是匿名的调用句柄。
清单 6. 基于代理的 Decorator,为每个方法调用生成调试日志
private static class LoggingInvocationHandler<T>
implements InvocationHandler {
final T underlying;
public LoggingHandler(T underlying) {
this.underlying = underlying;
}
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
StringBuffer sb = new StringBuffer();
sb.append(method.getName()); sb.append("(");
for (int i=0; args != null && i<args.length; i++) {
if (i != 0)
sb.append(", ");
sb.append(args[i]);
}
sb.append(")");
Object ret = method.invoke(underlying, args);
if (ret != null) {
sb.append(" -> "); sb.append(ret);
}
System.out.println(sb);
return ret;
}
}
|
如果用日志代理包装 HashSet ,并执行下面这个简单的测试程序:
Set s = newLoggingProxy(Set.class, new HashSet());
s.add("three");
if (!s.contains("four"))
s.add("four");
System.out.println(s);
|
会得到以下输出:
add(three) -> true
contains(four) -> false
add(four) -> true
toString() -> [four, three]
[four, three]
|
这种方式是给对象添加调试包装器的一种好的而且容易的方式。它当然比生成代理类并手工创建大量 println() 语句容易得多(也更通用)。我进一步改进了这一方法;不必无条件地生成调试输出,相反,代理可以查询动态配置存储(从配置文件初始化,可以由 JMX MBean 动态修改),确定是否需要生成调试语句,甚至可能在逐个类或逐个实例的基础上进行。
在这一点上,我认为读者中的 AOP 爱好者们几乎要跳出来说“这正是 AOP 擅长的啊!”是的,但是解决问题的方法不止一种 —— 仅仅因为某项技术能解决某个问题,并不意味着它就是最好的解决方案。在任何情况下,动态代理方式都有完全在“纯 Java”范围内工作的优势,不是每个公司都用(或应当用) AOP 的。
动态代理作为适配器
代理也可以用作真正的适配器,提供了对象的一个视图,导出与底层对象实现的接口不同的接口。调用句柄不需要把每个方法调用都分派给相同的底层对象;它可以检查名称,并把不同的方法分派给不同的对象。例如,假设有一组表示持久实体(Person 、Company 和 PurchaseOrder ) 的 JavaBean 接口,指定了属性的 getter 和 setter,而且正在编写一个持久层,把数据库记录映射到实现这些接口的对象上。现在不用为每个接口编写或生成类,可以只用一个 JavaBean 风格的通用代理类,把属性保存在 Map 中。
清单 7 显示的动态代理检查被调用方法的名称,并通过查询或修改属性图直接实现 getter 和 setter 方法。现在,这一个代理类就能实现多个 JavaBean 风格接口的对象。
清单 7. 用于把 getter 和 setter 分派给 Map 的动态代理类
public class JavaBeanProxyFactory {
private static class JavaBeanProxy implements InvocationHandler {
Map<String, Object> properties = new HashMap<String,
Object>();
public JavaBeanProxy(Map<String, Object> properties) {
this.properties.putAll(properties);
}
public Object invoke(Object proxy, Method method,
Object[] args)
throws Throwable {
String meth = method.getName();
if (meth.startsWith("get")) {
String prop = meth.substring(3);
Object o = properties.get(prop);
if (o != null && !method.getReturnType().isInstance(o))
throw new ClassCastException(o.getClass().getName() +
" is not a " + method.getReturnType().getName());
return o;
}
else if (meth.startsWith("set")) {
// Dispatch setters similarly
}
else if (meth.startsWith("is")) {
// Alternate version of get for boolean properties
}
else {
// Can dispatch non get/set/is methods as desired
}
}
}
public static<T> T getProxy(Class<T> intf,
Map<String, Object> values) {
return (T) Proxy.newProxyInstance
(JavaBeanProxyFactory.class.getClassLoader(),
new Class[] { intf }, new JavaBeanProxy(values));
}
}
|
虽然因为反射在 Object 上工作会有潜在的类型安全性上的损失,但是,JavaBeanProxyFactory 中的 getter 处理会进行一些必要的额外的类型检测,就像我在这里用 isInstance() 对 getter 进行的检测一样。
性能成本
正如已经看到的,动态代理拥有简化大量代码的潜力 —— 不仅能替代许多生成的代码,而且一个代理类还能代替多个手写的类或生成的代码。什么是成本呢? 因为反射地分派方法而不是采用内置的虚方法分派,可能有一些性能上的成本。在早期的 JDK 中,反射的性能很差(就像早期 JDK 中几乎其他每件事的性能一样),但是在近 10 年,反射已经变得快多了。
不必进入基准测试构造的主题,我编写了一个简单的、不太科学的测试程序,它循环地把数据填充到 Set ,随机地对 Set 进行插入、查询和删除元素。我用三个 Set 实现运行它:一个未经修饰的 HashSet ,一个手写的、只是把所有方法转发到底层的 HashSet 的 Set 适配器,还有一个基于代理的、也只是把所有方法转发到底层 HashSet 的 Set 适配器。每次循环迭代都生成若干随机数,并执行一个或多个 Set 操作。手写的适配器比起原始的 HashSet 只产生很少百分比的性能负荷(大概是因为 JVM 级有效的内联缓冲和硬件级的分支预测);代理适配器则明显比原始 HashSet 慢,但是开销要少于两个量级。
我从这个试验得出的结论是:对于大多数情况,代理方式即使对轻量级方法也执行得足够好,而随着被代理的操作变得越来越重量级(例如远程方法调用,或者使用序列化、执行 IO 或者从数据库检索数据的方法),代理开销就会有效地接近于 0。当然也存在一些代理方式的性能开销无法接受的情况,但是这些通常只是少数情况。
将泛型添加到 Java™ 语言中增加了类型系统的复杂性,提高了许多变量和方法声明的冗长程度。因为没有提供 “typedef” 工具来定义类型的简短名称,所以有些开发人员转而把扩展当作 “穷人的 typedef”,但是收到的决不是好的结果。在这个月的 Java 理论与实践 中,Java 专家 Brian Goetz 解释了这个 “反模式” 的限制。
对于 Java 5.0 中新增的泛型工具,一个常见的抱怨就是,它使代码变得太冗长。原来用一行就够的变量声明不再存在了,与声明参数化类型有关的重复非常讨厌,特别是还没有良好地支持自动补足的 IDE。例如,如果想声明一个 Map ,它的键是 Socket ,值是 Future<String> ,那么老方法就是:
Map socketOwner = new HashMap();
|
比新方法紧凑得多:
Map<Socket, Future<String>> socketOwner
= new HashMap<Socket, Future<String>>();
|
当然,新方法内置了更多类型信息,减少了编程错误,提高了程序的可读性,但是确实带来了更多声明变量和方法签名方面的前期工作。类型参数在声明和初始化中的重复看起来尤其没有必要;Socket 和 Future<String> 需要输入两次,这迫使我们违犯了 “DRY” 原则(不要重复自己)。 合成类似于 typedef 的东西 添加泛型给类型系统增加了一些复杂性。在 Java 5.0 之前,“type” 和 “class” 几乎是同义的,而参数化类型,特别是那些绑定的通配类型,使子类型和子类的概念有了显著区别。类型 ArrayList<?> 、ArrayList<? extends Number> 和 ArrayList<Integer> 是不同的类型,虽然它们是由同一个类 ArrayList 实现的。这些类型构成了一个层次结构;ArrayList<?> 是 ArrayList<? extends Number> 的超类型,而 ArrayList<? extends Number> 是 ArrayList<Integer> 的超类型。 对于原来的简单类型系统,像 C 的 typedef 这样的特性没有意义。但是对于更复杂的类型系统,typedef 工具可能会提供一些好处。不知是好还是坏,总之在泛型加入的时候,typedef 没有加入 Java 语言。 有些人用作 “穷人的 typedef” 的一个(坏的)做法是一个小小的扩展:创建一个类,扩展泛型类型,但是不添加功能,例如 SocketUserMap 类型,如清单 1 所示: 清单 1. 伪 typedef 反模式 —— 不要这么做public class SocketUserMap extends HashMap<Socket<Future<String>> { }
SocketUserMap socketOwner = new SocketUserMap();
|
我将这个技巧称为伪 typedef 反模式,它实现了将 socketOwner 定义简化为一行的这一(有问题的)目标,但是有些副作用,最终成为重用和维护的障碍。(对于有明确的构造函数而不是无参构造函数的类来说,派生类也需要声明每个构造函数,因为构造函数没有被继承。) 伪类型的问题 在 C 中,用 typedef 定义一个新类型更像是宏,而不是类型声明。定义等价类型的 typedef,可以与原始类型自由地互换。清单 2 显示了一个定义回调函数的示例,其中在签名中使用了一个 typedef,但是调用者提供给回调的是一个等价类型,而编译器和运行时都可以接受它: 清单 2. C 语言的 typedef 示例// Define a type called "callback" that is a function pointer
typedef void (*Callback)(int);
void doSomething(Callback callback) { }
// This function conforms to the type defined by Callback
void callbackFunction(int arg) { }
// So a caller can pass the address of callbackFunction to doSomething
void useCallback() {
doSomething(&callbackFunction);
}
|
扩展不是类型定义 用 Java 语言编写的试图使用伪 typedef 的等价程序就会出现麻烦。清单 3 的 StringList 和 UserList 类型都扩展了一个公共超类,但是它们不是等价的类型。这意味着任何想调用 lookupAll 的代码都必须传递一个 StringList ,而不能是 List<String> 或 UserList 。 清单 3. 伪类型如何把客户限定在只能使用伪类型class StringList extends ArrayList<String> { }
class UserList extends ArrayList<String> { }
...
class SomeClass {
public void validateUsers(UserList users) { ... }
public UserList lookupAll(StringList names) { ... }
}
|
这个限制要比初看上去严格得多。在小程序中,可能不会有太大差异,但是当程序变大的时候,使用伪类型的需求就会不断地造成问题。如果变量类型是 StringList ,就不能给它分配普通的 List<String> ,因为 List<String> 是 StringList 的超类型,所以不是 StringList 。就像不能把 Object 分配给类型为 String 的变量一样,也不能把 List<String> 分配给类型为 StringList 的变量(但是,可以反过来,例如,可以把 StringList 分配给类型为 List<String> 的变量,因为 List<String> 是 StringList 的超类型。) 同样的情况也适用于方法的参数;如果一个方法参数是 StringList 类型,那么就不能把普通的 List<String> 传递给它。这意味着,如果不要求这个方法的每次使用都使用伪类型,那么根本不能用伪类型作为方法参数,而这在实践当中就意味着在库 API 中根本就不能使用伪类型。而且大多数库 API 都源自本来没想成为库代码的那些代码,所以 “这个代码只是给我自己的,没有其他人会用它” 可不是个好借口(只要您的代码有一点儿用处,别人就有可能会使用它;如果您的代码臭得很,那您可能是对的)。 伪类型会传染 这种 “病毒” 性质是让 C 代码的重用有困难的因素之一。差不多每个 C 包都有头文件,定义工具宏和类型,像 int32 、boolean 、true 、false ,诸如此类。如果想在一个应用程序内使用几个包,而它们对于这些公共条目没有使用相同的定义,那么即使要编译一个只包含所有头文件的空程序,之前也要在 “头文件地狱” 问题上花好长时间。如果编写的 C 应用程序要使用许多来自不同作者的不同的包,那么几乎肯定要涉及一些这类痛苦。另一方面,对于 Java 应用程序来说,在没有这类痛苦的情况下使用许多甚至更多的包,是非常常见的事。如果包要在它们的 API 中使用伪类型,那么我们可能就要重新经历早已留在痛苦回忆中的问题。 作为示例,假设有两个不同的包,每个包都用伪类型反模式定义了 StringList ,如清单 4 所示,而且每个包都定义了操作 StringList 的工具方法。两个包都定义了同样的标识符,这一事实已经是不方便的一个小源头了;客户程序必须选择导入一个定义,而另一个定义则要使用完全限定的名称。但是更大的问题是现在这些包的客户无法创建既能传递给 sortList 又能传递给 reverseList 的对象,因为两个不同的 StringList 类型是不同的类型,彼此互不兼容。客户现在必须在使用一个包还是使用另一个包之间进行选择,否则他们就必须做许多工作,在不同类型的 StringList 之间进行转换。对包的作者来说以为方便的东西,成为在所有地方使用这个包的突出障碍,除非在最受限的环境中。 清单 4. 伪类型的使用如何妨碍重用package a;
class StringList extends ArrayList<String> { }
class ListUtilities {
public static void sortList(StringList list) { }
}
package b;
class StringList extends ArrayList<String> { }
class SomeOtherUtilityClass {
public static void reverseList(StringList list) { }
}
...
class Client {
public void someMethod() {
StringList list = ...;
// Can't do this
ListUtilities.sortList(list);
SomeOtherUtilityClass.reverseList(list);
}
}
|
伪类型通常太具体 伪类型反模式的进一步问题是,它会丧失使用接口定义变量类型和方法参数的好处。虽然可以把 StringList 定义成扩展 List<String> 的接口,再定义一个具体类型 StringArrayList 来扩展 ArrayList<String> 并实现 StringList ,但多数伪 typedef 反模式的用户通常达不到这种水平,因为这项技术的目的主要是为了简化和缩短类型的名称。但结果是,API 的用处减少了并变得更脆弱,因为它们使用 ArrayList 这样的具体类型,而不是 List 这样的抽象类型。 更安全的技巧 一个更安全的减少声明泛型集合所需打字量的技巧是使用类型推导(type inference)。编译器可以非常聪明地使用程序中内嵌的类型信息来分配类型参数。如果定义了下面这样一个工具方法: public static <K,V> Map<K,V> newHashMap() {
return new HashMap<K,V>();
}
|
那么可以安全地用它来避免录入两次参数:
Map<Socket, Future<String>> socketOwner = Util.newHashMap();
|
这种方法之所以能够奏效,在于编译器可以根据泛型方法 newHashMap() 被调用的位置推导出 K 和 V 的值。 结束语 伪 typedef 反模式的动机很简单 —— 开发人员想要一种方法可以定义更紧凑的类型标识符,特别是在泛型把类型标识符变得更冗长的时候。问题在于这个做法在使用它的代码和代码的客户之间形成了紧密的耦合,从而妨碍了重用。不喜欢泛型类型标识符的冗长是可以理解的,但这不是解决问题的办法。
在没有垃圾收集的语言中,比如C++,必须特别关注内存管理。对于每个动态对象,必须要么实现引用计数以模拟 垃圾收集效果,要么管理每个对象的“所有权”――确定哪个类负责删除一个对象。通常,对这种所有权的维护并没有什么成文的规则,而是按照约定(通常是不成文的)进行维护。尽管垃圾收集意味着Java开发者不必太多地担心内存 泄漏,有时我们仍然需要担心对象所有权,以防止数据争用(data races)和不必要的副作用。在这篇文章中,Brian Goetz 指出了一些这样的情况,即Java开发者必须注意对象所有权。请在 论坛上与作者及其他读者共享您对本文的一些想法(您也可以在文章的顶部或底部点击 讨论来访问论坛)。
如果您是在1997年之前开始学习编程,那么可能您学习的第一种编程语言没有提供透明的垃圾收集。每一个new 操作必须有相应的delete操作 ,否则您的程序就会泄漏内存,最终内存分配器(memory allocator )就会出故障,而您的程序就会崩溃。每当利用 new 分配一个对象时,您就得问自己,谁将删除该对象?何时删除?
别名, 也叫做 ...
内存管理复杂性的主要原因是别名使用:同一块内存或对象具有 多个指针或引用。别名在任何时候都会很自然地出现。例如,在清单 1 中,在 makeSomething 的第一行创建的 Something 对象至少有四个引用:
- something 引用。
- 集合 c1 中至少有一个引用。
- 当 something 被作为参数传递给 registerSomething 时,会创建临时 aSomething 引用。
- 集合 c2 中至少有一个引用。
清单 1. 典型代码中的别名
Collection c1, c2;
public void makeSomething {
Something something = new Something();
c1.add(something);
registerSomething(something);
}
private void registerSomething(Something aSomething) {
c2.add(aSomething);
}
|
在非垃圾收集语言中需要避免两个主要的内存管理危险:内存泄漏和悬空指针。为了防止内存泄漏,必须确保每个分配了内存的对象最终都会被删除。 为了避免悬空指针(一种危险的情况,即一块内存已经被释放了,而一个指针还在引用它),必须在最后的引用释放之后才删除对象。为满足这两条约束,采用一定的策略是很重要的。
为内存管理而管理对象所有权 除了垃圾收集之外,通常还有其他两种方法用于处理别名问题: 引用计数和所有权管理。引用计数(reference counting)是对一个给定的对象当前有多少指向它的引用保留有一个计数,然后当最后一个引用被释放时自动删除该对象。在 C和20世纪90年代中期之前的多数 C++ 版本中,这是不可能自动完成的。标准模板库(Standard Template Library,STL)允许创建“灵巧”指针,而不能自动实现引用计数(要查看一些例子,请参见开放源代码 Boost 库中的 shared_ptr 类,或者参见STL中的更加简单的 auto_ptr 类)。
所有权管理(ownership management) 是这样一个过程,该过程指明一个指针是“拥有”指针("owning" pointer),而 所有其他别名只是临时的二类副本( temporary second-class copies),并且只在所拥有的指针被释放时才删除对象。在有些情况下,所有权可以从一个指针“转移”到另一个指针,比如一个这样的方法,它以一个缓冲区作为参数,该方法用于向一个套接字写数据,并且在写操作完成时删除这个缓冲区。这样的方法通常叫做接收器 (sinks)。在这个例子中,缓冲区的所有权已经被有效地转移,因而进行调用的代码必须假设在被调用方法返回时缓冲区已经被删除。(通过确保所有的别名指针都具有与调用堆栈(比如方法参数或局部变量)一致的作用域(scope ),可以进一步简化所有权管理,如果引用将由非堆栈作用域的变量保存,则通过复制对象来进行简化。)
那么,怎么着?
此时,您可能正纳闷,为什么我还要讨论内存管理、别名和对象所有权。毕竟,垃圾收集是 Java语言的核心特性之一,而内存管理是已经过时的一件麻烦事。就让垃圾收集器来处理这件事吧,这正是它的工作。那些从内存管理的麻烦中解脱出来的人不愿意再回到过去,而那些从未处理过内存管理的人则根本无法想象在过去倒霉的日子里――比如1996年――程序员的编程是多么可怕。
提防悬空别名
那么这意味着我们可以与对象所有权的概念说再见了吗?可以说是,也可以说不是。 大多数情况下,垃圾收集确实消除了显式资源存储单元分配(explicit resource deallocation)的必要(在以后的专栏中我将讨论一些例外)。但是,有一个区域中,所有权管理仍然是Java 程序中的一个问题,而这就是悬空别名(dangling aliases)问题。 Java 开发者通常依赖于这样一个隐含的假设,即假设由对象所有权来确定哪些引用应该被看作是只读的 (在C++中就是一个 const 指针),哪些引用可以用来修改被引用的对象的状态。当两个类都(错误地)认为自己保存有对给定对象的惟一可写的引用时,就会出现悬空指针。发生这种情况时,如果对象的状态被意外地更改,这两个类中的一个或两者将会产生混淆。
一个贴切的例子
考虑清单 2 中的代码,其中的 UI 组件保存有一个 Point 对象,用于表示它的位置。当调用 MathUtil.calculateDistance 来计算对象移动了多远时,我们依赖于一个隐含而微妙的假设――即 calculateDistance 不会改变传递给它的 Point 对象的状态,或者情况更坏,维护着对那些 Point 对象的一个引用(比如通过将它们保存在集合中或者将它们传递到另一个线程),然后这个引用将用于在 calculateDistance 返回后更改Point 对象的状态。 在 calculateDistance的例子中,为这种行为担心似乎有些可笑,因为这明显是一个可怕的违背惯例的情况。但是,如果要说将一个可变的对象传递给一个方法,之后对象还能够毫发无损地返回来,并且将来对于对象的状态也不会有不可预料的副作用(比如该方法与另一个线程共享引用,该线程可能会等待5分钟,然后更改对象的状态),那么这只不过是一厢情愿的想法而已。
清单 2. 将可变对象传递给外部方法是不可取的
private Point initialLocation, currentLocation;
public Widget(Point initialLocation) {
this.initialLocation = initialLocation;
this.currentLocation = initialLocation;
}
public double getDistanceMoved() {
return MathUtil.calculateDistance(initialLocation, currentLocation);
}
. . .
// The ill-behaved utility class MathUtil
public static double calculateDistance(Point p1,
Point p2) {
double distance = Math.sqrt((p2.x - p1.x) ^ 2
+ (p2.y - p1.y) ^ 2);
p2.x = p1.x;
p2.y = p1.y;
return distance;
}
|
一个愚蠢的例子
大家对该例子明显而普遍的反应就是――这是一个愚蠢的例子――只是强调了这样一个事实,即对象所有权的概念在 Java 程序中依然存在,而且存在得很好,只是没有说明而已。calculateDistance 方法不应该改变它的参数的状态,因为它并不“拥有”它们――当然,调用方法拥有它们。因此说不用考虑对象所有权。
下面是一个更加实用的例子,它说明了不知道谁拥有对象就有可能会引起混淆。再次考虑一个以Point 属性 来表示其位置的 UI组件。 清单 3 显示了实现存取器方法 setLocation 和 getLocation的三种方式。第一种方式是最懒散的,并且提供了最好的性能,但是对于蓄意攻击和无意识的失误,它有几个薄弱环节。
清单 3. getters 和 setters的值语义以及引用语义
public class Widget {
private Point location;
// Version 1: No copying -- getter and setter implement reference
// semantics
// This approach effectively assumes that we are transferring
// ownership of the Point from the caller to the Widget, but this
// assumption is rarely explicitly documented.
public void setLocation(Point p) {
this.location = p;
}
public Point getLocation() {
return location;
}
// Version 2: Defensive copy on setter, implementing value
// semantics for the setter
// This approach effectively assumes that callers of
// getLocation will respect the assumption that the Widget
// owns the Point, but this assumption is rarely documented.
public void setLocation(Point p) {
this.location = new Point(p.x, p.y);
}
public Point getLocation() {
return location;
}
// Version 3: Defensive copy on getter and setter, implementing
// true value semantics, at a performance cost
public void setLocation(Point p) {
this.location = new Point(p.x, p.y);
}
public Point getLocation() {
return (Point) location.clone();
}
}
|
现在来考虑 setLocation 看起来是无意的使用 :
Widget w1, w2;
. . .
Point p = new Point();
p.x = p.y = 1;
w1.setLocation(p);
p.x = p.y = 2;
w2.setLocation(p);
|
或者是:
w2.setLocation(w1.getLocation());
|
在setLocation/getLocation存取器实现的版本 1 之下,可能看起来好像第一个Widget的 位置是 (1, 1) ,第二个Widget的位置是 (2, 2),而事实上,二者都是 (2, 2)。这可能对于调用者(因为第一个Widget意外地移动了)和Widget 类(因为它的位置改变了,而与Widget代码无关)来说都会产生混淆。在第二个例子中,您可能认为自己只是将Widget w2移动到 Widget w1当前所在的位置 ,但是实际上您这样做便规定了每次w1 移动时w2都跟随w1 。
防御性副本
setLocation 的版本 2 做得更好:它创建了传递给它的参数的一个副本,以确保不存在可以意外改变其状态的 Point的别名。但是它也并非无可挑剔,因为下面的代码也将具有一个很可能不希望出现的效果,即Widget在不知情的情况下被移动了:
Point p = w1.getLocation();
. . .
p.x = 0;
|
getLocation 和 setLocation 的版本 3 对于别名引用的恶意或无意使用是完全安全的。这一安全是以一些性能为代价换来的:每次调用一个 getter 或 setter 都会创建一个新对象。
getLocation 和 setLocation 的不同版本具有不同的语义,通常这些语义被称作值语义(版本 1)和引用语义(版本 3)。不幸的是,通常没有说明实现者应该使用的是哪种语义。结果,这个类的使用者并不清楚这一点,从而作出了更差的假设(即选择了不是最合适的语义)。
getLocation 和 setLocation 的版本 3 所使用的技术叫做防御性复制( defensive copying),尽管存在着明显的性能上的代价,您也应该养成这样的习惯,即几乎每次返回和存储对可变对象或数组的引用时都使用这一技术,尤其是在您编写一个通用的可能被不是您自己编写的代码调用(事实上这很常见)的工具时更是如此。有别名的可变对象被意外修改的情况会以许多微妙且令人惊奇的方式突然出现,并且调试起来相当困难。
而且情况还会变得更坏。假设您是Widget类的一个使用者,您并不知道存取器具有值语义还是引用语义。 谨慎的做法是,在调用存取器方法时也使用防御性副本。所以,如果您想要将 w2 移动到 w1 的当前位置,您应该这样去做:
Point p = w1.getLocation();
w2.setLocation(new Point(p.x, p.y));
|
如果 Widget 像在版本 2 或 3 中一样实现其存取器,那么我们将为每个调用创建两个临时对象 ――一个在 setLocation 调用的外面,一个在里面。
文档说明存取器语义
getLocation 和 setLocation 的版本 1 的真正问题不是它们易受混淆别名副作用的不良影响(确实是这样),而是它们的语义没有清楚的说明。如果存取器被清楚地说明为具有引用语义(而不是像通常那样被假设为值语义),那么调用者将更可能认识到,在它们调用setLocation时,它们是将Point对象的所有权转移给另一个实体,并且也不大可能仍然认为它们还拥有Point对象的所有权,因而还能够再次使用它。
利用不可改变性解决以上问题
如果一开始就使得Point 成为不可变的,那么这些与 Point 有关的问题早就迎刃而解了。不可变对象上没有副作用,并且缓存不可变对象的引用总是安全的,不会出现别名问题。如果 Point是不可变的,那么与setLocation 和 getLocation存取器的语义有关的所有问题都是非常确定的 。不可变属性的存取器将总是具有值引用,因而调用的任何一方都不需要防御性复制,这使得它们效率更高。
那么为什么不在一开始就使得Point 成为不可变的呢?这可能是出于性能上的原因,因为早期的 JVM具有不太有效的垃圾收集器。 那时,每当一个对象(甚至是鼠标)在屏幕上移动就创建一个新的Point的对象创建开销可能有些让人生畏,而创建防御性副本的开销则不在话下。
依后见之明,使Point成为可变的这个决定被证明对于程序清晰性和性能是昂贵的代价。Point类的可变性使得每一个接受Point作为参数或者要返回一个Point的方法背上了编写文档说明的沉重负担。也就是说,它得说明它是要改变Point,还是在返回之后保留对Point的一个引用。因为很少有类真正包含这样的文档,所以在调用一个没有用文档说明其调用语义或副作用行为的方法时,安全的策略是在传递它到任何这样的方法之前创建一份防御副本。
有讽刺意味的是,使 Point成为可变的这个决定所带来的性能优势被由于Point的可变性而需要进行的防御性复制给抵消了。由于缺乏清晰的文档说明(或者缺少信任),在方法调用的两边都需要创建防御副本 ――调用者需要这样做是因为它不知道被调用者是否会粗暴地改变 Point,而被调用者需要这样做是因为它不知道是否保留了对 Point 的引用。
一个现实的例子
下面是悬空别名问题的另一个例子,该例子非常类似于我最近在一个服务器应用中所看到的。 该应用在内部使用了发布-订阅式消息传递方式,以将事件和状态更新传达到服务器内的其他代理。这些代理可以订阅任何一个它们感兴趣的消息流。一旦发布之后,传递到其他代理的消息就可能在将来某个时候在一个不同的线程中被处理。
清单 4 显示了一个典型的消息传递事件(即发布拍卖系统中一个新的高投标通知)和产生该事件的代码。不幸的是,消息传递事件实现和调用者实现的交互合起来创建了一个悬空别名。通过简单地复制而不是克隆数组引用,消息和产生消息的类都保存了前一投标数组的主副本的一个引用。如果消息发布时的时间和消费时的时间有任何延迟,那么订阅者看到的 previous5Bids 数组的值将不同于消息发布时的时间,并且多个订阅者看到的前面投标的值可能会互不相同。在这个例子中,订阅者将看到当前投标的历史值和前面投标的更接近现在的值,从而形成了这样的错觉,认为前面投标比当前投标的值要高。不难设想这将如何引起问题――这还不算,当应用在很大的负载下时,这样一个问题则更是暴露无遗。 使得消息类不可变并在构造时克隆像数组这样的可变引用,就可以防止该问题。
清单 4. 发布-订阅式消息传递代码中的悬空数组别名
public interface MessagingEvent { ... }
public class CurrentBidEvent implements MessagingEvent {
public final int currentBid;
public final int[] previous5Bids;
public CurrentBidEvent(int currentBid, int[] previousBids) {
this.currentBid = currentBid;
// Danger -- copying array reference instead of values
this.previous5Bids = previous5Bids;
}
...
}
// Now, somewhere in the bid-processing code, we create a
// CurrentBidEvent and publish it.
public void newBid(int newBid) {
if (newBid > currentBid) {
for (int i=1; i<5; i++)
previous5Bids[i] = previous5Bids[i-1];
previous5Bids[0] = currentBid;
currentBid = newBid;
messagingTopic.publish(new CurrentBidEvent(currentBid, previousBids));
}
}
}
|
可变对象的指导
如果您要创建一个可变类 M,那么您应该准备编写比 M 是不可变的情况下多得多的文档说明,以说明怎样处理 M 的引用。 首先,您必须选择以 M 为参数或返回 M 对象的方法是使用值语义还是引用语义,并准备在每一个在其接口内使用 M 的其他类中清晰地文档说明这一点 。如果接受或返回 M 对象的任何方法隐式地假设 M 的所有权被转移,那么您必须也文档说明这一点。您还要准备着接受在必要时创建防御副本的性能开销。
一个必须处理对象所有权问题的特殊情况是数组,因为数组不可以是不可变的。当传递一个数组引用到另一个类时,可能有创建防御副本的代价,除非您能确保其他类要么创建了它自己的副本,要么只在调用期间保存引用,否则您可能需要在传递数组之前创建副本。另外,您可以容易地结束这样一种情形,即调用的两边的类都隐式地假设它们拥有数组,只是这样会有不可预知的结果出现。
ThreadLocal
类是悄悄地出现在 Java 平台版本 1.2 中的。虽然支持线程局部变量早就是许多线程工具(例如 Posix pthreads 工具)的一部分,但 Java Threads API 的最初设计却没有这项有用的功能。而且,最初的实现也相当低效。由于这些原因, ThreadLocal 极少受到关注,但对简化线程安全并发程序的开发来说,它却是很方便的。在 轻松使用线程的第 3 部分,Java 软件顾问 Brian Goetz 研究了 ThreadLocal 并提供了一些使用技巧。
参加 Brian 的 多线程 Java 编程讨论论坛以获得您工程中的线程和并发问题的帮助。
编写线程安全类是困难的。它不但要求仔细分析在什么条件可以对变量进行读写,而且要求仔细分析其它类能如何使用某个类。 有时,要在不影响类的功能、易用性或性能的情况下使类成为线程安全的是很困难的。有些类保留从一个方法调用到下一个方法调用的状态信息,要在实践中使这样的类成为线程安全的是困难的。
管理非线程安全类的使用比试图使类成为线程安全的要更容易些。非线程安全类通常可以安全地在多线程程序中使用,只要您能确保一个线程所用的类的实例不被其它线程使用。例如,JDBC Connection 类是非线程安全的 — 两个线程不能在小粒度级上安全地共享一个 Connection — 但如果每个线程都有它自己的 Connection ,那么多个线程就可以同时安全地进行数据库操作。
不使用 ThreadLocal 为每个线程维护一个单独的 JDBC 连接(或任何其它对象)当然是可能的;Thread API 给了我们把对象和线程联系起来所需的所有工具。而 ThreadLocal 则使我们能更容易地把线程和它的每线程(per-thread)数据成功地联系起来。
什么是线程局部变量(thread-local variable)?
线程局部变量高效地为每个使用它的线程提供单独的线程局部变量值的副本。每个线程只能看到与自己相联系的值,而不知道别的线程可能正在使用或修改它们自己的副本。一些编译器(例如 Microsoft Visual C++ 编译器或 IBM XL FORTRAN 编译器)用存储类别修饰符(像 static 或 volatile )把对线程局部变量的支持集成到了其语言中。Java 编译器对线程局部变量不提供特别的语言支持;相反地,它用 ThreadLocal 类实现这些支持, 核心 Thread 类中有这个类的特别支持。
因为线程局部变量是通过一个类来实现的,而不是作为 Java 语言本身的一部分,所以 Java 语言线程局部变量的使用语法比内建线程局部变量语言的使用语法要笨拙一些。要创建一个线程局部变量,请实例化类 ThreadLocal 的一个对象。 ThreadLocal 类的行为与 java.lang.ref 中的各种 Reference 类的行为很相似; ThreadLocal 类充当存储或检索一个值时的间接句柄。清单 1 显示了 ThreadLocal 接口。
清单 1. ThreadLocal 接口
public class ThreadLocal {
public Object get();
public void set(Object newValue);
public Object initialValue();
}
|
get()
访问器检索变量的当前线程的值; set() 访问器修改当前线程的值。 initialValue() 方法是可选的,如果线程未使用过某个变量,那么您可以用这个方法来设置这个变量的初始值;它允许延迟初始化。用一个示例实现来说明 ThreadLocal 的工作方式是最好的方法。清单 2 显示了 ThreadLocal 的一个实现方式。它不是一个特别好的实现(虽然它与最初实现非常相似),所以很可能性能不佳,但它清楚地说明了 ThreadLocal 的工作方式。
清单 2. ThreadLocal 的糟糕实现
public class ThreadLocal {
private Map values = Collections.synchronizedMap(new HashMap());
public Object get() {
Thread curThread = Thread.currentThread();
Object o = values.get(curThread);
if (o == null && !values.containsKey(curThread)) {
o = initialValue();
values.put(curThread, o);
}
return o;
}
public void set(Object newValue) {
values.put(Thread.currentThread(), newValue);
}
public Object initialValue() {
return null;
}
}
|
这个实现的性能不会很好,因为每个 get() 和 set() 操作都需要 values 映射表上的同步,而且如果多个线程同时访问同一个 ThreadLocal ,那么将发生争用。此外,这个实现也是不切实际的,因为用 Thread 对象做 values 映射表中的关键字将导致无法在线程退出后对 Thread 进行垃圾回收,而且也无法对死线程的 ThreadLocal 的特定于线程的值进行垃圾回收。
用 ThreadLocal 实现每线程 Singleton
线程局部变量常被用来描绘有状态“单子”(Singleton) 或线程安全的共享对象,或者是通过把不安全的整个变量封装进 ThreadLocal ,或者是通过把对象的特定于线程的状态封装进 ThreadLocal 。例如,在与数据库有紧密联系的应用程序中,程序的很多方法可能都需要访问数据库。在系统的每个方法中都包含一个 Connection 作为参数是不方便的 — 用“单子”来访问连接可能是一个虽然更粗糙,但却方便得多的技术。然而,多个线程不能安全地共享一个 JDBC Connection 。如清单 3 所示,通过使用“单子”中的 ThreadLocal ,我们就能让我们的程序中的任何类容易地获取每线程 Connection 的一个引用。这样,我们可以认为 ThreadLocal 允许我们创建 每线程单子。
清单 3. 把一个 JDBC 连接存储到一个每线程 Singleton 中
public class ConnectionDispenser {
private static class ThreadLocalConnection extends ThreadLocal {
public Object initialValue() {
return DriverManager.getConnection(ConfigurationSingleton.getDbUrl());
}
}
private ThreadLocalConnection conn = new ThreadLocalConnection();
public static Connection getConnection() {
return (Connection) conn.get();
}
}
|
任何创建的花费比使用的花费相对昂贵些的有状态或非线程安全的对象,例如 JDBC Connection 或正则表达式匹配器,都是可以使用每线程单子(singleton)技术的好地方。当然,在类似这样的地方,您可以使用其它技术,例如用池,来安全地管理共享访问。然而,从可伸缩性角度看,即使是用池也存在一些潜在缺陷。因为池实现必须使用同步,以维护池数据结构的完整性,如果所有线程使用同一个池,那么在有很多线程频繁地对池进行访问的系统中,程序性能将因争用而降低。
用 ThreadLocal 简化调试日志纪录
其它适合使用 ThreadLocal 但用池却不能成为很好的替代技术的应用程序包括存储或累积每线程上下文信息以备稍后检索之用这样的应用程序。例如,假设您想创建一个用于管理多线程应用程序调试信息的工具。您可以用如清单 4 所示的 DebugLogger 类作为线程局部容器来累积调试信息。在一个工作单元的开头,您清空容器,而当一个错误出现时,您查询该容器以检索这个工作单元迄今为止生成的所有调试信息。
清单 4. 用 ThreadLocal 管理每线程调试日志
public class DebugLogger {
private static class ThreadLocalList extends ThreadLocal {
public Object initialValue() {
return new ArrayList();
}
public List getList() {
return (List) super.get();
}
}
private ThreadLocalList list = new ThreadLocalList();
private static String[] stringArray = new String[0];
public void clear() {
list.getList().clear();
}
public void put(String text) {
list.getList().add(text);
}
public String[] get() {
return list.getList().toArray(stringArray);
}
}
|
在您的代码中,您可以调用 DebugLogger.put() 来保存您的程序正在做什么的信息,而且,稍后如果有必要(例如发生了一个错误),您能够容易地检索与某个特定线程相关的调试信息。 与简单地把所有信息转储到一个日志文件,然后努力找出哪个日志记录来自哪个线程(还要担心线程争用日志纪录对象)相比,这种技术简便得多,也有效得多。
ThreadLocal
在基于 servlet 的应用程序或工作单元是一个整体请求的任何多线程应用程序服务器中也是很有用的,因为在处理请求的整个过程中将要用到单个线程。您可以通过前面讲述的每线程单子技术用 ThreadLocal 变量来存储各种每请求(per-request)上下文信息。
ThreadLocal 的线程安全性稍差的堂兄弟,InheritableThreadLocal
ThreadLocal 类有一个亲戚,InheritableThreadLocal,它以相似的方式工作,但适用于种类完全不同的应用程序。创建一个线程时如果保存了所有 InheritableThreadLocal 对象的值,那么这些值也将自动传递给子线程。如果一个子线程调用 InheritableThreadLocal 的 get() ,那么它将与它的父线程看到同一个对象。为保护线程安全性,您应该只对不可变对象(一旦创建,其状态就永远不会被改变的对象)使用 InheritableThreadLocal ,因为对象被多个线程共享。 InheritableThreadLocal 很合适用于把数据从父线程传到子线程,例如用户标识(user id)或事务标识(transaction id),但不能是有状态对象,例如 JDBC Connection 。
ThreadLocal 的性能
虽然线程局部变量早已赫赫有名并被包括 Posix pthreads 规范在内的很多线程框架支持,但最初的 Java 线程设计中却省略了它,只是在 Java 平台的版本 1.2 中才添加上去。在很多方面, ThreadLocal 仍在发展之中;在版本 1.3 中它被重写,版本 1.4 中又重写了一次,两次都专门是为了性能问题。
在 JDK 1.2 中, ThreadLocal 的实现方式与清单 2 中的方式非常相似,除了用同步 WeakHashMap 代替 HashMap 来存储 values 之外。(以一些额外的性能开销为代价,使用 WeakHashMap 解决了无法对 Thread 对象进行垃圾回收的问题。)不用说, ThreadLocal 的性能是相当差的。
Java 平台版本 1.3 提供的 ThreadLocal 版本已经尽量更好了;它不使用任何同步,从而不存在可伸缩性问题,而且它也不使用弱引用。相反地,人们通过给 Thread 添加一个实例变量(该变量用于保存当前线程的从线程局部变量到它的值的映射的 HashMap )来修改 Thread 类以支持 ThreadLocal 。因为检索或设置一个线程局部变量的过程不涉及对可能被另一个线程读写的数据的读写操作,所以您可以不用任何同步就实现 ThreadLocal.get() 和 set() 。而且,因为每线程值的引用被存储在自已的 Thread 对象中,所以当对 Thread 进行垃圾回收时,也能对该 Thread 的每线程值进行垃圾回收。
不幸的是,即使有了这些改进,Java 1.3 中的 ThreadLocal 的性能仍然出奇地慢。据我的粗略测量,在双处理器 Linux 系统上的 Sun 1.3 JDK 中进行 ThreadLocal.get() 操作,所耗费的时间大约是无争用同步的两倍。性能这么差的原因是 Thread.currentThread() 方法的花费非常大,占了 ThreadLocal.get() 运行时间的三分之二还多。虽然有这些缺点,JDK 1.3 ThreadLocal.get() 仍然比争用同步快得多,所以如果在任何存在严重争用的地方(可能是有非常多的线程,或者同步块被频繁地执行,或者同步块很大), ThreadLocal 可能仍然要高效得多。
在 Java 平台的最新版本,即版本 1.4b2 中, ThreadLocal 和 Thread.currentThread() 的性能都有了很大提高。有了这些提高, ThreadLocal 应该比其它技术,如用池,更快。由于它比其它技术更简单,也更不易出错,人们最终将发现它是避免线程间出现不希望的交互的有效途径。
ThreadLocal 的好处
ThreadLocal
能带来很多好处。它常常是把有状态类描绘成线程安全的,或者封装非线程安全类以使它们能够在多线程环境中安全地使用的最容易的方式。使用 ThreadLocal 使我们可以绕过为实现线程安全而对何时需要同步进行判断的复杂过程,而且因为它不需要任何同步,所以也改善了可伸缩性。除简单之外,用 ThreadLocal 存储每线程单子或每线程上下文信息在归档方面还有一个颇有价值好处 — 通过使用 ThreadLocal ,存储在 ThreadLocal 中的对象都是 不被线程共享的是清晰的,从而简化了判断一个类是否线程安全的工作。
Java™ 5.0 第一次让使用 Java 语言开发非阻塞算法成为可能,java.util.concurrent 包充分地利用了这个功能。非阻塞算法属于并发算法,它们可以安全地派生它们的线程,不通过锁定派生,而是通过低级的原子性的硬件原生形式 —— 例如比较和交换。非阻塞算法的设计与实现极为困难,但是它们能够提供更好的吞吐率,对生存问题(例如死锁和优先级反转)也能提供更好的防御。在这期的 Java 理论与实践 中,并发性大师 Brian Goetz 演示了几种比较简单的非阻塞算法的工作方式。
在不只一个线程访问一个互斥的变量时,所有线程都必须使用同步,否则就可能会发生一些非常糟糕的事情。Java 语言中主要的同步手段就是 synchronized 关键字(也称为内在锁),它强制实行互斥,确保执行 synchronized 块的线程的动作,能够被后来执行受相同锁保护的 synchronized 块的其他线程看到。在使用得当的时候,内在锁可以让程序做到线程安全,但是在使用锁定保护短的代码路径,而且线程频繁地争用锁的时候,锁定可能成为相当繁重的操作。
在 “流行的原子” 一文中,我们研究了原子变量,原子变量提供了原子性的读-写-修改操作,可以在不使用锁的情况下安全地更新共享变量。原子变量的内存语义与 volatile 变量类似,但是因为它们也可以被原子性地修改,所以可以把它们用作不使用锁的并发算法的基础。
非阻塞的计数器
清单 1 中的 Counter 是线程安全的,但是使用锁的需求带来的性能成本困扰了一些开发人员。但是锁是必需的,因为虽然增加看起来是单一操作,但实际是三个独立操作的简化:检索值,给值加 1,再写回值。(在 getValue 方法上也需要同步,以保证调用 getValue 的线程看到的是最新的值。虽然许多开发人员勉强地使自己相信忽略锁定需求是可以接受的,但忽略锁定需求并不是好策略。)
在多个线程同时请求同一个锁时,会有一个线程获胜并得到锁,而其他线程被阻塞。JVM 实现阻塞的方式通常是挂起阻塞的线程,过一会儿再重新调度它。由此造成的上下文切换相对于锁保护的少数几条指令来说,会造成相当大的延迟。
清单 1. 使用同步的线程安全的计数器
public final class Counter {
private long value = 0;
public synchronized long getValue() {
return value;
}
public synchronized long increment() {
return ++value;
}
}
|
清单 2 中的 NonblockingCounter 显示了一种最简单的非阻塞算法:使用 AtomicInteger 的 compareAndSet() (CAS)方法的计数器。compareAndSet() 方法规定 “将这个变量更新为新值,但是如果从我上次看到这个变量之后其他线程修改了它的值,那么更新就失败”(请参阅 “流行的原子” 获得关于原子变量以及 “比较和设置” 的更多解释。)
清单 2. 使用 CAS 的非阻塞算法
public class NonblockingCounter {
private AtomicInteger value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
v = value.get();
while (!value.compareAndSet(v, v + 1));
return v + 1;
}
}
|
原子变量类之所以被称为原子的,是因为它们提供了对数字和对象引用的细粒度的原子更新,但是在作为非阻塞算法的基本构造块的意义上,它们也是原子的。非阻塞算法作为科研的主题,已经有 20 多年了,但是直到 Java 5.0 出现,在 Java 语言中才成为可能。
现代的处理器提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。(如果要做的只是递增计数器,那么 AtomicInteger 提供了进行递增的方法,但是这些方法基于 compareAndSet() ,例如 NonblockingCounter.increment() )。
非阻塞版本相对于基于锁的版本有几个性能优势。首先,它用硬件的原生形态代替 JVM 的锁定代码路径,从而在更细的粒度层次上(独立的内存位置)进行同步,失败的线程也可以立即重试,而不会被挂起后重新调度。更细的粒度降低了争用的机会,不用重新调度就能重试的能力也降低了争用的成本。即使有少量失败的 CAS 操作,这种方法仍然会比由于锁争用造成的重新调度快得多。
NonblockingCounter
这个示例可能简单了些,但是它演示了所有非阻塞算法的一个基本特征 —— 有些算法步骤的执行是要冒险的,因为知道如果 CAS 不成功可能不得不重做。非阻塞算法通常叫作乐观算法,因为它们继续操作的假设是不会有干扰。如果发现干扰,就会回退并重试。在计数器的示例中,冒险的步骤是递增 —— 它检索旧值并在旧值上加一,希望在计算更新期间值不会变化。如果它的希望落空,就会再次检索值,并重做递增计算。
非阻塞堆栈
非阻塞算法稍微复杂一些的示例是清单 3 中的 ConcurrentStack 。ConcurrentStack 中的 push() 和 pop() 操作在结构上与 NonblockingCounter 上相似,只是做的工作有些冒险,希望在 “提交” 工作的时候,底层假设没有失效。push() 方法观察当前最顶的节点,构建一个新节点放在堆栈上,然后,如果最顶端的节点在初始观察之后没有变化,那么就安装新节点。如果 CAS 失败,意味着另一个线程已经修改了堆栈,那么过程就会重新开始。
清单 3. 使用 Treiber 算法的非阻塞堆栈
public class ConcurrentStack<E> {
AtomicReference<Node<E>> head = new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = head.get();
newHead.next = oldHead;
} while (!head.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = head.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!head.compareAndSet(oldHead,newHead));
return oldHead.item;
}
static class Node<E> {
final E item;
Node<E> next;
public Node(E item) { this.item = item; }
}
}
|
性能考虑
在轻度到中度的争用情况下,非阻塞算法的性能会超越阻塞算法,因为 CAS 的多数时间都在第一次尝试时就成功,而发生争用时的开销也不涉及线程挂起和上下文切换,只多了几个循环迭代。没有争用的 CAS 要比没有争用的锁便宜得多(这句话肯定是真的,因为没有争用的锁涉及 CAS 加上额外的处理),而争用的 CAS 比争用的锁获取涉及更短的延迟。
在高度争用的情况下(即有多个线程不断争用一个内存位置的时候),基于锁的算法开始提供比非阻塞算法更好的吞吐率,因为当线程阻塞时,它就会停止争用,耐心地等候轮到自己,从而避免了进一步争用。但是,这么高的争用程度并不常见,因为多数时候,线程会把线程本地的计算与争用共享数据的操作分开,从而给其他线程使用共享数据的机会。(这么高的争用程度也表明需要重新检查算法,朝着更少共享数据的方向努力。)“流行的原子” 中的图在这方面就有点儿让人困惑,因为被测量的程序中发生的争用极其密集,看起来即使对数量很少的线程,锁定也是更好的解决方案。
非阻塞的链表
目前为止的示例(计数器和堆栈)都是非常简单的非阻塞算法,一旦掌握了在循环中使用 CAS,就可以容易地模仿它们。对于更复杂的数据结构,非阻塞算法要比这些简单示例复杂得多,因为修改链表、树或哈希表可能涉及对多个指针的更新。CAS 支持对单一指针的原子性条件更新,但是不支持两个以上的指针。所以,要构建一个非阻塞的链表、树或哈希表,需要找到一种方式,可以用 CAS 更新多个指针,同时不会让数据结构处于不一致的状态。
在链表的尾部插入元素,通常涉及对两个指针的更新:“尾” 指针总是指向列表中的最后一个元素,“下一个” 指针从过去的最后一个元素指向新插入的元素。因为需要更新两个指针,所以需要两个 CAS。在独立的 CAS 中更新两个指针带来了两个需要考虑的潜在问题:如果第一个 CAS 成功,而第二个 CAS 失败,会发生什么?如果其他线程在第一个和第二个 CAS 之间企图访问链表,会发生什么?
对于非复杂数据结构,构建非阻塞算法的 “技巧” 是确保数据结构总处于一致的状态(甚至包括在线程开始修改数据结构和它完成修改之间),还要确保其他线程不仅能够判断出第一个线程已经完成了更新还是处在更新的中途,还能够判断出如果第一个线程走向 AWOL,完成更新还需要什么操作。如果线程发现了处在更新中途的数据结构,它就可以 “帮助” 正在执行更新的线程完成更新,然后再进行自己的操作。当第一个线程回来试图完成自己的更新时,会发现不再需要了,返回即可,因为 CAS 会检测到帮助线程的干预(在这种情况下,是建设性的干预)。
这种 “帮助邻居” 的要求,对于让数据结构免受单个线程失败的影响,是必需的。如果线程发现数据结构正处在被其他线程更新的中途,然后就等候其他线程完成更新,那么如果其他线程在操作中途失败,这个线程就可能永远等候下去。即使不出现故障,这种方式也会提供糟糕的性能,因为新到达的线程必须放弃处理器,导致上下文切换,或者等到自己的时间片过期(而这更糟)。
清单 4 的 LinkedQueue 显示了 Michael-Scott 非阻塞队列算法的插入操作,它是由 ConcurrentLinkedQueue 实现的:
清单 4. Michael-Scott 非阻塞队列算法中的插入
public class LinkedQueue <E> {
private static class Node <E> {
final E item;
final AtomicReference<Node<E>> next;
Node(E item, Node<E> next) {
this.item = item;
this.next = new AtomicReference<Node<E>>(next);
}
}
private AtomicReference<Node<E>> head
= new AtomicReference<Node<E>>(new Node<E>(null, null));
private AtomicReference<Node<E>> tail = head;
public boolean put(E item) {
Node<E> newNode = new Node<E>(item, null);
while (true) {
Node<E> curTail = tail.get();
Node<E> residue = curTail.next.get();
if (curTail == tail.get()) {
if (residue == null) /* A */ {
if (curTail.next.compareAndSet(null, newNode)) /* C */ {
tail.compareAndSet(curTail, newNode) /* D */ ;
return true;
}
} else {
tail.compareAndSet(curTail, residue) /* B */;
}
}
}
}
}
|
像许多队列算法一样,空队列只包含一个假节点。头指针总是指向假节点;尾指针总指向最后一个节点或倒数第二个节点。图 1 演示了正常情况下有两个元素的队列:
图 1. 有两个元素,处在静止状态的队列
如 清单 4 所示,插入一个元素涉及两个指针更新,这两个更新都是通过 CAS 进行的:从队列当前的最后节点(C)链接到新节点,并把尾指针移动到新的最后一个节点(D)。如果第一步失败,那么队列的状态不变,插入线程会继续重试,直到成功。一旦操作成功,插入被当成生效,其他线程就可以看到修改。还需要把尾指针移动到新节点的位置上,但是这项工作可以看成是 “清理工作”,因为任何处在这种情况下的线程都可以判断出是否需要这种清理,也知道如何进行清理。
队列总是处于两种状态之一:正常状态(或称静止状态,图 1 和 图 3)或中间状态(图 2)。在插入操作之前和第二个 CAS(D)成功之后,队列处在静止状态;在第一个 CAS(C)成功之后,队列处在中间状态。在静止状态时,尾指针指向的链接节点的 next 字段总为 null,而在中间状态时,这个字段为非 null。任何线程通过比较 tail.next 是否为 null,就可以判断出队列的状态,这是让线程可以帮助其他线程 “完成” 操作的关键。
图 2. 处在插入中间状态的队列,在新元素插入之后,尾指针更新之前
插入操作在插入新元素(A)之前,先检查队列是否处在中间状态,如 清单 4 所示。如果是在中间状态,那么肯定有其他线程已经处在元素插入的中途,在步骤(C)和(D)之间。不必等候其他线程完成,当前线程就可以 “帮助” 它完成操作,把尾指针向前移动(B)。如果有必要,它还会继续检查尾指针并向前移动指针,直到队列处于静止状态,这时它就可以开始自己的插入了。
第一个 CAS(C)可能因为两个线程竞争访问队列当前的最后一个元素而失败;在这种情况下,没有发生修改,失去 CAS 的线程会重新装入尾指针并再次尝试。如果第二个 CAS(D)失败,插入线程不需要重试 —— 因为其他线程已经在步骤(B)中替它完成了这个操作!
图 3. 在尾指针更新后,队列重新处在静止状态
幕后的非阻塞算法
如果深入 JVM 和操作系统,会发现非阻塞算法无处不在。垃圾收集器使用非阻塞算法加快并发和平行的垃圾搜集;调度器使用非阻塞算法有效地调度线程和进程,实现内在锁。在 Mustang(Java 6.0)中,基于锁的 SynchronousQueue 算法被新的非阻塞版本代替。很少有开发人员会直接使用 SynchronousQueue ,但是通过 Executors.newCachedThreadPool() 工厂构建的线程池用它作为工作队列。比较缓存线程池性能的对比测试显示,新的非阻塞同步队列实现提供了几乎是当前实现 3 倍的速度。在 Mustang 的后续版本(代码名称为 Dolphin)中,已经规划了进一步的改进。
结束语
非阻塞算法要比基于锁的算法复杂得多。开发非阻塞算法是相当专业的训练,而且要证明算法的正确也极为困难。但是在 Java 版本之间并发性能上的众多改进来自对非阻塞算法的采用,而且随着并发性能变得越来越重要,可以预见在 Java 平台的未来发行版中,会使用更多的非阻塞算法。
JDK 5.0 中增加的泛型类型,是 Java 语言中类型安全的一次重要改进。但是,对于初次使用泛型类型的用户来说,泛型的某些方面看起来可能不容易明白,甚至非常奇怪。在本月的“Java 理论和实践”中,Brian Goetz 分析了束缚第一次使用泛型的用户的常见陷阱。您可以通过讨论论坛与作者和其他读者分享您对本文的看法。(也可以单击本文顶端或底端的讨论来访问这个论坛。)
表面上看起来,无论语法还是应用的环境(比如容器类),泛型类型(或者泛型)都类似于 C++ 中的模板。但是这种相似性仅限于表面,Java 语言中的泛型基本上完全在编译器中实现,由编译器执行类型检查和类型推断,然后生成普通的非泛型的字节码。这种实现技术称为擦除(erasure)(编译器使用泛型类型信息保证类型安全,然后在生成字节码之前将其清除),这项技术有一些奇怪,并且有时会带来一些令人迷惑的后果。虽然范型是 Java 类走向类型安全的一大步,但是在学习使用泛型的过程中几乎肯定会遇到头痛(有时候让人无法忍受)的问题。
注意:本文假设您对 JDK 5.0 中的范型有基本的了解。
泛型不是协变的
虽然将集合看作是数组的抽象会有所帮助,但是数组还有一些集合不具备的特殊性质。Java 语言中的数组是协变的(covariant),也就是说,如果 Integer 扩展了 Number (事实也是如此),那么不仅 Integer 是 Number ,而且 Integer[] 也是 Number[] ,在要求 Number[] 的地方完全可以传递或者赋予 Integer[] 。(更正式地说,如果 Number 是 Integer 的超类型,那么 Number[] 也是 Integer[] 的超类型)。您也许认为这一原理同样适用于泛型类型 —— List<Number> 是 List<Integer> 的超类型,那么可以在需要 List<Number> 的地方传递 List<Integer> 。不幸的是,情况并非如此。
不允许这样做有一个很充分的理由:这样做将破坏要提供的类型安全泛型。如果能够将 List<Integer> 赋给 List<Number> 。那么下面的代码就允许将非 Integer 的内容放入 List<Integer> :
List<Integer> li = new ArrayList<Integer>();
List<Number> ln = li; // illegal
ln.add(new Float(3.1415));
|
因为 ln 是 List<Number> ,所以向其添加 Float 似乎是完全合法的。但是如果 ln 是 li 的别名,那么这就破坏了蕴含在 li 定义中的类型安全承诺 —— 它是一个整数列表,这就是泛型类型不能协变的原因。
其他的协变问题
数组能够协变而泛型不能协变的另一个后果是,不能实例化泛型类型的数组(new List<String>[3] 是不合法的),除非类型参数是一个未绑定的通配符(new List<?>[3] 是合法的)。让我们看看如果允许声明泛型类型数组会造成什么后果:
List<String>[] lsa = new List<String>[10]; // illegal
Object[] oa = lsa; // OK because List<String> is a subtype of Object
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[0] = li;
String s = lsa[0].get(0);
|
最后一行将抛出 ClassCastException ,因为这样将把 List<Integer> 填入本应是 List<String> 的位置。因为数组协变会破坏泛型的类型安全,所以不允许实例化泛型类型的数组(除非类型参数是未绑定的通配符,比如 List<?> )。
构造延迟
因为可以擦除功能,所以 List<Integer> 和 List<String> 是同一个类,编译器在编译 List<V> 时只生成一个类(和 C++ 不同)。因此,在编译 List<V> 类时,编译器不知道 V 所表示的类型,所以它就不能像知道类所表示的具体类型那样处理 List<V> 类定义中的类型参数(List<V> 中的 V )。
因为运行时不能区分 List<String> 和 List<Integer> (运行时都是 List ),用泛型类型参数标识类型的变量的构造就成了问题。运行时缺乏类型信息,这给泛型容器类和希望创建保护性副本的泛型类提出了难题。
比如泛型类 Foo :
class Foo<T> {
public void doSomething(T param) { ... }
}
|
假设 doSomething() 方法希望复制输入的 param 参数,会怎么样呢?没有多少选择。您可能希望按以下方式实现 doSomething() :
public void doSomething(T param) {
T copy = new T(param); // illegal
}
|
但是您不能使用类型参数访问构造函数,因为在编译的时候还不知道要构造什么类,因此也就不知道使用什么构造函数。使用泛型不能表达“T 必须拥有一个拷贝构造函数(copy constructor)”(甚至一个无参数的构造函数)这类约束,因此不能使用泛型类型参数所表示的类的构造函数。
clone()
怎么样呢?假设在 Foo 的定义中,T 扩展了 Cloneable :
class Foo<T extends Cloneable> {
public void doSomething(T param) {
T copy = (T) param.clone(); // illegal
}
}
|
不幸的是,仍然不能调用 param.clone() 。为什么呢?因为 clone() 在 Object 中是保护访问的,调用 clone() 必须通过将 clone() 改写公共访问的类引用来完成。但是重新声明 clone() 为 public 并不知道 T ,因此克隆也无济于事。
构造通配符引用
因此,不能复制在编译时根本不知道是什么类的类型引用。那么使用通配符类型怎么样?假设要创建类型为 Set<?> 的参数的保护性副本。您知道 Set 有一个拷贝构造函数。而且别人可能曾经告诉过您,如果不知道要设置的内容的类型,最好使用 Set<?> 代替原始类型的 Set ,因为这种方法引起的未检查类型转换警告更少。于是,可以试着这样写:
class Foo {
public void doSomething(Set<?> set) {
Set<?> copy = new HashSet<?>(set); // illegal
}
}
|
不幸的是,您不能用通配符类型的参数调用泛型构造函数,即使知道存在这样的构造函数也不行。不过您可以这样做:
class Foo {
public void doSomething(Set<?> set) {
Set<?> copy = new HashSet<Object>(set);
}
}
|
这种构造不那么直观,但它是类型安全的,而且可以像 new HashSet<?>(set) 那样工作。
构造数组
如何实现 ArrayList<V> ?假设类 ArrayList 管理一个 V 数组,您可能希望用 ArrayList<V> 的构造函数创建一个 V 数组:
class ArrayList<V> {
private V[] backingArray;
public ArrayList() {
backingArray = new V[DEFAULT_SIZE]; // illegal
}
}
|
但是这段代码不能工作 —— 不能实例化用类型参数表示的类型数组。编译器不知道 V 到底表示什么类型,因此不能实例化 V 数组。
Collections 类通过一种别扭的方法绕过了这个问题,在 Collections 类编译时会产生类型未检查转换的警告。ArrayList 具体实现的构造函数如下:
class ArrayList<V> {
private V[] backingArray;
public ArrayList() {
backingArray = (V[]) new Object[DEFAULT_SIZE];
}
}
|
为何这些代码在访问 backingArray 时没有产生 ArrayStoreException 呢?无论如何,都不能将 Object 数组赋给 String 数组。因为泛型是通过擦除实现的,backingArray 的类型实际上就是 Object[] ,因为 Object 代替了 V 。这意味着:实际上这个类期望 backingArray 是一个 Object 数组,但是编译器要进行额外的类型检查,以确保它包含 V 类型的对象。所以这种方法很奏效,但是非常别扭,因此不值得效仿(甚至连泛型 Collections 框架的作者都这么说,请参阅参考资料)。
还有一种方法就是声明 backingArray 为 Object 数组,并在使用它的各个地方强制将它转化为 V[] 。仍然会看到类型未检查转换警告(与上一种方法一样),但是它使一些未明确的假设更清楚了(比如 backingArray 不应逃避 ArrayList 的实现)。
其他方法
最好的办法是向构造函数传递类文字(Foo.class ),这样,该实现就能在运行时知道 T 的值。不采用这种方法的原因在于向后兼容性 —— 新的泛型集合类不能与 Collections 框架以前的版本兼容。
下面的代码中 ArrayList 采用了以下方法:
public class ArrayList<V> implements List<V> {
private V[] backingArray;
private Class<V> elementType;
public ArrayList(Class<V> elementType) {
this.elementType = elementType;
backingArray = (V[]) Array.newInstance(elementType, DEFAULT_LENGTH);
}
}
|
但是等一等!仍然有不妥的地方,调用 Array.newInstance() 时会引起未经检查的类型转换。为什么呢?同样是由于向后兼容性。Array.newInstance() 的签名是:
public static Object newInstance(Class<?> componentType, int length)
|
而不是类型安全的:
public static<T> T[] newInstance(Class<T> componentType, int length)
|
为何 Array 用这种方式进行泛化呢?同样是为了保持向后兼容。要创建基本类型的数组,如 int[] ,可以使用适当的包装器类中的 TYPE 字段调用 Array.newInstance() (对于 int ,可以传递 Integer.TYPE 作为类文字)。用 Class<T> 参数而不是 Class<?> 泛化 Array.newInstance() ,对于引用类型有更好的类型安全,但是就不能使用 Array.newInstance() 创建基本类型数组的实例了。也许将来会为引用类型提供新的 newInstance() 版本,这样就两者兼顾了。
在这里可以看到一种模式 —— 与泛型有关的很多问题或者折衷并非来自泛型本身,而是保持和已有代码兼容的要求带来的副作用。
泛化已有的类
在转化现有的库类来使用泛型方面没有多少技巧,但与平常的情况相同,向后兼容性不会凭空而来。我已经讨论了两个例子,其中向后兼容性限制了类库的泛化。
另一种不同的泛化方法可能不存在向后兼容问题,这就是 Collections.toArray(Object[]) 。传入 toArray() 的数组有两个目的 —— 如果集合足够小,那么可以将其内容直接放在提供的数组中。否则,利用反射(reflection)创建相同类型的新数组来接受结果。如果从头开始重写 Collections 框架,那么很可能传递给 Collections.toArray() 的参数不是一个数组,而是一个类文字:
interface Collection<E> {
public T[] toArray(Class<T super E> elementClass);
}
|
因为 Collections 框架作为良好类设计的例子被广泛效仿,但是它的设计受到向后兼容性约束,所以这些地方值得您注意,不要盲目效仿。
首先,常常被混淆的泛型 Collections API 的一个重要方面是 containsAll() 、removeAll() 和 retainAll() 的签名。您可能认为 remove() 和 removeAll() 的签名应该是:
interface Collection<E> {
public boolean remove(E e); // not really
public void removeAll(Collection<? extends E> c); // not really
}
|
但实际上却是:
interface Collection<E> {
public boolean remove(Object o);
public void removeAll(Collection<?> c);
}
|
为什么呢?答案同样是因为向后兼容性。x.remove(o) 的接口表明“如果 o 包含在 x 中,则删除它,否则什么也不做。”如果 x 是一个泛型集合,那么 o 不一定与 x 的类型参数兼容。如果 removeAll() 被泛化为只有类型兼容时才能调用(Collection<? extends E> ),那么在泛化之前,合法的代码序列就会变得不合法,比如:
// a collection of Integers
Collection c = new HashSet();
// a collection of Objects
Collection r = new HashSet();
c.removeAll(r);
|
如果上述片段用直观的方法泛化(将 c 设为 Collection<Integer> ,r 设为 Collection<Object> ),如果 removeAll() 的签名要求其参数为 Collection<? extends E> 而不是 no-op,那么就无法编译上面的代码。泛型类库的一个主要目标就是不打破或者改变已有代码的语义,因此,必须用比从头重新设计泛型所使用类型约束更弱的类型约束来定义 remove() 、removeAll() 、retainAll() 和 containsAll() 。
在泛型之前设计的类可能阻碍了“显然的”泛型化方法。这种情况下就要像上例这样进行折衷,但是如果从头设计新的泛型类,理解 Java 类库中的哪些东西是向后兼容的结果很有意义,这样可以避免不适当的模仿。
擦除的实现
因为泛型基本上都是在 Java 编译器中而不是运行库中实现的,所以在生成字节码的时候,差不多所有关于泛型类型的类型信息都被“擦掉”了。换句话说,编译器生成的代码与您手工编写的不用泛型、检查程序的类型安全后进行强制类型转换所得到的代码基本相同。与 C++ 不同,List<Integer> 和 List<String> 是同一个类(虽然是不同的类型但都是 List<?> 的子类型,与以前的版本相比,在 JDK 5.0 中这是一个更重要的区别)。
擦除意味着一个类不能同时实现 Comparable<String> 和 Comparable<Number> ,因为事实上两者都在同一个接口中,指定同一个 compareTo() 方法。声明 DecimalString 类以便与 String 与 Number 比较似乎是明智的,但对于 Java 编译器来说,这相当于对同一个方法进行了两次声明:
public class DecimalString implements Comparable<Number>, Comparable<String> { ... } // nope
|
擦除的另一个后果是,对泛型类型参数是用强制类型转换或者 instanceof 毫无意义。下面的代码完全不会改善代码的类型安全性:
public <T> T naiveCast(T t, Object o) { return (T) o; }
|
编译器仅仅发出一个类型未检查转换警告,因为它不知道这种转换是否安全。naiveCast() 方法实际上根本不作任何转换,T 直接被替换为 Object ,与期望的相反,传入的对象被强制转换为 Object 。
擦除也是造成上述构造问题的原因,即不能创建泛型类型的对象,因为编译器不知道要调用什么构造函数。如果泛型类需要构造用泛型类型参数来指定类型的对象,那么构造函数应该接受类文字(Foo.class )并将它们保存起来,以便通过反射创建实例。
设计目标 Ø 提供一个线程池的组件,具有良好的伸缩性,当线程够用时,销毁不用线程,当线程不够用时,自动增加线程数量; Ø 提供一个工作任务接口和工作队列,实际所需要的任务都必须实现这个工作任务接口,然后放入工作队列中; Ø 线程池中的线程从工作队列中,自动取得工作任务,执行任务。 主要控制类和功能接口设计 线程池管理器 ThreadPoolManager 的功能: Ø 管理线程池中的各个属性变量 ü 最大工作线程数 ü 最小工作线程数 ü 激活的工作线程总数 ü 睡眠的工作线程总数 ü 工作线程总数 (即:激活的工作线程总数+睡眠的工作线程总数) Ø 创建工作线程 Ø 销毁工作线程 Ø 启动处于睡眠的工作线程 Ø 睡眠处于激活的工作线程 Ø 缩任务:当工作线程总数小于或等于最小工作线程数时,销毁多余的睡眠的工作线程,使得现有工作线程总数等于最小工作任务总数 Ø 伸任务:当任务队列任务总数大于工作线程数时,增加工作线程总数至最大工作线程数 Ø 提供线程池启动接口 Ø 提供线程池销毁接口 工作线程 WorkThread 的功能: Ø 从工作队列取得工作任务 Ø 执行工作任务接口中的指定任务 工作任务接口 ITask 的功能: Ø 提供指定任务动作 工作队列 IWorkQueue 的功能: Ø 提供获取任务接口,并删除工作队列中的任务; Ø 提供加入任务接口; Ø 提供删除任务接口; Ø 提供取得任务总数接口; Ø 提供自动填任务接口;(当任务总数少于或等于默认总数的25%时,自动装填) Ø 提供删除所有任务接口; Code ThreadPoolManager: ===================================== CODE:
package test.thread.pool1;
import java.util.ArrayList;
import java.util.List;
import test.thread.pool1.impl.MyWorkQueue;
/**
* <p>Title: 线程池管理器</p>
* <p>Description: </p>
* <p>Copyright: Copyright (c) 2005</p>
* <p>Company: </p>
* @author not attributable
* @version 1.0
*/
public class ThreadPoolManager {
/*最大线程数*/
private int threads_max_num;
/*最小线程数*/
private int threads_min_num;
/* 线程池线程增长步长 */
private int threads_increase_step = 5;
/* 任务工作队列 */
private IWorkQueue queue;
/* 线程池监视狗 */
private PoolWatchDog poolWatchDog ;
/* 队列线程 */
private Thread queueThread ;
/* 线程池 封装所有工作线程的数据结构 */
private List pool = new ArrayList();
/* 线程池中 封装所有钝化后的数据结构*/
private List passivePool = new ArrayList();
/* 空闲60秒 */
private static final long IDLE_TIMEOUT = 60000L;
/* 关闭连接池标志位 */
private boolean close = false;
/**
* 线程池管理器
* @param queue 任务队列
* @param threads_min_num 工作线程最小数
* @param threads_max_num 工作线程最大数
*/
public ThreadPoolManager(int threads_max_num
,int threads_min_num
,IWorkQueue queue){
this.threads_max_num = threads_max_num;
this.threads_min_num = threads_min_num;
this.queue = queue;
}
/**
* 线程池启动
*/
public void startPool(){
System.out.println("=== startPool..........");
poolWatchDog = new PoolWatchDog("PoolWatchDog");
poolWatchDog.setDaemon(true);
poolWatchDog.start();
System.out.println("=== startPool..........over");
}
/**
* 线程池销毁接口
*/
public void destoryPool(){
System.out.println("==========================DestoryPool starting ...");
this.close = true;
int pool_size = this.pool.size();
//中断队列线程
System.out.println("===Interrupt queue thread ... ");
queueThread.interrupt();
queueThread = null;
System.out.println("===Interrupt thread pool ... ");
Thread pool_thread = null;
for(int i=0; i<pool_size; i++){
pool_thread = (Thread)pool.get(i);
if(pool_thread !=null
&& pool_thread.isAlive()
&& !pool_thread.isInterrupted()){
pool_thread.interrupt();
System.out.println("Stop pool_thread:"
+pool_thread.getName()+"[interrupt] "
+pool_thread.isInterrupted());
}
}//end for
if(pool != null){
pool.clear();
}
if(passivePool != null){
pool.clear();
}
try{
System.out.println("=== poolWatchDog.join() starting ...");
poolWatchDog.join();
System.out.println("=== poolWatchDog.join() is over ...");
}
catch(Throwable ex){
System.out.println("###poolWatchDog ... join method throw a exception ... "
+ex.toString());
}
poolWatchDog =null;
System.out.println("==============================DestoryPool is over ...");
}
public static void main(String[] args) throws Exception{
ThreadPoolManager threadPoolManager1 = new ThreadPoolManager(10,5,new MyWorkQueue(50,30000));
threadPoolManager1.startPool();
Thread.sleep(60000);
threadPoolManager1.destoryPool();
}
/**
* 线程池监视狗
*/
private class PoolWatchDog extends Thread{
public PoolWatchDog(String name){
super(name);
}
public void run(){
Thread workThread = null;
Runnable run = null;
//开启任务队列线程,获取数据--------
System.out.println("===QueueThread starting ... ... ");
queueThread = new Thread(new QueueThread(),"QueueThread");
queueThread.start();
System.out.println("===Initial thread Pool ... ...");
//初始化线程池的最小线程数,并放入池中
for(int i=0; i<threads_min_num; i++){
run = new WorkThread();
workThread = new Thread(run,"WorkThread_"+System.currentTimeMillis()+i);
workThread.start();
if(i == threads_min_num -1){
workThread = null;
run = null;
}
}
System.out.println("===Initial thread Pool..........over ,and get pool's size:"+pool.size());
//线程池线程动态增加线程算法--------------
while(!close){
//等待5秒钟,等上述线程都启动----------
synchronized(this){
try{
System.out.println("===Wait the [last time] threads starting ....");
this.wait(15000);
}
catch(Throwable ex){
System.out.println("###PoolWatchDog invoking is failure ... "+ex);
}
}//end synchronized
//开始增加线程-----------------------spread动作
int queue_size = queue.getTaskSize();
int temp_size = (queue_size - threads_min_num);
if((temp_size > 0) && (temp_size/threads_increase_step > 2) ){
System.out.println("================Spread thread pool starting ....");
for(int i=0; i<threads_increase_step && (pool.size() < threads_max_num); i++){
System.out.println("=== Spread thread num : "+i);
run = new WorkThread();
workThread = new Thread(run,"WorkThread_"+System.currentTimeMillis()+i);
workThread.start();
}//end for
workThread = null;
run = null;
System.out.println("===Spread thread pool is over .... and pool size:"+pool.size());
}//end if
//删除已经多余的睡眠线程-------------shrink动作
int more_sleep_size = pool.size() - threads_min_num;//最多能删除的线程数
int sleep_threads_size = passivePool.size();
if(more_sleep_size >0 && sleep_threads_size >0){
System.out.println("================Shrink thread pool starting ....");
for(int i=0; i < more_sleep_size && i < sleep_threads_size ; i++){
System.out.println("=== Shrink thread num : "+i);
Thread removeThread = (Thread)passivePool.get(0);
if(removeThread != null && removeThread.isAlive() && !removeThread.isInterrupted()){
removeThread.interrupt();
}
}
System.out.println("===Shrink thread pool is over .... and pool size:"+pool.size());
}
System.out.println("===End one return [shrink - spread operator] ....");
}//end while
}//end run
}//end private class
/**
* 工作线程
*/
class WorkThread implements Runnable{
public WorkThread(){
}
public void run(){
String name = Thread.currentThread().getName();
System.out.println("===Thread.currentThread():"+name);
pool.add(Thread.currentThread());
while(true){
//获取任务---------
ITask task = null;
try{
System.out.println("===Get task from queue is starting ... ");
//看线程是否被中断,如果被中断停止执行任务----
if(Thread.currentThread().isInterrupted()){
System.out.println("===Breaking current thread and jump whlie [1] ... ");
break;
}
task = queue.getTask();
}
catch(Throwable ex){
System.out.println("###No task in queue:"+ex);
}//end tryc
if(task != null){
//执行任务---------
try{
System.out.println("===Execute the task is starting ... ");
//看线程是否被中断,如果被中断停止执行任务----
if(Thread.currentThread().isInterrupted()){
System.out.println("===Breaking current thread and jump whlie [1] ... ");
break;
}
task.executeTask();
//任务执行完毕-------
System.out.println("===Execute the task is over ... ");
}
catch(Throwable ex){
System.out.println("###Execute the task is failure ... "+ex);
}//end tryc
}else{
//没有任务,则钝化线程至规定时间--------
synchronized(this){
try{
System.out.println("===Passivate into passivePool ... ");
//看线程是否被中断,如果被中断停止执行任务----
boolean isInterrupted = Thread.currentThread().isInterrupted();
if(isInterrupted){
System.out.println("===Breaking current thread and jump whlie [1] ... ");
break;
}
// passivePool.add(this);
passivePool.add(Thread.currentThread());
//准备睡眠线程-------
isInterrupted = Thread.currentThread().isInterrupted();
if(isInterrupted){
System.out.println("===Breaking current thread and jump whlie [2] ... ");
break;
}
this.wait(IDLE_TIMEOUT);
}
catch(Throwable ex1){
System.out.println("###Current Thread passivate is failure ... break while cycle. "+ex1);
break;
}
}
}
}//end while--------
if(pool.contains(passivePool)){
pool.remove(this);
}
if(passivePool.contains(passivePool)){
passivePool.remove(this);
}
System.out.println("===The thread execute over ... ");
}//end run----------
}
class QueueThread implements Runnable{
public QueueThread(){
}
public void run(){
while(true){
//自动装在任务--------
queue.autoAddTask();
System.out.println("===The size of queue's task is "+queue.getTaskSize());
synchronized(this){
if(Thread.currentThread().isInterrupted()){
break;
}else{
try{
this.wait(queue.getLoadDataPollingTime());
}
catch(Throwable ex){
System.out.println("===QueueThread invoked wait is failure ... break while cycle."+ex);
break;
}
}//end if
}//end synchr
}//end while
}//end run
}
}
WorkQueue ===================================== CODE:
package test.thread.pool1;
import java.util.LinkedList;
import test.thread.pool1.impl.MyTask;
/**
* <p>Title: 工作队列对象 </p>
* <p>Description: </p>
* <p>Copyright: Copyright (c) 2005</p>
* <p>Company: </p>
* @author not attributable
* @version 1.0
*/
public abstract class WorkQueue implements IWorkQueue{
/* 预计装载量 */
private int load_size;
/* 数据装载轮循时间 */
private long load_polling_time;
/* 队列 */
private LinkedList queue = new LinkedList();
/**
*
* @param load_size 预计装载量
* @param load_polling_time 数据装载轮循时间
*/
public WorkQueue(int load_size,long load_polling_time){
this.load_size = (load_size <= 10) ? 10 : load_size;
this.load_polling_time = load_polling_time;
}
/* 数据装载轮循时间 */
public long getLoadDataPollingTime(){
return this.load_polling_time;
}
/*获取任务,并删除队列中的任务*/
public synchronized ITask getTask(){
ITask task = (ITask)queue.getFirst();
queue.removeFirst();
return task;
}
/*加入任务*/
public void addTask(ITask task){
queue.addLast(task);
}
/*删除任务*/
public synchronized void removeTask(ITask task){
queue.remove(task);
}
/*任务总数*/
public synchronized int getTaskSize(){
return queue.size();
}
/*自动装填任务*/
public synchronized void autoAddTask(){
synchronized(this){
float load_size_auto = load_size - getTaskSize() / load_size;
System.out.println("===load_size_auto:"+load_size_auto);
if(load_size_auto > 0.25){
autoAddTask0();
}
else {
System.out.println("=== Not must load new work queue ... Now! ");
}
}
}
/*删除所有任务*/
public synchronized void clearAllTask(){
queue.clear();
}
/**
* 程序员自己实现该方法
*/
protected abstract void autoAddTask0();
}
MyWorkQueue ===================================== CODE:
package test.thread.pool1.impl;
import java.util.LinkedList;
import test.thread.pool1.WorkQueue;
/**
* <p>Title: 例子工作队列对象 </p>
* <p>Description: </p>
* <p>Copyright: Copyright (c) 2005</p>
* <p>Company: </p>
* @author not attributable
* @version 1.0
*/
public class MyWorkQueue extends WorkQueue{
/**
* @param load_size 预计装载量
* @param load_polling_time 数据装载轮循时间
*/
public MyWorkQueue(int load_size,long load_polling_time){
super(load_size,load_polling_time);
}
/**
* 自动加载任务
*/
protected synchronized void autoAddTask0(){
//-------------------
System.out.println("===MyWorkQueue ... invoked autoAddTask0() method ...");
for(int i=0; i<10; i++){
System.out.println("===add task :"+i);
this.addTask(new MyTask());
}
//-------------------
}
}
MyTask ===================================== CODE:
package test.thread.pool1.impl;
import test.thread.pool1.ITask;
/**
* <p>Title: 工作任务接口 </p>
* <p>Description: </p>
* <p>Copyright: Copyright (c) 2005</p>
* <p>Company: </p>
* @author not attributable
* @version 1.0
*/
public class MyTask implements ITask {
/**
* 执行的任务
* @throws java.lang.Throwable
*/
public void executeTask() throws Throwable{
System.out.println("["+this.hashCode()+"] MyTask ... invoked executeTask() method ... ");
}
}
注:本文是由
马嘉楠
翻译的javaworld.com上的一篇名为《Solving the logout problem properly and elegantly》的文章,原文请参看
Solving the logout problem properly and elegantly
。 花了2天翻译过后才发现wolfmanchen已经捷足先登了,而且翻译得很准确(比我的好,^+^)我进行了修改,在此谢谢wolfmanchen。 文中所有示例程序的代码可以从javaworld.com中下载,文章后面有资源链接。
我看过之后觉得很好,希望对你也有所帮助!
正确优雅的解决用户退出问题 ------JSP和Struts解决方案
摘要
在一个有密码保护的Web应用当中,正确妥善的处理用户退出过程并不仅仅只需要调用HttpSession对象的invalidate()方法,因为现在大部分浏览器上都有后退(Back)和前进(Forward)按钮,允许用户后退或前进到一个页面。在用户退出一个Web应用之后,如果按了后退按钮,浏览器把缓存中的页面呈现给用户,这会使用户产生疑惑,他们会开始担心他们的个人数据是否安全。
实际上,许多Web应用会弹出一个页面,警告用户退出时关闭整个浏览器,以此来阻止用户点击后退按钮。还有一些使用JavaScript,但在某些客户端浏览器中这却不一定起作用。这些解决方案大多数实现都很笨拙,且不能保证在任何情况下都100%有效,同时,它还要求用户有一定的操作经验。
这篇文章以简单的程序示例阐述了正确解决用户退出问题的方案。作者Kevin Le首先描述了一个理想的密码保护Web应用,然后以示例程序解释问题如何产生并讨论解决问题的方案。文章虽然是针对JSP进行讨论阐述,但作者所阐述的概念很容易理解而且能够为其他Web技术所采用。最后最后,作者Kevin Le用Jakarta Struts更为优雅地解决用户退出问题。文中包含JSP和Struts的示例程序 (3,700 words; September 27, 2004)
大部分Web应用不会包含像银行账户或信用卡资料那样机密的信息,但是一旦涉及到敏感数据,就需要我们提供某些密码保护机制。例如,在一个工厂当中,工人必须通过Web应用程序访问他们的时间安排、进入他们的培训课程以及查看他们的薪金等等。此时应用SSL(Secure Socket Layer)就有些大材小用了(SSL页面不会在缓存中保存,关于SSL的讨论已经超出本文的范围)。但是这些应用又确实需要某种密码保护措施,否则,工人(在这种情况下,也就是Web应用的使用者)就可以发现工厂中所有员工的私人机密信息。
类似上面的情况还包括位于公共图书馆、医院、网吧等公共场所的计算机。在这些地方,许多用户共同使用几台计算机,此时保护用户的个人数据就显得至关重要。
同时应用程序的良好设计与实现对用户专业知识以及相关培训要求少之又少。
让我们来看一下现实世界中一个完美的Web应用是怎样工作的: 1. 用户在浏览器中输入URL,访问一个页面。 2. Web应用显示一个登陆页面,要求用户输入有效的验证信息。 3. 用户输入用户名和密码。 4. 假设用户提供的验证信息是正确的,经过了验证过程,Web应用允许用户浏览他有权访问的区域。 5. 退出时,用户点击页面的退出按钮,Web应用显示确认页面,询问用户是否真的需要退出。一旦用户点击确定按钮,Session结束,Web应用重新定位到登陆页面。用户现在可以放心的离开而不用担心他的信息会被泄露。 6. 另一个用户坐到了同一台电脑前。他点击后退按钮,Web应用不应该显示上一个用户访问过的任何一个页面。 事实上,Web应用将一直停留在登陆页面上,除非第二个用户提供正确的验证信息,之后才可以访问他有权限的区域。
通过示例程序,文章向您阐述了如何在一个Web应用中实现上面的功能。
一. JSP samples
为了更为有效地向您说明这个解决方案,本文将从展示一个Web应用logoutSampleJSP1中碰到的问题开始。这个示例代表了许多没有正确解决退出过程的Web应用。logoutSampleJSP1包含一下JSP页面:login.jsp, home.jsp, secure1.jsp, secure2.jsp, logout.jsp, loginAction.jsp, 和 logoutAction.jsp。其中页面home.jsp, secure1.jsp, secure2.jsp, 和 logout.jsp是不允许未经认证的用户访问的,也就是说,这些页面包含了重要信息,在用户登陆之前或者退出之后都不应该显示在浏览器中。login.jsp页面包含了用于用户输入用户名和密码的form。logout.jsp页面包含了要求用户确认是否退出的form。loginAction.jsp和logoutAction.jsp作为控制器分别包含了登陆和退出动作的代码。
第二个Web示例应用logoutSampleJSP2展示了如何纠正示例logoutSampleJSP1中的问题。但是第二个示例logoutSampleJSP2自身也是有问题的。在特定情况下,退出问题依然存在。
第三个Web示例应用logoutSampleJSP3对logoutSampleJSP2进行了改进,比较妥善地解决了退出问题。
最后一个Web示例logoutSampleStruts展示了JakartaStruts如何优雅地解决退出问题。
注意:本文所附示例在最新版本的Microsoft Internet Explorer (IE), Netscape Navigator, Mozilla, FireFox和Avant浏览器上测试通过。
二. Login action
Brian Pontarelli的经典文章
《J2EE Security: Container Versus Custom》
讨论了不同的J2EE认证方法。文章同时指出,HTTP协议和基于form的认证方法并不能提供处理用户退出问题的机制。因此,解决方法便是引入用户自定义的安全实现机制,这就提供了更大的灵活性。
在用户自定义的认证方法中,普遍采用的方法是从用户提交的form中获得用户输入的认证信息,然后到诸如LDAP (lightweight directory access protocol)或关系数据库(relational database management system, RDBMS)的安全域中进行认证。如果用户提供的认证信息是有效的,登陆动作在HttpSession对象中保存某个对象。HttpSession存在着保存的对象则表示用户已经登陆到Web应用当中。为了方便起见,本文所附的示例只在HttpSession中保存一个用户名以表明用户已经登陆。清单1是从loginAction.jsp页面中节选的一段代码以此讲解登陆动作:
Listing 1 //...
//initialize RequestDispatcher object; set forward to home page by default
RequestDispatcher rd = request.getRequestDispatcher(
"home.jsp"
);
//Prepare connection and statement
rs = stmt.executeQuery(
"select password from USER where userName = '"
+ userName +
"'"
); if (rs.next()) {
//Query only returns 1 record in the result set; //Only 1
password per userName which is also the primary key
if (rs.getString(
"password"
).equals(password)) {
//If valid password
session.setAttribute(
"User"
, userName);
//Saves username string in the session object
} else {
//Password does not match, i.e., invalid user password
request.setAttribute(
"Error"
,
"Invalid password."
);
rd = request.getRequestDispatcher(
"login.jsp"
); } }
//No record in the result set, i.e., invalid username
else {
request.setAttribute(
"Error"
,
"Invalid user name."
); rd = request.getRequestDispatcher(
"login.jsp"
); } }
//As a controller, loginAction.jsp finally either forwards to "login.jsp" or "home.jsp"
rd.forward(request, response);
//...
本文当中所附Web应用示例均以关系型数据库作为安全域,但本问所讲述的内容同样适用于其他任何类型的安全域。
三. Logout action
退出动作包含删除用户名以及调用用户的HttpSession对象的invalidate()方法。清单2是从loginoutAction.jsp中节选的一段代码,以此说明退出动作:
Listing 2 //...
session.removeAttribute(
"User"
); session.invalidate();
//...
四. 阻止未经认证访问受保护的JSP页面
从提交的form中获取用户提交的认证信息并经过验证后,登陆动作仅仅在HttpSession对象中写入一个用户名。退出动作则刚好相反,它从HttpSession中删除用户名并调用HttpSession对象的invalidate()方法。为了使登陆和退出动作真正发挥作用,所有受保护的JSP页面必须首先验证HttpSession中包含的用户名,以便确认用户当前是否已经登陆。如果HttpSession中包含了用户名,就说明用户已经登陆,Web应用会将剩余的JSP页中的动态内容发送给浏览器。否则,JSP页将跳转到登陆页面,login.jsp。页面home.jsp, secure1.jsp, secure2.jsp和 logout.jsp均包含清单3中的代码段:
Listing 3
//...
String userName = (String) session.getAttribute(
"User"
); if (null == userName) { request.setAttribute(
"Error"
,
"Session has ended. Please login."
); RequestDispatcher rd = request.getRequestDispatcher(
"login.jsp"
); rd.forward(request, response); }
//...
//Allow the rest of the dynamic content in this JSP to be served to the browser
//...
在这个代码段中,程序从HttpSession中检索username字符串。如果username字符串为空,Web应用则自动中止执行当前页面并跳转到登陆页,同时给出错误信息“Session has ended. Please log in.”;如果不为空,Web应用继续执行,把剩余的页面提供给用户,从而使JSP页面的动态内容成为服务对象。
五.运行logoutSampleJSP1
运行logoutSampleJSP1将会出现如下几种情形:
• 如果用户没有登陆,Web应用将会正确中止受保护页面home.jsp, secure1.jsp, secure2.jsp和logout.jsp中动态内容的执行。也就是说,假如用户并没有登陆,但是在浏览器地址栏中直接敲入受保护JSP页的地址试图访问,Web应用将自动跳转到登陆页面,同时显示错误信息“Session has ended.Please log in.”
• 同样的,当一个用户已经退出,Web应用将会正确中止受保护页面home.jsp, secure1.jsp, secure2.jsp和logout.jsp中动态内容的执行。也就是说,用户退出以后,如果在浏览器地址栏中直接敲入受保护JSP页的地址试图访问,Web应用将自动跳转到登陆页面,同时显示错误信息“Session has ended.Please log in.”
• 用户退出以后,如果点击浏览器上的后退按钮返回到先前的页面,Web应用将不能正确保护受保护的JSP页面——在Session销毁后(用户退出)受保护的JSP页会重新显示在浏览器中。然而,点击该页面上的任何链接,Web应用都会跳转到登陆页面,同时显示错误信息“Session has ended.Please log in.”
六. 阻止浏览器缓存 上述问题的根源就在于现代大部分浏览器都有一个后退按钮。当点击后退按钮时,默认情况下浏览器不会从Web服务器上重新获取页面,而是简单的从浏览器缓存中重新载入页面。这个问题并不仅限于基于Java(JSP/servlets/Struts) 的Web应用当中,在基于PHP (Hypertext Preprocessor)、ASP、(Active Server Pages)、和.NET的Web应用中也同样存在。
在用户点击后退按钮之后,浏览器到Web服务器(一般来说)或者应用服务器(在java的情况下)再从服务器到浏览器这样通常意义上的HTTP回路并没有建立。仅仅只是用户,浏览器和缓存之间进行了交互。所以即使受保护的JSP页面,例如home.jsp, secure1.jsp, secure2.jsp和logout.jsp包含了清单3上的代码,当点击后退按钮时,这些代码也永远不会执行的。
缓存的好坏,真是仁者见仁智者见智。缓存事实上的确提供了一些便利,但这些便利通常只存在于静态的HTML页面或基于图形或影像的页面。而另一方面,Web应用通常是面向数据的。由于Web应用中的数据频繁变更,所以与为了节省时间从缓存中读取并显示过期的数据相比,提供最新的数据显得尤为重要!
幸运的是,HTTP头信息“Expires”和“Cache-Control”为应用程序服务器提供了一个控制浏览器和代理服务器上缓存的机制。HTTP头信息Expires告诉代理服务器它的缓存页面何时将过期。HTTP1.1规范中新定义的头信息Cache-Control在Web应用当中可以通知浏览器不缓存任何页面。当点击后退按钮时,浏览器发送Http请求道应用服务器以便获取该页面的最新拷贝。如下是使用Cache-Control的基本方法:
• no-cache:强制缓存从服务器上获取该页面的最新拷贝 • no-store: 在任何情况下缓存不保存该页面
HTTP1.0规范中的Pragma:no-cache等同于HTTP1.1规范中的Cache-Control:no-cache,同样可以包含在头信息中。
通过使用HTTP头信息的cache控制,第二个示例应用logoutSampleJSP2解决了logoutSampleJSP1的问题。logoutSampleJSP2与logoutSampleJSP1不同表现在如下代码段中,这一代码段加入进所有受保护的页面中:
//...
response.setHeader(
"Cache-Control"
,
"no-cache"
);
//Forces caches to obtain a new copy of the page from the origin server
response.setHeader(
"Cache-Control"
,
"no-store"
);
//Directs caches not to store the page under any circumstance
response.setDateHeader(
"Expires"
, 0);
//Causes the proxy cache to see the page as "stale"
response.setHeader(
"Pragma"
,
"no-cache"
);
//HTTP 1.0 backward compatibility
String userName = (String) session.getAttribute(
"User"
); if (null == userName) { request.setAttribute(
"Error"
,
"Session has ended. Please login."
); RequestDispatcher rd = request.getRequestDispatcher(
"login.jsp"
); rd.forward(request, response); }
//...
通过设置头信息和检查HttpSession对象中的用户名来确保浏览器不会缓存JSP页面。同时,如果用户未登陆,JSP页面的动态内容不会发送到浏览器,取而代之的将是登陆页面login.jsp。
七. 运行logoutSampleJSP2
运行Web示例应用logoutSampleJSP2后将会看到如下结果:
• 当用户退出后试图点击后退按钮,浏览器不会重新显示受保护的页面,它只会显示登陆页login.jsp同时给出提示信息Session has ended. Please log in.
• 然而,当按了后退按钮返回的页是处理用户提交数据的页面时,IE和Avant浏览器将弹出如下信息提示:
警告:页面已过期 The page you requested was created using information you submitted in a form. This page is no longer available. As a security precaution, Internet Explorer does not automatically resubmit your information for you.
Mozilla和FireFox浏览器将会显示一个对话框,提示信息如下:
The page you are trying to view contains POSTDATA that has expired from cache. If you resend the data, any action from the form carried out (such as a search or online purchase) will be repeated. To resend the data, click OK. Otherwise, click Cancel.
在IE和Avant浏览器中选择刷新或者在Mozilla和FireFox浏览器中选择重新发送数据后,前一个JSP页面将重新显示在浏览器中。显然的,这病不是我们所想看到的因为它违背了logout动作的目的。发生这一现象时,很可能是一个恶意用户在尝试获取其他用户的数据。然而,这个问题仅仅出现在点击后退按钮后,浏览器返回到一个处理POST请求的页面。
八. 记录最后登陆时间
上述问题的发生是因为浏览器重新提交了其缓存中的数据。这本文的例子中,数据包含了用户名和密码。尽管IE浏览器给出了安全警告信息,但事实上浏览器此时起到了负面作用。
为了解决logoutSampleJSP2中出现的问题,logoutSampleJSP3的login.jsp除了包含username和password的之外,还增加了一个称作lastLogon的隐藏表单域,此表单域将会动态的被初始化为一个long型值。这个long型值是通过调用System.currentTimeMillis()获取到的自1970年1月1日以来的毫秒数。当login.jsp中的form提交时,loginAction.jsp首先将隐藏域中的值与用户数据库中的lastLogon值进行比较。只有当lastLogon表单域中的值大于数据库中的值时Web应用才认为这是个有效的登陆。
为了验证登陆,数据库中lastLogon字段必须用表单中的lastLogon值进行更新。上例中,当浏览器重复提交缓存中的数据时,表单中的lastLogon值不比数据库中的lastLogon值大,因此,loginAction将跳转到login.jsp页面,并显示如下错误信息“Session has ended.Please log in.”清单5是loginAction中节选的代码段:
清单5
//...
RequestDispatcher rd = request.getRequestDispatcher(
"home.jsp"
);
//Forward to homepage by default
//...
if (rs.getString(
"password"
).equals(password)) {
//If valid password
long lastLogonDB = rs.getLong(
"lastLogon"
); if (lastLogonForm > lastLogonDB) { session.setAttribute(
"User"
, userName);
//Saves username string in the session object
stmt.executeUpdate(
"update USER set lastLogon= "
+ lastLogonForm +
" where userName = '"
+ userName +
"'"
); } else { request.setAttribute(
"Error"
,
"Session has ended. Please login."
); rd = request.getRequestDispatcher(
"login.jsp"
); } } else {
//Password does not match, i.e., invalid user password
request.setAttribute(
"Error"
,
"Invalid password."
); rd = request.getRequestDispatcher(
"login.jsp"
); }
//...
rd.forward(request, response);
//...
为了实现上述方法,你必须记录每个用户的最后登陆时间。对于采用关系型数据库安全域来说,这点可以可以通过在某个表中加上lastLogin字段轻松实现。虽然对LDAP以及其他的安全域来说需要稍微动下脑筋,但最后登陆方法很显然是可以实现的。
表示最后登陆时间的方法有很多。示例logoutSampleJSP3利用了自1970年1月1日以来的毫秒数。这个方法即使在许多人在不同浏览器中用一个用户账号登陆时也是可行的。
九. 运行logoutSampleJSP3
运行示例logoutSampleJSP3将展示如何正确处理退出问题。一旦用户退出,点击浏览器上的后退按钮在任何情况下都不会在浏览器中显示受保护的JSP页面。这个示例展示了如何正确处理退出问题而不需要对用户进行额外的培训。
为了使代码更简练有效,一些冗余的代码可以剔除。一种途径就是把清单4中的代码写到一个单独的JSP页中,其他JSP页面可以通过标签
<jsp:include>进行使用
。
十. Struts框架下的退出实现
与直接使用JSP或JSP/servlets进行Web应用开发相比,另一个更好的可选方案是使用Struts。对于一个基于Struts的Web应用来说,添加一个处理退出问题的框架可以优雅地不费气力的实现。这归功于Struts是采用MVC设计模式的,因此可以将模型和视图代码清晰的分离。另外,Java是一个面向对象的语言,支持继承,可以比JSP中的脚本更为容易地实现代码重用。对于Struts来说,清单4中的代码可以从JSP页面中移植到Action类的execute()方法中。
此外,我们还可以定义一个继承Struts Action类的Action基类,其execute()方法中包含了类似清单4中的代码。通过继承,其他Action类可以继承基本类中的通用逻辑来设置HTTP头信息以及检索HttpSession对象中的username字符串。这个Action基类是一个抽象类并定义了一个抽象方法executeAction()。所有继承自Action基类的子类都必须实现exectuteAction()方法而不是覆盖它。通过继承这一机制,所有继承自Action基类的子类都不必再担心退出代码接口。(plumbing实在不知道怎么翻译了,^+^,高手帮帮忙啊!原文:With this inheritance hierarchy in place, all of the base Action 's subclasses no longer need to worry about any plumbing logout code.)。他们将只包含正常的业务逻辑代码。清单6是基类的部分代码:
清单6 publicabstractclass BaseAction extends Action { public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
response.setHeader(
"Cache-Control"
,
"no-cache"
);
//Forces caches to obtain a new copy of the page from the origin server
response.setHeader(
"Cache-Control"
,
"no-store"
);
//Directs caches not to store the page under any circumstance
response.setDateHeader(
"Expires"
, 0);
//Causes the proxy cache to see the page as "stale"
response.setHeader(
"Pragma"
,
"no-cache"
);
//HTTP 1.0 backward compatibility
if (!this.userIsLoggedIn(request)) { ActionErrors errors = new ActionErrors();
errors.add(
"error"
, new ActionError(
"logon.sessionEnded"
)); this.saveErrors(request, errors);
return mapping.findForward(
"sessionEnded"
); }
return executeAction(mapping, form, request, response); }
protectedabstract ActionForward executeAction(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException;
privateboolean userIsLoggedIn(HttpServletRequest request) { if (request.getSession().getAttribute(
"User"
) == null) { return false; }
return true; } }
清单6中的代码与清单4中的很相像,唯一区别是用ActionMapping findForward替代了RequestDispatcher forward。清单6中,如果在HttpSession中未找到username字符串,ActionMapping对象将找到名为sessionEnded的forward元素并跳转到对应的path。如果找到了,子类通过实现executeAction()方法,将执行他们自己的业务逻辑。因此,在struts-web.xml配置文件中为所有继承自Action基类的子类声明个一名为sessionEnded的forward元素并将其指向login.jsp是至关重要的。清单7以secure1 action阐明了这样一个声明:
清单7 <action path=
"/secure1"
type=
"com.kevinhle.logoutSampleStruts.Secure1Action"
scope=
"request"
> <forward name=
"success"
path=
"/WEB-INF/jsps/secure1.jsp"
/> <forward name=
"sessionEnded"
path=
"/login.jsp"
/> </action>
继承自BaseAction类的子类Secure1Action实现了executeAction()方法而不是覆盖它。Secure1Action类不需要执行任何退出代码,如清单8:
清单8 publicclass Secure1Action extends BaseAction { public ActionForward executeAction(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
HttpSession session = request.getSession(); return (mapping.findForward(
"success"
)); } }
上面的解决方案是如此的优雅有效,它仅仅只需要定义一个基类而不需要额外的代码工作。将通用的行为方法写成一个继承StrutsAction的基类是者的推荐的,而且这是许多Struts项目的共同经验。
十一. 局限性
上述解决方案对JSP或基于Struts的Web应用都是非常简单而实用的,但它还是有某些局限。在我看来,这些局限并不是至关紧要的。
• 通过取消与浏览器后退按钮有关的缓存机制,一旦用户离开页面而没有对数据进行提交,那么页面将会丢失所有输入的数据。即使点击浏览器的后退按钮返回到刚才的页面也无济于事,因为浏览器会从服务器获取新的空白页面显示出来。一种可能的方法并不是阻止这些JSP页面包含数据数据表格。在基于JSP的解决方案当中,那些JSP页面可以删除在清单4中的代码。在基于Struts的解决方案当中,Action类需要继承自Struts的Action类而非BaseAction类。
• 上面讲述的方法在Opera浏览器中不能工作。事实上没有适用于Opera浏览器的解决方案,因为Opera浏览器与2616 Hypertext Transfer Protocol—HTTP/1.1紧密相关。Section 13.13 of RFC 2616 states:
User agents often have history mechanisms, such as "Back" buttons and history lists, which can be used to redisplay an entity retrieved earlier in a session.
History mechanisms and caches are different. In particular history mechanisms SHOULD NOT try to show a semantically transparent view of the current state of a resource. Rather, a history mechanism is meant to show exactly what the user saw at the time when the resource was retrieved. 幸运的是,使用微软的IE和基于Mozilla的浏览器用户多余Opera浏览器。上面讲述的解决方案对大多数用户来说还是有帮助的。另外,无论是否使用上述的解决方案,Opera浏览器仍然存在用户退出问题,就Opera来说没有任何改变。然而,正如RFC2616中所说,通过像上面一样设置头文件指令,当用户点击一个链接时,Opera浏览器不会从缓存中获取页面。
十二. 结论
这篇文章讲述了处理退出问题的解决方案,尽管方案简单的令人惊讶,但在所有情况下都能有效地工作。无论是对JSP还是Struts,所要做的不过是写一段不超过50行的代码以及一个记录用户最后登陆时间的方法。在有密码保护的Web应用中使用这些方案能够确保在任何情况下用户的私人数据不致泄露,同时,也能增加用户的经验。
About the author [i]Kevin H. Le[/i] has more than 12 years of experience in software development. In the first half of his career, his programming language of choice was C++. In 1997, he shifted his focus to Java. He has engaged in and successfully completed several J2EE and EAI projects as both developer and architect. In addition to J2EE, his current interests now include Web services and SOA. More information on Kevin can be found on his Website http://kevinhle.com.
CowNew
开源团队网站
www.cownew.com
论坛
http://www.cownew.com/newpeng/
转载请保留此信息
今天一个CownewStudio的使用者通过QQ问我他的Eclipse安装CownewStudio以后在eclipse中可以看到studio,但是运行的时候提示类加载错误。因为CownewStudio目前的版本只支持JDK5,所以我询问他Eclipse使用的是不是1.4的JRE,但是他确认它用的就是1.5的。 后来经过实验,我确认还是JRE版本的问题,他就把他的Eclipse截图发给了我,以证明他用的是JDK1.5,但是我发现他发过来的图片工程编译器配置对话框的。哈哈,我终于明白了,让他把Eclipse的配置详细信息(“帮助”=》“关于Eclipse”=》“配置详细信息”)发过来,果然: -vm c:\programe\jdk1.4.2\jre\bin\javaw.exe
原来他装了多个版本的JDK。我要他把JDK1.5目录下的JRE目录拷贝到eclipse安装目录下,然后重启Eclipse,一切工作正常了。 其实这是很多刚刚接触Eclipse、甚至用了好长时间Eclipse的开发人员经常犯的错,也就是把Eclipse运行时的JRE与工作空间中项目所用的JRE版本弄混乱。 Eclipse也是一个普通的Java程序,因此必须有一个JRE做为运行环境。如果你的机器上没有安装任何JRE(或者JDK,本文不做二者的区分),那么点击eclipse.exe就会报错说找不到JRE。此时可以安装一个JRE、或者直接把JRE目录拷贝到eclipse安装目录下。 在Eclipse的每个项目中可以为项目指定不同的JRE版本,比如A项目使用JDK1.4编译,B项目使用JDK1.5编译。这个JDK版本是和Eclipse运行JRE没有直接关系的。 项目的JDK版本是很容易修改的,那么任何指定Eclipse启动所用的JRE呢? Eclipse启动的时候找JRE的顺序是:如果eclipse.ini中配置了-vm参数,那么则使用这个参数指定的JRE;否则就去查看eclipse安装目录下是否有JRE文件夹,如果有的话就使用这个JRE;否则的话就去系统中查找安装的JRE,如果还找不到的话就报错。 所以如果不想卸载掉其他的JDK的话,可以有两种方式:(1)直接把要使用的JRE文件夹拷贝到Eclipse目录下,这是懒人常用的方法(2)修改eclipse.ini文件,添加-vm参数,指定要运行的虚拟机的地址,使用 -vm 命令行自变量例子:-vm c:\jre\bin\javaw.exe
一直不敢写点什么,是因为战战兢兢,生怕写的不好甚至写错了会误人子弟。随笔可以随便写一下,不用太过计较,可是技术从来都要不得半点马虎,差之毫厘,谬以千里啊!但敝帚自珍又不是我的风格,虽然文笔不好,也要勉为其难了。废话少说,进入正题。
从我开始接触
Java
的多线程起就总是觉得书上讲的不是那么清楚。不是说读完了不会写,而是对写出来的多线程代码懵懵懂懂,不知道每一句会有什么影响,心里感觉忐忑。后来仔细研读
Java
语言规范后,才慢慢搞明白一些细节。我主要想说的,也就是这些经验吧。
首先要搞清楚的是线程的共享资源,共享资源是多线程中每个线程都要访问的类变量或实例变量,共享资源可以是单个类变量或实例变量,也可以是一组类变量或实例变量。多线程程序可以有多个共享资源。下面描述他们之间的一对多关系(
*
表示多):
多线程程序(
1
)
----
共享资源(
*
)
----
类变量或实例变量(
1…*
)
只有类变量和实例变量可以成为共享资源,细分如下:
<!--[if !supportLists]-->1. <!--[endif]-->实现线程的类(继承Thread类、实现Throwable接口的类)的类变量、实例变量。
<!--[if !supportLists]-->2. <!--[endif]-->实现线程的类的类变量、实例变量的类变量、实例变量,可以不规范的写为:TreadClass.ClassOrInstanceVar[.ClassOrInstanceVar]*,[]*的内容表示无限可重复。
<!--[if !supportLists]-->3. <!--[endif]-->不是实现线程的类,但其对象可能是线程的类变量或实例变量。如Servlet、EJB。这些类的类变量和实例变量,不规范的写为:ServletOrEJB.ClassOrInstanceVar[.ClassOrInstanceVar]*。
<!--[if !supportLists]-->4. <!--[endif]-->特别注意:局部变量、做为参数传递的非类变量、非实例变量不是共享资源。
那么什么是线程安全呢?关于这个问题我在网上百度了一下(没办法,有时候
GOOGLE
用不了),发现不少人在问这个问题,也有不少错误的理解。所以我给出一个较容易理解的解释:在线程中使用共享资源时,能够保证共享资源在任何时候都是原子的、一致的,这样的线程就是线程安全的线程。还不太理解?没有关系,慢慢解释。
首先来介绍一下共享资源的类型(这是我自己分类的,为了后文好解释),共享资源从其类型可以分为三类(下文讲到变量一律指类变量或实例变量,不再特别指出):
<!--[if !supportLists]-->1. <!--[endif]-->独立的基本类型共享资源,如一个简单的int变量,例:
public class Cls1 {
private int a;
public int getA(){return a;}
public void setA(int a){this.a = a;}
}
可以看到
a
没有任何依赖。
public class Cls2{
private int a;
private int b;
private int c;
//
没有对
a
的访问方法,
a
在
Cls
外不可见。
}
假设上面类中
b
、
c
都不依赖
a
,则
a
是这种类型。
<!--[if !supportLists]-->2. <!--[endif]-->相互依赖的基本类型共享资源,一个类中的几个基本类型变量互相依赖,但从对象设计的角度又不能单独把这几个变量设计成一个类。
假设上例
Cls2
中的
b
、
c
互相依赖,则属此种情况。
<!--[if !supportLists]-->3. <!--[endif]-->64位的基本类型变量。这个比较特殊,因为某些机器上64变量会分成两个32位的操作,所以和1不一样。如double、long类型。
<!--[if !supportLists]-->4. <!--[endif]-->类类型的共享资源。如下例中的obj:
public class Cls3{
private SomeObj obj;
}
public class SomeObj{
private int a;
private int b;
}
其次来看看什么是原子性、一致性。其实在这里我借用了事务
ACID
属性的
A
和
C
,熟悉的朋友就不用我废话了。所谓原子性,是指一个共享资源的所有属性在任何时刻都是一起变化、密不可分的;所谓一致性,是指一个共享资源的所有属性在变化之后一定会达到一个一致的状态。
最后根据上述四种共享资源类型,来看看如何做到线程安全。
<!--[if !supportLists]-->1. <!--[endif]-->不用做什么,只一个独立的变量,任何时候它都是原子、一致的。
<!--[if !supportLists]-->2. <!--[endif]-->使用synchronized关键字,保证几个变量被一起修改、一起读取。
<!--[if !supportLists]-->3. <!--[endif]-->使用volatile关键字,然后就和1一样了。
<!--[if !supportLists]-->4. <!--[endif]-->和2一样处理。
当对访问共享资源的方法不同时使用
synchronized
关键字时,是什么样一种情况呢?这是需要特别注意的,这样不能保证线程安全!看看下面例子的运行结果就知道了(自己运行啊,我不贴结果了):
/**
* $Author: $
* $Date: $
* $Revision: $
* $History: $
*
* Created by feelyou, at time
22:31:53
, 2005-11-16.
*/
public class TestThread extends Thread {
private int a = 0;
private int b = 0;
public static void main(String[] args) {
TestThread test = new TestThread();
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(test, "thread-" + i);
thread.start();
}
}
public synchronized void doWrite() {
a++;
try {
sleep((int)(Math.random()*100));
}
catch (InterruptedException e) {
}
b++;
try {
sleep((int)(Math.random()*100));
}
catch (InterruptedException e) {
}
}
public void print() {
System.out.println("" + Thread.currentThread().getName() + ":a:" + a);
System.out.println("" + Thread.currentThread().getName() + ":b:" + b);
}
public void run() {
super.run(); //To change body of overridden methods use File | Settings | File Templates.
for (int i = 0; i < 10; i++) {
doWrite();
print();
}
}
public synchronized void start() {
super.start(); //To change body of overridden methods use File | Settings | File Templates.
}
}
ThreadLocal
?
ThreadLocal
对于线程安全还是很有用的,如果资源不是共享的,那么应该使用
ThreadLocal
,但如果确实需要在线程间共享资源,
ThreadLocal
就没有用了!
最后,来一个完整的线程安全的例子:
/**
* $Author: $
* $Date: $
* $Revision: $
* $History: $
*
* Created by feelyou, at time
22:31:53
, 2005-11-16.
*/
public class TestThread extends Thread {
private int a = 0; //
独立的共享资源
private int b = 0; //b
、
c
互相依赖
private int c = 0;
private volatile long d = 0L; //64
位
// private SomeObj obj = new SomeObj(); //
对象类型,大家自己写吧,我就不写了。
public static void main(String[] args) {
TestThread test = new TestThread();
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(test, "thread-" + i);
thread.start();
}
}
public synchronized void doWrite() {
b++;
try {
sleep((int)(Math.random()*100));
}
catch (InterruptedException e) {
}
c++;
try {
sleep((int)(Math.random()*100));
}
catch (InterruptedException e) {
}
}
public synchronized void print() {
System.out.println("" + Thread.currentThread().getName() + ":b:" + b);
System.out.println("" + Thread.currentThread().getName() + ":c:" + c);
}
private void setA(int a) {
this.a = a;
}
private int getA() {
return a;
}
public long getD() {
return d;
}
public void setD(long d) {
this.d = d;
}
public void run() {
super.run(); //To change body of overridden methods use File | Settings | File Templates.
for (int i = 0; i < 10; i++) {
doWrite();
print();
setA(i);
System.out.println(getA());
setD(18456187413L * i);
System.out.println(getD());
}
}
public synchronized void start() {
super.start(); //To change body of overridden methods use File | Settings | File Templates.
}
}
摘要: 延迟初始化错误是运用Hibernate开发项目时最常见的错误。如果对一个类或者集合配置了延迟检索策略,那么必须当代理类实例或代理集合处于持久化状态(即处于Session范围内)时,才能初始化它。如果在游离状态时才初始化它,就会产生延迟初始化错误。
延迟初始化错误(ERROR LazyInitializer)是如何产生的?
选自<<精通Hibernate:Java对象持久化技术详解>> 作者:孙卫琴 来源:www.javathinker.org
延迟初始化错误是运用Hibernate开发项目时最常见的错误。如果对一个类或者集合配置了延迟检索策略,那么必须当代理类实例或代理集合处于持久化状态(即处于Session范围内)时,才能初始化它。如果在游离状态时才初始化它,就会产生延迟初始化错误。
下面把Customer.hbm.xml文件的<class>元素的lazy属性设为true,表示使用延迟检索策略: <class name="mypack.Customer" table="CUSTOMERS" lazy="true"> 当执行Session的load()方法时,Hibernate不会立即执行查询CUSTOMERS表的select语句,仅仅返回Customer类的代理类的实例,这个代理类具由以下特征: (1) 由Hibernate在运行时动态生成,它扩展了Customer类,因此它继承了Customer类的所有属性和方法,但它的实现对于应用程序是透明的。 (2) 当Hibernate创建Customer代理类实例时,仅仅初始化了它的OID属性,其他属性都为null,因此这个代理类实例占用的内存很少。 (3)当应用程序第一次访问Customer代理类实例时(例如调用customer.getXXX()或customer.setXXX()方法), Hibernate会初始化代理类实例,在初始化过程中执行select语句,真正从数据库中加载Customer对象的所有数据。但有个例外,那就是当应用程序访问Customer代理类实例的getId()方法时,Hibernate不会初始化代理类实例,因为在创建代理类实例时OID就存在了,不必到数据库中去查询。
提示:Hibernate采用CGLIB工具来生成持久化类的代理类。CGLIB是一个功能强大的Java字节码生成工具,它能够在程序运行时动态生成扩展Java类或者实现Java接口的代理类。关于CGLIB的更多知识,请参考:
http://cglib.sourceforge.net/。
以下代码先通过Session的load()方法加载Customer对象,然后访问它的name属性:
tx = session.beginTransaction(); Customer customer=(Customer)session.load(Customer.class,new Long(1)); customer.getName();tx.commit(); 在运行session.load()方法时Hibernate不执行任何select语句,仅仅返回Customer类的代理类的实例,它的OID为1,这是由load()方法的第二个参数指定的。当应用程序调用customer.getName()方法时,Hibernate会初始化Customer代理类实例,从数据库中加载Customer对象的数据,执行以下select语句: select * from CUSTOMERS where ID=1;select * from ORDERS where CUSTOMER_ID=1; 当<class>元素的lazy属性为true,会影响Session的load()方法的各种运行时行为,下面举例说明。 1.如果加载的Customer对象在数据库中不存在,Session的load()方法不会抛出异常,只有当运行customer.getName()方法时才会抛出以下异常:
ERROR LazyInitializer:63 - Exception initializing proxynet.sf.hibernate.ObjectNotFoundException: No row with the given identifier exists: 1, of class: mypack.Customer 2.如果在整个Session范围内,应用程序没有访问过Customer对象,那么Customer代理类的实例一直不会被初始化,Hibernate不会执行任何select语句。以下代码试图在关闭Session后访问Customer游离对象:
tx = session.beginTransaction(); Customer customer=(Customer)session.load(Customer.class,new Long(1)); tx.commit(); session.close(); customer.getName(); 由于引用变量customer引用的Customer代理类的实例在Session范围内始终没有被初始化,因此在执行customer.getName()方法时,Hibernate会抛出以下异常:
ERROR LazyInitializer:63 - Exception initializing proxynet.sf.hibernate.HibernateException: Could not initialize proxy - the owning Session was closed 由此可见,Customer代理类的实例只有在当前Session范围内才能被初始化。 3.net.sf.hibernate.Hibernate类的initialize()静态方法用于在Session范围内显式初始化代理类实例,isInitialized()方法用于判断代理类实例是否已经被初始化。例如:
tx = session.beginTransaction(); Customer customer=(Customer)session.load(Customer.class,new Long(1)); if(!Hibernate.isInitialized(customer)) Hibernate.initialize(customer); tx.commit(); session.close(); customer.getName(); 以上代码在Session范围内通过Hibernate类的initialize()方法显式初始化了Customer代理类实例,因此当Session关闭后,可以正常访问Customer游离对象。 4.当应用程序访问代理类实例的getId()方法时,不会触发Hibernate初始化代理类实例的行为,例如:
tx = session.beginTransaction(); Customer customer=(Customer)session.load(Customer.class,new Long(1)); customer.getId(); tx.commit(); session.close(); customer.getName(); 当应用程序访问customer.getId()方法时,该方法直接返回Customer代理类实例的OID值,无需查询数据库。由于引用变量 customer始终引用的是没有被初始化的Customer代理类实例,因此当Session关闭后再执行customer.getName()方法, Hibernate会抛出以下异常: ERROR LazyInitializer:63 - Exception initializing proxynet.sf.hibernate.HibernateException: Could not initialize proxy - the owning Session was closed
利用Thread-Specific Storage撰寫一個HibernateUtil
import java.io.Serializable;
import net.sf.hibernate.HibernateException; import net.sf.hibernate.Session; import net.sf.hibernate.SessionFactory; import net.sf.hibernate.Transaction;
public class HibernateSessionUtil implements Serializable { publicstaticfinal ThreadLocal tLocalsess = new ThreadLocal();
publicstaticfinal ThreadLocal tLocaltx = new ThreadLocal();
/* * getting the thread-safe session for using */ publicstatic Session currentSession(){ Session session = (Session) tLocalsess.get();
try{ if (session == null){ session = openSession(); tLocalsess.set(session); } }catch (HibernateException e){ thrownew InfrastructureException(e); } return session; }
/* * closing the thread-safe session */ publicstatic void closeSession(){
Session session = (Session) tLocalsess.get(); tLocalsess.set(null); try{ if (session != null && session.isOpen()){ session.close(); }
}catch (HibernateException e){ thrownew InfrastructureException(e); } }
/* * begin the transaction */ publicstatic void beginTransaction(){ Transaction tx = (Transaction) tLocaltx.get(); try{ if (tx == null){ tx = currentSession().beginTransaction(); tLocaltx.set(tx); } }catch (HibernateException e){ thrownew InfrastructureException(e); } }
/* * close the transaction */ publicstatic void commitTransaction(){ Transaction tx = (Transaction) tLocaltx.get(); try{ if (tx != null && !tx.wasCommitted() && !tx.wasRolledBack()) tx.commit(); tLocaltx.set(null); }catch (HibernateException e){ thrownew InfrastructureException(e); } }
/* * for rollbacking */ publicstatic void rollbackTransaction(){ Transaction tx = (Transaction) tLocaltx.get(); try{ tLocaltx.set(null); if (tx != null && !tx.wasCommitted() && !tx.wasRolledBack()){ tx.rollback(); } }catch (HibernateException e){ thrownew InfrastructureException(e); } }
privatestatic Session openSession() throws HibernateException{ return getSessionFactory().openSession(); }
privatestatic SessionFactory getSessionFactory() throws HibernateException{ return SingletonSessionFactory.getInstance(); } }
filter中的程式碼如下
public class HibernateSessionCloser implements Filter{
protected FilterConfig filterConfig = null;
public void init(FilterConfig filterConfig)throws ServletException{ this.filterConfig = filterConfig; }
public void destroy(){ this.filterConfig = null; }
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try{ chain.doFilter(request, response); } finally{ try{ HibernateSessionUtil.commitTransaction(); }catch (InfrastructureException e){ HibernateSessionUtil.rollbackTransaction(); }finally{ HibernateSessionUtil.closeSession(); } }
} }
然後在操作資料庫之前加上
HibernateSessionUtil.beginTransaction(); HibernateSessionUtil.currentSession();
|
Java 5的泛型语法已经有太多书讲了,这里不再打字贴书。GP一定有用,不然Java和C#不会约好了似的同时开始支持GP。但大家也清楚,GP和Ruby式的动态OO语言属于不同的意识形态,如果是一人一票,我想大部分的平民程序员更热衷动态OO语言的平白自然。但如果不准备跳槽到支持JSR223的动态语言,那还是看看GP吧。 胡乱总结泛型的四点作用:
第一是泛化,可以拿个T代表任意类型。 但GP是被C++严苛的静态性逼出来的,落到Java、C#这样的花语平原里----所有对象除几个原始类型外都派生于Object,再加上Java的反射功能,Java的Collection库没有范型一样过得好好的。 第二是泛型 + 反射,原本因为Java的泛型拿不到T.class而觉得泛型没用,最近才刚刚学到通过反射的API来获取T的Class,后述。 第三是收敛,就是增加了类型安全,减少了强制类型转换的代码。这点倒是Java Collection历来的弱项。 第四是可以在编译期搞很多东西,比如MetaProgramming。但除非能完全封闭于框架内部,框架的使用者和扩展者都不用学习这些东西的用法,否则那就是自绝于人民的票房毒药。C++的MetaProgramming好厉害吧,但对比一下Python拿Meta Programming生造一个Class出来的简便语法,就明白什么才是真正的叫好又叫座。 所以,作为一个架构设计师,应该使用上述的第2,3项用法,在框架类里配合使用反射和泛型,使得框架的能力更强; 同时采用收敛特性,本着对人民负责的精神,用泛型使框架更加类型安全,更少强制类型转换。 擦拭法避免了Java的流血分裂 : 大家经常骂Java GP的擦拭法实现,但我觉得多亏于它的中庸特性---如果你用就是范型,不用就是普通Object,避免了Java阵营又要经历一场to be or not to be的分裂。 最大的例子莫过Java 5的Collection 框架, 比如有些同学坚持认为自己不会白痴到类型出错,而且难以忍受每个定义的地方都要带一个泛型定义List〈Book〉 ,不用强制类型转换所省下的代码还不够N处定义花的(对了,java里面还没有tyepdef.....),因此对范型十分不感冒,这时就要齐齐感谢这个搽拭法让你依然可以对一个泛型框架保持非泛型的用法了...
通过反射获得 T.class: 不知为何书上不怎么讲这个,是差沙告诉我才知道的,最经典的应用见Hibernate wiki的Generic Data Access Objects, 代码如下:
abstract public class BaseHibernateEntityDao<T> extends HibernateDaoSupport { private Class<T> entityClass; public BaseHibernateEntityDao() { entityClass =(Class<T>) ((ParameterizedType) getClass() .getGenericSuperclass()).getActualTypeArguments()[0]; } public T get(Serializable id) { T o = (T) getHibernateTemplate().get(entityClass, id); } } 精华就是这句了:
Class<T> entityClass = (Class<T>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0]; 泛型之后,所有BaseHibernateEntityDao的子类只要定义了泛型,就无需再重载getEnttityClass(),get()函数和find()函数,销益挺明显的,所以SpringSide的Dao基类毫不犹豫就泛型了。
不过擦拭法的大棒仍在,所以子类的泛型语法可不能乱写,最正确的用法只有:
public class BookDao extends BaseHibernateEntityDao<Book>
1:排序类
package com.tixa.bad.customer.util;
import java.util.ArrayList; import java.util.Collections; import java.util.Comparator;
import com.tixa.bad.customer.data.AdstatGraphArea;
/** * 对地区数据进行排序 * * @StatAreaSort * * @author * * TODO */ public class StatAreaSort { /** * 点击排序规则,倒序 */ private static Comparator orderClick = new Comparator() { public int compare(Object o1, Object o2) { AdstatGraphArea b1 = (AdstatGraphArea) o1; AdstatGraphArea b2 = (AdstatGraphArea) o2; return (b2.getClickcount() - b1.getClickcount()); } };
/** * ip排序规则,倒序 */ private static Comparator orderIP = new Comparator() { public int compare(Object o1, Object o2) { AdstatGraphArea b1 = (AdstatGraphArea) o1; AdstatGraphArea b2 = (AdstatGraphArea) o2; return (b2.getIpcount() - b1.getIpcount()); } };
/** * 根据点击量进行排序 * * @param list */ public static void getSortClick(ArrayList list) { Collections.sort(list, orderClick); // Collections.reverse(list); }
/** * 根据ip量进行排序 * * @param list */ public static void getSortIp(ArrayList list) { Collections.sort(list, orderIP); // Collections.reverse(list); }
} 2:对排序进行测试 package com.tixa.bad.customer.util;
import java.util.ArrayList;
import junit.framework.TestCase;
import com.tixa.bad.customer.data.AdstatGraphArea;
public class StatAreaSortTest extends TestCase {
protected void setUp() throws Exception { super.setUp(); }
protected void tearDown() throws Exception { super.tearDown(); }
/* * Test method for 'com.tixa.bad.customer.util.StatAreaSort.getSortClick(ArrayList)' */ public void testGetSortClick() {
ArrayList list = new ArrayList(); AdstatGraphArea graAre = new AdstatGraphArea(); graAre.setAdid(1); graAre.setClickcount(786); graAre.setIpcount(43453); list.add(graAre); AdstatGraphArea graAre1 = new AdstatGraphArea(); graAre1.setAdid(2); graAre1.setClickcount(987876); graAre1.setIpcount(545); list.add(graAre1); AdstatGraphArea graAre2 = new AdstatGraphArea(); graAre2.setAdid(3); graAre2.setClickcount(877887); graAre2.setIpcount(4534534); list.add(graAre2); AdstatGraphArea graAre3 = new AdstatGraphArea(); graAre3.setAdid(4); graAre3.setClickcount(97998); graAre3.setIpcount(34534); list.add(graAre3); AdstatGraphArea graAre4 = new AdstatGraphArea(); graAre4.setAdid(5); graAre4.setClickcount(500); graAre4.setIpcount(2000); list.add(graAre4); System.out.print("sore before "); for(int i = 0;i<list.size();i++) { AdstatGraphArea a = (AdstatGraphArea)list.get(i); System.out.println(a.getAdid()); } StatAreaSort.getSortClick(list); System.out.print("sore after"); for(int i = 0;i<list.size();i++) { AdstatGraphArea a = (AdstatGraphArea)list.get(i); System.out.println(a.getAdid()); } System.out.println("----------------------"); StatAreaSort.getSortIp(list); for(int i = 0;i<list.size();i++) { AdstatGraphArea a = (AdstatGraphArea)list.get(i); System.out.println(a.getAdid()); } }
/* * Test method for 'com.tixa.bad.customer.util.StatAreaSort.getSortIp(ArrayList)' */ public void testGetSortIp() {
} }
一、避免在循环条件中使用复杂表达式
在不做编译优化的情况下,在循环中,循环条件会被反复计算,如果不使用复杂表达式,而使循环条件值不变的话,程序将会运行的更快。
例子: import java.util.Vector; class CEL { void method (Vector vector) { for (int i = 0; i < vector.size (); i++) // Violation ; // ... } }
更正: class CEL_fixed { void method (Vector vector) { int size = vector.size () for (int i = 0; i < size; i++) ; // ... } }
二、为'Vectors' 和 'Hashtables'定义初始大小 JVM为Vector扩充大小的时候需要重新创建一个更大的数组,将原原先数组中的内容复制过来,最后,原先的数组再被回收。可见Vector容量的扩大是一个颇费时间的事。 通常,默认的10个元素大小是不够的。你最好能准确的估计你所需要的最佳大小。
例子: import java.util.Vector; public class DIC { public void addObjects (Object[] o) { // if length > 10, Vector needs to expand for (int i = 0; i< o.length;i++) { v.add(o); // capacity before it can add more elements. } } public Vector v = new Vector(); // no initialCapacity. }
更正: 自己设定初始大小。 public Vector v = new Vector(20); public Hashtable hash = new Hashtable(10);
参考资料: Dov Bulka, "Java Performance and Scalability Volume 1: Server-Side Programming Techniques" Addison Wesley, ISBN: 0-201-70429-3 pp.55 – 57
三、在finally块中关闭Stream 程序中使用到的资源应当被释放,以避免资源泄漏。这最好在finally块中去做。不管程序执行的结果如何,finally块总是会执行的,以确保资源的正确关闭。 例子: import java.io.*; public class CS { public static void main (String args[]) { CS cs = new CS (); cs.method (); } public void method () { try { FileInputStream fis = new FileInputStream ("CS.java"); int count = 0; while (fis.read () != -1) count++; System.out.println (count); fis.close (); } catch (FileNotFoundException e1) { } catch (IOException e2) { } } } 更正: 在最后一个catch后添加一个finally块
参考资料: Peter Haggar: "Practical Java - Programming Language Guide". Addison Wesley, 2000, pp.77-79
四、使用'System.arraycopy ()'代替通过来循环复制数组 'System.arraycopy ()' 要比通过循环来复制数组快的多。 例子: public class IRB { void method () { int[] array1 = new int [100]; for (int i = 0; i < array1.length; i++) { array1 [i] = i; } int[] array2 = new int [100]; for (int i = 0; i < array2.length; i++) { array2 [i] = array1 [i]; // Violation } } } 更正: public class IRB { void method () { int[] array1 = new int [100]; for (int i = 0; i < array1.length; i++) { array1 [i] = i; } int[] array2 = new int [100]; System.arraycopy(array1, 0, array2, 0, 100); } } 参考资料: http://www.cs.cmu.edu/~jch/java/speed.html
五、让访问实例内变量的getter/setter方法变成”final” 简单的getter/setter方法应该被置成final,这会告诉编译器,这个方法不会被重载,所以,可以变成”inlined”
例子: class MAF { public void setSize (int size) { _size = size; } private int _size; }
更正: class DAF_fixed { final public void setSize (int size) { _size = size; } private int _size; }
参考资料: Warren N. and Bishop P. (1999), "Java in Practice", p. 4-5 Addison-Wesley, ISBN 0-201-36065-9
六、避免不需要的instanceof操作 如果左边的对象的静态类型等于右边的,instanceof表达式返回永远为true。 例子: public class UISO { public UISO () {} } class Dog extends UISO { void method (Dog dog, UISO u) { Dog d = dog; if (d instanceof UISO) // always true. System.out.println("Dog is a UISO"); UISO uiso = u; if (uiso instanceof Object) // always true. System.out.println("uiso is an Object"); } } 更正: 删掉不需要的instanceof操作。 class Dog extends UISO { void method () { Dog d; System.out.println ("Dog is an UISO"); System.out.println ("UISO is an UISO"); } }
七、避免不需要的造型操作 所有的类都是直接或者间接继承自Object。同样,所有的子类也都隐含的“等于”其父类。那么,由子类造型至父类的操作就是不必要的了。 例子: class UNC { String _id = "UNC"; } class Dog extends UNC { void method () { Dog dog = new Dog (); UNC animal = (UNC)dog; // not necessary. Object o = (Object)dog; // not necessary. } } 更正: class Dog extends UNC { void method () { Dog dog = new Dog(); UNC animal = dog; Object o = dog; } } 参考资料: Nigel Warren, Philip Bishop: "Java in Practice - Design Styles and Idioms for Effective Java". Addison-Wesley, 1999. pp.22-23
八、如果只是查找单个字符的话,用charAt()代替startsWith() 用一个字符作为参数调用startsWith()也会工作的很好,但从性能角度上来看,调用用String API无疑是错误的! 例子: public class PCTS { private void method(String s) { if (s.startsWith("a")) { // violation // ... } } } 更正 将'startsWith()' 替换成'charAt()'. public class PCTS { private void method(String s) { if ('a' == s.charAt(0)) { // ... } } } 参考资料: Dov Bulka, "Java Performance and Scalability Volume 1: Server-Side Programming Techniques" Addison Wesley, ISBN: 0-201-70429-3
九、使用移位操作来代替'a / b'操作 "/"是一个很“昂贵”的操作,使用移位操作将会更快更有效。
例子: public class SDIV { public static final int NUM = 16; public void calculate(int a) { int div = a / 4; // should be replaced with "a >> 2". int div2 = a / 8; // should be replaced with "a >> 3". int temp = a / 3; } }
更正: public class SDIV { public static final int NUM = 16; public void calculate(int a) { int div = a >> 2; int div2 = a >> 3; int temp = a / 3; // 不能转换成位移操作 } }
十、使用移位操作代替'a * b' 同上。 [i]但我个人认为,除非是在一个非常大的循环内,性能非常重要,而且你很清楚你自己在做什么,方可使用这种方法。否则提高性能所带来的程序晚读性的降低将是不合算的。
例子: public class SMUL { public void calculate(int a) { int mul = a * 4; // should be replaced with "a << 2". int mul2 = 8 * a; // should be replaced with "a << 3". int temp = a * 3; } }
更正: package OPT; public class SMUL { public void calculate(int a) { int mul = a << 2; int mul2 = a << 3; int temp = a * 3; // 不能转换 } }
十一、在字符串相加的时候,使用 ' ' 代替 " ",如果该字符串只有一个字符的话
例子: public class STR { public void method(String s) { String string = s + "d" // violation. string = "abc" + "d" // violation. } }
更正: 将一个字符的字符串替换成' ' public class STR { public void method(String s) { String string = s + 'd' string = "abc" + 'd' } }
十二、不要在循环中调用synchronized(同步)方法 方法的同步需要消耗相当大的资料,在一个循环中调用它绝对不是一个好主意。
例子: import java.util.Vector; public class SYN { public synchronized void method (Object o) { } private void test () { for (int i = 0; i < vector.size(); i++) { method (vector.elementAt(i)); // violation } } private Vector vector = new Vector (5, 5); }
更正: 不要在循环体中调用同步方法,如果必须同步的话,推荐以下方式: import java.util.Vector; public class SYN { public void method (Object o) { } private void test () { synchronized{//在一个同步块中执行非同步方法 for (int i = 0; i < vector.size(); i++) { method (vector.elementAt(i)); } } } private Vector vector = new Vector (5, 5); }
十三、将try/catch块移出循环 把try/catch块放入循环体内,会极大的影响性能,如果编译JIT被关闭或者你所使用的是一个不带JIT的JVM,性能会将下降21%之多! 例子: import java.io.FileInputStream; public class TRY { void method (FileInputStream fis) { for (int i = 0; i < size; i++) { try { // violation _sum += fis.read(); } catch (Exception e) {} } } private int _sum; } 更正: 将try/catch块移出循环 void method (FileInputStream fis) { try { for (int i = 0; i < size; i++) { _sum += fis.read(); } } catch (Exception e) {} } 参考资料: Peter Haggar: "Practical Java - Programming Language Guide". Addison Wesley, 2000, pp.81 – 83
十四、对于boolean值,避免不必要的等式判断 将一个boolean值与一个true比较是一个恒等操作(直接返回该boolean变量的值). 移走对于boolean的不必要操作至少会带来2个好处: 1)代码执行的更快 (生成的字节码少了5个字节); 2)代码也会更加干净 。
例子: public class UEQ { boolean method (String string) { return string.endsWith ("a") == true; // Violation } }
更正: class UEQ_fixed { boolean method (String string) { return string.endsWith ("a"); } }
十五、对于常量字符串,用'String' 代替 'StringBuffer' 常量字符串并不需要动态改变长度。 例子: public class USC { String method () { StringBuffer s = new StringBuffer ("Hello"); String t = s + "World!"; return t; } }
更正: 把StringBuffer换成String,如果确定这个String不会再变的话,这将会减少运行开销提高性能。
十六、用'StringTokenizer' 代替 'indexOf()' 和'substring()' 字符串的分析在很多应用中都是常见的。使用indexOf()和substring()来分析字符串容易导致StringIndexOutOfBoundsException。而使用StringTokenizer类来分析字符串则会容易一些,效率也会高一些。
例子: public class UST { void parseString(String string) { int index = 0; while ((index = string.indexOf(".", index)) != -1) { System.out.println (string.substring(index, string.length())); } } }
参考资料: Graig Larman, Rhett Guthrie: "Java 2 Performance and Idiom Guide" Prentice Hall PTR, ISBN: 0-13-014260-3 pp. 282 – 283
十七、使用条件操作符替代"if (cond) return; else return;" 结构 条件操作符更加的简捷 例子: public class IF { public int method(boolean isDone) { if (isDone) { return 0; } else { return 10; } } }
更正: public class IF { public int method(boolean isDone) { return (isDone ? 0 : 10); } }
十八、使用条件操作符代替"if (cond) a = b; else a = c;" 结构 例子: public class IFAS { void method(boolean isTrue) { if (isTrue) { _value = 0; } else { _value = 1; } } private int _value = 0; }
更正: public class IFAS { void method(boolean isTrue) { _value = (isTrue ? 0 : 1); // compact expression. } private int _value = 0; }
十九、不要在循环体中实例化变量 在循环体中实例化临时变量将会增加内存消耗
例子: import java.util.Vector; public class LOOP { void method (Vector v) { for (int i=0;i < v.size();i++) { Object o = new Object(); o = v.elementAt(i); } } } 更正: 在循环体外定义变量,并反复使用 import java.util.Vector; public class LOOP { void method (Vector v) { Object o; for (int i=0;i<v.size();i++) { o = v.elementAt(i); } } }
二十、确定 StringBuffer的容量 StringBuffer的构造器会创建一个默认大小(通常是16)的字符数组。在使用中,如果超出这个大小,就会重新分配内存,创建一个更大的数组,并将原先的数组复制过来,再丢弃旧的数组。在大多数情况下,你可以在创建StringBuffer的时候指定大小,这样就避免了在容量不够的时候自动增长,以提高性能。
例子: public class RSBC { void method () { StringBuffer buffer = new StringBuffer(); // violation buffer.append ("hello"); } } 更正: 为StringBuffer提供寝大小。 public class RSBC { void method () { StringBuffer buffer = new StringBuffer(MAX); buffer.append ("hello"); } private final int MAX = 100; } 参考资料: Dov Bulka, "Java Performance and Scalability Volume 1: Server-Side Programming Techniques" Addison Wesley, ISBN: 0-201-70429-3 p.30 – 31
二十一、尽可能的使用栈变量 如果一个变量需要经常访问,那么你就需要考虑这个变量的作用域了。static? local?还是实例变量?访问静态变量和实例变量将会比访问局部变量多耗费2-3个时钟周期。 例子: public class USV { void getSum (int[] values) { for (int i=0; i < value.length; i++) { _sum += value[i]; // violation. } } void getSum2 (int[] values) { for (int i=0; i < value.length; i++) { _staticSum += value[i]; } } private int _sum; private static int _staticSum; } 更正: 如果可能,请使用局部变量作为你经常访问的变量。 你可以按下面的方法来修改getSum()方法: void getSum (int[] values) { int sum = _sum; // temporary local variable. for (int i=0; i < value.length; i++) { sum += value[i]; } _sum = sum; } 参考资料: Peter Haggar: "Practical Java - Programming Language Guide". Addison Wesley, 2000, pp.122 – 125
二十二、不要总是使用取反操作符(!) 取反操作符(!)降低程序的可读性,所以不要总是使用。
例子: public class DUN { boolean method (boolean a, boolean b) { if (!a) return !a; else return !b; } }
更正: 如果可能不要使用取反操作符(!)
二十三、与一个接口 进行instanceof操作 基于接口的设计通常是件好事,因为它允许有不同的实现,而又保持灵活。只要可能,对一个对象进行instanceof操作,以判断它是否某一接口要比是否某一个类要快。
例子: public class INSOF { private void method (Object o) { if (o instanceof InterfaceBase) { } // better if (o instanceof ClassBase) { } // worse. } }
class ClassBase {} interface InterfaceBase {}
UUID(Universally Unique Identifier)全局唯一标识符,是指在一台机器上生成的数字,它保证对在同一时空中的所有机器都是唯一的。按照开放软件基金会(OSF)制定的标准计算,用到了以太网卡地址、纳秒级时间、芯片ID码和许多可能的数字。由以下几部分的组合:当前日期和时间(UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同),时钟序列,全局唯一的IEEE机器识别号(如果有网卡,从网卡获得,没有网卡以其他方式获得),UUID的唯一缺陷在于生成的结果串会比较长。
在Java中生成UUID主要有以下几种方式:
1. JDK1.5 如果使用的JDK1.5的话,那么生成UUID变成了一件简单的事,以为JDK实现了UUID: java.util.UUID,直接调用即可. UUID uuid = UUID.randomUUID();
2. 第三方开源类库(推荐使用): 最著名的是 JUG .特点上是: 纯Java实现,开源,LGPL协议。采用了Native的方式产生真正的Uuid.而且提供了不同平台的实现,包括:
* Linux / x86 * Windows (98, ME, NT, 2K, XP?) / x86 * Solaris / Sparc * Mac OS X * FreeBSD / x86
import org.doomdark.uuid.UUID; import org.doomdark.uuid.UUIDGenerator;
UUIDGenerator generator = UUIDGenerator.getInstance(); UUID uuid = generator.generateRandomBasedUUID();
3. Java代码实现: 如果你使用JDK1.4或以前版本,又不想加入第三方的类库的话,下面提供了一个纯Java的UUID实现. 不过需要注意的是:这里产生的可能不是真正的UUID,只不过重复的机会少一些而已。 import java.net.InetAddress; import java.net.UnknownHostException;
/** */ /** * UUID(Univeral Unique Identity)类 *
* Title: 生成唯一编码 *
*
* Description: 源自于w3c.org <</font> http://源自于w3c.org > * *
* Copyright: Copyright (c) 2001-2004 *
* * @version 1.0 */ public final class UUID {
/** */ /** * @serial Integer that helps create a unique UID. */ private int unique;
/** */ /** * @serial Long used to record the time. The time will be * used to create a unique UID. */ private long time;
/** */ /** * InetAddress to make the UID globally unique */ private static String address;
/** */ /** * a random number */ private static int hostUnique;
/** */ /** * Used for synchronization */ private static Object mutex;
private static long lastTime;
private static long DELAY;
private static String generateNoNetworkID() { Thread current = Thread.currentThread(); String nid = current.activeCount() + System.getProperty( " os.version " ) + System.getProperty( " user.name " ) + System.getProperty( " java.version " ); System.out.println( " netWorkId = " + nid); MD5 md5 = new MD5(nid); md5.processString(); return md5.getStringDigest(); }
static { hostUnique = ( new Object()).hashCode(); mutex = new Object(); lastTime = System.currentTimeMillis(); DELAY = 10 ; // in milliseconds try { String s = InetAddress.getLocalHost().getHostAddress(); MD5 md5 = new MD5(s); md5.processString(); address = md5.getStringDigest(); } catch (UnknownHostException ex) { address = generateNoNetworkID(); } }
public UUID() { synchronized (mutex) { boolean done = false ; while ( ! done) { time = System.currentTimeMillis(); if (time lastTime + DELAY) { // pause for a second to wait for time to change try { Thread.currentThread().sleep(DELAY); } catch (java.lang.InterruptedException e) { } // ignore exception continue ; } else { lastTime = time; done = true ; } } unique = hostUnique; } }
public String toString() { return Integer.toString(unique, 16 ) + Long.toString(time, 16 ) + address; }
public boolean equals(Object obj) { if ((obj != null ) && (obj instanceof UUID)) { UUID uuid = (UUID) obj; return (unique == uuid.unique && time == uuid.time && address .equals(uuid.address)); } else { return false ; } }
public static void main(String args[]) { System.out.println( new UUID()); System.out.println( new UUID()); System.out.println( new UUID()); long start = System.currentTimeMillis(); System.out.println( new UUID()); long end = System.currentTimeMillis(); System.out.println((end - start)); System.out.println( new UUID().toString().length()); }
/** */ /** * 返回最新的UUID号码 * * @return String UUID,长50位 * */ public final static String getUUID() { UUID uid = new UUID(); return uid.toString(); } }
其中使用到MD5加密算法,实现代码如下:
import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException;
/** */ /** * MD5 加密算法类 * *
* Description: 源自于w3c.org <</font> http://源自于w3c.org > * *
* Copyright: Copyright (c) 2001-2004 *
* * @version 1.0 */ public class MD5 { private static final int BUFFER_SIZE = 1024 ;
private static final int S11 = 7 ;
private static final int S12 = 12 ;
private static final int S13 = 17 ;
private static final int S14 = 22 ;
private static final int S21 = 5 ;
private static final int S22 = 9 ;
private static final int S23 = 14 ;
private static final int S24 = 20 ;
private static final int S31 = 4 ;
private static final int S32 = 11 ;
private static final int S33 = 16 ;
private static final int S34 = 23 ;
private static final int S41 = 6 ;
private static final int S42 = 10 ;
private static final int S43 = 15 ;
private static final int S44 = 21 ;
private static byte padding[] = { ( byte ) 0x80 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 , ( byte ) 0 } ;
private InputStream in = null ;
private boolean stringp = false ;
private int state[] = null ;
private long count = 0 ;
private byte buffer[] = null ;
private byte digest[] = null ;
private static String stringify( byte buf[]) { StringBuffer sb = new StringBuffer( 2 * buf.length); for ( int i = 0 ; i buf.length; i ++ ) { int h = (buf[i] & 0xf0 ) >> 4 ; int l = (buf[i] & 0x0f ); sb.append( new Character(( char ) ((h > 9 ) ? ' a ' + h - 10 : ' 0 ' + h))); sb.append( new Character(( char ) ((l > 9 ) ? ' a ' + l - 10 : ' 0 ' + l))); } return sb.toString(); }
private final int F( int x, int y, int z) { return ((x & y) | (( ~ x) & z)); }
private final int G( int x, int y, int z) { return ((x & z) | (y & ( ~ z))); }
private final int H( int x, int y, int z) { return (x ^ y ^ z); }
private final int I( int x, int y, int z) { return (y ^ (x | ( ~ z))); }
private final int rotate_left( int x, int n) { return ((x <<</span> n) | (x >>> (32 - n))); }
private final int FF( int a, int b, int c, int d, int x, int s, int ac) { a += (F(b, c, d) + x + ac); a = rotate_left(a, s); a += b; return a; }
private final int GG( int a, int b, int c, int d, int x, int s, int ac) { a += (G(b, c, d) + x + ac); a = rotate_left(a, s); a += b; return a; }
private final int HH( int a, int b, int c, int d, int x, int s, int ac) { a += (H(b, c, d) + x + ac); a = rotate_left(a, s); a += b; return a; }
private final int II( int a, int b, int c, int d, int x, int s, int ac) { a += (I(b, c, d) + x + ac); a = rotate_left(a, s); a += b; return a; }
private final void decode( int output[], byte input[], int off, int len) { int i = 0 ; int j = 0 ; for (; j len; i ++ , j += 4 ) { output[i] = ((( int ) (input[off + j] & 0xff )) | ((( int ) (input[off + j + 1 ] & 0xff )) <<</span> 8 ) | ((( int ) (input[off + j + 2 ] & 0xff )) <<</span> 16) | (((int ) (input[off + j + 3 ] & 0xff )) <<</span> 24 )); } }
private final void transform( byte block[], int offset) { int a = state[ 0 ]; int b = state[ 1 ]; int c = state[ 2 ]; int d = state[ 3 ]; int x[] = new int [ 16 ];
decode(x, block, offset, 64 ); /**/ /* Round 1 */ a = FF(a, b, c, d, x[ 0 ], S11, 0xd76aa478 ); /**/ /* 1 */ d = FF(d, a, b, c, x[ 1 ], S12, 0xe8c7b756 ); /**/ /* 2 */ c = FF(c, d, a, b, x[ 2 ], S13, 0x242070db ); /**/ /* 3 */ b = FF(b, c, d, a, x[ 3 ], S14, 0xc1bdceee ); /**/ /* 4 */ a = FF(a, b, c, d, x[ 4 ], S11, 0xf57c0faf ); /**/ /* 5 */ d = FF(d, a, b, c, x[ 5 ], S12, 0x4787c62a ); /**/ /* 6 */ c = FF(c, d, a, b, x[ 6 ], S13, 0xa8304613 ); /**/ /* 7 */ b = FF(b, c, d, a, x[ 7 ], S14, 0xfd469501 ); /**/ /* 8 */ a = FF(a, b, c, d, x[ 8 ], S11, 0x698098d8 ); /**/ /* 9 */ d = FF(d, a, b, c, x[ 9 ], S12, 0x8b44f7af ); /**/ /* 10 */ c = FF(c, d, a, b, x[ 10 ], S13, 0xffff5bb1 ); /**/ /* 11 */ b = FF(b, c, d, a, x[ 11 ], S14, 0x895cd7be ); /**/ /* 12 */ a = FF(a, b, c, d, x[ 12 ], S11, 0x6b901122 ); /**/ /* 13 */ d = FF(d, a, b, c, x[ 13 ], S12, 0xfd987193 ); /**/ /* 14 */ c = FF(c, d, a, b, x[ 14 ], S13, 0xa679438e ); /**/ /* 15 */ b = FF(b, c, d, a, x[ 15 ], S14, 0x49b40821 ); /**/ /* 16 */ /**/ /* Round 2 */ a = GG(a, b, c, d, x[ 1 ], S21, 0xf61e2562 ); /**/ /* 17 */ d = GG(d, a, b, c, x[ 6 ], S22, 0xc040b340 ); /**/ /* 18 */ c = GG(c, d, a, b, x[ 11 ], S23, 0x265e5a51 ); /**/ /* 19 */ b = GG(b, c, d, a, x[ 0 ], S24, 0xe9b6c7aa ); /**/ /* 20 */ a = GG(a, b, c, d, x[ 5 ], S21, 0xd62f105d ); /**/ /* 21 */ d = GG(d, a, b, c, x[ 10 ], S22, 0x2441453 ); /**/ /* 22 */ c = GG(c, d, a, b, x[ 15 ], S23, 0xd8a1e681 ); /**/ /* 23 */ b = GG(b, c, d, a, x[ 4 ], S24, 0xe7d3fbc8 ); /**/ /* 24 */ a = GG(a, b, c, d, x[ 9 ], S21, 0x21e1cde6 ); /**/ /* 25 */ d = GG(d, a, b, c, x[ 14 ], S22, 0xc33707d6 ); /**/ /* 26 */ c = GG(c, d, a, b, x[ 3 ], S23, 0xf4d50d87 ); /**/ /* 27 */ b = GG(b, c, d, a, x[ 8 ], S24, 0x455a14ed ); /**/ /* 28 */ a = GG(a, b, c, d, x[ 13 ], S21, 0xa9e3e905 ); /**/ /* 29 */ d = GG(d, a, b, c, x[ 2 ], S22, 0xfcefa3f8 ); /**/ /* 30 */ c = GG(c, d, a, b, x[ 7 ], S23, 0x676f02d9 ); /**/ /* 31 */ b = GG(b, c, d, a, x[ 12 ], S24, 0x8d2a4c8a ); /**/ /* 32 */
/**/ /* Round 3 */ a = HH(a, b, c, d, x[ 5 ], S31, 0xfffa3942 ); /**/ /* 33 */ d = HH(d, a, b, c, x[ 8 ], S32, 0x8771f681 ); /**/ /* 34 */ c = HH(c, d, a, b, x[ 11 ], S33, 0x6d9d6122 ); /**/ /* 35 */ b = HH(b, c, d, a, x[ 14 ], S34, 0xfde5380c ); /**/ /* 36 */ a = HH(a, b, c, d, x[ 1 ], S31, 0xa4beea44 ); /**/ /* 37 */ d = HH(d, a, b, c, x[ 4 ], S32, 0x4bdecfa9 ); /**/ /* 38 */ c = HH(c, d, a, b, x[ 7 ], S33, 0xf6bb4b60 ); /**/ /* 39 */ b = HH(b, c, d, a, x[ 10 ], S34, 0xbebfbc70 ); /**/ /* 40 */ a = HH(a, b, c, d, x[ 13 ], S31, 0x289b7ec6 ); /**/ /* 41 */ d = HH(d, a, b, c, x[ 0 ], S32, 0xeaa127fa ); /**/ /* 42 */ c = HH(c, d, a, b, x[ 3 ], S33, 0xd4ef3085 ); /**/ /* 43 */ b = HH(b, c, d, a, x[ 6 ], S34, 0x4881d05 ); /**/ /* 44 */ a = HH(a, b, c, d, x[ 9 ], S31, 0xd9d4d039 ); /**/ /* 45 */ d = HH(d, a, b, c, x[ 12 ], S32, 0xe6db99e5 ); /**/ /* 46 */ c = HH(c, d, a, b, x[ 15 ], S33, 0x1fa27cf8 ); /**/ /* 47 */ b = HH(b, c, d, a, x[ 2 ], S34, 0xc4ac5665 ); /**/ /* 48 */
/**/ /* Round 4 */ a = II(a, b, c, d, x[ 0 ], S41, 0xf4292244 ); /**/ /* 49 */ d = II(d, a, b, c, x[ 7 ], S42, 0x432aff97 ); /**/ /* 50 */ c = II(c, d, a, b, x[ 14 ], S43, 0xab9423a7 ); /**/ /* 51 */ b = II(b, c, d, a, x[ 5 ], S44, 0xfc93a039 ); /**/ /* 52 */ a = II(a, b, c, d, x[ 12 ], S41, 0x655b59c3 ); /**/ /* 53 */ d = II(d, a, b, c, x[ 3 ], S42, 0x8f0ccc92 ); /**/ /* 54 */ c = II(c, d, a, b, x[ 10 ], S43, 0xffeff47d ); /**/ /* 55 */ b = II(b, c, d, a, x[ 1 ], S44, 0x85845dd1 ); /**/ /* 56 */ a = II(a, b, c, d, x[ 8 ], S41, 0x6fa87e4f ); /**/ /* 57 */ d = II(d, a, b, c, x[ 15 ], S42, 0xfe2ce6e0 ); /**/ /* 58 */ c = II(c, d, a, b, x[ 6 ], S43, 0xa3014314 ); /**/ /* 59 */ b = II(b, c, d, a, x[ 13 ], S44, 0x4e0811a1 ); /**/ /* 60 */ a = II(a, b, c, d, x[ 4 ], S41, 0xf7537e82 ); /**/ /* 61 */ d = II(d, a, b, c, x[ 11 ], S42, 0xbd3af235 ); /**/ /* 62 */ c = II(c, d, a, b, x[ 2 ], S43, 0x2ad7d2bb ); /**/ /* 63 */ b = II(b, c, d, a, x[ 9 ], S44, 0xeb86d391 ); /**/ /* 64 */
state[ 0 ] += a; state[ 1 ] += b; state[ 2 ] += c; state[ 3 ] += d; }
private final void update( byte input[], int len) { int index = (( int ) (count >> 3 )) & 0x3f ; count += (len <<</span> 3 ); int partLen = 64 - index; int i = 0 ; if (len >= partLen) { System.arraycopy(input, 0 , buffer, index, partLen); transform(buffer, 0 ); for (i = partLen; i + 63 len; i += 64 ) transform(input, i); index = 0 ; } else { i = 0 ; } System.arraycopy(input, i, buffer, index, len - i); }
private byte [] end() { byte bits[] = new byte [ 8 ]; for ( int i = 0 ; i 8 ; i ++ ) bits[i] = ( byte ) ((count >>> (i * 8 )) & 0xff ); int index = (( int ) (count >> 3 )) & 0x3f ; int padlen = (index 56 ) ? ( 56 - index) : ( 120 - index); update(padding, padlen); update(bits, 8 ); return encode(state, 16 ); }
// Encode the content.state array into 16 bytes array private byte [] encode( int input[], int len) { byte output[] = new byte [len]; int i = 0 ; int j = 0 ; for (; j len; i ++ , j += 4 ) { output[j] = ( byte ) ((input[i]) & 0xff ); output[j + 1 ] = ( byte ) ((input[i] >> 8 ) & 0xff ); output[j + 2 ] = ( byte ) ((input[i] >> 16 ) & 0xff ); output[j + 3 ] = ( byte ) ((input[i] >> 24 ) & 0xff ); } return output; }
/** */ /** * Get the digest for our input stream. This method constructs the input * stream digest, and return it, as a a String, following the MD5 (rfc1321) * algorithm, * * @return An instance of String, giving the message digest. * @exception IOException * Thrown if the digestifier was unable to read the input * stream. */
public byte [] getDigest() throws IOException { byte buffer[] = new byte [BUFFER_SIZE]; int got = - 1 ;
if (digest != null ) return digest; while ((got = in.read(buffer)) > 0 ) update(buffer, got); this .digest = end(); return digest; }
/** */ /** * Get the digest, for this string digestifier. This method doesn't throw * any IOException, since it knows that the underlying stream ws built from * a String. */
public byte [] processString() { if ( ! stringp) throw new RuntimeException( this .getClass().getName() + " [processString] " + " not a string. " ); try { return getDigest(); } catch (IOException ex) { } throw new RuntimeException( this .getClass().getName() + " [processString] " + " : implementation error. " ); }
/** */ /** * Get the digest, as a proper string. */
public String getStringDigest() { if (digest == null ) throw new RuntimeException( this .getClass().getName() + " [getStringDigest] " + " : called before processing. " ); return stringify(digest); }
/** */ /** * Construct a digestifier for the given string. * * @param input * The string to be digestified. * @param encoding * the encoding name used (such as UTF8) */
public MD5(String input, String enc) { byte bytes[] = null ; try { bytes = input.getBytes(enc); } catch (UnsupportedEncodingException e) { throw new RuntimeException( " no " + enc + " encoding!!! " ); } this .stringp = true ; this .in = new ByteArrayInputStream(bytes); this .state = new int [ 4 ]; this .buffer = new byte [ 64 ]; this .count = 0 ; state[ 0 ] = 0x67452301 ; state[ 1 ] = 0xefcdab89 ; state[ 2 ] = 0x98badcfe ; state[ 3 ] = 0x10325476 ; }
/** */ /** * Construct a digestifier for the given string. * * @param input * The string to be digestified. */
public MD5(String input) { this (input, " UTF8 " ); }
/** */ /** * Construct a digestifier for the given input stream. * * @param in * The input stream to be digestified. */
public MD5(InputStream in) { this .stringp = false ; this .in = in; this .state = new int [ 4 ]; this .buffer = new byte [ 64 ]; this .count = 0 ; state[ 0 ] = 0x67452301 ; state[ 1 ] = 0xefcdab89 ; state[ 2 ] = 0x98badcfe ; state[ 3 ] = 0x10325476 ; }
public static void main(String args[]) throws IOException { if (args.length != 1 ) { System.out.println( " Md5 " ); System.exit( 1 ); } MD5 md5 = new MD5( new FileInputStream( new File(args[ 0 ]))); byte b[] = md5.getDigest(); System.out.println(stringify(b)); }
}
这是一个非常好的Socket服务器样板程序,这个socket服务器可以为你建立指定的监听端口、客户端请求响应机制等一些服务器所具备的基本框架
/*
* Copyright (c) 2000 David Flanagan. All rights reserved.
* This code is from the book Java Examples in a Nutshell, 2nd Edition.
* It is provided AS-IS, WITHOUT ANY WARRANTY either expressed or implied.
* You may study, use, and modify it for any non-commercial purpose.
* You may distribute it non-commercially as long as you retain this notice.
* For a commercial use license, or to purchase the book (recommended),
* visit http://www.davidflanagan.com/javaexamples2.
*/
import java.io.*;
import java.net.*;
import java.util.*;
/**
* This class is a generic framework for a flexible, multi-threaded server.
* It listens on any number of specified ports, and, when it receives a
* connection on a port, passes input and output streams to a specified Service
* object which provides the actual service. It can limit the number of
* concurrent connections, and logs activity to a specified stream.
**/
public class Server {
/**
* A main() method for running the server as a standalone program. The
* command-line arguments to the program should be pairs of servicenames
* and port numbers. For each pair, the program will dynamically load the
* named Service class, instantiate it, and tell the server to provide
* that Service on the specified port. The special -control argument
* should be followed by a password and port, and will start special
* server control service running on the specified port, protected by the
* specified password.
**/
public static void main(String[] args) {
try {
if (args.length < 2) // Check number of arguments
throw new IllegalArgumentException("Must specify a service");
// Create a Server object that uses standard out as its log and
// has a limit of ten concurrent connections at once.
Server s = new Server(System.out, 10);
// Parse the argument list
int i = 0;
while(i < args.length) {
if (args[i].equals("-control")) { // Handle the -control arg
i++;
String password = args[i++];
int port = Integer.parseInt(args[i++]);
// add control service
s.addService(new Control(s, password), port);
}
else {
// Otherwise start a named service on the specified port.
// Dynamically load and instantiate a Service class
String serviceName = args[i++];
Class serviceClass = Class.forName(serviceName);
Service service = (Service)serviceClass.newInstance();
int port = Integer.parseInt(args[i++]);
s.addService(service, port);
}
}
}
catch (Exception e) { // Display a message if anything goes wrong
System.err.println("Server: " + e);
System.err.println("Usage: java Server " +
"[-control ] " +
"[ ... ]");
System.exit(1);
}
}
// This is the state for the server
Map services; // Hashtable mapping ports to Listeners
Set connections; // The set of current connections
int maxConnections; // The concurrent connection limit
ThreadGroup threadGroup; // The threadgroup for all our threads
PrintWriter logStream; // Where we send our logging output to
/**
* This is the Server() constructor. It must be passed a stream
* to send log output to (may be null), and the limit on the number of
* concurrent connections.
**/
public Server(OutputStream logStream, int maxConnections) {
setLogStream(logStream);
log("Starting server");
threadGroup = new ThreadGroup(Server.class.getName());
this.maxConnections = maxConnections;
services = new HashMap();
connections = new HashSet(maxConnections);
}
/**
* A public method to set the current logging stream. Pass null
* to turn logging off
**/
public synchronized void setLogStream(OutputStream out) {
if (out != null) logStream = new PrintWriter(out);
else logStream = null;
}
/** Write the specified string to the log */
protected synchronized void log(String s) {
if (logStream != null) {
logStream.println("[" + new Date() + "] " + s);
logStream.flush();
}
}
/** Write the specified object to the log */
protected void log(Object o) { log(o.toString()); }
/**
* This method makes the server start providing a new service.
* It runs the specified Service object on the specified port.
**/
public synchronized void addService(Service service, int port)
throws IOException
{
Integer key = new Integer(port); // the hashtable key
// Check whether a service is already on that port
if (services.get(key) != null)
throw new IllegalArgumentException("Port " + port +
" already in use.");
// Create a Listener object to listen for connections on the port
Listener listener = new Listener(threadGroup, port, service);
// Store it in the hashtable
services.put(key, listener);
// Log it
log("Starting service " + service.getClass().getName() +
" on port " + port);
// Start the listener running.
listener.start();
}
/**
* This method makes the server stop providing a service on a port.
* It does not terminate any pending connections to that service, merely
* causes the server to stop accepting new connections
**/
public synchronized void removeService(int port) {
Integer key = new Integer(port); // hashtable key
// Look up the Listener object for the port in the hashtable
final Listener listener = (Listener) services.get(key);
if (listener == null) return;
// Ask the listener to stop
listener.pleaseStop();
// Remove it from the hashtable
services.remove(key);
// And log it.
log("Stopping service " + listener.service.getClass().getName() +
" on port " + port);
}
/**
* This nested Thread subclass is a "listener". It listens for
* connections on a specified port (using a ServerSocket) and when it gets
* a connection request, it calls the servers addConnection() method to
* accept (or reject) the connection. There is one Listener for each
* Service being provided by the Server.
**/
public class Listener extends Thread {
ServerSocket listen_socket; // The socket to listen for connections
int port; // The port we're listening on
Service service; // The service to provide on that port
volatile boolean stop = false; // Whether we've been asked to stop
/**
* The Listener constructor creates a thread for itself in the
* threadgroup. It creates a ServerSocket to listen for connections
* on the specified port. It arranges for the ServerSocket to be
* interruptible, so that services can be removed from the server.
**/
public Listener(ThreadGroup group, int port, Service service)
throws IOException
{
super(group, "Listener:" + port);
listen_socket = new ServerSocket(port);
// give it a non-zero timeout so accept() can be interrupted
listen_socket.setSoTimeout(600000);
this.port = port;
this.service = service;
}
/**
* This is the polite way to get a Listener to stop accepting
* connections
***/
public void pleaseStop() {
this.stop = true; // Set the stop flag
this.interrupt(); // Stop blocking in accept()
try { listen_socket.close(); } // Stop listening.
catch(IOException e) {}
}
/**
* A Listener is a Thread, and this is its body.
* Wait for connection requests, accept them, and pass the socket on
* to the addConnection method of the server.
**/
public void run() {
while(!stop) { // loop until we're asked to stop.
try {
Socket client = listen_socket.accept();
addConnection(client, service);
}
catch (InterruptedIOException e) {}
catch (IOException e) {log(e);}
}
}
}
/**
* This is the method that Listener objects call when they accept a
* connection from a client. It either creates a Connection object
* for the connection and adds it to the list of current connections,
* or, if the limit on connections has been reached, it closes the
* connection.
**/
protected synchronized void addConnection(Socket s, Service service) {
// If the connection limit has been reached
if (connections.size() >= maxConnections) {
try {
// Then tell the client it is being rejected.
PrintWriter out = new PrintWriter(s.getOutputStream());
out.print("Connection refused; " +
"the server is busy; please try again later.\n");
out.flush();
// And close the connection to the rejected client.
s.close();
// And log it, of course
log("Connection refused to " +
s.getInetAddress().getHostAddress() +
":" + s.getPort() + ": max connections reached.");
} catch (IOException e) {log(e);}
}
else { // Otherwise, if the limit has not been reached
// Create a Connection thread to handle this connection
Connection c = new Connection(s, service);
// Add it to the list of current connections
connections.add(c);
// Log this new connection
log("Connected to " + s.getInetAddress().getHostAddress() +
":" + s.getPort() + " on port " + s.getLocalPort() +
" for service " + service.getClass().getName());
// And start the Connection thread to provide the service
c.start();
}
}
/**
* A Connection thread calls this method just before it exits. It removes
* the specified Connection from the set of connections.
**/
protected synchronized void endConnection(Connection c) {
connections.remove(c);
log("Connection to " + c.client.getInetAddress().getHostAddress() +
":" + c.client.getPort() + " closed.");
}
/** Change the current connection limit */
public synchronized void setMaxConnections(int max) {
maxConnections = max;
}
/**
* This method displays status information about the server on the
* specified stream. It can be used for debugging, and is used by the
* Control service later in this example.
**/
public synchronized void displayStatus(PrintWriter out) {
// Display a list of all Services that are being provided
Iterator keys = services.keySet().iterator();
while(keys.hasNext()) {
Integer port = (Integer) keys.next();
Listener listener = (Listener) services.get(port);
out.print("SERVICE " + listener.service.getClass().getName()
+ " ON PORT " + port + "\n");
}
// Display the current connection limit
out.print("MAX CONNECTIONS: " + maxConnections + "\n");
// Display a list of all current connections
Iterator conns = connections.iterator();
while(conns.hasNext()) {
Connection c = (Connection)conns.next();
out.print("CONNECTED TO " +
c.client.getInetAddress().getHostAddress() +
":" + c.client.getPort() + " ON PORT " +
c.client.getLocalPort() + " FOR SERVICE " +
c.service.getClass().getName() + "\n");
}
}
/**
* This class is a subclass of Thread that handles an individual
* connection between a client and a Service provided by this server.
* Because each such connection has a thread of its own, each Service can
* have multiple connections pending at once. Despite all the other
* threads in use, this is the key feature that makes this a
* multi-threaded server implementation.
**/
public class Connection extends Thread {
Socket client; // The socket to talk to the client through
Service service; // The service being provided to that client
/**
* This constructor just saves some state and calls the superclass
* constructor to create a thread to handle the connection. Connection
* objects are created by Listener threads. These threads are part of
* the server's ThreadGroup, so all Connection threads are part of that
* group, too.
**/
public Connection(Socket client, Service service) {
super("Server.Connection:" +
client.getInetAddress().getHostAddress() +
":" + client.getPort());
this.client = client;
this.service = service;
}
/**
* This is the body of each and every Connection thread.
* All it does is pass the client input and output streams to the
* serve() method of the specified Service object. That method is
* responsible for reading from and writing to those streams to
* provide the actual service. Recall that the Service object has
* been passed from the Server.addService() method to a Listener
* object to the addConnection() method to this Connection object, and
* is now finally being used to provide the service. Note that just
* before this thread exits it always calls the endConnection() method
* to remove itself from the set of connections
**/
public void run() {
try {
InputStream in = client.getInputStream();
OutputStream out = client.getOutputStream();
service.serve(in, out);
}
catch (IOException e) {log(e);}
finally { endConnection(this); }
}
}
/**
* Here is the Service interface that we have seen so much of. It defines
* only a single method which is invoked to provide the service. serve()
* will be passed an input stream and an output stream to the client. It
* should do whatever it wants with them, and should close them before
* returning.
*
* All connections through the same port to this service share a single
* Service object. Thus, any state local to an individual connection must
* be stored in local variables within the serve() method. State that
* should be global to all connections on the same port should be stored
* in instance variables of the Service class. If the same Service is
* running on more than one port, there will typically be different
* Service instances for each port. Data that should be global to all
* connections on any port should be stored in static variables.
*
* Note that implementations of this interface must have a no-argument
* constructor if they are to be dynamically instantiated by the main()
* method of the Server class.
**/
public interface Service {
public void serve(InputStream in, OutputStream out) throws IOException;
}
/**
* A very simple service. It displays the current time on the server
* to the client, and closes the connection.
**/
public static class Time implements Service {
public void serve(InputStream i, OutputStream o) throws IOException {
PrintWriter out = new PrintWriter(o);
out.print(new Date() + "\n");
out.close();
i.close();
}
}
/**
* This is another example service. It reads lines of input from the
* client, and sends them back, reversed. It also displays a welcome
* message and instructions, and closes the connection when the user
* enters a '.' on a line by itself.
**/
public static class Reverse implements Service {
public void serve(InputStream i, OutputStream o) throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(i));
PrintWriter out =
new PrintWriter(new BufferedWriter(new OutputStreamWriter(o)));
out.print("Welcome to the line reversal server.\n");
out.print("Enter lines. End with a '.' on a line by itself.\n");
for(;;) {
out.print("> ");
out.flush();
String line = in.readLine();
if ((line == null) || line.equals(".")) break;
for(int j = line.length()-1; j >= 0; j--)
out.print(line.charAt(j));
out.print("\n");
}
out.close();
in.close();
}
}
/**
* This service is an HTTP mirror, just like the HttpMirror class
* implemented earlier in this chapter. It echos back the client's
* HTTP request
**/
public static class HTTPMirror implements Service {
public void serve(InputStream i, OutputStream o) throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(i));
PrintWriter out = new PrintWriter(o);
out.print("HTTP/1.0 200 \n");
out.print("Content-Type: text/plain\n\n");
String line;
while((line = in.readLine()) != null) {
if (line.length() == 0) break;
out.print(line + "\n");
}
out.close();
in.close();
}
}
/**
* This service demonstrates how to maintain state across connections by
* saving it in instance variables and using synchronized access to those
* variables. It maintains a count of how many clients have connected and
* tells each client what number it is
**/
public static class UniqueID implements Service {
public int id=0;
public synchronized int nextId() { return id++; }
public void serve(InputStream i, OutputStream o) throws IOException {
PrintWriter out = new PrintWriter(o);
out.print("You are client #: " + nextId() + "\n");
out.close();
i.close();
}
}
/**
* This is a non-trivial service. It implements a command-based protocol
* that gives password-protected runtime control over the operation of the
* server. See the main() method of the Server class to see how this
* service is started.
*
* The recognized commands are:
* password: give password; authorization is required for most commands
* add: dynamically add a named service on a specified port
* remove: dynamically remove the service running on a specified port
* max: change the current maximum connection limit.
* status: display current services, connections, and connection limit
* help: display a help message
* quit: disconnect
*
* This service displays a prompt, and sends all of its output to the user
* in capital letters. Only one client is allowed to connect to this
* service at a time.
**/
public static class Control implements Service {
Server server; // The server we control
String password; // The password we require
boolean connected = false; // Whether a client is already connected
/**
* Create a new Control service. It will control the specified Server
* object, and will require the specified password for authorization
* Note that this Service does not have a no argument constructor,
* which means that it cannot be dynamically instantiated and added as
* the other, generic services above can be.
**/
public Control(Server server, String password) {
this.server = server;
this.password = password;
}
/**
* This is the serve method that provides the service. It reads a
* line the client, and uses java.util.StringTokenizer to parse it
* into commands and arguments. It does various things depending on
* the command.
**/
public void serve(InputStream i, OutputStream o) throws IOException {
// Setup the streams
BufferedReader in = new BufferedReader(new InputStreamReader(i));
PrintWriter out = new PrintWriter(o);
String line; // For reading client input lines
// Has the user has given the password yet?
boolean authorized = false;
// If there is already a client connected to this service, display
// a message to this client and close the connection. We use a
// synchronized block to prevent a race condition.
synchronized(this) {
if (connected) {
out.print("ONLY ONE CONTROL CONNECTION ALLOWED.\n");
out.close();
return;
}
else connected = true;
}
// This is the main loop: read a command, parse it, and handle it
for(;;) { // infinite loop
out.print("> "); // Display a prompt
out.flush(); // Make it appear right away
line = in.readLine(); // Get the user's input
if (line == null) break; // Quit if we get EOF.
try {
// Use a StringTokenizer to parse the user's command
StringTokenizer t = new StringTokenizer(line);
if (!t.hasMoreTokens()) continue; // if input was empty
// Get first word of the input and convert to lower case
String command = t.nextToken().toLowerCase();
// Now compare to each of the possible commands, doing the
// appropriate thing for each command
if (command.equals("password")) { // Password command
String p = t.nextToken(); // Get the next word
if (p.equals(this.password)) { // Is it the password?
out.print("OK\n"); // Say so
authorized = true; // Grant authorization
}
else out.print("INVALID PASSWORD\n"); // Otherwise fail
}
else if (command.equals("add")) { // Add Service command
// Check whether password has been given
if (!authorized) out.print("PASSWORD REQUIRED\n");
else {
// Get the name of the service and try to
// dynamically load and instantiate it.
// Exceptions will be handled below
String serviceName = t.nextToken();
Class serviceClass = Class.forName(serviceName);
Service service;
try {
service = (Service)serviceClass.newInstance();
}
catch (NoSuchMethodError e) {
throw new IllegalArgumentException(
"Service must have a " +
"no-argument constructor");
}
int port = Integer.parseInt(t.nextToken());
// If no exceptions occurred, add the service
server.addService(service, port);
out.print("SERVICE ADDED\n"); // acknowledge
}
}
else if (command.equals("remove")) { // Remove service
if (!authorized) out.print("PASSWORD REQUIRED\n");
else {
int port = Integer.parseInt(t.nextToken());
server.removeService(port); // remove the service
out.print("SERVICE REMOVED\n"); // acknowledge
}
}
else if (command.equals("max")) { // Set connection limit
if (!authorized) out.print("PASSWORD REQUIRED\n");
else {
int max = Integer.parseInt(t.nextToken());
server.setMaxConnections(max);
out.print("MAX CONNECTIONS CHANGED\n");
}
}
else if (command.equals("status")) { // Status Display
if (!authorized) out.print("PASSWORD REQUIRED\n");
else server.displayStatus(out);
}
else if (command.equals("help")) { // Help command
// Display command syntax. Password not required
out.print("COMMANDS:\n" +
"\tpassword \n" +
"\tadd \n" +
"\tremove \n" +
"\tmax \n" +
"\tstatus\n" +
"\thelp\n" +
"\tquit\n");
}
else if (command.equals("quit")) break; // Quit command.
else out.print("UNRECOGNIZED COMMAND\n"); // Error
}
catch (Exception e) {
// If an exception occurred during the command, print an
// error message, then output details of the exception.
out.print("ERROR WHILE PARSING OR EXECUTING COMMAND:\n" +
e + "\n");
}
}
// Finally, when the loop command loop ends, close the streams
// and set our connected flag to false so that other clients can
// now connect.
connected = false;
out.close();
in.close();
}
}
}
|
一、软件开发技术
1)服务器端
在最近5年内,Java还是主流,不光是因为当前的普及程度和遗留系统问题,而且除Microsoft几乎所有大公司都投资到Java上面的原因,此外开源也是一股无法忽略的力量:除了Java方面的开源框架在推动Java,也有Linux在带动java企业应用在普及(别忘记dotnet只能在 Windows Server上面运行)
dotnet有自己的优势,但是在五年内无法和Java取得均势,不光是因为Java普及带来的优势,也不光因为开源界对java的推动,也不光因为其他大公司在java上面的投资,而是很多公司的行业性质决定了dotnet的出局,例如电信行业,金融行业,电子政务行业等等,是根本没有可能采用 dotnet的。
Python和Ruby算不上后起,但是很有竞争实力,不过基于上面的原因,仍然不能成为主流。
在Java服务器端技术中,清晰的分为两条路线:高端的商业路线,这条路线是EJB3,J2EE5.0;低端的开源路线,这条路线是Hibernate, Spring。这两条路线也有重叠的地方,例如开源的Struts几乎成为J2EE Web层的标准,开源的Hibernate奠定了EJB3的基础。但是划分路线不是基于技术上的区别,而是基于商业运作上的区别。注重技术支持和商业服务的公司会选择前者,注重成本控制和选择自由的公司会选择后者。
商业路线的技术方案是:EJB3+Struts; 开源路线的技术方案是:Spring+Hibernate+Struts/Webwork
Struts是一个很成功的开源框架,它的地位短期内还无法动摇,JavaEye有一项使命,就是动摇Struts在Java Web领域的地位,把它赶下王座,把Webwork扶上位!
商业的Web层技术,JSTL算是一个不错的东西,但是和灵活的模板语言如FreeMarker相比,却有很大的差距。JSF基本上是一个没有前途的东西。商业Web层技术因为一直没有出现好的应用,这样也导致了Struts的上位。
服务器端业务层和持久层框架,我非常看好EJB3,原因也不用多谈了,从商业上来说,需要这样一个东西,跨国公司们也需要这样一个产品来卖,来取代糟糕的 EJB2。开源的方案里面,Spring+Hibenrate是一个很好的商业方案的开源替代,他们不存在很直接的竞争,而是一个互补的关系。这里比较尴尬的反而是JDO:JDO是商业产品(目前没有好的开源实现),造成开源应用不会对它感兴趣,JDO没有一个像EJB容器那样的脱管环境,造成商业方案对它不感兴趣。不过有了JDO,我觉得是对EJB3,对Hibernate形成一个良好的竞争环境,这一点是非常有利的。
2)客户端技术
准确的说是RIA应用。虽然我前面对XAML进行了正面的评价,但是我认为我前面有些结论给错了。经过这段时间,我觉得,XAML即时在多年之后,也未必能够成为一个非常成功的解决方案。道理很二:
1、XAML会带来比ActiveX更严重的安全性问题。 XAML本质上就是一个本地应用程序,虽然号称可以在IE浏览器里面运行,但IE就是一个皮而已,XAML应用具备对本地资源完全的访问能力(就算IE限制也没有用,IE限制就丧失功能,那样的话,功能并不会比Javascript来得更多;不限制的话,就为所欲为了),因此只要IE具备了运行XAML的能力,黑客将可以非常轻易的通过IE进行入侵,这仅仅需要引导用户在不知不觉中访问一个恶意的网页就搞定了!用户必须面临选择:要么禁止IE对XAML的运行能力,要么接受随时被攻击的危险。
2、XAML应用本质上也是RIA应用,因此必须进行大量的RPC调用 当前XAML采用XML Web Services进行通讯,这是一种低效的RPC。当前的XAML案例中并没有注意到RPC领域,实际上根据我现在做RIA的体验来说,RPC绝对不是一个简单的事情,要考虑的问题非常多。
从当前的阶段来说,最实际可用的方案有两个:
1、AJAX 实际上就是基于XMLHTTP的JS异步交互,这个东西已经出现很多年了,最近随着Google应用和Sun Blueprint的推出开始火热。我原来对这个东西持否定态度,但是后来转变了。我原来否定态度的一个前提就是:XMLHTTP缺乏成熟的组件库!但是没有想到的是,现在XMLHTTP从去年下半年开始,如雨后春笋般冒出来。AJAX应用最大的好处就是充分利用现有资源,我认为应成为RIA应用的首选。
2、Flash Flash的优势也很明显,强大的AS支持,强大的组件可视化设计,强大的交互能力和很炫的用户体验,并且Flash Remoting也已经非常成熟了。Flash的缺点就是Flash虽然嵌入网页,但是和网页没有数据交互能力,Flash另一个缺点就是不适合处理大量文本内容(HTML最适合)。现在有些人开始滥用Flash了。
因此比较好的方式可能是两种混用,一般不过度复杂的交互交给AJAX,非常复杂,甚至需要托拽操作的,交给Flash。
总结一下:
软件开发领域服务器端技术Java是主流,两个技术路线,一个是EJB3,一个是Spring+Hibernate,此外iBATIS也有一席之地;客户端技术就是AJAX和Flash。
二、数据库技术
基本上格局不会发生多大变化,Oracle还是高高在上,SQL Server进一步蚕食NT平台其他数据库的领地。开源方面,MySQL将一枝独秀,但是开源数据库在很多方面还是和商业数据库有无法拉近的巨大差距。这也使得商业数据库的地位不可替代。我会比较关注Oracle,MySQL这两个数据库。面向对象数据库仍然不会有什么起色。
三、桌面编程技术
我还是相信一点,对于桌面应用来说,本地代码的位置永远无法被取代,所以我总觉得XAML那样的东西效率实在很成问题。Longhorn要像成熟,也不是第一个版本就可以达到的。当前桌面应用开发技术,还是首推Delphi,不过我觉得Python是后起之秀,非常有可能在未来取代Delphi。
初探在下一代 Windows 中编写和部署应用程序 http://www.microsoft.com/china/MSDN/library/windev/longhorn/DevelopAppLonghorn.mspx
首先,以Microsoft公司的实力和Windows操作系统的占有率来说,Longhorn迟早会被普及,而XAML的开发方式也有可能普及的。记得当初WindowsXP刚出来的时候,因为资源占用率和新的激活制度招致一片骂声,但是慢慢的,现在也都接受了下来。由此可以推断,Longhorn以其更加丰富的桌面功能和诱人的外观,会在将来成为主流。
但是Longhorn什么时候才会全面普及,这是很值得琢磨的问题。WindowsXP是2001年推出的,在随后的几年,Microsoft采用了一些商业手段来迫使用户升级,例如企图取消Windows98的技术支持,不再提供WindowsNT技术支持,不再销售 WindowsNT/Windows98,将Windows2000保持在一个比较高的售价的同时,对WindowsXP推出优惠价格,让 WindowsXP的售价低于Windows2000等等手段。但是直到现在,Windows2000仍然占据了非常高的份额,据我个人的观察是比 WindowsXP略高。按照这种情况来推断,Longhorn要普及,恐怕难度更大,非常多的用户现在仍然是Windows2000的死忠派, WindowsXP推广了四年还未能超过Windows2000,那么Longhorn究竟要几年才能超过WindowsXP呢?我估计四年以上是起码的。
XAML应用程序不同以往,它只能跑在Longhorn上面,甚至比Java和dotnet要求更严格,后者仅仅下载安装一个运行环境就可以了,但是前者要求你必须更新操作系统。XAML在IE浏览器中运行虽然肯定是下一代RIA的主流,但是不可忽视的问题是,只要Longhorn没有彻底淘汰 Windows2000/XP,软件开发商和网站开发商就不敢大面积采用XAML。而根据我的观察,现在企业中,Windows98仍有少部分市场份额。因此Longhorn必须要等待到彻底的,毫不残留的淘汰Windows98,Windows2000,WindowsXP之后,才会全面普及,而在此之前,不得不经历一个漫长的过渡期。
就好像现在,假设你开发桌面应用程序,你敢只针对WindowsXP开发吗?而彻底不支持98和2000吗?我想,没有哪个软件开发商敢这样做。除非 Windows2000几乎被彻底淘汰了,你才敢这样做,但是WindowsXP已经推出四年了,还没有Windows2000占用率高,哪全面淘汰究竟要几年呢?再看看现在dotnet winforms应用,推出也已经五年时间了,但是到现在仍然没有普及开来,根本的原因就是Windows2000/WindowsXP没有预装 dotnet framework。仅仅是需要打包安装一个运行环境就使得winforms五年都推广不了,更何况要求你升级操作系统呢?
我个人的估计是,假设2006年Longhorn如期上市,那么将需要7-9年时间来彻底淘汰Windows2000/WindowsXP。 Longhorm上面XAML应用的初步普及也至少需要4-5年时间以后才会有软件开发商大量去做(想向dotnet是2000年开始宣传和推广的,到 2004年开始普及,今年和明年才会全面普及)。因此,基于XAML应用的普及可能是在2010年以后!上面的估计中还没有包括MacOS 和Linux在桌面会否有什么表现。
先说说服务器端吧:
从可预见的未来来看,服务器和客户端TCP通讯的主流方式一定是HTTP协议(即时通讯软件走UDP端口,不在讨论范围)。在基于HTTP协议之上,又分为两类:一类是SOAP协议,异构系统支持良好,但是性能很差,目前Microsoft很喜欢用这种方式;一类是轻量级二进制协议,例如Flash的 AMF协议,Resin的Hessian协议。值得一提的是,不管哪种方式,他们都支持异构的系统,所以完全可用在客户端采用dotnet,在服务器端采用Java或者Python。因此,XAML的流行不会对服务器端技术产生致命的影响(肯定会提高dotnet的服务器的市场份额)。所以我们可用抛开客户端影响,单独来看服务器端技术:
1、Java Java是当前服务器端技术当之无愧的王者,在未来五年内,也不会有任何动摇(受到dotnet和python的影响,市场份额会下降一些)。Java特别有利的一点是,现在有太多的现存系统基于Java,这些系统都不会轻易迁移到其他平台上。另外还有一个决定因素是除了Microsoft之外的几乎全部 IT大公司都在Java方面的投资巨大,放弃Java对他们来说也意味着沉重的打击,甚至毁灭性的打击。这些公司可以列很长很长,IBM,HP, Oracle,SAP,Sun,BEA,Macromedia等等。
2、dotnet 由于Microsoft的影响力,dotnet会成为为仅次于Java的第二大服务器端技术,但是Microsoft有一个隐忧,就是Linux操作系统在服务器端的高速成长。虽然现在Linux在整个服务器端市场的出货量只有13%左右,但是成长率惊人,根据我看到的资料显示,到2008年,将占据 25%以上的市场份额。考虑到很多公司是自己安装Linux,因此不会被硬件服务器厂商统计进来,因此Linux的服务器端的市场份额应该比25%高一些。并且现在主要的服务器厂商都对Linux有非常巨大的投入和支持,这些公司包括IBM,HP,Dell(只有Sun不支持),因此Linux在未来会对Windows在服务器端的市场构成最严重的威胁。
不要忘记dotnet只能在Windows平台上面跑,虽然有mono,但是你不可能移植MTS,COM+,SQL Server etc。所以只要Linux在服务器市场对Windows构成持续的威胁,dotnet就不可能超过Java,Java的地位还是稳稳的老大。从某种程度上来说,Java的命运是和Linux联系在一起的,只要Linux在服务器端不输于Windows,Java就稳稳压制dotnet。
BTW:从未来来看,Linux和Windows会在低端和中端服务器市场成为主要竞争对手,由于各自都有其不可替代性,所以双方都不可能彻底消灭对方,最大的可能性是Linux和Windows平分市场,或者Windows市场份额略高一点。
3、Python 我个人认为Python会成长为第三大服务器端技术,Python成长于开源,但是又有商业公司来商业运作,并且背后还有大公司的支持,在欧洲普及的非常好。当然最重要的原因是我觉得Python在技术上非常先进,并且技术发展方向上比较统一,不会出现Java那种吵架的事情。
4、PHP PHP这东西是不错,Yahoo也在用,IBM现在也对他感兴趣,但是我还是要说PHP没有太广阔的前途,原因很简单,PHP没有服务端中间件,例如 Java有App Server,dotnet有IIS/MTS,Python有Zope,但是PHP他就是一个脚本,没有自己的中间件就是致命问题。Yahoo用PHP有其特定的原因,主要是从原先自己的技术迁移到PHP很方便,而IBM支持PHP,显然醉翁之意不在酒,IBM意不在推广PHP,而在于争取到那些使用 PHP的商业大客户们,向他们卖服务。
BTW:感觉欧洲用Python/PHP的很多,似乎开源在欧洲非常深入人心。
从服务器端技术来说,Java还是我们最需要下功夫去学习和掌握的,此外,我会比较倾向于钻研和应用Python,而不是dotnet。原因也很简单,跟随Micorsoft的技术会很辛苦,Microsoft产生的新概念多,他总是会猛的推出n多种技术,然后让他们在市场上自己生存,最后根据市场反馈,无情的抛弃某些东西,大力推进有市场前景的东西,这样的例子太多了,举不胜举了。我的感觉就是这种方式会让Microsft经过市场尝试在技术竞争中筛选最优秀的技术,但是对于Microsoft技术的跟随者来说,未免有点太不公平,整天吭哧吭哧被Microsoft拿来当免费的试验品来用。我特别不理解的是MSDN宇宙版,Microsoft总是把无穷无尽的文档灌给你,让你永远学不完,但实际上我真的不需要那么多概念,我只需要能够很好的完成我工作的技术,并且这个技术可以持续的完善就好了。而不是今天给我这样一个东西,明天灌给我无穷的文档,后天当我用顺手以后,又告诉我这东西作废了,你给我重新学习新东西,然后又是无穷的文档,总之很恼火。
所以就是:重点学习Java,有时间去学习Python,保持对dotnet的关注即可。
客户端:
前面说了那么多XAML的东西,都是和这有关,七年以后肯定是XAML的天下,但是五到七年之内还不是:
1、Java Java在客户端真的是扶不起的阿斗,这都怪Sun。Sun造就了Java的成功,又一手毁了Java在客户端的市场。那些个Swing和SWT的死忠团也不要和我争什么,我也懒得和你们争,你们觉得好就好吧,道不同不相与谋,你觉得好你就用你的,我觉得不好我就用别的。用不着缠着我非逼我说Java做客户端好,没必要,况且就算你逼我承认又怎样?我就是玉皇大帝金口玉言了?得到我的承认,Java就有前途了?我好像还没有那么大本领吧?就是IBM, Sun也没有那么大本领,所以好不好也不是我说了算,用不着逼我。
2、dotnet winforms 由于Windows2000/WindowsXP不带dotnet CLR,所以winforms一直没有能够普及得很好,等Longhorn一出来,又变成了XAML了,winforms又被淘汰了,所以 winforms的地位特别尴尬,但是在这5-7年中,你想开发既能够在Windows2000/WindowsXP,又能够在Longhorn上面跑的桌面程序,winforms好像又是Microsoft技术中最好的选择。所以只好一直尴尬下去。
3、VC,VB dotnet出来以后就开始尴尬了,说用吧,好像很落伍了,都dotnet时代了,说不用吧,又没有好的替代品,现阶段开发桌面程序,还真得不得不用,而且还挺好用的。所以VC6SP5,VB6的死忠团也比较多。
4、Delphi dotnet出来以后Borland就开始跟风了,这一跟风,连老本都跟没有了。未来的XAML时代,我也不知道Borland怎样找自己的定位,但不管怎么说,从历史来看,本地代码的应用程序永远有它一席之地!就算XAML又如何如何做得漂亮了,关键的地方,和特定资源处理相关的部分,还是本地代码的程序管用。你看VB出来多少年了,用VB开发的都是一些上层的项目级别的应用软件,一旦涉及产品领域,还是VC和Delphi管用。所以现在大家还是不得不用Delphi7阿。
BTW:XAML应用致力于快速开发项目级别的应用,特别是可以跑在IE浏览器里面的,因此是RIA的首选。但是毕竟也有很多不适合用RIA的场所,特别是例如我要备份某些文件,你用XAML?那性能就不用提了。所以Delphi如果好好发展VCL,封装Windows32 API,我觉得也是一条路,未必比现在跟随dotnet差。
5、Flash RIA 其实我觉得Flash不适合做RIA的,但是Flash普及率太高,XAML又离普及太遥远,而Flash现在就可以用了,所以是当前RIA的首选。不过我对Macromedia公司比较失望,如果Macromedia能够公布Flash实现细节,作为一个公开的标准向ISO提交,同时免费开源Flex,我敢说,Flash RIA会迅速普及的。等5-7年XAML的时代,由于Flash的市场占有率,XAML就未必能拼得过Flash。可惜的是Macromedia公司目光过于短浅,只知道赚眼前的小钱。
6、Python 这5-7年内,RIA应用和RCP应用不会统一,XAML才具备将RIA和RCP统一的实力。从这5-7年来看,Flash是RIA的首选,而RCP的首选,我要推荐Python。原因前面已经提过,简单总结一下: 1)wxWidgets是一个比MFC优雅的库,TortoiseCVS用wxWidges而不用MFC,就是因为wxWidgets好用,而不是为了可以移植。 2)Python的面向对象脚本语言编程适合快速界面开发 3)Python在服务器端和客户端都非常有前途,可以形成一个统一的解决方案,这一点明显比Java有优势 4)Python桌面应用程序可以完全编译为本地代码,脱离Python运行环境,这一点比dotnet winforms都有优势 5)Python可以不受限制的任意调用Windows32 API,所以凡是VC6可以做的事情,Python就可以做
试想一下,现在我们开发桌面应用程序有什么要求? 一、不要附带一个JRE或者CLR的累赘 二、可以快速开发 三、性能要有保证 四、方便的远程方法调用支持 此外如果能够跨平台就最好了
Java前三点都不符合;dotnet winforms不符合一;VC6不符合二和四,VB6不符合三和四;Delphi7符合前四点;Flash RIA不符合三;Python全部都符合!并且请记住Python是一个完全开源免费的方案!
客户端技术在这5-7年中,在RIA领域我会学习一下Flash,在RCP领域我会重点学习Python,此外会观望一下XAML。
人们总是偏爱“大词”。一个表达方式,如果听起来足够响亮,写在纸上能够吸引眼球,那就会变成很多人的新宠。但同样是这些大词,经过太多人的传递、消费之后,原本的含义反而像硬币上的图案一样被磨损殆尽:几乎没有人知道这些说法到底是指什么了。在IT业界,“平台(platform)”、“框架(framework)”、“构架(architecture)”等等就是这种人见人爱的大词。几乎每个厂商都愿意请来其中的一位、甚至多位为自己推销。久而久之,这些说法似乎适用于各个领域、各个层面:所有的软件系统都是“平台”,所有的开发者都在自矜于独有的“框架”。原本有确切意义的“好词”,经过这一番争夺和滥用,也只能衰减为所谓的“buzzwords”,供市场营销人士们玩味了。 我想让这些词中的一个——“框架”——荡污涤垢,重现青春。要完成这样的任务,必须动用重典才行。软件业圣经《设计模式》对框架有如下定义:“A framework is a set of cooperating classes that make up a reusable design for a specific class of software(一个框架,就是一组相互协作的类,对于特定的一类软件,框架构成了一种可重用的设计)”。这个定义虽然主要着眼于面向对象的软件开发,但已经基本上给出了这个词的核心含义:框架是软件系统的设计、开发过程中的一个概念,它强调对以完成的设计、代码的重复使用,并且,一个框架主要适用于实现某一特定类型的软件系统。 为了更好地说明框架是什么,也许还应该看看框架不是什么。 框架不是现成可用的应用系统。它仍是一个半成品,等待后来者做“二次开发”,实现为具体的应用系统。 框架不是“平台”。后者的概念更加浮泛和模糊——人们说的一个平台,可以是一种操作系统,一种应用服务器,一种数据库软件,一种通信中间件等等,因此“平台”几乎成了所有系统软件的统称。在平台的大家族中,框架的概念可能与近来人们常说的“应用平台”最为接近,但平台主要指提供特定服务的系统软件,而框架则更侧重于设计、开发过程,或者可以说,框架通过调用平台提供的服务而起作用。 框架不是工具包(toolkit)/类库(library) /API。目前流行的很多框架中,就包括了大量的类库和API,但是调用API并不就是在使用框架开发。仅仅使用API时,开发者完成系统的主体部分,并不时地调用类库实现特定任务。而框架构成了通用的、具有一般性的系统主体部分,“二次开发者”只是像做填空题一样,根据具体业务,完成特定应用系统中与众不同特殊的部分。 框架不是构架(architecture)。构架确定了系统整体结构、层次划分、不同部分之间的协作等设计考虑。框架比构架更具体,更偏重于技术实现。确定框架后,构架也随之确定,而对于同一种构架(比如web开发中的MVC),可以通过多种框架(比如Apache Struts或Apache Velocity)实现。
2
那么,在企业应用系统开发中,框架具有什么样的意义?要阐明这一点,大概要看一看在这个领域里软件开发方式的演变。在计算机应用普及之前,只有少数大企业才负担得起企业信息系统软件,这一类的软件开发也已委托定制(custom-made software)为主。在企业信息化基础设施逐步完备之后,多数中、小企业也要在预算不高的前提下实施企业应用系统,按照以前的方式逐个定制开发,是这种类型的企业难以承受的。因此,对于一些需求简明的系统,往往会购买现成软件(shrink-wrapped software)解决问题。但是各个企业具体业务不同,需求很难统一,现成软件只能满足最通用的情况和最一致的操作(比如财会系统、网站内容发布系统等),对于头绪众多的业务处理就难以胜任了。 如何最大程度地萃取不同企业应用系统的共性,重复使用已经完成的设计和代码,对企业应用系统中典型场景给出最佳解决方案——这是一个“一般性”的问题;如何让一个早先完成的软件产品贴切地适应极为多变、复杂的企业需求——这是一个“特殊性”的问题。作为对这一组冲突的一种解决方案,不少厂商推出了自己的企业应用框架。这些框架往往是从大量的委托项目开发中精选出的系统“不变项”,因此具有很强的普适性和实用性。 目前,主流企业应用框架中大都包含对以下问题的现成解决方案: * 持久性(persistence):实现数据存储、处理,数据与对象映射,数据缓存(caching)。 * 事务(transaction):确保一组关联操作正常、完整的执行。 * 安全性(security):保证系统的通信安全、数据安全。 * 负载均衡(load balance):在大量并发访问时,保持系统可用。 * 监控(system monitoring/management):监控系统运行状况,设置系统参数。 * 日志(logging):记录系统运行情况和异常,记录特定用户操作。 * 应用集成 (application integration):与其他系统、应用程序集成。 * 认证/权限/组织角色管理(authentication/authorization):管理系统用户、组织职权结构,限制特定用户对特定功能、特定数据的访问。 * 业务模型(domain model):管理系统中业务对象的属性、字段。 * 业务逻辑(business logic/rules):实现业务规则和业务逻辑。 * 工作流(work flow):实现多用户、多环节之间的业务处理流程。 * 文件管理(file management):管理文档,实现系统内部的文件传递。 * 报表/打印 (reporting/printing):实现数据打印,实现报表的定制和输出。 * 门户/信息发布 (portal solution):发布企业相关的信息、新闻,提供企业客户的访问入口。 * 通信(communication/messaging):系统内部的消息、通知;系统与外部角色(比如企业客户)之间通过不同通信媒介(电话、网站、邮件等)的互动。 * 特定行业/领域模块 (business modules):实现特定行业、流域相关的业务模块。 以上诸方面中,除了前四项目前主要由应用服务器解决之外,其他的部分本身都是专门的软件开发领域。框架的作用,在于确定上述每种因素的具体技术实现,并规定它们在系统中的组织方式和协作方式,从而给出完整的企业应用解决方案。 企业应用框架的特点首先是,当应用框架确定之后,系统的整个构架,也就是主体结构就已经固定。因此框架的选取往往是方案选型的首要问题。 其次,人们常常听信“组件式开发”的一面之词,认为系统搭建的过程类似于搭积木,好像是用胶水代码(glue code)拼合现成的组件或模块。其实采用框架开发时,系统的构建过程更类似于填空——系统骨架早已完成,开发者填写特定的代码,由系统来调用。《设计模式》中提到的“好莱坞原则(the Hollywood principle——Don't call us, we'll call you)”,非常符合我们谈的这种情况。很多框架还允许下游厂商开发系统插件(plug-ins),以满足特定需要——这是另一种形式的“填空”。 另外,对于实现具体应用系统的二次开发者来说,不少任务都无需通过编程实现。比如要给一个业务模型增添一个新字段,或是要设置一种新的工作流程,这些工作都可以通过简单的图形用户界面(GUI)操作,或是修改部署描述符(DD),或是编写脚本来完成。也就是说,相当多(而不是全部)的开发任务是通过声明/配置的(declarative),而不是编程的(programmatic)的方式实现的。系统往往会在启动或运行时载入相关的配置,据此实现特定的功能。
企业应用框架是菜场里的半成品。当我们面对要么自己下厨、要么去饭馆吃饭的选择时,我们往往会采取这种省时省力的折衷方案。但是选择之所以为选择,就因为其中肯定包含对收益和代价的权衡,都隐含着复杂的利弊关系(pros and cons)。下面我们也来检讨一下企业应用框架的情况: Pros: * 缩短开发周期 毫无疑问,采用框架的开发,要比一切从头做起快速、高效得多。通过一般化(generalization)和重用(reuse)机制,框架能最大限度地加快一个特定应用系统的实现。 * 客户化 如上所述,基于框架的系统有很多功能通过配置而不是编程实现,这样也给用户带来了一定便利。比如,企业内部的IT人员经过一定培训,就能够自己完成一种新的工作流程的设置。这对于不断变化的业务需求是一个很理想的解决方案。 * 不重新发明轮子 框架对于大量典型场景给出了最优的实践。在具体开发时,与其无视前人的成果,重新构思答案,不如套用这些成熟、稳定的做法。这不仅能加快开发进度,更能够提升系统的质量和健壮性。 * 可维护性/知识共享 完全通过委托开发完成的系统很难由其他厂商维护。框架往往是多个企业、大量开发者实践的成果,因此能在一定程度上打破上述壁垒,增进系统的可维护性。当框架使用者形成社区之后,还能实现更高层次上的知识共享。 Cons: * 太多 半成品总有其代价。超市配好的一包菜里,老是又我们用不到的调料——但是我们却不得不为之付费。同样,为了达到一般性和普适性,框架总比紧凑、贴切的特定应用多出不少内容。二次开发完成后,企业获得的只是一种特定的实现,却要为所有的客户化可能性付费,为所有用不上的功能付费。这是一个相当让人尴尬的事实。 * 太少 框架总是一种限制。就像半成品菜限制了我们的烹调方法,框架也限制了我们实际应用的可能性。当框架本身设计的足够普适时,我们不太会感到类似的限制。但是,情况往往正好相反——面对一个足够特殊的需求,二次开发者总有一种冲破框架的渴望。最后的解决办法,往往是狡计、妥协和框架补丁的结合体。 * 效率 上面说过,基于框架的系统中,具体功能经常是通过配置实现的。与硬编码(hard-coded)的方式相比较,这虽然能提供很大的灵活性,但也往往牺牲了运行时的效率。 * 锁定 一个采用特定框架的系统几乎肯定被锁定在这个厂商的产品上。换言之,框架意味着all or nothing式的态度,很难调和两种不同的框架,各取所长,更难把应用系统从一个框架迁移到另一个——这往往要求系统的全部改写。 * 学习曲线 一种框架也就是一种方言。要精通特定框架的开发,你要熟悉其中的所有的用法、思路和弱点。对于开发者,这也意味着比较陡峭的学习曲线。
3
上面谈到的种种弊端,还属于一般开发框架共有的缺陷。对于市面上流行的很多企业应用框架来说,更大的问题是框架产品自身的价格过高。我在别处也讲过,企业应用系统项目往往不能靠运行安装程序,再作简单的设置就完成,而是一个复杂、漫长、不断尝试/修改的过程,或者说,更近似于一种服务而不是简单的产品销售。但是框架厂商的产品(或者说是半成品)价格过高,经常就蚕食了整个系统的大部分开发预算,使方案总价偏重于框架本身而不是后期开发。对于需求不甚符合原有框架,需要大量开发的项目,或是需求本身不够清晰的项目,这都几乎肯定会导致整个项目的失败。
软件工程宗师F. Brooks曾经表述过这样一个道理:没有银弹(No Silver Bullet/NSB)。那意思是说,没有一种万应药能够戏剧性地提升软件开发的效率和质量。最近的很多舆论好像是专门和这个经典论述抬杠,动不动就把一些特殊的解决方案奉为银弹。对于企业应用开发而言,基于框架的开发模式是多种选择中的一种。在不少情况下,这种选择是不错的,但同时应该留意随之而来的风险,更不该以为,选定了框架就一定能保证项目成功。 很多企业应用项目的难点,在于客户自身缺乏规范的企业管理制度和合格的计算机应用水平。客户不具备成型的业务流程,也无法明晰表达需求,不知道怎样的应用形式对自身业务更合理:这种需求不清、或是需求剧烈变更的困境是困扰大量企业应用开发项目的症结。简言之,企业应用项目的成败经常是“业务”、“技术”、“管理”三种因素共同作用的结果,而单纯引入框架,只能解决部分“技术问题”。如果过于乐观地估计框架在其中的作用,甚至认为它能解决任何项目的任何问题,这对于本领域的各个环节(厂商、项目开发商、企业客户)都只能起到消极作用。 我个人的建议是:在搭建企业应用系统时,针对应用情况不同、预算/时限不同、对系统指标要求不同,有多种替代方案可以从中选择。当需求明确、固定,又有现成产品完全满足需要时,或者当企业想要以极低预算消除某个业务瓶颈时,应该优先考虑现成产品;在需求明确、固定,但很难被现成产品完全覆盖时,可以选择应用框架,并由合格开发商完成实施;在需求不够明确,或者预感到需求会发生剧烈变更时,采用开发源码的应用框架,从而避免高昂的初期投资,并“软化”框架带来的种种限制,是另一种可供选择的思路。还是那个比方,一顿饭怎么吃,究竟是下馆子、买半成品或者全由自己动手,还要视具体情形而定——我也希望,每个企业都能吃上可口顺心的应用大餐。
Extract Method
如果方法中含有过多特定的操作,方法太长,或者其中的某段代码被多次使用,这时,可以用提炼方法重构将这部分代码提取到单独的方法中。在Eclipse中应用此重构方便快捷。
选中要提炼的代码段,从重构菜单中选择提炼方法项,或者使用快捷键Alt + Shift + M。
在提炼方法对话框中,输入新方法的名字,选择修饰词,选择是否让新方法抛出运行时异常。在底部提供了新方法的预览。
Extract Local Variable
使用一个变量来代替一个表达式有很多好处。如果表达式在多处被使用,这样能够提高性能,而且也提高了代码的可读性。要把一个表达式提炼为局部变量,选择要提炼的表达式,从重构菜单中选择提炼局部变量项,或者使用快捷键Alt + Shift + L。
在提炼局部变量对话框中输入新变量的名字,选择是否要替换所有的表达式,是否使此变量为final。在对话框的底部提供变量的预览。
Extract Constant
提炼常量与提炼局部变量很相似,唯一的区别是提炼常量重构可以选择提炼出的常量的修饰词,而且此常量将作为类的成员变量。
Introduce Parameter
介绍参数重构在方法中创建新的参数,然后用此新参数取代局部变量或者成员变量的实例。要是用此重构,选中方法中一个成员变量或局部变量的引用,然后从重构菜单中选择介绍参数项。
Introduce Factory
工厂是用来创建新对象,返回新创建对象的方法。你可以选择一个类的构造方法,从重构菜单中选择介绍工厂项,应用此重构,为此类创建工厂方法。
在介绍工厂对话框,输入工厂方法的名字和需要工厂方法创建的对象的名字。选择构造方法的修饰词是否为私有。
点击OK按钮后,在指定的类中会出现此指定工厂方法。此方法创建一个当前类的实例,然后返回此实例。
Convert Local Variable to Field
转换局部变量为成员变量重构,将方法内的变量声明移动到方法所在类中,使该变量对整个类可见。选择一个局部变量,从重构菜单中选择转换局部变量为成员变量项,随后打开配置的对话框。
在此对话框中,添入成员变量的名字,选择修饰词,选择在哪里实例化此成员变量。随后的声明为静态,声明为final 选择项是否可以使用,取决于实例化位置的选择情况。
Encapsulate Field
要正确的实践面向对象编程,应该将成员变量的修饰词置为私有,提供相应的访问器来访问这些成员变量。但是这些操作很烦琐。如果使用了封装成员变量重构,则十分方便。选择一个成员变量,从重构菜单中选择封装成员变量项。
在封装局部变量对话框中,添入Getter, Setter方法的名字,选择新方法在哪个方法后出现。选择合适的修饰词。应用了此重构会创建两个新方法,将此成员变量的修饰词置为私有,将对此成员变量的引用改变为对新方法的引用。
重构项列表:
下表从Eclipse帮助中提取,列出了各种重构支持的Java资源类型,对应的快捷键。
名字
|
可应用的Java元素
|
快捷键
|
Undo
|
在一次重构后可执行
|
Alt + Shift + Z
|
Redo
|
在一次撤销重构后可执行
|
Alt + Shift + Y
|
Rename
|
对方法,成员变量,局部变量,方法参数,对象,类,包,源代码目录,工程可用。
|
Alt + Shift + R
|
Move
|
对方法,成员变量,局部变量,方法参数,对象,类,包,源代码目录,工程可用。
|
Alt + Shift + V
|
Change Method Signature
|
对方法可用。
|
Alt + Shift + C
|
Convert Anonymous Class to Nested
|
对匿名内部类可用。
|
|
Move Member Type to New File
|
对嵌套类可用。
|
|
Push Down
|
对同一个类中成员变量和方法可用。
|
|
Pull Up
|
对同一个类中成员变量和方法,嵌套类可用。
|
|
Extract Interface
|
对类可用。
|
|
Generalize Type
|
对对象的声明可用。
|
|
Use Supertype Where Possible
|
对类可用。
|
|
Inline
|
对方法,静态final类,局部变量可用。
|
Alt + Shift + I
|
Extract Method
|
对方法中的一段代码可用。
|
Alt + Shift + M
|
Extract Local Variable
|
对选中的与局部变量相关的代码可用。
|
Alt + Shift + L
|
Extract Constant
|
对静态final类变量,选中的与静态final类变量相关的代码可用。
|
|
Introduce Parameter
|
对方法中对成员变量和局部变量的引用可用。
|
|
Introduce Factory
|
对构造方法可用。
|
|
Convert Local Variable to Field
|
对局部变量可用。
|
Alt + Shift + F
|
Encapsulate Field
|
对成员变量可用。
|
|
本文介绍了Eclipse提供的各种重构。这些重构易于使用,可以确保代码重构更加方便安全。而且可以自动生成代码以提高生产率。
某些重构改变了某些类的结构,但没有改变项目中其他类的结构,如下推,上移重构。这时,就要确保项目中所有对改变元素的引用都要被更新。这也是为什么要有一个好的测试套。同时,你也要更新测试套中的对改变元素的引用。所以说,重构和单元测试的有机结合对于软件开发是多么的重要。
重构和单元测试是程序员的两大法宝,他们的作用就像空气和水对于人一样,平凡,不起眼,但是意义深重。预善事,必先利器,本文就介绍怎样在Eclipse中进行重构。
本文介绍了Eclipse支持的重构种类,它们的含义,以及怎样重构。本文同时也可以作为学习重构知识的快速手册。
什么是重构
重构是指在保持程序的全部功能的基础上改变程序结构的过程。重构的类型有很多,如更改类名,改变方法名,或者提取代码到方法中。每一次重构,都要执行一系列的步骤,这些步骤要保证代码和原代码相一致。
为什么重构很重要
手工重构时,很容易在代码中引入错误,例如拼写错误或者漏掉了重构的某一步。为了防止引入错误,在每次重构前后,都要执行充分的测试。你可能会好奇重构是否是值得的。
重构的理由很多。你可能想要更新一段代码很烂的程序。或者最初的设计队伍都不在了,现在队伍中每人了解这些程序。为了更新,你必须要重新设计构建程序来满足你的需求。另一个原因是原来的设计无法使你将新的特性添加进去。为了添加进去,你要重构这些代码。第三个原因是一个自动重构的工具可以为你自动生成代码,例如Eclipse中的重构功能。使用重构,你可以在重写尽量少的代码和仍保持软件功能的同时,使代码的逻辑性更好。
测试
在重构时,测试是十分重要的。应为重构改变了代码的结构,你要保证重构后代码的功能没有被改变。手工重构时,一个好的测试套是必须的。使用自动重构工具是,测试也是必要的,但不需要很频繁,应为自动重构工具不会产生手工重构时的那些错误,如拼写错误。
在Eclipse中可以使用JUnit方便的为程序创建测试代码,具体方法不在本文描述。
Eclipse中的重构
JDT,Eclipse中的Java插件,能够对Java项目,类,或成员进行多种类型的自动重构。可以采取多种方法快速的为Java项目中的某个元素进行重构。
为某些元素进行重构的前提是你必须选中他们。你可以在多个视图中选择这些元素,像大纲视图或包浏览视图。可以按住Ctrl或Shift键,在视图中选择多个元素。另外一种选择的方法是使该元素的编辑区高亮显示,或者把鼠标定位到源程序文件。在选中希望重构的元素后,可以从重构菜单的下拉项选择重构,也可以从右键单击后弹出菜单中选择重构子菜单。同时,Eclipse还提供了重构的快捷键操作。
某些重构可以应用在任意元素上,有些则只能用在特定类型的元素上,如类或方法。在本文的最后的表格中,列出了重构能够应用的元素类型,以及重构的快捷键。
在Eclipse中,所有的重构都能够在正式执行之前预览一下。在重构对话框中点击“预览”按钮,可以查看所有将要被改变的地方。唯一没有预览按钮的的重构是Pull Up,在它的重构向导中,到最后,预览面板总会出现。可以将其中的个别变化反选掉,这样这些改变就不会生效。
撤销和重做
在重构菜单中有撤销和重做项。他们和编辑菜单中的撤销重做不同。即使重构改变了很多文件,编辑菜单中的撤销重做只会更改当前文件。重构菜单中的撤销和重做则会对一次重构的所有文件进行撤销和重做操作。但是在使用时,它们有一定的限制。
重构后,无论重构改变了文件与否,如果任一个文件被另外改变而且保存了,你就无法撤销或重做这个重构。假如一个文件在重构中被修改了,然后又被编辑了,但是还没有保存,这时就会有错误信息提示,如果你想要撤销或重做该重构,必须撤销未保存的文件。
只要注意到以上的限制条件,你就可以随心所欲的对重构进行撤销或重做。你甚至能够编译,运行你的程序测试一下,然后再撤销该重构,只要你没有改变并保存任何文件。
Eclipse中的重构类型
如果你看一下Eclipse的重构菜单,可以看到四部分。第一部分是撤销和重做。其他的三部分包含Eclipse提供的三种类型的重构。
第一种类型的重构改变代码的物理结构,像Rename和Move。第二种是在类层次上改变代码结构,例如Pull Up和Push Down。第三种是改变类内部的代码,像Extract Method和Encapsulate Field。这三部分的重构列表如下。
类型1 物理结构
l Rename
l Move
l Change Method signature
l Convert Anonymous Class to Nested
l Convert Member Type to New File
类型2 类层次结构
l Push Down
l Push Up
l Extract Interface
l Generalize Type (Eclipse 3)
l User Supertype Where Possible
类型3 类内部结构
l Inline
l Extract Method
l Extract Local Variable
l Extract Constant
l Introduce Parameter
l Introduce Factory
l Encapsulate Field
Rename:
Rename用来改变一个Java元素的名字。虽然你可以手工改变Java文件Java元素的名字,但是这样不能自动更新所有引用它们的文件或Java元素。你必须在项目中搜索文件然后手工替换这些引用。很可能你就会漏掉一个或者改错一个。Rename 重构会智能的更新所有有此引用的地方。
有时候,Java元素的名字不是很明了,或者它的功能已经改变了。为了保持代码的可读性,该元素的名字也要更新。使用Rename重构,能够十分快捷的更新元素的名字和所有引用它的地方。
要为一个Java元素改名,在包浏览视图或大纲视图选中该元素,从重构菜单中选择Rename项,或者使用快捷键Alt+Shift+R。Rename对话框会出现。在这里添入新的名字,选择是否更新该元素的引用。点击预览按钮,会打开预览窗口,在这里,你可以看到那些内容会被改变。点击OK按钮,重构结束。
Move
Move和Rename很相似。它用来把元素从一个位置移动到另一个位置。它主要用来将类从一个包移动到另一个包。选中要移动的元素,从重构菜单中选择Move,或者使用快捷键,Alt+Shift+V,在弹出窗口中选择要移动的目的地。你仍然可以用预览功能检查一下有什么改变,也可以按OK按钮直接让其生效。
Change Method Signature
更改方法签名能够改变参数名,参数类型,参数顺序,返回类型,以及方法的可见性。也可以添加,删除参数。
要执行此重构,选择要重构的方法,选中重构菜单的更改方法签名项,会出现更改方法签名对话框。
在此对话框中选择方法的修饰词,返回类型,参数。参数的添加,修改,移动,删除可以通过右边的按钮控制。当添加新的参数时,会自动赋予默认值。凡是调用此方法的地方都会用此默认值作为参数输入。
改变方法签名可能在方法中导致问题,如果有问题,当你点击预览或OK时,会被标记出来。
Move Members Type to New File
此重构将嵌套类转为一个单独类。将会创建一个新的Java文件包含此嵌套类。选中要重构的类,在重构菜单上选择Move Member Type to New File项,在弹出的对话框中添入要创建的实例的名字。
Push Down
此重构将算中的方法和成员从父类中移动到它的直接子类中,所有下推的方法都可选作为一个抽象方法留在父类中。下推重构对于重新构建项目设计十分有用。
选择若干方法或成员,从重构菜单中选择下推项,弹出下推对话框。
在此对话框中,可以分别选择方法或成员,所有选中元素都会移动到当前类的子类中。当点击Add Required按钮时,所有已选择元素所必需的元素也会自动选上,此行为并不能保证所有必须的元素都能自动选中,还是需要人工确认。当有方法被选中时,编辑按钮就会可用,点击编辑按钮,弹出编辑对话框。在其中可以选择为选中方法在当前类中遗留抽象方法,还是在当前类中删除这些方法。双击一天选中的方法,也可以打开编辑对话框。在方法的Action列点击,会出现一个下拉列表,可以在其中选择遗留抽象方法还是在当前类中删除方法。按回车键确认编辑结果。
Pull Up
上移与下推类似,也是在类之间移动方法和成员。上移将方法或成员从一个类移动到它的一个父类中。选中若干个方法或成员,在重构菜单中选择上移项,上移向导马上会出现。
在选择目标类多选框中,列出了当前类继承的所有父类。你只能将方法或成员移动到它们其中的一个里面。
如果在选中方法的Action列,被设置成在目标类中声明抽象方法,那么在目标类的非抽象子类中创建必须的方法选项变为可选。当它选中时,目标类的所有子类,如果它们中没有选中的方法,则会为它们创建选中的方法。
和在下推中一样,选择多个方法,点击编辑按钮,或者双击一个方法,都会打开编辑成员对话框。其中有两个选项,上移和在目标类中声明抽象方法。上移只是简单的复制方法到到父类中,并提供选择是否在当前类中删除该方法。在目标类中声明抽象方法会在父类中创建一个选中方法的抽象方法,如果父类不是抽象类则置为抽象类,最后选中方法留在当前类中。和在下推中一样,也可以点击Action列,可以在出现的下拉列表中选择。
如果方法的Action列选为上移,在下一步的向导中,将会要求你选择是否在当前类中删除这些方法,选中的方法会在当前类中被删除。
在向导的任意一步都可以按完成按钮,结束重构操作,此时按照默认规则进行重构。
Extract Interface
提炼接口可以从一个存在的类中创建一个接口。你可以选择在接口中包含着个类的那些方法。选中一个类,从重构菜单选择提炼接口项,就可以打开提炼接口对话框。
这此对话框中添入接口的名字,选择希望包含的方法,在这个列表里面只列出了公共方法。选中改变对类[当前类名]的应用为对接口的引用选择框,将把所有对当前类的引用更新为对此接口的引用。
Generalize Type
泛化类型重构可以将一个声明对象的类型改变为它的超类,选择变量,参数,对象成员,方法返回类型,然后选择重构菜单的泛化类型项。在打开的泛化类型对话框,选择希望的新类型,然后点击完成按钮,结束重构。
Use Supertype Where Possible
使用超类会将对一个特定类型的引用改变为对它的超类的引用。选择一个类,选中重构菜单的使用超类项,会打开使用超类对话框。选中希望的超类类型,点击完成按钮完成重构。重构后,instanceof 表达式也会做相应的替换。
Inline
内联是用代码或值来取代调用方法的地方,静态final对象成员,或局部变量。比如说,如果你内联一个方法调用,这个调用的地方就会被替换为该方法体。要内联一个方法,静态final对象成员,局部变量,选中这些元素,在重构菜单中选择内联项,或者使用快捷键Alt + Ctrl + I。在随后打开的内联对话框,你可以选择是否要内联所有的调用,或者是选择的调用。如果选择所有调用,你还可以选择是否删除声明本身。
|