JVM内存结构

Java运行时数据区:

  • 线程不共享:程序计数器JVM栈本地方法栈
  • 线程共享:方法区堆区

不属于Java运行时内存:直接内存

PC

  • PC的作用是控制指令的执行。
  • 多线程下,需要通过PC记录CPU切换前的执行位置。程序计数器只会保存固定长度的内存地址,不会发生内存溢出。
  • 程序员无需对PC做任何处理

JVM栈介绍

  • JVM中,每个方法的调用使用一个栈帧来保存。使用idea的Debug工具可以查看栈帧的内容。在程序抛出异常的时候,也会打印出栈帧。

栈帧组成

  • 局部变量表:this对象、方法参数、方法体中的局部变量。
    • 使用jclasslib查看字节码文件中的局部变量表:{% image https://obj.cagurzhan.cn/blog/202402/jvm2/Pasted%20image%2020240225100320.png, width=400px %}
    • 解释:起始PC和长度构成了生效范围。序号指的是槽的起始位置。
    • 本质是数组:栈帧中的局部变量表本质上是数组,每个位置是一个槽,long和double占用两个槽,其它占用一个槽。
    • 可复用:局部变量的槽可以复用,一旦某个局部变量不生效,当前槽可以再次被使用。
  • 操作数栈:存放临时数据。
    • iconst就是将常量入栈,iadd就是将操作数栈顶两个数相加
  • 帧数据:动态链接、方法出口、异常表的引用。
    • 每个虚拟机可以添加自己需要的数据。
    • 动态链接:保存了编号到运行时常量池的内存地址的映射关系
    • 方法出口:存储此方法的出口地址。即存储下一行指令的地址
    • 异常表:异常处理信息。包含异常捕获的生效范围和异常发生后跳转到的字节码指令位置。

栈内存溢出

  • 栈大小:如果不指定栈大小,JVM会创建一个默认大小的栈。取决于操作系统和计算机体系结构。
    • Linux的x86默认是1MB
    • BSD:1MB
    • Windows:基于操作系统默认值
  • 修改JVM栈大小的参数-Xss字节数,例如 -Xss1024k-Xss1m
  • HotSpot对栈大小的限制:Windows下JDK8最小是180K,最大时1024m
  • 局部变量过多、操作数栈深度过大会影响栈大小。

一般情况下,工作中即使用了递归,栈深度最多几百。因此可以手动指定 -Xss256k节省内存。

本地栈帧和JVM栈帧

  • Hotspot中,JVM和本地方法栈使用同一个栈空间。
  • 本地方法栈:存储native方法的栈帧。
  • JVM栈:存储Java方法调用时的栈帧。

  • 堆是Java最大的内存区域,创建的对象都位于堆上。
  • 栈上的局部变量表,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用。通过静态变量可以实现线程之间共享

堆内存分布

  • used:已使用堆内存
  • total:JVM已分配可用堆内存
  • max:JVM可分配最大堆内存

并非used=max=total就发生堆溢出,与GC有关。

堆大小

  • 堆内存有上限,一直向堆放数据上限后,会抛OutOfMemory
  • max默认是系统内存的四分之一,total默认是系统内存六四分之一。

JVM堆大小参数

  • 虚拟参数:-Xmx值设置最大值、-Xms值设置初始total
  • Xmx必须大于2MB,Xms必须大于1M
  • 建议将Xmx和Xms设置为相同值,这样程序启动后可用内存就是最大内存,无需再次申请,减少申请开销。

Q:为什么Arthas显示的堆大小和设置的不一样
A:arthas的堆内存采用JMX技术获取,与GC有关,计算的是可分配对象的内存,不是整个内存。

方法区

方法区存放基础信息,线程共享,包含三部分信息:

  • 类的元信息:保存了所有类的元信息,一般称之为InstanceKlass对象,在类加载阶段完成。
  • 运行时常量池:保存字节码文件中的常量池内容。
  • 字符串常量池:保存了字符串常量。

方法区是一个虚拟概念,Hotspot的方法区设计

  • JDK7以及以前:方法区存放在堆区域的永久代空间(PermGen Space),堆大小由JVM控制:-XX:MaxPermSize=值
  • JDK8以及以后:方法区存放在元空间(MetaSpace)中,元空间位于OS维护的直接内存中,只要不超过OS上限,可以一直分配。
    • -XX:MaxMetaspaceSize=值可以设置元空间大小。一般设置256M

Arthas查看方法区

memory

静态变量的存储
JDK7之后,静态变量放到了Class中,脱离永久代。

字符串常量池StringTable

存储:代码中定义的常量字符串内容。

经典题

String s1 = new String("abc"); // 堆区
String s2 = "abc"; // 字符串常量池
s1 == s2 // false

字符串常量池和运行时常量池的关系

  • JDK7之前:字符串常量池属于运行时常量池。在永久代中。
  • JDK7以后:字符串常量池放到堆中。

练习题1

String a = "1"; // 字符串常量池
String b = "2"; // 字符串常量池
String c = a+b; // 堆内存,使用StringBuilder连接
String d = "1" + "2"; // 字符串常量池,编译阶段连接

String.intern()方法会手动将字符串放入常量池。

String s1 = new StringBuilder().append("a").append("1").toString();
s1.intern() == s1; 
String s2 = new StringBuilder().append("ja").append("va").toString();
s2.intern() == s2;
  • JDK6:false、false
  • JDK8:true、false
    • JDK7之后:字符串常量池在堆中,intern会把第一次遇到的字符串的引用放到字符串常量。
      {% image https://obj.cagurzhan.cn/blog/202402/jvm2/Pasted%20image%2020240229183954.png, width=400px %}

直接内存

  • 直接内存不在JVM规范中,不属于运行时内存区域。

JDK1.4的NIO机制使用了直接内存。

  • 解决1:Java对象回收会影响对象创建和使用。
  • 解决2:IO读文件需要先将文件放入直接内存,再读到堆区。
    • 现在直接放入直接内存即可,堆上维护直接内存的引用,减少了数据复制开销)

直接内存的管理:

  • 创建直接内存ByteBuffer bf = Bytebuffer.allocateDirect(size)
  • 查看直接内存:可以使用Arthas的memory命令查看direct的内存。
  • 手动调整直接内存大小-XX:MaxDirectMemorySize=大小
    • (建议使用了NIO,就要设置这个值。)

JVM垃圾回收

垃圾回收的概念

  • C/C++内存管理:需要手动释放内存,容易内存泄漏。内存泄漏累计容易导致内存溢出。
  • Java的GC:负责堆上的内存的回收,属于执行引擎的一部分。
    • 优点:降低程序员实现难度,降低内存泄漏可能性。
    • 缺点:程序员无法控制内存回收的及时性。
  • 应用场景
    • 解决系统僵死问题:与频繁的垃圾回收有关。
    • 性能优化:对gc进行合理设置。

方法区的回收

方法区回收在实际开发场景很少遇到。

不需要回收的内存
PC、Java虚拟机栈、本地方法栈会伴随线程的创建而创建,销毁而销毁。 方法的栈帧执行完方法之后就会自动出栈释放。

哪部分内存需要回收:方法区、堆。

一个类被卸载,需要满足三个条件

  1. 此类所有实例已被回收,堆中不存在任何该类的实例对象以及子类对象
  2. 加载类的类加载器已经被回收。
  3. 该类的java.lang.Class对象没有在任何地方被引用。

System.gc

  • System.gc():手动触发垃圾回收。但是不一定立即回收垃圾,只是向JVM发送垃圾回收请求。

堆回收

引用计数法和可达性分析法

判断对象是否可以被回收

  • 即判断对象是否被引用来决定,被引用了则不会回收

案例

  • 如下案例中,如何做才能将回收?
    • 答案:a1 = null、b1.a = null
  • 只是删掉a1和b1,能否回收A和B?
    • 可以回收,因为方法内无法再访问A和B对象了。

引用计数法

  • 为每个对象维护一个引用计数器,被引用+1,取消-1。实现简单,C++的智能指针就采用了此法。
  • 缺点:
    • 维护计数器,系统性能有一定影响。
    • 存在循环引用问题,即上面的案例。

查看垃圾回收日志

  • 可以使用JVM参数:-verbose:gc
  • 日志结果:

可达性分析算法

  • JVM实际上没有采用引用计数法,而是采用可达性分析算法来判断对象是否可以被回收。
  • 可达性分析将Java对象分两类:GC Root、普通对象。在引用链中,如果从某个GC Root对象是可达的,对象就不可被回收。 GC Root是不会被回收的。

垃圾回收的根对象GC Root

  • 线程Thread对象:线程Thread对象会引用线程栈帧中的方法参数、局部变量等。在上面的案例中,因为Thread引用了主线程的局部变量,所以只需要删除主线程中的局部变量即可符合GC条件。
  • 系统类加载器加载的java.lang.Class对象,包含Launcher,Launcher指向应用程序类加载器,引用类的静态变量。
  • 监视器对象:例如用来保存同步锁synchronized关键字持有的对象。
  • 本地方法调用时使用的全局对象

查看GC Root

  1. 使用arthas的heapdump命令,将堆内存快照保存到本地。heapdump xxx.hprof
  2. 打开Memory Analyzer,打开hprof文件。通过工具内Java basics下的GC Roots,就能打开。
  3. 通过Path to GCRoot,可以查看某个对象的引用链。

五种对象引用

五种对象引用:强引用、软引用、弱引用、虚引用、终结器引用。

  • 强引用:可达性算法描述的对象引用。
    • GCRoot对象和普通对象有引用关系,就不会被回收。
  • 软引用:JDK1.2后提供了SoftReference类
    • 当程序内存不足时,软引用的数据会被回收。
    • JDK1.2后提供了SoftReference类实现软引用。常用于缓存。
    • GC机制:内存不足-->开启GC-->仍然不足-->开启软引用回收-->仍不足-->OOM。
    • 软引用对象回收机制:SoftReference提供了队列机制:
      • 软引用创建时,通过构造器传入引用队列。
      • 软引用包含的对象被回收时,该软引用对象会被放入引用队列
      • 通过遍历引用队列,可以将SoftReference的强引用删除。

应用场景:实现简单缓存,思想很厉害,利用软引用的自动清理功能。

package Java.JVM;

import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.HashMap;


class Student {
    private Integer id;
    private String name;

    public Integer getId(){
        return id;
    }
    public Student(Integer id){
        this.id = id;
    }
}

public class StudentCache {

    private HashMap<Integer, StudentRef> studentRefs;

    private ReferenceQueue<Student> q;


    // 内部使用软引用
    private class StudentRef extends SoftReference<Student>{
        private Integer _key = null; 

        public StudentRef(Student st, ReferenceQueue<Student> q){
            super(st,q);
            _key  = st.getId();
        }
    }

    private StudentCache(){
        studentRefs = new HashMap<Integer, StudentRef>();
        q = new ReferenceQueue<Student>();
    }  

    private void cacheStudent(Student stu){
        cleanCache();
        StudentRef studentRef = new StudentRef(stu, q);
        studentRefs.put(stu.getId(), studentRef);
        System.out.println(studentRefs.size());
    }

    private void cleanCache(){
        StudentRef ref = null;
        while((ref = (StudentRef) q.poll()) != null) {
            studentRefs.remove(ref._key);
        }
    }

    public Student getStudent(Integer id){
        Student st = null;
        if(studentRefs.containsKey(id)){
            StudentRef studentRef = studentRefs.get(id);
            st =  studentRef.get();
        }
        // 不存在
        if(st == null){
            st = new Student(id);
            this.cacheStudent(st);
        }
        return st;
    }

}

  • 弱引用
    • 弱引用的整体机制和软引用基本一致。区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接回收。
    • WeakReference来实现弱引用。弱引用主要在ThreadLocal中使用,开发过程中一般不使用。
  • 虚引用:常规开发中不会使用。
    • 也叫幽灵引用,不能通过虚引用获取到对象。
    • 唯一的用途:对象被GC回收的时候可以接到通知。
    • Java中使用PhantomReference来实现虚引用,直接内存(DirectByteBuffer)中为了及时知道直接内存对象不再使用,从而回收内存。
  • 终结器引用:常规开发中不会使用。

垃圾回收算法

垃圾回收算法核心思想

  1. 找到内存中存活的对象。
  2. 释放不再存活对象的内存,使得程序能够再次利用这部分空间。

四种垃圾回收算法

  1. 标记-清除算法。Mark Sweep GC
  2. 复制算法。Copying GC
  3. 标记-整理算法。Mark Compact GC
  4. 分代GC。Generational GC

垃圾回收算法评价标准

  • STW:Stop the world,指的是GC会有部分阶段需要停止所有的用户线程。如果时间过长会影响用户的使用。
  • 吞吐量:执行用户时间 / 执行用户时间 + GC时间,越高越好。
  • 最大暂停时间:STW的最大值。最小越好。
  • 堆使用效率:比如复制算法会将堆内存一分为二,一次只能使用一半内存,堆使用效率低。

不同的垃圾回收算法,适合不同场景。

标记清除算法

算法过程

  1. 标记阶段:使用可达性分析算法堆存活对象标记。即从GC Root开始通过引用链遍历所有存活对象。
  2. 清除阶段:从内存中删除没有被标记的对象。

优点
实现简单,维护标志位即可。
缺点

  1. 存在内存碎片化问题。
  2. 分配速度慢,由于内存碎片化问题存在,需要维护空闲链表,可能要遍历到链表尾部才能获得合适空间。

复制算法

算法过程

  1. 将堆区分为两块内存,From区域和To区域。
  2. GC阶段开始,将GCRoot搬运到To区域。
  3. 将GC Root关联的对象放到To区域。
  4. 清理From区域,将名字互换。

算法优点

  • 吞吐量高。只需要遍历一次即可,比标记整理算法少了一次。但是不如标记清理算法,因为后者不需要复制。
  • 不会碎片化。

算法缺点
内存使用效率低,只能使用一半内存。

标记整理算法

算法过程

  1. 标记阶段,使用可达性分析算法标记所有对象。
  2. 整理阶段,将存活对象移动到堆一端,清理。

算法优点

  1. 内存使用效率高。整个堆内存都可以使用。
  2. 不会碎片化。

缺点
整理阶段效率不高。例如Lisp整理算法需要堆整个堆搜索3次。可以用其它整理算法提高性能。

分代垃圾回收算法

此算法将内存区域划分为年轻代和老年代

  • 年轻代:分为Eden区、S0S1两个存活区域
  • 老年代:存活时间比较长的对象。

查看分代垃圾回收器的JVM参数-XX:+UseSerialGC

使用Arthas查看内存情况memory

JVM参数总结

算法流程

  1. 新创建的对象会被放入Eden伊甸园区。
  2. Eden区满,会触发年轻GC(年轻代一般比较小)(复制算法),即MinorGC,把eden区域和From需要回收的对象回收,把没有回收对象放入To。
  3. S0会变成To区,S1变成From。MinorGC会回收eden和S1内的对象。每次MinorGC都会为存活对象记录年龄,初始值是0,每次加1.
  4. MinorGC区域的对象达到阈值(最大15)就会移动到老年代。
  5. 老年代空间不足时,先做MinorGC,还是不行触发FullGC,堆整个堆进行垃圾回收。仍然不足,则OOM。(之所以先做minorGC,是为了避免新生代满了导致一些未满阈值的对象被放到老年区。)

区分年轻代和老年代的依据

  • 大部分对象,创建出来后很快就会被回收。
  • 老年代存长期存活的对象,如Spring的Bean。
  • 虚拟机参数中,新生代远小于老年代大小。

为什么区分年轻代和老年代

  • 通过调整两个区域的比例来适合不同类型的应用,提高利用率。
  • 新生代一般使用复制算法,老年代可以选择标记类算法。
  • 分代设计算法只允许回收新生代,如果能满足对象分配要求旧不会做FullGC,STW时间就会减少。

垃圾回收器

垃圾回收器是对垃圾回收算法的实现。

垃圾回收器搭配:

Serial和Serial Old

Serial是一种单线程串行回收年轻代的垃圾回收器。采用复制算法。

  • 优点:单CPU吞吐量高。
  • 缺点:多CPU吞吐量不好。
  • 适合场景:资源有限的场景。

SerialOld是Serial的老年代版本,也是单线程串行回收。

使用参数-XX:+UseSerialGC

ParNew和CMS

ParNew:在Serial基础上支持多线程垃圾回收。

  • 优点:多CPU停顿时间短。
  • 缺点:吞吐和停顿不如G1回收器。JDK9后不建议使用。
  • 适合:JDK8以及之前版本,与CMS搭配。
  • 使用-XX:UseParNewGC

CMS:关注系统暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少等待时间。

  • 使用-XX:+UseConcMarkSweepGC
  • 优点:停顿时间短。
  • 缺点
    • 内存碎片问题(CMS会在FullGC做碎片整理,导致用户线程暂停)
    • 退化问题:老年代内存不足会退化为SerialOld
    • 浮动垃圾问题:(无法做完全垃圾回收)
  • 适合场景:高并发场景,数据量大场景。如订单接口、商品接口。
  • 执行步骤
    • 初始标记,短时间标记GCRoot的关联对象。
    • 并发标记:标记所有对象,用户线程不需要暂停。
    • 重新标记:并发标记存在错标、漏标等情况。
    • 并发清理:清理死亡对象。

Parallel Scavenge和Parallel Old

Parallel Scavenge

  • JDK8默认的年轻代垃圾回收器。
  • 多线程并行回收,关注吞吐量。具备自动调整堆内存大小特点。
  • 优点:吞吐高,手动可控。
  • 缺点:不能保证单次停顿时间。
  • 场景:后台任务。如大数据处理、大文件导出。
  • 允许设置最大暂停时间和吞吐量:不要设置为堆的最大值。
    • 最大暂停时间:-XX:MaxGCPauseMills=xxx
    • 吞吐量:-XX:GCTimeRatio=n
    • 自动调整内存大小:-XX:+UseAdaptiveSizePolicy

Parallel Old

  • 参数:-XX:+UseParallelGC-XX:+UseParallelOldGC
  • 优点:多核效率高
  • 缺点:暂停时间长
  • 场景:与Parallel Scavenge搭配

G1垃圾回收器

JDK9后默认的垃圾回收器:结合PS和CMS的优点。

  • PS关注吞吐量,允许设置最大暂停时间,但是会减少年轻代可用空间
  • CMS关注暂停时间,但是吞吐量不太行。

G1

  • 支持巨大的堆空间回收,吞吐量高
  • 支持多CPU并行GC
  • 允许用户设置最大暂停时间

内存结构
G1会将堆分为多个大小相同的Region,不要求连续。分为Eden、Survivor、Old。Region是堆空间/22048得到,也可以用参数 -XX:G1HeapRegionSize=来指定,必须是2的指数幂。范围从1到32M。

G1垃圾回收的两种方式:

  • 年轻代回收Young GC
  • 混合回收Mixed GC

年轻代回收:回收Eden区和Survivor区。

  • 会导致STW,G1可以通过参数 -XX:MaxGCPauseMills=n设置最大暂停时间毫秒数,默认是200ms。G1会尽可能保证暂停时间。

执行步骤

  1. 新创建对象存放在Eden区,当G1判断年轻代不足(max默认60%),会执行YoungGC。
  2. 标记出Eden和Survivor区域的存活对象。
  3. 根据配置的最大暂停时间,选择某些区域将存活对象复制到一个新的Survivor区,年龄+1,清空这些区域。G1在进行YoungGC的过程中,会记录每个垃圾回收时每个Eden区域和Survivor区域的平均耗时,作为下次回收的依据。这样就可以依据配置的最大暂停时间,计算出本次回收最多能回收多少个Region区域。比如配置最大暂停时间200ms,每个Region耗时40ms,那么最多回收4个Region。
  4. 后续Young GC和之前相同,不过Survivor区中存活对象会被搬运到另外一个Survivor区域。
  5. 当某个存活对象年龄达到阈值(默认15),将被放入老年代。
  6. 部分对象如果超过Region 的一半,会直接放入老年代。这个老年代叫做Humongous区。比如每个Region是2M,如果一个对象大于1M就会被放入。
  7. 多次回收之后,会出现很多Old老年区,此时总堆占有率达到阈值 -XX:InitiatingHeapOccupancyPercent会触发混合回收MixedGC。回收所有的年轻代和部分老年代对象以及大对象区。采用复制算法。

混合回收的阶段

  • 初始标记:标记GCRoot引用的对象为存活。
  • 并发标记:和用户线程一起执行,将第一步中标记的对象引用的对象为存活。
  • 最终标记:标记一些引用改变漏标的对象。不管新创建和不再关联的对象,和CMS不一样。
  • 并发清理:将存活对象复制到别的Region,不会产生内存碎片。

G1的老年代清理会选择存活度最低的区域来回收,可以保证回收率最高,这也是Garbage First名字的由来。

如果清理过程中发现没有足够的空Region存放转移i对象,会执行Full GC。采用单线程标记整理算法,会导致用户线程暂停。所以要尽量保证堆内存有足够多空间。

G1的参数

参数1:-XX:+UseG1GC  打开G1开关,JDK9后默认不需要打开
参数2:-XX:MaxGCPauseMills=ms数  最大暂停时间

优点

  • 对比较大的堆,如超过6G的堆回收时,延迟可控。
  • 不会产生内存碎片
  • 并发标记的SATB算法效率高
    缺点:JDK8之前还不成熟。
    适用场景:JDK8最新版本、JDK9之后的版本

总结

JDK8之前:

  • 关注暂停时间:使用ParNew + CMS
  • 关注吞吐量:使用Parallel Scavenge + Parallel Old

JDK9以及以后:G1