ZhouXiangの博客 后端工程师&机器学习爱好者

Java学习系列之JVM

2019-10-31
 

本文系记录对Java中虚拟机,如有异议,欢迎联系我讨论修改。PS:图侵删!如果看不清图请访问:https://github.com/zx950519/zx950519.github.io/blob/master/_posts/2019-10-31-Java%E5%AD%A6%E4%B9%A0%E7%B3%BB%E5%88%97%E4%B9%8BJVM.md

Java和C++在GC上的区别

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java程序员把内存控制权利交给Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

JVM内存模型

线程私有部分:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享部分:

  • 方法区
  • 直接内存

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。局部变量表主要存放了编译器可知的各种数据类型以及对象引用。Java虚拟机栈会出现两种异常:StackOverFlowError和OutOfMemoryError。

Java 虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。JDK1.8的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。整个永久代有一个JVM本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到 java.lang.OutOfMemoryError。

运行时常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。JDK1.7及之后版本的JVM已经将运行时常量池从方法区中移了出来,在Java堆(Heap)中开辟了一块区域存放运行时常量池。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

类加载器ClassLoader

Class文件需要加载到虚拟机中之后才能运行和使用,系统加载Class类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。

加载

类加载过程的第一步,主要完成下面3件事情:

  • 通过全类名获取定义此类的二进制字节流
  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

连接

先进行验证

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中
  • 所设置的初始值”通常情况”下是数据类型默认的零值(如0、0L、null、false等),特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被复制为 111

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

对于初始化阶段,虚拟机严格规范了有且只有5中情况下,必须对类进行初始化:

  • 当遇到 new、 getstatic、putstatic 或 invokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时
  • 使用 java.lang.reflect 包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化
  • 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化
  • 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类

初始化

初始化是类加载的最后一步,也是真正执行类中定义的Java程序代码(字节码),初始化阶段是执行类构造器 ()方法的过程。对于()方法的调用,虚拟机会自己确保其在多线程环境中的安全性。但是构造器并非绝对安全,尤其是在构造器执行完前将this已用暴露给另一个线程,可能导致引用泄漏,导致严重的并发安全问题。

对象创建过程

类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配并发问题,在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式

执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来

对象的访问定位方式

  • 句柄:如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
  • 直接指针:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址

JAVA对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。

垃圾回收机制——GC

内存分配和回收

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是堆内存中对象的分配与回收。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

常见的分配与回收策略如下:

  • 对象优先在 eden 区分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代

大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。

各区所占的比例:Tentired占2/3,剩下的1/3Eden占80%,两个survivor各占10%。

如何判断对象已经死亡

引用计数法给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。GC Roots一般包括如下对象:

  • 常量、static变量
  • 由ClassLoader加载的Class对象
  • VM栈中的对象
  • 本地方法栈中的对象
  • 用于同步的监控对象

Java中几种引用

引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱):

  • 强引用,常见的引用,垃圾回收器绝不会回收它。当内存空 间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题
  • 软引用:如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。软引用适合缓存的场景。
  • 弱引用:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。除了WeakHashMap使用了弱引用,ThreadLocal类中也是用了弱引用。
  • 虚引用:虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收

垃圾收集算法

标记清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有存活的对象,在标记完成后统一回收所有未被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  • 效率问题
  • 空间问题(标记清除后会产生大量不连续的碎片)

复制算法

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

标记-整理算法

根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

上述几种收集算法对比如下:
image

垃圾收集器

Serial 收集器

Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。新生代采用复制算法,老年代采用标记-整理算法。它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

Parallel Scavenge 收集器

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。新生代采用复制算法,老年代采用标记-整理算法。

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS 收集器

CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对为标记的区域做清扫

CMS的缺点:

  • 对 CPU 资源敏感(CMS默认启动的回收线程数是(cpu数量+3/4))
  • 无法处理浮动垃圾
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生

简述Concurrent Mode Failure(浮动垃圾)

CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

G1

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。它具备以下特性:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让Java 程序继续执行
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念
  • 空间整合:与 CMS 的“标记–清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内

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

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 GF 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

G1原理

G1的内存结构和传统的内存空间划分有比较的不同。G1将内存划分成了多个大小相等的Region(默认是512K),Region逻辑上连续,物理内存地址不连续。同时每个Region被标记成E、S、O、H,分别表示Eden、Survivor、Old、Humongous。其中E、S属于年轻代,O与H属于老年代。 示意图如下:

H表示Humongous。从字面上就可以理解表示大的对象(下面简称H对象)。当分配的对象大于等于Region大小的一半的时候就会被认为是巨型对象。H对象默认分配在老年代,可以防止GC的时候大对象的内存拷贝。通过如果发现堆内存容不下H对象的时候,会触发一次GC操作。

