JVM工作原理
一、JVM简介
JVM是Java Virtual Machine(Java虚拟机)的缩写,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
Java虚拟机主要由字节码指令集、寄存器、栈、垃圾回收堆和存储方法域等构成。JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

二、Java内存区域
2.1 运行时数据区域

其中,堆和方法区是所有线程共有的,而虚拟机栈、本地方法栈和程序计数器则是线程私有的。
2.1.1 程序计数器(Program Counter Register)
内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。
如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器的值则为无用的(Undefined)。所有CPU都有程序计数器,通常来说,程序计数器在每次指令执行后自增,它会维护下一个将要执行的指令的地址。JVM通过程序计数器来追踪指令执行的位置,在方法区中,程序计数器实际上是指向了一个内存地址。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
2.1.2 虚拟机栈(VM Stack)
线程私有,生命周期和线程一致。描述的是
Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。这个栈是一个后进先出的数据结构,每一个方法对应一个栈帧,而每一个方法从执行到返回,就对应着它所属的栈帧从入栈到出栈的过程,所以当前正在执行的方法在栈的顶端,每当一个方法被调用时,一个新的栈帧就会被创建然后放在了栈的顶端。当方法正常返回或者发生了未捕获的异常,栈帧就会从栈里移除。栈是不能被直接操作的,尤其是栈帧对象的入栈和出栈,因此,栈帧对象有可能在堆里分配并且内存不需要连续。
- 局部变量表:存放了编译期可知的各种基本类型(
boolean、byte、char、short、int、float、long、double)、对象引用(reference类型)和returnAddress类型(指向了一条字节码指令的地址)。在局部变量表里,除了long和double,所有类型都是占了一个槽位,它们占了2个连续槽位,因为他们是64位宽度。 - 操作数栈:从名字可以看出,这是一个栈
Stack,它存放的是:方法运行时需要用到的操作数。和局部变量表一样,它的最大深度也是在编译期间就被计算好,并被写入class文件中的。在一个方法刚准备运行前,它是空的,而在运行的过程中,不断的有操作数被入栈和出栈(比如假设方法中需要计算num = 1 + 2,则在这条语句执行时,1和2被入栈,然后被取出,计算出3存入栈中,再将3取出赋值给num),程序的计算都需要依靠这个栈进行。操作数栈可以存放任意类型的数据(基本数据类型和引用类型,引用类型存放的是地址),而double和long类型将占用两个单位,其余类型占用一个单位。
注意:栈的限制
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。
2.1.3 本地方法栈(Native Method Stack)
区别于Java虚拟机栈的是,Java虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。也会有StackOverflowError和OutOfMemoryError异常。
2.1.4 堆(Heap)
对于绝大多数应用来说,这块区域是JVM所管理的内存中最大的一块。线程共享,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从JDK 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在JDK 7版本及JDK 7版本之前,堆内存被通常被分为下面三部分:
- 新生代内存(
Young Generation) - 老生代内存(
Old Generation) - 永生代内存(
Permanent Generation)

JDK 1.8版本之后方法区(HotSpot的永久代)被彻底移除了(JDK 1.7就已经开始了),取而代之是元空间,元空间使用的是直接内存。

上图所示的Eden区、两个Survivor区都属于新生代(为了区分,这两个Survivor区域按照顺序被命名为from和to),中间一层属于老年代。
大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden区->Survivor区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。
堆这里最容易出现的就是OutOfMemoryError错误,并且出现这种错误之后的表现形式还会有几种。具体请参考OutOfMemoryError常见原因及解决方法
2.1.5 方法区(Method Area)
属于共享内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。现在用一张图来介绍每个区域存储的内容。

