写点什么

关于 JVM 的内存模型

作者:Java学术趴
  • 2022 年 7 月 29 日
  • 本文字数:8404 字

    阅读完需:约 28 分钟

关于JVM的内存模型

👨‍🎓作者:Java 学术趴

🏦仓库:GithubGitee

✏️博客:CSDN掘金InfoQ云+社区

💌公众号:Java 学术趴

🚫特别声明:原创不易,未经授权不得转载或抄袭,如需转载可联系小编授权。

🙏版权声明:文章里的部分文字或者图片来自于互联网以及百度百科,如有侵权请尽快联系小编。微信搜索公众号 Java 学术趴联系小编。

☠️每日毒鸡汤:这个社会是存在不公平的,不要抱怨,因为没有用!人总是在反省中进步的!

👋大家好!我是你们的老朋友 Java 学术趴,今天给大家分享一下 Java 中的核心技术 JVM。作为一个 Java 程序员,相比或多或少的都会接触到一些关于 Java 底层的知识,这些底层知识是非常重要的,相比之下这些知识也是比较难以理解的,小编今天用大白话的方式给大家整理了一些技术点,可以便于大家的理解,喜欢的家人们点赞支持呦.....

1. 请简述一下 JVM 的内存模型

JVM 在执行 Java 程序时,会把它管理的内存划分为若干个的区域,每个区域都有自己的用途和创建销毁时间。如下图所示,可以分为两大部分,线程私有区和共享区。

线程私有区

  1. 程序计数器

当同时进行的线程数超过 CPU 数或其内核数时,就要通过时间片轮询分派 CPU 的时间资源,不免发生线程切换。这时,每个线程就需要一个属于自己的计数器来记录下一条要运行的指令。如果执行的是 JAVA 方法,计数器记录正在执行的 java 字节码地址,如果执行的是 native 方法,则计数器为空。

  1. 虚拟机栈

  • 线程私有的,与线程在同一时间创建。管理 JAVA 方法执行的内存模型。每个方法执行时都会创建一个桢栈来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。栈的大小决定了方法调用的可达深度(递归多少层次,或嵌套调用多少层其他方法,-Xss 参数可以设置虚拟机栈大小),栈的大小可以是固定也可以动态扩展。

  • 一个线程对应一个虚拟机栈,一个虚拟机栈对应多个栈帧,每个栈帧的的入栈和出栈表示一个方法的调用。

  • 如果请求的栈深度大于最大可用深度,则抛出 stackOverflflowError;

  • 如果栈是可动态扩展的,但没有内存空间支持扩展,则抛出 OutofMemoryError。

  1. 本地方法栈

与虚拟机栈作用相似。但它不是为 Java 方法服务的,而是本地方法(C 语言)。由于规范对这块没有强制要求,不同虚拟机实现方法不同。

线程共享区

  1. 方法区

线程共享的,用于存放被虚拟机加载的类的元数据信息,如常量、静态变量和即时编译器编译后的代码。若要分代,算是永久代(老年代),以前类大多“static”的,很少被卸载或收集,现回收废弃常量和无用的类。其中运行时常量池存放编译生成的各种常量。(如果 hotspot 虚拟机确定一个类的定义信息不会被使用,也会将其回收。回收的基本条件至少有:所有该类的实例被回收,而且装载该类的 ClassLoader 被回收)。

存放对象实例和数组,是垃圾回收的主要区域,分为新生代和老年代。刚创建的对象在新生代的 Eden 区中,经过 GC 后进入新生代的 S0 区中,再经过 GC 进入新生代的 S1 区中,15 次 GC 后仍存在就进入老年代。这是按照一种回收机制进行划分的,不是固定的。若堆的空间不够实例分配,则 OutOfMemoryError。

Young Generation 即图中的Eden + From Space(s0) + To Space(s1)Eden 存放新生的对象Survivor Space 有两个,存放每次垃圾回收后存活的对象(s0+s1)Old Generation Tenured Generation 即图中的Old Space 主要存放应用程序中生命周期长的存活对象复制代码
复制代码

2. 说说堆和栈的区别

栈是运行时单位,代表着逻辑,内含基本数据类型和堆中对象引用,所在区域连续,没有碎片;堆是存储单位,代表着数据,可被多个栈共享(包括成员中基本数据类型、引用和引用对象),所在区域不连续,会有碎片。

  • 功能不同

栈内存用来存储局部变量和方法调用,而堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。

  • 共享性不同

栈内存是线程私有的。 堆内存是所有线程共有的。

  • 异常错误不同

