Skip to content

Latest commit

 

History

History
126 lines (95 loc) · 9.19 KB

第78条:同步访问共享的可变数据 42802fe8c4c44e78984339c060e50807.md

File metadata and controls

126 lines (95 loc) · 9.19 KB

第78条:同步访问共享的可变数据

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

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

Java语言规范保证读或者写一个变量的原子的(atomic),除非这个变量的类型为long或者为double。换句话说,读取一个非long或者double类型的变量,可以保证返回值是每个线程保存在该变量中的,即使多个线程在没有同步的情况下并发地修改这个变量也是如此。

你可能听说过,为了提高性能,在读或者写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的。在为了线程之间进行可靠地通信,也为了互斥访问,同步是必要的。这归因于Java语言规范中的内存模型,它规定了一个线程所做的变化何时以及如何变成其他线程可见。

如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子的可读写的。以下面这个阻止一个线程妨碍另一个线程的任务为例。Java的类库中提供了Thread.stop方法,但是在很久以前就不提倡使用该方法了,因为它本质上是不安全的——使用它会导致数据遭到破坏。千万不要使用Thread.stop方法。要阻止一个线程妨碍另一个线程,建议的做法是让第一个线程轮询一个boolean域,这个域一开始为false,但是可以通过第二个线程设置为true,以表示第一个线程将终止自己。由于boolean域的读和写操作都是原子的,程序员在访问这个域的时候不再需要使用同步:

public class StopThread{
    private static  boolean flag;
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!flag) {
                i++;
            }
        });
        thread.start();
        Thread.sleep(100);
        flag = true;
    }
}

你可能期待这个程序运行大约一秒钟左右,之后主线程将flag设置为true,导致后台线程的循环终止。但在我的机器上,这个程序永远不会终止:因为为后台线程永远在循环!

问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对flag的值所做的改变。没有同步,虚拟机将以下代码:

while (!flag)
    i++;

转变成这样:

if(!flat
	while (true) 
	    i++;

这种优化称作提升,正是OpenJDK Server VM的工作。结果是一个活性失败:这个程序并没有得到提升。修正这个问题的一种方式是同步访问flag域。这个程序会如预期般在大约一秒钟内终止:

public class StopThread{
    private static  boolean flag;

    **private static synchronized void requestStop(){
        flag = true;
    }

    private static synchronized boolean stopRequest(){
        return flag;
    }**
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!**stopRequest()**) {
                i++;
            }
        });
        thread.start();
        Thread.sleep(100);
        **requestStop();**
    }
}

注意写方法(requestStop)和读方法(stopRequest)都被同步了。只同步写方法还不够!除非度和写方法都被同步,否者无法保证同步能起作用。有时候,在某些机器上只同步了写操作的程序看起来也能正常工作,但是在这种情况下,表象具有很大的欺骗性。

StopThread中被同步的方法的动作即使没有同步也是也原子的。换句话说,这些方法的同步只是为了它的通信效果,而不是为了互斥访问。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的代替方法,它更加简洁,性能也可能更好。如果stopRequest被声明为volatile,第二个版本的StopThread中的锁就可以忽略。虽然volatile修饰符不执行互斥访问,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被写入的值:

public class StopThread{
    private static volatile  boolean flag;
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!flag) {
                i++;
            }
        });
        thread.start();
        Thread.sleep(100);
        flag = true;
    }
}

在使用volatile的时候务必要小心。以下面的方法为例,假设它要生产序列号:

private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber(){
	return nextSerialNumber;
}

这个方法的目的是要确保每个调用都返回不同的值(不超过int的最大值)。这个方法的状态只包含一个可原子访问的域:nextSerialNumber,这个域的所有可能的值都是合法的。因此,不需要任何同步来保护它的约束条件。然而,如果没有同步,这个方法仍然无法正确地工作。

问题在于,增量操作符(++)不是原子的。它在nextSerialNumber域中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是安全性失败(safety failure):这个程序会计算出错误的结果。

修正generateSerialNumber方法的一种方法是在它的声明中增加synchronize修饰符。这样可以确保多个调用不会交叉存取,确保每个调用都会看到之前所有调用的效果。一旦这么做,就可以且应该从nextSerialNumber中删除volatile 修饰符。为了保护这个方法,要用long代替int,或者在nextSerialNumber要进行包装时抛出异常。

最好还是遵循第59条中的建议,使用AtomicLong类,它是java.util.concurrent.atomic的组成部分。这个保为在单个变量上进行免锁定、线程安全的编程提供了基本类型。虽然volatile只提供了同步的通信效果,但这个保还提供了原子性。这正是你想要generateSerialNumber完成的工作,而且它可能比同步版本完成得更好:

private static volatile AtomicLong nextSerialNumber = new AtomicLong();

public static long generateSerialNumber(){
	return nextSerialNumber.getAndIncrement();
}

避免本条目中所讨论到的问题的最佳办法是不共享可变的数据。要么共享不可变的数据,要么压根不共享。换句话说,将可变数据限制在单个线程中。如果采用这一策略,对它建立文档就很重要,以便它可以随着程序的发展而得到维护。深刻地理解正在使用的框架和类库也是很重要,因为它们引入了你不知道的线程。

让一个线程在短时间内修改一个数据对象,然后其他线程共享,这是可以接收的,它只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作高效不可变。将这种对象引用从一个线程传递到其他的线程被称作安全发布。安全发布的对象引用有许多种方法:可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问的域中;或者可以将它放到并发地集合中。

总而言之,当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败和安全性失败。这样的失败是最难调试的。它们可能是间歇性的,且与时间无关,程序的行为在不同的虚拟机上可能根本不同。如果只是需要线程之间的交互通信,而不需要虎刺,volatile修饰符就是一种可以接受的同步形式,但要正确地使用它可能需要一些技巧。