2.1.5.1 运行时常量池(Runtime Constant Pool)
属于方法区一部分,用于存放编译期生成的各种字面量和符号引用。编译器和运行期(String的intern())都可以将常量放入池中。内存有限,无法申请时抛出OutOfMemoryError。
2.1.6 直接内存
非虚拟机运行时数据区的部分
在JDK 1.4中新加入NIO(New Input/Output)类,引入了一种基于通道(Channel)和缓存(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。可以避免在Java堆和Native堆中来回的数据耗时操作。
OutOfMemoryError:会受到本机内存限制,如果内存区域总和大于物理内存限制从而导致动态扩展时出现该异常。
2.2 HotSpot虚拟机对象探秘
主要介绍数据是如何创建、如何布局以及如何访问的。
2.2.1 对象的创建
- 遇到
new指令时
首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载。
- 分配内存
类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后便可确认)。在堆的空闲内存中划分一块区域('指针碰撞(bump-the-pointer)-内存规整'或'空闲列表-内存交错'的分配方式)。
- 分配内存时线程的安全性
对分配内存的动作进行同步处理(实际上虚拟机采用CAS配上失败重试的机制保证了更新操作的原子性)。把分配内存的动作按照线程划分在不同的空间之中进行(即每个线程在Java堆中预先分配一小块私有的缓冲区(TLAB))。
每个线程在堆中都会有私有的分配缓冲区(
Thread Local Allocation Buffer简称TLAB),这样可以很大程度避免在并发情况下频繁创建对象造成的线程不安全。年轻代晋升到老年代也有类似功能即Promotion Local Allocation Buffers,简称PLAB。指针碰撞和TLAB具体请参考:Java对象内存分配原理
- 填充对象头
内存空间分配完成后会初始化为0(不包括对象头),接下来就是填充对象头。
把对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息存入对象头。
- 执行init方法
执行new指令后执行init方法后才算一份真正可用的对象创建完成。
2.2.2 对象的内存布局
在
HotSpot虚拟机中,分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
具体请参考:Java对象内存布局
2.2.3 对象的访问定位
使用对象时,通过栈上的
reference数据来操作堆上的具体对象。
通过句柄访问
Java堆中会分配一块内存作为句柄池。reference存储的是句柄地址。详情见图。

使用直接指针访问
reference中直接存储对象地址

比较:使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁GC那么句柄方法好,如果是对象频繁访问则直接指针访问好。
三、垃圾回收器与内存分配策略
3.1 概述
程序计数器、虚拟机栈、本地方法栈3个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。
对象已死吗?在进行内存回收之前要做的事情就是判断哪些对象是死的,哪些是活的。
3.2 垃圾对象的判定
3.2.1 引用计数法
给对象添加一个引用计数器。但是难以解决循环引用问题。

从图中可以看出,如果不下小心直接把Obj1-reference和Obj2-reference置null。则在Java堆当中的两块内存依然保持着互相引用无法回收。
3.2.2 可达性分析法
通过一系列的GC Roots的对象作为起始点,从这些节点出发所走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连的时候说明对象不可用。

可作为GC Roots的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 方法区中类静态属性引用的对象,譬如
Java类的引用类型静态变量。 - 方法区中常量引用的对象,譬如字符串常量池(
String Table)里的引用。 - 本地方法栈中
JNI(即一般说的Native方法)引用的对象。 Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。- 所有被同步锁(
synchronized关键字)持有的对象。 - 反映
Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
3.2.3 再谈引用
前面的两种方式判断存活时都与‘引用’有关。但是
JDK 1.2之后,引用概念进行了扩充,下面具体介绍。
下面四种引用强度依次逐渐减弱
强引用
类似于Object obj = new Object();创建的,只要强引用在就不回收。
软引用
SoftReference类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。
弱引用
WeakReference类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
虚引用
PhantomReference类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
3.2.4 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是非死不可的,这时候它们暂时出于“缓刑”阶段,一个对象的真正死亡至少要经历两次标记过程:如果对象在进行中可达性分析后发现没有与GC Roots相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象竟会放置在一个叫做F-Queue的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象简历关联即可。
finalize()方法只会被系统自动调用一次。
3.2.5 回收方法区
在堆中,尤其是在新生代中,一次垃圾回收一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
永久代垃圾回收主要两部分内容:废弃的常量和无用的类。
- 判断废弃常量:一般是判断没有该常量的引用。
- 判断无用的类:要以下三个条件都满足
- 该类所有的实例都已经回收,也就是
Java堆中不存在该类的任何实例 - 加载该类的
ClassLoader已经被回收 - 该类对应的
java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
- 该类所有的实例都已经回收,也就是
3.3 垃圾回收算法
垃圾收集算法主要有以下几种:标记-清除算法(mark-sweep)、复制算法(copying)和标记-整理算法(mark-compact)。
3.3.1 标记-清除算法
算法的执行过程与名字一样,先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法有两个问题:
- 标记和清除过程效率不高。主要由于垃圾收集器需要从
GC Roots根对象中遍历所有可达的对象,并给这些对象加上一个标记,表明此对象在清除的时候被跳过,然后在清除阶段,垃圾收集器会从Java堆中从头到尾进行遍历,如果有对象没有被打上标记,那么这个对象就会被清除。显然遍历的效率是很低的。 - 会产生很多不连续的空间碎片,所以可能会导致程序运行过程中需要分配较大的对象的时候,无法找到足够的内存而不得不提前出发一次垃圾回收。