如果栈内存或者堆内存不足都会抛出异常。 栈空间不足:java.lang.StackOverFlowError。 堆空间不足:java.lang.OutOfMemoryError。

  • 空间大小

栈的空间大小远远小于堆的。

3. 什么时候会触发 FullGC

除直接调用 System.gc 外,触发 Full GC 执行的情况有如下四种。

  1. 老年代空间不足

老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行 Full GC 后空间仍然不足,则抛出如下错误: java.lang.OutOfMemoryError: Java heap spac e 为避免以上两种状况引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

  1. Permanet Generation 空间满

PermanetGeneration 中存放的为一些 class 的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下会执行 FullGC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space 为避免 Perm Gen 占满造成 Full GC 现象,可采用的方法为增大 Perm Gen 空间或转为使用 CMS GC。注意 JDK 版本,在 JDK1.8 开始就没有永久代,使用的是元空间代替永久代(元空间相关后,请看后面题目)。

  1. CMS GC 时出现 promotion failed 和 concurrent mode failure 对

于采用 CMS 进行旧生代 GC 的程序而言,尤其要注意 GC 日志中是否有 promotion failed 和 concurrent mode failure 两种状况,当这两种状况出现时可能会触发 Full GC。 promotionfailed 是在进行 Minor GC 时,survivor space 放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。 应对措施为:增大 survivorspace、旧生代空间或调低触发并发 GC 的比率,但在 JDK 5.0+、6.0+的版本中有可能会由于 JDK 的 bug29 导致 CMS 在 remark 完毕后很久才触发 sweeping 动作。对于这种状况,可通过设置-XX:CMSMaxAbortablePrecleanTime=5(单位为 ms)来避免。

  1. 统计得到的 Minor GC 晋升到旧生代的平均大小大于旧生代的剩余空间

这是一个较为复杂的触发情况,Hotspot 为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行 Minor GC 时,做了一个判断,如果之前统计所得到的 Minor GC 晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发 Full GC。 例如程序第一次触发 MinorGC 后,有 6MB 的对象晋升到旧生代,那么当下一次 Minor GC 发生时,首先检查旧生代的剩余空间是否大于 6MB,如果小于 6MB,则执行 Full GC。 当新生代采用 PSGC 时,方式稍有不同,PS GC 是在 MinorGC 后也会检查,例如上面的例子中第一次 Minor GC 后,PS GC 会检查此时旧生代的剩余空间是否大于 6MB,如小于,则触发对旧生代的回收。 除了以上 4 种状况外,对于使用 RMI 来进行 RPC 或管理的 Sun JDK 应用而言,默认情况下会一小时执行一次 Full GC。可通过在启动时通过- javaDsun.rmi.dgc.client.gcInterval=3600000 来设置 Full GC 执行的间隔时间或通过-XX:+DisableExplicitGC 来禁止 RMI 调用 System.gc。

4. 为什么 Java 被称作是“平台无关的编程语言”?

Java 虚拟机是一个可以执行 Java 字节码的虚拟机进程。Java 源文件被编译成能被 Java 虚拟机执行的字节码文件。 Java 被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java 虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。

平台无关关键在于需要在每个平台安装对应的的 JDK 版本。

5. 说说对象分配规则

  • 对象优先分配在 Eden 区,如果 Eden 区没有足够的空间时,虚拟机执行一次 Minor GC。

  • 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。

  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了 1 次 Minor GC 那么对象会进入 Survivor 区,之后每经过一次 Minor GC 那么对象的年龄加 1,知道达到阀值对象进入老年区。

  • 动态判断对象的年龄。如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

  • 空间分配担保。每次进行 Minor GC 时,JVM 会计算 Survivor 区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次 Full GC,如果小于检查 HandlePromotionFailure 设置,如果 true 则只进行 Monitor GC,如果 false 则进行 Full GC。

6. 熟悉类加载机制吗?

JVM 类加载分为 5 个过程:加载,验证,准备,解析,初始化,使用,卸载

加载

加载主要是将.class 文件(并不一定是.class。可以是 ZIP 包,网络中获取)中的二进制字节流读入到 JVM 中。 在加载阶段,JVM 需要完成 3 件事: 1)通过类的全限定名获取该类的二进制字节流; 2)将字节流所代表的静态存储结构转化为方法区的运行时数据结构; 3)在内存中生成一个该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

验证

验证是连接阶段的第一步,主要确保加载进来的字节流符合 JVM 规范。 验证阶段会完成以下 4 个阶段的检验动作: 1)文件格式验证 2)元数据验证(是否符合 Java 语言规范) 3)字节码验证(确定程序语义合法,符合逻辑) 4)符号引用验证(确保下一步的解析能正常执行)

