深入理解JVM虚拟机(十):Java内存模型与多线程

1. 硬件的效率与缓存一致性

由于存储设备和处理器运算速度之间的存在巨大的差异,现在计算机系统在内存与处理器之间加入高速缓存来作为处理器与内存之间的缓冲。将处理器需要的数据复制到缓存中,让处理器可以快速的获取数据进行计算,计算结束后再从缓存同步带内存中去,这样处理器无需等待缓慢的内存读写。虽然它很好的解决了处理器与存储的速度矛盾,但是它也为计算机系统带来更高的复杂度以及一个新问题:缓存一致性。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,而它们各自的缓存数据又不一致,那么同步回主内存时以谁的缓存数据为主呢?

为了解决一致性的问题,那怎么才能解决这个问题呢?

为了解决缓存一致性的问题,我们的操作系统提出了总线锁定机制以及缓存一致性原则。

  • 总线锁定:当CPU要对一个操作数进行操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。
  • 缓存一致性:当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取

《深入理解JVM虚拟机(十):Java内存模型与多线程》

上图说明了处理器,高速缓存,主内存之间的交互关系。Java虚拟机规范中试图定义一种**Java内存模型(JMM)**可以用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存存储效果。

2. Java内存模型

2.1 主内存和工作内存

我们已经了解了Java的内存模型是什么以及它有什么用,现在就来谈一谈主内存与工作内存:

  • 主内存:Java内存模型规定了所有变量都存储在主内存中,注意,这里说的变量与平常Java编程中说的变量有所区别,它包括了实例字段,静态字段和构成数组对象的元素,它不包括局部变量与方法参数,因为后者是线程私有的。也就是说,我们可以这样理解,除过线程私有的局部变量和方法参数之外,所有的变量都存在于主内存中。
  • 工作内存:内存可以和计算机中的物理内存进行类比,而工作内存可与高速缓存类比。工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其它的硬件和编译器优化。

在我们了解了主内存和工作内存的概念之后,那我们来了解一下什么是Cache缓存和Buffer缓冲区?

  • buffer(缓冲区):将数据缓冲下来,解决速度慢和快的交接问题;速度快的需要通过缓冲区将数据一点一点传给速度慢的区域。例如:从内存中将数据往硬盘中写入,并不是直接写入,而是缓冲到一定大小之后刷入硬盘中。
  • cache(缓存):实现数据的重复使用,速度慢的设备需要通过缓存将经常要用到的数据缓存起来,缓存下来的数据可以提供高速的传输速度给速度快的设备。例如:将硬盘中的数据读取出来放在内存的缓存区中,这样以后再次访问同一个资源,速度会快很多。

每个线程都有一个自己的工作内存,该内存空间保存了被该线程使用到的变量的主内存副本,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不直接读写主内存中的变量。看了这段话也许你会问,那假如线程访问一个10MB的对象,难道也会把这10MB的内存复制一份拷贝出来?这当然是不可能的,它有可能会将对象的引用,对象中某个线程访问到的字段拷贝出来,但绝不会将整个对象拷贝一次。

这个时候你可能会有一个问题:那就是JMM和Java虚拟机运行时的数据区到底有什么区别。

这里所讲的主内存,工作内存与Java内存区域中的Java堆,栈,方法区等并不是同一个层次的划分,这两者基本上是没有关系的。如果两者一定要勉强对应起来,那么变量,主内存,工作内存依次对应Java堆中对象实例数据部分,工作内存对应虚拟机栈中的部分区域。从更低层次上来说,主内存直接对应于物理硬件的内存,工作内存优先存储于寄存器以及高速缓存。

2.2 内存间交互操作

现在我们再来详细讨论一下一个变量是怎么从主内存拷贝到工作内存的,而工作内存的变量又是怎么同步回主内存的呢?

《深入理解JVM虚拟机(十):Java内存模型与多线程》

上图说明了工作内存和主内存之间交互的步骤,还有图上缺少的两种原子性操作分别是lock锁定,unlock解锁。由于这两个操作和内存之间的交互并没有关系,所以分开来说。Java定义了8中操作,虚拟机实现时必须保证下面提到的每一种操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把变量的值从主内存传输到线程的工作内存
  • load(载入):作用于主内存变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存变量
  • assign(赋值):作用于工作内存变量
  • store(存储):作用于工作内存变量,将工作内存中一个变量的值传送回主内存
  • write(写入):作用于主内存变量,将工作内存中得到的变量值放入主内存的变量中。

下面我们来看看对于这8种原子操作的使用:

  • read与load:从主存复制变量到当前线程工作内存
  • use与assign:执行代码,改变共享变量值
  • store与write:用工作内存数据刷新主存对应变量的值

2.3 volatile关键字

现在我们对Java内存模型已经有了一定的认识,这个时候我们再来谈谈volatile这个轻量级同步机制。

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

  1. 保证变量对所有线程时可见的,这里的“可见性”指的就是一条线程修改了这个变量的值,新值对于其他线程来说是可以立刻得到的

volatile关键字作用:强制从公共堆中取得变量的值,而不是从线程的私有堆栈中取得变量的值。

《深入理解JVM虚拟机(十):Java内存模型与多线程》

从图中可以看到,volatile保证了变量的新值能立即同步到主内存,以及每次使用之前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍要通过加锁(使用synchronized关键字或者java.util.concurrent中的原子类)来保证原子性:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 变量不需要与其他状态变量共同参与不变的约束
  1. 使用volatile变量的第二个语义就是禁止指令重排序。

