运行时数据区是指对 JVM 运行过程中涉及到的内存根据功能、目的进行的划分,而内存模型可以理解为对内存进行存取操作的过程定义。总是有人望文生义的将前者描述为 “Java 内存模型”,最近在阅读《深入理解 Java 虚拟机》之后对二者加深了部分理解,于是写一篇相关内容的学习总结。

运行时数据区

《Java 虚拟机规范》定义中,由 JVM 管理的内存区域分为以下几个运行时数据区域:

flowchart LR
subgraph 运行时数据区
subgraph 线程私有
虚拟机栈
本地方法栈
程序计数器
end
subgraph 线程共享
方法区
javaHeap[Java 堆]
end
end

程序计数器

程序计数器(Program Counter Register)的生命周期和 Java 线程一致,仅能被相关线程访问。用于记录当前线程所执行的字节码的行号,代码中的分支、循环、跳转、异常处理和多线程发生切换时的线程恢复等基础功能都需要依赖程序计数器完成。当执行 Java 代码时,程序计数器存储正在执行的虚拟机字节码指令地址,而执行本地方法时,程序计数器应该为空。

《Java 虚拟机规范》中规定程序计数器不会发生 OutOfMemoryError 异常情况。

虚拟机栈和本地方法栈

虚拟机栈(Java Virtual Machine Stack)的生命周期和 Java 线程一致,仅能被相关线程访问。用于描述 Java 方法执行的线程内存模型:每次进入一个新的方法时,JVM 都会同步创建一个栈帧,栈帧中存储了局部变量表、操作数栈、动态连接和方法出口等信息。每次执行结束一个方法时,对应栈帧就会出栈。

虚拟机栈可能发生两类异常情况:

  • StackOverflowError:线程请求的栈深度大于虚拟机允许的深度
  • OutOfMemoryError:如果 JVM 栈容量可以动态拓展,当需要拓展时 JVM 无法申请到足够的内存就会抛出该异常。而在 HotSpot 这种不允许动态拓展的虚拟机中,如果创建时就失败依然也会抛出该异常。

本地方法栈(Native Method Stack)与虚拟机栈基本类似,当执行本地方法时使用本地方法栈,同样会抛出 StackOverflowErrorOutOfMemoryError

《Java 虚拟机规范》对本地方法栈的实现方式没有任何强制规范,故 HotSpot 虚拟机中虚拟机栈和本地方法栈直接合二为一。

虚拟机栈的大小通过 java 命令的参数 -Xss 设定。

Java 堆

Java 堆(Java Heap)在虚拟机启动时就创建,虚拟机关闭时销毁,被所有 Java 线程共享。用于存放所有的对象实例。

Java 堆受垃圾回收器管理,由于现代主流的垃圾回收器都是基于分代收集理论设计,Java 堆中经常会出现“新生代”、“老年代”、“永久代”、“Eden空间”、“From Survivor空间”、“To Survivor空间” 等名词。这些名词对于 Java 堆的划分,是指一部分垃圾回收器的设计风格,而不是 JVM 具体实现的固有内存布局,更不是 《Java 虚拟机规范》里对 Java 堆的进一步细致划分。并且近年来新出现的垃圾回收器也有不采用分代设计的,再用这些名词划分 Java 堆空间也已经不正确了。

Java 堆可能发生 OutOfMemoryError异常:分配内存给新的对象实例失败、且堆无法再拓展时抛出该异常。

Java 堆的大小通过 java 命令的参数 -Xmx-Xms 设定。

方法区

方法区(Method Area)和 Java 堆一样在虚拟机启动时就创建,虚拟机关闭时销毁,被所有 Java 线程共享。用于存储已被虚拟机加载的类型信息、常量、静态变量和即时编译器编译后的代码缓存。

在 HotSpot 虚拟机中,方法区的实现经历过两次大规模改动:

  1. JDK 6 及以前:为了方便使用分代垃圾收集器管理方法区内存,使用永久代(Permanent Generation)实现方法区。
  2. JDK 7 时期:字符串常量池和静态变量转移到本地内存中(Native Memory),其他数据依然存放在由永久代实现的方法区中。
  3. JDK 8 及此后:方法区中的全部数据都存放在被称为元空间(Meta-space)的本地内存中。

如果方法区无法满足新的分配内存需求时会抛出 OutOfMemoryError 异常。

内存模型

内存模型:在特定的缓存一致性协议约束下,对特定的内存高速缓存进行读写访问的过程抽象。内存模型主要用于在共享内存的多核系统中解决缓存一致性的问题。不同架构的物理机通常具有不一样的内存模型,而Java虚拟机也有自己的内存模型。

在硬件层面上,内存模型针对高速缓存。如图所示,处理器运算速度和主内存的存取速度天差地别,必须加入高速缓存来作为内存和处理器之间的缓冲。如果两个处理器执行的指令都涉及到主内存同一区域,就会发生各自的缓存数据不一致。这种情况下需要通过缓存一致性协议规定处理器的读写行为。

