深入理解Java虚拟机之(一):JVM管理的内存结构

一:java代码编译执行过程(粗略介绍)
 1.源码编译:通过Java源码编译器将Java代码编译成JVM字节码(class文件)

    2.类加载:通过ClassLoader及其子类来完成JVM的类加载

    3.类执行:class文件字节码(8位2进制字节流,class文件中有字节码指令即虚拟机指令)被装入内存,进入JVM虚拟机(由虚拟机进行内存分配),,被解释器解释执行字节码指令,但是也得在映射(翻译)到CPU指令集或OS系统调用

二:JVM内存结构(只包括运行时数据区)
在这里插入图片描述
通过此图理解Java虚拟机栈,Java堆,方法区存的内容:
在这里插入图片描述
运行时数据区:
    这几个区域各有各有各的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

(1)有些区域则是依赖用户线程的启动和结束而建立和销毁?
答:程序计数器,Java虚拟机栈,线程私有,生命周期与线程相同,随用户线程启动而生,用户线程关闭而销毁。
(2)有的区域随着虚拟机进程的启动而一直存在?
答:java堆被所有线程共享的一块内存区域,在虚拟机启动时创建

(1):程序计数器
    程序计数器是一个较小的内存空间,它可以看成是当前线程执行的字节码行号计数器。字节码(class文件中就是字节码)解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要计数器来完成。
    由于Java虚拟机的多线程是通过线轮流切换,分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各个线程之间互不影响,独立存储,这块区域被称为线程私有的内存。

举例说明程序计数器作用:
(1)如果线程 正在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
(2)如果正在执行的是本地(Native)方法,这个计数器的值应该为空,此区域是唯一一个在《Java虚拟机规范》中没有规范任何OutOfMemoryError情况的区域。

(2):Java虚拟机栈
    Java虚拟机栈也是线程私有的,它的生命周期与线程是一样的。虚拟机栈描述的是Java执行的内存模型:每个方法执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个方法被被调用直至执行完毕,就对应着一个栈帧在虚拟机中从入栈到出栈并被销毁。
在这里插入图片描述

虚拟机栈的特点?
答:(2.1)虚拟机栈是线程隔离的,即每个线程都有自己独立的虚拟机栈。
     (2.2)每个方法被被调用直至执行完毕,就对应着一个栈帧在虚拟机中从入栈到出栈并被销毁

    我们常说的“堆内存、栈内存”中的“栈内存”指的便是虚拟机栈,确切地说,指的是虚拟机栈的栈帧中的局部变量表,因为这里存放了一个方法的所有局部变量。
    局部变量表存放了编译可知的各种Java虚拟机基本数据类型(boolean,byte,char,short,int,long,double),对象引用。这些数据类型在局部变量表中的存储空间以**局部变量槽(Slot)**来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余数据类型只占用一个变量槽。局部变量表所需的内存空间在编译器完成分配,当进入一个方法时,这个方法需要在栈帧分配多大的局部变量空间是完全确定的,在运行期间不会改变dueng局部变量表的大小,(其实这里说的"大小"指的是变量槽的数量),虚拟机真正使用多大的内存空间(比如一个变量槽占用32个bite,64bite或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

虚拟机栈的StackOverflowError?
答:(2.1)线程请求的栈深度大于虚拟机所允许的深度
       (2.2)虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存。

(2.1)线程请求的栈深度大于虚拟机所允许的深度举例:
    JVM会为每个线程的虚拟机栈分配一定的内存大小(-Xss参数),因此虚拟机栈能够容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽,典型如一个无结束条件的递归函数调用,代码见下:

/**
 * java栈溢出StackOverFlowError
 * JVM参数:-Xss128k
 * Created by chenjunyi on 2018/4/25.
 */
public class JavaVMStackSOF {

    private int stackLength = -1;

    //通过递归调用造成StackOverFlowError
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack length:" + oom.stackLength);
            e.printStackTrace();
        }
    }

}

设置单个线程的虚拟机栈内存大小为128K,执行main方法后,抛出了StackOverflow异常

Stack length:983
java.lang.StackOverflowError
    at com.manayi.study.jvm.chapter2._02_JavaVMStackSOF.stackLeak(_02_JavaVMStackSOF.java:14)
    at com.manayi.study.jvm.chapter2._02_JavaVMStackSOF.stackLeak(_02_JavaVMStackSOF.java:15)
    at com.manayi.study.jvm.chapter2._02_JavaVMStackSOF.stackLeak(_02_JavaVMStackSOF.java:15)
    ······

(2.2)虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存举例。
    不同于StackOverflowError,OutOfMemoryError指的是当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时抛出的异常。
 JVM未提供设置整个虚拟机栈占用内存的配置参数。虚拟机栈的最大内存大致上等于“JVM进程能占用的最大内存(依赖于具体操作系统) - 最大堆内存 - 最大方法区内存 - 程序计数器内存(可以忽略不计) - JVM进程本身消耗内存”。当虚拟机栈能够使用的最大内存被耗尽后,便会抛出OutOfMemoryError,可以通过不断开启新的线程来模拟这种异常,代码如下:

