java中的日期工具

蹩脚的日期API

众所周知,jdk自带的日期API用起来非常蹩脚,属于JDK中设计较差的典型。

首先我们来看看常见类的分工:

  • Calendar实现的是日期和时间之间的转换,其他就没什么用了
  • DateFormat用来格式化和解析日期字符串
  • Date用来表示日期和时间信息

下面我们再看看其中很混淆的一些设计:

  • 有两个Date类,分别是java.sql.Date和java.util.Date,前一个继承后一个,不要搞混了,通常我们是使用后一个。*
  • java.sql是用于数据库类型的,这个Date是一个单纯的日期类型,意思是没有时间的概念,如果要时间,应该选择java.sql.Timestamp。这是个很悲催的设计,把原来的Date功能阉割了。
  • 注意Date的构造方法参数是很神奇的,年份是减去1900的数,月份是从0到11来表示的。
  • Date的getTime返回的时间是相对于“1970-01-01 00:00:00”的毫秒数差值
  • 日期的调整、差距计算非常麻烦
  • 这些类都不是线程安全的,特别是格式化功能,经常有人掉坑,是重灾区来的

API的替代品

日期API实在是太烂了,幸好有其他开源选择,例如著名的Joda-Time,有兴趣的可以去试试。

另外,还有最新的关于日期API的规范:JSR-310。官方的描述叫做“This JSR will provide a new and improved date and time API for Java.”,JSR-310将解决许多现有Java日期API的设计问题。比如Date和Calendar目前是可变对象,你可以随意改变对象的日期或者时间,而Joda就将DateTime对象设计成String对象一样地不可变,能够带来线程安全等等的好处,因此这一点也将被JSR-310采纳。

时间的度量

有两个特殊的API是和计时有关的,就是System.currentTimeMillis和System.nanoTime。两个的区别和用法如下:

currentTimeMillis的值是相对于“1970-01-01 00:00:00”的毫秒数差值,跟new Date().getTime是一样的,因为new Date()就调用currentTimeMillis作为参数。大家应该对这个是比较熟悉的。

nanaTime是jdk5才增加的API,能够提供更为精确的时间度量(纳秒呀,精度太高了,应该是系统时钟的近似值)。不过,它返回的是一个相对时间,而不像currentTimeMillis是一直增长的。所以要让时间有意义,必须用上时间差,就是用两个nanaTime来相减。

简单来说,currentTimeMillis表示的是精确时间点的概念,nanaTime表示的是相对时间差的概念。用来表示一段时间差的话,nanoTime可以提供更好的精度。

顺便提一下,JDK没有度量微妙的API,需要的话只能自己模拟了。

更多时间工具

如果是多线程编程的话,就不能不忽视java.util.concurrent工具包。它也提供了一些时间方面的工具,如TimeUnit类,简单来说,它是一个单位转换的辅助类,可以用更加直观的时间单位来操作。例如休眠3s,可以用下面的代码:

TimeUnit.SECONDS.sleep(3d);

这个类也用于这个包里边许多带超时机制的API中,例如使用lock的API是这样使用的的:

Lock lock = ...;
if (lock.tryLock(50L, TimeUnit.MILLISECONDS)) {
    try {
        // manipulate protected state
    } finally {
        lock.unlock();
    }
} else {
    // perform alternative actions
}

关于这个并发工具包的内容,以后有机会再介绍。

Dom操作和渲染并不是同步的

一个在ie6不兼容的线上问题

昨天,有同事找我看一个线上问题,说一个在ie8测试通过的功能,在ie6上不能正常使用。 一开始,他用alert定位到是哪部分代码出问题,并认为后面有部分代码没有执行。奇怪的是,在这中间加上alert语句的话, 在ie6上也是能够正常运行的。

这部分代码简化后大概是这样的:

<select onclick="javascript:selectbank(this);">
<option value="">--all--</option>
</select>
function(obj){
  // get val and desc from a pop up page
  var opt = "<option value='" + val + "'>" + desc + "</option>";
  $(obj).append(opt);
  $(obj).val(val);
  // alert("test");
  // ...
}

我发现没有加alert的时候,页面会报js错误。并且,这种情况在ie8不会出现。

Dom操作和渲染并不是同步的

