jvm专题 - 内存结构

莫问天涯路几重,轻衫侧帽且从容。这篇文章主要讲述jvm专题 - 内存结构相关的知识,希望能为你提供帮助。

本章笔者会重新组织下语言,着重讲下JVM的内存结构。此章会贯穿JDK1.6到JDK1.8的内容,最后会阐述下类初始化的过程,从原理上了解JVM的内存分配机制,本章内容比较基础但非常重要,它是优化代码和JVM调优的基本一定要牢记。后续会专题讲解JVM调优的实操,本章相当于授渔,后续章节相当于授鱼吧。
      JVM通俗来讲有三种不同的解释:1、一套抽象的规范;2、一个具体的规范实现,分为硬件和软件实现;3、一个运行中的java实例。正常我们指的是第三种:运行在一个jvm实现上的java程序。在同一计算机上同时运行三个java程序,将得到三个java虚拟机实例,每个java程序都运行于它自己的java VM中。


一、概述1.1、体系结构

1.2、内存模型

  • 不同的虚拟机有不同的内存实现机制,实现逻辑大概和上图一样,也可以认为上图就是事实上的标准。每个JVM实例都有一个方法区和一个堆区,它们是可以被此JVM实例中所有线程共享的,当JVM装载一个.class文件时,会把从.class文件中解析的二进制数据放在方法区中,把程序运行时把所有在运行时创建的对象放在堆中。
  • 当一个新线程创建时,都将得到一个私有的PC寄存器(程序计数器)和一个栈,PC寄存器的值总是指示下一条将被执行的指令,而栈总是存储此线程中java方法调用的状态,包括局部变量、参数、返回值及中间结果等。每个线程的栈区是私有的,任何线程都不能访问另一个线程的PC寄存器和栈。
  • java栈是由很多帧组成的,java VM没有寄存器,其指令集使用java栈来存储中间数据,目的是为了保持java VM的指令集紧凑,也有助于VM实现动态编译器和即时编辑器的代码优化。
  • 本地方法的调用的状态,是以某种依赖于具体实现的方式存储在本地方法栈中,也可能是其它相关的内存区,因为是依赖java VM的具体实现的。
二、内存模型详解2.1、共享内存区
2.1.1、方法区即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静 态变量以及编译器编译后的代码等数据. HotSpot VM 把GC分代收集扩展至方法区, 使用 Java 堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理java堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。一般主要回收:
  • 常量:只在常量池中的常量没有被任何地方引用,就可以回收,比如String,所以有时对String的大量操作要谨慎;
  • 元数据:一般是应对的动态生成场景而设计的,没有被任何地方引用就会回收;
