JVM(一)内存模型

zhll 2019-07-01

一、前言

手上的这本《深入理解Java虚拟机》这本书买来已接近2年,期间也是看看停停,现如今也才只看到前10章(来回倒腾的看)。写这个专题的目的:1、作一个专题复习,老话说的好:好记性不如烂笔头,正好也可以把自己的一些理解记录;2、我买的这本书大部分是基于1.6、1.7的,而现在都java11了,一些内容做了改变,但我还是以JDK8作为讲解(毕竟高版本我也不太熟)。
作为本系列的第一章:就从内存模型开始说起。

JVM(一)内存模型

二、内存

我想大家刚毕业找工作面试的时候都被问过这种问题:Java的内存区域是如何划分的?由此可见这块还是挺重要都。总的来说,Java虚拟机内存区域共分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区、直接内存、运行时常量池七6块区域。下面将会一一讲解。

2.1、程序计数器

其实从名字就可以看出来,它是计数用的,我们在程序中在执行if、while、try/catch的时候都是依赖于这个计数器。要知道Java是多线程编程语言,为了在切换线程的时候程序计数器能恢复到正确的位置,每个线程都会维护一个程序计数器,也就是说:程序计数器是线程私有的,同时它还是内存区域唯一一个在Java虚拟机规范中没有规范任何OOM情况的区域。

特点:

  • 线程私有
  • 不会发生OOM

2.2、虚拟机栈

这里之所以称虚拟机栈是因为后面还有一个本地方法栈。这里的虚拟机栈指的就是我们平时说的堆栈中的栈,在数据结构中我们知道栈的特点是先进后出的,虚拟机栈描述的Java方法的执行模型。这里我举一个例子(为了简单,这里就用js举例):

function a(){
    b();
}
function b(){
    c();
}
function c(){
}
a();

从上面可以看出:a调用b,b调用c。执行开始的顺序是:a>b>c。执行结束的顺序是:c>b>a。正好符合栈的特性。
在我们调用一个Java方法的时候:每个方法都会创建一个栈帧(Stack Frame)。这里的栈帧你就把它理解成C语言的结构体,只是一个数据结构而已。它存放的是:*局部变量表、操作数栈、动态链接等

JVM(一)内存模型
这里又多了4个名词,下面分别对这三个名词作解释。
a、局部变量表
由名字可以知道它存放的是变量:局部变量和方法参数,它存放于方法的Code属性的max_locals数据项。至于这个Code属性是什么,后续会有专门的文章介绍。我们需要知道的是:系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)
b、操作数栈
Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。
操作数栈是一个基于字节的数组,但是它不是基于数组的角标来索引,而是通过压栈和出栈来访问,这里举一个小例子:

// int a = 1 ; b = 2; c = a + b ;
iload_0    // 将局部变量表中索引为0的操作数压入栈
iload_1    // 将局部变量表中索引为1的操作数压入栈
iadd       // 将相加结果压入栈
istore_2   // 从操作数栈中弹出结果然后放入局部变量表中索引为2的位置

动态链接
这个我会有一个后续将会有一篇文章来介绍。

这这块内存区域有可能发生两种异常:StackOverflowError、OOM。这两种异常都很好演示:

// StackOverflowError异常
function a(){
    a();
}
a();
// 演示OOM的话,则最好设置下堆内存
//-Xms=10M -Xmx=10M
function a(){
    int[] = new int[1024*10];
    a();
}

特点:

  • 线程私有,生命周期和线程相同
  • 抛SOE和OOM两种异常

2.3、本地方法栈

本地方法栈和虚拟机方法栈很类似,区别就是虚拟机为的是Java方法服务,而本地方法栈则为虚拟机使用的Native服务。这里就涉及了一个概念:本地方法。那什么是本地方法呢?

简单地讲,一个Native Method就是一个java调用非java代码的接口

这个非Java代码的接口可能是c,也有可能是c++。更多关于本地方法的内容就不过多展开。

2.4、堆

对于大部分应用来说,Java堆是虚拟机管理内存中最大的一块,它存放的内容是对象实例。根据Java虚拟机规范:绝大多数对象实例以及数组都都在堆上分配。(Class对象除外,它是存放在方法区)堆是垃圾回收器管理的主要区域,我们知道现代收集器是基于分代收集算法,因此我们可以对Java对进行划分:新生代、老年代。然后对新生代可以再划分:Eden空间、From Survivor、To Survivor空间。下面对这几块内存空间作介绍。
Eden
新生代的一块内存空间,它是新对象“出生”的地方,当Eden没有足够的空间进行分配的时候,发生一次Minor GC。

