Loading

内存结构与垃圾回收

😉 本文共6125字,阅读时间约12min

内存结构

JVM、JRE、JDK关系

  • JVM:运行在操作系统上
  • JRE:包含了JVM和基础类库
  • JDK:包含JRE的基础上,多了一些编译工具

学习路线

  • Java Class 编译后的字节码文件
  • 类加载器:加载字节码文件到JVM内存结构里
  • 内存结构
    • 堆、元空间、虚拟机栈、本地方法栈、程序计数器(后三个线程私有)
    • 本地方法栈:JVM执行的native方法,这些方法由非java实现。比如与操作系统交互时用到
  • 执行引擎:解释器逐行执行方法字节码,热点代码如频繁调用的方法,有JIT即时编译器直接编译成机器码。GC进行垃圾回收,回收堆中对象。

程序计数器

  1. PC,程序计数器,记录下一条jvm的执行地址
  2. 线程私有,不会内存溢出
  3. 作用:
    1. 解释器会解释指令为机器码交给 cpu 执行,程序计数器会记录下一条指令的地址行号,这样下一次解释器会从程序计数器拿到指令然后进行解释执行。
    2. 多线程,上下文切换时,程序计数器会记录当前线程下一行指令的地址。

虚拟机栈

  1. 每个线程都有自己的虚拟机栈,每个栈由多个栈帧组成,是调用方法时所占的内存
  2. 每个线程只能有一个活动栈帧,对应当前正在执行的方法
  3. 辨析:
    1. 垃圾回收不涉及栈内存,方法调用结束后会自动弹出栈帧
    2. 栈内存不是越大越好。栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
    3. 方法内部局部变量是否线程安全?
      1. 如果方法内部的变量没有逃离方法的作用访问,它是线程安全的
      2. 如果是局部变量引用了对象,并逃离了方法的访问,那就要考虑线程安全问题。

StackOverflow

栈内存溢出,由栈帧过大,过多引起。

线程运行诊断

案例一:cpu 占用过多
解决方法:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程

  • top 命令,查看是哪个进程占用 CPU 过高
  • ps H -eo pid, tid(线程id), %cpu | grep 刚才通过 top 查到的进程号 通过 ps 命令进一步查看是哪个线程占用 CPU 过高
  • jstack 进程 id 通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。

jstack命令

stack是JVM自带的Java堆栈跟踪工具,用于生成虚拟机当前时刻的线程快照。

线程快照是每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因, 如线程间死锁、死循环、请求外部资源导致的长时间等待等问题。

查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么,或者等待什么资源。

本地方法栈

本地方法栈:服务JVM执行的native方法,这些方法由非java实现,C或C++实现的。比如与操作系统交互时用到。

  1. new出来的对象都在堆内存
  2. 特点
    1. 它是线程共享,堆内存中的对象都需要考虑线程安全问题
    2. 有GC机制

OOM

java.lang.OutofMemoryError :java heap space. 堆内存溢出

可以使用 -Xmx8m 来指定堆内存大小。

OOM也可能是方法区空间溢出了

出现原因
  1. 申请内存速度超出GC释放速度,导致空间不够。
    1. 比如往内存加载超大对象
    2. 循环创建大量对象
  2. 内存泄漏
    1. 资源对象没关闭,比如File没关闭
    2. static修饰的大集合强引用没清理

堆内存诊断

  1. jps 工具
    查看当前系统中有哪些 java 进程及其进程id

  2. jmap 工具
    查看堆内存占用情况 jmap - heap 进程id

jmap(Java Memory Map)主要用于查看 jvm 内存,是 jvm 自带的一种内存映像工具。

  1. jconsole 工具
    图形界面的,多功能的监测工具,可以连续监测

方法区

运行时常量池

运行时常量池,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。

当该类被加载以后,对应的字节码文件的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。

注意常量池是字节码class文件的一部分。

一个java源文件中的类、接口,编译后产生一个字节码文件。二进制字节码包含类的基本信息,常量池,类方法定义,包含了虚拟机的指令。

而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用

常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

存放的内容有字面量,字符串值,类引用,字段引用,方法引用(这几个都是符号引用)

方法区内容

  1. 方法区内部包含:

    1. 运行时常量池
    2. 类信息:类结构、字段和方法数据,以及方法和构造函数代码
      1. 类信息:类的全限定名(包名+类名)、父类名称、修饰符如public
      2. 字段、方法
  2. 线程共享,方法区是一个概念,1.8以前由永久代实现,1.8由元空间实现。

    1. 串池在1.8前在永久代的运行时常量池里,1.8后在堆里。
    2. 元空间不在虚拟机设置的内存中,而是使用本地内存