G1跨代引用

在进行Young GC的时候,Young区的对象可能还存在Old区的引用, 这就是跨代引用的问题。为了解决Young GC的时候,扫描整个老年代,G1引入了Card Table 和Remember Set的概念,基本思想就是用空间换时间。这两个数据结构是专门用来处理Old区到Young区的引用。Young区到Old区的引用则不需要单独处理,因为Young区中的对象本身变化比较大,没必要浪费空间去记录下来:

  • RSet:全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。
  • Card: JVM将内存划分成了固定大小的Card。这里可以类比物理内存上page的概念。

下图展示的是RSet与Card的关系。每个Region被分成了多个Card,其中绿色部分的Card表示该Card中有对象引用了其他Card中的对象,这种引用关系用蓝色实线表示。RSet其实是一个HashTable,Key是Region的起始地址,Value是Card Table (字节数组),字节数组下标表示Card的空间地址,当该地址空间被引用的时候会被标记为dirty_card:

G1 Snapshot At The Beginning-SATB

SATB的全称(Snapshot At The Beginning)字面意思是开始GC前存活对象的一个快照。SATB的作用是保证在并发标记阶段的正确性。当并发标记阶段,应用线程改变了引用关系,则会破坏快照的准确性。G1采用的是pre-write barrier解决这个问题。简单说就是在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录并保存在一个队列里,在JVM源码中这个队列叫satb_mark_queue。在remark阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象,snapshot的完整性也就得到了保证。

SATB的方式记录活对象,也就是那一时刻对象snapshot, 但是在之后这里面的对象可能会变成垃圾, 叫做浮动垃圾(floating garbage),这种对象只能等到下一次收集回收掉。在GC过程中新分配的对象都当做是活的,其他不可达的对象就是死的。

Minor与Major与Full之间的区别

Minor GC

从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC。这一定义既清晰又易于理解。但是,当发生Minor GC事件的时候,有一些有趣的地方需要注意到:

  • 当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以分配率越高,越频繁执行 Minor GC
  • 内存池被填满的时候,其中的内容全部会被复制,指针会从0开始跟踪空闲内存。Eden 和 Survivor 区进行了标记和复制操作,取代了经典的标记、扫描、压缩、清理操作。所以 Eden 和 Survivor 区不存在内存碎片。写指针总是停留在所使用内存池的顶部
  • 执行 Minor GC 操作时,不会影响到永久代。从永久代到年轻代的引用被当成 GC roots,从年轻代到永久代的引用在标记阶段被直接忽略掉
  • 质疑常规的认知,所有的 Minor GC 都会触发”全世界的暂停(stop-the-world)”,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的。其中的真相就是,大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果正好相反,Eden 区大部分新生对象不符合 GC 条件,Minor GC 执行时暂停的时间将会长很多。

Minor GC 的情况就相当清楚了——每次 Minor GC 会清理年轻代的内存。

Major GC vs Full GC

  • Major GC 是清理老年代
  • Full GC 是清理整个堆空间—包括年轻代和老年代

很不幸,实际上它还有点复杂且令人困惑。首先,许多 Major GC 是由 Minor GC 触发的,所以很多情况下将这两种 GC 分离是不太可能的。另一方面,许多现代垃圾收集机制会清理部分永久代空间,所以使用“cleaning”一词只是部分正确。

大牛的意见

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

  • Partial GC:并不收集整个GC堆的模式
    • Young GC:只收集young gen的GC
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式

Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。

最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是:

  • young GC:当young gen中的eden区分配满的时候触发。注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。
  • full GC:当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC);或者,如果有perm gen的话,要在perm gen分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。

Parallel Scavenge(-XX:+UseParallelGC)框架下,默认是在要触发full GC前先执行一次young GC,并且两次GC之间能让应用程序稍微运行一小下,以期降低full GC的暂停时间(因为young GC会尽量清理了young gen的死对象,减少了full GC的工作量)。控制这个行为的VM参数是-XX:+ScavengeBeforeFullGC。这是HotSpot VM里的奇葩

类加载

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader:

  • BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类
  • ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包
  • AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类

双亲委派模型

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

简述类加载器级别判断两个类是否相等

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。所以两个类是否相等,必须建立在它们是由同一个类加载器加载的基础上,否则即使是由同一个VM加载,来自同一个Class文件,也不相等。

双亲委派模型的好处

双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

system.gc()

调用system.gc()马上就执行gc吗? 当调用System.gc()的时候,其实并不会马上进行垃圾回收,甚至不一定会执行垃圾回收。

public static void gc() {
    boolean shouldRunGC;
    synchronized(lock) {
        shouldRunGC = justRanFinalization;
        if (shouldRunGC) {
            justRanFinalization = false;
        } else {
            runGC = true;
        }
    }
    if (shouldRunGC) {
        Runtime.getRuntime().gc();
    }
}