为什么会出现这种情况,这主要因为Dom操作比普通js操作的消耗大很多,因为它需要改变页面元素, 导致页面出现重新渲染的情况,这取决于浏览器的实现。像ie8等比较高级的浏览器,渲染、js速度都要快许多, 所以不会出现这种情况。而ie6太慢了,很可能还没反应过来。

类似的情况是,在一个js调用过程中(某次事件),如果你频繁修改dom,浏览器很可能不会逐个生效, 而是几个操作一起生效,跟期望的有点不一样。在一些低端浏览器,可以做的优化是,让调用过程尽量快, 减少Dom操作,或者中间使用setTimeout休息一下。

而像上面的问题,修改成下面的代码就可以了:

function(obj){
  // get val and desc from a pop up page
  var opt = "<option selected value='" + val + "'>" + desc + "</option>";
  $(obj).append(opt);
  // alert("test");
  // ...
}

WebSphere数据源中的连接被意外关闭案例

频繁创建连接的现象

前阵子维护反馈说,oracle数据库每秒创建连接数过高,而主要来源来自于WebSphere集群所在的主机。 按理说,使用连接池的应用,连接是不会很频繁的。追溯一下所在主机的程序,发现可疑对象是一个使用jdbc轮子的应用。 就是自己写了一套代码封装了jdbc操作,虽然连接是从数据源中获取的。

但是,单纯看代码,的确没发现有什么问题,该关闭的地方也关闭,不存在泄露的情况。再说,这种现象也不是泄露的表现。 另外,由于生产上的数据库连接串,开发人员是没有的,所以生成都是通过jndi的方式来处理的,不存在盗链的代码。

不过,我们还是在日志里边发现一些问题。在SystemOut的日志里边,频繁出现类似下面的异常信息:

[12-2-27 9:01:29:262 GMT+08:00] 00000038 MCWrapper     E   J2CA0081E: 
尝试对资源 ecareDB  ManagedConnection WSRdbManagedConnectionImpl@23f823f8 执行方法 cleanup 
方法 cleanup 失败捕获的异常com.ibm.ws.exception.WsException: DSRA0080E: 
An exception was received by the Data Store Adapter. See original exception message: 
 Cannot call 'cleanup' on a ManagedConnection while it is still in a transaction..

异常的来源,居然是连接的close方法。

为什么close会失败

在WebSphere的资料上没有找到相关的描述,但是我在ibatis的文档上找到了相关的描述(ibatis developer guide的12-13页):

The element also allows an optional attribute commitRequired that can be true or false. Normally iBATIS will not commit transactions unless an insert, update, or delete operation has been performed. This is true even if you explicitly call the commitTransaction() method. This behavior creates problems in some cases. If you want iBATIS to always commit transactions, even if no insert, update, or delete operation has been performed, then set the value of the commitRequired attribute to true. Examples of where this attribute is useful include:

  1. If you call a stored procedures that updates data as well as returning rows. In that case you would call the procedure with the queryForList() operation – so iBATIS would not normally commit the transaction. But then the updates would be rolled back.

  2. In a WebSphere environment when you are using connection pooling and you use the JNDI

and the JDBC or JTA transaction manager. WebSphere requires all transactions on pooled connections to be committed or the connection will not be returned to the pool. Note that the commitRequired attribute has no effect when using the EXTERNAL transaction manager. ### 结论及对策 从上面的描述上看到,WebSphere环境的连接是需要提交事务的,否则会被意外关闭。 并且,如果选择ibatis的事务管理机制,就应该设置commitRequired属性,要么就应该使用spring的事务解决方案。 我们尝试给查询语句增加commit操作,果然异常信息不再出现,并且数据库连接数也大幅度减少了。 看来,在生产系统中还是应该优先考虑成熟的解决方案,不要随便造轮子。

带平衡的对象池设计

最近有个需求,是关于一个Socket连接池的功能改造,要求实现下面的需求:

  1. 可配置多个服务器地址
  2. 服务器地址可以配置权重(比如3:2:1)
  3. 连接池可以设置最小连接和最大连接
  4. 某服务器从崩溃中恢复后,连接池中的连接数可以自动恢复到服务器之间的权重比。
  5. 连接可设置最大空闲释放时间

