大家好,我是一个搬砖的农民工,很高兴认识大家 😊 ~
👨🎓 个人介绍:本人是一名后端Java开发工程师,坐标北京 ~
🎉 感谢关注 📖 一起学习 📝 一起讨论 🌈 一起进步 ~
🙏 作者水平有限,欢迎各位大佬指正留言,相互学习进步 ~
🌱 JVM是Java中最核心的概念之一,本文将按照以下思维导图的结构,深入讲解Java虚拟机(JVM)的核心概念 🍂
在网上借鉴几张图片,可以很形象看出jvm的内存结构
堆是JVM内存中最大的一块,用来存储对象和数组,它被所有。
🍨 (1)特点
- 通过 new 关键字,创建的对象都会使用堆内存,数组和字符串常量池(StringTable)也存储在堆中
- 它是线程共享的
- 堆中对象都需要考虑线程安全的问题,有垃圾回收机制
🍨 (2)堆内存分配
在 Java 的堆内存中,可以分配为和的主要依据是对象的生命周期。这个分配是为了更好地进行垃圾回收和提高内存利用率。默认分配比例如下:
- ⭐ 新生代(Young Generation): 新生代由和 组成。
- ⭐ 伊甸园(Eden Space):伊甸园是新生代中的一部分,用于存放。大部分对象在伊甸园中被创建。当内存需要分配给新对象时,大部分对象都会首先被放入伊甸园中。
- ⭐ 幸存者区(Survivor Space):幸存者区包括两个区域,分别为和。幸存者区的数据是在 From 区和 To 区之间进行交换的。
例如:当from区和to区都是null的时候,第一次从新生代eden进行垃圾回收,会把存活下来的对象放入from区,下次垃圾回收会把存活下来的数据放入to区,然后from区清空。再下次垃圾回收会把存活下来的数据放入from区,然后to区清空。直到达到一定的年龄后,这些对象会被晋升到老年代。 - ⭐ 老年代(Old Generation):用于存放新生代中经过多次gc依然存活的对象,或者新生代中放不下的大对象。
🍨 (3)晋升到老年代的方式
- ⭐ 年龄阈值:当对象在 survivor 区存活了 15 次(默认)之后,会被移到老年代区。可以通过JVM参数
修改。 - ⭐ 动态对象年龄判定:动态对象年龄判定:当 Survivor 空间中相同年龄所有对象的大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,而不需要达到默认的分代年龄。
- ⭐ survivor空间不足:当存活下来的对象大于survivor区容量的时候,会被移到老年代区。
假设新生代由100MB的Eden空间和两个50MB的Survivor空间组成,老年代有500MB的空间。
初始情况下,所有新创建的对象都分配在Eden空间。
进行第一次GC,此时Eden空间有80MB的对象,被GC后只有30MB的对象存活。这些存活的对象被移动到Survivor1,Eden被清空。
再次分配对象,Eden空间再次填满到80MB,此时Survivor1中还有30MB的存活对象。
进行第二次GC,Eden区的80MB对象中,60MB存活,加上Survivor1中的30MB存活对象,一共有90MB需要被移动到Survivor2,但Survivor2只有50MB的容量。
此时,JVM会检查Survivor1中对象的年龄,,假设10MB的对象被晋升,这样剩下20MB的对象与Eden区的60MB存活对象能够被移动到Survivor2。
如果Survivor空间依旧不足以处理这60MB的对象,。
GC的这些细节实际上取决于使用的垃圾收集器以及JVM的配置参数,不同的垃圾收集器(如Serial, Parallel, CMS, G1,
ZGC等)会以不同的方式管理这些区域。
🍨 (4)堆内存检验方式
✨ 1、jmap
- 首先使用查看有哪些进程
- 然后根据 查看进程的堆内存
打出 后先根据 命令查看到进程id
可以看出启动类Test进程ID是23968,然后输入命令:
然后控制台打印后,继续输入命令:
然后控制台打印后,继续输入命令:
✨ 2、jconsole
还是运行刚才代码,然后执行命令,选择’本地连接’->'对应进程’用图形查看该进程的堆内存变化
✨ 3、jvisualvm
如下代码可以使堆内存在1万秒内增加200M内存占用空间。以便模拟我们排查问题
首先我们还是jconsole 查看,并点击了垃圾回收,但是毫无作用,说明这个类一直被占用。
然后我们输入 ,根据图片进行操作
到了这里,我们可以很清楚看见,是Test这个类下面,一个Student的数组引起的,即可找到代码解决问题
每个线程都有自己的虚拟机栈,这个栈用于存储栈帧。每当一个线程调用一个方法时,JVM就会为这个方法创建一个栈帧,并且将它压入虚拟机栈中。栈帧是用来存储局部变量、执行运算过程中的操作栈、动态链接信息以及方法返回地址等数据。
🍨 (1)特点
线程私有,每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
🍨 (2)局部变量表
用来存储和方法内部定义的。这些数据包括各种基本数据类型(int、float、long、double等)、对象引用和returnAddress类型(指向了一条字节码指令的地址)。
举例:
在调用sum方法时,它的局部变量表将会包含以下内容:
🍨 (3)操作栈
每个栈帧内部含有一个操作数栈,通常也叫做操作栈。这是一个后进先出(LIFO)的栈,用于执行方法中的字节码指令。。
举例:
当这个方法被调用时,JVM会使用操作栈来执行计算过程。以下是一个简化的操作栈示例:
在这个例子中,iload_1 和 iload_2 将参数 a 和 b 压入操作数栈,iadd 从栈中弹出这两个参数相加,然后 istore_3 将结果存储到局部变量表中的 result 变量中。
🍨 (4)动态连接
每个栈帧内部含有一个指向运行时常量池中该栈帧所属方法的引用,这使得当前方法能够动态链接到其它方法和变量。简而言之,
举例:
在上述代码中,虽然变量 obj 的类型是 A,但在运行时,obj.foo() 动态链接到了 B 类的 foo 方法。
🍨 (5)方法返回地址
当一个方法开始执行后,它需要知道在完成执行后返回到哪里。方法返回地址就是保存这个信息的地方,它指向调用该方法的位置的下一条指令地址。
举例:
在 caller 方法中调用 callee 方法后,JVM 会在 callee 方法的栈帧中存储返回地址,当 callee 方法执行完毕后,控制权将会返回到 caller 方法中 callee 调用后的位置。
🍨 (6)栈内存溢出
- 栈帧过多导致栈内存溢出(如方法递归调用没有设置下线)
- 栈帧过大导致栈内存溢出
用debug方式演示:
每个线程会创建一个虚拟机栈,每个方法会创建一个栈帧,放入虚拟机栈。当走到方法b时就会创建三个栈帧(main,a,b),每个方法里面的参数(如变量x e)会被放入到这个栈帧里面。当调用方法b完成回到方法a时,就会释放方法b栈帧
(注:栈内存会自己释放,因此不需要垃圾回收)
栈是不是越大越好?
不是,如内存为500M,每个栈为1M,那么最多可以有500个线程并发。所以栈越大,线程越少。
记住下一条jvm指令的执行地址
🍨 (1)特点
- 是线程私有的(每个线程都有自己的程序计数器,因为每个线程执行地址不一样)
- 不会存在内存溢出(由jvm规定的)
🍨 (2)举例
在上面的方法中,程序计算器指向的地址分别是1到7,代码执行的每一步操作都会被记录
本地方法栈的结构与虚拟机栈类似,也是由栈帧(Stack Frame)组成的,栈帧中保存了Native方法的局部变量、操作数栈、方法出口等信息。因此,本地方法栈的结构与虚拟机栈类似,但是用于调用本地方法。
这里有一个简单的示例,演示了一个Java程序如何调用一个使用C语言编写的Native方法:
在这个示例中,NativeExample类中的nativeMethod方法是一个本地方法,它用native关键字修饰,表示这个方法是用其它语言实现的。在main方法中,通过example.nativeMethod()调用了这个本地方法。在执行时,
方法区实现方式:永久代、元空间
在早期的 Java 版本中,方法区与永久代有着密切的关系。。而。
在 Java 7 及之前的版本中,永久代用于存储类和方法相关的信息,包括类的字节码、运行时常量池、字段、方法、构造函数等。由于永久代的大小在JVM启动时固定,并且随着应用的运行可能会出现永久代内存溢出的错误(OutOfMemoryError),在Java 8中被元空间所替代。
因此,。它使用本地内存(即非JVM堆内存)来存储类元数据。这样的设计减少了内存溢出的可能性,因为元空间的大小仅受到系统可用内存的限制。当然,元空间中还是有一个初始大小,并且可以设置上限,一旦超过这个上限,仍然会抛出OutOfMemoryError异常。因此,方法区与永久代之间的关系在 Java 8 及以后的版本中已经不再存在。
元空间主要包括以下内容:
- ⭐ 类的元数据信息:包括类的名称、方法名、访问修饰符、字段描述符等。
- ⭐ 静态变量:类的静态变量存放在元空间中。
- ⭐ 常量池:其中存放着字符串常量、字面量和符号引用。
方法区的对象不会被Java堆中的垃圾回收器以相同的方式回收,它有自己的内存管理系统(在使用元空间的情况下,内存可以从操作系统直接获取)。
让我们通过一段简单的Java代码,说明方法区中某些部分是如何被使用的:
在上述代码中:
字符串"Hello, World!"会被存储在常量池中。
静态变量counter会被存储在方法区。
类ExampleClass的类型信息(比如它的方法和字段)也会存储在方法区。
increment方法和main方法的代码,在被即时编译器编译之后,编译后的机器码也会存储在方法区。
当Java程序运行时,JVM会加载ExampleClass,这个过程中会将ExampleClass的类型信息、常量池中的常量、increment和main方法的字节码等数据存储在方法区,静态变量counter同样存储在方法区内,但具体是在永久代还是元空间则取决于JVM的版本及配置。在Java 8及之后版本,这部分数据会存储在操作系统的本地内存中,称作元空间。
✨ 方法栈(Method stack):
- 方法栈存储的是 Java 方法的调用信息。每当一个方法被调用时,JVM都会在方法栈中分配一个栈帧(Stack Frame),用于存储该方法的调用信息。
- 方法栈中的栈帧会随着方法的调用和返回而动态地被创建和销毁,方法栈的栈帧也包括了方法的参数、局部变量以及用于返回的指令地址等信息。
✨ 本地方法栈(Native method stack):
- 本地方法栈则是用于执行本地(Native)方法的栈,即使用本地语言(如 C 或 C++)编写的方法。它与方法栈类似,但是用于执行本地方法。
- 本地方法栈也会为每个本地方法分配一个栈帧,用于存储本地方法的调用信息。
- :运行时常量池存储在方法区(元空间)中,而字符串常量池在 JDK 8 时存储在堆中。
- :运行时常量池主要存储编译期间生成的字面量、符号引用等,而字符串常量池则用于存储字符串对象实例的引用。
- :运行时常量池在运行期间可以动态地放入新的常量,而字符串常量池则相对较为固定。
以下是一个具体的示例来说明它们的区别:
假设有一个类 MyClass,其中包含一个字符串常量 STRING_CONSTANT。
在编译阶段,STRING_CONSTANT 会被存储在 class 文件的常量池中。当类加载器加载 MyClass 类时,常量池中的内容会被复制到中。
在运行时,如果创建了一个 MyClass 的实例,并调用了 STRING_CONSTANT,那么虚拟机首先会在运行时常量池中查找该字符串的引用。如果找到了,就直接使用该引用;如果没有找到,就会在字符串常量池中创建一个新的字符串对象,并将其引用存储在运行时常量池中。
注:运行时常量池和字符串常量池存的字符串都是
- ⭐ 程序计数器:存储jvm指令的执行地址,不会内存溢出
- ⭐ 虚拟机栈:每个线程运行时所需要的内存,每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存, 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
- ⭐ 本地方法栈:存储非java代码编写的本地方法
- ⭐ 堆:通过 new 关键字,创建对象都会使用堆内存。同时包含字符串常量池和数组。
- ⭐ 方法区:它存储每个类的结构,如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括在类和实例初始化以及接口初始化中使用的特殊方法
✨ 共同特点:
- 程序计数器、栈是线程私有;方法区、堆是线程共享。
- 程序计数器不会内存溢出,其他都会。
垃圾判定是指在编程中确定哪些内存中的对象是“垃圾”,即不再被应用程序使用的对象,因此可以被垃圾回收器回收的过程。
在Java中,垃圾回收(Garbage Collection, GC)主要采用两种基本方法:引用计数法和可达性分析。下面分别对这两种方法进行说明:
🍨 (1)引用计数法
引用计数算法是一种最直观的垃圾收集技术。其基本思想是任何时刻计数器为0的对象就是不可能再被使用的,因此可以回收其占用的内存。
不过,Java并不采用引用计数法来进行垃圾回收,因为它存在循环引用的问题。在循环引用中,两个或多个对象相互引用,但它们可能都已经不再被其他活动部分的应用程序所引用。由于它们相云引用,因此它们的引用计数永远不会达到0,导致内存泄漏。
🍨 (2)可达性分析
Java采用的是可达性分析算法来进行垃圾回收。在这种方法中,通过一系列的称为“GC Roots”的对象作为起点,然后向下搜索,搜索所走过的路径称为引用链(Reference Chain)。时,则证明此对象是不可用的。
在Java中,可作为GC Roots的常见对象包括:
- ⭐ 虚拟机栈(栈帧中的局部变量表)中引用的对象:例如,正在执行的方法中的局部变量或参数。
- ⭐ 方法区中类静态属性引用的对象:这些静态变量所引用的对象也被称为 GC Roots。
- ⭐ 方法区中常量引用的对象:例如,字符串常量池(String Table)的引用
- ⭐ 本地方法栈中JNI(Java Native Interface)引用的对象:在使用 JNI 调用本地方法的过程中,会涉及到本地方法栈,其中引用的对象也是 GC Roots。
举一个简单的例子来描述可达性分析:
在JVM模型中,垃圾回收主要发生在堆内存(Heap)中,因为这里是存放对象实例的地方。当前主流的JVM使用分代垃圾收集算法,将堆内存分为年轻代(Young Generation),老年代(Old Generation),以及永久代(Permanent Generation,但在Java 8及之后被MetaSpace所替代)。不同代的对象会根据其生命周期的不同被相应的垃圾回收器回收,以提高回收效率。
垃圾回收算法、垃圾回收器的选择以及垃圾回收的时机,通常是由JVM自动管理的,但是开发者可以通过JVM参数来对其进行调优。
🍨 (1)标记-清除
标记-清除算法分为两个阶段:标记阶段和清除阶段。
- 标记阶段:从根对象(如活动线程的堆栈指针、静态对象等)开始,递归遍历所有可达的对象,并将它们标记为活动的。
- 清除阶段:遍历堆内存中所有对象,对于没有被标记为活动的对象,释放其占用的内存空间。
缺点:
- 整个过程中需要停止应用程序,导致停顿时间(STW,Stop-The-World)。
- 会产生内存碎片。
🍨 (2)标记-整理
标记-整理算法是标记-清除的改进版。在标记活动对象之后,它会将所有存活的对象移到内存的一端,然后清理掉端边界外的内存空间。
优点:
- 解决了内存碎片问题,不需要复制活动对象。
缺点:
- 需要移动存活对象,可能会造成较大的内存迁移开销。
- 需要较多的停顿时间,不适合对响应时间要求较高的应用。
🍨 (3)复制
复制算法将堆内存分为两半:一半用于分配内存,另一半处于空闲状态。在垃圾收集期间,它将所有活动对象从当前的内存区域复制到另一半,接着清除原有的内存区域中的所有对象。
优点:
- 解决了内存碎片问题,适合存活对象较少场景。
缺点:
- 不适用于处理存活较多对象的场景
- 会占用双倍内存空间
⚡ 推荐参考:JVM中 Minor GC 和 Full GC 的区别
空间担保策略是指当触发 minor gc 时,会判断老年代剩余最大连续空间大于历次Minor GC晋升的平均大小 或者 大于新生代所有对象的大小总和 , 大于任意一个,就允许触发MinorGC,反之触发 Full GC
⚡ 推荐参考:深入理解JVM内存空间的担保策略
JDK 8 中默认的垃圾回收器组合为Parallel Scavenge(用于Young Generation)加上Parallel Old(用于Old Generation)。
⚡ 推荐参考:Java中常用的垃圾回收器
Java类加载主要分为三个阶段:加载、链接、初始化
⚡ 推荐参考:深入理解Java类加载过程
双亲委派是 Java 类加载器的一种机制。当一个类加载器收到加载类的请求时,它会首先将这个请求委派给父类加载器去完成。只有当父类加载器无法完成这个加载请求时,子类加载器才会尝试加载。
- ⭐ 避免重复加载:由于双亲委派机制,如果一个类已经被某个类加载器加载过了,那么其他的类加载器就没有必要再加载一次,可以直接复用已经加载的类。这样可以避免类的重复加载,节省内存。
- ⭐ 安全性:通过双亲委派机制,核心类库会被由启动类加载器加载,因此可以防止核心类库被恶意篡改。另外,由于类加载器可以通过双亲委派机制追溯到启动类加载器,所以可以确保核心类库不会被自定义的类所替代,从而保证了系统安全性。
要打破双亲委派机制,可以自定义类加载器,并重写 方法(或者是 方法,根据具体需求)。自定义的类加载器可以先尝试加载类,而不是直接委派给父加载器。
下面是一个简化的示例,说明如何自定义类加载器以打破双亲委派模型:
在这个例子中,findClass(String name) 方法被重写用于尝试加载类。如果在 findClass 中没有找到类,则会抛出 ClassNotFoundException 异常,然后调用父类加载器尝试加载。
注意,直接破坏双亲委派机制可能会导致各种问题,如类冲突、安全问题等。因此,在实际开发中,只有在真正需要时才应该打破双亲委派模型,并且必须非常小心地实现。
自定义类加载器可以用在很多场景中,例如热部署(hot deploy)一个正在运行的应用程序,这通常需要动态地加载和卸载类。在框架开发中,比如OSGI、JSP的servlet容器等,这样的需求也是很常见的。
直接内存是操作系统中分配的一块内存,不受JVM管理,Java代码可以直接获取直接内存中的数据。
⚡ 推荐参考:直接内存(Direct Memory)
Java 提供了四种不同的引用类型:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。
⚡ 推荐参考:Java 中的四种引用类型和它们的使用场景
JVM提供了一些常用调优参数:-Xms、-Xmx、-Xmn等
⚡ 推荐参考:JVM常用调优参数
JVM调优通常涉及到调整内存设置、选择合适的垃圾回收器以及优化JVM参数等方面。
- 堆内存设置:通过调整堆(heap)大小,你可以控制Java应用可用的内存数量。堆内存过小可能导致频繁的垃圾回收,降低应用性能;过大则可能导致垃圾回收停顿时间过长。比如:设置-Xms和-Xmx来定义堆的初始大小和最大大小。
- 选择垃圾回收器:根据应用的需求选择合适的垃圾回收器(GC)。不同的垃圾回收器,比如Parallel GC、CMS、G1 GC,有着不同的特点和适用场景。
JVM中出现OOM的区域通常有:
- 堆内存(Heap Memory):如果堆内存太小,或者应用程序中有内存泄漏,都可能导致堆内存OOM。
- 永久代/元空间(PermGen/Metaspace):存储Java类元数据的地方。如果加载了大量的类或者大量的动态生成类的情形,可能导致这部分内存溢出。
- 方法栈:比如方法递归调用,可能会导致这个区域内存溢出。
- JConsole:Java监控和管理控制台,是Java Development Kit (JDK)的一部分,可以用来监控JAVA应用运行时的资源消耗。
- JVisualVM:集成了多个JDK命令行工具的可视化工具,提供了内存和CPU分析功能。
- Memory Analyzer Tool (MAT):用于分析堆转储,可以帮助你找出内存泄漏和查看内存消耗的对象。
- jmap:命令行工具,可以用来生成堆转储文件,分析内存使用情况。
⚡ 推荐参考:OOM日志分析
下面是一个简单的Java代码片段,用于模拟堆内存溢出。
运行这个程序,很快就会因为堆内存溢出而出现 。
首先使用 查看进程ID,或者查看内存较高进程ID,然后使用jmap来生成堆转储文件:
使用MAT打开堆转储文件(heapdump.dat),MAT将会对文件进行分析,并提供内存使用的概览。
⚡ 推荐参考:linux系统cpu飙高如何排查
1、使用命令查看占用过高的进程ID(pid)
2、使用 查看这个进程里面哪个线程导致的
3、使用将十进制的线程ID转换为十六进制的线程ID
4、使用命令打印线程日志
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjsbk/10999.html