当前位置: 首页 > article >正文

《深入理解JAVA虚拟机(第2版)》- 第12章 - 学习笔记

第12章 Java内存模型与线程

12.1 概述

TPS是用来衡量一个服务性能好坏高低的重要指标值。TPS是Transactions Per Second的缩写,用来表示每秒事务处理数,即服务端每秒平均能碰响应的请求数。

12.2 硬件的效率与一致性

处理器与内存的运算效率差了好几个数量级(处理器要比内存快的多的多),为了提高处理器的处理效率,诞生了高速缓存(Cache),它介于处理器与内存之间,运算的时候需要将数据从内存中加载到缓存中,运算结束后还需要将结果同步回内存中。通过高速缓存,处理器就不需要等待内存读取了。

虽然,处理器的处理效率提高了,但是在一个多处理器的系统中同时也带来一个新的问题——缓存一致性(Cache Coherence)。因为在一个多处理器的系统中,每个处理器都有一个与之对应的高速缓存,而它们又共享一个主内存(Main Memory)。

处理器、高速缓存、主内存间的交互关系,如下图:
在这里插入图片描述

12.3 Java内存模型

Java内存模型屏蔽了各种物理硬件和操作系统对内存访问的差异,使得Java程序能在不同的平台上达到一致的内存访问效果。

而C/C++是直接使用物理硬件和操作系统的内存模型,这导致有可能发生相同的程序在不同的平台上并发执行的结果不同(有的并发能顺利执行,有的并发则失败)。

12.3.1 主内存与工作内存
  1. Java内存模型的主要目标是规定程序中各个变量的访问规则,即在虚拟机中将变量存储到内存中和从内存中取出变量这样的底层细节。这里提到的变量包括,实例字段、静态字段和构成数组的元素,而不包括局部变量和方法参数,因为局部变量和方法参数都是线程私有的,不会被共享,也就不会存在竞争问题。

  2. Java内存模型规定所有变量都要存储在主内存(Main Memory,可以类比12.2中提到的硬件的主内存)中。每条线程有自己的工作内存(Working Memory,可以类比12.2中提到高速缓存),线程的工作内存中所保存的线程中所使用的变量实际上是主内存中该变量的一个副本,线程只能操作(读写)变量的副本,而不能直接操作(读写)主内存中的变量。

  3. 不同线程之间不能直接访问对方工作内存中的变量,要想实现线程间的变量值传递只能通过主内存来完成。

  4. 线程、工作内存、主内存三者的交互关系(与12.2中的处理器、高速缓存、主内存类似),如下图:
    在这里插入图片描述

12.3.2 内存间交互操作

这里要说的内存交互操作,实际就是主内存和工作线程之间的交互操作,即一个变量从主内存加载到工作内存中,或从工作内存同步回主内存中。

Java内存模型规定了8种操作来完成内存间的交互,这8中操作都是原子的、不可再分的(但是对于long和double类型的变量而言,在某些平台上read、load、write、store操作允许有了例外情况,具体介绍可跳到本文的【12.3.4】)。

  • lock(锁定)

    作用于主内存中的变量,将该变量标记为被某线程占有(即锁定)。

  • unlock(解锁)

    与lock相同,同样作用于主内存中的变量,将处于锁定状态的变量释放出来,释放后的变量才允许被其他线程再锁定。

  • read(读取)

    作用于主内存的变量,将主内存中变量的值传输到工作内存中。

  • load(载入)

    作用于工作内存的变量,将read进来的变量值存储到工作内存中的变量副本中。

  • use(使用)

    作用于工作内存中的变量,将工作内存中的变量值传递给执行引擎。

  • assign(赋值)

    作用于工作内存中的变量,将执行引擎接收到的变量值赋值给工作内存中的变量。

  • store(存储)

    作用于工作内存中的变量,将工作内存中的变量值传输到主内存中。

  • write(写入)

    作用于主内存中的变量,将store进来的变量值写入到主内存的变量中。

其中read、load这组操作是将一个变量从主内存中复制到工作内存中,而store、write这组操作是将工作内存中的变量同步回主内存中。这两组操作只要保证按顺序执行就行,没必要是连续的。拿read、load这组操作为例,如果对主内存中的a、b两个变量进行访问,指令的顺序有一种可能是:read-a、read-b、load-b、load-a。

12.3.3 对于Volatile型变量的特殊规则

关键字Volatile是Java虚拟机提供的最轻量级的同步机制。