**
 * java栈溢出OutOfMemoryError
 * JVM参数:-Xss2m
 * Created by chenjunyi on 2018/4/25.
 */
public class JavaVMStackOOM {

    private void dontStop() {
        while (true) {
        }
    }

    //通过不断的创建新的线程使Stack内存耗尽
    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(() -> dontStop());
            thread.start();
        }
    }

    public static void main(String[] args) {
        JavaVMStackOOM oom = new _03_JavaVMStackOOM();
        oom.stackLeakByThread();
    }

}

设置单个线程虚拟机栈的占用内存为2m并不断生成新的线程,最终虚拟机栈无法申请到新的内存,抛出异常:

 Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

(3):本地方法栈
    本地方法栈与虚拟机栈所发挥的作用非常相似,其区别是虚拟机为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是虚拟机使用到的本地方法(Native)服务。
    《Java虚拟机规范》对本地方法栈使用的语言,使用方式与数据结构并没有任何强制规定,因此具体虚拟机可以根据需要自由去实现它。与虚拟机栈一样,本地方法栈也会有栈深度溢出或者扩展失败时抛出StackOverflowError和OutOfMemoryError异常。

    Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。
在这里插入图片描述

本地方法栈特点:
    答:(3.1)本地方法栈是一个后入先出(Last In First Out)栈。
    (3.2)由于是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
    (3.3)本地方法栈会抛出 StackOverflowError 和 OutOfMemoryError 异常。
    (3.4)虚拟机栈是线程隔离的,即每个线程都有自己独立的 本地方法栈。

(4):Java堆
    Java虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里"几乎"所有对象实例都存放在这里。
在这里插入图片描述

“几乎”解释:
答:这里几乎是从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持。

    所有的对象实例以及数组都要在堆上分配,此内存区域的唯一目的就是存放对象实例

    堆是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建

堆是理解Java GC机制最重要的区域,没有之一
  结构:新生代(Eden区+2个Survivor区) 老年代 永久代(HotSpot有)
  新生代:新创建的对象——>Eden区
  GC之后,存活的对象由Eden区 Survivor区0进入Survivor区1
  再次GC,存活的对象由Eden区 Survivor区1进入Survivor区0
  老年代:对象如果在新生代存活了足够长的时间而没有被清理掉(即在几次Young GC后存活了下来),则会被复制到老年代
    永久代:可以简单理解为方法区(本质上两者并不等价)
其实分成这几个目的:无论是哪个区域,存储的都是对象实例,细分目的为了更好的回收内存,或者更块的分配内存。

在JVM运行时,可以通过配置以下参数改变整个JVM堆的配置比例
1.Java heap的大小(新生代+老年代)
  -Xms堆的最小值
  -Xmx堆空间的最大值
2.新生代堆空间大小调整
  -XX:NewSize新生代的最小值
  -XX:MaxNewSize新生代的最大值
  -XX:NewRatio设置新生代与老年代在堆空间的大小
  -XX:SurvivorRatio新生代中Eden所占区域的大小
3.永久代大小调整
  -XX:MaxPermSize
4.其他
  -XX:MaxTenuringThreshold,设置将新生代对象转到老年代时需要经过多少次垃圾回收,但是仍然没有被回收。

(5):方法区
    方法区和Java堆一样,是各个线程共享的内存区域,它用于存已经被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等。《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它有一个别名叫做“非堆”,目的是与Java堆区分开。
保存在着被加载过的每一个类的信息;这些信息由类加载器在加载类的时候,从类的源文件中抽取出来;static变量信息也保存在方法区中;
方法区是线程共享的;当有多个线程都用到一个类的时候,而这个类还未被加载,则应该只有一个线程去加载类,让其他线程等待;

方法区可以存放即时编译器编译后的代码缓存理解?
答:概述:JIT编译期能在JVM发现热点代码时,将这些热点代码编译成与本地平台相关的机器码,并进行各个层次的优化,从而提高热点代码的执行效率。
    热点代码:某个方法或代码块运行频繁。
    JIT编译器(Just In Time Compiler):即时编译器。
    目的:提高热点代码的执行效率。

方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。jvm也可以允许用户和程序指定方法区的初始大小,最小和最大限制;

方法区同样存在垃圾收集,因为通过用户定义的类加载器可以动态扩展Java程序,这样可能会导致一些类,不再被使用,变为垃圾。这时候需要进行垃圾清理。

图例(方法区中都保存什么)
在这里插入图片描述

