JVM源码解析
一、java虚拟机概述
1. java程序的跨平台性
之前的话,通过Linux或者Windows开发,当需要跨平台时,程序不能运行。java出现后,产生了jvm,针对不同的操作系统,产生了不同的java虚拟机。
在Java虚拟机中执行的指令,称为Java字节码指令。
下面显示了同一个Java程序,被编译为一组Java字节码的集合之后,可以通过Java虚拟机运行于不同的操作系统上,它以Java虚拟机为中介,实现了跨平台的特性。
2. JVM的基本结构
类加载子系统:java文件先被编译成class文件,类加载子系统(classLoader)加载class文件,在jvm层面,就会涉及类加载里的加载、验证、准备、解析、初始化五个步骤,实现类的加载行为。当class文件被加载完,就会进入JMM。
JMM:java 内存模型,包括:公有的方法区、java堆、私有的java栈、本地方法栈、PC寄存器
两个线程同时请求一个路径,请求的资源可以互通认为是公有的,反之为私有的。
栈对应的是方法,java栈是自己本地写的方法,本地方法栈是native方法,私有则意味着两个线程互不打扰,A线程运行到一个位置,此时B线程进来了,私有则意味着B要从头运行。
class文件经过五个步骤加载完成后,类信息保存到方法区中,对象调用之后才会用到JMM另外四个位置。
垃圾回收系统:失去引用的对象为垃圾对象。包括:垃圾回收算法和垃圾回收器。
执行引擎:负责虚拟机的字节码。(忽略)
3. JVM类加载流程和内存结构总览
4. 类加载——加载阶段
通过类的全路径名称,读取类的二进制数据流。解析类的二进制数据流,转化为方法区(永久代or元空间)内部的数据结构。创建java.lang.Class类的实例对象,表示该类型。
5. 类加载——验证阶段
它的目的是保证第一步中加载的字节码 是合法且符合规范的。 大体分为4步验证:
格式检查:检查魔数、版本、长度等等。
语义检查:抽象方法是否有实现类、 是否继承了final类等等编码语义上的 错误检查。
字节码验证:跳转指令是否指向正确的位置,操作数类型是否合理等。
符号引用验证:符号引用的直接引用是否存在。
6. 类加载——准备阶段
准备阶段是正式为类变量分配内存并设置类 变量的初始值阶段,即:在方法区中分配这些变量所使用的内存空间。
注意这里所说的初始值概念,比如一个类变 量定义为:public static int v = 8080; 实际上变量v在准备阶段过后的初始值为0而不是 8080,将v赋值为8080的put static指令是程 序被编译后,存放于类构造器方法之中。
但是注意,如果声明为:public static final int v = 8080; 在编译阶段会为v生成 ConstantValue属性,在准备阶段虚拟机会根 据ConstantValue属性将v赋值为8080。
7. 类加载——解析阶段
解析阶段是指虚拟机将运行时常量池中的符号引用替换为直接引用的过程。
符号引用就是class文件中的:CONSTANT_Class_info、CONSTANT_Field_info、 CONSTANT_Method_info 等类型的常量。
8. 类加载——初始化阶段
到达这个阶段,类就可以顺利加载到系统中。此时,类才会开始执行Java字节码。初始化阶段是执行类构造器方法的过程。
方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕,如果一 个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成() 方法。
二、java虚拟机内存模型
1. 程序计数器
是当前线程所执行的字节码的行号指示器,指向虚拟机字节码指令的位置。
被分配了一块较小的内存空间。
针对于非Native方法(自己写的方法):是当前线程执行的字节码的行号指示器。
针对于Native方法:则为undefined。
每个线程都有自己独立的程序计数器,所以,该内存是线程私有的。
这块区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError情况的区域
2. 虚拟机栈 & 本地方法栈
虚拟机栈为执行Java方法服务的,是描述方法执行的内存模型。
栈是线程私有的内存空间。
每次函数调用的数据都是通过栈传递的。
在栈中保存的主要内容为栈帧。它的数据结构就是先进后出。每当函数被调用,该函数就 会被入栈,每当函数执行完毕,就会执行出栈操作。而当前栈顶,即为正在执行的函数。
每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、帧数据区等信 息。
本地方法栈是为native方法服务的。
栈帧操作:
局部变量和入参会影响栈帧(操作数栈)的大小。例如下面的例子:
例子1说明了栈内存大,则可存储的操作数多;
例子2说明了入参多,则局部变量表占用空间大,则可存储的操作数变少。
举例:
/*** 通过参数 -Xss来指定线程的最大栈空间* 设置最大栈内存为 -Xss为160K,造成StackOverflowError异常后,查看输出的count值为:1907* 设置最大栈内存为 -Xss为256K,造成StackOverflowError异常后,查看输出的count值为:2729**/
public class StackOverflowTest {private static int count = 0;public static void main(String[] args) {try {count();} catch (StackOverflowError e) {System.err.println("StackOverflowError! count = " + count);}}private static void count(){count++;count();}
}
其中通过以下配置使用 VM 操作:
/*** 增加局部变量表对栈空间占用的验证* 设置最大栈内存为 -Xss256K,造成StackOverflowError异常**/
public class StackOverflowTest2 {private static int count = 0;public static void main(String[] args) {try{
// count1(1,2); // count = 19111count2(1,2,3,4,5); // count = 11221}catch (Throwable e){System.err.println(count);}}private static void count1(int a, int b){count++;int num1 = 1, num2 = 2;count1(a + num1,b + num2);}private static void count2(int a, int b, int c, int d, int e){count++;int num1 = 1, num2 = 2, num3 = 3, num4 = 4, num5 = 5, num6 = 6, num7 = 7, num8 = 8;count2(a + num1 + num2,b + num3, c + num4, d + num5 + num6, e + num7 + num8);}
}
3. 堆
运行时数据区,几乎所有的对象都保存在java堆中。
Java堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会被自动清理,而不需要显示地释放。
堆是垃圾收集器进行GC的最重要的内存区域。
Java堆可以分为:新生代(Eden区、S0区、S1区)和 老年代。
在绝大多数情况下,对象首先分配在eden区,在一次新生代GC回收后,如果对象还存活, 则会进入S0或S1,之后,每经历过一次新生代回收,对象如果存活,它的年龄就会加一。 当对象的年龄达到一定条件后,就会被认为是老年代对象,从而进入老年代。
4. 方法区
逻辑上的东西,是JVM的规范,所有虚拟机必须遵守的。
是JVM 所有线程共享的、用于存储类信息,例如:类的字段、方法数据、常量池等。
方法区的大小决定了系统可以保存多少个类。
JDK8之前——永久代。
JDK8及之后——元空间。
永久代
指内存的永久保存区域,主要存放Class和Meta(元数据)的信息,Class在被加载的时候被放入永久区域,它和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常(OutOfMemory)。
如果系统使用了一些动态代理,那么有可能会在运行时生成大量的类,从而造成内存溢出。 所以,设置合适的永久代大小,对于系统的稳定性是至关重要的。
-XX:PermSize 设置初始永久代大小。例如:-XX:PermSize=5m
-XX:MaxPermSize 设置最大永久代大小,默认情况下为64MB。例如:-XX:MaxPermSize=5m
元空间
在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。
元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用堆外的直接内存。
因此,与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。
-XX:MaxMetaspaceSize 设置元空间默认初始大小。
例如:-XX:MetaspaceSize=40m 设置最大元数据空间。例如:-XX:MaxMetaspaceSize=40m
常见面试题:为什么使用元空间替换永久代?
因为永久代在过去的实现中存在一些问题和限制,而元空间提供了更好的性能和灵活性。以下是一些详细的原因:
(1)内存管理:永久代的内存管理是由虚拟机自身控制的,无法根据应用程序的需求进行动态调整。而元空间使用本地内存进行管理,可以根据应用程序的需求动态分配和释放内存,提高内存的利用率。
(2)永久代内存溢出:在永久代中,存储类的元数据、常量池、静态变量等,当应用程序加载大量类或者使用大量字符串常量时,可能导致永久代内存溢出。而元空间不再有固定的大小限制,可以根据应用程序的需要自动扩展。
(3)类的卸载:在永久代中,由于类的卸载机制比较复杂,很难实现完全的类卸载。而元空间使用本地内存,可以更容易地实现类的卸载,减少内存的占用。
(4)性能优化:元空间的实现采用了更高效的数据结构和算法,例如使用指针碰撞(Bump the Pointer)的方式分配内存,减少内存碎片化,提高内存分配的效率。此外,元空间还支持并发的类加载和卸载操作,提高了性能。(5)垃圾收集的复杂性:永久代的垃圾收集比较复杂,因为它涉及到类的卸载,而类的卸载又和类加载器有关。在某些情况下,即使类不再被使用,但由于类加载器的存在,类也不会被卸载,从而导致内存泄漏。此外,永久代的垃圾收集通常与Java堆的其他部分分开进行,增加了垃圾收集器的实现复杂性。
————————————————
原文链接:https://blog.csdn.net/weixin_44989660/article/details/137261106
三、垃圾回收算法
1. 什么是垃圾回收
GC:垃圾回收,即:Garbage Collection。
垃圾:特指存在于内存中的、不会再被使用的对象。
回收:清除内存中的“垃圾”对象。
2. 可触及性
什么是可触及性? 就是GC时,是根据它来确定对象是否可被回收的。 也就是说,从根节点开始是否可以访问到某个对象,也说明这个对象是否被使用。
可触及性分为3种状态:
① 可触及:从根节点开始,可以到达某个对象(存在调用关系的时候)。
② 可复活:对象引用被释放(被置为null),但是可能在finalize()函数中被初始化复活。
③ 不可触及:由于finalize()只会执行一次,所以,错过这一次复活机会的对象,则为不可触及状态
示例:
/*** 死去活来的对象** @author : wanglinping* @version : 1.0* @time : 2024/9/16 11:17**/
public class DieAliveObject {private static DieAliveObject dieAliveObject;public static void main(String[] args) {dieAliveObject = new DieAliveObject();int i = 0;while (i < 2){System.out.println(String.format("--------GC nums = %d--------", i++));dieAliveObject = null; // 将dieAliveObject对象置为“垃圾对象”System.gc(); // 通知JVM可以执行GC了try{Thread.sleep(100); // 等待GC执行}catch (InterruptedException e){e.printStackTrace();}if (dieAliveObject == null){System.out.println("dieAliveObject is null");} else {System.out.println("dieAliveObject is not null");}}}/** finalize只会被调用一次,给对象唯一一次重生的机会* */@Overrideprotected void finalize() {System.out.println("finalize is called!");dieAliveObject = this; // 使对象复生,添加引用}
}
运行结果:
dieAliveObject 对象被置为空之后进行判断,第一次判断该对象不为空是因为调用了finalize方法,对象被复活,但因为只能复活一次,所以第二次该对象被判断为空。
引用类型 | 说明 |
强引用 | 就是一般程序中创建的引用,例如 Student student = new Student(); |
软引用 SoftReferenct | 当堆空间不足时,才会被回收。因此,软引用对象不会引起内存溢出。 通过 .get() 方法 引用,当发生了gc,如果空间不足才会返回null。 |
弱引用 WeakReferenct | 当GC的时候,只要发现存在弱引用,无论系统堆空间是否不足,均会将其回收。 通过 .get() 方法引用,当发生了gc,返回null。 |
虚引用 PhantomReferenct | 如果对象持有虚引用,其实与没有引用是一样的。虚引用必须和引用队 列在一起使用,它的作用是用于跟踪GC回收过程,所以可以将一些资 源释放操作放置在虚引用中执行和记录 通过 .get() 方法引用,无论什么情况,都返回null。 |
示例:
软引用:
/*** 软引用示例* -Xmx10m -XX:+PrintGCDetails**/
public class SoftReferenceDemo {public static void main(String[] args) throws Throwable{/* 查看空余内存 */System.out.println("----Free " + Runtime.getRuntime().freeMemory() / 1000000 + "M-----");/* 创建Teacher对象的软引用 */Teacher teacher = new Teacher("aa", 15);SoftReference<Teacher> softReference = new SoftReference<>(teacher);System.out.println("softReference = " + softReference.get());/* 使得teacher失去引用,可被GC回收 */teacher = null;/* 执行第一次GC后,软引用并未被回收 */System.gc();System.out.println("------First GC------");System.out.println("softReference = " + softReference.get());/* 可以通过对数组大小数值调整,来造成内存资源紧张 */byte[] bytes = new byte[7 * 937 * 1024];System.out.println("------Assign Big Object------");/* 执行第二次GC,由于堆空间不足,所以软引用已经被回收 */System.gc();System.out.println("------Second GC------");Thread.sleep(1000);System.out.println("softReference = " + softReference.get());}
}class Teacher{private String name;private int age;public Teacher(String name, int age) {this.name = name;this.age = age;}public String getName() {return name;}public void setName(String name) {this.name = name;}public int getAge() {return age;}public void setAge(int age) {this.age = age;}@Overridepublic String toString() {return "Teacher{" +"name='" + name + '\'' +", age=" + age +'}';}
}
运行结果:
可以看到当内存不足时,gc() 之后返回null。
弱引用:
/*** 弱引用demo**/
public class WeakReferenceDemo {public static void main(String[] args) throws Throwable{/* 创建Teacher对象的弱引用 */Teacher teacher = new Teacher("aaa", 20); // teacher的强引用WeakReference<Teacher> weakReference = new WeakReference<>(teacher); // teacher的弱引用/* 使得teacher失去引用来了,可被GC回收 */teacher = null;/* 执行GC前,查看弱引用并未被回收 */System.out.println("------Before GC------");System.out.println("weakReference = " + weakReference.get());/* 执行GC,所以弱引用已经被回收 */System.gc();System.out.println("------After GC------");Thread.sleep(1000); // 睡眠1秒钟,保证GC已经执行完毕System.out.println("weakReference = " + weakReference.get());}
}
运行结果:
虚引用:
/*** 虚引用demo 虚引用必须和引用队列一起使用**/
public class PhantomReferenceDemo {private static PhantomReferenceDemo obj;public static void main(String[] args) {/* 创建引用队列 */ReferenceQueue<PhantomReferenceDemo> phantomReQueue = new ReferenceQueue<>();/* 创建虚引用 */obj = new PhantomReferenceDemo();PhantomReference<PhantomReferenceDemo> phantomReference = new PhantomReference<>(obj, phantomReQueue);System.out.println("phantomReference: " + phantomReference.get()); // 总会返回null/* 创建后台线程 */CheckRefQueueThread thread = new CheckRefQueueThread(phantomReQueue);thread.setDaemon(true);thread.start();/* 执行两次GC,一次被finalize复活,一次真正被回收 */for (int i = 1; i <= 2; i++) {gc(i);}}public String print(){return "这是一个打印方法";}private static void gc(int nums){obj = null;System.gc();System.out.println("------第" + nums + "次GC------");try{Thread.sleep(500);}catch(InterruptedException e){e.printStackTrace();}if (obj == null){System.out.println("obj is null");} else {System.out.println("obj is not null");}}@Overrideprotected void finalize() throws Throwable {super.finalize();System.out.println("finalize() is called!");obj = this; // 复活对象}
}
/*
* 从引用队列中获得被回收的对象
* */
class CheckRefQueueThread extends Thread{private ReferenceQueue<PhantomReferenceDemo> phantomRefQueue;public CheckRefQueueThread(ReferenceQueue<PhantomReferenceDemo> phantomRefQueue) {this.phantomRefQueue = phantomRefQueue;}@Overridepublic void run() {while (true){if (phantomRefQueue != null){PhantomReference<PhantomReferenceDemo> phantomReference = null;try {/* 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入到引用队列,以通知应用程序对象的回收情况 */phantomReference = (PhantomReference<PhantomReferenceDemo>) phantomRefQueue.remove();} catch (Throwable e){e.printStackTrace();}if (phantomReference != null){System.out.println("Object = " + phantomReference + "is delete by GC");}}}}
}
运行结果:
3. 槽位复用
局部变量表中的槽位是可以复用的,如果一个局部变量超过了其作用域,则在其作用域之后 的局部变量就有可能复用该变量的槽位,这样能够起到节省资源的目的。
4. 对象分配总览
栈上分配的两种技术(逃逸分析和标量替换)和TLAB分配都是默认开启的。
栈上分配 和 TLAB分配都是在栈或者线程上进行的;老年代分配和新生代分配是在堆上进行的。
新生代分配特点:朝生暮死,执行一次gc的时间短。
对象分配 — — 栈上分配
栈上分配是JVM提供的一项优化技术。基本思想如下所示:
① 对于那些线程私有的对象(即:不可能被其他线程访问的对象),可以将它们打散分 配在栈上,而不是分配在堆上。
② 分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入, 从而提高系统的性能。
③ 对于大量的零散小对象,栈上分配提供了一种很好的对象分配优化策略,栈上分配速度快,并且可以有效避免GC带来的负面影响,但是由于和堆空间相比,栈空间较小, 因此对于大对象无法也不适合在栈上分配。
栈上分配的技术基础,两者必须都开启:
① 逃逸分析:逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。
② 标量替换:允许将对象打散分配在栈上。比如:若一个对象拥有两个字段,会将这两 个字段视作局部变量进行分配。
栈上分配 — — 逃逸分析
对于线程私有的对象,可以分配在栈上,而不是分配在堆上。好处是方法执行完,对象自行销毁,不需要gc介入。可以提高性能。而栈上分配的一个技术基础(如果关闭逃逸分析或关 闭标量替换,那么无法将对象分配在栈上)就是逃逸分析。
逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。如图所示:
栈上分配 — — 标量替换
标量:不可被进一步分解的量,JAVA的基本数据类型就是标量(如:int,long等基本数据类型等)。
聚合量:标量的对立就是可以被进一步分解的量,JAVA中对象就是可以被进一步分解的聚合量。
替换过程:
① 通过逃逸分析确定该对象不会被外部访问。
② 对象可以被进一步分解,即:聚合量。其中,JVM不会创建该对象,而会将该对象成 员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或 寄存器上分配空间。
举例:
/*** 栈上分配(以下三种默认都是开启的)* 逃逸分析:DoEscapeAnalysis* 标量替换:EliminateAllocations* TLAB:UseTLAB* 【栈上分配】-Xmx50m -Xms50m -XX:+PrintFlagsFinal -XX:+PrintGCDetails -XX:-UseTLAB* (设置堆的初始化大小,以及最大大小为50M,打印出当前配置参数的状态,打印回收的情况,不使用TLAB)* 【关闭栈上分配】-Xmx50m -Xms50m -XX:+PrintFlagsFinal -XX:+PrintGCDetails -XX:-UseTLAB -XX:-DoEscapeAnalysis* 注意:-XX:+PrintFlagsFinal只是为了查看参数设置情况,可以去掉。**/
public class AssignOnStack {public static void main(String[] args) {sizeOfStudent();StopWatch stopWatch = StopWatch.createStarted();// 制造将近7.5个G左右的对象for (int i=0; i< 100000000; i++) {initStudent();}stopWatch.stop();System.out.println("========执行一共耗时:" + stopWatch.getTime(TimeUnit.MILLISECONDS) + "毫秒");}/*** student所占用空间为72bytes*/public static void sizeOfStudent() {Student student = new Student();student.setName("wahaha");System.out.println("========student大小为:" + ObjectSizeCalculator.getObjectSize(student));System.out.println("========student大小为:" + RamUsageEstimator.humanSizeOf(student));}public static void initStudent() {Student student = new Student();student.setName("wahaha");}
}
运行结果:
栈上分配:4ms (在栈上分配了,无需gc回收操作)
关闭栈上分配:1.5s
结论:直接采用堆上分配,效率很低。
对象分配 — — TLAB分配
TLAB的全称是Thread Local Allocation Buffer,即:线程本地分配缓存区,这是一个线程 专用的内存分配区域。
由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆 上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS配上失败重试的方式 保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB 来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程 同步,提高了对象分配的效率。
TLAB本身占用Eden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB 空间。参数-XX:+UseTLAB开启TLAB,默认是开启的。TLAB空间的内存非常小,缺省情况下仅占整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所 占用Eden空间的百分比大小。由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行 分配,总是会直接分配在堆上。TLAB空间由于比较小,因此很容易装满。
主要的垃圾回收算法
引用计数法:增加引用+1,失去引用-1
复制算法:为了解决复制算法只能使用1/2内存的问题。 适用于垃圾对象多的情况,适用于老年代。 标记压缩法 但内存碎片多,对于大对象的内存分配。不连 续的内存空间分配效率低于连续空间。是现代 垃圾回收算法的思想基础。 标记清除法 为了解决标记清除算法效率低的问题。该算法 效率高,并且没有内存碎片,但是只能使用一 半的系统内存。适用于新生代。 复制算法 将内存区间根据对象的生命周期分为两块,每 块特点不同,使用回收算法也不同,从而提升 回收效率。 分代算法 将这个堆空间划分成连续不同的小区间,每 个区间独立使用、独立回收。避免GC时间过 长,造成系统停顿