一开始有同事自定义了一个链表来做取数和归还的操作,在线程安全方面操作感觉还是有点麻烦。 后来我采用PriorityBlockingQueue来实现,其实就是内部变成了堆的结构。见下面的图:

数据结构

类图

状态图

初探Java字符串

String印象

String是java中的无处不在的类,使用也很简单。初学java,就已经有字符串是不可变的盖棺定论,解释通常是:它是final的。

不过,String是有字面量这一说法的,这是其他类型所没有的特性(除原生类型)。另外,java中也有字符串常量池这个说法,用来存储字符串字面量,不是在堆上,而是在方法区里边存在的。

字面量和常量池初探

字符串对象内部是用字符数组存储的,那么看下面的例子:

String m = "hello,world";
String n = "hello,world";
String u = new String(m);
String v = new String("hello,world");

这些语句会发生什么事情? 大概是这样的:

  1. 会分配一个11长度的char数组,并在常量池分配一个由这个char数组组成的字符串,然后由m去引用这个字符串。
  2. 用n去引用常量池里边的字符串,所以和n引用的是同一个对象。
  3. 生成一个新的字符串,但内部的字符数组引用着m内部的字符数组。
  4. 同样会生成一个新的字符串,但内部的字符数组引用常量池里边的字符串内部的字符数组,意思是和u是同样的字符数组。

如果我们使用一个图来表示的话,情况就大概是这样的(使用虚线只是表示两者其实没什么特别的关系):

对象在内存中的布局

结论就是,m和n是同一个对象,但m,u,v都是不同的对象,但都使用了同样的字符数组,并且用equal判断的话也会返回true。

我们可以使用反射修改字符数组来验证一下效果,可以试试下面的测试代码:

@Test
public void test1() throws Exception {
    String m = "hello,world";
    String n = "hello,world";
    String u = new String(m);
    String v = new String("hello,world");

    Field f = m.getClass().getDeclaredField("value");
    f.setAccessible(true);
    char[] cs = (char[]) f.get(m);
    cs[0] = 'H';
    
    String p = "Hello,world";
    Assert.assertEquals(p, m);
    Assert.assertEquals(p, n);
    Assert.assertEquals(p, u);
    Assert.assertEquals(p, v);
}

从上面的例子可以看到,经常说的字符串是不可变的,其实和其他的final类还是没什么区别,还是引用不可变的意思。 虽然String类不开放value,但同样是可以通过反射进行修改,只是通常没人这么做而已。 即使是涉及”修改”的方法,都是通过产生一个新的字符串对象来实现的,例如replace、toLower、concat等。 这样做的好处就是让字符串是一个状态不可变类,在多线程操作时没有后顾之忧。

当然,在字符串修改的时候,会产生一个新的对象,如果执行很频繁,就会导致大量对象的创建,性能问题也就随之而来了。 为了应付这个问题,通常我们会采用StringBuffer或StringBuilder类来处理。

另外,字符串常量通常是在编译的时候就确定好的,定义在类的方法区里边,也就是说,不同的类,即使用了同样的字符串, 还是属于不同的对象。所以才需要通过引用字符串常量来减少相同的字符串的数量。可以通过下面的代码来测试一下:

class A {
    public void print() {
        System.out.println("hello");
    }
}

class B {
    public void print() {
        String s = "hello";
        // 修改s的第一个字符为H
        System.out.println("hello"); // 输出Hello
        new A().print(); // 输出hello
    }
}

字符串操作细节

String类内部处理有个字符数组之外,还使用偏移位置offset和长度count, 通过offset和count来确定字符数组的一部分,这部分才是这个字符串的真正的内容。 例如,有substring这个常用方法,看下面的例子:

String m = "hello,world";
String u = m.substring(2,10);
String v = u.substring(4,7);

按照上面的说法,m,n的数据结构就如下图所示:

substring在内存中的布局

可以发现,m,n,v是三个不同的字符串对象,但引用的value数组其实是同一个。 同样可以通过上述反射的代码进行验证,这里就不详述了。

