周六精进-同步访问共享的可变数据

178 阅读8分钟

10月份的一项目标是把《Effective Java中文版》这本书看完,今天利用空闲时间继续日更,加油。

许多程序员把同步的概念仅仅理解为一个种互斥的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象的内部不一致的状态。正确地使用同步可以保证其他任何方法都不会看到对象处于不一致的状态中。这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态中(即原子性),它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改结果(即可见性)。

同步 = 原子性 + 可见性

synchronized就是同步的代名词,它具有原子性与可见性。而volatile只具有可见性,但不具有原子性。可见性其实说的就是在读之前与写之后都与主内同步,除了可见性外,volatile还严禁语义重排:“禁止reorder任意两个volatile字段或者volatile变量,并且同时严格限制(尽管没有禁止)reorder volatile字段(或变量)周围的非volatile字段(或变量)。”

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

你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值(严格的说是完整的值,即不会读取还未修改完成的值),但是它并不保证一个线程写入的值对于另一个线程将是可见的(即另一线程修改完后,其他线程有可能将永远读不到这个修改后的值)。为了在线程之间进行可靠的通信(需要靠可见性来保证),也为了互斥访问(需要原子性来保证),同步(需要可见性和原子性来保证)是必要的。这归因于Java语言规范中内存模型(memory model),它规定了一个线程所做的变化何时以及如何让其他线程可见[JLS 17]。

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

import java.util.concurrent.TimeUnit;

public class StopThread {

    private static boolean stopRequested;

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

       Thread backgroundThread = new Thread(new Runnable() {

           public void run() {

              int i = 0;

              while (!stopRequested)

                  i++;

           }

       });

       backgroundThread.start();

       //睡一秒

       TimeUnit.SECONDS.sleep(1);

       stopRequested = true;

    }

}

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

问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对stopRequested的值所做的修改。在没有同步的情况下,VM将个这个代码:

while (!stopRequested)

    i++;

转变成这样:

if (!stopRequested)

    while (true)

       i++;

这是完全有可能的,也是可以接受的。这种优化称作提升(hoisting),正是HopSpot Server VM的工作。结果是个“活性失败”:这个程序无法结束。修改这个问题的一种方式是同步访问stopRequested域,修改如下:

public class StopThread {

    private static boolean stopRequested;

 

    private static synchronized void requestStop() {

       stopRequested = true;

    }

 

    private static synchronized boolean stopRequested() {

       return stopRequested;

    }

 

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

       Thread backgroundThread = new Thread(new Runnable() {

           public void run() {

              int i = 0;

              while (!stopRequested())

                  i++;

           }

       });

       backgroundThread.start();

       TimeUnit.SECONDS.sleep(1);

       requestStop();

    }

}

这是完全有可能的,也是可以接受的。这种优化称作提升(hoisting),正是HopSpot Server VM的工作。结果是个“活性失败”:这个程序无法结束。修改这个问题的一种方式是同步访问stopRequested域,修改如下:

public class StopThread {

    private static boolean stopRequested;

 

    private static synchronized void requestStop() {

       stopRequested = true;

    }

 

    private static synchronized boolean stopRequested() {

       return stopRequested;

    }

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

       Thread backgroundThread = new Thread(new Runnable() {

           public void run() {

              int i = 0;

              while (!stopRequested())

                  i++;

           }

       });

       backgroundThread.start();

       TimeUnit.SECONDS.sleep(1);

       requestStop();

    }

}

注意上面的写方法(requestStop)和读方法(stopRequested)都被同步了,只同步写方法或读方法是不够的!

StopThread程序中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果(即可见性),而不是为了互斥访问(即原子性)。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。这种替代就是将stopRequested声明为volatile,第二版本的StopThread中的锁就可以省略。

虽然volatile修饰符不具有互斥访问的特性,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被其他线程写入的值,下面是使用volatile修正后的版本:

public class StopThread {

    private static volatile boolean stopRequested;

 

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

       Thread backgroundThread = new Thread(new Runnable() {

           public void run() {

              int i = 0;

              while (!stopRequested)

                  i++;

           }

       });

       backgroundThread.start();

       TimeUnit.SECONDS.sleep(1);

       stopRequested = true;

    }

}

上面就说了,volatile只具有可见性,而不具有原子性,所以使用时要格外小心,请考虑下面的方法,假设它要产生序列号:

private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {

    return nextSerialNumber++;

}

这个方法的目的是要确保每次调用都要返回不同的值,而且是递增的(只要不超过2^32次调用)。这个方法的状态只包含一个可原子访问的域:nextSerialNumber,不同步的情况下读到的这个域的所有可能的值都是合法(即不可能读到修改未完成的值),但是,这个方法仍然无法工作。

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

修正generateSerialNumber方法的一种方法是是在它的声明中加上synchronized修饰符。这样可能确保多个调用不会交叉存在。一旦这么做,就可以且应该从nextSerialNumber中删除volatile修饰符。为了让这个方法更可靠,要用long代替int。但最好还是遵循第47条中的建议,使用类AtomicLong,它是java.util.concurrent.atomic的一部分,它比同步版本的generateSerialNumber性能上可能要更好,因为atomic包使用了非锁定的线程安全技术来做到同步的,下面是使用AtomicLong修正后的版本:

private static final AtomicLong nextSerialNum = new AtomicLong();

public static long generateSerialNumber() {

    return nextSerialNum.getAndIncrement();

}

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

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