你的代码线程安全么?

2007年6月初的一天,由Doug Lea维护的一个关于j.u.c的邮件列表里收到一封邮件,顿时激起千层浪。邮件的内容大致是说BigDecimal的LONGMIN和LONGMAX两个静态变量在获取时会发生初始化失败。简单地讲,初始化失败是指,线程T认为对象O已经初始化完毕而使用O,但事实上O并未完成初始化。这封邮件立即引发了广泛的讨论,甚至Joshua Bloch本人也加入了争论,他先说“这绝对是个问题!”,之后又表示“事实上,我并不是非常担心这个问题代来的影响...”而这句话又引来了更多的争论。抛开这些不提,我们可以看到,程序中的并发错误有时是非常隐蔽的,即使一个已经工作了数十年的JDK核心类也难逃这一劫。

那么您的代码呢?随着多核的普及,我们的程序会越来越多地运行在真实的并行环境中。在让代码享受并行带来的性能提升的同时,首先要确保现有的代码不会出现错误。如果不能保证软件的正确性,任何性能的改良都是枉然的。然而,现有的代码不可避免地包含了并发错误,在单核系统下,这些错误发生的可能性极低;但是在多核环境下,这些错误会频繁(至少是更有可能)发生,正如前TheServerSide的编辑Dion Almaer所说,我们的软件只是“碰巧”可以工作罢了。

线程安全的bug不仅难以发现和再现,而且即使是最熟悉的代码,也会悄然无声地带有bug。比如前面提到的BigDecimal。在Java中,即使我们最熟悉的long、double类型的变量,也会在某一天成为隐患。代码1展示了Java代码中司空见惯的setter/getter方法,这里它操作的数据是长整型的id。

----------
private long _id;

public void setId(long id) {
_id = id;
}

public long getId() {
return id;
}
----------
代码 1

这段普通得不能再普通的代码却有可能在并发环境下出现错误。原因在于Java在读取和写入长整型时,不能保证是原子操作。在JLS中规定,32位的基本类型的读写操作是原子的,然而long和double是64位的,因此读写long和double的操作是分为两步完成的。例如,声明long a的值如下图:

long a:111......11,000......00
<-63---32->| <-31----0->
long b: 000......00,111......11
<-63---32->| <-31----0->
long x: 111......11,111......11
<-63----------------0->

线程A读取a的值。首先A会读入a的63-32位的值,这时线程B恰好将b的值赋给了a,接下来A继续读入a的31-0位的值,而这时前32位的值已经“过期”了,程序最终看到的a将等于x,而x既不等于a,也不等于b。对于double的操作也可能会出现完全类似的情况。

为了避免这个问题,我们可以使用synchronized关键字封装long、double类型数据的getter和setter方法。这样可以避免原子性的失败,不过也带来新的一些问题:

1.可能会影响程序的性能;
2.类变得脆弱;比如,客户可以很容易地覆写getter/setter,同时忽略synchronized关键字。

除了synchronized关键字,Java还提供了轻量级的同步机制:volatile。JVM会保证对volatile类型的long和double的读写操作是原子化的,这刚好适合解决这里的问题:将所有的long和double类型的变量都声明为volatile类型。synchronized不能修饰变量,而volatile是只能修饰变量的。volatile不需要加锁/解锁的过程,因此性能要好于synchronized,并且与非volatile类型变量的读写效率几乎相差无几,而且代码更加清晰。如同代码2。

----------
private volatile long _id;

public void setId(long id) {
_id = id;
}

public long getId() {
return id;
}
----------
代码2

你可能感兴趣的:(jvm,jdk,工作,J#)