深入Java虚拟机(2):JVM内存结构详解

       我的上一篇讲了关于Java类的生命周期和类加载机制中有涉及到JVM虚拟机内存,这篇文章就详细介绍关于JVM内存的结构、内存中不同区域的主要职责,在描述各区域的职责的同时,还会说到具体不同内存区域中的具体结构空间以及这些结构布局的目的及特性,了解这些基础的东西对在今后学习或者工作开发当中出现了有关内存的问题就可以很快的定位问题然后解决问题,另外结合内存结构再学习关于垃圾回收相关的知识内容又可以进行更高级的jvm调优、GC调优等等,博主会通过文章一步一步的与大家一起学习、进阶,希望读者每一步的学习都能学的非常透彻。

      注:本文相关图片资源来源均在文尾给出参考文献来由

       JVM内存也就是经常说的运行时数据区,程序在计算机当中跑就意味着会用到内存来存放数据、程序执行的指令等,相比C++、C一些底层的开发语言来说,Java开发者不需要把重心放在对象在内存中的分配与销毁等工作,这样也就大大减少因人为造成的内存泄漏和内存溢出问题,简化了开发者的工作,而这些都是得益于Java虚拟机对内存的管理,包括运行时对对象自动分配内存空间,以及对对象的销毁工作(垃圾回收相关),结合上一篇的的内容,已经知道了java类被加载后在虚拟机内存的分布情况,本文不单讲述内存结构、每个内存的职责外还会讲解一个程序中类的实例、方法、字段、静态字段等在内存中的分布情况,从内存的角度去分析有关内存问题。

我们先讲述内存结构和每个区域的职责:

深入Java虚拟机(2):JVM内存结构详解_第1张图片

上面为jvm内存的结构图,java虚拟机启动之后会将某个内存空间划分一个运行时数据区,该区域里分布着上述五个区域,五个内存区域中方法区和堆是线程共享的,其他三个区域是每个线程在执行程序是自己独有的内存数据区,这也就说明方法区和堆中存放的内容可以给所有线程共同访问的,现在我们来看每个区域的详细解释:

Java堆(Heap)

职责: 简而言之就是存放对象实例(也就是程序创建类的实例)

根据堆的职责就会产生一个问题需要思考:大家知道一个应用程序中会涉及到频繁地对类进行实例创建(也就是new操作),那在整个程序运行中意味着堆中会不断的存放新的实例对象,而内存是有大小限制的,如果在堆中一直进行给实例对象分配空间,堆的空间肯定就不够用了,大家可以用程序无限创建实例看看,当堆的空间不够并无法扩展之后会抛出OutOfMemoryError异常,通过这个问题说明了堆这块内存区域是需要被进行空间管理的,那如何被管理呢?就是垃圾回收机制(GC),也正是因为这样的问题存在,所以java堆是垃圾回收器主要管理的区域,现在对堆的垃圾回收机制都是采用分代收集算法(在这里暂时不详细讲解垃圾回收机制,什么是分代收集算法、为什么堆会用这样的回收方式,我在GC(垃圾回收)算法和垃圾回收器这篇文章中有详细介绍),在这我先描述堆这块区域内的结构分布:

深入Java虚拟机(2):JVM内存结构详解_第2张图片

大家只看Heap这块区域同宽度的下面区域可以看出堆里面的结构划分为:年轻代和老年代,年轻代里面的结构又被划分为三个区域:Eden、FromSpace(Survivors1)和ToSpace(Survivors2),大家阅读到此时只需要对堆里面的结构划分有个印象即可,至于这样的结构布局的目的肯定是为了更好的进行垃圾回收的,具体的原因也在垃圾回收文章中详细介绍。

 

方法区(Method Area)

职责:存放加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

看过我Java类的生命周期和加载机制其实就可以知道在加载阶段的时候,其中的第二件事情就是将这些信息加载到方法区的,大家前面印象不深刻的话可以再去看下

方法区也需要进行空间管理,这个区域的内存空间管理主要是针对常量池的回收和对类型的卸载,毕竟在类被加载的时候就意味着上述这些数据要分配到方法区当中,那同样当方法区的内存空间不够再分配的时候,也会出现OutOfMemoryError异常。

 

JVM栈(JVM Stack)

职责: 每个方法被执行的时候会先在这个栈内存中创建一个栈帧,用来存储局部变量表、操作栈、动态链接、方法出口等信息,每个方法从开始到执行完成的过程,就对应着一个栈帧在JVM栈中从入栈到出栈的过程。

 其中局部变量表是存放编译期就已经知道的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用,这里的的对象引用不是指对象本身,不同的虚拟机它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置和returnAddress类型(指向了一条字节码指令的地址),下文介绍对象访问过程的内容中就会涉设计到局部变量表。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

