- 1. Java内存区域与内存溢出异常
- 2. JVM垃圾回收
- 3. Class文件结构
- 4. 类加载过程
- 5. 类加载器
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。JDK1.8之前与之后的运行时数据区域略有不同。
JDK1.8之前:
JDK1.8之后:
线程共享:
- 堆
- 方法区(包括运行时常量池)
- 直接内存(这里是非运行时数据区的一部分)
线程私有:
- 虚拟机栈
- 程序计数器
- 本地方法栈
程序计数器是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。字节码解释器就是通过程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个程序技术器来完成。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,称这类内存区域为“线程私有”内存。程序计数器是线程私有的,生命周期与线程相同。
如果线程正在执行一个Java方法,程序计数器记录的就是正在执行的虚拟机字节码指令的地址。如果线程正在执行native方法,程序计数器的值为空。
程序计数器是内存区域唯一一个在Java虚拟机规范中没有规定任何的OutOfMemoryError的情况。
小结: 从上面的介绍来看,可以得知程序计数器的主要功能:
- 字节码解释器通过程序计数器的值来依次读取指令。
- 在多线程的情况下,程序计数器可以保存当前线程执行的位置,线程切换返回之后,不受影响,继续执行。
虚拟机栈也是线程私有的,生命周期和线程相同。
虚拟机栈描述的是Java方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、方法出口等信息。每一个方法从调用到执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。
经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),栈内存就是指虚拟机栈或者说是虚拟机栈中的局部变量表。
局部变量表存放了编译期间可知的各种基本数据类型(boolean、short、int、long、float、double、byte、char)、对象引用(reference类,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
局部变量表所需要的内存空间在编译期间就已经完成分配了,当进入一个方法是,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间是不会改变局部变量表的大小。
该区域规定了两种异常状况:
- StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
- OutOfMemoryError:如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存
问题:方法如何调用?
Java栈类似于数据结构的栈结构,Java栈中主要保存的是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java栈,每一个方法调用结束后,栈帧又会被弹出。
Java两种返回方式:
- return返回
- 抛出异常。
不管哪种方式,都会弹出栈帧。
与虚拟机栈发挥的作用相似,区别是:虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务的,本地方法栈则是为虚拟机使用到的native方法服务的。
本地方法栈也会创建一个栈帧,用于存储本地方法的局部变量表、操作数栈、动态链接、出口等信息。
HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一,与虚拟机栈一样,也会出现StackOverflowError和OutOfMemoryError异常。
Java堆(Java Heap)是Java虚拟机所管理的内存最大一块,Java堆是被所有线程共享的一块内存区域,虚拟机启动时创建Java堆。堆内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在Java堆进行内存分配。Java虚拟机规范中描述:所有的对象实例以及数组都要在堆上分配。
Java堆是垃圾收集器管理的主要区域,因此也被称为“GC堆(Garbage Collected Heap)”。
从内存回收的角度来看,现在收集器采用的分代收集算法,所以Java堆可细分为:新生代和老年代;再细分一点有Eden空间、From Survivor空间、To Survivor空间。
从内存分配的角度来看,线程共享的Java堆可以划分出多个线程私有的分配缓冲区(Thead Local Allocation Buffer,TLAB),不过无论怎么划分,都与存放内容无关,无论哪个区域,存储的都是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
Java堆可以处于物理上不连续的内存空间中,只要逻辑连续即可。堆大小可以固定也可以扩展,扩展通过-Xmx和-Xms控。
如果堆中没有内存完成实例分配,并且堆也无法再扩展时,就会抛出OutOfMemoryError异常。
方法区(Method Area)与java堆一样,是各个线程共享的内存区域,用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是方法区有个别名叫做Non-Heap(非堆),目的是与Java堆进行区分。
在HotSpot虚拟机,很多人把方法区成为“永久代”,仅仅是因为HotSpot虚拟机设计团队选择把GC分代收集器扩展至方法区,或者使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存了,免去了还单独为方法区编写内存管理的代码,实际上方法区和永久代并不等价。
永久代和方法区的区别?
方法区是Java虚拟机规范中的一个慨念和作用,但是并没有规定如何具体的去实现。不同的JVM虚拟机肯定实现方式是不一样的。而永久代是HotSpot虚拟机团队是对方法区一种实现方式。
在JDK1.7中的HotSpot中,已经把原本放在永久代的字符串常量池移出。永久代也逐步改为采用native memory来实现方法区。
当方法区无法满足内存分配需求的时候,会跑出OutOfMemoryError异常。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本信息、子段、方法、接口等信息外,还有一项信息就是常量池,用于存放编译期间生成的各种字面量和符号引用。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请内存时会抛出OutOfMemoryError异常。
JDK1.7之后已经将运行时常量池移出到Java堆中。
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁的使用,而且可可能导致OutOfMemoryError异常出现。
在JDK1.4中加入的NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式。它可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和native堆中来回复制数据。
这句话的意思就是:通过Java堆的DirectByteBuffer对象来操作native函数分配的堆外内存。
通过上面的介绍大致知道了虚拟机内存的概况,知道了内存中放了些什么,下面继续学习HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
第一步:类加载检查
虚拟机遇到一条new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用(方法区中的运行时常量池中的符号引用),并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
第二步:内存分配
在类加载检查通过后,接下来虚拟机将会为新生对象分配内存。对象所需内存的大小在类加载完成后便可以完全确定,为对象分配内存空间的任务等同于把一块确定大小的内存从Java堆中划分出来。内存分配方式有两种:“指针碰撞”和“空闲列表”。具体采用哪种分配方式是由Java堆是否规整决定的,而Java堆是否规整又是由GC收集器是否带有压缩整理功能决定的。
还有一个问题需要考虑的是:对象创建在虚拟机是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,所以还需要考虑线程安全的问题,解决方法有两种方案:
- CAS+失败重试方式:CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突去完成某项操作,如果遇到冲突失败,则重试,直到成功。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
- TLAB:每个线程预先分配一小块内存,这块区域也就称为本地线程分配缓冲(TLAB),哪个线程要分配内存就在哪个线程的TLAB上进行分配。只有TLAB用完并分配新的TLAB时,才需要采用同步锁定方式。
第三步:内存初始化为零值:
内存分配完成后,虚拟机要将分配到的内存空间都初始化为零值(不包括对象头)。这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
第四步:设置对象头:
虚拟机要对对象头进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息都存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头都有不同的设置方式。
第五步:执行<init>方法:
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。
-
对象头:对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等。第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
实例数据:是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
-
对齐填充:不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象(reference在虚拟机栈上有提到,具体向上查看)。Java虚拟机规范只说reference类型是一个指向对象的引用,并没有定义这个reference应该通过何从方式去定位、访问堆中对象的具体位置,具体实现取决于虚拟机的具体实现。目前主流的访问方式有使用句柄和直接指针两种。
- 使用句柄方式:
Java堆中划分一块内存用作句柄池,reference中存储的就是对象的句柄地址,而句柄地址包含了对象的实例数据和类型数据各自的地址信息。

- 直接指针:
reference存储的直接就是对象地址,此时在Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
GC需要完成的3件事情:
- 哪些内存需要回收?
- 什么时候进行回收?
- 如何回收?
Java堆和方法区的内存分配和回收是动态的,只有程序在运行期间才能知道会创建哪些对象。垃圾收集器所关注的就是这部分内存。
在堆中存放着几乎所有的对象实例,GC收集器在对堆进行回收之前,首先就要判断这些对象中哪些已经“死去”(即不可能再被任何途径使用的对象)。
算法思路:给对象添加引用计数器,每当有一个地方引用它,那么计数器就加1,当引用失效时,计数器值就减1;任何时刻计数器为0的对象就不可能再被使用。
引用计数算法实现简单,效率高,但是在主流的Java虚拟机里面并没有选用计数算法来管理内存,其中最主要的原因就是很难解决对象之间相互循环引用的问题。
public class ReferenceCouningGC {
public Object instance = null;
public static void main(String[] args){
ReferenceCouningGC objA = new ReferenceCouningGC();
ReferenceCouningGC objB = new ReferenceCouningGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}对象objA和objB都有字段instance,除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
基本思路:通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用连,当一个对象到GC Roots没有任何引用链相连时,则说明此对象是不可用的。
无论通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都是与“引用”有关。
在JDK1.2之前,Java对引用的定义为:如果reference类型的数据中存储的数据值代表的是另外一块内存的起始地址,就称这块内存代表的一个引用。
在JDK1.2之后,Java对引用的慨念进行了扩充,将引用分为了强引用、软引用、弱引用、虚引用,这四种引用强度依次减弱。
强引用就是指程序代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还在,GC永远不会回收掉被引用的对象。
软引用用来描述一些还有用但是并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存异常。
弱引用用来描述一些非必需的对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。也就是,当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置成虚拟应关联的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。
在可达性分析算法中不可达的对象也并非“非死不可”,这时候它们暂时处于“缓刑”阶段,要正真宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”筛选。
当对象被判定有必要执行finalize()方法,那么这个对象将会放置在一个F-Queue队列之中进行二次标记,如果对象没有在finalize()方法中与引用链上的任何一个对象建立关联(如把自己this赋值给某个类变量或者对象的成员变量),那么第二次标记时该对象将会被回收。finalize()方法是对象逃脱死亡命运的最后一次机会。任何一个对象的finalize()方法只能被系统调用一次。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。对于废弃常量,虚拟机是如何进行回收的呢?
以常量池中的字面量的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象叫做是“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时候发生了内存回收,而且有必要的话,这个“abc”常量就会被系统清理出常量池。
类需要同时满足下面3个条件餐能算是“无用的类”:
- 该类的所有实例都已经被回收了,也就是Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在完成标记后统一回收所有被标记的对象。
主要不足:
- 效率问题:标记和清除两个过程效率都不高。
- 空间问题:标记清除后会产生大量的不连续内存碎片,空间碎片太多,会导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
为了解决效率问题,将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另外一块上,然后再把已经使用过的内存空间一次清理掉。这样每次都是对半块区域进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。
这种算法将内存缩小了原来的一半。
老年代的对象一般存活率比较高,对于复制算法在对象存活率较高时就要进行较多的复制操作,效率会变低。
根据老年代的特点,标记过程与标记-清除算法一样,但是后续步骤不是对可回收对象进行清理,而是让所有存活的对象都向一端移动,直接清理掉端边界以外的内存。
当前商业虚拟机的垃圾收集都是采用的“分代收集算法”,根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为:新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法。
在新生代,每次垃圾收集都会有大批对象死去,只有少量存活,那么就可以选用复制算法,只需复制少量的对象的复制成本就可以完成收集。
在老年代,对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除或者标记-整理算法来回收。
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。现在没有万能的垃圾收集器,只能选得对具体应用最合适的收集器。
这里要区分这些收集器处于新生代还是老年代。
Serial收集器是一个单线程的收集器,它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
新生代采用复制算法,老年代采用Serial Old收集器使用标记-整理算法。
优势:简答而高效(与其他收集器的单线程比),对于限定单个CPU环境来说,Serial收集器由于没有线程交互的开销,单线程收集效率高。Serial对于运行Client模式下的虚拟机是一个很好的选择。
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。
ParNew是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中一个与性能无关的重要原因是,除了Serial收集器,目前只有ParNew收集器能够与CMS收集器配合工作。
并行和并发的概念区别:
- 并发:指用户线程和垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
- 并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
Paraller Scavenge收集器是一个新生代收集器,使用复制算法的收集器,又是并行多线程收集器。于ParNew收集器相似,但是有什么差别呢?
Paraller Scavenge收集器关注点是吞吐量(吞吐量就是运行用户代码的时间与CPU总消耗时间的比较值,也就是CPU的执行效率),CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间。
Paraller Scavenge提供了两个参数用于精确控制吞吐量,最大垃圾收集停顿时间-XX:MaxGCPauseMillis和设置吞吐量大小-XX:GCTimeRation。
Paraller Scavenge还提供了一个参数-XX:UseAdaptiveSizePolicy,虚拟机会自动提供合适的停顿时间和最大的吞吐量,这也是与ParNew的最大一个区别。
Serial Old是Serial收集器的老年代版本,也是一个单线程收集器,使用标记-整理算法,主要给Client模式下的虚拟机使用。
在Server模式下使用,有两个用途:1. JDK1.5及以前的版本中,与Paraller Scavenge搭配使用;2. 作为CMS收集器的后备预案。
Paraller Old是Paraller Scavenge的老年代版本,使用多线程和标记-整理算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。希望系统停顿时间短,以给用户带来较好的体验。是一种老年代收集器。
CMS是基于标记-清除算法的,整个过程分为4个步骤:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记:就是进行GC Roots Tracing的过程。
- 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长,但是远比并发标记的时间短。
- 并发清理:对标记进行清理过程。
整个过程中耗时最长的并发标记和并发清除过程收集器线程都是可以与用户线程并发执行的。
CMS虽有并发收集、低停顿的特点,但是也有3个明显的缺点:
- 对CPU资源非常敏感。
- 无法处理浮动垃圾(浮动垃圾:由于CMS并发清理阶段用户线程还在运行,自然就会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留等到下一次GC时再清理掉)。
- CMS是一款基于标记-清除算法实现的收集器,所以会产生大量的碎片。
G1收集器是一款面型服务端应用的垃圾收集器。
与其他收集器相比,G1具有如下特点:
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:G1从整体上看是基于标记-整理算法,从局部上看(两个Region之间)上看是基于复制算法。但是这两种算法都是不会产生空间碎片。
- 可预测的停顿:G1除了追求低停顿之外,还建立了可预测的停顿时间模型,能指定在一个长度为M毫秒的时间片段上,消耗在垃圾收集上的时间不得超过N毫秒。
G1将整个Java堆分为多个大小相等的独立区域(Region),虽然还保留了新生代和老年代的慨念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
G1收集器的运作大致分为以下几步:
- 初始标记:标记一下GC Roots能直接关联到的对象,这个阶段需要停顿线程,速度很快。
- 并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这各阶段耗时长,但是与用户程序并发执行。
- 最终标记:修正在并发标记阶段因用户程序继续运作而导致标记变动的那一部分标记记录。
- 筛选回收:对各个Region的回收价值和成本进行回收。
Java的内存管理方面可以分为两个问题:给对象分配内存和回收分配给对象内存。内存回收上面已经讲了,接下来就是学习内存分配。对象的内存分配,就是在堆上分配,对象主要分配在新生代的Eden上。
堆的基本结构:
eden 区、s0("From") 区、s1("To") 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s1("To"),并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。经过这次GC后,Eden区和"From"区已经被清空。这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次GC前的“From”,新的"From"就是上次GC前的"To"。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,"To"区被填满之后,会将所有对象移动到年老代中。
内存分配规则:
大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
Minor GC和Full GC的差别:
- 新生代(Minor GC):指发生在新生代的垃圾收集动作。
- 老年代(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的minor GC。
所谓的大对象是指,需要大量连续内存空间的java对象,最典型的就是很长的字符串和数组。
这样做的目的就是:避免在Eden区以及两个Survivor区之间发生大量的内存复制。
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器。
如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,对象在 Survivor 中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到年龄阈值要求。
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置是否允许担保失败。如果允许,则会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC;如果小于,或者不允许担保失败,那么将会进行一次Full GC。
Class文件需要加载到虚拟机中才能运行和使用,那么虚拟机是如何加载这些Class文件的呢?
加载Class类型的文件主要分为三步:加载 -> 连接 -> 初始化。连接过程又分为:验证 -> 准备 -> 解析。
类加载过程的第一步,主要完成下面3件事:
- 通过全类名获取定义此类的二进制字节流;
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构;
- 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口;
验证分为以下4步:
- 文件格式验证:验证字节流是否符合Class文件格式的规范。例如:是否已0xcafebabe开头、主次版本号是否在当前的虚拟机的处理范围之内、常量池的常量是否有不被支持的类型。
- 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合java语言规范的要求。例如:这个类是否有父类,这个类是否继承了不允许继承的类(被final修饰的类)。
- 字节码验证:最复杂的一个阶段。通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如:保证任意时刻操作数栈和指令代码序列都能配合工作。
- 符号引用验证:确保解析动作能正确执行。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些内存都在方法区中分配。对于这个阶段有以下几点需要注意:
- 这时进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在java堆中。
- 这里设置初始值“通常情况”下是数据类型默认的零值(如:0,0L,null,false等),比如定义了
public static int value = 111,那么value变量在准备阶段初始值是0而不是111,但是如果给类变量加上了fial声明public static final int value = 111,那么准备阶段value的值就被复制为111。
基本数据类型的零值:
解析阶段就是将常量值池中的符号引用替换为直接引用的过程。解析动作主要针对类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7符号引用进行。
初始化是类加载的最后一步,也是真正执行类中定义的Java程序代码(字节码),初始化阶段是执行类构造器<clinit>()方法的过程。
对于<clinit>()方法的调用,虚拟机会自己确保其在多线程环境中的安全性,因为<clinit>()方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。
类加载器用于实现类的加载动作。对于任意一个,都需要有加载它的类加载器和这个类本身一同确定在该类在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
3类类加载器:
- 启动类加载器(Bootstrap ClassLoader):负责将存放在
<JAVA_HOME>\lib目录中的,或被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。 - 扩展类加载器(Extension ClassLoader):负责加载
<JAVA_HOME>\lib\ext目录中的,或被java.ext.dirs系统变量所指定的路径中的所有类库。 - 应用程序类加载器(Application ClassLoader):负责加载用户类路径
ClassPath上所指定的类库。如果应用程序中没有自定义过自己的类加载器,一般情况下默认使用这个类加载器。
除了启动类加载器由C++实现,是虚拟机的一部分外,其他的类加载器都是有Java语言实现,独立于虚拟机外部。
双亲委派模型要求除了顶层的启动加载器外,其余的类加载器都应该有自己的父类加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。
为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重载 loadClass() 即可。
参考:
- 周志明. 深入理解Java虚拟机(第2版)
- github. JavaGuide





