但字符串操作时,可能需要修改原来的字符串数组内容或者原数组没法容纳的时候,就会使用另外一个新的数组,例如replace,concat,+等操作。另外,oracle的JDK实现中,String的构造方法,对于字符串参数只是引用部分字符数组的情况(count小于字符数组长度),采用的是拷贝新数组的方式,是比较特别的,不过这个构造方法也没什么机会使用到。

例如下面的代码:

String m = "hello,";
String u = m.concat("world");
String v = new String(m.substring(0,2));

得到的结构图如下:

新字符数组在内存中的布局

可以发现,m,u,v内部的字符数组并不是同一个,有兴趣可以试验一下。

常量池中字符串的产生

常量池中的字符串通常是通过字面量的方式产生的,就像上述m语句那样。 并且他们是在编译的时候就准备好了,类加载的时候,顺便就在常量池生成。

可以通过javap命令检查一下class的字节码,可以发现下面的高亮部分(以上面代码为例):

 javap -v StringTest
 
 Compiled from "StringTest.java"
 public class com.github.mccxj.StringTest extends java.lang.Object
   SourceFile: "StringTest.java"
   minor version: 0
   major version: 50
   Constant pool:
 const #1 = Method       #9.#28; //  java/lang/Object."<init>":()V
+ const #2 = String       #29;    //  hello,
+ const #3 = String       #30;    //  world
 ...
+ const #46 = Asciz       hello,;
+ const #47 = Asciz       world;
 ...

大家不知有没有发现,上面的图中,u和v的字符数组没有被常量池里边的字符串引用到。 原因就是这些字符串(字符数组)都是运行时生成的,而常量池里边的字符串和字符数组是完整对应上的(count等于数组长度)。

即使是字符串的内容是一样的,都不能保证是同一个字符串数组。例如下面的代码:

String m = "hello,world";
String u = m + ".";
String v = "hello,world.";

u和v虽然是一样内容的字符串,但内部的字符数组不是同一个。画成图的话就是这样的:

不同字符数组在内存中的布局

另外有一点,如果让m声明为final,你就会发现u和v变成是同一个对象。画成图的话就是这样的:

u和v在内存中的布局

这应该怎么解释的?这其实都是编译器搞的鬼,因为m是final的, u直接被编译成”hello,world.”了,如果使用javap查看的话,会发现下面一段逻辑:

const #2 = String       #25;    //  hello,world
const #3 = String       #26;    //  hello,world.
...
public void test1()   throws java.lang.Exception;
  Code:
   Stack=1, Locals=4, Args_size=1
   0:   ldc     #2; //String hello,world
   2:   astore_1
   3:   ldc     #3; //String hello,world.
   5:   astore_2
   6:   ldc     #3; //String hello,world.
   8:   astore_3
   9:   return

那么,如何让运行时产生的字符串放到常量池里边呢? 可以借助String类的intern方法。 例如下面的用法

String m = "hello,world";
String u = m.substring(0,2);
String v = u.intern();

上面我们已经知道m,n使用的是同一个字符数组,但intern方法会到常量池里边去寻找字符串”he”,如果找到的话,就直接返回该字符串, 否则就在常量池里边创建一个并返回,所以v使用的字符数组和m,n不是同一个。画成图的话就是这样的:

intern在内存中的布局

字符串的内存释放问题

像字面量字符串,因为存放在常量池里边,被常量池引用着,是没法被GC的。例如下面的代码:

String m = "hello,world";
String n = m.substring(0,2);

m = null;
n = null;

经过上述的操作,画成图的话就是这样的:

内存释放后的布局

而经过上面的分析,我们知道像substring、split等方法得到的结果都是引用原字符数组的。 如果某字符串很大,而且不是在常量池里存在的,当你采用substring等方法拿到一小部分新字符串之后,长期保存的话(例如用于缓存等), 会造成原来的大字符数组意外无法被GC的问题。

关于这个问题,常见的解决办法就是使用new String(String original)或java.io.StreamTokenizer类。并且在网上已经有比较广泛的讨论,大家可以去阅读一下:

结论

  • 任何时候,比较字符串内容都应该使用equals方法
  • 修改字符串操作,应该使用StringBuffer,StringBuilder
  • 可以使用intern方法让运行时产生字符串的复用常量池中的字符串
  • 字符串操作可能会复用原字符数组,在某些情况可能造成内存泄露的问题