JVM 篇,本章主要介绍 JVM 相关的知识,包括堆结构,垃圾回收算法,类加载机制等。
JVM内存结构
JVM的内存结构主要指运行时数据区,其主要由五个部分:
程序计数器PC:线程私有,主要记录当前线程执行到哪一条命令,存储的是字节码指令的地址。
虚拟机栈:线程私有,每个线程对应一个栈,栈由栈帧组成,每调用一个方法,就会创建一个栈帧入栈;方法执行完毕,栈帧销毁出栈。
本地方法栈:线程私有,功能和虚拟机栈几乎一样,区别是本地方法栈主要存放本地方法的栈帧。
堆:存储所有对象实例、数组也分配在这里。
方法区:线程共享,存储类的元信息。
JVM 内存结构示意图

程序计数器
是一小块线程私有的空间,主要用于记录当前线程执行的字节码地址,主要用于线程重新获得CPU时间时继续从上次执行结束的位置开始继续向下执行。
线程计数器是唯一不会OOM的内存区域,因为它只存储一个地址,而地址的长度是确定的,因此不会出现OOM
堆
堆是 JVM 中最大的一块内存区域,主要用来保存对象实例、数组等,堆的容量是有限的,当达到最大值时,如果此时再申请内存就会抛出OOM。
在现代 JVM 中堆一般分为年轻代和老年代,年轻代又细分为Eden区和两个大小相同的S区(幸存者区),年轻代主要存放存活时间不是很长的对象,当触发minor GC时,JVM 会将Eden区和其中一个S区中存活的对象全部复制到另一个S区中;当一个对象经历了很多次垃圾回收之后仍然存活时,JVM 便会认为该对象会是一个长期存活的对象,会将其升级到老年代,老年代一般存放存活时间较长的对象和一些大对象。
虚拟机栈
虚拟机栈是每个线程运行时所需的内存,每个栈由多个栈帧组成,每个栈帧都对应一次方法的调用,栈帧中存储了方法调用信息,例如参数、返回值、局部变量等。每个线程都对应一个虚拟机栈,每个虚拟机栈中可能有多个栈帧,对应多次方法的调用,但是每个线程只能由一个活动栈帧,也就是当前正在执行的方法对应的栈帧。
补充知识:
垃圾回收不涉及栈内存,垃圾回收主要操作的是堆内存,栈帧弹出栈之后其内存就会自动释放,不需要垃圾回收。
栈内存不是越大越好,栈内存大确实不容易栈溢出,但是栈内存变大之后会导致活动线程数变少,栈内存一般采用默认值即可。
方法内的局部变量不一定线程安全,是否是线程安全取决于局部变量有没有被外部线程引用,如果局部变量是在方法内创建,并且没有对外暴露引用,那么这个局部变量就是线程安全的,因为只在方法内部被操作,否则就是非线程安全的。
本地方法栈结构和功能和虚拟机栈类似,主要区别是本地方法栈存放的是本地方法的栈帧。
方法区
方法区是一块线程共享的区域,主要存储类的元信息和运行时常量池。
运行时常量池是将class文件常量池加载到内存后的版本,里面将class文件常量池中的符号引用变为了直接引用(内存地址),主要存放字面量和符号引用,符号引用会在类的解析阶段转换为真实的物理引用。
方法区在 JDK 1.7 和 1.8 实现的区别:方法区在 1.7 和 1.8 的区别主要是位置上的不同。在 JDK 1.7 中,方法区主要存放在堆上的永久代中, JDK 1.8 将方法区从 永久代移动到了本地内存的元空间中。
更换实现的原因:
永久代位于 Java 堆内,有固定上限,需要手动配置大小。动态代理、热部署场景会不断生成新类,容易出现OOM;调大永久代又会挤压普通 Java 对象的堆空间,很难预估合适大小。
永久代只有 Full GC 时才会尝试做类卸载,类元数据回收效率差,内存泄漏风险高。
元空间使用本地内存,默认自动扩容,上限受物理内存限制;到达阈值就触发 GC,对废弃类加载器和类元数据做回收,类卸载能力相比永久代大幅提升。
把类元数据放到堆外,将 Java 对象堆 和类元信息存储做隔离,互不抢占内存。
双亲委派机制
Java的双亲委派机制是指,当一个类加载器接收到加载类的任务时,会先尝试委托上级加载器加载,如果上级加载器也没能成功加载,则由自己尝试加载。
类加载器按照层级分为四类:
启动类加载器:由 c++ 实现,加载 Java 中最核心的基础类,例如String类等。
扩展类加载器:Java 实现,主要加载的是 JDK 扩展包,例如数据库驱动类等。
应用程序类加载器:主要加载我们自己写的类,默认扫描目录是项目的 CLASSPATH 路径。
自定义类加载器:业务中自定义的类加载器,实现了自定义的类加载逻辑,例如从数据库读取字节码文件等。
为什么 JVM 采用双亲委派机制 ?
通过双亲委派机制可以防止类被重复加载,如果父类加载器已经加载过这个类了,子类加载器就不会重复加载了,保证了类的唯一性。
保证类的安全性,防止恶意代码替换核心类。
类的生命周期
类的声明周期可以分为如下几个阶段:
加载:通过类的全名获取类的二进制流,解析这个二进制流为方法区的数据结构(Klass对象)并创建Class实例作为方法区中Klass对象的访问入口。
连接:连接阶段又可以细分为三个阶段。
验证:验证类是否符合 JVM 规范,检查内容包括文件格式、元数据、字节码等。并且检查符号引用是否存在。
准备:为类变量分配内存并设置初始值。static变量赋默认值,赋值在初始化阶段完成,非引用类型的final变量在准备阶段直接进行赋值。
解析:将类中的符号引用转换为直接引用。方法名可以理解为符号引用,指向方法的指针就是直接引用。
初始化:对类的静态变量、静态代码块执行初始化操作。
使用:在应用中使用类,例如调用静态方法、创建对象实例等。
卸载:销毁类的class对象,当一个类满足如下三个条件时,就会被卸载,释放类元数据。
该类的所有实例对象均已被GC。
指向该类的class对象不存在任何强引用。
加载这个类的类加载器本身已经被GC。
JVM垃圾回收算法
JVM 的垃圾回收算法一般有三种:标记清除算法、复制算法、标记整理算法。
标记清除算法分为两个阶段:标记和清除
根据可达性分析算法得出的垃圾进行标记。
将标记出的垃圾进行回收。
优点:不需要移动对象,直接回收,实现简单。
缺点:容易产生大量内存碎片,内存不连贯,导致大对象很难分配。
复制算法:复制算法将内存分为了两个区域,每次只使用其中的一块,GC时将存活对象复制到另一块区域中,然后将该区域的内存清空。
优点:没有内存碎片。
缺点:两个内存区域中只有一个能存储对象,另一个需要用来在GC时存放存活对象,内存利用率低。
标记整理算法:和标记清除算法类似,第一阶段也是先对存活对象进行标记,第二阶段会将存活对象向内存的一端移动,然后清理边界以外的垃圾。
优点:无内存碎片。
缺点:需要进行对象的移动,效率低。
JVM 的分代回收算法
在 JDK8 之后, JVM采用的垃圾回收算法是分代回收算法,该算法将整个堆内存分为了两大部分:新生代和老年代。
新生代一般分为一个 Eden 区和两个 Survivor 区(from区和to区),新创建的对象会分配到 Eden 区,当 Eden 区满时会触发年轻代GC(Minor GC),会标记 Eden 区和 from 区中的存活对象,然后将其复制到 to 区中,并将 from 和 to 区角色互换。某些对象如果经历了很多次 GC 依然存活,那么 JVM 会认为该对象会长期存活,会将其晋升到老年代.
老年代一般存放分代年龄较大的对象以及一些大对象,老年代的空间较大,因此GC停顿时间较长,所以老年代的GC频率一般较低。