被Volatile修饰的变量具有两个特性:

  • 保证该变量对所有线程的可见性

    这里所说的“可见性”是指当一个线程修改了该变量的值,新值对其他线程来说是可以立即得知的。

    下面提出两个问题:

    1. volatile变量在不同的线程中是一致的吗?

      :在各个线程的工作内存中,volatile变量也可能出现不一致的情况,但由于每次在使用(即use操作)之前都要先刷新下,执行引擎每次用的都是最新的变量值,因此可以认为不存在一致性的问题。

    2. 基于volatitle变量的运算在并发情况下是线程安全的吗?

      答:volatile变量只是能保证对所有线程的可见性,但是线程中的运算却不是原子操作,所以基于volatile变量的运算在并发的情况下是线程不安全的。为了帮助大家理解,请看下图: 在这里插入图片描述

    下面举一个使用volatile变量来控制并发的场景,如下:

    volatile boolean shutdownRequested;
    
    ​​public void shutdown() {
        shutdownRequested = true;
    }
    
    ​​public void doWork() {
        while (!shutdownRequested) {
            // do stuff
        }
    }​​
    

    从上边的代码我们可以看到,当一个线程执行了shutdown()方法,则其他所有正在执行doWork()的线程都将结束while循环。

  • 禁止指令重排序

    指令重排序从硬件架构上来讲,是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。

    指令重排序优化是机器级的优化操作,对应的是汇编代码

    指令重排序会给程序并发执行带来一些干扰,DCL(双重锁定检查)单例模式是一个很典型的例子,这里我推荐一篇Blog(星夜孤帆的《DCL单例模式》),大家可以去看一下,应该会对大家去理解禁止指令重排序有一些帮助(PS:重点看【二、DCL单例】最后部分)。

12.3.4 对于long和double型数据的特殊规定

上文中提到的Java内存模型要求lock、unlock、read、load、use、assign、store、write这8个操作都必须具有原子性。但是对于64位的数据类型long和double,模型特别定义了一条宽松的规定,即允许没有被volatile修饰的long和double类型的数据在进行read、load、store、write这4种操作的时候不必具有原子性(将64位的数据读写操作拆分为成2次32位的操作),这就是long和double的非原子性协定(Nonatomic Treatment of double and long Variables)

虽然有long和double非原子性协定存在,但是大家也不必担心(每次编写代码的时候还要给long和double类型的变量声明volatile),目前各平台下的商业虚拟机都会将64位数据的读写操作作为原子操作来对待

12.3.5 原子性、可见性与有序性

Java内存模型是围绕着在并发情况下如何处理原子性、可见性和有序性这三个特征来建立的。下面整理下有哪些操作实现了这三个特征。

  • 原子性(Atomicity)

    由Java内存模型直接保证的原子性变量操作包括:read、load、use、assign、store、write,基本上我们可以认为基本数据类型的读写操作都是原子性的(除long和double的非原子性协定以外)。

  • 可见性(Visibility)

    对可见性的定义已经在上文的【12.3.3】中提到了,这里就不在赘述了。

    这里谈下可见性是如何实现的?

    Java内存模型是通过将修改后的新值同步回主内存,在读取变量前刷新变量值(从主内存中)这种依赖主内存的方式来实现可见性的。无论普通变量也好还是volatile变量也好,都是如此。普通变量与volatile变量的区别在于,被volatile修饰的变量修改后会立即同步回主内存,每次使用变量的时候也会先立即刷新变量值。

    除了volatile以外,关键字synchronized和final也可以保证可见性。

    • synchronized的可见性是通过“对一个变量进行unlock操作之前,必须先把该变量同步回主内存(执行store、write操作)”这条规则获得的。
    • final的可见性是指变量在构造器中初始化完成,并且在构造器中并没有将“this”的引用传递出去(此时将this的引用传递出去,可能会导致其他线程通过该引用访问到“初始化了一半”的对象),那么在其他线程中就能看到final变量的值。
  • 有序性(Ordering)

    有序性在上文的【12.3.3】中介绍volatile的禁止指令重排序中有过介绍,这里也不再赘述了。

    除了volatile关键字以外,关键字synchronized同样也可以保证线程之间操作的有序性,volatile是通过它本身的语义(禁止指令重排序)来保证的,而synchronized则是通过“一个变量在同一时刻只允许在被一条线程lock”这条规则获得的。

12.3.6 先行发生原则

//

