分享

一篇文章总结了JVM线程基本原理

 云澳 2019-09-29

最近开发了一个文件同步助手,用的是生产者消费者模式,用线程池初始化3条线程做自定义文件生成操作,用一条线程去处理第一步完成的结果Future,利用几天的时间结合项目,站在JVM的角度回顾JAVA线程的相关知识,接下去再整一篇线程安全的,之前写的多线程还是在一年以前,再次回顾受益匪浅

一、JAVA内存模型与线程

1 CPU工作效率比IO工作效率大

1.1 为什么
计算机的存储设备与处理器的运算速度有几个数量级的差距

1.2 怎么处理CPU与IO之间效率的差距
加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)作为内存与处理器之间的缓冲

内存->缓存(计算)->内存

1.3 引发什么问题?
缓存一致性,也是因为这一点,所以有了线程安全问题

1.4 操作系统是如何解决缓存一致性的问题的?
通过协议进行处理

1.5 什么是指令重排序?
处理器可能会对输入代码进行乱序执行(Out-of-Order Execution)优化,以让处理器内部的运算单元能尽量被充分利用

这里写图片描述

2 JAVA内存模型

2.1 为什么需要使用JAVA自定义的内存模型
屏蔽各种硬件和操作系统内存访问差异

2.2 JAVA工作的主内存与工作内存是什么?
主内存

  • 所有变量都存储在主内存

  • 对应堆中的对象实例数据部分

  • 直接对应于物理硬件的内存

工作内存

  • 线程私有的内存区域,存储主内存中变量的副本

  • 对变量的操作都是在工作内存中,不能对主内存进行操作

  • 不同线程不能相互访问工作内存

  • 对应虚拟机栈中的部分区域

  • 对应物理硬件的寄存器和高速缓存

这里写图片描述

3 内存间交互操作指令

lock(锁定)
作用于主内存的变量,把一个变量标志为一条线程独占的状态

unlock(解锁)
作用于内存的变量,把一个处于锁定状态的变量释放出来

read(读取)
把变量的值从主内存传输到线程的工作内存

load(载入)
把read操作从主内存中得到的变量值放入工作内存的变量副本中

use(使用)
把工作内存中一个变量的值传递给执行引擎

assign(赋值)
从执行引擎接受到的值赋给工作内存的变量

store(存储)
把工作内存中的一个变量的值传送到主内存中

write(写入)
把store操作从工作内存中的到的变量对的值放入主内存变量中

4 指令操作之间的规则

不允许read和load、store和write操作之一单独出现。不允许一个变量从主内存读取了但工作内存不接受,或从工作内存发起会写但主内存不接受

不允许丢弃assign操作,在工作内存中改变之后必须把该变化同步回内存

不允许一个线程无原因的把数据从线程的工作内存同步回主内存

一个新的变量只能再主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量

一个变量在同一时刻只允许一条线程对其lock操作,lock几次,需要unlock几次

执行lock操作,会先清除工作内存中的值,然后重新获取主内存中的该值

如果一个变量事先没有被lock操作,不允许unlock,不允许unlock其他线程lock的变量

unlock的时候,需要把变量同步回主内存

5 对于volatile型变量的特殊规则

5.1 关键字volatile是最轻量级的同步机制

5.2 volatile的两种特性

5.2.1 保证变量对所有线程的可见性(更新某个变量值,新值对于其他线程来说是可以立即得知的),但不意味这样就是线程安全的,只有在以下两种场景,volatile才是线程安全的

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值

  • 变量不需要与其他状态变量共同参与不变约束

5.2.2 禁止指令重排序优化。保证变量赋值操作顺序与程序代码中的执行顺序一致,这一个也是volatile的特性,不仅仅是锁的特性

5.3 volatile程序性能

  • 性能优于锁

  • 读操作与普通变量几乎没有什么差别

  • 写操作因为需要插入许多内存屏障指令保证不发生乱序,所以会慢一点

5.4 volatile内存中的特殊规则

  • load、read的动作必须连续一起出现(每次使用变量的时候必须先从主内存中刷新最新的值)

  • assign、store的动作必须连续一起出现(每次修改完V后都必须立刻同步回主内存,用于保证其他线程可以看到自己对变量的修改)

  • 执行动作是有序的,保证代码的执行顺序与程序的顺序相同

因为这些特殊规则才促就了volatile的特性

