面试题jvmjava

JVM内存结构、运行时内存以及类加载过程

2019-12-06  本文已影响0人  雪飘千里

以下内容都是基于jdk1.8

1、JVM 内存管理

image.png

2、JVM内存区域

image.png

JVM内存区域主要分为线程私有Thread Local区域(程序计数器,虚拟机栈,本地方法区)、线程共享Thread Shared区域(java heap堆、方法区)、直接内存Direct Memory。

image.png image.png

1、程序计数器(线程私有Thread Local)

一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每个线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。
正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是 Native 方法,则为空。
这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。

2、虚拟机栈(线程私有Thread Local)

描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

栈用来存储线程的局部变量表、操作数栈、动态链接、方法出口等信息。如果请求栈的深度不足时抛出的错误会包含类似下面的信息:
java.lang.StackOverflowError

另外,由于每个线程占的内存大概为1M,因此线程的创建也需要内存空间。操作系统可用内存-Xmx-MaxPermSize即是栈可用的内存,如果申请创建的线程比较多超过剩余内存的时候,也会抛出如下类似错误:
java.lang.OutofMemoryError: unable to create new native thread

相关的JVM参数有:
-Xss: 每个线程的堆栈大小,JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.
在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

3、本地方法栈(线程私有Thread Local)

本地方法区和 Java Stack 作用类似,区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务,
如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。

4、堆heap(线程共享Thread shared)——运行时数据区

Heap堆是被线程共享的一块内存区域,创建的对象和数组都保存在Java堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代VM采用分代收集算法,因此Java堆从GC的角度还可以细分为:新生代(Eden取、From Survivor区和To Survivor区)和老年代。

Java堆heap内存主要用来存放运行过程中生成的对象,该区域OOM异常一般会有如下错误信息:
java.lang.OutofMemoryError:Java heap space;
通过设置-XX:-HeapDumpOnOutOfMemoryError,可以使其在OOM时,输出一个dump.core文件,记录当时的堆内存快照。
然后我们就可以通过分析这个dump的内存快照来找到问题原因,比如说,是由于程序原因导致的内存泄露,还是由于没有估计好JVM内存的大小而导致的内存溢出。

Java堆常用的JVM常数:

5、方法区(线程共享Thread shared)

即我们常说的永久代(Permanent Generation),用于存储被 JVM 加载的类信息、常量(final)、静态变量(static)、即时编译器编译后的代码等数据

HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的老年代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久代的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

方法区主要存储被虚拟机加载的类信息,如类名、访问修饰符、常量池、字段描述、方法描述等。理论上在JVM启动后该区域大小应该比较稳定,但是目前很多框架,比如Spring和Hibernate等在运行过程中都会动态生成类,因此也存在OOM的风险。
如果该区域OOM,错误结果会包含类似下面的信息:
java.lang.OutofMemoryError: PermGen space

相关的JVM参数可以参考运行时常量。

上面说的是jdk1.8之前的内存模型,其中方法区和堆是是线程共享的,但是在jdk1.8之后元数据区取代了永久代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存,同时类的元数据放入 native memory,运行时常量池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制,而由系统的实际可用空间来控制。

image.png

注:数组和对象是保存在堆中,类信息(类中字段、变量)、常量(final)、静态变量(static)是保存在方法区(永久代);1.8以后,类信息保存在元空间,常量(final)、静态变量(static)保存在堆中

3、JVM运行时内存——堆heap

Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。

image.png

新生代

是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。

MinorGC(也可以称之为young gc) 的过程(复制->清空->互换)——MinorGC 采用复制算法。

优点:实现简单,内存效率高,不易产生碎片,每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集

缺点:可用内存被压缩了,且存活对象增多的话,Copying 算法的效率会大大降低

image.png

老年代

主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发
当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。

MajorGC

标记清除算法(Mark-Sweep):首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
image.png

缺点:该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

标记整理(Mark-Compact)算法:标记阶段和 Mark-Sweep 算法相同,但是标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象
image.png

