跟着狂神老师入门所总结的笔记。
jvm 是什么
jvm 全称叫 java virtual machine ,也叫 Java 虚拟机 ,Java 虚拟机是一个使用 c++ 编写的,给 Java 程序运行的一个环境 。它运行于操作系统之上 。
jvm 有很多种,目前我所使用的 jvm 是 HotSpot 。除了 Hotspot ,还有 Microsoft JDK,Open JDK 等等等等
怎么说呢?jvm 是一种模型,一种规范 。而 HotSpot 、Microsoft JDK,Open JDK 是一种实现 。
探索 jvm 的内部构造
在一个 jvm 中 ,有这些部件,分别是“类加载器”、“栈”、“堆”、“方法区”、“程序计数器”、”本地方法栈“、“执行引擎”、“本地方法接口”和“本地方法库”。
jvm 的大致架构如图所示 。
类加载器
一个 class 文件的加载,首先是需要经过加载器 ClassLoader 进行加载,当加载完成之后,放到了方法区。如果要创建一个实例对象,就从方法区调取,将该实例对象存入堆中 ,然后通过引用来获取对象。
类的加载并不是那么的简单,它有一套机制,叫“双亲委派机制” 。在 Java 中,类的加载器分为 4 类,即“启动类(根)加载器”、“扩展类加载器”、“应用程序(系统类)加载器”、“用户自定义加载器” 。
其中,前三个加载器非常重要:
根加载器又叫 Bootstrap 加载器 :主要是加载 rt.jar 里面的类 ,然后加载到类加载器中。
扩展加载器
这里有一个简单的实验,旁边的注释是运行的结果
public class Car {
public int id;
public static void main(String[] args) {
Car car = new Car();
Class<? extends Car> aClass = car.getClass();
System.out.println(ClassLoader.getSystemClassLoader()); // sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(aClass.getClassLoader()); // sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(aClass.getClassLoader().getParent()); // sun.misc.Launcher$ExtClassLoader@7ea987ac
System.out.println(aClass.getClassLoader().getParent().getParent()); // null
}
}
getSystemClassLoader():是获取应用程序加载器,也叫系统加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
getClassLoader():获取当前类的加载器,此时的加载器是 AppClassLoader 。
getParent():获取父加载器,此时的父加载器是 ExtClassLoader ,叫扩展类加载器。如果再调用父加载器则返回的是 null 。返回为 null 的原因是 Bootstrap 类加载器,是用 C++ 实现的,是虚拟机自身的一部分,如果获取它的对象,将会返回 null 。
注意,这里所谓的父加载器和当前的加载器,不是继承关系 。
双亲委派机制
这里有一个实验,假如我在自己新建的工程中新建一个 java.lang.String
类 。这其实是不会冲突的,然后我们再 String 类中创建一个 main 方法,然后运行该类,看看会发生什么事情。
package java.lang;
public class String {
public static void main(String[] args) {
int a = 1 / 0;
}
}
结果,报了一个很是令人不理解的错误。说我的 String 类中居然没有这个方法,可是我明明有 main 方法呀,这是什么鬼!
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
出现这个问题,就是双亲委派机制造成的,说白了,双亲委派机制就是保证 Java 中内置的一些代码保证是安全的 ,这也是类加载的一个过程。
注意,这里所谓的父加载器和当前的加载器,不是继承关系 。
假设,我们的 java.lang.String
要被加载时,在不考虑我们自定义类加载器,首先会在 AppClassLoader 中检查是否加载过,如果有那就不用再加载了。
如果没有,那么会拿到父加载器,然后调用父加载器的 loadClass 方法。父加载器中同理也会先检查自己是否已经加载过,如果还没有再往上,直到到达 Bootstrap 类加载器之前,都是在检查是否加载过,并不会选择自己去加载。直到 Bootstrap 类加载器,已经没有父加载器了,这时候开始考虑自己是否能加载了。
此 Bootstrap 非彼 Bootstrap
然后就开始向下找。通过根加载器发现 rt.jar 中发现了 java.lang.String
,找到了之后就不会向下继续开始找了。如果没找到,他就在扩展加载器中加载,如果最后在应用程序加载器中还没找到(不考虑自定义类加载器),那就会抛出 ClassNotFoundException
异常。
类加载时的运行图如下图所示
加载过程的相关代码如下所示
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先检查这个类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false); // 如果有父加载器就加载
} else {
c = findBootstrapClassOrNull(name); // 没有找到根启动器(Bootstrap)
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
沙箱安全机制
👆 参见,Java 中的安全沙箱模型
Native 和本地方法栈
native
你或许有看过这种“奇葩”的写法,比如打开一个 Object 类。看起来很像接口,没有方法体,却又没有报错。
public class Object {
private static native void registerNatives();
static {
registerNatives();
}
public final native Class<?> getClass();
public native int hashCode();
public final native void notify();
//.......
}
这,就是本地方法。不过我们也可以自己写一个这样的方法,不过我们自己写的是没有用的。调用它会报错。
public native void helloWorld();
如果方法带了 native 关健字,就没有方法体,说明 Java 的作用范围达不到了。进入本地方法栈,调用 JNI(本地方法本地接口),从而可以调用 C 、C++ 底层的代码。
JNI 作用:扩展 Java 的使用,融合不同的编程语言为 Java 所用 。在 Java 诞生的时候,当时 C 、C++ 横行,那时想要立足就必须要有 C 、C++ 的能力,于是就有了 Native 关键字。
本地方法栈
它的具体做法是在 Native Method Stack(本地方法栈)中登记 native 方法,在执行引擎执行的时候,加载本地方法库中的方法。
PC 寄存器
程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向像一条指令的地址,也即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计 。
方法区
方法区是被所有线程共享的,所有字段和方法字节码(指令),以及一些特殊方法,如构造函数,接口代码也在此定义。
也就是说,所有定义的方法的信息都保存在该区域,此区域属于共享区间。
静态变量(static)、常量(final)、类(Class)信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关 。
栈
栈内存,主管程序的运行,栈的生命周期和线程同步。栈遵循着 FILO (First In Last Out) 的原则。
线程结束,栈内存也就是释放,对于栈来说,不存在垃圾回收问题。
如果栈满了,就会爆出一个 StackOverflowError 的错误 ,虚拟机被迫停止运行。
栈帧是什么?
栈帧是 Java 虚拟机中栈的组成元素,栈帧由三部分组成:局部变量区、操作数栈、帧数据区。局部变量区和操作数栈的大小要视对应的方法而定,他们是按字长计算的。但调用一个方法时,它从类型信息中得到此方法局部变量区和操作数栈大小,并据此分配栈内存,然后压入 Java 栈。
局部变量区:存放了基本数据类型、对象引用和 returnAddress 类型(指向一条字节码指令的地址)。
操作数栈:Java 没有寄存器,所有参数传递都是使用操作数栈。
帧数据区:除了局部变量区和操作数栈外,Java 栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常派发机制。这些数据都保存在 Java 栈帧的帧数据区中。当 JVM 执行到需要常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它。
除了处理常量池解析外,帧里的数据还要处理 Java 方法的正常结束和异常终止。如果是通过 return 正常结束,则当前栈帧从 Java 栈中弹出,恢复发起调用的方法的栈。如果方法又返回值,JVM 会把返回值压入到发起调用方法的操作数栈。
堆
一个 JVM 只有一个堆内存,堆内存可以人为的去分配。类加载器读取了类文件后,将对象实例(对象实例中包含成员方法、成员变量)、常量保存到堆中 。
堆内存中还需要细分为三个区域。
新生区
- Eden Space(伊甸园)
- 所有的对象都是从“伊甸园”创建出来的。
- 幸存区 0 区
- 幸存区 1 区
- Eden Space(伊甸园)
老年区
永久区:该区域常驻内存。用来存放 JDK 自身携带的 Class 对象。Interface 元数据,存储 Java 运行时的一些环境或类信息,永久区域不存在垃圾回收。
永久区可能造成的内存溢出:加载很多第三方的 jar 包。
在不同版本的 jdk ,各种命名都不一样。
- jdk 1.6 之前,叫永久代,常量池是在方法区中。
- jdk 1.7 永久代开始退化,常量池在堆中。
- jdk 1.8 之后:无永久代,常量池在元空间中。
垃圾回收规则(GC)
所有的对象都是从新生区的“伊甸园”中创建出来的,假设“伊甸园”、“幸存区 0 区”、“幸存区 1 区” 和 “老年区”分别都只能保存 10 个对象。如果伊甸园区的对象满了,就会触发垃圾回收(轻GC)。
如果 Java 栈中有引用堆中的对象,那么就不会被清除 ,并且进入幸存者区(可能是 0 区,也可能是 1 区);如果对象未被引用,则会被垃圾回收器(轻GC)给回收掉,此时这些在幸存者区的对象在左右横跳。
如果新生区满了,即这里面的三个区都满了,则又会触发一次垃圾回收,通过垃圾回收器(重GC)将在新生区存活的对象放到老年区中。
如果新生区和老年区都满了,那么就会报严重的内存溢出错误。
内存溢出的原因
造成溢出的原因是因为新生区和老年区都满了,则会报错严重的内存溢出错误。
下面有一个实验来测试堆内存溢出的错误 。
import java.util.Random;
public class Hello {
public static void main(String[] args) {
String str = "asdfghkfofksodffdsfdsfdsfsdfdf";
while (true) {
str += str.concat("asdfghkfofksodffdsfdsfdsfsdfdf").concat(new Random().nextInt(888888888) + "");
}
}
}
运行几秒钟 ,很快就爆出了内存溢出的错误。爆出的错误的原因是堆内存溢出的错误。
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.String.concat(String.java:2032)
at Hello.main(Hello.java:8)
如果我们将堆内存大小调整到 10M,看看是怎么报错的,在运行之前加入这个参数 -Xmx10m -Xms10m -XX:+PrintGCDetails
,在运行的结果后面,有标注堆内存的三个区,其中 PSYoungGen 是新生区、ParOldGen 是老年区、Metaspace 叫元空间 。
运行时的参数解释
- -Xms 设置 jvm 初始化对内存分配大小 ,默认是最大分配内存的 1/64
- -Xmx 设置 jvm 最大分配的内存大小,默认是物理内存的 1/4
- -XX:+PrintGCDetails 该参数是打印 GC 的垃圾回收器回收时的内存状态。
- -XX:+HeapDumpOnOutOfMemoryError 将“内存溢出错误”的状态转储到文件中。当然,HeapDumpOn 后面可以导出其它的错误。
点击查看运行结果
[GC (Allocation Failure) [PSYoungGen: 2048K->496K(2560K)] 2048K->724K(9728K), 0.0340698 secs] [Times: user=0.00 sys=0.00, real=0.06 secs] [GC (Allocation Failure) [PSYoungGen: 2441K->512K(2560K)] 2670K->1040K(9728K), 0.0120401 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 2482K->512K(2560K)] 3010K->1591K(9728K), 0.0015323 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Ergonomics) [PSYoungGen: 2206K->0K(2560K)] [ParOldGen: 6594K->3920K(7168K)] 8801K->3920K(9728K), [Metaspace: 3053K->3053K(1056768K)], 0.0076374 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 20K->32K(2560K)] 6146K->6158K(9728K), 0.0006039 secs] [Times: user=0.03 sys=0.00, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 32K->32K(2560K)] 6158K->6158K(9728K), 0.0005377 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Allocation Failure) [PSYoungGen: 32K->0K(2560K)] [ParOldGen: 6126K->3919K(7168K)] 6158K->3919K(9728K), [Metaspace: 3053K->3053K(1056768K)], 0.0061232 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 40K->32K(1536K)] 6166K->6157K(8704K), 0.0015948 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 32K->32K(2048K)] 6157K->6157K(9216K), 0.0005505 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Allocation Failure) [PSYoungGen: 32K->0K(2048K)] [ParOldGen: 6125K->2816K(7168K)] 6157K->2816K(9216K), [Metaspace: 3053K->3053K(1056768K)], 0.0071274 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 20K->0K(2048K)] 5043K->5022K(9216K), 0.0004772 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] 5022K->5022K(9216K), 0.0004349 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] [ParOldGen: 5022K->5022K(7168K)] 5022K->5022K(9216K), [Metaspace: 3053K->3053K(1056768K)], 0.0028990 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] 5022K->5022K(9216K), 0.0004763 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] [ParOldGen: 5022K->5005K(7168K)] 5022K->5005K(9216K), [Metaspace: 3053K->3053K(1056768K)], 0.0087491 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] Heap PSYoungGen total 2048K, used 40K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000) eden space 1024K, 3% used [0x00000000ffd00000,0x00000000ffd0a2f8,0x00000000ffe00000) from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000) to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000) ParOldGen total 7168K, used 5034K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000) object space 7168K, 70% used [0x00000000ff600000,0x00000000ffaea8f8,0x00000000ffd00000) Metaspace used 3257K, capacity 4496K, committed 4864K, reserved 1056768K class space used 353K, capacity 388K, committed 512K, reserved 1048576K Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3332) at java.lang.String.concat(String.java:2032) at Hello.main(Hello.java:8)
如何解决内存溢出的问题
- 尝试扩大堆内存大小
- 如果扩充堆内存大小还是会有内存错误,可以使用内存快照分析工具对内存溢出产生的原因进行分析。比如 JProfiler 、MAT 等等,使用内存分析工具可以知道我们内存溢出的根本原因,也就是说,是第几行的代码导致内存溢出。
垃圾回收算法
在 Java 中,垃圾回收机制有 4 种 ,分别是“复制法”、“标记压缩法”、“标记清除法”、“引用计数法”。
复制法
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另外一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾回收。
- 每次 GC 都会将 Eden 区存活的对象移到幸存区中,—旦 Eden 区被 GC 后,Eden 区存活的都在幸存区,即 Eden 区是空的。
- 然后在幸存区,这些存活的对象在 from 区(可能是幸存 0 区是,也有可能是幸存 1 区,不过这已经不重要了),如果 to 区是空的,幸存的对象就会到 to 区,然后就这样一直循环 15 次 。
- 如果幸存区的对象经历了 15 次的“拷打”,那么这些幸存的对象就保存到老年区。
由于是将原有的内存一分为二,所以复制法非常适合新生区的对象。
优缺点:
- 优点:在垃圾对象多的情况下,效率较高。清理后,内存无碎片。
- 缺点:在垃圾对象少的情况下,不适用,浪费内存。
标记清除法
标记清除顾名思义是一种分两阶段对对象进行垃圾回收的算法。
第一阶段:标记。从根结点出发遍历对象,对访问过的对象打上标记,表示该对象可达。
第二阶段:清除。对那些没有标记的对象进行回收,这样使得不能利用的空间能够重新被利用。
优点:不会损失50%的空间、可解决循环引用的问题
缺点:效率不高、会产生大量不连续的内存碎片
标记压缩法
标记压缩法,就是执行了标记清除之后,再将碎片化的内存空间进行移动,最终变成连续的空间。
优点:不会损失50%的空间、回收后空间连续
缺点:效率不高
其中,标记清除法和标记压缩法主要是在老年区中。
引用计数法
java 虚拟机不采用这种垃圾回收算法,Python 采用的是这种算法
引用计数法,实际上是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加 1,如果删除对该对象的引用,那么它的引用计数就减 1,当该对象的引用计数为 0 时,那么该对象就会被回收。
比如我创建了一个 String 对象 ,创建完成之后,p 指向我们刚刚创建的对象。此时计数器是 1 。
String p = new String("abc");
如果我们将 p 指向新创建的对象,此时 new String("abc")
的计数器是 0 ,就要被 GC 处理掉。
p = new String("bdc");
当对象的引用计数为0时,垃圾回收就发生了。这跟前面三种垃圾收集算法不同,前面三种垃圾收集都是在为新对象分配内存空间时由于内存空间不足而触发的,而且垃圾收集是针对整个堆中的所有对象进行的。而引用计数垃圾收集机制不一样,它只是在引用计数变化为0时即刻发生,而且只针对某一个对象以及它所依赖的其它对象。所以,我们一般也称呼引用计数垃圾收集为直接的垃圾收集机制,而前面三种都属于间接的垃圾收集机制。
三种垃圾回收算法的比较
内存效率:复制算法 > 标记清除算法 > 标记压缩算法(时间复杂度)
内存整齐度:复制算法 = 标记压缩算法 > 标记清除算法
内存利用率:标记压缩算法 = 标记清除算法 > 复制算法
参考资料
- 深入理解Java类加载:https://www.cnblogs.com/czwbig/p/11127222.html
- 沙箱安全机制:https://blog.csdn.net/qq_30336433/article/details/83268945
- 通俗易懂的双亲委派机制:https://blog.csdn.net/codeyanbao/article/details/82875064
- JVM之虚拟机栈详解:https://juejin.cn/post/6844903983400632327
- JVM垃圾回收之复制算法:https://blog.csdn.net/yanghenan19870513/article/details/92803409
- 垃圾回收算法详解:https://blog.csdn.net/m0_37860933/article/details/82154989
- 引用计数算法:https://www.jianshu.com/p/1d5fa7f6035c
请勿发布违反中国大陆地区法律的言论,请勿人身攻击、谩骂、侮辱和煽动式的语言。