现在又来思考一个问题:结合栈这样的数据结构特性(应该都知道栈类型的数据结构吧)和它的职责来思考,如果不断的进行方法里面调用另一个方法,或者无线递归调用的情况下,极限情况也会出现内存问题,因为JVM栈也是一块内存空间,也存在大小限制,那不断地创建栈帧然后入栈,当栈的深度大于虚拟机所允许的深度时,会抛出StackOverflowError(栈溢出)异常,这是其中的一种内存问题,虚拟机栈空间不够的时候大部分虚拟机是支持动态扩展的,即使扩展,但是在无限入栈然后扩展再扩展,当无法再扩展足够的内存时,就会出现与堆一样可能存在的OutOfMemoryError异常。

所以JVM栈是可能出现两种异常的,一种是栈的大小超过虚拟机限定的大小又不允许扩展栈大小时会出现StackOverflowError(栈溢出)异常,另一种是栈大小不够并且无法再扩展新的空间时会出现OutOfMemoryError异常,知道前面说的堆以及这里的JVM栈中可能会出现的内存异常问题后,大家在实际的项目当中出现这类异常的话就可以根据不同异常的底层原因去分析,然后尽快定位问题再解决问题。

 

本地方法栈(Native Method Stack)

职责:本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

也是栈结构,所以也会存在两种异常情况:StackOverflowError和OutOfMemoryError异常,具体的底层原因可以结合JVM处的分析。

 

程序计数器(Program Counter Register)

职责:说白了就是记录对应线程执行到哪了。

专业的描述就是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个这个程序计数器的指来选取下一条需要执行的字节码指令,分之、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

为什么需要这么个东西呢?首先大家知道程序在计算机中跑的时候,线程利用CPU资源的时候是有时间限制的,就是CPU是不断切换着线程去执行线程对应任务的,如果没有程序计数器的话,那某个线程在执行某个方法的时候,还没执行完这个方法CPU就切换到另外一个线程去执行了,那再次切回到这个线程的时候,这个线程就不知道上次是做了啥做到哪了,这是非常不合理的,也就是针对这个问题,才有了程序计数器来记录线程执行到了哪一行的字节码,以便在线程被挂起之后再次获取到CPU资源时能够从上次断开的位置继续执行。

程序计数器的空间是比较小的,它的职责很明确,从它的职责来看空间也没必要多大,这个内存区域也是五个区域中唯一一个没有内存异常的,因为它只是记录线程执行到哪里了,这样的记录信息不存需要很大的空间容量,每个线程都有其自己的程序计数器并且私有,线程间互不干扰。

五块区域的职责以及涉及的相关内容到这里就讲完了,大家到这里位置要知道的最重要的两点就是:每个区域的职责是什么和每个区域可能会出现的内存问题。

 

实例对象访问的过程

这部分内容会涉及到JVM栈中存放的局部变量表,还会涉及到堆、方法区这几块内存区域之间的关联关系,先看下面一段代码:

public class test {
    public void test(){
        Object obj = new Object();
    }
}

当执行test实例的test方法时,会创建一个栈帧并在JVM栈中入栈(结合上文JVM栈相关内容来思考),此时方法体里面的Object obj会存放到局部变量表(也叫本地变量表)中,obj在这里就只是一个Object类型的对象引用,而new Object()创建Object类型的实例数据存放到堆中,通过Java类的生命周期和类加载机制那边文章大家已经熟悉new实例的时候会触发初始化(初始化之前会先进行加载等阶段),那就意味着在方法区中就已经存放Object的类信息等内容,现在大家知道的是obj只是个引用,那后续要访问这个对象比如调用Object这个类的方法时,肯定是要知道这个对象在哪,而obj在JVM当前栈帧的局部变量表中,Object实例在堆中,那如何通过obj访问到所引用的实例呢?这就是实例对象的访问过程,Java有不同的虚拟机,访问对象的方式也不同,主流的访问方式有两种 :使用句柄和直接指针

下面来介绍两种不同的方式,本文章的图和下面的图都是在其他文章找的,我看很多文章都是一样图,所以这里也引用过来,文末会给出引用文章的地址,大家也可以去学习学习

使用句柄访问对象的方式

深入Java虚拟机(2):JVM内存结构详解_第3张图片

以上面例子来讲,obj就是存放在Java栈中的本地变量表中,用上图的reference(引用)代表,如何访问了,reference中存储的内容就是对象的句柄地址,该种方式在堆中才会有这么一块句柄池空间,而句柄中包含了对象实例数据以及对象类型数据的具体地址的指针,那么也就可以通过引用访问到了具体对象了,其中对象实例数据就是指创建的实例后的数据内容,对象类型数据是我上面提到的通过加载后在方法区中存放的数据。