永久代

即上面说的线程共享Thread shared的方法区
指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同(数组和对象是保存在堆中),GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。

在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory,字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

4、Minor GC VS Major GC vs Full GC

Minor GC:清理年轻代的垃圾;
Major GC:清理老年代的垃圾;
Full GC:清理整个堆空间—包括年轻代和老年代;

5、垃圾回收器

CMS收集器(多线程标记清除算法)

Concurrent mark sweep(CMS)收集器是一种老年代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法
最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。

CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

image.png

CMS 出现FullGC的原因:
1、年轻代晋升到老年代没有足够的连续空间,很有可能是内存碎片导致的,因此会触发FULL GC

2、在并发过程中JVM觉得在并发过程结束之前堆就会满,需要提前触发FullGC

G1收集器

Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,是一款面向服务端应用的垃圾收集器,目标是替换掉CMS收集器

相比与 CMS 收集器,G1 收集器两个最突出的改进是:

  1. 基于标记-整理算法,不产生内存碎片。
  2. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

与其它收集器相比,G1变化较大的是它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留了新生代和来年代的概念,但新生代和老年代不再是物理隔离的了它们都是一部分Region(不需要连续)的集合。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率

G1收集器的运作大致可划分为以下几个步骤:

看上去跟CMS收集器的运作过程有几分相似,不过确实也这样。初始阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以用的Region中创建新对象,这个阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这一阶段耗时较长但能与用户线程并发运行。而最终标记阶段需要吧Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但可并行执行。最后筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这一过程同样是需要停顿线程的,但Sun公司透露这个阶段其实也可以做到并发,但考虑到停顿线程将大幅度提高收集效率,所以选择停顿。

image.png

6、类加载过程

在代码编译后,就会生成JVM(Java虚拟机)能够识别的二进制字节流文件(*.class)。而JVM把Class文件中的类描述数据从文件加载到内存,并对数据进行校验、准备、解析、初始化,使这些数据最终成为可以被JVM直接使用的Java类型,这个说来简单但实际复杂的过程叫做JVM的类加载机制。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括7个阶段,加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading),其中验证其中验证、准备、解析三个阶段统称为连接。

image.png

注:加载、验证、准备、初始化、卸载这五个阶段顺序是一定的,而解析阶段在某些情况下可以在初始化之后再开始。

6.1 加载阶段:

在这个阶段,JVM主要完成三件事:

对于数组而言,加载情况有所不同,数组类本身不通过类加载器创建,是由 JVM 直接创建的。但是数组中的元素还是要靠类加载器去创建,如果数组去掉一个维度后是引用类型,就采用类加载器去加载,否则就交给启动类加载器去加载。

另外加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

6.2 连接阶段:

类的加载过程后生成了类的java.lang.Class对象,接着会进入连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分三个阶段。

6.3 初始化阶段:

类初始化是类加载的最后一步,除了加载阶段,用户可以通过自定义的类加载器参与,其他阶段都完全由虚拟机主导和控制。到了初始化阶段才真正执行Java代码。

初始化的过程包括执行类构造器方法,static变量赋值语句,static{}代码块,如果是一个子类进行初始化会先对其父类进行初始化,保证其父类在子类之前进行初始化;所以其实在java中初始化一个类,那么必然是先初始化java.lang.Object,因为所有的java类都继承自java.lang.Object。

如static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100。

首先什么情况下类会初始化?

Java虚拟机规范中严格规定了有且只有五种情况必须对类进行初始化:

注意,虚拟机规范使用了“有且只有”这个词描述,这五种情况被称为“主动引用”,除了这五种情况,所有其他的类引用方式都不会触发类初始化,被称为“被动引用”。

6.4 总结

前面讲了这么多,那还是有一个疑问,一个类是啥时候开始加载的呢?

其实,Java虚拟机规范中并没有进行强制约束,这点虚拟机根据自身实现来把握。但对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行初始化(加载,验证,准备肯定要在此之前进行了),这5种情况我们上面有过介绍。

