JMM 与 线程

线程之间如何通信?

通信是指线程之间以何种机制来交换信息。 线程之间的通信机制有两种: 共享内存消息传递

  • 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。

  • 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

Java 采用的是共享内存模型, 线程之间的通信由 Java 内存模型控制 (后文简称 JMM)

JMM (JSR 133)

Java 内存模型是一种抽象的概念, 旨在屏蔽各个硬件和操作系统的内存访问差异, 以实现 Java 程序在各个平台下都能达到一致的内存访问效果

JDK 1.5 (实现了 JSR 133) 发布后, Java 内存模型才趋于成熟, 完善

JMM 规定

所有的变量都应存储在主内存 (Main memory) 中, 而每个线程都应有自己的工作内存 (Working Memory) , 工作内存应当持有该线程使用变量的主内存的副本. 线程对变量进行操作时, 只能在其工作内存中进行, 不能直接读写主内存中的数据 (每个线程之间的工作内存中的变量不能相互访问

需要注意, 这里的主内存, 工作内存和 Java 内存区域的 堆, 栈, 方法区等并不是同一个层次对内存的划分, 这两者是没有任务关系的

主内存与工作内存的交互操作

JMM 定义了 8 项原子操作, 分别是:

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

在最新的 JSR 133 文档中, 已经将 8 中操作缩减为 4 种 (只是描述方式改变了, JMM 并没有改变)

JMM 对 volatile 定义了一些特殊访问规则

可以参考之前写的 : https://blog.csdn.net/Gp_2512212842/article/details/107285181

对于 volatile 变量 (用 V 表示) , JMM 规定 : (这里总结一下)

  • 在工作内存中, 每次使用 V 前都必须先从主内存中刷新最新的值, 用于保证能看见其他线程对 V 的修改动作
  • 在工作内存中, 每次修改 V 后都必须立刻同步回到主内存中, 用于保证其他线程可以看到本线程对 V 多做的修改
  • V 不会被指令重排序优化, 从而保证代码的执行顺序与程序的顺序相同

三大特征

原子性

除了 JMM 定义的 8 中原子操作可以用于保证原子性, synchronized 同步块之间的操作也是原子性的

可见性

除了 volatile 之外, Java 还有两个关键字能实现可见性, 他们就是 synchronized 和 final. synchronized 就不用说了, 而 final 关键字的可见性是指 : 被 final 修饰的字段一旦在构造器初始化完成 (构造器没有把 “this” 引用传递出去) , 那么其他线程就能看见 final 字段的值

有序性

Java 提供了 volatile 和 synchronized 来保证线程之间的有序性, Java 还存在一个原则 : happen-before 原则

happen-before 原则

happen-before 原则是 JMM 中定义的两项操作之间的偏序关系, 比如下面这段代码 :

i = 1;
// j 的值取决于 i, 但是 JVM 并不保证 i = 1 先于 i = 2 执行
// 所以 j 的值不确定 !
j = i;
i = 2;

下面就是 JMM 的一些 “天然的” happen-before 原则 :

  • 程序次序规则 (Program Order Rule) : 在一个线程内, 按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。

    注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

  • 管程锁定规则 (Monitor Lock Rule) : 一个 unlock 操作先行发生于后面对同一个锁的lock 操作。

    这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后

  • volatile 变量规则 (Volatile Variable Rule) : 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作

    这里的 “后面” 同样是指时间上的先后

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

  • 线程终止规则 (Thread Termination Rule) : 线程中的所有操作都先行发生于对此线程的终止检测

    我们可以通过 Thread#join() 方法是否结束、Thread#isAlive() 的返回值等手段检测线程是否已经终止执行。

  • 线程中断规则 (Thread Inerruption Rule) : 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生

    可以通过 Thread#interrupted() 方法检测到是否有中断发生。

  • 对象终结规则 (Finalizer Rule) : 一个对象的初始化完成 (构造函数执行结束 )先行发生于它的 finalize() 方法的开始。

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

时间先后顺序与 happen-before 原则之间基本没有因果关系, 所以衡量并发安全问题的时候不要受时间顺序的干扰, 一切必须以 happen-before 原则为准

Java 与线程

Java 能创建线程吗? 答案是否定的, 如果你看过 Thread 的源码就会发现, start() 方法调用的是一个叫做 start0() 的 native 方法, 这是一个本地方法, Java 就是调用这个由 C/C++ 实现的方法来创建线程的

线程

进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位 (一个 Java 进程就是一个 JVM 实例 )

实现线程主要有三种方式 : 内核线程 (1 : 1 实现), 用户线程 (1 : N 实现), 混合实现 (N : M实现)

内核线程

直接由 OS 内核 (Kernel) 支持的线程, 线程的切换由内核完成, 内核通过操纵调度器 (Scheduler) 对线程进行调度, 并负责将线程的任务映射到各个处理器上

程序一般不会直接使用内核线程, 而是使用一种内核线程的一种高级接口 : 轻量级进程 (Light Weight Process , LWP), 每个轻量级进程都由一个内核线程实现

各种线程操作,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态 (User Mode) 和内核态 (Kernel Mode) 中来回切换。

其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源 (如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的 (Linux 系统默认允许一个进程最多开 1024 个线程)

JDK 1.3 起, 主流的 JVM 线程模型使用的就是内核线程模型, 以 HotSpot 为例, 每一个 JAVA 的线程都是直接映射到 OS 原生线程实现的

用户线程

广义上来说, 一个线程只要不是内核线程, 都可以认为是用户线程的一种.

狭义上的用户线程指的是完全建立在用户空间的线程库上的, OS 内核不能感知用户线程的存在及如何实现的.

用户线程的建立, 同步销毁, 调度工作完全在用户态中完成, 不需要内核的帮助, 也就不需要 OS 切换到内核态, 所以这种线程是非常快且低消耗的

成也用户态, 败也用户态, 没有内核的支援, 所有的线程操作都需要由用户程序自己处理, 比如线程的调度, 销毁, 阻塞等问题解决起来相当麻烦, 甚至有些是不可能实现的

Golang, Erlang 语言使用的就是用户线程

混合实现

同时存在用户线程, 又存在轻量级进程

用户线程还是存在用户态空间上, 而轻量级进程作为用户线程与内核线程之间的桥梁, 可以使用内核提供的线程调度器及处理器映射, 并且用户线程的调度由轻量级进程完成, 大大降低了整个进程被阻塞的风险

UNIX 系列的操作系统, Solaris, HP-UX 等都提供了该实现

线程调度

调度方式分为两种 : 协同式调度抢占式调度

  • 如果使用前者, 线程的执行时间由线程本身控制

    这种方式的最大好处就是实现简单, 但是其坏处也是很明显, 如果代码存在, 可能永远不会让出 CPU, 那么整个程序就会一直阻塞, 就有可能导致 OS 崩溃

  • 而后者, 由 OS 分配时间片给予执行时间

    每个线程的执行时间由 OS 决定, 线程的执行时间时可控的, 也不会有一个线程导致整个进程甚至 OS 阻塞的问题

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

并发&并行

并发 : 多个线程在操作统一资源. 比方说, 单核 CPU 也能做到多线程并发执行, 通过给各个线程分配实现片来达到线程并发的目的.

并行 : 在多核CPU情况下, 多个线程同时执行, 同时执行的线程数取决于计算机的CPU核心数

线程状态:

Java定义了六种线程状态, 放在 Thread.State 枚举类型中, 它有六个成员

public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
  1. 新建 (NEW) : 线程对象被创建后,就进入了新建状态

    例如,Thread thread = new Thread

  2. 运行 (RUNNABLE) : 包括 OS 线程状态中的 Running 和 Ready.

    也就是说此时处于此状态的线程有可能正在执行, 也有可能正在等待操作系统为它分配执行时间

  3. 限期等待 (TIMED_WAITING) : 在等待指定时间后被 OS 唤醒

    1. 设置了 TimeOut 参数的 Object#wait()
    2. 设置了 TimeOut 参数的 Thread#jion()
    3. LockSupport#parkNanos()
    4. LockSupport#parkUntil()
    5. Thread#sleep()
  4. 无限期等待 (WAITING) : 需要被其他线程显示唤醒

    1. 没有设置 TimeOut 参数的 Object#wait() (由 notify/notifyAll 唤醒)
    2. 没有设置 TimeOut 参数的 Thread#join() (执行完后自动唤醒)
    3. LockSupport#park() (由 unpark 唤醒 )
  5. 阻塞 (BLOCKED) : 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行

    比如Java中的线程没有竞争到同步锁就会进入阻塞状态

  6. 死亡 (TERMINATED) : 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

哪几种轻情况会导致线程进入阻塞状态
  • 时间片用完了

  • 获取同步锁失败

  • Java提供了大量的方法可以阻塞线程, Thread::sleep, Thread::join(), Object::wait()

  • 被其他优先级更高的任务抢占

  • 硬件中断

  • 执行任务碰到IO阻塞,调度器挂起当前任务,切换执行下一个任务

sleep()和wait()方法的区别

  1. sleep() 方法是 Thread 类中的方法, wait() 是 Object 类中的方法

  2. sleep() 不会释放锁, 而 wait() 会释放锁, 可以让其他线程执行同步控制代码块

  3. sleep() 可以在任何地方使用, wait() 必须在同步控块中使用

  4. sleep() 必须捕获异常, 而 wait() 不用捕获异常 (代码中 wait() 还是需要捕获 InterruptException 异常)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值