JVM

深入理解Java虚拟机总结-Java内存模型与线程

Posted by AlstonWilliams on February 17, 2019

注:此文是我在读完周志明老师的深入理解Java虚拟机之后总结的一篇文章,请阅读此书获取更加详细的信息.

在介绍Java内存之前,我们先考虑一下硬件上的结构.

我们都知道,一台机器有几个必要的组件,CPU,内存,高速缓存,磁盘.高速缓存就是为了解决CPU在进行运算的时候,由于需要经常和内存交互并且内存的速度和CPU的速度差距很大而引入的一个速度尽可能接近CPU运算速度的缓存.

那么问题来了,虽然每个处理器都有自己的高速缓存,而它们又共享同一主内存.当多个处理器的运算任务都涉及同一块主内存时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回主存时以谁的缓存数据为谁为准呢?为了解决一致性的问题,需要多个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI,MESI,MOSI,Synapse,Firefly及Dragon Protocol等.

除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序排序优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中每个语句计算到先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证.与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化.

Java内存模型

Java中为了屏蔽不同硬件和操作系统的内存访问差异,Java提出了一种内存模型.

主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节.此处的变量与Java编程中所说的变量有所区别,它包括了实例字段,静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题,为了获得较好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施.

Java内存模型规定了所有的变量都存储在主内存(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)中,每条线程还有自己的工作内存(可以前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量.线程间变量值的传递均需要通过主内存来完成.

内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量是如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的,不可再分的:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用域主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到的变量的值的字节码指令时将会执行这个操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中,以便随后的write操作使用
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存复制到工作内存,那就要顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作.注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行.也就是说,read与load之间,store和write之间是可插入其他指令的,如对主内存中的变量a,b进行访问时,一种可能出现的顺序是read a, read b, load b, load a.除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load,store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因地(没有发生过任何assign)操作把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中"诞生",不允许在工作空间中直接使用一个未被初始化的变量,换句话说,就是对一个变量实施use,store操作之前,必须先执行过了assign和load操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先并没有被lock操作锁住,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store, write操作)

对volatile型变量的特殊规则

当一个变量定义为volatile之后,它将具备两种特性:

  • 此变量对所有线程是立即可见的
  • 禁止指令重排序

那么volatile变量是如何做到对其他线程立即可见的呢?

当一个变量被定义为volatile之后,当对此变量执行更新操作时,会同时将此变量写入到主内存中,并会让其他线程的工作内存失效.

另外,一个变量被定义为volatile并不就是意味它是原子性的,即并不意味着它是线程安全的.最典型的例子就是++操作.

那什么时候可以将volatile用在同步中呢?当把volatile标记的变量作为标志位的时候,只有一个线程能够设置它,其他的线程都是需要检测这个标志位是否设置的情况下,就可以.比如,考虑下面的代码:


volatile flag = false;

public void runThread() {
  
  for(int i = 0; i < 100; i++) {
    new Thread(new Runnable(){
      public void run() {
        while(flag != true) {}
        System.out.println("Allright, flag has been set to true");
      }
    }).start();

   new Thread(new Runnable() {
     public void run() {
        Thread.sleep(1000 * 5);
        flag = true;
      }
   }).start();

  }

}

在这种情况下,就可以用volatile进行同步.

那么,volatile进行同步的性能跟使用锁进行同步相比,性能就一定高吗?

不一定.

在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包中的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难量化地认为volatile就会比synchronized快多少.如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行.不过即使如此,大多数场景下volatile的总开销仍然比锁低,我们在volatile与锁之中选择的唯一依据仅仅是volatile的语义能否满足使用场景的需求.

我们看一下Java内存模型中对volatile变量定义的特殊规则.假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read,load,use,assign,store和write操作时需要满足如下规则:

  • 只有在线程T对变量V执行的前一个动作是load的时候,线程T才能对V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作.线程T对变量V的use动作可以认为是和线程T对变量V的load,read动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)

  • 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的最后一个动作是store的时候,线程T才能对变量V执行assign动作.线程T对变量V的assign动作可以认为是和线程T对变量V的store,write动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)

  • 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作.如果A先于B,那么P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同).

对于long或double型变量的特殊规则

Java内存模型要求lock,unlock,read,load,assign,use,store,write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两个32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load,store,read和write这四个原子性,这就是所谓的long和double的非原子性协定.

但是,虚拟机一般都是将其声明为原子性的.

原子性,可见性与有序性

  • 原子性:由Java内存模型来直接保证的原子性变量操作包括read,load,assign,use,store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的.如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用.但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块-synchronized关键字,因此在synchronized块之间的操作也具备原子性.

  • 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改.Java内存模型是通过在变量修改后将新值更新回主内存,在变量读取前将主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新.因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点.

    除了volatile之外,Java中synchronized和final都能实现可见性.其原因很显然,这里就不解释了.

  • 有序性:如果在线程内观察,所有的操作都是有序的,而如果在一个线程内观察另一个线程,那么所有的操作都是无序的.

先行发生原则

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作.准确的说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支,循环等结构

  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作.这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序

  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序

  • 线程启动规则:Thread对象的start()方法先行发生与此线程的每一个动作

  • 线程终止规则:线程中所有的操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法的结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行

  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生

  • 对象终结规则:一个对象的初始化规则先行发生于它的finalize()方法的开始

  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

Java与线程

线程的实现

实现线程主要有三种方式:使用内核线程实现,使用用户线程实现和使用用户线程加轻量级进程混合实现.

Java线程调度

  • 协同式调度:如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上.协同式多线程的最大好处是实现简单,所有没有什么线程安全的同步问题.它的坏处是,线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行切换,那么程序就会一直阻塞在那里.

  • 抢占式调用:如果采用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,现成的切换不由线程本身来实现.在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度.

状态转换

  • 新建(New):创建后尚未启动的线程处于这种状态
  • 运行(Runnable):Runnable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间
  • 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒.以下方法会让线程陷入无限期的等待状态:
    • 没有设置Timeout参数的Object.wait()方法
    • 没有设置Timeout参数的Thread.join()方法
    • LockSupport.park()方法
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待其他线程显式地唤醒,在一定时间之后它们还由系统自动唤醒.以下方法会让线程进入无限期等待状态:
    • Thread.sleep()方法
    • 设置了Timeout参数的Object.wait()方法
    • 设置了Timeout参数的Thread.join()方法
    • LockSupport.parkNanos()方法
    • LockSupport.parkUnit()方法
  • 阻塞(Blocked):线程被阻塞了,"阻塞状态"与"等待状态"的区别是:"阻塞状态"在等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而"等待状态"则是在等待一段时间,或者唤醒动作的发生.在程序等待进入同步区域的时候,线程将进入这种状态.

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行.