准备

主要为静态变量在方法区分配内存,并设置默认初始值。

解析

是虚拟机将常量池内的符号引用替换为直接引用的过程。

初始化

初始化阶段是类加载过程的最后一步,主要是根据程序中的赋值语句主动为类变量赋值。 注: 1)当有父类且父类为初始化的时候,先去初始化父类; 2)再进行子类初始化语句。

7. 什么是类加载器?

类加载器是一个用来加载类文件的类。Java 源代码通过 javac 编译器编译成类文件。然后 JVM 来执行类文件中的字节码来执行程序。类加载器负责加载文件系统、网络或其他来源的类文件。

实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。

8. 类加载器有哪些?

主要有一下四种类加载器:

  • 启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。

  • 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。

  • 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。

  • 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。

9. 什么是双亲委派模型?

在介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。

类加载器分类:

  • 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载 Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;

  • 其他类加载器:一些外部的 jar 依赖。

  • 扩展类加载器(Extension ClassLoader):负责加载\lib\ext 目录或 Java. ext. dirs 系统变量指定的路径中的所有类库;

  • 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

双亲委派机制

如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。

当一个类收到了类加载请求时,不会自己先去加载这个类,先查询是否已经加载过,没有加载过就将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。

10. 如何破坏双亲委派模型?

如果不想打破双亲委派模型,就重写 ClassLoader 类中的 fifindClass()方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。而如果想打破双亲委派模型则需要重写 loadClass()方法(当然其中的坑也不会少)。典型的打破双亲委派模型的框架和中间件有 tomcat 与 osgi 。

11. 如何自定义类加载器?

用户根据需求自己定义的。需要继承自 ClassLoader ,重写方法 findClass() 。

如果想要编写自己的类加载器,只需要两步:

继承 ClassLoader 类

覆盖 findClass(String className) 方法

ClassLoader 超类的 loadClass 方法用于将类的加载操作委托给其父类加载器去进行,只有当该类尚未加载并且父类加载器也无法加载该类时,才调用 findClass 方法。 如果要实现该方法,必须做到以下几点:

1.为来自本地文件系统或者其他来源的类加载其字节码。

2.调用 ClassLoader 超类的 defineClass 方法,向虚拟机提供字节码。

12. 为什么需要 Survivor 区?只有 Eden 不行吗?

  • 如果没有 Survivor,Eden 区每进行一次 Minor GC ,并且没有年龄限制的话, 存活的对象就会被送到老年代。 这样一来,老年代很快被填满,触发 Major GC(因为 Major GC 一般伴随着 Minor GC,也可以看做触发了 Full GC)。 老年代的内存空间远大于新生代,进行一次 Full GC 消耗的时间比 Minor GC 长得多。

  • 执行时间长有什么坏处?频发的 Full GC 消耗的时间很长,会影响大型程序的执行和响应速度。可能你会说,那就对老年代的空间进行增加或者较少咯。

  • 假如增加老年代空间,更多存活对象才能填满老年代。虽然降低 Full GC 频率,但是随着老年代空间加大,一旦发生 Full GC,执行所需要的时间更长。

  • 假如减少老年代空间,虽然 Full GC 所需时间减少,但是老年代很快被存活对象填满,Full GC 频率增加。

  • 所以 Survivor 的存在意义,就是减少被送到老年代的对象,进而减少 Full GC 的发生,Survivor 的预筛选保证,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

13. 为什么需要两个大小一样的 Survivor 区?

最大的好处就是解决了碎片化。也就是说为什么一个 Survivor 区不行?第一部分中,我们知道了必须设置 Survivor 区。假设 现在只有一个 Survivor 区,我们来模拟一下流程:刚刚新建的对象在 Eden 中,一旦 Eden 满了,触发一次 Minor GC,Eden 中的存活对象就会被移动到 Survivor 区。这样继续循 环下去,下一次 Eden 满了的时候,问题来了,此时进行 Minor GC,Eden 和 Survivor 各有一些存活对象,如果此时把 Eden 区的 存活对象硬放到 Survivor 区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。永远有一个 Survivor space 是空的,另一个非空的 Survivor space 无碎片。

14. 新生代中 Eden:S1:S2 为什么是 8:1:1?

新生代中的可用内存:复制算法用来担保的内存为 9:1,所以只会造成 10% 的空间浪费。 可用内存中 Eden:S1 区为 8:1 即新生代中 Eden:S1:S2 = 8:1:1 这个比例,是由参数 -XX:SurvivorRatio 进行配置的(默认为 8)。

