关于java:25W-字详解线程与锁了面试随便问

34次阅读

共计 24943 个字符,预计需要花费 63 分钟才能阅读完成。

在 java 并发编程中,线程和锁永远是最重要的概念。语言标准尽管是标准形容,然而其中也有十分多的常识和最佳实际是值得学习的,置信这篇文章还是能够给很多读者提供学习参考的。

本文次要是 翻译 + 解释 Oracle 《The Java Language Specification, Java SE 8 Edition》 的第 17 章 《Threads and Locks》,原文大略 30 页 pdf,我退出了很多本人的了解,心愿能帮大家把标准看懂,并且从中失去很多你始终想要晓得然而还不晓得的常识。

留神,本文在说 Java 语言标准,不是 JVM 标准,JVM 的实现须要满足语言标准中定义的内容,然而具体的实现细节由各 JVM 厂商本人来决定。所以,语言标准要尽可能谨严全面,然而也不能限度过多,不然会限度 JVM 厂商对很多细节进行性能优化。

我能力无限,尽管曾经很用心了,但有些中央我真的不懂,我曾经在文中标记进去了。

倡议分 3 局部浏览。

  • 将 17.1、17.2、17.3 一起浏览,这里对于线程中的 wait、notify、中断有很多的常识;
  • 17.4 的内存模型比拟长,重排序和 happens-before 关系是重点;
  • 剩下的 final、字决裂、double 和 long 的非原子问题,这些都是绝对独立的 topic。

Chapter 17. Threads and Locks

前言

在 java 中,线程由 Thread 类示意,用户创立线程的惟一形式是创立 Thread 类的一个实例,每一个线程都和这样的一个实例关联。在相应的 Thread 实例上调用 start() 办法将启动一个线程。

如果没有正确应用同步,线程体现进去的景象将会是令人纳闷的、违反直觉的。这个章节将形容多线程编程的语义问题,包含一系列的规定,这些规定定义了 在多线程环境中线程对共享内存中值的批改是否对其余线程立刻可见

java 编程语言内存模型定义了对立的内存模型用于屏蔽不同的硬件架构,在没有歧义的状况下,上面将用 内存模型 示意这个概念。

这些语义没有规定多线程的程序在 JVM 的实现上应该怎么执行,而是限定了一系列规定,由 JVM 厂商来满足这些规定,即不论 JVM 的执行策略是什么,体现进去的行为必须是可被承受的。

操作系统有本人的内存模型,C/C++ 这些语言间接应用的就是操作系统的内存模型,而 Java 为了屏蔽各个系统的差别,定义了本人的对立的内存模型。简略说,Java 开发者不再关怀每个 CPU 外围有本人的内存,而后共享主内存。而是把关注点转移到:每个线程都有本人的工作内存,所有线程共享主内存。

17.1 同步(synchronization)

Java 提供了多种线程之间通信的机制,其中最根本的就是应用同步 (synchronization),其应用监视器 (monitor) 来实现。java 中的 每个对象都关联了一个监视器,线程能够对其进行加锁和解锁操作。

在同一时间,只有一个线程能够拿到对象上的监视器锁。如果其余线程在锁被占用期间试图去获取锁,那么将会被阻塞直到胜利获取到锁。同时,监视器锁能够重入,也就是说如果线程 t 拿到了锁,那么线程 t 能够在解锁之前反复获取锁;每次解锁操作会反转一次加锁产生的成果。

synchronized 有以下两种应用形式:

  • synchronized 代码块。synchronized(object)在对某个对象上执行加锁时,会尝试在该对象的监视器上进行加锁操作,只有胜利获取锁之后,线程才会持续往下执行。线程获取到了监视器锁后,将继续执行 synchronized 代码块中的代码,如果代码块执行实现,或者抛出了异样,线程将会主动对该对象上的监视器执行解锁操作。
  • synchronized 作用于办法,称为同步办法。同步办法被调用时,会主动执行加锁操作,只有加锁胜利,办法体才会失去执行。如果被 synchronized 润饰的办法是实例办法,那么 这个实例的监视器 会被锁定。如果是 static 办法,线程会锁住相应的 Class 对象的监视器。办法体执行实现或者异样退出后,会主动执行解锁操作。

Java 语言标准既不要求阻止死锁的产生,也不要求检测到死锁的产生。如果线程要在多个对象上执行加锁操作,那么就应该应用传统的办法来防止死锁的产生,如果有必要的话,须要创立更高层次的不会产生死锁的加锁原语。

java 还提供了其余的一些同步机制,比方对 volatile 变量的读写、应用 java.util.concurrent 包中的同步工具类等。

同步 这一节说了 Java 并发编程中最根底的 synchronized 这个关键字,大家肯定要了解 synchronize 的锁是什么,它的锁是基于 Java 对象的监视器 monitor,所以任何对象都能够用来做锁。有趣味的读者能够去理解相干常识,包含偏差锁、轻量级锁、重量级锁等。

小知识点:对 Class 对象加锁、对对象加锁,它们之间不形成同步 。synchronized 作用于静态方法时是对 Class 对象 加锁,作用于实例办法时是对实例加锁。

面试中常常会问到一个类中的两个 synchronized static 办法之间是否形成同步?形成同步。

17.2 期待汇合 和 唤醒(Wait Sets and Notification)

每个 java 对象,都关联了一个监视器,也关联了一个 期待汇合。期待汇合是一个线程汇合。

当对象被创立进去时,它的期待汇合是空的,对于向期待汇合中增加或者移除线程的操作都是原子的,以下几个操作能够操纵这个期待汇合:Object.wait, Object.notify, Object.notifyAll。

期待汇合也可能受到线程的中断状态的影响,也受到线程中解决中断的办法的影响。另外,sleep 办法和 join 办法能够感知到线程的 wait 和 notify。

这里概括得比拟简略,没看懂的读者没关系,持续往下看就是了。

这节要讲 Java 线程的相干常识,次要包含:

  • Thread 中的 sleep、join、interrupt
  • 继承自 Object 的 wait、notify、notifyAll
  • 还有 Java 的中断,这个概念也很重要

17.2.1 期待(Wait)

期待操作由以下几个办法引发:wait(),wait(long millisecs),wait(long millisecs, int nanosecs)。在前面两个重载办法中,如果参数为 0,即 wait(0)、wait(0, 0) 和 wait() 是等效的。

如果调用 wait 办法时没有抛出 InterruptedException 异样,则示意失常返回。

后方高能,请读者放弃高度精力集中。

咱们在线程 t 中对对象 m 调用 m.wait() 办法,n 代表加锁编号,同时还没有相匹配的解锁操作,则上面的其中之一会产生:

  • 如果 n 等于 0(如线程 t 没有持有对象 m 的锁),那么会抛出 IllegalMonitorStateException 异样。

