【JAVA学习笔记】java并发-内存模型及线程 探究

前言

文章仅是笔者个人的学习笔记,存在一些只有笔者个人能看到的用词或者描述,如果有不明确的地方,欢迎留言,理性讨论。

一、概述

  • 多任务处理在现代计算机操作系统中几乎已是一项必备的功能了
  • 对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越 高;反之,线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。

二、硬件的效率与一致性

1.物理机对并发的处理方案
  • 绝大多数运算任务不能只靠处理器“计算”来完成。
  • 处理器至少要与内存交互,无法仅靠寄存器来完成所有运算任务。
    • 存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多 层读写速度尽可能接近处理器运算速度的高速缓存
    • 将运算 需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中
    • (传说中的Buffer
  • 基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来 更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。

2.“内存模型”的定义
  • 当多个处理器的运算任务都涉及 同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内 存时该以谁的缓存数据为准呢?
  • 为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协 议,在读写时要根据协议来进行操作
  • 它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
  • 除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化。

三、 Java内存模型

1.主内存与工作内存
  • 所有的变量都存储在主内存中(变量不包含局部变量,方法参数,这些是线程私有的)
  • 每条线程 还有自己的工作内存
  • 工作内存里面保存了该线程使用的变量的副本,线程所有对变量的操作,需要在工作内存里面进行,不能直接读写主内存数据

2.所谓内存模型与内存区域(个人理解)
  • 书里原话:主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一 个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,…,主内存主要对应于Java堆中的对象实例数据部分,而工作内存 则对应于虚拟机栈中的部分区域。
  • 个人理解:假设内存是一块地
    • 内存区域是JVM对运行时内存的实际分区划分,把内存这块地分成了不同的部分,是描述每部分会放置哪些不同的东西的。
    • 内存模型是定义了线程和主内存之间的抽象关系,就像前面所说的,内存模型是一种内存间通信的操作协议,是描述如何并发的利用这片土地的。

3.内存间交互操作
  • 对于变量在工作内存和主内存之间的传递交互,Java定义了8种操作(现在已经改成4种了),同时制定了相关的操作准则以确保这8种操作能被正确执行。
  • 除此之外,java 中还有 volatile 的相关特殊规定。
  • 理解了上述的4种操作,若干规则,特殊规定,就能知道哪些内存访问操作在并发下是严谨的。
  • 书中原话:
    • 这种定义相当严谨,但也是 极为烦琐,实践起来更是无比麻烦。可能部分读者阅读到这里已经对多线程开发产生恐惧感了,
    • 后来 Java设计团队大概也意识到了这个问题,将Java内存模型的操作简化为read、write、lock和unlock四种
    • 但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变

4.volatile型变量的特殊规则
  • 原文:volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地 理解,以至于许多程序员都习惯去避免使用它,遇到需要处理多线程数据竞争问题的时候一律使用 synchronized来进行同步。
    • (太真实了吧,直接指出了大部分人会存在的问题
  • volatile的特性
    • 第一项是保证此变量对所有线程的可见性
      • 这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知 的。
      • volatile变量在各个线程的工作内存中是不存在一致性问题的,但是因为本身java里面的运算操作符并非原子操作,所以单单使用volatile变量不能实现线程安全
    • 简单实验发现:
      • 以笔者现有的硬件条件,模拟多线程同时对一个volatile变量做++操作,十次里面有三次是最后的值有错误的。
      • 如果不加volatile修饰,那么十次里面有4次最后的值有错误。
      • 可以说简单的加上volatile,在确保线程安全上并没有什么用。
    • 那么volatile的可见性到底可以在哪些场景发挥作用呢?
      • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。(感觉是一句废话
      • 变量不需要与其他的状态变量共同参与不变约束。(意思似乎是,如果判断逻辑中有别的变量(不管是不是 volatile)参与 ,那就需要加锁了,因为存在需要顺序同步的语义。
    • 第二项是禁止指令重排序优化
      • 普通的变量仅会保证,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
      • 指重排序时不能把后面的指令重排序到内存屏障之前的位置
    • 那么volatile的禁止指令重排序,可以在哪些场景发挥作用呢?
      • 毫无疑问是 单例模式 的双重锁检测
  • volatile发挥作用的方式是通过“内存屏障”:
    • 如果用汇编代码来看就会是一句“lock addl$0x0,(%esp)”操作,这个指令后面的add其实是一个空操作,关键就是这个“lock”前缀,
    • 它的直接作用是将本处理器的缓存写入内存,同时这个写入动作会引起别的处理器或者内核,无效化该变量的缓存。(这个是确保可见性
    • 阻止屏障两侧(其实就是该指令前后的其他指令)的指令重排序。(这个是确保有序性
  • 选用volatile的意义:
    • 由于虚拟机对锁实行的许多消除和 优化,使得我们很难确切地说volatile就会比synchronized快上多少。不过,大多数场景下volatile的总开销仍然要比锁来得更低
  • Java内存模型中对volatile变量定义的特殊规则
    • (说实话,最后一大段把我绕的有点晕,,没看懂,大概是说volatile概念是基于java内存模型中的特殊规则设定而来的,禁止指令重排序的目的,就是实现多线程环境下,代码执行顺序于与程序顺序相同

5.long和double型变量的特殊规则
  • java内存模型,特别定义了一条宽松的规定:允许虚拟机将没有 被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行
  • 但是实际应用中,并不会这么做 (靠,那还单独列一章出来讲这个干啥。。

6.原子性、可见性与有序性
  • Java内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的
  • 原子性
    • 基本数据类型的访问,读写都是具备原子性的
    • 在更大范围要确保原子性,Java内存模型还提供了lock和 unlock操作来满足这种需求
      • 对应的字节码指令是 令monitorenter和monitorexit
      • 反映到java代码中是同步块——synchronized关键字
  • 可见性
    • 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
    • Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式,来实现可见性的。
    • 普通变量与volatile变量的区别是,volatile的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。
    • Java还有两个关键字能实现可见性,它们是synchronized和final。
      • synchronized的可见 性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的。
      • final的可见性,
        • 原文是:被final修饰的字段在构造器中一旦被初始化完 成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通 过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。
        • 个人理解:final的变量初始化之后就不能更改引用了, 那么只要在初始化过程中没有将对应的引用传递到多个线程,那么在完成初始化之后,final就是对所有线程可见的。实际上这还能算可见性吗,这其实体现的是final的不变性吧。
  • 有序性
    • Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作都是无序的。(精辟啊!
      • 前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
    • Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性
      • volatile关键字本 身就包含了禁止指令重排序的语义,
      • 而synchronized则是由“一个变量在同一个时刻只允许一条线程对 其进行lock操作”这条规则获得的
  • 总结:
    • 可以发现,volatile可以确保可见性和有序性,但是没办法确保原子性。
    • 同样可以发现,synchronized 可以确保原子性,可见性和有序性三种特性,因此它更加全能,使用也更加广泛。
      • synchronized的“万能”也间接造就了它被程序员滥用的局面,越“万能”的并发控制,通常会伴随 着越大的性能影响

7.先行发生原则
  • 概念解释:
    • 先行发生”(Happens-Before)这个原则非常重要,它是判断数据是否存在竞争,线程是 否安全的非常有用的手段。
    • 先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到。
    • (这看起来是一个理所当然的废话,但其实仔细思索,这实际上是构建java代码的逻辑基础,就像1+1=2一样,其实对于一个体系结构来说,没有“理所当然”的先天性准则。
  • 一系列java体系中“天然的” 先行发生关系:
    • image.png
    • Java语言无须任何同步手段保障就能成立的先行发生规则有且只有上面这些
  • 看一个具体场景
    • image.png
    • 很明显,这里AB线程是不满足先行发生规则的,会有多线程并发问题
    • 如何解决这个问题呢?
      • 要么用synchronized加锁
      • 要么把value定义为volatile变量,由于setter方 法对value的修改不依赖value的原值,满足volatile关键字使用场景
  • “时间顺序”与“先行发生”
    • 上面的例子告诉我们,“时间顺序”无法确保“先行发生”
    • 同时逆向思考可以知道,“先行发生”关系下,也无法确保“时间顺序”,例如:指令重排序的场景
    • 衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

四、 Java与线程

1.线程的实现
  • 特点:Thread类与大部分的Java类库API有着显著差别,它的所有关键方法都被声明为Native。
    • 先把Java的技术背景放下,以一个通用的应用程序的角度来看看线程是如何实现 的
  • 实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现), 使用用户线程加轻量级进程混合实现(N:M实现)。
  • 内核线程实现:
    • image.png
    • 缺点:
      • 首先,经常需要进行系统调用。而系统调 用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
      • 其次,轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
  • 用户线程的实现:
    • image.png
    • 优点:这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持 规模更大的线程数量
    • 缺点:
      • 优势在于不需要系统内核支援,劣势也在于没有系统内核的支援
      • 使用起来较为复杂,因为缺少内核支援,甚至很多效果是不可能实现的
    • 一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使 用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支 持了用户线程,譬如Golang、Erlang等,
  • 混合实现:
    • image.png
  • java线程的实现:
    • “主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作 系统原生线程模型来实现,即采用1:1的线程模型。
    • 线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来 说,这些差异都是完全透明的。(系统分层的优势所在,更好的复用性,修改实现后更小的影响面

2.java线程的调度
  • 这一部分原文只讲了java线程的调度,很明显,后续笔者是需要延申到安卓平台上的线程的
  • 调度方式:
    • Java使用的线程调度方式就是抢占式 调度,每个线程将由系统来分配执行时间,线程的切换不由线 程本身来决定。
  • 线程优先级:
    • Java 语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)

3.java线程的状态转换
  • java线程共有6种状态:
    • 新建:
      • 创建后尚未启动的线程处于这种状态。
    • 运行:
      • 处于此状态的线程有可 能正在执行,也有可能正在等待着操作系统为它分配执行时间。
    • 无限期等待:
      • 处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线 程显式唤醒。
    • 限期等待:
      • 处于这种状态的线程也不会被分配处理器执行时间,不过无须等待 被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。
    • 阻塞:
      • 线程被阻塞了,等待一个排它锁的释放
    • 结束:
      • 已被终止的线程状态
  • 来张图概括一下:
    • image.png

4.java与协程
  • Java目前的并发编程机制就与现有趋势产生了一些矛盾
    • 1:1的内核线程模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统 能容纳的线程数量也很有限。
    • (很常见的场景是有更多的并发请求或者任务,每个任务的处理时间实际非常短。这种情况下,在内核线程模型中,线程频繁切换的开销,反而比线程使用本身的开销更加重了
  • 内核线程的切换开销是来自于保护和恢复现场的成本,如果改为采用用户线程,这部分开销也不能省略,但是使用者拥有了更多自定义的空间,用各种方式减少这部分开销。
  • 这种用户线程就叫:协程
    • 有栈协程:会 完整地做调用栈的保护、恢复工作
    • 无栈协程:不做栈的保护、恢复工作,本质上是一种有限状态机,状态保存在闭包里,更加轻量(kotlin的协程应该就是这种吧

五、总结

  • 了解的java的内存模型的结构和操作
    • 主内存与工作内存,数据在主内存和工作内存之间传输
    • volatile的作用及原理(讲道理这个应该多看几遍,算是讲的很透彻了
  • 了解了java多线程的三大特性,及它们在内存模型中的体现
    • 三大特性分别是:原子性、可见性、有序性
    • (一切并发都是在处理以上三者,可以说它们是学习并发的关键基础
  • 了解了先行发生原则的定义和使用
    • 多线程并发的逻辑基础,后操作对前操作结果的感知关系
    • (注意区分“先行并发”与“时间顺序”的区别!
  • 了解了java线程的基本特点
    • 内核线程、用户线程、混合实现
    • 线程调度及转换
    • 协程的定义及为什么需要协程

六、引用

  • 《深入理解java虚拟机-第三版》-- 第十二章:java内存模型与线程
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值