12.4 Java与线程

  • 并发不一定依赖多线程,例如:PHP就是多进程并发,在Java中,并发大多与线程有关。

  • 线程是比进程更轻量级的调度执行单位,线程可以共享进行的资源(内存地址、文件I/O),又可以作为CPU调度的基本单位被CPU独立调度。

  • 在Java中Thread类中,所有关键方法都是声明为Native的

  • 线程的实现(注意:这里没有特指Java线程的实现)有三种方式,如下:

    1. 基于内核线程实现

      首先说下什么是内核线程?

      内核线程(Kernel-Level Thread,KLT)是由操作系统内核(Kernel)支持的线程,内核通过操作调度器来完成线程调度,并将线程任务映射到各个处理器上。支持多线程的内核称为多线程内核。

      程序一般不会直接操作内核线程,而是通过内核线程的高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常所说的线程,每个轻量级进程都需要一个内核线程来支持,它们是1:1的关系,被称为一对一的线程模型,如下图: 在这里插入图片描述
      采用内核线程实现的线程以下两个缺点:

      • 所有线程的操作都需要进行系统调用,代价比较大,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
      • 由于每个轻量级进程都需要对应一个内核线程,因此会消耗一定的内核资源(例如:内核线程的栈空间),因此系统支持轻量级进程的数量是有限的。
    2. 基于用户线程实现

      广义上来讲,所有非内核线程的线程,都属于用户线程(User Thread, UT),轻量级进程也算用户线程。

      狭义上来讲,用户线程是建立在用户空间的线程池中,内核无法感知线程的存在。线程的所有操作(建立、同步、销毁、调度)都只是在用户态中完成(不需要内核的帮助)

      进程与用户线程属于1:N的关系,这种关系称为1对多的线程模型,如下图: 在这里插入图片描述
      采用用户线程实现线程优缺点如下:

      • 由于不需要在用户态和内核态之间来回切换,所以操作可以非常快。
      • 由于不会占用内核资源,所以能支持更大规模的线程数。
      • 缺点是由于没有内核的支持,所以所有线程操作都需要用户程序自己来完成。
    3. 基于用户线程+轻量级进程实现

      在这种混合模式中,用户线程还是在用户空间的线程池中创建,而轻量级进程则作为用户线程和内核线程的桥梁

      用户线程与轻量级进程的数量比试不确定的,即为N:M的关系,这种关系称为多对多的线程模型,如下图: 在这里插入图片描述

  • Java线程的实现

    在JDK1.2之前,Java线程是基于用户线程来实现的。而在JDK1.2中,Java线程是基于操作系统的原生线程模型来实现的。

    对于SUN JDK来说,Windows和Linux平台上均是采用的一对一的线程模型,一条Java线程映射到一条轻量级进程之中。而在Solaris平台上,既有支持一对一的线程模型也同时支持多对多的线程模型。

  • Java线程调度

    所谓线程调度就是系统为线程分配处理器使用权的过程

    主要的调度方式分为两种,如下:

    1. 协同式线程调度(Cooperative Threads-Scheduling)

      线程的执行时间由线程自己来决定,执行完之后,通知系统切换到另外一个线程上。

      优点:实现简单,切换对线程来说是完全可知的,所以不存在线程同步的问题。

      缺点:如果线程出了问题,无法通知系统切换,则会造成程序一直阻塞在那里。

    2. 抢占式线程调度(Preemptive Threads-Scheduling)

      线程的执行时间和线程的切换都是由系统来决定。

      在这种调度方式下,由于线程的执行时间是可控的,就不会造成一个线程导致整个进程阻塞的问题。

      Java采用的就是抢占式线程调度

  • 状态转换

    Java语言定义了6种现成的状态,如下:

    1. 新建

    2. 运行

    3. 无限期等待

      处于此状态的线程不会被分配CPU执行时间,只能等待被其他线程显性的唤醒。

    4. 限期等待

      处于此状态的线程也不会被分配CPU执行时间,它等到一定时间后,会被系统自动唤醒。

    5. 阻塞

      阻塞和等待的区别在于:阻塞是等待获取一个排它锁;而等待就是等待一段时间或者唤醒动作。

    6. 终止

    线程状态转换关系如下图:
    在这里插入图片描述

上一篇:《深入理解JAVA虚拟机(第2版)》- 第11章 - 学习笔记
下一篇:《深入理解JAVA虚拟机(第2版)》- 第13章 - 学习笔记


http://www.kler.cn/a/307460.html

相关文章:

  • 使用Python实现定期从API获取数据并存储到数据库的完整指南
  • react动态路由
  • Rust学习(二):rust基础语法Ⅰ
  • PYNQ 框架 - 中断(INTR)驱动
  • 【深度学习】LSTM、BiLSTM详解
  • 【前端】深入浅出的React.js详解
  • IP 协议分析《实验报告》
  • Hadoop生态圈拓展内容(一)
  • 计算机三级网络技术总结(一)
  • illustrator插件大全 免费插件介绍 Ai设计插件集合 (2)
  • 数据结构-2.顺序表
  • 春招审核流程优化:Spring Boot系统设计
  • vue3 axios ant-design-vue cdn的方式使用
  • zookeeper集群的搭建
  • 【机器学习】分类与回归——掌握两大核心算法的区别与应用
  • 【退役之再次线上部署】Spring Boot + VUE + Nginx + MySQL
  • C++ | Leetcode C++题解之第412题Fizz Buzz
  • PointNet++改进策略 :模块改进 | EdgeConv | DGCNN, 动态图卷积在3d任务上应用
  • vue中v-bind和v-model的区别和应用
  • 车载软件架构 --- SOA设计与应用(中)
  • Spring Boot- 配置中心问题
  • 【Hot100】LeetCode—51. N 皇后
  • F12抓包11:UI自动化 - Recoder(记录器)
  • 【裸机装机系列】3.kali(ubuntu)-更新sources.list并重启
  • 华为OD机试真题-单词接龙-E卷(含题目描述+解题思路+代码解析)
  • 【CMake】使用CMake在Visual Studio 构建多cpp文件项目