当前位置: 首页 > 科技观察

JVM内存管理你得掌握

时间:2023-03-12 09:42:58 科技观察

Java以其自动内存管理机制而自豪。相对于C++的手工内存管理、复杂难懂的指针等,Java程序编写起来要方便的多。不过,这种一调用就来就去的内存申请与释放方法,自然是有其代价的。为了管理这些快速的内存分配和释放操作,需要引入一个池来延迟这些内存区域的回收。我们常说的内存回收就是针对这个pool的操作。我们把上面说的池称为堆,我们可以暂时把它看成一个整体。JVM内存布局Java程序的数据结构非常丰富。对于内容,举几个例子:静态成员变动态成员变量区域变量对象声明简短紧凑内存申请庞大复杂先来看看JVM的内存布局。随着Java的发展,内存布局也有所调整。例如,Java8及之后的版本将PersistentGeneration完全去掉,取而代之的是Metaspace。这也意味着-XX:PermSize和-XX:MaxPermSize等参数调整是没有意义的。但一般来说,比较重要的内存区域是固定的。JVM内存区的划分如图所示。从图中我们可以看出,JVM堆中的数据是共享的,占用的内存面积最大。可以执行字节码的模块称为执行引擎。执行引擎在线程切换时如何恢复?依靠程序计数器。JVM的内存划分与多线程密切相关。像我们程序在运行时使用的栈,以及本地方法栈,它们的维度都是线程。本地内存包括元数据区和一些直接内存。虚拟机堆栈Java虚拟机堆栈是基于线程的。即使您只有一个main()方法,它也会作为一个线程运行。在一个线程的生命周期中,参与计算的数据会频繁的入栈和出栈,栈的生命周期和线程是一样的。栈中的每条数据都是一个栈帧。当调用每个Java方法时,都会创建一个堆栈帧并将其压入堆栈。一旦相应的调用完成,就会弹出堆栈。弹出所有堆栈帧后,线程结束。每个栈帧包含四个区域:局部变量表操作数栈动态链接返回地址我们的应用程序就是在这些内存空间的不断操作中完成的。本地方法栈是一个非常类似于虚拟机栈的区域,它的服务对象就是本地方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域,这并不影响我们对JVM的理解。有一种称为returnAdress的特殊数据类型。因为这种类型只存在于字节码层面,所以我们平时处理的比较少。对于JVM来说,程序就是存放在方法区的字节码指令,returnAddress类型的值是指向具体指令内存地址的指针。这是一个两级堆栈。第一层是栈帧,对应方法;第二层是方法的执行,对应操作数。小心不要让他们混淆。可以看到所有的字节码指令其实都被抽象成了入栈和出栈操作。执行引擎只需要傻傻地按顺序执行即可保证其正确性。由于程序计数器是一个线程,这意味着它在获取CPU时间片上是不可预测的。需要有一个地方来缓冲记录线程运行的点,以便在获取CPU时间片时可以快速回收。程序计数器是一个小的内存空间,作为当前线程正在执行的字节码行号的指示器。这里存储的是当前线程执行的进度。下面这张图可以加深大家对这个过程的理解。可以看出,程序计数器也是因为线程而产生的,配合虚拟机栈完成计算操作。程序计数器还存储了当前正在运行的进程,包括正在执行的指令、跳转、分支、循环、异常处理等,我们可以查看程序计数器的具体内容。下图是使用javap命令输出的字节码。可以看到每个操作码前面都有一个序号。就是图中红框内的偏移地址,你可以把它们看成是程序计数器的内容。堆是JVM上最大的内存区域,我们申请的对象几乎都存放在这里。我们常说的垃圾回收,运行的对象就是堆。堆空间一般是在程序启动时申请的,但不一定会全部使用。随着对象的频繁创建,堆空间被占用越来越多,需要时不时的回收不用的对象。这在Java中称为GC(垃圾收集)。由于对象大小不一,运行时间长了,堆空间会被很多小碎片占用,造成空间浪费。所以仅仅销毁对象是不够的,还需要整理堆空间。这个过程非常复杂。创建该对象时,它是分配在堆上还是堆栈上?这与两件事有关:对象的类型和它在Java类中的位置。Java对象可以分为基本数据类型和普通对象。对于普通对象,JVM会先在堆上创建对象,然后在别处使用它的引用。比如将这个引用保存在虚拟机栈的局部变量表中。对于基本数据类型(byte、short、int、long、float、double、char),有两种情况。上面我们提到每个线程都有一个虚拟机栈。当您在方法体中声明原始数据类型的对象时,它会直接分配到堆栈上。在其他情况下,它分配在堆上。请注意,像int[]数组这样的东西是在堆上分配的。数组不是原始数据类型。这是JVM的基本内存分配策略。堆是所有线程共享的,如果被多个线程访问,就会涉及到数据同步问题。Metaspace关于元空间,我们还是从一个很常见的面试问题说起:“为什么会有元空间区域?它有什么问题?”说到这里,你应该还记得类和对象的区别。对象是一个有生命的个体,可以参与程序的运行;一个类更像是一个模板,定义了一系列的属性和操作。那你可以想象一下。我们之前生成的A.class放在JVM的哪个区域?如果要回答这个问题,就不得不提到Java的历史。在Java8之前,这些类的信息都放在一个叫做Perm区的内存中。在更早的版本中,连String.intern相关的运行时常量池也放在这里。该区域有大小限制,容易造成JVM内存溢出,导致JVM崩溃。Perm区在Java8中已经完全废除,取而代之的是Metaspace。原来的Perm区在堆上,现在的元空间在非堆上。这是背景。请参阅下图以了解它们的比较。然而,元空间的好处也是它的坏处。使用非堆可以使用操作系统的内存,JVM不会再有方法区内存溢出;但是,无限使用会导致操作系统死亡。所以一般用参数-XX:MaxMetaspaceSize来控制大小。方法区,作为一个概念,依然存在。它的物理存储容器是Metaspace。现在,你只需要了解这个区域存储的内容,包括:类信息、常量池、方法数据、方法代码。总结我们常说的字符串常量存放在哪里?由于Java7之后常量池是放在堆上的,所以我们创建的字符串会分配在堆上。堆,非堆,本地内存,有什么关系?关于他们的关系,我们可以看一张图。在我看来,堆是柔软、松散和有弹性的;non-heap是冰冷死板的,内存非常紧凑。大家都知道JVM在运行的时候会向操作系统申请一大块堆内存来存放数据。但是,堆外内存是操作系统在申请之后剩余的内存,其中一部分会被JVM控制。比较典型的是一些原生的关键字修饰方式,以及内存的申请和处理。在Linux机器上,使用top或ps命令。在大多数情况下,您可以看到RSS段(实际内存使用量)大于分配给JVM的堆内存。如果你申请一个2GB系统内存的主机,JVM可能只能使用1GB,这是一个限制。总结JVM的运行区是栈,而存储区是堆。许多变量实际上是在编译时固定的。