指令重排序:指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保证程序能得出正确的执行结果。

2.4 原子性、可见性和有序性

ava内存模型是围绕并发过程中如何处理原子性、可见性、有序性3个特征来建立的。

2.4.1 原子性

在Java中,对基本数据类型的访问读写都是原子性的(long、double除外)。

Int y = 1是一个原子操作,x++不是原子操作(3个操作:先读取x,x加1,加1后的新值写入x)。Java内存模型提供了lock和unlock这两个操作来满足原子操作需求。在字节码层次是使用monitorenter和monitorexit指令隐式使用这两个操作,在Java代码层次就是同步块synchronize。所以synchronize块之间的操作具有原子性。

2.4.2 可见性

可见性是指一个线程修改了共享变量的值,其他线程可以立即得到这个修改。Java中synchronize和final关键字也可以实现可见性。

2.4.3 有序性

可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

2.5 先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值/发送了消息/调用了方法等。

  i = 1//在线程A中执行
    j = i;//在线程B中执行
    i = 2;//在线程C中执行
    //A先于B,但是C与B没有先行关系,B存在读取过期数据风险,不具备多线程安全性

下面是Java内存模型下一些“天然的”先行发生关系,无须任何同步器协助就已经存在,可直接在编码中使用。如果两个操作之间的关系不在此列,并且无法从下列规则推倒出来,它们就没有顺序性的保障,虚拟机可以对它们进行随意地重排序。

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地来说应该是控制流顺序而不是程序代码顺序,因为要考虑分支/循环结构。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一锁的lock操作。这里必须强调的是同一锁,而“后面”是指时间上的先后顺序。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”是指时间上的先后顺序。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束/Thread.isAlive()的返回值等手段检测到县城已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

时间上的先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题时不要受时间顺序的干扰,一切必须以先行发生原则为准。

3. Java与线程

3.1 线程的实现

线程是比进程更加轻量级的调度执行单位,线程的引入,可以把一个进程的资源调度和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O)又可以独立调度(线程是CPU调度的最小单位)

实现线程主要三种方式:

  1. 使用内核线程实现。
  2. 使用用户线程实现。
  3. 使用用户线程加轻量级进程实现。

3.1.1 使用内核线程实现

内核线程(Kernel Thread, KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看作是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫多线程内核(Multi-Thread Kernel)。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。

轻量级进程的局限性:由于是基于内核线程实现的,所以各种进程操作,如创建/析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换;每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程需要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程是有限的。

《深入理解JVM虚拟机(十):Java内存模型与多线程》

3.1.2 使用用户级线程实现

狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立/同步/销毁和调度完全在用户态完成,不需要内核的帮助。如果程序实现得当,

优点:这种线程不需要切换到内核态,因此操作快速且低消耗,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型。

缺陷:不需要系统内核支援,缺陷也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换、调度都是需要考虑的问题。

《深入理解JVM虚拟机(十):Java内存模型与多线程》

3.1.3 使用用户线程加轻量级进程混合实现

既存在用户线程,也存在轻量级进程。

  1. 用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发
  2. 操作系统提供的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能以及处理器映射,并且用户线程的系统调用都要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。

《深入理解JVM虚拟机(十):Java内存模型与多线程》

3.1.4 Java线程的实现

对于Sun JDK来说,它使用的都是一对一的线程模型实现的。一条Java线程就映射到一条轻量级线程之中。

3.2 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程。主要调度方式两种:

  1. 使用协同调度的多线程系统,线程执行时间由线程本身控制,线程把自己的工作执行完后,要主动通知系统切换到另外一个线程上去。优点:实现简单。缺点:执行时间不可控制。
  2. 使用抢占调用的多线程系统,每个线程由系统分配执行时间,线程的切换不由线程本身决定。Java使用的就是这种线程调度方式。

Java提供10个级别的线程优先级设置,不过,线程优先级不靠谱,因为Java线程是被映射到系统的原生线程上实现的,所以线程调度最终还是由操作系统决定。

3.2.1 状态转换

Java语言定义了5种进程状态,在任意一个时间点,一个线程只能有且只有其中一种状态:

1. 新建(New)

新建(New):创建尚未启动的线程处于这种状态。

2. 运行(Runable)

运行(Runable):包括操作系统线程状态中的Running和Ready,处于此状态的线程可能正在运行,也可能等待着CPU为它分配执行时间。

3. 无限期等待(Waiting)

无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待其他线程显示地唤醒。以下方法会让线程陷入无限期的等待状态:

没有设置Timeout参数的Object.wait()方法。

没有设置Timeout参数的Thread.join()方法。

LockSupport.park()方法。

4. 限期等待(Timed Waiting)

限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显示地唤醒,在一定时间后由系统自动唤醒。以下方法会让线程陷入限期的等待状态:

Thread.sleep()方法。

设置了Timeout参数的Object.wait()方法。

设置了Timeout参数的Thread.join()方法。

LockSupport.parkNanos()方法。

LockSupport.parkUntil()方法。

5. 阻塞(Blocked)

阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待获取一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序进入等待进入同步块区域的时候,线程将进入这种状态。

6. 结束(Terminated)

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

《深入理解JVM虚拟机(十):Java内存模型与多线程》

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/qq_21125183/article/details/85173245
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