Java虚拟机--虚拟机内存区域

作者: 贾博岩 | 来源:发表于2018-03-28 21:50 被阅读1204次

    文末有彩蛋!!!!!!

    Java虚拟机内存区域

    Java程序在JVM虚拟机中运行,当我们一个类被加载到虚拟机中时,JVM会给该类分配具体的内存空间/内存地址,而这被分配的区域就是Java虚拟机运行时内存区域。那么,该片区域到底有什么,又可以做什么,接下来就来一一解答。

    在讨论JVM内存区域划分之前,先来看一下Java程序具体执行的过程:

    首先是我们的编写的Java源代码文件---xxx.java,该文件会被Java编译器编译成字节码文件---xxx.class,然后Java虚拟机开始启动,Java虚拟机通过类加载器加载xxx.class字节码文件,加载完毕之后(加载过程后续会讲到),再交由Java虚拟机的执行引擎进行实际的运行。

    在后续的执行过程中,Java虚拟机会用一部分内存区域来存储程序运行期间所需要用到的数据和相关信息,通常我们称这部分内存区域叫做Runtime Data Area---运行时数据区,也就是我们常说的Java虚拟机内存。

    Java运行时数据区会被分割为若干个不同的区域,这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖程序中线程的启动而建立,结束而销毁。

    根据《Java虚拟机规范(Java SE 7版本)》的规定,Java虚拟机所管理的内存包括如下几个Java运行时候数据区域:

    image

    程序计数器

    在《Java虚拟机规范(Java SE 7版本)》中,程序计数器又叫做“PC寄存器”,每一个Java线程都有自己的“PC寄存器”。在PC寄存器中,保存的是当前线程中正在执行的字节码指令偏移地址,说的直白点就是保存着当前线程正在执行字节码的行号。

    由于Java虚拟机支持多线程操作,线程又是CPU最小的调度单元,那么在任意一个时间点时,CPU只会执行具体的某一个线程,而当我们的Java虚拟机在进行线程切换时,我们需要让CPU可以正确的执行该线程,所以Java虚拟机在每一个线程中维护了一个PC寄存器(程序计数器),保存的就是线程中代码的行号。

    由于PC寄存器(程序计数器)是每个线程独有的,所以在各个线程之间的PC寄存器(程序计数器)互不影响,独立存储,我们称这类内存区域为“线程私有内存区”。

    说了这么多,有没有什么方法,能更直观的了解程序计数器呢?答案是肯定的,下面我们就通过javap命令来进一步了解。

    首先,我们先简单写一个Java程序,代码如下:

    public class Test {
        public void test(){
            int x = 1;
            int y = 2;
            int z = 3;
            int xx = x + y + z;
        }
    }
    

    接下来,对Test.java文件,进行编译,得到Test.class文件。

    再对Test.class文件使用javap命令操作:

    image

    如上图所示,在Test类中,除了我们显式声明的test()方法之外,还有默认的构造方法。

    图中,画红色框框的中,就是PC寄存器(程序计数器)中存储的字节码指令的偏移地址0 1 2 3....,而偏移地址对应的aload_0、invokespecial #1等就是Java虚拟机中具体的操作指令。

    在我们程序运行期间,每个线程中随着代码的运行,PC寄存器(程序计数器)中的数值也随着改变,由于只是改变具体的数值,由0变1、由1变2等等,而不会随着程序的运行需要更大的内存空间,所以在PC寄存器(程序计数器)中并不会发生内存溢出情况,也就是程序中不会由PC寄存器(程序计数器)抛出OutOfMemoryError异常。

    Java虚拟机栈

    与PC寄存器(程序计数器)一样,Java虚拟机栈也是线程私有的,生命周期与线程相同,随着线程的诞生而创建,随着线程的结束而销毁。

    Java虚拟机栈用于存储栈帧,说白了就是用于存储方法中的局部变量、方法的返回地址以及动态链接等。对于方法来说,栈帧在方法的调用和返回中扮演了十分重要的作用。

    栈帧是什么?

    栈帧是一种数据结构,用于虚拟机方法的调用和执行。每一个方法的执行和结束对应着栈帧的入栈和出栈,入栈表示方法被调用,出栈表述方法执行完毕或者抛出异常。

    每个线程中都有方法在执行,虚拟机为每一个线程分配一定的栈内存空间,当前CPU调度的那个线程叫做活动线程;每执行一个方法就会新产生一个一个栈帧。当前活动线程的虚拟机栈中最顶部的栈帧代表了当前正在执行的方法,而这个栈帧也被叫做‘当前栈帧’。

    局部变量

    栈帧中存储这方法的中使用到的局部变量,有方法的参数,或者方法中定义的局部变量。

    方法的返回地址

    返回一个值给调用的方法。Java虚拟机根据不同数据类型有不同的return指令。当被调用方法执行某条return指令时,会选择相应的return指令来返回。

    栈帧--动态链接

    一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,需要知道被调用者的名字,这些被调用者的名字就存放在Java字节码文件里。
    此时,名字知道了,但是Java真正运行起来的时候,需要将名字解析成相应的直接引用,利用直接引用来准确地找到具体的内存地址,找到内存地址中的值。这个名字就叫做 符号引用。

    举个例子,就相当于我在0X0300H这个地址存入了一个1234的数字,为了方便编程,我把这个给这个地址起了个Integer A = 1234, 在整个程序中我可以用A来访问这个空间的数据,但其实程序运行起来后,实质上还是去寻找0X0300H这片空间来获取1234这个数字。

    这样的符号引用和直接引用在运行时进行解析和链接的过程,叫动态链接。

    在《Java虚拟机规范(Java SE 7版本)》中,Java虚拟机栈可以被设计成固定大小或者随着程序的执行动态扩展和收缩的形态。不过,在我们最长用的Sun HotSpot中好像并没有提供动态扩展的参数,只能被设定成固定大小 -Xss2M指令。

    如果采用固定大小的设计,那么允许程序员调节虚拟机栈的初始容量。

    对于采用动态扩展的设计,那么就需提供调节其最大、最小容量的手段。

    与PC寄存器(程序计数器)不同的是,Java虚拟机栈中会抛出异常,这在递归方法中经常出现,或者创建线程过多时候也容易出现。

    StackOverflowError:

    如果一个执行线程请求分配的栈容量超过了Java虚拟机栈为该线程分配的最大栈容量时,就会抛出StackOverflowException异常。例如:递归操作,无限递归.

    每当Java程序启动一个新的线程时,Java虚拟机会为该线程分配一个栈空间,Java虚拟机栈以栈帧为单位保持线程运行状态,每当线程调用一个方法时候就会向线程栈里面压入一个栈帧,只要这个方法还没返回,这个栈帧就存在。所以如果一个线程中的方法的嵌套调用层次太多(例如:递归调),那么就会随着该线程栈中的栈帧增多,最终导致该线程栈中所有栈帧的大小的总和大于-Xss设置的值,进而产生StackOverflowError异常。

    递归异常:

    public class JVMTest {
    
        private int length = 1;
    
        public void test(){
            length++;
            test();
        }
    
        public static void main(String[] agrs) throws Throwable {
            JVMTest test = new JVMTest();
            try{
                test.test();
            }catch (Throwable th){
                System.out.println("stack length:"+test.length);
                throw th;
            }
        }
    }
    

    递归异常测试结果:

    -Xss1024K

    image

    -Xss2048K

    image

    OutOfMemoryError:

    Java虚拟机栈可以动态扩展,当程序在进行扩展时候无法得到所需的内存时候,那么就会抛出OutOfMemoryError异常。

    假如:Jav虚拟机被分配了3G内存,其中减去堆的最大容量,再减去方法区的最大容量,还剩2G内存,此时我们创建了100个线程,每个线程分配15M内存,还剩余500M。这样的话,如果Java程序继续新建线程的话,就会在剩余的500M里申请。但是,系统对每一个线程分配的内存是一定的,新建的线程越多剩下的内存就越少,直到内存不足,不能再新建县城时候就会出现OutOfMemoryError异常。

    线程过多异常:

    public class JVMTest {
    
        public void test(){
            while(true){
                Thread thread = new Thread(new Runnable() {
                    public void run() {
                        dontStop();
                    }
                });
                thread.start();
            }
        }
    
        public void dontStop(){
            while(true){
                System.out.println(System.currentTimeMillis());
            }
        }
    
        public static void main(String[] agrs) throws Throwable {
            JVMTest test = new JVMTest();
            test.test();
        }
    }
    

    线程过多测试结果:

    image

    值得说明的是,笔者使用如上代码在进行测试时,偶尔才会出现如上异常,不知原因为何,如果你能更容易的复现的话,及时告诉我一下!

    本地方法栈

    在Sun HotSpot虚拟机中,本地方法栈和虚拟机栈并没有做任何区分,二者合二为一。在其他虚拟机实现上,略有不同。

    本地方法栈为虚拟机执行的本地Native方法,而虚拟机栈则执行的是普通的Java方法。

    Java虚拟机堆

    在Java虚拟机中,Java虚拟机堆是各个线程中共享的内存区域,也是我们Java程序中新建的对象数数组锁分配内存的区域。

    Java虚拟机堆在启动的时候就被创建了,我们通常所说的GC垃圾回收,回收的内存区域就是Java虚拟机堆。在Java虚拟机堆中,还可以细分为新生代、老年代,对于这两者我们后续在做讲解。

    从内存分配的角度来看,线程共享的Java虚拟机堆中可能会被划分出多个线程私有的分配缓冲区(TLAB)。分配缓冲区(TLAB)的存在只是为了GC可以更快更好的回收内存,再分配内存。

    根据,Java虚拟机规范所规定,Java虚拟机堆可以被是实现成固定大小,也可被实现成可扩展的。在Sun HotSpot中,我们通常使用-Xms和-Xmx指令来控制堆的大小。

    与Java虚拟机栈一样,当堆中没有内存可继续分配实例时候,并且也再无法扩展时候,就会抛出OutOfMemoryError异常。

    堆异常:

    大量的创造对象,并且此对象无法被垃圾回收。

    public class JVMTest {
    
        public static void main(String[] agrs){
            List<Object> objectList = new ArrayList<Object>();
            while(true){
                objectList.add(new Object());
            }
        }
    }
    

    堆异常测试结果:

    为了让异常尽快发生,调整了Java虚拟机参数,具体值为 -Xms20M -Xmx30M

    image

    方法区

    与Java堆一样,方法区也属于线程共享内存区域,它的主要作用就是存储已被虚拟机加载的类信息、常量、静态变量等数据。现阶段来说,方法区与Java堆在逻辑上不做区分,方法区属于Java堆的一部分。

    这么做的主要目的,或许是能让垃圾收集器可以像Java堆一样管理这部分内存,省去对方法区单独编码的工作。根据Java虚拟机规范要求,当方法区无法满足内存分配时,也会抛出OutOfMemoryError异常。

    方法区具体存储数据如下:

    类名

    在方法区中,存储这类的全限定类名,也就是包名+类名

    父类/接口

    在方法区中,存储这该类的父类,或者是该类的接口的全限定类名

    类与接口的区分标识

    标识这该类到底是接口还是一个类

    权限修饰符

    类型的访问修饰符,如public、abstract、final等

    常量池

    常量池中存储着该类所用到的常量集合,例如:String CONSTANT_S = "123"、int CONSTANT_I = 1

    变量信息

    类中所有成员变量的信息,包含变量的修饰符、变量类型、变量名称、变量初始值

    方法信息

    与变量信息类似,方法信息包含类中方法的修饰符、返回值、方法名、参数列表、方法字节码(就是方法的实际内容是啥)、异常表

    静态变量

    由于静态变量量是所有该类生成的对象所共享的,因此并不保存在堆、栈中,而是保存在方法区中。即使没有任何该类的实例对象,也可以访问这些静态变量。存储了:该静态变量的修饰符、变量名、初始值

    装载该类的类加载器引用

    在程序中,我们调用String.class.getClassLoader()方法便可获取到该类的类加载器,在方法区中保存这该类的类加载器

    java.lang.Class对象的引用

    在加载过程中,虚拟机会创建一个代表该类的Class对象,方法区中必须保存对该Class对象的引用,可以通过Class类的forName静态方法来得到该Class对象。例如Class.forName("java.lang.Thread")将会返回一个代表Thread类的Class对象。

    如果虚拟机无法将Class类加载到当前的命名空间,则会抛出ClassNotFoundException。此外,还可以通过Class对象获取到该类的一些类型信息,这就是实现反射的基础。

    方法表

    Java虚拟机会对每个非抽象类,都声明一个方法表,主要将可能使用到的方法的直接引用保存到方法区中,包括从父类继承而来的方法。在运行期间可以通过方法表快速寻找到对象调用的方法,也就是实际的保存在方法区中的方法字节码。

    常量池

    Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池。

    所谓静态常量池,即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,占用class文件绝大部分空间。

    而运行时常量池,则是Java虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。

    常量池在Java用于保存在编译期已确定的,是已编译的class文件中的一份数据。它包括了关于类,方法,接口等中的常量,也包括字符串常量,如String s = "java"这种申明方式。

    此外,除了上面两种常量池之外,还有一类特殊的常量池,那就是字符串常量池。下面,我们分别对上述三种一一讲解。

    字符串常量池

    说直白点,就是存放我们程序中写的字符串

    在Java1.6中,字符串常量池存放在方法区中;在Java1.7后,字符串常量池存放在了Java虚拟机堆中。

    为什么要设计字符串常量池呢?

    和其他的对象分配一样,Java虚拟机是需要消耗一定的资源的。作为最基础的数据类型,大量频繁的创建字符串,会极大程度地影响程序的性能。所以将我们所创建的字符串常量缓存到字符串常量池中。

    Java虚拟机为了节省性能和内存的开销,在实例化字符串时进行了一些优化,首先为字符串开辟一个字符串常量池,可以理解为缓存区。在创建字符串常量时,首先坚持字符串常量池是否存在该字符串。存在该字符串,返回引用实例。若不存在,则实例化该字符串并放入池中。

    在Java虚拟机中,字符串常量池的实现基于一个String Table,它是一个hash表结构,默认长度是1009。该Table是整个Java虚拟机所共享的,每一个字符串都会放到该表中。在Java1.6中,该表的长度固定,就是1009,当程序中创建大量字符串时,容易出现哈希冲突,进而引起hash表中的链表增长,影响程序的性能。为了解决此问题,在Java1.7中Java虚拟机可通过-XX:StringTableSize 指令来设置Table的长度。

    此外,由于该hash表一直维护着字符串的引用,所以字符串常量池中字符串并不会被垃圾回收器所回收。

    举例:

    String str1 = “abc”;
    
    String str2 = “abc”;
    
    String str3 = “abc”;
    
    String str4 = new String(“abc”);
    
    String str5 = new String(“abc”);
    
    image

    当发现abc在字符串常量池中存在时,就不会再继续创建相同的实例,而是直接返回,将其引用直接指向字符串常量池中的abc;

    String str1 = “abc”;
    
    String str2 = “abc”;
    
    System.out.printl("str1 == str2" : str1 == str2 ) //true 
    

    在String类中,有一个intern()方法,该方法是将字符串添加到常量池中,也就是说我们在new String("abc")时,我们会在堆中创建一个new String的对象,并且还会在字符串常量池中创建一个“abc”。

    当我们调用intern()方法时,会将“abc”在字符串常量池中创建。你可能会疑问,我们在new String("abc")时候不已经创建了吗,那intern()方法的作用又有啥意义呢?

    请看如下代码:

    String s = new String("1");  
    
    s.intern();  
    
    String s2 = "1";  
    
    System.out.println(s == s2); 
    
    String s3 = new String("1") + new String("1");  
    
    s3.intern();  
    
    String s4 = "11";  
    
    System.out.println(s3 == s4);  
    

    结果:

    Java1.6:false false
    
    Java1.7:false true
    

    String s3 = new String("1") + new String("1"),这行代码在字符串常量池中生成“1” ,并在堆空间中生成s3引用指向的对象(内容为"11"),注意此时常量池中是没有 “11”对象的。

    当调用s3.intern(),这一行代码时,是将s3中的“11”字符串放入字符串常量池中。但是,在Java1.6是直接在常量池中生成一个 "11" 的对象;而在Java1.7中常量池中不再存储“11”了,而是直接存储new String("11")在堆中的引用。所以再Java1.7中结果为true.

    Java1.6 字符串常量池在方法区

    image

    Java1.7 字符串常量池在堆区

    image

    class常量池

    class常量池,就是class文件的资源仓库,在.java文件经过javac编译后产生.class文件中,存放这编译期生成的各种字面量和符号引用。在程序运行期间,进入到运行时常量区。

    字面量就是我们java语言层面上的概念,如文本字符串、声明为final的常量值等。而符号引用包含了“类和接口的全限定类名”、“字段的名称和描述符”、“方法的名称和描述符”。

    image

    关于class常量池具体的解析,请等待笔者后续class文件解析的文章。

    运行时常量池

    运行时常量池,是Java虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。

    当类被加载到内存中后,Java虚拟机会将class常量池中的内容存放到运行时常量池中,每个类都保存在运行时常量池中。在解析阶段,Java虚拟机会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的String Table,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

    运行时常量池属于方法区的一部分,当内存不足时,就会抛出OutOfMemoryError异常。

    可伸缩服务架构-框架与中间件

    京东购买链接:可伸缩服务架构-框架与中间件

    相关文章

      网友评论

      本文标题:Java虚拟机--虚拟机内存区域

      本文链接:https://www.haomeiwen.com/subject/ouxpcftx.html