深入理解Java内存结构

由于Java程序是交由JVM(Java虚拟机)执行的,所以我们在谈Java内存区域划分的时候事实上是指JVM内存区域划分。

一、Java程序执行流程

  首先.java文件会被Java编译器编译为.class字节码文件,然后由JVM中的类加载器加载个各类的字节码文件,加载完毕后,交给JVM执行引擎执行,在整个程序执行过程中,JVM会用一段空间存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们说的JVM内存,因此在Java中的内存管理就是针对这段空间进行管理(分配和回收内存空间)。

那么这篇文章主要是分析Runtime Data Area的结构

二、运行时数据区的组成部分

方法区和堆为线程共享区,虚拟机栈、本地方法栈和程序计数器为线程独占区。

三、方法区

  1. 方法区用于存储虚拟机加载的类信息(类的版本、字段、方法、接口),常量,静态变量,即时编译器编译后的代码等数据。

  方法区逻辑上属于堆的一部分,但是为了和堆区分,通常又叫“非堆”。

  当方法区无法满足内存分配需求时,将会抛OutOfMemoryError异常

  1. HotSpot虚拟机(一款较新的Java虚拟机)使用永久代来实现方法区,使得HotSpot虚拟机的垃圾收集器可以像管理堆内存一样来管理这部分内存,能省去专门为方法区编写内存管理代码工作,所以开发者喜欢将方法区称为永久代,本质上两者并不等价,对于其他虚拟机来说不存在永久代的概念

  2. PerGen(永久代):绝大部分的程序员都应该见过"java.lang.OutOfMemoryError:PreGen space"异常,这里的PermGen space其实就是指的是方法区,但是上面我们已经说了方法区和永久代有着本质的区别,前者是JVM的规范,而后者则是JVM规范的一种实现,只有HotSpot才有PermGen space,由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。

  3. 元空间:
      其实,移除永久代的工作从JDK1.7就开始了,JDK1.7中,在存储永久代的部分数据就已经转移到Java Heap 或者Native Heap,但永久代仍存在于JDK1.7中,并没有完全移除,比如符号引用(Symbols)转移到了Native Heap,字面量(interned strings)转移到Java Heap,类的静态变量(class statics)转移到了Java Heap。

  JDK1.8对于JVM架构的改造将类元数据放到本地内存中,另外,将常量池和静态变量放到Java堆里,HotSpot VM将会为类的元数据明确分配和释放本地内存,在这种架构下,类元信息就突破了原来-XX:MaxPermSize的限制,现在可以使用更多的本地内存,这样就从一定程度上解决了原来在运行时生成大量类造成经常Full GC问题,如运行时使用反射、代理等,所以升级以后Java堆空间可能会增加。

  元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数指定元空间的大小:

-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对改值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。

-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

除了上面的两个指定大小的选项外,还有两个与GC相关的属性:

-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。

-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。

  1. 对于方法区,Java8之后的变化:
  • 移除了永久代(PermGen),替换为元空间(Metaspace);

  • 永久代中的class metadata转移到了native memory(本地内存空间,不是虚拟机);

  • 永久代中的interned Strings 和 class static variables转移到了Java Heap;

  • 永久代参数(PermSize MaxPermSize)->元空间参数(MetaspaceSize MaxMetaspaceSize)

  1. 运行时常量池

  运行时常量池是方法区的一部分,class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后加入方法区的运行时常量池中存放。运行时常量池相当于class文件中的常量池,所不同的其具备了动态性,class文件中常量池中的常量在编译期间就已经定义好了,而运行时常量池在程序运行期间也可以将常量放入该常量池中,最常见的做法就是调用String类的intern()方法。

四、程序计数器

  程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存。

  如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native方法,这个计数器值则为空(Undefined)。

  此内存域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域,因为程序计数器是由虚拟机内部维护的,不需要开发者进行操作。