3.3.2 复制算法
复制算法是为了解决标记-清除算法的效率问题的,其思想如下:将可用内存的容量分为大小相等的两块,每次只使用其中的一块,当这一块内存使用完了,就把存活着的对象复制到另外一块上面,然后再把已使用过的内存空间清理掉。
- 优点:每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
- 缺点:算法的代价是将内存缩小为了原来的一半,未免太高了一点。

现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。
当然,90%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时(例如,存活的对象需要的空间大于剩余一块Survivor的空间),需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
3.3.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
与标记-清除算法过程一样,只不过在标记后不是对未标记的内存区域进行清理,而是让所有的存活对象都向一端移动,然后清理掉边界外的内存。该方法主要用于老年代。

3.3.4 分代回收
堆是java内存模型种最大的一块,为了方便管理对象以及内存回收,jvm将堆内存分为两块区域,即年轻代和老年代。
- 新生代:用来存放生命周期短的对象。由于这一块内存中的对象存活时间较短,且对象首先在这里被分配,所以频繁发生垃圾回收,而且每次回收一般都能释放大量空间;
- 老年代:用来存放生命周期长的对象。新生代中存活了较长时间的对象会被迁移到这里(当然,对象进入老年代不仅仅只有这一个方法),所以这里存放的对象生命周期一般较长,所以这一块区域发生垃圾回收的频率较低,每次回收释放的空间也较少;
对于新生代而言,这一块区域中的对象存活时间短,每一次垃圾回收都能回收大部分内存,所以适合使用复制算法进行垃圾回收,同时以老年代作为这个算法的担保空间;对于老年代而言,每次垃圾回收只能释放小部分空间,若使用复制算法,每次将需要做大量复制,而且此时Survivor需要较大的空间,所以不适合使用复制算法,因此在老年代中,一般使用标记—清除或者标记—整理算法进行垃圾回收。
3.4 垃圾回收器
收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。

说明:如果两个收集器之间存在连线说明他们之间可以搭配使用。
3.4.1 Serial

这是一个单线程收集器。意味着它只会使用一个CPU或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。
3.4.2 ParNew
可以认为是Serial收集器的多线程版本。

- 并行(
Parallel):指多条垃圾收集线程并行工作,此时用户线程处于等待状态。 - 并发(
Concurrent):指用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个CPU上运行。
3.4.3 Parallel Scavenge

这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而Parallel Scavenge收集器的目的是达到一个可控制的吞吐量(\(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)\))。
作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整停顿时间。这就是GC的自适应调整策略(GCErgonomics)。
3.4.4 Serial Old
收集器的老年代版本,单线程,使用标记—整理算法实现。
3.4.5 Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本。多线程,使用标记—整理算法实现。
3.4.6 CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记—清除算法实现。具体请参考CMS垃圾回收器
3.4.7 G1(Garbage First)
面向服务端的垃圾回收器。具体请参考G1垃圾回收器
3.4.8 ZGC
zgc是jdk11中要发布的最新垃圾收集器。完全没有分代的概念,先说下它的优点吧,官方给出的是无碎片,时间可控,超大堆。具体请参考ZGC垃圾回收器
3.4.9 Shenandoah
Shenandoah是一款concurrent及parallel的垃圾收集器;跟ZGC一样也是面向low-pause-time的垃圾收集器,不过ZGC是基于colored pointers来实现,而Shenandoah GC是基于brooks pointers来实现。其实低停顿的GC,业界早就出现,只不过Java比较晚Azul的Zing中C4 GC土豪选择oracle中的HotSpot ZGC JDK11的选择R大说ZGC说抄袭Azul的,两者是等价的。具体请参考Shenandoah垃圾回收器
3.4.10 Epsilon
java 11新的Epsilon垃圾收集器
Epsilon(A No-Op Garbage Collector)垃圾回收器控制内存分配,但是不执行任何垃圾回收工作。一旦java的堆被耗尽,jvm就直接关闭。设计的目的是提供一个完全消极的GC实现,分配有限的内存分配,最大限度降低消费内存占用量和内存吞吐时的延迟时间。一个好的实现是隔离代码变化,不影响其他GC,最小限度的改变其他的JVM代码。具体请参考Epsilon垃圾回收器
3.5 内存分配与回收策略
3.5.1 对象优先在Eden分配
对象主要分配在新生代的
Eden区上,如果启动了本地线程分配缓冲区,将线程优先在(TLAB)上分配。少数情况会直接分配在老年代中。
一般来说Java堆的内存模型如下图所示:

