Java中同步访问共享的可变数据

我们知道,线程机制允许同时进行多个活动。并发程序设计比单线程设计要困难得多,因为有更多的东西可能出错,也很难重现失败。但是我们无法避免并发,因为所做的大部分事情都需要并发,而且并发也是能否从多核的处理器中获得好的性能的一个条件。

Java中的原子操作

原子操作指的是在一步之内就完成而且不能被中断。原子操作在多线程环境中是线程安全的,无需考虑同步的问题。在java中,下列操作是原子操作:

  • all assignments of primitive types except for long and double
  • all assignments of references
  • all operations of java.concurrent.Atomic* classes
  • all assignments to volatile longs and doubles

为什么longdouble类型的赋值不是原子操作呢?Java中规定只有32位以下的数据类型才会有原子操作,longdouble类型都是64位的,所以实际上它是把原子操作拆分成了两步:

private long number = 123456789L;

Java会分两步写入这个long变量,先写32位,再写后32位。这样就线程不安全了。如果改成下面的就线程安全了:

private volatile long number = 123456789L;

因为volatile内部已经做了同步。

同步(Synchronized)

我们知道,关键字synchronized可以保证在同一时刻只有一个线程可以执行某一个方法,或者某一个代码块。许多程序员把同步的概念仅仅理解为一种互斥的方式。即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象内部不一致的状态。按照这种观点,对象被创建的时候处于一致的状态,当有方法访问它的时候,它就被锁定了。这些方法观察到对象的状态,并且可能引起状态转变,即把对象从一种一致的状态转换到另一种一致的状态。正确地使用同步可以保证没有任何方法会看到对象处于不一致的状态中。

这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态,它还可以保证进入同步方法或者同步代码块的每个线程都看到由一个锁保护之前所有的修改效果。

虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它也并不保证一个线程写入的值对于另一个线程是可见的。为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。

如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。Java类库中提供了Thread.stop()方法来阻止一个线程妨碍另一个线程的任务。但是这个方法很早之前就不提倡使用,它是不安全的,使用它会导致数据遭到破坏。不要是用Thread.stop()。要阻止一个线程妨碍另一个线程,建议是先让第一个线程轮询一个boolean域,这个域一开始为false,然后通过第二个线程改为true,让第一个线程终止自己。

接下来看一下下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StopThread {

private static boolean flag = true;//false终止线程,true为运行

public static void main(String[] args) throws InterruptedException {

new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
System.out.println(Thread.currentThread().getName() + ": " + flag);
while (flag) {
i++;
}
System.out.println(Thread.currentThread().getName() + ": " + flag);
}
}).start();

TimeUnit.SECONDS.sleep(1);
flag = false;
System.out.println(Thread.currentThread().getName() + ": " + flag);
}

}

输出结果:

1
2
Thread-0: true
main: false

可以看到线程Thread-0flag值并没有改变并且1秒后程序还是一直在运行。

上面我们定义的flag这个boolean域的读和写操作都是原子的,程序员在访问这个域的时候不使用同步,这将导致上面的程序永远不会终止。由于没有同步,就不能保证后台线程何时看到主线程对flag值所做的改变。

接着我们修正一下,使flag域能被同步访问到:

使用同步锁,synchronized 关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class StopThread {

private static boolean flag = true;//false终止线程,true为运行

private static synchronized boolean getFlag() {
return flag;
}

private static synchronized void stop() {
flag = false;
}

public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
System.out.println(Thread.currentThread().getName() + ": " + flag);
while (getFlag()) {
i++;
}
System.out.println(Thread.currentThread().getName() + ": " + flag);
}
}).start();

TimeUnit.SECONDS.sleep(1);
// flag = false;
stop();
System.out.println(Thread.currentThread().getName() + ": " + flag);
}

}

输出结果:

1
2
3
Thread-0: true
main: false
Thread-0: false

这回的结果就是我们想要的了。这里要注意写方法stop()和读方法getFlag()都被同步了。如果两者其一没有被同步,那么同步也不会起作用。

上面的同步方法的动作即使没有同步也是原子的。也就是说,这些方法的同步只是为了通信效果,而不是为了互斥访问。这种方法性能开销会比较大,我们还有一种更好的替代方法来完成同步,它更加简洁并且性能更好。

使用修饰符,volatile 关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class StopThread {

private static volatile boolean flag = true;//false终止线程,true为运行

public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
System.out.println(Thread.currentThread().getName() + ": " + flag);
while (flag) {
i++;
}
System.out.println(Thread.currentThread().getName() + ": " + flag);
}
}).start();

TimeUnit.SECONDS.sleep(1);
flag = false;
System.out.println(Thread.currentThread().getName() + ": " + flag);
}

}

输出结果也是一样的:

1
2
3
Thread-0: true
main: false
Thread-0: false

只需要在flag前加上一个修饰符volatile,就能达到我们想要的效果。虽然volatile修饰符不执行互斥访问,但它可以保证任何一个线程在读取该域的时候都将看到最新被修改的值。

在我们的程序中,最好的办法是不共享可变的数据,要么共享不可变的数据。换句话说,将可变数据限制在单个线程中。让一个线程在短时间内修改一个数据对象,然后与其它线程共享,这是可以接受的,只同步对象引用的动作。然后其它线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作事实上不可变的。将这种对象引用从一个线程传递到其它的线程被称作安全发布。安全发布对象引用有许多种方法:

  1. 将对象保存在静态域中,作为类初始化的一部分;
  2. 将对象保存在volatile域、final域或者通过正常锁定访问域中;
  3. 将对象放到并发的集合中

总而言之,当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败和安全性失败。

Tips

乐观锁与悲观锁

独占锁是一种悲观锁,synchronized就是一种独占锁,它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。