一晚上搞懂 | Java 内存模型与线程

前言

本文已经收录到个人 Github 我的博客,欢迎大佬们光临寒舍:git

个人 GIthub 博客程序员

学习导图

学习导图

一.为何要学习内存模型与线程?

并发处理的普遍应用是 Amdah1 定律代替摩尔定律成为计算机性能发展源动力的根本缘由,也是人类压制计算机运算能力的最有力武器github

线程通讯是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递数据库

线程同步是指程序用于控制不一样线程之间操做发生相对顺序的机制。编程

Java 的并发采用的是共享内存模型,Java 线程之间的通讯老是隐式进行,整个通讯过程对程序员彻底透明。若是你想设计表现良好的并发程序,理解 Java 内存模型是很是重要的Java 内存模型规定了如何和什么时候能够看到由其余线程修改事后的共享变量的值,以及在必须时如何同步的访问共享变量。数组

二.核心知识点概括

2.1 概述

Q1:多任务处理的必要性缓存

  • 充分利用计算机处理器的能力,避免处理器在磁盘 I/O、网络通讯或数据库访问时老是处于等待其余资源的状态
  • 便于一个服务端同时对多个客户端提供服务

经过指标 TPSTransactions Per Second)可衡量一个服务性能的高低好坏,它表示每秒服务端平均能响应的请求总数,进而体现出程序的并发能力安全

Q2:硬件的效率与一致性网络

为了更好的理解 Java 内存模型,先理解物理计算机中的并发问题,二者有很高的可比性多线程

为了平衡内存交互速度与处理器的运算速度之间几个数量级的差距,引入一层高速缓存Cache)来做为内存与处理器之间的缓冲:

  • 将运算须要使用到的数据复制到缓存中,让运算能快速进行
  • 当运算结束后再从缓存同步回内存之中,而无须让处理器等待缓慢的内存读写
  • 出现问题:引入高速缓存虽解决了处理器与内存速度之间的矛盾,可是其引入了一个新的问题——缓存一致性
  • 解决办法:须要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操做

缓存与协议的结构图

内存模型能够理解为:在特定的操做协议下,对特定的内存或高速缓存进行读写访问的过程抽象

2.2 Java 内存模型

以前笔者在 进阶之路 | 奇妙的Thread之旅中简要介绍过 Java 内存模型,相信看过的读者都有一些印象

2.2.1 设计目的

屏蔽掉各类硬件和操做系统的内存访问差别,实现 Java 程序在各类平台下都能达到一致的内存访问效果

2.2.2 设计方法

经过定义程序中各个变量访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节

注意:这里的变量与 Java 中说的变量不一样,而指的是实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数(其存放于局部变量表中,而局部变量表在 JVM 栈中),由于后者是线程私有的,不会被共享,天然就不会存在竞争问题。

2.2.3 模型结构

内存模型

  • 主内存:全部变量的存储位置。直接对应于物理硬件的内存

注意:这里的主内存、工做内存与 一文洞悉JVM内存管理机制 说的 Java 内存区域中的 Java 堆、栈、方法区等并非同一个层次的内存划分

  • 工做内存:每条线程还有本身的工做内存,用于保存被该线程使用到的变量的主内存副本拷贝。为了获取更好的运行速度,虚拟机可能会让工做内存优先存储于寄存器和高速缓存中

注意:

  • 线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量
  • 不一样的线程之间也没法直接访问对方工做内存中的变量,线程间变量值的传递必须经过主内存来完成
  • 交互协议:用于规定一个变量如何从主内存拷贝到工做内存、如何从工做内存同步回主内存之类的实现细节。

    共有 8 种操做:

交互协议

  1. 做用于主内存变量:
  • 锁定 lock :把变量标识为一条线程独占的状态
  • 解锁 unlock :把处于锁定状态的变量释放出来
  • 写入 write :把 store 操做从工做内存中获得的变量的值放入主内存的变量中
  • 读取 read :把变量的值从主内存传输到线程的工做内存中,以便随后的 load 动做使用

​ 2.用于工做内存变量:

  • 赋值 assign :把从执行引擎接收到的值赋给工做内存的变量
  • 使用 use :把工做内存中一个变量的值传递给执行引擎
  • 存储 store :把工做内存中变量的值传送到主内存中,以便随后的 write 操做使用
  • 写入 write :把 store 操做从工做内存中获得的变量的值放入主内存的变量中

结论:注意是顺序非连续

  • 若是要把变量从主内存复制到工做内存,那就要顺序地执行 readload
  • 若是要把变量从工做内存同步回主内存,就要顺序地执行 storewrite

2.2.4 确保并发操做安全的原则

A1:执行八种基本操做的时候,必须知足以下规则:

  • 不容许 readloadstorewrite 操做之一单独出现,即不容许一个变量从主内存读取了但工做内存不接受,或者从工做内存发起回写了但主内存不接受的状况出现