新生代GC(Minor GC)
发生在新生代的垃圾回收动作,频繁,速度快。
老年代GC(Major GC/Full GC)
发生在老年代的垃圾回收动作,出现了
Major GC经常会伴随至少一次Minor GC(非绝对)。Major GC的速度一般会比Minor GC慢十倍以上。
3.5.2 大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数-XX:PretenureSizeThreshold可以设置大对象的大小,如果对象扯过设置大小会直接进入老年代,不会进入年轻代,这个参数值在Serial和ParNew两个收集器下有效。
3.5.3 长期存活的对象将进入老年代
虚拟机给每个对象定义了对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
设置对象晋升老年代的年龄阈值
-XX:MaxTenuringThreshold=threshold
关于-XX:MaxTenuringThreshold=threshold参数说明
Sets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.
上面大意是说:默认晋升年龄并不都是15,这个是要区分垃圾收集器的,CMS就是6。
3.5.4 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,然后取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈。
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
3.5.3 空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果大于,则此次
Minor GC是安全的 - 如果小于,则虚拟机会查看
HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
为什么要进行空间担保
上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失败,如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC。
四、类加载时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、 初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)。

4.1 类的生命周期(7个阶段)
其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。解析阶段可以在初始化之后再开始(运行时绑定或动态绑定或晚期绑定)。
以下五种情况必须对类进行初始化(而加载、验证、准备自然需要在此之前完成):
- 遇到
new、getstatic、putstatic或invokestatic这4条字节码指令时没初始化触发初始化。使用场景:使用new关键字实例化对象、读取一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。 - 使用
java.lang.reflect包的方法对类进行反射调用的时候。 - 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。
- 当虚拟机启动时,用户需指定一个要加载的主类(包含
main()方法的那个类),虚拟机会先初始化这个主类。 - 当使用
JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。
前面的五种方式是对一个类的主动引用,除此之外,所有引用类的方法都不会触发初始化,称为被动引用。
4.2 类的加载过程
4.2.1 加载
- 通过一个类的全限定名来获取定义次类的二进制流(
ZIP包、网络、运算生成、JSP生成、数据库读取)。 - 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为方法去这个类的各种数据的访问入口。
数组类的特殊性:数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程如下:
- 如果数组的组件类型是引用类型,那就递归采用类加载加载。
- 如果数组的组件类型不是引用类型,
Java虚拟机会把数组标记为引导类加载器关联。 - 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为
public。
内存中实例的java.lang.Class对象存在方法区中。作为程序访问方法区中这些类型数据的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的,但是开始时间保持先后顺序。
4.2.2 验证
是连接的第一步,确保Class文件的字节流中包含的信息符合当前虚拟机要求。
文件格式验证
- 是否以魔数
0xCAFEBABE开头 - 主、次版本号是否在当前虚拟机处理范围之内
- 常量池的常量是否有不被支持常量的类型(检查常量
tag标志) - 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据Class文件中各个部分集文件本身是否有被删除的附加的其他信息- ……
只有通过这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面3个验证阶段全部是基于方法区的存储结构进行的,不再直接操作字节流。
元数据验证
- 这个类是否有父类(除
java.lang.Object之外) - 这个类的父类是否继承了不允许被继承的类(
final修饰的类) - 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(覆盖父类
final字段、出现不符合规范的重载)
这一阶段主要是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
字节码验证
- 保证任意时刻操作数栈的数据类型与指令代码序列都鞥配合工作(不会出现按照
long类型读一个int型数据) - 保证跳转指令不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法的)
- ……
这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。
符号引用验证
- 符号引用中通过字符创描述的全限定名是否能找到对应的类
- 在指定类中是否存在符方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的访问性(
private、protected、public、default)是否可被当前类访问 - ……
最后一个阶段的校验发生在迅疾将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,还有以上提及的内容。
符号引用的目的是确保解析动作能正常执行,如果无法通过符号引用验证将抛出一个java.lang.IncompatibleClass.ChangeError异常的子类。如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
4.2.3 准备
这个阶段正式为类分配内存并设置类变量初始值,内存在方法去中分配(含static修饰的变量不含实例变量)。
public static int value = 1127;
这句代码在初始值设置之后为0,因为这时候尚未开始执行任何Java方法。而把value赋值为1127的putstatic指令是程序被编译后,存放于clinit()方法中,所以初始化阶段才会对value进行赋值。
基本数据类型的零值
| 数据类型 | 零值 | 数据类型 | 零值 |
|---|---|---|---|
| int | 0 | boolean | false |
| long | 0L | float | 0.0f |
| short | (short) 0 | double | 0.0d |
| char | '\u0000' | reference | null |
| byte | (byte) 0 |
特殊情况:如果类字段的字段属性表中存在ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为1127。
4.2.4 解析
这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。
- 直接引用:直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的7中常量类型。
4.2.5 初始化
前面过程都是以虚拟机主导,而初始化阶段开始执行类中的Java代码。
4.3 类加载器
通过一个类的全限定名来获取描述此类的二进制字节流。
4.3.1 双亲委派模型
双亲委派模型又称为双亲委托模型,从Java虚拟机角度讲,只存在两种类加载器:一种是启动类加载器(C++实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java实现,独立于虚拟机外部且全继承自java.lang.ClassLoader)
- 启动类加载器:加载
lib下或被-Xbootclasspath路径下的类 - 扩展类加载器:加载
lib/ext或者被java.ext.dirs系统变量所指定的路径下的类 - 引用程序类加载器:
ClassLoader负责,加载用户路径上所指定的类库。