方法区OOM

  • 1.8 之前会导致永久代内存溢出
  • 1.8 之后会导致元空间内存溢出

方法区垃圾回收

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类。HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace)

  1. 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

串池

串池,StringTable,字符串常量池。hashtable结构,不能扩容。

串池为什么要调整位置?

jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。

这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

串池作用

  • 运行时常量池中的字符串仅是符号,只有在被用到时才会转化为加载对象放入串池
    • 这就是字符串延迟加载
  • 利用串池的机制,来避免重复创建字符串对象

字符串拼接原理

  • 字符串常量拼接的原理是编译器优化

  • 字符串变量拼接的原理是StringBuilder

String s1 = "a",s2 = "b";
String s3 = "a" + "b";
String s4 = s1 +s2; // s3 != s4

字符串延迟加载

运行时常量池中的字符串仅是符号,只有在被用到时才会转化为加载对象放入串池

intern方法

  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中。
    • 如果串池中没有该字符串对象,则放入成功。
    • 如果有该字符串对象,则放入失败。
    • 无论放入是否成功,都会返回串池中的字符串对象

注意:此时如果调用 intern 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象。

public class Main {
	public static void main(String[] args) {
		// 堆中创建新对象"a"、"b"、"ab",并返回引用
        // "ab"不在串池,因为串池只存常量字符串, 现在是拼接后new出来的
		String str = new String("a") + new String("b");  // ["a","b"]
		
        // 将"ab"放入串池,无论成功失败都返回串池中的引用
		String st2 = str.intern();  // ["a","b","ab"]
		
        // 常量池中的信息,都会被加载到运行时常量池中。
        // 此时运行时常量池中“ab”,此时还是符号,还没有变成Java中的对象
        // 延迟加载:只有执行到引用“ab”的代码,才会创建对象,并尝试将其放到串池。
		String str3 = "ab"; 

		System.out.println(str == st2);  // true
		System.out.println(str == str3); // true
	}
}

串池垃圾回收

触发垃圾回收时,串池中没有被引用的字符串常量会被回收

StringTable 性能调优

  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少哈希碰撞,降低链表长度。来减少字符串放入串池所需要的时间。-XX:StringTableSize=桶个数(最少设置为 1009 以上)
  • 通过 intern 方法让字符串入池,减少堆内存的使用

直接内存

Direct Memory

  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理

使用直接内存的好处

文件读写流程:

因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法进行操作,然后读取磁盘文件,会在系统内存中创建一个缓冲区,将数据读到系统缓冲区, 然后在将系统缓冲区数据,复制到 java 堆内存中。缺点是数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制。

使用了 DirectBuffer 文件读取流程

直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。

直接内存回收原理

  • 虚引用监测手动回收:直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过unsafe.freeMemory 来手动释放。
    • 使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用freeMemory 方法
    • ByteBuffer 实现内部使用了 Cleaner(继承了虚引用类型),由ReferenceHandler线程来监测 ByteBuffer对象 ,如果虚引用的实际对象(这里是 DirectByteBuffer )被回收以后,就会调用 Cleaner 的 clean 方法,来清除直接内存中占用的内存。

垃圾回收

如何判断对象可回收?

引用计数法

当一个对象被引用时,就当引用对象的值加一,当值为 0 时,就表示该对象不被引用,可以被垃圾收集器回收。
这个引用计数法听起来不错,但是有一个弊端,如下图所示,循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。

可达性分析

  • JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看能否沿着 GC Root 对象为起点的引用链找到该对象,如果找不到,则表示可以回收
  • 所谓GCRoots就是一组必须活跃的引用,可以作为 GC Root 的对象
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中 JNI(即一般说的Native方法)引用的对象
  • 对象被gc掉同时需要满足两个条件,一个是gc roots不可达,另一个是没必要执行finalize方法。

四种引用

强引用、软引用、弱引用、虚引用、终结器引用

引用继承关系图

强引用是Java的默认实现,它会尽可能长时间的存活于JVM内,当没有GC Root引用时,强引用变量引用的对象将被回收。

其他引用需要使用Reference的子类进行声明,一般的应用程序不会涉及到 Reference 编程, 但是了解这些知识会对理解 GC 的工作原理以及性能调优有一定帮助, 在实现一些基础性设施比如缓存时也可能会用到。

WeakReference<Object> weakReference = new WeakReference<Object>(referent, referenceQueue); 

