一、JVM简略图
1、程序计数器
=一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。此内存区域也是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemory(OOM)情况的区域。
2、Java虚拟机栈
描述了java方法执行时的线程内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息 。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧随着方法调用而创建,随着方法结束而销毁。在《Java虚拟机规范》对该内存区域规定了两种内存情况:(1)如果Java虚拟机栈不可以动态扩展,而且线程请求的栈深度大于虚拟机栈允许的深度,将抛出 StackOverflowError 异常;(2)如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。
动态链接:
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令
- 在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
3、本地方法栈
本地方法栈和 Java虚拟机栈作用类似, 区别是Java虚拟机栈是为执行 Java 方法(也就是字节码)服务, 而本地方法栈则是为执行本地方法(Native )服务。与Java虚拟机栈一样,本地方法栈也会在栈深度溢出或栈扩展失败时分别抛出 StackOverflowError 异常和 OutOfMemoryError 异常。
4、堆
堆是被所有线程共享的一块内存区域,在虚拟机启动时被创建,此内存区域的唯一目的是存放对象实例(包括数组),它也是垃圾收集器进行垃圾收集的最重要的内存区域。如果Java堆中没有足够的内存完成对象分配并且堆也无法扩展时,Java虚拟机将会抛出 OutOfMemory 异常。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
5、方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类元信息、常量、静态变量、即时编译器编译后的代码缓存等数据。该内存区域的回收主要是针对废弃的常量和不再使用的类型,根据《Java虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常 。运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
元空间与永久代的区别:最初HotSpot上把GC分代收集扩展到方法区,或者说使用永久代来实现方法区。从JDK1.8开始,HotSpot取消了永久代的说法,取代永久代的就是元空间。元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小受本地内存的限制。但是可以通过参数来指定元空间的大小。永久代理论上是堆的一部分。
二、对象的创建过程
给对象分配内存的两种方式:
- 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配的内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
- 空闲列表:如果Java堆中内存不是规整的,已被使用的内存和空闲内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间用来存放对象实例,并更新列表上的记录,这种方式称之为空闲列表。
如何保证在多线程环境下对象内存分配的线程安全:
- CAS+失败重试:对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS+失败重试的方式保证更新操作的原子性。
- TLAB:给每个线程在Java堆中预先分配一小块内存,称为 本地线程分配缓冲区(Thread Local Allocation Buffer,TLAB) ,某个线程如果要给对象分配内存,就在该线程的本地缓冲区进行分配,只有本地缓冲区用完了,在分配新的缓冲区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
对象结构:
Mark Word:
三、GC的三个流程
1、什么时候进行垃圾回收?
- Minor GC:只从年轻代空间(包括 Eden 和 Survivor 区域)回收内存的过程被称为 Minor GC。当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。
- Major GC:只从从老年代空间回收内存的过程称为Major GC,一般由 Minor GC 触发,如果回收的空间依然不足则可能触发 MajorGC或者新生的大对象的所需空间超过了老年代的剩余空间。
- Mixed GC:收集整个新生代以及部分老年代的垃圾收集过程称为Mixed GC,目前只有G1有这种行为。
- Full GC :针对整个堆空间(包括年轻代和老年代)和方法区的垃圾收集过程称为Full GC,如果老年代满了或ystem.gc()被调用、统计得到的Minor GC晋升到老年代的对象大小大于老年代的剩余空间、方法区空间不足
2、如何判断哪些对象可以被回收?
- 引用计数法:引用通常是和对象是相关联的,通过引用而操作对象。因此,一个简单的办法是通过引用计数 来判断一个对象是否可以回收。一个对象如果没有任何与之关联的引用,即他们的引用计数都为 0,则说明该对象不太可能再被用到,那么这个对象就是可回收对象。
- 根搜索算法:以一系列"GC root"对象作为搜索的起点,如果某个对象和"GC roots"对象之间不存在一条可达路径,则称该对象是不可达的。不可达对象不等价于可回收对象,一个对象如果要被标记为可回收对象最多要经过两次(标记和筛选)过程。
一、具体的标记流程:
1、第一次标记和筛选:如果对象在可达性分析之后发现与GC Roots对象不存在一条引用链,那么它将会第一次被标记 (对不可达对象进行标记),随后进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。假如该对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况定义为“没必要执行”,在这种情况下不需要进行第二次标记和筛选对象就会被直接回收。假如这个对象被判定为有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的执行是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它的结束。这样做的原因是如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其它对象永久处于等待,甚至导致整个内存回收子系统地奔溃。
2、第二次标记和筛选:finalize()方法是不可达对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue队列中的对象进行第二次小规模标记,如果队列中的对象再执行finalize()方法后成功拯救自己——只需重新与引用链上的任何一个对象建立链接即可,那么第二次标记时他将会被移出“即将回收集合”;如果对象没有拯救自己,那么它就真的要被回收了。
二、哪些对象可以作为GC root对象:
1、虚拟机栈(栈帧中的本地变量表)中引用的对象。
2、方法区中的类静态属性引用的对象
3、方法区中的常量引用的对象
4、本地方法栈中JNI引用的对象
5、被同步锁持有的对象
3、如何进行回收?
3.1 分代收集
当前主流 VM 在进行垃圾收集时都会采用”分代收集”(Generational Collection)的思想, 这种思想会根据对象存活周期的不同将内存划分为几块, 如 JVM 中的 新生代、老年代、永久代,这样就可以根据各年代特点分别采用最适当的 GC 算法
- 新生代GC(Minor GC):采用复制算法进行垃圾回收。首先,把 Eden 和 Survivor From 区域中存活的对象复制到 Survivor To 区域(如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代),并把这些对象的年龄+1(如果某些对象的年龄已经达到了老年的标准15,则复制到老年代区),然后,清空 Eden 和 Servivor From 中的对象,最后,Servivor To 和 Servivor From 互换。
1、复制算法:按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中,最后交换两块survivor空间的角色。
- 老年代GC(Major GC):
1、 标记清除算法(Mark-Sweep):分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清
除阶段回收被标记的对象所占用的空间。
2、标记整理算法(Mark-Compact):标记阶段和 Mark-Sweep 算法相同,标记后并不是着急清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
3.2 分区收集
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以根据最大允许停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。
三、 Java中的四种引用
1、强引用
在 Java 中最常见的就是强引用,例如把一个对象赋给一个引用变量:Object obj=new Object(),这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收器回收的,即该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏(OOM)的主要原因之一。
2、软引用
软引用是用来描述一些还有用,但非必须的对象,使用 SoftReference 类来实现,对于只有软引用的对象来说,当系统将要发生内存溢出异常之前,才会把这些对象列进回收范围之中进行回收,如果再回收之后仍然没有足够的内存时才会抛出内存溢出异常。
3、弱引用
弱引用需要用 WeakReference 类来实现,它比软引用对象的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否充足,该对象总会被回收——>弱引用对象只能活到下一次垃圾收集。
4、虚引用
虚引用需要 PhantomReference 类来实现,虚引用不会对它所关联对象的生存时间造成影响,而且也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是为了在这个对象被收集器回收时收到一个系统通知。
四、垃圾收集器
1、Serial 垃圾收集器(单线程、复制算法、STW)
Serial(英文连续)是最基本垃圾收集器,使用复制算法,曾经是JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一个单线程的垃圾收集器,它不但只使用一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,还会暂停其他所有的工作线程(STW),直到垃圾收集结束。Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器 。
2、ParNew 垃圾收集器(多线程、复制算法、STW)
ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程(STW)。ParNew 收集器默认开启和 CPU 数目相同的线程数,ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。
3、Parallel Scavenge垃圾收集器(多线程、复制算法、STW、高效)
Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是使程序达到一个可控制的吞吐量,高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。
4、Serial Old 垃圾收集器(单线程、标记整理算法、STW)
Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是java 虚拟机运行在 Client 模式下默认的年老代垃圾收集器。
5、Parallel Old 垃圾收集器(多线程、标记整理算法、STW)
Parallel Old 收集器是Parallel Scavenge的老年代版本,使用多线程的标记-整理算法,在 JDK1.6才开始提供。在 JDK1.6 之前,新生代使用 Parallel Scavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在老年代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和老年代 Parallel Old 收集器的搭配策略。
6、CMS垃圾收集器(多线程、标记清除算法)
Concurrent mark sweep(CMS)收集器是一种老年代垃圾收集器,其最主要目标是获取最短的垃圾回收停顿时间,和其他老年代使用标记-整理算法不同,它使用多线程的标记-清除算法。CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:
- 初始标记: 标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程
- 并发标记:由GC Roots直接关联的对象开始遍历整个对象图,标记所有可达对象,这个过程耗时较长但是不需要停顿用户线程,用户线程可以与垃圾收集线程一起并发运行
- 重新标记:为了修正在并发标记期间,因用户程序运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。该阶段的停顿时间通常会比初始标记阶段稍长一些,但远比并发标记阶段的时间段
- 并发清除 :删除标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以同时与用户线程同时并发的。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行
7、G1垃圾收集器(*)
- 发展历程:在G1垃圾回收器出现之前的所有其它收集器,包括CMS在内,垃圾收集的目标要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),而G1跳出了这个樊笼,它的回收集可以由堆内存中的任何部分组成,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
- G1特点:1、 G1仍是遵循分代理论设计的,但其堆内存的布局与其它收集器有明显的差异。2、 G1不在坚持固定大小以及固定数量的分代区域划分,而是把Java堆划分为多个连续的独立区域,每一个区域都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的region采用不同的策略进行处理,这样无论是对新创建的对象还是已经存活了一段时间、熬过多次收集的就对象都能取得良好的手机效果。3、 Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象,对于超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous区域中,G1的大多数行为都把Humongous区域作为老年代的一部分看待。4、 虽然G1仍然保存着新生代和老年代的概念,但新生代和老年代不在是固定的,他们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能够建立可预测的时间停顿模型是因为他将Region作为单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍---->G1收集器会跟踪各个Region里面堆积垃圾的价值大小,价值即回收所获得的空间的大小以及回收所需要时间的经验值,然后再后台维护一个优先级列表,每次根据用户设计允许的收集停顿时间,优先回收价值收益最大的那些Region
G1的垃圾回收大致可以划分为四个步骤:
- 初始标记(Initial Marking): 仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿
- 并发标记( Concurrent Marking): 从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,并发时有引用变动的对象会产生漏标问题,G1中会使用SATB(snapshot-at-the-beginning)算法来解决,后面会详细介绍
- 最终标记(Final Marking): 对用户线程做一个短暂的暂停,用于处理并发标记阶段仍遗留下来的最后那少量的SATB记录(漏标对象)
- 筛选回收(Live Data Counting and Evacuation): 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个回收集中的Region全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
补充: G1为每个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须在这两个指针位置以上。G1默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围!
8、各个垃圾收集器的优缺点
9、垃圾收集器的搭配使用
五、类加载机制
-
加载: 加载”是”类加载机制”的第一个过程,在加载阶段,虚拟机主要完成三件事:(1)通过一个类的全限定名来获取其定义的二进制字节流。(2)将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。(3)在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。
-
验证: 验证的主要作用就是确保被加载的类的正确性。1、文件格式的验证。2、元数据验证。3、字节码验证。4、符号引用验证。
对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,可以使用一Xverfity:none来关闭大部分的验证。 -
准备: 准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,类变量如果只是被static所修饰那么在准备阶段之后的初始值是0,但是如果同时被final和static修饰那么在准备阶段之后就是相应值了。
1、 注意这里所说的初始值概念,比如一个类变量定义如下:实际上变量 v 在准备阶段过后的初始值为 0 而不是 8080,将 v 赋值为 8080 的 put static 指令在程序被编译后,存放于类构造器方法之中,所以把v赋值为8080的动作要到类初始化阶段才会被执行。
public static int v = 8080;
2、 但是注意如果声明如下:在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将v赋值为所指定的初始值 8080。
public static final int v = 8080;
-
解析: 解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符这7类符号引用进行,它们分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Field_info、CONSTANT_Method_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info、CONSTANT_InvokeDynamic_info等8种常量类型。
1、符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用目标不一定已经加载到虚拟机内存
2、直接引用: 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在虚拟机中的内存中存在。
-
初始化 : 初始化阶段就是执行类构造器()方法的过程 ,主要为类的静态变量赋予正确的初始值,初始值的设定有两种方式:①赋值语句指定的初始值。②静态代码块指定的初始值。
1、类构造器方法 : < clinit>()方法是由编译器自动收集的由类中所有类变量的赋值动作(static修饰的变量)和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,前面的静态语句块可以赋值但是不能访问。Java虚拟机会保证在子类的< clinit>()方法执行前,父类的< clinit>()方法已经执行完毕,因此在Java虚拟机中第一个被执行的< clinit>()方法的类型肯定是Java.lang.Object。< clinit>()方法对于类或接口不是必须的,如果一个类中没有静态语句块也没有对变量的赋值操作,那么编译器可以不为这个类生成< clinit>()方法。接口中不能使用静态语句块,但仍可以有变量初始化的赋值操作,因此接口与类一样都会生成< clinit>()方法。但接口与类不同,执行接口的< clinit>()方法不需要先执行父接口的< clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外接口的实现类在初始化时也一样不会执行接口的< clinit>()方法。
2、实例构造器方法 : 在实例创建出来的时候调用——>调用new操作符;调用Class或java.lang.reflect.Constructor对象的newInstance()方法;调用任何现有对象的clone()方法;通过java.io.ObjectInputStream类的getObject()方法反序列化生成对象。 -
使用
-
卸载
六、类加载器
虚拟机的设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,JVM 提供了 3 种类加载器:
- 启动类加载器(Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类
- 扩展类加载器(Extension ClassLoader): 负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径的类库中的类
- 应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)指定的类库中的类
- JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader实现自定义的类加载器
七、双亲委派
当一个类加载器收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载器,只有当父类加载器反馈自己无法完成这个类加载请求时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了对于同一个类来说即使使用不同的类加载器进行加载最终加载的都是同一个类,不会发生类加载冲突。
参考资料一
参考资料二
参考资料三
参考资料四