(4.1)类型信息:
包括以下几点:
    类的完整名称(比如,java.long.String)
    类的直接父类的完整名称
    类的直接实现接口的有序列表(因为一个类直接实现的接口可能不止一个,因此放到一个有序表中)
    类的修饰符。
(4.2)类型的常量池 (即运行时常量池)
    每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 。字面值:就是像string, 基本数据类型,以及它们的包装类的值,以及final修饰的变量,简单说就是在编译期间,就可以确定下来的值符号引用:不同于我们常说的引用,它们是对类型,域和方法的引用,类似于面向过程语言使用的前期绑定,对方法调用产生的引用。存在这里面的数据,类似于保存在数组中,外部根据索引来获得它们 。
(4.3)字段信息
    声明的顺序
    修饰符
    类型
    名字
(4.4)方法信息
    声明的顺序
    修饰符
    返回值类型
    名字
    参数列表(有序保存)
    异常表(方法抛出的异常)
    方法字节码(native、abstract方法除外,)
    操作数栈和局部变量表大小。
(4.5)类变量(即static变量)
    (4.5.1)非final类变量
        在java虚拟机使用一个类之前,它必须在方法区中为每个非final类变量分配空间。非final类变量存储在定义它的类中。
    (4.5.2)final类变量(不存储在这里
        由于final的不可改变性,因此,final类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面 ;final类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;
(4.6)对类加载器的引用
jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。
jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。
(4.7)对Class类的引用
jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用。

为了显示jvm如何使用方法区中的信息,我们据一个例子

class Lava { 
    private int speed = 5; // 5 kilometers per hour 
    void flow() { 
    } 
} 

class Volcano { 
    public static void main(String[] args) { 
        Lava lava = new Lava(); 
        lava.flow(); 
    } 
} 

下面我们描述一下main()方法的第一条指令的字节码是如何被执行的。不同的jvm实现的差别很大,这里只是其中之一。

    为了运行这个程序,你以某种方式把“Volcano”传给了jvm。有了这个名字,jvm找到了这个类文件(Volcano.class)并读入,它从
类文件提取了类型信息并放在了方法区中,通过解析存在方法区中的字节码,jvm激活了main()方法,在执行时,jvm保持了一个指向当前类(Volcano)常量池的指针。
    注意jvm在还没有加载Lava类的时候就已经开始执行了。正像大多数的jvm一样,不会等所有类都加载了以后才开始执行,它只会在需要的时候才加载。
    main()的第一条指令告知jvm为列在常量池第一项的类分配足够的内存。jvm使用指向Volcano常量池的指针找到第一项,发现是一个对Lava类的符号引用,然后它就检查方法区看lava是否已经被加载了。
    这个符号引用仅仅是类lava的完整有效名”lava“。这里我们看到为了jvm能尽快从一个名称找到一个类,一个良好的数据结构是多么重要。这里jvm的实现者可以采用各种方法,如hash表,查找树等等。同样的算法可以用于Class类的forName()的实现。
    当jvm发现还没有加载过一个称为”Lava”的类,它就开始查找并加载类文件”Lava.class”。它从类文件中抽取类型信息并放在了方法区中。
    jvm于是以一个直接指向方法区lava类的指针替换了常量池第一项的符号引用。以后就可以用这个指针快速的找到lava类了。而这个替换过程称为常量池解析(constant pool resolution)。在这里我们替换的是一个native指针。

jvm终于开始为新的lava对象分配空间了。这次,jvm仍然需要方法区中的信息。它使用指向lava数据的指针(刚才指向volcano常量池第一项的指针)找到一个lava对象究竟需要多少空间。

jvm总能够从存储在方法区中的类型信息知道某类型对象需要的空间。但一个对象在不同的jvm中可能需要不同的空间,而且它的空间分布也是不同的。(译者:这与在C++中,不同的编译器也有不同的对象模型是一个道理)

一旦jvm知道了一个Lava对象所要的空间,它就在堆上分配这个空间并把这个实例的变量speed初始化为缺省值0。假如lava的父对象也有实例变量,则也会初始化。

    当把新生成的lava对象的引用压到栈中,第一条指令也结束了。下面的指令利用这个引用激活java代码把speed变量设为初始值,5。另外一条指令会用这个引用激活Lava对象的flow()方法。

(6):运行时常量池
    运行时常量池是方法区的一部分。
    Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。
 类加载后,常量池中的数据会在运行时常量池中存放!
 这里所说的常量包括:基本类型包装类(包装类不管理浮点型,整形只会管理-128到127)和String(也可以通过String.intern()方法可以强制将String放入常量池)

(7):直接内存
    直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。
    在JDK1.4版本中加入了NIO类,引入了基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数直接分配堆外内存,也就是说通过这种方式,不会在运行时数据区域分配内存,这样就避免了在运行时数据区域来回复制数据,直接调用外部内存。
    显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。

发布了163 篇原创文章 · 获赞 9 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_41987908/article/details/103904829
今日推荐