flowchart LR

CpuOne(处理器一) <--> CacheOne(高速缓存一)
CacheOne <--> Protocol[缓存一致性协议]

CpuTwo(处理器二) <--> CacheTwo(高速缓存二)
CacheTwo <--> Protocol

Protocol <--> Memory((主内存))

在 JVM 中,内存模型针对各线程的工作内存。如图所示,其中关键名词解释如下:

  • 主内存:与上方硬件层面的主内存概念一致,实际是操作系统为 JVM 分配的物理内存空间。
  • 工作内存:每个线程各自的工作内存,可与上方硬件层面的高速缓存类比。

工作内存中保存了该线程使用到的变量的主内存副本(并不是百分之百的完全副本,例如两条线程同时访问一个 10MB 的对象,并不会在各自的工作内存中都创建一个完全相同的对象),对每个变量的读、写操作都必须在工作内存中进行,不能直接访问主内存(volatile 变量通过读写屏障实现,也受这条规则约束,并不是直接访问主内存)。

主内存、工作内存的划分方式与前一章中阐述的堆、栈、方法区等划分方式是截然不同的概念,不能类比或对应。

flowchart LR

ThreadOne(线程一) <--> WorkingMemoryOne(工作内存一)
WorkingMemoryOne <--> SaveAndLoad[Save和Load操作]

ThreadTwo(线程二) <--> WorkingMemoryTwo(工作内存二)
WorkingMemoryTwo <--> SaveAndLoad

SaveAndLoad <--> Memory((主内存))

volatile 变量

volatile 变量有可见性和禁止指令重排序优化两个特点。

可见性

一个线程修改了 volatile 变量,其他线程立即可知。在 JVM 中的实现方式为,线程对 volatile 变量的读取时,需要先将主内存中的当前值拷贝到工作内存中;线程对 volatile 变量写入时,写入工作内存后立即同步到主内存中。注意可见性和原子性是不同的概念,volatile 关键字无法保证对变量操作的原子性。

禁止指令重排序优化

指令重排序优化是指处理器为了提高运算单元的利用率,会对指令进行乱序执行优化,处理器能够保证经过乱序的指令和原始顺序的指令执行结果一致。禁止指令重排序的方式是添加内存屏障,重排序时不能把后面的指令重排序到内存屏障之前的位置。

如果在多线程环境中,指令重排序优化可能导致访问共享资源出错,一个常见的例子就是单例模式的双锁检查式实现方式需要将实例引用添加 volatile 修饰。

public class Singleton {
//如果不添加 volatile 修饰可能发生异常
private volatile static Singleton instance; public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
} private Singleton() {};
}

其中 instance = new Singleton(); 一句对应的字节码指令为:

NEW Singleton
DUP
INVOKESPECIAL Singleton.<init> ()V
PUTSTATIC Singleton.instance : LSingleton;

总共分为四个步骤:

  1. NEW 指令创建对象(此时还没有执行构造函数,对象的所有成员变量都是默认的“零值”),将对象引用压入操作数栈。
  2. DUP 指令将当前操作数栈顶的值拷贝一份(此时操作数栈顶的两个元素是两个相同的、值为前一步创建的对象的引用)。
  3. INVOKESPECIAL 指令会调用当前操作数栈顶的对象的 <init> 方法,该方法是根据空参的构造函数生成的。
  4. PUTSTATIC 指令将操作数栈顶的对象引用赋值给 Singleton 类的 instance 引用。

根据指令重排序的原则,三、四两步之间乱序执行不会影响结果,那么如果发生了指令重排、且两个线程恰好按照如下步骤执行就会发生异常情况(篇幅所限不给出能发生指令重排序的代码,其实就是上方代码删去 volatile ):

sequenceDiagram
threadOne ->> threadOne : NEW Singleton
threadOne ->> threadOne : DUP
threadOne ->> Singleton.class : PUTSTATIC Singleton.instance : LSingleton;

threadTwo ->> Singleton.class : 调用 getInstance() 函数
Singleton.class ->> threadTwo : Singleton.instance 引用不为 null,返回引用值

threadTwo ->> threadTwo : 使用单例对象
Note over threadTwo : 此时单例对象还未完全初始化,发生异常

threadOne ->> threadOne : INVOKESPECIAL

而如果 instance 使用 volatile 修饰,由于内存屏障的存在指令 INVOKESPECIALPUTSTATIC 之间就不会发生指令重排,确保了上述问题不会发生。另外请注意,Java 中双锁检查式方式实现的单例在 Java 5 之后才能完全保证可用,此前版本依然会出现问题。

