Java利用happen-before规则如何实现共享变量的同步操作详解

前言

熟悉 Java 并发编程的都知道,JMM(Java 内存模型) 中的 happen-before(简称 hb)规则,该规则定义了 Java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响。

Java语言中有一个“先行发生”(happen—before)的规则,它是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其意思就是说,在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个原则特别重要,它是判断数据是否存在竞争、线程是否安全的主要依据。

按照官方的说法:

当一个变量被多个线程读取并且至少被一个线程写入时,如果读操作和写操作没有 HB 关系,则会产生数据竞争问题。

要想保证操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在一个线程),那么在 A 和 B 之间必须满足 HB 原则,如果没有,将有可能导致重排序。

当缺少 HB 关系时,就可能出现重排序问题。

HB 有哪些规则?

这个大家都非常熟悉了应该,大部分书籍和文章都会介绍,这里稍微回顾一下:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:在监视器锁上的解锁操作必须在同一个监视器上的加锁操作之前执行。
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

其中,传递规则我加粗了,这个规则至关重要。如何熟练的使用传递规则是实现同步的关键。

然后,再换个角度解释 HB:当一个操作 A HB 操作 B,那么,操作 A 对共享变量的操作结果对操作 B 都是可见的。

同时,如果 操作 B HB 操作 C,那么,操作 A 对共享变量的操作结果对操作 B 都是可见的。

而实现可见性的原理则是 cache protocol 和 memory barrier。通过缓存一致性协议和内存屏障实现可见性。

如何实现同步?

在 Doug Lea 著作 《Java Concurrency in Practice》中,有下面的描述:

书中提到:通过组合 hb 的一些规则,可以实现对某个未被锁保护变量的可见性。

但由于这个技术对语句的顺序很敏感,因此容易出错。

楼主接下来,将演示如何通过 volatile 规则和程序次序规则实现对一个变量同步。

来一个熟悉的例子:

class ThreadPrintDemo {

 static int num = 0;
 static volatile boolean flag = false;

 public static void main(String[] args) {

 Thread t1 = new Thread(() -> {
  for (; 100 > num; ) {
  if (!flag && (num == 0 || ++num % 2 == 0)) {
   System.out.println(num);
   flag = true;
  }
  }
 }
 );

 Thread t2 = new Thread(() -> {
  for (; 100 > num; ) {
  if (flag && (++num % 2 != 0)) {
   System.out.println(num);
   flag = false;
  }
  }
 }
 );

 t1.start();
 t2.start();
 }
}

这段代码的作用是两个线程间隔打印出 0 - 100 的数字。

熟悉并发编程的同学肯定要说了,这个 num 变量没有使用 volatile,会有可见性问题,即:t1 线程更新了 num,t2 线程无法感知。

哈哈,楼主刚开始也是这么认为的,但最近通过研究 HB 规则,我发现,去掉 num 的 volatile 修饰也是可以的。

我们分析一下,楼主画了一个图:

我们分析这个图:

  • 首先,红色和黄色表示不同的线程操作。
  • 红色线程对 num 变量做 ++,然后修改了 volatile 变量,这个是符合 程序次序规则的。也就是 1 HB 2.
  • 红色线程对 volatile 的写 HB 黄色线程对 volatile 的读,也就是 2 HB 3.
  • 黄色线程读取 volatile 变量,然后对 num 变量做 ++,符合 程序次序规则,也就是 3 HB 4.
  • 根据传递性规则,1 肯定 HB 4. 所以,1 的修改对 4来说都是可见的。

注意:HB 规则保证上一个操作的结果对下一个操作都是可见的。

所以,上面的小程序中,线程 A 对 num 的修改,线程 B 是完全感知的 —— 即使 num 没有使用 volatile 修饰。

这样,我们就借助 HB 原则实现了对一个变量的同步操作,也就是在多线程环境中,保证了并发修改共享变量的安全性。并且没有对这个变量使用 Java 的原语:volatile 和 synchronized 和 CAS(假设算的话)。

这可能看起来不安全(实际上安全),也好像不太容易理解。因为这一切都是 HB 底层的 cache protocol 和 memory barrier 实现的。

其他规则实现同步