5.5 对于long和double型变量的特殊规则
因为虚拟机把long和double变量都实现成原子操作,所以一般不需要把用到的long和double变量专门声明为volatile

5.6 并发中的三个特性

5.6.1 原子性
保证原子性变量操作,如果需要更大范围的原子性操作,那需要用锁或synchronized来保证

5.6.2 可见性
当一个线程修改了共享变量的值,其他线程能够立即得知这个修改
synchronized 和 final 能保证可见性

5.6.3 有序性
如果在本线程内观察,所有的操作都是有序的,如果一个线程中观察另一个线程,所有操作都是无序的
volatile synchronized能保证有序性

5.7 先行发生原则
判断数据是否存在竞争、是否安全的主要依据

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

5.7.2 天然的先行发生关系(无须加锁)

程序次序规则。在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。

volatile变量规则。对一个volatilc变量的写操作先行发生于后面对这个变量的读操作。

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

线程终止规则。线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止运行。

线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

对象终结规则。一个对象的初始化完成先行发生于它的finalize()方法的开始。

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

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

6 Java与线程

6.1 线程的实现

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

主流操作系统都提供了线程的实现,Java线程的关键方法都是声明Native,所以是直接使用了平台相关的方法去创建线程

6.2 实现线程的3种方式

6.2.1 使用内核线程
内核线程(Kernel-Level Thread KLT) 就是直接由操作系统内核支持的线程,这种线程由内核来完成线程的切换。

内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

每个内核线程都可以视为内核的一个分身。

支持多线程的内核叫做多线程内核。

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

这里写图片描述

局限性

  • 基于内核线程实现,线程操作(创建,析构、同步)都需要系统调用,代价相对比较高,需在用户态(User Mode)和内核态(Kernel Mode)中来回切换

  • 需要消耗内核资源(如内核的栈空间)

6.2.2 使用用户线程
广义上面讲,一个线程只要不是内核线程,就可以认为是用户线程

从定义上讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制

狭义上面讲,完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现

用户线程的建立、同步、销户和调度完全在用户态中完成,不需要内核的帮助

如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的

这种进程与用户线程之间1:N的关系称为一对多的线程模型
这里写图片描述

优势:不需要内核支援

劣势:没有系统内核的支援,所有的线程操作都需要用户程序自己处理

  • 线程的创建、切换和调度

  • 阻塞如何处理

  • 如何将线程映射到其他处理器上

因为用户线程实现程序比较复杂,所以使用用户线程的程序越来越少

6.2.3 使用用户线程加轻量级进程混合实现
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。

轻量级进程作为桥梁,可以使用内核提供线程调度功能以及处理器映射功能,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。

这种关系为N:M关系,多对多的线程模型
这里写图片描述

6.3 JAVA线程的实现

  • Windows版和Linux版本使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程中。

  • Solaris通过支持一对一、多对多的线程模式。

6.4 JAVA线程调度
6.4.1 协同式线程调度
线程的执行时间由线程本身控制,线程把自己的工作执行完,主动通知系统切换到另外一个线程
好处

  • 实现简单

  • 切换操作堆线程自己是可知的,所以没有什么线程同步问题

坏处

  • 线程执行时间不可控制,如果一个线程编写有问题,那程序一直会阻塞在那里

6.4.2 抢占式线程调度(JAVA线程实现方式)
线程的切换不由线程本身来做决定

好处

  • 执行时间是可控的,不会有一个线程导致整个进程阻塞

使用优先级来建议系统对某个线程多分配执行时间

  • 提供了10个级别的线程优先级

  • 优先级是不太靠谱的,因为优先级不一定与系统线程优先级相对应,有可能有几个优先级相同的情况

  • 优先级可能会被系统自行改变,如在window系统中

6.5 线程状态切换

新建(new)
创建后尚未启动的线程处于这种状态

运行(Runable)
Runnable包括了操作系统状态中的Running和Ready,也就是处于此线程由可能正在执行,也有可能正在等待CPU为他分配执行时间

无限期等待(Waiting)
处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显示的唤醒(释放锁)

  • Object.wait()

  • Thread.join()

  • LockSupport.park()

限期等待(Timed Waiting)
处于这种状态的线程不会被分配CPU执行时间,无须等待被其他线程显示的唤醒,在一定时间之后会被系统自动唤醒。

阻塞(Blocked)
与等待状态的区别是,在等待获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。

结束(Terminated)
已终止线程的线程状态。
这里写图片描述

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多