Java 运行时数据区和内存模型的更多相关文章

  1. 002-JVM运行时数据区【内存模型】

    一.概述 JVM定义了不同运行时数据区,他们是用来执行应用程序的.某些区域随着JVM启动及销毁,另外一些区域的数据是线程性独立的,随着线程创建和销毁. 1.1.jvm自身物理结构 1.2.java内存 ...

  2. 读书笔记-浅析Java运行时数据区

    作为一个 Java 为主语言的程序员,我偶尔也需要 用 C/C++ 写程序,在使用时让我很烦恼的一件事情就是需要对 new 出来的对象进行 delete/free 操作,我老是担心忘了这件事情,从而导 ...

  3. Jvm基础(1)-Java运行时数据区

    最近在看<深入理解Java虚拟机>,里面讲到了Java运行时数据区,这是Jvm基本知识,把读书笔记记录在此.这些知识属于常识,都能查到的,如果我有理解不对的地方,还请指出. 首先把图贴上来 ...

  4. Java JVM运行时数据区,内存管理和GC垃圾回收

    一 . 运行时数据区 程序计数器是线程私有的,是一块很小的内存空间,是当前线程执行到字节码行号的计数指示器.每个CPU处理器核心 在任何一个时刻,都只可能运行着唯一的一个线程,执行着一条指令.所以在多 ...

  5. Java运行时数据区

    目录 1. 概述 2. Java内存结构 3. 程序计数器 4. Java虚拟机栈 5. 本地方法栈 6. 堆 7. 方法区 8. 运行时常量池 9. 直接内存 10. 总结 1. 概述 作为日常的J ...

  6. Java 运行时数据区

    写在前面 本文描述的有关于 JVM 的运行时数据区是基于 HotSpot 虚拟机. 概述 JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域.这些区域都有各自的用途,以 ...

  7. 【转】Java运行时数据区简介及堆与栈的区别

    理解JVM运行时的数据区是Java编程中的进阶部分.我们在开发中都遇到过一个很头疼的问题就是OutOfMemoryError(内存溢出错误),但是如果我们了解JVM的内部实现和其运行时的数据区的工作机 ...

  8. JVM内存布局(又叫Java运行时数据区)

    JVM 堆中的数据是共享的,是占用内存最大的一块区域. 可以执行字节码的模块叫作执行引擎. 执行引擎在线程切换时怎么恢复?依靠的就是程序计数器. JVM 的内存划分与多线程是息息相关的.像我们程序中运 ...

  9. Java运行时数据区概述

    Java 虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,如图所示: 程序计数器 程序计数器是一块比较小的内存空间,可以看作是当前线程所执行的字节 ...

随机推荐

  1. XV6学习(16)Lab net: Network stack

    最后一个实验了,代码在Github上. 这一个实验其实挺简单的,就是要实现网卡的e1000_transmit和e1000_recv函数.不过看以前的实验好像还要实现上层socket相关的代码,今年就只 ...

  2. 字节笔试题 leetcode 69. x 的平方根

    更多精彩文章请关注公众号:TanLiuYi00 题目 解题思路 题目要求非负整数 x 的平方根,相当于求函数 y = √x 中 y 的值. 函数 y = √x  图像如下: 从上图中,可以看出函数是单 ...

  3. 操作系统:Linux进程与线程

    这里是一部分内容,还会做修改. 一:目的及内容 学习fork(),exec,pthread库函数的使用,阅读源码,分析fork,exec,pthread_create函数的机理 代码实现: 进程A创建 ...

  4. C# 特殊符号

    特殊符号 @开头 前面提到过,字符串里免转义用的, 字符串里写的啥就是啥,遇到\ 不转义 ?? 判断一个值是不是null,是的话就变成后面的默认值,不是的话就还是原值 $开头 字符串篡改 和forma ...

  5. Python源码剖析——02虚拟机

    <Python源码剖析>笔记 第七章:编译结果 1.大概过程 运行一个Python程序会经历以下几个步骤: 由解释器对源文件(.py)进行编译,得到字节码(.pyc文件) 然后由虚拟机按照 ...

  6. zsh & for loop bug

    zsh & for loop bug ​for: command not found syntax error near unexpected token do' do' Unicode 编码 ...

  7. BattleBots

    BattleBots 搏茨大战 https://battlebots.com/ BiteForce https://www.youtube.com/watch?v=06lyUXuQT_Y xgqfrm ...

  8. Micro Frontends & microservices

    Micro Frontends & microservices https://micro-frontends.org/ https://github.com/neuland/micro-fr ...

  9. Flutter: debounce 避免高频率事件

    原文 函数 import 'dart:async'; Function debounce(Function fn, [int t = 30]) { Timer _debounce; return () ...

  10. c#初体验

    虚方法.抽象类.接口区别:虚方法:父类可能需要实例化,父类方法需要方法体,可以找到一个父类 抽象类:抽象方法,父类不能实例化,且父类方法不能实现方法体,不可以找出一个父类,需要抽象 接口:多继承 le ...