留神,如果没有获取到监视器锁,wait 办法是会抛异样的,而且留神这个异样是 IllegalMonitorStateException 异样。这是重要知识点,要考。

  • 如果线程 t 调用的是 m.wait(millisecs) 或 m.wait(millisecs, nanosecs),形参 millisecs 不能为正数,nanosecs 取值应为 [0, 999999],否则会抛出 IllegalArgumentException 异样。
  • 如果线程 t 被中断,此时中断状态为 true,则 wait 办法将抛出 InterruptedException 异样,并将中断状态设置为 false。

中断,如果读者不理解这个概念,能够参考我在 AQS(二) 中的介绍,这是十分重要的常识。

  • 否则,上面的操作会程序产生:

留神:到这里的时候,wait 参数是失常的,同时 t 没有被中断,并且线程 t 曾经拿到了 m 的监视器锁。

1. 线程 t 会退出到对象 m 的期待汇合中,执行 加锁编号 n 对应的解锁操作

这里也十分要害,后面说了,wait 办法的调用必须是线程获取到了对象的监视器锁,而到这里会进行解锁操作。切记切记。。。

 public Object object = new Object();
 void thread1() {synchronized (object) { // 获取监视器锁
         try {object.wait(); // 这里会解锁,这里会解锁,这里会解锁
             // 顺便提一下,只是解了 object 上的监视器锁,如果这个线程还持有其余对象的监视器锁,这个时候是不会开释的。} catch (InterruptedException e) {// do somethings}
     }
 }

2. 线程 t 不会执行任何进一步的指令,直到它从 m 的期待汇合中移出(也就是期待唤醒)。在产生以下操作的时候,线程 t 会从 m 的期待汇合中移出,而后在之后的某个工夫点复原,并继续执行之后的指令。

并不是说线程移出期待队列就马上往下执行,这个线程还须要从新获取锁才行,这里也很要害,请往后看 17.2.4 中我写的两个简略的例子。

  • 在 m 上执行了 notify 操作,而且线程 t 被选中从期待汇合中移除。
  • 在 m 上执行了 notifyAll 操作,那么线程 t 会从期待汇合中移除。
  • 线程 t 产生了 interrupt 操作。
  • 如果线程 t 是调用 wait(millisecs) 或者 wait(millisecs, nanosecs) 办法进入期待汇合的,那么过了 millisecs 毫秒或者 (millisecs*1000000+nanosecs) 纳秒后,线程 t 也会从期待汇合中移出。
  • JVM 的“假唤醒”,尽管这是不激励的,然而这种操作是被容许的,这样 JVM 能实现将线程从期待汇合中移出,而不用期待具体的移出指令。

留神,良好的 Java 编码习惯是,只在循环中应用 wait 办法,这个循环期待某些条件来退出循环。

集体了解 wait 办法是这么用的:

 synchronized(m) {while(!canExit) {m.wait(10); // 期待 10ms; 当然中断也是罕用的
       canExit = something();  // 判断是否能够退出循环}
 }
 // 2 个知识点:// 1. 必须先获取到对象上的监视器锁
 // 2. wait 有可能被假唤醒

每个线程在一系列 可能导致它从期待汇合中移出的事件 中必须决定一个程序。这个程序不必要和其余程序统一,然而线程必须体现为它是依照那个程序产生的。

例如,线程 t 当初在 m 的期待汇合中,不论是线程 t 中断还是 m 的 notify 办法被调用,这些操作事件必定存在一个程序。如果线程 t 的中断先产生,那么 t 会因为 InterruptedException 异样而从 wait 办法中返回,同时 m 的期待汇合中的其余线程(如果有的话)会收到这个告诉。如果 m 的 notify 先产生,那么 t 会失常从 wait 办法返回,且不会扭转中断状态。

咱们思考这个场景:

线程 1 和线程 2 此时都 wait 了,线程 3 调用了:

synchronized (object) {thread1.interrupt(); //1
    object.notify();  //2}

原本我认为下面的状况 线程 1 肯定是抛出 InterruptedException,线程 2 是失常返回的。感激评论留言的 xupeng.zhang,我的这个想法是谬误的,齐全有可能线程 1 失常返回(即便其中断状态是 true),线程 2 始终 wait。

3. 线程 t 执行编号为 n 的加锁操作

回去看 2 说了什么,线程刚刚从期待汇合中移出,而后这里须要从新获取监视器锁能力持续往下执行。

4. 如果线程 t 在 2 的时候因为中断而从 m 的期待汇合中移出,那么它的中断状态会重置为 false,同时 wait 办法会抛出 InterruptedException 异样。

这一节次要在讲线程进出期待汇合的各种状况,同时,最好要晓得中断是怎么用的,中断的状态重置产生于什么时候。

这里的 1,2,3,4 的产生程序十分要害,大家能够认真再看看是不是齐全了解了,之后的几个大节还会更具体地论述这个,参考代码请看 17.2.4 大节我写的简略的例子。

17.2.2 告诉(Notification)

告诉操作产生于调用 notify 和 notifyAll 办法。

咱们在线程 t 中对对象 m 调用 m.notify() 或 m.notifyAll() 办法,n 代表加锁编号,同时对应的解锁操作没有执行,则上面的其中之一会产生:

  • 如果 n 等于 0,抛出 IllegalMonitorStateException 异样,因为线程 t 还没有获取到对象 m 上的锁。

这一点很要害,只有获取到了对象上的监视器锁的线程才能够失常调用 notify,后面咱们也说过,调用 wait 办法的时候也要先获取锁

  • 如果 n 大于 0,而且这是一个 notify 操作,如果 m 的期待汇合不为空,那么期待汇合中的线程 u 被选中从期待汇合中移出。

对于哪个线程会被选中而被移出,虚拟机没有提供任何保障,从期待汇合中将线程 u 移出,能够让线程 u 得以复原。留神,复原之后的线程 u 如果对 m 进行加锁操作将不会胜利,直到线程 t 齐全开释锁之后。

因为线程 t 这个时候还持有 m 的锁。这个知识点在 17.2.4 节我还会重点说。这里记住,被 notify 的线程在唤醒后是须要从新获取监视器锁的。

  • 如果 n 大于 0,而且这是一个 notifyAll 操作,那么期待汇合中的所有线程都将从期待汇合中移出,而后复原。

留神,这些线程复原后,只有一个线程能够锁住监视器。

本大节完结,告诉操作相对来说还是很简略的吧。

17.2.3 中断(Interruptions)

中断产生于 Thread.interrupt 办法的调用。

令线程 t 调用线程 u 上的办法 u.interrupt(),其中 t 和 u 能够是同一个线程,这个操作会将 u 的中断状态设置为 true。

顺便说说中断状态吧,初学者必定认为 thread.interrupt() 办法是用来暂停线程的,次要是和它对应中文翻译的“中断”无关。中断在并发中是罕用的伎俩,请大家肯定好好把握。能够将中断了解为线程的状态,它的非凡之处在于设置了中断状态为 true 后,这几个办法会感知到:

  • wait(), wait(long), wait(long, int), join(), join(long), join(long, int), sleep(long), sleep(long, int)这些办法都有一个共同之处,办法签名上都有 throws InterruptedException,这个就是用来响应中断状态批改的。
  • 如果线程阻塞在 InterruptibleChannel 类的 IO 操作中,那么这个 channel 会被敞开。
  • 如果线程阻塞在一个 Selector 中,那么 select 办法会立刻返回。

如果线程阻塞在以上 3 种状况中,那么当线程感知到中断状态后(此线程的 interrupt() 办法被调用),会将中断状态从新设置为 false,而后执行相应的操作(通常就是跳到 catch 异样处)。

如果不是以上 3 种状况,那么,线程的 interrupt() 办法被调用,会将线程的中断状态设置为 true。

当然,除了这几个办法,我晓得的是 LockSupport 中的 park 办法也能主动感知到线程被中断,当然,它不会重置中断状态为 false。咱们说了,只有下面的几种状况会在感知到中断后先重置中断状态为 false,而后再继续执行。

另外,如果有一个对象 m,而且线程 u 此时在 m 的期待汇合中,那么 u 将会从 m 的期待汇合中移出。这会让 u 从 wait 操作中恢复过来,u 此时须要获取 m 的监视器锁,获取完锁当前,发现线程 u 处于中断状态,此时会抛出 InterruptedException 异样。

这里的流程:t 设置 u 的中断状态 => u 线程复原 => u 获取 m 的监视器锁 => 获取锁当前,抛出 InterruptedException 异样。

这个流程在后面 wait 的大节曾经讲过了,这也是很多人都不理解的知识点。如果还不懂,能够看下一大节的完结,我的两个简略的例子。

一个小细节:u 被中断,wait 办法返回,并不会立刻抛出 InterruptedException 异样,而是在从新获取监视器锁之后才会抛出异样。

实例办法 thread.isInterrupted() 能够晓得线程的中断状态。

调用静态方法 Thread.interrupted() 能够返回以后线程的中断状态,同时将中断状态设置为 false。

所以说,如果是这个办法调用两次,那么第二次肯定会返回 false,因为第一次会重置状态。当然了,前提是两次调用的两头没有产生设置线程中断状态的其余语句。

17.2.4 期待、告诉和中断的交互(Interactions of Waits, Notification, and Interruption)

以上的一系列标准能让咱们确定 在期待、告诉、中断的交互中 无关的几个属性。

如果一个线程在期待期间,同时产生了告诉和中断,它将产生:

  • 从 wait 办法中失常返回,同时不扭转中断状态(也就是说,调用 Thread.interrupted 办法将会返回 true)
  • 因为抛出了 InterruptedException 异样而从 wait 办法中返回,中断状态设置为 false

线程可能没有重置它的中断状态,同时从 wait 办法中失常返回,即第一种状况。

也就是说,线程是从 notify 被唤醒的,因为产生了中断,所以中断状态为 true

同样的,告诉也不能因为中断而失落。

这个要说的是,线程其实是从中断唤醒的,那么线程醒过来,同时中断状态会被重置为 false。

假如 m 的期待汇合为 线程汇合 s,并且在另一个线程中调用了 m.notify(), 那么将产生:

  • 至多有汇合 s 中的一个线程失常从 wait 办法返回,或者
  • 汇合 s 中的所有线程由抛出 InterruptedException 异样而返回。

思考是否有这个场景:x 被设置了中断状态,notify 选中了汇合中的线程 x,那么这次 notify 将唤醒线程 x,其余线程(咱们假如还有其余线程在期待)不会有变动。

答案:存在这种场景。因为这种场景是满足上述条件的,而且此时 x 的中断状态是 true。

留神,如果一个线程同时被中断和告诉唤醒,同时这个线程通过抛出 InterruptedException 异样从 wait 中返回,那么期待汇合中的某个其余线程肯定会被告诉。

上面咱们通过 3 个例子简略剖析下 wait、notify、中断 它们的组合应用。

第一个例子展现了 wait 和 notify 操作过程中的监视器锁的 持有、开释 的问题。思考以下操作:

public class WaitNotify {public static void main(String[] args) {Object object = new Object();

        new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 1 获取到监视器锁");
                    try {object.wait();
                        System.out.println("线程 1 复原啦。我为什么这么久才复原,因为 notify 办法尽管早就产生了,可是我还要获取锁能力继续执行。");
                    } catch (InterruptedException e) {System.out.println("线程 1 wait 办法抛出了 InterruptedException 异样");
                    }
                }
            }
        }, "线程 1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 2 拿到了监视器锁。为什么呢,因为线程 1 在 wait 办法的时候会主动开释锁");
                    System.out.println("线程 2 执行 notify 操作");
                    object.notify();
                    System.out.println("线程 2 执行完了 notify,先劳动 3 秒再说。");
                    try {Thread.sleep(3000);
                        System.out.println("线程 2 劳动完啦。留神了,调 sleep 办法和 wait 办法不一样,不会开释监视器锁");
                    } catch (InterruptedException e) { }
                    System.out.println("线程 2 劳动够了,完结操作");
                }
            }
        }, "线程 2").start();}
}