除顶层启动类加载器之外,其他都有自己的父类加载器。
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
- 向上委派:实际上就是查找缓存,是否加载了该类,有则直接返回,没有继续向上。
- 委派到顶层之后,缓存中还是没有,则到加载路径中查找,有则加载返回,没有则向下查找。
- 向下查找:查找加载路径,有则加载返回,没有则继续向下查找。
注意:向上委派到顶层加载器为止,向下查找到发起加载的加载器为止。
双亲委派模型的好处:
- 主要是为了安全性,避免用户自己编写的类动态替换
Java的一些核心类,比如String。所有自定义的类不能以java.开头。 - 同时也避免了类的重复加载,因为
JVM中区分不同类,不仅仅是根据类名,相同的class文件被不同的ClassLoader加载就是不同的两个类。
4.3.2 破坏双亲委派模型
双亲委派模型主要出现过3次较大规模的“被破坏的”情况。
第一次:
由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法唯一逻辑就是去调用自己的loadClass()。
第二次:
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。如果基础类又要调用回用户的代码,那该么办?
一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
第三次:
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。
OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:
- 将
java.*开头的类委派给父类加载器加载。 - 否则,将委派列表名单内的类委派给父类加载器加载。
- 否则,将
Import列表中的类委派给Export这个类的Bundle的类加载器加载。 - 否则,查找当前
Bundle的ClassPath,使用自己的类加载器加载。 - 否则,查找类是否在自己的
Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。 - 否则,查找
Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。 - 否则,类加载器失败。
4.3.3 如何破坏双亲委派模型?
- 使用
SPI机制; - 自定义类继承
ClassLoader,作为自定义类加载器,重写loadClass()方法,不让它执行双亲委派逻辑,从而打破双亲委派。但是遇到自定义类加载器和核心类重名或者篡改核心类内容,jvm会使用沙箱安全机制,保护核心类,防止打破双亲委派机制,防篡改,如果重名的话就报异常。
五、Java内存模型与线程

5.1 Java内存模型(JMM)
Java内存模型(Java Memory Model,简称JMM)本身是一种抽象的概念,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
关于同步的规定:
- 线程解锁前,必须将同步变量刷新到主内存中
- 线程加锁前,必须读取主内存的最新值到自己工作内存中
- 加锁和解锁是同一把锁。
由于JVM运行程序的主体是线程,而每个线程创建的时候都会有一个工作内存(栈),工作内存是线程的私有数据区域,而java内存模型中规定所有变量都存储在主内存(线程共享区域),但线程对变量的操作必须是在工作内存中完成,首先要把变量从主内存中拷贝到工作内存中,再对变量进行操作,操作完成再将变量写回到主内存中。不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过住内存来完成。
屏蔽掉各种硬件和操作系统的内存访问差异。