利用线程终结规则实现:

 static int a = 1;

 public static void main(String[] args) {
 Thread tb = new Thread(() -> {
  a = 2;
 });
 Thread ta = new Thread(() -> {
  try {
  tb.join();
  } catch (InterruptedException e) {
  //NO
  }
  System.out.println(a);
 });

 ta.start();
 tb.start();
 }

利用线程 start 规则实现:

 static int a = 1;

 public static void main(String[] args) {
 Thread tb = new Thread(() -> {
  System.out.println(a);
 });
 Thread ta = new Thread(() -> {
  tb.start();
  a = 2;
 });

 ta.start();
 }

这两个操作,也可以保证变量 a 的可见性。

确实有点颠覆之前的观念。之前的观念中,如果一个变量没有被 volatile 修饰或 final 修饰,那么他在多线程下的读写肯定是不安全的 —— 因为会有缓存,导致读取到的不是最新的。

然而,通过借助 HB,我们可以实现。

总结

虽然本文标题是通过 happen-before 实现对共享变量的同步操作,但主要目的还是更深刻的理解 happen-before,理解他的 happen-before 概念其实就是保证多线程环境中,上一个操作对下一个操作的有序性和操作结果的可见性。

同时,通过灵活的使用传递性规则,再对规则进行组合,就可以将两个线程进行同步 —— 实现指定的共享变量不使用原语也可以保证可见性。虽然这好像不是很易读,但也是一种尝试。

关于如何组合使用规则实现同步,Doug Lea 在 JUC 中给出了实践。

例如老版本的 FutureTask 的内部类 Sync(已消失),通过 tryReleaseShared 方法修改 volatile 变量,tryAcquireShared 读取 volatile 变量,这是利用了 volatile 规则;

通过在 tryReleaseShared 之前设置非 volatile 的 result 变量,然后在 tryAcquireShared 之后读取 result 变量,这是利用了程序次序规则。

从而保证 result 变量的可见性。和我们的第一个例子类似:利用程序次序规则和 volatile 规则实现普通变量可见性。

而 Doug Lea 自己也说了,这个“借助”技术非常容易出错,要谨慎使用。但在某些情况下,这种“借助”是非常合理的。

实际上,BlockingQueue 也是“借助”了 happen-before 的规则。还记得 unlock 规则吗?当 unlock 发生后,内部元素一定是可见的。

而类库中还有其他的操作也“借助”了 happen-before 原则:并发容器,CountDownLatch,Semaphore,Future,Executor,CyclicBarrier,Exchanger 等。

总而言之,言而总之:

happen-before 原则是 JMM 的核心所在,只有满足了 hb 原则才能保证有序性和可见性,否则编译器将会对代码重排序。hb 甚至将 lock 和 volatile 也定义了规则。

通过适当的对 hb 规则的组合,可以实现对普通共享变量的正确使用。

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

(0)