output:线程 1 获取到监视器锁
线程 2 拿到了监视器锁。为什么呢,因为线程 1 在 wait 办法的时候会主动开释锁
线程 2 执行 notify 操作
线程 2 执行完了 notify,先劳动 3 秒再说。线程 2 劳动完啦。留神了,调 sleep 办法和 wait 办法不一样,不会开释监视器锁
线程 2 劳动够了,完结操作
线程 1 复原啦。我为什么这么久才复原,因为 notify 办法尽管早就产生了,可是我还要获取锁能力继续执行。

下面的例子展现了,wait 办法返回后,须要从新获取监视器锁,才能够持续往下执行。

同理,咱们略微批改下以上的程序,看下中断和 wait 之间的交互:

public class WaitNotify {public static void main(String[] args) {Object object = new Object();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 1 获取到监视器锁");
                    try {object.wait();
                        System.out.println("线程 1 复原啦。我为什么这么久才复原,因为 notify 办法尽管早就产生了,可是我还要获取锁能力继续执行。");
                    } catch (InterruptedException e) {System.out.println("线程 1 wait 办法抛出了 InterruptedException 异样,即便是异样,我也是要获取到监视器锁了才会抛出");
                    }
                }
            }
        }, "线程 1");
        thread1.start();

        new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 2 拿到了监视器锁。为什么呢,因为线程 1 在 wait 办法的时候会主动开释锁");
                    System.out.println("线程 2 设置线程 1 中断");
                    thread1.interrupt();
                    System.out.println("线程 2 执行完了 中断,先劳动 3 秒再说。");
                    try {Thread.sleep(3000);
                        System.out.println("线程 2 劳动完啦。留神了,调 sleep 办法和 wait 办法不一样,不会开释监视器锁");
                    } catch (InterruptedException e) { }
                    System.out.println("线程 2 劳动够了,完结操作");
                }
            }
        }, "线程 2").start();}
}
output:
线程 1 获取到监视器锁
线程 2 拿到了监视器锁。为什么呢,因为线程 1 在 wait 办法的时候会主动开释锁
线程 2 设置线程 1 中断
线程 2 执行完了 中断,先劳动 3 秒再说。线程 2 劳动完啦。留神了,调 sleep 办法和 wait 办法不一样,不会开释监视器锁
线程 2 劳动够了,完结操作
线程 1 wait 办法抛出了 InterruptedException 异样,即便是异样,我也是要获取到监视器锁了才会抛出