能够简单理解为不能拒绝别人给的东西

  • 不容许一个线程丢弃它的最近的 assign 操做,即变量在工做内存中改变了以后必须把该变化同步回主内存
  • 不容许一个线程无缘由地,即没有发生过任何 assign 操做,就把数据从线程的工做内存同步回主内存中
  • 一个新的变量只能在主内存中『诞生』 ,不容许在工做内存中直接使用一个未被初始化(loadassign)的变量,即对一个变量实施 usestore 操做以前必须先执行过了 assignload 操做
  • 若是对一个变量执行 lock 操做,那将会清空工做内存中此变量的值,在执行引擎使用这个变量前,须要从新执行 loadassign 操做初始化变量的值

下文的 volatile底层就是用到了 lock 来实现可见性

  • 若是一个变量事先没有被 lock 操做锁定,那就不容许对它执行 unlock 操做,也不容许去 unlock 一个被其余线程锁定住的变量
  • 对一个变量执行 unlock 操做以前,必须先把此变量同步回主内存中

可见这么多规则很是繁琐,实践也麻烦,下面再介绍一个等效判断原则 -- 『先行发生原则』

A2:先行发生原则:

Java 内存模型中定义的两项操做之间的偏序关系。

下面例举一些 『自然的』先行发生关系,无须任何同步器协助就已经存在,能够在编码中直接使用

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操做先行发生于书写在后面的操做
  • 管程锁定规则:一个 unlock 操做先行发生于后面对同一个锁的 lock 操做
  • volatile 变量规则:对一个 volatile 变量的操做先行发生于后面对这个变量的操做
  • 线程启动规则Threadstart() 先行发生于此线程的每个动做
  • 线程终止规则:线程中的全部操做都先行发生于对此线程的终止检测。可经过 Thread.join() 结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行
  • 线程中断规则:对线程 interrupt() 的调用先行发生于被中断线程的代码检测到中断事件的发生。可经过 Thread.isInterrupted() 检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 的开始
  • 传递性:若是操做 A 先行发生于操做 B,操做 B 先行发生于操做 C,那么操做 A 必定先行发生于操做 C

2.2.5 保证原子性、可见性和有序性的措施

  • 原子性:一个操做要么都执行要么都不执行
  • 可直接保证的原子性变量操做有:readloadassignusestorewrite,所以可认为基本数据类型的访问读写具有原子性的特征

  • 若须要保证更大范围的原子性,可经过更高层次的字节码指令 monitorentermonitorexit 来隐式地使用 lockunlock 这两个操做,反映到 Java 代码中就是同步代码块 synchronized 关键字

  • 可见性:当一个线程修改了共享变量的值,其余线程可以当即得知这个修改
  • 经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存做为传递媒介的方式来实现
  • 提供三个关键字保证可见性:
    1. volatile 能保证新值能当即同步到主内存,且每次使用前当即从主内存刷新
    2. synchronized 对一个变量执行 unlock 操做以前能够先把此变量同步回主内存中
    3. final 修饰的字段在构造器中一旦初始化完成且构造器没有把 this 的引用传递出去,就能够在其余线程中就能看见 final 字段的值
  • 有序性:程序代码按照指令顺序执行
  • 若是在本线程内观察,全部的操做都是有序的,指 “线程内表现为串行的语义”;
  • 若是在一个线程中观察另外一个线程,全部的操做都是无序的,指 “指令重排序” 现象和 “工做内存与主内存同步延迟” 现象
  • 提供两个关键字保证有序性:
    1. volatile 自己就包含了禁止指令重排序的语义
    2. synchronized 保证一个变量在同一个时刻只容许一条线程对其进行 lock 操做,使得持有同一个锁的两个同步块只能串行地进入

想详细了解 volatile 的读者,能够看下笔者以前写的文章:进阶之路 | 奇妙的 Thread 之旅

2.3 Java 与线程

2.3.1 线程实现的三种方式

1.使用内核线程

英文: Kernel-Level Thread ,简称:KLT

  • 定义:由操做系统内核支持的线程
  • 原理:由内核来完成线程切换,内核经过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每一个内核线程能够视为内核的一个分身, 这样操做系统就有能力同时处理多件事情
  • 多线程内核:支持多线程的内核
  • 轻量级进程Light Weight Process,简称:LWP):内核线程的一种高级接口
  • 优势:每一个轻量级进程都由一个内核线程支持,所以每一个都成为一个独立的调度单元,即便有一个轻量级进程在系统调用中阻塞,也不会影响整个进程继续工做
  • 缺点:
    1. 因为基于内核线程实现,因此各类线程操做(建立、析构及同步)都须要进行系统调用,代价相对较高,须要在用户态内核态中来回切换
    2. 一个系统支持轻量级进程的数量是有限的
  • 一对一线程模型:轻量级进程与内核线程之间 1:1 的关系,如图所示