运行时常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。java 虚拟机对.class文件的每一部分(自然也包括常量 池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。
方法区是所有线程共享的,需要注意线程安全的问题。有几点需要说明:1、方法区可以不是连续的内存空间;2、类变量也就是static变量是由所有实例共享的,访问它不需要实例化一个类是直接从方法区中取得的;3、用final修饰的类变量和普通的类变量不同,当访问final类型的类变量时java VM会把final复制一份到自己的类型常量池中。
在1.6和1.7这块区域称为永久区由-XX:PerSize和-XX:MaxPerSize(默认64M)来指定,在1.8中去掉了永久区用-XX:MaxMetaspaceSize来代替(一个称为“元数据区”(元空间)的区域),如果不指定由会直接耗掉物理内存大小,在1.8中不建议指定其大小(除非存在应用混部的情况)。元空间的本质和永久代类似,它们之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
2.1.2、Heap堆区      java程序在运行时创建的所有实例和数组(在java VM中数组是当做一个对象)都存放在堆中。一个java VM只有一个堆空间,它被所有线程共享的,同样需要注意线程安全的问题。在堆中有创建新对象的指令、但没有释放对象的指令码,在程序运行时堆和方法区都是可以动态扩展的。堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代,新生代分三个区是为了减少碎片,GC一次只会回收eden区和其中一个survivor区。默认的堆区结构和内存分配如下图所示:

Young区内存分配
  • 对象在Eden Space创建(正常情况下如此),当Eden Space满了的时候,gc就把所有在Eden Space中的对象扫描一次,把所有有效的对象复制到第一个Survivor Space,同时把无效的对象所占用的空间释放掉;
  • 当Eden Space再次变满了的时候,就启动移动程序把Eden Space中有效的对象复制到第二个Survivor Space,同时,也将第一个Survivor Space中的有效对象复制到第二个Survivor Space。如果填充到第二个Survivor Space中的有效对象被第一个Survivor Space或Eden Space中的对象引用,那么这些对象就是长期存在的,此时这些对象将被复制到Permanent Generation。若垃圾收集器依据这种小幅度的调整收集不能腾出足够的空间,就会运行Full GC,此时JVM GC停止(此处的停止可参考STW机制是一个相对的停止)所有在堆中运行的线程并执行清除动作。
Old区内存分配
  • 初创对象全在eden区,只有GC才会离开此区域;但当对象体积超过PretenureSizeThreashold参数设置的字节数,会绕过eden, from, to区直接在老年代中创建 ;
  • eden中对象是根据年龄来算的,一次GC年龄+1,由MaxTenuringThreshold,默认值为15,当超过15次时就会移到老年代,另一个控制参数是TargetSurvivorRatio,指新生区的使用率,默认为50%,如果GC后超过50%使用率,也会有一部分对象直接放到老年代;注意上述提到的age=15是一个上限值,jvm会根据实际情况动态调整此值。
Tlab内存分配全称,线程本地分配缓存。由于堆是共享的,多个线程同时分配空间时会存在竞争,为了加快内存分配。每个线程都有一个tlab区,它直接占用的是eden空间,默认开启,也可以被禁用,下图是内存分配的全过程:

LABRefillWasteFraction默认值为tlab区的64分之1大小;JVM默认会自动调整TLAB和LABRefillWasteFraction的大小,可通过ResizeTLAB来禁用此功能并可用TLABSize来手动指定大小,实际不建议调整。
2.1.3、示例:堆区的典型设置
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k
-XX:NewRatio=4 -XX:SurvivorRatio=4
-XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

  • -Xmx3550m:设置JVM最大可用内存为3550M;
  • -Xms3550m:此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存;
  • -Xmn2g:设置年轻代大小为2G。整个JVM内存大小=年轻代大小 + 年老代大小 + 持久代大小。持久代默认大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8;
  • -Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。可根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右;
  • -XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5;
  • -XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6;
  • -XX:MaxPermSize=16m:设置持久代大小为16m。JDK1.8取消了PermGen,取而代之的是Metaspace,所以PermSize和MaxPermSize参数失效,取而代之的是-XX:MetaspaceSize -XX:MaxMetaspaceSize;
  • -XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。
2.2、私有内存区
2.2.1、PC寄存器对于一个运行中的java程序,每一个线程都有它自己的PC寄存器,它是在此线程创建时创建的,大小为一个字长,它存储的是下一条将被执行指令的“地址”,也就是字节码的偏移地址。如果线程在执行一个本地方法,它的内存永远是“undefined”。这是内存区唯一不会报outofMemoryError的区域。
2.2.2、stack栈运行时内存区,先进后出模式用于方法的压和出,当有死循环时,有可能出现StackOverflowError错误,方法区需要线程安全的设计。每当启动一个线程时,java VM都会为它分配一个java栈。以帧为单位保存线程的运行状态,VM只会直接对帧进行进栈和出栈两种操作,栈上的所有数据都是私有的。某个线程正在执行的方法称为该线程的当前方法,当前方法使用的帧称为当前帧。当前方法正常返回或抛出异常,都会弹出。上一个方法的帧变成当前帧。在线程上执行一个方法时,JVM都会在java栈中压入一个新的帧。
私有变量分配在了栈上,好处是栈会自动销毁,不需要垃圾回收器介入,可提高性能;但是大的对象不建议在栈上分配,因为其空间小,栈的大小是在编译时指定的。用-Xss来指定。
栈帧
栈帧的结构如下图所示:
?

类型在方法区中的结构:全限定名,直接超类的全限定名+类型(类||接口)包括它的结构信息、修饰符、超类的有序列表、一个到类ClassLoader的引用、一个到Class类的引用(即创建一个java.lang.Class类的实例)。每个帧都有自己的局部变量表和指向常量池的引用;它随着方法创建或结束而创建或销毁,如果stackoutException一般是由于死循环引起的。一个帧栈由三部分组成:
  • 局部变量区:是一个数组,存储方法的参数和临时变量。需要注意的是对于比int短的数据类型在java栈中都会转变为Int再进行运算,存回方法区时再转换为原来的类型;java栈中不会拷贝对象,只是存储到堆区的引用;
  • 操作数栈:是一个数组结构,存储中间计算结果,它不是通过索引来访问,而是标准的栈操作来访问的,A指令可能把数据压入栈中,稍后B指令可能执行出栈操作再取来;
  • 帧数据区:除了以上两个数据区后,java栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常机制派发,当需要用到常量池中的数据时,就会通过存放在此帧数据区的指针来访问(如果是普通数据则直接取出压入栈中);
2.2.3、本地方法栈本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个 C 栈。运行中的每一个java线程都有一个私有的执行引擎实例,这个执行引擎会执行两种操作:字节码或是本地方法(JNI,本地方法视为一种扩展),它主要操作的是操作数栈。HostSpot-JVM把本地方法堆栈和JVM堆栈合二为一了。
当java程序直接调用本地方法(一般用C语言实现),当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受VM限制的世界,本地方法可以通过本地方法接口来访问虚拟机的运行时数据区。java调用本地方法时不会操作栈,而是一个动态链接。本地方法执行后会有一个返回值或异常,它也可以回调java方法。
2.3、直接内存区
这是类似堆的一块内存区,不是 JVM 运行时数据区的一部分。java的NIO库就是直接使用这块的内存区域,性能会更好。这块区域大小不受xmx限制。它的速度要快于堆,默认为Xmx的大小 ,也可以用MaxDirectMemorySize来指定。也会被GC。它适合多读的场景,比如把文件读到内存,然后多次被访问。原因是它写内存的速度要远低于堆,但它读内存的速度的要远高于堆。
在NIO的设计中有体现,比如 在 JDK 1.4 引入的 NIO 提 供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在 Java 堆和 Native 堆中来回复制数据, 因此在一些场景中可以显著提高性能。
三、生命周期3.1、基础
数据类型javaVM的数据分为两种类型:基本类型和引用类型。其中基本类型中的boolean有点特别,当编译为字节码时,会用int或byte来表示boolean。涉及到boolean的值操作时则会使用int,boolean数组是当做byte数组来使用的。在基本类型中还有一个returnAddress的基本类型,程序用不了,它用来实现finally语句。

在java VM中最基本的数据单元就是字(word),它的大小由每个VM实现的设计者来决定,但一般至少是一个byte或byte的整数倍。字长大小程序不会侦测到,但也不会影响程序的运行。
类型解析
详细的可看上图中的描述,有几点需要注意的如下:
  1. 在引用和被引用对象由不同的类加载器加载时,为了保证安全,这两边必须保证全路径名在方法区中一致,这样即保证了安全性也保证了一致性;
  2. 解析final常量:常量类解析为一个本地的拷贝,所以这保证了在使用switch和if时要注意线程的安全性。final是在编译时解析的,所以如果if中引用了final变量,在改变final时也要重新编译if所在的语句;
  3. 接口引用调用方法要比类引用计用方法慢很多,因为在JVM中method在方法区中会维护一个列表,外部通过引用方法偏移表来直接引用,但接口就不一定,因为外部可以持有同一个接口的不同引用,每次调用都需要在接口的实现树上找到一个适合的实现。
  4. 其它基本类型的解析就比较简单一般就是直接引用即可;
类装载子系统java VM有两种类加载器:启动和用户自定义加载器,用户自定义加载器必须派生自java.lang.ClassLoader,这个类提供了访问类加载器机制的接口,用户自定义的类加载器以及Class类的实例也同样存在于堆区中,而装载的类型信息则都位于方法区。其它他们都是系统的一部分。类加载器的工作过程主要包括:1、装载,查找并装载类型的二进制数据;2、连接,执行验证,准备,以及解析(可选);3、初始化;4、使用;5、卸载;
【jvm专题 - 内存结构】ClassLoader有四个方法:defineClass两个、findSystemClass、resoloveClass。defineClass方法负责把新类型导入到方法区中, resoloveClass接受defineClass返回值做为参数,对此Class执行连接动作,defineClass执行后此类就位于方法区了,因为方法区中存的是class实例。
java VM的命名空间,其实是解析过程的结果。对于每一个被装载的类型,java VM都会记录装载它的class loader。这种机制也防止了类型混淆,即JVM除了限定全路径名以后还会限定类加载器。
3.2、线程的生命周期
在java VM中有两种线程:守护线程和非守护线程。守护线程通常是由虚拟机自己使用的,比如GC线程,但是java程序也允许把自定义的任何线程标记为守护线程。java程序中的初始线程(main())为非守护线程。只要还有非守护线程在运行,java VM就处于存活状态,一旦所有的非守护线程都停止了,虚拟机实例将自动退出。
这里所说的线程是指程序执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程,操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上,当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。Java 线程结束原生线程随之被回收。当线程结束时会释放原生线程和 Java 线程的所有资源。JVM 后台运行的系统线程主要有下面几个:
线程分类
描述
虚拟机线程
负责等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当 堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有:stop-the- world 垃圾回收、线程栈 dump、线程暂停、线程偏向锁(biased locking)解除。
周期性任务线程
负责定时器事件(也就是中断),用来调度周期性操作的执行。
GC线程
支持 JVM 中不同的垃圾回收活动。
编译器线程
在运行时将字节码动态编译成本地平台相关的机器码。
信号分发线程
接收发送到 JVM 的信号并调用适当的 JVM 方法处理。
3.3、对象的生命周期
类一旦被加载、连接、初始化后。程序就可以访问它的静态方法、变量等。这是在堆上进行的操作。GC主要是对这个内存区进行垃圾回收,下一章节笔者会详细描述GC的内容,此小节会详细说明下类创建的过程,也把上一章节埋的一个坑填上。
类实例化
类实例化一般有4种途径:new()、clone()、newInstance()、java.io.ObjectInputStream().getObject()(序列化);另外还有隐式的创建,经如String运算过程中的创建。java编译器会为每个类创建一个实例化的init构造方法,如果这个构造函数中没有显示的this或super方法,则默认从超类的init方法开始调用。
垃圾回收-对象的终结
jvm必须实现自动的堆管理策略。程序员也可以重载finalize()方法:这个方法是由FinalizerThread线程来处理的,执行前要加入一个引用对列,对列内部是个链表结构,再执行。这也就是GC时时间不确定的原因之一;
  • gc时只会主动调用一次此类,并且此类中的任何异常都会被忽略;
  • 此类被程序员显式调用并不会影响gc过程;
  • 在这个类里也可以处理复杂的清理和对象复活任务,但不建议复活对象,原因是这个方法是由gc来调用的,无法确定其调用的时间,可以做一些清理工作;
  • 因为复活的原因,gc一般会进行二次检查来确认
class Finale
protected void finalize()
System.out.println("A Finale object was finalized.");
//...

//...

不建议重写finalize()方法的原因是,GC时JVM会把所有可回收的对象包装成一个java.lang.ref.Finalizer,然后放在ReferenceQueue队列中,内部为一个双向链表结构。然后 FinalizerThread线程依次执行队列中引用对象的finalize()方法,如果重写的finalize方法中存在sleep这样比较耗时的操作,就会导致很多对象来不及GC,导致oom异常。
但有时可做为双保险使用,比如DB连接,除了程序中手动关闭后,也可以在此处在关闭一次。因为之前关了一次,所以重载的finalize中基本是什么都不做的。还有一种情况就是在分配了大量的空间后也最好进行一次显示的System.gc()调用;
3.4、类型的生命周期
jvm通过以下三步初始化一个java类型,使其可被当前程序可用。其中连接中的解析可以初始化后运行;一般的JVM有个规则是首次使用时才加载和初始化,有的也会提前感知预先装载。这个过程一般是在方法区上进行的;

装载分三阶段完成:1、通过类型的全限定名,产生一个代表此类型的二进制数据流;2、解析内部结构;3、创建一个java.lang.Class实例;可能会占用方法区存放部分元数据;
连接驱动java连接模型的引擎是解析过程,可以允许用户自定义类装载器,在运行时动态地扩展用户程序。 这个连接过程包括常量池、方法表。class文件把它所有的引用符号都保存在常量池中,这个池是独享的。解析过程中根据符号引用查找实体,再把符号引用替换成一个直接引用,主要过程如下所述:
  1. 验证:初步验证.class文件的合理性;
  2. 准备:除了分配内存外,jvm会给变量分配默认的初始值;初始化阶段再赋予程序员期望的值;
  3. 解析:在常量池中寻找类、接口、方法、字段的符号引用,再把这些符号替换成直接引用;
理解连接模型,需要知道类的装载、检验、连接、解析、初始化这样的一个过程。
动态连接jvm装载程序类和接口,并在动态连接的过程中把他们连接起来。这个连接是通过符号连接起来的,class文件被装载后都有一个内部版本的常量池(连接符号保存的地方),它和class文件的结构相对应。当引用于jvm会解析常量池中数据的入口并且只会解析一次,连接的过程主要是把符号替换成直接引用(指针),同时还要检查正确性和权限。类找不到的错误就是这个过程发现的。
动态扩展在jvm中可以使用java.lang.Class.forName()和java.lang.ClassLoader.loadClass()来动态扩展。这两种的区别在于装载的命名空间不同:
  • forName():可以用参数指定是否在返回前被初始化,比如JDBC需要一个注册过程,返回前必须被初始化;它试图把类装载到当前的命名空间(系统类装载器,即classpath的路径)
  • loadClass():没有初始化操作。这种方法的扩展会提供一个命名空间,提供安全保护功能,forName()的第三个参数也可以指定一个classLoader实现安全的功能;它试图把类装载到用户自定义的装载器的命名空间里
classLoader
存在1.1和1.2两种版本的实现,其区别就在findClass和loadClass。前者是后者的子集,其中findClass是1.2的更容易扩展,它把loadClass的工作分离开,只负责按路径查找要加载的类,并转换成数组给defineClass方法。示例代码如下:
public interface Greeter
void greet();

public class Hello implements Greeter
public void greet()
System.out.println("Hello, world!");


//这个实现是java1.1的实现,是源码也是一个自定义的实现
public class GreeterClassLoader extends ClassLoader

private String basePath; //用来保存目录路径

public GreeterClassLoader(String basePath)
this.basePath = basePath;


public synchronized Class loadClass(String className, boolean resolveIt) throws ClassNotFoundException
Class result;
byte classData[];

// 检查要调用的类型是否被加载过
result = findLoadedClass(className);
if (result != null)
return result;

// 双亲加载
try
result = super.findSystemClass(className);
return result;

catch (ClassNotFoundException e)


// Dont attempt to load a system file except through
// the primordial class loader
if (className.startsWith("java."))
throw new ClassNotFoundException();


// Try to load it from the basePath directory.
classData = https://www.songbingjia.com/android/getTypeFromBasePath(className);
if (

    推荐阅读