下面的这个例子也很分明,如果线程调用 wait 办法,当此线程被中断的时候,wait 办法会返回,而后从新获取监视器锁,而后抛出 InterruptedException 异样。

咱们再来思考下,之前说的 notify 和中断:

package com.javadoop.learning;

/**
 * Created by hongjie on 2017/7/7.
 */
public class WaitNotify {

    volatile int a = 0;

    public static void main(String[] args) {Object object = new Object();

        WaitNotify waitNotify = new WaitNotify();

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 1 获取到监视器锁");
                    try {object.wait();
                        System.out.println("线程 1 失常复原啦。");
                    } catch (InterruptedException e) {System.out.println("线程 1 wait 办法抛出了 InterruptedException 异样");
                    }
                }
            }
        }, "线程 1");
        thread1.start();

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 2 获取到监视器锁");
                    try {object.wait();
                        System.out.println("线程 2 失常复原啦。");
                    } catch (InterruptedException e) {System.out.println("线程 2 wait 办法抛出了 InterruptedException 异样");
                    }
                }
            }
        }, "线程 2");
        thread2.start();

         // 这里让 thread1 和 thread2 先起来,而后再起前面的 thread3
        try {Thread.sleep(1000);
        } catch (InterruptedException e) { }

        new Thread(new Runnable() {
            @Override
            public void run() {synchronized (object) {System.out.println("线程 3 拿到了监视器锁。");
                    System.out.println("线程 3 设置线程 1 中断");
                    thread1.interrupt(); // 1
                    waitNotify.a = 1; // 这行是为了禁止高低的两行中断和 notify 代码重排序
                    System.out.println("线程 3 调用 notify");
                    object.notify(); //2
                    System.out.println("线程 3 调用完 notify 后,劳动一会");
                    try {Thread.sleep(3000);
                    } catch (InterruptedException e) { }
                    System.out.println("线程 3 劳动够了,完结同步代码块");
                }
            }
        }, "线程 3").start();}
}

// 最常见的 output:
线程 1 获取到监视器锁
线程 2 获取到监视器锁
线程 3 拿到了监视器锁。线程 3 设置线程 1 中断
线程 3 调用 notify
线程 3 调用完 notify 后,劳动一会
线程 3 劳动够了,完结同步代码块
线程 2 失常复原啦。线程 1 wait 办法抛出了 InterruptedException 异样

上述输入不是相对的,有可能产生 线程 1 是失常复原的,尽管产生了中断,它的中断状态也的确是 true,然而它没有抛出 InterruptedException,而是失常返回。此时,thread2 将得不到唤醒,始终 wait。

17.3. 休眠和礼让(Sleep and Yield)

Thread.sleep(millisecs) 使以后正在执行的线程休眠指定的一段时间(临时进行执行任何指令),工夫取决于参数值,精度受制于零碎的定时器。休眠期间,线程不会开释任何的监视器锁。线程的复原取决于定时器和处理器的可用性,即有可用的处理器来唤醒线程。

须要留神的是,Thread.sleep 和 Thread.yield 都不具备同步的语义。在 Thread.sleep 和 Thread.yield 办法调用之前,不要求虚拟机将寄存器中的缓存刷出到共享内存中,同时也不要求虚拟机在这两个办法调用之后,从新从共享内存中读取数据到缓存。

例如,咱们有如下代码块,this.done 定义为一个 non-volatile 的属性,初始值为 false。

while (!this.done)
    Thread.sleep(1000);

编译器能够只读取一次 this.done 到缓存中,而后始终应用缓存中的值,也就是说,这个循环可能永远不会完结,即便是有其余线程将 this.done 的值批改为 true。

yield 是通知操作系统的调度器:我的 cpu 能够先让给其余线程。留神,调度器能够不理睬这个信息。

这个办法太鸡肋,简直没用。

17.4 内存模型(Memory Model)

内存模型这一节比拟长,请急躁浏览

内存模型形容的是程序在 JVM 的执行过程中对数据的读写是否是依照程序的规定正确执行的。Java 内存模型定义了一系列规定,这些规定定义了对共享内存的写操作对于读操作的可见性。

简略地说,定义内存模型,次要就是为了标准多线程程序中批改或者拜访同一个值的时候的行为。对于那些自身就是线程平安的问题,这里不做探讨。

内存模型形容了程序执行时的可能的体现行为。只有执行的后果是满足 java 内存模型的所有规定,那么虚拟机对于具体的实现能够自由发挥。

从侧面说,不论虚拟机的实现是怎么样的,多线程程序的执行后果都应该是可预测的。

虚拟机实现者能够自在地执行大量的代码转换,包含重排序操作和删除一些不必要的同步。

这里我画了一条线,从这条线到下一条线之间是两个重排序的例子,如果你没接触过,能够看一下,如果你曾经相熟了或者在其余中央看过了,请间接往下滑。

示例 17.4-1 不正确的同步可能导致奇怪的后果

java 语言容许 compilers 和 CPU 对执行指令进行重排序,导致咱们会常常看到似是而非的景象。

这里没有翻译 compiler 为编译器,因为它不仅仅代表编译器,后续它会代表所有会导致指令重排序的机制。

如表 17.4-A 中所示,A 和 B 是共享属性,r1 和 r2 是局部变量。初始时,令 A == B == 0。

表 17.4-A. 重排序导致奇怪的后果 – 原始代码

依照咱们的直觉来说,r2 == 2 同时 r1 == 1 应该是不可能的。直观地说,指令 1 和 3 应该是最先执行的。如果指令 1 最先执行,那么它应该不会看到指令 4 对 A 的写入操作。如果指令 3 最先执行,那么它应该不会看到执行 2 对 B 的写入操作。

如果真的体现出了 r2==2 和 r1==1,那么咱们应该晓得,指令 4 先于指令 1 执行了。

如果在执行过程出体现出这种行为(r2==2 和 r1==1),那么咱们能够推断出以下指令顺次执行:指令 4 => 指令 1=> 指令 2 => 指令 3。看上去,这种程序是荒诞的。

然而,Java 是容许 compilers 对指令进行重排序的,只有保障在单线程的状况下,能保障程序是依照咱们想要的后果进行执行,即 compilers 能够对 单线程内不产生数据依赖的语句之间进行重排序。如果指令 1 和指令 2 产生了重排序,如依照表 17.4-B 所示的程序进行执行,那么咱们就很容易看到,r2==2 和 r1==1 是可能产生的。