5.1.1 主内存和工作内存之间的交互
| 操作 | 作用对象 | 解释 |
|---|---|---|
lock(锁定) |
主内存 | 把一个变量标识为一条线程独占的状态 |
unlock(解锁) |
主内存 | 把一个处于锁定状态的变量释放出来,释放后才可被其他线程锁定 |
read(读取) |
主内存 | 把一个变量的值从主内存传输到线程工作内存中,以便load操作使用 |
load(载入) |
工作内存 | 把read操作从主内存中得到的变量值放入工作内存中 |
use(使用) |
工作内存 | 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码 指令时将会执行这个操作 |
assign(赋值) |
工作内存 | 把一个从执行引擎接收到的值赋接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量 赋值的字节码指令时执行这个操作 |
store(存储) |
工作内存 | 把工作内存中的一个变量的值传送到主内存中,以便write操作 |
write(写入) |
主内存 | 把store操作从工作内存中得到的变量的值放入主内存的变量中 |
5.1.2 对于volatile型变量的特殊规则
关键字
volatile是Java虚拟机提供的最轻量级的同步机制。
一个变量被定义为volatile的特性:
- 保证此变量对所有线程的可见性。但是操作并非原子操作,并发情况下不安全。
如果不符合运算结果并不依赖变量当前值,或者能够确保只有单一的线程修改变量的值和变量不需要与其他的状态变量共同参与不变约束就要通过加锁(使用
synchronize或java.util.concurrent中的原子类)来保证原子性。 - 禁止指令重排序优化。
通过插入内存屏障保证一致性。
5.1.3 对于long和double型变量的特殊规则
Java要求对于主内存和工作内存之间的八个操作都是原子性的,但是对于64的数据类型,有一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性。这就是long和double的非原子性协定。
5.1.4 原子性、可见性与有序性
回顾下并发下应该注意操作的那些特性是什么,同时加深理解。
-
原子性(
Atomicity)由
Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write。大致可以认为基本数据类型的操作是原子性的。同时lock和unlock可以保证更大范围操作的原子性。而synchronize同步块操作的原子性是用更高层次的字节码指令monitorenter和monitorexit来隐式操作的。 -
可见性(
Visibility)是指当一个线程修改了共享变量的值,其他线程也能够立即得知这个通知。主要操作细节就是修改值后将值同步至主内存(
volatile值使用前都会从主内存刷新),除了volatile还有synchronize和final可以保证可见性。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步会主内存中(store、write操作)”这条规则获得。而final可见性是指:被final修饰的字段在构造器中一旦完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。 -
有序性(
Ordering)如果在被线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指“线程内表现为串行的语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。
Java语言通过volatile和synchronize两个关键字来保证线程之间操作的有序性。volatile自身就禁止指令重排,而synchronize则是由“一个变量在同一时刻指允许一条线程对其进行lock操作”这条规则获得,这条规则决定了持有同一个锁的两个同步块只能串行的进入。
5.1.5 先行发生原则
也就是
happens-before原则。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。先行发生是Java内存模型中定义的两项操作之间的偏序关系。
天然的先行发生关系
| 规则 | 解释 |
|---|---|
| 程序次序规则 | 在一个线程内,代码按照书写的控制流顺序执行 |
| 管程锁定规则 | 一个unlock操作先行发生于后面对同一个锁的lock操作 |
volatile变量规则 |
volatile变量的写操作先行发生于后面对这个变量的读操作 |
| 线程启动规则 | Thread对象的start()方法先行发生于此线程的每一个动作 |
| 线程终止规则 | 线程中所有的操作都先行发生于对此线程的终止检测 (通过 Thread.join()方法结束、Thread.isAlive()的返回值检测) |
| 线程中断规则 | 对线程interrupt()方法调用优先发生于被中断线程的代码检测到中断事件的发生(通过 Thread.interrupted()方法检测) |
| 对象终结规则 | 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始 |
| 传递性 | 如果操作A先于操作B发生,操作B先于操作C发生,那么操作A先于操作C |
六、拓展
使用元空间替代永久代作为方法区的原因?
Java虚拟机规范规定的方法区只是换种方式实现。有客观和主观两个原因。
- 客观上使用永久代来实现方法区的决定的设计导致了
Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。 - 主观上当
Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容((主要是类型信息)全部移到元空间中。

浙公网安备 33010602011771号