1,关于synchronized:一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定性“临界区(critical section)”。同步还有一另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态,而另一个对象同时修改该状态,而且希望确保当一个线程修改了对象状态后,其它线程能够看到发生的状态变化。
2,可见性:是指在没有线程同步的情况下,一个线程可能会看不到一个“我们认为它可以看到”的值。例如:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
上面的程序中,读线程可能永远看不到ready的值,或者输出0。因为读线程看到了写入后的ready的值,但却没有看到写入后的number的值。产生这种现象的原因是因为“重排序(Reordering)”。对于上面的例子来说,主线程先写入number,然后在没有同步的情况下写入ready,读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,想要对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
3,失效数据
失效数据是指,当我们要做一个复合操作时(例如“某个变量变成一种特定状态后,我们要做一个特定操作”),在我们取得这个变量的状态,看到这个变量已经变成我们设想的特定状态后,在做一个特定操作之前的时间点,这个变更的值被改成了其它状态的时候,我们可以称我们取到的变量的状态是一种失效数据。
4,非原子的64位操作
对于非volatile类型的64位数据变量(double或long),JVM允许将64位的读或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量读操作和写操作在两个不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。可以用volatile来声明它们,或用锁保护起来解决这个问题。
5,volatile变量
volatile变量是一种稍弱的同步机制。当把变量声明为volatile后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其它内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方,因此在读取volatile类型的变量时,总会返回最新的写入值。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。
volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。加锁机制可以确保可见性又可以确保原子性,但volatile变量只能确保可见性。
6,发布:发布一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。
7,逸出:当某个不应该发布的对象被发布时,这种情况称为逸出。
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
void doSomething(Event e) {
}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {
}
}
上面的代码中有逸出行为。当从对象的构造函数中发布对象时,只是发布了一个沿未构造完成的对象。即使发布对象位于构造函数的最后一行也是如何。如果this引用在构造过程中逸出,那么这种对象被认为是不正确构造。
这里的doSomething(e);
方法是this引用逸出的问题点。在构造函数未执行完时,就把当前类的引用给了EventSource,新的线程可以看见当前类。如果使用了当前类,则使用的是一个未构造完成的类。
在构造函数中创建线程关没有错误,但最好不要立即启动它,而是通过一个start或initailze方法来启动。例如下面:
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
void doSomething(Event e) {
}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {
}
}
8,线程封闭
一种避旬使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭。这种技术一种常见的应用是JDBC对象。维持线程封闭的一种规范方法是使用ThreadLocal类。
9,栈封闭
只能通过局部变量才能访问对象。就是把使用的变量定义在方法里,这样就不会被外部访问了。
10,不变性
满足同步需求的另一种方法是使用不可变对象。当满足以下条件时,对象才是不可变的。
- 对象创建以后其状态不能修改
- 对象的所有域都是final类型(也不是都必须是final类型,String类型就是一个例外)
- 对象是正确创建的(在创建期间,没有this引用逸出)
11,Final域
final域能确认初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。除非需要更高的可见性,否则应该将所有的域都声明成私有的,这是一个良好的编程习惯。
12,一个设计原则
当需要对一组相关数据以原子的方式执行某个操作时,就可以考虑:
- 创建一个不可变的类来包含这些数据
- 如果要更新这些变量,可以创建一个新的容器对象保存修改后的值,然后替换原来的对象。这个变量最好声明成volatile,可以保持其它线程可见性。
// 不可变的对象
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i,
BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
// 使用volatile保持可见性的同步
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
// Doesn't really factor
return new BigInteger[]{i};
}
}
13,安全发布
下面的例子,对于Holder类,像StuffIntoPublic类那样初始化,会有问题。
public class StuffIntoPublic {
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
}
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
如果把Holder里的n变成final类型的话,就会没有问题。原因请看Java中final修飾符的初始化安全性的理解
13,安全发布常用模式
- 在静态初始化函数中,初始化一个对象的引用。
- 将对象的引用保存到volatile类型的域,或者AtomicReferance对象中。
- 将对象的引用保存到某个正确构造对象的final域中。
- 将对象的引用保存到一个由锁保护的域中。
通常,要发布一个静态构造的对象,最简单的方式是使用静态的初始化器:
public static Holder holder = new Holder(42);
静态初始化器,由JVM在类的初始化
阶段执行。由于在JVM内部存在着同步机制,因此这种方式初始化的任何对象都可以被安全的发布。
(这里说一下类的初始化
,一个类的初始化包含了3步:加载、链接、初始化。在初始化时,首先执行顺序如下:
- 静态初始化块static{}
- 初始化静态变量
- 执行静态方法(如构造方法)。
静态初始化块和初始化静态变量,在构造函数之前执行,而且被JVM同步机制保护。关于类的初始化,可以参考Java系列笔记(1) - Java 类加载与初始化
)
14,在并发程序中使用和共享对象时,可以使用一些实用的策略:
- 线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
- 只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享:线程安全的对象共享在其内部实现同步,因此多个线程可以通过对象的共有接口来进行访问,而不需要进一步的同步。
- 保护对象:被保护的对象只能通过持有特定的锁来访问。保护对象包括封闭在其它线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。