表 17.4-B. 重排序导致奇怪的后果 – 容许的编译器转换

B = 1; => r1 = B; => A = 2; => r2 = A;

对于很多程序员来说,这个后果看上去是 broken 的,然而这段代码是没有正确的同步导致的:

  • 其中有一个线程执行了写操作
  • 另一个线程对同一个属性执行了读操作
  • 同时,读操作和写操作没有应用同步来确定它们之间的执行程序

简略地说,之后要讲的一大堆货色次要就是为了确定共享内存读写的执行程序,不正确或者说非法的代码就是因为读写同一内存地址没有应用同步(这里不仅仅只是说 synchronized),从而导致执行的后果具备不确定性。

这个是 数据竞争(data race) 的一个例子。当代码蕴含数据竞争时,常常会产生违反咱们直觉的后果。

有几个机制会导致表 17.4-B 中的指令重排序。java 的 JIT 编译器实现可能会重排序代码,或者处理器也会做重排序操作。此外,java 虚拟机实现中的内存层次结构也会使代码像重排序一样。在本章中,咱们将所有这些会导致代码重排序的货色统称为 compiler。

所以,后续咱们不要再简略地将 compiler 翻译为编译器,不要狭窄地了解为 Java 编译器。而是代表了所有可能会制作重排序的机制,包含 JVM 优化、CPU 优化等。

另一个可能产生奇怪的后果的示例如表 17.4-C,初始时 p == q 同时 p.x == 0。这个代码也是没有正确应用同步的;在这些写入共享内存的写操作中,没有进行强制的先后排序。

Table 17.4-C

一个简略的编译器优化操作是会复用 r2 的后果给 r5,因为它们都是读取 r1.x,而且在单线程语义中,r2 到 r5 之间没有其余的相干的写入操作,这种状况如表 17.4-D 所示。

Table 17.4-D

当初,咱们来思考一种状况,在线程 1 第一次读取 r1.x 和 r3.x 之间,线程 2 执行 r6=p; r6.x=3; 编译器进行了 r5 复用 r2 后果的优化操作,那么 r2==r5==0,r4 == 3,从程序员的角度来看,p.x 的值由 0 变为 3,而后又变为 0。

我简略整顿了一下:

例子完结,回到正题

Java 内存模型定义了在程序的每一步,哪些值是内存可见的。对于隔离的每个线程来说,其操作是由咱们线程中的语义来决定的,然而线程中读取到的值是由内存模型来管制的。

当咱们提到这点时,咱们说程序恪守线程内语义,线程内语义说的是单线程内的语义,它容许咱们基于线程内读操作看到的值齐全预测线程的行为。如果咱们要确定线程 t 中的操作是否是非法的,咱们只有评估当线程 t 在单线程环境中运行时是否是非法的就能够,该标准的其余部分也在定义这个问题。

这段话不太好了解,首先记住“线程内语义”这个概念,之后还会用到。我对这段话的了解是,在单线程中,咱们是能够通过一行一行看代码来预测执行后果的,只不过,代码中应用到的读取内存的值咱们是不能确定的,这取决于在内存模型这个大框架下,咱们的程序会读到的值。兴许是最新的值,兴许是过期的值。

此节形容除了 final 关键字外的 java 内存模型的标准,final 将在之后的 17.5 节介绍。

这里形容的内存模型并不是基于 Java 编程语言的面向对象。为了简洁起见,咱们常常展现没有类或办法定义的代码片段。大多数示例蕴含两个或多个线程,其中蕴含局部变量,共享全局变量或对象的实例字段的语句。咱们通常应用诸如 r1 或 r2 之类的变量名来示意办法或线程本地的变量。其余线程无法访问此类变量。

17.4.1. 共享变量(Shared Variables)

所有线程都能够拜访到的内存称为 共享内存 堆内存

所有的实例属性,动态属性,还有数组的元素都存储在堆内存中。在本章中,咱们用术语变量来示意这些元素。

局部变量、办法参数、异样对象,它们不会在线程间共享,也不会受到内存模型定义的任何影响。

两个线程对同一个变量同时进行 读 - 写操作 写 - 写操作,咱们称之为“抵触”。

好,这一节都是废话,欢快地进入到下一节

17.4.2. 操作(Actions)

这一节次要是解说实践,次要就是谨严地定义操作。

线程间操作是指由一个线程执行的动作,能够被另一个线程检测到或间接影响到。以下是几种可能产生的线程间操作:

  • 读(一般变量,非 volatile)。读一个变量。
  • 写(一般变量,非 volatile)。写一个变量。
  • 同步操作,如下:
    • volatile 读。读一个 volatile 变量
    • volatile 写。写入一个 volatile 变量
    • 加锁。对一个对象的监视器加锁。
    • 解锁。解除对某个对象的监视器锁。
    • 线程的第一个和最初一个操作。
    • 开启线程操作,或检测一个线程是否曾经完结。
  • 内部操作。一个内部操作指的是可能被察看到的在内部执行的操作,同时它的执行后果受外部环境管制。

简略说,内部操作的内部指的是在 JVM 之外,如 native 操作。

  • 线程一致操作(§17.4.9)。此操作只由处于有限循环的线程执行,在该循环中不执行任何内存操作、同步操作、或内部操作。如果一个线程执行了一致操作,那么其后将跟着有数的线程一致操作。

一致操作的引入是为了用来阐明,线程可能会导致其余所有线程进展而不能继续执行。

此标准仅关怀线程间操作,咱们不关怀线程外部的操作(比方将两个局部变量的值相加存到第三个局部变量中)。如前文所说,所有的线程都须要恪守线程内语义。对于线程间操作,咱们常常会简略地称为操作。

咱们用元祖 < t, k, v, u > 来形容一个操作:

  • t – 执行操作的线程
  • k – 操作的类型。
  • v – 操作波及的变量或监视器
    • 对于加锁操作,v 是被锁住的监视器;对于解锁操作,v 是被解锁的监视器。
    • 如果是一个读操作(volatile 读或非 volatile 读),v 是读操作对应的变量
    • 如果是一个写操作(volatile 写或非 volatile 写),v 是写操作对应的变量
  • u – 惟一的标识符标识此操作

内部动作元组还蕴含一个附加组件,其中蕴含由执行操作的线程感知的内部操作的后果。这可能是对于操作的成败的信息,以及操作中所读的任何值。

内部操作的参数(如哪些字节写入哪个 socket)不是内部操作元祖的一部分。这些参数是通过线程中的其余操作进行设置的,并能够通过查看线程内语义进行确定。它们在内存模型中没有被明确探讨。

在非终结执行中,不是所有的内部操作都是可察看的。17.4.9 大节探讨非终结执行和可察看操作。

大家看完这节最懵逼的应该是内部操作和线程一致操作,我简略解释下。