相关推荐

  • Java利用happen-before规则如何实现共享变量的同步操作详解

    前言 熟悉 Java 并发编程的都知道,JMM(Java 内存模型) 中的 happen-before(简称 hb)规则,该规则定义了 Java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响. Java语言中有一个"先行发生"(happen-before)的规则,它是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其意思就是说,在发生操作B之前,操作A产生的影响都能被操作B观察到,"影响"包括修改了内存中共享变量的值.发

  • Java利用反射如何查找使用指定注解的类详解

    前言 最近有些空,想自己写个跟spring里的注解一样的注解来用,然后希望能找到使用了自己写了注解的类,下面来介绍一下实现方法 声明,下面代码是没看过spring源码写的,基本上都是网上找的博客,整理的 定义注解 Controller.java @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Controller { } RequestMapping.jav

  • .NET Core利用 AsyncLocal 实现共享变量的代码详解

    目录 简介 AsyncLocal 解读 总结 简介 我们如果需要整个程序共享一个变量,我们仅需将该变量放在某个静态类的静态变量上即可(不满足我们的需求,静态变量上,整个程序都是固定值).我们在Web 应用程序中,每个Web 请求服务器都为其分配了一个独立线程,如何实现用户,租户等信息隔离在这些独立线程中.这就是今天要说的线程本地存储.针对线程本地存储 .NET 给我们提供了两个类 ThreadLocal 和 AsyncLocal.我们可以通过查看以下例子清晰的看到两者的区别: [TestClas

  • java并发编程关键字volatile保证可见性不保证原子性详解

    目录 关于可见性 关于指令重排 volatile关键字可以说是Java虚拟机提供的最轻量级的同步机制,但对于为什么它只能保证可见性,不保证原子性,它又是如何禁用指令重排的,还有很多同学没彻底理解 相信我,坚持看完这篇文章,你将牢牢掌握一个Java核心知识点 先说它的两个作用: 保证变量在内存中对线程的可见性禁用指令重排 每个字都认识,凑在一起就麻了 这两个作用通常很不容易被我们Java开发人员正确.完整地理解,以至于许多同学不能正确地使用volatile 关于可见性 不多bb,码来 public

  • java反射之通过反射了解集合泛型的本质(详解)

    本文接上文"java反射之方法反射的基本操作方法",利用反射了解下java集合中泛型的本质 1.初始化两个集合,一个使用泛型,一个不使用 ArrayList list1 = new ArrayList(); ArrayList<String> list2 = new ArrayList<String>(); 2.有定义类型可得在list2中添加int类型会报错 list2.add("Hello"); list2.add(20); //报错 3

  • Java多线程之显示锁和内置锁总结详解

    总结多线程之显示锁和内置锁 Java中具有通过Synchronized实现的内置锁,和ReentrantLock实现的显示锁,这两种锁各有各的好处,算是互有补充,这篇文章就是做一个总结. *Synchronized* 内置锁获得锁和释放锁是隐式的,进入synchronized修饰的代码就获得锁,走出相应的代码就释放锁. synchronized(list){ //获得锁 list.append(); list.count(); }//释放锁 通信 与Synchronized配套使用的通信方法通常

  • Java图形化界面设计之容器(JFrame)详解

    Java图形化界面设计--容器(JFrame) 程序是为了方便用户使用的,因此实现图形化界面的程序编写是所有编程语言发展的必然趋势,在命令提示符下运行的程序可以让我们了解java程序的基本知识体系结构,现在就进入java图形化界面编程. 一.Java基本类(JFC) Java基本类("JavaFoundationClasses",JFC),由一些软件包组成.这些软件包主要包括下面一些应用程序接口(API): ·抽象窗口工具集(AWT)(1.1及以上版本). ·Swing构件. ·Jav

  • java使用RSA与AES加密解密的实例代码详解

    首先了解下,什么是堆成加密,什么是非对称加密? 对称加密:加密与解密的密钥是相同的,加解密速度很快,比如AES 非对称加密:加密与解密的秘钥是不同的,速度较慢,比如RSA •先看代码(先会用在研究) 相关依赖: <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.58</versio

  • java数组的三种扩容方式以及程序实现详解

    因为数组是在内存中连续的一段存储空间,所以数组一旦被创建,空间就固定了,长度是不能扩增的. 数组的长度是固定的,如果需要扩充**,必须创建新数组,原数组的长度要复制到新数组中 .** java中,数组类型的变量传值的时候,事实上传递的是数组的地址 . Java数组扩容的原理 1)Java数组对象的大小是固定不变的,数组对象是不可扩容的. 2)利用数组复制方法可以变通的实现数组扩容. 3)System.arraycopy()可以复制数组. 4)Arrays.copyOf()可以简便的创建数组副本.

  • Java杂谈之类和对象 封装 构造方法以及代码块详解

    目录 1. 类和对象的初步认知 2. 类的实例化 3. 类的成员 字段(属性/成员变量) 方法 static 关键字 修饰字段 修饰方法 修饰代码块(暂不讲) 修饰类(暂不讲) 4. 封装 5. 构造方法 6. this 用法 关于引用的几个注意事项: 7. 代码块 Java当中的类和对象 1. 类和对象的初步认知 java 是一门面向对象的语言,所谓面向对象有别于面向过程,面向对象是只需对象之间的交互即可完成任务,但是面向过程的话,需要我们将每一个步骤都详细地做出来.比如,以洗衣服为例,如果是

随机推荐