7、类加载器

类加载器实现的功能是即为加载阶段获取二进制字节流的时候。

JVM提供了以下3种系统的类加载器:

类加载器之间的层次关系如下:

image.png

类加载器之间的这种层次关系叫做双亲委派模型。
双亲委派模型要求除了顶层的启动类加载器(Bootstrap ClassLoader)外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不是以继承关系实现的,而是用组合实现的。

双亲委派模型的工作过程:
如果一个类接受到类加载请求,他自己不会去加载这个请求,而是将这个类加载请求委派给父类加载器,这样一层一层传送,直到到达启动类加载器(Bootstrap ClassLoader)。
只有当父类加载器无法加载这个请求时,子加载器才会尝试自己去加载。

双亲委派模型很好的解决了各个类加载器加载基础类的统一性问题。即越基础的类由越上层的加载器进行加载。

破坏双亲委派模型:
若加载的基础类中需要回调用户代码,而这时顶层的类加载器无法识别这些用户代码,怎么办呢?这时就需要破坏双亲委派模型了。

Spring破坏双亲委派模型
Spring要对用户程序进行组织和管理,而用户程序一般放在WEB-INF目录下,由WebAppClassLoader类加载器加载,而Spring由Common类加载器或Shared类加载器加载。
那么Spring是如何访问WEB-INF下的用户程序呢?
使用线程上下文类加载器。 Spring加载类所用的classLoader都是通过Thread.currentThread().getContextClassLoader()获取的。当线程创建时会默认创建一个AppClassLoader类加载器(对应Tomcat中的WebAppclassLoader类加载器): setContextClassLoader(AppClassLoader)。
利用这个来加载用户程序。即任何一个线程都可通过getContextClassLoader()获取到WebAppclassLoader。

8、tomcat类加载架构

image.png

Tomcat目录下有4组目录:

/common目录下:类库可以被Tomcat和Web应用程序共同使用;由 Common ClassLoader类加载器加载目录下的类库;
/server目录:类库只能被Tomcat可见;由 Catalina ClassLoader类加载器加载目录下的类库;
/shared目录:类库对所有Web应用程序可见,但对Tomcat不可见;由 Shared ClassLoader类加载器加载目录下的类库;
/WebApp/WEB-INF目录:仅仅对当前web应用程序可见。由 WebApp ClassLoader类加载器加载目录下的类库;
每一个JSP文件对应一个JSP类加载器。

9、总结

我们前面写了JVM内存管理,OOM,垃圾回收,类加载,下面我们通过一个对象的生命周期来把这些知识点串联起来。

对象的生命周期可以从类加载开始算起,但是JVM并没有严格规定类加载开始的时机,我们这里以new 一个对象开始。

Java在new一个对象的时候,会先查看对象所属的类有没有被加载到内存,如果没有的话,就会先通过类的全限定名来加载。

加载并初始化类完成后,再进行对象的创建工作。

我们先假设是第一次使用该类,这样的话new一个对象就可以分为两个过程:加载并初始化类和创建对象。

https://mp.weixin.qq.com/s/QXDINKJ_5PfUgvDRREctwQ

9.1 类加载

最终,方法区会存储当前类类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句 和 静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用。

7.2 创建对象

1、在堆区分配对象需要的内存
分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量。
2、对所有实例变量赋默认值
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值。
3、执行实例初始化代码
初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法。
4、如果有类似于Child c = new Child()形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它

需要注意的是,每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问。

7.3 垃圾回收

触发GC运行的条件要分新生代和老年代的情况来进行讨论,有以下几点会触发GC:

7.4 OOM

内存溢出:当申请的内存超出了JVM能提供的内存大小,此时称之为溢出。
从上面内存模型中我们可以总结出最常见的OOM情况:

4.2 如何排查

JVM Heap dump和Thread dump

上一篇下一篇

猜你喜欢

热点阅读