内部操作大家能够了解为 Java 调用了一个 native 的办法,Java 能够失去这个 native 办法的返回值,然而对于具体的执行其实不感知的,意味着 Java 其实不能对这种语句进行重排序,因为 Java 无奈晓得办法领会执行哪些指令。

援用 stackoverflow 中的一个例子:

// method()办法中 jni()是内部操作,不会和 "foo = 42;" 这条语句进行重排序。class Externalization {
  int foo = 0;
  void method() {jni(); // 内部操作
    foo = 42;
  }
  native void jni(); /* {assert foo == 0; // 咱们假如内部操作执行的是这个。} */
}

在下面这个例子中,显然,jni() 与 foo = 42 之间不能进行重排序。

再来个线程一致操作的例子:

// 线程一致操作阻止了重排序,所以 "foo = 42;" 这条语句不会先执行
class ThreadDivergence {
  int foo = 0;
  void thread1() {while (true){} // 线程一致操作
    foo = 42;
  }

  void thread2() {assert foo == 0; // 这里永远不会失败}
}

17.4.3. 程序和程序程序(Programs and Program Order)

在每个线程 t 执行的所有线程间动作中,t 的程序程序是反映 依据 t 的线程内语义执行这些动作的程序 的总程序。

如果所有操作的执行程序 和 代码中的程序统一,那么一组操作就是间断统一的,并且,对变量 v 的每个读操作 r 会看到写操作 w 写入的值,也就是:

  • 写操作 w 先于 读操作 r 实现,并且
  • 没有其余的写操作 w’ 使得 w’ 在 w 之后 r 之前产生。

间断一致性 对于可见性和程序执行程序是一个十分强的保障。在这种场景下,所有的单个操作(比方读和写)形成一个对立的执行程序,这个执行程序和代码呈现的程序是统一的,同时每个单个操作都是原子的,且对所有线程来说立刻可见。

如果程序没有任何的数据竞争,那么程序的所有执行操作将体现为间断统一。间断一致性 和 / 或 数据竞争的自在依然容许谬误从一组操作中产生。

齐全不晓得这句话是什么意思

如果咱们用间断一致性作为咱们的内存模型,那咱们探讨的许多对于编译器优化和处理器优化就是非法的。比方在 17.4- C 中,一旦执行 p.x=3,那么后续对于该地位的读操作应该是立刻能够读到最新值的。

间断一致性的外围在于每一步的操作都是原子的,同时对于所有线程都是可见的,而且不存在重排序。所以,Java 语言定义的内存模型必定不会采纳这种策略,因为它间接限度了编译器和 JVM 的各种优化措施。

留神:很多中央所说的程序一致性就是这里的间断一致性,英文是 Sequential consistency

17.4.4. 同步程序(Synchronization Order)

每个执行都有一个同步程序。同步程序是由执行过程中的每个同步操作组成的程序。对于每个线程 t,同步操作组成的同步程序是和线程 t 中的代码程序统一的。

尽管拗口,但毕竟说的是同步,咱们都不生疏。同步操作包含了如下同步关系:

  • 对于监视器 m 的解锁与所有后续操作对于 m 的加锁同步
  • 对 volatile 变量 v 的写入,与所有其余线程后续对 v 的读同步
  • 启动线程的操作与线程中的第一个操作同步。
  • 对于每个属性写入默认值(0,false,null)与每个线程对其进行的操作同步。
  • 只管在创建对象实现之前对对象属性写入默认值有点奇怪,但从概念上来说,每个对象都是在程序启动时用默认值初始化来创立的。
  • 线程 T1 的最初操作与线程 T2 发现线程 T1 曾经完结同步。
  • 线程 T2 能够通过 T1.isAlive() 或 T1.join() 办法来判断 T1 是否曾经终结。
  • 如果线程 T1 中断了 T2,那么线程 T1 的中断操作与其余所有线程发现 T2 被中断了同步(通过抛出 InterruptedException 异样,或者调用 Thread.interrupted 或 Thread.isInterrupted)

以上同步程序能够了解为对于某资源的开释先于其余操作对同一资源的获取。

好,这节绝对 easy,说的就是对于 A synchronizes-with B 的一系列规定。

17.4.5. Happens-before 程序(Happens-before Order)

Happens-before 是十分重要的常识,有些中央我没有很了解,我尽量将原文直译过去。想要理解更深的货色,你可能还须要查问更多的其余材料。

两个操作能够用 happens-before 来确定它们的执行程序,如果一个操作 happens-before 于另一个操作,那么咱们说第一个操作对于第二个操作是可见的。

留神:happens-before 强调的是可见性问题

如果咱们别离有操作 x 和操作 y,咱们写成 hb(x, y) 来示意 x happens-before y。

  • 如果操作 x 和操作 y 是同一个线程的两个操作,并且在代码上操作 x 先于操作 y 呈现,那么有 hb(x, y)。请留神,这里不代表不能够重排序,只有没有数据依赖关系,重排序就是可能的。
  • 对象构造方法的最初一行指令 happens-before 于 finalize() 办法的第一行指令。
  • 如果操作 x 与随后的操作 y 形成同步,那么 hb(x, y)。
  • hb(x, y) 和 hb(y, z),那么能够推断出 hb(x, z)

对象的 wait 办法关联了加锁和解锁的操作,它们的 happens-before 关系即是加锁 happens-before 解锁。

咱们应该留神到,两个操作之间的 happens-before 的关系并不一定示意它们在 JVM 的具体实现上必须是这个程序,如果重排序后的操作后果和非法的执行后果是统一的,那么这种实现就不是非法的。

比如说,在线程中对对象的每个属性写入初始默认值并不需要先于线程的开始,只有这个事实没有被读到就能够了。

咱们能够发现,happens-before 规定次要还是上一节 同步程序 中的规定,加上额定的几条

更具体地说,如果两个操作是 happens-before 的关系,然而在代码中它们并没有这种程序,那么就没有必要体现出 happens-before 关系。如线程 1 对变量进行写入,线程 2 随后对变量进行读操作,那么这两个操作是没有 happens-before 关系的。

happens-before 关系用于定义当产生数据竞争的时候。将下面所有的规定简化成以下列表:

  • 对一个监视器的解锁操作 happens-before 于后续的对这个监视器的加锁操作。
  • 对 volatile 属性的写操作先于后续对这个属性的读操作。也就是一旦写操作实现,那么后续的读操作肯定能读到最新的值
  • 线程的 start() 先于任何在线程中定义的语句。
  • 如果 A 线程中调用了 B.join(),那么 B 线程中的操作先于 A 线程 join() 返回之后的任何语句。因为 join() 自身就是让其余线程先执行完的意思。
  • 对象的默认初始值 happens-before 于程序中对它的其余操作。也就是说不论咱们要对这个对象干什么,这个对象即便没有创立实现,它的各个属性也肯定有初始零值。

当程序呈现两个没有 happens-before 关系的操作对同一数据进行拜访时,咱们称之为程序中有数据竞争。

除了线程间操作,数据竞争不间接影响其余操作的语义,如读取数组的长度、查看转换的执行、虚构办法的调用。

因而,数据竞争不会导致谬误的行为,例如为数组返回谬误的长度。当且仅当所有间断统一的操作都没有数据争用时,程序就是正确同步的。

如果一个程序是正确同步的,那么程序中的所有操作就会体现出间断一致性。

这是一个对于程序员来说强有力的保障,程序员不须要晓得重排序的起因,就能够确定他们的代码是否蕴含数据争用。因而,他们不须要晓得重排序的起因,来确定他们的代码是否是正确同步的。一旦确定了代码是正确同步的,程序员也就不须要放心重排序对于代码的影响。

其实就是正确同步的代码不存在数据竞争问题,这个时候程序员不须要关怀重排序是否会影响咱们的代码,咱们的代码执行肯定会体现出间断统一。

程序必须正确同步,以防止当呈现重排序时,会呈现一系列的奇怪的行为。正确同步的应用,不能保障程序的全副行为都是正确的。

然而,它的应用能够让程序员以很简略的形式就能晓得可能产生的行为。正确同步的程序体现进去的行为更不会依赖于可能的重排序。没有应用正确同步,十分奇怪、令人纳闷、违反直觉的任何行为都是可能的。

咱们说,对变量 v 的读操作 r 能看到对 v 的写操作 w,如果:

读操作 r 不是先于 w 产生(比方不是 hb(r, w)),同时没有写操作 w’ 穿插在 w 和 r 两头(如不存在 hb(w, w’) 和 hb(w’, r))。非正式地,如果没有 happens-before 关系阻止读操作 r,那么读操作 r 就能看到写操作 w 的后果。

17.5. final 属性的语义(final Field Semantics)

咱们常常应用 final,对于它最根底的常识是:用 final 润饰的类不能够被继承,用 final 润饰的办法不能够被覆写,用 final 润饰的属性一旦初始化当前不能够被批改。

当然,这节说的不是这些,这里将论述 final 关键字的深层次含意。

用 final 申明的属性失常状况下初始化一次后,就不会被扭转。final 属性的语义与一般属性的语义有一些不一样。尤其是,对于 final 属性的读操作,compilers 能够自在地去除不必要的同步。相应地,compilers 能够将 final 属性的值缓存在寄存器中,而不必像一般属性一样从内存中从新读取。

final 属性同时也容许程序员不须要应用同步就能够实现线程平安的不可变对象。一个线程平安的不可变对象对于所有线程来说都是不可变的,即便传递这个对象的援用存在数据竞争。

这能够提供平安的保障,即便是谬误的或者歹意的对于这个不可变对象的应用。如果须要保障对象不可变,须要正确地应用 final 属性域。

对象只有在构造方法完结了才被认为齐全初始化了。如果一个对象齐全初始化当前,一个线程持有该对象的援用,那么这个线程肯定能够看到正确初始化的 final 属性的值。

这个隐含了,如果属性值不是 final 的,那就不能保障肯定能够看到正确初始化的值,可能看到初始零值。

final 属性的应用是非常简单的:在对象的构造方法中设置 final 属性;同时在对象初始化实现前,不要将此对象的援用写入到其余线程能够拜访到的中央。如果这个条件满足,当其余线程看到这个对象的时候,那个线程始终能够看到正确初始化后的对象的 final 属性。

这外面说到了一个正确初始化的问题,看过《Java 并发编程实战》的可能对这个会有印象,不要在构造方法中将 this 公布进来。

这段代码把 final 属性和一般属性进行比照。

class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3;
        y = 4;
    }

    static void writer() {f = new FinalFieldExample();
    }

    static void reader() {if (f != null) {
            int i = f.x;  // 程序肯定能失去 3
            int j = f.y;  // 兴许会看到 0
        }
    }
}

这个类 FinalFieldExample 有一个 final 属性 x 和一个一般属性 y。咱们假设有一个线程执行 writer() 办法,另一个线程再执行 reader() 办法。

因为 writer() 办法在对象齐全结构后将援用写入 f,那么 reader() 办法将肯定能够看到初始化后的 f.x : 将读到一个 int 值 3。然而,f.y 不是 final 的,所以程序不能保障能够看到 4,可能会失去 0。

final 属性被设计成用来保障很多操作的安全性。思考以下代码,线程 1 执行:

Global.s = "/tmp/usr".substring(4);

同时,线程 2 执行:

String myS = Global.s;
if (myS.equals("/tmp")) System.out.println(myS);

String 对象是不可变对象,同时 String 操作不须要应用同步。尽管 String 的实现没有任何的数据竞争,然而其余应用到 String 对象的代码可能是存在数据竞争的,内存模型没有对存在数据竞争的代码提供安全性保障。

特地是,如果 String 类中的属性不是 final 的,那么有可能(尽管不太可能)线程 2 会看到这个 string 对象的 offset 为初始值 0,那么就会呈现 myS.equals(“/tmp”)。

之后的一个操作可能会看到这个 String 对象的正确的 offset 值 4,那么会失去“/usr”。Java 中的许多平安个性都依赖于 String 对象的不可变性,即便是恶意代码在数据竞争的环境中在线程之间传递 String 对象的援用。