image-20230117180644335

四种引用特点

强引用

强引用指的是代码中普遍存在的Object obj=new Object()这类的引用。

只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。

软引用

仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收

可以配合引用队列来释放软引用自身

弱引用

仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足都会回收弱引用对象。

可以配合引用队列来释放弱引用自身。

虚引用

主要配合 ByteBuffer 使用,唯一作用是被引用对象回收时,会将虚引用入队。由 Reference Handler 线程调用虚引用相关方法释放直接内存

必须配合引用队列使用

终结器引用(FinalReference)

为什么需要FinalReference?

因为jvm只能管理jvm内存空间,但是对于应用运行时需要的其它native资源(jvm通过jni暴漏出来的功能):例如直接内存DirectByteBuffer,网络连接SocksSocketImpl,文件流FileInputStream等与操作系统有交互的资源,jvm就无能为力了,需要我们自己来调用释放这些资源方法来释放。

为了避免对象死了之后,程序员忘记手动释放这些资源,导致这些对象有的外部资源泄露,java提供了finalizer机制通过重写对象的finalizer方法,比如关闭socket连接,在这个方法里面执行释放对象占用的外部资源的操作

帮助我们调用这个方法回收资源的线程就是我们在导出jvm线程栈时看到的名为Finalizer的守护线程。Finalizer其实是实现了析构函数的概念,我们在对象被回收前可以执行一些『收拾性』的逻辑,应该说是一个特殊场景的补充。

其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。

但是如果真的是用户忘记关闭了,那这些socket对象可能因为FinalizeThread迟迟没有执行到这些socket对象的finalize方法,对象占用的内存也迟迟得不到释放,而导致内存泄露。(所以不建议重写finalize()方法)

引用队列

如果WeakReference的get方法返回null了,那么这个WeakReference所关联的对象已经被释放了,但是这个WeakReference对象本身还是存在的,它会占用空间,为了避免内存泄漏。我们需要一个机制来确保WeakReference也能被释放。

如果在创建WeakReference的时候,在构造函数里传入一个ReferenceQueue,那么在这个WeakReference所引用的对象被回收之后,这个WeakReference会被自动插入到ReferenceQueue里来。于是我们可以在适当的时候(比方说后台开一个定时线程)去扫描这个ReferenceQueue,然后把队列里的无用的WeakReference全部清除掉。

适用场景

  • 软引用:比如图片缓存
  • 虚引用:比如那些需要手动释放

finalize()方法

finalize()是Object中的方法,当垃圾回收器将要回收对象所占内存时,该方法被调用,即当一个对象被虚拟机宣告死亡时会先调用它的finalize()方法,让此对象临终前交代点遗言,当然对象也可以趁机复活。

可达性分析算法中,要宣告一个对象真正的死亡,要经历两次标记的过程:

如果对象在经历可达性分析之后,发现没有与GC root相连的引用链,将会被第一次标记并且进行第一次筛选,筛选的条件是是否有必要执行finalize()方法,当对象没有finalize()方法或者虚拟机已经调用过finalize()方法,视为没有必要执行。

如果这个对象被判定为有必要执行finalize()方法,那么对象将会放置在一个F-Queue队列中,稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。finalize()方法是对象逃离死亡的最后一次机会,稍后GC将会对队列进行第二次小规标记,如果对象要finalize()拯救它,只需要与引用链上的任意一个对象建立关联即可,那么第二次标记的时候它将会被移出即将回收的集合,如果没逃脱,就会真正被回收。

书中记载,“它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协”。

垃圾回收算法

标记清除

Mark Sweep

  • 速度较快
  • 会产生内存碎片

标记整理

Mark Compact

  • 速度慢
  • 没有内存碎片

复制

Copy

  • 不会有内存碎片
  • 需要占用两倍内存空间

分代垃圾回收

什么是分代垃圾回收

Minor GC:当年轻代Eden区域满的时候会触发一次Minor GC,新生代的 GC。

Major GC:老年代的 GC。目前,只有 CMS GC 会有单独收集老年代的行为。

Full GC:整堆收集,收集整个 Java 堆和方法区的垃圾收集。

JVM 的调优的一个环节,就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现 STW 的问题。

Major GC 和 Full GC 出现 STW 的时间是 Minor GC 的10倍以上。

为什么要进行分代垃圾回收

多轮回收后仍存活的对象在这一轮大概率也不会被回收,回收它们浪费性能。

新生代朝生夕死,回收效率高。

