JVM 基础、堆内存分析和垃圾回收算法
文章首发我的博客,欢迎访问:https://blog.itzhouq.cn/jvm
首先基本的面试题都是下面的夺命连环问,感受一下。
- 请你谈谈你对 JVM 的理解。java8 虚拟机和之前有什么变化?
- 什么是 OOM, 什么是栈溢出 StackOverFlowError? 怎么分析?
- JVM 的常用调优参数有哪些?
- 内存快照如何抓取,怎么分析 Dump 文件?你知道吗?
- 谈谈 JVM 中,你对类加载器的认识?
这篇文章先大体梳理一下相关的知识点,后面再整理一篇基本面试题相关的,先挖个坑。要说明的是,文章中很多地方关于概念是一带而过的,难免有部分内容没有说明白。对于不明白的点,建议自己动手查查相关资料,决不能指望一篇笔记就能把 JVM 搞明白,这显然也是不可能的。
1、JVM 的位置
可以看到 JVM 是 JRE 的一部分。主要工作是解释自己的字节码并映射到本地的 CPU 指令集和 OS 的系统调用。Java 语言是跨平台的,不同的操作系统会有不同的 JVM 映射规则,这就使得 Java 语言与操作系统无关。
2、JVM 的体系结构
3、类加载器
作用:加载 Class 文件,比如我们 new Student()
的时候,Student
是类,是抽象的,使用 new
关键词创建对象实例,实例的引用是在栈中,而具体的人是放在堆中。
3.1、类的实例化和双亲委派机制
public class Car { public static void main(String[] args) { // 类是模板,对象是具体的 Car car1 = new Car(); Car car2 = new Car(); Car car3 = new Car(); System.out.println(car1.hashCode()); // 460141958 System.out.println(car2.hashCode()); // 1163157884 System.out.println(car3.hashCode()); // 1956725890 Class<? extends Car> aClass1 = car1.getClass(); Class<? extends Car> aClass2 = car2.getClass(); Class<? extends Car> aClass3 = car3.getClass(); System.out.println(aClass1.hashCode()); // 685325104 System.out.println(aClass2.hashCode()); // 685325104 System.out.println(aClass3.hashCode()); // 685325104 } }
3.2、类加载
类加载器:
- 虚拟机自带的加载器
- 启动类(根)加载器
- 扩展类加载器
- 应用程序加载器
试验:自己定义一个String类,看是否能执行
package java.lang; public class String { // 双亲委派机制:安全 // BOOT --> EXT --> APP (最终执行) // BOOT // EXT // APP public String toString () { return "Hello"; } public static void main(String[] args) { String s = new String(); System.out.println(s.toString()); } // 错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: // public static void main(String[] args) //否则 JavaFX 应用程序类必须扩展javafx.application.Application /** * 类加载的流程 * 1. 类加载器收到类加载的请求 * 2. 将这个请求向上委托给父类加载器去完成,一直向上委托,直到启动类加载器 * 3. 启动节加载器检查是否能够加载当前这个类,能加载就结束,使用当前的加载器, 否则,抛出异常通知子加载器进行加载 * 4. 重复步骤3 */ }
百度:双亲委派机制
4、沙盒安全机制
Java安全的模型的核心就是 Java 沙箱 (sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限制在虚拟机特定的运行环境中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统操作破坏。沙箱主要限制系统资源访问。
5、Native
开启一个多线程启动类:
public static void main(String[] args) { new Thread(() -> { }, "my thread name").start(); }
点进去查看start()
方法的源码:
public synchronized void start() { /** * This method is not invoked for the main method thread or "system" * group threads created/set up by the VM. Any new functionality added * to this method in the future may have to also be added to the VM. * * A zero status value corresponds to state "NEW". */ if (threadStatus != 0) throw new IllegalThreadStateException(); /* Notify the group that this thread is about to be started * so that it can be added to the group‘s list of threads * and the group‘s unstarted count can be decremented. */ group.add(this); boolean started = false; try { start0(); // 调用start0()方法 started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) { /* do nothing. If start0 threw a Throwable then it will be passed up the call stack */ } } } private native void start0();
可以看到源码中使用了特殊的方法 start0()
,使用了 native
关键词。
native :凡是带了 native 关键词,说明 java 的作用范围达不到了,会调用底层 C 语言的库! native 的方法会进入本地方法栈,调用本地方法接口 JNI(Java Native Interface 本地方法接口),其他的就是 Java 栈。 JNI的作用:扩展 java 的使用,融合不同的编程语言为 java 所用!最初的时候需要融合 C 和 C++。 Java 诞生的时候, C 和 C++ 横行,想要立足,必须要有调用 C 和 C++的程序。 它在内存区域中专门开辟了一块标记区域: Native Method Stack ,登记 native 方法, 在最终执行的时候,加载本地方法库中的方法通过 JNI。 比如: Java程序驱动打印机,管理系统。这部分掌握即可,在企业级应用中较为少见。 现在调用第三方语言接口的方式很多,比如:Socket、WebService、HTTP等。
6、PC 寄存器
程序计数器:Program Counter Register
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向一条指令的地址,也即将要执行的指令代码),在执行引擎下读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。
7、方法区
Method Area 方法区
方法区是被所有线程共享的,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间。
**静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关 ** 。
static、final、Class、常量池。
8、栈
栈是一种数据结构,可以形象地理解为一个水桶或水杯,其特点是先进后出。 比如,依次将乒乓球放入杯子中,先放进去的球,最后才能拿出来。
栈中存放 8 大基本数据类型 + 对象引用 + 实例的方法。
栈内存主管程序的运行,生命周期和线程同步。Java 中执行方法的过程就是调用栈的过程。为什么 main() 方法,最先执行,最后结束呢?因为main() 方法是程序的入口,执行时 main() 会最先被压到栈底, 在 main() 方法中调用其他方法时,依次将其他方法压入栈中。
线程结束,栈内存就释放了,对于栈来说,不存在垃圾回收问题。
栈的运行原理:
栈 + 堆 + 方法区的交互关系
画一个对象实例化的过程在内存中:百度、看视频。
9、三种 JVM
- Sun 公司
HotSpot
:Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
- BEA:
JRockit
- IBM:
J9VM
我们学习的都是HotSpot
。
10、堆
Heap,一个 JVM 只有一个堆内存,堆内存的大小是可以调节的。
类加载器读取类文件后,一般会把什么东西放入堆中?类、方法、常量、变量,保存所有引用类型的真实对象。
堆内存中还要细分为三个区域:
- 新生区(伊甸园区 Eden):Young/New
- 养老区:old
- 永久区:Perm
新生区中没有被垃圾收集器干掉的对象会进入幸存区0区
,幸存区0区
中没有被干掉的对象会进入幸存区1区
。幸存区0区
和幸存区1区
会不停的交换位置。经过一定次数后的垃圾回收后还没有被干掉的对象会进入养老区
,这个区域的对象一般不会被干掉,但不是绝对的。假设养老区满了,对象会进入永久存储区
。
针对新生区的垃圾回收称为轻量级的垃圾收集,也称轻 GC。针对养老区的垃圾回收称为重量级的垃圾收集,也称重 GC。
GC 垃圾回收,主要是在伊甸园区和养老区。
假设内存满了,会报错 OOM,OutOfMemeroy,对内存不够!
public static void main(String[] args) { String str = "hello world!"; while (true) { str += str + new Random().nextInt(88888888) + new Random().nextInt(99999999); } // Exception in thread "main" java.lang.OutOfMemoryError: Java heap space // at java.util.Arrays.copyOf(Arrays.java:3332) // at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) // at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674) // at java.lang.StringBuilder.append(StringBuilder.java:208) // at Hello.main(Hello.java:8) }
在 JDK8 以后,永久存储区改名为元空间。
11、新生区、老年区
新生区:
- 类:诞生和成长的地方,甚至死亡;
- 伊甸园,所有对象都是在伊甸园区 new 出来的;
- 幸存者区:0 和 1 区。
- 经过研究,99%的对象都是临时对象,所以进入老年区的对象很少。
12、永久区
这个区域常驻内存的。用来存放 JDK 自身携带的 Class 对象。Interface 元数据,存储的是 Java 运行时的一些环境或类信息,这个区域不存在垃圾回收!关闭 VM 虚拟机就会释放这个区域的内存。
一个启动类,加载了大量的第三方 jar 包。Tomcat 部署了太多的应用,大量动态生成的反射类。不断的被加载,知道内存满,就会出现 OOM。
- JDK1.6:永久代,常量池存放在方法区;
- JDK1.7:永久代,但是慢慢的退化了,常量池在堆中;
- JDK1.8:无永久代,常量池在元空间。
13、堆内存调优
public static void main(String[] args) { // 返回 JVM 试图使用的最大内存 long max = Runtime.getRuntime().maxMemory(); // 字节 // 返回 JVM 的初始化总内存 long total = Runtime.getRuntime().totalMemory(); System.out.println("max=" + max + "字节\t" + (max / (double)1024/1024) + "MB"); // max=2831679488字节 2700.5MB System.out.println("total=" + max + "字节\t" + (total / (double)1024/1024) + "MB"); // total=2831679488字节 182.5MB // 默认情况下:分配的从内存是电脑内存的 1/4, 而初始化内存是电脑内存的 1/64。 }
元空间在逻辑上存在,物理上不存在。
OOM 解决方案:
- 尝试扩大堆内存看结果;
- 分析内存,看一下哪个地方出现了问题(专业工具)。
14、使用 JProfier 工具分析 OOM 原因
在一个项目中,突然出现了 OOM 故障,那么该如何排除,研究为什么出错?
- 能够看到代码第几行出错:内存快照分析工具,MAT(Eclipse)、JProfiler
- Debugger,一行行分析代码。
MAT 、JProfiler的作用:
- 分析 Dump 内存文件,快读定位内存泄漏;
- 获得堆中的数据;
- 获得大的对象;
- 。。。。。。
JProfiler 插件和Windows客户端安装百度。
编写一个 OOM 的程序测试
public class Demo03 { byte[] array = new byte[1 * 1024 * 1024]; // 1MB public static void main(String[] args) { ArrayList<Demo03> list = new ArrayList<>(); int count = 0; try { while (true) { list.add(new Demo03()); count ++; } } catch (Exception e) { // 错误写法 e.printStackTrace(); } } // Exception in thread "main" java.lang.OutOfMemoryError: Java heap space // at Demo03.<init>(Demo03.java:4) // at Demo03.main(Demo03.java:12) }
这个程序出现了 OOM,但是从报错信息无法看出哪里的问题。
此时需要添加一些配置,打印一些信息。
通过 JProfiler 工具打开文件:
总结: // -Xms 设置初始化内存分配的大小 默认1/64 // -Xmx 设置最大分配内存 默认 1/4 // -XX:+PrintDCDetails // 打印GC垃圾回收信息 // -Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError OOM Dump(转储文件)
15、GC : 常用算法
GC 的作用区域:
JVM 在进行 GC 时,并不是对这三个区域统一回收,大部分时候,回收都是新生区。
- 新生区
- 幸存区:from to
- 老年区
GC 的分类:
轻 GC(普通的 GC):主要针对新生区,偶尔对幸存区进行 GC。
重 GC(全局 GC):把上面所有的区域都进行 GC,也就是释放内存。
堆内存中的幸存区是可以交换位置的。
GC 的题目:
- JVM 的内存模型和分区,详细到每个区放什么;
- 堆里面的分区有哪些? Eden、from、to、Old,说说他们的特点;
- GC 的算法有哪些?标记清除法、标记压缩、复制算法、引用计数器,怎么用的?
- 轻 GC 和重 GC 分别在什么时候发生?
引用计数器:(用得少)
复制算法:幸存区的复制
GC 复制算法:
好处:没有内存碎片;
坏处:浪费了空间内存,多了一半空间(to)永远都是空的。极端情况下,比如对象 100% 存活,这个缺点就很明显。
复制算法最佳使用场景:对象存活度较低,比如新生区。
标记清除算法
优点:不需要额外的空间!
缺点:两次扫描,严重浪费时间,会产生内存碎片。
标记压缩
对标清除进行再优化。
标记清除压缩
再次优化上述算法,可以多次进行标记清除,进行一次标记压缩。
16、总结:
- 内存效率:复制算法 > 标记清除算法 > 标记压缩算法(时间复杂度)
- 内存整齐度:复制算法 == 标记压缩算法 > 标记清除算法
- 内存利用率:标记压缩算法 == 标记清除算法 > 复制算法
思考一个问题:难道没有一个最优的算法吗?
答案:没有,没有最好的算法,只有最合适的算法---> GC:分代收集算法
年轻代:
- 存活率低
- 复制算法
老年代:
- 区域大,存活率低
- 标记清除(内存碎片不是太多)+ 标记压缩混合实现。
参考书籍:《深入理解 JVM》
17、JMM(高频) 和 快速学习方法
- 什么是 JMM?
JMM:Java Memory Model 的缩写。
- 它是干嘛的?
作用:缓存一致性协议,用于定义数据读写的规则。
JMM 定义了线程工作内存和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(Local Memory)。
解决共享对象可见性这个问题: volilate
- 它该如何学习?
官方、其他人的博客、对应的视频。。。
关于内存图:可以去思维导图 : processon 网站搜索 JVM 可以看到别人画的相关思维导图。