写点什么

【并发编程的艺术】JVM 体系与内存模型

发布于: 2021 年 01 月 19 日
【并发编程的艺术】JVM体系与内存模型

内容参考来源:《Java 并发编程的艺术》,有需要可私聊获取资料。

一 JVM 体系结构

先看一张 JVM 体系结构图:


通过上图可见,JVM 由类加载器运行时数据区执行引擎三个子系统组成。

简单介绍下三个子系统的功能,以便后续介绍中有更明确的理解:

1.1 类加载器

类加载器的功能,是处理类的动态加载(Loading)链接(Linking),并且在第一次引用类时进行初始化(Initialization)

Loading->Linking->Initialization 也是通常类的加载过程。

1.2 运行时数据区

运行时数据区约定了在运行时程序代码的数据比如变量、参数等等的存储位置,包括:

  • PC 寄存器(程序计数器):保存正在执行的字节码指令的地址;

  • :在方法调用时,创建一个叫栈帧的数据结构,用于存储局部变量和部分过程的结果

  • :存储类实例对象和数组对象,垃圾回收的主要区域

  • 方法区:也被称为元空间,还有个别名 non-heap(非堆),使用本地内存存储 class meta-data 元数据(运行时常量池,字段和方法的数据,构造函数和方法的字节码等),在 JDK 8 中,把 interned String 和类静态变量移动到了 Java 堆

  • 运行时常量池:存储类或接口中的数值字面量字符串字面量以及所有方法或字段的引用,基本上涉及到方法或字段,JVM 就会在运行时常量池中搜索其具体的内存地址

  • 本地方法栈:与 JVM 栈类似,只不过服务于 Native 方法

1.3 执行引擎

运行时数据区存储着要执行的字节码,执行引擎将会读取并逐个执行。包括:

解释器(Interpreter),JIT 编译器(JIT Compiler),垃圾收集器(Garbage Collector。

此外,还有执行引擎所需的本地库(Native Method Libraries)和与其交互的 JNI 接口(Java Native Interface)

感兴趣想继续深入研究的朋友可以查询 JVM 相关文档,或等待后续文章中对此进行详细描述。

二 关于内存结构与内存模型

提起内存结构 和 内存模型,可能很多人会搞混。这里再明确一下。

2.1 内存结构

描述的是内存被划分为多个数据区域,各区域都有对应的功能,重点是组成结构;简单来说,就是大家都了解过的下面这张图(来自《深入理解 Java 虚拟机(第 2 版)》):


以及堆内存的分代结构(Jvm1.8 以前,1.8 后永久代改为元数据区,以下仅用于示例):



2.2 内存模型

内存模型是一个比较复杂的概念,基于Java Memory Model and Thread Specification(JSR-133)的描述,Java 内存模型(Java Memory Model-JMM)与线程规范紧密相关,通过下面的内容目录,我们可以看到其涵盖了锁(Locks),可见性(Visibility),顺序(Ordering)、原子性(Atomicity)、顺序一致性(Sequential Consistency)、Final 字段(Final Fields)等一系列我们熟知的概念。

简单来说,Java 内存模型描述了一组规范,来解决 Java 多线程对共享内存进行操作的时候,会出现的一些如可见性、原子性和顺序性的问题。


三 并发的典型场景与分析

3.1 并发代价

首先提几个经典的问题:

1、多线程一定比单线程快吗? 或者,并发一定比串行快吗?

答案:不一定,并发执行可能会比串行慢。原因?线程有创建和上下文切换的开销。

那么由此带来的直接问题,如何减少上下文切换?

无锁并发CAS使用最少线程和使用协程

其中,无锁并发和 CAS 都是从“锁”的角度来减少开销。

无锁并发编程:多线程竞争锁时,会引起上下文切换,所以考虑通过避免使用锁的方式。例如根据数据 id,做 hash 算法取模后分段,不同线程处理不同的段来避免争用;

CAS:Java 提供 Atomic 包,使用 CAS 来更新数据,而不需要加锁。(这里实际是不显示使用锁,根据 Linux x86 架构下的 cas 源码,仍然有 LOCK_IF_MP)。

使用最少线程:避免创建过多线程,这会导致造成大量线程处于等待状态。

协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

3.2 资源限制

执行程序时,通常需要考虑的资源包括:网络带宽、磁盘(大小 &iops 性能)、内存、cpu,这些可归类为硬件资源,此外还有软件资源,例如数据库连接数,socket 连接数等。

使程序跑的更快,在资源的角度可以考虑两个方向,一是考虑资源扩充(扩容):单机->集群,并行执行程序,软件资源限制,考虑池化方式来实现资源复用;另一个方向,在固定的资源限制下,并发编程,尽可能对并行度调优。例如下载文件任务,主要依赖带宽和硬盘读写速度两个资源;涉及数据库读写操作时,连接数需要考虑;如果 SQL 执行很快且线程数比数据库连接数大很多,那么某些线程会被阻塞,等待数据库连接,我们就需要调整线程数来避免这种情况。

附:CAS 底层实现

程序会根据当前处理器的类型来决定是否为 cmpxchg 指令添加 lock 前缀。如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略 lock 前缀(单处理器自身会维护单处理器内的顺序一致性,不需要 lock 前缀提供的内存屏障效果)。

// Adding a lock prefix to an instruction on MP machine// VC++ doesn't like the lock prefix to be on a single line// so we can't insert a label after the lock prefix.// By emitting a lock prefix, we can define a label after it.#define LOCK_IF_MP(mp) __asm cmp mp, 0  \                       __asm je L0      \                       __asm _emit 0xF0 \                       __asm L0:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { // alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx }}
复制代码


intel 的手册对 lock 前缀的说明如下:

  1. 确保对内存的读-改-写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4,Intel Xeon 及 P6 处理器开始,intel 在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在 lock 前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低 lock 前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。

  2. 禁止该指令与之前和之后的读和写指令重排序。

  3. 把写缓冲区中的所有数据刷新到内存中。


发布于: 2021 年 01 月 19 日阅读数: 41
用户头像

磨炼中成长,痛苦中前行 2017.10.22 加入

微信公众号【程序员架构进阶】。多年项目实践,架构设计经验。曲折中向前,分享经验和教训

评论

发布
暂无评论
【并发编程的艺术】JVM体系与内存模型