五、虚拟机栈

  每个线程有一个私有的栈,随着线程的创建而创建,生命周期与线程相同。虚拟机栈里面存着的是一种叫栈帧的东西,每个方法会创建一个栈帧,栈帧中存放了局部变、操作数栈、动态链接、方法出口等信息。

  局部变量表存放了编译期可知的各种基本数据类型和对象引用类型,通常我们所说的栈内存指的就是局部变量表这一部分。

  64位的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧分配多少内存是固定的,运行期间不会改变局部变量表大小。

  方法的调用到执行完毕,对应的就是栈帧的入栈和出栈的过程。栈的大小可以固定也可以动态扩展,在固定大小的情况下,当栈调用深度大于JVM所允许的范围,会跑出StackOverflowError异常,在动态扩展无法申请到足够的内存,就会抛出OutOfMemoryError异常。

栈内存溢出模拟:

  public class Test {

      public static void main(String[] args){
      	new Test().test();
      }

      private void test() {
      	System.out.println("run...");
      	test();
      }
  }

报错如下:

图例:

六、本地方法栈

  本地方法栈(Native MethodStacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它,甚至有的虚拟机(Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛StackOverflowError和OutOfMemoryError异常。

七、堆

  堆是JVm管理的最大的一块内存区域,存放着对象的实例,是线程共享区,堆是垃圾收集器管理的主要区域,因此也被称为GC堆

  Java堆的分类:从内存的角度上看,可分为年轻代(Eden空间,From Survivor空间、To Survivor空间)及老年代(Tenured Gen);从内存分配的角度上看,为了解决分配内存时的线程安全性问题,线程共享的JAVA堆中可能划分出多个线程私有的分配缓冲区(TLAB)。

  Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,可通过-Xmx -Xms来指定运行时堆内存的大小,堆内存空间不足也会抛OutOfMemoryError异常。

Heap = {Old + NEW = {Eden,from,to}}年轻代和老年代的划分对垃圾收集影响比较大。

1. 年轻代

  所有新生成的对象首先都是放在年轻代,年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代一般分为三个区,1个Eden区,2个Survivor区(from和to)。

  大部分对象在Eden区中生成,当Eden区满的时候,还存活的对象将被复制到Survivor区(两个中的一个),当一个Survivor区满的时候,此区的存活对象将被复制到另一个Survivor区,当另一个Survivor区也满了的时候,从以前一个Survivor区复制过来的并且此时还存活的对象,将可能被复制到老年代。

  两个Survivor区是对称的,没有先后关系,所以同一个Survivor区中可能同时存在从Eden区复制过来的对象和从另一个Survivor区复制过来的对象,而复制到老年区的只有从另一个Survivor区过来的对象。而且,因为需要交换的原因,Survivor区至少有一个是空的。特殊情况下,根据程序需要,Survivor区是可以配置多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。针对年轻代的垃圾回收即Young GC

2. 老年代

  在年轻代中经历了N次(可配置)垃圾回收后仍然存活的对象,就会被复制到老年代中,因此,可以认为老年代中存放的都是一些生命周期较长的对象。针对年老代的垃圾回收即Full GC

八、OOM(Out of Memory)异常一般主要的原因:

  1. 老年代溢出,表现为:java.lang.OutOfMemoryError:Javaheapspace;这是最常见的情况,产生的原因可能是:设置的内存参数Xmx过小或程序内存泄露及使用不当问题。

例如:循环上万次的字符串处理,创建上千万个对象,在一段代码内申请上百个M甚至上G的内存,还有的时候虽然不会报内存溢出,却会使系统不间断的垃圾回收,也无法处理其他请求,这情况下除了检查程序、打印堆内存等方法排查,还可以借助一些内存分析工具,比如MAT。

  1. 持久代溢出,表现为java.lang.OutOfMemoryError:PermGenspace;通常由于持久代设置过小,动态加载了大量的Java类而导致溢出,解决办法唯有将参数-XX:MaxPermSize调大(一般256m能满足绝大多数应用程序需求)。将部分Java类放到容器共享区(例如Tomcat share lib)去加载的办法也是一个思路,但前提是容器里部署了多个应用,且这些应用有大量的共享类库。

九、对象的创建

指针碰撞:要求堆中内存绝对规整,所有用过的内存都放一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅只是将该指针向空闲空间那边挪动一段与对象大小相等的距离。

空闲列表:针对的是堆中内存不规整的情况,虚拟机维护着一个列表,记录哪些内存块是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

一个对象的一生:

  我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。

原文地址:https://www.cnblogs.com/zbh355376/p/14853337.html