单核CPU是否有线程可见性问题?

本文仅是本人对问题的思考记录,并没有实操验证,有误请大家评论指出。

今天见到了一个经典的问题,单核CPU是否有线程可见性问题,学完操作系统应该可以直接回答,不会有线程安全问题。但如果结合JVM虚拟机来进行分析,就又麻烦了一点。结合线程切换和JVM内存区域,本文重新梳理了一下对这个问题的思考。

文中可能还有其他问题,例如JDK8之后就没有方法区的概念了,

以及最后的data=1只是为了表示对数据进行更改,没有考虑变量存储位置和引用类型等问题。

1 从JVM内存区域说起

我们都知道,JVM的内存区域有两部分,一部分是线程共享的:堆和方法区(JDK8之后变为元空间的概念,且位于本地内存中),另一部分是线程私有的:虚拟机栈、本地方法栈、程序计数器。

请添加图片描述

既然是线程私有的,那么如果有多线程,难道有多个虚拟机栈、本地方法栈、程序计数器吗?答案是否定的。一个JVM虚拟机的内存区域就是这样的。那么问题来了,一个线程占有了这三个私有区,那其他线程到哪去了?我们需要回顾操作系统中的线程和进程间的关系:

线程和进程的关系

王道书里是这么解释的:进程是一个独立的运行单位,也是操作系统进行资源分配的基本单位。而线程不拥有资源,但却是CPU调度的最小单位。

进程。它包含三个部分:PCB(Process Control Block)进程控制块、程序段、数据段。其中PCB为核心,该结构常驻内存,与进程同生共死。切换进程的时候,处理机状态信息必须保存到响应的PCB中,以便在该进程重新执行时,能从断点继续执行。

线程。除了有TCB(Thread Control Block)线程控制块之外,还有其专有的存储区。切换线程的时候,状态信息需要保存到TCB中。

这里插入思考一个问题,为什么线程切换比进程切换更轻量级?

回答这个问题,我们首先要知道“保存状态信息”到底保存了什么?由于一个进程用到的数据都会加载到内存中(可能只加载了一部分,通过虚拟内存),所以PCB中保存的都是一些地址引用,而不是数据实体。大概包括:

  • 进程描述信息:PID、UID
  • 进程控制和管理信息:进程状态、优先级、代码入口地址、CPU占用时间、程序的外存地址等
  • 资源分配清单:I/O设备信息、代码段指针、数据段指针、堆栈指针、文件描述符等
  • CPU相关信息:各种寄存器的值、状态字

即便是只需要保存地址引用,但仍然需要保存这么多的信息。而线程切换时,TCB需要保存的,相比之下就轻量级很多:

  • PC程序计数器(寄存器)、栈等信息。

这两个一对比,就发现了切换的代价,线程切换明显更轻。

线程与进程在Android中的表现
一个Java程序,通过main()开启了它的进程生涯,承载这个程序的,就是JVM虚拟机。可以理解为,JVM虚拟机就是这个java程序的进程的概念。

Android的进程是什么呢?在Zygote孵化器 fork() 进程的时候,我们发现它还fork()了虚拟机,准备好虚拟机后,反射执行了ActivityThread的main()方法,换句话说,Android中一样也是一个虚拟机对应了一个APP进程。

APP进程中可能有很多线程,比如binder线程、用户自己开辟的线程。这些线程的切换,都通过TCB来进行现场的保存与恢复。虚拟机栈、本地方法栈、程序计数器在内存中的地址信息都会通过TCB来进行保存和恢复。

我们还要注意到,TCB和PCB的保存与恢复类似,都是保存数据地址,例如栈信息的数据地址、寄存器的数据等。线程的栈中有局部变量表,它通过指针,JVM中线程共享的部分获取数据,它本身并不存储数据。线程私有的,是线程各自的局部变量表、程序计数器等信息,而并不是数据本身。例如下面这张图所示的虚拟机栈内部结构,都是一些指针信息,并没有保存数据本体。

img

到这里,我们来梳理一下:

  1. App进程的切换,本质上是对该进程的PCB(程序控制器)的信息进行保存与恢复。其信息具体指向的内容,仍然还在内存中。
  2. 线程的切换,如果是同一进程下的线程切换,本质上是对线程的TCB(线程控制器)的信息进行保存与恢复。其信息具体指向的内容,仍然在对应进程所拥有的内存空间中。

我们来看到下面这张图:

请添加图片描述

为了结构清晰,这里没有加入多级Cache缓存,主要为了表达,如果仅是线程切换,只需要切换TCB的信息即可,也就是从上图蓝线引用切换为粉线引用,而PCB的信息则不需要改动。当然,如果要切换其他进程,就需要对PCB进行保存和恢复了。