也就是justRanFinalization=true的时候才会执行。查找发现当调用runFinalization()的时候justRanFinalization变为true。

public static void runFinalization() {
    boolean shouldRunGC;
    synchronized(lock) {
        shouldRunGC = runGC;
        runGC = false;
    }
    if (shouldRunGC) {
        Runtime.getRuntime().gc();
    }
    Runtime.getRuntime().runFinalization();
    synchronized(lock) {
        justRanFinalization = true;
    }
}

直接调用System.gc()只会把这次gc请求记录下来,等到runFinalization=true的时候才会先去执行GC,runFinalization=true之后会在允许一次system.gc()。之后在call System.gc()还会重复上面的行为。所以System.gc()要跟System.runFinalization()一起搭配使用:

static void gcAndFinalize() {
    final VMRuntime runtime = VMRuntime.getRuntime();
    System.gc();
    runtime.runFinalizationSync();
    System.gc();
}

System.gc()与Runtime.gc()的区别

java.lang.System.gc()只是java.lang.Runtime.getRuntime().gc()的简写,两者的行为没有任何不同。唯一的区别就是System.gc()写起来比Runtime.getRuntime().gc()简单点。GC本身是会周期性的自动运行的,由JVM决定运行的时机,而且现在的版本有多种更智能的模式可以选择,还会根据运行的机器自动去做选择,就算真的有性能上的需求,也应该去对GC的运行机制进行微调,而不是通过使用这个命令来实现性能的优化。

Java中各种变量的存放位置

JDK1.8下的java.lang.Class对象和static成员变量在堆还是方法区?

JDK1.8中,两者都位于堆(Heap),且static成员变量位于Class对象内。

方法中的变量

在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因。

  • 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
  • 当声明的是引用类型变量时,所声明的变量名(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中

类中的变量

在类中声明的变量是成员变量,也叫全局变量,放在堆中,全局变量不会随着某个方法执行结束而销毁。

  • 基本类型的变量,其变量名及其值放在堆内存中
  • 引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

静态变量

静态变量只能存在于类中,方法中不允许使用static修饰变量。其生命周期从JVM第一次读到这个类并加载类时开始,类销毁时便不再存在。静态变量存在于方法区即静态区(方法区包含整个程序中唯一存在的元素)

JVM与String

String a = new String("abc");

等号左边不创建对象,仅仅是一个对象实例的引用;等号右边才是真正创建的对象。其中,”abc”并不存在于堆内存中,他存在于常量池中。由于字符串对象可能会被大量引用,Java为了节省内存空间和运行时间,会把所有字符串放到常量池中保存。常量池中的所有相同字符串仅占一个空间。所以,比较字符串时用==时可以的,因为地址唯一。如果常量池中没有字符串”abc”,那么该过程会产生两个对象,分别在Heap与方法区的常量池;否则仅new产生一个对象,在Heap。

String s = "aaa" + new String("bbb")

上述代码最多可创造4个对象,分别是:

  • “aaa”一个对象
  • new Sring()一个对象
  • “bbb”一个对象
  • “aa” + new new Sring()一个对象

String的创建与位置案例分析

String a1 = "AA";\\只在常量池上创建常量
String a2 = new String("A") + new String("A");//只在堆上创建对象
String a3 = new String("AA");//在堆上创建对象,在常量池上创建常量
String a4 = new String("A") + new String("A");//只在堆上创建对象AA
a4.intern();//将该对象AA的引用保存到常量池上
String a5 = new String("A") + new String("A");//只在堆上创建对象
a5.intern();//在常量池上创建引用
String a6 = "AA";//此时不会再在常量池上创建常量AA,而是将a5的引用返回给a6
System.out.println(a5 == a6); //true
String s=1;
String s=new String(1);

undefined

String s7=1+2+3;

undefined

String s8=1+3+new String(1)+4;

undefined

字符串常量池随JDK版本的位置变化

  • 7以前方法区位于永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动JVM时可以设置一个固定值,不可变
  • 7时存储在永久代的部分数据就已经转移到Java Heap或者Native memory。但永久代仍存在于JDK 1.7中,并没有完全移除,譬如符号引用(Symbols)转移到了native memory;字符串常量池(interned strings)转移到了Java heap;类的静态变量(class statics)转移到了Java heap
  • 8中取消永久代,其余的数据作为元数据存储在元空间中存放于元空间(Metaspace), 元空间是一块与堆不相连的本地内存。

JDK1.6与1.8中字符串常量池的位置变化

https://www.cnblogs.com/Kidezyq/p/8040338.html

如何判断一个类是无用的类

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类” :

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Similar Posts

Comments