使用指针访问对象的方式

深入Java虚拟机(2):JVM内存结构详解_第4张图片

该种方式在堆中就没有句柄池这么个空间了,reference中存储的内容就是对象在堆中地址指针信息。

这两种方式各有优势,在讲两种方式的优势之前,大家先要知道对象在堆中的地址是会变动的,就是所谓的”移动“,为什么会移动呢?这个垃圾回收有关,垃圾回收时移动对象是非常普遍的行为,具体原因可以看我后续的关于垃圾回收文章。

句柄其实就相当于加了一个中介存储表,句柄的优点就是对于reference来说它面对的只是句柄池,它存放的内容是稳定的就是句柄地址,而在实例对象在被移动之后,只会改变句柄池里面的存放内容,对于reference不需要改变,使用指针访问的优点其实就相当于少了一次查询(指针定位)的时间开销,HotSpot就是指针访问的方式。

 

实际代码例子分析内存分配情况

我们现在根据一段简单代码(我从自己的IDEA中随便找的一个)来分析不同的数据在JVM内存中的分配情况,来加深印象:

// 简单的单例模式(非线程安全)
public class SingletonExample1 {

    // 私有构造函数
    private SingletonExample1() {}

    // 单例对象
    private static SingletonExample1 instance = null;

    // 静态的工厂方法
    public static SingletonExample1 getInstance() {
        if (instance == null) {  //这个地方存在线程不安全
            instance = new SingletonExample1();
        }
        return instance;
    }
}

在内存中的分布情况分析:

堆:用来存放对象实例的,那么在堆中肯定存放着SingletonExample1的实例对象

方法区:存放类信息相关(就是我们可以通过反射获取来的类的信息、方法Method、字段Field等数据),那么在方法区就肯定存放着SingletonExample1、SingletonExample1类型的字段instance,以及getInstance的方法(Method)。

这个例子给的有点low,本主后续会更新更全面的例子,大家可以先找相关文章看看,这里先暂时用这个吧

 

异常错误信息分析原因

下面我们给出几种异常来分析是哪出来的问题:

Exception in thread “main”: java.lang.OutOfMemoryError: Java heap space

原因:堆内存报OutOfMemoryError异常,说明堆内存空间不够,对象不能再被分配到堆内存中

Exception in thread “main”: java.lang.OutOfMemoryError: PermGen space

原因:方法区报OutOfMemoryError异常,说明方法区内存空间不够,那类或者方法不能再被加载到方法区。在程序启动和运行过程中加载了太多的类,可能是引用了很多第三方的库等原因

Exception in thread “main”: java.lang.OutOfMemoryError: Requested array size exceeds VM limit

原因:数组也是对象,创建的数组大于堆内存的空间

Exception in thread “main”: java.lang.OutOfMemoryError: request  bytes for . Out of swap space?

原因:分配本地分配失败。JNI、本地库或者Java虚拟机都会从本地堆中分配内存空间。

Exception in thread “main”: java.lang.OutOfMemoryError:  (Native method)

原因:同样是本地方法内存分配失败,只不过是JNI或者本地方法或者Java虚拟机发现

 

JVM内存大小配置

JVM内存空间是可以通过自己自定义配置的,大家可能之前也看过相关配置参数,下面我们来看下大小配置参数:

深入Java虚拟机(2):JVM内存结构详解_第5张图片

控制参数:

  • -Xms设置堆的最小空间大小。
  • -Xmx设置堆的最大空间大小。
  • -XX:NewSize设置新生代最小空间大小。
  • -XX:MaxNewSize设置新生代最大空间大小。
  • -XX:PermSize设置永久代最小空间大小。
  • -XX:MaxPermSize设置永久代最大空间大小。
  • -Xss设置每个线程的堆栈大小。

上图可以看出堆内存中没有设置老年代的参数,不过可以通过设置堆空间大小和年轻代的大小来控制老年代的空间大小的。知道这些配置参数之后,就可以在项目当中根据实际的情况来进行配置各区域空间的大小,虽然用的比较少,大家只要知道可以这么做就好了,另外大家可以网上找下有很多文章介绍相关的配置方式。

 

参考文献:

1.http://www.cnblogs.com/gw811/archive/2012/10/18/2730117.html

2.http://www.ityouknow.com/jvm/2017/08/25/jvm-memory-structure.html

上面两个地址都是两位大牛博主,有很多好文章,大家可以去学习学习

 

 

你可能感兴趣的:(Java基础之JVM章节)