到这,我们最初的问题:“一个JVM虚拟机的内存区域就是这样的,一个线程占有了虚拟机栈、本地方法栈、程序计数器,那其他线程到哪去了?”。可以回答,其他线程的虚拟机栈、本地方法栈、程序计数器还在内存中,这三者在内存中的地址信息存在了TCB(线程控制块)中。当这个线程需要被调度的时候,通过TCB的地址信息,将这个线程的数据恢复回来。

Java的线程是如何实现的?

我们从启动线程的源码中也能看到,start()最终调用到start0()这个本地方法,这就说明Java线程的启动是由JVM底层来决定的。又提到王道操作系统的内容,我们知道线程分为:用户线程、核心线程、组合线程。Java线程的实现方式随不同操作系统而异。

在《深入理解JVM虚拟机》中说到,例如Windows和Linux中,都是使用一对一的线程模型来实现的,一条Java线程对应一条轻量级进程。在Unix平台中,可以支持一对一以及多对多。但都没有使用用户线程,主要原因是,如果使用用户线程,进程就要自己处理线程的创建 、切换和销毁,但最重要的麻烦在于,很难处理:“阻塞如何处理”、“多处理器时,如何将线程映射到其他处理器上并行执行”。

下图给出用户线程与轻量级进程1:1的关系,其中UT为用户线程(User Thread)同时也是Java线程,LWP为轻量级进程(LowWeight Process),KLT为内核线程(Kernel-Level Thread):

请添加图片描述
也正是有内核线程的参与,即由操作系统介入,把线程作为最小单位进行调度,才使得在多核CPU下,一个Java进程的多个线程,可以运行在不同的处理机上。

2. 多核CPU的结构图

铺垫了这么多,仍然不能切入主题,在讨论线程间可见性问题的时候,避不开缓存一致性问题。我们知道,ALU计算单元的处理速度远高于内存数据的读写速度,所以我们引入了多级缓存,来减小这种效率差。为了节省时间,我们直接考虑一个JVM虚拟机进程,在多级缓存中是如何表现的,且我只画了二级缓存:

请添加图片描述

主存的数据很多,缓存中只根据局部性原理留下了几个物理块。我们先来梳理几个概念,再进入线程间可见性问题。

首先,JVM内存区域中的堆和方法区的“共享”概念,在上面这张图中就体现了。不同的线程并行跑在不同的CPU中,访问的都是JVM内存区域中的“共享”区。但是,由于多级缓存,Java线程访问到的只是一份数据拷贝,并不是真正对主存的数据做处理。我们首先明确一点,缓存中的数据是真拷贝,缓存是从主存中拷贝了一整个物理块到缓存中来,而不是保存了指向主存的指针。

在不同CPU中对共享区域数据的修改,在使用“写回法”的情况下,只要没出现物理块的调换,就不会将更改后的数据更新回主存。其他线程也根本不知道这部分数据被改了:

请添加图片描述

这久发生了线程安全问题,到底应该听谁的呢?最后写回主存的时候就会无法确认最后的data到底是2还是3。

Java中通过volatile、final、synchronized关键字来实现线程间可见性

为了解决这种缓存一致性问题,java引入了volatile、final、synchronized关键字来实现线程间可见性问题。具体如何做到的,这里就不做多余分析了。通过线程间可见性关键字,让对变量的访问需要强制从内存中重新把数据刷回。

3. 最后回到正题,单核CPU会有线程可见性问题么?

答案是没有的。还是刚才的例子,现在只有CPU-1,首先来到线程A,将data从1改为2:

请添加图片描述

接下来,进行线程切换,线程私有区被切换为了线程B的内容,但共有部分并不需要切换,这是进程资源,只有在进程切换,或者缺页等情况发生的时候,才会有所变动。

轮到线程B执行,线程B也要访问data变量,通过局部变量表的指针,找到了位于堆中data的位置,

请添加图片描述

由于线程B访问的也是这一块缓存,即便是没有缓存一致性协议,它仍然能够访问到最新的值,因为这里根本就不存在同一级别的缓存出现不一致的情况,因为它最终层级只有一个缓存。至此,就搞通了为什么单核CPU下的多线程场景,不会造成线程安全,不会有线程可见性问题。

当然,在进行进程切换时,或者发生缓存缺页时,进程的物理块可能会被逐层写回主存,但这并不影响我们之前的分析。

此外,本文举的data例子是int变量,暂且认为是一个对象中的某个成员变量,所以修改的是堆中某个对象的成员变量的值,避免掺杂其他问题。

单核CPU还有必要开启多线程吗?
还是有必要的,举个例子,其中一个线程需要进行I/O操作,不希望在IO还没完成的时候把CPU时间交给它,浪费CPU资源。

4. 参考文献

《深入理解Java虚拟机》,周志明

《王道操作系统考研复习指导》,王道论坛

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值