老年代存放生命周期比较长的对象,在新生代中经历了n次垃圾回收后仍然存活的对象就会被放到老年代中。此外,老年代的内存也比新生代大很多(比例大概是1:2)。full gc概率也低。

如何进行分代垃圾回收

  • 新创建的对象首先分配在 eden 区
  • 新生代空间不足时,触发 minor gc ,eden 区 和 from 区存活的对象使用 - copy 复制到 to 中,存活的对象年龄加一,然后交换 from to
  • minor gc 会引发 stop the world,暂停其他线程,等垃圾回收结束后,恢复用户线程运行
  • 当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(4bit)
  • 当老年代空间不足时,会先触发 minor gc,如果空间仍然不足,那么就触发 full fc ,停止的时间更长!(比如当新生代放不下新对象时,对象会直接放到老年代,这种情况下如果老年代也放不下,先触发minor gc,最好能让新对象待在新生代)。

空间分配担保策略

在发生Minor GC之前,检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看-X:HandlePromotionFailure设置值是否允许担保失败。
    • 不允许,直接Full GC
    • 允许,检查最大可用连续空间是否大于历代晋升的对象平均大小
      • 大于,进行一次有风险的Minor GC
      • 小于,直接Full GC

在这里插入图片描述

Full GC触发条件

  1. 老年代空间不足,引起Full GC
    1. 大对象直接进入老年代
    2. 经历过多次Minor GC仍存在的对象进入老年代
    3. Minor GC时,动态对象年龄判定机制会将对象提前转移老年代。
    4. Minor GC时,Eden和From Space区向To Space区复制时,大于To Space区可用内存,会直接把对象转移到老年代
  2. 空间分配担保机制可能会触发Full GC,如上图
  3. 调用System.gc()方法

频繁Full GC可能的原因

  1. 堆内存分配过低
  2. 集合类频繁加入大对象,比如图片等等

相关 JVM 参数

堆初始大小-Xms

堆最大大小-Xmx

新生代大小-Xmn

GC详情-XX:+PrintGCDetails -verbose:gc

FullGC 前 MinorGC-XX:+ScavengeBeforeFullGC

垃圾回收器

相关概念:

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上
  • 吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。

几种常见的垃圾回收器

  1. 串行

    1. 单线程
    2. 堆内存较小,适合个人电脑
    3. Serial + SerialOld
  2. 吞吐量优先

    1. 多线程
    2. 堆内存较大,多核CPU
    3. 单位时间内,STW 的时间最短 0.2 0.2 = 0.4,垃圾回收时间占比最低,这样就称吞吐量高
    4. Parallel Scavenge 收集器 + Parallel Old 收集器
  3. 响应时间优先

    1. 多线程
    2. 堆内存较大,多核CPU
    3. 尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5
    4. 新生代可搭配ParNew收集器 + 老年代用CMS收集器
  4. 同时注重吞吐量和低延迟(响应时间):

    1. G1收集器
    • 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
    • 整体上是标记-整理算法,两个区域之间是复制算法

什么是STW

Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。

GC时的Stop the World(STW)是大家最大的敌人。

串行

安全点

让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象。因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态。需要STW(Stop The World)。

几种收集器

Serial收集器 ParNew 收集器(非串行) Serial Old 收集器
Serial 收集器的多线程版本 Serial 收集器的老年代版本
单线程 多线程 单线程
复制算法 复制算法 标记整理
STW STW STW

吞吐量优先

image-20230116185049440

-XX:+UseParallelGC ~ -XX:+UsePrallerOldGC
* XX:MaxGCPauseMillis=ms 控制最大的垃圾收集停顿时间(默认200ms)
* XX:GCTimeRatio=rario 直接设置吞吐量的大小

Parallel Scavenge 收集器

吞吐量优先收集器,该收集器的目标是达到一个可控制的吞吐量。

新生代收集器、多线程并行、复制算法,很像ParNew,区别在于有GC自适应调节策略。

GC自适应调节策略:不需要手动指定新生代的大小、Eden 与 Survivor 区的比例、晋升老年代的对象年龄,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为 GC 的自适应调节策略。

Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本,多线程,采用标记-整理算法。

响应时间优先

image-20230116185156442

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC
-XX:+CMSScavengeBeforeRemark

CMS收集器

使用CMS 收集器。一种以获取最短回收停顿时间为目标的老年代收集器。

应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务。

同时注重吞吐量和低延迟(响应时间)

G1收集器

posted @ 2023-01-17 18:07  iterationjia  阅读(112)  评论(0编辑  收藏  举报