内核线程模型

2.使用用户线程

英文: User Thread ,简称:UT

  • 定义:
  • 广义上认为一个线程不是内核线程就是用户线程
  • 狭义上认为用户线程指的是彻底创建在用户空间的线程库上,而系统内核不能感知线程存在的实现
  • 优势:因为用户线程的创建、同步、销毁和调度彻底在用户态中完成,不须要内核的帮助,甚至能够不须要切换到内核态,因此操做很是快速且低消耗的,且能够支持规模更大的线程数量
  • 缺点:因为没有系统内核的支援,全部的线程操做都须要用户程序本身处理,线程的建立、切换和调度都是须要考虑的问题,实现较复杂
  • 一对多的线程模型进程:进程与用户线程之间 1:N 的关系,如图所示

用户线程模型

3.混合
  • 定义:既存在用户线程,也存在轻量级进程

  • 优势:

  • 用户线程彻底创建在用户空间中,所以用户线程的建立、切换、析构等操做依然廉价,而且能够支持大规模的用户线程并发
  • 操做系统提供支持的轻量级进程做为用户线程和内核线程之间的桥梁,可使用内核提供的线程调度功能及处理器映射,且用户线程的系统调用要经过轻量级线程来完成,大大下降了整个进程被彻底阻塞的风险
  • 多对多的线程模型:用户线程与轻量级进程的数量比不定,即用户线程与轻量级进程之间 N:M 的关系,如图所示

混合模型

Q: Java 线程的实现是选择哪种呢?

A:答案是不肯定的。操做系统支持怎样的线程模型,在很大程度上决定了 JVM 的线程是怎样映射的。线程模型只对线程的并发规模和操做成本产生影响,而对 Java 程序的编码和运行过程来讲,这些差别都是透明的。

2.3.2 线程调度的两种方式

线程调度:指系统为线程分配处理器使用权的过程

1.协同式线程调度
  • 线程自己来控制线程的执行时间。线程把本身的工做执行完后,要主动通知系统切换到另一个线程上
  • 好处:
  • 实现简单
  • 切换操做本身可知,不存在线程同步的问题
  • 坏处:线程执行时间不可控,假如一个线程编写有问题一直不告知系统进行线程切换,那么程序就会一直被阻塞
2.抢占式线程调度
  • 系统来分配每一个线程的执行时间
  • 好处:线程执行时间是系统可控的,不存在一个线程致使整个进程阻塞的问题
  • 能够经过设置线程优先级,优先级越高的线程越容易被系统选择执行

可是线程优先级并非太靠谱,一方面由于 Java 的线程是经过映射到系统的原生线程上来实现的,因此线程调度最终仍是取决于操做系统,在一些平台上不一样的优先级实际会变得相同;另外一方面优先级可能会被系统自行改变。

2.3.3 线程的六种状态

线程状态

在任意一个时间点,一个线程只能有且只有其中的一种状态:

  • 新建 New :线程建立后还没有启动

  • 运行 Runable:包括正在执行(Running)和等待着 CPU 为它分配执行时间(Ready)两种

  • 无限期等待 Waiting:该线程不会被分配 CPU 执行时间,要等待被其余线程显式地唤醒。

    如下方法会让线程陷入无限期等待状态:

  • 限期等待 Timed Waiting:该线程不会被分配 CPU 执行时间,但在必定时间后会被系统自动唤醒。如下方法会让线程进入限期等待状态:
  • Thread.sleep()
  • 设置了 Timeout 参数的 Object.wait()
  • 设置了 Timeout 参数的 Thread.join()
  • LockSupport.parkNanos()
  • LockSupport.parkUntil()
  • 阻塞 Blocked:线程被阻塞

注意区别阻塞和等待:

  • 阻塞状态:在等待获取到一个排他锁,在另一个线程放弃这个锁的时候发生;
  • 等待状态:在等待一段时间或者唤醒动做的发生,在程序等待进入同步区域的时候发生。
  • 结束 Terminated:线程已经结束执行

三.碎碎念

恭喜你!已经看完了前面的文章,相信你对 Java 内存模型与线程已经有必定深度的了解!你能够稍微放松奖励本身一下,能够睡一个美美的觉,明天起来继续冲冲冲!!!

PS:本来《深刻理解Java虚拟机》第3版中还说起了协程,可是我还没学过协程的基本用法,这时候给你们讲解感受有点打肿脸充胖子的感受 hhh,明天《第一行代码-第三版》也要到了,待我看完《第一行代码》再补充协程的内容吧 hhhh


若是文章对您有一点帮助的话,但愿您能点一下赞,您的点赞,是我前进的动力

本文参考连接:

相关文章
相关标签/搜索