15. 能够触发条件 Full GC 有哪些?

(1)调用 System.gc 时,系统建议执行 Full GC,但是不必然执行

(2)老年代空间不足

(3)方法去空间不足

(4)通过 Minor GC 后进入老年代的平均大小 > 老年代的可用内存

(5)由 Eden 区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。即老年代无法存放下新年代过度到老年代的对象的时候,会触发 Full GC。

16. 了解 fifinalize 方法吗?

这个方法就有点类似于某个人被拍了死刑,但是不一定会死。

即使在可达性分析算法中不可达的对象,也并非一定是“非死不可”的,这时候他们暂时处于“缓刑”阶段,真正宣告一个对象死亡至少要经历两个阶段:

  1. 如果对象在可达性分析算法中不可达,那么它会被第一次标记并进行一次刷选,刷选的条件是是否需要执行 fifinalize()方法(当对象没有覆盖 fifinalize()或者 fifinalize()方法已经执行过了(对象的此方法只会执行一次)),虚拟机将这两种情况都会视为没有必要执行)。

  2. 如果这个对象有必要执行 fifinalize()方法会将其放入 F-Queue 队列中,稍后 GC 将对 F-Queue 队列进行第二次标记,如果在重写 fifinalize()方法中将对象自己赋值给某个类变量或者对象的成员变量,那么第二次标记时候就会将它移出“即将回收”的集合。

17. java 中都有哪些引用类型?

  • 强引用:发生 gc 的时候不会被回收,开发中使用最多的场景。当 JVM 内存空间不足, JVM 宁愿抛出 OutOfMemoryError 运行时错误( OOM ),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。

  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

  • 弱引用:有用但不是必须的对象,在下一次 GC 时会被回收。弱引用可以和一个引用队列( ReferenceQueue )联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。 应用场景:弱应用同样可用于内存敏感的缓存。

  • 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。

18. 熟悉 java 对象结构吗?

Java 对象由三个部分组成:对象头、实例数据、对齐填充。

对象头由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC 分代年龄、锁标识状态、线程持有的锁、偏向线程 ID(一般占 32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)

对齐填充:JVM 要求对象起始地址必须是 8 字节的整数倍(8 字节对齐)

19. 说说 Java 对象创建过程

  1. JVM 遇到一条新建对象的指令时首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用。然后加载这个类(类加载过程在后边讲)

  2. 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,最终常用的办法“本地线程缓冲分配(TLAB)”

  3. 将除对象头外的对象内存空间初始化为 0

  4. 对对象头进行必要设置

20. JVM 的永久代中会发生垃圾回收么?

垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(FullGC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免 Full GC 是非常重要的原因。请参考下 Java8:从永久代到元数据区 (注:Java8 中已经移除了永久代,新加了一个叫做元数据区的 native 内存区)

21. 你知道哪些垃圾收集算法?

GC 最基础的算法有三种: 标记 -清除算法、复制算法、标记-压缩算法,我们常用的垃圾回收器一般都采用分代收集算法。

  • 标记 -清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清 除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

  • 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 标记-压缩算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

  • 分代收集算法,“分代收集”(Generational Collection)算法,把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

22. 如何选择垃圾收集器?

  1. 如果你的堆大小不是很大(比如 100MB ),选择串行收集器一般是效率最高的。

  2. 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有单核,选择串行收集器依然是合适的,这时候启用一些并行收集器没有任何收益。

    参数: -XX:+UseSerialGC 。

  3. 如果你的应用是“吞吐量”优先的,并且对较长时间的停顿没有什么特别的要求。选择并行收集器是比较好的。

    参数: -XX:+UseParallelGC 。

  4. 如果你的应用对响应时间要求较高,想要较少的停顿。甚至 1 秒的停顿都会引起大量的请求失败,那么选择 G1 、 ZGC 、 CMS 都是合理的。虽然这些收集器的 GC 停顿通常都比较短,但它需要一些额外的资源去处理这些工作,通常吞吐量会低一些。

    参数:

    -XX:+UseConcMarkSweepGC 、 -XX:+UseG1GC 、 -XX:+UseZGC 等。

从上面这些出发点来看,我们平常的 Web 服务器,都是对响应性要求非常高的。选择性其实就集中在 CMS 、 G1 、 ZGC 上。而对于某些定时任务,使用并行收集器,是一个比较好的选择。

发布于: 刚刚阅读数: 4
用户头像

Java学术趴

关注

还未添加个人签名 2022.07.02 加入

还未添加个人简介

评论

发布
暂无评论
关于JVM的内存模型_7 月月更_Java学术趴_InfoQ写作社区