bigwolftime / gitmentCommentsPlugin

0 stars 0 forks source link

JVM Memory Model – LOFFER – 一个可以fork的博客 #8

Open bigwolftime opened 4 years ago

bigwolftime commented 4 years ago

https://index1024.gitee.io/blog/JVM_memory_model/

在万变的技术浪潮中找到不变的东西才是最重要的.

《深入理解 Java 虚拟机 - 第三版》已于近日出版, 第三版在编撰期间 Java 已经发展到了 JDK 13. 此书相对于第二版, 讲到的技术更加与时 俱进, 内容更加全面丰富, 且对上一版部分描述不清的地方进行了补充说明, 值得一读.

一. Java 运行时数据区域:

  1. 程序计数器(Program Counter Register)

程序计数器是当前线程执行字节码的行号指示器, 通过改变计数器的值来确定下一条指令的地址, 通过改变此值可以完成循环, 分支, 跳转, 异常处理, 线程切换等.

以多线程为例, Java 虚拟机的多线程通过线程轮流切换, 分配 CPU 执行时间片的方式来实现. 在宏观上讲程序是并行执行的, 但在微观上: 某一时刻t, 一个处理器会执行一条指令. 因此, 为了线程恢复之后能够找到正确的执行位置, 每条线程都需要一个单独(或者私有)的计数器.

若线程执行的是 Java 方法, 则计数器保存的是正在执行的字节码指令的地址; 如果执行 Native 方法, 则此值为 Undefined.

  1. Java 虚拟机栈(Java Virtual Machine Stack)

虚拟机栈描述的是 Java 方法执行的线程内存模型, 每个方法被调用执行时, 会同步创建一个栈帧, 存储局部变量表, 操作数栈, 动态链接, 方法出口等. 每个方法的执行到结束, 都会对应入栈 - 出栈的过程.

局部变量表存放编译期已知的基本数据类型(int, long, double, short, boolean, char, float, byte), 对象引用(reference), returnAddress(指向字节码指令的地址). 其生命周期与线程相同.

  1. 本地方法栈(Native Method Stacks)

与虚拟机栈作用相似, 为虚拟机执行 Native 方法服务.

在 Hotspot 虚拟机中并没有区分虚拟机栈和本地方法栈. 本地方法栈参数: -Xoss 即使配置了也不会生效.

  1. 堆(Heap)

堆是所有线程共享的区域, 在虚拟机启动时创建, 存放对象实例(实际上并非所有对象都存储在堆中, 例如 JIT 技术: 逃逸分析, 栈上分配, 标量替换等 优化手段下, 对象并非存储到堆).

Java 堆在逻辑上是连续的, 但物理上可以处于不连续的空间. 不过若涉及到大对象(数组), 并考虑到简单高效, 可能会要求连续的内存空间.

  1. 方法区(Method Area)

方法区对于所有线程共享, 存储虚拟机加载的类型信息, 常量, 静态变量, 即时编译后的代码缓存数据等.

提到方法区就会想到 JDK 8 以前的永久代, Hotspot 虚拟机设计团队为了省略方法区的内存管理工作, 便把虚拟机的分代设计扩展到方法区, 使其可以像堆一样管理此部分内存. 现在看来, 用”永久代”的思路实现方法区的设计, 更容易导致内存溢出的问题.

方法区的垃圾回收主要是针对常量池和类型的卸载, 但条件较苛刻, 回收效果欠佳; 另外, 自 2008 年 Oracle 收购 BEA 公司后, 准备将 BEA 公司的 JRockit 虚拟机 Java Mission Control 等优秀特性移植 到 Hotspot, 但 JRockit 并没有方法区的概念, 给两者的融合带来了不少问题.

综上考虑, Hotspot 虚拟机决定将”永久代”废弃掉. 自 JDK 7 原本存储在方法区的: 字符串常量池, 类型信息, 静态变量等移动到了堆空间, 到了 JDK 8 则完全废弃了”永久代”, 改用本地内存来实现. 因此方法区容量指定参数: -XX: MaxPermSize 在已经 JDK 8 就已失效了, 可以 使用 -XX: MaxMeta-spaceSzie 参数指定元空间容量大小.

运行时常量池是方法区的一部分(自 JDK 8 运行时常量池已被移动到堆区), 存放编译期间生成的字面量和符号引用, 运行时新的常量也会进入 常量池.

直接内存

直接内存并不属于虚拟机数据区, 但也会被频繁使用到. JDK 1.4 加入的 NIO 使用一种基于通道(Channel)和缓冲区(Buffer)的 I/O 方式, 可以使用 Native 函数直接分配对外内存, 使用 DirectByteBuffer 对象作为这块内存的引用进行操作, 可以避免 Java 堆 和 Native 堆之间复制数据, 提高性能.

二. 对象的存储结构

  1. 对象的创建

创建一个对象, 除了使用 new 关键字, 还有很多手段: 复制, 反序列化, 反射等. 以 new 关键字为例:

首先会检查该指令的参数能否在常量池定位到类的符号引用, 并检查此符号引用是否被加载, 解析, 初始化, 若没有需执行类加载; 通过检查后为对象分配内存, 即在堆中分出一部分空间存储实例; 将内存空间初始化为0(不包括对象头); 填充对象头(Object Header)信息: 类的元数据, hashCode, GC 年龄, 类型指针(属于哪个对象), 锁状态等; 执行自定义构造方法.

在这过程中需要关注几点.

内存分配

关于内存分配, 目前有两种方式:

假如堆是内存规整的, 被占用的内存放一边, 空闲内存放一边, 中间有指针分界, 那么此时只需要将指针向空闲内存方向移动一定大小即可. 这种分配办法称为: 指针碰撞 若内存不是规整的, 则不能简单地使用指针碰撞了, 需要维护一个列表: 用于记录哪些内存被占用, 哪些是空闲的. 从表中筛选出可供对象存储的 内存. 这种办法称为: 空闲列表

由此可见, 使用哪种方式分配取决于堆内存是否规整, 堆内存规整与否则由”空间压缩(Compact)”特性有关. Serial, ParNew 等收集器支持 压缩整理, 使用指针碰撞简单又高效; CMS 收集器基于清除算法, 理论上只能使用空闲列表来分配(实际上为了提高性能做了一些优化, 例如通过 空闲列表的方式拿到一大块内存, 在此块内存中则使用指针碰撞的方式分配).

并发性

对象创建是非常频繁的行为, 即使只修改指针的指向, 放在并发环境下也不安全, 为了解决此问题, 有两种办法:

“CAS + 重试” 可以保证操作的原子性, 原理是用到了 CPU cmpxchg 指令; 为每个线程在堆中分配一块内存, 称为本地线程分配缓冲区(Thread Local Allocation Buffer, TLAB), 哪个线程分配内存, 则在哪个缓冲区 分配. 可以降低并发导致的资源冲突.

  1. 对象的内存布局

对象的存储结构包括三部分: 对象头(Object Header), 实例数据(Instance Data), 对齐填充(Pdding).

对象头

对象头一般包含两部分信息:

对象自身的运行时数据: hashCode, GC 分代年龄, 锁标志, 偏向线程 ID 等, 这部分称为: MarkWord. MarkWord 为了实现以极小的 内存存储更多的信息, 使用了动态定义的数据结构:

  存储内容
  标志位
  状态

  :
   
   
bigwolftime commented 4 years ago

xyz