成为一名合格的 Java 程序员,JVM 是避不开的知识点。
这篇学习笔记的内容来自《深入理解Java虚拟机:JVM高级特性与最佳实践》。
漫谈 JVM
JVM 全称是 Java Virtual Machine,就是说,Java 程序运行在虚拟的环境中,它屏蔽了与各大操作系统的交互难度,所有 Java 程序只对 JVM 负责,不像某些程序,居然要兼容浏览器。所以 Java 程序员是幸福的,不用担心运行效果不一致的问题。
一、发展史
回顾 Java 的历史,了解 JVM 的更新换代,这对我们解决“旧项目”故障,有一定的帮助。
- 1996 年 1 月 23 日,JDK 1.0 发布
- 提供虚拟机实现(Sun Classic VM)
- 1998 年 12 月 4 日,JDK 1.2 发布
- HotSpot VM(内置 JIT 编译器)
- Exact VM(内置 JIT 编译器,Solaris 平台)
- 2009 年 2 月 19 日,JDK 1.7 发布
- Oracle 收购 Sun,准备合并 JRockit 和 HotSpot 虚拟机
二、结构
JVM 到底是什么,包含哪些部分?
-
JVM 本质上就是一个程序,加载并执行字节码中的指令。
-
主要分为五大模块:
- 类装载器子系统
- 运行时数据区
- 执行引擎
- 本地方法接口
- 垃圾收集模块
三、运行时数据区
通过下面这张图片,我们来了解运行时数据区的状况:
- 程序计数器
- 线程私有的内存区域,实现基本逻辑功能
- 执行 Java 方法时,记录的是字节码地址,而本地方法则为
Undefined
- 虚拟机规范唯一没有规定
OutOfMemoryError
情况
- 虚拟机栈
- 线程私有,生命周期与线程相同
- 包含栈帧,由方法执行时创建,栈帧存储局部变量表、操作栈、动态链接、方法出口等信息
- 局部变量表编译期确定内存空间,运行时不会改变大小
- 栈深度大于允许值,抛出
StackOverFlowError
- 扩展无法申请到足够的内存,抛出
OutOfMemoryError
- 本地方法栈
- 与虚拟机栈相似,不同的是,这个栈执行的是本地方法
- 规范没有强制要求,可以自由实现
- 例如 Sun HotSpot VM,将本地方法栈与虚拟机栈合二为一
- 也会抛出
StackOverFlowError
和OutOfMemoryError
- 堆
- 线程共享,虚拟机启动时创建
- 唯一的功能就是存放对象实例
- 垃圾收集器管理的主要区域
- 从内存回收的角度,分为新生代和老年代
- 可以处于物理上不连续的内存空间
- 没有内存完成实例分配,并且无法扩展时,抛出
OutOfMemoryError
- 方法区
- 线程共享,存储已加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 在 HotSpot VM 上,方法区是作为永久代实现的
- JDK 1.8 会将方法区改为元空间,放到物理内存上
- 垃圾回收在方法区收效甚微,主要针对常量池的回收和对类型的卸载,条件比较苛刻
- 无法满足内存分配需求时,抛出
OutOfMemoryError
- 运行时常量池
- 是方法区的一部分,在类加载后存放编译生成的各种字面量和符号引用
- 一般情况下,除了符号引用,还有翻译出来的直接引用也会存储进来
- 具备动态性,例如
String
类的intern()
方法,可以在运行期间将新的常量加入
- 直接内存
- 不是运行时数据区的一部分,也不是 JVM 规范定义的内存区域,但也被频繁使用
- 由 NIO 使用 Native 函数库直接分配,通过 Java 堆中的
DirectByteBuffer
对象引用 - 扩展时内存不足,抛出
OutOfMemoryError
3.1 对象访问
简述 Object obj = new Object();
这条语句将在 JVM 中发生哪些事情?
new
关键字需要在堆中申请分配内存空间,得到Object()
实例的结构化内存- 假若
Object
还有父类、接口等信息,那么必须在方法区中能找到它们的信息 - 从方法区中找到
Object
的全部信息,这是 Java 类级别,实例级别的数据存储在堆中 - 最后
Object obj
会在当前栈帧的局部变量表中,作为一个reference
类型指向堆里面的实例地址
有个问题:虚拟机栈中的局部变量如果是 reference
类型,那么它是如何定位到堆中的实例呢?
主流的虚拟机实现有两种方式:
- 句柄
- 在堆中划分一块内存作为句柄池
reference
类型存储的是对象的句柄地址- 句柄中包含访问对象实例数据及类型数据的指针
- 指针
- 堆中的实例数据包含访问对象类型数据的指针
reference
类型存储的是对象地址
两种方式的优势:
- 句柄的最大好处是,对象被移动后,只改变句柄中的实例数据指针
- 指针的最大好处是,速度更快,节省了一次指针定位的时间开销
3.2 OutOfMemoryError
除了程序计数器,其他几个运行时数据区都有可能抛出内存溢出(OOM)异常,了解 JVM 就可以很快定位异常的具体位置。
3.2.1 堆溢出(Java heap space)
通常是对象创建过多,由于 GC Roots 到这些对象之间有可达路径,垃圾回收器不会回收这些对象,并且无法向堆申请更多的扩展空间,导致堆内存溢出。
解决办法:通过对堆转储快照(*.dump)进行分析,确认内存中导致溢出的对象是否有必要,相当于分辨到底是内存泄漏还是内存溢出。
- 内存泄漏:通过工具查看泄漏对象到 GC Roots 的引用链,找到那个“多管闲事”的罪魁祸首,定位并“剁掉那只手”
- 内存溢出:说明内存中的对象都有用,那么就看能不能加大堆空间,然后还可以处理溢出对象的生命周期,让大家“排队”而不是“拥挤”
3.2.2 栈溢出
对于栈来说,深度不够和内存不足都是导致抛出堆栈溢出异常的原因,它们俩从根本上是同一个问题,即内存不足导致栈深度不够;栈深度不够是因为内存不足。在单线程中,基本上会抛出 StackOverflowError
,而在多线程中,抛出的 OutOfMemoryError
却是由于创建线程时无法申请到足够多的内存。
解决办法:内存不足就去加内存,这是一般人员的做法,有时候要换个思路,每个线程的内存分配过大,可用的线程数就相应变少,无法向系统申请到足够的内存用以创建线程,自然而然就会抛出 OOM 异常,所以就应该减少最大堆设置和栈容量,从而提高可用内存资源给线程使用。
3.3 运行时常量池溢出
String.intern()
方法会创建一个新的常量放入常量池,常量池在方法区中,在 JDK 1.7 版本里,JVM 的方法区设置在 Java 内存模型中,所以如果可用内存不足,则会导致方法区内存溢出,异常提示为:PermGen space
。
3.4 方法区溢出
方法区内存储的是类的信息,当加载足够多的类信息,并且可用方法区空间不足,则会抛出方法区溢出异常。
类如果要被垃圾收集器回收,生效的条件非常苛刻,除了 GCLib 字节码增强器会动态生成大量 Class 外,还有 JSP 文件会在第一次运行时被编译成 Java 类,以及基于 OSGi 的应用被不同的加载器加载会视为不同的类。
3.5 直接内存溢出
直接内存默认与 -Xmx
的值保持一致,可以通过 -XX:MaxDirectMemorySize
指定。
总结
了解运行时数据区,就相当于你清楚钱花在哪些地方,一旦手头紧,便可以“节省”一点。
下一篇文章,我们来了解一下垃圾收集模块。