深入理解JVM
0. Intro
Java程序运行在JVM之上,JVM标准结构由JVM规范来定义,如下图:
各JVM生产厂家并不严格遵照这个规范来设计实际使用的JVM。
JVM负责装载.class
文件并运行。
把各语言(Java,Scala,Groovy)的源码编译成.class
文件是由JDK中的源码编译器完成的。
.class
文件的加载通常由类加载器(ClassLoader)子系统来完成。
.class
文件的执行有解释执行和编译执行两种方法。
Java程序无需显示分配和回收内存,由JVM负责内存的分配与回收。
JVM原生支持多线程。
1. .class
文件组成
.class
文件是完整的自描述文件。
其中,字节码只占很小一部分,还包括许多辅助jvm来执行class的附加信息,主要如下:
- 结构信息:
.class
文件格式版本号及各部分的数量和大小的信息。 - 元数据: 对应Java源码中的“声明”与“常量”的信息,主要有:类、继承的超类、实现的接口、域、方法、常量池。
- 方法信息: Java源码中的语句和表达式,主要有:字节码、异常处理器表、求值栈、局部变量区大小、求值栈的类型记录、调试用符号信息。
2. 类加载过程
类加载机制是指“.class
文件被加载到JVM,并形成Class对象的机制”。
加载到JVM后,程序应用就可以对Class对象进行实例化并调用。
类加载机制可以在运行时动态加载外部的类、从网络上下载的.class
文件等。
加载过程主要分为装载、连接、初始化三个部分:
-
装载
JVM通过类的全限定名(例:com.tsj.HelloWorld)找到要加载类的.class
文件(二进制字节码)
再通过类加载器(ClassLoader实例)完成类的加载
加载后的类通过上述两个元素(全限定名+类加载器实例ID)来进行标识
对于接口或非数组的类,其名称即为类名,这种类由ClassLoader负责加载
对于数组型的类,其名称为"["+"基本类型/L引用类型类名;"
例byte[] bytes = new byte[512]
中bytes的类型名称为[B;
,
Object[] objects=new Object[10]
中objects的类型名称为[Ljava.lang.Object;
数组类则由JVM直接创建 -
连接
连接过程主要有三部分,分别是- 对二进制字节码的格式进行校验
二进制字节码格式遵循Java Class File Format(JVM规范中定义)
格式不符则抛出VerifyError
校验过程中碰到其他类和接口,也会进行加载,加载失败则抛出NoClassDefFoundError - 初始化类中的静态变量
完成校验后,JVM初始化类中的静态变量,并将其赋为默认值 - 解析类中调用的接口和类
对调用的接口和类中的属性、方法进行验证,确保其调用的属性、方法存在,以及具备相应的权限
- 对二进制字节码的格式进行校验
-
初始化
初始化即执行类中的静态初始化代码、构造器代码及静态属性的初始化。
在执行初始化过程前,必须完成连接过程中的校验和准备阶段,解析阶段不强制。
在以下四种情况下,初始化过程会被触发执行:- 调用了new
- 反射调用类中的方法
- 子类调用了初始化
- JVM启动过程中指定的初始化类
3. 类加载器
JVM的类加载过程通过ClassLoader及其子类来完成,主要有四种:
- Bootstrap ClassLoader 根类加载器
- Extension ClassLoader 扩展类加载器
- System ClassLoader 系统(应用)类加载器
- User-defined ClassLoader 用户自定义类加载器
根类加载器使用C++实现,并非ClassLoader的子类,在JVM启动时会初始化这个ClassLoader。
根类加载器完成$JAVA_HOME$
中jre/lib/rt.jar
里面所有class文件的加载。
扩展类加载器(类名:ExtClassLoader)加载包含扩展功能的jar包中的class文件。
系统类加载器(类名:AppClassLoader)加载ClassPath
及其子目录下的class文件。
用户自定义类加载器继承自ClassLoader抽象类,可以用于加载非ClassPath
中的jar及目录,还能在加载之前对class文件做一些动作,如解密等。
4. 类加载机制——双亲委派机制
JVM的四种类加载器中,除根类加载器外,其他的ClassLoader都会有parent ClassLoader(双亲类加载器)。注意:这里的parent不是表示继承关系,而是表示一种加载关系。用户自定义类加载器的默认parent ClassLoader是系统(应用)类加载器。 根据parent关系形成如3.
中图所示的树形加载关系。
加载类时通常按照树形结构的原则来进行:
加载一个类(HelloWorld.class)时,首先会在系统类加载器中检查是否加载过,如果有那就无需再加载了。如果没有,那么会拿到parent加载器,然后调用parent加载器的loadClass方法。parent加载器类中同理也会先检查自己是否已经加载过,如果没有再往上。一直递归到Bootstrap classLoader之前,都是在检查是否加载过,并不会选择自己去加载。直到BootstrapClassLoader,已经没有parent加载器了,这时候开始考虑自己是否能加载了,如果自己无法加载,会返回到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException。
5. 类执行机制
.class
文件信息加载到JVM并产生Class对象,就可执行Class对象的静态方法或实例化对象进行调用了。.class
文件的执行可以是解释执行或者编译执行。
- 字节码解释执行
基于程序计数器和栈 - 编译执行
编译在运行时进行——JIT: 把执行过程中执行频率较高的代码进行编译,对执行不频繁的代码继续采用解释执行,因此被称为Hotspot VM - 反射执行
动态生成字节码,并加载到JVM中执行
性能比直接编译成字节码较差
6. JVM内存管理
JVM自动管理内存的分配与回收,虽然降低了编写程序的难度,但自动垃圾回收不会把内存回收得彻底,浪费很多内存以及CPU时间,有造成内存泄露的可能,因此需要掌握JVM内存回收的情况。
-
JVM运行时数据区(内存)
a. 方法区(永久代 ==> 元空间)
- 方法区存放要加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息。
- 当开发人员在程序中通过Class对象的getName()、isInterface()等方法来获取类的信息时,这些数据都来自方法区。
- 方法区是全局共享,一定条件下也会被GC
- 当方法区域使用内存超过允许大小时会跑出OutOfMemory的错误信息
b. 堆
- 存储对象实例及数组值,所有通过new创建的对象都在此分配
- 大小通过-Xms(最小堆内存)和-Xmx(最大堆内存)来控制
- 新生代:
Eden Space
,Survivor Space 0
,Survivor Space 1
;存放新建的对象 - 老年代: 存放新生代中经过多次回收仍然存活的对象,新建对象也有可能在老年代直接分配内存:1)大对象;2)大的数组对象
c. 本地方法栈
- 用于支持native方法的执行,和JVM方法栈是同一个
d. PC寄存器和JVM方法栈(虚拟机栈)
- 线程私有:每个线程均会创建PC寄存器和JVM方法栈
- JVM方法栈空间不足时会抛出StackOverflowError错误,可用-Xss指定大小
-
内存分配
- 堆上分配:由于堆是线程共享的,故堆上分配内存需要加锁(乐观锁CAS)
- TLAB:JVM为每个新创建的线程在Eden Space上分配一块独立空间
TLAB
(Thread Local Allocation Buffer)。在TLAB上分配内存不需加锁,若TLAB上空间用完,则转为堆上分配。
-
内存回收(GC, garbage collection)
JVM通过GC来回收堆上和方法区的内存,回收程序中不需要被使用的对象所占用的内存。
a. 判断对象可回收- 引用计数法:存在循环引用问题
- 根搜索算法:采用集中式的管理方式,全局记录数据的引用状态。基于一定条件的触发(定时、空间不足时),执行时需要从根集合来全局扫描对象的引用关系。
b. 可用作GC roots的对象:
- 虚拟机栈中的引用对象。(java栈的栈帧中的本地变量表)
- 方法区中的类静态属性引用的对象。
- 方法区中的常量引用的对象。(声明为final的常量对象)
- 本地方法栈中JNI的引用的对象。(本地方法栈的栈帧中的本地变量表)
c. GC算法
判断对象可回收的搜索算法都是基于根搜索算法。- 标记-清除算法(Mark-Sweep)
- 标记Mark:从GC ROOTS开始,遍历堆内存区域的所有根对象,对在引用链上的对象都进行标记。这样下来,如果是存活的对象就会被做了标记,反之如果是垃圾对象,则没做有标记。GC很容易根据有没有被做标记就完成了垃圾对象回收。
- 清除Sweep:遍历堆中的所有的对象(标记阶段遍历的是所有根节点),找到未被标记的对象,直接回收所占的内存,释放空间。
- 复制算法(Copying)
- 复制算法把内存区间一分为二,有对象存在的一半区间称为“活动区间”,没有对象存在处于空闲状态的空间则为“空闲区间”。
- 当内存空间不足时触发GC,先采用根搜索算法标记对象,然后把活着的对象全部复制到另一半空闲区间上,复制算法的“复制”就来自这一操作。复制到另一半区间的时候,严格按照内存地址依次排列要存放的对象,然后一次性回收垃圾对象。
- 这样原来的空闲区间在GC后就变成活动区间,而且内存顺序齐整美观。原来的活动区间在GC后就变成了完全空的空闲区间,等待下一次GC把活的对象被copy进来。
- 标记-整理算法(Mark-Compact)
- 标记:这个阶段和标记-清除Mark-Sweep算法一样,遍历GC ROOTS并标记存活的对象。
- 整理:移动所有活着的对象到内存区域的一侧(具体在哪一侧则由GC实现),严格按照内存地址次序依次排列活着的对象,然后将最后一个活着的对象地址以后的空间全部回收。
- 分代回收算法
- 程序中大部分对象的存活时间都是较短的,少部分对象是长期存活的。
- 基于此,JVM将堆划分为新生代和老年代,并基于不同代中对象存活时间和对象多少提供了不同的GC实现。
d. 分代回收算法
- 新生代GC: 新生代的对象通常存活时间较短,回收后存活数量很少,因此选择基于复制算法的GC,新生代GC又被称为Minor GC。
划分新生代为Eden,Survivor 0,Survivor 1三块空间。Eden存放新创建的对象。在Minor GC时,Survivor 0, Survivor 1中有一块充当复制的目标空间,另一块则被清空。Survivor 0和Survivor 1又被称为From Space和To Space。刚开始创建的对象是在Eden中,此时Eden中有对象,而Survivor 0,Survivor 1没有对象,都是空闲区间。第一次Minor GC后,存活的对象被放到其中一个Survivor,Eden中的内存空间直接被回收。在下一次GC到来时,Eden和一个Survivor中又创建满了对象,这个时候GC清除的就是Eden和这个放满对象的Survivor组成的大区域(占90%),Minor GC使用复制算法把活的对象复制到另一个空闲的Survivor区间,然后直接回收之前90%的内存。周而复始。始终会有一个10%空闲的survivor区间,作为下一次Minor GC存放对象的准备空间。要完成上面的算法,每次Minor GC过程都要满足:存活的对象大小都不能超过Survivor那10%的新生代空间,不然就没有空间复制剩下的对象了;如果存活的对象超过了10%的空间,则把这些对象放入老年代,同时经过若干次(默认15)GC仍存活的对象可放入老年代中
- 老年代GC:负责老年代中GC操作的是全局GC,Major GC,Full GC。
老年中的对象特点就是存活时间较长,而且没有备用的空闲空间,所以显然不适合使用复制算法了,这个时候使用标记-清除算法或者标记-整理算法来实现GC
- 什么样的对象可以进入老年代?
- 在新生代中,如果一个对象的年龄(GC一次后还存活的对象岁数加1)达到一个阈值(可以配置),就会被移动到老年代。
- Survivor中相同年龄的对象大小总和超过Survivor空间的一半,则不小于这个年龄的对象都会直接进入老年代。
- 创建的对象的大小超过设定阈值,这个对象会被直接存进老年代。
- 新生代中大于Survivor空间的对象,Minor GC时会被移进老年代。
- 触发全局GC/Major GC/Full GC的情况
- 老年代空间不足
只有在新生代对象转入、创建大对象和大数组的情况下才会出现老年代空间不足的现象; 所以应该分配足够大空间给老年代,避免直接创建过大对象或者数组,否则会绕过新生代直接进入老年代,应该使对象尽量在新生代就被回收,或待得时间尽量久,避免过早的把对象移进老年代。 - 方法区(永久代)空间不足
避免创建过多的静态对象。 - 被显示调用System.gc()
- 在Minor GC时,先统计历史上进入老年代的对象平均大小是否大于目前老年代中的剩余空间,如果大于则触发Full GC。
- 老年代空间不足