JVM 学习笔记
前言
JVM是Java程序的运行环境,Java代码编译后得到.class文件(Java的二进制字节码文件),然后把.class文件给JVM,就能运行了。
跨平台
不同的操作系统上,有不同版本的JVM,这些JVM都能运行.class文件,并将其转化为对应操作系统的指令。不过docker出现后,这种跨平台的优势就被大大削弱了。
JVM JDK JRE
- JVM:Java Virtual Machine,运行.class文件的虚拟机。
- JRE:Java Runtime Environment,在JVM的基础上,再加上一些可以被
.class文件调用的基本类库。 - JDK:Java Development Kit,在JRE的基础上,再加上编译、调试、打包等一系列工具。
JVM如何运行程序
JVM大致分为以下几个部分:类加载系统、运行时数据区、执行引擎、本地方法接口、本地方法库。
JVM的类加载系统加载.class文件中各种各样的类到运行时数据区(一块放代码运行产生的数据的内存区域)的运行时方法区(存放已加载的类的元数据信息,包括类名、父类、接口、字段、方法、访问修饰等)。代码执行时new出来的对象会存放到运行时数据区的堆区,运行时调用的方法会被放到运行时数据区的栈区(虚拟机栈),运行时数据区的程序计数器用来记录代码执行到哪里了,运行时数据区的本地方法栈用于存放调用的C/C++写的方法(本地方法)。
.class文件只是JVM的一套指令集规范,不能直接交给底层操作系统执行,需要由执行引擎将这些字节码解释为底层系统的指令,然后再交给CPU执行。执行过程中调用本地方法,就需要和本地方法接口与本地方法库做交互。
内存结构
Java只支持直接使用基本数据类型和对象类型,内存分配并不是由程序员来处理,而是JVM控制,这样查一下就节省了很多内存上的工作。虽然这带来了很大的便利,但是一旦出现内存问题,无法像C/C++那样对所管理的内存进行合理地处理。只有了解了JVM的内存管理机制,程序员才能够在出现内存相关问题时找到解决方案。
JVM对内存的管理采用的是分区治理,不同的内存区域有着各自的职责所在,在虚拟机运行时,内存区域(运行时数据区)可以划分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。其中,方法区和堆是所有线程共享的,随着虚拟机的创建而创建,虚拟机的结束而销毁。而虚拟机栈、本地方法栈和程序计数器则是线程之间相互隔离的,每个线程都有一个自己的区域,线程启动时自动创建,结束后自动销毁。
程序计数器
程序计数器和CPU中PC寄存器的工作差不多,毕竟JVM虚拟机目的就是实现物理机那样的程序执行。在CPU中,PC作为程序计数器,负责储存内存地址,该地址指向下一条即将执行的指令,每执行完一条指令,PC寄存器的值就会自动被更新为下一条指令的地址。
而JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号可以简单理解为指令对应的位置。字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令。一个CPU在任一时间点只会处理一个JVM线程,当某个线程的时间片消耗完成后,会自动切换到下一个线程继续执行,而当前线程的执行位置会被保存到当前线程的程序计数器中,当下次轮转到此线程时,又继续根据之前的执行位置继续向下执行。因为程序计数器只需要记录很少的信息,所以只占用很少一部分内存。
虚拟机栈
虚拟机栈是一个非常关键的部分,它是一个栈结构,在每个方法被执行的时候,JVM都会同步创建一个栈帧(栈里面的元素),栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。方法执行完后就会出栈。先执行栈顶的方法。
其中局部变量表就是方法中的局部变量,实际上局部变量表在class文件中就已经定义好了。操作数栈就是字节码执行时使用到操作数对应的栈结构。每个栈帧还保存了一个指向当前方法所在类的运行时常量池,在当前方法中需要调用其他方法的时候,可以从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,再调用对应方法,这就是动态链接(可以简单理解为)。最后是方法出口,也就是方法该如何结束,是抛出异常还是正常返回。
通过下面的例子,对各个信息再进行讲解,首先方法出口为return。
1 | |
1 | |
可以看到,局部变量表LocalVariableTable中有locals=2个变量,args和res,类型分别为[Ljava/lang/String(String数组)和I(int),还能看出来偏移量从0到12,槽位0是args,从4到12,槽位1是res。
操作数栈存放要操作的变量,深度为stack=2,操作数栈的变化如下:
1 | |
动态链接也会有体现:
1 | |
虚拟机栈不需要JVM的GC回收。
本地方法栈
而本地方法栈与虚拟机栈作用差不多,但是它是为本地方法准备的。
堆
堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间,而此区域的职责就是存放和管理对象和数组,垃圾回收机制也是主要作用于这一部分内存区域。注意,基本类型是放在虚拟机栈的栈帧的操作数栈里,而不是堆里的,原因如下:
- 基本类型比较小,放到堆里会增加GC压力
- 基本类型不太容易被多个线程共享,就算要共享,复制一份的开销也很小;如果对象放在栈里,要共享就得深拷贝一份,开销比较大,并且还涉及到对象的生命周期等问题,所以对象更适合放在堆里,通过引用来共享。
不过,也并非所有对象都在堆里,比如在逃逸分析(分析对象是不是只在当前方法内部使用,不作为返回值或者参数传递给其他方法或外部对象)发现这个对象不会给到外部(完全没有逃逸),JVM就会把对象拆散,根据对象内部的属性拆成基本类型,再在栈上进行分配。
方法区
方法区也是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池。
方法区是一个规范,不同JVM对方法区的实现不同。JDK1.8之前,方法区的实现是永久代,在JVM内存里面,是运行时数据区的一部分;JDK1.8之后,方法区的实现就变成了元空间,放到本地内存(操作系统的内存,由操作系统分配、管理)里面。这么做是因为永久代比较小,加载的类多了就容易OOM,而元空间依赖于操作系统的内存,加载多少类的元数据信息由系统的实际可用的空间来控制,能加载的类就更多了,不容易OOM。
类信息表中存放的是当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息,同时会将编译时生成的常量池数据全部存放到运行时常量池中。常量也并不是只能从类信息中获取,在程序运行时,也有可能会有新的常量进入到常量池。
1 | |
实际上两次调用String类的intern()方法,和上面的效果差不多,也是第一次调用会将堆中字符串复制并放入常量池中,第二次通过此方法获取字符串时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址。
1 | |
在JDK1.7之后,稍微有一些区别,在调用intern()方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用:

1 | |
在JDK1.7之后,字符串常量池从方法区移动到了堆中。
内存溢出和栈溢出
在Java程序运行时,内存容量不可能是无限制的。当对象创建过多或是数组容量过大时,就会导致堆内存不足以存放更多新的对象或是数组,这时就会出现错误,得到OutOfMemoryError(内存溢出错误)。可以通过参数来控制堆内存的最大值和最小值:-Xms最小值 -Xmx最大值。不过堆内存不要设置太小,不然连虚拟机都不足以启动。
当栈的深度已经不足以继续插入栈帧时,就会得到StackOverflowError,即栈溢出错误。栈溢出和堆溢出比较类似,也是由于容纳不下才导致的,可以使用-Xss来设定栈容量。
堆外内存(直接内存)
除了堆内存可以存放对象数据以外,也可以申请堆外内存(直接内存),即JVM申请的一块额外的内存空间,不受JVM管控,但这些内存依然属于是JVM的。这部分区域的内存需要程序员自行去申请和释放,本质就是JVM通过C/C++调用malloc函数申请的内存。由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度。不过虽然不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出OutOfMemoryError异常。
堆外内存操作类是Unsafe(不安全)。只有完全了解底层原理并且能够合理控制堆外内存,才能安全地使用堆外内存。注意,这个类不可以new,也没有直接获取方式(构造函数为private,压根就没想让使用者用)。
不过,可以通过反射拿到。成功拿到Unsafe类之后,就可以开始申请堆外内存了。比如现在想要申请一个int大小的内存空间,并在此空间存放一个int类型的数据(allocateMemory和freeMemory是native方法):
1 | |
垃圾回收
JVM提供了一套全自动的内存管理机制,当一个Java对象不再用到时,JVM会自动将其进行回收并释放内存,那么对象所占内存在什么时候被回收,如何判定对象可以被回收,以及如何去进行回收工作也是JVM需要关注的问题。
对象存活判定算法
引用计数法
如果要经常操作一个对象,那么首先一定会创建一个引用变量。可以通过它的引用变量,从而判断一个对象是否还要被使用:
- 每个对象都包含一个引用计数器,用于存放引用计数(被引用的次数)
- 每当有一个地方引用此对象时,引用计数 + 1
- 当引用失效( 比如离开了局部变量的作用域或是引用被设定为null)时,引用计数 - 1
- 当引用计数为 0 时,表示此对象不可能再被使用,因为这时已经没有任何方法可以得到此对象的引用了
但是这样存在一个问题,如果两个对象相互引用呢?按照引用计数算法,当出现以上情况时,虽然可能无法再得到对象的引用且此对象也无需再使用,但是由于这两个对象直接存在相互引用的情况,引用计数器的值将会永远是1,即使实际上此对象已经没有任何用途了。所以引用计数法并不是最好的解决方案。
可达性分析算法
目前比较主流的编程语言(包括Java),一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。
首先每个对象的引用都有机会成为树的根节点(GC Roots),可以被选定作为根节点条件如下:
- 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(方法中的局部变量)或本地方法栈中JNI引用的对象。
- 类的静态成员变量引用的对象。
- 方法区中,常量池里面引用的对象。
- 被添加了锁的对象。
- 虚拟机内部需要用到的对象。
一旦已经存在的根节点不满足作为根节点存在的条件时,那么根节点与对象之间的连接将被断开。如果对象没有任何根节点引用(或间接引用),此对象即可被判定为不再使用。这样就能很好地解决循环引用问题。当两个相互引用的对象不再被使用,就不会有任何根节点连接这两个对象,两个对象就可以被判定为不再被使用。
总结来说,如果某个对象无法到达任何GC Roots,则证明此对象是不可能再被使用的。
使用三色标记法来进行可达性分析。白色是没有被访问到的对象,若可达性分析后某对象还是白的,则是垃圾;灰色是已经访问到,但对象内部的引用的其他对象还没有被全部扫描到,还需要继续扫描;黑色则是这个对象和内部引用的其他对象全部被访问过了。三色标记还有两个问题:
- 浮动垃圾:在并发标记(标记和用户代码同时执行)的场景下,对象的实际状态会发生变化。比如前一秒标记了黑色,后一秒对象就不用了。这个问题对系统的影响不大,下次GC清理即可。
- 漏标问题:黑色对象去引用了一个白色对象,但不会再对黑色对象的内部引用遍历,引用的白色对象就会被清楚,对系统影响较大。
最终判定
在经历了可达性分析算法之后,基本可以判定哪些对象能够被回收,但是并不代表此对象一定会被回收,可以在最终判定阶段对其进行挽留。Object类中有finalize()方法,此方法正是最终判定方法,如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()方法,而在此方法中,当前对象是完全有可能重新建立GC Roots的。如果在二次确认后对象不满足可回收的条件,那么此对象不会被回收。
注意,finalize()方法并不是在主线程调用的,而是虚拟机自动建立的一个低优先级的Finalizer线程进行处理。不过,同一个对象的finalize()方法只会有一次调用机会。当然,finalize()方法也并不是专门防止对象被回收的,可以使用它来释放一些程序使用中的资源等,并且Java 9后,这个方法被去除了。最后,总结成一张图:

除了堆中的对象以外,方法区中的数据也是可以被垃圾回收的,但是回收条件比较严格,这里就暂时不谈了。
垃圾回收算法
垃圾收集器会不定期地检查堆中的对象,查看它们是否满足被回收的条件,然后进行回收。
分代收集机制
如果对堆中的每一个对象都依次判断是否需要回收,这样的效率其实是很低的。可以对堆中的对象进行分代管理,比如某些对象,在多次垃圾回收时,都未被判定为可回收对象,就将这一部分对象放在一起,并让垃圾收集器减少回收此区域对象的频率,这样就能很好地提高垃圾回收的效率了。
JVM将堆内存划分为新生代、老年代和永久代(其中永久代是HotSpot虚拟机特有的概念,在JDK8之前方法区实际上就是采用的永久代作为实现,而在JDK8之后,方法区由元空间实现,并且使用的是本地内存,容量大小取决于物理机实际大小)。不同的分代内存回收机制也存在一些不同之处,在HotSpot虚拟机中,新生代被划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为 8:1:1。老年代的GC频率相对较低,永久代一般存放类信息等。
首先,所有新创建的对象,在一开始都会进入到新生代的Eden区(如果是大对象会被直接丢进老年代),在进行新生代区域的垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用对象。
接着,在一次垃圾回收之后,Eden区域没有被回收的对象,会进入到Survivor区。在一开始From和To都是空的,而GC之后,所有Eden区域存活的对象都会直接被放入到From区,最后From和To会发生一次交换,也就是说目前存放对象的From区,变为To区,而To区变为From区。
再接着,就是下一次垃圾回收了,操作与上面是一样的,不过这时由于To区域中已经存在对象了,所以,在Eden区的存活对象复制到From区之后,所有To区域中的对象会进行年龄判定(每经历一轮GC年龄 +1,如果对象的年龄大于默认值15,那么会直接进入到老年代,否则移动到From区),最后再次交换To区和From区,之后不断重复以上步骤。
垃圾收集也分为:
- 次要垃圾回收Minor GC(Young GC),主要进行新生代区域的垃圾收集,触发条件为新生代的Eden区容量已满。
- 主要垃圾回收Major GC,主要进行老年代的垃圾收集。
- 混合垃圾回收Mixed GC,G1收集器引入的概念,会同时回收年轻代和部分老年代(根据G1的预测和设定,选择一部分老年代Region进行回收)。
- 完全垃圾回收Full GC,对整个Java堆内存和方法区进行垃圾回收,触发条件有四个,满足其一即可:
- 每次晋升到老年代的对象平均大小大于老年代剩余空间(预计进入老年代的对象比老年代剩余空间多)
- Minor GC后存活的新生代对象超过了老年代剩余空间(新生代存活的对象比老年代剩余空间多)
- 永久代内存不足(JDK8之前)
- 手动调用
System.gc()方法
可以添加启动参数来查看JVM的GC日志-XX:+PrintGCDetails。
空间分配担保
存在这样一种极端情况(正常情况下新生代的回收率是很高的,所以不用太担心会经常出现这种问题),在一次GC后,新生代Eden区仍然存在大量的对象,已经超出Survivor区的容量了。
这时就需要用到空间分配担保机制了,可以把Survivor区无法容纳的对象直接晋升到老年代,让老年代进行分配担保(当然老年代也得装得下才行),这样新生代就腾出了空间来容纳更多的对象。要是老年代也装不下新生代的数据,则会先来一次Full GC,进行大规模垃圾回收,来尝试腾出空间,再次判断老年代是否有空间存放,要是还是装不下,直接抛出OOM错误。
标记-清除算法
对于具体的回收过程,首先是最古老的标记-清除算法。先标记出所有需要回收的对象,然后再依次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。
虽然此方法非常简单,但是缺点也是非常明显。首先如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。并且一次标记清除之后,连续的内存空间可能会出现许许多多的空隙,碎片化会导致连续内存空间利用率降低。
标记-复制算法
标记-清除算法在面对大量对象时效率低,所以有了标记-复制算法。将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。虽然浪费了一些时间进行复制操作,但是这样能够很好地解决对象大面积回收后空间碎片化严重的问题。
这种算法非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收,而之前所说的新生代Survivor区其实就是这个思路,包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的。但在老年代,回收率比较低,而这种算法会在GC后完整复制整个区域内容,且会折损50%空间,因此并不适合老年代。
标记-整理算法
因为标记-复制算法不适合于老年代,因此又有了标记-整理算法。在标记所有待回收对象之后,先将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。
虽然这样能保证内存空间充分使用,并且也没有标记-复制算法那么繁杂,但是缺点也是显而易见的,它的效率比前两者都低。甚至由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿(被称为“Stop The World”)。可以将标记-清除算法和标记-整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记-清除算法其实是没有多大问题的,当内存空间凌乱到一定程度后,可以进行一次标记-整理算法。
垃圾回收器实现
可以自由地为新生代和老年代选择更适合它们的回收器。
Serial收集器
Serial收集器在JDK1.3.1之前是虚拟机新生代区域收集器的唯一选择。这是一款单线程的垃圾收集器,也就是说,当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。
缺点很明显,单线程垃圾回收造成的程序卡顿严重。但优势也很明显,设计上简单高效。而且在用户的桌面应用场景中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用串行回收器是可以接受的。
所以,在客户端模式(一般用于一些桌面级图形化界面应用程序)下的新生代中,默认垃圾收集器至今依然是Serial收集器。可以在jvm.cfg文件中切换JRE为Server VM或是Client VM,默认路径为:JDK安装目录/jre/lib/jvm.cfg。
ParNew收集器
ParNew收集器相当于是Serial收集器的多线程版本,它能够支持多线程垃圾收集。
除了多线程支持以外,其他内容基本与Serial收集器一致,并且目前某些JVM默认的服务端模式新生代收集器就是使用的ParNew收集器。
Parallel Scavenge/Parallel Old收集器
Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现。
与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。
CMS(Concurrent-Mark-Sweep)收集器
在JDK1.5,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发(注意这里的并发和之前的并行是有区别的,并发可以理解为同时运行用户线程和GC线程,而并行可以理解为多条GC线程同时工作)收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,主要采用标记清除算法。
CMS收集器的垃圾回收分为4个阶段:
- 初始标记:需要暂停用户线程,主要任务仅仅只是标记出GC Roots能直接关联到的对象,速度比较快,不用担心会停顿太长时间。
- 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 重新标记:需要暂停用户线程,由于并发标记阶段可能某些用户线程会导致标记产生变化,因此需要再次暂停所有线程进行并行标记,这个时间会比初始标记时间长一丢丢。
- 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。
虽然它的优点非常大,但是缺点也是显而易见的。标记清除算法会产生大量的内存碎片,导致可用连续空间逐渐变少,长期这样下来,会有更高的概率触发Full GC,并且在与用户线程并发执行的情况下,也会占用一部分的系统资源,导致用户线程的运行速度一定程度上减慢。自从G1收集器问世之后,CMS收集器不再推荐使用了。
Garbage First (G1) 收集器
G1收集器也是一款划时代的垃圾收集器,在JDK7的时候被正式推出。它是一款主要面向于服务端的垃圾收集器,并且在JDK9时,取代了JDK8默认的 Parallel Scavenge + Parallel Old 的回收方案。
垃圾回收分为Minor GC、Major GC 和Full GC,它们分别对应的是新生代,老年代和整个堆内存的垃圾回收,而G1收集器绕过了这些约定,它将整个Java堆划分成2048个大小相同的独立Region块,每个Region块的大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的Region大小相同,且在JVM的整个生命周期内不会发生改变。
每一个Region都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。此外,G1收集器还存在一个Humongous区域,它专门用于存放大对象(一般认为大小超过了Region容量一半的对象为大对象)这样,新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的。它的回收过程与CMS大体类似,分为以下四个步骤:
- 初始标记:需要暂停用户线程,仅仅只是标记一下GC Roots能直接关联到的对象。这个阶段需要停顿线程,但耗时很短。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记:需要暂停用户线程,对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
- 筛选回收:需要暂停用户线程,负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,由多个收集器线程并行完成的。
CMS和G1选择问题
- CMS适合中小型的堆,因为CMS一次性要回收所有垃圾。堆比较大,回收就比较慢。
- G1适合比较大的堆,因为G1是选择性回收Region,不会回收整个堆的垃圾。而且G1还需要记录新生代和老年代的跨代引用问题,内存占用较大。记录跨代引用关系的目的是,当某一个新生代对象只被老年代引用时,记录下来后,下次young gc不需要扫描老年代region就可以知道了。
元空间(Metaspace)
JDK8之前,Hotspot虚拟机的方法区实际上是永久代实现的。在JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了全新的元空间。类的元信息被存储在元空间中。
元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。
因此在JDK8时直接将本地内存作为元空间的区域,物理内存有多大,元空间内存就可以有多大,这样永久代的空间分配问题就解决了。
其他引用类型
除了强引用之外,Java也为我们提供了三种额外的引用类型。
软引用(Soft Reference)
软引用不像强引用那样不可回收,当 JVM 认为内存不足时,会去试图回收软引用指向的对象,即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。当然,如果内存充足,那么是不会轻易被回收的。
1 | |
软引用还存在一个带队列的构造方法,软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,JVM就会把这个软引用加入到与之关联的引用队列中。
弱引用(Weak Reference)
弱引用比软引用的生命周期还要短,在进行垃圾回收时,不管当前内存空间是否充足,都会回收它的内存。
1 | |
同样的,它也支持ReferenceQueue,和软引用用法一致。WeakHashMap是一种类似于弱引用的HashMap类,如果Map中的Key没有其他引用那么此Map会自动丢弃此键值对,适合拿去做缓存的。
虚引用(Phantom Reference,幽灵引用)
虚引用相当于没有引用,随时都有可能会被回收。
1 | |
无论调用多少次get方法得到的永远都是null,因为虚引用本身就不算是个引用,相当于这个对象不存在任何引用,并且只能使用带队列的构造方法,以便对象被回收时接到通知。虚引用的用途:
- 对象被回收时收到通知,进行一些后置处理,比如资源清理。
- 比finalize()方法更灵活、更安全,因为finalize()方法有性能问题且不确定性大。
类加载机制
类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
类加载过程
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
加载
类加载过程的第一步,主要完成下面 3 件事情:
通过全类名获取定义此类的二进制字节流。
将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。
虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:”通过全类名获取定义此类的二进制字节流” 并没有指明具体从哪里获取( ZIP、 JAR、EAR、WAR、网络、动态代理技术运行时动态生成、其他文件生成比如 JSP…)、怎样获取。
加载这一步主要是通过类加载器完成的。类加载器ClassLoader有很多种,当想要加载一个类的时候,具体是哪个类加载器加载由双亲委派模型决定(不过,也能打破双亲委派模型)。 每个Java类都有一个引用指向加载它的ClassLoader。不过,数组类不是通过 ClassLoader创建的,而是 JVM 在需要的时候自动创建的.数组类通过getClassLoader()方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的。
一个非数组类的加载阶段(获取类的二进制字节流)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
验证
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。 验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。
不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。但是需要注意的是,-Xverify:none 和 -noverify 在 JDK 13 中被标记为 deprecated ,在未来版本的 JDK 中可能会被移除。
验证阶段主要由四个检验阶段组成: 文件格式验证(Class 文件格式检查)、元数据验证(字节码语义检查)、字节码验证(程序语义检查)、符号引用验证(类的正确性检查)。
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候。 符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:
java.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。- ……
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 这时候进行内存分配的仅包括类变量(Class Variables,即静态变量,被static关键字修饰的变量,只与类相关),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
- 从概念上讲,类变量所使用的内存都应当在方法区中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
- 这里所设置的初始值”通常情况”下是数据类型默认的零值(如 0、0L、null、false 等),比如
public static int value=111,那value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况是,给 value 变量加上了 final 关键字public static final int value=111,那么准备阶段 value 的值就被赋值为 111。
基本数据类型的零值:

解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。符号引用和直接引用的解释如下:

在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
初始化
初始化阶段是执行初始化方法 <clinit> ()方法(编译之后自动生成的)的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
遇到
new、getstatic、putstatic或者invokestatic这 4 条字节码指令时:new: 创建一个类的实例对象。getstatic、putstatic: 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)。invokestatic: 调用类的静态方法。
使用
java.lang.reflect包的方法对类进行反射调用时如Class.forName("..."),newInstance()等等。如果类没初始化,需要触发其初始化。初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main方法的那个类),虚拟机会先初始化这个类。MethodHandle和VarHandle可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,就必须先使用findStaticVarHandle来初始化要调用的类。当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
类卸载
卸载类即该类的 Class 对象被 GC,卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用。
- 该类的类加载器的实例已被 GC。
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是程序员自定义的类加载器加载的类是可能被卸载的。因为,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用自定义加载器加载的类是可以被卸载掉的。
类加载器
类加载器从 JDK 1.0 就出现了,最初只是为了满足 Java Applet(已经被淘汰) 的需要。后来,慢慢成为 Java 程序中的一个重要组成部分,赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的
ClassLoader。 - 数组类不是通过
ClassLoader创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
简单来说,类加载器的主要作用就是动态加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。这里只讨论其核心功能:加载类。
类加载规则
- JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
- 对于已经加载的类会被放在
ClassLoader中。 - 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
类加载器总结
JVM 中内置了三个重要的 ClassLoader:
- BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(
%JAVA_HOME%/lib目录下的rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被-Xbootclasspath参数指定的路径下的所有类。 - ExtensionClassLoader(扩展类加载器):主要负责加载
%JRE_HOME%/lib/ext目录下的 jar 包和类以及被java.ext.dirs系统变量所指定的路径下的所有类。 - AppClassLoader(应用程序类加载器):面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
rt.jar:rt 代表RunTime,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,常用内置库java.xxx.*都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*。Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说
java.base是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。
除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。
每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类加载器的父类加载器是 BootstrapClassLoader (因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null)。
- 我们编写的类的
ClassLoader是AppClassLoader。 AppClassLoader的父ClassLoader是ExtClassLoader。ExtClassLoader的父ClassLoader是Bootstrap ClassLoader。
自定义类加载器
除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果要自定义自己的类加载器,也要继承 ClassLoader抽象类。ClassLoader 类有两个关键的方法:
protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name为类的二进制名称,resolve如果为 true,在加载时调用resolveClass(Class<?> c)方法解析该类。protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。
如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。官方 API 文档 建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。
双亲委派模型
ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。
虚拟机中被称为 “bootstrap class loader”的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。
总结来说:
ClassLoader类使用委托模型来搜索类和资源。- 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。
注意 ,双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的。这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个 MotherClassLoader 和一个FatherClassLoader。
另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承。
执行流程
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。每个父类加载器都会走一遍这个流程。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()方法来加载类)。 - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException异常。
双亲委派的好处
双亲委派模型实现了两个关键的安全目标:避免类的重复加载和防止核心 API 被篡改。
JVM 区分不同类的依据是类名加上加载该类的类加载器,即使类名相同,如果由不同的类加载器加载,也会被视为不同的类。 双亲委派模型确保核心类总是由 BootstrapClassLoader 加载,保证了核心类的唯一性。即使攻击者绕过了双亲委派模型,Java 仍然具备更底层的安全机制来保护核心类库。ClassLoader 的 preDefineClass 方法会在定义类之前进行类名校验。任何以 "java." 开头的类名都会触发 SecurityException,阻止恶意代码定义或加载伪造的核心类。
打破双亲委派的方法
自定义加载器需要继承 ClassLoader 。如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。这是因为类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。
重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制,每个应用都会有一个独立的 WebAppClassLoader 实例。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。