From、To
Survivor之所以会划分两块区域,是由于新生代的回收算法决定的。From、To这个不是固定的,而且To区域永远是空的,Eden:Survior它们的默认比例是8:1,也就是说新生代的可用内存大小是8+1=9。当对象从Eden进入到From之后它的年龄设置为1,每熬过一次Minor GC,那它的年龄就+1,当年龄到达一定的值(默认15)就会进入到老年代。

注意: 虚拟机并并不是永远要求对象的年龄达到我们设置的值或者默认值15才能进入到老年代,如果Survivor中相同年龄所有对象大小总和大于Survivor空间的一半,那么年龄大于或者等于该年龄对象的就可以直接进入到老年代。

老年代
老年代存放的是长期存活的对象和大对象,这里的大对象可能是大字符串和大数组。
MinorGC

  • 将已经死亡的对象消除,将依然存活的对象移动到From空间
  • 当From空间已满的时候,将已经死亡的对象消除,将依然存活的对象移动到From空间;此后Eden执行MinorGC时将依然存活的对象移动到To空间(From和To对调)
  • 当执行了n次对象还未死亡,将会进入到老年代。
Minor GC:发生在新生代的垃圾回收动作,因为大多数Java对象都是朝生夕死,因此这次回收会很频繁,速度也会很快。
Major GC(Full GC):老年代的垃圾回收动作,Full GC的速度一般比Minor GC慢10倍以上。

2.5、方法区

永久代
其实一开始我一直理不清方法区和永久代之间的概念,最近才整明白。方法区是Java虚拟机规范的叫法,而永久代是Hotspot的叫法,我们可以将永久代当成对方法区的一种实现,而且Java8已经去永久代。永久代是一片连续的堆空间,可以通过-XX:MaxPermSize来设置。永久代的垃圾回收是和老年代捆绑在一起的,因此不论那个满了都会触发两者的垃圾回收。
JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。
元数据
元数据是jdk8出来的,它和永久代类似,最大的区别是元空间并不在虚拟机中,而是使用本地内存。

-XX:MetaspaceSize // 初始空间大小
-XX:MaxMetaspaceSize // 最大空间

至于为什么要作永久代到元数据之间的转换,我想主要有两个原因:

  1. 字符串存放到永久代代容易出现性能问题和内存溢出
  2. 为了JRocket和HotSpot的合并

2.6、运行时常量池

运行时常量池,属于方法区的一部分,用于存放编译期生成的各种字面量和符号引用。对于运行时常量池,不同产商有不同实现。还有两个个类似的名词叫:字符串常量池、class文件常量池,下面来分别介绍这三者。
字符串常量池
符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)
在HotSpot VM里实现的string pool功能的是一个HashTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

class文件常量池
我们知道class类结构最前面除了魔数、主次版本号之后就是常量池了,这个常量池就是我们说的class文件常量池,它存放的是我们编译生成的各种字面量和符号引用。

符号引用:符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。

运行时常量池
当java文件被编译成class文件之后,也就是会生成我上面所说的class常量池。当jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的HashTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

小结
1.全局常量池在每个VM中只有一份,存放的是字符串常量的引用值
2.class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用
3.运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致

2.7、直接内存

直接内存不属于虚拟机运行时数据区的一部分,它不是Java虚拟机规范定义的区域,在JDK1.4加入了NIO我们可以直接操作堆外内存,在RPC通信中我们经常会使用到NIO,著名框架Netty就是基于此。直接内存不受Java堆的限制,但是收到本机的内存限制。

三、总结

本篇主要就JVM的内存模型作了介绍,主要介绍了虚拟机栈、堆、常量池,这三个也是我们平时用的比较多的,当然也不代表其它不重要。

一些唠叨:刚毕业那会也经常好奇为什么现在公司都喜欢面试造飞机、工作拧螺丝,现在也逐渐慢慢有体会,因为螺丝绝大部分人都会拧啊。花通样的薪资肯定人家不愿意就招个拧螺丝的。而且,一些原理性东西对长远的工作确实有益,反正学到手总不是坏事。

欢迎关注公众号:码农有道
JVM(一)内存模型

参考《深入理解Java虚拟机》,Java中几种常量池的区分

相关推荐