【深入理解JVM-Java内存区域】

众所周知C,C++的内存管理是由开发人员控制的(什么malloc,free之类的),但是在java中这个权利交给了jvm(java虚拟机)由虚拟机的自动内存管理机制管理。这样为java程序员带来了好处,即不需要手动的为每一个new操作去delete/free了,所以相对来说不容易出现内存溢出问题,但是一旦出现内存溢出,如果对虚拟机怎样使用内存不了解,那么排查错误会很困难的。

一、运行时数据区域

java虚拟机执行java程序时会把它管理的内存划分为若干个不同的数据区域。

1、图解

在这里插入图片描述

jvm运行数据区域图

在这里插入图片描述

2、五个运行时数据区域
  • 程序计数器
  • java虚拟机栈
  • 本地方法栈
  • java 堆
  • 方法区

(1)程序计数器

  • 较小的一块内存空间

  • 内存属于线程私有

  • 可以看做当前线程执行字节码指示器

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

  • 在虚拟机的概念模型中,字节码指示器就是通过改变这个程序计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程回复等基础功能都需要依赖这个计数器完成。

(2)java虚拟机栈

  • 线程私有(生命周期与线程相同)
  • java虚拟机栈描述的是java方法执行的内存模型(函数即方法在在这块内存区域中执行)
  • 每个方法在执行的同时都会创建一个“栈帧”,每一个方法从调用直至执行完成的过程,就对应着一个栈帧虚拟机入栈出栈的过程出栈。(参考下函数调用图)
// 假设执行了如下两个方法,则函数调用图如下
     new Person().name()
                 .age();

在这里插入图片描述

函数调用图

回顾:

我们经常说基本类型和引用类型在栈上分配,new 对象时,内存分配在堆上分配。其实这句话不严谨。

缘由:

我们在学习java基础的时候经常会有人吧java的内存划分为:栈,堆。这两块。其实这两种分法比较粗糙,这种划分方式只能说明大多数程序员最关注的,与对象内存分配关系最密切的内存区域是这两块。其实他们所说的“栈”是虚拟机栈中的局部变量表部分。局部变量表存放了编译期可知的各种数据类型:八中常见的数据类型,引用类型(可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置),和return Address类型(指向了一条字节码指令的地址)。

更正:

基本类型和引用类型在栈(实际是局部变量表)上分配,new 对象时,内存分配在堆上分配。

虚拟机规范对这个区域规定了两种异常状况:
1、线程请求的栈深度大于虚拟机所允许的深度jvm抛出StackOverflowError
2、如果虚拟机的栈可以动态扩展,如果扩展时无法申请到足够的内存jvm就会抛出OOM
ps:当前大部分的虚拟机都可以动态扩展,只不过虚拟机规范中也允许固定长度的虚拟机栈

帧栈组成简介:

  • 局部变量表

1、主要保存函数的参数,以及局部变量信息
2、函数调用结束后随着栈帧的销毁局部变量表也会随之销毁释放空间
3、如果函数的参数多或者局部变量定义的多,会使局部变量表膨胀,每次调用方法会占用更多的栈空间 最终导致栈空间 在一定的条件下函数的调用次数减少
4、同等栈容量下局部变量少的方法,可以支持更深度的函数调用 调用次数也就越多

  • 操作数栈

1、后进先出数据结构,最大深度由编译期确定。
2、栈帧刚建立时操作数栈为空
3、方法执行中进行算术运算或者是调用其他的方法进行参数传递的时候是通过操作数栈进行的
4、执行方法操作时,操作数栈用于存放 jvm从局部变量表复制的常量或变量提供提取,以及结果入栈。
5、也用于存放调用方法需要的参数以及接受方法返回值结果。
ps:局部变量表存放的是方法的参数,操作数栈存放的是调用方法时需要的参数(参考下栗子)

  • 动态链接

1、每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)
2、Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

  • 返回地址(方法出口)

当一个方法开始执行以后,只有两种方法可以退出当前方法:
1、当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址
2、当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。
当方法返回时,可能进行3个操作:
1、恢复上层方法的局部变量表和操作数栈
2、把返回值(如果有的话)压入调用者栈帧的操作数栈
3、调整 PC (程序计数器)计数器的值以指向方法调用指令后面的一条指令等

ps:栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。

栗子:


     // 调用方法时
     sum(10,20;// 10 ,20 存放在操作数栈内

     public int sum(int a,int b){
    
      // 方法参数a,b 存放在局部变量表中
        int s = a+b;// s变量存放在局部变量表
        return s // 方法返回值存在操作数栈中
    }

(3)本地方法栈

1、与虚拟机栈发挥的作用非常类似
2、虚拟机栈为虚拟机执行java方法(也就是字节码,java虚拟机执行的是.class 字节码文件)服务,而本地方法栈则为虚拟机使用到的Native方法服务
3、在JVM规范中,对本地方法栈中native方法使用的语言、使用方式、数据结构并没有强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。
4、此区域也会抛出oom或者StackOverflowError

(4)java 堆

  • java虚拟机管理的内存区域中最大的一块
  • 所有线程共享的区域,在虚拟机启动时创建
  • 用于存放对象的实例
  • 几乎所有的对象实例及其数组的分配都在堆上分配
  • 垃圾回收器 管理的主要区域因此很多时候也被称为“GC”堆
  • 在堆中分配一定的内存来保存对象实例,实际上也只是保存对象实例的属性值(字段值)、属性的类型、对象本身的类型标记,等,并不保存对象的方法(以帧栈的形式保存在Stack中),在堆中分配一定的内存保存对象实例。而对象实例在堆中分配好以后,需要在栈中保存一个4字节的Heap 内存地址,用来定位该对象实例在堆中的位置,便于找到该对象实例。
  • java堆处于物理不连续的内存空间中,只要逻辑上连续即可。
  • 当前主流的虚拟机都是按照可扩展实现的(通过-Xmx和-Xms控制),如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError。

1、按照垃圾回收的角度来看堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。
这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。(参考下堆的划分图)
2、从内存分配的角度看,线程共享的java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer)

