正文 JVM 拾年之璐 V管理员 /2022年 /291 阅读 0706 ## 内存区域 ### 运行时数据区 **线程私有的:** 程序计数器**(程序计数器是唯一一个不会出现 `OutOfMemoryError` 的内存区域)** - 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 - 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 虚拟机栈 - 每次方法调用的数据都是通过栈传递的 - Java 虚拟机栈会出现两种错误: - **`StackOverFlowError`:** 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 `StackOverFlowError` 错误。 - **`OutOfMemoryError`:** Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出`OutOfMemoryError`异常。 本地方法栈 - 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。 - 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 `StackOverFlowError` 和 `OutOfMemoryError` 两种错误。 **线程共享的:** - 堆 - **存放对象实例,几乎所有的对象实例以及数组都在这里分配内存** - Java 堆是垃圾收集器管理的主要区域,因此也被称作 **GC 堆** - 方法区 - 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 - **运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)** - JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。 - 直接内存 (非运行时数据区的一部分) - JDK1.4 中新加入的 **NIO(New Input/Output) 类**,引入了一种基于**通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据**。 #### 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢? 1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。 2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 `MaxPermSize` 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。 ### HotSpot 虚拟机 #### 对象的创建 1. 类加载检查 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 2. 分配内存 在**类加载检查**通过后,接下来虚拟机将为新生对象**分配内存**。对象所需的内存大小在类加载完成后便可确定 **两种内存的分配方式** - 指针碰撞 - 适用场合 :堆内存规整(即没有内存碎片)的情况下。 - 原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。 - 使用该分配方式的 GC 收集器:Serial, ParNew - 空闲列表 - 空闲列表 : - 适用场合 : 堆内存不规整的情况下。 - 原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。 - 使用该分配方式的 GC 收集器:CMS **内存分配并发问题** - **CAS+失败重试:** CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。**虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。** - **TLAB:** 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配 3. 初始化零值 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用 4. 设置对象头 **虚拟机要对对象进行必要的设置**,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 **这些信息存放在对象头中。** 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。 5. 执行init方法 一般来说,执行 new 指令之后会接着执行 `` 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。 #### 对象的内存布局 对象在内存中的布局可以分为 3 块区域:**对象头**、**实例数据**和**对齐填充** - **Hotspot 虚拟机的对象头包括两部分信息**,**第一部分用于存储对象自身的运行时数据**(哈希码、GC 分代年龄、锁状态标志等等),**另一部分是类型指针**,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 - **实例数据部分是对象真正存储的有效信息** - **对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。** ## 垃圾回收 Java 堆是垃圾收集器管理的主要区域,因此也被称作**GC 堆**  大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为大于 15 岁),就会被晋升到老年代中。 经过这次 GC 后,Eden 区和"From"区已经被清空。这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To"。不管怎样,都会保证名为 To 的 Survivor 区域是空的。 ### 大对象直接进入老年代 大对象就是需要大量连续内存空间的对象(比如:字符串、数组), 为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。 ### HotSpot VM的GC种类 - 部分收集 - 新生代收集 : Minor/Young GC - 老年代收集 : Major/Old GC - 混合收集 : Mixed GC - 整堆收集 : Full GC ### 如何判断对象已经死亡 - 引用计数法 给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。 **这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。** - 可达性分析算法 通过一系列的称为 **“GC Roots”** 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。 举个例子就类似于一串葡萄 , 买回来之后洗干净了之后 , 从根部提起来一整串 , 会发现盆子里面总会有剩下的单独的几颗 , 这个就类似于需要被回收的对象 ### **哪些对象可以作为 GC Roots 呢?** - 虚拟机栈(栈帧中的本地变量表)中引用的对象 - 本地方法栈(Native 方法)中引用的对象 - 方法区中类静态属性引用的对象 - 方法区中常量引用的对象 - 所有被同步锁持有的对象 ### 引用 - 强引用 使用最普遍的引用。如果一个对象具有强引用,那就类似于**必不可少的生活用品**,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。 - 软引用**(经常使用)** 如果一个对象只具有软引用,那就类似于**可有可无的生活用品**。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。 - 弱引用 如果一个对象只具有弱引用,那就类似于**可有可无的生活用品**。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,**一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。** - 虚引用 如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。 **虚引用必须和引用队列(ReferenceQueue)联合使用。**当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。 ### 如何判断一个常量是废弃常量 如果当前没有任何对象引用该常量的话,就说明是废弃常量 ### 如何判断一个类是无用的类 - 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。 - 加载该类的 `ClassLoader` 已经被回收。 - 该类对应的 `java.lang.Class` 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 ### 垃圾收集算法 #### 标记-清除 **问题** - 效率问题 - 空间问题(标记后会产生大量的空间碎片) #### 标记-复制 **为了解决效率问题,“标记-复制”收集算法出现了** 将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。 #### 标记-整理 **根据老年代的特点提出的一种标记算法** #### 分代收集算法 根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。 ### 垃圾收集器 #### Serial收集器 (**新生代采用标记-复制算法,老年代采用标记-整理算法**) Serial(串行)收集器是一个单线程收集器了。它的 **“单线程”** 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( **"Stop The World"** ),直到它收集结束。 #### ParNew收集器 **(新生代采用标记-复制算法,老年代采用标记-整理算法)** **ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。** ### Parallel Scavenge 收集器 **(新生代采用标记-复制算法,老年代采用标记-整理算法)** **Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。** ### Serial Old 收集器 ### Parallel Old 收集器 ### CMS 收集器 **(采用标记-清除算法)** - **CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。** - **CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。** 工作步骤: 1. **初始标记** : 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 2. **并发标记** : 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象 3. **重新标记** : 为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短 4. **并发清除** : 开启用户线程,同时 GC 线程开始对未标记的区域做清扫 ### G1 收集器 **(采用标记-整理算法)** **G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.** **特点** 1. 并行与并发 2. 分代收集 3. 空间整合 4. 可预测停顿 G1除了追求低停顿以外 , 还能建立可以预测的时间模型 , 能让使用者明确指定在一个长度为M毫秒的时间片内 **步骤** 1. 初始标记 2. 并发标记 3. 最终标记 4. 筛选回收 **G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)** 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。 ## 类文件结构 - 魔数 - Class文件版本号 - 常量池 - 访问标志 - 当前类 , 父类 , 接口索引集合 - 字段表集合 - 方法表集合 - 属性表集合 ## 类加载过程 ### 类的生命周期  #### 加载 加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了 #### 连接 1. 验证 - 文件格式验证 - 元数据验证 - 字节码验证 - 符号引用验证 2. 准备 **准备阶段是正式为类变量分配内存并设置类变量初始值的阶段** 3. 解析 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程 #### 初始化 初始化阶段是执行初始化方法 ` ()`方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。 对于` ()` 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 ` ()` 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个进程阻塞,并且这种阻塞很难被发现。 #### 拆卸 ## 类加载器 1. **BootstrapClassLoader(启动类加载器)** :最顶层的加载类,由 C++实现,负责加载 `%JAVA_HOME%/lib`目录下的 jar 包和类或者被 `-Xbootclasspath`参数指定的路径中的所有类。 2. **ExtensionClassLoader(扩展类加载器)** :主要负责加载 `%JRE_HOME%/lib/ext` 目录下的 jar 包和类,或被 `java.ext.dirs` 系统变量所指定的路径下的 jar 包。 3. **AppClassLoader(应用程序类加载器)** :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类 ## 双亲委派模型 每一个类都有一个对应它的类加载器。系统中的 ClassLoader 在协同工作的时候会默认使用 **双亲委派模型** 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派给父类加载器的 `loadClass()` 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 `BootstrapClassLoader` 中。当父类加载器无法处理时,才由自己来处理。 ### 双亲委派模型的好处 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 `java.lang.Object` 类的话,那么程序运行的时候,系统就会出现多个不同的 `Object` 类。 ### 如果我们不想用双亲委派模型怎么办? 如果我们不想打破双亲委派模型,就重写 `ClassLoader` 类中的 `findClass()` 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 `loadClass()` 方法 本文采用创作共用版权 CC BY-NC-SA 3.0 CN 许可协议,转载或复制请注明出处! -- 展开阅读全文 --