JVM之Java内存区域

starlin 700 2018-09-01

Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域。每个区域都有各自的用途,以及创建和销毁的时间,根据Java虚拟机规范,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
Java虚拟机运行时数据区

程序计数器

程序计数器是一块较小的内存区域,它可以看作是当前线程锁执行的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一跳要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成。

由于Java虚拟机的多线程是通过线程切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,个线程之间的计数器互不影响,独立存储,我们称这类区域为“线程私有”的内存

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值为空,此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

Java虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种分发比较粗糙,Java内存区域的划分实际上远比这复杂

在Java虚拟机规范中,对这个区域规定了两种异情况:

  • 如果线程请求的栈深度大于虚拟机锁允许的深度,将抛出StackOverflowEroor异常
  • 如果虚拟机栈可以动态扩展,如果动态扩展时无法申请到足够的内存,就是抛出OutOfMemoryError异常

本地方法栈

本地方法栈与虚拟机栈锁发挥的作用是非常类似的,他们的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用的Native服务,与Java虚拟机栈一样,本地方法栈也会抛出StackOverflowEroor异常和OutOfMemoryError异常。

Java堆

对大多说应用来说,Java堆是Java虚拟机锁管理内存的最大一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存.

Java堆是垃圾收集器的主要区域,因此很多时候都称为“GC堆”,由于现在的收集器基本都采用分代收集算法,所以在Java堆中还可以分为:新生代老年代;在细致一点的有Eden空间From Survivor空间To Survivor空间等。

当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制),如果在堆中没有内存完成实例分配,并且堆也无法在扩展时,将会抛出OutOfMemoryError异常。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,对于HotSpot虚拟机来说,又称为“永久代”。
虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求是,将抛出OutOfMemoryError异常

运行时的常量池

运行时的常量池是方法区的一部分,运行时的常量池具有的一个重要特征就是具备动态性,Java语言并不是要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入常量池,这种特性利用String类的intern()方法,
该方法会先查询常量池中是否已经存在,如果存在则返回常量池中的引用,在JDK1.7之后,如果常量池中找不到对应的字符串,则不会在将字符串拷贝至常量池,而只是在常量池中生成一个对原字符串的引用
示例代码如下:

public class InternDemo {
    public static void main(String[] args) {

        String str3 = new StringBuilder("sss").append("xxx").toString();
        String str2 = str3.intern();
        System.out.println(str2 == str3);

        String str1 = new StringBuffer("ja").append("va").toString();
        String str4 = str1.intern();
        System.out.println(str4 == str1);


    }
}

运行结果:

true
false

类加载过程

类的生命周期会经历以下 7 个阶段:

  • 加载阶段(Loading)
  • 验证阶段(Verification)
  • 准备阶段(Preparation)
  • 解析阶段(Resolution)
  • 初始化阶段(Initialization)
  • 使用阶段(Using)
  • 卸载阶段(Unloading)
    其中验证、准备、解析 3 个阶段统称为连接(Linking),如下图所示:
    类加载过程

加载阶段

此阶段用于查到相应的类(通过类名进行查找)并将此类的字节流转换为方法区运行时的数据结构,然后再在内存中生成一个能代表此类的 java.lang.Class 对象,作为其他数据访问的入口

需要注意的是加载阶段和连接阶段的部分动作有可能是交叉执行的,比如一部分字节码文件格式的验证,在加载阶段还未完成时就已经开始验证了

验证阶段

此步骤主要是为了验证字节码的安全性,如果不做安全校验的话可能会载入非安全或有错误的字节码,从而导致系统崩溃,它是 JVM 自我保护的一项重要举措
验证的主要动作大概有以下几个:

  • 文件格式校验包括常量池中的常量类型、Class 文件的各个部分是否被删除或被追加了其他信息等;
  • 元数据校验包括父类正确性校验(检查父类是否有被 final 修饰)、抽象类校验等;
  • 字节码校验,此步骤最为关键和复杂,主要用于校验程序中的语义是否合法且符合逻辑;
  • 符号引用校验,对类自身以外比如常量池中的各种符号引用的信息进行匹配性校验

准备阶段

此阶段是用来初始化并为类中定义的静态变量分配内存的,这些静态变量会被分配到方法区上。
HotSpot 虚拟机在 JDK 1.7 之前都在方法区,而 JDK 1.8 之后此变量会随着类对象一起存放到 Java 堆中。

解析阶段

此阶段主要是用来解析类、接口、字段及方法的,解析时会把符号引用替换成直接引用。

所谓的符号引用是指以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可;而直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

符号引用和直接引用有一个重要的区别:使用符号引用时被引用的目标不一定已经加载到内存中;而使用直接引用时,引用的目标必定已经存在虚拟机的内存中了。

初始化

初始化阶段 JVM 就正式开始执行类中编写的 Java 业务代码了。到这一步骤之后,类的加载过程就算正式完成了


# JVM