ITEM 78: 对共享数据添加同步控制

ITEM 78: SYNCHRONIZE ACCESS TO SHARED MUTABLE DATA
  synchronized 关键字确保了一次只能有一个线程执行一个方法或代码块。许多程序员认为同步仅仅是互斥的一种方式,防止一个线程看到一个对象处于不一致的状态,而另一个线程正在修改它。在这个视图中,对象以一致的状态创建(item 17),并由访问它的方法锁定。这些方法观察状态并可选择导致状态转换,将对象从一种一致状态转换为另一种一致状态。正确使用同步可以保证任何方法都不会观察到处于不一致状态的对象。
  这种观点是正确的,但这只是故事的一半。如果没有同步,一个线程的更改可能对其他线程不可见。同步不仅可以防止线程观察处于不一致状态的对象,还可以确保进入同步方法或块的每个线程看到由同一锁保护的以前所有修改的效果。
  语言规范保证读或写变量是原子的,除非变量是 long 或 double 类型[JLS, 17.4, 17.7]。换句话说,读取 long 或 double 以外的变量保证会返回某个线程存储到该变量中的值,即使多个线程并发地修改该变量而没有同步。
  您可能听说过,为了提高性能,在读写原子数据时应该避免同步。这个建议是危险的错误。虽然语言规范保证线程在读取字段时不会看到任意值,但它不能保证一个线程写入的值对另一个线程可见。线程之间的可靠通信和互斥都需要同步。这是由于被称为内存模型的语言规范的一部分,它指定了一个线程所做的改变何时以及如何对其他线程可见[JLS, 17.4;Goetz06, 16)。
  即使数据是原子可读和可写的,未能同步访问共享可变数据的后果也可能是可怕的。考虑从一个线程停止另一个线程的任务。库提供 Thread.stop 方法,但是这种方法很久以前就不推荐使用了,因为它本身就不安全 —— 使用它可能会导致数据损坏。不要使用 Thread.stop。让一个线程停止另一个线程的一个推荐方法是让第一个线程轮询一个布尔字段,该字段最初为假,但可以被第二个线程设置为真,以指示第一个线程停止自己。
  因为读取和写入布尔字段是原子的,一些程序员在访问字段时免除了同步:

// Broken! - How long would you expect this program to run?
public class StopThread {
  private static boolean stopRequested;
  public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(() -> { 
      int i = 0;
      while (!stopRequested) i++;
    }); 
    backgroundThread.start();
    TimeUnit.SECONDS.sleep(1);
    stopRequested = true; 
  }
}

  您可能希望这个程序运行大约一秒,在此之后,主线程将stoprequest设置为true,从而导致后台线程的循环终止。然而,在我的机器上,程序永远不会终止:后台线程永远循环!
  问题是,在没有同步的情况下,无法保证后台线程何时(如果有的话)会看到主线程stopRequested值的变化。在没有同步的情况下,虚拟机转换以下代码是可以接受的:
while (!stopRequested) i++;
转换为

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

  这种优化称为提升,而这正是 OpenJDK Server VM 所做的。其结果是活性失败:程序无法取得进展。解决这个问题的一种方法是同步对 stopRequested 字段的访问。这个程序终止在大约一秒,如预期:

// Properly synchronized cooperative thread termination
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(() -> {
      int i = 0;
      while (!stopRequested())
        i++; 
    });
    backgroundThread.start();  
    TimeUnit.SECONDS.sleep(1);
    requestStop(); 
  }
}

  注意,写方法(requestStop)和读方法(stopRequested)都是同步的。仅仅同步写方法是不够的!除非读和写操作同步,否则同步不能保证有效。偶尔,一个只同步写(或读)操作的程序可能看起来在某些机器上工作,但在这种情况下,外观是欺骗性的。
  即使没有同步,StopThread中同步方法的动作也是原子的。换句话说,这些方法上的同步仅仅用于通信效果,而不是互斥。虽然在循环的每次迭代上进行同步的成本很小,但是有一种正确的替代方法,它不那么冗长,而且性能可能更好。如果stoprequest 被声明为 volatile,那么第二个 StopThread 版本中的锁可以省略。虽然volatile 修饰符不执行互斥,但它保证任何读取字段的线程都会看到最近写入的值:

// Cooperative thread termination with a volatile field
public class StopThread {
  private static volatile boolean stopRequested;
  public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(() -> { 
      int i = 0;
      while (!stopRequested) i++;
    }); 
    backgroundThread.start();
    TimeUnit.SECONDS.sleep(1);
    stopRequested = true; 
  }
}

  在使用 volatile 时一定要小心。考虑以下生成序列号的方法:

// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() { 
  return nextSerialNumber++;
}

  该方法的目的是保证每次调用都返回一个惟一的值(只要不超过2^32次调用)。该方法的状态由单个原子可访问的字段 nextSerialNumber 组成,该字段的所有可能值都是合法的。因此,不需要同步来保护它的不变量。不过,如果没有同步,该方法将无法正常工作。
  问题在于递增操作符(++)不是原子的。它对 nextSerialNumber 字段执行两个操作:首先读取值,然后写回一个等于旧值加1的新值。如果在一个线程读取旧值并写回新值的时间间隔内,另一个线程读取字段,那么第二个线程将看到与第一个线程相同的值并返回相同的序列号。这是一个安全故障:程序计算了错误的结果。
  修复 generateSerialNumber 的一种方法是在其声明中添加同步修饰符。这确保了多个调用不会交织在一起,并且该方法的每次调用都将看到以前所有调用的效果。一旦你这样做了,你可以并且应该从 nextSerialNumber 删除 volatile 修饰符。要使方法防弹,使用 long 而不是 int,或者在 nextSerialNumber 即将换行时抛出异常。
  更好的方法是,遵循item 59 中的建议并使用类 AtomicLong,它是java.util.concurrent.atomic 的一部分。这个包为单个变量上的无锁、线程安全编程提供了原语。虽然 volatile 只提供同步的通信效果,但是这个包还提供了原子性。这正是我们想要的 generateSerialNumber,它可能会超过同步版本:

// Lock-free synchronization with java.util.concurrent.atomic
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
  return nextSerialNum.getAndIncrement(); 
}

  避免本项目中讨论的问题的最佳方法是不共享可变数据。共享不可变数据(item 17)或者根本不共享。换句话说,将可变数据限制在单个线程中。如果采用此策略,务必对其进行文档化,以便在程序发展时维护策略。深入了解您正在使用的框架和库也很重要,因为它们可能会引入您没有意识到的线程。
  一个线程在一段时间内修改一个数据对象,然后与其他线程共享它,只同步共享对象引用的行为,这是可以接受的。其他线程无需进一步同步就可以读取该对象,只要它没有被再次修改。这样的对象被认为是有效的不可变对象[Goetz06, 3.5.4]。将这样一个对象引用从一个线程传输到其他线程称为安全发布[Goetz06, 3.5.3]。有很多方法可以安全地发布对象引用:您可以将其存储在静态字段中,作为类初始化的一部分;您可以将它存储在volatile字段、final字段或使用普通锁定访问的字段中;或者您可以将其放入并发集合中(item 81)。
  总之,当多个线程共享可变数据时,每个读或写数据的线程都必须执行同步。在没有同步的情况下,不能保证一个线程的更改对另一个线程可见。未能同步共享的可变数据的惩罚是活性和安全故障。这些故障是最难调试的故障之一。它们可能是间歇性的,并且依赖于时间,而且程序行为在不同的虚拟机之间可能完全不同。如果您只需要线程间通信,而不需要互斥,那么volatile修饰符是一种可接受的同步形式,但是正确使用它可能需要一些技巧。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,718评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,683评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,207评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,755评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,862评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,050评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,136评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,882评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,330评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,651评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,789评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,477评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,135评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,864评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,099评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,598评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,697评论 2 351