大家看这段的时候,如果要看代码,请留神,这里说的是 JDK6 及以前的 String 类:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence
{
    /** The value is used for character storage. */
    private final char value[];

    /** The offset is the first index of the storage that is used. */
    private final int offset;

    /** The count is the number of characters in the String. */
    private final int count;

    /** Cache the hash code for the string */
    private int hash; // Default to 0

因为到 JDK7 和 JDK8 的时候,代码曾经变为:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

17.5.1. final 属性的语义(Semantics of final Fields)

令 o 为一个对象,c 为 o 的构造方法,构造方法中对 final 的属性 f 进行写入值。当构造方法 c 退出的时候,会在 final 属性 f 上执行一个 freeze 操作。

留神,如果一个构造方法调用了另一个构造方法,在被调用的构造方法中设置 final 属性,那么对于 final 属性的 freeze 操作产生于被调用的构造方法完结的时候。

对于每一个执行,读操作的行为被其余的两个偏序影响,解援用链 dereferences() 和内存链 mc(),它们被认为是执行的一部分。这些偏序必须满足上面的束缚:

17.5.2. 在结构期间读 final 属性(Reading final Fields During Construction)

在结构对象的线程中,对该对象的 final 属性的读操作,恪守失常的 happens-before 规定。如果在构造方法内,读某个 final 属性晚于对这个属性的写操作,那么这个读操作能够看到这个 final 属性曾经被定义的值,否则就会看到默认值。

17.5.3. final 属性的批改(Subsequent Modification of final Fields)

在许多场景下,如反序列化,零碎须要在对象结构之后扭转 final 属性的值。final 属性能够通过反射和其余办法来扭转。

惟一的具备正当语义的模式是:对象被结构进去,而后对象中的 final 属性被更新。在这个对象的所有 final 属性更新操作实现之前,此对象不应该对其余线程可见,也不应该对 final 属性进行读操作。

对于 final 属性的 freeze 操作产生于 构造方法的完结,这个时候 final 属性曾经被设值,还有通过反射或其余形式对于 final 属性的更新之后。

即便是这样,仍然存在几个难点。如果一个 final 属性在属性申明的时候初始化为一个常量表达式,对于这个 final 属性值的变动过程兴许是不可见的,因为对于这个 final 属性的应用是在编译时用常量表达式来替换的。

另一个问题是,该标准容许 JVM 实现对 final 属性进行强制优化。在一个线程内,容许 对于 final 属性的读操作与构造方法之外的对于这个 final 属性的批改进行重排序。

对于 final 属性的强制优化(Aggressive Optimization of final Fields)

class A {
    final int x;
    A() {x = 1;}

    int f() {return d(this,this);
    }

    int d(A a1, A a2) {
        int i = a1.x;
        g(a1);
        int j = a2.x;
        return j - i;
    }

    static void g(A a) {
        // 利用反射将 a.x 的值批改为 2
        // uses reflection to change a.x to 2
    }
}

在办法 d 中,编译器容许对 x 的读操作和办法 g 进行重排序,这样的话,new A().f()可能会返回 -1, 0, 或 1。

我在我的 MBP 上试了好多方法,真的没法重现进去,不过并发问题就是这样,咱们不能重现不代表不存在。StackOverflow 上有网友说在 Sparc 上运行,惋惜我没有 Sparc 机器。

下文将说到一个比拟少见的 final-field-safe context

JVM 实现能够提供一种形式在 final 属性平安上下文(final-field-safe context)中执行代码块。如果一个对象是在 final 属性平安上下文中结构进去的,那么在这个 final 属性平安上下文 中对于 final 属性的读操作不会和相应的对于 final 属性的批改进行重排序。

final 属性平安上下文还提供了额定的保障。如果一个线程曾经看到一个不正确公布的一个对象的援用,那么此线程能够看到了 final 属性的默认值,而后,在 final 属性平安上下文中读取该对象的正确公布的援用,这能够保障看到正确的 final 属性的值。在模式上,在 final 属性平安上下文中执行的代码被认为是一个独立的线程(仅用于满足 final 属性的语义)。

在实现中,compiler 不应该将对 final 属性的拜访移入或移出 final 属性平安上下文(只管它能够在这个执行上下文的周边挪动,只有这个对象没有在这个上下文中进行结构)。

对于 final 属性平安上下文的应用,一个失当的中央是执行器或者线程池。在每个独立的 final 属性平安上下文中执行每一个 Runnable,执行器能够保障在一个 Runnable 中对对象 o 的不正确的拜访不会影响同一执行器内的其余 Runnable 中的 final 带来的平安保障。

17.5.4. 写爱护属性(Write-Protected Fields)

通常,如果一个属性是 final 的和 static 的,那么这个属性是不会被扭转的。然而,System.in, System.out, 和 System.err 是 static final 的,出于遗留的历史起因,它们必须容许被 System.setIn, System.setOut, 和 System.setErr 这几个办法扭转。咱们称这些属性是写爱护的,用以辨别一般的 final 属性。

  public final static InputStream in = null;
    public final static PrintStream out = null;
    public final static PrintStream err = null;

编译器须要将这些属性与 final 属性区别对待。例如,一般 final 属性的读操作对于同步是“免疫的”:锁或 volatile 读操作中的内存屏障并不会影响到对于 final 属性的读操作读到的值。因为写爱护属性的值是能够被扭转的,所以同步事件应该对它们有影响。因而,语义规定这些属性被当做一般属性,不能被用户的代码扭转,除非是 System 类中的代码。

17.6. 字决裂(Word Tearing)

实现 Java 虚拟机须要思考的一件事件是,每个对象属性以及数组元素之间是独立的,更新一个属性或元素不能影响其余属性或元素的读取与更新。尤其是,两个线程在别离更新 byte 数组相邻的元素时,不能相互影响与烦扰,且不须要同步来保障间断一致性。

一些处理器不提供写入单个字节的能力。通过简略地读取整个字,更新相应的字节,而后将整个字写入内存,用这种形式在这种处理器上实现字节数组更新是非法的。这个问题有时被称为字决裂(word tearing),在这种不能独自更新单个字节的处理器上,将须要寻求其余的办法。

请留神,对于大部分处理器来说,都没有这个问题

Example 17.6-1. Detection of Word Tearing

以下程序用于测试是否存在字决裂:

public class WordTearing extends Thread {
    static final int LENGTH = 8;
    static final int ITERS = 1000000;
    static byte[] counts = new byte[LENGTH];
    static Thread[] threads = new Thread[LENGTH];

    final int id;

    WordTearing(int i) {id = i;}

    public void run() {
        byte v = 0;
        for (int i = 0; i < ITERS; i++) {byte v2 = counts[id];
            if (v != v2) {
                System.err.println("Word-Tearing found:" +
                        "counts[" + id + "] =" + v2 +
                        ", should be" + v);
                return;
            }
            v++;
            counts[id] = v;
        }
        System.out.println("done");
    }

    public static void main(String[] args) {for (int i = 0; i < LENGTH; ++i)
            (threads[i] = new WordTearing(i)).start();}
}

这表明写入字节时不得覆写相邻的字节。

17.7. double 和 long 的非原子解决(Non-Atomic Treatment of double and long)

在 Java 内存模型中,对于 non-volatile 的 long 或 double 值的写入是通过两个独自的写操作实现的:long 和 double 是 64 位的,被分为两个 32 位来进行写入。那么可能就会导致一个线程看到了某个操作的低 32 位的写入和另一个操作的高 32 位的写入。

写入或者读取 volatile 的 long 和 double 值是原子的。

写入和读取对象援用肯定是原子的,不论具体实现是 32 位还是 64 位。

将一个 64 位的 long 或 double 值的写入分为相邻的两个 32 位的写入对于 JVM 的实现来说是很不便的。为了性能上的思考,JVM 的实现是能够决定采纳原子写入还是分为两个局部写入的。

如果可能的话,咱们激励 JVM 的实现避开将 64 位值的写入分拆成两个操作。咱们也心愿程序员将共享的 64 位值操作设置为 volatile 或者应用正确的同步,这样能够提供更好的兼容性。

目前来看,64 位虚拟机对于 long 和 double 的写入都是原子的,没必要加 volatile 来保障原子性。

起源:https://javadoop.com/post/Thr…\

参考:\
https://docs.oracle.com/javas…\
http://www.cs.umd.edu/~pugh/j…\
http://gee.cs.oswego.edu/dl/j…

近期热文举荐:

1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)

2. 劲爆!Java 协程要来了。。。

3.Spring Boot 2.x 教程,太全了!

4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!

5.《Java 开发手册(嵩山版)》最新公布,速速下载!

感觉不错,别忘了顺手点赞 + 转发哦!

正文完
 0