- JVM 内存结构
- 栈中存放什么数据,堆中呢?
- 大对象放在哪个内存区域
- 堆区如何分类,如何分配内存
- 判断对象是否死亡
- 垃圾回收有哪些算法
- GC 的全流程
- GC 中老年代用什么回收方法?
- 垃圾收集器
- 什么是类加载?何时类加载?类加载流程?
- 知道哪些类加载器。类加载器之间的关系?
- 双亲委派
- JVM 调优参数
Java内存结构
JDK 1.8 之前 :
JDK 1.8 之后 :
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
⚠️ 注意 :程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈
与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈帧(Stack Frame):每个线程在执行方法时,都会创建对应的栈帧,用于存储方法的局部变量表、操作数栈、动态链接、方法出口等信息。栈帧的创建和销毁与方法的调用和返回密切相关。
局部变量表 用于存储方法中的局部变量和方法参数。局部变量表的每个槽位可以容纳一个基本数据类型或对象引用。
操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
动态链接 动态链接是指在方法调用过程中将符号引用转换为实际的方法入口地址的过程。在Java中,方法的调用是基于动态绑定的,也就是说方法调用的目标可能在编译时无法确定,需要在运行时进行解析。
方法出口 用于记录方法的返回地址,即方法执行完成后需要返回到哪个位置继续执行。
简单总结一下程序运行中栈可能会出现两种错误:
StackOverFlowError
: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
错误。OutOfMemoryError
: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
本地方法栈
在Java中,本地方法(Native Method)是指使用非Java语言(如C、C++等)编写的方法。本地方法允许Java程序与底层系统或其他非Java代码进行交互。
在Java中,本地方法的声明由native
关键字修饰,它只有方法的声明而没有实现。本地方法的实现需要在本地代码中完成,并通过JNI和Java代码进行关联。Java程序通过JNI接口调用本地方法。
本地方法允许直接访问底层系统资源,如操作系统的API、硬件设备等。这使得Java程序可以利用本地方法获得更高的性能或访问特定的底层功能。
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 世界中“几乎”所有的对象都在堆中分配,但是,从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用,那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。
堆这里最容易出现的就是 OutOfMemoryError
错误
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
字符串常量池中存储的是字符串对象。字符串常量池中包含一个 StringTable
,用于实现字符串常量池的管理和维护。它通常是基于哈希表(HashTable)的数据结构,用于快速查找和存储字符串对象。
StringTable
中保存的是字符串对象的引用,字符串对象的引用指向字符串常量池中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
方法区
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区和永久代以及元空间是什么关系呢? 方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
运行时常量池
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
Java 的运行时常量池(Runtime Constant Pool)是一块位于方法区的内存区域,用于存储编译时期生成的各种字面量(Literal)和符号引用(Symbolic References)。
-
存储字面量:运行时常量池存储了编译时期生成的字符串字面量、数字字面量、布尔值等字面量。这些字面量可以直接被引用和使用,而无需再次创建和初始化。
-
存储符号引用:符号引用是指在编译时期生成的、用于描述类、方法、字段等符号的字符串符号。运行时常量池中存储了类的全限定名、方法的名称和描述符、字段的名称和描述符等符号引用。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError
错误。
JVM 垃圾回收
内存分配和回收原则
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。如果GC 期间虚拟机又发现变量无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放变量所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。
大对象直接进入老年代,比如:字符串、数组等需要大量连续内存空间的对象,避免分配担保机制带来的复制而降低效率。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。
死亡对象判断方法
- 引用计数法
给对象中添加一个引用计数器:
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
- 可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
哪些对象可以作为 GC Roots 呢?
- 栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
垃圾收集算法
- 标记-清除算法
该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
这种垃圾收集算法会带来两个明显的问题:
-
效率问题
-
空间问题(标记清除后会产生大量不连续的碎片)
-
标记-复制算法
为了解决效率问题,它将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
虽然改进了标记-清除算法,但依然存在下面这些问题:
- 可用内存变小 :可用内存缩小为原来的一半。
- 不适合老年代 :如果存活对象数量比较大,复制性能会变得很差。
- 标记-整理算法
根据老年代的特点提出的一种标记算法,首先标记出所有不需要回收的对象,然后让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
- 分代收集算法
比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
垃圾收集器
- Serial 收集器
Serial收集器是一个新生代收集器,单线程执行,使用复制算法。它在进行垃圾收集时,必须暂停其他所有的工作线程( “Stop The World” )。是JVM Client模式下默认的新生代收集器。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
Serial Old 收集器
Serial 收集器的老年代版本,它同样是一个单线程收集器,使用 “标记-整理”算法。
优点:简单而高效(与其他收集器的单线程相比),没有线程交互的开销,可以获得很高的单线程收集效率。
- ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
新生代采用标记-复制算法,老年代采用标记-整理算法。
- Parallel Scavenge 收集器
JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old
在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
- 吞吐量优先收集器
- 新生代收集器复制算法,是并行的多线程收集器。
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。
- CMS 收集器
CMS(Concurrent Mark Sweep)
特点:
- 低延迟: 减少STW对用户体验的影响【响应时间和延迟要求高】
- 并发收集:可以同时执行用户线程
- CMS收集器 不能像其他收集器那样等到老年代几乎完全被填满了再进行收集 ,而是当堆内存使用率达到某一阈值时,便开始进行回收。
- CMS收集器的垃圾收集算法采用的是标记清除算法。会产生内存碎片,导致并发清除后,用户线程可用的空间不足。
- CMS收集器对CPU资源非常敏感。
运行步骤:
- 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
- G1 收集器
jdk1.9 默认垃圾收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
- 并行与并发:充分利用多CPU、多核环境下的硬件优势
- 分代收集:不需要其他收集器配合就能独立管理整个GC堆
- 空间整合:“标记—整理”算法实现的收集器,局部上基于“复制”算法不会产生内存空间碎片
- 可预测的停顿:能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
G1 收集器的运作大致分为以下几个步骤:
- 初始标记:标记一下GC Roots能直接关联到的对象,需要停顿线程,但耗时很短
- 并发标记:是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行
- 最终标记:修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
- 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率。
类
类加载过程
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
- 加载
类加载过程的第一步,主要完成下面 3 件事情:
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口
一个非数组类的加载阶段是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass()
方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。
加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
- 验证
- 准备
主要内容是为类变量分配内存并设置初始值,类变量所使用的内存都应当在 方法区 中进行分配。
这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),除非变量加上了 final 关键字 public static final int value=111
,那么准备阶段 value 的值就被赋值为 111。
- 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
- 初始化
初始化阶段是执行初始化方法的过程,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,才对类进行初始化(只有主动去使用类才会初始化类):
-
当遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条直接码指令时,比如new
一个类,读取和设置一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。 -
使用
java.lang.reflect
包的方法对类进行反射调用时如Class.forname("...")
,newInstance()
等等。如果类没初始化,需要触发其初始化。 -
初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
-
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。 -
MethodHandle
和VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用findStaticVarHandle
来初始化要调用的类。 -
卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
类加载器
所有的类都由类加载器加载,加载的作用就是将 .class
文件加载到内存。
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的
ClassLoader
。 - 数组类不是通过
ClassLoader
创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
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 包和类。
双亲委派模型
ClassLoader
类使用双亲委派模型来搜索类和资源。- 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader
实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。
下图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。
简单总结一下双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。
双亲委派模型可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。