在这里插入图片描述

堆的具体划分图

(5)方法区

  • 线程共享的内存区域
  • 用于存储被虚拟机加载的类信息、常量、静态变量,即时编译器编译后的代码等数据。
  • 当硬盘的.class文件加载时,会被加载到方法区,然后解析类中的方法成员然后创建个class对象在方法区中,吧这些信息存在class对象中,此对象只有一份。
  • 方法区也被叫做共享区、永久代(不是真正的永久代),持久带、非堆。
  • HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。
  • 使用永久代来实现方法区也是有弊端的容易遇到OOM(永久代有-XX:MaxPermSize上限,I9和IRockit只要没触及到进程的上限就没问题例如32位系统的4GB就不会出问题)
  • HotSpot虚拟机团队开始放弃永久代并逐步改为采用Native Memory来实现方法区,在jdk1.7中吧原本放在永久代的字符常量池移出。
  • java 虚拟机规范对方法区的限制十分宽松,除了和java堆一样不需要连续的内存空间,和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集,相对而言垃圾收集在这个区域出现是比较少的,但是也有,这个区域内存回收的目标主要针对常量池的回收,类型的卸载
  • 方法区无法满足内存分需求时将会抛出oom

(6)运行时常量池

  • 运行时常量池(Runtime Constant Pool),它是方法区的一部分
  • Class文件的组成有:类的版本、字段、方法、接口等信息描述,常量池(Constant Pool Table)
  • class文件的常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 除了保存class文件中描述的符号引用外还会把编译出来的直接引用存储在运行时常量池中。
  • class文件的常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:

1、类和接口的全限定名(也就是包名.类名)
2、字段名称和描述符
3、方法名称和描述符

注意:class文件的常量池与方法区的运行时常量池概念差别:
1 、class文件常量池是class文件结构的一部分。
2、运行时常量池是jvm 运行时内存区域方法区内的一部分。
3、class文件常量池内的信息在类加载时会被加载到运行时常量池中。

常量池的好处:

1、常量池好处
常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。
2、拓展==的含义

  • 基本数据类型之间应用双等号,比较的是他们的数值。
  • 复合数据类型(类)之间应用双等号,比较的是他们在内存中的存放地址。

常量池技术拓展

java中基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean。这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。 两种浮点数类型的包装类Float,Double没有实现常量池技术。

Integer的栗子(代码中注释很重要)

        Integer i1 = 40;//Java在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40),吧数值存入常量池,然后从常量池中取i1的数据。
        Integer i2 = 40;
        Integer i3 = 0;
        Integer i4 = new Integer(40);// new 时直接在堆上创建新的对象i4
        Integer i5 = new Integer(40);
        Integer i6 = new Integer(0);

        System.out.println("i1=i2   " + (i1 == i2));//t
        System.out.println("i1=i2+i3   " + (i1 == i2 + i3));//t
        System.out.println("i1=i4   " + (i1 == i4));//f
        System.out.println("i4=i5   " + (i4 == i5));//f
        System.out.println("i4=i5+i6   " + (i4 == i5 + i6));//t ,由于+号操作不适合对象(这里i5、i6),所以这里对象会进行自动拆箱再相加。
        System.out.println("40=i5+i6   " + (40 == i5 + i6));//t

String的栗子(代码中注释很重要)

        String stra = "abcd"; // 对象存储在常量池中
        String strb = new String("abcd");// new 就是在堆中分配了新的地址
        System.out.println(stra==strb);//false
       
        String str1 = "str";
        String str2 = "ing";
        String str3 = "str" + "ing";
        String str4 = str1 + str2; //对于字符串变量的“+”连接表达式,它所产生的新对象都不会被加入字符串池中,其属于在运行时创建的字符串,具有独立的内存地址,所以不引用自同一String对象。
        System.out.println("string" == "str" + "ing");// true 只有使用引号包含文本的方式创建的String对象之间使用“+”连接产生的新对象才会被加入常量池中
        System.out.println(str3 == str4);//false
        String str5 = "string";
        System.out.println(str3 == str5);//true

String的intern方法

        String s1 = new String("tom");
        String s2 = s1.intern();
        //String的intern()方法会查找在常量池中是否存在一份equal相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。

面试常问:一下代码创建了几个对象

  String s1 = new String("tom");
  • 类加载对一个类只会进行一次。”tom”在类加载时就已经创建并驻留了(如果该类被加载之前已经有”tom”字符串被驻留过则不需要重复创建用于驻留的”tom”实例)。驻留的字符串是放在全局共享的字符串常量池中的。
  • 在这段代码后续被运行的时候,”tom”字面量对应的String实例已经固定了,不会再被重复创建。所以这段代码将常量池中的对象复制一份放到heap中,并且把heap中的这个对象的引用交给s1 持有。

参考文章:java虚拟机:运行时常量池

二、直接内存

1、简介

1、直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中农定义的内存区域。但是这部分内存被频繁使用时也可能导致OOM。
2、在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

2、注意

1、本机直接内存的分配不会受到Java 堆大小的限制,但是既是内存便受到本机总内存大小(RAM、SWAP、分页文件)、处理器寻址空间限制。
2、配置虚拟机参数时,不要忽略直接内存 防止出现OutOfMemoryError异常。

猜你喜欢

转载自blog.csdn.net/qq_38350635/article/details/102005806