操作系统有自己的内存模型,C/C++ 这些语言直接使用的就是操作系统的内存模型,而 Java 为了屏蔽各个系统的差异,定义了自己的统一的内存模型。简单说,Java 开发者不再关心每个 CPU 核心有自己的内存,然后共享主内存。而是把关注点转移到:每个线程都有自己的工作内存,所有线程共享主内存。17.1 同步(synchronization)
同步这一节说了 Java 并发编程中最基础的 synchronized 这个关键字,大家一定要理解 synchronize 的锁是什么,它的锁是基于 Java 对象的监视器 monitor,所以任何对象都可以用来做锁。有兴趣的读者可以去了解相关知识,包括偏向锁、轻量级锁、重量级锁等。
小知识点:对 Class 对象加锁、对对象加锁,它们之间不构成同步。synchronized 作用于静态方法时是对 Class 对象加锁,作用于实例方法时是对实例加锁。
面试中经常会问到一个类中的两个 synchronized static 方法之间是否构成同步?构成同步。17.2 等待集合 和 唤醒(Wait Sets and Notification)
前方高能,请读者保持高度精神集中。我们在线程 t 中对对象 m 调用 m.wait() 方法,n 代表加锁编号,同时还没有相匹配的解锁操作,则下面的其中之一会发生:
注意,如果没有获取到监视器锁,wait 方法是会抛异常的,而且注意这个异常是IllegalMonitorStateException异常。这是重要知识点,要考。
中断,如果读者不了解这个概念,可以参考我在 AQS(二) 中的介绍,这是非常重要的知识。
注意:到这里的时候,wait 参数是正常的,同时 t 没有被中断,并且线程 t 已经拿到了 m 的监视器锁。1.线程 t 会加入到对象 m 的等待集合中,执行 加锁编号 n 对应的解锁操作
并不是说线程移出等待队列就马上往下执行,这个线程还需要重新获取锁才行,这里也很关键,请往后看17.2.4中我写的两个简单的例子。
个人理解wait方法是这么用的:
所以说,如果是这个方法调用两次,那么第二次一定会返回 false,因为第一次会重置状态。当然了,前提是两次调用的中间没有发生设置线程中断状态的其他语句。17.2.4 等待、通知和中断的交互(Interactions of Waits, Notification, and Interruption)
也就是说,线程是从 notify 被唤醒的,由于发生了中断,所以中断状态为 true同样的,通知也不能由于中断而丢失。
这个要说的是,线程其实是从中断唤醒的,那么线程醒过来,同时中断状态会被重置为 false。假设 m 的等待集合为 线程集合 s,并且在另一个线程中调用了 m.notify(), 那么将发生:
考虑是否有这个场景:x 被设置了中断状态,notify 选中了集合中的线程 x,那么这次 notify 将唤醒线程 x,其他线程(我们假设还有其他线程在等待)不会有变化。答案:存在这种场景。因为这种场景是满足上述条件的,而且此时 x 的中断状态是 true。
简单地说,定义内存模型,主要就是为了规范多线程程序中修改或者访问同一个值的时候的行为。对于那些本身就是线程安全的问题,这里不做讨论。内存模型描述了程序执行时的可能的表现行为。只要执行的结果是满足 java 内存模型的所有规则,那么虚拟机对于具体的实现可以自由发挥。
从侧面说,不管虚拟机的实现是怎么样的,多线程程序的执行结果都应该是可预测的。虚拟机实现者可以自由地执行大量的代码转换,包括重排序操作和删除一些不必要的同步。
这里没有翻译 compiler 为编译器,因为它不仅仅代表编译器,后续它会代表所有会导致指令重排序的机制。如表 17.4-A 中所示,A 和 B 是共享属性,r1 和 r2 是局部变量。初始时,令 A == B == 0。
B = 1; => r1 = B; => A = 2; => r2 = A;对于很多程序员来说,这个结果看上去是 broken 的,但是这段代码是没有正确的同步导致的:
简单地说,之后要讲的一大堆东西主要就是为了确定共享内存读写的执行顺序,不正确或者说非法的代码就是因为读写同一内存地址没有使用同步(这里不仅仅只是说synchronized),从而导致执行的结果具有不确定性。这个是 数据竞争(data race) 的一个例子。当代码包含数据竞争时,经常会发生违反我们直觉的结果。
所以,后续我们不要再简单地将 compiler 翻译为编译器,不要狭隘地理解为 Java 编译器。而是代表了所有可能会制造重排序的机制,包括 JVM 优化、CPU 优化等。另一个可能产生奇怪的结果的示例如表 17.4-C,初始时 p == q 同时 p.x == 0。这个代码也是没有正确使用同步的;在这些写入共享内存的写操作中,没有进行强制的先后排序。
我简单整理了一下:
例子结束,回到正题Java 内存模型定义了在程序的每一步,哪些值是内存可见的。对于隔离的每个线程来说,其操作是由我们线程中的语义来决定的,但是线程中读取到的值是由内存模型来控制的。
这段话不太好理解,首先记住“线程内语义”这个概念,之后还会用到。我对这段话的理解是,在单线程中,我们是可以通过一行一行看代码来预测执行结果的,只不过,代码中使用到的读取内存的值我们是不能确定的,这取决于在内存模型这个大框架下,我们的程序会读到的值。也许是最新的值,也许是过时的值。此节描述除了 final 关键字外的java内存模型的规范,final将在之后的17.5节介绍。
好,这一节都是废话,愉快地进入到下一节17.4.2. 操作(Actions)
外部操作大家可以理解为 Java 调用了一个 native 的方法,Java 可以得到这个 native 方法的返回值,但是对于具体的执行其实不感知的,意味着 Java 其实不能对这种语句进行重排序,因为 Java 无法知道方法体会执行哪些指令。引用 stackoverflow 中的一个例子:
在上面这个例子中,显然,jni() 与 foo = 42 之间不能进行重排序。再来个线程分歧操作的例子:
连续一致性的核心在于每一步的操作都是原子的,同时对于所有线程都是可见的,而且不存在重排序。所以,Java 语言定义的内存模型肯定不会采用这种策略,因为它直接限制了编译器和 JVM 的各种优化措施。注意:很多地方所说的顺序一致性就是这里的连续一致性,英文是 Sequential consistency
Happens-before 是非常重要的知识,有些地方我没有很理解,我尽量将原文直译过来。想要了解更深的东西,你可能还需要查询更多的其他资料。两个操作可以用 happens-before 来确定它们的执行顺序,如果一个操作 happens-before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的。
注意:happens-before 强调的是可见性问题如果我们分别有操作 x 和操作 y,我们写成 hb(x, y) 来表示 x happens-before y。
我们可以发现,happens-before 规则主要还是上一节 同步顺序 中的规则,加上额外的几条更具体地说,如果两个操作是 happens-before 的关系,但是在代码中它们并没有这种顺序,那么就没有必要表现出 happens-before 关系。如线程 1 对变量进行写入,线程 2 随后对变量进行读操作,那么这两个操作是没有 happens-before 关系的。
其实就是正确同步的代码不存在数据竞争问题,这个时候程序员不需要关心重排序是否会影响我们的代码,我们的代码执行一定会表现出连续一致。程序必须正确同步,以避免当出现重排序时,会出现一系列的奇怪的行为。正确同步的使用,不能保证程序的全部行为都是正确的。
这个隐含了,如果属性值不是 final 的,那就不能保证一定可以看到正确初始化的值,可能看到初始零值。final 属性的使用是非常简单的:在对象的构造方法中设置 final 属性;同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方。如果这个条件满足,当其他线程看到这个对象的时候,那个线程始终可以看到正确初始化后的对象的 final 属性。
因为到 JDK7 和 JDK8 的时候,代码已经变为:
我在我的 MBP 上试了好多办法,真的没法重现出来,不过并发问题就是这样,我们不能重现不代表不存在。StackOverflow 上有网友说在 Sparc 上运行,可惜我没有 Sparc 机器。下文将说到一个比较少见的 final-field-safe context
请注意,对于大部分处理器来说,都没有这个问题Example 17.6-1. Detection of Word Tearing
目前来看,64 位虚拟机对于 long 和 double 的写入都是原子的,没必要加 volatile 来保证原子性。来源:https://javadoop.com/post/Threads-And-Locks-md\
欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) | Powered by Discuz! X3.4 |