赞
踩
本文将按照以下思维导图的结构,深入讲解Java虚拟机(JVM)的核心概念
在网上借鉴几张图片,可以很形象看出jvm的内存结构
堆是JVM内存中最大的一块,用来存储对象和数组,它被所有线程共享
。
在 Java 的堆内存中,可以分配为新生代
和老年代
的主要依据是对象的生命周期。这个分配是为了更好地进行垃圾回收和提高内存利用率。默认分配比例如下:
新生代由伊甸园(Eden Space)
和 两个幸存者区(Survivor Space)
组成。
伊甸园(Eden Space):伊甸园是新生代中的一部分,用于存放新创建的对象
。大部分对象在伊甸园中被创建。当内存需要分配给新对象时,大部分对象都会首先被放入伊甸园中。
幸存者区(Survivor Space):幸存者区包括两个区域,分别为From区
和To区
。幸存者区的数据是在 From 区和 To 区之间进行交换的。
例如:当from区和to区都是null的时候,第一次从新生代eden进行垃圾回收,会把存活下来的对象放入from区,下次垃圾回收会把存活下来的数据放入to区,然后from区清空。再下次垃圾回收会把存活下来的数据放入from区,然后to区清空。直到达到一定的年龄后,这些对象会被晋升到老年代。
老年代(Old Generation):用于存放新生代中经过多次gc依然存活的对象,或者新生代中放不下的大对象。
-XX:MaxTenuringThreshold
修改。假设新生代由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等)会以不同的方式管理这些区域。
1、jmap
jps
查看有哪些进程jmap -heap [进程ID]
查看进程的堆内存实例:
new一个10M的字节对象,来占用堆内存,在输出分别在输出 1 2 3后打出 jmap命令对比堆内存变化
public class Test {
public static void main(String[] args) throws InterruptedException {
System.out.println("1111111111111111111111111");
Thread.sleep(20000);
byte[] array = new byte[1024 * 1024 * 10]; // 10M内存
System.out.println("2222222222222222222222222");
Thread.sleep(20000);
System.gc(); // 垃圾回收
System.out.println("3333333333333333333333333");
Thread.sleep(10000);
}
}
打出 111111 后先根据 jps
命令查看到进程id
23968 Test
3312
24196 Jps
22764 Launcher
4828 RemoteMavenServer36
可以看出启动类Test进程ID是23968,然后输入命令:jmap -heap 23968
Heap Usage:
PS Young Generation
Eden Space:
capacity = 66584576 (63.5MB)
used = 8754440 (8.348884582519531MB) // 这里只展示部分打印信息,可以看见这里最初占用了8M
13.14784973625123% used
From Space:
capacity = 11010048 (10.5MB)
used = 0 (0.0MB)
free = 11010048 (10.5MB)
0.0% used
然后控制台打印22222222后,继续输入命令:jmap -heap 23968
Heap Usage:
PS Young Generation
Eden Space:
capacity = 66584576 (63.5MB)
used = 19240216 (18.348899841308594MB) // 可以看见这里占用内存变成了18M
28.895905261903298% used
From Space:
capacity = 11010048 (10.5MB)
used = 0 (0.0MB)
free = 11010048 (10.5MB)
0.0% used
然后控制台打印333333333后,继续输入命令:jmap -heap 23968
Heap Usage:
PS Young Generation
Eden Space:
capacity = 66584576 (63.5MB)
used = 1331712 (1.27001953125MB) // 可以看见这里占用内存变成了1M
free = 65252864 (62.22998046875MB)
2.0000307578740157% used
From Space:
capacity = 11010048 (10.5MB)
used = 0 (0.0MB)
free = 11010048 (10.5MB)
0.0% used
2、jconsole
还是运行刚才代码,然后执行jconsole
命令,选择’本地连接’->'对应进程’用图形查看该进程的堆内存变化
3、jvisualvm
如下代码可以使堆内存在1万秒内增加200M内存占用空间。以便模拟我们排查问题
public class Test {
public static void main(String[] args) throws InterruptedException {
List<Student> students = new ArrayList<>();
for (int i =0; i < 200 ;i++){
students.add(new Student());
}
Thread.sleep(10000000); // 10000秒
}
}
class Student {
private byte[] big = new byte[1024*1024];
}
首先我们还是jconsole 查看,并点击了垃圾回收,但是毫无作用,说明这个类一直被占用。
然后我们输入 jvisualvm
,根据图片进行操作
到了这里,我们可以很清楚看见,是Test这个类下面,一个Student的数组引起的,即可找到代码解决问题
每个线程都有自己的虚拟机栈,这个栈用于存储栈帧。每当一个线程调用一个方法时,JVM就会为这个方法创建一个栈帧,并且将它压入虚拟机栈中。栈帧是用来存储局部变量、执行运算过程中的操作栈、动态链接信息以及方法返回地址等数据。
线程私有,每个线程运行时所需要的内存,称为虚拟机栈
用来存储方法的参数
和方法内部定义的局部变量
。这些数据包括各种基本数据类型(int、float、long、double等)、对象引用和returnAddress类型(指向了一条字节码指令的地址)。
举例:
int sum(int a, int b) {
int result = a + b;
return result;
}
在调用sum方法时,它的局部变量表将会包含以下内容:
a 的值
b 的值
result 变量
每个栈帧内部含有一个操作数栈,通常也叫做操作栈。这是一个后进先出(LIFO)的栈,用于执行方法中的字节码指令。操作数栈的主要作用是作为计算过程中的临时存储空间,用于存储操作指令的输入和输出参数
。
举例:
public int addNumbers(int a, int b) {
int result = a + b;
return result;
}
当这个方法被调用时,JVM会使用操作栈来执行计算过程。以下是一个简化的操作栈示例:
操作 | 操作数栈
---------------------
// 初始状态: 操作数栈为空
iload_1 // 将第一个参数a压入操作数栈
iload_2 // 将第二个参数b压入操作数栈
iadd // 将栈顶两个元素相加
istore_3 // 将结果存储到局部变量表的索引3(即result)
在这个例子中,iload_1 和 iload_2 将参数 a 和 b 压入操作数栈,iadd 从栈中弹出这两个参数相加,然后 istore_3 将结果存储到局部变量表中的 result 变量中。
每个栈帧内部含有一个指向运行时常量池中该栈帧所属方法的引用,这使得当前方法能够动态链接到其它方法和变量。简而言之,动态连接是指方法在运行时实际引用的地址可以被替换成其他的方法或变量地址,这为Java的多态和方法重载提供了基础。
举例:
class A {
void foo() {
System.out.println("A's foo()");
}
}
class B extends A {
void foo() {
System.out.println("B's foo()");
}
}
public class Test {
public static void main(String[] args) {
A obj = new B();
obj.foo(); // 动态链接到B类的foo()方法
}
}
在上述代码中,虽然变量 obj 的类型是 A,但在运行时,obj.foo() 动态链接到了 B 类的 foo 方法。
当一个方法开始执行后,它需要知道在完成执行后返回到哪里。方法返回地址就是保存这个信息的地方,它指向调用该方法的位置的下一条指令地址。
举例:
void caller() {
callee();
int a = 10; // 当callee方法完成之后,返回到这里继续执行
}
void callee() {
// do something
return; // 在这里,方法返回地址指向caller方法中callee调用之后的指令
}
在 caller 方法中调用 callee 方法后,JVM 会在 callee 方法的栈帧中存储返回地址,当 callee 方法执行完毕后,控制权将会返回到 caller 方法中 callee 调用后的位置。
用debug方式演示:
每个线程会创建一个虚拟机栈,每个方法会创建一个栈帧,放入虚拟机栈。当走到方法b时就会创建三个栈帧(main,a,b),每个方法里面的参数(如变量x e)会被放入到这个栈帧里面。当调用方法b完成回到方法a时,就会释放方法b栈帧
(注:栈内存会自己释放,因此不需要垃圾回收)
栈是不是越大越好?
不是,如内存为500M,每个栈为1M,那么最多可以有500个线程并发。所以栈越大,线程越少。
记住下一条jvm指令的执行地址
public class Example {
public static void main(String[] args) { // 1
int a = 5; // 2
int b = 10; // 3
int c = addNumbers(a, b); // 4
System.out.println(c); // 7
}
public static int addNumbers(int a, int b) {
int result = a + b; // 5
return result; // 6
}
}
在上面的方法中,程序计算器指向的地址分别是1到7,代码执行的每一步操作都会被记录
本地方法栈的结构与虚拟机栈类似,也是由栈帧(Stack Frame)组成的,栈帧中保存了Native方法的局部变量、操作数栈、方法出口等信息。与虚拟机栈不同的是,本地方法栈中的方法不是用Java语言编写的,而是用其它语言编写的,比如C、C++等。
因此,本地方法栈的结构与虚拟机栈类似,但是用于调用本地方法。
这里有一个简单的示例,演示了一个Java程序如何调用一个使用C语言编写的Native方法:
public class NativeExample {
static {
System.loadLibrary("NativeLibrary");
}
public native void nativeMethod();
public static void main(String[] args) {
NativeExample example = new NativeExample();
example.nativeMethod();
}
}
在这个示例中,NativeExample类中的nativeMethod方法是一个本地方法,它用native关键字修饰,表示这个方法是用其它语言实现的。在main方法中,通过example.nativeMethod()调用了这个本地方法。在执行时,虚拟机会使用本地方法栈来执行native Method方法的相关操作。
方法区实现方式:永久代、元空间
在早期的 Java 版本中,方法区与永久代有着密切的关系。方法区是一块用于存储类的相关信息、常量、静态变量、即时编译器优化后的代码等数据的内存区域
。而永久代是 HotSpot 虚拟机中的概念,它实际上就是方法区的一种实现
。
在 Java 7 及之前的版本中,永久代用于存储类和方法相关的信息,包括类的字节码、运行时常量池、字段、方法、构造函数等。由于永久代的大小在JVM启动时固定,并且随着应用的运行可能会出现永久代内存溢出的错误(OutOfMemoryError),在Java 8中被元空间所替代。
因此,从 Java 8 开始,永久代逐渐被元空间(Metaspace)所取代
。它使用本地内存(即非JVM堆内存)来存储类元数据。这样的设计减少了内存溢出的可能性,因为元空间的大小仅受到系统可用内存的限制。当然,元空间中还是有一个初始大小,并且可以设置上限,一旦超过这个上限,仍然会抛出OutOfMemoryError异常。因此,方法区与永久代之间的关系在 Java 8 及以后的版本中已经不再存在。
元空间主要包括以下内容:
方法区的对象不会被Java堆中的垃圾回收器以相同的方式回收,它有自己的内存管理系统(在使用元空间的情况下,内存可以从操作系统直接获取)。
让我们通过一段简单的Java代码,说明方法区中某些部分是如何被使用的:
public class ExampleClass {
// 常量池中的内容
private final static String CONSTANT_STRING = "Hello, World!";
// 方法区中静态变量
private static int counter = 0;
// 类型信息和方法代码
public static void increment() {
counter++;
}
public static void main(String[] args) {
ExampleClass.increment();
System.out.println(CONSTANT_STRING);
}
}
在上述代码中:
字符串"Hello, World!"会被存储在常量池中。
静态变量counter会被存储在方法区。
类ExampleClass的类型信息(比如它的方法和字段)也会存储在方法区。
increment方法和main方法的代码,在被即时编译器编译之后,编译后的机器码也会存储在方法区。
当Java程序运行时,JVM会加载ExampleClass,这个过程中会将ExampleClass的类型信息、常量池中的常量、increment和main方法的字节码等数据存储在方法区,静态变量counter同样存储在方法区内,但具体是在永久代还是元空间则取决于JVM的版本及配置。在Java 8及之后版本,这部分数据会存储在操作系统的本地内存中,称作元空间。
方法栈(Method stack):
本地方法栈(Native method stack):
存储位置
:运行时常量池存储在方法区(元空间)中,而字符串常量池在 JDK 8 时存储在堆中。作用
:运行时常量池主要存储编译期间生成的字面量、符号引用等,而字符串常量池则用于存储字符串对象实例的引用。动态性
:运行时常量池在运行期间可以动态地放入新的常量,而字符串常量池则相对较为固定。以下是一个具体的示例来说明它们的区别:
假设有一个类 MyClass,其中包含一个字符串常量 STRING_CONSTANT。
在编译阶段,STRING_CONSTANT 会被存储在 class 文件的常量池中。当类加载器加载 MyClass 类时,常量池中的内容会被复制到运行时常量池
中。
在运行时,如果创建了一个 MyClass 的实例,并调用了 STRING_CONSTANT,那么虚拟机首先会在运行时常量池中查找该字符串的引用。如果找到了,就直接使用该引用;如果没有找到,就会在字符串常量池中创建一个新的字符串对象,并将其引用存储在运行时常量池中。
注:运行时常量池和字符串常量池存的字符串都是对象
共同特点:
垃圾判定是指在编程中确定哪些内存中的对象是“垃圾”,即不再被应用程序使用的对象,因此可以被垃圾回收器回收的过程。
在Java中,垃圾回收(Garbage Collection, GC)主要采用两种基本方法:引用计数法和可达性分析。下面分别对这两种方法进行说明:
引用计数算法是一种最直观的垃圾收集技术。其基本思想是给每个对象分配一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1。
任何时刻计数器为0的对象就是不可能再被使用的,因此可以回收其占用的内存。
不过,Java并不采用引用计数法来进行垃圾回收,因为它存在循环引用的问题。在循环引用中,两个或多个对象相互引用,但它们可能都已经不再被其他活动部分的应用程序所引用。由于它们相云引用,因此它们的引用计数永远不会达到0,导致内存泄漏。
public class ReferenceCounting {
Object instance = null;
public static void main(String[] args) {
ReferenceCounting objA = new ReferenceCounting();
ReferenceCounting objB = new ReferenceCounting();
// 创建循环引用
objA.instance = objB;
objB.instance = objA;
// 尝试手动置空以断开引用
objA = null;
objB = null;
// 希望GC能回收objA和objB,但如果是采用引用计数法,则无法回收
System.gc();
}
}
Java采用的是可达性分析算法来进行垃圾回收。在这种方法中,通过一系列的称为“GC Roots”的对象作为起点,然后向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达)
时,则证明此对象是不可用的。
在Java中,可作为GC Roots的常见对象包括:
举一个简单的例子来描述可达性分析:
public class ReachabilityAnalysis {
public static void main(String[] args) {
ReachabilityAnalysis obj = new ReachabilityAnalysis(); // 对象obj是可达的,因为它被栈上的引用变量所引用
// 现在让我们断开这个引用
obj = null; // 此时对象不再可达
// 垃圾回收可以执行了,它将使用可达性分析来确定obj的内存是否可以被释放
System.gc();
}
}
在JVM模型中,垃圾回收主要发生在堆内存(Heap)中,因为这里是存放对象实例的地方。当前主流的JVM使用分代垃圾收集算法,将堆内存分为年轻代(Young Generation),老年代(Old Generation),以及永久代(Permanent Generation,但在Java 8及之后被MetaSpace所替代)。不同代的对象会根据其生命周期的不同被相应的垃圾回收器回收,以提高回收效率。
垃圾回收算法、垃圾回收器的选择以及垃圾回收的时机,通常是由JVM自动管理的,但是开发者可以通过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 类加载器的一种机制。当一个类加载器收到加载类的请求时,它会首先将这个请求委派给父类加载器去完成。只有当父类加载器无法完成这个加载请求时,子类加载器才会尝试加载。
要打破双亲委派机制,可以自定义类加载器,并重写 ClassLoader 类中的 loadClass(String name, boolean resolve)
方法(或者是 findClass(String name)
方法,根据具体需求)。自定义的类加载器可以先尝试加载类,而不是直接委派给父加载器。
下面是一个简化的示例,说明如何自定义类加载器以打破双亲委派模型:
public class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 首先, 检查请求的类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 尝试自己加载类,而不是委派给父类加载器
c = findClass(name);
} catch (ClassNotFoundException e) {
// 如果自己无法加载类,那么调用父类加载器尝试加载
c = super.loadClass(name);
}
}
return c;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 在这里加入具体的类加载逻辑,比如从文件系统中读取.class文件的字节流
// byte[] classBytes = ...;
// return defineClass(name, classBytes, 0, classBytes.length);
// 示例中没有具体实现,因为它通常需要读取文件或其他数据源中的类数据
throw new ClassNotFoundException();
}
}
在这个例子中,findClass(String name) 方法被重写用于尝试加载类。如果在 findClass 中没有找到类,则会抛出 ClassNotFoundException 异常,然后调用父类加载器尝试加载。
注意,直接破坏双亲委派机制可能会导致各种问题,如类冲突、安全问题等。因此,在实际开发中,只有在真正需要时才应该打破双亲委派模型,并且必须非常小心地实现。
自定义类加载器可以用在很多场景中,例如热部署(hot deploy)一个正在运行的应用程序,这通常需要动态地加载和卸载类。在框架开发中,比如OSGI、JSP的servlet容器等,这样的需求也是很常见的。
直接内存是操作系统中分配的一块内存,不受JVM管理,Java代码可以直接获取直接内存中的数据。
推荐参考:直接内存(Direct Memory)
Java 提供了四种不同的引用类型:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。
JVM提供了一些常用调优参数:-Xms、-Xmx、-Xmn等
推荐参考:JVM常用调优参数
正常情况下,JVM 是不需要额外调优的。默认的配置通常适用于许多应用程序,因为 JVM 实现考虑了大量的使用情况,并经过了在不同场景下的测试和优化。
除非是系统有特殊的性能需求或者存在特定的瓶颈,一般来说,在生产环境中使用默认参数是合适的。然而,在一些特殊场景下,可以对 JVM 进行一些微调,以获取更好的性能或者更好的资源利用率。这通常需要仔细评估和测试,以确保调整后的参数能够有效地改善系统的性能。
JVM调优通常涉及到调整内存设置、选择合适的垃圾回收器以及优化JVM参数等方面。
JVM中出现OOM的区域通常有:
推荐参考:OOM日志分析
下面是一个简单的Java代码片段,用于模拟堆内存溢出。
import java.util.ArrayList;
import java.util.List;
public class GenerateOOM {
static final int SIZE = 2 * 1024 * 1024;
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while (true) {
list.add(new Object[SIZE]);
}
}
}
运行这个程序,很快就会因为堆内存溢出而出现 OutOfMemoryError
。
首先使用 jps
查看进程ID,或者top
查看内存较高进程ID,然后使用jmap来生成堆转储文件:
jmap -dump:live,format=b,file=heapdump.dat<PID>
使用MAT打开堆转储文件(heapdump.dat),MAT将会对文件进行分析,并提供内存使用的概览。
推荐参考:linux系统cpu飙高如何排查
1、使用top
命令查看占用过高的进程ID(pid)
2、使用top -H -p <进程ID>
查看这个进程里面哪个线程导致的
3、使用printf "%x\n" [tid]
将十进制的线程ID转换为十六进制的线程ID
4、使用jstack <进程ID> | grep <16进制线程ID> -A 20
命令打印线程日志
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。